This commit is contained in:
Daniel Arantes Loverde
2026-07-07 15:11:31 -03:00
parent 8b9ebdcb44
commit e51c99973f
293 changed files with 457 additions and 4919 deletions

View File

@@ -0,0 +1,83 @@
import Foundation
struct SavedCard: Decodable, Identifiable, Hashable {
let id: String
let nickname: String?
let holderName: String
let last4: String
let brand: String?
let expiryMonth: String
let expiryYear: String
let isDefault: Bool
var displayLabel: String {
if let nickname, nickname.isEmpty == false { return nickname }
let brandLabel = (brand ?? "Cartão").capitalized
return "\(brandLabel) •••• \(last4)"
}
var expiryLabel: String { "\(expiryMonth)/\(expiryYear)" }
}
struct SaveCardCreditCardPayload: Encodable {
let holderName: String
let number: String
let expiryMonth: String
let expiryYear: String
let ccv: String
}
struct SaveCardHolderInfoPayload: Encodable {
let name: String
let email: String
let cpfCnpj: String
let postalCode: String
let addressNumber: String
let phone: String
}
struct SaveCardPayload: Encodable {
let creditCard: SaveCardCreditCardPayload
let creditCardHolderInfo: SaveCardHolderInfoPayload
let nickname: String?
let isDefault: Bool
}
struct UpdateCardPayload: Encodable {
let nickname: String?
let isDefault: Bool?
}
struct SavedCardResult: Decodable {
let id: String
let holderName: String
let last4: String
let brand: String?
let expiryMonth: String
let expiryYear: String
let isDefault: Bool
}
struct CreditCardOrderPayload: Encodable {
let holderName: String
let number: String
let expiryMonth: String
let expiryYear: String
let ccv: String
}
struct ChangePaymentMethodPayload: Encodable {
let paymentMethod: String
let clientCpfCnpj: String?
let creditCard: CreditCardOrderPayload?
let creditCardHolderInfo: SaveCardHolderInfoPayload?
let savedCardId: String?
}
struct ChangePaymentMethodResult: Decodable {
let paymentMethod: String?
let paymentLocation: String?
let paymentId: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
}

View File

@@ -0,0 +1,488 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if canImport(LCEssentials) && os(iOS)
import LCEssentials
#endif
enum NetworkError: Error, LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int, String?)
case unauthorized(String?)
case decodeError(String?)
case rateLimited(Int?)
case cancelled
case timedOut
case transportError(String)
var errorDescription: String? {
switch self {
case .invalidURL: return "URL invalida"
case .invalidResponse: return "Resposta invalida do servidor"
case .httpError(let code, let message):
return message ?? "Erro HTTP (\(code))"
case .unauthorized(let message):
return message ?? "Sessao expirada. Faca login novamente."
case .decodeError(let payload):
if let payload, payload.isEmpty == false {
return "Erro ao interpretar dados: \(payload)"
}
return "Erro ao interpretar dados"
case .rateLimited(let retryAfter):
if let retryAfter {
return "Muitas requisicoes. Tente novamente em \(retryAfter)s."
}
return "Muitas requisicoes. Tente novamente."
case .cancelled:
return "Requisicao cancelada"
case .timedOut:
return "O servidor demorou demais para responder. Tente novamente."
case .transportError(let message):
return "Erro de rede: \(message)"
}
}
}
private struct ApiErrorDescriptor {
let code: String?
let message: String?
}
struct ApiRequest: Sendable {
let path: String
let method: String
let module: ApiModule
let requiresAuth: Bool
let queryItems: [URLQueryItem]
let body: Data?
init(path: String,
method: String = "GET",
module: ApiModule = .none,
requiresAuth: Bool = true,
queryItems: [URLQueryItem] = [],
body: Data? = nil) {
self.path = path
self.method = method
self.module = module
self.requiresAuth = requiresAuth
self.queryItems = queryItems
self.body = body
}
}
final class ApiClient: @unchecked Sendable {
private let session: URLSession
private let tokenStore: TokenStore
private let maxAttempts = 3
private let baseBackoffNanoseconds: UInt64 = 300_000_000
init(session: URLSession = .shared, tokenStore: TokenStore = DefaultTokenStore()) {
self.session = session
self.tokenStore = tokenStore
}
func send<T: Decodable & Sendable>(_ request: ApiRequest) async throws -> T {
// Hard client-side cutoff independent of whatever timeout logic
// lives inside the underlying transport (LCEssentials or plain
// URLSession). If that transport ever stalls without ever
// resolving no response, no error, nothing the UI must still
// get an answer so it can stop showing "nothing happened."
try await withTimeout(seconds: 20) { [self] in
#if canImport(LCEssentials) && os(iOS)
try await sendWithLCEssentials(request)
#else
try await sendWithURLSession(request)
#endif
}
}
private func withTimeout<T: Sendable>(seconds: Double, operation: @escaping @Sendable () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw NetworkError.timedOut
}
guard let result = try await group.next() else {
throw NetworkError.timedOut
}
group.cancelAll()
return result
}
}
}
private extension ApiClient {
#if canImport(LCEssentials) && os(iOS)
func sendWithLCEssentials<T: Decodable>(_ request: ApiRequest) async throws -> T {
let urlString = try buildURL(path: request.path, query: request.queryItems).absoluteString
let method = request.method
let headers = buildHeaders(for: request)
let params = request.body
var attempt = 1
while attempt <= maxAttempts {
do {
let responseString = try await Self.performLCERequest(
url: urlString,
params: params,
method: method,
headers: headers
)
guard let data = responseString.data(using: .utf8) else {
throw NetworkError.decodeError("Resposta nao UTF-8")
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorFlag = object["error"] as? Bool, errorFlag {
let message = object["message"] as? String ?? object["msg"] as? String
throw NetworkError.httpError(200, message ?? "Erro no servidor")
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw NetworkError.decodeError(sanitizedBody(data))
}
} catch {
let mapped = mapError(error)
guard shouldRetry(mapped), attempt < maxAttempts else {
throw mapped
}
try await Task.sleep(nanoseconds: backoff(for: attempt, error: mapped))
attempt += 1
}
}
throw NetworkError.invalidResponse
}
@MainActor
static func performLCERequest(
url: String,
params: Data?,
method: String,
headers: [String: String]
) async throws -> String {
let httpMethod = toHTTPMethod(method)
return try await API.shared.request(
url: url,
params: params,
method: httpMethod,
headers: headers,
jsonEncoding: true,
debug: true
)
}
static func toHTTPMethod(_ method: String) -> httpMethod {
switch method.uppercased() {
case "POST": return .post
case "PUT": return .put
case "DELETE": return .delete
case "PATCH": return .patch
default: return .get
}
}
func mapError(_ error: Error) -> NetworkError {
if let network = error as? NetworkError {
return network
}
if let decoding = error as? DecodingError {
return .decodeError(String(describing: decoding))
}
let nsError = error as NSError
let apiMessage = serverMessage(from: nsError)
let payload = serverPayload(from: nsError)
if isSessionExpiredPayload(code: payload?.code, message: payload?.message ?? apiMessage) {
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)
case 429:
return .rateLimited(nil)
case 400...599:
return .httpError(nsError.code, apiMessage)
default:
break
}
if nsError.domain == NSURLErrorDomain {
return .transportError(nsError.localizedDescription)
}
return .transportError(nsError.localizedDescription)
}
#endif
func sendWithURLSession<T: Decodable>(_ request: ApiRequest) async throws -> T {
let url = try buildURL(path: request.path, query: request.queryItems)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method
urlRequest.httpBody = request.body
for (key, value) in buildHeaders(for: request) {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
var attempt = 1
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
}
try await Task.sleep(nanoseconds: backoff(for: attempt, error: error))
attempt += 1
} catch {
let wrapped = NetworkError.transportError(error.localizedDescription)
guard attempt < maxAttempts else { throw wrapped }
try await Task.sleep(nanoseconds: backoff(for: attempt, error: wrapped))
attempt += 1
}
}
throw NetworkError.invalidResponse
}
func perform<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
let (data, response): (Data, URLResponse)
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)
}
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
let payload = serverPayload(from: data)
if isSessionExpiredPayload(code: payload?.code, message: payload?.message) {
throw NetworkError.unauthorized(payload?.message)
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorFlag = object["error"] as? Bool, errorFlag {
let message = object["message"] as? String ?? object["msg"] as? String
throw NetworkError.httpError(http.statusCode, message ?? "Erro no servidor")
}
if http.statusCode == 429 {
let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "")
throw NetworkError.rateLimited(retryAfter)
}
if http.statusCode == 401 || http.statusCode == 403 {
throw NetworkError.unauthorized(serverMessage(from: data))
}
if !(200...299).contains(http.statusCode) {
throw NetworkError.httpError(http.statusCode, serverMessage(from: data))
}
do {
return try JSONDecoder().decode(type, from: data)
} catch {
throw NetworkError.decodeError(sanitizedBody(data))
}
}
func buildHeaders(for request: ApiRequest) -> [String: String] {
var headers: [String: String] = [
"Accept": "application/json",
"Content-Type": "application/json"
]
if let token = ApiConfig.token(for: request.module) {
headers["Atomenta-Token"] = token
}
if request.requiresAuth, let jwt = tokenStore.jwt {
headers["Authorization"] = "Bearer \(jwt)"
}
return headers
}
func shouldRetry(_ error: NetworkError) -> Bool {
switch error {
case .rateLimited, .transportError:
return true
case .httpError(let statusCode, _):
return statusCode >= 500
case .invalidURL, .invalidResponse, .decodeError, .unauthorized, .cancelled, .timedOut:
return false
}
}
func backoff(for attempt: Int, error: NetworkError) -> UInt64 {
if case .rateLimited(let retryAfter) = error, let retryAfter {
return UInt64(retryAfter) * 1_000_000_000
}
let multiplier = UInt64(max(1, attempt))
return min(baseBackoffNanoseconds * multiplier, 2_000_000_000)
}
func serverMessage(from data: Data) -> String? {
let payload = serverPayload(from: data)
if let message = payload?.message, message.isEmpty == false {
return message
}
if let code = payload?.code, code.isEmpty == false {
return "Erro: \(code)"
}
// fallback if error true is present but without a classic structure
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorFlag = object["error"] as? Bool, errorFlag {
if let msg = object["message"] as? String ?? object["msg"] as? String {
return msg
}
}
return sanitizedBody(data)
}
func serverPayload(from data: Data) -> ApiErrorDescriptor? {
if let envelope = try? JSONDecoder().decode(ApiEnvelope<EmptyResult>.self, from: data) {
return ApiErrorDescriptor(code: envelope.code, message: envelope.message)
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let errorFlag = object["error"] as? Bool ?? false
let code = object["code"] as? String
let message = object["message"] as? String ?? object["msg"] as? String ?? object["error_description"] as? String
if errorFlag || code != nil || message != nil {
return ApiErrorDescriptor(code: code, message: message)
}
}
return nil
}
func isSessionExpiredPayload(code: String?, message: String?) -> Bool {
let normalizedCode = (code ?? "").lowercased()
let normalizedMessage = (message ?? "").lowercased()
if normalizedCode.contains("auth") || normalizedCode.contains("token") || normalizedCode.contains("unauthorized") {
return true
}
if normalizedMessage.contains("token") && (normalizedMessage.contains("expir") || normalizedMessage.contains("invalid") || normalizedMessage.contains("sess")) {
return true
}
return false
}
#if canImport(LCEssentials) && os(iOS)
func serverMessage(from error: NSError) -> String? {
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] {
if let value = error.userInfo[key] as? String,
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let data = value.data(using: .utf8),
let parsed = serverMessage(from: data),
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
if t.hasPrefix("{") { continue }
if let safe = sanitizedMessage(t) { return safe }
}
let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
} else if let valueData = error.userInfo[key] as? Data {
if let parsed = serverMessage(from: valueData) {
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
}
}
}
for (_, value) in error.userInfo {
if let str = value as? String,
str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"),
let data = str.data(using: .utf8),
let parsed = serverMessage(from: data) {
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
}
}
if let description = error.userInfo[NSLocalizedDescriptionKey] as? String,
!description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!description.lowercased().contains("nsurlerrordomain"),
let safe = sanitizedMessage(description) {
return safe
}
return nil
}
func serverPayload(from error: NSError) -> ApiErrorDescriptor? {
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data"] {
if let value = error.userInfo[key] as? String,
let data = value.data(using: .utf8),
let payload = serverPayload(from: data) {
return payload
} else if let valueData = error.userInfo[key] as? Data,
let payload = serverPayload(from: valueData) {
return payload
}
}
for (_, value) in error.userInfo {
if let str = value as? String,
str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"),
let data = str.data(using: .utf8),
let payload = serverPayload(from: data) {
return payload
}
}
return nil
}
#endif
func sanitizedBody(_ data: Data) -> String? {
guard let raw = String(data: data, encoding: .utf8) else { return nil }
return sanitizedMessage(raw)
}
func sanitizedMessage(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return nil }
// Drop data URLs (base64 images)
if trimmed.lowercased().hasPrefix("data:image") { return "Erro ao processar imagem." }
// Drop fields containing base64,
if trimmed.contains("base64,") { return "Resposta do servidor inválida." }
// Truncate long strings (raw JSON bodies, etc.)
if trimmed.count > 300 {
return String(trimmed.prefix(300)) + ""
}
return trimmed
}
func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL
}
components.path = components.path.appending(path)
if !query.isEmpty {
components.queryItems = query
}
guard let url = components.url else { throw NetworkError.invalidURL }
return url
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
enum ApiModule: Sendable {
case app
case customer
case store
case resource
case none
}
enum ApiConfig {
static var baseURL: URL {
let raw = ProcessInfo.processInfo.environment["ATOMENTA_API_URL"] ?? "https://atomenta.com.br"
return URL(string: raw) ?? URL(string: "https://atomenta.com.br")!
}
static var featureControlBffURL: URL {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_BFF_URL"] ?? "http://localhost:8787"
return URL(string: raw) ?? URL(string: "http://localhost:8787")!
}
static var featureControlEnvironment: String {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production"
let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return clean.isEmpty ? "production" : clean
}
// Tokens provided by backend modules
static let storeToken = "550e8400-e29b-41d4-a716-446655440008"
static let customerToken = "550e8400-e29b-41d4-a716-44665544000a"
static let resourceToken = "550e8400-e29b-41d4-a716-446655440009"
static func token(for module: ApiModule) -> String? {
switch module {
case .store: return storeToken
case .customer: return customerToken
case .resource: return resourceToken
case .app, .none: return nil
}
}
}

