@doswiftly/cli 0.2.1 → 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.
- package/README.md +1 -1
- package/bin/doswiftly.js +0 -0
- package/dist/commands/dev.d.ts +7 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +110 -51
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +11 -1
- package/dist/commands/env.js.map +1 -1
- package/dist/commands/init.js +2 -2
- package/dist/commands/inspect.d.ts.map +1 -1
- package/dist/commands/inspect.js +5 -1
- package/dist/commands/inspect.js.map +1 -1
- package/dist/commands/proxy.d.ts.map +1 -1
- package/dist/commands/proxy.js +7 -1
- package/dist/commands/proxy.js.map +1 -1
- package/dist/commands/template.js +1 -1
- package/dist/config/types.d.ts +8 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/api-url.d.ts +77 -8
- package/dist/lib/api-url.d.ts.map +1 -1
- package/dist/lib/api-url.js +54 -12
- package/dist/lib/api-url.js.map +1 -1
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +9 -12
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/i18n.d.ts +24 -0
- package/dist/lib/i18n.d.ts.map +1 -1
- package/dist/lib/i18n.js +48 -0
- package/dist/lib/i18n.js.map +1 -1
- package/dist/lib/proxy-server.d.ts +26 -0
- package/dist/lib/proxy-server.d.ts.map +1 -1
- package/dist/lib/proxy-server.js +57 -0
- package/dist/lib/proxy-server.js.map +1 -1
- package/package.json +10 -11
- package/templates/storefront-nextjs-shadcn/app/[locale]/products/[slug]/product-client.tsx +87 -2
- package/templates/storefront-nextjs-shadcn/app/[locale]/products/products-client.tsx +1 -1
- package/templates/storefront-nextjs-shadcn/codegen.ts +1 -1
- package/templates/storefront-nextjs-shadcn/components/cart/cart-item.tsx +18 -0
- package/templates/storefront-nextjs-shadcn/components/cart/cart-line.fragment.graphql +17 -1
- package/templates/storefront-nextjs-shadcn/components/home/collection-card.fragment.graphql +2 -1
- package/templates/storefront-nextjs-shadcn/components/layout/category-node.fragment.graphql +2 -1
- package/templates/storefront-nextjs-shadcn/components/product/add-to-cart-button.tsx +23 -3
- package/templates/storefront-nextjs-shadcn/components/product/filter-active-pills.tsx +1 -1
- package/templates/storefront-nextjs-shadcn/components/product/filter-mobile-sheet.tsx +1 -1
- package/templates/storefront-nextjs-shadcn/components/product/product-card.fragment.graphql +2 -1
- package/templates/storefront-nextjs-shadcn/components/product/product-configurator.fragment.graphql +43 -0
- package/templates/storefront-nextjs-shadcn/components/product/product-configurator.tsx +282 -0
- package/templates/storefront-nextjs-shadcn/components/product/product-detail.fragment.graphql +8 -1
- package/templates/storefront-nextjs-shadcn/components/product/product-image.tsx +7 -3
- package/templates/storefront-nextjs-shadcn/components/product/product-variant.fragment.graphql +2 -1
- package/templates/storefront-nextjs-shadcn/graphql/custom.example.graphql +100 -0
- package/templates/storefront-nextjs-shadcn/hooks/use-cart-actions.ts +26 -5
- package/templates/storefront-nextjs-shadcn/hooks/use-cart-sync.ts +26 -0
- package/templates/storefront-nextjs-shadcn/lib/image-loader.ts +12 -0
- package/templates/storefront-nextjs-shadcn/next.config.ts +6 -17
- 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
|
+
}
|
package/templates/storefront-nextjs-shadcn/components/product/product-detail.fragment.graphql
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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) {
|
|
@@ -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
|
|
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(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
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"
|