Skip to content

Commit 3385e9d

Browse files
committed
fix(portal): parse multi-paragraph bio text/HTML in guest-profile save and sync drafts.guest
1 parent f812a10 commit 3385e9d

4 files changed

Lines changed: 778 additions & 48 deletions

File tree

‎apps/site/src/lib/guest-portal.ts‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,3 +505,112 @@ export function sanitizePortableTextBlocks(
505505

506506
return sanitized;
507507
}
508+
509+
/**
510+
* Converts multi-paragraph plain text or HTML (`<p>...</p>`) from the Studio Portal
511+
* bio textarea into Sanity Portable Text blocks, auto-linking any https:// URLs.
512+
*/
513+
export function plainTextOrHtmlToPortableTextBlocks(
514+
rawInput: string | undefined | null,
515+
): PortableTextBlock[] {
516+
const raw = String(rawInput ?? "").trim();
517+
if (!raw) {
518+
return [];
519+
}
520+
521+
// Normalize HTML paragraphs/line-breaks into double-newline separated plain text paragraphs
522+
const normalizedText = raw
523+
.replace(/<\/p>\s*<p[^>]*>/gi, "\n\n")
524+
.replace(/<br\s*\/?>/gi, "\n")
525+
.replace(/<[^>]+>/g, "")
526+
.replace(/&amp;/g, "&")
527+
.replace(/&lt;/g, "<")
528+
.replace(/&gt;/g, ">")
529+
.replace(/&quot;/g, '"')
530+
.replace(/&#39;/g, "'")
531+
.trim();
532+
533+
const paragraphs = normalizedText
534+
.split(/\n{2,}/)
535+
.map((p) => p.trim())
536+
.filter((p) => p.length > 0);
537+
538+
const blocks: PortableTextBlock[] = [];
539+
const urlRegex = /(https?:\/\/[^\s<>)]+)/g;
540+
541+
for (const para of paragraphs) {
542+
const markDefs: PortableTextMarkDef[] = [];
543+
const children: PortableTextSpan[] = [];
544+
let lastIndex = 0;
545+
let match: RegExpExecArray | null = urlRegex.exec(para);
546+
547+
while (match !== null) {
548+
const matchStart = match.index;
549+
let urlText = match[0];
550+
// Strip trailing punctuation like period or comma from the URL itself
551+
let trailingPunct = "";
552+
const punctMatch = urlText.match(/([.,;!?]+)$/);
553+
if (punctMatch) {
554+
trailingPunct = punctMatch[1];
555+
urlText = urlText.slice(0, -trailingPunct.length);
556+
}
557+
558+
if (matchStart > lastIndex) {
559+
children.push({
560+
_type: "span",
561+
_key: generateKey(),
562+
text: para.slice(lastIndex, matchStart),
563+
marks: [],
564+
});
565+
}
566+
567+
const linkKey = `link-${generateKey()}`;
568+
markDefs.push({
569+
_type: "link",
570+
_key: linkKey,
571+
href: urlText,
572+
blank: true,
573+
});
574+
children.push({
575+
_type: "span",
576+
_key: generateKey(),
577+
text: urlText,
578+
marks: [linkKey],
579+
});
580+
581+
lastIndex = matchStart + urlText.length;
582+
if (trailingPunct) {
583+
// Leave trailing punctuation for the next plain text span
584+
}
585+
match = urlRegex.exec(para);
586+
}
587+
588+
if (lastIndex < para.length) {
589+
children.push({
590+
_type: "span",
591+
_key: generateKey(),
592+
text: para.slice(lastIndex),
593+
marks: [],
594+
});
595+
}
596+
597+
if (children.length === 0) {
598+
children.push({
599+
_type: "span",
600+
_key: generateKey(),
601+
text: para,
602+
marks: [],
603+
});
604+
}
605+
606+
blocks.push({
607+
_type: "block",
608+
_key: generateKey(),
609+
style: "normal",
610+
markDefs,
611+
children,
612+
});
613+
}
614+
615+
return blocks;
616+
}

