create-nextblock 0.11.1 → 0.11.3

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 (59) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/actions/interactions.test.ts +301 -0
  3. package/templates/nextblock-template/app/actions/interactions.ts +372 -0
  4. package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +4 -4
  5. package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +2 -2
  6. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +56 -57
  7. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +1 -1
  8. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +951 -0
  9. package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +6 -0
  10. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +4 -0
  11. package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +7 -2
  12. package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +4 -0
  13. package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +408 -0
  14. package/templates/nextblock-template/app/cms/interactions/page.tsx +51 -0
  15. package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +4 -3
  16. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +1 -1
  17. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +3 -5
  18. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +1 -1
  19. package/templates/nextblock-template/app/page.tsx +2 -2
  20. package/templates/nextblock-template/app/product/[slug]/page.tsx +2 -0
  21. package/templates/nextblock-template/components/AppShell.tsx +1 -1
  22. package/templates/nextblock-template/components/PostCommentsSection.tsx +369 -0
  23. package/templates/nextblock-template/components/ProductReviewsSection.tsx +419 -0
  24. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +2 -0
  25. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +62 -19
  26. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +19 -19
  27. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +4 -4
  28. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +7 -0
  29. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +2 -0
  30. package/templates/nextblock-template/lib/setup/actions.ts +3 -1
  31. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +40 -0
  32. package/templates/nextblock-template/lib/updates/check-upstream.ts +38 -4
  33. package/templates/nextblock-template/package.json +2 -1
  34. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +2 -4
  35. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +1 -1
  36. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +1 -1
  37. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +1 -1
  38. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  39. package/templates/nextblock-template/lib/ai-block-generation.ts +0 -339
  40. package/templates/nextblock-template/lib/ai-client.ts +0 -247
  41. package/templates/nextblock-template/lib/ai-config.ts +0 -98
  42. package/templates/nextblock-template/lib/ai-cortex-widget-builder.ts +0 -125
  43. package/templates/nextblock-template/lib/ai-global-agent-custom-block-tools.ts +0 -363
  44. package/templates/nextblock-template/lib/ai-global-agent-db-tools.test.ts +0 -405
  45. package/templates/nextblock-template/lib/ai-global-agent-db-tools.ts +0 -1228
  46. package/templates/nextblock-template/lib/ai-global-agent-ecommerce.ts +0 -5
  47. package/templates/nextblock-template/lib/ai-global-agent-tools-stats.test.ts +0 -223
  48. package/templates/nextblock-template/lib/ai-global-agent-tools.test.ts +0 -2183
  49. package/templates/nextblock-template/lib/ai-global-agent-tools.ts +0 -4807
  50. package/templates/nextblock-template/lib/ai-key-crypto.test.ts +0 -70
  51. package/templates/nextblock-template/lib/ai-key-crypto.ts +0 -132
  52. package/templates/nextblock-template/lib/ai-model-catalog.test.ts +0 -49
  53. package/templates/nextblock-template/lib/ai-model-catalog.ts +0 -41
  54. package/templates/nextblock-template/lib/ai-model-registry.test.ts +0 -231
  55. package/templates/nextblock-template/lib/ai-model-registry.ts +0 -522
  56. package/templates/nextblock-template/lib/cortex-widget-registry.test.ts +0 -199
  57. package/templates/nextblock-template/lib/cortex-widget-registry.ts +0 -88
  58. package/templates/nextblock-template/lib/cortex-widget-schema.test.tsx +0 -237
  59. package/templates/nextblock-template/lib/cortex-widget-schema.ts +0 -393