View File

@@ -0,0 +1,75 @@
import Foundation
struct PublicCategory: Decodable {
let id: String
let name: String
let icon: String?
}
struct CustomerProfileUpdatePayload: Encodable {
let addressBook: [CustomerAddressPayload]
enum CodingKeys: String, CodingKey {
case addressBook = "address_book"
}
}
struct CustomerIdentityUpdatePayload: Encodable {
let name: String?
let email: String?
let phoneNumber: String?
let profilePicture: String?
enum CodingKeys: String, CodingKey {
case name
case email
case phoneNumber
case profilePicture
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(name, forKey: .name)
try container.encodeIfPresent(email, forKey: .email)
try container.encodeIfPresent(phoneNumber, forKey: .phoneNumber)
if let profilePicture, profilePicture.isEmpty == false {
try container.encode(profilePicture, forKey: .profilePicture)
}
}
}
struct CustomerAddressPayload: Encodable {
let label: String?
let address: String?
let number: String?
let complement: String?
let neighborhood: String?
let city: String?
let state: String?
let zipCode: String?
let latLong: [Double]?
enum CodingKeys: String, CodingKey {
case label
case address
case number
case complement
case neighborhood
case city
case state
case zipCode
case latLong = "lat_long"
}
init(from address: CustomerAddress) {
self.label = address.label
self.address = address.address
self.number = address.number
self.complement = address.complement
self.neighborhood = address.neighborhood
self.city = address.city
self.state = address.state
self.zipCode = address.zipCode
self.latLong = address.latLong
}
}

View File

@@ -0,0 +1,6 @@
import Foundation
struct CustomerFavoritesMutationResult: Decodable {
let favorites: [String]
let store: StoreSummary?
}

View File

