@xexr/blog-platform 1.0.10 → 1.0.11
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.
- package/README.md +1 -1
- package/dist/app/blog/[slug]/page.d.ts +7 -2
- package/dist/app/blog/[slug]/page.tsx +21 -1
- package/dist/app/blog/page.d.ts +59 -0
- package/dist/app/blog/page.test.ts +83 -0
- package/dist/app/blog/page.tsx +20 -0
- package/dist/components/NewsletterSignup.tsx +279 -0
- package/dist/index.d.mts +61 -53
- package/dist/index.d.ts +61 -53
- package/dist/index.js +10 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +10 -10
- package/dist/index.mjs.map +1 -1
- package/dist/types/contracts.ts +26 -0
- package/dist/types/index.d.mts +20 -1
- package/dist/types/index.d.ts +20 -1
- package/dist/types/index.ts +1 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -237,7 +237,7 @@ export default async function ArticlePage({
|
|
|
237
237
|
}
|
|
238
238
|
```
|
|
239
239
|
|
|
240
|
-
> **Note**: The `navigation` prop is optional. You can omit `previousArticle` and `nextArticle` if you don't want article navigation links.
|
|
240
|
+
> **Note**: The `navigation` prop is optional. You can omit `previousArticle` and `nextArticle` if you don't want article navigation links. Bloggy can also publish a site-scoped `newsletter` configuration; pass it through to `BlogHomePage` or `BlogArticlePage` if you are not using the article/site objects returned by Bloggy storage directly. The newsletter form posts back to Bloggy's `/api/newsletter/subscribe` endpoint so subscribers are captured consistently across host apps.
|
|
241
241
|
|
|
242
242
|
**`app/blog/authors/[slug]/page.tsx`** - Author profile pages:
|
|
243
243
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { ReactElement } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
PublishedArticle,
|
|
4
|
+
PublishedNewsletterSettings,
|
|
5
|
+
} from "../../../types/contracts";
|
|
3
6
|
import type { BlogTheme } from "../../../lib/article-ui";
|
|
4
7
|
|
|
5
|
-
interface ArticlePageProps {
|
|
8
|
+
export interface ArticlePageProps {
|
|
6
9
|
article: PublishedArticle;
|
|
7
10
|
className?: string;
|
|
8
11
|
navigation?: {
|
|
@@ -31,6 +34,8 @@ interface ArticlePageProps {
|
|
|
31
34
|
href: string;
|
|
32
35
|
label: string;
|
|
33
36
|
};
|
|
37
|
+
newsletter?: PublishedNewsletterSettings;
|
|
38
|
+
newsletterPreviewMode?: boolean;
|
|
34
39
|
relatedArticles?: PublishedArticle[];
|
|
35
40
|
showShareLinks?: boolean;
|
|
36
41
|
}
|
|
@@ -5,6 +5,7 @@ import { MDXRemote } from "next-mdx-remote-client/rsc";
|
|
|
5
5
|
import {
|
|
6
6
|
type PublishedArticle,
|
|
7
7
|
type PublishedAuthor,
|
|
8
|
+
type PublishedNewsletterSettings,
|
|
8
9
|
} from "../../../types/contracts";
|
|
9
10
|
import { cn } from "../../../lib/utils";
|
|
10
11
|
import { ArrowLeft, ArrowUpRight, Globe, Mail } from "lucide-react";
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
buildBlogPath,
|
|
26
27
|
normalizeBlogBasePath,
|
|
27
28
|
} from "../../../lib/urls";
|
|
29
|
+
import { NewsletterSignup } from "../../../components/NewsletterSignup";
|
|
28
30
|
import {
|
|
29
31
|
type BlogTheme,
|
|
30
32
|
extractTableOfContents,
|
|
@@ -32,7 +34,7 @@ import {
|
|
|
32
34
|
getBlogThemeStyle,
|
|
33
35
|
} from "../../../lib/article-ui";
|
|
34
36
|
|
|
35
|
-
interface ArticlePageProps {
|
|
37
|
+
export interface ArticlePageProps {
|
|
36
38
|
article: PublishedArticle;
|
|
37
39
|
className?: string;
|
|
38
40
|
/** Optional navigation links */
|
|
@@ -65,6 +67,10 @@ interface ArticlePageProps {
|
|
|
65
67
|
href: string;
|
|
66
68
|
label: string;
|
|
67
69
|
};
|
|
70
|
+
/** Optional Bloggy-owned newsletter capture configuration. */
|
|
71
|
+
newsletter?: PublishedNewsletterSettings;
|
|
72
|
+
/** Render interactive preview success without persisting subscribers. */
|
|
73
|
+
newsletterPreviewMode?: boolean;
|
|
68
74
|
/** Optional related articles supplied by the host site. */
|
|
69
75
|
relatedArticles?: PublishedArticle[];
|
|
70
76
|
/** Whether to render static article share links. */
|
|
@@ -133,6 +139,8 @@ export default function ArticlePage({
|
|
|
133
139
|
basePath = "/blog",
|
|
134
140
|
theme,
|
|
135
141
|
postArticleCta,
|
|
142
|
+
newsletter,
|
|
143
|
+
newsletterPreviewMode = false,
|
|
136
144
|
relatedArticles = [],
|
|
137
145
|
showShareLinks = true,
|
|
138
146
|
}: ArticlePageProps) {
|
|
@@ -146,6 +154,7 @@ export default function ArticlePage({
|
|
|
146
154
|
const articleUrl =
|
|
147
155
|
article.meta.canonicalUrl ??
|
|
148
156
|
buildBlogPath(normalizedBasePath, article.slug);
|
|
157
|
+
const newsletterSettings = newsletter ?? article.site.newsletter;
|
|
149
158
|
|
|
150
159
|
// Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
|
|
151
160
|
const imageUrl = article.featuredImage;
|
|
@@ -431,11 +440,22 @@ export default function ArticlePage({
|
|
|
431
440
|
)}
|
|
432
441
|
|
|
433
442
|
{((postArticleCta ?? false) ||
|
|
443
|
+
(newsletterSettings?.enabled ?? false) ||
|
|
434
444
|
showShareLinks ||
|
|
435
445
|
relatedArticles.length > 0) && (
|
|
436
446
|
<div className="mx-auto mt-12 max-w-4xl space-y-8">
|
|
437
447
|
<Separator />
|
|
438
448
|
{postArticleCta && <PostArticleCta cta={postArticleCta} />}
|
|
449
|
+
{newsletterSettings?.enabled && (
|
|
450
|
+
<NewsletterSignup
|
|
451
|
+
siteId={article.site.id}
|
|
452
|
+
settings={newsletterSettings}
|
|
453
|
+
sourceSurface="article"
|
|
454
|
+
sourceArticleSlug={article.slug}
|
|
455
|
+
sourceUrl={articleUrl}
|
|
456
|
+
previewMode={newsletterPreviewMode}
|
|
457
|
+
/>
|
|
458
|
+
)}
|
|
439
459
|
{showShareLinks && (
|
|
440
460
|
<ArticleShareLinks title={article.title} url={articleUrl} />
|
|
441
461
|
)}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
PublishedArticle,
|
|
4
|
+
PublishedNewsletterSettings,
|
|
5
|
+
PublishedSite,
|
|
6
|
+
} from "../../types/contracts";
|
|
7
|
+
import type { BlogTheme } from "../../lib/article-ui";
|
|
8
|
+
|
|
9
|
+
export interface BlogHomePageProps {
|
|
10
|
+
articles: PublishedArticle[];
|
|
11
|
+
siteName?: string;
|
|
12
|
+
siteTagline?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
site?: PublishedSite;
|
|
15
|
+
includeStructuredData?: boolean;
|
|
16
|
+
basePath?: string;
|
|
17
|
+
pagination?: {
|
|
18
|
+
currentPage: number;
|
|
19
|
+
totalPages: number;
|
|
20
|
+
baseUrl?: string;
|
|
21
|
+
};
|
|
22
|
+
theme?: BlogTheme;
|
|
23
|
+
newsletter?: PublishedNewsletterSettings;
|
|
24
|
+
newsletterPreviewMode?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function BlogHomePage(props: BlogHomePageProps): ReactElement;
|
|
28
|
+
|
|
29
|
+
export function generateBlogHomeMetadata(
|
|
30
|
+
siteName?: string,
|
|
31
|
+
siteTagline?: string,
|
|
32
|
+
siteUrl?: string,
|
|
33
|
+
pagination?: {
|
|
34
|
+
currentPage: number;
|
|
35
|
+
totalPages: number;
|
|
36
|
+
},
|
|
37
|
+
basePath?: string,
|
|
38
|
+
): {
|
|
39
|
+
title: string;
|
|
40
|
+
description: string;
|
|
41
|
+
openGraph: {
|
|
42
|
+
title: string;
|
|
43
|
+
description: string;
|
|
44
|
+
type: string;
|
|
45
|
+
url?: string;
|
|
46
|
+
};
|
|
47
|
+
twitter: {
|
|
48
|
+
card: string;
|
|
49
|
+
title: string;
|
|
50
|
+
description: string;
|
|
51
|
+
};
|
|
52
|
+
alternates: {
|
|
53
|
+
canonical?: string;
|
|
54
|
+
};
|
|
55
|
+
other: {
|
|
56
|
+
prev?: string;
|
|
57
|
+
next?: string;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { renderToPipeableStream } from "react-dom/server";
|
|
3
|
+
import { PassThrough } from "stream";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import BlogHomePage from "./page";
|
|
7
|
+
import type { PublishedArticle } from "../../types/contracts";
|
|
8
|
+
|
|
9
|
+
const article: PublishedArticle = {
|
|
10
|
+
id: "article-1",
|
|
11
|
+
slug: "useful-routines",
|
|
12
|
+
title: "Useful routines",
|
|
13
|
+
excerpt: "A practical guide to keeping routines useful.",
|
|
14
|
+
content: "Routines work best when they are easy to maintain.",
|
|
15
|
+
readingSeconds: 360,
|
|
16
|
+
wordCount: 900,
|
|
17
|
+
publishedAt: "2026-05-06T12:00:00.000Z",
|
|
18
|
+
updatedAt: "2026-05-07T12:00:00.000Z",
|
|
19
|
+
author: null,
|
|
20
|
+
site: {
|
|
21
|
+
id: "site-1",
|
|
22
|
+
name: "Example Blog",
|
|
23
|
+
tagline: "Useful writing",
|
|
24
|
+
description: "Articles about useful systems.",
|
|
25
|
+
domain: "example.com",
|
|
26
|
+
toneAdjectives: [],
|
|
27
|
+
locale: "en",
|
|
28
|
+
},
|
|
29
|
+
meta: {
|
|
30
|
+
canonicalUrl: "https://example.com/blog/useful-routines",
|
|
31
|
+
keywords: [],
|
|
32
|
+
},
|
|
33
|
+
version: 1,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("BlogHomePage", () => {
|
|
37
|
+
it("renders the newsletter form when configured", async () => {
|
|
38
|
+
const html = await renderToHtml(
|
|
39
|
+
React.createElement(BlogHomePage, {
|
|
40
|
+
articles: [article],
|
|
41
|
+
siteName: "Example Blog",
|
|
42
|
+
newsletter: {
|
|
43
|
+
enabled: true,
|
|
44
|
+
title: "Get product updates",
|
|
45
|
+
description: "Follow new articles and product notes.",
|
|
46
|
+
buttonLabel: "Get updates",
|
|
47
|
+
captureBaseUrl: "https://bloggy.example.com",
|
|
48
|
+
publicSubscribeToken: "public_subscribe_token",
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(html).toContain("Get product updates");
|
|
54
|
+
expect(html).toContain("Follow new articles and product notes.");
|
|
55
|
+
expect(html).toContain('type="email"');
|
|
56
|
+
expect(html).toContain('name="name"');
|
|
57
|
+
expect(html.indexOf("Get product updates")).toBeGreaterThan(
|
|
58
|
+
html.indexOf("Useful routines"),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function renderToHtml(element: React.ReactElement): Promise<string> {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
let html = "";
|
|
66
|
+
const stream = new PassThrough();
|
|
67
|
+
stream.setEncoding("utf8");
|
|
68
|
+
stream.on("data", (chunk: string) => {
|
|
69
|
+
html += chunk;
|
|
70
|
+
});
|
|
71
|
+
stream.on("end", () => resolve(html));
|
|
72
|
+
stream.on("error", reject);
|
|
73
|
+
|
|
74
|
+
const { pipe } = renderToPipeableStream(element, {
|
|
75
|
+
onAllReady() {
|
|
76
|
+
pipe(stream);
|
|
77
|
+
},
|
|
78
|
+
onError(error) {
|
|
79
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
package/dist/app/blog/page.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import Image from "next/image";
|
|
|
4
4
|
import {
|
|
5
5
|
type PublishedArticle,
|
|
6
6
|
type PublishedAuthor,
|
|
7
|
+
type PublishedNewsletterSettings,
|
|
7
8
|
type PublishedSite,
|
|
8
9
|
} from "../../types/contracts";
|
|
9
10
|
import { cn } from "../../lib/utils";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
} from "../../lib/urls";
|
|
20
21
|
import { formatDistanceToNow } from "date-fns";
|
|
21
22
|
import { type BlogTheme, getBlogThemeStyle } from "../../lib/article-ui";
|
|
23
|
+
import { NewsletterSignup } from "../../components/NewsletterSignup";
|
|
22
24
|
|
|
23
25
|
interface BlogHomePageProps {
|
|
24
26
|
articles: PublishedArticle[];
|
|
@@ -39,6 +41,10 @@ interface BlogHomePageProps {
|
|
|
39
41
|
};
|
|
40
42
|
/** Optional theme values for host-site brand integration. */
|
|
41
43
|
theme?: BlogTheme;
|
|
44
|
+
/** Optional Bloggy-owned newsletter capture configuration. */
|
|
45
|
+
newsletter?: PublishedNewsletterSettings;
|
|
46
|
+
/** Render interactive preview success without persisting subscribers. */
|
|
47
|
+
newsletterPreviewMode?: boolean;
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
/**
|
|
@@ -58,8 +64,12 @@ export default function BlogHomePage({
|
|
|
58
64
|
basePath = "/blog",
|
|
59
65
|
pagination,
|
|
60
66
|
theme,
|
|
67
|
+
newsletter,
|
|
68
|
+
newsletterPreviewMode = false,
|
|
61
69
|
}: BlogHomePageProps) {
|
|
62
70
|
const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
|
|
71
|
+
const newsletterSettings = newsletter ?? site?.newsletter;
|
|
72
|
+
const newsletterSiteId = site?.id ?? articles[0]?.site.id;
|
|
63
73
|
|
|
64
74
|
return (
|
|
65
75
|
<div
|
|
@@ -119,6 +129,16 @@ export default function BlogHomePage({
|
|
|
119
129
|
)}
|
|
120
130
|
</main>
|
|
121
131
|
|
|
132
|
+
{newsletterSettings?.enabled && newsletterSiteId && (
|
|
133
|
+
<NewsletterSignup
|
|
134
|
+
siteId={newsletterSiteId}
|
|
135
|
+
settings={newsletterSettings}
|
|
136
|
+
sourceSurface="blog_home"
|
|
137
|
+
previewMode={newsletterPreviewMode}
|
|
138
|
+
className="mt-10 sm:mt-12"
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
|
|
122
142
|
{/* JSON-LD Structured Data */}
|
|
123
143
|
{includeStructuredData &&
|
|
124
144
|
site &&
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useSyncExternalStore, type FormEvent } from "react";
|
|
4
|
+
import { ArrowUpRight, Check } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
import type { PublishedNewsletterSettings } from "../types/contracts";
|
|
8
|
+
|
|
9
|
+
export const NEWSLETTER_SUCCESS_MESSAGE =
|
|
10
|
+
"Thanks, you're on the list. We'll confirm before sending updates.";
|
|
11
|
+
|
|
12
|
+
export interface NewsletterSignupProps {
|
|
13
|
+
siteId: string;
|
|
14
|
+
settings: PublishedNewsletterSettings;
|
|
15
|
+
sourceSurface: "blog_home" | "article";
|
|
16
|
+
sourceArticleSlug?: string;
|
|
17
|
+
sourceUrl?: string;
|
|
18
|
+
previewMode?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function NewsletterSignup({
|
|
23
|
+
siteId,
|
|
24
|
+
settings,
|
|
25
|
+
sourceSurface,
|
|
26
|
+
sourceArticleSlug,
|
|
27
|
+
sourceUrl,
|
|
28
|
+
previewMode = false,
|
|
29
|
+
className,
|
|
30
|
+
}: NewsletterSignupProps) {
|
|
31
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
const [hasSubmitted, setHasSubmitted] = useState(false);
|
|
34
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
35
|
+
const subscriptionKey = getSubscriptionStorageKey(siteId);
|
|
36
|
+
const hasStoredSubscribed = useSyncExternalStore(
|
|
37
|
+
subscribeToStoredSubscription,
|
|
38
|
+
() => hasStoredSubscription(subscriptionKey),
|
|
39
|
+
() => false,
|
|
40
|
+
);
|
|
41
|
+
const hasSubscribed = hasSubmitted || (!previewMode && hasStoredSubscribed);
|
|
42
|
+
|
|
43
|
+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
setError(null);
|
|
46
|
+
setMessage(null);
|
|
47
|
+
|
|
48
|
+
const formData = new FormData(event.currentTarget);
|
|
49
|
+
const honeypot = getFormString(formData, "company");
|
|
50
|
+
|
|
51
|
+
if (previewMode) {
|
|
52
|
+
setMessage(NEWSLETTER_SUCCESS_MESSAGE);
|
|
53
|
+
setHasSubmitted(true);
|
|
54
|
+
event.currentTarget.reset();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setIsSubmitting(true);
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetch(
|
|
61
|
+
`${settings.captureBaseUrl.replace(/\/$/, "")}/api/newsletter/subscribe`,
|
|
62
|
+
{
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
siteId: Number(siteId),
|
|
69
|
+
publicSubscribeToken: settings.publicSubscribeToken,
|
|
70
|
+
email: getFormString(formData, "email"),
|
|
71
|
+
name: getOptionalFormString(formData, "name"),
|
|
72
|
+
sourceSurface,
|
|
73
|
+
sourceUrl: resolveSourceUrl(sourceUrl),
|
|
74
|
+
sourceArticleSlug: sourceArticleSlug ?? null,
|
|
75
|
+
honeypot,
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw new Error("Newsletter signup failed.");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const result: unknown = await response.json().catch(() => null);
|
|
85
|
+
const successMessage =
|
|
86
|
+
getNewsletterResponseMessage(result) ?? NEWSLETTER_SUCCESS_MESSAGE;
|
|
87
|
+
|
|
88
|
+
storeSubscription(subscriptionKey);
|
|
89
|
+
setMessage(successMessage);
|
|
90
|
+
setHasSubmitted(true);
|
|
91
|
+
event.currentTarget.reset();
|
|
92
|
+
} catch {
|
|
93
|
+
setMessage(null);
|
|
94
|
+
setError("Newsletter signup failed. Please try again.");
|
|
95
|
+
} finally {
|
|
96
|
+
setIsSubmitting(false);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<section
|
|
102
|
+
className={cn(
|
|
103
|
+
"border-y border-[var(--blog-accent-border)] py-5 sm:py-6",
|
|
104
|
+
className,
|
|
105
|
+
)}
|
|
106
|
+
aria-labelledby={`newsletter-${sourceSurface}-title`}
|
|
107
|
+
>
|
|
108
|
+
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.4fr)] lg:items-end">
|
|
109
|
+
<div className="max-w-xl space-y-1">
|
|
110
|
+
<h2
|
|
111
|
+
id={`newsletter-${sourceSurface}-title`}
|
|
112
|
+
className="text-base leading-6 font-semibold"
|
|
113
|
+
>
|
|
114
|
+
{settings.title}
|
|
115
|
+
</h2>
|
|
116
|
+
<p className="text-muted-foreground text-sm leading-6">
|
|
117
|
+
{settings.description}
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{hasSubscribed ? (
|
|
122
|
+
<div
|
|
123
|
+
className="flex items-start gap-3 text-sm leading-6 text-emerald-500"
|
|
124
|
+
role="status"
|
|
125
|
+
aria-live="polite"
|
|
126
|
+
>
|
|
127
|
+
<span className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-emerald-500/15">
|
|
128
|
+
<Check className="size-3.5" aria-hidden="true" />
|
|
129
|
+
</span>
|
|
130
|
+
<p>{message ?? NEWSLETTER_SUCCESS_MESSAGE}</p>
|
|
131
|
+
</div>
|
|
132
|
+
) : (
|
|
133
|
+
<form onSubmit={handleSubmit} className="space-y-3">
|
|
134
|
+
<div className="grid gap-2 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)_auto]">
|
|
135
|
+
<label
|
|
136
|
+
className="sr-only"
|
|
137
|
+
htmlFor={`newsletter-name-${sourceSurface}`}
|
|
138
|
+
>
|
|
139
|
+
Name
|
|
140
|
+
</label>
|
|
141
|
+
<input
|
|
142
|
+
id={`newsletter-name-${sourceSurface}`}
|
|
143
|
+
name="name"
|
|
144
|
+
type="text"
|
|
145
|
+
autoComplete="name"
|
|
146
|
+
placeholder="Name"
|
|
147
|
+
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring min-h-10 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
148
|
+
/>
|
|
149
|
+
|
|
150
|
+
<label
|
|
151
|
+
className="sr-only"
|
|
152
|
+
htmlFor={`newsletter-email-${sourceSurface}`}
|
|
153
|
+
>
|
|
154
|
+
Email
|
|
155
|
+
</label>
|
|
156
|
+
<input
|
|
157
|
+
id={`newsletter-email-${sourceSurface}`}
|
|
158
|
+
name="email"
|
|
159
|
+
type="email"
|
|
160
|
+
autoComplete="email"
|
|
161
|
+
placeholder="Email address"
|
|
162
|
+
required
|
|
163
|
+
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring min-h-10 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
<input
|
|
167
|
+
type="text"
|
|
168
|
+
name="company"
|
|
169
|
+
tabIndex={-1}
|
|
170
|
+
autoComplete="off"
|
|
171
|
+
aria-hidden="true"
|
|
172
|
+
className="hidden"
|
|
173
|
+
/>
|
|
174
|
+
|
|
175
|
+
<button
|
|
176
|
+
type="submit"
|
|
177
|
+
disabled={isSubmitting}
|
|
178
|
+
className="inline-flex min-h-10 w-full shrink-0 items-center justify-center gap-2 rounded-full bg-[var(--blog-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto"
|
|
179
|
+
>
|
|
180
|
+
{isSubmitting ? "Submitting..." : settings.buttonLabel}
|
|
181
|
+
<ArrowUpRight className="size-4" aria-hidden="true" />
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
186
|
+
</form>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
</section>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveSourceUrl(sourceUrl: string | undefined): string | null {
|
|
194
|
+
if (sourceUrl?.startsWith("http://") || sourceUrl?.startsWith("https://")) {
|
|
195
|
+
return sourceUrl;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (typeof window !== "undefined") {
|
|
199
|
+
return window.location.href;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getFormString(formData: FormData, key: string): string {
|
|
206
|
+
const value = formData.get(key);
|
|
207
|
+
return typeof value === "string" ? value : "";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function getOptionalFormString(formData: FormData, key: string): string | null {
|
|
211
|
+
const value = getFormString(formData, key).trim();
|
|
212
|
+
if (value.length === 0) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function getSubscriptionStorageKey(siteId: string): string {
|
|
220
|
+
return `bloggy_newsletter_subscribed_${siteId}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function hasStoredSubscription(key: string): boolean {
|
|
224
|
+
if (typeof window === "undefined") {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
if (window.localStorage.getItem(key) === "true") {
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
// Ignore storage failures; the subscription API remains the source of truth.
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (typeof document === "undefined") {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return document.cookie
|
|
241
|
+
.split(";")
|
|
242
|
+
.some((cookie) => cookie.trim().startsWith(`${key}=true`));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function storeSubscription(key: string): void {
|
|
246
|
+
if (typeof window !== "undefined") {
|
|
247
|
+
try {
|
|
248
|
+
window.localStorage.setItem(key, "true");
|
|
249
|
+
} catch {
|
|
250
|
+
// Ignore storage failures; users can still submit normally.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (typeof document !== "undefined") {
|
|
255
|
+
document.cookie = `${key}=true; Max-Age=31536000; Path=/; SameSite=Lax`;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function subscribeToStoredSubscription(callback: () => void): () => void {
|
|
260
|
+
if (typeof window === "undefined") {
|
|
261
|
+
return () => undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
window.addEventListener("storage", callback);
|
|
265
|
+
return () => window.removeEventListener("storage", callback);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getNewsletterResponseMessage(value: unknown): string | null {
|
|
269
|
+
if (
|
|
270
|
+
value &&
|
|
271
|
+
typeof value === "object" &&
|
|
272
|
+
"message" in value &&
|
|
273
|
+
typeof value.message === "string"
|
|
274
|
+
) {
|
|
275
|
+
return value.message;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return null;
|
|
279
|
+
}
|