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