@@ -0,0 +1,548 @@
import Foundation
// MARK: - DTOs
struct EmptyResult: Decodable {}
struct ProfilePatchEnvelope: Decodable {
let error: Bool
let code: String?
let message: String?
let profilePictureUrl: String?
}
struct RegistrationResult: Decodable {
let id: String?
let name: String?
let email: String?
}
struct LoginResult: Decodable {
let token: String
let customer: CustomerProfile?
}
struct CustomerProfile: Decodable {
let id: String
let name: String
let email: String
let phoneNumber: String?
let profilePicture: String?
let favorites: [String]?
let addressBook: [CustomerAddress]?
enum CodingKeys: String, CodingKey {
case id
case name
case email
case phoneNumber
case profilePicture
case favorites
case addressBook = "address_book"
}
}
struct CustomerAddress: Decodable {
let id: String?
let label: String?
let address: String?
let number: String?
let complement: String?
let neighborhood: String?
let city: String?
let state: String?
let zipCode: String?
let latLong: [Double]?
let isDefault: Bool?
init(id: String?, label: String?, address: String?, number: String?,
complement: String?, neighborhood: String?, city: String?,
state: String?, zipCode: String?, latLong: [Double]?, isDefault: Bool?) {
self.id = id; self.label = label; self.address = address
self.number = number; self.complement = complement
self.neighborhood = neighborhood; self.city = city
self.state = state; self.zipCode = zipCode
self.latLong = latLong; self.isDefault = isDefault
}
enum CodingKeys: String, CodingKey {
case id, label, address, number, complement
case neighborhood, city, state, zipCode
case latLong = "lat_long"
case isDefault
}
}
struct StoreSummary: Decodable {
let id: String
let name: String
let logo: String?
let cover: String?
let category: String?
let rating: Double?
let reviewsCount: Int?
let positiveReviews: Int?
let deliveryTime: String?
let deliveryFee: Double?
let distance: Double?
let isOpen: Bool?
let statusLabel: String?
enum CodingKeys: String, CodingKey {
case id
case name
case logo
case cover
case category
case rating
case reviewsCount
case totalReviews
case reviews
case positiveReviews
case positive_reviews
case deliveryTime
case deliveryFee
case distance
case isOpen
case statusLabel
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = (try? container.decode(String.self, forKey: .name)) ?? "Loja"
logo = try? container.decode(String.self, forKey: .logo)
cover = try? container.decode(String.self, forKey: .cover)
category = try? container.decode(String.self, forKey: .category)
rating = ApiService.decodeFlexibleDouble(from: container, keys: [.rating])
reviewsCount = ApiService.decodeFlexibleInt(from: container, keys: [.reviewsCount, .totalReviews, .reviews])
positiveReviews = ApiService.decodeFlexibleInt(from: container, keys: [.positiveReviews, .positive_reviews])
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
deliveryFee = ApiService.decodeFlexibleDouble(from: container, keys: [.deliveryFee])
distance = ApiService.decodeFlexibleDouble(from: container, keys: [.distance])
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
}
}
struct StoreInfoResult: Decodable {
let isOpen: Bool?
let statusLabel: String?
let fantasyName: String?
let phone: String?
let whatsapp: String?
let logo: String?
let cover: String?
let deliveryTime: String?
let minOrder: Double?
let address: StoreAddressInfo?
let paymentMethods: StorePaymentMethodsInfo?
enum CodingKeys: String, CodingKey {
case isOpen
case statusLabel
case fantasyName
case phone
case whatsapp
case logo
case cover
case deliveryTime
case minOrder
case address
case paymentMethods
}
init(from decoder: Decoder) throws {
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)
phone = ApiService.decodeFlexibleString(from: container, keys: [.phone])
whatsapp = ApiService.decodeFlexibleString(from: container, keys: [.whatsapp, .phone])
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)
paymentMethods = try? container.decode(StorePaymentMethodsInfo.self, forKey: .paymentMethods)
}
}
struct StoreAddressInfo: Decodable {
let street: String?
let number: String?
let neighborhood: String?
let city: String?
let state: String?
let zipCode: String?
let latitude: Double?
let longitude: Double?
enum CodingKeys: String, CodingKey {
case street
case number
case neighborhood
case city
case state
case zipCode
case zipcode
case latitude
case longitude
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
street = try? container.decode(String.self, forKey: .street)
number = try? container.decode(String.self, forKey: .number)
neighborhood = try? container.decode(String.self, forKey: .neighborhood)
city = try? container.decode(String.self, forKey: .city)
state = try? container.decode(String.self, forKey: .state)
zipCode = (try? container.decode(String.self, forKey: .zipCode))
?? (try? container.decode(String.self, forKey: .zipcode))
latitude = ApiService.decodeFlexibleDouble(from: container, keys: [.latitude])
longitude = ApiService.decodeFlexibleDouble(from: container, keys: [.longitude])
}
}
struct StorePaymentMethodsInfo: Decodable {
let paymentOnDelivery: Bool?
let paymentOnPickup: Bool?
let acceptPix: Bool?
let acceptCash: Bool?
let acceptCreditCard: Bool?
let acceptDebitCard: Bool?
let acceptCreditVisa: Bool?
let acceptCreditMaster: Bool?
let acceptCreditElo: Bool?
let acceptCreditAmex: Bool?
let acceptCreditHipercard: Bool?
let acceptDebitVisa: Bool?
let acceptDebitMaster: Bool?
let acceptDebitElo: Bool?
let acceptVoucherAlelo: Bool?
let acceptVoucherSodexo: Bool?
let acceptVoucherTicket: Bool?
let acceptVoucherVR: Bool?
enum CodingKeys: String, CodingKey {
case paymentOnDelivery
case paymentOnPickup
case acceptPix
case acceptCash
case acceptCreditCard
case acceptDebitCard
case acceptCreditVisa
case acceptCreditMaster
case acceptCreditElo
case acceptCreditAmex
case acceptCreditHipercard
case acceptDebitVisa
case acceptDebitMaster
case acceptDebitElo
case acceptVoucherAlelo
case acceptVoucherSodexo
case acceptVoucherTicket
case acceptVoucherVR
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
paymentOnDelivery = try? container.decode(Bool.self, forKey: .paymentOnDelivery)
paymentOnPickup = try? container.decode(Bool.self, forKey: .paymentOnPickup)
acceptPix = try? container.decode(Bool.self, forKey: .acceptPix)
acceptCash = try? container.decode(Bool.self, forKey: .acceptCash)
acceptCreditCard = try? container.decode(Bool.self, forKey: .acceptCreditCard)
acceptDebitCard = try? container.decode(Bool.self, forKey: .acceptDebitCard)
acceptCreditVisa = try? container.decode(Bool.self, forKey: .acceptCreditVisa)
acceptCreditMaster = try? container.decode(Bool.self, forKey: .acceptCreditMaster)
acceptCreditElo = try? container.decode(Bool.self, forKey: .acceptCreditElo)
acceptCreditAmex = try? container.decode(Bool.self, forKey: .acceptCreditAmex)
acceptCreditHipercard = try? container.decode(Bool.self, forKey: .acceptCreditHipercard)
acceptDebitVisa = try? container.decode(Bool.self, forKey: .acceptDebitVisa)
acceptDebitMaster = try? container.decode(Bool.self, forKey: .acceptDebitMaster)
acceptDebitElo = try? container.decode(Bool.self, forKey: .acceptDebitElo)
acceptVoucherAlelo = try? container.decode(Bool.self, forKey: .acceptVoucherAlelo)
acceptVoucherSodexo = try? container.decode(Bool.self, forKey: .acceptVoucherSodexo)
acceptVoucherTicket = try? container.decode(Bool.self, forKey: .acceptVoucherTicket)
acceptVoucherVR = try? container.decode(Bool.self, forKey: .acceptVoucherVR)
}
var hasAnyCreditCard: Bool {
(acceptCreditCard ?? false)
|| (acceptCreditVisa ?? false)
|| (acceptCreditMaster ?? false)
|| (acceptCreditElo ?? false)
|| (acceptCreditAmex ?? false)
|| (acceptCreditHipercard ?? false)
}
var hasAnyDebitCard: Bool {
(acceptDebitCard ?? false)
|| (acceptDebitVisa ?? false)
|| (acceptDebitMaster ?? false)
|| (acceptDebitElo ?? false)
}
var hasAnyVoucher: Bool {
(acceptVoucherAlelo ?? false)
|| (acceptVoucherSodexo ?? false)
|| (acceptVoucherTicket ?? false)
|| (acceptVoucherVR ?? false)
}
}
struct StoreCatalogCategory: Decodable {
let id: String
let name: String
let isPizzaCategory: Bool
let pizzaConfig: StorePizzaConfig?
let products: [StoreCatalogProduct]
enum CodingKeys: String, CodingKey {
case id
case name
case isPizzaCategory
case pizzaConfig
case products
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = (try? container.decode(String.self, forKey: .name)) ?? "Categoria"
isPizzaCategory = (try? container.decode(Bool.self, forKey: .isPizzaCategory)) ?? false
pizzaConfig = try? container.decode(StorePizzaConfig.self, forKey: .pizzaConfig)
products = (try? container.decode([StoreCatalogProduct].self, forKey: .products)) ?? []
}
}
struct StoreCatalogProduct: Decodable, Identifiable {
let id: String
let type: String?
let name: String
let description: String?
let image: String?
let price: Double?
let originalPrice: Double?
let pizzaPrices: [String: Double]
let addonGroups: [StoreAddonGroup]
enum CodingKeys: String, CodingKey {
case id
case type
case name
case description
case desc
case image
case cover
case photo
case price
case originalPrice
case oldPrice
case pizzaPrices
case addonGroups
case addons
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
type = try? container.decode(String.self, forKey: .type)
name = (try? container.decode(String.self, forKey: .name)) ?? "Produto"
description = (try? container.decode(String.self, forKey: .description)) ?? (try? container.decode(String.self, forKey: .desc))
image = (try? container.decode(String.self, forKey: .image))
?? (try? container.decode(String.self, forKey: .cover))
?? (try? container.decode(String.self, forKey: .photo))
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
originalPrice = ApiService.decodeFlexibleDouble(from: container, keys: [.originalPrice, .oldPrice])
pizzaPrices = StoreCatalogProduct.decodePizzaPrices(container: container)
addonGroups = (try? container.decode([StoreAddonGroup].self, forKey: .addonGroups))
?? (try? container.decode([StoreAddonGroup].self, forKey: .addons))
?? []
}
private static func decodePizzaPrices(container: KeyedDecodingContainer<CodingKeys>) -> [String: Double] {
if let direct = try? container.decode([String: Double].self, forKey: .pizzaPrices) {
return direct
}
if let asInt = try? container.decode([String: Int].self, forKey: .pizzaPrices) {
return asInt.mapValues { Double($0) }
}
if let asString = try? container.decode([String: String].self, forKey: .pizzaPrices) {
var parsed: [String: Double] = [:]
for (key, value) in asString {
let normalized = value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
if let number = Double(normalized) {
parsed[key] = number
}
}
return parsed
}
return [:]
}
}
struct StoreAddonGroup: Decodable, Identifiable {
let id: String
let name: String
let minSelectors: Int?
let maxSelectors: Int?
let items: [StoreAddonItem]
enum CodingKeys: String, CodingKey {
case id
case name
case minSelectors
case maxSelectors
case items
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = (try? container.decode(String.self, forKey: .name)) ?? "Adicionais"
minSelectors = try? container.decode(Int.self, forKey: .minSelectors)
maxSelectors = try? container.decode(Int.self, forKey: .maxSelectors)
items = (try? container.decode([StoreAddonItem].self, forKey: .items)) ?? []
}
}
struct StoreAddonItem: Decodable, Identifiable {
let id: String
let name: String
let price: Double?
enum CodingKeys: String, CodingKey {
case id
case name
case price
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = (try? container.decode(String.self, forKey: .name)) ?? "Item"
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
}
}
struct CepLookupResult: Decodable {
let zipCode: String?
let street: String?
let neighborhood: String?
let city: String?
let state: String?
let complement: String?
let latitude: Double?
let longitude: Double?
enum CodingKeys: String, CodingKey {
case zipCode
case cep
case zip
case normalized
case raw
case street
case logradouro
case address
case neighborhood
case bairro
case district
case city
case cidade
case localidade
case state
case estado
case uf
case complement
case complemento
case latitude
case lat
case longitude
case lng
}
enum NormalizedKeys: String, CodingKey {
case cep
case logradouro
case bairro
case cidade
case uf
case latitude
case longitude
}
enum RawKeys: String, CodingKey {
case cep
case address
case district
case city
case state
case lat
case lng
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let normalizedContainer = try? container.nestedContainer(keyedBy: NormalizedKeys.self, forKey: .normalized)
let rawContainer = try? container.nestedContainer(keyedBy: RawKeys.self, forKey: .raw)
let directZip = CepLookupResult.decodeString(from: container, keys: [.zipCode, .cep, .zip])
let directStreet = CepLookupResult.decodeString(from: container, keys: [.street, .logradouro, .address])
let directNeighborhood = CepLookupResult.decodeString(from: container, keys: [.neighborhood, .bairro, .district])
let directCity = CepLookupResult.decodeString(from: container, keys: [.city, .cidade, .localidade])
let directState = CepLookupResult.decodeString(from: container, keys: [.state, .estado, .uf])
let directComplement = CepLookupResult.decodeString(from: container, keys: [.complement, .complemento])
let directLatitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.latitude, .lat])
let directLongitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.longitude, .lng])
let normalizedZip = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) }
let normalizedStreet = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.logradouro]) }
let normalizedNeighborhood = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.bairro]) }
let normalizedCity = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cidade]) }
let normalizedState = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.uf]) }
let normalizedLatitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.latitude]) }
let normalizedLongitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.longitude]) }
let rawZip = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) }
let rawStreet = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.address]) }
let rawNeighborhood = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.district]) }
let rawCity = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.city]) }
let rawState = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.state]) }
let rawLatitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lat]) }
let rawLongitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lng]) }
zipCode = directZip ?? normalizedZip ?? rawZip
street = directStreet ?? normalizedStreet ?? rawStreet
neighborhood = directNeighborhood ?? normalizedNeighborhood ?? rawNeighborhood
city = directCity ?? normalizedCity ?? rawCity
state = directState ?? normalizedState ?? rawState
complement = directComplement
latitude = directLatitude ?? normalizedLatitude ?? rawLatitude
longitude = directLongitude ?? normalizedLongitude ?? rawLongitude
}
private static func decodeString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? {
for key in keys {
if let value = try? container.decode(String.self, forKey: key) {
return value
}
}
return nil
}
private 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) {
return value
}
if let valueAsString = try? container.decode(String.self, forKey: key),
let parsed = Double(valueAsString.replacingOccurrences(of: ",", with: ".")) {
return parsed
}
}
return nil
}
}

View File

