create-brainerce-store 1.68.0 → 1.72.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 (50) hide show
  1. package/README.md +31 -10
  2. package/dist/index.js +197 -105
  3. package/messages/en.json +63 -3
  4. package/messages/he.json +63 -3
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/TRANSLATIONS.md +14 -7
  7. package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1017
  8. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
  9. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +1 -1
  10. package/templates/nextjs/base/src/app/register/page.tsx +67 -64
  11. package/templates/nextjs/base/src/components/account/order-history.tsx +25 -39
  12. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +30 -11
  13. package/templates/nextjs/base/src/components/account/profile-section.tsx +303 -226
  14. package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
  15. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
  16. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
  17. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
  18. package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
  19. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +71 -2
  20. package/templates/nextjs/base/src/core/lib/auth.ts +155 -154
  21. package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
  22. package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
  23. package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
  24. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +3 -6
  25. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +19 -1
  26. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +42 -6
  27. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +52 -10
  28. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +143 -0
  29. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
  30. package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
  31. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
  32. package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
  33. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
  34. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +21 -3
  35. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +19 -1
  36. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +44 -7
  37. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +52 -10
  38. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
  39. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
  40. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
  41. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
  42. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +15 -1
  43. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +38 -4
  44. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +54 -11
  45. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
  46. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
  47. package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
  48. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
  49. package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
  50. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +127 -96
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import { Clock } from 'lucide-react';
5
5
  import type { ReservationInfo } from 'brainerce';
6
6
  import { useTranslations } from '@/core/lib/translations';
@@ -8,12 +8,28 @@ import { cn } from '@/core/lib/utils';
8
8
 
9
9
  interface ReservationCountdownProps {
10
10
  reservation: ReservationInfo;
11
+ /**
12
+ * Fired once per reservation window when the timer reaches zero, including
13
+ * when the page opens on an already-expired reservation. The handler is
14
+ * expected to refresh the cart and gate checkout: this component only
15
+ * displays, it never decides what is still purchasable. Wire it to
16
+ * `useCartPage().onReservationExpired`.
17
+ */
18
+ onExpire?: () => void;
11
19
  className?: string;
12
20
  }
13
21
 
14
- export function ReservationCountdown({ reservation, className }: ReservationCountdownProps) {
22
+ export function ReservationCountdown({
23
+ reservation,
24
+ onExpire,
25
+ className,
26
+ }: ReservationCountdownProps) {
15
27
  const t = useTranslations('reservation');
16
- const [remainingSeconds, setRemainingSeconds] = useState<number>(0);
28
+ // `null` means "not measured yet". Server-rendered HTML must not compute a
29
+ // time, or it disagrees with the client on hydration; starting at 0 instead
30
+ // would render the expired banner for one frame on a perfectly live
31
+ // reservation, and would fire onExpire on every mount.
32
+ const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
17
33
 
18
34
  const calculateRemaining = useCallback(() => {
19
35
  if (!reservation.expiresAt) return 0;
@@ -22,8 +38,29 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
22
38
  return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
23
39
  }, [reservation.expiresAt]);
24
40
 
41
+ // Report each expiry window at most once. Keyed on `expiresAt` rather than a
42
+ // boolean, so the cart refresh that expiry triggers (which remounts this
43
+ // component) cannot bounce straight back into a second report.
44
+ const expiresAt = reservation.expiresAt;
45
+ const reportedExpiryRef = useRef<string | null>(null);
46
+ const onExpireRef = useRef(onExpire);
47
+ // Kept in a ref, and synced in an effect rather than during render, so a
48
+ // caller passing an inline arrow does not restart the timer every render.
25
49
  useEffect(() => {
26
- setRemainingSeconds(calculateRemaining());
50
+ onExpireRef.current = onExpire;
51
+ }, [onExpire]);
52
+
53
+ const reportExpired = useCallback(() => {
54
+ if (!expiresAt) return;
55
+ if (reportedExpiryRef.current === expiresAt) return;
56
+ reportedExpiryRef.current = expiresAt;
57
+ onExpireRef.current?.();
58
+ }, [expiresAt]);
59
+
60
+ useEffect(() => {
61
+ const initial = calculateRemaining();
62
+ setRemainingSeconds(initial);
63
+ if (initial <= 0) reportExpired();
27
64
 
28
65
  const interval = setInterval(() => {
29
66
  const remaining = calculateRemaining();
@@ -31,18 +68,20 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
31
68
 
32
69
  if (remaining <= 0) {
33
70
  clearInterval(interval);
71
+ reportExpired();
34
72
  }
35
73
  }, 1000);
36
74
 
37
75
  return () => clearInterval(interval);
38
- }, [calculateRemaining]);
76
+ }, [calculateRemaining, reportExpired]);
39
77
 
