@doswiftly/storefront-operations 12.0.0 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -27,8 +27,8 @@ consumer's `codegen.ts` references this package's `.graphql` files as
27
27
  live in the consumer's repo.
28
28
 
29
29
  <!-- AUTOGEN:STATS:BEGIN — auto-regenerated, do not edit by hand -->
30
- - **Schema version**: 12.0.0
31
- - **Queries**: 49
30
+ - **Schema version**: 13.1.0
31
+ - **Queries**: 50
32
32
  - **Mutations**: 40
33
33
  - **Fragments**: 100
34
34
  <!-- AUTOGEN:STATS:END -->
package/CHANGELOG.md CHANGED
@@ -1,5 +1,244 @@
1
1
  # Changelog
2
2
 
3
+ ## 13.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2e137ad: Added cart-aware shipping methods discovery: `Cart.requiresShipping`, `CartLine.requiresShipping`, and `cart.availableShippingMethods(address)`.
8
+
9
+ **What's new for the storefront**
10
+ - **`cart.requiresShipping: Boolean!`** — single signal whether the cart needs physical shipping at all. `false` for carts that contain only digital goods (downloads, gift cards, services, subscriptions). Use it to skip the shipping picker step in checkout entirely.
11
+ - **`cart.lines.nodes[].requiresShipping: Boolean!`** — per-line classification. Useful in cart drawer UI to flag physical vs digital lines independently.
12
+ - **`cart.availableShippingMethods(address: ShippingAddressInput!): AvailableShippingMethodsPayload!`** — field on `Cart` that returns shipping methods for the cart's contents at the given destination. The backend pulls subtotal and weight from the cart aggregate — you no longer need to compute them on the client. For digital-only carts the response is `{ methods: [], userErrors: [{ code: 'DIGITAL_ONLY_NO_SHIPPING' }] }`.
13
+ - **`CartAvailableShippingMethods` query** — new named operation ready to drop into your storefront codegen pipeline.
14
+
15
+ **Recommended checkout flow**
16
+
17
+ ```graphql
18
+ query CartAvailableShippingMethods(
19
+ $cartId: ID!
20
+ $address: ShippingAddressInput!
21
+ ) {
22
+ cart(id: $cartId) {
23
+ id
24
+ requiresShipping
25
+ availableShippingMethods(address: $address) {
26
+ methods {
27
+ id
28
+ name
29
+ price {
30
+ amount
31
+ currencyCode
32
+ }
33
+ }
34
+ userErrors {
35
+ code
36
+ message
37
+ field
38
+ }
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ If `cart.requiresShipping === false`, skip rendering the shipping picker and proceed straight to payment. Calling `cartSelectShippingMethod` on a digital-only cart returns `userErrors[{ code: 'FORBIDDEN_SHIPPING_METHOD' }]`, matching the readiness check that `cartComplete` already enforces.
45
+
46
+ **Backward compatibility**
47
+ - The existing standalone `availableShippingMethods(address, cart: CartShippingInput)` query is **unchanged**. Keep using it for pre-cart shipping calculators on product detail pages (when the customer has not created a cart yet).
48
+ - The new field and new query are purely additive. No existing operations change shape.
49
+
50
+ **Free-shipping progress + delivery estimates are now localized**
51
+
52
+ The `freeShippingProgress.message` field and `estimatedDelivery.description` field used to be hardcoded in English. They now respect the `Accept-Language` header (defaults to Polish — `pl`). If your storefront previously parsed these strings, switch to the structured fields (`qualifies`, `progressPercent`, `remaining.amount`, `minDays`, `maxDays`) instead.
53
+
54
+ **Reason codes on shipping `userErrors`**
55
+
56
+ | Code | When | UI reaction |
57
+ | -------------------------- | -------------------------------- | -------------------------------------------- |
58
+ | `DIGITAL_ONLY_NO_SHIPPING` | Cart contains only digital items | Skip shipping picker, go straight to payment |
59
+ | `NO_SHIPPING_METHODS` | Address has no matching zone | Show "no delivery to your country" |
60
+ | `SHIPPING_ERROR` | Internal service error (rare) | Retry or fall back to pre-cart preview query |
61
+
62
+ **Why both packages bump together**
63
+
64
+ `@doswiftly/storefront-sdk` ships local copies of the cart fragments that this update extends with `requiresShipping`, so the SDK release pairs with the schema/operations release. `@doswiftly/commerce-policy` exports `NON_PHYSICAL_TYPES` for downstream tooling that wants to mirror the same classification on the client side.
65
+
66
+ ## 13.0.0
67
+
68
+ ### Major Changes
69
+
70
+ - 783ce01: Faceted aggregation accuracy + DX naming consolidation w `productFilters` API. Plus boolean facet count dla "availableForSale" + payload cleanup w Cart query.
71
+
72
+ ## BREAKING — `AvailableFilters.matchCount` removed → `totalCount`
73
+
74
+ Pole `matchCount` zostało usunięte. Użyj `totalCount` — semantyka identyczna (produkty w current context PRZED zaaplikowaniem faceted filters), nazwa zaligned z `ProductConnection.totalCount` (Relay Connection spec).
75
+
76
+ **Migration** (1:1 rename):
77
+
78
+ ```graphql
79
+ # PRZED
80
+ query Listing {
81
+ productFilters {
82
+ matchCount # ❌ removed
83
+ activeCount
84
+ }
85
+ }
86
+
87
+ # PO
88
+ query Listing {
89
+ productFilters {
90
+ totalCount # ✅ same semantics, Relay-aligned
91
+ activeCount
92
+ }
93
+ }
94
+ ```
95
+
96
+ ## BREAKING — `ProductFilter.available: true` semantics
97
+
98
+ Filter teraz includuje untracked produkty (gift cards, digital, made-to-order — wszystko z `trackQuantity = false`). Semantyka zgodna z `Product.isAvailable` resolver — single source of truth dla "co jest sellable".
99
+
100
+ **Pre-fix**: `available: true` zwracał tylko produkty z fizycznym stockiem (`available > 0`), wykluczał untracked. Storefront widział "Dostępne (3)" w sidebar (computed via `isAvailable`), po kliku dostawał 2 produkty (SQL filter różny).
101
+
102
+ **Post-fix**: oba źródła zwracają to samo.
103
+
104
+ Jeśli polegałeś na exclusion untracked przez `available: true` (np. żeby ukryć gift cards) — użyj `ProductFilter.type` lub osobnej logiki klienckiej.
105
+
106
+ ## NEW — `AvailableFilters.availableCount: Int!`
107
+
108
+ Nowy boolean facet count — liczba produktów spełniających `Product.isAvailable` w current context. Storefront UI renderuje "Dostępne (N)" w sidebar facet panel.
109
+
110
+ Exclude-self: gdy `AvailableFiltersInput.available` jest provided w `currentFilters`, `availableCount` IGNORUJE ten filter (pokazuje "ile produktów ten facet odsłoni gdy włączysz") — inne facets aplikują go normalnie.
111
+
112
+ ## NEW — `AvailableFiltersInput.available: Boolean`
113
+
114
+ Storefront może passować obecny `available` filter context do `productFilters` query — wymagane dla exclude-self semantyki. Pomiń (null/undefined) gdy filtr "Dostępne" nie jest aktywny w UI.
115
+
116
+ ## IMPROVEMENT — Faceted count accuracy (`productCount` mismatch eliminated)
117
+
118
+ Per-attribute / per-brand / per-category `productCount` teraz używa exclude-self aggregation pattern. Facet count MATCHUJE `products(filters).totalCount` gdy storefront aplikuje facet jako filter.
119
+
120
+ **Pre-fix**: `productFilters({categoryId, available: true}).attributes[producent].filterValues[Funko].productCount = 4` (ignoring `available`), `products({categoryId, available: true, attributes: [Funko]}).totalCount = 3` (3 sellable + 1 OOS Funko). Storefront pokazywał "Funko (4)", user dostawał 3 — utracone zaufanie do countów.
121
+
122
+ **Post-fix**: oba zwracają 3. Facet ignoruje WYŁĄCZNIE swój wymiar w `currentFilters`, aplikuje pozostałe + `available` + context (categoryId/collectionId/searchQuery).
123
+
124
+ ## IMPROVEMENT — Cart query payload size
125
+
126
+ `Cart.lines` query w SDK używał obu form (`edges + nodes`) z których konsumowane były wyłącznie `nodes`. Backend serializował `CartLine` fragment 2× per request. Usunięcie duplikatu — redukcja payload typowo 5-20KB per cart request (proportional do liczby pozycji).
127
+
128
+ Brak API change widzialnej dla consumer — SDK używa `cart.lines.nodes` jak wcześniej. Internal SDK improvement.
129
+
130
+ ## Example: end-to-end facet sidebar query (post-rename)
131
+
132
+ ```graphql
133
+ query ProductListing($input: AvailableFiltersInput) {
134
+ productFilters(input: $input) {
135
+ totalCount # ile produktów w current context (przed faceted filters)
136
+ availableCount # ile sellable (boolean facet, exclude-self)
137
+ activeCount # input.currentFilters.length
138
+ attributes {
139
+ handle
140
+ filterValues {
141
+ value
142
+ productCount
143
+ } # per-value facet count (exclude-self per attrDef)
144
+ }
145
+ brands {
146
+ slug
147
+ name
148
+ productCount
149
+ } # per-brand facet count
150
+ categories {
151
+ slug
152
+ name
153
+ productCount
154
+ } # per-category facet count
155
+ priceRange {
156
+ min {
157
+ amount
158
+ currencyCode
159
+ }
160
+ max {
161
+ amount
162
+ currencyCode
163
+ }
164
+ }
165
+ }
166
+
167
+ products(
168
+ filters: [
169
+ { available: true }
170
+ { attributes: [{ attributeId: "producent", values: ["Funko"] }] }
171
+ ]
172
+ ) {
173
+ totalCount
174
+ nodes {
175
+ id
176
+ title
177
+ isAvailable
178
+ }
179
+ }
180
+ }
181
+ ```
182
+
183
+ Gdy storefront aplikuje "Dostępne + Producent=Funko" jako filter w UI:
184
+
185
+ ```graphql
186
+ variables: {
187
+ input: {
188
+ available: true,
189
+ currentFilters: [{ attributeId: "producent", values: ["Funko"] }]
190
+ }
191
+ }
192
+ ```
193
+
194
+ - `availableCount` = liczba sellable Funko w context (ignoring `input.available`, applying `producent: Funko`)
195
+ - `attributes[producent].filterValues[Funko].productCount` = liczba sellable produktów (ignoring `producent`, applying `available: true`) = `products(filters).totalCount`
196
+
197
+ ### Minor Changes
198
+
199
+ - e64cfc5: Brand entity available in Storefront API: filter products by canonical brand + read brand details per product.
200
+
201
+ **What's new in Storefront API**
202
+ - **`Product.brand: BrandSummary`** — every product now exposes its canonical brand (when assigned). Returns `{ id, name, slug, logo }` or `null` if the product has no brand. Resolved via DataLoader so listing 50 products incurs at most 1 brand query (no N+1).
203
+ - **`ProductFilter.brand: BrandFilter`** — new filter input accepting either `{ id: "uuid" }` or `{ slug: "funko" }`. Multiple `brand` entries in `filters[]` use OR semantics. Combine with other filters (price, tags, attributes) using AND across different field names.
204
+ - **`AvailableFilters.brands: [BrandFilterValue!]!`** — `productFilters` query now returns aggregated brands present in the current product set, each with `productCount`. Build a brand facet (checkbox list or dropdown with logo) directly from this response. Only active brands with at least one product in context are included.
205
+
206
+ **Example: brand facet + filter on a listing page**
207
+
208
+ ```graphql
209
+ query Listing {
210
+ productFilters {
211
+ brands {
212
+ id
213
+ name
214
+ slug
215
+ logo
216
+ productCount
217
+ }
218
+ }
219
+ products(filters: [{ brand: { slug: "funko" } }]) {
220
+ totalCount
221
+ nodes {
222
+ id
223
+ title
224
+ brand {
225
+ name
226
+ slug
227
+ logo
228
+ }
229
+ }
230
+ }
231
+ }
232
+ ```
233
+
234
+ **Migration**
235
+ - Existing `Product.vendor: String` (legacy free-text) remains supported. New brand-based filtering uses `ProductFilter.brand` instead of `productVendor`.
236
+ - No breaking changes. All new fields are nullable / optional — queries written before this release continue to work.
237
+
238
+ **SDK**
239
+
240
+ Regenerated types include `BrandSummary`, `BrandFilter`, `BrandFilterValue`, and `Product.brand`. Import them from `@doswiftly/storefront-operations` schema types.
241
+
3
242
  ## 12.0.0
4
243
 
5
244
  ### Major Changes
package/README.md CHANGED
@@ -241,13 +241,14 @@ full executable body of each operation.
241
241
 
242
242
  | Operation | Description |
243
243
  | --- | --- |
244
- | `AvailableShippingMethods` | Returns shipping methods for a given destination address and cart shape (subtotal, total weight, currency). The query computes everything from the inputs alone — no existing cart is required, so it can be used for "shipping cost preview" UIs before the customer adds anything to a cart. Each method includes price, free-shipping progress (`{ qualifies, currentAmount, threshold, remaining, progressPercent }`), estimated delivery, and carrier metadata. Sorted by the merchant's `sortOrder`, then by price. |
244
+ | `AvailableShippingMethods` | Returns shipping methods for a given destination address and cart shape (subtotal, total weight, currency). The query computes everything from the inputs alone — no existing cart is required, so it can be used for "shipping cost preview" UIs (e.g. product detail page shipping calculator) before the customer adds anything to a cart. Each method includes price, free-shipping progress (`{ qualifies, currentAmount, threshold, remaining, progressPercent }`), estimated delivery, and carrier metadata. Sorted by the merchant's `sortOrder`, then by price. For a cart-bound checkout flow (where the cart is already known and the storefront wants the resolver to skip non-physical items and surface a `DIGITAL_ONLY_NO_SHIPPING` user error for all-digital carts), use `CartAvailableShippingMethods` against `cart.availableShippingMethods(address)` instead. |
245
+ | `CartAvailableShippingMethods` | Cart-aware shipping methods discovery. Returns shipping methods available for the cart's contents at the given destination, with subtotal and physical-item weight pulled from the cart aggregate (no need to compute them client-side). When the cart contains only non-physical items (digital, gift card, service, subscription), the response is `methods: []` plus a `DIGITAL_ONLY_NO_SHIPPING` user error — use this as the signal to skip rendering the shipping picker step. Prefer this query over the standalone `AvailableShippingMethods` once a cart has been created (`cartCreate`). For pre-cart "shipping cost preview" UIs on product detail pages, the standalone query remains the right tool. |
245
246
 
246
247
  #### Attribute Filters
247
248
 
248
249
  | Operation | Description |
249
250
  | --- | --- |
250
- | `ProductFilters` | Returns the dynamic facet filters available for a listing context — pass `collectionId`, `categoryId`, or `searchQuery`. For each visible & filterable attribute, returns either discrete value counts (for `SELECT` / `CHECKBOX` types) or numeric range bounds (for `SLIDER` types). Plus `priceRange`, per-category counts, `activeCount` (length of `currentFilters` input), and `matchCount` (total products in the context). Use to render filter sidebars on listing/search pages. |
251
+ | `ProductFilters` | Returns the dynamic facet filters available for a listing context — pass `collectionId`, `categoryId`, `searchQuery`, optional `available` (boolean for the availability facet), and optional `currentFilters` (array of attribute filters currently applied by the UI). For each visible & filterable attribute, returns either discrete value counts (for `SELECT` / `CHECKBOX` types) or numeric range bounds (for `SLIDER` types). Plus `priceRange`, `brands`, per-category counts, `activeCount` (length of `currentFilters`), `totalCount` (products in context — Relay-aligned), and `availableCount` (boolean facet count for `availableForSale`). All per-facet counts use exclude-self aggregation: a facet's count IGNORES its own currently-applied filter and APPLIES other filters — so the count reflects "products if this facet were toggled" rather than "products currently visible". Untracked inventory (gift cards, digital, made-to-order) is always counted as available. Use to render filter sidebars on listing/search pages. |
251
252
 
252
253
  #### Loyalty Program
253
254
 
@@ -554,7 +555,7 @@ full executable body of each operation.
554
555
  | `AttributeDefinition` | `AttributeDefinition` | Filterable attribute exposed on the storefront — name, type (SELECT / CHECKBOX / SLIDER / etc.), filterability flags, display order, and either discrete `filterValues[]` or numeric `rangeBounds`. Spread inside `availableFilters.attributes[]`. |
555
556
  | `PriceRangeFilter` | `PriceRange` | Min / max price range across products in the current listing context. Use to bound the price slider. |
556
557
  | `CategoryFilterOption` | `CategoryFilterOption` | One category option in the categories filter — id, name, slug, count of products in this category within the current listing context, level + parentId for tree rendering. |
557
- | `AvailableFilters` | `AvailableFilters` | Full result of the `productFilters($input)` query — attribute filters, price range, category filters, count of currently active filters, total products matching the context. Spread on listing pages to render filter sidebars. |
558
+ | `AvailableFilters` | `AvailableFilters` | Full result of the `productFilters($input)` query — attribute filters, price range, category filters, brands, count of currently active filters, total products in context (Relay-aligned `totalCount`), and boolean facet count for `availableForSale` (`availableCount`). Spread on listing pages to render filter sidebars. Per-facet counts (`attributes[].filterValues[].productCount`, `brands[].productCount`, `categories[].productCount`, `availableCount`) use exclude-self aggregation: when a dimension is in `input.currentFilters` / `input.available`, its own facet count IGNORES that filter (shows "how many products would appear if this facet were toggled"), while OTHER dimensions APPLY their filters. This matches industry convention for accurate facet UX. |
558
559
 
559
560
  #### Loyalty Program
560
561
 
package/fragments.graphql CHANGED
@@ -407,6 +407,7 @@ fragment CartLine on CartLine {
407
407
  productTitle
408
408
  productHandle
409
409
  productType
410
+ requiresShipping
410
411
  }
411
412
 
412
413
  # Buyer identity associated with the cart. Note: only `customerId` is currently persisted server-side; `email`, `phone`, `countryCode` are accepted in the input but ignored.
@@ -488,6 +489,7 @@ fragment Cart on Cart {
488
489
  appliedGiftCards {
489
490
  ...CartAppliedGiftCard
490
491
  }
492
+ requiresShipping
491
493
  createdAt
492
494
  updatedAt
493
495
  }
@@ -1012,7 +1014,7 @@ fragment CategoryFilterOption on CategoryFilterOption {
1012
1014
  parentId
1013
1015
  }
1014
1016
 
1015
- # Full result of the `productFilters($input)` query — attribute filters, price range, category filters, count of currently active filters, total products matching the context. Spread on listing pages to render filter sidebars.
1017
+ # Full result of the `productFilters($input)` query — attribute filters, price range, category filters, brands, count of currently active filters, total products in context (Relay-aligned `totalCount`), and boolean facet count for `availableForSale` (`availableCount`). Spread on listing pages to render filter sidebars. Per-facet counts (`attributes[].filterValues[].productCount`, `brands[].productCount`, `categories[].productCount`, `availableCount`) use exclude-self aggregation: when a dimension is in `input.currentFilters` / `input.available`, its own facet count IGNORES that filter (shows "how many products would appear if this facet were toggled"), while OTHER dimensions APPLY their filters. This matches industry convention for accurate facet UX.
1016
1018
  fragment AvailableFilters on AvailableFilters {
1017
1019
  attributes {
1018
1020
  ...AttributeDefinition
@@ -1024,7 +1026,8 @@ fragment AvailableFilters on AvailableFilters {
1024
1026
  ...CategoryFilterOption
1025
1027
  }
1026
1028
  activeCount
1027
- matchCount
1029
+ totalCount
1030
+ availableCount
1028
1031
  }
1029
1032
 
1030
1033
  # ============================================
package/llms-full.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # DoSwiftly Storefront Operations — Full Reference
2
2
 
3
- > Schema version: **12.0.0**
4
- > 49 queries · 40 mutations · 100 fragments
3
+ > Schema version: **13.1.0**
4
+ > 50 queries · 40 mutations · 100 fragments
5
5
 
6
6
  Auto-generated from `.graphql` source files. Do not edit by hand — this file is
7
7
  regenerated on every release to match the published schema.
@@ -676,7 +676,7 @@ query GiftCardValidate($code: String!, $amount: Float) {
676
676
 
677
677
  **Section**: Shipping Methods
678
678
 
679
- **Description**: Returns shipping methods for a given destination address and cart shape (subtotal, total weight, currency). The query computes everything from the inputs alone — no existing cart is required, so it can be used for "shipping cost preview" UIs before the customer adds anything to a cart. Each method includes price, free-shipping progress (`{ qualifies, currentAmount, threshold, remaining, progressPercent }`), estimated delivery, and carrier metadata. Sorted by the merchant's `sortOrder`, then by price.
679
+ **Description**: Returns shipping methods for a given destination address and cart shape (subtotal, total weight, currency). The query computes everything from the inputs alone — no existing cart is required, so it can be used for "shipping cost preview" UIs (e.g. product detail page shipping calculator) before the customer adds anything to a cart. Each method includes price, free-shipping progress (`{ qualifies, currentAmount, threshold, remaining, progressPercent }`), estimated delivery, and carrier metadata. Sorted by the merchant's `sortOrder`, then by price. For a cart-bound checkout flow (where the cart is already known and the storefront wants the resolver to skip non-physical items and surface a `DIGITAL_ONLY_NO_SHIPPING` user error for all-digital carts), use `CartAvailableShippingMethods` against `cart.availableShippingMethods(address)` instead.
680
680
 
681
681
  **Variables**:
682
682
  - `$address`: `ShippingAddressInput!`
@@ -701,11 +701,44 @@ query AvailableShippingMethods($address: ShippingAddressInput!, $cart: CartShipp
701
701
  }
702
702
  ```