@@ -0,0 +1,196 @@
import Foundation
struct CreateOrderPayload: Encodable {
let customer: CreateOrderCustomerPayload
let items: [CreateOrderItemPayload]
let total: Double
let paymentMethod: String
let deliveryType: String
let address: CreateOrderAddressPayload?
// Cartão salvo
let savedCardId: String?
// Novo cartão (checkout transparente)
let clientCpfCnpj: String?
let creditCard: CreditCardOrderPayload?
let creditCardHolderInfo: SaveCardHolderInfoPayload?
}
struct CreateOrderCustomerPayload: Encodable {
let name: String
let phone: String
let email: String
let asaasId: String?
}
struct CreateOrderItemPayload: Codable {
let productId: String
let name: String
let qty: Int
let price: Double
let addons: [CreateOrderAddonPayload]
let choices: [String]?
}
struct CreateOrderAddonPayload: Codable {
let addonId: String
let name: String
let qty: Int
let price: Double
}
struct CreateOrderAddressPayload: Encodable {
let street: String
let number: String
let neighborhood: String
let city: String?
let state: String?
let zip: String?
let complement: String?
}
struct CreateOrderResult: Decodable {
let id: String?
let shortId: String?
let status: String?
let paymentStatus: String?
let paymentConfirmed: Bool?
let paymentMethod: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
enum CodingKeys: String, CodingKey {
case id
case shortId
case status
case paymentStatus
case paymentConfirmed
case paymentMethod
case paymentPayload
case payment
}
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])
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) {
paymentPayload = objectPayload
} else if let stringPayload = try? container.decode(String.self, forKey: .paymentPayload) {
paymentPayload = CreateOrderPaymentPayload(
copyPaste: stringPayload,
qrCodeImage: nil,
expirationDate: nil
)
} else {
paymentPayload = nil
}
}
}
struct CreateOrderPaymentInfo: Codable {
let method: String?
let status: String?
let pix: CreateOrderPaymentPayload?
}
struct CreateOrderPaymentPayload: Codable {
let copyPaste: String?
let qrCodeImage: String?
let expirationDate: String?
enum CodingKeys: String, CodingKey {
case copyPaste
case payload
case qrCodeImage
case encodedImage
case expirationDate
}
init(copyPaste: String?, qrCodeImage: String?, expirationDate: String?) {
self.copyPaste = copyPaste
self.qrCodeImage = qrCodeImage
self.expirationDate = expirationDate
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
copyPaste = (try? container.decode(String.self, forKey: .copyPaste))
?? (try? container.decode(String.self, forKey: .payload))
qrCodeImage = (try? container.decode(String.self, forKey: .qrCodeImage))
?? (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 {
let address: ValidateDeliveryAddressDataPayload
}
struct ValidateDeliveryAddressDataPayload: Encodable {
let street: String?
let number: String?
let neighborhood: String?
let city: String?
let state: String?
let zip: String?
let lat: Double?
let lng: Double?
}
struct ValidateDeliveryAddressResult: Decodable {
let deliveryAllowed: Bool?
let reasonCode: String?
let reasonMessage: String?
let deliveryMode: String?
let distance: Double?
let deliveryFee: Double?
let deliveryTime: String?
let sameCity: Bool?
enum CodingKeys: String, CodingKey {
case deliveryAllowed
case delivery_allowed
case reasonCode
case reason_code
case reasonMessage
case reason_message
case deliveryMode
case delivery_mode
case distance
case deliveryFee
case delivery_fee
case fee
case taxa
case deliveryTime
case delivery_time
case sameCity
case same_city
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
deliveryAllowed = (try? c.decode(Bool.self, forKey: .deliveryAllowed))
?? (try? c.decode(Bool.self, forKey: .delivery_allowed))
reasonCode = ApiService.decodeFlexibleString(from: c, keys: [.reasonCode, .reason_code])
reasonMessage = ApiService.decodeFlexibleString(from: c, keys: [.reasonMessage, .reason_message])
deliveryMode = ApiService.decodeFlexibleString(from: c, keys: [.deliveryMode, .delivery_mode])
distance = ApiService.decodeFlexibleDouble(from: c, keys: [.distance])
deliveryFee = ApiService.decodeFlexibleDouble(from: c, keys: [.deliveryFee, .delivery_fee, .fee, .taxa])
deliveryTime = ApiService.decodeFlexibleString(from: c, keys: [.deliveryTime, .delivery_time])
sameCity = (try? c.decode(Bool.self, forKey: .sameCity))
?? (try? c.decode(Bool.self, forKey: .same_city))
}
}

View File

@@ -0,0 +1,715 @@
import Foundation
struct AppOrderSummary: Decodable, Identifiable {
let id: String
let orderId: String?
let realId: String?
let storeId: 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?
let storeName: String?
let storePhone: String?
let storeLogoURL: String?
let createdAt: String?
let updatedAt: String?
enum CodingKeys: String, CodingKey {
case id
case orderId
case realId
case storeId
case store_id
case shortId
case total
case status
case statusDetailed
case statusLabel
case nextAction
case paymentStatus
case paymentMethod
case deliveryType
case storeName
case storePhone
case storeLogo
case store_logo
case logo
case storeImage
case store_image
case storeImageUrl
case logoUrl
case date
case createdAt
case updatedAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
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])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId, .store_id])
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
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])
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl])
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 storeId: String?
let status: String?
let paymentStatus: String?
let paymentConfirmed: Bool?
let paymentMethod: String?
let paymentMethodCode: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
let nextAction: String?
let deliveryType: String?
let deliveryTypeLabel: String?
let subtotal: Double?
let deliveryFee: Double?
let discount: Double?
let total: Double?
let storeName: String?
let storePhone: String?
let storeLogoURL: String?
let createdAt: String?
let updatedAt: String?
let otp: String?
let customerOtp: String?
let confirmOtp: String?
let cancellationReason: String?
let fullAddress: String?
let deliveryAddress: PublicOrderDeliveryAddress?
let review: PublicOrderReview?
let items: [PublicOrderItem]
let timeline: [PublicOrderTimelineEvent]
enum CodingKeys: String, CodingKey {
case id
case shortId
case realId
case storeId
case store_id
case status
case paymentStatus
case paymentConfirmed
case paymentMethod
case paymentMethodCode
case paymentPayload
case payment
case nextAction
case deliveryType
case deliveryTypeLabel
case subtotal
case subTotal
case itemsTotal
case deliveryFee
case delivery_fee
case fee
case discount
case desconto
case couponDiscount
case total
case storeName
case storePhone
case storeLogo
case store_logo
case logo
case storeImage
case store_image
case storeImageUrl
case logoUrl
case createdAt
case updatedAt
case otp
case customerOtp
case confirmOtp
case cancellationReason
case fullAddress
case address
case deliveryAddress
case delivery_address
case customerAddress
case customer_address
case review
case orderReview
case items
case timeline
case history
case orderedAt
}
init(
id: String,
shortId: String? = nil,
realId: String? = nil,
storeId: String? = nil,
status: String? = nil,
paymentStatus: String? = nil,
paymentConfirmed: Bool? = nil,
paymentMethod: String? = nil,
paymentMethodCode: String? = nil,
paymentPayload: CreateOrderPaymentPayload? = nil,
payment: CreateOrderPaymentInfo? = nil,
nextAction: String? = nil,
deliveryType: String? = nil,
deliveryTypeLabel: String? = nil,
subtotal: Double? = nil,
deliveryFee: Double? = nil,
discount: Double? = nil,
total: Double? = nil,
storeName: String? = nil,
storePhone: String? = nil,
storeLogoURL: String? = nil,
createdAt: String? = nil,
updatedAt: String? = nil,
otp: String? = nil,
customerOtp: String? = nil,
confirmOtp: String? = nil,
cancellationReason: String? = nil,
fullAddress: String? = nil,
deliveryAddress: PublicOrderDeliveryAddress? = nil,
review: PublicOrderReview? = nil,
items: [PublicOrderItem] = [],
timeline: [PublicOrderTimelineEvent] = []
) {
self.id = id
self.shortId = shortId
self.realId = realId
self.storeId = storeId
self.status = status
self.paymentStatus = paymentStatus
self.paymentConfirmed = paymentConfirmed
self.paymentMethod = paymentMethod
self.paymentMethodCode = paymentMethodCode
self.paymentPayload = paymentPayload
self.payment = payment
self.nextAction = nextAction
self.deliveryType = deliveryType
self.deliveryTypeLabel = deliveryTypeLabel
self.subtotal = subtotal
self.deliveryFee = deliveryFee
self.discount = discount
self.total = total
self.storeName = storeName
self.storePhone = storePhone
self.storeLogoURL = storeLogoURL
self.createdAt = createdAt
self.updatedAt = updatedAt
self.otp = otp
self.customerOtp = customerOtp
self.confirmOtp = confirmOtp
self.cancellationReason = cancellationReason
self.fullAddress = fullAddress
self.deliveryAddress = deliveryAddress
self.review = review
self.items = items
self.timeline = timeline
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id]) ?? UUID().uuidString
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
realId = ApiService.decodeFlexibleString(from: container, keys: [.realId])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId, .store_id])
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])
nextAction = ApiService.decodeFlexibleString(from: container, keys: [.nextAction])
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])
subtotal = ApiService.decodeFlexibleDouble(from: container, keys: [.subtotal, .subTotal, .itemsTotal])
deliveryFee = ApiService.decodeFlexibleDouble(from: container, keys: [.deliveryFee, .delivery_fee, .fee])
discount = ApiService.decodeFlexibleDouble(from: container, keys: [.discount, .desconto, .couponDiscount])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt, .orderedAt])
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])
fullAddress = ApiService.decodeFlexibleString(from: container, keys: [.fullAddress])
deliveryAddress = (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .address))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .deliveryAddress))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .delivery_address))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .customerAddress))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .customer_address))
review = (try? container.decode(PublicOrderReview.self, forKey: .review))
?? (try? container.decode(PublicOrderReview.self, forKey: .orderReview))
items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? []
timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline))
?? (try? container.decode([PublicOrderTimelineEvent].self, forKey: .history))
?? []
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(shortId, forKey: .shortId)
try container.encodeIfPresent(realId, forKey: .realId)
try container.encodeIfPresent(storeId, forKey: .storeId)
try container.encodeIfPresent(status, forKey: .status)
try container.encodeIfPresent(paymentStatus, forKey: .paymentStatus)
try container.encodeIfPresent(paymentConfirmed, forKey: .paymentConfirmed)
try container.encodeIfPresent(paymentMethod, forKey: .paymentMethod)
try container.encodeIfPresent(paymentMethodCode, forKey: .paymentMethodCode)
try container.encodeIfPresent(paymentPayload, forKey: .paymentPayload)
try container.encodeIfPresent(payment, forKey: .payment)
try container.encodeIfPresent(nextAction, forKey: .nextAction)
try container.encodeIfPresent(deliveryType, forKey: .deliveryType)
try container.encodeIfPresent(deliveryTypeLabel, forKey: .deliveryTypeLabel)
try container.encodeIfPresent(subtotal, forKey: .subtotal)
try container.encodeIfPresent(deliveryFee, forKey: .deliveryFee)
try container.encodeIfPresent(discount, forKey: .discount)
try container.encodeIfPresent(total, forKey: .total)
try container.encodeIfPresent(storeName, forKey: .storeName)
try container.encodeIfPresent(storePhone, forKey: .storePhone)
try container.encodeIfPresent(createdAt, forKey: .createdAt)
try container.encodeIfPresent(updatedAt, forKey: .updatedAt)
try container.encodeIfPresent(otp, forKey: .otp)
try container.encodeIfPresent(customerOtp, forKey: .customerOtp)
try container.encodeIfPresent(confirmOtp, forKey: .confirmOtp)
try container.encodeIfPresent(cancellationReason, forKey: .cancellationReason)
try container.encodeIfPresent(fullAddress, forKey: .fullAddress)
try container.encodeIfPresent(deliveryAddress, forKey: .address)
try container.encodeIfPresent(review, forKey: .review)
try container.encode(items, forKey: .items)
try container.encode(timeline, forKey: .timeline)
}
var displayOtpCode: String? {
let values = [customerOtp, otp, confirmOtp]
for value in values {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty == false { return trimmed }
}
return nil
}
var isInDeliveryRoute: Bool {
let normalized = (status ?? "").uppercased()
if normalized.contains("OUT_FOR_DELIVERY") { return true }
if normalized.contains("EM_ROTA") { return true }
if normalized.contains("ON_ROUTE") { return true }
if normalized.contains("ROTA") { return true }
return false
}
var isFinalStatus: Bool {
let normalized = (status ?? "").uppercased()
return normalized == "COMPLETED" || normalized == "CANCELED" || normalized == "REFUNDED"
}
var isPaymentConfirmed: Bool {
if let paymentConfirmed {
return paymentConfirmed
}
let payment = (paymentStatus ?? "").uppercased()
let currentStatus = (status ?? "").uppercased()
if Self.looksConfirmed(payment) || Self.looksConfirmed(currentStatus) {
return true
}
// Fallback: alguns ambientes atualizam apenas a timeline primeiro.
if timeline.contains(where: { event in
let statusValue = (event.status ?? "").uppercased()
let messageValue = (event.message ?? "").uppercased()
return Self.looksConfirmed(statusValue) || Self.looksConfirmed(messageValue)
}) {
return true
}
return false
}
private static func looksConfirmed(_ value: String) -> Bool {
if value.isEmpty { return false }
if value.contains("PENDING") || value.contains("AWAIT") { return false }
if value.contains("FAILED") || value.contains("ERROR") { return false }
if value.contains("CANCEL") || value.contains("REFUND") { return false }
if value.contains("CONFIRM") { return true }
if value.contains("APPROV") { return true }
if value.contains("PAID") { return true }
if value.contains("RECEIV") { return true }
return value == "SUCCESS" || value == "DONE"
}
}
struct PublicOrderDeliveryAddress: Codable {
let label: String?
let street: String?
let number: String?
let neighborhood: String?
let city: String?
let state: String?
let zip: String?
let complement: String?
enum CodingKeys: String, CodingKey {
case label
case street
case address
case number
case neighborhood
case district
case city
case state
case zip
case zipCode
case zipcode
case complement
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
label = ApiService.decodeFlexibleString(from: container, keys: [.label])
street = ApiService.decodeFlexibleString(from: container, keys: [.street, .address])
number = ApiService.decodeFlexibleString(from: container, keys: [.number])
neighborhood = ApiService.decodeFlexibleString(from: container, keys: [.neighborhood, .district])
city = ApiService.decodeFlexibleString(from: container, keys: [.city])
state = ApiService.decodeFlexibleString(from: container, keys: [.state])
zip = ApiService.decodeFlexibleString(from: container, keys: [.zip, .zipCode, .zipcode])
complement = ApiService.decodeFlexibleString(from: container, keys: [.complement])
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(label, forKey: .label)
try container.encodeIfPresent(street, forKey: .street)
try container.encodeIfPresent(number, forKey: .number)
try container.encodeIfPresent(neighborhood, forKey: .neighborhood)
try container.encodeIfPresent(city, forKey: .city)
try container.encodeIfPresent(state, forKey: .state)
try container.encodeIfPresent(zip, forKey: .zip)
try container.encodeIfPresent(complement, forKey: .complement)
}
}
struct PublicOrderReview: Codable {
let id: String?
let orderId: String?
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?
let date: String?
enum CodingKeys: String, CodingKey {
case id
case orderId
case rate
case message
case orderRate
case orderComment
case orderPositiveTags
case orderImprovementTags
case itemFeedback
case improvementFeedback
case deliverySentiment
case deliveryFeedback
case deliveryPositiveTags
case deliveryNegativeTags
case appNps
case app_nps
case platform
case date
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
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])
orderPositiveTags = Self.decodeStringList(
from: container,
keys: [.orderPositiveTags, .itemFeedback]
)
orderImprovementTags = Self.decodeStringList(
from: container,
keys: [.orderImprovementTags, .improvementFeedback]
)
deliverySentiment = ApiService.decodeFlexibleString(from: container, keys: [.deliverySentiment, .deliveryFeedback])
deliveryPositiveTags = Self.decodeStringList(from: container, keys: [.deliveryPositiveTags])
deliveryNegativeTags = Self.decodeStringList(from: container, keys: [.deliveryNegativeTags])
appNps = Self.decodeNps(from: container, keys: [.appNps, .app_nps])
platform = ApiService.decodeFlexibleString(from: container, keys: [.platform])
date = ApiService.decodeFlexibleString(from: container, keys: [.date])
}
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
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(id, forKey: .id)
try container.encodeIfPresent(orderId, forKey: .orderId)
try container.encodeIfPresent(rate, forKey: .rate)
try container.encodeIfPresent(message, forKey: .message)
try container.encodeIfPresent(orderRate, forKey: .orderRate)
try container.encodeIfPresent(orderComment, forKey: .orderComment)
try container.encodeIfPresent(orderPositiveTags, forKey: .orderPositiveTags)
try container.encodeIfPresent(orderImprovementTags, forKey: .orderImprovementTags)
try container.encodeIfPresent(deliverySentiment, forKey: .deliverySentiment)
try container.encodeIfPresent(deliveryPositiveTags, forKey: .deliveryPositiveTags)
try container.encodeIfPresent(deliveryNegativeTags, forKey: .deliveryNegativeTags)
try container.encodeIfPresent(appNps, forKey: .appNps)
try container.encodeIfPresent(platform, forKey: .platform)
try container.encodeIfPresent(date, forKey: .date)
}
}
struct PublicOrderItem: Codable, Identifiable {
let id: String
let productId: String?
let name: String?
let qty: Int?
let price: Double?
enum CodingKeys: String, CodingKey {
case id
case productId
case name
case qty
case quantity
case price
}
init(id: String = UUID().uuidString, productId: String? = nil, name: String?, qty: Int?, price: Double?) {
self.id = id
self.productId = productId
self.name = name
self.qty = qty
self.price = price
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
productId = try? container.decode(String.self, forKey: .productId)
name = try? container.decode(String.self, forKey: .name)
qty = ApiService.decodeFlexibleInt(from: container, keys: [.qty, .quantity])
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(productId, forKey: .productId)
try container.encodeIfPresent(name, forKey: .name)
try container.encodeIfPresent(qty, forKey: .qty)
try container.encodeIfPresent(price, forKey: .price)
}
}
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?
let date: String?
enum CodingKeys: String, CodingKey {
case id
case status
case label
case active
case completed
case message
case event
case time
case date
case createdAt
case updatedAt
}
init(
id: String = UUID().uuidString,
status: String?,
label: String? = nil,
active: Bool? = nil,
completed: Bool? = nil,
message: String?,
time: String?,
date: String? = nil
) {
self.id = id
self.status = status
self.label = label
self.active = active
self.completed = completed
self.message = message
self.time = time
self.date = date
}
init(from decoder: Decoder) throws {
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))
?? (try? container.decode(String.self, forKey: .event))
time = (try? container.decode(String.self, forKey: .time))
?? (try? container.decode(String.self, forKey: .createdAt))
?? (try? container.decode(String.self, forKey: .updatedAt))
date = try? container.decode(String.self, forKey: .date)
}
func encode(to encoder: Encoder) throws {
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)
try container.encodeIfPresent(date, forKey: .date)
}
}
struct OrderRealtimeUpdate: Decodable {
let id: String?
let shortId: String?
let storeId: String?
let userId: String?
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 {
func asPublicOrderResult() -> PublicOrderResult {
PublicOrderResult(
id: id ?? UUID().uuidString,
shortId: shortId,
status: status,
paymentStatus: paymentStatus,
paymentConfirmed: paymentConfirmed,
paymentMethod: paymentMethod,
paymentPayload: paymentPayload,
payment: payment
)
}
}

View File

@@ -0,0 +1,80 @@
import Foundation
struct StorePizzaConfig: Decodable {
let sizes: [StorePizzaSize]
let doughs: [StorePizzaDough]
let crusts: [StorePizzaCrust]
enum CodingKeys: String, CodingKey {
case sizes
case doughs
case crusts
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
sizes = (try? container.decode([StorePizzaSize].self, forKey: .sizes)) ?? []
doughs = (try? container.decode([StorePizzaDough].self, forKey: .doughs)) ?? []
crusts = (try? container.decode([StorePizzaCrust].self, forKey: .crusts)) ?? []
}
}
struct StorePizzaSize: Decodable, Identifiable {
let id: String
let name: String?
let slices: Int?
let maxFlavors: Int?
enum CodingKeys: String, CodingKey {
case id
case name
case slices
case maxFlavors
}
}
struct StorePizzaDough: Decodable, Identifiable {
let id: String
let name: String?
let active: Bool?
enum CodingKeys: String, CodingKey {
case id
case name
case active
}
}
struct StorePizzaCrust: Decodable, Identifiable {
let id: String
let name: String?
let active: Bool?
let priceModifier: Double?
enum CodingKeys: String, CodingKey {
case id
case name
case active
case priceModifier
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = try? container.decode(String.self, forKey: .name)
active = try? container.decode(Bool.self, forKey: .active)
if let value = try? container.decode(Double.self, forKey: .priceModifier) {
priceModifier = value
} else if let value = try? container.decode(Int.self, forKey: .priceModifier) {
priceModifier = Double(value)
} else if let value = try? container.decode(String.self, forKey: .priceModifier) {
let normalized = value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
priceModifier = Double(normalized)
} else {
priceModifier = nil
}
}
}

View File

@@ -0,0 +1,268 @@
import Foundation
enum ReviewPlatform: String, Encodable {
case ios
case android
case web
static var current: ReviewPlatform {
#if os(iOS)
return .ios
#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

@@ -0,0 +1,531 @@
import Foundation
enum ApiServiceError: Error, LocalizedError {
case sessionExpired(String?)
var errorDescription: String? {
switch self {
case .sessionExpired(let message):
return message ?? "Sessao expirada. Faca login novamente."
}
}
}
struct ApiEnvelope<T: Decodable & Sendable>: Decodable, Sendable {
let error: Bool
let code: String?
let message: String?
let result: T?
}
final class ApiService {
private let client: ApiClient
private var tokenStore: TokenStore
private let profileCachePrefix = "api:profile:"
private let ordersCachePrefix = "api:orders:"
private let favoritesCachePrefix = "api:favorites:"
private let publicCategoriesCacheKey = "api:public-categories"
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
self.client = client
self.tokenStore = tokenStore
}
private func send<T: Decodable & Sendable>(_ req: ApiRequest) async throws -> T {
do {
return try await client.send(req)
} catch let error as NetworkError {
if case .unauthorized(let message) = error {
expireSession(message)
throw ApiServiceError.sessionExpired(message)
}
throw error
}
}
private func sendEnvelope<T: Decodable>(_ req: ApiRequest) async throws -> ApiEnvelope<T> {
let envelope: ApiEnvelope<T> = try await send(req)
if isSessionExpiredEnvelope(envelope) {
expireSession(envelope.message)
throw ApiServiceError.sessionExpired(envelope.message)
}
return envelope
}
private func isSessionExpiredEnvelope<T>(_ envelope: ApiEnvelope<T>) -> Bool {
guard envelope.error else { return false }
let code = (envelope.code ?? "").lowercased()
let message = (envelope.message ?? "").lowercased()
if code.contains("auth") || code.contains("token") || code.contains("unauthorized") {
return true
}
if message.contains("token") && (message.contains("expir") || message.contains("invalid") || message.contains("sess")) {
return true
}
return false
}
private func expireSession(_ message: String?) {
tokenStore.clear()
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
NotificationCenter.default.post(name: .sessionExpired, object: message)
}
private func invalidateFavoritesCache() {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
}
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> {
var payload: [String: String] = [
"name": name,
"email": email,
"phoneNumber": phoneNumber
]
if let birthDate, birthDate.isEmpty == false {
payload["birthDate"] = birthDate
}
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await sendEnvelope(req)
}
func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope<EmptyResult> {
let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: nil)
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await sendEnvelope(req)
}
func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope<LoginResult> {
let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: otp)
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
let response: ApiEnvelope<LoginResult> = try await sendEnvelope(req)
if let token = response.result?.token {
tokenStore.jwt = token
}
return response
}
private func makeLoginBody(email: String, phoneNumber: String, otp: String?) throws -> Data {
let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let sanitizedPhone = phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
guard sanitizedEmail.isEmpty == false, sanitizedPhone.isEmpty == false else {
throw NetworkError.httpError(400, "Email e telefone são obrigatórios.")
}
var payload: [String: String] = [
"email": sanitizedEmail,
"phoneNumber": sanitizedPhone,
"phone": sanitizedPhone
]
if let otp, otp.isEmpty == false {
payload["otp"] = otp
}
guard JSONSerialization.isValidJSONObject(payload) else {
throw NetworkError.invalidResponse
}
return try JSONSerialization.data(withJSONObject: payload, options: [])
}
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)
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 updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ProfilePatchEnvelope {
let payload = CustomerIdentityUpdatePayload(
name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name,
email: email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : email,
phoneNumber: phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : phoneNumber,
profilePicture: profilePicture
)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let envelope: ProfilePatchEnvelope = try await send(req)
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
return envelope
}
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
return try await saveCustomerAddress(address, replacingAddressId: nil)
}
func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: 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 currentAddressBook = customer.addressBook ?? []
var addressBook = currentAddressBook.map(CustomerAddressPayload.init(from:))
let addressPayload = CustomerAddressPayload(from: address)
if let replacingAddressId,
let replaceIndex = currentAddressBook.firstIndex(where: { $0.id == replacingAddressId }) {
addressBook[replaceIndex] = addressPayload
} else {
addressBook.insert(addressPayload, at: 0)
}
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: addressBook)
}
func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
var currentAddressBook = customer.addressBook ?? []
if let targetId = address.id, targetId.isEmpty == false {
if let index = currentAddressBook.firstIndex(where: { $0.id == targetId }) {
currentAddressBook.remove(at: index)
}
} else if let index = currentAddressBook.firstIndex(where: { matchesAddress($0, address) }) {
currentAddressBook.remove(at: index)
}
let updatedAddressBook = currentAddressBook.map(CustomerAddressPayload.init(from:))
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook)
}
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
return envelope
}
func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
let digits = zipCode.filter(\.isNumber)
let normalized = String(digits.prefix(8))
let formatted: String
if normalized.count == 8 {
let prefix = String(normalized.prefix(5))
let suffix = String(normalized.dropFirst(5))
formatted = "\(prefix)-\(suffix)"
} else {
formatted = normalized
}
let req = ApiRequest(path: "/api/public/cep/\(formatted)", method: "GET", module: .none, requiresAuth: true)
return try await sendEnvelope(req)
}
private func updateCustomerAddressBook(customerId: String, addressBook: [CustomerAddressPayload]) async throws -> ApiEnvelope<CustomerProfile> {
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)
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 {
invalidateFavoritesCache()
}
return envelope
}
func listFavoriteStores(forceRefresh: Bool = false) async throws -> ApiEnvelope<[StoreSummary]> {
let cacheKey = "\(favoritesCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<[StoreSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[StoreSummary]>.self) {
return cached
}
let req = ApiRequest(path: "/api/customer/favorites", method: "GET", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<[StoreSummary]> = try await sendEnvelope(req)
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func addStoreToFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
if envelope.error == false {
invalidateFavoritesCache()
}
return envelope
}
func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
if envelope.error == false {
invalidateFavoritesCache()
}
return envelope
}
func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
if isFavorite {
return try await addStoreToFavorites(storeId: storeId)
}
return try await removeStoreFromFavorites(storeId: storeId)
}
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
lhs.label == rhs.label &&
lhs.address == rhs.address &&
lhs.number == rhs.number &&
lhs.complement == rhs.complement &&
lhs.neighborhood == rhs.neighborhood &&
lhs.city == rhs.city &&
lhs.state == rhs.state &&
lhs.zipCode == rhs.zipCode
}
// MARK: - Stores
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)
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]> {
var items: [URLQueryItem] = []
if let lat, let lng {
items.append(URLQueryItem(name: "lat", value: String(lat)))
items.append(URLQueryItem(name: "lng", value: String(lng)))
}
if let category {
items.append(URLQueryItem(name: "category", value: category))
}
if let search {
items.append(URLQueryItem(name: "search", value: search))
}
let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items)
return try await sendEnvelope(req)
}
func storeInfo(storeId: String) async throws -> ApiEnvelope<StoreInfoResult> {
let req = ApiRequest(path: "/api/store/\(storeId)/info", method: "GET", module: .store, requiresAuth: true)
return try await sendEnvelope(req)
}
func storeCatalog(storeId: String) async throws -> ApiEnvelope<[StoreCatalogCategory]> {
let req = ApiRequest(path: "/api/store/\(storeId)/catalog", method: "GET", module: .store, requiresAuth: true)
return try await sendEnvelope(req)
}
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)
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> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/delivery/validate-address", method: "POST", module: .store, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
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,
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)
}
return envelope
}
func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> {
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)
}
// MARK: - Cards
func listCards() async throws -> ApiEnvelope<[SavedCard]> {
let req = ApiRequest(path: "/api/customer/cards", method: "GET", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
}
func saveCard(payload: SaveCardPayload) async throws -> ApiEnvelope<SavedCardResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/cards", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func updateCard(cardId: String, payload: UpdateCardPayload) async throws -> ApiEnvelope<SavedCardResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "PATCH", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func deleteCard(cardId: String) async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "DELETE", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
}
func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope<ChangePaymentMethodResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
// MARK: - Profile CPF
func updateProfileCpf(cpf: String) async throws -> ApiEnvelope<EmptyResult> {
let payload = ["cpf": cpf]
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let result: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
if result.error == false {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
}
return result
}
}
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) {
return value
}
if let asInt = try? container.decode(Int.self, forKey: key) {
return Double(asInt)
}
if let asString = try? container.decode(String.self, forKey: key) {
let normalized = asString
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
if let parsed = Double(normalized) {
return parsed
}
}
}
return nil
}
static func decodeFlexibleInt<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Int? {
for key in keys {
if let value = try? container.decode(Int.self, forKey: key) {
return value
}
if let asDouble = try? container.decode(Double.self, forKey: key) {
return Int(asDouble)
}
if let asString = try? container.decode(String.self, forKey: key) {
let normalized = asString
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ".", with: "")
.replacingOccurrences(of: ",", with: "")
if let parsed = Int(normalized) {
return parsed
}
}
}
return nil
}
}