@@ -0,0 +1,369 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect, useTransition, useOptimistic } from "react";
4
+ import { createClient } from "@nextblock-cms/db";
5
+ import { Button } from "@nextblock-cms/ui";
6
+ import { Textarea } from "@nextblock-cms/ui";
7
+ import { Avatar, AvatarFallback, AvatarImage } from "@nextblock-cms/ui";
8
+ import { submitInteraction, toggleReaction } from "../app/actions/interactions";
9
+ import { cn, useTranslations } from "@nextblock-cms/utils";
10
+ import { MessageSquare, ThumbsUp, Loader2, PenTool } from "lucide-react";
11
+
12
+ interface PostCommentsSectionProps {
13
+ postId: number;
14
+ }
15
+
16
+ export default function PostCommentsSection({ postId }: PostCommentsSectionProps) {
17
+ const { t, lang } = useTranslations();
18
+ const [comments, setComments] = useState<any[]>([]);
19
+ const [loading, setLoading] = useState(true);
20
+ const [page, setPage] = useState(0);
21
+ const [hasMore, setHasMore] = useState(true);
22
+ const [user, setUser] = useState<any>(null);
23
+
24
+ // Form states
25
+ const [isFormOpen, setIsFormOpen] = useState(false);
26
+ const [content, setContent] = useState("");
27
+ const [submitting, setSubmitting] = useState(false);
28
+ const [error, setError] = useState<string | null>(null);
29
+ const [success, setSuccess] = useState<string | null>(null);
30
+
31
+ // Liked interactions tracking
32
+ const [likedIds, setLikedIds] = useState<string[]>([]);
33
+ const [, startTransition] = useTransition();
34
+
35
+ // Optimistic comments list
36
+ const [optimisticComments, setOptimisticComments] = useOptimistic(
37
+ comments,
38
+ (state, { commentId, hasReacted, count }: { commentId: string; hasReacted: boolean; count: number }) =>
39
+ state.map((c) => {
40
+ if (c.id === commentId) {
41
+ const reactions = { ...((c.reactions as Record<string, number>) || {}) };
42
+ reactions.likes = count;
43
+ return { ...c, reactions, tempHasReacted: hasReacted };
44
+ }
45
+ return c;
46
+ })
47
+ );
48
+
49
+ useEffect(() => {
50
+ // 1. Fetch user
51
+ const supabase = createClient();
52
+ supabase.auth.getUser().then(({ data: { user } }) => {
53
+ setUser(user);
54
+ });
55
+
56
+ // 2. Read liked interactions from cookies
57
+ const match = document.cookie.match(/reacted_interactions=([^;]+)/);
58
+ if (match) {
59
+ try {
60
+ setLikedIds(JSON.parse(decodeURIComponent(match[1])));
61
+ } catch (err) {
62
+ console.warn("Failed to parse liked interactions cookie:", err);
63
+ }
64
+ }
65
+
66
+ // 3. Load initial comments
67
+ fetchComments(0, true);
68
+ }, [postId]);
69
+
70
+ const fetchComments = async (pageNum: number, isInitial = false) => {
71
+ setLoading(true);
72
+ const supabase = createClient();
73
+ const limit = 5;
74
+ const start = pageNum * limit;
75
+ const end = start + limit - 1;
76
+
77
+ try {
78
+ const { data, error: dbError } = await supabase
79
+ .from("cms_interactions" as any)
80
+ .select("*, profiles(full_name, avatar_url, github_username)")
81
+ .eq("post_id", postId)
82
+ .eq("type", "comment")
83
+ .eq("status", "approved")
84
+ .order("created_at", { ascending: false })
85
+ .range(start, end);
86
+
87
+ if (dbError) throw dbError;
88
+
89
+ if (data) {
90
+ if (isInitial) {
91
+ setComments(data);
92
+ setPage(0);
93
+ } else {
94
+ setComments((prev) => [...prev, ...data]);
95
+ setPage(pageNum);
96
+ }
97
+ setHasMore(data.length === limit);
98
+ }
99
+ } catch (err) {
100
+ console.error("Error fetching comments:", err);
101
+ } finally {
102
+ setLoading(false);
103
+ }
104
+ };
105
+
106
+ const handleLoadMore = () => {
107
+ fetchComments(page + 1);
108
+ };
109
+
110
+ const handleLike = async (commentId: string) => {
111
+ const isLiked = likedIds.includes(commentId);
112
+ const comment = comments.find((c) => c.id === commentId);
113
+ if (!comment) return;
114
+
115
+ const currentLikes = (comment.reactions as Record<string, number>)?.likes || 0;
116
+ const nextCount = isLiked ? Math.max(0, currentLikes - 1) : currentLikes + 1;
117
+
118
+ startTransition(async () => {
119
+ // Apply optimistic update
120
+ setOptimisticComments({ commentId, hasReacted: !isLiked, count: nextCount });
121
+
122
+ const res = await toggleReaction(commentId);
123
+ if (res.success) {
124
+ // Update liked cookie state locally
125
+ if (isLiked) {
126
+ setLikedIds((prev) => prev.filter((id) => id !== commentId));
127
+ } else {
128
+ setLikedIds((prev) => [...prev, commentId]);
129
+ }
130
+ // Sync actual state
131
+ setComments((prev) =>
132
+ prev.map((c) => {
133
+ if (c.id === commentId) {
134
+ const reactions = { ...((c.reactions as Record<string, number>) || {}) };
135
+ reactions.likes = res.count ?? nextCount;
136
+ return { ...c, reactions };
137
+ }
138
+ return c;
139
+ })
140
+ );
141
+ }
142
+ });
143
+ };
144
+
145
+ const handleSubmitComment = async (e: React.FormEvent) => {
146
+ e.preventDefault();
147
+ if (!user) {
148
+ setError(t("comments.login_to_write"));
149
+ return;
150
+ }
151
+
152
+ if (content.trim().length < 5) {
153
+ setError(lang === "fr" ? "Votre commentaire doit faire au moins 5 caractères." : "Your comment must be at least 5 characters long.");
154
+ return;
155
+ }
156
+
157
+ setSubmitting(true);
158
+ setError(null);
159
+ setSuccess(null);
160
+
161
+ const res = await submitInteraction({
162
+ type: "comment",
163
+ content: content.trim(),
164
+ postId,
165
+ });
166
+
167
+ setSubmitting(false);
168
+
169
+ if (res.error) {
170
+ setError(res.error);
171
+ } else {
172
+ setSuccess(t("comments.success_pending"));
173
+ setContent("");
174
+ // Close form after a brief delay
175
+ setTimeout(() => {
176
+ setIsFormOpen(false);
177
+ setSuccess(null);
178
+ }, 3000);
179
+ }
180
+ };
181
+
182
+ const getInitials = (profile: any) => {
183
+ if (profile?.full_name) {
184
+ return profile.full_name
185
+ .split(" ")
186
+ .map((n: string) => n[0])
187
+ .join("")
188
+ .toUpperCase()
189
+ .slice(0, 2);
190
+ }
191
+ return "U";
192
+ };
193
+
194
+ return (
195
+ <div className="space-y-8">
196
+ {/* Header */}
197
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between border-b pb-6 gap-4">
198
+ <div>
199
+ <h2 className="text-2xl font-bold tracking-tight text-foreground flex items-center gap-2">
200
+ <MessageSquare className="h-6 w-6 text-primary" />
201
+ {t("comments.discussion")}
202
+ </h2>
203
+ <p className="text-sm text-muted-foreground mt-1">
204
+ {t("comments.join_conversation")}
205
+ </p>
206
+ </div>
207
+
208
+ {user ? (
209
+ <Button
210
+ onClick={() => setIsFormOpen(!isFormOpen)}
211
+ className="flex items-center gap-2 transition-all"
212
+ variant={isFormOpen ? "outline" : "default"}
213
+ >
214
+ <PenTool className="h-4 w-4" />
215
+ {isFormOpen ? t("comments.cancel_comment") : t("comments.write_comment")}
216
+ </Button>
217
+ ) : (
218
+ <p className="text-sm text-muted-foreground italic">
219
+ {t("comments.login_to_write")}
220
+ </p>
221
+ )}
222
+ </div>
223
+
224
+ {/* Submission Form */}
225
+ {isFormOpen && (
226
+ <form
227
+ onSubmit={handleSubmitComment}
228
+ className="bg-card/50 border border-border/80 rounded-2xl p-6 shadow-sm space-y-4 animate-in fade-in slide-in-from-top-4 duration-300"
229
+ >
230
+ <h3 className="text-lg font-semibold text-foreground">{t("comments.join_discussion")}</h3>
231
+
232
+ {/* Text Area */}
233
+ <div className="space-y-1.5">
234
+ <label
235
+ htmlFor="comment-content"
236
+ className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block"
237
+ >
238
+ {t("comments.your_message")}
239
+ </label>
240
+ <Textarea
241
+ id="comment-content"
242
+ placeholder={t("comments.message_placeholder")}
243
+ value={content}
244
+ onChange={(e) => setContent(e.target.value)}
245
+ className="min-h-[120px] focus:ring-1 focus:ring-primary"
246
+ disabled={submitting}
247
+ required
248
+ />
249
+ </div>
250
+
251
+ {error && <div className="text-sm font-semibold text-destructive">{error}</div>}
252
+ {success && <div className="text-sm font-semibold text-emerald-600">{success}</div>}
253
+
254
+ <div className="flex justify-end gap-3 pt-2">
255
+ <Button
256
+ type="button"
257
+ variant="ghost"
258
+ onClick={() => setIsFormOpen(false)}
259
+ disabled={submitting}
260
+ >
261
+ {t("comments.cancel")}
262
+ </Button>
263
+ <Button type="submit" disabled={submitting} className="min-w-[120px]">
264
+ {submitting ? (
265
+ <>
266
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
267
+ {t("comments.submitting")}
268
+ </>
269
+ ) : (
270
+ t("comments.post_comment")
271
+ )}
272
+ </Button>
273
+ </div>
274
+ </form>
275
+ )}
276
+
277
+ {/* Comments List */}
278
+ <div className="space-y-6">
279
+ {optimisticComments.length > 0 ? (
280
+ optimisticComments.map((comment) => {
281
+ const hasLiked = likedIds.includes(comment.id) || comment.tempHasReacted;
282
+ const likeCount = (comment.reactions as Record<string, number>)?.likes || 0;
283
+ const commenterName = comment.profiles?.full_name || comment.profiles?.github_username || "Anonymous";
284
+ const dateStr = new Date(comment.created_at).toLocaleDateString(lang, {
285
+ year: "numeric",
286
+ month: "long",
287
+ day: "numeric",
288
+ });
289
+
290
+ return (
291
+ <div
292
+ key={comment.id}
293
+ className="bg-card border border-border/60 rounded-2xl p-5 shadow-sm space-y-4 hover:border-border/100 transition-all duration-300"
294
+ >
295
+ {/* Commenter Header */}
296
+ <div className="flex items-center justify-between gap-4">
297
+ <div className="flex items-center gap-3">
298
+ <Avatar className="h-10 w-10 border">
299
+ <AvatarImage src={comment.profiles?.avatar_url || undefined} alt={commenterName} />
300
+ <AvatarFallback className="bg-primary/5 text-primary text-xs font-semibold">
301
+ {getInitials(comment.profiles)}
302
+ </AvatarFallback>
303
+ </Avatar>
304
+ <div>
305
+ <h4 className="text-sm font-semibold text-foreground leading-none">
306
+ {commenterName}
307
+ </h4>
308
+ <span className="text-[10px] text-muted-foreground mt-1.5 block" suppressHydrationWarning>
309
+ {dateStr}
310
+ </span>
311
+ </div>
312
+ </div>
313
+ </div>
314
+
315
+ {/* Comment Content */}
316
+ <p className="text-sm text-slate-600 dark:text-slate-350 leading-relaxed whitespace-pre-line text-left">
317
+ {comment.content}
318
+ </p>
319
+
320
+ {/* Reaction Actions */}
321
+ <div className="flex items-center pt-2">
322
+ <button
323
+ onClick={() => handleLike(comment.id)}
324
+ className={cn(
325
+ "flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-full border transition-all active:scale-95",
326
+ hasLiked
327
+ ? "bg-primary/5 border-primary/20 text-primary"
328
+ : "bg-background border-border text-muted-foreground hover:text-foreground hover:border-border/100"
329
+ )}
330
+ >
331
+ <ThumbsUp className={cn("h-3.5 w-3.5", hasLiked && "fill-current")} />
332
+ <span>{t("comments.like")}</span>
333
+ {likeCount > 0 && <span className="font-semibold ml-0.5">{likeCount}</span>}
334
+ </button>
335
+ </div>
336
+ </div>
337
+ );
338
+ })
339
+ ) : (
340
+ !loading && (
341
+ <div className="text-center py-12 border border-dashed rounded-2xl bg-slate-50/50 dark:bg-slate-900/10">
342
+ <MessageSquare className="mx-auto h-8 w-8 text-slate-300 dark:text-slate-700" />
343
+ <h3 className="mt-4 text-sm font-semibold text-foreground">{t("comments.no_comments")}</h3>
344
+ <p className="mt-1 text-xs text-muted-foreground">
345
+ {t("comments.be_the_first")}
346
+ </p>
347
+ </div>
348
+ )
349
+ )}
350
+
351
+ {/* Loading Spinner */}
352
+ {loading && (
353
+ <div className="flex justify-center py-6">
354
+ <Loader2 className="h-6 w-6 animate-spin text-primary" />
355
+ </div>
356
+ )}
357
+
358
+ {/* Load More */}
359
+ {hasMore && !loading && (
360
+ <div className="flex justify-center pt-2">
361
+ <Button variant="outline" size="sm" onClick={handleLoadMore}>
362
+ {t("comments.load_more")}
363
+ </Button>
364
+ </div>
365
+ )}
366
+ </div>
367
+ </div>
368
+ );
369
+ }