@doswiftly/cli 0.1.23 → 0.2.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.
Files changed (98) hide show
  1. package/dist/commands/check.js +2 -2
  2. package/dist/commands/deploy.d.ts.map +1 -1
  3. package/dist/commands/deploy.js +34 -7
  4. package/dist/commands/deploy.js.map +1 -1
  5. package/dist/commands/dev.d.ts +13 -0
  6. package/dist/commands/dev.d.ts.map +1 -1
  7. package/dist/commands/dev.js +155 -63
  8. package/dist/commands/dev.js.map +1 -1
  9. package/dist/commands/doctor.d.ts.map +1 -1
  10. package/dist/commands/doctor.js +3 -4
  11. package/dist/commands/doctor.js.map +1 -1
  12. package/dist/commands/init.d.ts.map +1 -1
  13. package/dist/commands/init.js +271 -166
  14. package/dist/commands/init.js.map +1 -1
  15. package/dist/commands/sdk.d.ts +1 -1
  16. package/dist/commands/sdk.js +3 -3
  17. package/dist/commands/sdk.js.map +1 -1
  18. package/dist/commands/template.d.ts.map +1 -1
  19. package/dist/commands/template.js +4 -31
  20. package/dist/commands/template.js.map +1 -1
  21. package/dist/commands/verify.js +5 -5
  22. package/dist/commands/verify.js.map +1 -1
  23. package/dist/index.js +2 -3
  24. package/dist/index.js.map +1 -1
  25. package/dist/lib/i18n.d.ts +12 -0
  26. package/dist/lib/i18n.d.ts.map +1 -1
  27. package/dist/lib/i18n.js +24 -0
  28. package/dist/lib/i18n.js.map +1 -1
  29. package/dist/lib/proxy-server.d.ts +22 -6
  30. package/dist/lib/proxy-server.d.ts.map +1 -1
  31. package/dist/lib/proxy-server.js +174 -75
  32. package/dist/lib/proxy-server.js.map +1 -1
  33. package/package.json +1 -1
  34. package/dist/commands/types.d.ts +0 -5
  35. package/dist/commands/types.d.ts.map +0 -1
  36. package/dist/commands/types.js +0 -82
  37. package/dist/commands/types.js.map +0 -1
  38. package/templates/storefront-minimal/.env.example +0 -10
  39. package/templates/storefront-minimal/.github/workflows/build-template.yml +0 -119
  40. package/templates/storefront-minimal/app/globals.css +0 -18
  41. package/templates/storefront-minimal/app/layout.tsx +0 -26
  42. package/templates/storefront-minimal/app/page.tsx +0 -93
  43. package/templates/storefront-minimal/lib/graphql-client.ts +0 -23
  44. package/templates/storefront-minimal/next.config.ts +0 -15
  45. package/templates/storefront-minimal/open-next.config.ts +0 -3
  46. package/templates/storefront-minimal/package.json +0 -30
  47. package/templates/storefront-minimal/postcss.config.mjs +0 -5
  48. package/templates/storefront-minimal/tailwind.config.ts +0 -14
  49. package/templates/storefront-minimal/tsconfig.json +0 -27
  50. package/templates/storefront-minimal/wrangler.toml +0 -24
  51. package/templates/storefront-nextjs/.env.example +0 -68
  52. package/templates/storefront-nextjs/.github/workflows/build-template.yml +0 -119
  53. package/templates/storefront-nextjs/README.md +0 -524
  54. package/templates/storefront-nextjs/app/account/orders/page.tsx +0 -216
  55. package/templates/storefront-nextjs/app/account/page.tsx +0 -167
  56. package/templates/storefront-nextjs/app/auth/login/page.tsx +0 -135
  57. package/templates/storefront-nextjs/app/auth/register/page.tsx +0 -212
  58. package/templates/storefront-nextjs/app/cart/page.tsx +0 -263
  59. package/templates/storefront-nextjs/app/categories/[slug]/page.tsx +0 -200
  60. package/templates/storefront-nextjs/app/categories/page.tsx +0 -58
  61. package/templates/storefront-nextjs/app/checkout/page.tsx +0 -351
  62. package/templates/storefront-nextjs/app/collections/[slug]/page.tsx +0 -158
  63. package/templates/storefront-nextjs/app/collections/page.tsx +0 -61
  64. package/templates/storefront-nextjs/app/globals.css +0 -98
  65. package/templates/storefront-nextjs/app/layout.tsx +0 -39
  66. package/templates/storefront-nextjs/app/page.tsx +0 -136
  67. package/templates/storefront-nextjs/app/products/[slug]/page.tsx +0 -119
  68. package/templates/storefront-nextjs/app/products/page.tsx +0 -107
  69. package/templates/storefront-nextjs/app/search/page.tsx +0 -127
  70. package/templates/storefront-nextjs/components/auth/auth-guard.tsx +0 -94
  71. package/templates/storefront-nextjs/components/commerce/add-to-cart-button.tsx +0 -77
  72. package/templates/storefront-nextjs/components/commerce/cart-icon.tsx +0 -29
  73. package/templates/storefront-nextjs/components/commerce/currency-selector.tsx +0 -217
  74. package/templates/storefront-nextjs/components/commerce/pagination.tsx +0 -62
  75. package/templates/storefront-nextjs/components/commerce/product-actions.tsx +0 -135
  76. package/templates/storefront-nextjs/components/commerce/product-filters.tsx +0 -109
  77. package/templates/storefront-nextjs/components/commerce/product-price.tsx +0 -375
  78. package/templates/storefront-nextjs/components/commerce/search-input.tsx +0 -178
  79. package/templates/storefront-nextjs/components/commerce/sort-select.tsx +0 -64
  80. package/templates/storefront-nextjs/components/commerce/variant-selector.tsx +0 -210
  81. package/templates/storefront-nextjs/components/layout/footer.tsx +0 -107
  82. package/templates/storefront-nextjs/components/layout/header.tsx +0 -104
  83. package/templates/storefront-nextjs/components/providers.tsx +0 -62
  84. package/templates/storefront-nextjs/lib/auth/routes.ts +0 -52
  85. package/templates/storefront-nextjs/lib/currency.tsx +0 -140
  86. package/templates/storefront-nextjs/lib/format.ts +0 -159
  87. package/templates/storefront-nextjs/lib/graphql-queries.ts +0 -629
  88. package/templates/storefront-nextjs/lib/hooks.ts +0 -30
  89. package/templates/storefront-nextjs/middleware.ts +0 -80
  90. package/templates/storefront-nextjs/next.config.ts +0 -37
  91. package/templates/storefront-nextjs/open-next.config.ts +0 -3
  92. package/templates/storefront-nextjs/package.dev.json +0 -30
  93. package/templates/storefront-nextjs/package.json +0 -32
  94. package/templates/storefront-nextjs/package.json.template +0 -32
  95. package/templates/storefront-nextjs/postcss.config.mjs +0 -8
  96. package/templates/storefront-nextjs/tailwind.config.ts +0 -111
  97. package/templates/storefront-nextjs/tsconfig.json +0 -27
  98. package/templates/storefront-nextjs/wrangler.toml +0 -24
