@levo-so/blocks 0.3.25 → 0.3.26

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.
@@ -0,0 +1,320 @@
1
+ import type { IBlogCategory, IModuleStudioConfig, IPost } from "@levo-so/core";
2
+ import {
3
+ Avatar,
4
+ AvatarFallbackV2,
5
+ AvatarGroup,
6
+ AvatarGroupCount,
7
+ AvatarImageV2,
8
+ Box,
9
+ Button,
10
+ Container,
11
+ Heading,
12
+ Icon,
13
+ type ILevoBlockBaseProps,
14
+ type IStudioSource,
15
+ Media,
16
+ Section,
17
+ Typography,
18
+ useBlockContext,
19
+ useContextEngine,
20
+ } from "@levo-so/studio";
21
+ import dayjs from "dayjs";
22
+
23
+ import { ClientOnly } from "../event/ClientOnly";
24
+ import type { IBlogListing4Content } from "./blog-listing-4.schema";
25
+
26
+ const getAuthorsText = (authors: any[]): string => {
27
+ // `authors` is variable-bound and can resolve to null / a non-array at runtime —
28
+ // an unguarded `.map` here throws and blanks the whole studio canvas.
29
+ if (!Array.isArray(authors) || authors.length === 0) return "Anonymous";
30
+ const names = authors.map((a) => [a?.first_name].filter(Boolean).join(" ") || "Anonymous");
31
+
32
+ if (names.length === 2) return `${names[0]} and ${names[1]}`;
33
+ // if (names.length === 3) return `${names[0]}, ${names[1]}, and ${names[2]}`;
34
+
35
+ // 4+ authors: show first 3 names then remaining count
36
+ const remaining = names.length - 2;
37
+ return `${names[0]}, ${names[1]} and ${remaining} ${remaining === 1 ? "other" : "others"}`;
38
+ };
39
+
40
+ /**
41
+ * Trimmed, lowercased slug for category matching; null for anything that isn't a non-empty
42
+ * string. Both sides are runtime data (page record, hand-typed or AI-filled content), and a
43
+ * throw during render blanks the whole studio canvas.
44
+ */
45
+ const normalizeSlug = (value: unknown) =>
46
+ typeof value === "string" && value.trim() ? value.trim().toLowerCase() : null;
47
+
48
+ /** */
49
+ interface IBlogMeta {
50
+ total: number;
51
+ pages: number;
52
+ studio_config?: (IModuleStudioConfig & {
53
+ module: IStudioSource["module"];
54
+ })[];
55
+ }
56
+
57
+ const AuthorRenderer: React.FC<{
58
+ index: number;
59
+ authors: any[];
60
+ }> = ({ authors, index }) => {
61
+ if (authors?.length === 1) {
62
+ const authorNameArr = [authors?.[0]?.first_name, authors?.[0]?.last_name].filter(Boolean);
63
+
64
+ const authorName = authorNameArr.join(" ");
65
+
66
+ const authorNameFallback = authorNameArr
67
+ ?.map((v) => v?.[0])
68
+ .join("")
69
+ .toUpperCase();
70
+
71
+ return (
72
+ <Box elementKey={`blogs.${index}.blog-author`}>
73
+ <Avatar className="h-8 w-8">
74
+ <AvatarImageV2 src={authors?.[0]?.profile_picture?.location} />
75
+ <AvatarFallbackV2>{authorNameFallback}</AvatarFallbackV2>
76
+ </Avatar>
77
+ <Box elementKey={`blogs.${index}.author-info-wrapper`}>
78
+ <Typography elementKey={`blogs.${index}.author-name`}>
79
+ {authorName ? authorName : "Anonymous"}
80
+ </Typography>
81
+ {/* <Typography elementKey={`blogs.${index}.author-description`}>
82
+ {authors?.[0]?.username}
83
+ </Typography> */}
84
+ </Box>
85
+ </Box>
86
+ );
87
+ }
88
+
89
+ if (authors?.length > 1) {
90
+ const firstThreeAuthors = authors?.slice(0, 2);
91
+ const remainingAuthors = authors?.slice(2);
92
+
93
+ return (
94
+ <Box
95
+ elementKey={`blogs.${index}.blog-authors`}
96
+ style={{
97
+ display: "flex",
98
+ gap: "16px",
99
+ alignItems: "center",
100
+ }}
101
+ >
102
+ <AvatarGroup className="">
103
+ {firstThreeAuthors?.map((author, authorIndex) => {
104
+ const authorNameArr = [author?.first_name, author?.last_name].filter(Boolean);
105
+
106
+ const authorName = authorNameArr.join(" ");
107
+
108
+ const authorNameFallback = authorNameArr
109
+ ?.map((v) => v?.[0])
110
+ .join("")
111
+ .toUpperCase();
112
+
113
+ return (
114
+ <Avatar key={`blogs.${index}.blog-author.${authorIndex}`} className="h-8 w-8">
115
+ <AvatarImageV2
116
+ src={author?.profile_picture?.location}
117
+ className="object-cover"
118
+ alt={authorName}
119
+ />
120
+ <AvatarFallbackV2>{authorNameFallback}</AvatarFallbackV2>
121
+ </Avatar>
122
+ );
123
+ })}
124
+ {remainingAuthors?.length > 0 && (
125
+ <AvatarGroupCount
126
+ className="h-8 w-8"
127
+ style={{
128
+ background: "var(--color-brand-secondary)",
129
+ }}
130
+ >
131
+ +{remainingAuthors?.length}
132
+ </AvatarGroupCount>
133
+ )}
134
+ </AvatarGroup>
135
+ <Typography
136
+ elementKey={`blogs.${index}.author-name`}
137
+ title={authors?.map((v) => v?.first_name)?.join(", ")}
138
+ >
139
+ {getAuthorsText(authors)}
140
+ </Typography>
141
+ </Box>
142
+ );
143
+ }
144
+
145
+ return <Box elementKey={`blogs.${index}.blog-author`} />;
146
+ };
147
+
148
+ const BlogListing4: React.FC<ILevoBlockBaseProps<IBlogListing4Content>> = ({ content }) => {
149
+ const { response, query, setQuery } = useContextEngine<IPost, IBlogMeta>("blogs");
150
+ const { isStudio, blockData, pageContext } = useBlockContext();
151
+
152
+ // Same page-1 fallback as useContextEngine's default query; Previous never requests below page 1.
153
+ const handleNext = () => setQuery({ page: (query?.page || 1) + 1 });
154
+ const handlePrevious = () => setQuery({ page: Math.max(1, (query?.page || 1) - 1) });
155
+
156
+ const blogStudioConfig = response?.meta?.studio_config?.find(
157
+ (item) => !item.block_instance_id && item?.module === "blog",
158
+ );
159
+ const fallbackConfig = (blockData as any)?.config?.blogs?.[0]?.blog?.action?.value;
160
+
161
+ // Instances saved before the category row existed have no content for it; rendering the
162
+ // empty wrapper would still add the container's row-gap between header and grid.
163
+ const hasCategoryPills = !!content?.["categories-all"] || !!content?.categories?.length;
164
+
165
+ // Set only on a blog category page (`blog_category` page source). Read from page context
166
+ // rather than the URL so the selected pill is right in the server render, with no flip
167
+ // after hydration. Cast: page context values are untyped records keyed by source identifier.
168
+ const currentCategorySlug = normalizeSlug(
169
+ (pageContext?.blog_category as IBlogCategory | undefined)?.slug,
170
+ );
171
+
172
+ return (
173
+ <Section elementKey="layout">
174
+ <Container elementKey="container">
175
+ <Box elementKey="header-wrapper">
176
+ <Box elementKey="title-wrapper">
177
+ <Icon elementKey="header-icon" />
178
+ <Box elementKey="header-text-wrapper">
179
+ <Heading elementKey="header" />
180
+ <Typography elementKey="description" />
181
+ </Box>
182
+ </Box>
183
+ <Box elementKey="header-ctas_levoGroup" data-levo_group>
184
+ {content?.["header-ctas"]?.map((v, index: number) => (
185
+ <Button
186
+ key={`header-ctas.${index}`}
187
+ elementKey={`header-ctas.${index}.header-cta`}
188
+ data-levo_group_item
189
+ />
190
+ ))}
191
+ </Box>
192
+ </Box>
193
+ {hasCategoryPills && (
194
+ <Box elementKey="categories-wrapper">
195
+ <Box elementKey="categories_levoGroup" data-levo_group>
196
+ {/* Not a group item, so it stays first and keeps its own all-blogs link even
197
+ when the category pills get populated from data. Selected on every page
198
+ that isn't a category page. */}
199
+ <Button
200
+ elementKey="categories-all"
201
+ data-state={currentCategorySlug ? "inactive" : "active"}
202
+ aria-current={currentCategorySlug ? undefined : "page"}
203
+ />
204
+ {content?.categories?.map((item, index: number) => {
205
+ const isSelected =
206
+ !!currentCategorySlug && normalizeSlug(item?.slug) === currentCategorySlug;
207
+ return (
208
+ <Button
209
+ key={`categories.${index}`}
210
+ elementKey={`categories.${index}.category`}
211
+ data-levo_group_item
212
+ data-state={isSelected ? "active" : "inactive"}
213
+ aria-current={isSelected ? "page" : undefined}
214
+ />
215
+ );
216
+ })}
217
+ </Box>
218
+ </Box>
219
+ )}
220
+ <Box elementKey="blogs_levoGroup" data-levo_group>
221
+ {response?.data?.map((item, index: number) => (
222
+ <Box
223
+ key={`blogs.${index}`}
224
+ data-levo_group_item
225
+ elementKey={`blogs.${index}.blog`}
226
+ config={
227
+ (isStudio && blogStudioConfig?.preview_url) ||
228
+ (!isStudio && blogStudioConfig?.live_url)
229
+ ? {
230
+ action: {
231
+ type: "external_link",
232
+ value:
233
+ isStudio && blogStudioConfig.preview_url
234
+ ? blogStudioConfig.preview_url.replace(/\{[^}]+\}/g, item?.slug)
235
+ : !isStudio && blogStudioConfig.live_url
236
+ ? blogStudioConfig.live_url.replace(/\{[^}]+\}/g, item?.slug)
237
+ : "",
238
+ options: {},
239
+ },
240
+ }
241
+ : fallbackConfig
242
+ ? {
243
+ action: {
244
+ type: "external_link" as const,
245
+ value: fallbackConfig.replace(/\{[^}]+\}/g, item?.slug),
246
+ options: {},
247
+ },
248
+ }
249
+ : {}
250
+ }
251
+ >
252
+ <Media
253
+ elementKey={`blogs.${index}.media`}
254
+ alt="Blog Media"
255
+ media={item?.cover_image ?? undefined}
256
+ />
257
+ <Box elementKey={`blogs.${index}.blog-info-wrapper`}>
258
+ <Box elementKey={`blogs.${index}.blog-content`}>
259
+ <Box elementKey={`blogs.${index}.blog-header-wrapper`}>
260
+ {item?.published_at && dayjs(item?.published_at).isValid() && (
261
+ <Box elementKey={`blogs.${index}.dateWrapper`}>
262
+ <Icon elementKey={`blogs.${index}.dateIcon`} />
263
+ <ClientOnly>
264
+ <Typography elementKey={`blogs.${index}.date`}>
265
+ {dayjs(item?.published_at).format("MMM D, YYYY")}
266
+ </Typography>
267
+ </ClientOnly>
268
+ </Box>
269
+ )}
270
+ <Box elementKey={`blogs.${index}.blog-header`}>
271
+ <Icon elementKey={`blogs.${index}.header-icon`} />
272
+ <Typography elementKey={`blogs.${index}.blog-type`}>
273
+ {item?.categories?.[0]?.name || "Blog"}
274
+ </Typography>
275
+ </Box>
276
+ </Box>
277
+ <Box elementKey={`blogs.${index}.blog-content-wrapper`}>
278
+ <Heading elementKey={`blogs.${index}.title`}>{item?.title}</Heading>
279
+ <Typography elementKey={`blogs.${index}.description`}>
280
+ {item?.summary}
281
+ </Typography>
282
+ </Box>
283
+ <Box elementKey={`blogs.${index}.blog-footer`}>
284
+ <AuthorRenderer authors={item?.authors ?? []} index={index} />
285
+ <Box elementKey={`blogs.${index}.blog-reading-time-wrapper`}>
286
+ <Icon elementKey={`blogs.${index}.reading-time-icon`} />
287
+ <Typography elementKey={`blogs.${index}.reading-time`}>
288
+ {item?.reading_time ? `${item?.reading_time} min read` : "5 min read"}
289
+ </Typography>
290
+ </Box>
291
+ </Box>
292
+ </Box>
293
+ <Button elementKey={`blogs.${index}.cta`} />
294
+ </Box>
295
+ </Box>
296
+ ))}
297
+ </Box>
298
+ {response?.meta && (
299
+ <Box elementKey="pagination-wrapper">
300
+ <Button
301
+ elementKey="pagination-left"
302
+ disabled={query?.page === 1}
303
+ onClick={handlePrevious}
304
+ />
305
+ <Typography elementKey="pagination-text">
306
+ {`${query?.page || 1}/${response?.meta?.pages || 0}`}
307
+ </Typography>
308
+ <Button
309
+ elementKey="pagination-right"
310
+ onClick={handleNext}
311
+ disabled={query?.page === response?.meta?.pages}
312
+ />
313
+ </Box>
314
+ )}
315
+ </Container>
316
+ </Section>
317
+ );
318
+ };
319
+
320
+ export default BlogListing4;
@@ -5,6 +5,7 @@ const AboutUs1 = lazy(() => import("./about-us/about-us-1"));
5
5
  const BlogListing1 = lazy(() => import("./blogs/blog-listing-1"));