40
78
  if (!reservation.hasReservation) return null;
41
79
 
42
- const minutes = Math.floor(remainingSeconds / 60);
43
- const seconds = remainingSeconds % 60;
44
- const isExpired = remainingSeconds <= 0;
45
- const isUrgent = remainingSeconds > 0 && remainingSeconds < 120;
80
+ const displaySeconds = remainingSeconds ?? 0;
81
+ const minutes = Math.floor(displaySeconds / 60);
82
+ const seconds = displaySeconds % 60;
83
+ const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
84
+ const isUrgent = displaySeconds > 0 && displaySeconds < 120;
46
85
 
47
86
  const displayMessage = reservation.countdownMessage
48
87
  ? reservation.countdownMessage.replace(
@@ -77,7 +116,10 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
77
116
 
78
117
  <div className="flex-1">
79
118
  {isExpired ? (
80
- <p className="font-medium">{t('expired')}</p>
119
+ <>
120
+ <p className="font-medium">{t('expired')}</p>
121
+ <p className="text-xs opacity-90">{t('expiredHint')}</p>
122
+ </>
81
123
  ) : displayMessage ? (
82
124
  <p>{displayMessage}</p>
83
125
  ) : (
@@ -0,0 +1,143 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Newsletter signup — confirmed opt-in.
5
+ *
6
+ * ⛔ THIS DOES NOT SUBSCRIBE ANYONE. `marketing.subscribe()` creates the contact
7
+ * and mails them a confirmation link; the address is unmailable, and invisible
8
+ * to every campaign audience, until the recipient clicks it. That is why the
9
+ * success state below says "check your email" and never "you're subscribed" —
10
+ * the second is untrue until a click lands in a mailbox this code cannot see,
11
+ * and most people never click.
12
+ *
13
+ * ⛔ THE RESPONSE CARRIES NO INFORMATION. `{ ok: true }` comes back identically
14
+ * for a brand-new address, one that confirmed months ago, one still inside its
15
+ * 24-hour resend cooldown, and one suppressed after a hard bounce. That is
16
+ * deliberate: a response that distinguished them would turn this public form
17
+ * into a way to test whether a given person shops here. There is nothing to
18
+ * branch on — render one success state and stop.
19
+ *
20
+ * Naming the spam folder is not padding. A filtered confirmation is the single
21
+ * commonest reason a signup never becomes a subscriber, and the resend cooldown
22
+ * means no second copy arrives for 24 hours.
23
+ *
24
+ * **Discount codes.** Subscribing mints nothing. For a "10% off your first
25
+ * order" offer, the merchant creates a coupon in the dashboard with the
26
+ * `customer_first_order` condition and you render that fixed code in the
27
+ * success state — see the commented line below. Show it immediately: it must
28
+ * not wait for the confirmation click, or the shopper loses the reason they
29
+ * filled the form in.
30
+ *
31
+ * Rate limited to 3 requests / 60s per IP.
32
+ */
33
+
34
+ import { useMemo, useState } from 'react';
35
+ import { getClient } from '@/core/lib/brainerce';
36
+ import { useTranslations } from '@/core/lib/translations';
37
+
38
+ export function NewsletterSignup() {
39
+ const t = useTranslations('newsletter');
40
+ const [email, setEmail] = useState('');
41
+ const [honeypot, setHoneypot] = useState('');
42
+ const [loading, setLoading] = useState(false);
43
+ const [done, setDone] = useState(false);
44
+ const [error, setError] = useState<string | null>(null);
45
+
46
+ // Same source the contact form uses — <html lang> is set from the active
47
+ // locale, and passing it is what decides the language of the confirmation
48
+ // email. Omit it on a Hebrew storefront and the shopper gets English.
49
+ const locale = useMemo(() => {
50
+ if (typeof document !== 'undefined') return document.documentElement.lang || undefined;
51
+ return undefined;
52
+ }, []);
53
+
54
+ async function handleSubmit(e: React.FormEvent) {
55
+ e.preventDefault();
56
+ if (loading || !email.trim()) return;
57
+
58
+ setLoading(true);
59
+ setError(null);
60
+ try {
61
+ await getClient().marketing.subscribe({
62
+ email: email.trim(),
63
+ locale,
64
+ source: 'footer',
65
+ honeypot,
66
+ });
67
+ setDone(true);
68
+ setEmail('');
69
+ } catch {
70
+ // A 429 lands here too — the copy stays generic rather than explaining
71
+ // the rate limit, which would only tell an abuser where the edge is.
72
+ setError(t('genericError'));
73
+ } finally {
74
+ setLoading(false);
75
+ }
76
+ }
77
+
78
+ if (done) {
79
+ return (
80
+ <div className="space-y-1">
81
+ <p className="text-sm font-medium">{t('checkEmailTitle')}</p>
82
+ <p className="text-sm opacity-70">{t('checkEmailBody')}</p>
83
+ {/*
84
+ Merchant offering a signup discount? Render the coupon code here, now
85
+ — not after the confirmation click, which may never come:
86
+ <p className="text-sm font-medium">{t('discountCode', { code: 'WELCOME10' })}</p>
87
+ */}
88
+ </div>
89
+ );
90
+ }
91
+
92
+ return (
93
+ <form onSubmit={handleSubmit} className="space-y-2">
94
+ <label htmlFor="newsletter-email" className="block text-sm font-medium">
95
+ {t('title')}
96
+ </label>
97
+ <p className="text-sm opacity-70">{t('subtitle')}</p>
98
+
99
+ <div className="flex flex-wrap gap-2">
100
+ <input
101
+ id="newsletter-email"
102
+ type="email"
103
+ required
104
+ autoComplete="email"
105
+ value={email}
106
+ onChange={(e) => setEmail(e.target.value)}
107
+ placeholder={t('placeholder')}
108
+ className="border-border min-w-0 flex-1 rounded border px-3 py-2 text-sm"
109
+ />
110
+ <button
111
+ type="submit"
112
+ disabled={loading}
113
+ className="bg-primary text-primary-foreground rounded px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-60"
114
+ >
115
+ {loading ? t('submitting') : t('submit')}
116
+ </button>
117
+ </div>
118
+
119
+ {/*
120
+ Honeypot. Bots complete every text input; a human never sees this one, so
121
+ a non-empty value rejects the request server-side. Positioned off-screen
122
+ rather than `display:none` — some bots skip hidden inputs — and kept out
123
+ of the tab order and the accessibility tree.
124
+ */}
125
+ <input
126
+ type="text"
127
+ name="company_website"
128
+ tabIndex={-1}
129
+ autoComplete="off"
130
+ aria-hidden="true"
131
+ value={honeypot}
132
+ onChange={(e) => setHoneypot(e.target.value)}
133
+ style={{ position: 'absolute', left: '-9999px', width: 1, height: 1 }}
134
+ />
135
+
136
+ {error ? (
137
+ <p role="alert" className="text-sm text-red-600">
138
+ {error}
139
+ </p>
140
+ ) : null}
141
+ </form>
142
+ );
143
+ }
@@ -21,6 +21,9 @@ import type { Content } from 'brainerce';
21
21
  // component). Merchant-configured link columns keep plain <a> since their
22
22
  // URLs are arbitrary (may be external).
23
23
  import { Link } from '@/core/lib/navigation';
24
+ // Client component — the signup posts from the browser, which is also what
25
+ // satisfies the endpoint's browser-Origin requirement.
26
+ import { NewsletterSignup } from './newsletter-signup';
24
27
 
25
28
  interface SiteFooterProps {
26
29
  /** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
@@ -64,8 +67,11 @@ export function SiteFooter({ footer, storeName }: SiteFooterProps) {
64
67
  if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
65
68
  return (
66
69
  <footer className="border-border bg-muted/30 text-muted-foreground mt-16 border-t py-8 text-sm">
67
- <div className="mx-auto max-w-7xl px-4 text-center sm:px-6 lg:px-8">
68
- © {year} {brandLabel}. All rights reserved.
70
+ <div className="mx-auto max-w-7xl space-y-6 px-4 text-center sm:px-6 lg:px-8">
71
+ <div className="mx-auto max-w-md text-start">
72
+ <NewsletterSignup />
73
+ </div>
74
+ <div>© {year} {brandLabel}. All rights reserved.</div>
69
75
  </div>
70
76
  </footer>
71
77
  );
@@ -108,6 +114,16 @@ export function SiteFooter({ footer, storeName }: SiteFooterProps) {
108
114
  })}
109
115
  </div>
110
116
  ) : null}
117
+
118
+ {/*
119
+ Newsletter signup. Remove this block if the merchant does not want
120
+ a mailing list — nothing else depends on it. Confirmed opt-in: the
121
+ address is not subscribed until the recipient clicks the link they
122
+ are mailed, which is why the success copy says "check your email".
123
+ */}
124
+ <div className="mt-8">
125
+ <NewsletterSignup />
126
+ </div>
111
127
  </div>
112
128
 
113
129
  {columns.length > 0 ? (
@@ -0,0 +1,173 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Back-in-stock alert — "email me when this is back".
5
+ *
6
+ * ⛔ THIS IS NOT A NEWSLETTER SIGNUP. `stockAlerts.subscribe()` grants no
7
+ * marketing consent, creates no customer account, and the person receives
8
+ * exactly one message: the alert itself, about this item, carrying a link that
9
+ * stops it. That is why the button says "Notify me" and never "Subscribe" —
10
+ * and why a shopper who unsubscribed from the store's marketing can still use
11
+ * it. Never gate it on consent.
12
+ *
13
+ * ⛔ RENDER IT ONLY WHERE IT APPLIES. `canOfferStockAlert()` below is the whole
14
+ * gate. Requests for anything else are silently discarded server-side (the
15
+ * response is uniform on purpose, so it cannot be used to read a store's stock
16
+ * levels), which means a button in the wrong place looks like it worked and
17
+ * does nothing.
18
+ *
19
+ * ⛔ THE RESPONSE CARRIES NO INFORMATION. `{ ok: true }` comes back identically
20
+ * for a new request, a duplicate, a product id that does not exist, an item
21
+ * already in stock, and an address suppressed after a hard bounce. There is
22
+ * nothing to branch on — render one success state and stop.
23
+ *
24
+ * ⛔ DO NOT PROMISE TIMING. Availability is `total - reserved`, so an expiring
25
+ * cart briefly lifts a sold-out item above zero; the alert waits for stock to
26
+ * hold, then goes out in waves sized to the units that came back, oldest
27
+ * request first. A shopper can sit through a restock without hearing. "We'll
28
+ * email you when it's back" is true; "you'll be the first to know" is not.
29
+ *
30
+ * Rate limited to 5 requests / 60s per IP, 25 open alerts per address per store.
31
+ */
32
+
33
+ import { useMemo, useState } from 'react';
34
+ import type { InventoryInfo } from 'brainerce';
35
+ import { getClient } from '@/core/lib/brainerce';
36
+ import { useTranslations } from '@/core/lib/translations';
37
+
38
+ /**
39
+ * The gate. Four ways the answer is no, and every one of them matters:
40
+ *
41
+ * - the merchant switched the feature off for this storefront
42
+ * (`storeInfo.stockAlertsEnabled`, read once at boot);
43
+ * - the item is in stock, so there is nothing to wait for;
44
+ * - stock is not TRACKED — UNLIMITED never runs out, DISABLED is not for sale
45
+ * and no restock changes that;
46
+ * - the merchant allows backorders on it, so the storefront can already sell
47
+ * it while out of stock and an alert would tell someone to come back and do
48
+ * what they can already do.
49
+ */
50
+ export function canOfferStockAlert(
51
+ inventory: InventoryInfo | null | undefined,
52
+ stockAlertsEnabled: boolean | undefined
53
+ ): boolean {
54
+ if (stockAlertsEnabled === false) return false;
55
+ if (!inventory) return false;
56
+ if (inventory.trackingMode !== 'TRACKED') return false;
57
+ if (inventory.canPurchase) return false;
58
+ // Older backends omit the field entirely; absent means no backorder.
59
+ return (inventory.backorderMode ?? 'NONE') === 'NONE';
60
+ }
61
+
62
+ interface BackInStockFormProps {
63
+ productId: string;
64
+ /**
65
+ * ⛔ Pass the SELECTED variant on any product with variants. Without it the
66
+ * alert waits on the product as a whole, so a shopper who wanted the medium
67
+ * is mailed when the small returns and arrives to find their size still gone.
68
+ */
69
+ variantId?: string;
70
+ }
71
+
72
+ export function BackInStockForm({ productId, variantId }: BackInStockFormProps) {
73
+ const t = useTranslations('backInStock');
74
+ const [email, setEmail] = useState('');
75
+ const [honeypot, setHoneypot] = useState('');
76
+ const [loading, setLoading] = useState(false);
77
+ const [done, setDone] = useState(false);
78
+ const [error, setError] = useState<string | null>(null);
79
+
80
+ // Same source the newsletter form uses — <html lang> is set from the active
81
+ // locale, and passing it is what decides the language of the alert email.
82
+ // Omit it on a Hebrew storefront and the shopper gets English.
83
+ const locale = useMemo(() => {
84
+ if (typeof document !== 'undefined') return document.documentElement.lang || undefined;
85
+ return undefined;
86
+ }, []);
87
+
88
+ async function handleSubmit(e: React.FormEvent) {
89
+ e.preventDefault();
90
+ if (loading || !email.trim()) return;
91
+
92
+ setLoading(true);
93
+ setError(null);
94
+ try {
95
+ await getClient().stockAlerts.subscribe({
96
+ email: email.trim(),
97
+ productId,
98
+ variantId,
99
+ locale,
100
+ honeypot,
101
+ });
102
+ setDone(true);
103
+ setEmail('');
104
+ } catch {
105
+ // A 429 lands here too — the copy stays generic rather than explaining
106
+ // the rate limit, which would only tell an abuser where the edge is.
107
+ setError(t('genericError'));
108
+ } finally {
109
+ setLoading(false);
110
+ }
111
+ }
112
+
113
+ if (done) {
114
+ return (
115
+ <div className="border-border space-y-1 rounded border p-4">
116
+ <p className="text-sm font-medium">{t('doneTitle')}</p>
117
+ <p className="text-sm opacity-70">{t('doneBody')}</p>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ return (
123
+ <form onSubmit={handleSubmit} className="border-border space-y-2 rounded border p-4">
124
+ <label htmlFor="back-in-stock-email" className="block text-sm font-medium">
125
+ {t('title')}
126
+ </label>
127
+ <p className="text-sm opacity-70">{t('subtitle')}</p>
128
+
129
+ <div className="flex flex-wrap gap-2">
130
+ <input
131
+ id="back-in-stock-email"
132
+ type="email"
133
+ required
134
+ autoComplete="email"
135
+ value={email}
136
+ onChange={(e) => setEmail(e.target.value)}
137
+ placeholder={t('placeholder')}
138
+ className="border-border min-w-0 flex-1 rounded border px-3 py-2 text-sm"
139
+ />
140
+ <button
141
+ type="submit"
142
+ disabled={loading}
143
+ className="bg-primary text-primary-foreground rounded px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-60"
144
+ >
145
+ {loading ? t('submitting') : t('submit')}
146
+ </button>
147
+ </div>
148
+
149
+ {/*
150
+ Honeypot. Bots complete every text input; a human never sees this one, so
151
+ a non-empty value rejects the request server-side. Positioned off-screen
152
+ rather than `display:none` — some bots skip hidden inputs — and kept out
153
+ of the tab order and the accessibility tree.
154
+ */}
155
+ <input
156
+ type="text"
157
+ name="company_website"
158
+ tabIndex={-1}
159
+ autoComplete="off"
160
+ aria-hidden="true"
161
+ value={honeypot}
162
+ onChange={(e) => setHoneypot(e.target.value)}
163
+ style={{ position: 'absolute', left: '-9999px', width: 1, height: 1 }}
164
+ />
165
+
166
+ {error ? (
167
+ <p role="alert" className="text-sm text-red-600">
168
+ {error}
169
+ </p>
170
+ ) : null}
171
+ </form>
172
+ );
173
+ }