View File

@@ -0,0 +1,154 @@
import Foundation
#if canImport(UIKit)
import UIKit
typealias PlatformImage = UIImage
#elseif canImport(AppKit)
import AppKit
typealias PlatformImage = NSImage
#endif
enum AppCacheTTL {
static let twoHours: TimeInterval = 2 * 60 * 60
static let homeStores: TimeInterval = 5 * 60
}
enum AppCacheKey {
static let homeStoresLatestSnapshot = "home-stores.latest.snapshot"
}
final class AppContentCache: @unchecked Sendable {
static let shared = AppContentCache()
private struct Entry {
let value: Any
let expiry: Date
}
private var entries: [String: Entry] = [:]
private let queue = DispatchQueue(label: "com.pedifoods.content-cache", qos: .userInitiated)
private init() {}
func value<T>(for key: String, as type: T.Type = T.self) -> T? {
queue.sync {
guard let entry = entries[key] else { return nil }
if entry.expiry <= Date() {
entries.removeValue(forKey: key)
return nil
}
return entry.value as? T
}
}
func set<T>(_ value: T, for key: String, ttl: TimeInterval) {
queue.sync {
entries[key] = Entry(value: value, expiry: Date().addingTimeInterval(ttl))
}
}
func invalidate(prefix: String? = nil) {
queue.sync {
guard let prefix, prefix.isEmpty == false else {
entries.removeAll()
return
}
let keys = entries.keys.filter { $0.hasPrefix(prefix) }
for key in keys {
entries.removeValue(forKey: key)
}
}
}
}
#if canImport(UIKit) || canImport(AppKit)
final class AppImageCache: @unchecked Sendable {
static let shared = AppImageCache()
private struct Entry {
let image: PlatformImage
let expiry: Date
}
private var entries: [String: Entry] = [:]
private let queue = DispatchQueue(label: "com.pedifoods.image-cache", qos: .userInitiated)
private init() {
configureURLCacheIfNeeded()
}
func image(for url: URL, ttl: TimeInterval, forceRefresh: Bool = false) async -> PlatformImage? {
let key = url.absoluteString
let now = Date()
if forceRefresh == false {
let cached = queue.sync { entries[key] }
if let cached, cached.expiry > now {
return cached.image
}
}
var request = URLRequest(url: url)
request.timeoutInterval = 20
request.cachePolicy = forceRefresh ? .reloadIgnoringLocalCacheData : .returnCacheDataElseLoad
if forceRefresh == false,
let diskCached = URLCache.shared.cachedResponse(for: request),
let image = platformImage(from: diskCached.data) {
queue.sync {
entries[key] = Entry(image: image, expiry: now.addingTimeInterval(ttl))
}
return image
}
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let image = platformImage(from: data) else { return nil }
URLCache.shared.storeCachedResponse(CachedURLResponse(response: response, data: data), for: request)
queue.sync {
entries[key] = Entry(image: image, expiry: now.addingTimeInterval(ttl))
}
return image
} catch {
return nil
}
}
func invalidateAll() {
queue.sync {
entries.removeAll()
}
URLCache.shared.removeAllCachedResponses()
}
private func configureURLCacheIfNeeded() {
let current = URLCache.shared
let minMemoryCapacity = 64 * 1024 * 1024
let minDiskCapacity = 256 * 1024 * 1024
if current.memoryCapacity < minMemoryCapacity || current.diskCapacity < minDiskCapacity {
URLCache.shared = URLCache(memoryCapacity: minMemoryCapacity, diskCapacity: minDiskCapacity)
}
}
private func platformImage(from data: Data) -> PlatformImage? {
#if canImport(UIKit)
return UIImage(data: data)
#elseif canImport(AppKit)
return NSImage(data: data)
#else
return nil
#endif
}
}
#endif
#if !(canImport(UIKit) || canImport(AppKit))
final class AppImageCache: @unchecked Sendable {
static let shared = AppImageCache()
private init() {}
func invalidateAll() {}
}
#endif