@@ -1,109 +0,0 @@
1
- "use client";
2
-
3
- import { useRouter, useSearchParams, usePathname } from "next/navigation";
4
- import { useCallback } from "react";
5
- import { ProductSortKeys } from "@doswiftly/storefront-sdk/graphql";
6
-
7
- interface Category {
8
- id: string;
9
- name: string;
10
- slug: string;
11
- }
12
-
13
- interface ProductFiltersProps {
14
- categories: Category[];
15
- }
16
-
17
- /**
18
- * ProductFilters - URL-based filtering for products
19
- *
20
- * Uses Next.js router to update URL search params.
21
- * Server Component re-fetches data automatically.
22
- */
23
- export function ProductFilters({ categories }: ProductFiltersProps) {
24
- const router = useRouter();
25
- const pathname = usePathname();
26
- const searchParams = useSearchParams();
27
-
28
- // Get current filter values
29
- const currentCategory = searchParams.get("category") || "";
30
- const currentSort = searchParams.get("sort") || ProductSortKeys.CreatedAt;
31
- const currentOrder = searchParams.get("order") || "desc";
32
-
33
- // Update URL with new params
34
- const updateParams = useCallback(
35
- (updates: Record<string, string | null>) => {
36
- const params = new URLSearchParams(searchParams.toString());
37
-
38
- Object.entries(updates).forEach(([key, value]) => {
39
- if (value === null || value === "") {
40
- params.delete(key);
41
- } else {
42
- params.set(key, value);
43
- }
44
- });
45
-
46
- // Reset pagination when filters change
47
- params.delete("after");
48
-
49
- router.push(`${pathname}?${params.toString()}`);
50
- },
51
- [router, pathname, searchParams]
52
- );
53
-
54
- const handleCategoryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
55
- updateParams({ category: e.target.value || null });
56
- };
57
-
58
- const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
59
- const value = e.target.value;
60
- // Parse combined sort value (e.g., "PRICE_asc" or "CREATED_AT_desc")
61
- const [sortKey, order] = value.includes("_")
62
- ? value.split("_")
63
- : [value, "desc"];
64
- updateParams({ sort: sortKey, order });
65
- };
66
-
67
- // Combined sort value for select
68
- const sortValue =
69
- currentSort === ProductSortKeys.Price && currentOrder === "asc"
70
- ? "PRICE_asc"
71
- : currentSort === ProductSortKeys.Price && currentOrder === "desc"
72
- ? "PRICE_desc"
73
- : currentSort;
74
-
75
- return (
76
- <div className="mb-8 flex flex-wrap items-center justify-between gap-4">
77
- {/* Category Filter */}
78
- <div className="flex gap-2">
79
- <select
80
- className="input w-auto"
81
- value={currentCategory}
82
- onChange={handleCategoryChange}
83
- >
84
- <option value="">All Categories</option>
85
- {categories.map((cat) => (
86
- <option key={cat.id} value={cat.slug}>
87
- {cat.name}
88
- </option>
89
- ))}
90
- </select>
91
- </div>
92
-
93
- {/* Sort */}
94
- <div className="flex gap-2">
95
- <select
96
- className="input w-auto"
97
- value={sortValue}
98
- onChange={handleSortChange}
99
- >
100
- <option value={ProductSortKeys.CreatedAt}>Newest</option>
101
- <option value="PRICE_asc">Price: Low to High</option>
102
- <option value="PRICE_desc">Price: High to Low</option>
103
- <option value={ProductSortKeys.Title}>Name: A-Z</option>
104
- <option value={ProductSortKeys.BestSelling}>Most Popular</option>
105
- </select>
106
- </div>
107
- </div>
108
- );
109
- }
@@ -1,375 +0,0 @@
1
- "use client";
2
-
3
- import { useMemo } from "react";
4
- import { formatPrice, formatAmount } from "@/lib/currency";
5
- import type {
6
- PriceMoney,
7
- Money,
8
- ConvertedPriceRange,
9
- ProductPriceRange,
10
- } from "@doswiftly/storefront-sdk/graphql";
11
-
12
- // ============================================================================
13
- // TYPES - Using SDK types (SSOT)
14
- // ============================================================================
15
-
16
- // Re-export types that might not be directly available
17
- type PriceRange = ConvertedPriceRange;
18
- type OriginalPriceRange = ProductPriceRange;
19
-
20
- /**
21
- * Product variant with price
22
- */
23
- interface ProductVariantPrice {
24
- id: string;
25
- title: string;
26
- price: PriceMoney;
27
- originalPrice?: Money;
28
- compareAtPrice?: PriceMoney;
29
- originalCompareAtPrice?: Money;
30
- available: boolean;
31
- }
32
-
33
- interface ProductPriceProps {
34
- /**
35
- * Price range from product (converted prices)
36
- * Use for product listing/cards
37
- */
38
- priceRange?: PriceRange | null;
39
-
40
- /**
41
- * Original price range (shop base currency)
42
- * Optional - shown as reference when showOriginal=true
43
- */
44
- originalPriceRange?: OriginalPriceRange | null;
45
-
46
- /**
47
- * Single variant price (converted)
48
- * Use for product detail page with selected variant
49
- */
50
- variantPrice?: PriceMoney | null;
51
-
52
- /**
53
- * Original variant price (shop base currency)
54
- */
55
- originalVariantPrice?: Money | null;
56
-
57
- /**
58
- * Compare at price (for sales)
59
- */
60
- compareAtPrice?: PriceMoney | null;
61
-
62
- /**
63
- * Show original price in base currency
64
- */
65
- showOriginal?: boolean;
66
-
67
- /**
68
- * Show exchange rate info
69
- */
70
- showExchangeRate?: boolean;
71
-
72
- /**
73
- * Show "from" prefix for price ranges
74
- */
75
- showFromPrefix?: boolean;
76
-
77
- /**
78
- * Show strikethrough compare at price
79
- */
80
- showCompareAt?: boolean;
81
-
82
- /**
83
- * Custom className
84
- */
85
- className?: string;
86
-
87
- /**
88
- * Size variant
89
- */
90
- size?: "sm" | "md" | "lg" | "xl";
91
- }
92
-
93
- // ============================================================================
94
- // COMPONENT
95
- // ============================================================================
96
-
97
- /**
98
- * ProductPrice - Display product/variant price with currency conversion
99
- *
100
- * Works with the new intuitive price API where:
101
- * - `price` / `priceRange` = converted price (customer's currency)
102
- * - `originalPrice` / `originalPriceRange` = original price (shop currency)
103
- *
104
- * @example
105
- * ```tsx
106
- * // Product listing - show price range
107
- * <ProductPrice
108
- * priceRange={product.priceRange}
109
- * showFromPrefix
110
- * />
111
- *
112
- * // Product detail - show selected variant price
113
- * <ProductPrice
114
- * variantPrice={selectedVariant.price}
115
- * compareAtPrice={selectedVariant.compareAtPrice}
116
- * showCompareAt
117
- * showOriginal
118
- * size="lg"
119
- * />
120
- *
121
- * // With exchange rate info
122
- * <ProductPrice
123
- * variantPrice={variant.price}
124
- * showExchangeRate
125
- * />
126
- * ```
127
- */
128
- export function ProductPrice({
129
- priceRange,
130
- originalPriceRange,
131
- variantPrice,
132
- originalVariantPrice,
133
- compareAtPrice,
134
- showOriginal = false,
135
- showExchangeRate = false,
136
- showFromPrefix = false,
137
- showCompareAt = true,
138
- className = "",
139
- size = "md",
140
- }: ProductPriceProps) {
141
- // Determine which price to display
142
- const displayPrice = useMemo(() => {
143
- if (variantPrice) {
144
- return variantPrice;
145
- }
146
- if (priceRange) {
147
- return priceRange.minVariantPrice;
148
- }
149
- return null;
150
- }, [variantPrice, priceRange]);
151
-
152
- // Determine original price
153
- const originalPrice = useMemo(() => {
154
- if (originalVariantPrice) {
155
- return originalVariantPrice;
156
- }
157
- if (originalPriceRange) {
158
- return originalPriceRange.minVariantPrice;
159
- }
160
- return null;
161
- }, [originalVariantPrice, originalPriceRange]);
162
-
163
- // Check if price range varies (min != max)
164
- const hasVariedRange = useMemo(() => {
165
- if (!priceRange) return false;
166
- return (
167
- priceRange.minVariantPrice.amount !== priceRange.maxVariantPrice.amount
168
- );
169
- }, [priceRange]);
170
-
171
- // Check if it's a sale (has compare at price higher than current)
172
- const isOnSale = useMemo(() => {
173
- if (!compareAtPrice || !displayPrice) return false;
174
- return parseFloat(compareAtPrice.amount) > parseFloat(displayPrice.amount);
175
- }, [compareAtPrice, displayPrice]);
176
-
177
- // Size styles
178
- const sizeStyles = {
179
- sm: "text-sm",
180
- md: "text-base",
181
- lg: "text-lg",
182
- xl: "text-2xl",
183
- };
184
-
185
- const smallTextStyles = {
186
- sm: "text-xs",
187
- md: "text-xs",
188
- lg: "text-sm",
189
- xl: "text-base",
190
- };
191
-
192
- if (!displayPrice) {
193
- return null;
194
- }
195
-
196
- return (
197
- <div className={`flex flex-col gap-0.5 ${className}`}>
198
- {/* Main price line */}
199
- <div className="flex items-baseline gap-2 flex-wrap">
200
- {/* Compare at price (strikethrough) */}
201
- {showCompareAt && isOnSale && compareAtPrice && (
202
- <span
203
- className={`text-gray-400 line-through ${smallTextStyles[size]}`}
204
- >
205
- {formatPrice(compareAtPrice)}
206
- </span>
207
- )}
208
-
209
- {/* Current price */}
210
- <span
211
- className={`font-semibold ${sizeStyles[size]} ${
212
- isOnSale
213
- ? "text-red-600 dark:text-red-400"
214
- : "text-gray-900 dark:text-gray-100"
215
- }`}
216
- >
217
- {showFromPrefix && (hasVariedRange || priceRange) && (
218
- <span className="font-normal text-gray-500 dark:text-gray-400 mr-1">
219
- od
220
- </span>
221
- )}
222
- {formatPrice(displayPrice)}
223
- </span>
224
-
225
- {/* Sale badge */}
226
- {isOnSale && (
227
- <span className="px-1.5 py-0.5 text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 rounded">
228
- Promocja
229
- </span>
230
- )}
231
- </div>
232
-
233
- {/* Price range (if varies) */}
234
- {hasVariedRange && priceRange && (
235
- <span
236
- className={`text-gray-500 dark:text-gray-400 ${smallTextStyles[size]}`}
237
- >
238
- {formatPrice(priceRange.minVariantPrice)} –{" "}
239
- {formatPrice(priceRange.maxVariantPrice)}
240
- </span>
241
- )}
242
-
243
- {/* Original price (in base currency) */}
244
- {showOriginal && displayPrice.isConverted && originalPrice && (
245
- <span
246
- className={`text-gray-400 dark:text-gray-500 ${smallTextStyles[size]}`}
247
- >
248
- ≈ {formatPrice(originalPrice)}
249
- </span>
250
- )}
251
-
252
- {/* Exchange rate info */}
253
- {showExchangeRate &&
254
- displayPrice.isConverted &&
255
- displayPrice.exchangeRate && (
256
- <span
257
- className={`text-gray-400 dark:text-gray-500 ${smallTextStyles[size]}`}
258
- >
259
- Kurs: 1 {displayPrice.baseCurrencyCode} ={" "}
260
- {displayPrice.exchangeRate.toFixed(4)} {displayPrice.currencyCode}
261
- {displayPrice.marginApplied && (
262
- <span className="ml-1">
263
- (+{(displayPrice.marginApplied * 100).toFixed(1)}% marża)
264
- </span>
265
- )}
266
- </span>
267
- )}
268
- </div>
269
- );
270
- }
271
-
272
- // ============================================================================
273
- // VARIANT PRICE SELECTOR
274
- // ============================================================================
275
-
276
- interface VariantPriceSelectorProps {
277
- /** All variants with prices */
278
- variants: ProductVariantPrice[];
279
- /** Currently selected variant ID */
280
- selectedVariantId: string | null;
281
- /** Callback when variant is selected */
282
- onSelectVariant: (variantId: string) => void;
283
- /** Show original prices */
284
- showOriginal?: boolean;
285
- /** Layout direction */
286
- layout?: "horizontal" | "vertical";
287
- /** Custom className */
288
- className?: string;
289
- }
290
-
291
- /**
292
- * VariantPriceSelector - Select variant and see its price
293
- *
294
- * @example
295
- * ```tsx
296
- * const [selectedId, setSelectedId] = useState(variants[0]?.id);
297
- *
298
- * <VariantPriceSelector
299
- * variants={product.variants}
300
- * selectedVariantId={selectedId}
301
- * onSelectVariant={setSelectedId}
302
- * />
303
- * ```
304
- */
305
- export function VariantPriceSelector({
306
- variants,
307
- selectedVariantId,
308
- onSelectVariant,
309
- showOriginal = false,
310
- layout = "horizontal",
311
- className = "",
312
- }: VariantPriceSelectorProps) {
313
- const selectedVariant = useMemo(
314
- () => variants.find((v) => v.id === selectedVariantId),
315
- [variants, selectedVariantId]
316
- );
317
-
318
- return (
319
- <div className={`flex flex-col gap-3 ${className}`}>
320
- {/* Variant buttons */}
321
- <div
322
- className={`flex gap-2 ${
323
- layout === "vertical" ? "flex-col" : "flex-wrap"
324
- }`}
325
- >
326
- {variants.map((variant) => {
327
- const isSelected = variant.id === selectedVariantId;
328
- const isAvailable = variant.available;
329
-
330
- return (
331
- <button
332
- key={variant.id}
333
- type="button"
334
- onClick={() => onSelectVariant(variant.id)}
335
- disabled={!isAvailable}
336
- className={`
337
- px-4 py-2 rounded-lg border text-sm font-medium
338
- transition-all
339
- ${
340
- isSelected
341
- ? "border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
342
- : "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
343
- }
344
- ${
345
- !isAvailable
346
- ? "opacity-50 cursor-not-allowed line-through"
347
- : "cursor-pointer"
348
- }
349
- `}
350
- >
351
- <span>{variant.title}</span>
352
- <span className="ml-2 text-gray-500 dark:text-gray-400">
353
- {formatPrice(variant.price)}
354
- </span>
355
- </button>
356
- );
357
- })}
358
- </div>
359
-
360
- {/* Selected variant price details */}
361
- {selectedVariant && (
362
- <ProductPrice
363
- variantPrice={selectedVariant.price}
364
- originalVariantPrice={selectedVariant.originalPrice}
365
- compareAtPrice={selectedVariant.compareAtPrice}
366
- showOriginal={showOriginal}
367
- showCompareAt
368
- size="lg"
369
- />
370
- )}
371
- </div>
372
- );
373
- }
374
-
375
- export default ProductPrice;
@@ -1,178 +0,0 @@
1
- "use client";
2
-
3
- import { useState, useEffect, useRef, useCallback } from "react";
4
- import { useRouter } from "next/navigation";
5
- import { Search, X, Loader2 } from "lucide-react";
6
- import { useProductSearch } from "@doswiftly/storefront-sdk/graphql/client";
7
- import { useDebouncedValue } from "@/lib/hooks";
8
- import Link from "next/link";
9
-
10
- interface SearchInputProps {
11
- placeholder?: string;
12
- className?: string;
13
- }
14
-
15
- /**
16
- * SearchInput - Product search with autocomplete
17
- *
18
- * Shows instant search results as user types.
19
- * Pressing Enter navigates to full search page.
20
- */
21
- export function SearchInput({
22
- placeholder = "Search products...",
23
- className = "",
24
- }: SearchInputProps) {
25
- const router = useRouter();
26
- const [query, setQuery] = useState("");
27
- const [isOpen, setIsOpen] = useState(false);
28
- const inputRef = useRef<HTMLInputElement>(null);
29
- const containerRef = useRef<HTMLDivElement>(null);
30
-
31
- // Debounce search query
32
- const debouncedQuery = useDebouncedValue(query, 300);
33
-
34
- // Fetch search results (client hook - now returns normalized data!)
35
- const { data, isLoading } = useProductSearch(
36
- { query: debouncedQuery, first: 5 },
37
- { enabled: debouncedQuery.length >= 2 }
38
- );
39
-
40
- const products = data?.products ?? [];
41
- const hasResults = products.length > 0;
42
-
43
- // Handle form submit
44
- const handleSubmit = (e: React.FormEvent) => {
45
- e.preventDefault();
46
- if (query.trim()) {
47
- router.push(`/search?q=${encodeURIComponent(query.trim())}`);
48
- setIsOpen(false);
49
- setQuery("");
50
- }
51
- };
52
-
53
- // Handle click outside
54
- useEffect(() => {
55
- const handleClickOutside = (e: MouseEvent) => {
56
- if (
57
- containerRef.current &&
58
- !containerRef.current.contains(e.target as Node)
59
- ) {
60
- setIsOpen(false);
61
- }
62
- };
63
-
64
- document.addEventListener("mousedown", handleClickOutside);
65
- return () => document.removeEventListener("mousedown", handleClickOutside);
66
- }, []);
67
-
68
- // Handle escape key
69
- useEffect(() => {
70
- const handleEscape = (e: KeyboardEvent) => {
71
- if (e.key === "Escape") {
72
- setIsOpen(false);
73
- inputRef.current?.blur();
74
- }
75
- };
76
-
77
- document.addEventListener("keydown", handleEscape);
78
- return () => document.removeEventListener("keydown", handleEscape);
79
- }, []);
80
-
81
- return (
82
- <div ref={containerRef} className={`relative ${className}`}>
83
- <form onSubmit={handleSubmit}>
84
- <div className="relative">
85
- <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
86
- <input
87
- ref={inputRef}
88
- type="text"
89
- value={query}
90
- onChange={(e) => {
91
- setQuery(e.target.value);
92
- setIsOpen(true);
93
- }}
94
- onFocus={() => setIsOpen(true)}
95
- placeholder={placeholder}
96
- className="input w-full pl-10 pr-10"
97
- />
98
- {query && (
99
- <button
100
- type="button"
101
- onClick={() => {
102
- setQuery("");
103
- inputRef.current?.focus();
104
- }}
105
- className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
106
- >
107
- <X className="h-4 w-4" />
108
- </button>
109
- )}
110
- </div>
111
- </form>
112
-
113
- {/* Dropdown Results */}
114
- {isOpen && query.length >= 2 && (
115
- <div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-96 overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg">
116
- {isLoading ? (
117
- <div className="flex items-center justify-center py-8">
118
- <Loader2 className="h-5 w-5 animate-spin text-gray-400" />
119
- <span className="ml-2 text-sm text-gray-500">Searching...</span>
120
- </div>
121
- ) : hasResults ? (
122
- <>
123
- <ul className="divide-y divide-gray-100">
124
- {products.slice(0, 5).map((product) => (
125
- <li key={product.id}>
126
- <Link
127
- href={`/products/${product.handle}`}
128
- onClick={() => {
129
- setIsOpen(false);
130
- setQuery("");
131
- }}
132
- className="flex items-center gap-4 p-3 hover:bg-gray-50"
133
- >
134
- {product.featuredImage?.url ? (
135
- <img
136
- src={product.featuredImage.url}
137
- alt={product.featuredImage.altText || product.title}
138
- className="h-12 w-12 rounded object-cover"
139
- />
140
- ) : (
141
- <div className="flex h-12 w-12 items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
142
- No img
143
- </div>
144
- )}
145
- <div className="flex-1 overflow-hidden">
146
- <h4 className="truncate font-medium text-gray-900">
147
- {product.title}
148
- </h4>
149
- <p className="text-sm text-primary">
150
- {product.priceRange?.minVariantPrice?.amount}{" "}
151
- {product.priceRange?.minVariantPrice?.currencyCode}
152
- </p>
153
- </div>
154
- </Link>
155
- </li>
156
- ))}
157
- </ul>
158
- <Link
159
- href={`/search?q=${encodeURIComponent(query)}`}
160
- onClick={() => {
161
- setIsOpen(false);
162
- setQuery("");
163
- }}
164
- className="block border-t border-gray-100 p-3 text-center text-sm text-primary hover:bg-gray-50"
165
- >
166
- View all results →
167
- </Link>
168
- </>
169
- ) : (
170
- <div className="py-8 text-center text-sm text-gray-500">
171
- No products found for &quot;{query}&quot;
172
- </div>
173
- )}
174
- </div>
175
- )}
176
- </div>
177
- );
178
- }