@doswiftly/cli 0.2.0 → 0.2.5

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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/bin/doswiftly.js +0 -0
  3. package/dist/commands/dev.d.ts +7 -1
  4. package/dist/commands/dev.d.ts.map +1 -1
  5. package/dist/commands/dev.js +110 -51
  6. package/dist/commands/dev.js.map +1 -1
  7. package/dist/commands/env.d.ts.map +1 -1
  8. package/dist/commands/env.js +11 -1
  9. package/dist/commands/env.js.map +1 -1
  10. package/dist/commands/init.js +2 -2
  11. package/dist/commands/inspect.d.ts.map +1 -1
  12. package/dist/commands/inspect.js +5 -1
  13. package/dist/commands/inspect.js.map +1 -1
  14. package/dist/commands/proxy.d.ts.map +1 -1
  15. package/dist/commands/proxy.js +7 -1
  16. package/dist/commands/proxy.js.map +1 -1
  17. package/dist/commands/template.js +1 -1
  18. package/dist/config/types.d.ts +8 -0
  19. package/dist/config/types.d.ts.map +1 -1
  20. package/dist/config/types.js.map +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/lib/api-url.d.ts +77 -8
  24. package/dist/lib/api-url.d.ts.map +1 -1
  25. package/dist/lib/api-url.js +54 -12
  26. package/dist/lib/api-url.js.map +1 -1
  27. package/dist/lib/config.d.ts +1 -0
  28. package/dist/lib/config.d.ts.map +1 -1
  29. package/dist/lib/config.js +9 -12
  30. package/dist/lib/config.js.map +1 -1
  31. package/dist/lib/i18n.d.ts +24 -0
  32. package/dist/lib/i18n.d.ts.map +1 -1
  33. package/dist/lib/i18n.js +48 -0
  34. package/dist/lib/i18n.js.map +1 -1
  35. package/dist/lib/proxy-server.d.ts +26 -0
  36. package/dist/lib/proxy-server.d.ts.map +1 -1
  37. package/dist/lib/proxy-server.js +59 -2
  38. package/dist/lib/proxy-server.js.map +1 -1
  39. package/package.json +10 -11
  40. package/templates/storefront-nextjs-shadcn/app/[locale]/products/[slug]/product-client.tsx +87 -2
  41. package/templates/storefront-nextjs-shadcn/app/[locale]/products/products-client.tsx +1 -1
  42. package/templates/storefront-nextjs-shadcn/codegen.ts +1 -1
  43. package/templates/storefront-nextjs-shadcn/components/cart/cart-item.tsx +18 -0
  44. package/templates/storefront-nextjs-shadcn/components/cart/cart-line.fragment.graphql +17 -1
  45. package/templates/storefront-nextjs-shadcn/components/home/collection-card.fragment.graphql +2 -1
  46. package/templates/storefront-nextjs-shadcn/components/layout/category-node.fragment.graphql +2 -1
  47. package/templates/storefront-nextjs-shadcn/components/product/add-to-cart-button.tsx +23 -3
  48. package/templates/storefront-nextjs-shadcn/components/product/filter-active-pills.tsx +1 -1
  49. package/templates/storefront-nextjs-shadcn/components/product/filter-mobile-sheet.tsx +1 -1
  50. package/templates/storefront-nextjs-shadcn/components/product/product-card.fragment.graphql +2 -1
  51. package/templates/storefront-nextjs-shadcn/components/product/product-configurator.fragment.graphql +43 -0
  52. package/templates/storefront-nextjs-shadcn/components/product/product-configurator.tsx +282 -0
  53. package/templates/storefront-nextjs-shadcn/components/product/product-detail.fragment.graphql +8 -1
  54. package/templates/storefront-nextjs-shadcn/components/product/product-image.tsx +7 -3
  55. package/templates/storefront-nextjs-shadcn/components/product/product-variant.fragment.graphql +2 -1
  56. package/templates/storefront-nextjs-shadcn/graphql/custom.example.graphql +100 -0
  57. package/templates/storefront-nextjs-shadcn/hooks/use-cart-actions.ts +26 -5
  58. package/templates/storefront-nextjs-shadcn/hooks/use-cart-sync.ts +26 -0
  59. package/templates/storefront-nextjs-shadcn/lib/image-loader.ts +12 -0
  60. package/templates/storefront-nextjs-shadcn/next.config.ts +6 -17
  61. package/templates/storefront-nextjs-shadcn/package.json +2 -2