View File

@@ -0,0 +1,283 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
struct FeatureControlRawFlag: Codable, Equatable {
let enabled: Bool
let variant: String
let payload: FeatureControlJSONValue?
let reason: String?
}
enum FeatureControlJSONValue: Codable, Equatable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: FeatureControlJSONValue])
case array([FeatureControlJSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([String: FeatureControlJSONValue].self) {
self = .object(value)
} else if let value = try? container.decode([FeatureControlJSONValue].self) {
self = .array(value)
} else {
self = .null
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let value): try container.encode(value)
case .number(let value): try container.encode(value)
case .bool(let value): try container.encode(value)
case .object(let value): try container.encode(value)
case .array(let value): try container.encode(value)
case .null: try container.encodeNil()
}
}
}
private struct FeatureControlBootstrapRequest: Codable {
struct Context: Codable {
let subjectType: String
let subjectId: String
let storeId: String?
let platform: String
let appVersion: String
let attributes: [String: String]
}
let environment: String
let keys: [String]
let context: Context
}
private struct FeatureControlBootstrapResponse: Codable {
let ok: Bool
let source: String?
let configVersion: Int
let evaluatedAt: String?
let flags: [String: FeatureFlagValue]
let raw: [String: FeatureControlRawFlag]
}
private struct FeatureControlExposureRequest: Codable {
struct Event: Codable {
let featureKey: String
let variant: String
let subjectType: String
let storeId: String?
}
let events: [Event]
}
private struct FeatureControlCacheEntry: Codable {
let expiresAtUnixMs: Int64
let snapshot: FeatureFlagsState
}
struct FeatureControlEvaluationContext {
let subjectType: String
let subjectId: String
let storeId: String?
let attributes: [String: String]
}
@MainActor
final class FeatureControlService {
static let shared = FeatureControlService()
private let session: URLSession
private let cacheTTL: TimeInterval
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let userDefaults: UserDefaults
private let defaultsPrefix = "feature-control.cache.v1."
init(
session: URLSession = .shared,
cacheTTL: TimeInterval = 60,
userDefaults: UserDefaults = .standard
) {
self.session = session
self.cacheTTL = cacheTTL
self.userDefaults = userDefaults
}
func evaluate(
context: FeatureControlEvaluationContext,
jwt: String?,
forceRefresh: Bool = false
) async -> FeatureFlagsState {
let key = storageKey(for: context)
if forceRefresh == false, let cached = loadFromCache(storageKey: key) {
return cached
}
let requestBody = FeatureControlBootstrapRequest(
environment: ApiConfig.featureControlEnvironment,
keys: featureKeys(),
context: .init(
subjectType: context.subjectType,
subjectId: context.subjectId,
storeId: context.storeId,
platform: platformName(),
appVersion: appVersion(),
attributes: context.attributes
)
)
do {
let response = try await performBootstrapRequest(body: requestBody, jwt: jwt)
let snapshot = FeatureFlagsState(
configVersion: response.configVersion,
evaluatedAt: response.evaluatedAt,
source: response.source ?? "live",
values: response.flags,
raw: response.raw
)
saveToCache(snapshot: snapshot, storageKey: key)
return snapshot
} catch {
if let cached = loadFromCache(storageKey: key) {
return FeatureFlagsState(
configVersion: cached.configVersion,
evaluatedAt: cached.evaluatedAt,
source: "cache_fallback",
values: cached.values,
raw: cached.raw
)
}
return FeatureFlagsState(source: "defaults")
}
}
func sendExposureEvents(snapshot: FeatureFlagsState, context: FeatureControlEvaluationContext, jwt: String?) async {
guard snapshot.raw.isEmpty == false else { return }
let events = snapshot.raw.compactMap { entry -> FeatureControlExposureRequest.Event? in
let key = entry.key
let value = entry.value
guard value.enabled || value.variant.lowercased() != "off" else { return nil }
return .init(
featureKey: key,
variant: value.variant,
subjectType: context.subjectType,
storeId: context.storeId
)
}
guard events.isEmpty == false else { return }
let batched = Array(events.prefix(100))
let payload = FeatureControlExposureRequest(events: batched)
guard let body = try? encoder.encode(payload) else { return }
var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/telemetry/exposure"))
request.httpMethod = "POST"
request.httpBody = body
request.timeoutInterval = 3
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let jwt, jwt.isEmpty == false {
request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
}
_ = try? await session.data(for: request)
}
private func performBootstrapRequest(body: FeatureControlBootstrapRequest, jwt: String?) async throws -> FeatureControlBootstrapResponse {
var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/bootstrap"))
request.httpMethod = "POST"
request.httpBody = try encoder.encode(body)
request.timeoutInterval = 3
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let jwt, jwt.isEmpty == false {
request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw NetworkError.invalidResponse
}
return try decoder.decode(FeatureControlBootstrapResponse.self, from: data)
}
private func storageKey(for context: FeatureControlEvaluationContext) -> String {
let tokens = [
ApiConfig.featureControlEnvironment,
context.subjectType,
context.subjectId,
context.storeId ?? "none",
platformName(),
appVersion(),
featureKeys().joined(separator: "|")
]
let base = tokens.joined(separator: "::")
.lowercased()
.replacingOccurrences(of: " ", with: "_")
return defaultsPrefix + base
}
private func saveToCache(snapshot: FeatureFlagsState, storageKey: String) {
let expiresAt = Int64((Date().timeIntervalSince1970 + cacheTTL) * 1000)
let entry = FeatureControlCacheEntry(expiresAtUnixMs: expiresAt, snapshot: snapshot)
guard let data = try? encoder.encode(entry) else { return }
userDefaults.set(data, forKey: storageKey)
}
private func loadFromCache(storageKey: String) -> FeatureFlagsState? {
guard let data = userDefaults.data(forKey: storageKey),
let entry = try? decoder.decode(FeatureControlCacheEntry.self, from: data) else {
return nil
}
let now = Int64(Date().timeIntervalSince1970 * 1000)
guard entry.expiresAtUnixMs > now else {
userDefaults.removeObject(forKey: storageKey)
return nil
}
return entry.snapshot
}
private func featureKeys() -> [String] {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only,at.cupons"
let items = raw
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false }
return items.isEmpty ? ["at.ios.only"] : items
}
private func platformName() -> String {
return "ios"
}
private func appVersion() -> String {
#if canImport(UIKit) || canImport(AppKit)
let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let version, version.isEmpty == false {
return version
}
#endif
return "0.0.0"
}
}

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