703
703
 
704
+ ### Query: `CartAvailableShippingMethods`
705
+
706
+ **Section**: Shipping Methods
707
+
708
+ **Description**: Cart-aware shipping methods discovery. Returns shipping methods available for the cart's contents at the given destination, with subtotal and physical-item weight pulled from the cart aggregate (no need to compute them client-side). When the cart contains only non-physical items (digital, gift card, service, subscription), the response is `methods: []` plus a `DIGITAL_ONLY_NO_SHIPPING` user error — use this as the signal to skip rendering the shipping picker step. Prefer this query over the standalone `AvailableShippingMethods` once a cart has been created (`cartCreate`). For pre-cart "shipping cost preview" UIs on product detail pages, the standalone query remains the right tool.
709
+
710
+ **Variables**:
711
+ - `$cartId`: `ID!`
712
+ - `$address`: `ShippingAddressInput!`
713
+
714
+ **Fragments used**: `AvailableShippingMethod`, `FreeShippingProgress`, `UserError`
715
+
716
+ **GraphQL**:
717
+ ```graphql
718
+ query CartAvailableShippingMethods($cartId: ID!, $address: ShippingAddressInput!) {
719
+ cart(id: $cartId) {
720
+ id
721
+ requiresShipping
722
+ availableShippingMethods(address: $address) {
723
+ methods {
724
+ ...AvailableShippingMethod
725
+ }
726
+ freeShippingProgress {
727
+ ...FreeShippingProgress
728
+ }
729
+ userErrors {
730
+ ...UserError
731
+ }
732
+ }
733
+ }
734
+ }
735
+ ```
736
+
704
737
  ### Query: `ProductFilters`
