create-nextblock 0.15.5 → 0.15.9

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 (79) hide show
  1. package/package.json +50 -26
  2. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -17
  3. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -0
  4. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -0
  5. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -0
  6. package/templates/nextblock-template/app/actions/email.ts +241 -110
  7. package/templates/nextblock-template/app/actions/formActions.ts +245 -116
  8. package/templates/nextblock-template/app/actions/interactions.ts +489 -396
  9. package/templates/nextblock-template/app/actions/threadActions.ts +166 -0
  10. package/templates/nextblock-template/app/api/checkout/route.ts +162 -146
  11. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +14 -0
  12. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +664 -1
  13. package/templates/nextblock-template/app/checkout/page.tsx +57 -52
  14. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +552 -529
  15. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -181
  16. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -0
  17. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -0
  18. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -528
  19. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -0
  20. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -0
  21. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -51
  22. package/templates/nextblock-template/app/cms/layout.tsx +101 -73
  23. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -0
  24. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -0
  25. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -0
  26. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -0
  27. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -0
  28. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -362
  29. package/templates/nextblock-template/app/cms/revisions/service.ts +20 -0
  30. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -185
  31. package/templates/nextblock-template/app/layout.tsx +671 -667
  32. package/templates/nextblock-template/app/lib/seo.ts +319 -311
  33. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -482
  34. package/templates/nextblock-template/app/providers.tsx +96 -96
  35. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -0
  36. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -0
  37. package/templates/nextblock-template/app/thread/layout.tsx +15 -0
  38. package/templates/nextblock-template/app/thread/page.tsx +98 -0
  39. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -296
  40. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -0
  41. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -369
  42. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -419
  43. package/templates/nextblock-template/components/StaffReplies.tsx +102 -0
  44. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -17
  45. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -19
  46. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -22
  47. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -381
  48. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -92
  49. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -31
  50. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -600
  51. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -0
  52. package/templates/nextblock-template/docs/05-DEVELOPER-GUIDE.md +25 -19
  53. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +1 -1
  54. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +2 -2
  55. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +32 -9
  56. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -0
  57. package/templates/nextblock-template/docs/README.md +42 -41
  58. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +540 -542
  59. package/templates/nextblock-template/docs/assets/lighthouse-scores.png +0 -0
  60. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +22 -2
  61. package/templates/nextblock-template/lib/blocks/blockColors.ts +44 -1
  62. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -753
  63. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -0
  64. package/templates/nextblock-template/lib/cms/payments-reminder.ts +98 -0
  65. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -0
  66. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -0
  67. package/templates/nextblock-template/lib/config/email-settings.ts +323 -254
  68. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -0
  69. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -0
  70. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -0
  71. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -0
  72. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -0
  73. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -0
  74. package/templates/nextblock-template/lib/messages/threads.ts +579 -0
  75. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +20 -0
  76. package/templates/nextblock-template/lib/site-url.test.ts +89 -0
  77. package/templates/nextblock-template/lib/site-url.ts +102 -48
  78. package/templates/nextblock-template/package.json +14 -1
  79. package/templates/nextblock-template/public/assets/nextblock-banner.jpg +0 -0
