@doswiftly/cli 0.1.24 → 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.
- package/dist/commands/check.js +2 -2
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +8 -5
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts +13 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +155 -63
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +3 -4
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +271 -166
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/sdk.d.ts +1 -1
- package/dist/commands/sdk.js +3 -3
- package/dist/commands/sdk.js.map +1 -1
- package/dist/commands/template.d.ts.map +1 -1
- package/dist/commands/template.js +4 -31
- package/dist/commands/template.js.map +1 -1
- package/dist/commands/verify.js +5 -5
- package/dist/commands/verify.js.map +1 -1
- package/dist/index.js +2 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/i18n.d.ts +12 -0
- package/dist/lib/i18n.d.ts.map +1 -1
- package/dist/lib/i18n.js +24 -0
- package/dist/lib/i18n.js.map +1 -1
- package/dist/lib/proxy-server.d.ts +22 -6
- package/dist/lib/proxy-server.d.ts.map +1 -1
- package/dist/lib/proxy-server.js +174 -75
- package/dist/lib/proxy-server.js.map +1 -1
- package/package.json +1 -1
- package/dist/commands/types.d.ts +0 -5
- package/dist/commands/types.d.ts.map +0 -1
- package/dist/commands/types.js +0 -82
- package/dist/commands/types.js.map +0 -1
- package/templates/storefront-minimal/.env.example +0 -10
- package/templates/storefront-minimal/.github/workflows/build-template.yml +0 -119
- package/templates/storefront-minimal/app/globals.css +0 -18
- package/templates/storefront-minimal/app/layout.tsx +0 -26
- package/templates/storefront-minimal/app/page.tsx +0 -93
- package/templates/storefront-minimal/lib/graphql-client.ts +0 -23
- package/templates/storefront-minimal/next.config.ts +0 -15
- package/templates/storefront-minimal/open-next.config.ts +0 -3
- package/templates/storefront-minimal/package.json +0 -30
- package/templates/storefront-minimal/postcss.config.mjs +0 -5
- package/templates/storefront-minimal/tailwind.config.ts +0 -14
- package/templates/storefront-minimal/tsconfig.json +0 -27
- package/templates/storefront-minimal/wrangler.toml +0 -24
- package/templates/storefront-nextjs/.env.example +0 -68
- package/templates/storefront-nextjs/.github/workflows/build-template.yml +0 -119
- package/templates/storefront-nextjs/README.md +0 -524
- package/templates/storefront-nextjs/app/account/orders/page.tsx +0 -216
- package/templates/storefront-nextjs/app/account/page.tsx +0 -167
- package/templates/storefront-nextjs/app/auth/login/page.tsx +0 -135
- package/templates/storefront-nextjs/app/auth/register/page.tsx +0 -212
- package/templates/storefront-nextjs/app/cart/page.tsx +0 -263
- package/templates/storefront-nextjs/app/categories/[slug]/page.tsx +0 -200
- package/templates/storefront-nextjs/app/categories/page.tsx +0 -58
- package/templates/storefront-nextjs/app/checkout/page.tsx +0 -351
- package/templates/storefront-nextjs/app/collections/[slug]/page.tsx +0 -158
- package/templates/storefront-nextjs/app/collections/page.tsx +0 -61
- package/templates/storefront-nextjs/app/globals.css +0 -98
- package/templates/storefront-nextjs/app/layout.tsx +0 -39
- package/templates/storefront-nextjs/app/page.tsx +0 -136
- package/templates/storefront-nextjs/app/products/[slug]/page.tsx +0 -119
- package/templates/storefront-nextjs/app/products/page.tsx +0 -107
- package/templates/storefront-nextjs/app/search/page.tsx +0 -127
- package/templates/storefront-nextjs/components/auth/auth-guard.tsx +0 -94
- package/templates/storefront-nextjs/components/commerce/add-to-cart-button.tsx +0 -77
- package/templates/storefront-nextjs/components/commerce/cart-icon.tsx +0 -29
- package/templates/storefront-nextjs/components/commerce/currency-selector.tsx +0 -217
- package/templates/storefront-nextjs/components/commerce/pagination.tsx +0 -62
- package/templates/storefront-nextjs/components/commerce/product-actions.tsx +0 -135
- package/templates/storefront-nextjs/components/commerce/product-filters.tsx +0 -109
- package/templates/storefront-nextjs/components/commerce/product-price.tsx +0 -375
- package/templates/storefront-nextjs/components/commerce/search-input.tsx +0 -178
- package/templates/storefront-nextjs/components/commerce/sort-select.tsx +0 -64
- package/templates/storefront-nextjs/components/commerce/variant-selector.tsx +0 -210
- package/templates/storefront-nextjs/components/layout/footer.tsx +0 -107
- package/templates/storefront-nextjs/components/layout/header.tsx +0 -104
- package/templates/storefront-nextjs/components/providers.tsx +0 -62
- package/templates/storefront-nextjs/lib/auth/routes.ts +0 -52
- package/templates/storefront-nextjs/lib/currency.tsx +0 -140
- package/templates/storefront-nextjs/lib/format.ts +0 -159
- package/templates/storefront-nextjs/lib/graphql-queries.ts +0 -629
- package/templates/storefront-nextjs/lib/hooks.ts +0 -30
- package/templates/storefront-nextjs/middleware.ts +0 -80
- package/templates/storefront-nextjs/next.config.ts +0 -37
- package/templates/storefront-nextjs/open-next.config.ts +0 -3
- package/templates/storefront-nextjs/package.dev.json +0 -30
- package/templates/storefront-nextjs/package.json +0 -32
- package/templates/storefront-nextjs/package.json.template +0 -32
- package/templates/storefront-nextjs/postcss.config.mjs +0 -8
- package/templates/storefront-nextjs/tailwind.config.ts +0 -111
- package/templates/storefront-nextjs/tsconfig.json +0 -27
- package/templates/storefront-nextjs/wrangler.toml +0 -24
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useEffect, ReactNode } from "react";
|
|
4
|
-
import { useRouter } from "next/navigation";
|
|
5
|
-
import { useAuth } from "@doswiftly/storefront-sdk/graphql/react";
|
|
6
|
-
import { redirects } from "@/lib/auth/routes";
|
|
7
|
-
|
|
8
|
-
interface AuthGuardProps {
|
|
9
|
-
children: ReactNode;
|
|
10
|
-
/** URL to redirect to (default: from lib/auth/routes.ts) */
|
|
11
|
-
redirectTo?: string;
|
|
12
|
-
/** Custom loading component */
|
|
13
|
-
fallback?: ReactNode;
|
|
14
|
-
/** If true, only guests can access (redirects authenticated users) */
|
|
15
|
-
requireGuest?: boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* AuthGuard - Client-side route protection based on authentication status
|
|
20
|
-
*
|
|
21
|
-
* Security Model (Layer 2 of 3):
|
|
22
|
-
* 1. Middleware - Fast redirect based on cookie (runs first)
|
|
23
|
-
* 2. AuthGuard (this) - Client-side validation via useAuth hook
|
|
24
|
-
* 3. GraphQL Backend - Ultimate security (validates token)
|
|
25
|
-
*
|
|
26
|
-
* Why use AuthGuard if middleware exists?
|
|
27
|
-
* - Middleware only checks cookie existence (fast but not validation)
|
|
28
|
-
* - AuthGuard validates token via useAuth (catches expired/invalid tokens)
|
|
29
|
-
* - Provides loading states during redirect
|
|
30
|
-
* - Works for client-side navigation (middleware only runs on initial load)
|
|
31
|
-
*
|
|
32
|
-
* @example
|
|
33
|
-
* ```tsx
|
|
34
|
-
* // Protected route (requires login)
|
|
35
|
-
* export default function AccountPage() {
|
|
36
|
-
* return (
|
|
37
|
-
* <AuthGuard>
|
|
38
|
-
* <AccountContent />
|
|
39
|
-
* </AuthGuard>
|
|
40
|
-
* );
|
|
41
|
-
* }
|
|
42
|
-
*
|
|
43
|
-
* // Guest-only route (login/register pages)
|
|
44
|
-
* export default function LoginPage() {
|
|
45
|
-
* return (
|
|
46
|
-
* <AuthGuard requireGuest>
|
|
47
|
-
* <LoginForm />
|
|
48
|
-
* </AuthGuard>
|
|
49
|
-
* );
|
|
50
|
-
* }
|
|
51
|
-
*
|
|
52
|
-
* // Custom redirect (e.g., preserve checkout URL)
|
|
53
|
-
* export default function CheckoutPage() {
|
|
54
|
-
* return (
|
|
55
|
-
* <AuthGuard redirectTo="/auth/login?redirect=/checkout">
|
|
56
|
-
* <CheckoutContent />
|
|
57
|
-
* </AuthGuard>
|
|
58
|
-
* );
|
|
59
|
-
* }
|
|
60
|
-
* ```
|
|
61
|
-
*
|
|
62
|
-
* @see lib/auth/routes.ts - SSOT for route configuration
|
|
63
|
-
* @see middleware.ts - Server-side route protection
|
|
64
|
-
*/
|
|
65
|
-
export function AuthGuard({
|
|
66
|
-
children,
|
|
67
|
-
redirectTo,
|
|
68
|
-
fallback,
|
|
69
|
-
requireGuest = false,
|
|
70
|
-
}: AuthGuardProps) {
|
|
71
|
-
const router = useRouter();
|
|
72
|
-
const { isAuthenticated } = useAuth();
|
|
73
|
-
|
|
74
|
-
// Use SSOT defaults from routes.ts
|
|
75
|
-
const defaultRedirect = requireGuest
|
|
76
|
-
? redirects.authenticated
|
|
77
|
-
: redirects.unauthenticated;
|
|
78
|
-
const targetRedirect = redirectTo ?? defaultRedirect;
|
|
79
|
-
|
|
80
|
-
// Determine if we should redirect
|
|
81
|
-
const shouldRedirect = requireGuest ? isAuthenticated : !isAuthenticated;
|
|
82
|
-
|
|
83
|
-
useEffect(() => {
|
|
84
|
-
if (shouldRedirect) {
|
|
85
|
-
// Use replace instead of push to prevent back button issues
|
|
86
|
-
router.replace(targetRedirect);
|
|
87
|
-
}
|
|
88
|
-
}, [shouldRedirect, targetRedirect, router]);
|
|
89
|
-
|
|
90
|
-
// Always render children immediately (non-blocking)
|
|
91
|
-
// Redirect happens async in useEffect
|
|
92
|
-
// This prevents blocking router.replace() execution
|
|
93
|
-
return <>{children}</>;
|
|
94
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import { ShoppingCart, Loader2, Check } from "lucide-react";
|
|
5
|
-
import { useCartManager } from "@doswiftly/storefront-sdk/graphql/react";
|
|
6
|
-
|
|
7
|
-
interface AddToCartButtonProps {
|
|
8
|
-
variantId: string;
|
|
9
|
-
quantity?: number;
|
|
10
|
-
disabled?: boolean;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* AddToCartButton - Developer-friendly add to cart button
|
|
15
|
-
*
|
|
16
|
-
* Uses useCartManager hook - all loading/error states come from the hook.
|
|
17
|
-
* No manual state management needed!
|
|
18
|
-
*
|
|
19
|
-
* @example
|
|
20
|
-
* ```tsx
|
|
21
|
-
* <AddToCartButton variantId="variant-123" />
|
|
22
|
-
* <AddToCartButton variantId="variant-123" quantity={2} />
|
|
23
|
-
* <AddToCartButton variantId="variant-123" disabled={!inStock} />
|
|
24
|
-
* ```
|
|
25
|
-
*/
|
|
26
|
-
export function AddToCartButton({
|
|
27
|
-
variantId,
|
|
28
|
-
quantity = 1,
|
|
29
|
-
disabled = false,
|
|
30
|
-
}: AddToCartButtonProps) {
|
|
31
|
-
// Success state (only local state needed - for brief "Added!" feedback)
|
|
32
|
-
const [justAdded, setJustAdded] = useState(false);
|
|
33
|
-
|
|
34
|
-
// All cart logic from hook - no manual state management!
|
|
35
|
-
const { addItem, addItemLoading } = useCartManager();
|
|
36
|
-
|
|
37
|
-
const handleAddToCart = async () => {
|
|
38
|
-
if (disabled || addItemLoading) return;
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
await addItem(variantId, quantity);
|
|
42
|
-
setJustAdded(true);
|
|
43
|
-
setTimeout(() => setJustAdded(false), 2000);
|
|
44
|
-
} catch (err) {
|
|
45
|
-
console.error("Failed to add to cart:", err);
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
return (
|
|
50
|
-
<button
|
|
51
|
-
onClick={handleAddToCart}
|
|
52
|
-
disabled={disabled || addItemLoading}
|
|
53
|
-
className={`btn w-full ${
|
|
54
|
-
justAdded
|
|
55
|
-
? "bg-green-500 text-white hover:bg-green-600"
|
|
56
|
-
: "bg-gray-900 text-white hover:bg-gray-800"
|
|
57
|
-
}`}
|
|
58
|
-
>
|
|
59
|
-
{addItemLoading ? (
|
|
60
|
-
<>
|
|
61
|
-
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
62
|
-
Adding...
|
|
63
|
-
</>
|
|
64
|
-
) : justAdded ? (
|
|
65
|
-
<>
|
|
66
|
-
<Check className="mr-2 h-5 w-5" />
|
|
67
|
-
Added to Cart!
|
|
68
|
-
</>
|
|
69
|
-
) : (
|
|
70
|
-
<>
|
|
71
|
-
<ShoppingCart className="mr-2 h-5 w-5" />
|
|
72
|
-
Add to Cart
|
|
73
|
-
</>
|
|
74
|
-
)}
|
|
75
|
-
</button>
|
|
76
|
-
);
|
|
77
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import { ShoppingCart } from "lucide-react";
|
|
5
|
-
import { useCartManager } from "@doswiftly/storefront-sdk/graphql/react";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* CartIcon - Cart icon with dynamic item count
|
|
9
|
-
*
|
|
10
|
-
* Shows total quantity from useCartManager.
|
|
11
|
-
* Updates automatically when cart changes.
|
|
12
|
-
*/
|
|
13
|
-
export function CartIcon() {
|
|
14
|
-
const { totalQuantity } = useCartManager();
|
|
15
|
-
|
|
16
|
-
return (
|
|
17
|
-
<Link
|
|
18
|
-
href="/cart"
|
|
19
|
-
className="relative p-2 text-gray-600 hover:text-primary"
|
|
20
|
-
>
|
|
21
|
-
<ShoppingCart className="h-5 w-5" />
|
|
22
|
-
{totalQuantity > 0 && (
|
|
23
|
-
<span className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-white">
|
|
24
|
-
{totalQuantity > 99 ? "99+" : totalQuantity}
|
|
25
|
-
</span>
|
|
26
|
-
)}
|
|
27
|
-
</Link>
|
|
28
|
-
);
|
|
29
|
-
}
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useRef, useEffect } from "react";
|
|
4
|
-
import { ChevronDown, Check, Globe } from "lucide-react";
|
|
5
|
-
import { useCurrency } from "@doswiftly/storefront-sdk/graphql/react";
|
|
6
|
-
import {
|
|
7
|
-
useCurrencyStore,
|
|
8
|
-
selectIsHydrated,
|
|
9
|
-
} from "@doswiftly/storefront-sdk/graphql/react/stores";
|
|
10
|
-
|
|
11
|
-
// ============================================================================
|
|
12
|
-
// CURRENCY DATA
|
|
13
|
-
// ============================================================================
|
|
14
|
-
|
|
15
|
-
const CURRENCY_SYMBOLS: Record<string, string> = {
|
|
16
|
-
PLN: "zł",
|
|
17
|
-
EUR: "€",
|
|
18
|
-
USD: "$",
|
|
19
|
-
GBP: "£",
|
|
20
|
-
CHF: "CHF",
|
|
21
|
-
CZK: "Kč",
|
|
22
|
-
SEK: "kr",
|
|
23
|
-
NOK: "kr",
|
|
24
|
-
DKK: "kr",
|
|
25
|
-
JPY: "¥",
|
|
26
|
-
CNY: "¥",
|
|
27
|
-
AUD: "A$",
|
|
28
|
-
CAD: "C$",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const CURRENCY_NAMES: Record<string, string> = {
|
|
32
|
-
PLN: "Polski złoty",
|
|
33
|
-
EUR: "Euro",
|
|
34
|
-
USD: "US Dollar",
|
|
35
|
-
GBP: "British Pound",
|
|
36
|
-
CHF: "Swiss Franc",
|
|
37
|
-
CZK: "Czech Koruna",
|
|
38
|
-
SEK: "Swedish Krona",
|
|
39
|
-
NOK: "Norwegian Krone",
|
|
40
|
-
DKK: "Danish Krone",
|
|
41
|
-
JPY: "Japanese Yen",
|
|
42
|
-
CNY: "Chinese Yuan",
|
|
43
|
-
AUD: "Australian Dollar",
|
|
44
|
-
CAD: "Canadian Dollar",
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
// ============================================================================
|
|
48
|
-
// TYPES
|
|
49
|
-
// ============================================================================
|
|
50
|
-
|
|
51
|
-
interface CurrencySelectorProps {
|
|
52
|
-
className?: string;
|
|
53
|
-
showSymbol?: boolean;
|
|
54
|
-
showIcon?: boolean;
|
|
55
|
-
variant?: "default" | "compact" | "minimal";
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ============================================================================
|
|
59
|
-
// COMPONENT
|
|
60
|
-
// ============================================================================
|
|
61
|
-
|
|
62
|
-
export function CurrencySelector({
|
|
63
|
-
className = "",
|
|
64
|
-
showSymbol = false,
|
|
65
|
-
showIcon = true,
|
|
66
|
-
variant = "default",
|
|
67
|
-
}: CurrencySelectorProps) {
|
|
68
|
-
// useCurrency from SDK context - setCurrency includes QueryClient invalidation
|
|
69
|
-
const { currency, supportedCurrencies, setCurrency, isAutoDetected } = useCurrency();
|
|
70
|
-
|
|
71
|
-
// Hydration state from Zustand (prevents SSR mismatch flash)
|
|
72
|
-
const isHydrated = useCurrencyStore(selectIsHydrated);
|
|
73
|
-
|
|
74
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
75
|
-
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
76
|
-
|
|
77
|
-
// Close dropdown on outside click
|
|
78
|
-
useEffect(() => {
|
|
79
|
-
function handleClickOutside(event: MouseEvent) {
|
|
80
|
-
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
81
|
-
setIsOpen(false);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
85
|
-
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
86
|
-
}, []);
|
|
87
|
-
|
|
88
|
-
// Close on escape
|
|
89
|
-
useEffect(() => {
|
|
90
|
-
function handleEscape(event: KeyboardEvent) {
|
|
91
|
-
if (event.key === "Escape") setIsOpen(false);
|
|
92
|
-
}
|
|
93
|
-
document.addEventListener("keydown", handleEscape);
|
|
94
|
-
return () => document.removeEventListener("keydown", handleEscape);
|
|
95
|
-
}, []);
|
|
96
|
-
|
|
97
|
-
// Prevent SSR mismatch - skeleton until hydrated
|
|
98
|
-
if (!isHydrated) {
|
|
99
|
-
return (
|
|
100
|
-
<div className={`inline-block ${className}`}>
|
|
101
|
-
<div className="px-3 py-2 bg-gray-100 rounded-lg animate-pulse w-16 h-9" />
|
|
102
|
-
</div>
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const handleSelect = (newCurrency: string) => {
|
|
107
|
-
if (newCurrency !== currency) {
|
|
108
|
-
setCurrency(newCurrency); // Includes QueryClient invalidation
|
|
109
|
-
}
|
|
110
|
-
setIsOpen(false);
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const displayValue = showSymbol ? (CURRENCY_SYMBOLS[currency] || currency) : currency;
|
|
114
|
-
|
|
115
|
-
const buttonStyles = {
|
|
116
|
-
default: "px-3 py-2 text-sm",
|
|
117
|
-
compact: "px-2 py-1.5 text-xs",
|
|
118
|
-
minimal: "px-2 py-1 text-xs",
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
const dropdownStyles = {
|
|
122
|
-
default: "w-48",
|
|
123
|
-
compact: "w-40",
|
|
124
|
-
minimal: "w-36",
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
return (
|
|
128
|
-
<div ref={dropdownRef} className={`relative inline-block ${className}`}>
|
|
129
|
-
<button
|
|
130
|
-
type="button"
|
|
131
|
-
onClick={() => setIsOpen(!isOpen)}
|
|
132
|
-
className={`
|
|
133
|
-
inline-flex items-center justify-between gap-2
|
|
134
|
-
bg-white dark:bg-gray-800
|
|
135
|
-
border border-gray-200 dark:border-gray-700
|
|
136
|
-
rounded-lg
|
|
137
|
-
text-gray-700 dark:text-gray-200
|
|
138
|
-
hover:bg-gray-50 dark:hover:bg-gray-700
|
|
139
|
-
focus:outline-none focus:ring-2 focus:ring-blue-500
|
|
140
|
-
transition-colors
|
|
141
|
-
${buttonStyles[variant]}
|
|
142
|
-
`}
|
|
143
|
-
aria-expanded={isOpen}
|
|
144
|
-
aria-haspopup="listbox"
|
|
145
|
-
>
|
|
146
|
-
{showIcon && variant !== "minimal" && (
|
|
147
|
-
<Globe className="w-4 h-4 text-gray-400" />
|
|
148
|
-
)}
|
|
149
|
-
<span className="font-medium">{displayValue}</span>
|
|
150
|
-
{isAutoDetected && variant === "default" && (
|
|
151
|
-
<span className="text-xs text-gray-400">(auto)</span>
|
|
152
|
-
)}
|
|
153
|
-
<ChevronDown
|
|
154
|
-
className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
|
155
|
-
/>
|
|
156
|
-
</button>
|
|
157
|
-
|
|
158
|
-
{isOpen && (
|
|
159
|
-
<div
|
|
160
|
-
className={`
|
|
161
|
-
absolute right-0 mt-2 z-50
|
|
162
|
-
bg-white dark:bg-gray-800
|
|
163
|
-
border border-gray-200 dark:border-gray-700
|
|
164
|
-
rounded-lg shadow-lg
|
|
165
|
-
overflow-hidden
|
|
166
|
-
${dropdownStyles[variant]}
|
|
167
|
-
`}
|
|
168
|
-
role="listbox"
|
|
169
|
-
>
|
|
170
|
-
<div className="py-1 max-h-64 overflow-y-auto">
|
|
171
|
-
{supportedCurrencies.map((code) => {
|
|
172
|
-
const isSelected = code === currency;
|
|
173
|
-
const symbol = CURRENCY_SYMBOLS[code] || code;
|
|
174
|
-
const name = CURRENCY_NAMES[code] || code;
|
|
175
|
-
|
|
176
|
-
return (
|
|
177
|
-
<button
|
|
178
|
-
key={code}
|
|
179
|
-
type="button"
|
|
180
|
-
onClick={() => handleSelect(code)}
|
|
181
|
-
className={`
|
|
182
|
-
w-full px-3 py-2 text-left
|
|
183
|
-
flex items-center justify-between gap-2
|
|
184
|
-
hover:bg-gray-100 dark:hover:bg-gray-700
|
|
185
|
-
transition-colors
|
|
186
|
-
${isSelected ? "bg-blue-50 dark:bg-blue-900/20" : ""}
|
|
187
|
-
`}
|
|
188
|
-
role="option"
|
|
189
|
-
aria-selected={isSelected}
|
|
190
|
-
>
|
|
191
|
-
<div className="flex items-center gap-2">
|
|
192
|
-
<span className="w-6 text-center text-gray-400 font-mono text-sm">
|
|
193
|
-
{symbol}
|
|
194
|
-
</span>
|
|
195
|
-
<div>
|
|
196
|
-
<span className="font-medium text-gray-900 dark:text-gray-100">
|
|
197
|
-
{code}
|
|
198
|
-
</span>
|
|
199
|
-
{variant === "default" && (
|
|
200
|
-
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
|
201
|
-
{name}
|
|
202
|
-
</span>
|
|
203
|
-
)}
|
|
204
|
-
</div>
|
|
205
|
-
</div>
|
|
206
|
-
{isSelected && <Check className="w-4 h-4 text-blue-500" />}
|
|
207
|
-
</button>
|
|
208
|
-
);
|
|
209
|
-
})}
|
|
210
|
-
</div>
|
|
211
|
-
</div>
|
|
212
|
-
)}
|
|
213
|
-
</div>
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export default CurrencySelector;
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useSearchParams, usePathname } from "next/navigation";
|
|
4
|
-
import Link from "next/link";
|
|
5
|
-
|
|
6
|
-
interface PaginationProps {
|
|
7
|
-
hasMore: boolean;
|
|
8
|
-
endCursor?: string | null;
|
|
9
|
-
currentCursor?: string;
|
|
10
|
-
totalShown: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Pagination - URL-based pagination that preserves filters
|
|
15
|
-
*/
|
|
16
|
-
export function Pagination({
|
|
17
|
-
hasMore,
|
|
18
|
-
endCursor,
|
|
19
|
-
currentCursor,
|
|
20
|
-
totalShown,
|
|
21
|
-
}: PaginationProps) {
|
|
22
|
-
const pathname = usePathname();
|
|
23
|
-
const searchParams = useSearchParams();
|
|
24
|
-
|
|
25
|
-
// Build URL preserving current filters
|
|
26
|
-
const buildUrl = (cursor?: string | null) => {
|
|
27
|
-
const params = new URLSearchParams(searchParams.toString());
|
|
28
|
-
|
|
29
|
-
if (cursor) {
|
|
30
|
-
params.set("after", cursor);
|
|
31
|
-
} else {
|
|
32
|
-
params.delete("after");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const queryString = params.toString();
|
|
36
|
-
return queryString ? `${pathname}?${queryString}` : pathname;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
<div className="mt-12 flex justify-center gap-2">
|
|
41
|
-
<Link
|
|
42
|
-
href={buildUrl()}
|
|
43
|
-
className={`btn btn-outline ${
|
|
44
|
-
!currentCursor ? "pointer-events-none opacity-50" : ""
|
|
45
|
-
}`}
|
|
46
|
-
>
|
|
47
|
-
First Page
|
|
48
|
-
</Link>
|
|
49
|
-
<span className="flex items-center px-4 text-gray-600">
|
|
50
|
-
Showing {totalShown} products
|
|
51
|
-
</span>
|
|
52
|
-
<Link
|
|
53
|
-
href={hasMore && endCursor ? buildUrl(endCursor) : "#"}
|
|
54
|
-
className={`btn btn-outline ${
|
|
55
|
-
!hasMore ? "pointer-events-none opacity-50" : ""
|
|
56
|
-
}`}
|
|
57
|
-
>
|
|
58
|
-
Next
|
|
59
|
-
</Link>
|
|
60
|
-
</div>
|
|
61
|
-
);
|
|
62
|
-
}
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useMemo } from "react";
|
|
4
|
-
import { VariantSelector } from "@/components/commerce/variant-selector";
|
|
5
|
-
import { AddToCartButton } from "@/components/commerce/add-to-cart-button";
|
|
6
|
-
import { formatPrice } from "@/lib/currency";
|
|
7
|
-
|
|
8
|
-
interface ProductVariant {
|
|
9
|
-
id: string;
|
|
10
|
-
title: string;
|
|
11
|
-
available: boolean;
|
|
12
|
-
selectedOptions: Array<{ name: string; value: string }>;
|
|
13
|
-
price: {
|
|
14
|
-
amount: string;
|
|
15
|
-
currencyCode: string;
|
|
16
|
-
};
|
|
17
|
-
compareAtPrice?: {
|
|
18
|
-
amount: string;
|
|
19
|
-
currencyCode: string;
|
|
20
|
-
} | null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface ProductActionsProps {
|
|
24
|
-
variants: ProductVariant[];
|
|
25
|
-
productId: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* ProductActions - Client-side product interactions
|
|
30
|
-
*
|
|
31
|
-
* Handles variant selection, quantity, and add to cart.
|
|
32
|
-
* Server Component renders the rest of the page.
|
|
33
|
-
*/
|
|
34
|
-
export function ProductActions({ variants, productId }: ProductActionsProps) {
|
|
35
|
-
// Store only variant ID - derive variant object from props (SSOT)
|
|
36
|
-
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(
|
|
37
|
-
() => variants[0]?.id ?? null
|
|
38
|
-
);
|
|
39
|
-
const [quantity, setQuantity] = useState(1);
|
|
40
|
-
|
|
41
|
-
// Derived state: automatically updates when variants prop changes (e.g., after currency change)
|
|
42
|
-
const selectedVariant = useMemo(() => {
|
|
43
|
-
if (!variants || variants.length === 0) return null;
|
|
44
|
-
const found = variants.find((v) => v.id === selectedVariantId);
|
|
45
|
-
// Fallback to first variant if selected ID not found in new variants
|
|
46
|
-
return found ?? variants[0];
|
|
47
|
-
}, [variants, selectedVariantId]);
|
|
48
|
-
|
|
49
|
-
// Handler for VariantSelector - extracts ID from variant object
|
|
50
|
-
const handleVariantChange = (variant: ProductVariant) => {
|
|
51
|
-
setSelectedVariantId(variant.id);
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const inStock = selectedVariant?.available ?? true;
|
|
55
|
-
const price = selectedVariant?.price;
|
|
56
|
-
const compareAtPrice = selectedVariant?.compareAtPrice;
|
|
57
|
-
|
|
58
|
-
return (
|
|
59
|
-
<div className="space-y-6">
|
|
60
|
-
{/* Price */}
|
|
61
|
-
<div className="flex items-baseline gap-4">
|
|
62
|
-
<span className="text-2xl font-bold text-primary">
|
|
63
|
-
{formatPrice(price)}
|
|
64
|
-
</span>
|
|
65
|
-
{compareAtPrice && (
|
|
66
|
-
<span className="text-lg text-gray-400 line-through">
|
|
67
|
-
{formatPrice(compareAtPrice)}
|
|
68
|
-
</span>
|
|
69
|
-
)}
|
|
70
|
-
</div>
|
|
71
|
-
|
|
72
|
-
{/* Variant Selector */}
|
|
73
|
-
{variants.length > 1 && (
|
|
74
|
-
<VariantSelector
|
|
75
|
-
variants={variants}
|
|
76
|
-
selectedVariantId={selectedVariant?.id}
|
|
77
|
-
onVariantChange={handleVariantChange}
|
|
78
|
-
/>
|
|
79
|
-
)}
|
|
80
|
-
|
|
81
|
-
{/* Stock Status */}
|
|
82
|
-
<div>
|
|
83
|
-
{inStock ? (
|
|
84
|
-
<span className="inline-flex items-center gap-2 text-green-600">
|
|
85
|
-
<span className="h-2 w-2 rounded-full bg-green-600" />
|
|
86
|
-
In Stock
|
|
87
|
-
</span>
|
|
88
|
-
) : (
|
|
89
|
-
<span className="inline-flex items-center gap-2 text-red-600">
|
|
90
|
-
<span className="h-2 w-2 rounded-full bg-red-600" />
|
|
91
|
-
Out of Stock
|
|
92
|
-
</span>
|
|
93
|
-
)}
|
|
94
|
-
</div>
|
|
95
|
-
|
|
96
|
-
{/* Quantity & Add to Cart */}
|
|
97
|
-
<div className="space-y-4">
|
|
98
|
-
<div className="flex items-center gap-4">
|
|
99
|
-
<label className="text-sm font-medium text-gray-700">Quantity:</label>
|
|
100
|
-
<div className="flex items-center rounded-lg border border-gray-300">
|
|
101
|
-
<button
|
|
102
|
-
type="button"
|
|
103
|
-
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
|
104
|
-
className="px-3 py-2 text-gray-600 hover:text-primary"
|
|
105
|
-
>
|
|
106
|
-
-
|
|
107
|
-
</button>
|
|
108
|
-
<input
|
|
109
|
-
type="number"
|
|
110
|
-
min="1"
|
|
111
|
-
value={quantity}
|
|
112
|
-
onChange={(e) =>
|
|
113
|
-
setQuantity(Math.max(1, parseInt(e.target.value) || 1))
|
|
114
|
-
}
|
|
115
|
-
className="w-16 border-x border-gray-300 py-2 text-center"
|
|
116
|
-
/>
|
|
117
|
-
<button
|
|
118
|
-
type="button"
|
|
119
|
-
onClick={() => setQuantity((q) => q + 1)}
|
|
120
|
-
className="px-3 py-2 text-gray-600 hover:text-primary"
|
|
121
|
-
>
|
|
122
|
-
+
|
|
123
|
-
</button>
|
|
124
|
-
</div>
|
|
125
|
-
</div>
|
|
126
|
-
|
|
127
|
-
<AddToCartButton
|
|
128
|
-
variantId={selectedVariant?.id || productId}
|
|
129
|
-
quantity={quantity}
|
|
130
|
-
disabled={!inStock}
|
|
131
|
-
/>
|
|
132
|
-
</div>
|
|
133
|
-
</div>
|
|
134
|
-
);
|
|
135
|
-
}
|