create-nextblock 0.13.7 → 0.13.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 (44) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/(auth-pages)/two-factor/actions.ts +21 -1
  3. package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +34 -10
  4. package/templates/nextblock-template/app/actions/email.ts +78 -7
  5. package/templates/nextblock-template/app/actions/feedback.ts +57 -14
  6. package/templates/nextblock-template/app/actions/interactions.test.ts +3 -0
  7. package/templates/nextblock-template/app/actions/productGridActions.ts +40 -0
  8. package/templates/nextblock-template/app/actions.ts +17 -4
  9. package/templates/nextblock-template/app/api/cms/ecommerce/product-picker/route.ts +151 -0
  10. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +4 -1
  11. package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +17 -5
  12. package/templates/nextblock-template/app/cms/blocks/components/MultiEntityPicker.tsx +251 -0
  13. package/templates/nextblock-template/app/cms/blocks/editors/ProductGridBlockEditor.tsx +375 -18
  14. package/templates/nextblock-template/app/cms/components/EcommerceActiveContext.tsx +27 -0
  15. package/templates/nextblock-template/app/cms/settings/bot-protection/actions.ts +9 -6
  16. package/templates/nextblock-template/app/cms/settings/bot-protection/components/BotProtectionForm.tsx +1 -5
  17. package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +9 -6
  18. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +1 -5
  19. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +18 -8
  20. package/templates/nextblock-template/app/cms/settings/email/actions.ts +59 -29
  21. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +5 -1
  22. package/templates/nextblock-template/app/cms/settings/global-css/actions.ts +6 -5
  23. package/templates/nextblock-template/app/cms/settings/global-css/components/GlobalCssForm.tsx +2 -1
  24. package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +18 -7
  25. package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +1 -1
  26. package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +16 -7
  27. package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +1 -1
  28. package/templates/nextblock-template/app/cms/settings/registration/actions.ts +18 -7
  29. package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +1 -1
  30. package/templates/nextblock-template/app/cms/settings/security/actions.ts +227 -131
  31. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +134 -18
  32. package/templates/nextblock-template/components/blocks/ProductGridClient.tsx +114 -0
  33. package/templates/nextblock-template/lib/auth/twoFactor.test.ts +254 -0
  34. package/templates/nextblock-template/lib/auth/twoFactor.ts +56 -13
  35. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +78 -139
  36. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +3 -3
  37. package/templates/nextblock-template/lib/blocks/blockTypes.ts +19 -0
  38. package/templates/nextblock-template/lib/blocks/ecommerce-block-schemas.ts +61 -2
  39. package/templates/nextblock-template/lib/blocks/product-grid-data.ts +210 -0
  40. package/templates/nextblock-template/lib/cms/action-result.ts +12 -0
  41. package/templates/nextblock-template/lib/config/email-settings.ts +40 -3
  42. package/templates/nextblock-template/next-env.d.ts +1 -1
  43. package/templates/nextblock-template/package.json +1 -1
  44. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.13.7",
3
+ "version": "0.13.9",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
5
5
  import { createClient } from '@nextblock-cms/db/server';
6
6
  import {
7
7
  createEmailChallenge,
8
+ getEmailResendCooldownSeconds,
8
9
  issueTwoFactorVerifiedCookie,
9
10
  verifyEmailChallenge,
10
11
  } from '../../../lib/auth/twoFactor';
@@ -15,6 +16,7 @@ import {
15
16
  getCookieValue,
16
17
  } from '../../../lib/auth/cookies';
17
18
  import { sendTwoFactorCodeEmail } from '../../actions/twoFactorEmail';
19
+ import { isEmailConfigured } from '../../../lib/config/email-settings';
18
20
 
