Initial commit
This commit is contained in:
32
components/landing/banner.editor.tsx
Normal file
32
components/landing/banner.editor.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { Banner, type BannerProps } from "@/components/landing/banner";
|
||||
|
||||
const bannerEditor: ComponentConfig<BannerProps> = {
|
||||
label: "Announcement bar",
|
||||
icon: <Megaphone size={16} />,
|
||||
category: "hero",
|
||||
defaultProps: {
|
||||
text: "Free shipping on orders over $150",
|
||||
ctaLabel: "Shop new",
|
||||
ctaHref: "/collections/new",
|
||||
tone: "default",
|
||||
},
|
||||
fields: {
|
||||
text: { label: "Text", type: "text", contentEditable: true },
|
||||
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
|
||||
ctaHref: { label: "CTA link", type: "text" },
|
||||
tone: {
|
||||
label: "Tone",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Default (dark)", value: "default" },
|
||||
{ label: "Inverse (light)", value: "inverse" },
|
||||
{ label: "Muted", value: "muted" },
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (props) => <Banner {...props} />,
|
||||
};
|
||||
|
||||
export default bannerEditor;
|
||||
32
components/landing/banner.tsx
Normal file
32
components/landing/banner.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type BannerProps = {
|
||||
text: string;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
tone: "default" | "inverse" | "muted";
|
||||
};
|
||||
|
||||
export function Banner({ text, ctaLabel, ctaHref, tone }: BannerProps) {
|
||||
const toneClass: Record<BannerProps["tone"], string> = {
|
||||
default: "bg-foreground text-background",
|
||||
inverse: "bg-background text-foreground border-y border-border",
|
||||
muted: "bg-muted text-foreground border-y border-border",
|
||||
};
|
||||
return (
|
||||
<div className={cn("w-full py-2 text-center text-xs tracking-[0.18em] uppercase", toneClass[tone])}>
|
||||
<div className="container mx-auto flex flex-col items-center justify-center gap-2 px-6 sm:flex-row">
|
||||
<span>{text}</span>
|
||||
{ctaLabel ? (
|
||||
<Link
|
||||
href={ctaHref || "#"}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{ctaLabel} →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
components/landing/image-gallery.editor.tsx
Normal file
50
components/landing/image-gallery.editor.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Images } from "lucide-react";
|
||||
import { ImageGallery, type ImageGalleryProps } from "@/components/landing/image-gallery";
|
||||
|
||||
const imageGalleryEditor: ComponentConfig<ImageGalleryProps> = {
|
||||
label: "Image gallery",
|
||||
icon: <Images size={16} />,
|
||||
category: "content",
|
||||
defaultProps: {
|
||||
tagline: "Lookbook",
|
||||
heading: "Spring in the studio",
|
||||
subheading: "",
|
||||
layout: "editorial",
|
||||
items: [
|
||||
{ src: "https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=1600&q=80", alt: "" },
|
||||
{ src: "https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=1200&q=80", alt: "" },
|
||||
{ src: "https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1200&q=80", alt: "" },
|
||||
{ src: "https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=1200&q=80", alt: "" },
|
||||
{ src: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&w=1200&q=80", alt: "" },
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
layout: {
|
||||
label: "Layout",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Grid", value: "grid" },
|
||||
{ label: "Masonry", value: "masonry" },
|
||||
{ label: "Editorial (mosaic)", value: "editorial" },
|
||||
],
|
||||
},
|
||||
items: {
|
||||
label: "Images",
|
||||
type: "array",
|
||||
defaultItemProps: { src: "", alt: "" },
|
||||
getItemSummary: (it) => it?.caption || "Image",
|
||||
arrayFields: {
|
||||
src: { label: "Image", type: "image" },
|
||||
alt: { label: "Alt text", type: "text", contentEditable: true },
|
||||
caption: { label: "Caption", type: "text", contentEditable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (props) => <ImageGallery {...props} />,
|
||||
};
|
||||
|
||||
export default imageGalleryEditor;
|
||||
87
components/landing/image-gallery.tsx
Normal file
87
components/landing/image-gallery.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { Heading } from "@/components/Heading";
|
||||
|
||||
export type ImageGalleryProps = {
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
layout: "grid" | "masonry" | "editorial";
|
||||
items: Array<{ src: string; alt: string; caption?: string }>;
|
||||
};
|
||||
|
||||
export function ImageGallery({ tagline, heading, subheading, layout, items }: ImageGalleryProps) {
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<Container>
|
||||
<Heading
|
||||
tagline={tagline}
|
||||
title={heading}
|
||||
subtitle={subheading}
|
||||
align="center"
|
||||
size="lg"
|
||||
className="mx-auto mb-12"
|
||||
maxWidth="max-w-2xl"
|
||||
/>
|
||||
|
||||
{layout === "masonry" ? (
|
||||
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
|
||||
{items.map((it, i) => (
|
||||
<figure key={i} className="mb-4 break-inside-avoid">
|
||||
<img
|
||||
src={it.src}
|
||||
alt={it.alt}
|
||||
className="w-full rounded-md object-cover"
|
||||
/>
|
||||
{it.caption ? (
|
||||
<figcaption className="mt-2 text-xs text-muted-foreground">
|
||||
{it.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : layout === "editorial" ? (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
|
||||
{items.slice(0, 5).map((it, i) => (
|
||||
<figure
|
||||
key={i}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-md bg-muted",
|
||||
i === 0 && "md:col-span-7 md:row-span-2",
|
||||
i === 1 && "md:col-span-5",
|
||||
i === 2 && "md:col-span-5",
|
||||
i === 3 && "md:col-span-6",
|
||||
i === 4 && "md:col-span-6",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={it.src}
|
||||
alt={it.alt}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((it, i) => (
|
||||
<figure key={i}>
|
||||
<img
|
||||
src={it.src}
|
||||
alt={it.alt}
|
||||
className="aspect-[4/5] w-full rounded-md object-cover"
|
||||
/>
|
||||
{it.caption ? (
|
||||
<figcaption className="mt-2 text-xs text-muted-foreground">
|
||||
{it.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
80
components/landing/newsletter-cta.editor.tsx
Normal file
80
components/landing/newsletter-cta.editor.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ComponentConfig, Fields } from "@reacteditor/core";
|
||||
import { Mail } from "lucide-react";
|
||||
import { NewsletterCta, type NewsletterCtaProps } from "@/components/landing/newsletter-cta";
|
||||
|
||||
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", type: "image" },
|
||||
layout: {
|
||||
label: "Layout",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Split (image + form)", value: "split" },
|
||||
{ label: "Stacked (centered)", value: "stacked" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const newsletterCtaEditor: ComponentConfig<NewsletterCtaProps> = {
|
||||
label: "Newsletter",
|
||||
icon: <Mail size={16} />,
|
||||
category: "content",
|
||||
defaultProps: {
|
||||
tagline: "Stay in the loop",
|
||||
heading: "Letters from the studio",
|
||||
subheading:
|
||||
"New collections, mill stories, and the occasional invitation to in-person events. Twice a month.",
|
||||
buttonLabel: "Subscribe",
|
||||
emailProvider: "none",
|
||||
endpoint: "",
|
||||
mailchimpApiKey: "",
|
||||
mailchimpServerPrefix: "",
|
||||
mailchimpAudienceId: "",
|
||||
klaviyoCompanyId: "",
|
||||
klaviyoListId: "",
|
||||
imageUrl:
|
||||
"https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1800&q=80",
|
||||
layout: "split",
|
||||
},
|
||||
fields: baseFields,
|
||||
resolveFields: (data) => {
|
||||
const provider = data.props.emailProvider;
|
||||
if (provider === "mailchimp") {
|
||||
return {
|
||||
...baseFields,
|
||||
mailchimpApiKey: { label: "Mailchimp API key", type: "text" },
|
||||
mailchimpServerPrefix: {
|
||||
label: "Server prefix (e.g. us21)",
|
||||
type: "text",
|
||||
},
|
||||
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} />,
|
||||
};
|
||||
|
||||
export default newsletterCtaEditor;
|
||||
190
components/landing/newsletter-cta.tsx
Normal file
190
components/landing/newsletter-cta.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Heading } from "@/components/Heading";
|
||||
|
||||
export type EmailProvider = "none" | "mailchimp" | "klaviyo";
|
||||
|
||||
export type NewsletterCtaProps = {
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
buttonLabel: string;
|
||||
emailProvider: EmailProvider;
|
||||
endpoint?: string;
|
||||
mailchimpApiKey?: string;
|
||||
mailchimpServerPrefix?: string;
|
||||
mailchimpAudienceId?: string;
|
||||
klaviyoCompanyId?: string;
|
||||
klaviyoListId?: string;
|
||||
imageUrl: string;
|
||||
layout: "split" | "stacked";
|
||||
};
|
||||
|
||||
export function NewsletterCta({
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
buttonLabel,
|
||||
emailProvider,
|
||||
endpoint,
|
||||
mailchimpApiKey,
|
||||
mailchimpServerPrefix,
|
||||
mailchimpAudienceId,
|
||||
klaviyoCompanyId,
|
||||
klaviyoListId,
|
||||
imageUrl,
|
||||
layout,
|
||||
}: NewsletterCtaProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
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, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
}
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setSubmitted(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const Form = (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="flex w-full max-w-md flex-col gap-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
className="h-11 flex-1"
|
||||
/>
|
||||
<Button type="submit" size="lg" disabled={submitting} className="h-11">
|
||||
{submitting ? "Joining…" : buttonLabel}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
const isStacked = layout === "stacked";
|
||||
|
||||
return (
|
||||
<section className="bg-background">
|
||||
<Container className="py-20 md:py-28">
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-10",
|
||||
isStacked
|
||||
? "mx-auto max-w-3xl items-center text-center"
|
||||
: "items-center md:grid-cols-12 md:gap-16",
|
||||
)}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<div className={cn(!isStacked && "md:col-span-7")}>
|
||||
<div className="relative overflow-hidden rounded-xl bg-muted">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
className={cn(
|
||||
"w-full object-cover transition-transform duration-700 hover:scale-105",
|
||||
isStacked ? "aspect-[16/9]" : "aspect-[4/3]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col",
|
||||
isStacked ? "items-center" : "items-start md:col-span-5",
|
||||
)}
|
||||
>
|
||||
<Heading
|
||||
tagline={tagline}
|
||||
title={heading}
|
||||
subtitle={subheading}
|
||||
align={isStacked ? "center" : "left"}
|
||||
size="lg"
|
||||
subtitleClassName={isStacked ? "mx-auto max-w-xl" : "max-w-md"}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-8 w-full",
|
||||
isStacked && "flex justify-center",
|
||||
)}
|
||||
>
|
||||
{submitted ? (
|
||||
<p className="text-sm font-medium uppercase tracking-wide">
|
||||
You're in. See you Monday at 5:30am.
|
||||
</p>
|
||||
) : (
|
||||
Form
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user