bestraw 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 (66) hide show
  1. package/index.mjs +436 -0
  2. package/package.json +17 -0
  3. package/templates/.env.example +51 -0
  4. package/templates/Caddyfile +21 -0
  5. package/templates/docker-compose.yml +80 -0
  6. package/templates/web/Dockerfile +19 -0
  7. package/templates/web/next-env.d.ts +6 -0
  8. package/templates/web/next.config.ts +10 -0
  9. package/templates/web/node_modules/.bin/next +17 -0
  10. package/templates/web/node_modules/.bin/tsc +17 -0
  11. package/templates/web/node_modules/.bin/tsserver +17 -0
  12. package/templates/web/package.json +28 -0
  13. package/templates/web/postcss.config.mjs +8 -0
  14. package/templates/web/public/images/.gitkeep +0 -0
  15. package/templates/web/src/app/[locale]/auth/page.tsx +222 -0
  16. package/templates/web/src/app/[locale]/blog/[slug]/page.tsx +104 -0
  17. package/templates/web/src/app/[locale]/blog/page.tsx +90 -0
  18. package/templates/web/src/app/[locale]/error.tsx +41 -0
  19. package/templates/web/src/app/[locale]/info/page.tsx +186 -0
  20. package/templates/web/src/app/[locale]/layout.tsx +86 -0
  21. package/templates/web/src/app/[locale]/loyalty/page.tsx +135 -0
  22. package/templates/web/src/app/[locale]/menu/page.tsx +69 -0
  23. package/templates/web/src/app/[locale]/order/cart/page.tsx +199 -0
  24. package/templates/web/src/app/[locale]/order/checkout/page.tsx +489 -0
  25. package/templates/web/src/app/[locale]/order/confirmation/[id]/page.tsx +159 -0
  26. package/templates/web/src/app/[locale]/order/page.tsx +207 -0
  27. package/templates/web/src/app/[locale]/page.tsx +119 -0
  28. package/templates/web/src/app/globals.css +11 -0
  29. package/templates/web/src/app/robots.ts +14 -0
  30. package/templates/web/src/app/sitemap.ts +56 -0
  31. package/templates/web/src/bestraw.config.ts +9 -0
  32. package/templates/web/src/components/auth/OtpForm.tsx +98 -0
  33. package/templates/web/src/components/blog/ArticleCard.tsx +67 -0
  34. package/templates/web/src/components/blog/ArticleContent.tsx +14 -0
  35. package/templates/web/src/components/cart/CartDrawer.tsx +152 -0
  36. package/templates/web/src/components/cart/CartItem.tsx +111 -0
  37. package/templates/web/src/components/checkout/StripePaymentForm.tsx +54 -0
  38. package/templates/web/src/components/layout/Footer.tsx +40 -0
  39. package/templates/web/src/components/layout/Header.tsx +240 -0
  40. package/templates/web/src/components/layout/LocaleSwitcher.tsx +34 -0
  41. package/templates/web/src/components/loyalty/PointsBalance.tsx +96 -0
  42. package/templates/web/src/components/loyalty/RewardCard.tsx +73 -0
  43. package/templates/web/src/components/loyalty/TransactionHistory.tsx +108 -0
  44. package/templates/web/src/components/menu/CategorySection.tsx +42 -0
  45. package/templates/web/src/components/menu/MealCard.tsx +55 -0
  46. package/templates/web/src/components/menu/MealDetailModal.tsx +355 -0
  47. package/templates/web/src/components/menu/MenuContent.tsx +216 -0
  48. package/templates/web/src/components/order/MealOrderCard.tsx +220 -0
  49. package/templates/web/src/components/order/OrderStatusTracker.tsx +138 -0
  50. package/templates/web/src/components/order/PaymentStatus.tsx +62 -0
  51. package/templates/web/src/components/ui/Button.tsx +40 -0
  52. package/templates/web/src/components/ui/ErrorAlert.tsx +15 -0
  53. package/templates/web/src/i18n/config.ts +3 -0
  54. package/templates/web/src/i18n/request.ts +13 -0
  55. package/templates/web/src/i18n/routing.ts +10 -0
  56. package/templates/web/src/lib/client.ts +5 -0
  57. package/templates/web/src/lib/errors.ts +31 -0
  58. package/templates/web/src/lib/features.ts +10 -0
  59. package/templates/web/src/lib/hooks/useCustomerClient.ts +28 -0
  60. package/templates/web/src/lib/hooks/useMenu.ts +46 -0
  61. package/templates/web/src/messages/en.json +283 -0
  62. package/templates/web/src/messages/fr.json +283 -0
  63. package/templates/web/src/middleware.ts +8 -0
  64. package/templates/web/src/providers/CartProvider.tsx +162 -0
  65. package/templates/web/src/providers/StripeProvider.tsx +21 -0
  66. package/templates/web/tsconfig.json +27 -0