19
21
  function safeRedirect(path?: string): string {
20
22
  return path && path.startsWith('/') && !path.startsWith('//') ? path : '/cms/dashboard';
@@ -85,7 +87,25 @@ export async function resendEmailCode() {
85
87
  } = await supabase.auth.getUser();
86
88
  if (!user?.email) return { error: 'No email address is associated with your account.' };
87
89
 
90
+ // No transport means the code can never arrive; say so rather than claiming it was sent.
91
+ if (!(await isEmailConfigured())) {
92
+ return {
93
+ error:
94
+ 'This site has no email server configured, so a code cannot be sent. Contact your administrator.',
95
+ };
96
+ }
97
+
98
+ const wait = await getEmailResendCooldownSeconds(user.id);
99
+ if (wait > 0) {
100
+ return { error: `A code was just sent. Please wait ${wait}s before requesting another.` };
101
+ }
102
+
88
103
  const code = await createEmailChallenge(user.id);
89
- await sendTwoFactorCodeEmail(user.email, code);
104
+ try {
105
+ await sendTwoFactorCodeEmail(user.email, code);
106
+ } catch (sendError) {
107
+ console.error('Failed to send 2FA email code:', sendError);
108
+ return { error: 'The mail server rejected the message. Please try again in a moment.' };
109
+ }
90
110
  return { success: true, message: `A new code is on its way to ${user.email}.` };
91
111
  }
@@ -1,10 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { useState, useTransition } from 'react';
3
+ import { useEffect, useState, useTransition } from 'react';
4
4
  import { unstable_rethrow } from 'next/navigation';
5
5
  import { Alert, AlertDescription, Button, Input, Label, Spinner } from '@nextblock-cms/ui';
6
6
  import { resendEmailCode, verifyEmailCode, verifyTotpChallenge } from '../actions';
7
7
 
