Compare commits

..

2 Commits

Author SHA1 Message Date
Rami Bitar
346fcb470e update git commit and push 2026-05-05 14:04:51 -04:00
Rami Bitar
fce386c518 Use headerActions override for custom Save button
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 13:51:10 -04:00
5 changed files with 146 additions and 28 deletions

View File

@@ -1,9 +1,34 @@
import { ComponentConfig } from "@reacteditor/core"; import { ComponentConfig, Fields } from "@reacteditor/core";
import { imageField } from "@reacteditor/plugin-media/field"; import { imageField } from "@reacteditor/plugin-media/field";
import { Mail } from "lucide-react"; import { Mail } from "lucide-react";
import { NewsletterCta, type NewsletterCtaProps } from "@/components/landing/newsletter-cta"; import { NewsletterCta, type NewsletterCtaProps } from "@/components/landing/newsletter-cta";
import { frontendAiMediaAdapter } from "@/services/media-adapter"; import { frontendAiMediaAdapter } from "@/services/media-adapter";
const baseFields: Fields<NewsletterCtaProps> = {
tagline: { label: "Tagline", type: "text", contentEditable: true },
heading: { label: "Heading", type: "text", contentEditable: true },
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
buttonLabel: { label: "Button label", type: "text", contentEditable: true },
emailProvider: {
label: "Email provider",
type: "select",
options: [
{ label: "None (custom endpoint)", value: "none" },
{ label: "Mailchimp", value: "mailchimp" },
{ label: "Klaviyo", value: "klaviyo" },
],
},
imageUrl: { label: "Image", ...imageField({ adapter: frontendAiMediaAdapter }) },
layout: {
label: "Layout",
type: "radio",
options: [
{ label: "Split (image + form)", value: "split" },
{ label: "Stacked (centered)", value: "stacked" },
],
},
};
export const newsletterCtaEditor: ComponentConfig<NewsletterCtaProps> = { export const newsletterCtaEditor: ComponentConfig<NewsletterCtaProps> = {
label: "Newsletter", label: "Newsletter",
icon: <Mail size={16} />, icon: <Mail size={16} />,
@@ -14,26 +39,42 @@ export const newsletterCtaEditor: ComponentConfig<NewsletterCtaProps> = {
subheading: subheading:
"New collections, mill stories, and the occasional invitation to in-person events. Twice a month.", "New collections, mill stories, and the occasional invitation to in-person events. Twice a month.",
buttonLabel: "Subscribe", buttonLabel: "Subscribe",
emailProvider: "none",
endpoint: "", endpoint: "",
mailchimpApiKey: "",
mailchimpServerPrefix: "",
mailchimpAudienceId: "",
klaviyoCompanyId: "",
klaviyoListId: "",
imageUrl: imageUrl:
"https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1800&q=80", "https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1800&q=80",
layout: "split", layout: "split",
}, },
fields: { fields: baseFields,
tagline: { label: "Tagline", type: "text", contentEditable: true }, resolveFields: (data) => {
heading: { label: "Heading", type: "text", contentEditable: true }, const provider = data.props.emailProvider;
subheading: { label: "Subheading", type: "textarea", contentEditable: true }, if (provider === "mailchimp") {
buttonLabel: { label: "Button label", type: "text", contentEditable: true }, return {
endpoint: { label: "Submit endpoint", type: "text" }, ...baseFields,
imageUrl: { label: "Image", ...imageField({ adapter: frontendAiMediaAdapter }) }, mailchimpApiKey: { label: "Mailchimp API key", type: "text" },
layout: { mailchimpServerPrefix: {
label: "Layout", label: "Server prefix (e.g. us21)",
type: "radio", type: "text",
options: [
{ label: "Split (image + form)", value: "split" },
{ label: "Stacked (centered)", value: "stacked" },
],
}, },
mailchimpAudienceId: { label: "Audience ID", type: "text" },
};
}
if (provider === "klaviyo") {
return {
...baseFields,
klaviyoCompanyId: { label: "Company ID (public API key)", type: "text" },
klaviyoListId: { label: "List ID", type: "text" },
};
}
return {
...baseFields,
endpoint: { label: "Submit endpoint", type: "text" },
};
}, },
render: (props) => <NewsletterCta {...props} />, render: (props) => <NewsletterCta {...props} />,
}; };