@@ -0,0 +1,136 @@
import Foundation
#if os(iOS)
import CoreLocation
#endif
@MainActor
final class LocationService: NSObject {
typealias LocationResult = Result<(Double, Double), LocationError>
static let shared = LocationService()
#if os(iOS)
enum LocationError: Error {
case servicesDisabled
case denied
case unavailable
}
#else
enum LocationError: Error {
case denied
case unavailable
}
#endif
#if os(iOS)
private let manager = CLLocationManager()
private var completion: ((LocationResult) -> Void)?
#endif
override init() {
super.init()
#if os(iOS)
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
#endif
}
func requestLocation(_ completion: @escaping (LocationResult) -> Void) {
#if os(iOS)
self.completion = completion
handleAuthorizationStatus(manager.authorizationStatus)
#else
let defaults = UserDefaults.standard
if defaults.bool(forKey: "location_permission_denied") {
completion(.failure(.denied))
return
}
guard let latRaw = defaults.string(forKey: "last_location_lat"),
let lngRaw = defaults.string(forKey: "last_location_lng"),
let lat = Double(latRaw),
let lng = Double(lngRaw) else {
completion(.failure(.unavailable))
return
}
completion(.success((lat, lng)))
#endif
}
func cachedLocation() -> (Double, Double)? {
#if os(iOS)
guard let location = manager.location else {
return nil
}
return (location.coordinate.latitude, location.coordinate.longitude)
#else
let defaults = UserDefaults.standard
guard let latRaw = defaults.string(forKey: "last_location_lat"),
let lngRaw = defaults.string(forKey: "last_location_lng"),
let lat = Double(latRaw),
let lng = Double(lngRaw) else {
return nil
}
return (lat, lng)
#endif
}
func requestLocationAsync(timeoutSeconds: TimeInterval = 8) async -> (Double, Double)? {
await withCheckedContinuation { continuation in
var hasResumed = false
func resumeOnce(_ value: (Double, Double)?) {
guard hasResumed == false else { return }
hasResumed = true
continuation.resume(returning: value)
}
requestLocation { result in
switch result {
case .success(let coordinate):
resumeOnce(coordinate)
case .failure:
resumeOnce(nil)
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) {
resumeOnce(nil)
}
}
}
}
#if os(iOS)
extension LocationService: @preconcurrency CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
handleAuthorizationStatus(manager.authorizationStatus)
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.first else { return }
completion?(.success((location.coordinate.latitude, location.coordinate.longitude)))
completion = nil
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
completion?(.failure(.unavailable))
completion = nil
}
private func handleAuthorizationStatus(_ status: CLAuthorizationStatus) {
switch status {
case .notDetermined:
manager.requestWhenInUseAuthorization()
case .authorizedAlways, .authorizedWhenInUse:
manager.requestLocation()
case .denied, .restricted:
completion?(.failure(.denied))
completion = nil
@unknown default:
completion?(.failure(.unavailable))
completion = nil
}
}
}
#endif

View File

@@ -0,0 +1,253 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
@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
var onOrderUpdated: ((PublicOrderResult) -> Void)?
func start(orderId: String, jwt: String?) {
stop()
activeOrderId = orderId
pollingTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.runPollingLoop(orderId: orderId)
}
guard useSocketRealtime, let jwt, jwt.isEmpty == false else { return }
let socket = OrderSocketClient()
socket.onOrderUpdate = { [weak self] update in
guard let self else { return }
guard update.id == orderId else { return }
Task { @MainActor [weak self] in
guard let self else { return }
await self.fetchLatest(orderId: orderId)
}
}
socket.connect(jwt: jwt)
socketClient = socket
}
func stop() {
pollingTask?.cancel()
pollingTask = nil
socketClient?.disconnect()
socketClient = nil
activeOrderId = nil
}
private func runPollingLoop(orderId: String) async {
var elapsedSeconds = 0
while Task.isCancelled == false {
if activeOrderId != orderId { return }
let fetched = await fetchLatest(orderId: orderId)
if fetched?.isFinalStatus == true {
return
}
let delay = pollingDelay(for: elapsedSeconds)
elapsedSeconds += delay
do {
try await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000_000)
} catch {
return
}
}
}
private func pollingDelay(for elapsedSeconds: Int) -> Int {
if elapsedSeconds < 60 { return 3 }
if elapsedSeconds < 180 { return 5 }
return 10
}
@discardableResult
private func fetchLatest(orderId: String) async -> PublicOrderResult? {
do {
logger.debug("OrderTracking poll request orderId=\(orderId)")
let response = try await ApiService().publicOrder(orderId: orderId)
guard response.error == false, let order = response.result else {
logger.error("OrderTracking poll API error orderId=\(orderId) message=\(response.message ?? "unknown")")
return nil
}
clearPendingCartIfNeeded(for: order)
logger.info("OrderTracking poll success orderId=\(orderId) status=\(order.status ?? "nil") paymentStatus=\(order.paymentStatus ?? "nil")")
onOrderUpdated?(order)
return order
} catch {
logger.error("OrderTracking poll failure orderId=\(orderId) error=\(error.localizedDescription)")
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 {
var onOrderUpdate: ((OrderRealtimeUpdate) -> Void)?
#if os(iOS) || os(macOS)
private var task: URLSessionWebSocketTask? = nil
private let session = URLSession(configuration: .default)
private var isConnected = false
private var pendingJWT: String? = nil
#endif
func connect(jwt: String) {
#if os(iOS) || os(macOS)
disconnect()
guard let url = makeSocketURL() else { return }
let wsTask = session.webSocketTask(with: url)
wsTask.resume()
task = wsTask
pendingJWT = jwt
receiveLoop()
#else
_ = jwt
#endif
}
func disconnect() {
#if os(iOS) || os(macOS)
isConnected = false
pendingJWT = nil
task?.cancel(with: .goingAway, reason: nil)
task = nil
#endif
}
#if os(iOS) || os(macOS)
private func receiveLoop() {
guard let task else { return }
task.receive { [weak self] result in
guard let self else { return }
switch result {
case .failure:
self.disconnect()
case .success(let message):
self.handleMessage(message)
self.receiveLoop()
}
}
}
private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
let text: String
switch message {
case .string(let value):
text = value
case .data(let data):
text = String(data: data, encoding: .utf8) ?? ""
@unknown default:
return
}
guard text.isEmpty == false else { return }
if text == "2" {
task?.send(.string("3")) { _ in }
return
}
if text.hasPrefix("0"), let jwt = pendingJWT {
let authPacket = "40{\"token\":\"Bearer \(jwt)\"}"
task?.send(.string(authPacket)) { _ in }
pendingJWT = nil
return
}
if text.hasPrefix("40") {
isConnected = true
return
}
guard text.hasPrefix("42") else { return }
let eventPayload = String(text.dropFirst(2))
guard let data = eventPayload.data(using: .utf8) else { return }
if let rawArray = try? JSONSerialization.jsonObject(with: data) as? [Any],
rawArray.count >= 2,
let eventName = rawArray[0] as? String,
eventName == "order_update" {
let payloadAny = rawArray[1]
guard JSONSerialization.isValidJSONObject(payloadAny),
let payloadData = try? JSONSerialization.data(withJSONObject: payloadAny),
let update = try? JSONDecoder().decode(OrderRealtimeUpdate.self, from: payloadData) else {
return
}
onOrderUpdate?(update)
return
}
// Compat: alguns servidores podem encapsular o evento como objeto.
if let rawObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let eventName = (rawObject["event"] as? String)?.lowercased(),
eventName == "order_update",
let payloadAny = rawObject["data"],
JSONSerialization.isValidJSONObject(payloadAny),
let payloadData = try? JSONSerialization.data(withJSONObject: payloadAny),
let update = try? JSONDecoder().decode(OrderRealtimeUpdate.self, from: payloadData) {
onOrderUpdate?(update)
}
}
private func makeSocketURL() -> URL? {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
return nil
}
if components.scheme == "https" {
components.scheme = "wss"
} else {
components.scheme = "ws"
}
components.path = "/socket.io/"
components.queryItems = [
URLQueryItem(name: "EIO", value: "4"),
URLQueryItem(name: "transport", value: "websocket")
]
return components.url
}
#endif
}

View File

@@ -0,0 +1,8 @@
import Foundation
extension Notification.Name {
static let sessionExpired = Notification.Name("SessionExpiredNotification")
static let cartDidReset = Notification.Name("CartDidResetNotification")
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
static let appDidResume = Notification.Name("AppDidResumeNotification")
}

View File