705
738
 
706
739
  **Section**: Attribute Filters
707
740
 
708
- **Description**: Returns the dynamic facet filters available for a listing context — pass `collectionId`, `categoryId`, or `searchQuery`. For each visible & filterable attribute, returns either discrete value counts (for `SELECT` / `CHECKBOX` types) or numeric range bounds (for `SLIDER` types). Plus `priceRange`, per-category counts, `activeCount` (length of `currentFilters` input), and `matchCount` (total products in the context). Use to render filter sidebars on listing/search pages.
741
+ **Description**: Returns the dynamic facet filters available for a listing context — pass `collectionId`, `categoryId`, `searchQuery`, optional `available` (boolean for the availability facet), and optional `currentFilters` (array of attribute filters currently applied by the UI). For each visible & filterable attribute, returns either discrete value counts (for `SELECT` / `CHECKBOX` types) or numeric range bounds (for `SLIDER` types). Plus `priceRange`, `brands`, per-category counts, `activeCount` (length of `currentFilters`), `totalCount` (products in context — Relay-aligned), and `availableCount` (boolean facet count for `availableForSale`). All per-facet counts use exclude-self aggregation: a facet's count IGNORES its own currently-applied filter and APPLIES other filters — so the count reflects "products if this facet were toggled" rather than "products currently visible". Untracked inventory (gift cards, digital, made-to-order) is always counted as available. Use to render filter sidebars on listing/search pages.
709
742
 
