create-nextblock 0.11.1 → 0.11.2

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 (58) 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 +837 -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/lib/blocks/ProductGridBlock.tsx +2 -0
  29. package/templates/nextblock-template/lib/setup/actions.ts +3 -1
  30. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +30 -0
  31. package/templates/nextblock-template/lib/updates/check-upstream.ts +38 -4
  32. package/templates/nextblock-template/package.json +2 -1
  33. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +2 -4
  34. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +1 -1
  35. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +1 -1
  36. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +1 -1
  37. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  38. package/templates/nextblock-template/lib/ai-block-generation.ts +0 -339
  39. package/templates/nextblock-template/lib/ai-client.ts +0 -247
  40. package/templates/nextblock-template/lib/ai-config.ts +0 -98
  41. package/templates/nextblock-template/lib/ai-cortex-widget-builder.ts +0 -125
  42. package/templates/nextblock-template/lib/ai-global-agent-custom-block-tools.ts +0 -363
  43. package/templates/nextblock-template/lib/ai-global-agent-db-tools.test.ts +0 -405
  44. package/templates/nextblock-template/lib/ai-global-agent-db-tools.ts +0 -1228
  45. package/templates/nextblock-template/lib/ai-global-agent-ecommerce.ts +0 -5
  46. package/templates/nextblock-template/lib/ai-global-agent-tools-stats.test.ts +0 -223
  47. package/templates/nextblock-template/lib/ai-global-agent-tools.test.ts +0 -2183
  48. package/templates/nextblock-template/lib/ai-global-agent-tools.ts +0 -4807
  49. package/templates/nextblock-template/lib/ai-key-crypto.test.ts +0 -70
  50. package/templates/nextblock-template/lib/ai-key-crypto.ts +0 -132
  51. package/templates/nextblock-template/lib/ai-model-catalog.test.ts +0 -49
  52. package/templates/nextblock-template/lib/ai-model-catalog.ts +0 -41
  53. package/templates/nextblock-template/lib/ai-model-registry.test.ts +0 -231
  54. package/templates/nextblock-template/lib/ai-model-registry.ts +0 -522
  55. package/templates/nextblock-template/lib/cortex-widget-registry.test.ts +0 -199
  56. package/templates/nextblock-template/lib/cortex-widget-registry.ts +0 -88
  57. package/templates/nextblock-template/lib/cortex-widget-schema.test.tsx +0 -237
  58. 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
+ }