@@ -0,0 +1,282 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProductConfigurator
5
+ *
6
+ * Renderuje pola konfiguratora produktu dla klienta końcowego — Product.attributes
7
+ * z fillingMode CUSTOMER lub BOTH. Każdy atrybut obsługuje:
8
+ *
9
+ * - SELECT / RADIO — lista opcji (1 wybór), opcje z dopłatą pokazują kwotę obok
10
+ * - CHECKBOX — lista opcji (wielokrotny wybór; Faza 2 stosuje MULTI_SELECT)
11
+ * - TEXT / TEXTAREA — wolny tekst (z walidacją minValue/maxValue jako min/max długości)
12
+ * - NUMBER — pole liczbowe z zakresem
13
+ * - DATE — date picker (natywny input type=date)
14
+ *
15
+ * Komponent jest kontrolowany: rodzic (product-client.tsx) trzyma mapę
16
+ * `Record<attributeDefinitionId, AttributeSelectionInput>` i mutacje koszyka
17
+ * przesyłają ją jako `cartLinesAdd.lines[i].attributeSelections`.
18
+ *
19
+ * Wymagane pola (`isRequired`) blokują AddToCart jeśli użytkownik nie wypełnił —
20
+ * walidacja odbywa się także po stronie serwera, ale local gate poprawia UX.
21
+ */
22
+
23
+ import { useMemo } from "react";
24
+ import { Label } from "@/components/ui/label";
25
+ import { Input } from "@/components/ui/input";
26
+ import { Textarea } from "@/components/ui/textarea";
27
+ import { Badge } from "@/components/ui/badge";
28
+ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
29
+ import { formatAmount } from "@doswiftly/storefront-sdk";
30
+ import type { ProductConfiguratorFieldsFragment } from "@/generated/graphql";
31
+ import { cn } from "@/lib/utils";
32
+
33
+ /**
34
+ * Single attribute selection (matches AttributeSelectionInput schema shape).
35
+ * Rodzic przekazuje Record<definitionId, ProductConfiguratorSelection>.
36
+ */
37
+ export interface ProductConfiguratorSelection {
38
+ attributeDefinitionId: string;
39
+ optionId?: string | null;
40
+ optionIds?: string[] | null;
41
+ textValue?: string | null;
42
+ }
43
+
44
+ export interface ProductConfiguratorProps {
45
+ attributes: readonly ProductConfiguratorFieldsFragment[];
46
+ selections: Record<string, ProductConfiguratorSelection>;
47
+ onChange: (definitionId: string, selection: ProductConfiguratorSelection) => void;
48
+ currency: string;
49
+ /** Podświetla pola wymagane bez wartości — przełączane przez rodzica po kliku AddToCart. */
50
+ showValidation?: boolean;
51
+ }
52
+
53
+ /**
54
+ * Compute total surcharge from current selections (for live price preview in parent).
55
+ * Zwraca tylko FIXED surcharge (PERCENT jest placeholderem Fazy 2 i nie jest pokazywany).
56
+ */
57
+ export function computeConfiguratorSurcharge(
58
+ attributes: readonly ProductConfiguratorFieldsFragment[],
59
+ selections: Record<string, ProductConfiguratorSelection>,
60
+ ): number {
61
+ let total = 0;
62
+ for (const def of attributes) {
63
+ const selection = selections[def.id];
64
+ if (!selection?.optionId) continue;
65
+ const option = def.options.find((opt) => opt.id === selection.optionId);
66
+ if (!option?.surchargeAmount || option.surchargeType !== "FIXED") continue;
67
+ total += option.surchargeAmount;
68
+ }
69
+ return total;
70
+ }
71
+
72
+ /**
73
+ * Detect unfilled required fields — rodzic używa przed wysłaniem cartLinesAdd.
74
+ * Zwraca tablicę nazw (dla toast/highlight), pustą gdy wszystko poprawnie wypełnione.
75
+ */
76
+ export function findMissingRequiredFields(
77
+ attributes: readonly ProductConfiguratorFieldsFragment[],
78
+ selections: Record<string, ProductConfiguratorSelection>,
79
+ ): string[] {
80
+ const missing: string[] = [];
81
+ for (const def of attributes) {
82
+ if (!def.isRequired) continue;
83
+ const selection = selections[def.id];
84
+ const isOptionType = def.type === "SELECT" || def.type === "RADIO" || def.type === "CHECKBOX";
85
+ const isTextType =
86
+ def.type === "TEXT" || def.type === "TEXTAREA" || def.type === "NUMBER" || def.type === "DATE";
87
+ if (isOptionType && !selection?.optionId && !selection?.optionIds?.length) {
88
+ missing.push(def.name);
89
+ } else if (isTextType && !selection?.textValue?.trim()) {
90
+ missing.push(def.name);
91
+ }
92
+ }
93
+ return missing;
94
+ }
95
+
96
+ export function ProductConfigurator({
97
+ attributes,
98
+ selections,
99
+ onChange,
100
+ currency,
101
+ showValidation = false,
102
+ }: ProductConfiguratorProps) {
103
+ const sortedAttributes = useMemo(
104
+ () => [...attributes].sort((a, b) => a.displayOrder - b.displayOrder),
105
+ [attributes],
106
+ );
107
+
108
+ if (sortedAttributes.length === 0) return null;
109
+
110
+ return (
111
+ <div className="space-y-5 rounded-lg border bg-muted/30 p-4">
112
+ <div>
113
+ <h3 className="text-sm font-semibold">Konfiguracja</h3>
114
+ <p className="text-xs text-muted-foreground">
115
+ Wybierz opcje zanim dodasz do koszyka. Dopłaty doliczymy do ceny lub wyszczególnimy
116
+ na fakturze zgodnie z ustawieniami sprzedawcy.
117
+ </p>
118
+ </div>
119
+
120
+ {sortedAttributes.map((def) => {
121
+ const selection = selections[def.id];
122
+ const isMissing =
123
+ showValidation && def.isRequired && isSelectionEmpty(def, selection);
124
+
125
+ return (
126
+ <div key={def.id} className={cn("space-y-2", isMissing && "rounded-md border border-destructive/40 p-2")}>
127
+ <div className="flex items-start justify-between gap-2">
128
+ <Label className="text-sm font-medium">
129
+ {def.name}
130
+ {def.isRequired && <span className="ml-1 text-destructive">*</span>}
131
+ </Label>
132
+ {def.billingMode === "SEPARATE_LINE" && (
133
+ <Badge variant="secondary" className="text-[10px]">
134
+ osobna pozycja faktury
135
+ </Badge>
136
+ )}
137
+ </div>
138
+ {def.description && (
139
+ <p className="text-xs text-muted-foreground">{def.description}</p>
140
+ )}
141
+
142
+ <ConfiguratorField
143
+ definition={def}
144
+ selection={selection}
145
+ currency={currency}
146
+ onChange={onChange}
147
+ />
148
+
149
+ {isMissing && (
150
+ <p className="text-xs text-destructive">Pole wymagane — wypełnij, żeby dodać do koszyka.</p>
151
+ )}
152
+ </div>
153
+ );
154
+ })}
155
+ </div>
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Per-type rendering delegate. Trzyma logikę switch w jednym miejscu żeby
161
+ * `ProductConfigurator` nie rozrastał się ponad wzorzec "list + row".
162
+ */
163
+ function ConfiguratorField({
164
+ definition,
165
+ selection,
166
+ currency,
167
+ onChange,
168
+ }: {
169
+ definition: ProductConfiguratorFieldsFragment;
170
+ selection?: ProductConfiguratorSelection;
171
+ currency: string;
172
+ onChange: ProductConfiguratorProps["onChange"];
173
+ }) {
174
+ const updateOption = (optionId: string) => {
175
+ onChange(definition.id, {
176
+ attributeDefinitionId: definition.id,
177
+ optionId,
178
+ });
179
+ };
180
+
181
+ const updateText = (textValue: string) => {
182
+ onChange(definition.id, {
183
+ attributeDefinitionId: definition.id,
184
+ textValue,
185
+ });
186
+ };
187
+
188
+ switch (definition.type) {
189
+ case "SELECT":
190
+ case "RADIO":
191
+ return (
192
+ <RadioGroup
193
+ value={selection?.optionId ?? ""}
194
+ onValueChange={updateOption}
195
+ className="space-y-1"
196
+ >
197
+ {definition.options.map((opt) => (
198
+ <label
199
+ key={opt.id}
200
+ className="flex cursor-pointer items-center justify-between gap-3 rounded-md border bg-background px-3 py-2 text-sm hover:bg-muted/50"
201
+ >
202
+ <span className="flex items-center gap-2">
203
+ <RadioGroupItem value={opt.id} id={`${definition.id}-${opt.id}`} />
204
+ <span>{opt.label}</span>
205
+ </span>
206
+ {typeof opt.surchargeAmount === "number" && opt.surchargeAmount > 0 && (
207
+ <span className="text-xs font-medium text-muted-foreground">
208
+ +{formatAmount(opt.surchargeAmount / 100, currency)}
209
+ </span>
210
+ )}
211
+ </label>
212
+ ))}
213
+ </RadioGroup>
214
+ );
215
+
216
+ case "CHECKBOX":
217
+ // Faza 1 obsługuje CHECKBOX jako pojedynczy wybór (backend przyjmuje optionId).
218
+ // MULTI_SELECT z optionIds[] jest w Fazie 2.
219
+ return (
220
+ <RadioGroup
221
+ value={selection?.optionId ?? ""}
222
+ onValueChange={updateOption}
223
+ className="space-y-1"
224
+ >
225
+ {definition.options.map((opt) => (
226
+ <label
227
+ key={opt.id}
228
+ className="flex cursor-pointer items-center justify-between gap-3 rounded-md border bg-background px-3 py-2 text-sm hover:bg-muted/50"
229
+ >
230
+ <span className="flex items-center gap-2">
231
+ <RadioGroupItem value={opt.id} id={`${definition.id}-${opt.id}`} />
232
+ <span>{opt.label}</span>
233
+ </span>
234
+ {typeof opt.surchargeAmount === "number" && opt.surchargeAmount > 0 && (
235
+ <span className="text-xs font-medium text-muted-foreground">
236
+ +{formatAmount(opt.surchargeAmount / 100, currency)}
237
+ </span>
238
+ )}
239
+ </label>
240
+ ))}
241
+ </RadioGroup>
242
+ );
243
+
244
+ case "TEXT":
245
+ case "NUMBER":
246
+ case "DATE":
247
+ return (
248
+ <Input
249
+ type={definition.type === "NUMBER" ? "number" : definition.type === "DATE" ? "date" : "text"}
250
+ value={selection?.textValue ?? ""}
251
+ onChange={(e) => updateText(e.target.value)}
252
+ min={definition.type === "NUMBER" ? definition.minValue ?? undefined : undefined}
253
+ max={definition.type === "NUMBER" ? definition.maxValue ?? undefined : undefined}
254
+ placeholder={definition.description ?? ""}
255
+ />
256
+ );
257
+
258
+ case "TEXTAREA":
259
+ return (
260
+ <Textarea
261
+ value={selection?.textValue ?? ""}
262
+ onChange={(e) => updateText(e.target.value)}
263
+ rows={3}
264
+ placeholder={definition.description ?? ""}
265
+ />
266
+ );
267
+
268
+ default:
269
+ // COLOR / IMAGE / FILE / BOOLEAN / CURRENCY — Faza 2+ / admin-only; nie renderujemy.
270
+ return null;
271
+ }
272
+ }
273
+
274
+ function isSelectionEmpty(
275
+ definition: ProductConfiguratorFieldsFragment,
276
+ selection: ProductConfiguratorSelection | undefined,
277
+ ): boolean {
278
+ if (!selection) return true;
279
+ const optionType = definition.type === "SELECT" || definition.type === "RADIO" || definition.type === "CHECKBOX";
280
+ if (optionType) return !selection.optionId;
281
+ return !selection.textValue?.trim();
282
+ }
@@ -28,10 +28,11 @@ fragment ProductDetailFields on Product {
28
28
  description
29
29
  }
30
30
  images(first: 20) {
31
- url
31
+ url(transform: { maxWidth: 1600 })
32
32
  altText
33
33
  width
34
34
  height
35
+ thumbhash
35
36
  }
36
37
  variants(first: 100) {
37
38
  ...ProductVariantFields
@@ -49,4 +50,10 @@ fragment ProductDetailFields on Product {
49
50
  currencyCode
50
51
  }
51
52
  }
53
+ # Customer-facing configurator fields (CUSTOMER + BOTH filling modes).
54
+ # Rendered by components/product/product-configurator.tsx on the product page.
55
+ # MERCHANT-filled fields (product metadata) are intentionally excluded.
56
+ attributes(filter: { fillingMode: "CUSTOMER" }) {
57
+ ...ProductConfiguratorFields
58
+ }
52
59
  }
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useState, useMemo } from "react";
4
4
  import Image from "next/image";