6
6
  const BlogListing2 = lazy(() => import("./blogs/blog-listing-2"));
7
7
  const BlogListing3 = lazy(() => import("./blogs/blog-listing-3"));
8
+ const BlogListing4 = lazy(() => import("./blogs/blog-listing-4"));
8
9
  const BlogAuthor1 = lazy(() => import("./blogs/blog-author-1"));
9
10
  const BlogPost1 = lazy(() => import("./blogs/blog-post-1"));
10
11
  const Calendar1 = lazy(() => import("./calendar/calendar-1"));
@@ -140,6 +141,7 @@ export const blocks: Record<string, React.LazyExoticComponent<React.ComponentTyp
140
141
  "blog-listing-1:v1": BlogListing1,
141
142
  "blog-listing-2:v1": BlogListing2,
142
143
  "blog-listing-3:v1": BlogListing3,
144
+ "blog-listing-4:v1": BlogListing4,
143
145
  "blog-author-1:v1": BlogAuthor1,
144
146
  "blog-post-1:v1": BlogPost1,
145
147
  "calendar-1:v1": Calendar1,
@@ -6,6 +6,7 @@ import { BlogAuthor1 } from "../blocks/blogs/blog-author-1.schema";
6
6
  import { BlogListing1 } from "../blocks/blogs/blog-listing-1.schema";
7
7
  import { BlogListing2 } from "../blocks/blogs/blog-listing-2.schema";
8
8
  import { BlogListing3 } from "../blocks/blogs/blog-listing-3.schema";
9
+ import { BlogListing4 } from "../blocks/blogs/blog-listing-4.schema";
9
10
  import { BlogPost1 } from "../blocks/blogs/blog-post-1.schema";
10
11
  import { Calendar1 } from "../blocks/calendar/calendar-1.schema";
11
12
  import { Cards2 } from "../blocks/cards/cards-2.schema";
@@ -140,6 +141,7 @@ export const BlockList: IBlock[] = [
140
141
  BlogListing1,
141
142
  BlogListing2,
142
143
  BlogListing3,
144
+ BlogListing4,
143
145
  BlogPost1,
144
146
  Calendar1,
145
147
  Community1,