8
+ /** Matches the security panel: relays queue, so parking the button beats spamming it. */
9
+ const RESEND_COOLDOWN_SECONDS = 30;
10
+
8
11
  interface TwoFactorFormProps {
9
12
  type: 'totp' | 'email';
10
13
  email: string;
@@ -24,6 +27,13 @@ export default function TwoFactorForm({
24
27
  const [info, setInfo] = useState<string | null>(
25
28
  type === 'email' && pendingEmailCode ? `Enter the code we sent to ${email}.` : null,
26
29
  );
30
+ const [cooldown, setCooldown] = useState(0);
31
+
32
+ useEffect(() => {
33
+ if (cooldown <= 0) return;
34
+ const timer = setTimeout(() => setCooldown((seconds) => seconds - 1), 1000);
35
+ return () => clearTimeout(timer);
36
+ }, [cooldown]);
27
37
 
28
38
  const submit = (codeToSubmit: string = code) => {
29
39
  if (codeToSubmit.length !== 6) return;
@@ -48,13 +58,17 @@ export default function TwoFactorForm({
48
58
  };
49
59
 
50
60
  const resend = () => {
61
+ if (cooldown > 0) return;
51
62
  setError(null);
52
63
  setInfo(null);
53
64
  startTransition(async () => {
54
65
  try {
55
66
  const result = await resendEmailCode();
56
67
  if (result?.error) setError(result.error);
57
- else if (result?.message) setInfo(result.message);
68
+ else if (result?.message) {
69
+ setInfo(result.message);
70
+ setCooldown(RESEND_COOLDOWN_SECONDS);
71
+ }
58
72
  } catch (err) {
59
73
  setError(err instanceof Error ? err.message : 'Could not send a code.');
60
74
  }
@@ -116,14 +130,24 @@ export default function TwoFactorForm({
116
130
  </form>
117
131
 
118
132
  {type === 'email' && (
119
- <button
120
- type="button"
121
- onClick={resend}
122
- disabled={isPending}
123
- className="mt-4 w-full text-center text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground disabled:opacity-50"
124
- >
125
- {pendingEmailCode ? 'Resend code' : 'Send me a code'}
126
- </button>
133
+ <>
134
+ <button
135
+ type="button"
136
+ onClick={resend}
137
+ disabled={isPending || cooldown > 0}
138
+ className="mt-4 w-full text-center text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground disabled:no-underline disabled:opacity-50"
139
+ >
140
+ {cooldown > 0
141
+ ? `Resend available in ${cooldown}s`
142
+ : pendingEmailCode
143
+ ? 'Resend code'
144
+ : 'Send me a code'}
145
+ </button>
146
+ <p className="mt-2 text-center text-xs text-muted-foreground">
147
+ Codes can take a minute to arrive. If you request another, the earlier one still
148
+ works — enter whichever reaches you first.
149
+ </p>
150
+ </>
127
151
  )}
128
152
  </div>
129
153
  );
@@ -1,8 +1,12 @@
1
- "use server";
1
+ import 'server-only';
2
+ // The single outbound-mail choke point. Deliberately NOT a "use server" module: every
3
+ // caller is server-side (2FA codes, form/interaction notifications, feedback, the SMTP
4
+ // test), and marking it as an action would register `sendEmail` as a client-callable
5
+ // endpoint — an open relay taking an arbitrary recipient, subject and HTML body.
2
6
 
3
- import { resolveEmailServerConfig } from '../../lib/config/email-settings';
7
+ import nodemailer, { type Transporter } from 'nodemailer';
8
+ import { resolveEmailServerConfig, type ResolvedEmailConfig } from '../../lib/config/email-settings';
4
9
  import { applyEmailBranding, resolveEmailBranding } from '../../lib/email/branding';
5
- import nodemailer from 'nodemailer';
6
10
 
7
11
  interface EmailParams {
8
12
  to: string;
@@ -11,9 +15,77 @@ interface EmailParams {
11
15
  html: string;
12
16
  }
13
17
 
18
+ // Without explicit bounds nodemailer inherits the OS socket timeouts, so an unreachable
19
+ // or silently-dropping relay hangs the request (and the user's spinner) for minutes.
20
+ const CONNECTION_TIMEOUT_MS = 10_000;
21
+ const GREETING_TIMEOUT_MS = 10_000;
22
+ const SOCKET_TIMEOUT_MS = 20_000;
23
+
24
+ // Opening a fresh SMTP connection per message costs a TCP handshake + TLS negotiation +
25
+ // AUTH round trip before the first byte of the message is sent — typically the bulk of the
26
+ // wait when a user clicks "send me a code". A pooled transport keeps the authenticated
27
+ // connection warm so subsequent sends start at DATA. Set SMTP_POOL=false to opt out.
28
+ const POOL_ENABLED = process.env['SMTP_POOL'] !== 'false';
29
+
30
+ let cachedTransport: { key: string; transporter: Transporter } | null = null;
31
+
32
+ /** Identity of a transport: anything that changes it must force a rebuild. */
33
+ function transportKey(config: ResolvedEmailConfig): string {
34
+ return [config.host, config.port, config.secure, config.auth.user].join('|');
35
+ }
36
+
37
+ function getTransporter(config: ResolvedEmailConfig): Transporter {
38
+ const key = transportKey(config);
39
+ if (cachedTransport && cachedTransport.key === key) {
40
+ return cachedTransport.transporter;
41
+ }
42
+
43
+ // Settings changed — tear the old pool down rather than leaking its sockets.
44
+ if (cachedTransport) {
45
+ try {
46
+ cachedTransport.transporter.close();
47
+ } catch {
48
+ /* already closed */
49
+ }
50
+ cachedTransport = null;
51
+ }
52
+
53
+ const base = {
54
+ host: config.host,
55
+ port: config.port,
56
+ secure: config.secure,
57
+ auth: config.auth,
58
+ connectionTimeout: CONNECTION_TIMEOUT_MS,
59
+ greetingTimeout: GREETING_TIMEOUT_MS,
60
+ socketTimeout: SOCKET_TIMEOUT_MS,
61
+ };
62
+
63
+ // Branched rather than `pool: POOL_ENABLED` — nodemailer types `pool` as the literal
64
+ // `true` to select the pooled transport overload, so a boolean matches neither.
65
+ const transporter: Transporter = POOL_ENABLED
66
+ ? nodemailer.createTransport({ ...base, pool: true, maxConnections: 3, maxMessages: 100 })
67
+ : nodemailer.createTransport(base);
68
+
69
+ // A pool that errors out (relay restart, credentials rotated, idle socket reaped) must
70
+ // not be handed to the next caller — drop it so the following send reconnects cleanly.
71
+ transporter.on('error', () => {
72
+ if (cachedTransport?.transporter === transporter) {
73
+ cachedTransport = null;
74
+ }
75
+ });
76
+
77
+ cachedTransport = { key, transporter };
78
+ return transporter;
79
+ }
80
+
14
81
  export async function sendEmail({ to, subject, text, html }: EmailParams) {
15
82
  // DB-first (CMS Settings → Configuration → Email), falling back to SMTP_* env vars.
16
- const emailConfig = await resolveEmailServerConfig();
83
+ // Resolved in parallel with branding — neither depends on the other, and both are
84
+ // pure reads standing between the click and the first SMTP packet.
85
+ const [emailConfig, branding] = await Promise.all([
86
+ resolveEmailServerConfig(),
87
+ resolveEmailBranding(),
88
+ ]);
17
89
 
18
90
  if (!emailConfig) {
19
91
  throw new Error("Email server is not configured. Configure SMTP in CMS Settings → Configuration → Email.");
@@ -22,10 +94,9 @@ export async function sendEmail({ to, subject, text, html }: EmailParams) {
22
94
  // Single interception point: white-label every outgoing email with the tenant's own
23
95
  // logo + site name (or a text banner when no logo is set). Every app-dispatched email
24
96
  // funnels through here, so branding is applied once, centrally.
25
- const branding = await resolveEmailBranding();
26
97
  const brandedHtml = applyEmailBranding(html, branding);
27
98
 
28
- const transporter = nodemailer.createTransport(emailConfig);
99
+ const transporter = getTransporter(emailConfig);
29
100
 
30
101
  const options = {
31
102
  from: emailConfig.from,
@@ -36,4 +107,4 @@ export async function sendEmail({ to, subject, text, html }: EmailParams) {
36
107
  };
37
108
 
38
109
  return transporter.sendMail(options);
39
- }
110
+ }
@@ -1,37 +1,79 @@
1
1
  "use server";
2
2
 
3
+ import { createClient } from "@nextblock-cms/db/server";
3
4
  import { sendEmail } from "./email";
4
5
 
5
-
6
6
  interface FeedbackData {
7
7
  subject: string;
8
8
  message: string;
9
- userEmail: string;
9
+ /** Accepted for call-site compatibility but ignored — identity comes from the session. */
10
+ userEmail?: string;
10
11
  userName?: string;
11
12
  url?: string;
12
13
  }
13
14
 
15
+ const MAX_SUBJECT_LENGTH = 200;
16
+ const MAX_MESSAGE_LENGTH = 5000;
17
+ const MAX_URL_LENGTH = 500;
18
+
19
+ /** Everything below is attacker-controlled text landing in an HTML email body. */
20
+ function escapeHtml(value: string): string {
21
+ return value
22
+ .replace(/&/g, '&amp;')
23
+ .replace(/</g, '&lt;')
24
+ .replace(/>/g, '&gt;')
25
+ .replace(/"/g, '&quot;')
26
+ .replace(/'/g, '&#39;');
27
+ }
28
+
14
29
  export async function submitFeedback(data: FeedbackData) {
15
30
  try {
16
- // If we have an email config, we use it. If not, we might fail or log.
17
- // sendEmail throws if not configured.
18
-
19
- const { subject, message, userEmail, userName, url } = data;
20
-
31
+ // This is a client-callable action, so the CMS-only modal in front of it is not a
32
+ // gate: without this check any unauthenticated caller could relay arbitrary mail
33
+ // through the tenant's SMTP credentials.
34
+ const supabase = createClient();
35
+ const {
36
+ data: { user },
37
+ } = await supabase.auth.getUser();
38
+ if (!user) {
39
+ return { success: false, error: "You must be signed in to send feedback." };
40
+ }
41
+
42
+ const { data: profile } = await supabase
43
+ .from('profiles')
44
+ .select('role, full_name')
45
+ .eq('id', user.id)
46
+ .maybeSingle();
47
+ if (profile?.role !== 'ADMIN' && profile?.role !== 'WRITER') {
48
+ return { success: false, error: "You do not have permission to send feedback." };
49
+ }
50
+
51
+ const subject = (data.subject ?? '').slice(0, MAX_SUBJECT_LENGTH);
52
+ const message = (data.message ?? '').slice(0, MAX_MESSAGE_LENGTH);
53
+ const url = (data.url ?? '').slice(0, MAX_URL_LENGTH);
54
+ if (!message.trim()) {
55
+ return { success: false, error: "Feedback message is empty." };
56
+ }
57
+
58
+ // Identity is read from the session rather than the request body — the client cannot
59
+ // choose whose name a report is filed under.
60
+ const senderEmail = user.email ?? 'unknown';
61
+ const senderName = profile?.full_name || senderEmail;
62
+
21
63
  const htmlContent = `
22
64
  {{brand_header}}
23
65
  <h2>New Feedback Received</h2>
24
- <p><strong>From:</strong> ${userName || 'Unknown'} (${userEmail})</p>
25
- <p><strong>Subject:</strong> ${subject}</p>
26
- <p><strong>URL:</strong> ${url || 'N/A'}</p>
66
+ <p><strong>From:</strong> ${escapeHtml(senderName)} (${escapeHtml(senderEmail)})</p>
67
+ <p><strong>Subject:</strong> ${escapeHtml(subject)}</p>
68
+ <p><strong>URL:</strong> ${escapeHtml(url) || 'N/A'}</p>
27
69
  <br/>
28
70
  <h3>Message:</h3>
29
- <p style="white-space: pre-wrap;">${message}</p>
71
+ <p style="white-space: pre-wrap;">${escapeHtml(message)}</p>
30
72
  `;
31
73
 
32
74
  const textContent = `
33
75
  New Feedback Received
34
- From: ${userName || 'Unknown'} (${userEmail})
76
+ From: ${senderName} (${senderEmail})
35
77
  Subject: ${subject}
36
78
  URL: ${url || 'N/A'}
37
79
 
@@ -40,8 +82,9 @@ export async function submitFeedback(data: FeedbackData) {
40
82
  `;
41
83
 
42
84
  await sendEmail({
43
- to: "feedback@nextblock.ca", // Fixed typo from 'feeedback'
44
- subject: `[CMS Feedback] ${subject}`,
85
+ to: "feedback@nextblock.ca",
86
+ // Newlines in a header would let a caller inject extra headers (Bcc, …).
87
+ subject: `[CMS Feedback] ${subject.replace(/[\r\n]+/g, ' ')}`,
45
88
  text: textContent,
46
89
  html: htmlContent,
47
90
  });
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  const cacheMocks = vi.hoisted(() => ({
4
4
  revalidatePath: vi.fn(),
5
+ // Reached transitively: interactions -> email -> email/branding -> site-settings, which
6
+ // wraps its reader in unstable_cache at module scope. Pass the loader straight through.
7
+ unstable_cache: vi.fn((fn: unknown) => fn),
5
8
  }));
6
9
 
7
10
  const dbServerMocks = vi.hoisted(() => ({
@@ -0,0 +1,40 @@
1
+ 'use server';
2
+
3
+ import {
4
+ loadProductGridPage,
5
+ type ProductGridQuery,
6
+ } from '../../lib/blocks/product-grid-data';
7
+ import {
8
+ PRODUCT_GRID_MAX_LIMIT,
9
+ PRODUCT_GRID_DEFAULT_PAGE_SIZE,
10
+ } from '../../lib/blocks/ecommerce-block-schemas';
11
+ import type { Product } from '@nextblock-cms/ecommerce/types';
12
+
13
+ export interface ProductGridPageResult {
14
+ products: Product[];
15
+ totalCount: number;
16
+ error?: string;
17
+ }
18
+
19
+ /**
20
+ * Pagination for the Product Grid block. Only ever returns published products
21
+ * (`getProducts` defaults to status `active`), so this is safe to call from the
22
+ * storefront. The page size is clamped here because the argument arrives from
23
+ * the client — the unlimited path stays server-render only.
24
+ */
25
+ export async function fetchProductGridPage(
26
+ query: ProductGridQuery
27
+ ): Promise<ProductGridPageResult> {
28
+ try {
29
+ const limit = Math.min(
30
+ Math.max(1, Math.floor(query.limit) || PRODUCT_GRID_DEFAULT_PAGE_SIZE),
31
+ PRODUCT_GRID_MAX_LIMIT
32
+ );
33
+
34
+ const { products, totalCount } = await loadProductGridPage({ ...query, limit });
35
+ return { products, totalCount };
36
+ } catch (error) {
37
+ console.error('[Product Grid] Failed to load page:', error);
38
+ return { products: [], totalCount: 0, error: 'Failed to load products.' };
39
+ }
40
+ }
@@ -3,6 +3,7 @@
3
3
  import { encodedRedirect } from "@nextblock-cms/utils/server";
4
4
  import { createClient, getServiceRoleSupabaseClient } from "@nextblock-cms/db/server";
5
5
  import { headers } from "next/headers";
6
+ import { after } from "next/server";
6
7
  import { redirect } from "next/navigation";
7
8
  import { resolvePostAuthRedirect } from "../lib/auth-redirects";
8
9
  import { createEmailChallenge, evaluateTwoFactor } from "../lib/auth/twoFactor";
@@ -169,13 +170,25 @@ export const signInAction = async (formData: FormData) => {
169
170
  await setSecureCookie(REMEMBER_INTENT_COOKIE, "1", 15 * 60);
170
171
  }
171
172
 
172
- // Email factor: send the first code now so the challenge page has one waiting.
173
+ // Email factor: mint the first code now so the challenge page has one waiting. The
174
+ // challenge row is created synchronously because the page reads it to decide between
175
+ // "Resend code" and "Send me a code"; only the SMTP conversation is deferred, so the
176
+ // redirect isn't held behind a relay handshake that can take seconds. Delivery failure
177
+ // was already swallowed here — the user resends from the challenge page — so moving it
178
+ // off the response path costs no error reporting.
173
179
  if (evaluation.status === "email_required" && data.user.email) {
180
+ const recipient = data.user.email;
174
181
  try {
175
182
  const code = await createEmailChallenge(data.user.id);
176
- await sendTwoFactorCodeEmail(data.user.email, code);
177
- } catch (sendError) {
178
- console.error("Failed to send 2FA email code:", sendError);
183
+ after(async () => {
184
+ try {
185
+ await sendTwoFactorCodeEmail(recipient, code);
186
+ } catch (sendError) {
187
+ console.error("Failed to send 2FA email code:", sendError);
188
+ }
189
+ });
190
+ } catch (challengeError) {
191
+ console.error("Failed to create 2FA email challenge:", challengeError);
179
192
  }
180
193
  }
181
194
 
@@ -0,0 +1,151 @@
1
+ import { NextResponse } from 'next/server';
2
+ import {
3
+ createClient,
4
+ getServiceRoleSupabaseClient,
5
+ verifyPackageOnline,
6
+ } from '@nextblock-cms/db/server';
7
+
8
+ export const dynamic = 'force-dynamic';
9
+
10
+ /** How many products a single search returns before the UI asks for a narrower query. */
11
+ const PRODUCT_PAGE_SIZE = 50;
12
+ /** Guard against an unbounded `ids` list when hydrating labels for an existing selection. */
13
+ const MAX_HYDRATED_IDS = 100;
14
+
15
+ export interface ProductPickerCategory {
16
+ id: string;
17
+ name: string;
18
+ slug: string;
19
+ }
20
+
21
+ export interface ProductPickerProduct {
22
+ id: string;
23
+ title: string;
24
+ slug: string | null;
25
+ sku: string | null;
26
+ status: string | null;
27
+ languageCode: string | null;
28
+ translationGroupId: string | null;
29
+ }
30
+
31
+ /**
32
+ * PostgREST parses `,`, `.`, `(` and `)` inside an `or(...)` filter, so anything
33
+ * the author types has to be stripped before it is interpolated into one.
34
+ */
35
+ function sanitizeSearchTerm(term: string): string {
36
+ return term.replace(/[,().*%\\]/g, ' ').trim().slice(0, 80);
37
+ }
38
+
39
+ /**
40
+ * Options for the Product Grid block pickers: every category plus a page of
41
+ * products (optionally filtered by `search`). `ids` hydrates labels for an
42
+ * existing selection whose products fall outside the current page.
43
+ */
44
+ export async function GET(request: Request) {
45
+ try {
46
+ const supabase = createClient();
47
+ const {
48
+ data: { user },
49
+ error: authError,
50
+ } = await supabase.auth.getUser();
51
+
52
+ if (authError || !user) {
53
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
54
+ }
55
+
56
+ const { data: profile } = await supabase
57
+ .from('profiles')
58
+ .select('role')
59
+ .eq('id', user.id)
60
+ .single();
61
+
62
+ if (!profile || !['ADMIN', 'WRITER'].includes(profile.role)) {
63
+ return NextResponse.json(
64
+ { error: 'Forbidden: Insufficient permissions' },
65
+ { status: 403 }
66
+ );
67
+ }
68
+
69
+ // Fail closed: without the ecommerce package there is no product catalog to
70
+ // browse, and the blocks that use this route are hidden from the picker.
71
+ if (!(await verifyPackageOnline('ecommerce'))) {
72
+ return NextResponse.json(
73
+ { error: 'The ecommerce package is not active.' },
74
+ { status: 403 }
75
+ );
76
+ }
77
+
78
+ const url = new URL(request.url);
79
+ const search = sanitizeSearchTerm(url.searchParams.get('search') ?? '');
80
+ const ids = (url.searchParams.get('ids') ?? '')
81
+ .split(',')
82
+ .map((id) => id.trim())
83
+ .filter(Boolean)
84
+ .slice(0, MAX_HYDRATED_IDS);
85
+
86
+ // Reading the catalog is admin work behind the role check above.
87
+ const admin = getServiceRoleSupabaseClient();
88
+
89
+ const productColumns =
90
+ 'id, title, slug, sku, status, translation_group_id, languages(code)';
91
+
92
+ let productsQuery = admin
93
+ .from('products')
94
+ .select(productColumns)
95
+ .order('created_at', { ascending: false })
96
+ // Fetch one extra row so the UI can tell the author the list was truncated.
97
+ .limit(PRODUCT_PAGE_SIZE + 1);
98
+
99
+ if (search) {
100
+ productsQuery = productsQuery.or(`title.ilike.%${search}%,sku.ilike.%${search}%`);
101
+ }
102
+
103
+ const [categoriesResult, productsResult, selectedResult] = await Promise.all([
104
+ admin.from('categories').select('id, name, slug').order('name', { ascending: true }),
105
+ productsQuery,
106
+ ids.length > 0
107
+ ? admin.from('products').select(productColumns).in('id', ids)
108
+ : Promise.resolve({ data: [], error: null }),
109
+ ]);
110
+
111
+ if (categoriesResult.error) throw categoriesResult.error;
112
+ if (productsResult.error) throw productsResult.error;
113
+ if (selectedResult.error) throw selectedResult.error;
114
+
115
+ const toPickerProduct = (row: any): ProductPickerProduct => ({
116
+ id: row.id,
117
+ title: row.title,
118
+ slug: row.slug ?? null,
119
+ sku: row.sku ?? null,
120
+ status: row.status ?? null,
121
+ languageCode: row.languages?.code ?? null,
122
+ translationGroupId: row.translation_group_id ?? null,
123
+ });
124
+
125
+ const productRows = (productsResult.data ?? []) as any[];
126
+ const hasMore = productRows.length > PRODUCT_PAGE_SIZE;
127
+ const products = productRows.slice(0, PRODUCT_PAGE_SIZE).map(toPickerProduct);
128
+
129
+ // Merge the hydrated selection in so already-chosen products always render
130
+ // with a real label, even when a search hides them.
131
+ const byId = new Map(products.map((product) => [product.id, product]));
132
+ for (const row of (selectedResult.data ?? []) as any[]) {
133
+ if (!byId.has(row.id)) byId.set(row.id, toPickerProduct(row));
134
+ }
135
+
136
+ return NextResponse.json(
137
+ {
138
+ categories: (categoriesResult.data ?? []) as ProductPickerCategory[],
139
+ products: Array.from(byId.values()),
140
+ hasMore,
141
+ },
142
+ { status: 200 }
143
+ );
144
+ } catch (error) {
145
+ console.error('[Product Picker API] Unexpected error:', error);
146
+ return NextResponse.json(
147
+ { error: 'Failed to load product picker options.' },
148
+ { status: 500 }
149
+ );
150
+ }
151
+ }
@@ -22,6 +22,7 @@ import { FeedbackModal } from "./components/FeedbackModal";
22
22
  import { CortexGlobalAgentChat } from "./components/CortexGlobalAgentChat";
23
23
  import { CortexAiPageContextProvider } from "./components/CortexAiPageContext";
24
24
  import { CortexAiActiveProvider } from "./components/CortexAiActiveContext";
25
+ import { EcommerceActiveProvider } from "./components/EcommerceActiveContext";
25
26
  import { useAppBranding } from "../../components/AppShell";
26
27
  import { resolveMediaUrl } from "../../lib/media/resolveMediaUrl";
27
28
 
@@ -257,6 +258,7 @@ export default function CmsClientLayout({
257
258
  return (
258
259
  <CortexAiPageContextProvider>
259
260
  <CortexAiActiveProvider isActive={isCortexAiActive}>
261
+ <EcommerceActiveProvider isActive={isEcommerceActive}>
260
262
  <div className="relative flex h-full min-h-0 w-full overflow-hidden bg-slate-50 dark:bg-slate-950 md:flex-row">
261
263
  <div className="fixed bottom-4 right-4 z-[60] md:hidden">
262
264
  <Button
@@ -496,7 +498,7 @@ export default function CmsClientLayout({
496
498
  {pageTitle}
497
499
  </h1>
498
500
  <Button asChild variant="outline" size="sm" className="shrink-0">
499
- <Link href="/">
501
+ <Link href="/" target="_blank">
500
502
  <ExternalLink className="h-4 w-4" />
501
503
  <span className="hidden sm:inline">View Site</span>
502
504
  </Link>
@@ -516,6 +518,7 @@ export default function CmsClientLayout({
516
518
  )}
517
519
  {isAdmin && isCortexAiActive && <CortexGlobalAgentChat />}
518
520
  </div>
521
+ </EcommerceActiveProvider>
519
522
  </CortexAiActiveProvider>
520
523
  </CortexAiPageContextProvider>
521
524
  )