‎apps/site/src/pages/api/guest-portal/update.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
22
import {
33
ALLOWED_SOCIAL_KEYS,
44
normalizeSocialInput,
5+
plainTextOrHtmlToPortableTextBlocks,
56
sanitizePortableTextBlocks,
67
verifyGuestPortalToken,
78
} from "@/lib/guest-portal";
@@ -17,6 +18,9 @@ export const POST: APIRoute = async ({ request }) => {
1718
title?: string;
1819
websiteUrl?: string;
1920
excerpt?: string;
21+
aboutHtml?: string;
22+
rawBio?: string;
23+
bio?: string;
2024
content?: unknown[];
2125
avatarDataUrl?: string;
2226
socials?: Record<string, string>;
@@ -132,6 +136,13 @@ export const POST: APIRoute = async ({ request }) => {
132136
if (sanitizedBlocks.length > 0) {
133137
patchSet.content = sanitizedBlocks;
134138
}
139+
} else if (body.rawBio || body.bio || body.aboutHtml) {
140+
const parsedBlocks = plainTextOrHtmlToPortableTextBlocks(
141+
body.rawBio || body.bio || body.aboutHtml,
142+
);
143+
if (parsedBlocks.length > 0) {
144+
patchSet.content = parsedBlocks;
145+
}
135146
}
136147

137148
let uploadedAvatarUrl: string | undefined;

‎apps/site/src/pages/api/portal/guest-profile.ts‎

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
22
import {
33
ALLOWED_SOCIAL_KEYS,
44
normalizeSocialInput,
5+
plainTextOrHtmlToPortableTextBlocks,
56
sanitizePortableTextBlocks,
67
} from "@/lib/guest-portal";
78
import {
@@ -49,6 +50,9 @@ export const POST: APIRoute = async ({ request, url }) => {
4950
title?: string;
5051
websiteUrl?: string;
5152
excerpt?: string;
53+
aboutHtml?: string;
54+
rawBio?: string;
55+
bio?: string;
5256
content?: unknown[];
5357
avatarDataUrl?: string;
5458
socials?: Record<string, string>;
@@ -154,7 +158,7 @@ export const POST: APIRoute = async ({ request, url }) => {
154158
`*[_type in ["guest", "author"] && (
155159
slug.current == $slug ||
156160
_id in [$draftId, $pubId]
157-
)] | order(_id desc)[0]{
161+
)] | order(_updatedAt desc)[0]{
158162
_id,
159163
_type,
160164
title,
@@ -222,25 +226,33 @@ export const POST: APIRoute = async ({ request, url }) => {
222226
const sanitizedBlocks = Array.isArray(body.content)
223227
? sanitizePortableTextBlocks(body.content)
224228
: [];
229+
const parsedTextBlocks =
230+
sanitizedBlocks.length === 0
231+
? plainTextOrHtmlToPortableTextBlocks(
232+
body.rawBio || body.bio || body.aboutHtml,
233+
)
234+
: [];
225235
const contentBlocks =
226236
sanitizedBlocks.length > 0
227237
? sanitizedBlocks
228-
: existingGuest?.content || [
229-
{
230-
_key: "bio-0",
231-
_type: "block",
232-
style: "normal",
233-
markDefs: [],
234-
children: [
235-
{
236-
_key: "bio-span-0",
237-
_type: "span",
238-
marks: [],
239-
text: cleanExcerpt,
240-
},
241-
],
242-
},
243-
];
238+
: parsedTextBlocks.length > 0
239+
? parsedTextBlocks
240+
: existingGuest?.content || [
241+
{
242+
_key: "bio-0",
243+
_type: "block",
244+
style: "normal",
245+
markDefs: [],
246+
children: [
247+
{
248+
_key: "bio-span-0",
249+
_type: "span",
250+
marks: [],
251+
text: cleanExcerpt,
252+
},
253+
],
254+
},
255+
];
244256

245257
let coverImage = existingGuest?.coverImage;
246258
let uploadedAvatarUrl: string | undefined;
@@ -327,21 +339,13 @@ export const POST: APIRoute = async ({ request, url }) => {
327339
// Always save to `drafts.<id>` so draft preview (`/guest/<slug>?sanity-preview-perspective=drafts`) works immediately
328340
await client.createOrReplace(guestPayload as any);
329341

330-
// If a published guest doc already exists, keep it updated too
331-
if (existingGuest?._id && !existingGuest._id.startsWith("drafts.")) {
332-
await client
333-
.patch(existingGuest._id)
334-
.set({
335-
title: resolvedTitle,
336-
excerpt: cleanExcerpt,
337-
content: contentBlocks,
338-
socials: mergedSocials,
339-
...(websites.length > 0 ? { websites } : {}),
340-
...(coverImage ? { coverImage } : {}),
341-
})
342-
.commit()
343-
.catch(() => {});
344-
}
342+
// Keep the published guest doc (`guest-<slug>`) synced as well so `/guest/<slug>` works with or without preview cookies
343+
await client
344+
.createOrReplace({
345+
...guestPayload,
346+
_id: basePubId,
347+
} as any)
348+
.catch(() => {});
345349

346350
// Sync guestSlug & guestDocId onto the booking / sponsorLead record(s)
347351
const finalSlug = existingGuest?.slug?.current || resolvedSlug;
@@ -363,6 +367,7 @@ export const POST: APIRoute = async ({ request, url }) => {
363367
JSON.stringify({
364368
ok: true,
365369
guestId: draftDocId,
370+
draftId: draftDocId,
366371
guestSlug: finalSlug,
367372
previewUrl: `/guest/${finalSlug}?sanity-preview-perspective=drafts`,
368373
socials: mergedSocials,

0 commit comments

Comments
 (0)