@@ -0,0 +1,441 @@
import Foundation
private struct PersistedAddressState: Codable {
let selectedId: String?
let display: String
let latitude: Double?
let longitude: Double?
}
private struct PersistedCartAddonState: Codable {
let id: String
let name: String
let quantity: Int
let unitPrice: Double
}
private struct PersistedCartItemState: Codable {
let id: String
let productId: String
let storeId: String
let name: String
let imageURL: String?
let details: String?
let addons: [PersistedCartAddonState]
let quantity: Int
let unitPrice: Double
}
private struct PersistedCartState: Codable {
let storeId: String?
let storeName: String?
let items: [PersistedCartItemState]
let total: Double
}
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."
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."
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 ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if id.isEmpty == false {
return "id:\(id)"
}
let mail = (email ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if mail.isEmpty == false {
return "email:\(mail)"
}
return nil
}
static func setActiveUserKey(_ userKey: String?) {
let trimmed = (userKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
UserDefaults.standard.removeObject(forKey: activeUserKey)
} else {
UserDefaults.standard.set(trimmed, forKey: activeUserKey)
}
}
static func loadActiveUserKey() -> String? {
let value = UserDefaults.standard.string(forKey: activeUserKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, value.isEmpty == false {
return value
}
return nil
}
private static func addressStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return addressKeyPrefix + safe
}
static func loadAddress() -> AddressState? {
let defaults = UserDefaults.standard
let activeKey = loadActiveUserKey()
let scopedKey = addressStorageKey(for: activeKey)
if let data = defaults.data(forKey: scopedKey),
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) {
return AddressState(
selectedId: decoded.selectedId,
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
latitude: decoded.latitude,
longitude: decoded.longitude,
onboardingMessage: nil
)
}
// Backward-compatible fallback for data persisted before user scoping.
let anonymousKey = addressStorageKey(for: "anonymous")
if let data = defaults.data(forKey: anonymousKey),
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) {
let recovered = AddressState(
selectedId: decoded.selectedId,
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
latitude: decoded.latitude,
longitude: decoded.longitude,
onboardingMessage: nil
)
// Migrate anonymous cache into the current active user namespace.
if let activeKey, activeKey.isEmpty == false {
let payload = PersistedAddressState(
selectedId: recovered.selectedId,
display: recovered.display,
latitude: recovered.latitude,
longitude: recovered.longitude
)
if let migratedData = try? JSONEncoder().encode(payload) {
defaults.set(migratedData, forKey: scopedKey)
}
}
return recovered
}
guard let data = defaults.data(forKey: legacyAddressKey),
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) else {
return nil
}
let migrated = AddressState(
selectedId: decoded.selectedId,
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
latitude: decoded.latitude,
longitude: decoded.longitude,
onboardingMessage: nil
)
saveAddress(migrated)
defaults.removeObject(forKey: legacyAddressKey)
return migrated
}
static func saveAddress(_ state: AddressState) {
let payload = PersistedAddressState(
selectedId: state.selectedId,
display: state.display,
latitude: state.latitude,
longitude: state.longitude
)
guard let data = try? JSONEncoder().encode(payload) else { return }
UserDefaults.standard.set(data, forKey: addressStorageKey(for: nil))
}
static func clearAddress() {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: addressStorageKey(for: nil))
defaults.removeObject(forKey: legacyAddressKey)
}
static func clearActiveUser() {
UserDefaults.standard.removeObject(forKey: activeUserKey)
}
private static func cartStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return cartKeyPrefix + safe
}
static func loadCart() -> CartState? {
let defaults = UserDefaults.standard
let key = cartStorageKey(for: nil)
if let data = defaults.data(forKey: key),
let decoded = try? JSONDecoder().decode(PersistedCartState.self, from: data) {
return CartState(
storeId: decoded.storeId,
storeName: decoded.storeName,
items: decoded.items.map {
CartItemState(
id: $0.id,
productId: $0.productId,
storeId: $0.storeId,
name: $0.name,
imageURL: $0.imageURL,
details: $0.details,
addons: $0.addons.map {
CartItemAddonState(
id: $0.id,
name: $0.name,
quantity: $0.quantity,
unitPrice: $0.unitPrice
)
},
quantity: $0.quantity,
unitPrice: $0.unitPrice
)
},
total: decoded.total
)
}
return nil
}
static func saveCart(_ state: CartState) {
let payload = PersistedCartState(
storeId: state.storeId,
storeName: state.storeName,
items: state.items.map {
PersistedCartItemState(
id: $0.id,
productId: $0.productId,
storeId: $0.storeId,
name: $0.name,
imageURL: $0.imageURL,
details: $0.details,
addons: $0.addons.map {
PersistedCartAddonState(
id: $0.id,
name: $0.name,
quantity: $0.quantity,
unitPrice: $0.unitPrice
)
},
quantity: $0.quantity,
unitPrice: $0.unitPrice
)
},
total: state.total
)
guard let data = try? JSONEncoder().encode(payload) else { return }
UserDefaults.standard.set(data, forKey: cartStorageKey(for: nil))
}
static func clearCart() {
UserDefaults.standard.removeObject(forKey: cartStorageKey(for: nil))
}
private static func trackedOrdersStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
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
}
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)
guard let data = defaults.data(forKey: key),
let decoded = try? JSONDecoder().decode(PersistedTrackedOrdersState.self, from: data) else {
return []
}
return decoded.orders
}
static func loadTrackedOrder(orderId: String) -> PublicOrderResult? {
loadTrackedOrders().first(where: { $0.id == orderId })
}
static func saveTrackedOrder(_ order: PublicOrderResult) {
var orders = loadTrackedOrders()
if let index = orders.firstIndex(where: { $0.id == order.id }) {
orders[index] = order
} else {
orders.insert(order, at: 0)
}
if orders.count > 60 {
orders = Array(orders.prefix(60))
}
let payload = PersistedTrackedOrdersState(orders: orders)
guard let data = try? JSONEncoder().encode(payload) else { return }
UserDefaults.standard.set(data, forKey: trackedOrdersStorageKey(for: nil))
}
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))
}
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)
}
}

View File

@@ -0,0 +1,280 @@
import Foundation
enum StoreCatalogNormalizer {
static func sanitize(categories: [StoreCatalogCategory], storeId: String) -> [StoreCatalogCategory] {
var seenCategoryIds: Set<String> = []
return categories.enumerated().map { categoryIndex, category in
let categoryId = makeUniqueId(
rawValue: category.id,
fallback: "\(storeId)-category-\(categoryIndex)",
seenIds: &seenCategoryIds
)
let normalizedPizzaConfig = sanitize(
pizzaConfig: category.pizzaConfig,
categoryId: categoryId
)
var seenProductIds: Set<String> = []
let normalizedProducts = category.products.enumerated().map { productIndex, product in
sanitize(
product: product,
categoryId: categoryId,
productIndex: productIndex,
seenProductIds: &seenProductIds
)
}
return StoreCatalogCategory(
id: categoryId,
name: category.name,
isPizzaCategory: category.isPizzaCategory,
pizzaConfig: normalizedPizzaConfig,
products: normalizedProducts
)
}
}
static func preferredCategoryId(
from categories: [StoreCatalogCategory],
preferredId: String?
) -> String? {
guard let preferredId, preferredId.isEmpty == false else {
return categories.first?.id
}
if categories.contains(where: { $0.id == preferredId }) {
return preferredId
}
return categories.first?.id
}
private static func sanitize(
pizzaConfig: StorePizzaConfig?,
categoryId: String
) -> StorePizzaConfig? {
guard let pizzaConfig else { return nil }
var seenSizeIds: Set<String> = []
let normalizedSizes = pizzaConfig.sizes.enumerated().map { index, size in
StorePizzaSize(
id: makeUniqueId(
rawValue: size.id,
fallback: "\(categoryId)-size-\(index)",
seenIds: &seenSizeIds
),
name: size.name,
slices: size.slices,
maxFlavors: size.maxFlavors
)
}
var seenDoughIds: Set<String> = []
let normalizedDoughs = pizzaConfig.doughs.enumerated().map { index, dough in
StorePizzaDough(
id: makeUniqueId(
rawValue: dough.id,
fallback: "\(categoryId)-dough-\(index)",
seenIds: &seenDoughIds
),
name: dough.name,
active: dough.active
)
}
var seenCrustIds: Set<String> = []
let normalizedCrusts = pizzaConfig.crusts.enumerated().map { index, crust in
StorePizzaCrust(
id: makeUniqueId(
rawValue: crust.id,
fallback: "\(categoryId)-crust-\(index)",
seenIds: &seenCrustIds
),
name: crust.name,
active: crust.active,
priceModifier: crust.priceModifier
)
}
return StorePizzaConfig(
sizes: normalizedSizes,
doughs: normalizedDoughs,
crusts: normalizedCrusts
)
}
private static func sanitize(
product: StoreCatalogProduct,
categoryId: String,
productIndex: Int,
seenProductIds: inout Set<String>
) -> StoreCatalogProduct {
let productId = makeUniqueId(
rawValue: product.id,
fallback: "\(categoryId)-product-\(productIndex)",
seenIds: &seenProductIds
)
var seenGroupIds: Set<String> = []
var seenAddonItemIds: Set<String> = []
let normalizedAddonGroups = product.addonGroups.enumerated().map { groupIndex, group in
let groupId = makeUniqueId(
rawValue: group.id,
fallback: "\(productId)-group-\(groupIndex)",
seenIds: &seenGroupIds
)
let normalizedItems = group.items.enumerated().map { itemIndex, item in
StoreAddonItem(
id: makeUniqueId(
rawValue: item.id,
fallback: "\(groupId)-item-\(itemIndex)",
seenIds: &seenAddonItemIds
),
name: item.name,
price: item.price
)
}
return StoreAddonGroup(
id: groupId,
name: group.name,
minSelectors: group.minSelectors,
maxSelectors: group.maxSelectors,
items: normalizedItems
)
}
return StoreCatalogProduct(
id: productId,
type: product.type,
name: product.name,
description: product.description,
image: product.image,
price: product.price,
originalPrice: product.originalPrice,
pizzaPrices: product.pizzaPrices,
addonGroups: normalizedAddonGroups
)
}
private static func makeUniqueId(
rawValue: String,
fallback: String,
seenIds: inout Set<String>
) -> String {
let trimmedRawValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
let baseId = trimmedRawValue.isEmpty ? fallback : trimmedRawValue
if seenIds.contains(baseId) == false {
seenIds.insert(baseId)
return baseId
}
var suffix = 1
while true {
let candidate = "\(baseId)-\(suffix)"
if seenIds.contains(candidate) == false {
seenIds.insert(candidate)
return candidate
}
suffix += 1
}
}
}
extension StoreCatalogCategory {
init(
id: String,
name: String,
isPizzaCategory: Bool,
pizzaConfig: StorePizzaConfig?,
products: [StoreCatalogProduct]
) {
self.id = id
self.name = name
self.isPizzaCategory = isPizzaCategory
self.pizzaConfig = pizzaConfig
self.products = products
}
}
extension StoreCatalogProduct {
init(
id: String,
type: String?,
name: String,
description: String?,
image: String?,
price: Double?,
originalPrice: Double?,
pizzaPrices: [String: Double],
addonGroups: [StoreAddonGroup]
) {
self.id = id
self.type = type
self.name = name
self.description = description
self.image = image
self.price = price
self.originalPrice = originalPrice
self.pizzaPrices = pizzaPrices
self.addonGroups = addonGroups
}
}
extension StoreAddonGroup {
init(
id: String,
name: String,
minSelectors: Int?,
maxSelectors: Int?,
items: [StoreAddonItem]
) {
self.id = id
self.name = name
self.minSelectors = minSelectors
self.maxSelectors = maxSelectors
self.items = items
}
}
extension StoreAddonItem {
init(
id: String,
name: String,
price: Double?
) {
self.id = id
self.name = name
self.price = price
}
}
extension StorePizzaConfig {
init(
sizes: [StorePizzaSize],
doughs: [StorePizzaDough],
crusts: [StorePizzaCrust]
) {
self.sizes = sizes
self.doughs = doughs
self.crusts = crusts
}
}
extension StorePizzaCrust {
init(
id: String,
name: String?,
active: Bool?,
priceModifier: Double?
) {
self.id = id
self.name = name
self.active = active
self.priceModifier = priceModifier
}
}

View File

@@ -0,0 +1,91 @@
import Foundation
#if os(iOS)
import Security
#endif
protocol TokenStore: AnyObject {
var jwt: String? { get set }
func clear()
}
final class DefaultTokenStore: TokenStore {
private let key = "auth_jwt"
private let defaults = UserDefaults.standard
var jwt: String? {
get {
#if os(iOS)
if let keychainValue = loadKeychainValue(for: key) {
return keychainValue
}
#endif
return defaults.string(forKey: key)
}
set {
#if os(iOS)
if let newValue {
saveKeychainValue(newValue, for: key)
} else {
deleteKeychainValue(for: key)
}
#endif
defaults.set(newValue, forKey: key)
}
}
func clear() {
#if os(iOS)
deleteKeychainValue(for: key)
#endif
defaults.removeObject(forKey: key)
}
#if os(iOS)
private var serviceName: String { "com.br.pedifoods.app.auth" }
private func saveKeychainValue(_ value: String, for key: String) {
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemAdd(attributes as CFDictionary, nil)
}
private func loadKeychainValue(for key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
}
private func deleteKeychainValue(for key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
#endif
}