diff --git a/.gitignore b/.gitignore index 5ef6a520..c59e31c6 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.aider* +components-autogen diff --git a/README.md b/README.md index df191aa9..72330c4a 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,81 @@ This project supports integration with Figma's Dev Mode MCP (Model Context Proto For more detailed instructions, refer to [Figma's official Dev Mode MCP Server guide](https://help.figma.com/hc/en-us/articles/32132100833559-Guide-to-the-Dev-Mode-MCP-Server). +# Plasmic integration + +Plasmic integration adds a two-way sync to and from the attached Plasmic Studio project. +Project ref is hard-coded in [./lib/plasmic-init.ts](./lib/plasmic-init.ts) and in [./plasmic.json](./plasmic.json) + +Plasmic integration adds `/plasmic-host` and `/plasmic/*` routes via a page router, along existing app router (the two are able to co-exist so long as they do not define overlapping routes, which is something we have to be careful about here). +Generally, the first of the two routes above is responsible for localdev->PlasmicStudio sync direction, while the second is responsible for PlasmicStudio->localdev direction of sync. + +The main PlasmicUI built page lives [http://localhost:3000/plasmic/showcase](http://localhost:3000/plasmic/showcase). +You can edit this page in the [shared plasmic project](https://studio.plasmic.app/projects/qkC7iPT4FekKxstnmsJETj) +The page is rendered via plasmic's [catchall renderer](./pages/plasmic/[[...catchall]].tsx). + +## Dev workflow with Plasmic UI + +- Have `pnpm run dev` in one terminal. +- Open http://localhost:3000/ for list of components +- Open http://localhost:3000/plasmic-host and confirm "Your app is ready to host Plasmic Studio!" message +- Open http://localhost:3000/plasmic/showcase for latest Plasmic-studio designed homepage +- (done once or when switching Plasmic studio projects) + - Update [./lib/plasmic-init.ts](./lib/plasmic-init.ts) with my new project ID and token. + - Delete [./components/plasmic-autogen/](./components/plasmic-autogen/) and [./plasmic.json](./plasmic.json) + - `npx plasmic sync --projects [PROJECT_ID]` + - Prepare corresponding project in Plasmic Studio UI. + - Open the project, under kebab menu next to project name on the top right, click on "Configure custom app host" + - Put `http://localhost:3000/plasmic-host` as app host + - Hard-refresh the Plasmic Studio page. You'll see requests in terminal and will see registered code components in the plasmic UI + +## Issues + +Issue 1: Note that as more pages are created in the Plasmic Studio editor, those get synced into the codebase directly under `/pages` under +corresponding global routes. For some reason these are broken and I have not spent time trying to fix them + +Issue 2: Note: please do not create a page with root URL ("/"). This causes a duplicate route issue in the componend library code project as plasmic pages under /plasmic/[PAGE] are rendered via the page router, while +the rest of the components are rendered via the app router and app router already has a route for root URL. + +Issue 3: Plasmic editor Actions API still uses ReactDOM.render API which was removed in React 19, that is why I downgraded the project to React 18, so integration does not break until this gets fixed in he upstream + +## How to add new code components + +Follow existing examples in [./lib/plasmic-init-client.tsx](./lib/plasmic-init-client.tsx). + +## Using Plasmic Components + +For each component, Plasmic generates two React components +in two files. For example, for component Homepage, there +are: + +- A blackbox component at plasmic-autogen/website_starter/PlasmicHomepage.tsx + This is a blackbox, purely-presentational library component + that you can use to render your designs. This file is owned + by Plasmic, and you should not edit it -- it will be + overwritten when the component design is updated. This + component should only be used by the "wrapper" component + (below). + +- A wrapper component at ../pages/index.tsx + This component is owned and edited by you to instantiate the + PlasmicHomepage component with desired variants, states, + event handlers, and data. You have complete control over + this file, and this is the actual component that should be + used by the rest of the codebase. + +Learn more at https://www.plasmic.app/learn/codegen-guide/ + +## Using Icons + +For each SVG icon, Plasmic also generates a React component. +The component takes in all the usual props that you can pass +to an svg element, and defaults to width/height of 1em. + +For example, for the CircleIcon icon at plasmic-autogen/website_starter/icons/PlasmicIcon\_\_Circle.tsx, +instantiate it like: + + + ## TODO Coming up: diff --git a/eslint.config.mjs b/eslint.config.mjs index 3b910158..0c8385ad 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,10 +10,10 @@ const compat = new FlatCompat({ }); const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), + // ...compat.extends("next/typescript"), { rules: { - "@next/next/no-img-element": "off", + // "@next/next/no-img-element": "off", }, }, ]; diff --git a/lib/plasmic-component-registrations/GENERATION_INSTRUCTIONS.md b/lib/plasmic-component-registrations/GENERATION_INSTRUCTIONS.md new file mode 100644 index 00000000..02aa855f --- /dev/null +++ b/lib/plasmic-component-registrations/GENERATION_INSTRUCTIONS.md @@ -0,0 +1,117 @@ +Your goal is to integrate several components from Lantern's component library +Use ./testimonial.tsx as a readonly blueprint for the file you'll generate + +The generated file will refer to existing components, include them and register them for plasmic use. Make sure the generated plasmic integration code is clean, does not have repetition +and uses common general prop type for all components. +if necessary, modify the base components to fit the general structure. use the readonly component examples as well as the DIFF snippets below to understand what needs to change in component source code: + +## Snippets for reference + +### snippet 1 + +```diff + {(role || companyName) && ( + +- {[role, companyName].filter(Boolean).join(", ")} ++ {role} ++ {role && companyName && ", "} ++ {companyName} + + )} +``` + +### snippet 2 + +```diff + /** + * Testimonial07: Center-aligned 5-star testimonial with bold quote, avatar, attribution, and company logo. +@@ -24,12 +14,12 @@ export function Testimonial07({ + content, + name, + role, ++ avatar, + companyName, + companyLogo, +- image, + className, + ...props +-}: Testimonial07Props) { ++}: TestimonialProps) { + return ( +
+ {/* Avatar */} +- ++ {avatar} + {/* Name/Role/Company */} +
+ {name} +@@ -73,12 +57,16 @@ export function Testimonial07({ + {/* Company Logo */} + {companyLogo && ( +
+- {companyName ++ {typeof companyLogo == "string" ? ( ++ {companyName ++ ) : ( ++ companyLogo ++ )} +
+ )} +
+``` + +### snippet 3 + +```diff +- +-export interface Testimonial09Props +- extends React.HTMLAttributes { +- content: string; +- name: string; +- role?: string; +- companyName?: string; +- image?: string; +- className?: string; +-} ++import { TestimonialProps } from "./testimonial-base"; + + /** + * Testimonial09: Card-style testimonial matching screenshot visual details. +@@ -24,10 +15,10 @@ export function Testimonial09({ + name, + role, + companyName, +- image, ++ avatar, + className, + ...props +-}: Testimonial09Props) { ++}: TestimonialProps) { + return ( +``` + +## Notes to consider when writing code: + +1. Make sure social links are separate props (type string, not slot), which will not be shown if the link is missing but those are individual components and not an object +2. If in any component before plasmic edits a prop is rendered in a

tag and you decide to convert that prop to a slot that takes in a react element, then make sure to switch the outer tag from

to

or .

tags expect flat text inside and so we cannot render full html subtree under them +3. If there is a sub-component on the component being converted that can be used in a standalone manner (e.g. one similar to ProfileAvatar. Other examples: SocialLinks, ) diff --git a/lib/plasmic-component-registrations/component-with-variants.tsx b/lib/plasmic-component-registrations/component-with-variants.tsx new file mode 100644 index 00000000..8c1ed364 --- /dev/null +++ b/lib/plasmic-component-registrations/component-with-variants.tsx @@ -0,0 +1,135 @@ +import { + ActionProps, + usePlasmicCanvasComponentInfo, +} from "@plasmicapp/react-web/lib/host"; +import { useRef, useState } from "react"; + +export type ComponentMap = Record>; + +export function getComponentAndControls( + variants: ComponentMap, + defaultVariant = "01" +) { + type ComponentKind = keyof typeof variants; + function Component({ + kind, + ...props + }: ComponentProps & { + kind: K; + }) { + const comp = variants[kind]; + const { isSelected, ...rest } = usePlasmicCanvasComponentInfo(props) ?? {}; + props.selectedInEditor = isSelected; + return comp(props); + } + + const ComponentControl: React.ComponentType< + ActionProps + > = ({ studioOps, componentProps }) => { + const [hoveredKind, setHoveredKind] = useState(null); + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + const containerRef = useRef(null); + + const kinds = Object.keys(variants) as ComponentKind[]; + kinds.sort(); + + const onSelect = (kind: string) => { + studioOps.updateProps({ kind }); + }; + + const onMouseMove = (e: React.MouseEvent) => { + setMousePos({ x: e.clientX + 20, y: e.clientY + 20 }); + }; + + const Preview = hoveredKind ? variants[hoveredKind] : null; + const PREVIEW_WIDTH = 600; + const PREVIEW_HEIGHT = 200; + + const clampedX = Math.min( + mousePos.x, + window.innerWidth - PREVIEW_WIDTH - 90 + ); // 90px buffer + + return ( +

+ {kinds.map((kind) => ( + + ))} + + {Preview && ( +
+ {/* tailwind currently does not work in preview since this runs in */} + {/* another iframe. */} + {/* TODO: fix tailwind here */} + +
+ )} +
+ ); + }; + + const componentVariantProps = { + kind: { + type: "choice", + options: Object.keys(variants), + defaultValue: defaultVariant, + }, + }; + + const componentVariantActions = [ + { + type: "custom-action", + control: ComponentControl, + }, + ]; + return { + Component, + componentVariantProps, + componentVariantActions, + }; +} diff --git a/lib/plasmic-component-registrations/faq.tsx b/lib/plasmic-component-registrations/faq.tsx new file mode 100644 index 00000000..e241083d --- /dev/null +++ b/lib/plasmic-component-registrations/faq.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { FAQ01, FAQ01Props, FAQItem } from "@/registry/new-york/section/faq-01"; +import { FAQ02, FAQ02Props } from "@/registry/new-york/section/faq-02"; +import { FAQ03, FAQ03Props } from "@/registry/new-york/section/faq-03"; +import { PLASMIC } from "../plasmic-init"; +import { getComponentAndControls } from "./component-with-variants"; + +export const FAQS = { + "01": FAQ01, + "02": FAQ02, + "03": FAQ03, +} as const; + +type FAQProps = FAQ01Props | FAQ02Props | FAQ03Props; +const { Component, componentVariantProps, componentVariantActions } = + getComponentAndControls(FAQS); + +export { Component as FAQ }; + +PLASMIC.registerComponent(FAQItem, { + name: "FAQItem", + importPath: "@/lib/plasmic-component-registrations/faq", + props: { + index: { + type: "number", + defaultValue: 0, + }, + question: { + type: "slot", + allowedComponents: ["text"], + defaultValue: { + type: "text", + value: "What is LanternDB?", + }, + }, + answer: { + type: "slot", + allowedComponents: ["text"], + defaultValue: { + type: "text", + value: + "LanternDB is a PostgreSQL extension that provides scalable vector search capabilities, enabling efficient semantic search and AI applications.", + }, + }, + }, +}); + +PLASMIC.registerComponent(Component, { + name: "FAQ", + importPath: "@/lib/plasmic-component-registrations/faq", + props: { + ...componentVariantProps, + eyebrow: { + type: "slot", + defaultValue: { + type: "text", + value: "FAQ", + }, + }, + title: { + type: "slot", + defaultValue: { + type: "text", + value: "Frequently Asked Questions", + }, + }, + subtitle: { + type: "slot", + defaultValue: { + type: "text", + value: + "Find answers to common questions about our products and services.", + }, + }, + primaryButtonText: { + type: "slot", + defaultValue: { + type: "text", + value: "Contact Support", + }, + }, + primaryButtonHref: { + type: "string", + defaultValue: "/support", + }, + secondaryButtonText: { + type: "slot", + defaultValue: { + type: "text", + value: "View Documentation", + }, + }, + secondaryButtonHref: { + type: "string", + defaultValue: "/docs", + }, + faqs: { + type: "slot", + allowedComponents: ["FAQItem"], + defaultValue: { + type: "component", + name: "FAQItem", + }, + }, + className: "string", + }, + actions: [...componentVariantActions], +}); diff --git a/lib/plasmic-component-registrations/link-button.tsx b/lib/plasmic-component-registrations/link-button.tsx new file mode 100644 index 00000000..38e50bad --- /dev/null +++ b/lib/plasmic-component-registrations/link-button.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { LinkButton } from "@/registry/new-york/ui/link-button"; +import { PLASMIC } from "../plasmic-init"; + +export interface LinkButtonWrapperProps { + href?: string; + children?: string; + variant?: + | "default" + | "destructive" + | "outline" + | "secondary" + | "ghost" + | "link"; + size?: "default" | "sm" | "lg" | "icon"; + className?: string; +} + +export function LinkButtonWrapper({ + href = "#", + children = "Button", + variant = "default", + size = "default", + className, +}: LinkButtonWrapperProps) { + return ( + + {children} + + ); +} + +PLASMIC.registerComponent(LinkButtonWrapper, { + name: "LinkButtonWrapper", + displayName: "LinkButton", + importPath: "@/lib/plasmic-component-registrations/link-button", + props: { + href: { + type: "string", + defaultValue: "#", + }, + children: { + type: "string", + defaultValue: "Get Started", + }, + variant: { + type: "choice", + options: [ + "default", + "destructive", + "outline", + "secondary", + "ghost", + "link", + ], + defaultValue: "default", + }, + size: { + type: "choice", + options: ["default", "sm", "lg", "icon"], + defaultValue: "default", + }, + className: "string", + }, +}); diff --git a/lib/plasmic-component-registrations/pricing-card.tsx b/lib/plasmic-component-registrations/pricing-card.tsx new file mode 100644 index 00000000..5084fcb9 --- /dev/null +++ b/lib/plasmic-component-registrations/pricing-card.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { + ActionProps, + usePlasmicCanvasComponentInfo, +} from "@plasmicapp/react-web/lib/host"; +import { useRef, useState } from "react"; +import { PricingCard01 } from "@/registry/new-york/marketing/pricing-card-01"; +import { PricingCardProps } from "@/registry/new-york/marketing/pricing-card-base"; +import { PLASMIC } from "../plasmic-init"; + +export const PRICING_CARDS = { + "01": PricingCard01, +} as const; + +type PricingCardKind = keyof typeof PRICING_CARDS; // "01" + +// Helper type (optional, handy elsewhere) +type PricingCardOf = (typeof PRICING_CARDS)[K]; + +// Generic accessor: returns the *precise* component type +function getPricingCard(kind: K): PricingCardOf { + return PRICING_CARDS[kind]; +} + +export function PricingCardWrapper({ + kind, + ...props +}: PricingCardProps & { + kind: K; +}) { + const comp = PRICING_CARDS[kind]; + const { isSelected, ...rest } = usePlasmicCanvasComponentInfo(props) ?? {}; + props.selectedInEditor = isSelected; + return comp(props); +} + +type TKind = keyof typeof PRICING_CARDS; + +const pricingCardControl: React.ComponentType< + ActionProps +> = ({ studioOps, componentProps }) => { + const [hoveredKind, setHoveredKind] = useState(null); + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + const containerRef = useRef(null); + + const kinds = Object.keys(PRICING_CARDS) as TKind[]; + kinds.sort(); + + const onSelect = (kind: string) => { + studioOps.updateProps({ kind }); + }; + + const onMouseMove = (e: React.MouseEvent) => { + setMousePos({ x: e.clientX + 20, y: e.clientY + 20 }); + }; + + const Preview = hoveredKind ? PRICING_CARDS[hoveredKind] : null; + const PREVIEW_WIDTH = 400; + const PREVIEW_HEIGHT = 500; + + const clampedX = Math.min(mousePos.x, window.innerWidth - PREVIEW_WIDTH - 90); // 90px buffer + + return ( +
+ {kinds.map((kind) => ( + + ))} + + {Preview && ( +
+ +
+ )} +
+ ); +}; + +PLASMIC.registerComponent(PricingCardWrapper, { + name: "PricingCardWrapper", + displayName: "PricingCard", + importPath: "@/lib/plasmic-component-registrations/pricing-card", + props: { + kind: { + type: "choice", + options: Object.keys(PRICING_CARDS), + defaultValue: "01", + }, + title: { + type: "slot", + mergeWithParent: true, + defaultValue: { + type: "text", + tag: "span", + value: "Pro Plan", + }, + }, + description: { + type: "slot", + defaultValue: { + type: "text", + value: "Perfect for growing teams and businesses", + }, + }, + price: { + type: "slot", + mergeWithParent: true, + defaultValue: { + type: "text", + tag: "span", + value: "$29", + }, + }, + interval: { + type: "slot", + mergeWithParent: true, + defaultValue: { + type: "text", + tag: "span", + value: "month", + }, + }, + unit: { + type: "slot", + mergeWithParent: true, + defaultValue: { + type: "text", + tag: "span", + value: "per user", + }, + }, + features: { + type: "object", + defaultValue: [ + "Unlimited projects", + "Advanced analytics", + "Priority support", + "Custom integrations", + "Team collaboration tools", + ], + }, + featured: { + type: "slot", + mergeWithParent: true, + defaultValue: { + type: "text", + tag: "span", + value: "Most Popular", + }, + }, + footnote: { + type: "slot", + defaultValue: { + type: "text", + value: "Cancel anytime. No setup fees.", + }, + }, + button: { + type: "slot", + defaultValue: { + type: "component", + name: "LinkButtonWrapper", + props: { + children: "Get Started", + href: "#", + className: "w-full", + }, + }, + }, + accent: { + type: "boolean", + defaultValue: false, + }, + className: "string", + }, + actions: [ + { + type: "custom-action", + control: pricingCardControl, + }, + ], +}); diff --git a/lib/plasmic-component-registrations/social-links.tsx b/lib/plasmic-component-registrations/social-links.tsx new file mode 100644 index 00000000..5fe9105e --- /dev/null +++ b/lib/plasmic-component-registrations/social-links.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { SocialLinks } from "@/lib/social-links"; +import { PLASMIC } from "../plasmic-init"; + +export interface SocialLinksWrapperProps { + twitterLink?: string; + linkedinLink?: string; + githubLink?: string; + instagramLink?: string; + facebookLink?: string; + youtubeLink?: string; + iconSize?: number; + className?: string; +} + +export function SocialLinksWrapper({ + twitterLink, + linkedinLink, + githubLink, + instagramLink, + facebookLink, + youtubeLink, + iconSize = 18, + className, +}: SocialLinksWrapperProps) { + const links = [ + ...(twitterLink ? [{ type: "twitter", href: twitterLink }] : []), + ...(linkedinLink ? [{ type: "linkedin", href: linkedinLink }] : []), + ...(githubLink ? [{ type: "github", href: githubLink }] : []), + ...(instagramLink ? [{ type: "instagram", href: instagramLink }] : []), + ...(facebookLink ? [{ type: "facebook", href: facebookLink }] : []), + ...(youtubeLink ? [{ type: "youtube", href: youtubeLink }] : []), + ]; + + if (links.length === 0) { + return null; + } + + return ( + + ); +} + +PLASMIC.registerComponent(SocialLinksWrapper, { + name: "SocialLinksWrapper", + displayName: "SocialLinks", + importPath: "@/lib/plasmic-component-registrations/social-links", + props: { + twitterLink: { + type: "string", + defaultValue: "https://twitter.com/johndoe", + }, + linkedinLink: { + type: "string", + defaultValue: "https://linkedin.com/in/johndoe", + }, + githubLink: { + type: "string", + }, + instagramLink: { + type: "string", + }, + facebookLink: { + type: "string", + }, + youtubeLink: { + type: "string", + }, + iconSize: { + type: "number", + defaultValue: 18, + }, + className: "string", + }, +}); diff --git a/lib/plasmic-component-registrations/team-member.tsx b/lib/plasmic-component-registrations/team-member.tsx new file mode 100644 index 00000000..a3d5db37 --- /dev/null +++ b/lib/plasmic-component-registrations/team-member.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { + ActionProps, + usePlasmicCanvasComponentInfo, +} from "@plasmicapp/react-web/lib/host"; +import { useRef, useState } from "react"; +import { ProfileAvatar } from "@/registry/new-york/marketing/profile-avatar"; +import { TeamMember01 } from "@/registry/new-york/marketing/team-member-01"; +import { TeamMember02 } from "@/registry/new-york/marketing/team-member-02"; +import { TeamMember03 } from "@/registry/new-york/marketing/team-member-03"; +import { TeamMember04 } from "@/registry/new-york/marketing/team-member-04"; +import { TeamMember05 } from "@/registry/new-york/marketing/team-member-05"; +import { TeamMember06 } from "@/registry/new-york/marketing/team-member-06"; +import { TeamMemberProps } from "@/registry/new-york/marketing/team-member-base"; +import { PLASMIC } from "../plasmic-init"; + +export const TEAM_MEMBERS = { + "01": TeamMember01, + "02": TeamMember02, + "03": TeamMember03, + "04": TeamMember04, + "05": TeamMember05, + "06": TeamMember06, +} as const; + +type TeamMemberKind = keyof typeof TEAM_MEMBERS; // "01" + +// Helper type (optional, handy elsewhere) +type TeamMemberOf = (typeof TEAM_MEMBERS)[K]; + +// Generic accessor: returns the *precise* component type +function getTeamMember(kind: K): TeamMemberOf { + return TEAM_MEMBERS[kind]; +} + +export function TeamMember({ + kind, + ...props +}: TeamMemberProps & { + kind: K; +}) { + const comp = TEAM_MEMBERS[kind]; + const { isSelected, ...rest } = usePlasmicCanvasComponentInfo(props) ?? {}; + props.selectedInEditor = isSelected; + return comp(props); +} + +type TKind = keyof typeof TEAM_MEMBERS; + +const teamMemberControl: React.ComponentType< + ActionProps +> = ({ studioOps, componentProps }) => { + const [hoveredKind, setHoveredKind] = useState(null); + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + const containerRef = useRef(null); + + const kinds = Object.keys(TEAM_MEMBERS) as TKind[]; + kinds.sort(); + + const onSelect = (kind: string) => { + studioOps.updateProps({ kind }); + }; + + const onMouseMove = (e: React.MouseEvent) => { + setMousePos({ x: e.clientX + 20, y: e.clientY + 20 }); + }; + + const Preview = hoveredKind ? TEAM_MEMBERS[hoveredKind] : null; + const PREVIEW_WIDTH = 400; + const PREVIEW_HEIGHT = 300; + + const clampedX = Math.min(mousePos.x, window.innerWidth - PREVIEW_WIDTH - 90); // 90px buffer + + return ( +
+ {kinds.map((kind) => ( + + ))} + + {Preview && ( +
+ +
+ )} +
+ ); +}; + +PLASMIC.registerComponent(ProfileAvatar, { + name: "ProfileAvatar", + importPath: "@/registry/new-york/marketing/profile-avatar", + props: { + name: { + type: "string", + defaultValue: "Emily Watson", + }, + image: { + type: "slot", + hidePlaceholder: true, + defaultValue: { + type: "img", + src: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200&h=200&fit=crop&crop=face", + styles: { display: "inline" }, + }, + }, + size: { + type: "choice", + options: ["sm", "md", "lg", "xl"], + defaultValue: "sm", + }, + fallbackType: { + type: "choice", + options: ["initials", "first-char"], + defaultValue: "initials", + }, + className: "string", + }, +}); + +PLASMIC.registerComponent(TeamMember, { + name: "TeamMember", + importPath: "@/lib/plasmic-component-registrations/team-member", + props: { + kind: { + type: "choice", + options: Object.keys(TEAM_MEMBERS), + defaultValue: "01", + }, + name: { + type: "slot", + mergeWithParent: true, + defaultValue: { + type: "text", + tag: "span", + value: "John Doe", + }, + }, + role: { + type: "slot", + mergeWithParent: true, + defaultValue: { + type: "text", + tag: "span", + value: "Senior Developer", + }, + }, + avatar: { + type: "slot", + displayName: "ProfileAvatar", + hidePlaceholder: true, + hidden: (props) => props["kind"] == "04" || props["kind"] == "06", + defaultValue: { + type: "component", + name: "ProfileAvatar", + props: { + size: "xl", + }, + }, + }, + image: { + type: "slot", + hidden: (props) => props["kind"] != "04" && props["kind"] != "06", + defaultValue: { + type: "img", + src: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400&h=296&fit=crop&crop=face", + styles: { + width: "100%", + height: "100%", + }, + }, + }, + description: { + type: "slot", + defaultValue: { + type: "text", + value: + "Passionate about creating innovative solutions and leading development teams.", + }, + }, + links: { + type: "slot", + displayName: "SocialLinks", + hidePlaceholder: true, + defaultValue: { + type: "component", + name: "SocialLinksWrapper", + props: {}, + }, + }, + align: { + type: "choice", + options: ["left", "center"], + defaultValue: "left", + }, + className: "string", + }, + actions: [ + { + type: "custom-action", + control: teamMemberControl, + }, + ], +}); diff --git a/lib/plasmic-component-registrations/testimonial.tsx b/lib/plasmic-component-registrations/testimonial.tsx new file mode 100644 index 00000000..bb037fef --- /dev/null +++ b/lib/plasmic-component-registrations/testimonial.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { Testimonial01 } from "@/components/marketing/testimonial-01"; +import { Testimonial02 } from "@/components/marketing/testimonial-02"; +import { Testimonial03 } from "@/components/marketing/testimonial-03"; +import { Testimonial04 } from "@/components/marketing/testimonial-04"; +import { Testimonial05 } from "@/components/marketing/testimonial-05"; +import { Testimonial06 } from "@/components/marketing/testimonial-06"; +import { Testimonial07 } from "@/components/marketing/testimonial-07"; +import { Testimonial08 } from "@/components/marketing/testimonial-08"; +import { Testimonial09 } from "@/components/marketing/testimonial-09"; +import { Testimonial10 } from "@/components/marketing/testimonial-10"; +import { Testimonial11 } from "@/components/marketing/testimonial-11"; +import { ProfileAvatar } from "@/registry/new-york/marketing/profile-avatar"; +import { TestimonialProps } from "@/registry/new-york/marketing/testimonial-base"; +import { PLASMIC } from "../plasmic-init"; +import { getComponentAndControls } from "./component-with-variants"; + +export const TESTIMONIALS = { + "01": Testimonial01, + "02": Testimonial02, + "03": Testimonial03, + "04": Testimonial04, + "05": Testimonial05, + "06": Testimonial06, + "07": Testimonial07, + "08": Testimonial08, + "09": Testimonial09, + "10": Testimonial10, + "11": Testimonial11, +} as const; + +const { Component, componentVariantProps, componentVariantActions } = + getComponentAndControls(TESTIMONIALS, "02"); + +export { Component as Testimonial }; + +PLASMIC.registerComponent(ProfileAvatar, { + name: "ProfileAvatar", + importPath: "@/registry/new-york/marketing/profile-avatar", + props: { + name: { + type: "string", + defaultValue: "Emily Watson", + }, + image: { + type: "slot", + hidePlaceholder: true, + defaultValue: { + type: "img", + src: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200&h=200&fit=crop&crop=face", + styles: { display: "inline" }, + }, + }, + size: { + type: "choice", + options: ["sm", "md", "lg", "xl"], + defaultValue: "sm", + }, + fallbackType: { + type: "choice", + options: ["initials", "first-char"], + defaultValue: "initials", + }, + className: "string", + }, +}); + +PLASMIC.registerComponent(Component, { + name: "Testimonial", + importPath: "@/lib/plasmic-component-registrations/testimonial", + props: { + ...componentVariantProps, + content: { + type: "slot", + defaultValue: { + type: "text", + value: + "Lantern's component library made our product launch seamless. The design and accessibility are top-notch.", + }, + }, + attribution: { + type: "slot", + hidePlaceholder: true, + defaultValue: { + type: "text", + tag: "span", + value: "— Alex Kim, Lead Designer at PixelPerfect Inc.", + }, + }, + name: { + type: "slot", + mergeWithParent: true, + defaultValue: { + type: "text", + tag: "span", + value: "Emily Watson", + }, + }, + avatar: { + type: "slot", + hidden: (props) => props["kind"] == "08", + displayName: "ProfileAvatar", + hidePlaceholder: true, + defaultValue: { + type: "component", + name: "ProfileAvatar", + props: { + size: "lg", + }, + }, + }, + image: { + type: "slot", + hidden: (props) => props["kind"] != "08", + defaultValue: { + type: "img", + src: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200&h=200&fit=crop&crop=face", + styles: { + width: "100%", + height: "100%", + }, + }, + }, + role: { + type: "slot", + mergeWithParent: true, + defaultValue: { + type: "text", + tag: "span", + value: "Senior Developer", + }, + }, + companyName: { + type: "slot", + mergeWithParent: true, + defaultValue: { + type: "text", + tag: "span", + value: "IBM", + }, + }, + align: { + type: "choice", + options: ["left", "center", "right"], + hidden: (props, ctx) => props["kind"] != "02", + defaultValue: "left", + }, + companyLogo: { + type: "slot", + defaultValue: { + type: "img", + src: "https://placehold.co/96x48?text=Logo&font=roboto", + }, + }, + link: { + type: "object", + defaultValue: { + href: "http://example.com", + children: "Read Full Case Study", + }, + }, + className: "string", + }, + actions: [...componentVariantActions], +}); diff --git a/lib/plasmic-init-client.tsx b/lib/plasmic-init-client.tsx new file mode 100644 index 00000000..b5d38c4e --- /dev/null +++ b/lib/plasmic-init-client.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { PlasmicRootProvider } from "@plasmicapp/loader-nextjs"; +import { + Ticker, + TickerContent, + TickerItem, +} from "@/registry/new-york/layout/ticker"; +// this line ensures that tailwind classes are part of the loaded page in the editor +import "../app/globals.css"; +import { PLASMIC } from "./plasmic-init"; +// +// import component registrations +import "./plasmic-component-registrations/social-links"; +import "./plasmic-component-registrations/link-button"; +import "./plasmic-component-registrations/testimonial"; +import "./plasmic-component-registrations/team-member"; +import "./plasmic-component-registrations/pricing-card"; +import "./plasmic-component-registrations/faq"; + +// You can register any code components that you want to use here; see +// https://docs.plasmic.app/learn/code-components-ref/ +// And configure your Plasmic project to use the host url pointing at +// the /plasmic-host page of your nextjs app (for example, +// http://localhost:3000/plasmic-host). See +// https://docs.plasmic.app/learn/app-hosting/#set-a-plasmic-project-to-use-your-app-host + +// PLASMIC.registerComponent(...); + +PLASMIC.registerComponent(TickerItem, { + name: "TickerItem", + importPath: "@/registry/new-york/layout/ticker", + props: { + className: "string", + children: { + type: "slot", + defaultValue: { + type: "text", + value: "Logo Example", + }, + }, + }, +}); + +PLASMIC.registerComponent(TickerContent, { + name: "TickerContent", + importPath: "@/registry/new-york/layout/ticker", + props: { + className: "string", + children: { + type: "slot", + defaultValue: ["#ff0000", "#00ff00", "#0000ff"].map((c, i) => ({ + type: "component", + name: "TickerItem", + props: { + children: [ + { type: "text", value: "Logo Example " + i, styles: { color: c } }, + ], + }, + })), + }, + }, + actions: [ + { + type: "button-action", + label: "Add Logo", + onClick: ({ studioOps, ...props }) => { + studioOps.appendToSlot( + { + type: "component", + name: "TickerItem", + props: { + children: [{ type: "text", value: "Added Logo" }], + }, + }, + "children" + ); + }, + }, + ], +}); + +PLASMIC.registerComponent(Ticker, { + name: "Ticker", + importPath: "@/registry/new-york/layout/ticker", + props: { + className: "string", + speed: { type: "number", defaultValue: 15 }, + pauseOnHover: { type: "boolean", defaultValue: true }, + fade: { type: "boolean", defaultValue: true }, + direction: { + type: "choice", + options: ["left", "right", "up", "down"], + defaultValue: "left", + }, + children: { + type: "slot", + defaultValue: { + type: "component", + name: "TickerContent", + }, + }, + }, + actions: [ + { + type: "custom-action", + control: () => <>Select TickerContent to add new Logos., + }, + ], +}); + +/** + * PlasmicClientRootProvider is a Client Component that passes in the loader for you. + * + * Why? Props passed from Server to Client Components must be serializable. + * https://beta.nextjs.org/docs/rendering/server-and-client-components#passing-props-from-server-to-client-components-serialization + * However, PlasmicRootProvider requires a loader, but the loader is NOT serializable. + */ +export function PlasmicClientRootProvider( + props: Omit, "loader"> +) { + return ( + + ); +} diff --git a/lib/plasmic-init.ts b/lib/plasmic-init.ts new file mode 100644 index 00000000..ffa7a1e6 --- /dev/null +++ b/lib/plasmic-init.ts @@ -0,0 +1,17 @@ +import { initPlasmicLoader } from "@plasmicapp/loader-nextjs"; +import * as NextNavigation from "next/navigation"; +import "../app/globals.css"; + +export const PLASMIC = initPlasmicLoader({ + nextNavigation: NextNavigation, + projects: [ + { + id: "qkC7iPT4FekKxstnmsJETj", // ID of a project you are using + token: + "vmtH2Z3Rp0OnR9TF3vU3SC3PRMyM2L3PYLJJrpl063nCqxlQLYUyC1U7tWcLnZmCMRzGQd5bhfsqUPiusIyA", // API token for that project + }, + ], + // Fetches the latest revisions, whether or not they were unpublished! + // Disable for production to ensure you render only published changes. + preview: true, +}); diff --git a/lib/utils-client.ts b/lib/utils-client.ts new file mode 100644 index 00000000..b63add25 --- /dev/null +++ b/lib/utils-client.ts @@ -0,0 +1,20 @@ +import { isValidElement } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; + +// ideally, if the text content of the passed component was passed in as part of prop chain, we could extract +// it without having to render. However, Plasmic stores all content separately and places references in the props +// and reaches out to the DB to collect content during rendering. +// So, this is the only simple way to extract the content. Alternatively, we would have to reimplement a bunch of things that Plasmic handles +export function innerText(component: string | undefined | React.ReactElement) { + if (!component || typeof component == "string") return component; + if (isValidElement(component)) { + const el = document.createElement("div"); + const root = createRoot(el); + flushSync(() => { + root.render(component); + }); + return el.innerText; + } + throw new Error("unknown input type. Expected string or ReactElement"); +} diff --git a/lib/utils-server.ts b/lib/utils-server.ts new file mode 100644 index 00000000..3a79c0ec --- /dev/null +++ b/lib/utils-server.ts @@ -0,0 +1,64 @@ +import { isValidElement } from "react"; +import { renderToString } from "react-dom/server"; + +// The following would work on the server but we cannot use react - dom / server on the client + +export function innerTextServer( + component: string | undefined | React.ReactElement +) { + if (!component || typeof component == "string") return component; + if (isValidElement(component)) { + const html = renderToString(component); + return stripHtml(html); + } + throw new Error("unknown input type. Expected string or ReactElement"); +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +/** Very small “poor-man’s” HTML-to-text converter. */ +function stripHtml(html: string): string { + const text = html + // remove comments,