710
743
  **Variables**:
711
744
  - `$input`: `AvailableFiltersInput`
@@ -2943,6 +2976,7 @@ fragment CartLine on CartLine {
2943
2976
  productTitle
2944
2977
  productHandle
2945
2978
  productType
2979
+ requiresShipping
2946
2980
  }
2947
2981
  ```
2948
2982
 
@@ -3060,6 +3094,7 @@ fragment Cart on Cart {
3060
3094
  appliedGiftCards {
3061
3095
  ...CartAppliedGiftCard
3062
3096
  }
3097
+ requiresShipping
3063
3098
  createdAt
3064
3099
  updatedAt
3065
3100
  }
@@ -3887,7 +3922,7 @@ fragment CategoryFilterOption on CategoryFilterOption {
3887
3922
 
3888
3923
  **Section**: Attribute Filters
3889
3924
 
3890
- **Description**: Full result of the `productFilters($input)` query — attribute filters, price range, category filters, count of currently active filters, total products matching the context. Spread on listing pages to render filter sidebars.
3925
+ **Description**: Full result of the `productFilters($input)` query — attribute filters, price range, category filters, brands, count of currently active filters, total products in context (Relay-aligned `totalCount`), and boolean facet count for `availableForSale` (`availableCount`). Spread on listing pages to render filter sidebars. Per-facet counts (`attributes[].filterValues[].productCount`, `brands[].productCount`, `categories[].productCount`, `availableCount`) use exclude-self aggregation: when a dimension is in `input.currentFilters` / `input.available`, its own facet count IGNORES that filter (shows "how many products would appear if this facet were toggled"), while OTHER dimensions APPLY their filters. This matches industry convention for accurate facet UX.
3891
3926
 
3892
3927
  **Uses fragments**: `AttributeDefinition`, `CategoryFilterOption`, `PriceRangeFilter`
3893
3928
 
@@ -3904,7 +3939,8 @@ fragment AvailableFilters on AvailableFilters {
3904
3939
  ...CategoryFilterOption
3905
3940
  }
3906
3941
  activeCount
3907
- matchCount
3942
+ totalCount
3943
+ availableCount
3908
3944
  }
3909
3945
  ```
3910
3946
 
