create-brainerce-store 1.0.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 (53) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +502 -0
  3. package/package.json +44 -0
  4. package/templates/nextjs/base/.env.local.ejs +3 -0
  5. package/templates/nextjs/base/.eslintrc.json +3 -0
  6. package/templates/nextjs/base/gitignore +30 -0
  7. package/templates/nextjs/base/next.config.ts +9 -0
  8. package/templates/nextjs/base/package.json.ejs +30 -0
  9. package/templates/nextjs/base/postcss.config.mjs +9 -0
  10. package/templates/nextjs/base/src/app/account/page.tsx +105 -0
  11. package/templates/nextjs/base/src/app/auth/callback/page.tsx +99 -0
  12. package/templates/nextjs/base/src/app/cart/page.tsx +263 -0
  13. package/templates/nextjs/base/src/app/checkout/page.tsx +463 -0
  14. package/templates/nextjs/base/src/app/globals.css +30 -0
  15. package/templates/nextjs/base/src/app/layout.tsx.ejs +33 -0
  16. package/templates/nextjs/base/src/app/login/page.tsx +56 -0
  17. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +191 -0
  18. package/templates/nextjs/base/src/app/page.tsx +95 -0
  19. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +346 -0
  20. package/templates/nextjs/base/src/app/products/page.tsx +243 -0
  21. package/templates/nextjs/base/src/app/register/page.tsx +66 -0
  22. package/templates/nextjs/base/src/app/verify-email/page.tsx +291 -0
  23. package/templates/nextjs/base/src/components/account/order-history.tsx +184 -0
  24. package/templates/nextjs/base/src/components/account/profile-section.tsx +73 -0
  25. package/templates/nextjs/base/src/components/auth/login-form.tsx +92 -0
  26. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +134 -0
  27. package/templates/nextjs/base/src/components/auth/register-form.tsx +177 -0
  28. package/templates/nextjs/base/src/components/cart/cart-item.tsx +150 -0
  29. package/templates/nextjs/base/src/components/cart/cart-nudges.tsx +39 -0
  30. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +67 -0
  31. package/templates/nextjs/base/src/components/cart/coupon-input.tsx +131 -0
  32. package/templates/nextjs/base/src/components/cart/reservation-countdown.tsx +100 -0
  33. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +273 -0
  34. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +124 -0
  35. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +111 -0
  36. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +62 -0
  37. package/templates/nextjs/base/src/components/layout/footer.tsx +35 -0
  38. package/templates/nextjs/base/src/components/layout/header.tsx +329 -0
  39. package/templates/nextjs/base/src/components/products/discount-badge.tsx +36 -0
  40. package/templates/nextjs/base/src/components/products/product-card.tsx +94 -0
  41. package/templates/nextjs/base/src/components/products/product-grid.tsx +33 -0
  42. package/templates/nextjs/base/src/components/products/stock-badge.tsx +34 -0
  43. package/templates/nextjs/base/src/components/products/variant-selector.tsx +147 -0
  44. package/templates/nextjs/base/src/components/shared/loading-spinner.tsx +30 -0
  45. package/templates/nextjs/base/src/components/shared/price-display.tsx +62 -0
  46. package/templates/nextjs/base/src/hooks/use-search.ts +77 -0
  47. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +59 -0
  48. package/templates/nextjs/base/src/lib/utils.ts +6 -0
  49. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +168 -0
  50. package/templates/nextjs/base/tailwind.config.ts +30 -0
  51. package/templates/nextjs/base/tsconfig.json +23 -0
  52. package/templates/nextjs/themes/minimal/globals.css +30 -0
  53. package/templates/nextjs/themes/minimal/theme.json +23 -0
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@/lib/utils';
4
+
5
+ interface LoadingSpinnerProps {
6
+ size?: 'sm' | 'md' | 'lg';
7
+ className?: string;
8
+ }
9
+
10
+ const sizeClasses = {
11
+ sm: 'h-4 w-4 border-2',
12
+ md: 'h-8 w-8 border-2',
13
+ lg: 'h-12 w-12 border-3',
14
+ };
15
+
16
+ export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
17
+ return (
18
+ <div
19
+ className={cn(
20
+ 'border-muted-foreground/30 border-t-primary animate-spin rounded-full',
21
+ sizeClasses[size],
22
+ className
23
+ )}
24
+ role="status"
25
+ aria-label="Loading"
26
+ >
27
+ <span className="sr-only">Loading...</span>
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { useStoreInfo } from '@/providers/store-provider';
5
+ import { cn } from '@/lib/utils';
6
+
7
+ interface PriceDisplayProps {
8
+ price: string | number;
9
+ salePrice?: string | number | null;
10
+ currency?: string;
11
+ className?: string;
12
+ size?: 'sm' | 'md' | 'lg';
13
+ }
14
+
15
+ const sizeClasses = {
16
+ sm: 'text-sm',
17
+ md: 'text-base',
18
+ lg: 'text-xl font-semibold',
19
+ };
20
+
21
+ export function PriceDisplay({
22
+ price,
23
+ salePrice,
24
+ currency,
25
+ className,
26
+ size = 'md',
27
+ }: PriceDisplayProps) {
28
+ const { storeInfo } = useStoreInfo();
29
+ const currencyCode = currency || storeInfo?.currency || 'USD';
30
+
31
+ const basePrice = typeof price === 'string' ? parseFloat(price) : price;
32
+ const sale =
33
+ salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
34
+ const isOnSale = sale !== null && sale < basePrice;
35
+
36
+ const discountPercent =
37
+ isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
38
+
39
+ return (
40
+ <span className={cn('inline-flex items-center gap-2', sizeClasses[size], className)}>
41
+ {isOnSale ? (
42
+ <>
43
+ <span className="text-destructive font-medium">
44
+ {formatPrice(sale!, { currency: currencyCode }) as string}
45
+ </span>
46
+ <span className="text-muted-foreground text-[0.85em] line-through">
47
+ {formatPrice(basePrice, { currency: currencyCode }) as string}
48
+ </span>
49
+ {discountPercent > 0 && (
50
+ <span className="bg-destructive text-destructive-foreground rounded px-1.5 py-0.5 text-xs font-medium">
51
+ -{discountPercent}%
52
+ </span>
53
+ )}
54
+ </>
55
+ ) : (
56
+ <span className="text-foreground font-medium">
57
+ {formatPrice(basePrice, { currency: currencyCode }) as string}
58
+ </span>
59
+ )}
60
+ </span>
61
+ );
62
+ }
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+ import type { SearchSuggestions } from 'brainerce';
5
+ import { getClient } from '@/lib/brainerce';
6
+
7
+ const DEBOUNCE_MS = 300;
8
+ const MIN_QUERY_LENGTH = 2;
9
+ const SUGGESTION_LIMIT = 5;
10
+
11
+ interface UseSearchResult {
12
+ suggestions: SearchSuggestions | null;
13
+ loading: boolean;
14
+ }
15
+
16
+ export function useSearch(query: string): UseSearchResult {
17
+ const [suggestions, setSuggestions] = useState<SearchSuggestions | null>(null);
18
+ const [loading, setLoading] = useState(false);
19
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
20
+ const abortRef = useRef<AbortController | null>(null);
21
+
22
+ const fetchSuggestions = useCallback(async (searchQuery: string) => {
23
+ // Cancel any in-flight request
24
+ abortRef.current?.abort();
25
+ abortRef.current = new AbortController();
26
+
27
+ try {
28
+ setLoading(true);
29
+ const client = getClient();
30
+ const result = await client.getSearchSuggestions(searchQuery, SUGGESTION_LIMIT);
31
+ setSuggestions(result);
32
+ } catch {
33
+ // Silently ignore errors (likely aborted or network issue)
34
+ setSuggestions(null);
35
+ } finally {
36
+ setLoading(false);
37
+ }
38
+ }, []);
39
+
40
+ useEffect(() => {
41
+ // Clear previous debounce
42
+ if (debounceRef.current) {
43
+ clearTimeout(debounceRef.current);
44
+ debounceRef.current = null;
45
+ }
46
+
47
+ // Clear suggestions if query is too short
48
+ if (!query || query.trim().length < MIN_QUERY_LENGTH) {
49
+ setSuggestions(null);
50
+ setLoading(false);
51
+ return;
52
+ }
53
+
54
+ // Debounce the search
55
+ debounceRef.current = setTimeout(() => {
56
+ fetchSuggestions(query.trim());
57
+ }, DEBOUNCE_MS);
58
+
59
+ return () => {
60
+ if (debounceRef.current) {
61
+ clearTimeout(debounceRef.current);
62
+ }
63
+ };
64
+ }, [query, fetchSuggestions]);
65
+
66
+ // Cleanup on unmount
67
+ useEffect(() => {
68
+ return () => {
69
+ abortRef.current?.abort();
70
+ if (debounceRef.current) {
71
+ clearTimeout(debounceRef.current);
72
+ }
73
+ };
74
+ }, []);
75
+
76
+ return { suggestions, loading };
77
+ }
@@ -0,0 +1,59 @@
1
+ import { BrainerceClient } from 'brainerce';
2
+
3
+ const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '<%= connectionId %>';
4
+ const API_URL = process.env.NEXT_PUBLIC_BRAINERCE_API_URL || '<%= apiBaseUrl %>';
5
+
6
+ // Singleton SDK client
7
+ let clientInstance: BrainerceClient | null = null;
8
+
9
+ export function getClient(): BrainerceClient {
10
+ if (!clientInstance) {
11
+ clientInstance = new BrainerceClient({
12
+ connectionId: CONNECTION_ID,
13
+ baseUrl: API_URL,
14
+ });
15
+ }
16
+ return clientInstance;
17
+ }
18
+
19
+ // Auth token helpers
20
+ const TOKEN_KEY = 'brainerce_customer_token';
21
+ const CART_ID_KEY = 'brainerce_cart_id';
22
+
23
+ export function getStoredToken(): string | null {
24
+ if (typeof window === 'undefined') return null;
25
+ return localStorage.getItem(TOKEN_KEY);
26
+ }
27
+
28
+ export function setStoredToken(token: string | null): void {
29
+ if (typeof window === 'undefined') return;
30
+ if (token) {
31
+ localStorage.setItem(TOKEN_KEY, token);
32
+ } else {
33
+ localStorage.removeItem(TOKEN_KEY);
34
+ }
35
+ }
36
+
37
+ export function getStoredCartId(): string | null {
38
+ if (typeof window === 'undefined') return null;
39
+ return localStorage.getItem(CART_ID_KEY);
40
+ }
41
+
42
+ export function setStoredCartId(cartId: string | null): void {
43
+ if (typeof window === 'undefined') return;
44
+ if (cartId) {
45
+ localStorage.setItem(CART_ID_KEY, cartId);
46
+ } else {
47
+ localStorage.removeItem(CART_ID_KEY);
48
+ }
49
+ }
50
+
51
+ // Initialize client with stored auth
52
+ export function initClient(): BrainerceClient {
53
+ const client = getClient();
54
+ const token = getStoredToken();
55
+ if (token) {
56
+ client.setCustomerToken(token);
57
+ }
58
+ return client;
59
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,168 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
4
+ import type { StoreInfo, Cart, LocalCart } from 'brainerce';
5
+ import { getCartTotals } from 'brainerce';
6
+ import { getClient, initClient, getStoredToken, setStoredToken, setStoredCartId } from '@/lib/brainerce';
7
+
8
+ // ---- Store Info Context ----
9
+ interface StoreInfoContextValue {
10
+ storeInfo: StoreInfo | null;
11
+ loading: boolean;
12
+ }
13
+
14
+ const StoreInfoContext = createContext<StoreInfoContextValue>({
15
+ storeInfo: null,
16
+ loading: true,
17
+ });
18
+
19
+ export function useStoreInfo() {
20
+ return useContext(StoreInfoContext);
21
+ }
22
+
23
+ // ---- Auth Context ----
24
+ interface AuthContextValue {
25
+ isLoggedIn: boolean;
26
+ token: string | null;
27
+ login: (token: string) => void;
28
+ logout: () => void;
29
+ }
30
+
31
+ const AuthContext = createContext<AuthContextValue>({
32
+ isLoggedIn: false,
33
+ token: null,
34
+ login: () => {},
35
+ logout: () => {},
36
+ });
37
+
38
+ export function useAuth() {
39
+ return useContext(AuthContext);
40
+ }
41
+
42
+ // ---- Cart Context ----
43
+ interface CartContextValue {
44
+ cart: Cart | LocalCart | null;
45
+ cartLoading: boolean;
46
+ refreshCart: () => Promise<void>;
47
+ itemCount: number;
48
+ totals: { subtotal: number; discount: number; shipping: number; total: number };
49
+ isServerCart: (c: Cart | LocalCart | null) => c is Cart;
50
+ }
51
+
52
+ const CartContext = createContext<CartContextValue>({
53
+ cart: null,
54
+ cartLoading: true,
55
+ refreshCart: async () => {},
56
+ itemCount: 0,
57
+ totals: { subtotal: 0, discount: 0, shipping: 0, total: 0 },
58
+ isServerCart: (_c): _c is Cart => false,
59
+ });
60
+
61
+ export function useCart() {
62
+ return useContext(CartContext);
63
+ }
64
+
65
+ // ---- Provider Component ----
66
+ export function StoreProvider({ children }: { children: React.ReactNode }) {
67
+ const [storeInfo, setStoreInfo] = useState<StoreInfo | null>(null);
68
+ const [storeLoading, setStoreLoading] = useState(true);
69
+ const [token, setToken] = useState<string | null>(null);
70
+ const [cart, setCart] = useState<Cart | LocalCart | null>(null);
71
+ const [cartLoading, setCartLoading] = useState(true);
72
+
73
+ // Initialize client and auth
74
+ useEffect(() => {
75
+ const client = initClient();
76
+ const stored = getStoredToken();
77
+ if (stored) {
78
+ setToken(stored);
79
+ }
80
+
81
+ client
82
+ .getStoreInfo()
83
+ .then(setStoreInfo)
84
+ .catch(console.error)
85
+ .finally(() => setStoreLoading(false));
86
+ }, []);
87
+
88
+ // Cart management
89
+ const refreshCart = useCallback(async () => {
90
+ try {
91
+ setCartLoading(true);
92
+ const client = getClient();
93
+ const c = await client.smartGetCart();
94
+ setCart(c);
95
+
96
+ // Persist server cart ID
97
+ if (c && 'id' in c) {
98
+ setStoredCartId(c.id);
99
+ }
100
+ } catch (err) {
101
+ console.error('Failed to load cart:', err);
102
+ } finally {
103
+ setCartLoading(false);
104
+ }
105
+ }, []);
106
+
107
+ useEffect(() => {
108
+ refreshCart();
109
+ }, [refreshCart, token]);
110
+
111
+ const login = useCallback((newToken: string) => {
112
+ const client = getClient();
113
+ client.setCustomerToken(newToken);
114
+ setStoredToken(newToken);
115
+ setToken(newToken);
116
+
117
+ // Sync local cart to server
118
+ client.syncCartOnLogin().catch(console.error);
119
+ }, []);
120
+
121
+ const logout = useCallback(() => {
122
+ const client = getClient();
123
+ client.clearCustomerToken();
124
+ setStoredToken(null);
125
+ setToken(null);
126
+ setCart(null);
127
+ refreshCart();
128
+ }, [refreshCart]);
129
+
130
+ const isServerCart = (c: Cart | LocalCart | null): c is Cart => {
131
+ return c !== null && 'id' in c;
132
+ };
133
+
134
+ const itemCount = cart
135
+ ? cart.items.reduce((sum, item) => sum + item.quantity, 0)
136
+ : 0;
137
+
138
+ const totals = isServerCart(cart)
139
+ ? getCartTotals(cart)
140
+ : {
141
+ subtotal: cart
142
+ ? cart.items.reduce(
143
+ (sum, item) => sum + parseFloat(String(item.price || '0')) * item.quantity,
144
+ 0
145
+ )
146
+ : 0,
147
+ discount: 0,
148
+ shipping: 0,
149
+ total: cart
150
+ ? cart.items.reduce(
151
+ (sum, item) => sum + parseFloat(String(item.price || '0')) * item.quantity,
152
+ 0
153
+ )
154
+ : 0,
155
+ };
156
+
157
+ return (
158
+ <StoreInfoContext.Provider value={{ storeInfo, loading: storeLoading }}>
159
+ <AuthContext.Provider value={{ isLoggedIn: !!token, token, login, logout }}>
160
+ <CartContext.Provider
161
+ value={{ cart, cartLoading, refreshCart, itemCount, totals, isServerCart }}
162
+ >
163
+ {children}
164
+ </CartContext.Provider>
165
+ </AuthContext.Provider>
166
+ </StoreInfoContext.Provider>
167
+ );
168
+ }
@@ -0,0 +1,30 @@
1
+ import type { Config } from 'tailwindcss';
2
+
3
+ const config: Config = {
4
+ content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
5
+ theme: {
6
+ extend: {
7
+ colors: {
8
+ primary: 'hsl(var(--primary))',
9
+ 'primary-foreground': 'hsl(var(--primary-foreground))',
10
+ secondary: 'hsl(var(--secondary))',
11
+ 'secondary-foreground': 'hsl(var(--secondary-foreground))',
12
+ background: 'hsl(var(--background))',
13
+ foreground: 'hsl(var(--foreground))',
14
+ muted: 'hsl(var(--muted))',
15
+ 'muted-foreground': 'hsl(var(--muted-foreground))',
16
+ border: 'hsl(var(--border))',
17
+ destructive: 'hsl(var(--destructive))',
18
+ 'destructive-foreground': 'hsl(var(--destructive-foreground))',
19
+ accent: 'hsl(var(--accent))',
20
+ 'accent-foreground': 'hsl(var(--accent-foreground))',
21
+ },
22
+ borderRadius: {
23
+ DEFAULT: 'var(--radius)',
24
+ },
25
+ },
26
+ },
27
+ plugins: [],
28
+ };
29
+
30
+ export default config;
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": {
18
+ "@/*": ["./src/*"]
19
+ }
20
+ },
21
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
22
+ "exclude": ["node_modules"]
23
+ }
@@ -0,0 +1,30 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 0 0% 100%;
8
+ --foreground: 0 0% 9%;
9
+ --primary: 0 0% 9%;
10
+ --primary-foreground: 0 0% 98%;
11
+ --secondary: 0 0% 96%;
12
+ --secondary-foreground: 0 0% 9%;
13
+ --muted: 0 0% 96%;
14
+ --muted-foreground: 0 0% 45%;
15
+ --accent: 0 0% 96%;
16
+ --accent-foreground: 0 0% 9%;
17
+ --destructive: 0 84% 60%;
18
+ --destructive-foreground: 0 0% 98%;
19
+ --border: 0 0% 90%;
20
+ --radius: 0.5rem;
21
+ }
22
+
23
+ * {
24
+ @apply border-border;
25
+ }
26
+
27
+ body {
28
+ @apply bg-background text-foreground antialiased;
29
+ }
30
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "Minimal",
3
+ "description": "Clean, neutral design with Inter font and medium radius",
4
+ "font": {
5
+ "family": "Inter",
6
+ "import": "next/font/google"
7
+ },
8
+ "colors": {
9
+ "background": "0 0% 100%",
10
+ "foreground": "0 0% 9%",
11
+ "primary": "0 0% 9%",
12
+ "primary-foreground": "0 0% 98%",
13
+ "secondary": "0 0% 96%",
14
+ "secondary-foreground": "0 0% 9%",
15
+ "muted": "0 0% 96%",
16
+ "muted-foreground": "0 0% 45%",
17
+ "accent": "0 0% 96%",
18
+ "accent-foreground": "0 0% 9%",
19
+ "destructive": "0 84% 60%",
20
+ "border": "0 0% 90%"
21
+ },
22
+ "radius": "0.5rem"
23
+ }