Skip to content

Commit ab56415

Browse files
authored
Merge pull request #18 from deco-sites/jonasjesus/deco-5286-newsletter-real-mutation
feat(newsletter): real subscription via local invoke action (DECO-5286)
2 parents c0b11f5 + bcb93db commit ab56415

4 files changed

Lines changed: 100 additions & 5 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { RequestContext } from "@decocms/start/sdk/requestContext";
2+
import { usePlatform } from "../../apps/site";
3+
import { readNewsletterCookie, serializeNewsletterCookie } from "../../loaders/_cookie";
4+
5+
export interface SubscribeNewsletterProps {
6+
email: string;
7+
}
8+
9+
export interface SubscribeNewsletterResult {
10+
success: boolean;
11+
email: string;
12+
}
13+
14+
async function action(
15+
props: SubscribeNewsletterProps,
16+
req?: Request,
17+
): Promise<SubscribeNewsletterResult> {
18+
const email = props?.email?.trim();
19+
// The invoke endpoint is publicly POST-able, so validate here too (not just
20+
// via the form's type=email). Keep it minimal — a basic shape check.
21+
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
22+
throw new Error("a valid email is required");
23+
}
24+
25+
const request = req ?? RequestContext.current?.request;
26+
const platform = usePlatform();
27+
28+
if (platform === "vtex") {
29+
// TODO(consumer): real VTEX newsletter opt-in, e.g.
30+
// await invoke("vtex/actions/newsletter/subscribe.ts", { email });
31+
}
32+
if (platform === "shopify") {
33+
// TODO(consumer): Shopify has no Storefront newsletter endpoint — integrate
34+
// a marketing provider (Klaviyo, Mailchimp, Resend) or set acceptsMarketing
35+
// on a customer mutation.
36+
}
37+
if (platform === "wake") {
38+
// TODO(consumer): wire wake newsletter endpoint here.
39+
}
40+
41+
// Default: cookie-backed so the demo persists per-browser without a backend.
42+
// Idempotent — re-subscribing the same email is a no-op success.
43+
const emails = request ? readNewsletterCookie(request) : [];
44+
if (!emails.includes(email)) emails.push(email);
45+
46+
RequestContext.responseHeaders.append("Set-Cookie", serializeNewsletterCookie(emails));
47+
return { success: true, email };
48+
}
49+
50+
export default action;

src/loaders/_cookie.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,28 @@ export function serializeWishlistCookie(state: WishlistState): string {
2424
const value = encodeURIComponent(JSON.stringify(state.productIDs));
2525
return `${WISHLIST_COOKIE}=${value}; Path=/; Max-Age=${WISHLIST_COOKIE_TTL}; SameSite=Lax`;
2626
}
27+
28+
export const NEWSLETTER_COOKIE = "deco_newsletter";
29+
export const NEWSLETTER_COOKIE_TTL = 60 * 60 * 24 * 365;
30+
31+
export function readNewsletterCookie(req: Request): string[] {
32+
const header = req.headers.get("cookie") ?? "";
33+
const match = header
34+
.split(/;\s*/)
35+
.find((c) => c.startsWith(`${NEWSLETTER_COOKIE}=`));
36+
if (!match) return [];
37+
try {
38+
const raw = decodeURIComponent(match.slice(NEWSLETTER_COOKIE.length + 1));
39+
const emails = JSON.parse(raw);
40+
return Array.isArray(emails) && emails.every((x) => typeof x === "string")
41+
? emails
42+
: [];
43+
} catch {
44+
return [];
45+
}
46+
}
47+
48+
export function serializeNewsletterCookie(emails: string[]): string {
49+
const value = encodeURIComponent(JSON.stringify(emails));
50+
return `${NEWSLETTER_COOKIE}=${value}; Path=/; Max-Age=${NEWSLETTER_COOKIE_TTL}; SameSite=Lax`;
51+
}

src/sections/Newsletter/Newsletter.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useMutation } from "@tanstack/react-query";
22
import Icon from "../../components/ui/Icon";
33
import Section from "../../components/ui/Section";
4+
import { invoke } from "../../runtime";
5+
import type { SubscribeNewsletterResult } from "../../actions/newsletter/subscribe";
46
import { clx } from "~/sdk/clx";
57

68
export interface NoticeProps {
@@ -80,10 +82,13 @@ const DEFAULT_NOTICES = {
8082
},
8183
} satisfies Required<NoticeConfig>;
8284

83-
// TODO(phase-6): replace with a real subscribeNewsletterServerFn (createServerFn
84-
// or invoke.site.actions.newsletter.subscribe) once the platform action lands.
85-
async function subscribeNewsletter(_email: string): Promise<void> {
86-
throw new Error("Newsletter subscription not yet wired to a platform action.");
85+
async function subscribeNewsletter(email: string): Promise<void> {
86+
const result = (await invoke.site.actions.newsletter.subscribe({
87+
email,
88+
})) as SubscribeNewsletterResult | undefined;
89+
if (!result?.success) {
90+
throw new Error("Newsletter subscription failed");
91+
}
8792
}
8893

8994
function Notice({ title, description }: NoticeProps) {
@@ -118,7 +123,18 @@ export default function Newsletter({ notices, form }: Props) {
118123
className={clx(isSuccess ? "text-success" : "text-error")}
119124
id={isSuccess ? "check-circle" : "error"}
120125
/>
121-
<Notice {...(isSuccess ? success : failed)} />
126+
<div className="flex flex-col items-center sm:items-start gap-4">
127+
<Notice {...(isSuccess ? success : failed)} />
128+
{subscribe.isError && (
129+
<button
130+
type="button"
131+
className="btn btn-outline"
132+
onClick={() => subscribe.reset()}
133+
>
134+
Try again
135+
</button>
136+
)}
137+
</div>
122138
</div>
123139
</Section.Container>
124140
);

src/setup.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,8 @@ registerInvokeHandlers({
143143
(await import("./actions/notifyMe/subscribe")).default(props),
144144
"site/actions/notifyMe/subscribe": async (props) =>
145145
(await import("./actions/notifyMe/subscribe")).default(props),
146+
"site/actions/newsletter/subscribe.ts": async (props, req) =>
147+
(await import("./actions/newsletter/subscribe")).default(props, req),
148+
"site/actions/newsletter/subscribe": async (props, req) =>
149+
(await import("./actions/newsletter/subscribe")).default(props, req),
146150
});

0 commit comments

Comments
 (0)