@@ -0,0 +1,220 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useTranslations } from 'next-intl';
5
+ import { useCart } from '@/providers/CartProvider';
6
+ import type { Meal } from 'bestraw-sdk';
7
+
8
+ interface MealOrderCardProps {
9
+ meal: Meal;
10
+ apiUrl: string;
11
+ onShowDetail?: () => void;
12
+ }
13
+
14
+ function formatPrice(price: number): string {
15
+ return price.toFixed(2).replace('.', ',') + ' \u20AC';
16
+ }
17
+
18
+ export function MealOrderCard({ meal, apiUrl, onShowDetail }: MealOrderCardProps) {
19
+ const t = useTranslations('order');
20
+ const { addItem } = useCart();
21
+ const [quantity, setQuantity] = useState(1);
22
+ const [selectedSauces, setSelectedSauces] = useState<string[]>([]);
23
+ const [selectedSides, setSelectedSides] = useState<string[]>([]);
24
+ const [specialInstructions, setSpecialInstructions] = useState('');
25
+ const [showDetails, setShowDetails] = useState(false);
26
+
27
+ const picture = meal.pictures?.[0];
28
+ const imageUrl = picture ? `${apiUrl}${picture.url}` : null;
29
+ const hasSauces = meal.sauces && meal.sauces.length > 0;
30
+ const hasSides = meal.sides && meal.sides.length > 0;
31
+ const hasOptions = hasSauces || hasSides;
32
+
33
+ function handleSauceToggle(sauceName: string) {
34
+ setSelectedSauces((prev) =>
35
+ prev.includes(sauceName)
36
+ ? prev.filter((s) => s !== sauceName)
37
+ : [...prev, sauceName]
38
+ );
39
+ }
40
+
41
+ function handleSideToggle(sideName: string) {
42
+ setSelectedSides((prev) =>
43
+ prev.includes(sideName)
44
+ ? prev.filter((s) => s !== sideName)
45
+ : [...prev, sideName]
46
+ );
47
+ }
48
+
49
+ function handleAddToCart() {
50
+ addItem({
51
+ mealDocumentId: meal.documentId,
52
+ mealName: meal.name,
53
+ unitPrice: meal.price,
54
+ quantity,
55
+ selectedSauces,
56
+ selectedSides,
57
+ specialInstructions: specialInstructions.trim(),
58
+ picture: imageUrl || undefined,
59
+ });
60
+
61
+ // Reset form
62
+ setQuantity(1);
63
+ setSelectedSauces([]);
64
+ setSelectedSides([]);
65
+ setSpecialInstructions('');
66
+ setShowDetails(false);
67
+ }
68
+
69
+ return (
70
+ <div className="bg-white rounded-lg border border-gray-100 overflow-hidden hover:shadow-md transition-shadow">
71
+ {imageUrl && (
72
+ <div className="aspect-[4/3] overflow-hidden bg-gray-100">
73
+ <img
74
+ src={imageUrl}
75
+ alt={picture?.alternativeText || meal.name}
76
+ className="w-full h-full object-cover"
77
+ />
78
+ </div>
79
+ )}
80
+
81
+ <div className="p-4">
82
+ <div className="flex items-start justify-between gap-2">
83
+ <h3 className="font-semibold text-[var(--color-primary)]">
84
+ {meal.name}
85
+ </h3>
86
+ <span className="text-sm font-medium text-[var(--color-accent)] whitespace-nowrap">
87
+ {formatPrice(meal.price)}
88
+ </span>
89
+ </div>
90
+
91
+ {meal.description && (
92
+ <p className="mt-1 text-sm text-gray-600 line-clamp-2">
93
+ {meal.description}
94
+ </p>
95
+ )}
96
+
97
+ {/* Options toggle */}
98
+ {hasOptions && (
99
+ <button
100
+ type="button"
101
+ onClick={() => setShowDetails(!showDetails)}
102
+ className="mt-2 text-xs text-[var(--color-accent)] hover:underline"
103
+ >
104
+ {showDetails ? t('hideOptions') : t('showOptions')}
105
+ </button>
106
+ )}
107
+
108
+ {/* Expanded options */}
109
+ {showDetails && (
110
+ <div className="mt-3 space-y-3">
111
+ {hasSauces && (
112
+ <div>
113
+ <p className="text-xs font-medium text-gray-700 mb-1">
114
+ {t('sauces')}
115
+ </p>
116
+ <div className="space-y-1">
117
+ {meal.sauces!.map((sauce) => (
118
+ <label
119
+ key={sauce.name}
120
+ className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer"
121
+ >
122
+ <input
123
+ type="checkbox"
124
+ checked={selectedSauces.includes(sauce.name)}
125
+ onChange={() => handleSauceToggle(sauce.name)}
126
+ className="rounded border-gray-300 text-[var(--color-accent)] focus:ring-[var(--color-accent)]"
127
+ />
128
+ {sauce.name}
129
+ </label>
130
+ ))}
131
+ </div>
132
+ </div>
133
+ )}
134
+
135
+ {hasSides && (
136
+ <div>
137
+ <p className="text-xs font-medium text-gray-700 mb-1">
138
+ {t('sides')}
139
+ </p>
140
+ <div className="space-y-1">
141
+ {meal.sides!.map((side) => (
142
+ <label
143
+ key={side.name}
144
+ className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer"
145
+ >
146
+ <input
147
+ type="checkbox"
148
+ checked={selectedSides.includes(side.name)}
149
+ onChange={() => handleSideToggle(side.name)}
150
+ className="rounded border-gray-300 text-[var(--color-accent)] focus:ring-[var(--color-accent)]"
151
+ />
152
+ {side.name}
153
+ </label>
154
+ ))}
155
+ </div>
156
+ </div>
157
+ )}
158
+ </div>
159
+ )}
160
+
161
+ {/* Special instructions */}
162
+ <div className="mt-3">
163
+ <input
164
+ type="text"
165
+ placeholder={t('specialInstructions')}
166
+ value={specialInstructions}
167
+ onChange={(e) => setSpecialInstructions(e.target.value)}
168
+ className="w-full text-sm px-3 py-1.5 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)] focus:border-[var(--color-accent)]"
169
+ />
170
+ </div>
171
+
172
+ {/* Quantity + Detail + Add to cart */}
173
+ <div className="mt-3 flex items-center gap-2">
174
+ <div className="flex items-center border border-gray-200 rounded-md">
175
+ <button
176
+ type="button"
177
+ onClick={() => setQuantity(Math.max(1, quantity - 1))}
178
+ className="px-2 py-1 text-gray-500 hover:text-gray-700"
179
+ aria-label={t('decreaseQuantity')}
180
+ >
181
+ -
182
+ </button>
183
+ <span className="px-3 py-1 text-sm font-medium min-w-[2rem] text-center">
184
+ {quantity}
185
+ </span>
186
+ <button
187
+ type="button"
188
+ onClick={() => setQuantity(quantity + 1)}
189
+ className="px-2 py-1 text-gray-500 hover:text-gray-700"
190
+ aria-label={t('increaseQuantity')}
191
+ >
192
+ +
193
+ </button>
194
+ </div>
195
+
196
+ {onShowDetail && (
197
+ <button
198
+ type="button"
199
+ onClick={onShowDetail}
200
+ className="p-2 rounded-md border border-gray-200 text-gray-500 hover:text-[var(--color-primary)] hover:border-[var(--color-primary)] transition-colors"
201
+ aria-label={t('viewDetails')}
202
+ >
203
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
204
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
205
+ </svg>
206
+ </button>
207
+ )}
208
+
209
+ <button
210
+ type="button"
211
+ onClick={handleAddToCart}
212
+ className="flex-1 px-4 py-2 rounded-md bg-[var(--color-accent)] text-white text-sm font-medium hover:opacity-90 transition-opacity"
213
+ >
214
+ {t('addToCart')}
215
+ </button>
216
+ </div>
217
+ </div>
218
+ </div>
219
+ );
220
+ }
@@ -0,0 +1,138 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useTranslations } from 'next-intl';
5
+ import type { OrderStatus } from 'bestraw-sdk';
6
+
7
+ interface OrderStatusTrackerProps {
8
+ orderId: string;
9
+ initialStatus: string;
10
+ apiUrl: string;
11
+ }
12
+
13
+ const STATUS_STEPS: OrderStatus[] = [
14
+ 'pending',
15
+ 'confirmed',
16
+ 'preparing',
17
+ 'ready',
18
+ 'completed',
19
+ ];
20
+
21
+ export function OrderStatusTracker({
22
+ orderId,
23
+ initialStatus,
24
+ apiUrl,
25
+ }: OrderStatusTrackerProps) {
26
+ const t = useTranslations('orderStatus');
27
+ const [currentStatus, setCurrentStatus] = useState<string>(initialStatus);
28
+
29
+ useEffect(() => {
30
+ const eventSource = new EventSource(
31
+ `${apiUrl}/api/realtime/events`
32
+ );
33
+
34
+ function handleStatusChanged(event: MessageEvent) {
35
+ try {
36
+ const data = JSON.parse(event.data);
37
+ if (data.order?.documentId === orderId && data.order?.status) {
38
+ setCurrentStatus(data.order.status);
39
+ }
40
+ } catch {
41
+ // Ignore parse errors
42
+ }
43
+ }
44
+
45
+ function handleCancelled(event: MessageEvent) {
46
+ try {
47
+ const data = JSON.parse(event.data);
48
+ if (data.order?.documentId === orderId) {
49
+ setCurrentStatus('cancelled');
50
+ }
51
+ } catch {
52
+ // Ignore parse errors
53
+ }
54
+ }
55
+
56
+ eventSource.addEventListener('order.statusChanged', handleStatusChanged);
57
+ eventSource.addEventListener('order.cancelled', handleCancelled);
58
+
59
+ return () => {
60
+ eventSource.close();
61
+ };
62
+ }, [orderId, apiUrl]);
63
+
64
+ const currentIndex = STATUS_STEPS.indexOf(currentStatus as OrderStatus);
65
+ const isCancelled = currentStatus === 'cancelled';
66
+
67
+ return (
68
+ <div className="py-4">
69
+ {isCancelled ? (
70
+ <div className="text-center p-4 bg-red-50 rounded-lg border border-red-200">
71
+ <p className="text-red-600 font-medium">{t('cancelled')}</p>
72
+ </div>
73
+ ) : (
74
+ <div className="flex items-center justify-between">
75
+ {STATUS_STEPS.map((step, index) => {
76
+ const isCompleted = index < currentIndex;
77
+ const isActive = index === currentIndex;
78
+
79
+ return (
80
+ <div key={step} className="flex-1 flex flex-col items-center relative">
81
+ {/* Connector line */}
82
+ {index > 0 && (
83
+ <div
84
+ className={`absolute top-4 right-1/2 w-full h-0.5 -translate-y-1/2 ${
85
+ index <= currentIndex ? 'bg-[var(--color-accent)]' : 'bg-gray-200'
86
+ }`}
87
+ />
88
+ )}
89
+
90
+ {/* Step circle */}
91
+ <div
92
+ className={`relative z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-all ${
93
+ isCompleted
94
+ ? 'bg-[var(--color-accent)] text-white'
95
+ : isActive
96
+ ? 'bg-[var(--color-accent)] text-white ring-4 ring-[var(--color-accent)]/20'
97
+ : 'bg-gray-200 text-gray-500'
98
+ }`}
99
+ >
100
+ {isCompleted ? (
101
+ <svg
102
+ className="w-4 h-4"
103
+ fill="none"
104
+ stroke="currentColor"
105
+ viewBox="0 0 24 24"
106
+ >
107
+ <path
108
+ strokeLinecap="round"
109
+ strokeLinejoin="round"
110
+ strokeWidth={2}
111
+ d="M5 13l4 4L19 7"
112
+ />
113
+ </svg>
114
+ ) : (
115
+ <span>{index + 1}</span>
116
+ )}
117
+ </div>
118
+
119
+ {/* Step label */}
120
+ <span
121
+ className={`mt-2 text-xs text-center ${
122
+ isActive
123
+ ? 'font-semibold text-[var(--color-accent)]'
124
+ : isCompleted
125
+ ? 'font-medium text-gray-700'
126
+ : 'text-gray-400'
127
+ }`}
128
+ >
129
+ {t(step)}
130
+ </span>
131
+ </div>
132
+ );
133
+ })}
134
+ </div>
135
+ )}
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { loadStripe } from '@stripe/stripe-js';
5
+ import { useTranslations } from 'next-intl';
6
+
7
+ const stripePromise = loadStripe(
8
+ process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
9
+ );
10
+
11
+ type Status = 'loading' | 'succeeded' | 'processing' | 'failed';
12
+
13
+ interface PaymentStatusProps {
14
+ clientSecret: string;
15
+ }
16
+
17
+ export function PaymentStatus({ clientSecret }: PaymentStatusProps) {
18
+ const t = useTranslations('confirmation');
19
+ const [status, setStatus] = useState<Status>('loading');
20
+
21
+ useEffect(() => {
22
+ stripePromise.then(async (stripe) => {
23
+ if (!stripe) return;
24
+ const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret);
25
+ if (!paymentIntent) {
26
+ setStatus('failed');
27
+ return;
28
+ }
29
+ switch (paymentIntent.status) {
30
+ case 'succeeded':
31
+ setStatus('succeeded');
32
+ break;
33
+ case 'processing':
34
+ setStatus('processing');
35
+ break;
36
+ default:
37
+ setStatus('failed');
38
+ }
39
+ });
40
+ }, [clientSecret]);
41
+
42
+ if (status === 'loading') return null;
43
+
44
+ if (status === 'failed') {
45
+ return (
46
+ <div className="p-4 bg-red-50 rounded-lg border border-red-200 text-center mb-8">
47
+ <p className="text-red-600 font-medium">{t('paymentFailed')}</p>
48
+ <p className="text-red-500 text-sm mt-1">{t('paymentFailedHint')}</p>
49
+ </div>
50
+ );
51
+ }
52
+
53
+ if (status === 'processing') {
54
+ return (
55
+ <div className="p-4 bg-amber-50 rounded-lg border border-amber-200 text-center mb-8">
56
+ <p className="text-amber-600 font-medium">{t('paymentProcessing')}</p>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ return null;
62
+ }
@@ -0,0 +1,40 @@
1
+ type ButtonVariant = 'primary' | 'secondary' | 'outline';
2
+
3
+ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
4
+ variant?: ButtonVariant;
5
+ children: React.ReactNode;
6
+ href?: string;
7
+ }
8
+
9
+ const variantClasses: Record<ButtonVariant, string> = {
10
+ primary:
11
+ 'bg-[var(--color-primary)] text-white hover:opacity-90',
12
+ secondary:
13
+ 'bg-[var(--color-accent)] text-white hover:opacity-90',
14
+ outline:
15
+ 'border-2 border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white',
16
+ };
17
+
18
+ export function Button({
19
+ variant = 'primary',
20
+ children,
21
+ href,
22
+ className = '',
23
+ ...props
24
+ }: ButtonProps) {
25
+ const classes = `inline-block px-6 py-3 rounded-md font-medium transition-all duration-200 text-center ${variantClasses[variant]} ${className}`;
26
+
27
+ if (href) {
28
+ return (
29
+ <a href={href} className={classes}>
30
+ {children}
31
+ </a>
32
+ );
33
+ }
34
+
35
+ return (
36
+ <button className={classes} {...props}>
37
+ {children}
38
+ </button>
39
+ );
40
+ }
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+
3
+ interface ErrorAlertProps {
4
+ message: string;
5
+ }
6
+
7
+ export function ErrorAlert({ message }: ErrorAlertProps) {
8
+ if (!message) return null;
9
+
10
+ return (
11
+ <div className="p-3 bg-red-50 border border-red-200 rounded-md">
12
+ <p className="text-sm text-red-600">{message}</p>
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,3 @@
1
+ export const locales = ['fr', 'en'] as const;
2
+ export type Locale = (typeof locales)[number];
3
+ export const defaultLocale: Locale = 'fr';
@@ -0,0 +1,13 @@
1
+ import { getRequestConfig } from 'next-intl/server';
2
+ import { routing } from './routing';
3
+
4
+ export default getRequestConfig(async ({ requestLocale }) => {
5
+ let locale = await requestLocale;
6
+ if (!locale || !routing.locales.includes(locale as any)) {
7
+ locale = routing.defaultLocale;
8
+ }
9
+ return {
10
+ locale,
11
+ messages: (await import(`../messages/${locale}.json`)).default,
12
+ };
13
+ });
@@ -0,0 +1,10 @@
1
+ import { defineRouting } from 'next-intl/routing';
2
+ import { createNavigation } from 'next-intl/navigation';
3
+
4
+ export const routing = defineRouting({
5
+ locales: ['fr', 'en'],
6
+ defaultLocale: 'fr',
7
+ });
8
+
9
+ export const { Link, redirect, usePathname, useRouter } =
10
+ createNavigation(routing);
@@ -0,0 +1,5 @@
1
+ import { BestrawClient } from 'bestraw-sdk';
2
+
3
+ export const bestraw = new BestrawClient({
4
+ baseUrl: process.env.API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1338',
5
+ });
@@ -0,0 +1,31 @@
1
+ import { BestrawError } from 'bestraw-sdk';
2
+
3
+ /**
4
+ * Extracts an error code from any caught error.
5
+ * Returns the structured code from BestrawError, or 'UNKNOWN'.
6
+ */
7
+ export function getErrorCode(error: unknown): string {
8
+ if (error instanceof BestrawError && error.code) {
9
+ return error.code;
10
+ }
11
+ return 'UNKNOWN';
12
+ }
13
+
14
+ /**
15
+ * Maps a caught error to a user-facing translated message.
16
+ * Uses the error code to look up the i18n key under "errors.<CODE>".
17
+ *
18
+ * @param error - The caught error (BestrawError, Error, or unknown)
19
+ * @param t - The next-intl translation function scoped to "errors"
20
+ * @returns A localized, user-friendly error message
21
+ */
22
+ export function getErrorMessage(
23
+ error: unknown,
24
+ t: { (key: string): string; has(key: string): boolean },
25
+ ): string {
26
+ const code = getErrorCode(error);
27
+ if (t.has(code)) {
28
+ return t(code);
29
+ }
30
+ return t('unknown');
31
+ }
@@ -0,0 +1,10 @@
1
+ import { config } from '@/bestraw.config';
2
+
3
+ export const features = config.features;
4
+ export const hasOrdering = features.ordering;
5
+ export const hasLoyalty = features.loyalty;
6
+ export const hasPayments = features.payments;
7
+ export const hasPhoneAuth = features.auth.phone;
8
+ export const hasEmailAuth = features.auth.email;
9
+ export const hasAuth = hasPhoneAuth || hasEmailAuth;
10
+ export const hasBlog = features.blog;
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+ import { BestrawClient } from 'bestraw-sdk';
5
+
6
+ const STORAGE_KEY = 'bestraw-customer-token';
7
+
8
+ export function getCustomerToken(): string | null {
9
+ if (typeof window === 'undefined') return null;
10
+ return localStorage.getItem(STORAGE_KEY);
11
+ }
12
+
13
+ export function setCustomerToken(token: string): void {
14
+ localStorage.setItem(STORAGE_KEY, token);
15
+ }
16
+
17
+ export function removeCustomerToken(): void {
18
+ localStorage.removeItem(STORAGE_KEY);
19
+ }
20
+
21
+ export function useCustomerClient(): BestrawClient {
22
+ return useMemo(() => {
23
+ return new BestrawClient({
24
+ baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1338',
25
+ getAuthToken: () => getCustomerToken(),
26
+ });
27
+ }, []);
28
+ }
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import type { Variant } from 'bestraw-sdk';
5
+ import { BestrawClient } from 'bestraw-sdk';
6
+
7
+ const client = new BestrawClient({
8
+ baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337',
9
+ });
10
+
11
+ interface UseMenuResult {
12
+ variant: Variant | null;
13
+ loading: boolean;
14
+ error: string | null;
15
+ refetch: () => void;
16
+ }
17
+
18
+ export function useMenu(menuDocumentId: string): UseMenuResult {
19
+ const [variant, setVariant] = useState<Variant | null>(null);
20
+ const [loading, setLoading] = useState(true);
21
+ const [error, setError] = useState<string | null>(null);
22
+
23
+ const fetch = () => {
24
+ setLoading(true);
25
+ setError(null);
26
+
27
+ client.menu
28
+ .getActiveVariant(menuDocumentId)
29
+ .then((data) => {
30
+ setVariant(data);
31
+ })
32
+ .catch((err) => {
33
+ setError(err instanceof Error ? err.message : 'Unknown error');
34
+ })
35
+ .finally(() => {
36
+ setLoading(false);
37
+ });
38
+ };
39
+
40
+ useEffect(() => {
41
+ fetch();
42
+ // eslint-disable-next-line react-hooks/exhaustive-deps
43
+ }, [menuDocumentId]);
44
+
45
+ return { variant, loading, error, refetch: fetch };
46
+ }