updates
This commit is contained in:
@@ -167,6 +167,106 @@ Used when interacting with a specific store (Catalog, Checkout).
|
|||||||
- Se a distância entre `lat_long` enviado e o ponto do CEP for maior que `1.5 km`, a API substitui `lat_long` pelo ponto do CEP cacheado.
|
- Se a distância entre `lat_long` enviado e o ponto do CEP for maior que `1.5 km`, a API substitui `lat_long` pelo ponto do CEP cacheado.
|
||||||
- Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil.
|
- Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil.
|
||||||
|
|
||||||
|
### 5. List Favorite Stores
|
||||||
|
**GET** `/api/customer/favorites`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
- `Authorization: Bearer <JWT_Token>`
|
||||||
|
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- Retorna as lojas favoritas do customer autenticado.
|
||||||
|
- Apenas lojas públicas/visíveis são retornadas.
|
||||||
|
- A ordem segue a ordem salva em `favorites`.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": false,
|
||||||
|
"code": "CUSTOMER_FAVORITES_RETRIEVED",
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": "store_1775510226293_yn5ys",
|
||||||
|
"name": "CPS Drinks",
|
||||||
|
"logo": null,
|
||||||
|
"cover": null,
|
||||||
|
"category": "Doces & Bolos",
|
||||||
|
"rating": 4.8,
|
||||||
|
"totalReviews": 12,
|
||||||
|
"isOpen": true,
|
||||||
|
"statusLabel": "Aberto",
|
||||||
|
"nextOpenLabel": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Add Store to Favorites
|
||||||
|
**POST** `/api/customer/favorites/:storeId`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
- `Authorization: Bearer <JWT_Token>`
|
||||||
|
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- Salva a loja nos favoritos do customer autenticado.
|
||||||
|
- Se a loja já estiver favoritada, a operação continua idempotente.
|
||||||
|
- A loja precisa existir e estar publicamente visível.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": false,
|
||||||
|
"code": "CUSTOMER_FAVORITE_SAVED",
|
||||||
|
"result": {
|
||||||
|
"favorites": ["store_1775510226293_yn5ys"],
|
||||||
|
"store": {
|
||||||
|
"id": "store_1775510226293_yn5ys",
|
||||||
|
"name": "CPS Drinks",
|
||||||
|
"logo": null,
|
||||||
|
"cover": null,
|
||||||
|
"category": "Doces & Bolos",
|
||||||
|
"rating": 4.8,
|
||||||
|
"totalReviews": 12,
|
||||||
|
"isOpen": true,
|
||||||
|
"statusLabel": "Aberto",
|
||||||
|
"nextOpenLabel": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Remove Store from Favorites
|
||||||
|
**DELETE** `/api/customer/favorites/:storeId`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
- `Authorization: Bearer <JWT_Token>`
|
||||||
|
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- Remove a loja dos favoritos do customer autenticado.
|
||||||
|
- A operação é idempotente: se a loja não estiver favoritada, o array volta sem ela.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": false,
|
||||||
|
"code": "CUSTOMER_FAVORITE_REMOVED",
|
||||||
|
"result": {
|
||||||
|
"favorites": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏠 App Home Screen
|
## 🏠 App Home Screen
|
||||||
@@ -538,19 +638,29 @@ Use este canal para acompanhar mudança de status do pedido em tempo real (sem d
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Eventos recebidos pelo app:**
|
**Eventos recebidos pelo app:**
|
||||||
- `order_update`: enviado quando o pedido muda (pagamento aprovado, aceito pela loja, saiu para entrega, concluído, cancelado).
|
- `order_update`: alteração geral de status do pedido.
|
||||||
|
- `order_cancelled`: cancelamento confirmado, com motivo e metadados de estorno.
|
||||||
|
- `delivery_order_cancelled`: evento dedicado para telas de OTP/entrega interromperem o fluxo imediatamente.
|
||||||
|
|
||||||
**Payload típico de `order_update`:**
|
**Payload típico de `order_cancelled`:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "ord_987...",
|
"event": "order_cancelled",
|
||||||
"shortId": "1234",
|
"emittedAt": "2026-03-05T18:00:00.000Z",
|
||||||
"storeId": "store_123...",
|
"orderId": "ffdc47b4-6a55-4713-aea9-45169e4cf0d7",
|
||||||
"userId": "cust_uuid...",
|
"shortId": "3778",
|
||||||
"status": "CONFIRMED",
|
"storeId": "store_1772117366848_wmqw4",
|
||||||
"paymentStatus": "CONFIRMED",
|
"userId": "cust_uuid_001",
|
||||||
"updatedAt": "2026-02-15T20:10:00.000Z"
|
"status": "CANCELED",
|
||||||
|
"cancelledBy": "store",
|
||||||
|
"cancelledAt": "2026-03-05T18:00:00.000Z",
|
||||||
|
"cancelReasonCode": "STORE_UNAVAILABLE",
|
||||||
|
"cancelReasonDetail": "Falta de insumo crítico para finalizar o pedido",
|
||||||
|
"refundStatus": "failed",
|
||||||
|
"refundIdempotencyKey": "refund:ffdc47b4-...",
|
||||||
|
"refundProviderRef": null,
|
||||||
|
"refundError": "Falha ao solicitar estorno automático"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -566,9 +676,45 @@ Se o socket cair, o app deve continuar consultando o status até estado final.
|
|||||||
- depois: a cada `10s`
|
- depois: a cada `10s`
|
||||||
- parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela.
|
- parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela.
|
||||||
|
|
||||||
### 8. Catálogo Oficial de Tags de Review
|
### 8. Cancelamento de Pedido (Loja/Customer)
|
||||||
|
|
||||||
### 8.1 Matriz Completa de Endpoints (Reviews + Tags)
|
**Cancelamento pela loja (Store API):**
|
||||||
|
- `POST /api/store/:storeId/orders/:orderId/cancel`
|
||||||
|
|
||||||
|
**Cancelamento pelo customer:**
|
||||||
|
- `POST /api/customer/orders/:orderId/cancel`
|
||||||
|
|
||||||
|
**Request body (ambos):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reasonCode": "CUSTOMER_REQUEST",
|
||||||
|
"reasonDetail": "Cliente solicitou cancelamento antes da entrega"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Motivos aceitos (`reasonCode`):**
|
||||||
|
- `STORE_UNAVAILABLE`
|
||||||
|
- `CUSTOMER_REQUEST`
|
||||||
|
- `DELIVERY_ISSUE`
|
||||||
|
|
||||||
|
**Regras atuais de cancelamento:**
|
||||||
|
- Permitido em: `PAYMENT_PENDING`, `PENDING`, `ACCEPTED`, `PREPARING`, `READY`, `DELIVERING`.
|
||||||
|
- Bloqueado em: `COMPLETED` e `CANCELED`.
|
||||||
|
- Motoboy não cancela pedido.
|
||||||
|
- Ao cancelar, o backend invalida `otp`, `confirmOtp` e `customerOtp`.
|
||||||
|
|
||||||
|
**Concorrência e idempotência (ponta a ponta):**
|
||||||
|
- Cancelamento é serializado por `orderId` (lock por pedido).
|
||||||
|
- Estorno automático usa lock + chave idempotente de estorno para evitar duplicidade.
|
||||||
|
- Em segunda tentativa após cancelamento efetivado, a API retorna `409 ORDER_ALREADY_CANCELED`.
|
||||||
|
|
||||||
|
**Campos relevantes no retorno:**
|
||||||
|
- `cancelledBy`, `cancelledAt`, `cancelReasonCode`, `cancelReasonDetail`
|
||||||
|
- `refundStatus`, `refundIdempotencyKey`, `refundProviderRef`
|
||||||
|
|
||||||
|
### 9. Catálogo Oficial de Tags de Review
|
||||||
|
|
||||||
|
### 9.1 Matriz Completa de Endpoints (Reviews + Tags)
|
||||||
|
|
||||||
**Público/App (Customer):**
|
**Público/App (Customer):**
|
||||||
- `GET /api/public/reviews/tags` → catálogo oficial de tags e regras de validação.
|
- `GET /api/public/reviews/tags` → catálogo oficial de tags e regras de validação.
|
||||||
@@ -630,7 +776,7 @@ O app deve buscar este endpoint para renderizar as tags válidas e enviar soment
|
|||||||
- Não hardcodear tags no app; usar o catálogo do backend.
|
- Não hardcodear tags no app; usar o catálogo do backend.
|
||||||
- Enviar no POST de review apenas os `id` retornados em cada bloco.
|
- Enviar no POST de review apenas os `id` retornados em cada bloco.
|
||||||
|
|
||||||
### 9. Enviar Avaliação do Pedido
|
### 10. Enviar Avaliação do Pedido
|
||||||
Permite o customer enviar review da loja a partir de um pedido finalizado.
|
Permite o customer enviar review da loja a partir de um pedido finalizado.
|
||||||
|
|
||||||
**Endpoint:**
|
**Endpoint:**
|
||||||
@@ -749,7 +895,7 @@ Exemplo inválido (entrega positiva com tags negativas):
|
|||||||
|
|
||||||
Retorno esperado: `400 INVALID_DELIVERY_SENTIMENT_TAGS`.
|
Retorno esperado: `400 INVALID_DELIVERY_SENTIMENT_TAGS`.
|
||||||
|
|
||||||
### 10. Analytics de Reviews (Store/API)
|
### 11. Analytics de Reviews (Store/API)
|
||||||
Para dashboard da loja e análise de produto, usar:
|
Para dashboard da loja e análise de produto, usar:
|
||||||
|
|
||||||
- `GET /api/store/:storeId/reviews`
|
- `GET /api/store/:storeId/reviews`
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ android {
|
|||||||
// versionName = MARKETING_VERSION
|
// versionName = MARKETING_VERSION
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
buildConfig = true
|
|
||||||
}
|
|
||||||
|
|
||||||
lint {
|
lint {
|
||||||
disable.add("Instantiatable")
|
disable.add("Instantiatable")
|
||||||
disable.add("MissingPermission")
|
disable.add("MissingPermission")
|
||||||
|
|||||||
@@ -300,7 +300,7 @@
|
|||||||
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
|
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = upToNextMajorVersion;
|
kind = upToNextMajorVersion;
|
||||||
minimumVersion = 1.0.5;
|
minimumVersion = 1.0.7;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ let package = Package(
|
|||||||
.library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]),
|
.library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://source.skip.tools/skip.git", from: "1.7.0"),
|
.package(url: "https://source.skip.tools/skip.git", from: "1.8.4"),
|
||||||
.package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0"),
|
.package(path: "Vendor/skip-fuse-ui"),
|
||||||
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5")
|
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FeaturedStoreCard: View {
|
struct FeaturedStoreCard: View {
|
||||||
let store: FeaturedStoreCardModel
|
let store: FeaturedStoreCardModel
|
||||||
|
var onFavoriteToggle: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -21,7 +22,9 @@ struct FeaturedStoreCard: View {
|
|||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {}) {
|
Button {
|
||||||
|
onFavoriteToggle?()
|
||||||
|
} label: {
|
||||||
Image(systemName: store.isFavorite ? "heart.fill" : "heart")
|
Image(systemName: store.isFavorite ? "heart.fill" : "heart")
|
||||||
.foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted)
|
.foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
@@ -29,6 +32,8 @@ struct FeaturedStoreCard: View {
|
|||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
|
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.accessibilityLabel(store.isFavorite ? "Remover loja dos favoritos" : "Adicionar loja aos favoritos")
|
||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,9 @@
|
|||||||
"Adicionar fotos" : {
|
"Adicionar fotos" : {
|
||||||
"comment" : "A label for adding photos in the order review section.",
|
"comment" : "A label for adding photos in the order review section.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Adicionar loja aos favoritos" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Adicionar novo endereço" : {
|
"Adicionar novo endereço" : {
|
||||||
"comment" : "A button label that translates to \"Add new address\" in English.",
|
"comment" : "A button label that translates to \"Add new address\" in English.",
|
||||||
@@ -932,6 +935,9 @@
|
|||||||
"Remover" : {
|
"Remover" : {
|
||||||
"comment" : "A button that deletes the user's profile picture.",
|
"comment" : "A button that deletes the user's profile picture.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Remover loja dos favoritos" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Resposta da loja" : {
|
"Resposta da loja" : {
|
||||||
"comment" : "A heading for the reply from the store in an order review.",
|
"comment" : "A heading for the reply from the store in an order review.",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CustomerFavoritesMutationResult: Decodable {
|
||||||
|
let favorites: [String]
|
||||||
|
let store: StoreSummary?
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ struct CustomerProfile: Decodable {
|
|||||||
let email: String
|
let email: String
|
||||||
let phoneNumber: String?
|
let phoneNumber: String?
|
||||||
let profilePicture: String?
|
let profilePicture: String?
|
||||||
|
let favorites: [String]?
|
||||||
let addressBook: [CustomerAddress]?
|
let addressBook: [CustomerAddress]?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
@@ -29,6 +30,7 @@ struct CustomerProfile: Decodable {
|
|||||||
case email
|
case email
|
||||||
case phoneNumber
|
case phoneNumber
|
||||||
case profilePicture
|
case profilePicture
|
||||||
|
case favorites
|
||||||
case addressBook = "address_book"
|
case addressBook = "address_book"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ final class ApiService {
|
|||||||
private var tokenStore: TokenStore
|
private var tokenStore: TokenStore
|
||||||
private let profileCachePrefix = "api:profile:"
|
private let profileCachePrefix = "api:profile:"
|
||||||
private let ordersCachePrefix = "api:orders:"
|
private let ordersCachePrefix = "api:orders:"
|
||||||
|
private let favoritesCachePrefix = "api:favorites:"
|
||||||
private let publicCategoriesCacheKey = "api:public-categories"
|
private let publicCategoriesCacheKey = "api:public-categories"
|
||||||
|
|
||||||
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
|
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
|
||||||
@@ -68,10 +69,16 @@ final class ApiService {
|
|||||||
tokenStore.clear()
|
tokenStore.clear()
|
||||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||||
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
|
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
|
||||||
|
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
|
||||||
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
|
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
|
||||||
NotificationCenter.default.post(name: .sessionExpired, object: message)
|
NotificationCenter.default.post(name: .sessionExpired, object: message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func invalidateFavoritesCache() {
|
||||||
|
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||||
|
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
|
||||||
|
}
|
||||||
|
|
||||||
private func scopedCacheSuffix() -> String {
|
private func scopedCacheSuffix() -> String {
|
||||||
let jwt = tokenStore.jwt ?? "anonymous"
|
let jwt = tokenStore.jwt ?? "anonymous"
|
||||||
if jwt.count <= 16 { return jwt }
|
if jwt.count <= 16 { return jwt }
|
||||||
@@ -168,7 +175,7 @@ final class ApiService {
|
|||||||
if envelope.error == false, envelope.result != nil {
|
if envelope.error == false, envelope.result != nil {
|
||||||
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
||||||
} else {
|
} else {
|
||||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
invalidateFavoritesCache()
|
||||||
}
|
}
|
||||||
return envelope
|
return envelope
|
||||||
}
|
}
|
||||||
@@ -241,11 +248,51 @@ final class ApiService {
|
|||||||
if envelope.error == false, envelope.result != nil {
|
if envelope.error == false, envelope.result != nil {
|
||||||
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
||||||
} else {
|
} else {
|
||||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
invalidateFavoritesCache()
|
||||||
}
|
}
|
||||||
return envelope
|
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 {
|
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
|
||||||
lhs.label == rhs.label &&
|
lhs.label == rhs.label &&
|
||||||
lhs.address == rhs.address &&
|
lhs.address == rhs.address &&
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,6 +285,7 @@ struct OtpView: View {
|
|||||||
appState.profile.email = customer.email
|
appState.profile.email = customer.email
|
||||||
appState.profile.phone = customer.phoneNumber ?? ""
|
appState.profile.phone = customer.phoneNumber ?? ""
|
||||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||||
|
appState.favorites.storeIds = Set(customer.favorites ?? [])
|
||||||
SessionStateStore.setActiveUserKey(
|
SessionStateStore.setActiveUserKey(
|
||||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||||
)
|
)
|
||||||
@@ -315,6 +316,7 @@ struct OtpView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
appState.profile.email = email
|
appState.profile.email = email
|
||||||
|
appState.favorites = FavoritesState()
|
||||||
SessionStateStore.setActiveUserKey(
|
SessionStateStore.setActiveUserKey(
|
||||||
SessionStateStore.makeUserKey(profileId: nil, email: email)
|
SessionStateStore.makeUserKey(profileId: nil, email: email)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,76 +1,35 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
enum HomeScrollCoordinateSpace {
|
||||||
#endif
|
static let name = "home-scroll"
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@MainActor
|
struct HomeScrollOffsetPreferenceKey: PreferenceKey {
|
||||||
struct ScrollOffsetObserver: UIViewRepresentable {
|
static let defaultValue: CGFloat = 0
|
||||||
let onOffsetChange: (CGFloat) -> Void
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> ScrollOffsetProbeView {
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
let view = ScrollOffsetProbeView()
|
value = nextValue()
|
||||||
view.onOffsetChange = onOffsetChange
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) {
|
|
||||||
uiView.onOffsetChange = onOffsetChange
|
|
||||||
uiView.attachIfNeeded()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
struct ScrollOffsetObserver: View {
|
||||||
final class ScrollOffsetProbeView: UIView {
|
let onOffsetChange: (CGFloat) -> Void
|
||||||
var onOffsetChange: (CGFloat) -> Void = { _ in }
|
|
||||||
|
|
||||||
private weak var observedScrollView: UIScrollView?
|
var body: some View {
|
||||||
private var observation: NSKeyValueObservation?
|
Color.clear
|
||||||
|
.frame(height: 0)
|
||||||
override init(frame: CGRect) {
|
.background(
|
||||||
super.init(frame: frame)
|
GeometryReader { geometry in
|
||||||
backgroundColor = .clear
|
Color.clear.preference(
|
||||||
isUserInteractionEnabled = false
|
key: HomeScrollOffsetPreferenceKey.self,
|
||||||
}
|
value: geometry.frame(in: .named(HomeScrollCoordinateSpace.name)).minY
|
||||||
|
)
|
||||||
required init?(coder: NSCoder) {
|
}
|
||||||
super.init(coder: coder)
|
)
|
||||||
}
|
.onPreferenceChange(HomeScrollOffsetPreferenceKey.self) { minY in
|
||||||
|
onOffsetChange(-minY)
|
||||||
deinit {
|
|
||||||
observation?.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func didMoveToSuperview() {
|
|
||||||
super.didMoveToSuperview()
|
|
||||||
attachIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func didMoveToWindow() {
|
|
||||||
super.didMoveToWindow()
|
|
||||||
attachIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
func attachIfNeeded() {
|
|
||||||
guard let scrollView = findEnclosingScrollView() else { return }
|
|
||||||
guard scrollView !== observedScrollView else { return }
|
|
||||||
|
|
||||||
observation?.invalidate()
|
|
||||||
observedScrollView = scrollView
|
|
||||||
observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in
|
|
||||||
self?.onOffsetChange(sv.contentOffset.y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findEnclosingScrollView() -> UIScrollView? {
|
|
||||||
var view: UIView? = self
|
|
||||||
while let current = view {
|
|
||||||
if let scrollView = current as? UIScrollView {
|
|
||||||
return scrollView
|
|
||||||
}
|
}
|
||||||
view = current.superview
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension HomeView {
|
||||||
|
@MainActor
|
||||||
|
func toggleFavoriteStore(storeId: String, storeName: String) async {
|
||||||
|
guard favoriteRequestStoreIds.contains(storeId) == false else { return }
|
||||||
|
guard appState.session.isAuthenticated else {
|
||||||
|
SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isFavorite = appState.favorites.storeIds.contains(storeId)
|
||||||
|
favoriteRequestStoreIds.insert(storeId)
|
||||||
|
defer { favoriteRequestStoreIds.remove(storeId) }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false)
|
||||||
|
guard response.error == false, let result = response.result else {
|
||||||
|
let message = response.message ?? "Não foi possível atualizar seus favoritos."
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appState.favorites.storeIds = Set(result.favorites)
|
||||||
|
let successTitle = isFavorite
|
||||||
|
? "\(storeName) removida dos favoritos."
|
||||||
|
: "\(storeName) adicionada aos favoritos."
|
||||||
|
let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill"
|
||||||
|
SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2)
|
||||||
|
} catch {
|
||||||
|
let message: String
|
||||||
|
if let networkError = error as? NetworkError {
|
||||||
|
message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||||
|
} else if let serviceError = error as? ApiServiceError {
|
||||||
|
message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||||
|
} else {
|
||||||
|
message = "Não foi possível atualizar seus favoritos."
|
||||||
|
}
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ struct HomeView: View {
|
|||||||
@State var stores: [StoreSummary] = []
|
@State var stores: [StoreSummary] = []
|
||||||
@State var productSearchIndexByStoreId: [String: [String]] = [:]
|
@State var productSearchIndexByStoreId: [String: [String]] = [:]
|
||||||
@State var searchDebounceToken = 0
|
@State var searchDebounceToken = 0
|
||||||
|
@State var favoriteRequestStoreIds: Set<String> = []
|
||||||
|
|
||||||
private let specials: [SpecialOfferCardModel] = [
|
private let specials: [SpecialOfferCardModel] = [
|
||||||
// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
|
// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
|
||||||
@@ -47,7 +48,7 @@ struct HomeView: View {
|
|||||||
refreshCategories: true
|
refreshCategories: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.background(scrollOffsetObserver)
|
.coordinateSpace(name: HomeScrollCoordinateSpace.name)
|
||||||
|
|
||||||
header(collapseProgress: collapseProgress, height: headerHeight)
|
header(collapseProgress: collapseProgress, height: headerHeight)
|
||||||
.frame(maxWidth: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, alignment: .top)
|
||||||
@@ -84,6 +85,7 @@ struct HomeView: View {
|
|||||||
|
|
||||||
private var contentStack: some View {
|
private var contentStack: some View {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
|
scrollOffsetObserver
|
||||||
categoriesSection
|
categoriesSection
|
||||||
|
|
||||||
section(title: "Featured") {
|
section(title: "Featured") {
|
||||||
@@ -102,7 +104,14 @@ struct HomeView: View {
|
|||||||
appState: $appState
|
appState: $appState
|
||||||
)
|
)
|
||||||
} label: {
|
} label: {
|
||||||
FeaturedStoreCard(store: store)
|
FeaturedStoreCard(
|
||||||
|
store: store,
|
||||||
|
onFavoriteToggle: {
|
||||||
|
Task {
|
||||||
|
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
.frame(width: 190)
|
.frame(width: 190)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -171,7 +180,14 @@ struct HomeView: View {
|
|||||||
appState: $appState
|
appState: $appState
|
||||||
)
|
)
|
||||||
} label: {
|
} label: {
|
||||||
FeaturedStoreCard(store: store)
|
FeaturedStoreCard(
|
||||||
|
store: store,
|
||||||
|
onFavoriteToggle: {
|
||||||
|
Task {
|
||||||
|
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
|
struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
|
||||||
static let defaultValue: [String: CGFloat] = [:]
|
static let defaultValue: [String: CGFloat] = [:]
|
||||||
@@ -11,125 +8,41 @@ struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum StoreDetailScrollCoordinateSpace {
|
||||||
|
static let name = "store-detail-scroll"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||||
|
static let defaultValue: CGFloat = 0
|
||||||
|
|
||||||
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
|
value = nextValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ScrollOffsetReader: View {
|
struct ScrollOffsetReader: View {
|
||||||
@Binding var offsetY: CGFloat
|
@Binding var offsetY: CGFloat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if canImport(UIKit)
|
|
||||||
ScrollOffsetReaderRepresentable(offsetY: $offsetY)
|
|
||||||
#else
|
|
||||||
Color.clear
|
Color.clear
|
||||||
#endif
|
.frame(height: 0)
|
||||||
|
.background(
|
||||||
|
GeometryReader { geometry in
|
||||||
|
Color.clear.preference(
|
||||||
|
key: ScrollOffsetPreferenceKey.self,
|
||||||
|
value: geometry.frame(in: .named(StoreDetailScrollCoordinateSpace.name)).minY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in
|
||||||
|
let normalizedOffset = max(0, -minY)
|
||||||
|
if abs(offsetY - normalizedOffset) > 0.5 {
|
||||||
|
offsetY = normalizedOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
struct ScrollOffsetReaderRepresentable: UIViewRepresentable {
|
|
||||||
@Binding var offsetY: CGFloat
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> OffsetProbeView {
|
|
||||||
let view = OffsetProbeView()
|
|
||||||
view.onOffsetChanged = { value in
|
|
||||||
if offsetY != value {
|
|
||||||
offsetY = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: OffsetProbeView, context: Context) {
|
|
||||||
uiView.onOffsetChanged = { value in
|
|
||||||
if offsetY != value {
|
|
||||||
offsetY = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class OffsetProbeView: UIView {
|
|
||||||
var onOffsetChanged: ((CGFloat) -> Void)?
|
|
||||||
private var observation: NSKeyValueObservation?
|
|
||||||
private weak var observedScrollView: UIScrollView?
|
|
||||||
|
|
||||||
override func didMoveToWindow() {
|
|
||||||
super.didMoveToWindow()
|
|
||||||
attachIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func didMoveToSuperview() {
|
|
||||||
super.didMoveToSuperview()
|
|
||||||
attachIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func attachIfNeeded() {
|
|
||||||
guard observation == nil else { return }
|
|
||||||
guard observedScrollView == nil else { return }
|
|
||||||
|
|
||||||
if let scrollView = enclosingScrollView() ?? findScrollViewInWindow() {
|
|
||||||
observe(scrollView)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
retryAttach()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func observe(_ scrollView: UIScrollView) {
|
|
||||||
observedScrollView = scrollView
|
|
||||||
observation = scrollView.observe(\.contentOffset, options: [.new, .initial]) { [weak self, weak scrollView] _, change in
|
|
||||||
guard let self, let scrollView, let y = change.newValue?.y else { return }
|
|
||||||
let adjusted = max(0, y + scrollView.adjustedContentInset.top)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.onOffsetChanged?(adjusted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func retryAttach() {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
|
||||||
self?.attachIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func enclosingScrollView() -> UIScrollView? {
|
|
||||||
var current: UIView? = self
|
|
||||||
while let view = current {
|
|
||||||
if let scrollView = view as? UIScrollView {
|
|
||||||
return scrollView
|
|
||||||
}
|
|
||||||
current = view.superview
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findScrollViewInWindow() -> UIScrollView? {
|
|
||||||
guard let window else { return nil }
|
|
||||||
let targetPoint = convert(CGPoint(x: bounds.midX, y: bounds.midY), to: window)
|
|
||||||
return findScrollView(in: window, containing: targetPoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findScrollView(in root: UIView, containing point: CGPoint) -> UIScrollView? {
|
|
||||||
for subview in root.subviews.reversed() {
|
|
||||||
if let match = findScrollView(in: subview, containing: point) {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let scrollView = root as? UIScrollView {
|
|
||||||
let rectInWindow = scrollView.convert(scrollView.bounds, to: window)
|
|
||||||
if rectInWindow.contains(point) {
|
|
||||||
return scrollView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
observation?.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct AsyncStoreImage: View {
|
struct AsyncStoreImage: View {
|
||||||
let imageURL: String?
|
let imageURL: String?
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension StoreDetailView {
|
extension StoreDetailView {
|
||||||
func heroIconButton(icon: String, action: @escaping () -> Void) -> some View {
|
func heroIconButton(
|
||||||
|
icon: String,
|
||||||
|
foregroundStyle: Color = .white,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.foregroundStyle(Color.white)
|
.foregroundStyle(foregroundStyle)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(Color.white.opacity(0.24))
|
.background(Color.white.opacity(0.24))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
@@ -65,6 +69,49 @@ extension StoreDetailView {
|
|||||||
info?.isOpen ?? true
|
info?.isOpen ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isFavoriteStore: Bool {
|
||||||
|
appState.favorites.storeIds.contains(storeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func toggleFavoriteStore() async {
|
||||||
|
guard isFavoriteRequestInFlight == false else { return }
|
||||||
|
guard appState.session.isAuthenticated else {
|
||||||
|
SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isFavorite = isFavoriteStore
|
||||||
|
isFavoriteRequestInFlight = true
|
||||||
|
defer { isFavoriteRequestInFlight = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false)
|
||||||
|
guard response.error == false, let result = response.result else {
|
||||||
|
let message = response.message ?? "Não foi possível atualizar seus favoritos."
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appState.favorites.storeIds = Set(result.favorites)
|
||||||
|
let successTitle = isFavorite
|
||||||
|
? "\(storeName) removida dos favoritos."
|
||||||
|
: "\(storeName) adicionada aos favoritos."
|
||||||
|
let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill"
|
||||||
|
SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2)
|
||||||
|
} catch {
|
||||||
|
let message: String
|
||||||
|
if let networkError = error as? NetworkError {
|
||||||
|
message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||||
|
} else if let serviceError = error as? ApiServiceError {
|
||||||
|
message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||||
|
} else {
|
||||||
|
message = "Não foi possível atualizar seus favoritos."
|
||||||
|
}
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var summaryCardHeight: CGFloat {
|
var summaryCardHeight: CGFloat {
|
||||||
summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight)
|
summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight)
|
||||||
}
|
}
|
||||||
@@ -97,9 +144,13 @@ extension StoreDetailView {
|
|||||||
if forceRefresh == false,
|
if forceRefresh == false,
|
||||||
let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self),
|
let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self),
|
||||||
let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) {
|
let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) {
|
||||||
|
let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: cachedCatalog, storeId: storeId)
|
||||||
info = cachedInfo
|
info = cachedInfo
|
||||||
categories = cachedCatalog
|
categories = normalizedCatalog
|
||||||
selectedCategoryId = cachedCatalog.first?.id
|
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||||
|
from: normalizedCatalog,
|
||||||
|
preferredId: selectedCategoryId
|
||||||
|
)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -110,9 +161,9 @@ extension StoreDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
async let infoRequest = ApiService().storeInfo(storeId: storeId)
|
let apiService = ApiService()
|
||||||
async let catalogRequest = ApiService().storeCatalog(storeId: storeId)
|
let infoResponse = try await apiService.storeInfo(storeId: storeId)
|
||||||
let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest)
|
let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
|
||||||
|
|
||||||
if infoResponse.error {
|
if infoResponse.error {
|
||||||
errorMessage = infoResponse.message ?? "Não foi possível carregar a loja."
|
errorMessage = infoResponse.message ?? "Não foi possível carregar a loja."
|
||||||
@@ -125,13 +176,21 @@ extension StoreDetailView {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let normalizedCatalog = StoreCatalogNormalizer.sanitize(
|
||||||
|
categories: catalogResponse.result ?? [],
|
||||||
|
storeId: storeId
|
||||||
|
)
|
||||||
|
|
||||||
info = infoResponse.result
|
info = infoResponse.result
|
||||||
categories = catalogResponse.result ?? []
|
categories = normalizedCatalog
|
||||||
selectedCategoryId = categories.first?.id
|
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||||
|
from: normalizedCatalog,
|
||||||
|
preferredId: selectedCategoryId
|
||||||
|
)
|
||||||
if let info = infoResponse.result {
|
if let info = infoResponse.result {
|
||||||
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
|
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
|
||||||
}
|
}
|
||||||
AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
} catch {
|
} catch {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct StoreDetailView: View {
|
|||||||
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
||||||
@State var isProgrammaticCategoryScroll = false
|
@State var isProgrammaticCategoryScroll = false
|
||||||
@State var scrollOffsetY: CGFloat = 0
|
@State var scrollOffsetY: CGFloat = 0
|
||||||
|
@State var isFavoriteRequestInFlight = false
|
||||||
|
|
||||||
let cardTopInset: CGFloat = 168
|
let cardTopInset: CGFloat = 168
|
||||||
let summaryCardBaseHeight: CGFloat = 170
|
let summaryCardBaseHeight: CGFloat = 170
|
||||||
@@ -52,6 +53,7 @@ struct StoreDetailView: View {
|
|||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
#if os(Android)
|
#if os(Android)
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
|
ScrollOffsetReader(offsetY: $scrollOffsetY)
|
||||||
topSection
|
topSection
|
||||||
|
|
||||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
||||||
@@ -60,6 +62,7 @@ struct StoreDetailView: View {
|
|||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||||
|
ScrollOffsetReader(offsetY: $scrollOffsetY)
|
||||||
topSection
|
topSection
|
||||||
|
|
||||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
||||||
@@ -71,8 +74,8 @@ struct StoreDetailView: View {
|
|||||||
.refreshable {
|
.refreshable {
|
||||||
await loadStoreData(forceRefresh: true)
|
await loadStoreData(forceRefresh: true)
|
||||||
}
|
}
|
||||||
|
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
|
||||||
.ignoresSafeArea(edges: .top)
|
.ignoresSafeArea(edges: .top)
|
||||||
.background(ScrollOffsetReader(offsetY: $scrollOffsetY))
|
|
||||||
}
|
}
|
||||||
.ignoresSafeArea(edges: .top)
|
.ignoresSafeArea(edges: .top)
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
@@ -218,7 +221,14 @@ struct StoreDetailView: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
heroIconButton(icon: "magnifyingglass") {}
|
heroIconButton(icon: "magnifyingglass") {}
|
||||||
heroIconButton(icon: "heart") {}
|
heroIconButton(
|
||||||
|
icon: isFavoriteStore ? "heart.fill" : "heart",
|
||||||
|
foregroundStyle: isFavoriteStore ? Color.red : Color.white
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
await toggleFavoriteStore()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, UIDevice.topNotch)
|
.padding(.top, UIDevice.topNotch)
|
||||||
|
|||||||
49
pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift
Normal file
49
pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import PediFoods
|
||||||
|
|
||||||
|
@Test("Customer profile decodes persisted favorite store ids")
|
||||||
|
func customerProfileDecodesFavoriteStoreIds() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"error": false,
|
||||||
|
"result": {
|
||||||
|
"id": "cust_1",
|
||||||
|
"name": "Daniel",
|
||||||
|
"email": "daniel@example.com",
|
||||||
|
"phoneNumber": "+5511999999999",
|
||||||
|
"favorites": ["store_a", "store_b"],
|
||||||
|
"address_book": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
let envelope = try JSONDecoder().decode(ApiEnvelope<CustomerProfile>.self, from: Data(json.utf8))
|
||||||
|
#expect(envelope.result?.favorites == ["store_a", "store_b"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Favorite mutation decodes updated favorites array")
|
||||||
|
func favoriteMutationDecodesUpdatedFavoritesArray() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"error": false,
|
||||||
|
"result": {
|
||||||
|
"favorites": ["store_a"],
|
||||||
|
"store": {
|
||||||
|
"id": "store_a",
|
||||||
|
"name": "CPS Drinks",
|
||||||
|
"category": "Doces & Bolos",
|
||||||
|
"rating": 4.8,
|
||||||
|
"totalReviews": 12,
|
||||||
|
"isOpen": true,
|
||||||
|
"statusLabel": "Aberto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
let envelope = try JSONDecoder().decode(ApiEnvelope<CustomerFavoritesMutationResult>.self, from: Data(json.utf8))
|
||||||
|
#expect(envelope.result?.favorites == ["store_a"])
|
||||||
|
#expect(envelope.result?.store?.id == "store_a")
|
||||||
|
#expect(envelope.result?.store?.name == "CPS Drinks")
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import PediFoods
|
||||||
|
|
||||||
|
@Test("Store catalog normalizer makes IDs non-empty and unique")
|
||||||
|
func storeCatalogNormalizerMakesIdsUnique() {
|
||||||
|
let catalog = [
|
||||||
|
StoreCatalogCategory(
|
||||||
|
id: "",
|
||||||
|
name: "Pizzas",
|
||||||
|
isPizzaCategory: true,
|
||||||
|
pizzaConfig: StorePizzaConfig(
|
||||||
|
sizes: [
|
||||||
|
StorePizzaSize(id: "", name: "Grande", slices: 8, maxFlavors: 2),
|
||||||
|
StorePizzaSize(id: "", name: "Familia", slices: 12, maxFlavors: 3)
|
||||||
|
],
|
||||||
|
doughs: [
|
||||||
|
StorePizzaDough(id: "massa", name: "Tradicional", active: true),
|
||||||
|
StorePizzaDough(id: "massa", name: "Fina", active: true)
|
||||||
|
],
|
||||||
|
crusts: [
|
||||||
|
StorePizzaCrust(id: "", name: "Cheddar", active: true, priceModifier: 5),
|
||||||
|
StorePizzaCrust(id: "", name: "Catupiry", active: true, priceModifier: 6)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
products: [
|
||||||
|
StoreCatalogProduct(
|
||||||
|
id: "",
|
||||||
|
type: "pizza",
|
||||||
|
name: "Calabresa",
|
||||||
|
description: nil,
|
||||||
|
image: nil,
|
||||||
|
price: 10,
|
||||||
|
originalPrice: nil,
|
||||||
|
pizzaPrices: [:],
|
||||||
|
addonGroups: [
|
||||||
|
StoreAddonGroup(
|
||||||
|
id: "",
|
||||||
|
name: "Extras",
|
||||||
|
minSelectors: nil,
|
||||||
|
maxSelectors: nil,
|
||||||
|
items: [
|
||||||
|
StoreAddonItem(id: "", name: "Bacon", price: 2),
|
||||||
|
StoreAddonItem(id: "", name: "Bacon em dobro", price: 4)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
StoreAddonGroup(
|
||||||
|
id: "",
|
||||||
|
name: "Molhos",
|
||||||
|
minSelectors: nil,
|
||||||
|
maxSelectors: nil,
|
||||||
|
items: [
|
||||||
|
StoreAddonItem(id: "", name: "Alho", price: 1)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
StoreCatalogProduct(
|
||||||
|
id: "",
|
||||||
|
type: "pizza",
|
||||||
|
name: "Mussarela",
|
||||||
|
description: nil,
|
||||||
|
image: nil,
|
||||||
|
price: 12,
|
||||||
|
originalPrice: nil,
|
||||||
|
pizzaPrices: [:],
|
||||||
|
addonGroups: []
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
StoreCatalogCategory(
|
||||||
|
id: "",
|
||||||
|
name: "Bebidas",
|
||||||
|
isPizzaCategory: false,
|
||||||
|
pizzaConfig: nil,
|
||||||
|
products: [
|
||||||
|
StoreCatalogProduct(
|
||||||
|
id: "",
|
||||||
|
type: nil,
|
||||||
|
name: "Refrigerante",
|
||||||
|
description: nil,
|
||||||
|
image: nil,
|
||||||
|
price: 7,
|
||||||
|
originalPrice: nil,
|
||||||
|
pizzaPrices: [:],
|
||||||
|
addonGroups: []
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
let normalized = StoreCatalogNormalizer.sanitize(categories: catalog, storeId: "store-1")
|
||||||
|
|
||||||
|
let categoryIds = normalized.map(\.id)
|
||||||
|
#expect(Set(categoryIds).count == categoryIds.count)
|
||||||
|
#expect(categoryIds.allSatisfy { $0.isEmpty == false })
|
||||||
|
|
||||||
|
let firstCategory = normalized[0]
|
||||||
|
let productIds = firstCategory.products.map(\.id)
|
||||||
|
#expect(Set(productIds).count == productIds.count)
|
||||||
|
#expect(productIds.allSatisfy { $0.isEmpty == false })
|
||||||
|
|
||||||
|
let addonGroupIds = firstCategory.products[0].addonGroups.map(\.id)
|
||||||
|
#expect(Set(addonGroupIds).count == addonGroupIds.count)
|
||||||
|
#expect(addonGroupIds.allSatisfy { $0.isEmpty == false })
|
||||||
|
|
||||||
|
let addonItemIds = firstCategory.products[0].addonGroups.flatMap(\.items).map(\.id)
|
||||||
|
#expect(Set(addonItemIds).count == addonItemIds.count)
|
||||||
|
#expect(addonItemIds.allSatisfy { $0.isEmpty == false })
|
||||||
|
|
||||||
|
let sizeIds = firstCategory.pizzaConfig?.sizes.map(\.id) ?? []
|
||||||
|
let doughIds = firstCategory.pizzaConfig?.doughs.map(\.id) ?? []
|
||||||
|
let crustIds = firstCategory.pizzaConfig?.crusts.map(\.id) ?? []
|
||||||
|
|
||||||
|
#expect(Set(sizeIds).count == sizeIds.count)
|
||||||
|
#expect(Set(doughIds).count == doughIds.count)
|
||||||
|
#expect(Set(crustIds).count == crustIds.count)
|
||||||
|
}
|
||||||
BIN
sim-home.png
Normal file
BIN
sim-home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Reference in New Issue
Block a user