View File

@@ -2,12 +2,20 @@ import { useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Typography } from "@/components/Typography"; import { Typography } from "@/components/Typography";
export type EmailProvider = "none" | "mailchimp" | "klaviyo";
export type NewsletterCtaProps = { export type NewsletterCtaProps = {
tagline: string; tagline: string;
heading: string; heading: string;
subheading: string; subheading: string;
buttonLabel: string; buttonLabel: string;
endpoint: string; emailProvider: EmailProvider;
endpoint?: string;
mailchimpApiKey?: string;
mailchimpServerPrefix?: string;
mailchimpAudienceId?: string;
klaviyoCompanyId?: string;
klaviyoListId?: string;
imageUrl: string; imageUrl: string;
layout: "split" | "stacked"; layout: "split" | "stacked";
}; };
@@ -17,7 +25,13 @@ export function NewsletterCta({
heading, heading,
subheading, subheading,
buttonLabel, buttonLabel,
emailProvider,
endpoint, endpoint,
mailchimpApiKey,
mailchimpServerPrefix,
mailchimpAudienceId,
klaviyoCompanyId,
klaviyoListId,
imageUrl, imageUrl,
layout, layout,
}: NewsletterCtaProps) { }: NewsletterCtaProps) {
@@ -30,7 +44,53 @@ export function NewsletterCta({
if (!email) return; if (!email) return;
setSubmitting(true); setSubmitting(true);
try { try {
if (endpoint) { if (emailProvider === "mailchimp") {
if (mailchimpServerPrefix && mailchimpAudienceId && mailchimpApiKey) {
await fetch(
`https://${mailchimpServerPrefix}.api.mailchimp.com/3.0/lists/${mailchimpAudienceId}/members`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${mailchimpApiKey}`,
},
body: JSON.stringify({
email_address: email,
status: "subscribed",
}),
},
);
}
} else if (emailProvider === "klaviyo") {
if (klaviyoCompanyId && klaviyoListId) {
await fetch(
`https://a.klaviyo.com/client/subscriptions/?company_id=${klaviyoCompanyId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
revision: "2024-10-15",
},
body: JSON.stringify({
data: {
type: "subscription",
attributes: {
profile: {
data: {
type: "profile",
attributes: { email },
},
},
},
relationships: {
list: { data: { type: "list", id: klaviyoListId } },
},
},
}),
},
);
}
} else if (endpoint) {
await fetch(endpoint, { await fetch(endpoint, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },

View File

@@ -1,6 +1,8 @@
import { ComponentConfig } from "@reacteditor/core"; import { ComponentConfig } from "@reacteditor/core";
import { Menu as MenuIcon } from "lucide-react"; import { Menu as MenuIcon } from "lucide-react";
import { imageField } from "@reacteditor/plugin-media/field";
import { Navigation, type NavigationProps } from "@/components/navigation/navigation"; import { Navigation, type NavigationProps } from "@/components/navigation/navigation";
import { frontendAiMediaAdapter } from "@/services/media-adapter";
export const navigationEditor: ComponentConfig<NavigationProps> = { export const navigationEditor: ComponentConfig<NavigationProps> = {
label: "Navigation", label: "Navigation",
@@ -9,6 +11,7 @@ export const navigationEditor: ComponentConfig<NavigationProps> = {
global: true, global: true,
defaultProps: { defaultProps: {
brand: "Maison", brand: "Maison",
logo: "",
links: [ links: [
{ label: "Shop", href: "/collections" }, { label: "Shop", href: "/collections" },
{ label: "Lookbook", href: "/lookbook" }, { label: "Lookbook", href: "/lookbook" },
@@ -24,6 +27,7 @@ export const navigationEditor: ComponentConfig<NavigationProps> = {
}, },
fields: { fields: {
brand: { label: "Brand", type: "text", contentEditable: true }, brand: { label: "Brand", type: "text", contentEditable: true },
logo: { label: "Logo", ...imageField({ adapter: frontendAiMediaAdapter }) },
links: { links: {
label: "Links", label: "Links",
type: "array", type: "array",

View File

@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
export type NavigationProps = { export type NavigationProps = {
brand: string; brand: string;
logo?: string;
links: Array<{ label: string; href: string }>; links: Array<{ label: string; href: string }>;
showSearch: "yes" | "no"; showSearch: "yes" | "no";
showCart: "yes" | "no"; showCart: "yes" | "no";
@@ -18,6 +19,7 @@ export type NavigationProps = {
export function Navigation({ export function Navigation({
brand, brand,
logo,
links, links,
showSearch, showSearch,
showCart, showCart,
@@ -70,10 +72,18 @@ export function Navigation({
<div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-6 md:h-20"> <div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-6 md:h-20">
<Link <Link
to="/" to="/"
className="font-semibold tracking-tight" className="inline-flex items-center font-semibold tracking-tight"
style={{ fontSize: "1.125rem", letterSpacing: "0.02em" }} style={{ fontSize: "1.125rem", letterSpacing: "0.02em" }}
> >
{brand} {logo ? (
<img
src={logo}
alt={brand || "Brand Logo"}
className="h-8 w-auto object-contain"
/>
) : (
brand || "Brand Logo"
)}
</Link> </Link>
<nav className="hidden items-center gap-8 md:flex"> <nav className="hidden items-center gap-8 md:flex">

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { App as ReactEditorApp } from "@reacteditor/core"; import { App as ReactEditorApp, createUseEditor } from "@reacteditor/core";
import "@reacteditor/core/react-editor.css"; import "@reacteditor/core/react-editor.css";
import createTailwindCdnPlugin from "@reacteditor/plugin-tailwind-cdn"; import createTailwindCdnPlugin from "@reacteditor/plugin-tailwind-cdn";
import { mediaPlugin } from "@reacteditor/plugin-media"; import { mediaPlugin } from "@reacteditor/plugin-media";
@@ -16,6 +16,8 @@ const AI_API_KEY = (import.meta.env.VITE_API_KEY as string | undefined) ?? "";
type Pages = Record<string, { root: any; content: any[] }>; type Pages = Record<string, { root: any; content: any[] }>;
const useEditor = createUseEditor();
const SHOPIFY_DOMAIN = const SHOPIFY_DOMAIN =
(import.meta.env.VITE_SHOPIFY_DOMAIN as string | undefined) ?? "mock.shop"; (import.meta.env.VITE_SHOPIFY_DOMAIN as string | undefined) ?? "mock.shop";
const STOREFRONT_TOKEN = const STOREFRONT_TOKEN =
@@ -78,21 +80,22 @@ export default function App() {
onPublish={handlePublish} onPublish={handlePublish}
onChange={handleChange} onChange={handleChange}
overrides={{ overrides={{
header: ({ children }: any) => ( headerActions: () => {
<div className="flex items-center justify-between gap-2 border-b border-gray-200 bg-white px-4 py-2"> const appState = useEditor((s: any) => s.appState);
<div className="flex flex-1 items-center gap-2">{children}</div> return (
<button <button
type="button" type="button"
disabled={isPublishing} disabled={isPublishing}
onClick={() => { onClick={() => {
handlePublish(latestDataRef.current, currentPath); handlePublish(appState.data, currentPath);
}} }}
className="inline-flex items-center justify-center rounded-md bg-black px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60" className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
> >
{isPublishing && <Loader size={14} />}
{isPublishing ? "Saving..." : "Save"} {isPublishing ? "Saving..." : "Save"}
</button> </button>
</div> );
), },
}} }}
/> />
</ShopifyProvider> </ShopifyProvider>