@@ -0,0 +1,166 @@
1
+ 'use server';
2
+
3
+ import { headers } from 'next/headers';
4
+ import { after } from 'next/server';
5
+ import { revalidatePath } from 'next/cache';
6
+ import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
7
+
8
+ import { getCookieValue } from '../../lib/auth/cookies';
9
+ import { THREAD_COOKIE, verifyThreadToken } from '../../lib/messages/thread-token';
10
+ import {
11
+ getFormEndpoint,
12
+ notifyAdminOfMessage,
13
+ resolveFormRecipient,
14
+ } from '../../lib/messages/threads';
15
+ import { resolveSellerContactEmail } from '../../lib/commerce/seller-contact';
16
+ import { verifyBotProtection } from '../../lib/botProtection/verify';
17
+
18
+ /**
19
+ * A visitor replying on their own thread page.
20
+ *
21
+ * The token is read from the HttpOnly COOKIE, never from a form field — so it is never
22
+ * in a POST body, never in a client-side variable, and cannot be swapped by editing the
23
+ * request. Everything else here mirrors the enquiry action: bot protection first, a
24
+ * fail-closed per-IP throttle, then a write.
25
+ */
26
+
27
+ const MAX_BODY_LENGTH = 5000;
28
+ const MAX_USER_AGENT_LENGTH = 500;
29
+ const THROTTLE_WINDOW_MINUTES = 10;
30
+ const THROTTLE_MAX_REPLIES = 10;
31
+ const UNKNOWN_IP_BUCKET = 'unknown';
32
+
33
+ export interface ThreadReplyState {
34
+ success: boolean;
35
+ messageKey: 'thread.sent' | 'thread.error' | 'thread.throttled' | 'thread.closed' | '';
36
+ message?: string;
37
+ }
38
+
39
+ function firstHop(headerValue: string | null): string | null {
40
+ const [first] = (headerValue ?? '').split(',');
41
+ const trimmed = first?.trim() ?? '';
42
+ return trimmed || null;
43
+ }
44
+
45
+ function maskIp(ip: string | null): string {
46
+ if (!ip) return UNKNOWN_IP_BUCKET;
47
+ if (ip.includes(':')) {
48
+ const hextets = ip.split(':').filter(Boolean);
49
+ return `${hextets.slice(0, 3).join(':')}::x`;
50
+ }
51
+ const octets = ip.split('.');
52
+ if (octets.length === 4) {
53
+ octets[3] = 'x';
54
+ return octets.join('.');
55
+ }
56
+ return UNKNOWN_IP_BUCKET;
57
+ }
58
+
59
+ export async function submitThreadReply(
60
+ _prevState: unknown,
61
+ formData: FormData
62
+ ): Promise<ThreadReplyState> {
63
+ const verification = await verifyBotProtection(formData);
64
+ if (!verification.ok) {
65
+ if (verification.reason === 'honeypot') {
66
+ return { success: true, messageKey: 'thread.sent' };
67
+ }
68
+ return { success: false, messageKey: '', message: verification.message };
69
+ }
70
+
71
+ const rawBody = formData.get('body');
72
+ const body = typeof rawBody === 'string' ? rawBody.trim().slice(0, MAX_BODY_LENGTH) : '';
73
+ if (!body) return { success: false, messageKey: 'thread.error' };
74
+
75
+ try {
76
+ const supabase = getServiceRoleSupabaseClient();
77
+
78
+ const token = await getCookieValue(THREAD_COOKIE);
79
+ const check = await verifyThreadToken(supabase, token);
80
+ if (!check.valid) return { success: false, messageKey: 'thread.error' };
81
+
82
+ const thread = check.thread;
83
+ if (thread.status === 'closed') return { success: false, messageKey: 'thread.closed' };
84
+
85
+ const requestHeaders = await headers();
86
+ const ipMasked = maskIp(
87
+ firstHop(requestHeaders.get('x-vercel-forwarded-for')) ??
88
+ firstHop(requestHeaders.get('x-real-ip')) ??
89
+ firstHop(requestHeaders.get('x-forwarded-for'))
90
+ );
91
+ const userAgent = requestHeaders.get('user-agent');
92
+
93
+ const since = new Date(Date.now() - THROTTLE_WINDOW_MINUTES * 60_000).toISOString();
94
+ const { count } = await supabase
95
+ .from('thread_messages')
96
+ .select('id', { count: 'exact', head: true })
97
+ .eq('ip_masked', ipMasked)
98
+ .gte('created_at', since);
99
+ if ((count ?? 0) >= THROTTLE_MAX_REPLIES) {
100
+ return { success: false, messageKey: 'thread.throttled' };
101
+ }
102
+
103
+ const { data: message, error } = await supabase
104
+ .from('thread_messages')
105
+ .insert({
106
+ thread_id: thread.id,
107
+ direction: 'inbound',
108
+ body,
109
+ author_name: thread.sender_name,
110
+ ip_masked: ipMasked,
111
+ })
112
+ .select('id')
113
+ .single();
114
+
115
+ if (error || !message) {
116
+ console.error('[thread] Could not store a visitor reply:', error?.message);
117
+ return { success: false, messageKey: 'thread.error' };
118
+ }
119
+
120
+ await supabase
121
+ .from('message_threads')
122
+ .update({
123
+ last_message_at: new Date().toISOString(),
124
+ unread_for_admin: true,
125
+ unread_for_visitor: false,
126
+ // A visitor writing back REOPENS the conversation. Marking it handled was a
127
+ // statement about the last exchange, not a permanent verdict — leaving it closed
128
+ // would drop their reply out of the inbox's default view, which is precisely
129
+ // where someone waiting on an answer must not go.
130
+ status: 'open',
131
+ user_agent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
132
+ })
133
+ .eq('id', thread.id);
134
+
135
+ after(async () => {
136
+ // Route the notice the same way the original submission was routed, so a
137
+ // contact-form conversation keeps reaching that form's address.
138
+ const recipient =
139
+ thread.source === 'contact_form'
140
+ ? await resolveFormRecipient(
141
+ // Load the ORIGINATING form's endpoint. Passing null here would skip the
142
+ // per-form address entirely and quietly deliver a careers-form follow-up
143
+ // to the general mailbox.
144
+ thread.form_key ? await getFormEndpoint(thread.form_key) : null
145
+ )
146
+ : (await resolveSellerContactEmail()).email;
147
+
148
+ await notifyAdminOfMessage({
149
+ threadId: thread.id,
150
+ source: thread.source as 'product_inquiry' | 'contact_form',
151
+ messageId: message.id,
152
+ subjectLabel: thread.subject_label,
153
+ senderName: thread.sender_name,
154
+ senderEmail: thread.sender_email,
155
+ message: body,
156
+ recipient,
157
+ });
158
+ });
159
+
160
+ revalidatePath('/thread');
161
+ return { success: true, messageKey: 'thread.sent' };
162
+ } catch (error) {
163
+ console.error('[thread] Visitor reply failed:', error);
164
+ return { success: false, messageKey: 'thread.error' };
165
+ }
166
+ }
@@ -1,146 +1,162 @@
1
- import { NextResponse } from 'next/server';
2
- import { getPaymentProvider } from '@nextblock-cms/ecommerce/server';
3
- import { createClient, verifyPackageOnline } from '@nextblock-cms/db/server';
4
- import { normalizeCustomerAddress } from '@nextblock-cms/ecommerce';
5
-
6
- function jsonError(errorKey: string, error: string, status: number) {
7
- return NextResponse.json({ error, errorKey }, { status });
8
- }
9
-
10
- function resolveProviderFromItem(item: any): 'stripe' | 'freemius' | null {
11
- if (item?.provider === 'stripe' || item?.provider === 'freemius') {
12
- return item.provider;
13
- }
14
-
15
- if (item?.payment_provider === 'stripe' || item?.payment_provider === 'freemius') {
16
- return item.payment_provider;
17
- }
18
-
19
- if (item?.product_type === 'digital') {
20
- return 'freemius';
21
- }
22
-
23
- if (item?.product_type === 'physical') {
24
- return 'stripe';
25
- }
26
-
27
- if (item?.freemius_product_id) {
28
- return 'freemius';
29
- }
30
-
31
- return null;
32
- }
33
-
34
- export async function POST(req: Request) {
35
- try {
36
- const isOnline = await verifyPackageOnline('ecommerce');
37
- if (!isOnline) {
38
- return jsonError(
39
- 'ecommerce.checkout_license_inactive',
40
- 'Ecommerce module license is inactive',
41
- 403
42
- );
43
- }
44
-
45
- const {
46
- items,
47
- customerEmail,
48
- customerPhone,
49
- billingAddress,
50
- shippingAddress,
51
- shippingMethodId,
52
- currencyCode,
53
- locale,
54
- couponCode,
55
- couponContextItems,
56
- } = await req.json();
57
-
58
- if (!items || !Array.isArray(items)) {
59
- return jsonError(
60
- 'ecommerce.checkout_invalid_items',
61
- 'Invalid items data',
62
- 400
63
- );
64
- }
65
-
66
- const providerNames = Array.from(
67
- new Set(items.map((item) => resolveProviderFromItem(item)).filter(Boolean))
68
- ) as Array<'stripe' | 'freemius'>;
69
-
70
- if (providerNames.length === 0) {
71
- return jsonError(
72
- 'ecommerce.checkout_provider_items_required',
73
- 'Each checkout request must include provider-aware cart items.',
74
- 400
75
- );
76
- }
77
-
78
- if (providerNames.length > 1) {
79
- return jsonError(
80
- 'ecommerce.checkout_mixed_provider_steps',
81
- 'Mixed-provider carts must be checked out in separate steps.',
82
- 400
83
- );
84
- }
85
-
86
- const providerName = providerNames[0];
87
-
88
- if (providerName === 'freemius' && items.length !== 1) {
89
- return jsonError(
90
- 'ecommerce.checkout_freemius_single_item',
91
- 'Freemius items must be checked out one at a time.',
92
- 400
93
- );
94
- }
95
-
96
- if (!billingAddress) {
97
- return jsonError(
98
- 'ecommerce.checkout_billing_address_required',
99
- 'Billing address is required',
100
- 400
101
- );
102
- }
103
-
104
- const supabase = createClient();
105
- const provider = getPaymentProvider(providerName);
106
-
107
- const { data: { user } } = await supabase.auth.getUser();
108
- const userId = user?.id;
109
- const resolvedCustomerEmail = user?.email || customerEmail || null;
110
-
111
- const { url, error, errorKey, errorParams, errorStatus, customProps } =
112
- await provider.createCheckoutSession({
113
- items,
114
- customerEmail: resolvedCustomerEmail,
115
- customerPhone,
116
- userId,
117
- billingAddress: normalizeCustomerAddress(billingAddress) ?? billingAddress,
118
- shippingAddress:
119
- providerName === 'stripe'
120
- ? normalizeCustomerAddress(shippingAddress)
121
- : null,
122
- shippingMethodId: providerName === 'stripe' ? shippingMethodId : null,
123
- currencyCode: typeof currencyCode === 'string' ? currencyCode : null,
124
- locale: typeof locale === 'string' ? locale : null,
125
- couponCode: typeof couponCode === 'string' ? couponCode : null,
126
- couponContextItems: Array.isArray(couponContextItems) ? couponContextItems : items,
127
- });
128
-
129
- if (error) {
130
- console.error('Checkout Error:', error);
131
- return NextResponse.json(
132
- { error, errorKey, errorParams },
133
- { status: errorStatus ?? 500 }
134
- );
135
- }
136
-
137
- return NextResponse.json({ url, customProps });
138
- } catch (err: any) {
139
- console.error('Checkout API Error:', err);
140
- return jsonError(
141
- 'ecommerce.checkout_internal_server_error',
142
- 'Internal Server Error',
143
- 500
144
- );
145
- }
146
- }
1
+ import { NextResponse } from 'next/server';
2
+ import { getPaymentProvider, getProviderReadiness } from '@nextblock-cms/ecommerce/server';
3
+ import { createClient, verifyPackageOnline } from '@nextblock-cms/db/server';
4
+ import { normalizeCustomerAddress } from '@nextblock-cms/ecommerce';
5
+
6
+ function jsonError(errorKey: string, error: string, status: number) {
7
+ return NextResponse.json({ error, errorKey }, { status });
8
+ }
9
+
10
+ function resolveProviderFromItem(item: any): 'stripe' | 'freemius' | null {
11
+ if (item?.provider === 'stripe' || item?.provider === 'freemius') {
12
+ return item.provider;
13
+ }
14
+
15
+ if (item?.payment_provider === 'stripe' || item?.payment_provider === 'freemius') {
16
+ return item.payment_provider;
17
+ }
18
+
19
+ if (item?.product_type === 'digital') {
20
+ return 'freemius';
21
+ }
22
+
23
+ if (item?.product_type === 'physical') {
24
+ return 'stripe';
25
+ }
26
+
27
+ if (item?.freemius_product_id) {
28
+ return 'freemius';
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ export async function POST(req: Request) {
35
+ try {
36
+ const isOnline = await verifyPackageOnline('ecommerce');
37
+ if (!isOnline) {
38
+ return jsonError(
39
+ 'ecommerce.checkout_license_inactive',
40
+ 'Ecommerce module license is inactive',
41
+ 403
42
+ );
43
+ }
44
+
45
+ const {
46
+ items,
47
+ customerEmail,
48
+ customerPhone,
49
+ billingAddress,
50
+ shippingAddress,
51
+ shippingMethodId,
52
+ currencyCode,
53
+ locale,
54
+ couponCode,
55
+ couponContextItems,
56
+ } = await req.json();
57
+
58
+ if (!items || !Array.isArray(items)) {
59
+ return jsonError(
60
+ 'ecommerce.checkout_invalid_items',
61
+ 'Invalid items data',
62
+ 400
63
+ );
64
+ }
65
+
66
+ const providerNames = Array.from(
67
+ new Set(items.map((item) => resolveProviderFromItem(item)).filter(Boolean))
68
+ ) as Array<'stripe' | 'freemius'>;
69
+
70
+ if (providerNames.length === 0) {
71
+ return jsonError(
72
+ 'ecommerce.checkout_provider_items_required',
73
+ 'Each checkout request must include provider-aware cart items.',
74
+ 400
75
+ );
76
+ }
77
+
78
+ if (providerNames.length > 1) {
79
+ return jsonError(
80
+ 'ecommerce.checkout_mixed_provider_steps',
81
+ 'Mixed-provider carts must be checked out in separate steps.',
82
+ 400
83
+ );
84
+ }
85
+
86
+ const providerName = providerNames[0];
87
+
88
+ if (providerName === 'freemius' && items.length !== 1) {
89
+ return jsonError(
90
+ 'ecommerce.checkout_freemius_single_item',
91
+ 'Freemius items must be checked out one at a time.',
92
+ 400
93
+ );
94
+ }
95
+
96
+ if (!billingAddress) {
97
+ return jsonError(
98
+ 'ecommerce.checkout_billing_address_required',
99
+ 'Billing address is required',
100
+ 400
101
+ );
102
+ }
103
+
104
+ // Preflight: refuse before the provider writes anything. StripeProvider inserts a
105
+ // pending `orders` row and can burn a coupon redemption BEFORE it ever calls Stripe,
106
+ // so an unconfigured store used to accumulate failed orders and spent coupons on
107
+ // every attempt and the shopper got Stripe's raw "Invalid API Key" string back.
108
+ const readiness = await getProviderReadiness(providerName);
109
+ if (!readiness.ready) {
110
+ console.error(
111
+ `Checkout blocked: ${readiness.label} is not ready (missing: ${readiness.missing.join(', ') || 'unknown'})`
112
+ );
113
+ return jsonError(
114
+ 'ecommerce.checkout_payments_unavailable',
115
+ 'This store is not able to take payments right now. Please contact the seller to complete your purchase.',
116
+ 503
117
+ );
118
+ }
119
+
120
+ const supabase = createClient();
121
+ const provider = getPaymentProvider(providerName);
122
+
123
+ const { data: { user } } = await supabase.auth.getUser();
124
+ const userId = user?.id;
125
+ const resolvedCustomerEmail = user?.email || customerEmail || null;
126
+
127
+ const { url, error, errorKey, errorParams, errorStatus, customProps } =
128
+ await provider.createCheckoutSession({
129
+ items,
130
+ customerEmail: resolvedCustomerEmail,
131
+ customerPhone,
132
+ userId,
133
+ billingAddress: normalizeCustomerAddress(billingAddress) ?? billingAddress,
134
+ shippingAddress:
135
+ providerName === 'stripe'
136
+ ? normalizeCustomerAddress(shippingAddress)
137
+ : null,
138
+ shippingMethodId: providerName === 'stripe' ? shippingMethodId : null,
139
+ currencyCode: typeof currencyCode === 'string' ? currencyCode : null,
140
+ locale: typeof locale === 'string' ? locale : null,
141
+ couponCode: typeof couponCode === 'string' ? couponCode : null,
142
+ couponContextItems: Array.isArray(couponContextItems) ? couponContextItems : items,
143
+ });
144
+
145
+ if (error) {
146
+ console.error('Checkout Error:', error);
147
+ return NextResponse.json(
148
+ { error, errorKey, errorParams },
149
+ { status: errorStatus ?? 500 }
150
+ );
151
+ }
152
+
153
+ return NextResponse.json({ url, customProps });
154
+ } catch (err: any) {
155
+ console.error('Checkout API Error:', err);
156
+ return jsonError(
157
+ 'ecommerce.checkout_internal_server_error',
158
+ 'Internal Server Error',
159
+ 500
160
+ );
161
+ }
162
+ }
@@ -2621,6 +2621,20 @@ async function seedFakeStoreData(sql: SqlClient, supabaseAdmin: any) {
2621
2621
  console.log(`[Sandbox Reset] Created new demo user with ID: ${demoUser.id}`);
2622
2622
  } else {
2623
2623
  console.log(`[Sandbox Reset] Found existing demo user with ID: ${demoUser.id}`);
2624
+ // The demo credentials are published (README + community posts), so any visitor can
2625
+ // change this account's password and lock everyone else out. The SQL reset below does
2626
+ // not touch auth.users, so re-assert the known-good password on every run — otherwise a
2627
+ // single password change survives every future reset.
2628
+ const { error: resetPwError } = await supabaseAdmin.auth.admin.updateUserById(demoUser.id, {
2629
+ password: 'password',
2630
+ email_confirm: true,
2631
+ });
2632
+ if (resetPwError) {
2633
+ // Non-fatal: the rest of the sandbox reset is still worth doing.
2634
+ console.error('[Sandbox Reset] Failed to restore demo password:', resetPwError);
2635
+ } else {
2636
+ console.log('[Sandbox Reset] Restored demo user password to the published default.');
2637
+ }
2624
2638
  }
2625
2639
 
2626
2640
  const userId = demoUser.id;