package/operations.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": "12.0.0",
2
+ "schemaVersion": "13.1.0",
3
3
  "queries": [
4
4
  {
5
5
  "name": "Shop",
@@ -504,7 +504,7 @@
504
504
  "name": "AvailableShippingMethods",
505
505
  "kind": "query",
506
506
  "section": "Shipping Methods",
507
- "description": "Returns shipping methods for a given destination address and cart shape (subtotal, total weight, currency). The query computes everything from the inputs alone — no existing cart is required, so it can be used for \"shipping cost preview\" UIs before the customer adds anything to a cart. Each method includes price, free-shipping progress (`{ qualifies, currentAmount, threshold, remaining, progressPercent }`), estimated delivery, and carrier metadata. Sorted by the merchant's `sortOrder`, then by price.",
507
+ "description": "Returns shipping methods for a given destination address and cart shape (subtotal, total weight, currency). The query computes everything from the inputs alone — no existing cart is required, so it can be used for \"shipping cost preview\" UIs (e.g. product detail page shipping calculator) before the customer adds anything to a cart. Each method includes price, free-shipping progress (`{ qualifies, currentAmount, threshold, remaining, progressPercent }`), estimated delivery, and carrier metadata. Sorted by the merchant's `sortOrder`, then by price. For a cart-bound checkout flow (where the cart is already known and the storefront wants the resolver to skip non-physical items and surface a `DIGITAL_ONLY_NO_SHIPPING` user error for all-digital carts), use `CartAvailableShippingMethods` against `cart.availableShippingMethods(address)` instead.",
508
508
  "variables": [
509
509
  {
510
510
  "name": "address",
@@ -524,11 +524,35 @@
524
524
  ],
525
525
  "body": "query AvailableShippingMethods($address: ShippingAddressInput!, $cart: CartShippingInput) {\n availableShippingMethods(address: $address, cart: $cart) {\n methods {\n ...AvailableShippingMethod\n }\n freeShippingProgress {\n ...FreeShippingProgress\n }\n userErrors {\n ...UserError\n }\n }\n}"
526
526
  },
527
+ {
528
+ "name": "CartAvailableShippingMethods",
529
+ "kind": "query",
530
+ "section": "Shipping Methods",
531
+ "description": "Cart-aware shipping methods discovery. Returns shipping methods available for the cart's contents at the given destination, with subtotal and physical-item weight pulled from the cart aggregate (no need to compute them client-side). When the cart contains only non-physical items (digital, gift card, service, subscription), the response is `methods: []` plus a `DIGITAL_ONLY_NO_SHIPPING` user error — use this as the signal to skip rendering the shipping picker step. Prefer this query over the standalone `AvailableShippingMethods` once a cart has been created (`cartCreate`). For pre-cart \"shipping cost preview\" UIs on product detail pages, the standalone query remains the right tool.",
532
+ "variables": [
533
+ {
534
+ "name": "cartId",
535
+ "type": "ID!",
536
+ "defaultValue": null
537
+ },
538
+ {
539
+ "name": "address",
540
+ "type": "ShippingAddressInput!",
541
+ "defaultValue": null
542
+ }
543
+ ],
544
+ "fragmentRefs": [
545
+ "AvailableShippingMethod",
546
+ "FreeShippingProgress",
547
+ "UserError"
548
+ ],
549
+ "body": "query CartAvailableShippingMethods($cartId: ID!, $address: ShippingAddressInput!) {\n cart(id: $cartId) {\n id\n requiresShipping\n availableShippingMethods(address: $address) {\n methods {\n ...AvailableShippingMethod\n }\n freeShippingProgress {\n ...FreeShippingProgress\n }\n userErrors {\n ...UserError\n }\n }\n }\n}"
550
+ },
527
551
  {
528
552
  "name": "ProductFilters",
529
553
  "kind": "query",
530
554
  "section": "Attribute Filters",
531
- "description": "Returns the dynamic facet filters available for a listing context — pass `collectionId`, `categoryId`, or `searchQuery`. For each visible & filterable attribute, returns either discrete value counts (for `SELECT` / `CHECKBOX` types) or numeric range bounds (for `SLIDER` types). Plus `priceRange`, per-category counts, `activeCount` (length of `currentFilters` input), and `matchCount` (total products in the context). Use to render filter sidebars on listing/search pages.",
555
+ "description": "Returns the dynamic facet filters available for a listing context — pass `collectionId`, `categoryId`, `searchQuery`, optional `available` (boolean for the availability facet), and optional `currentFilters` (array of attribute filters currently applied by the UI). For each visible & filterable attribute, returns either discrete value counts (for `SELECT` / `CHECKBOX` types) or numeric range bounds (for `SLIDER` types). Plus `priceRange`, `brands`, per-category counts, `activeCount` (length of `currentFilters`), `totalCount` (products in context — Relay-aligned), and `availableCount` (boolean facet count for `availableForSale`). All per-facet counts use exclude-self aggregation: a facet's count IGNORES its own currently-applied filter and APPLIES other filters — so the count reflects \"products if this facet were toggled\" rather than \"products currently visible\". Untracked inventory (gift cards, digital, made-to-order) is always counted as available. Use to render filter sidebars on listing/search pages.",
532
556
  "variables": [
533
557
  {
534
558
  "name": "input",
@@ -2065,7 +2089,7 @@
2065
2089
  "CartLineCost",
2066
2090
  "ProductVariant"
2067
2091
  ],
2068
- "body": "fragment CartLine on CartLine {\n id\n quantity\n variant {\n ...ProductVariant\n }\n cost {\n ...CartLineCost\n }\n attributes {\n key\n value\n }\n attributeSelections {\n ...AttributeSelection\n }\n productId\n productTitle\n productHandle\n productType\n}",
2092
+ "body": "fragment CartLine on CartLine {\n id\n quantity\n variant {\n ...ProductVariant\n }\n cost {\n ...CartLineCost\n }\n attributes {\n key\n value\n }\n attributeSelections {\n ...AttributeSelection\n }\n productId\n productTitle\n productHandle\n productType\n requiresShipping\n}",
2069
2093
  "onType": "CartLine"
2070
2094
  },
2071
2095
  {
@@ -2118,7 +2142,7 @@
2118
2142
  "MailingAddress",
2119
2143
  "PageInfo"
2120
2144
  ],
2121
- "body": "fragment Cart on Cart {\n id\n checkoutUrl\n totalQuantity\n cost {\n ...CartCost\n }\n lines(first: 100) {\n edges {\n cursor\n node {\n ... on CartLine {\n ...CartLine\n }\n }\n }\n nodes {\n ... on CartLine {\n ...CartLine\n }\n }\n pageInfo {\n ...PageInfo\n }\n totalCount\n }\n buyerIdentity {\n ...CartBuyerIdentity\n }\n discountCodes {\n ...CartDiscountCode\n }\n discountAllocations {\n ...CartDiscountAllocation\n }\n note\n attributes {\n key\n value\n }\n email\n phone\n shippingAddress {\n ...MailingAddress\n }\n billingAddress {\n ...MailingAddress\n }\n selectedShippingMethod {\n ...CartShippingMethod\n }\n selectedPaymentMethod {\n ...CartSelectedPaymentMethod\n }\n appliedGiftCards {\n ...CartAppliedGiftCard\n }\n createdAt\n updatedAt\n}",
2145
+ "body": "fragment Cart on Cart {\n id\n checkoutUrl\n totalQuantity\n cost {\n ...CartCost\n }\n lines(first: 100) {\n edges {\n cursor\n node {\n ... on CartLine {\n ...CartLine\n }\n }\n }\n nodes {\n ... on CartLine {\n ...CartLine\n }\n }\n pageInfo {\n ...PageInfo\n }\n totalCount\n }\n buyerIdentity {\n ...CartBuyerIdentity\n }\n discountCodes {\n ...CartDiscountCode\n }\n discountAllocations {\n ...CartDiscountAllocation\n }\n note\n attributes {\n key\n value\n }\n email\n phone\n shippingAddress {\n ...MailingAddress\n }\n billingAddress {\n ...MailingAddress\n }\n selectedShippingMethod {\n ...CartShippingMethod\n }\n selectedPaymentMethod {\n ...CartSelectedPaymentMethod\n }\n appliedGiftCards {\n ...CartAppliedGiftCard\n }\n requiresShipping\n createdAt\n updatedAt\n}",
2122
2146
  "onType": "Cart"
2123
2147
  },
2124
2148
  {
@@ -2557,14 +2581,14 @@
2557
2581
  "name": "AvailableFilters",
2558
2582
  "kind": "fragment",
2559
2583
  "section": "Attribute Filters",
2560
- "description": "Full result of the `productFilters($input)` query — attribute filters, price range, category filters, count of currently active filters, total products matching the context. Spread on listing pages to render filter sidebars.",
2584
+ "description": "Full result of the `productFilters($input)` query — attribute filters, price range, category filters, brands, count of currently active filters, total products in context (Relay-aligned `totalCount`), and boolean facet count for `availableForSale` (`availableCount`). Spread on listing pages to render filter sidebars. Per-facet counts (`attributes[].filterValues[].productCount`, `brands[].productCount`, `categories[].productCount`, `availableCount`) use exclude-self aggregation: when a dimension is in `input.currentFilters` / `input.available`, its own facet count IGNORES that filter (shows \"how many products would appear if this facet were toggled\"), while OTHER dimensions APPLY their filters. This matches industry convention for accurate facet UX.",
2561
2585
  "variables": [],
2562
2586
  "fragmentRefs": [
2563
2587
  "AttributeDefinition",
2564
2588
  "CategoryFilterOption",
2565
2589
  "PriceRangeFilter"
2566
2590
  ],
2567
- "body": "fragment AvailableFilters on AvailableFilters {\n attributes {\n ...AttributeDefinition\n }\n priceRange {\n ...PriceRangeFilter\n }\n categories {\n ...CategoryFilterOption\n }\n activeCount\n matchCount\n}",
2591
+ "body": "fragment AvailableFilters on AvailableFilters {\n attributes {\n ...AttributeDefinition\n }\n priceRange {\n ...PriceRangeFilter\n }\n categories {\n ...CategoryFilterOption\n }\n activeCount\n totalCount\n availableCount\n}",
2568
2592
  "onType": "AvailableFilters"
2569
2593
  },
2570
2594
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doswiftly/storefront-operations",
3
- "version": "12.0.0",
3
+ "version": "13.1.0",
4
4
  "description": "GraphQL operations for DoSwiftly Storefront - SSOT from backend",
5
5
  "homepage": "https://doswiftly.pl",
6
6
  "publishConfig": {
package/queries.graphql CHANGED
@@ -368,7 +368,7 @@ query GiftCardValidate($code: String!, $amount: Float) {
368
368
  # Shipping Methods
369
369
  # ============================================
370
370
 
371
- # Returns shipping methods for a given destination address and cart shape (subtotal, total weight, currency). The query computes everything from the inputs alone — no existing cart is required, so it can be used for "shipping cost preview" UIs before the customer adds anything to a cart. Each method includes price, free-shipping progress (`{ qualifies, currentAmount, threshold, remaining, progressPercent }`), estimated delivery, and carrier metadata. Sorted by the merchant's `sortOrder`, then by price.
371
+ # Returns shipping methods for a given destination address and cart shape (subtotal, total weight, currency). The query computes everything from the inputs alone — no existing cart is required, so it can be used for "shipping cost preview" UIs (e.g. product detail page shipping calculator) before the customer adds anything to a cart. Each method includes price, free-shipping progress (`{ qualifies, currentAmount, threshold, remaining, progressPercent }`), estimated delivery, and carrier metadata. Sorted by the merchant's `sortOrder`, then by price. For a cart-bound checkout flow (where the cart is already known and the storefront wants the resolver to skip non-physical items and surface a `DIGITAL_ONLY_NO_SHIPPING` user error for all-digital carts), use `CartAvailableShippingMethods` against `cart.availableShippingMethods(address)` instead.
372
372
  query AvailableShippingMethods($address: ShippingAddressInput!, $cart: CartShippingInput) {
373
373
  availableShippingMethods(address: $address, cart: $cart) {
374
374
  methods {
@@ -383,11 +383,30 @@ query AvailableShippingMethods($address: ShippingAddressInput!, $cart: CartShipp
383
383
  }
384
384
  }
385
385
 
386
+ # Cart-aware shipping methods discovery. Returns shipping methods available for the cart's contents at the given destination, with subtotal and physical-item weight pulled from the cart aggregate (no need to compute them client-side). When the cart contains only non-physical items (digital, gift card, service, subscription), the response is `methods: []` plus a `DIGITAL_ONLY_NO_SHIPPING` user error — use this as the signal to skip rendering the shipping picker step. Prefer this query over the standalone `AvailableShippingMethods` once a cart has been created (`cartCreate`). For pre-cart "shipping cost preview" UIs on product detail pages, the standalone query remains the right tool.
387
+ query CartAvailableShippingMethods($cartId: ID!, $address: ShippingAddressInput!) {
388
+ cart(id: $cartId) {
389
+ id
390
+ requiresShipping
391
+ availableShippingMethods(address: $address) {
392
+ methods {
393
+ ...AvailableShippingMethod
394
+ }
395
+ freeShippingProgress {
396
+ ...FreeShippingProgress
397
+ }
398
+ userErrors {
399
+ ...UserError
400
+ }
401
+ }
402
+ }
403
+ }
404
+
386
405
  # ============================================
387
406
  # Attribute Filters
388
407
  # ============================================
389
408
 
390
- # Returns the dynamic facet filters available for a listing context — pass `collectionId`, `categoryId`, or `searchQuery`. For each visible & filterable attribute, returns either discrete value counts (for `SELECT` / `CHECKBOX` types) or numeric range bounds (for `SLIDER` types). Plus `priceRange`, per-category counts, `activeCount` (length of `currentFilters` input), and `matchCount` (total products in the context). Use to render filter sidebars on listing/search pages.
409
+ # Returns the dynamic facet filters available for a listing context — pass `collectionId`, `categoryId`, `searchQuery`, optional `available` (boolean for the availability facet), and optional `currentFilters` (array of attribute filters currently applied by the UI). For each visible & filterable attribute, returns either discrete value counts (for `SELECT` / `CHECKBOX` types) or numeric range bounds (for `SLIDER` types). Plus `priceRange`, `brands`, per-category counts, `activeCount` (length of `currentFilters`), `totalCount` (products in context — Relay-aligned), and `availableCount` (boolean facet count for `availableForSale`). All per-facet counts use exclude-self aggregation: a facet's count IGNORES its own currently-applied filter and APPLIES other filters — so the count reflects "products if this facet were toggled" rather than "products currently visible". Untracked inventory (gift cards, digital, made-to-order) is always counted as available. Use to render filter sidebars on listing/search pages.
391
410
  query ProductFilters($input: AvailableFiltersInput) {
392
411
  productFilters(input: $input) {
393
412
  ...AvailableFilters
package/schema.graphql CHANGED
@@ -72,6 +72,11 @@ type AttributeDefinition {
72
72
  """Attribute name (e.g., "Color", "Size")"""
73
73
  name: String!
74
74
 
75
+ """
76
+ Faza 3 Opcja A (2026-05-17): opcjonalne grupowanie atrybutu (np. "inventory", "marketing", "content"). NULL = atrybut bez grupy. Pomaga storefront-devom rozpoznać "te atrybuty są custom dane ERP" vs "te są filtrowalne cechy katalogu".
77
+ """
78
+ namespace: String
79
+
75
80
  """Range bounds (for NUMBER, CURRENCY)"""
76
81
  rangeBounds: AttributeRangeBounds
77
82
 
@@ -99,6 +104,11 @@ input AttributeFilterInput {
99
104
  """Minimum value (for NUMBER, CURRENCY)"""
100
105
  minValue: Float
101
106
 
107
+ """
108
+ Opcjonalny dyskryminator grupy atrybutów (np. "inventory", "marketing"). Faza 3 Opcja A (2026-05-17): konsolidacja MetaProperty-style custom fields pod AttributeDefinition — namespace pozwala filtrować "Marka z grupy XYZ" gdy multiple shopów ma `Marka` w różnych grupach.
109
+ """
110
+ namespace: String
111
+
102
112
  """Text search (for TEXT, TEXTAREA)"""
103
113
  textSearch: String
104
114
 
@@ -248,34 +258,51 @@ enum AttributeType {
248
258
  """Available filters for product listing"""
249
259
  type AvailableFilters {
250
260
  """
251
- Number of currently active filters (renamed from activeFilterCount in Storefront 5.0)
261
+ Liczba currently applied filters w `currentFilters` (Storefront UI renderuje "Filtry (3)" w sidebar header).
252
262
  """
253
263
  activeCount: Int!
254
264
 
255
265
  """Filterable attributes with values"""
256
266
  attributes: [AttributeDefinition!]!
257
267
 
258
- """Categories available for filtering"""
259
- categories: [CategoryFilterOption!]
268
+ """
269
+ Boolean facet count: liczba produktów w current context spełniających `Product.isAvailable` (przynajmniej jeden aktywny wariant z untracked inventory LUB available>0). Exclude-self: jeśli `available: true` jest w `currentFilters`, count IGNORUJE ten filtr i pokazuje "ile produktów dostępnych w bazowym kontekście" (nie "ile teraz widać"). Spójne semantically z `attributes[].filterValues[].productCount` (per-enum-value facet) i `brands[].productCount` (per-brand facet) — wszystkie używają exclude-self per dimension. Untracked inventory (gift cards, digital, na-zamówienie) zawsze liczy się jako available.
270
+ """
271
+ availableCount: Int!
260
272
 
261
273
  """
262
- Total products matching current context, before filters (renamed from totalProducts in Storefront 5.0)
274
+ Faza 3 (2026-05-17): canonical Brand entities z product counts w current context. Storefront UI renderuje checkbox list / dropdown z logo per brand. Klik → `filters: [{brand: {slug}}]`. Tylko brandy z >0 produktów w current product set + isActive=true.
263
275
  """
264
- matchCount: Int!
276
+ brands: [BrandFilterValue!]
277
+
278
+ """Categories available for filtering"""
279
+ categories: [CategoryFilterOption!]
265
280
 
266
281
  """Price range for filtering"""
267
282
  priceRange: PriceRange
283
+
284
+ """
285
+ Total products w current context (categoryId/collectionId/searchQuery) PRZED zaaplikowaniem faceted filters z `currentFilters`. Spójne z `ProductConnection.totalCount` Relay Connection spec — gdy `currentFilters` jest pusty, `AvailableFilters.totalCount` === `products(filters).totalCount`. Użyj do "Wszystkie produkty (N)" w sidebar header.
286
+ """
287
+ totalCount: Int!
268
288
  }
269
289
 
270
290
  """Context for available filters query"""
271
291
  input AvailableFiltersInput {
292
+ """
293
+ Czy filtr "dostępne" jest currently zaaplikowany. Mirror `ProductFilter.available`. Backend używa do exclude-self semantyki: gdy `available: true` (lub `false`) tutaj, `AvailableFilters.availableCount` IGNORUJE ten filtr (pokazuje "ile produktów ten facet odsłoni gdy włączysz"), pozostałe facet counts (`attributes[].filterValues[].productCount`, `brands[].productCount`, `categories[].productCount`) APLIKUJĄ go (pokazują "ile produktów w obecnym kontekście + tym facet"). Pomiń (null/undefined) gdy storefront nie aplikuje "dostępne" filter.
294
+ """
295
+ available: Boolean
296
+
272
297
  """Category ID context (supports comma-separated UUIDs)"""
273
298
  categoryId: ID
274
299
 
275
300
  """Collection ID context (supports comma-separated UUIDs)"""
276
301
  collectionId: ID
277
302
 
278
- """Currently applied filters (to update counts)"""
303
+ """
304
+ Currently applied attribute filters (to update facet counts). Per facet `productCount` używa exclude-self pattern: gdy attrId X jest w currentFilters, facet values X.* są liczone IGNORUJĄC X (pokazują "ile produktów dla X=Y w pozostałym kontekście"). Pozostałe attrIds są aplikowane.
305
+ """
279
306
  currentFilters: [AttributeFilterInput!]
280
307
 
281
308
  """Search query context"""
@@ -598,6 +625,52 @@ type BrandColors {
598
625
  secondary: BrandColorGroup
599
626
  }
600
627
 
628
+ """Filter products po brand (canonical entity)"""
629
+ input BrandFilter {
630
+ """Brand ID — exact UUID match"""
631
+ id: ID
632
+
633
+ """
634
+ Brand slug — URL-friendly identifier. Preferowany over `id` dla storefront refetch (stable po URL share).
635
+ """
636
+ slug: String
637
+ }
638
+
639
+ """Brand filter value z product count"""
640
+ type BrandFilterValue {
641
+ """Brand ID"""
642
+ id: ID!
643
+
644
+ """Brand logo URL (UI rendering)"""
645
+ logo: String
646
+
647
+ """Brand name"""
648
+ name: String!
649
+
650
+ """Liczba produktów z tym brandem w current product set"""
651
+ productCount: Int!
652
+
653
+ """Brand slug (refetch payload — paste w ProductFilter.brand.slug)"""
654
+ slug: String!
655
+ }
656
+
657
+ """
658
+ Brand summary — minimalna projekcja dla storefront catalog + filter rendering
659
+ """
660
+ type BrandSummary {
661
+ """Brand unique identifier"""
662
+ id: ID!
663
+
664
+ """Brand logo URL (CDN-served, storefront-rendered)"""
665
+ logo: String
666
+
667
+ """Brand display name (e.g. "Funko", "Nintendo")"""
668
+ name: String!
669
+
670
+ """URL-friendly slug — używane dla future /marka/[slug] landing pages"""
671
+ slug: String!
672
+ }
673
+
601
674
  """Business hours for a day"""
602
675
  type BusinessHour {
603
676
  """Closing time (HH:MM format)"""
@@ -703,6 +776,11 @@ type Cart implements Node {
703
776
  """Available payment methods dla tego cart (shop-configured providers)"""
704
777
  availablePaymentMethods: [PaymentMethod!]!
705
778
 
779
+ """
780
+ Cart-aware shipping methods discovery. Returns empty methods + DIGITAL_ONLY_NO_SHIPPING user error when the cart contains only non-physical items (digital, gift card, service, subscription). Physical / mixed carts get the same method computation as the standalone `availableShippingMethods` query but with subtotal and weight pulled from the cart aggregate.
781
+ """
782
+ availableShippingMethods(address: ShippingAddressInput!): AvailableShippingMethodsPayload!
783
+
706
784
  """
707
785
  Billing address ustawiony przez cartSetBillingAddress (jeśli różny od shipping)
708
786
  """
@@ -748,6 +826,11 @@ type Cart implements Node {
748
826
  """Product recommendations based on cart contents"""
749
827
  recommendations(first: Int = 4): CartRecommendations
750
828
 
829
+ """
830
+ True if any line in the cart requires physical shipping. False when every line is non-physical (digital, gift card, service, subscription). Use as the single signal to render or skip the shipping picker in checkout.
831
+ """
832
+ requiresShipping: Boolean!
833
+
751
834
  """Selected payment method (PayU/Stripe/COD/etc.)"""
752
835
  selectedPaymentMethod: PaymentMethod
753
836
 
@@ -1066,6 +1149,11 @@ type CartLine {
1066
1149
  """Quantity of this item"""
1067
1150
  quantity: Int!
1068
1151
 
1152
+ """
1153
+ True if this line item requires physical shipping (productType=PHYSICAL). False for digital, gift card, service, and subscription items. Use to skip the shipping step in checkout when every line is non-physical.
1154
+ """
1155
+ requiresShipping: Boolean!
1156
+
1069
1157
  """Product variant being purchased"""
1070
1158
  variant: ProductVariant!
1071
1159
  }
@@ -1904,6 +1992,9 @@ type Customer implements Node {
1904
1992
  """Saved addresses (Relay Connection)"""
1905
1993
  addresses(after: String, before: String, first: Int, last: Int): MailingAddressConnection!
1906
1994
 
1995
+ """Customer custom field values (post-Opcja A unified custom fields)."""
1996
+ attributes(namespace: String): [EntityAttributeField!]!
1997
+
1907
1998
  """Company name (populated for COMPANY type)"""
1908
1999
  companyName: String
1909
2000
 
@@ -1937,16 +2028,6 @@ type Customer implements Node {
1937
2028
  """Last name"""
1938
2029
  lastName: String
1939
2030
 
1940
- """
1941
- Lista meta properties (Relay Connection) — opcjonalnie scoped do namespace
1942
- """
1943
- metaProperties(first: Int = 10, namespace: String): MetaPropertyConnection!
1944
-
1945
- """
1946
- Pojedyncze meta property po (namespace, key) — dla zalogowanego klienta zwraca także private
1947
- """
1948
- metaProperty(key: String!, namespace: String!): MetaProperty
1949
-
1950
2031
  """Total orders count (UnsignedInt64 — BigInt-safe)"""
1951
2032
  orderCount: UnsignedInt64!
1952
2033
 
@@ -2347,6 +2428,39 @@ enum EmailMarketingState {
2347
2428
  UNSUBSCRIBED
2348
2429
  }
2349
2430
 
2431
+ """Attribute assignment per entity (polymorphic owner)"""
2432
+ type EntityAttributeField {
2433
+ """AttributeDefinition ID"""
2434
+ definitionId: ID!
2435
+
2436
+ """Attribute handle (URL-friendly identifier)"""
2437
+ handle: String!
2438
+
2439
+ """EntityAttribute row ID"""
2440
+ id: ID!
2441
+
2442
+ """
2443
+ Per-entity visibility override (NULL = use AttributeDefinition.isVisible).
2444
+ """
2445
+ isVisibleOverride: Boolean
2446
+
2447
+ """Attribute display name"""
2448
+ name: String!
2449
+
2450
+ """
2451
+ Optional grouping namespace (e.g. "inventory", "marketing"). NULL = ungrouped.
2452
+ """
2453
+ namespace: String
2454
+
2455
+ """Attribute data type"""
2456
+ type: AttributeType!
2457
+
2458
+ """
2459
+ Value serialized as JSON string. Storefront-dev parses according to `type` (TEXT/NUMBER/BOOLEAN/JSON/etc.). Null values serialize as JSON `null`.
2460
+ """
2461
+ value: String!
2462
+ }
2463
+
2350
2464
  """Exchange rate between currencies"""
2351
2465
  type ExchangeRate {
2352
2466
  """Source currency code"""
@@ -3269,119 +3383,6 @@ enum MenuItemType {
3269
3383
  SEARCH
3270
3384
  }
3271
3385
 
3272
- """
3273
- Payload `customerMetaPropertiesSet` — bulk upsert własnych meta properties klienta
3274
- """
3275
- type MetaPropertiesSetPayload {
3276
- """Upserted meta properties (puste gdy userErrors)"""
3277
- metaProperties: [MetaProperty!]!
3278
-
3279
- """User errors (code z `MetaPropertyErrorCode` enum)"""
3280
- userErrors: [UserError!]!
3281
- }
3282
-
3283
- """Pojedyncze pole dodatkowe (meta property) na encji commerce"""
3284
- type MetaProperty implements Node {
3285
- """Created at"""
3286
- createdAt: DateTime!
3287
-
3288
- """Unique identifier"""
3289
- id: ID!
3290
-
3291
- """
3292
- true = readable tylko przez authenticated context (admin/customer self). false (default) = readable również przez anonymous Storefront API.
3293
- """
3294
- isPrivate: Boolean!
3295
-
3296
- """
3297
- Key w obrębie namespace (3-64 chars). Unique per (ownerType, ownerId, namespace).
3298
- """
3299
- key: String!
3300
-
3301
- """
3302
- Namespace (3-64 chars). Zapobiega kolizji kluczy między aplikacjami. Reserved prefix: `doswiftly:` (platform).
3303
- """
3304
- namespace: String!
3305
-
3306
- """Typ wartości — informuje klienta jak parse value."""
3307
- type: MetaPropertyValueType!
3308
-
3309
- """Last updated at"""
3310
- updatedAt: DateTime!
3311
-
3312
- """
3313
- Wartość — zawsze String. Klient parsuje zgodnie z polem `type` (np. INTEGER → parseInt, JSON → JSON.parse).
3314
- """
3315
- value: String!
3316
- }
3317
-
3318
- """Paginated meta property list (Relay Connection)"""
3319
- type MetaPropertyConnection {
3320
- """Edges (cursor + node pairs)"""
3321
- edges: [MetaPropertyEdge!]!
3322
-
3323
- """Nodes (shortcut bez cursor)"""
3324
- nodes: [MetaProperty!]!
3325
-
3326
- """Page info"""
3327
- pageInfo: PageInfo!
3328
-
3329
- """Total count (max po wszystkich pages)"""
3330
- totalCount: Int!
3331
- }
3332
-
3333
- """Payload `customerMetaPropertyDelete`"""
3334
- type MetaPropertyDeletePayload {
3335
- """ID usuniętego meta property (null gdy userErrors)"""
3336
- deletedId: ID
3337
-
3338
- """User errors (code z `MetaPropertyErrorCode` enum)"""
3339
- userErrors: [UserError!]!
3340
- }
3341
-
3342
- """Meta property edge (Relay pagination)"""
3343
- type MetaPropertyEdge {
3344
- """Cursor for pagination"""
3345
- cursor: String!
3346
-
3347
- """Meta property node"""
3348
- node: MetaProperty!
3349
- }
3350
-
3351
- """Input dla pojedynczego meta property w bulk set"""
3352
- input MetaPropertyInput {
3353
- """Visibility — true = admin/customer-only, false = storefront-readable"""
3354
- isPrivate: Boolean = false
3355
-
3356
- """Key (3-64 chars)"""
3357
- key: String!
3358
-
3359
- """Namespace (3-64 chars, NOT starting with `doswiftly:`)"""
3360
- namespace: String!
3361
-
3362
- """Typ wartości (driver walidacji backend-side)"""
3363
- type: MetaPropertyValueType!
3364
-
3365
- """Wartość jako String (parse zgodnie z type)"""
3366
- value: String!
3367
- }
3368
-
3369
- """
3370
- Typy wartości — STRING (max 255 chars), TEXT (no cap), INTEGER, DECIMAL, BOOLEAN ("true"/"false"), JSON (stringified), DATE (YYYY-MM-DD), DATE_TIME (ISO 8601), URL (http(s)://...), COLOR (#RRGGBB / #RRGGBBAA hex).
3371
- """
3372
- enum MetaPropertyValueType {
3373
- BOOLEAN
3374
- COLOR
3375
- DATE
3376
- DATE_TIME
3377
- DECIMAL
3378
- INTEGER
3379
- JSON
3380
- STRING
3381
- TEXT
3382
- URL
3383
- }
3384
-
3385
3386
  """Monetary value with currency"""
3386
3387
  type Money {
3387
3388
  """Decimal money amount"""
@@ -3463,12 +3464,6 @@ type Mutation {
3463
3464
  """Logout customer (clears auth cookie)"""
3464
3465
  customerLogout: CustomerLogoutPayload!
3465
3466
 
3466
- """Bulk upsert własnych meta properties klienta (Bearer token wymagany)"""
3467
- customerMetaPropertiesSet(properties: [MetaPropertyInput!]!): MetaPropertiesSetPayload!
3468
-
3469
- """Usuwa pojedyncze meta property klienta po (namespace, key)"""
3470
- customerMetaPropertyDelete(key: String!, namespace: String!): MetaPropertyDeletePayload!
3471
-
3472
3467
  """Refresh access token"""
3473
3468
  customerRefreshToken: CustomerRefreshTokenPayload!
3474
3469
 
@@ -3576,6 +3571,9 @@ type Order implements Node {
3576
3571
  """
3577
3572
  accessToken: String!
3578
3573
 
3574
+ """Order custom field values (post-Opcja A unified custom fields)."""
3575
+ attributes(namespace: String): [EntityAttributeField!]!
3576
+
3579
3577
  """Czy storefront może zainicjować płatność dla tego order"""
3580
3578
  canCreatePayment: Boolean!
3581
3579
 
@@ -3602,12 +3600,6 @@ type Order implements Node {
3602
3600
  """
3603
3601
  lineItems(after: String, first: Int = 10): OrderLineItemConnection!
3604
3602
 
3605
- """Lista meta properties (Relay Connection)"""
3606
- metaProperties(first: Int = 10, namespace: String): MetaPropertyConnection!
3607
-
3608
- """Pojedyncze meta property po (namespace, key)"""
3609
- metaProperty(key: String!, namespace: String!): MetaProperty
3610
-
3611
3603
  """Order number (human-readable)"""
3612
3604
  orderNumber: String!
3613
3605
 
@@ -4014,6 +4006,11 @@ type Product implements Node {
4014
4006
  """Average rating (1-5)"""
4015
4007
  averageRating: Float
4016
4008
 
4009
+ """
4010
+ Canonical brand entity (Faza 3, 2026-05-17). Resolved via DataLoader (N+1 safe dla list queries). Null gdy product nie ma przypisanego brand_id.
4011
+ """
4012
+ brand: BrandSummary
4013
+
4017
4014
  """
4018
4015
  Wszystkie kategorie do których produkt należy (M2M przez ProductCategory junction). Posortowane po junction.sortOrder ASC. Empty list gdy produkt nie jest w żadnej kategorii.
4019
4016
  """
@@ -4056,12 +4053,6 @@ type Product implements Node {
4056
4053
  """
4057
4054
  isPurchasable: Boolean!
4058
4055
 
4059
- """Lista meta properties — Storefront API filtruje isPrivate=false"""
4060
- metaProperties(first: Int = 10, namespace: String): MetaPropertyConnection!
4061
-
4062
- """Pojedyncze meta property — Storefront API filtruje isPrivate=false"""
4063
- metaProperty(key: String!, namespace: String!): MetaProperty
4064
-
4065
4056
  """
4066
4057
  Per-product option definitions (Color, Size, …) with their available values. Use these to build a variant picker without aggregating `selectedOptions` manually.
4067
4058
  """
@@ -4109,7 +4100,9 @@ type Product implements Node {
4109
4100
  """Product variants (Relay Connection)"""
4110
4101
  variants(after: String, before: String, first: Float, last: Float): ProductVariantConnection!
4111
4102
 
4112
- """Vendor/brand name"""
4103
+ """
4104
+ Legacy vendor/brand name (free-text). Preferred: `brand` field (canonical Brand entity, Faza 3).
4105
+ """
4113
4106
  vendor: String
4114
4107
 
4115
4108
  """Catalog visibility (Faza 1.5) — PUBLIC | HIDDEN | BUNDLE_ONLY"""
@@ -4245,13 +4238,18 @@ type ProductEdge {
4245
4238
  """Single product filter"""
4246
4239
  input ProductFilter {
4247
4240
  """
4248
- DoSwiftly: dynamic attribute filters (configurator system). Product/variant metafield filters will be added in Wave 5.
4241
+ DoSwiftly: dynamic attribute filters (configurator + custom fields system). Optional `namespace` w AttributeFilterInput dyskryminuje per grupa atrybutów.
4249
4242
  """
4250
4243
  attributes: [AttributeFilterInput!]
4251
4244
 
4252
4245
  """Filter by availability for sale"""
4253
4246
  available: Boolean
4254
4247
 
4248
+ """
4249
+ Faza 3 (2026-05-17): filter by canonical Brand entity. Wybierz po `slug` (URL-stable) lub `id` (raw UUID). Multiple `brand` entries w `filters[]` array = OR semantics. Vendor (legacy free-text) wciąż wspierany via `productVendor`.
4250
+ """
4251
+ brand: BrandFilter
4252
+
4255
4253
  """Filter by category"""
4256
4254
  category: CategoryFilter
4257
4255
 
@@ -4437,6 +4435,9 @@ enum ProductTypeEnum {
4437
4435
 
4438
4436
  """Product variant - purchasable unit"""
4439
4437
  type ProductVariant {
4438
+ """Variant custom field values (post-Opcja A unified custom fields)."""
4439
+ attributes(namespace: String): [EntityAttributeField!]!
4440
+
4440
4441
  """
4441
4442
  Available stock (computed: stock - reserved). Null when track is disabled.
4442
4443
  """
@@ -4460,12 +4461,6 @@ type ProductVariant {
4460
4461
  """Whether variant is available for purchase"""
4461
4462
  isAvailable: Boolean!
4462
4463
 
4463
- """Lista meta properties — Storefront API filtruje isPrivate=false"""
4464
- metaProperties(first: Int = 10, namespace: String): MetaPropertyConnection!
4465
-
4466
- """Pojedyncze meta property — Storefront API filtruje isPrivate=false"""
4467
- metaProperty(key: String!, namespace: String!): MetaProperty
4468
-
4469
4464
  """Variant price (Money). Default field — industry-standard schema."""
4470
4465
  price: Money!
4471
4466