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,96 @@
1
+ 'use client';
2
+
3
+ import { useTranslations } from 'next-intl';
4
+
5
+ interface PointsBalanceProps {
6
+ points: number;
7
+ tier: string;
8
+ totalEarned: number;
9
+ totalRedeemed: number;
10
+ }
11
+
12
+ const tierColors: Record<string, string> = {
13
+ bronze: '#CD7F32',
14
+ silver: '#C0C0C0',
15
+ gold: '#FFD700',
16
+ };
17
+
18
+ const tierThresholds: Record<string, number> = {
19
+ bronze: 1000,
20
+ silver: 5000,
21
+ };
22
+
23
+ export function PointsBalance({
24
+ points = 0,
25
+ tier = 'bronze',
26
+ totalEarned = 0,
27
+ totalRedeemed = 0,
28
+ }: PointsBalanceProps) {
29
+ const t = useTranslations('loyalty');
30
+
31
+ const tierKey = tier.toLowerCase();
32
+ const tierColor = tierColors[tierKey] || tierColors.bronze;
33
+ const tierLabel =
34
+ t(tierKey as 'bronze' | 'silver' | 'gold');
35
+
36
+ const nextTierThreshold = tierThresholds[tierKey];
37
+ const progress = nextTierThreshold
38
+ ? Math.min((totalEarned / nextTierThreshold) * 100, 100)
39
+ : 100;
40
+
41
+ return (
42
+ <div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
43
+ <div className="text-center mb-6">
44
+ <p className="text-5xl font-bold text-[var(--color-primary)]">
45
+ {points.toLocaleString()}
46
+ </p>
47
+ <p className="text-gray-500 mt-1">{t('points')}</p>
48
+ </div>
49
+
50
+ <div className="flex justify-center mb-6">
51
+ <span
52
+ className="inline-flex items-center px-4 py-1.5 rounded-full text-sm font-semibold text-white"
53
+ style={{ backgroundColor: tierColor }}
54
+ >
55
+ {t('tier')}: {tierLabel}
56
+ </span>
57
+ </div>
58
+
59
+ {nextTierThreshold && (
60
+ <div className="mb-6">
61
+ <div className="flex justify-between text-xs text-gray-500 mb-1">
62
+ <span>{tierLabel}</span>
63
+ <span>
64
+ {t('nextTier')}{' '}
65
+ {(nextTierThreshold - totalEarned).toLocaleString()} {t('points')}
66
+ </span>
67
+ </div>
68
+ <div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
69
+ <div
70
+ className="h-full rounded-full transition-all duration-500"
71
+ style={{
72
+ width: `${progress}%`,
73
+ backgroundColor: tierColor,
74
+ }}
75
+ />
76
+ </div>
77
+ </div>
78
+ )}
79
+
80
+ <div className="grid grid-cols-2 gap-4 text-center">
81
+ <div className="bg-gray-50 rounded-lg p-3">
82
+ <p className="text-lg font-semibold text-green-600">
83
+ {totalEarned.toLocaleString()}
84
+ </p>
85
+ <p className="text-xs text-gray-500">{t('totalEarned')}</p>
86
+ </div>
87
+ <div className="bg-gray-50 rounded-lg p-3">
88
+ <p className="text-lg font-semibold text-red-500">
89
+ {totalRedeemed.toLocaleString()}
90
+ </p>
91
+ <p className="text-xs text-gray-500">{t('totalRedeemed')}</p>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,73 @@
1
+ 'use client';
2
+
3
+ import { useTranslations } from 'next-intl';
4
+
5
+ interface Reward {
6
+ name: string;
7
+ description: string;
8
+ pointsCost: number;
9
+ type: string;
10
+ value: number;
11
+ active: boolean;
12
+ }
13
+
14
+ interface RewardCardProps {
15
+ reward: Reward;
16
+ currentPoints: number;
17
+ onRedeem?: () => void;
18
+ checkoutMode?: boolean;
19
+ }
20
+
21
+ export function RewardCard({ reward, currentPoints, onRedeem, checkoutMode }: RewardCardProps) {
22
+ const t = useTranslations('loyalty');
23
+
24
+ const canRedeem = reward.active && currentPoints >= reward.pointsCost;
25
+
26
+ function getTypeLabel() {
27
+ switch (reward.type) {
28
+ case 'free_item':
29
+ return t('freeItem');
30
+ case 'percent_discount':
31
+ return t('discountPercent', { value: reward.value });
32
+ case 'fixed_discount':
33
+ return t('discountFixed', { value: reward.value });
34
+ default:
35
+ return reward.type;
36
+ }
37
+ }
38
+
39
+ return (
40
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5 flex flex-col">
41
+ <div className="flex-1">
42
+ <div className="flex items-start justify-between mb-2">
43
+ <h3 className="font-semibold text-gray-900">{reward.name}</h3>
44
+ <span className="text-xs font-medium px-2 py-1 bg-gray-100 text-gray-600 rounded-full whitespace-nowrap ml-2">
45
+ {getTypeLabel()}
46
+ </span>
47
+ </div>
48
+ <p className="text-sm text-gray-500 mb-4">{reward.description}</p>
49
+ </div>
50
+
51
+ <div className="flex items-center justify-between pt-3 border-t border-gray-100">
52
+ <span className="text-sm font-semibold text-[var(--color-primary)]">
53
+ {t('pointsCost', { cost: reward.pointsCost })}
54
+ </span>
55
+ {checkoutMode ? (
56
+ <span className="px-4 py-2 text-sm font-medium text-[var(--color-accent)]">
57
+ {t('useAtCheckout')}
58
+ </span>
59
+ ) : (
60
+ <button
61
+ onClick={onRedeem}
62
+ disabled={!canRedeem}
63
+ className="px-4 py-2 text-sm font-medium rounded-lg transition-all
64
+ bg-[var(--color-primary)] text-white hover:opacity-90
65
+ disabled:opacity-40 disabled:cursor-not-allowed"
66
+ >
67
+ {canRedeem ? t('redeem') : t('notEnoughPoints')}
68
+ </button>
69
+ )}
70
+ </div>
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,108 @@
1
+ 'use client';
2
+
3
+ import { useTranslations } from 'next-intl';
4
+
5
+ interface Transaction {
6
+ type: string;
7
+ points: number;
8
+ description: string;
9
+ createdAt: string;
10
+ }
11
+
12
+ interface TransactionHistoryProps {
13
+ transactions: Transaction[];
14
+ }
15
+
16
+ const typeConfig: Record<
17
+ string,
18
+ { color: string; bgColor: string; sign: string }
19
+ > = {
20
+ earn: { color: 'text-green-600', bgColor: 'bg-green-100', sign: '+' },
21
+ redeem: { color: 'text-red-500', bgColor: 'bg-red-100', sign: '-' },
22
+ adjust: { color: 'text-blue-600', bgColor: 'bg-blue-100', sign: '' },
23
+ expire: { color: 'text-gray-500', bgColor: 'bg-gray-100', sign: '-' },
24
+ };
25
+
26
+ function TypeIcon({ type }: { type: string }) {
27
+ const config = typeConfig[type] || typeConfig.adjust;
28
+
29
+ const icons: Record<string, string> = {
30
+ earn: 'M12 4v16m8-8H4',
31
+ redeem: 'M20 12H4',
32
+ adjust: 'M4 4l16 16M4 20L20 4',
33
+ expire: 'M12 8v4l3 3',
34
+ };
35
+
36
+ return (
37
+ <div
38
+ className={`w-8 h-8 rounded-full ${config.bgColor} flex items-center justify-center flex-shrink-0`}
39
+ >
40
+ <svg
41
+ className={`w-4 h-4 ${config.color}`}
42
+ fill="none"
43
+ stroke="currentColor"
44
+ viewBox="0 0 24 24"
45
+ strokeWidth={2}
46
+ strokeLinecap="round"
47
+ strokeLinejoin="round"
48
+ >
49
+ <path d={icons[type] || icons.adjust} />
50
+ </svg>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ export function TransactionHistory({ transactions }: TransactionHistoryProps) {
56
+ const t = useTranslations('loyalty');
57
+
58
+ const sorted = [...transactions].sort(
59
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
60
+ );
61
+
62
+ const typeLabels: Record<string, string> = {
63
+ earn: t('earn'),
64
+ redeem: t('redeemed'),
65
+ adjust: t('adjusted'),
66
+ expire: t('expired'),
67
+ };
68
+
69
+ return (
70
+ <div className="bg-white rounded-2xl shadow-sm border border-gray-100">
71
+ <h2 className="text-lg font-semibold text-gray-900 px-6 py-4 border-b border-gray-100">
72
+ {t('history')}
73
+ </h2>
74
+
75
+ {sorted.length === 0 ? (
76
+ <p className="text-sm text-gray-500 px-6 py-8 text-center">--</p>
77
+ ) : (
78
+ <ul className="divide-y divide-gray-50">
79
+ {sorted.map((tx, index) => {
80
+ const config = typeConfig[tx.type] || typeConfig.adjust;
81
+ const date = new Date(tx.createdAt);
82
+
83
+ return (
84
+ <li key={index} className="px-6 py-4 flex items-center gap-4">
85
+ <TypeIcon type={tx.type} />
86
+
87
+ <div className="flex-1 min-w-0">
88
+ <p className="text-sm font-medium text-gray-900 truncate">
89
+ {tx.description}
90
+ </p>
91
+ <p className="text-xs text-gray-400">
92
+ {typeLabels[tx.type] || tx.type} &middot;{' '}
93
+ {date.toLocaleDateString()}
94
+ </p>
95
+ </div>
96
+
97
+ <span className={`text-sm font-semibold ${config.color}`}>
98
+ {config.sign}
99
+ {Math.abs(tx.points).toLocaleString()} {t('points')}
100
+ </span>
101
+ </li>
102
+ );
103
+ })}
104
+ </ul>
105
+ )}
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,42 @@
1
+ import type { Category } from 'bestraw-sdk';
2
+ import { MealCard } from './MealCard';
3
+ import { MealOrderCard } from '@/components/order/MealOrderCard';
4
+
5
+ interface CategorySectionProps {
6
+ category: Category;
7
+ apiUrl: string;
8
+ orderable?: boolean;
9
+ }
10
+
11
+ export function CategorySection({ category, apiUrl, orderable }: CategorySectionProps) {
12
+ const visibleMeals = (category.meals ?? [])
13
+ .filter((meal) => meal.show)
14
+ .sort((a, b) => a.sortOrder - b.sortOrder);
15
+
16
+ if (visibleMeals.length === 0) {
17
+ return null;
18
+ }
19
+
20
+ return (
21
+ <section className="mb-12">
22
+ <div className="mb-6">
23
+ <h2 className="text-2xl font-bold text-[var(--color-primary)]">
24
+ {category.name}
25
+ </h2>
26
+ {category.description && (
27
+ <p className="mt-1 text-gray-600">{category.description}</p>
28
+ )}
29
+ </div>
30
+
31
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
32
+ {visibleMeals.map((meal) =>
33
+ orderable ? (
34
+ <MealOrderCard key={meal.documentId} meal={meal} apiUrl={apiUrl} />
35
+ ) : (
36
+ <MealCard key={meal.documentId} meal={meal} apiUrl={apiUrl} />
37
+ )
38
+ )}
39
+ </div>
40
+ </section>
41
+ );
42
+ }
@@ -0,0 +1,55 @@
1
+ import type { Meal } from 'bestraw-sdk';
2
+
3
+ interface MealCardProps {
4
+ meal: Meal;
5
+ apiUrl: string;
6
+ onClick?: () => void;
7
+ }
8
+
9
+ function formatPrice(price: number): string {
10
+ return price.toFixed(2).replace('.', ',') + ' \u20AC';
11
+ }
12
+
13
+ export function MealCard({ meal, apiUrl, onClick }: MealCardProps) {
14
+ const picture = meal.pictures?.[0];
15
+ const imageUrl = picture
16
+ ? `${apiUrl}${picture.url}`
17
+ : null;
18
+
19
+ return (
20
+ <div
21
+ className="bg-white rounded-lg border border-gray-100 overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
22
+ onClick={onClick}
23
+ role={onClick ? 'button' : undefined}
24
+ tabIndex={onClick ? 0 : undefined}
25
+ onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick(); } : undefined}
26
+ >
27
+ {imageUrl && (
28
+ <div className="aspect-[4/3] overflow-hidden bg-gray-100">
29
+ <img
30
+ src={imageUrl}
31
+ alt={picture?.alternativeText || meal.name}
32
+ className="w-full h-full object-cover"
33
+ />
34
+ </div>
35
+ )}
36
+
37
+ <div className="p-4">
38
+ <div className="flex items-start justify-between gap-2">
39
+ <h3 className="font-semibold text-[var(--color-primary)]">
40
+ {meal.name}
41
+ </h3>
42
+ <span className="text-sm font-medium text-[var(--color-accent)] whitespace-nowrap">
43
+ {formatPrice(meal.price)}
44
+ </span>
45
+ </div>
46
+
47
+ {meal.description && (
48
+ <p className="mt-1 text-sm text-gray-600 line-clamp-2">
49
+ {meal.description}
50
+ </p>
51
+ )}
52
+ </div>
53
+ </div>
54
+ );
55
+ }