Fix
This commit is contained in:
@@ -84,12 +84,8 @@ final class ApiClient {
|
||||
func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
|
||||
// NOTE:
|
||||
// /api/customer/login is strict about body fields (email/phoneNumber/otp).
|
||||
// On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is.
|
||||
// Also route order-related polling/listing through URLSession to avoid
|
||||
// intermittent cancellation observed with the shared API bridge.
|
||||
if request.path == "/api/customer/login" ||
|
||||
request.path == "/api/app/orders" ||
|
||||
request.path.hasPrefix("/api/public/orders/") {
|
||||
// On iOS, route this endpoint through URLSession to preserve raw JSON body behavior.
|
||||
if request.path == "/api/customer/login" {
|
||||
if let body = request.body, let bodyText = String(data: body, encoding: .utf8) {
|
||||
print("[ApiClient] /api/customer/login body: \(bodyText)")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,37 @@ struct CustomerProfileUpdatePayload: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomerIdentityUpdatePayload: Encodable {
|
||||
let name: String
|
||||
let email: String
|
||||
let phoneNumber: String
|
||||
let profilePicture: String?
|
||||
let addressBook: [CustomerAddressPayload]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case email
|
||||
case phoneNumber
|
||||
case phone
|
||||
case profilePicture
|
||||
case profile_picture
|
||||
case addressBook = "address_book"
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(email, forKey: .email)
|
||||
try container.encode(phoneNumber, forKey: .phoneNumber)
|
||||
try container.encode(phoneNumber, forKey: .phone)
|
||||
try container.encode(addressBook, forKey: .addressBook)
|
||||
if let profilePicture, profilePicture.isEmpty == false {
|
||||
try container.encode(profilePicture, forKey: .profilePicture)
|
||||
try container.encode(profilePicture, forKey: .profile_picture)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomerAddressPayload: Encodable {
|
||||
let label: String?
|
||||
let address: String?
|
||||
|
||||
@@ -114,6 +114,9 @@ struct StoreSummary: Decodable {
|
||||
struct StoreInfoResult: Decodable {
|
||||
let isOpen: Bool?
|
||||
let statusLabel: String?
|
||||
let fantasyName: String?
|
||||
let logo: String?
|
||||
let cover: String?
|
||||
let deliveryTime: String?
|
||||
let minOrder: Double?
|
||||
let address: StoreAddressInfo?
|
||||
@@ -122,6 +125,9 @@ struct StoreInfoResult: Decodable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case isOpen
|
||||
case statusLabel
|
||||
case fantasyName
|
||||
case logo
|
||||
case cover
|
||||
case deliveryTime
|
||||
case minOrder
|
||||
case address
|
||||
@@ -132,6 +138,9 @@ struct StoreInfoResult: Decodable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
|
||||
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
|
||||
fantasyName = try? container.decode(String.self, forKey: .fantasyName)
|
||||
logo = try? container.decode(String.self, forKey: .logo)
|
||||
cover = try? container.decode(String.self, forKey: .cover)
|
||||
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
|
||||
minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder])
|
||||
address = try? container.decode(StoreAddressInfo.self, forKey: .address)
|
||||
|
||||
270
pedi-foods/Sources/PediFoods/Services/ApiReviewModels.swift
Normal file
270
pedi-foods/Sources/PediFoods/Services/ApiReviewModels.swift
Normal file
@@ -0,0 +1,270 @@
|
||||
import Foundation
|
||||
|
||||
enum ReviewPlatform: String, Encodable {
|
||||
case ios
|
||||
case android
|
||||
case web
|
||||
|
||||
static var current: ReviewPlatform {
|
||||
#if os(iOS)
|
||||
return .ios
|
||||
#elseif os(Android)
|
||||
return .android
|
||||
#else
|
||||
return .web
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct SubmitOrderReviewPayload: Encodable {
|
||||
let rate: Int
|
||||
let message: String
|
||||
let orderRate: Int
|
||||
let orderComment: String
|
||||
let orderPositiveTags: [String]
|
||||
let orderImprovementTags: [String]
|
||||
let deliverySentiment: String
|
||||
let deliveryPositiveTags: [String]
|
||||
let deliveryNegativeTags: [String]
|
||||
let appNps: Int
|
||||
let platform: String
|
||||
}
|
||||
|
||||
struct ReviewTagItem: Decodable, Hashable, Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
}
|
||||
|
||||
struct ReviewOrderTagRules: Decodable {
|
||||
let positiveAllowedWhenRateGte: Int?
|
||||
let improvementAllowedWhenRateLte: Int?
|
||||
}
|
||||
|
||||
struct ReviewOrderTagsCatalog: Decodable {
|
||||
let positive: [ReviewTagItem]
|
||||
let improvement: [ReviewTagItem]
|
||||
let rules: ReviewOrderTagRules?
|
||||
}
|
||||
|
||||
struct ReviewDeliverySentimentRule: Decodable {
|
||||
let id: String
|
||||
let allowedTags: [String]
|
||||
}
|
||||
|
||||
struct ReviewDeliveryTagsCatalog: Decodable {
|
||||
let sentiments: [ReviewDeliverySentimentRule]
|
||||
let positive: [ReviewTagItem]
|
||||
let negative: [ReviewTagItem]
|
||||
}
|
||||
|
||||
struct ReviewNpsCatalog: Decodable {
|
||||
let min: Int?
|
||||
let max: Int?
|
||||
}
|
||||
|
||||
struct ReviewAppTagsCatalog: Decodable {
|
||||
let nps: ReviewNpsCatalog?
|
||||
let platforms: [String]?
|
||||
}
|
||||
|
||||
struct ReviewTagsCatalog: Decodable {
|
||||
let version: String?
|
||||
let order: ReviewOrderTagsCatalog?
|
||||
let delivery: ReviewDeliveryTagsCatalog?
|
||||
let app: ReviewAppTagsCatalog?
|
||||
}
|
||||
|
||||
struct SubmitOrderReviewResult: Decodable {
|
||||
let id: String?
|
||||
let storeId: String?
|
||||
let userId: String?
|
||||
let clientName: String?
|
||||
let rate: Int?
|
||||
let message: String?
|
||||
let orderRate: Int?
|
||||
let orderComment: String?
|
||||
let deliverySentiment: String?
|
||||
let orderPositiveTags: [String]?
|
||||
let orderImprovementTags: [String]?
|
||||
let deliveryPositiveTags: [String]?
|
||||
let deliveryNegativeTags: [String]?
|
||||
let appNps: Int?
|
||||
let platform: String?
|
||||
let orderId: String?
|
||||
let date: String?
|
||||
let editableUntil: String?
|
||||
let storeReplyUntil: String?
|
||||
let reviewWindowExpiresAt: String?
|
||||
let storeReplyMessage: String?
|
||||
let storeReplyAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case storeId
|
||||
case userId
|
||||
case clientName
|
||||
case rate
|
||||
case message
|
||||
case orderRate
|
||||
case orderComment
|
||||
case deliverySentiment
|
||||
case deliveryFeedback
|
||||
case itemFeedback
|
||||
case improvementFeedback
|
||||
case orderPositiveTags
|
||||
case orderImprovementTags
|
||||
case deliveryPositiveTags
|
||||
case deliveryNegativeTags
|
||||
case appNps
|
||||
case app_nps
|
||||
case platform
|
||||
case orderId
|
||||
case date
|
||||
case editableUntil
|
||||
case storeReplyUntil
|
||||
case reviewWindowExpiresAt
|
||||
case storeReply
|
||||
case store_response
|
||||
case storeResponse
|
||||
case reply
|
||||
case storeReplyMessage
|
||||
case storeReplyAt
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
|
||||
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId])
|
||||
userId = ApiService.decodeFlexibleString(from: container, keys: [.userId])
|
||||
clientName = ApiService.decodeFlexibleString(from: container, keys: [.clientName])
|
||||
rate = ApiService.decodeFlexibleInt(from: container, keys: [.rate])
|
||||
message = ApiService.decodeFlexibleString(from: container, keys: [.message])
|
||||
orderRate = ApiService.decodeFlexibleInt(from: container, keys: [.orderRate, .rate])
|
||||
orderComment = ApiService.decodeFlexibleString(from: container, keys: [.orderComment, .message])
|
||||
deliverySentiment = ApiService.decodeFlexibleString(from: container, keys: [.deliverySentiment, .deliveryFeedback])
|
||||
orderPositiveTags = Self.decodeStringList(from: container, keys: [.orderPositiveTags, .itemFeedback])
|
||||
orderImprovementTags = Self.decodeStringList(from: container, keys: [.orderImprovementTags, .improvementFeedback])
|
||||
deliveryPositiveTags = (try? container.decode([String].self, forKey: .deliveryPositiveTags)) ?? nil
|
||||
deliveryNegativeTags = (try? container.decode([String].self, forKey: .deliveryNegativeTags)) ?? nil
|
||||
appNps = Self.decodeNps(from: container, keys: [.appNps, .app_nps])
|
||||
platform = ApiService.decodeFlexibleString(from: container, keys: [.platform])
|
||||
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
|
||||
date = ApiService.decodeFlexibleString(from: container, keys: [.date])
|
||||
editableUntil = ApiService.decodeFlexibleString(from: container, keys: [.editableUntil])
|
||||
storeReplyUntil = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyUntil])
|
||||
reviewWindowExpiresAt = ApiService.decodeFlexibleString(from: container, keys: [.reviewWindowExpiresAt])
|
||||
storeReplyMessage = Self.decodeReplyMessage(from: container)
|
||||
storeReplyAt = Self.decodeReplyDate(from: container)
|
||||
}
|
||||
|
||||
private static func decodeStringList(
|
||||
from container: KeyedDecodingContainer<CodingKeys>,
|
||||
keys: [CodingKeys]
|
||||
) -> [String]? {
|
||||
for key in keys {
|
||||
if let list = try? container.decode([String].self, forKey: key) {
|
||||
return list
|
||||
}
|
||||
if let single = try? container.decode(String.self, forKey: key) {
|
||||
let normalized = single.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if normalized.isEmpty == false {
|
||||
return [normalized]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodeNps(
|
||||
from container: KeyedDecodingContainer<CodingKeys>,
|
||||
keys: [CodingKeys]
|
||||
) -> Int? {
|
||||
for key in keys {
|
||||
if let value = try? container.decode(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Double.self, forKey: key) {
|
||||
return Int(value.rounded())
|
||||
}
|
||||
if let raw = try? container.decode(String.self, forKey: key) {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { continue }
|
||||
if let asInt = Int(trimmed) {
|
||||
return asInt
|
||||
}
|
||||
let normalized = trimmed.replacingOccurrences(of: ",", with: ".")
|
||||
if let asDouble = Double(normalized) {
|
||||
return Int(asDouble.rounded())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodeReplyMessage(from container: KeyedDecodingContainer<CodingKeys>) -> String? {
|
||||
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyMessage]) {
|
||||
return value
|
||||
}
|
||||
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReply, .store_response, .storeResponse, .reply]) {
|
||||
return value
|
||||
}
|
||||
for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] {
|
||||
if let object = try? container.decode([String: String].self, forKey: key) {
|
||||
let candidates = ["message", "text", "reply", "content", "body"]
|
||||
for candidate in candidates {
|
||||
let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.isEmpty == false { return value }
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodeReplyDate(from container: KeyedDecodingContainer<CodingKeys>) -> String? {
|
||||
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyAt]) {
|
||||
return value
|
||||
}
|
||||
for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] {
|
||||
if let object = try? container.decode([String: String].self, forKey: key) {
|
||||
let candidates = ["date", "createdAt", "updatedAt", "repliedAt", "replyAt"]
|
||||
for candidate in candidates {
|
||||
let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.isEmpty == false { return value }
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct PublicStoreReviewsResult: Decodable {
|
||||
let reviews: [SubmitOrderReviewResult]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case reviews
|
||||
case data
|
||||
case items
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
if let list = try? [SubmitOrderReviewResult](from: decoder) {
|
||||
reviews = list
|
||||
return
|
||||
}
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .reviews) {
|
||||
reviews = list
|
||||
return
|
||||
}
|
||||
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .data) {
|
||||
reviews = list
|
||||
return
|
||||
}
|
||||
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .items) {
|
||||
reviews = list
|
||||
return
|
||||
}
|
||||
reviews = []
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,31 @@ final class ApiService {
|
||||
return envelope
|
||||
}
|
||||
|
||||
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ApiEnvelope<CustomerProfile> {
|
||||
let currentProfile = try await profile(forceRefresh: true)
|
||||
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
let payload = CustomerIdentityUpdatePayload(
|
||||
name: name,
|
||||
email: email,
|
||||
phoneNumber: phoneNumber,
|
||||
profilePicture: profilePicture,
|
||||
addressBook: (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:))
|
||||
)
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
||||
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
|
||||
if envelope.error == false, envelope.result != nil {
|
||||
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
||||
} else {
|
||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
|
||||
return try await saveCustomerAddress(address, replacingAddressId: nil)
|
||||
}
|
||||
@@ -297,7 +322,13 @@ final class ApiService {
|
||||
return cached
|
||||
}
|
||||
|
||||
let req = ApiRequest(path: "/api/app/orders", method: "GET", module: .app, requiresAuth: true)
|
||||
let req = ApiRequest(
|
||||
path: "/api/app/orders",
|
||||
method: "GET",
|
||||
module: .app,
|
||||
requiresAuth: true,
|
||||
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
|
||||
)
|
||||
let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req)
|
||||
if envelope.error == false {
|
||||
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
|
||||
@@ -306,7 +337,46 @@ final class ApiService {
|
||||
}
|
||||
|
||||
func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> {
|
||||
let req = ApiRequest(path: "/api/public/orders/\(orderId)", method: "GET", module: .none, requiresAuth: true)
|
||||
let req = ApiRequest(
|
||||
path: "/api/public/orders/\(orderId)",
|
||||
method: "GET",
|
||||
module: .none,
|
||||
requiresAuth: true,
|
||||
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
|
||||
)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func submitOrderReview(orderId: String, payload: SubmitOrderReviewPayload) async throws -> ApiEnvelope<SubmitOrderReviewResult> {
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(
|
||||
path: "/api/public/orders/\(orderId)/review",
|
||||
method: "POST",
|
||||
module: .none,
|
||||
requiresAuth: true,
|
||||
body: body
|
||||
)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func reviewTagsCatalog() async throws -> ApiEnvelope<ReviewTagsCatalog> {
|
||||
let req = ApiRequest(
|
||||
path: "/api/public/reviews/tags",
|
||||
method: "GET",
|
||||
module: .none,
|
||||
requiresAuth: false
|
||||
)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func publicStoreReviews(storeId: String) async throws -> ApiEnvelope<PublicStoreReviewsResult> {
|
||||
let req = ApiRequest(
|
||||
path: "/api/public/store/\(storeId)/reviews",
|
||||
method: "GET",
|
||||
module: .none,
|
||||
requiresAuth: false,
|
||||
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
|
||||
)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ enum AppCacheTTL {
|
||||
static let homeStores: TimeInterval = 5 * 60
|
||||
}
|
||||
|
||||
enum AppCacheKey {
|
||||
static let homeStoresLatestSnapshot = "home-stores.latest.snapshot"
|
||||
}
|
||||
|
||||
final class AppContentCache: @unchecked Sendable {
|
||||
static let shared = AppContentCache()
|
||||
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -76,13 +76,18 @@ final class OrderRealtimeTracker {
|
||||
@discardableResult
|
||||
private func fetchLatest(orderId: String) async -> PublicOrderResult? {
|
||||
do {
|
||||
logger.debug("OrderTracking poll request orderId=\(orderId, privacy: .public)")
|
||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||
guard response.error == false, let order = response.result else { return nil }
|
||||
SessionStateStore.saveTrackedOrder(order)
|
||||
guard response.error == false, let order = response.result else {
|
||||
logger.error("OrderTracking poll API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
clearPendingCartIfNeeded(for: order)
|
||||
logger.info("OrderTracking poll success orderId=\(orderId, privacy: .public) status=\((order.status ?? "nil"), privacy: .public) paymentStatus=\((order.paymentStatus ?? "nil"), privacy: .public)")
|
||||
onOrderUpdated?(order)
|
||||
return order
|
||||
} catch {
|
||||
logger.error("OrderTracking poll failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ import Foundation
|
||||
extension Notification.Name {
|
||||
static let sessionExpired = Notification.Name("SessionExpiredNotification")
|
||||
static let cartDidReset = Notification.Name("CartDidResetNotification")
|
||||
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
|
||||
}
|
||||
|
||||
@@ -37,6 +37,48 @@ private struct PersistedTrackedOrdersState: Codable {
|
||||
let orders: [PublicOrderResult]
|
||||
}
|
||||
|
||||
struct OrderReviewRecord: Codable, Identifiable, Hashable {
|
||||
var id: String { orderId }
|
||||
let orderId: String
|
||||
let storeId: String?
|
||||
let shortId: String?
|
||||
let storeName: String?
|
||||
let storeLogoURL: String?
|
||||
let createdAt: String?
|
||||
let submittedAt: String
|
||||
let rating: Int
|
||||
let comment: String
|
||||
let orderPositiveTags: [String]?
|
||||
let orderImprovementTags: [String]?
|
||||
let deliverySentiment: String?
|
||||
let deliveryPositiveTags: [String]?
|
||||
let deliveryNegativeTags: [String]?
|
||||
let appNps: Int?
|
||||
let platform: String?
|
||||
let editableUntil: String?
|
||||
let storeReplyUntil: String?
|
||||
let reviewWindowExpiresAt: String?
|
||||
let storeReplyMessage: String?
|
||||
let storeReplyAt: String?
|
||||
}
|
||||
|
||||
struct OrderReviewDraftState: Codable, Hashable {
|
||||
var orderId: String
|
||||
var orderRate: Int
|
||||
var orderComment: String
|
||||
var orderPositiveTags: [String]
|
||||
var orderImprovementTags: [String]
|
||||
var deliverySentiment: String
|
||||
var deliveryPositiveTags: [String]
|
||||
var deliveryNegativeTags: [String]
|
||||
var appNps: Int
|
||||
var platform: String
|
||||
}
|
||||
|
||||
private struct PersistedOrderReviewsState: Codable {
|
||||
let reviews: [OrderReviewRecord]
|
||||
}
|
||||
|
||||
enum SessionStateStore {
|
||||
private static let legacyAddressKey = "session.address.state.v1"
|
||||
private static let addressKeyPrefix = "session.address.state.v2."
|
||||
@@ -44,6 +86,8 @@ enum SessionStateStore {
|
||||
private static let cartKeyPrefix = "session.cart.state.v1."
|
||||
private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1."
|
||||
private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
|
||||
private static let orderReviewsKeyPrefix = "session.orders.reviews.v1."
|
||||
private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1."
|
||||
|
||||
static func makeUserKey(profileId: String?, email: String?) -> String? {
|
||||
let id = (profileId ?? "")
|
||||
@@ -261,6 +305,23 @@ enum SessionStateStore {
|
||||
return pendingCartOrderKeyPrefix + safe
|
||||
}
|
||||
|
||||
private static func orderReviewsStorageKey(for userKey: String?) -> String {
|
||||
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let safe = key.replacingOccurrences(of: " ", with: "_")
|
||||
return orderReviewsKeyPrefix + safe
|
||||
}
|
||||
|
||||
private static func orderReviewDraftStorageKey(for orderId: String, userKey: String?) -> String {
|
||||
let scope = (userKey ?? loadActiveUserKey() ?? "anonymous")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: " ", with: "_")
|
||||
let id = orderId
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
return "\(orderReviewDraftKeyPrefix)\(scope).\(id)"
|
||||
}
|
||||
|
||||
static func loadTrackedOrders() -> [PublicOrderResult] {
|
||||
let defaults = UserDefaults.standard
|
||||
let key = trackedOrdersStorageKey(for: nil)
|
||||
@@ -315,4 +376,66 @@ enum SessionStateStore {
|
||||
static func clearPendingCartOrder() {
|
||||
UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
|
||||
}
|
||||
|
||||
static func loadOrderReviews() -> [OrderReviewRecord] {
|
||||
let key = orderReviewsStorageKey(for: nil)
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
let decoded = try? JSONDecoder().decode(PersistedOrderReviewsState.self, from: data) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return decoded.reviews.sorted { lhs, rhs in
|
||||
lhs.submittedAt > rhs.submittedAt
|
||||
}
|
||||
}
|
||||
|
||||
static func loadOrderReview(orderId: String) -> OrderReviewRecord? {
|
||||
let normalized = orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard normalized.isEmpty == false else { return nil }
|
||||
return loadOrderReviews().first { review in
|
||||
review.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
|
||||
}
|
||||
}
|
||||
|
||||
static func hasOrderReview(orderId: String) -> Bool {
|
||||
loadOrderReview(orderId: orderId) != nil
|
||||
}
|
||||
|
||||
static func saveOrderReview(_ review: OrderReviewRecord) {
|
||||
let cleanId = review.orderId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard cleanId.isEmpty == false else { return }
|
||||
|
||||
var reviews = loadOrderReviews()
|
||||
if let index = reviews.firstIndex(where: {
|
||||
$0.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == cleanId.lowercased()
|
||||
}) {
|
||||
reviews[index] = review
|
||||
} else {
|
||||
reviews.insert(review, at: 0)
|
||||
}
|
||||
|
||||
let payload = PersistedOrderReviewsState(reviews: reviews)
|
||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
||||
UserDefaults.standard.set(data, forKey: orderReviewsStorageKey(for: nil))
|
||||
}
|
||||
|
||||
static func loadOrderReviewDraft(orderId: String) -> OrderReviewDraftState? {
|
||||
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
let draft = try? JSONDecoder().decode(OrderReviewDraftState.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
static func saveOrderReviewDraft(_ draft: OrderReviewDraftState) {
|
||||
let key = orderReviewDraftStorageKey(for: draft.orderId, userKey: nil)
|
||||
guard let data = try? JSONEncoder().encode(draft) else { return }
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
|
||||
static func clearOrderReviewDraft(orderId: String) {
|
||||
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user