5
5
  import { cn } from "@/lib/utils";
6
- import type { ImageData } from "@doswiftly/storefront-sdk";
6
+ import { type ImageData, thumbHashToDataURL } from "@doswiftly/storefront-sdk";
7
7
 
8
8
  export type ProductImageData = ImageData;
9
9
 
@@ -111,7 +111,10 @@ export function ProductImage({
111
111
 
112
112
  const imageAlt = alt || image.altText || "Product image";
113
113
 
114
- // Global loaderFile (next.config.ts) handles imgproxy resizeno per-component loader needed
114
+ // Decode ThumbHash to data URL for instant blur placeholder (memoized decode once per image)
115
+ const blurDataURL = useMemo(() => thumbHashToDataURL(image.thumbhash), [image.thumbhash]);
116
+
117
+ // CDN URL already has correct transform params from GraphQL — passthrough loader skips /_next/image
115
118
  const commonProps = {
116
119
  src: image.url,
117
120
  alt: imageAlt,
@@ -121,6 +124,7 @@ export function ProductImage({
121
124
  className
122
125
  ),
123
126
  onError: () => setHasError(true),
127
+ ...(blurDataURL ? { placeholder: "blur" as const, blurDataURL } : {}),
124
128
  };
125
129
 
126
130
  if (fill) {
@@ -39,10 +39,11 @@ fragment ProductVariantFields on ProductVariant {
39
39
  currencyCode
40
40
  }
41
41
  image {
42
- url
42
+ url(transform: { maxWidth: 300 })
43
43
  altText
44
44
  width
45
45
  height
46
+ thumbhash
46
47
  }
47
48
  selectedOptions {
48
49
  name
@@ -101,6 +101,106 @@ query SearchProductsWithFilters(
101
101
  }
102
102
  }
103
103
 
104
+ # ============================================================================
105
+ # BOPIS (Buy Online, Pick Up In Store) — per-location stock availability
106
+ # ============================================================================
107
+ #
108
+ # For shops with multiple active locations (warehouses, physical stores, fulfillment
109
+ # centers) the `ProductVariant.storeAvailability` field exposes per-location stock
110
+ # with `near` proximity sort, `locationType` filter, and the
111
+ # `@inContext(preferredLocationId)` operation directive.
112
+ #
113
+ # Returns `null` for single-location shops (backward-compat shortcut — the storefront
114
+ # can skip rendering the store picker UI entirely without an extra round-trip).
115
+ #
116
+ # Token gating:
117
+ # - `available: Boolean!` and `pickUpTime: String` are public.
118
+ # - `quantityAvailable: Int` is null for anonymous requests, Int for authenticated
119
+ # customers (send the `x-customer-access-token` header).
120
+ #
121
+ # Built-in query: `ProductStoreAvailability($handle, $id)` from
122
+ # @doswiftly/storefront-operations also works — this example shows how to write a
123
+ # BOPIS-focused variant with proximity + preferred-location pinning.
124
+
125
+ # Example: proximity-sorted pickup points near the customer, with a preferred location
126
+ # pinned to the top via the @inContext directive.
127
+ query GetProductStoreAvailability(
128
+ $handle: String!
129
+ $near: GeoCoordinateInput
130
+ $preferredLocationId: ID
131
+ ) @inContext(preferredLocationId: $preferredLocationId) {
132
+ product(handle: $handle) {
133
+ id
134
+ handle
135
+ title
136
+ variants {
137
+ id
138
+ title
139
+ sku
140
+ available
141
+ quantityAvailable
142
+ storeAvailability(first: 10, near: $near, locationType: STORE) {
143
+ totalCount
144
+ pageInfo { hasNextPage endCursor }
145
+ edges {
146
+ cursor
147
+ node {
148
+ available
149
+ quantityAvailable # null for anonymous, Int for authenticated
150
+ pickUpTime # localized, e.g. "Usually ready in 2 hours"
151
+ location {
152
+ id
153
+ name
154
+ pickupEnabled
155
+ timezone
156
+ pickupInstructions
157
+ address { city country latitude longitude formatted }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ # Example: store picker — list pickup-enabled locations sorted by distance.
167
+ query GetPickupLocations($near: GeoCoordinateInput) {
168
+ locations(first: 20, hasPickupEnabled: true, near: $near) {
169
+ totalCount
170
+ pageInfo { hasNextPage endCursor }
171
+ edges {
172
+ cursor
173
+ node {
174
+ id
175
+ name
176
+ pickupEnabled
177
+ address { city formatted }
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ # Example usage in component:
184
+ #
185
+ # 'use client';
186
+ # import { useGraphQLQuery } from '@/lib/graphql/hooks';
187
+ # import { GetProductStoreAvailabilityDocument } from '@/generated/graphql';
188
+ #
189
+ # function StorePickup({ handle, userCoords, preferredLocationId }: Props) {
190
+ # const { data } = useGraphQLQuery(GetProductStoreAvailabilityDocument, {
191
+ # handle,
192
+ # near: userCoords, // { latitude, longitude } or undefined
193
+ # preferredLocationId,
194
+ # });
195
+ # const conn = data?.product?.variants?.[0]?.storeAvailability;
196
+ # if (!conn) return <p>Online only</p>;
197
+ # return conn.edges.map(({ node }) => (
198
+ # <div key={node.location.id}>
199
+ # {node.location.name}: {node.available ? (node.pickUpTime ?? 'Available') : 'Out of stock'}
200
+ # </div>
201
+ # ));
202
+ # }
203
+
104
204
  # Get collection with products (inline — useful when collection page needs custom fields)
105
205
  query GetCollectionWithProducts(
106
206
  $handle: String!
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useCallback, useEffect, useRef } from 'react';
4
+ import type { CartAttributeSelectionInput } from '@doswiftly/storefront-sdk';
4
5
  import { useCartStore, useCartStoreApi } from '@/stores/cart-store';
5
6
 
6
7
  // Debounce delay for quantity updates (prevents rate limiting)
@@ -26,12 +27,32 @@ export function useCartActions() {
26
27
  const updateTimeoutRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
27
28
 
28
29
  /**
29
- * Add item to cart by variant ID, delegates to SDK store.
30
+ * Add item to cart by variant ID.
31
+ *
32
+ * Optional `attributeSelections` przekazywane do backendu jako konfigurator
33
+ * (Faza 1) — np. wybór Finiszera w drukarce. Backend waliduje + snapshotuje
34
+ * surcharge/taxRate; server odpowiada userErrors jeśli brak wymaganego pola
35
+ * (catch błędu w UI obsługuje `onMutationError` w CartProvider).
30
36
  */
31
- const addToCart = useCallback(async (variantId: string, quantity = 1) => {
32
- await api.getState().addToCart([{ merchandiseId: variantId, quantity }]);
33
- api.getState().openCart();
34
- }, [api]);
37
+ const addToCart = useCallback(
38
+ async (
39
+ variantId: string,
40
+ quantity = 1,
41
+ attributeSelections?: CartAttributeSelectionInput[],
42
+ ) => {
43
+ await api.getState().addToCart([
44
+ {
45
+ merchandiseId: variantId,
46
+ quantity,
47
+ ...(attributeSelections && attributeSelections.length > 0
48
+ ? { attributeSelections }
49
+ : {}),
50
+ },
51
+ ]);
52
+ api.getState().openCart();
53
+ },
54
+ [api],
55
+ );
35
56
 
36
57
  /**
37
58
  * Execute the actual quantity update via SDK store.
@@ -6,6 +6,21 @@ import { useCartStore } from "@/stores/cart-store";
6
6
  import { useHydrated } from "@doswiftly/storefront-sdk/react";
7
7
  import type { CartLineFields } from "@/lib/graphql/fragments";
8
8
 
9
+ /**
10
+ * Per-line attribute selection surfaced to cart UI (configurator choices).
11
+ * Mirroruje CartLine.attributeSelections z GraphQL, ale ograniczony do pól
12
+ * potrzebnych do wyświetlania — surchargeAmount kumulowany w cost.totalAmount
13
+ * (BUNDLED) lub emitowany jako child OrderItem po checkout (SEPARATE_LINE).
14
+ */
15
+ export interface CartItemAttributeSelection {
16
+ attributeDefinitionId: string;
17
+ attributeName: string;
18
+ optionLabel?: string | null;
19
+ textValue?: string | null;
20
+ billingMode?: string | null;
21
+ surchargeAmount: number;
22
+ }
23
+
9
24
  /**
10
25
  * Mapped cart item for display components.
11
26
  * Server is the single source of truth — no client-side items[].
@@ -22,6 +37,8 @@ export interface CartItemData {
22
37
  price: { amount: string; currencyCode: string };
23
38
  image?: { url: string; altText?: string | null } | null;
24
39
  available: boolean;
40
+ /** Faza 1 — customer configurator selections snapshot (empty when no configurator). */
41
+ attributeSelections: CartItemAttributeSelection[];
25
42
  }
26
43
 
27
44
  /**
@@ -78,6 +95,15 @@ export function useCartSync() {
78
95
  },
79
96
  image: line.merchandise.image || null,
80
97
  available: line.merchandise.available,
98
+ // Faza 1 — configurator selections (backend snapshot).
99
+ attributeSelections: (line.attributeSelections ?? []).map((sel) => ({
100
+ attributeDefinitionId: sel.attributeDefinitionId,
101
+ attributeName: sel.attributeName,
102
+ optionLabel: sel.optionLabel ?? null,
103
+ textValue: sel.textValue ?? null,
104
+ billingMode: sel.billingMode ?? null,
105
+ surchargeAmount: sel.surchargeAmount,
106
+ })),
81
107
  };
82
108
  });
83
109
 
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Passthrough image loader for Next.js.
3
+ *
4
+ * GraphQL API returns ready-to-use CDN URLs with transform query params
5
+ * (e.g., ?width=300). imgproxy auto-negotiates AVIF/WEBP from Accept header.
6
+ *
7
+ * No re-optimization needed — skips /_next/image proxy entirely.
8
+ * Browser loads directly from CDN (one hop, zero double-compression).
9
+ */
10
+ export default function doswiftlyLoader({ src }: { src: string }): string {
11
+ return src;
12
+ }
@@ -7,24 +7,13 @@ const nextConfig: NextConfig = {
7
7
  // Enable React strict mode for better development experience
8
8
  reactStrictMode: true,
9
9
 
10
- // Image optimization — GraphQL API returns ready-to-use CDN URLs.
11
- // Use url(transform: { maxWidth: 800 }) in queries. No client-side loader needed.
10
+ // Image optimization — GraphQL API returns ready-to-use CDN URLs with transform params.
11
+ // url(transform: { maxWidth: 800 }) in queries imgproxy serves correct size.
12
+ // imgproxy auto-negotiates AVIF/WEBP from Accept header (IMGPROXY_AUTO_AVIF=true).
13
+ // Passthrough loader skips /_next/image proxy — browser loads directly from CDN.
12
14
  images: {
13
- remotePatterns: [
14
- {
15
- protocol: 'https',
16
- hostname: '**.doswiftly.pl',
17
- },
18
- {
19
- protocol: 'https',
20
- hostname: 'images.unsplash.com',
21
- },
22
- {
23
- protocol: 'http',
24
- hostname: 'app.localhost',
25
- port: '8000',
26
- },
27
- ],
15
+ loader: 'custom',
16
+ loaderFile: './lib/image-loader.ts',
28
17
  dangerouslyAllowSVG: true,
29
18
  contentDispositionType: 'attachment',
30
19
  },
@@ -15,7 +15,7 @@
15
15
  "@doswiftly/storefront-operations": "{{STOREFRONT_OPS_VERSION}}",
16
16
  "@doswiftly/storefront-sdk": "^4.0.0",
17
17
  "@tanstack/react-query": "^5.62.0",
18
- "next": "^16.1.7",
18
+ "next": "^16.2.3",
19
19
  "react": "^19",
20
20
  "react-dom": "^19",
21
21
  "lucide-react": "latest",
@@ -34,7 +34,7 @@
34
34
  "zustand": "^5.0.2",
35
35
  "zod": "^3.23.8",
36
36
  "sonner": "^1.7.1",
37
- "next-intl": "^4.1.0",
37
+ "next-intl": "^4.9.1",
38
38
  "next-themes": "^0.4.4",
39
39
  "react-hook-form": "^7.55.0",
40
40
  "@hookform/resolvers": "^5.2.0"