diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 794b6c4..7362b45 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,6 +31,7 @@ jobs: (cd ./packages/block-text;pwd;npm ci) (cd ./packages/document-core;pwd;npm ci) (cd ./packages/editor-sample;pwd;npm ci) + (cd ./packages/email-builder;pwd;npm ci) - run: npx eslint . - run: npx prettier . --check - run: npm test diff --git a/packages/email-builder/.npmignore b/packages/email-builder/.npmignore new file mode 100644 index 0000000..564b640 --- /dev/null +++ b/packages/email-builder/.npmignore @@ -0,0 +1,9 @@ +.editorconfig +.envrc +.eslintignore +.eslintrc.json +.prettierrc +jest.config.ts +src +tests +tsconfig.json \ No newline at end of file diff --git a/packages/email-builder/LICENSE b/packages/email-builder/LICENSE new file mode 100644 index 0000000..5a09b83 --- /dev/null +++ b/packages/email-builder/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 Carlos Rodriguez-Rosario + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/email-builder/README.md b/packages/email-builder/README.md new file mode 100644 index 0000000..dec98dd --- /dev/null +++ b/packages/email-builder/README.md @@ -0,0 +1 @@ +# usewaypoint/email-builder diff --git a/packages/email-builder/package-lock.json b/packages/email-builder/package-lock.json new file mode 100644 index 0000000..1fefc1a --- /dev/null +++ b/packages/email-builder/package-lock.json @@ -0,0 +1,168 @@ +{ + "name": "@usewaypoint/email-builder", + "version": "0.0.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@usewaypoint/email-builder", + "version": "0.0.2", + "license": "MIT", + "dependencies": { + "@usewaypoint/block-avatar": "^0.0.1", + "@usewaypoint/block-button": "^0.0.2", + "@usewaypoint/block-columns-container": "^0.0.2", + "@usewaypoint/block-container": "^0.0.1", + "@usewaypoint/block-divider": "^0.0.3", + "@usewaypoint/block-heading": "^0.0.2", + "@usewaypoint/block-html": "^0.0.2", + "@usewaypoint/block-image": "^0.0.4", + "@usewaypoint/block-spacer": "^0.0.2", + "@usewaypoint/block-text": "^0.0.2", + "@usewaypoint/document-core": "^0.0.4" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + } + }, + "node_modules/@usewaypoint/block-avatar": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@usewaypoint/block-avatar/-/block-avatar-0.0.1.tgz", + "integrity": "sha512-RHpynXK6iLbejoZN8k+OXIaJGU22LZWHWcW9nEdsHwPaHxSbwh7UPcBrTntC5zRqMYDZlF7u3iYpSg02O4bxRQ==", + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + } + }, + "node_modules/@usewaypoint/block-button": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@usewaypoint/block-button/-/block-button-0.0.2.tgz", + "integrity": "sha512-WzWlJoJBiVfI3Iak9JPey7u/hJIkxXuhuAI6Y10ef795Panyr1GxQbdH2//fZtajjGJ1SjJStXpz6nPEd8wArg==", + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + } + }, + "node_modules/@usewaypoint/block-columns-container": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@usewaypoint/block-columns-container/-/block-columns-container-0.0.2.tgz", + "integrity": "sha512-W5rstBOb/uLJ0L43zilUbQEoc+H+2jA8L8RVlCt4unvZsaqYGdcyaqZ6bs6qpeHjB2LtzLvFEcaqSuevhg3uRQ==", + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + } + }, + "node_modules/@usewaypoint/block-container": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@usewaypoint/block-container/-/block-container-0.0.1.tgz", + "integrity": "sha512-DsxXVVKLWv86CbQhCAbnvRDe/wJ0sj5XNma5hb0l1p7YqK42NVxKCRPF1Bw6m2WhEcXZFbz+8CIxHqjo6p1QIw==", + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + } + }, + "node_modules/@usewaypoint/block-divider": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@usewaypoint/block-divider/-/block-divider-0.0.3.tgz", + "integrity": "sha512-ZtjBWVakxUg+YbI0yWIX/TO0mmj8pjSUqK/o6Je5bx+rfETXaWlxRTwhZGWMNnYeY1sTjIlcbg2zGgdmmgxvXg==", + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + } + }, + "node_modules/@usewaypoint/block-heading": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@usewaypoint/block-heading/-/block-heading-0.0.2.tgz", + "integrity": "sha512-l5tCcyfYAp+sta93ZXs1pmz+F/3W6XP2aiyAr/6uVpV2mwL7MloWUlpHRzwZEEyIW/jIJwYkNexabRLrxK181A==", + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + } + }, + "node_modules/@usewaypoint/block-html": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@usewaypoint/block-html/-/block-html-0.0.2.tgz", + "integrity": "sha512-QEaMF1DRk+MgJiC8s4TfQe4eBpJghzriIPyS/+hxRVAiDwbOmRencaPRZ4JpDBi34sVNwLvRJZa5KvUFN4w6hg==", + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + } + }, + "node_modules/@usewaypoint/block-image": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@usewaypoint/block-image/-/block-image-0.0.4.tgz", + "integrity": "sha512-QNslGaV4zrgXj+VtbAktT7QBUxoelIyDMOgQtF8HsZzkfl66S28MtwwofodQpzKRtUrtegQ9o9hpL5zgjQUv0w==", + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + } + }, + "node_modules/@usewaypoint/block-spacer": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@usewaypoint/block-spacer/-/block-spacer-0.0.2.tgz", + "integrity": "sha512-pH+QFmE2e0ULZeEGwh70qOtSKBdvmq/yue3IVPzx05fHymjr3fV/os1eSgEN2SRCcOC02wu3MvFX5LbV+vz4sQ==", + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + } + }, + "node_modules/@usewaypoint/block-text": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@usewaypoint/block-text/-/block-text-0.0.2.tgz", + "integrity": "sha512-iX4hxq/Rql5syTuXQYB3BGm6vZDKns83FUHH66y06pQuSLPR7i0YvokOLm2Ez96S+i6NOh3XVTg/wcx2Hg2FgA==", + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + } + }, + "node_modules/@usewaypoint/document-core": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@usewaypoint/document-core/-/document-core-0.0.4.tgz", + "integrity": "sha512-xlPM5zexUhuRDsSwPVZ8cvemmsffLDb4wxlaAVMXi4ayO7ZHHrc9SiLiBkR8cnfQ3OvnHlZaO8YDsGmnckGqYw==", + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "peer": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/email-builder/package.json b/packages/email-builder/package.json new file mode 100644 index 0000000..cdac964 --- /dev/null +++ b/packages/email-builder/package.json @@ -0,0 +1,33 @@ +{ + "name": "@usewaypoint/email-builder", + "version": "0.0.2", + "description": "React component to render email messages", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "target": "ES2022", + "files": [ + "dist" + ], + "scripts": { + "build": "npx tsc" + }, + "author": "carlos@usewaypoint.com", + "license": "MIT", + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "zod": "^1 || ^2 || ^3" + }, + "dependencies": { + "@usewaypoint/block-avatar": "^0.0.1", + "@usewaypoint/block-button": "^0.0.2", + "@usewaypoint/block-columns-container": "^0.0.2", + "@usewaypoint/block-container": "^0.0.1", + "@usewaypoint/block-divider": "^0.0.3", + "@usewaypoint/block-heading": "^0.0.2", + "@usewaypoint/block-html": "^0.0.2", + "@usewaypoint/block-image": "^0.0.4", + "@usewaypoint/block-spacer": "^0.0.2", + "@usewaypoint/block-text": "^0.0.2", + "@usewaypoint/document-core": "^0.0.4" + } +} diff --git a/packages/email-builder/src/Reader/core.tsx b/packages/email-builder/src/Reader/core.tsx new file mode 100644 index 0000000..7a98b23 --- /dev/null +++ b/packages/email-builder/src/Reader/core.tsx @@ -0,0 +1,100 @@ +import React, { createContext, useContext } from 'react'; +import { z } from 'zod'; + +import { Avatar, AvatarPropsSchema } from '@usewaypoint/block-avatar'; +import { Button, ButtonPropsSchema } from '@usewaypoint/block-button'; +import { Divider, DividerPropsSchema } from '@usewaypoint/block-divider'; +import { Heading, HeadingPropsSchema } from '@usewaypoint/block-heading'; +import { Html, HtmlPropsSchema } from '@usewaypoint/block-html'; +import { Image, ImagePropsSchema } from '@usewaypoint/block-image'; +import { Spacer, SpacerPropsSchema } from '@usewaypoint/block-spacer'; +import { Text, TextPropsSchema } from '@usewaypoint/block-text'; +import { + buildBlockComponent, + buildBlockConfigurationDictionary, + buildBlockConfigurationSchema, +} from '@usewaypoint/document-core'; + +import ColumnsContainerPropsSchema from '../blocks/ColumnsContainer/ColumnsContainerPropsSchema'; +import ColumnsContainerReader from '../blocks/ColumnsContainer/ColumnsContainerReader'; +import { ContainerPropsSchema } from '../blocks/Container/ContainerPropsSchema'; +import ContainerReader from '../blocks/Container/ContainerReader'; +import { EmailLayoutPropsSchema } from '../blocks/EmailLayout/EmailLayoutPropsSchema'; +import EmailLayoutReader from '../blocks/EmailLayout/EmailLayoutReader'; + +const ReaderContext = createContext({}); + +function useReaderDocument() { + return useContext(ReaderContext); +} + +const READER_DICTIONARY = buildBlockConfigurationDictionary({ + ColumnsContainer: { + schema: ColumnsContainerPropsSchema, + Component: ColumnsContainerReader, + }, + Container: { + schema: ContainerPropsSchema, + Component: ContainerReader, + }, + EmailLayout: { + schema: EmailLayoutPropsSchema, + Component: EmailLayoutReader, + }, + // + Avatar: { + schema: AvatarPropsSchema, + Component: Avatar, + }, + Button: { + schema: ButtonPropsSchema, + Component: Button, + }, + Divider: { + schema: DividerPropsSchema, + Component: Divider, + }, + Heading: { + schema: HeadingPropsSchema, + Component: Heading, + }, + Html: { + schema: HtmlPropsSchema, + Component: Html, + }, + Image: { + schema: ImagePropsSchema, + Component: Image, + }, + Spacer: { + schema: SpacerPropsSchema, + Component: Spacer, + }, + Text: { + schema: TextPropsSchema, + Component: Text, + }, +}); + +const ReaderBlockSchema = buildBlockConfigurationSchema(READER_DICTIONARY); +export const ReaderDocumentSchema = z.record(z.string(), ReaderBlockSchema); + +const BaseReaderBlock = buildBlockComponent(READER_DICTIONARY); + +export type TReaderBlockProps = { id: string }; +export function ReaderBlock({ id }: TReaderBlockProps) { + const document = useReaderDocument(); + return ; +} +export type TReaderDocument = Record>; +export type TReaderProps = { + document: Record>; + rootBlockId: string; +}; +export default function Reader({ document, rootBlockId }: TReaderProps) { + return ( + + + + ); +} diff --git a/packages/email-builder/src/blocks/ColumnsContainer/ColumnsContainerPropsSchema.ts b/packages/email-builder/src/blocks/ColumnsContainer/ColumnsContainerPropsSchema.ts new file mode 100644 index 0000000..5ea3185 --- /dev/null +++ b/packages/email-builder/src/blocks/ColumnsContainer/ColumnsContainerPropsSchema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { ColumnsContainerPropsSchema as BaseColumnsContainerPropsSchema } from '@usewaypoint/block-columns-container'; + +const BasePropsShape = BaseColumnsContainerPropsSchema.shape.props.unwrap().unwrap().shape; + +const ColumnsContainerPropsSchema = z.object({ + style: BaseColumnsContainerPropsSchema.shape.style, + props: z + .object({ + ...BasePropsShape, + columns: z.tuple([ + z.object({ childrenIds: z.array(z.string()) }), + z.object({ childrenIds: z.array(z.string()) }), + z.object({ childrenIds: z.array(z.string()) }), + ]), + }) + .optional() + .nullable(), +}); + +export default ColumnsContainerPropsSchema; +export type ColumnsContainerProps = z.infer; diff --git a/packages/email-builder/src/blocks/ColumnsContainer/ColumnsContainerReader.tsx b/packages/email-builder/src/blocks/ColumnsContainer/ColumnsContainerReader.tsx new file mode 100644 index 0000000..4310faf --- /dev/null +++ b/packages/email-builder/src/blocks/ColumnsContainer/ColumnsContainerReader.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { ColumnsContainer as BaseColumnsContainer } from '@usewaypoint/block-columns-container'; + +import { ReaderBlock } from '../../Reader/core'; + +import { ColumnsContainerProps } from './ColumnsContainerPropsSchema'; + +export default function ColumnsContainerReader({ style, props }: ColumnsContainerProps) { + const { columns, ...restProps } = props ?? {}; + let cols = undefined; + if (columns) { + cols = columns.map((col) => col.childrenIds.map((childId) => )); + } + + return ; +} diff --git a/packages/email-builder/src/blocks/Container/ContainerPropsSchema.tsx b/packages/email-builder/src/blocks/Container/ContainerPropsSchema.tsx new file mode 100644 index 0000000..3e353df --- /dev/null +++ b/packages/email-builder/src/blocks/Container/ContainerPropsSchema.tsx @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { ContainerPropsSchema as BaseContainerPropsSchema } from '@usewaypoint/block-container'; + +export const ContainerPropsSchema = z.object({ + style: BaseContainerPropsSchema.shape.style, + props: z + .object({ + childrenIds: z.array(z.string()).optional().nullable(), + }) + .optional() + .nullable(), +}); + +export type ContainerProps = z.infer; diff --git a/packages/email-builder/src/blocks/Container/ContainerReader.tsx b/packages/email-builder/src/blocks/Container/ContainerReader.tsx new file mode 100644 index 0000000..7925e3d --- /dev/null +++ b/packages/email-builder/src/blocks/Container/ContainerReader.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { Container as BaseContainer } from '@usewaypoint/block-container'; + +import { ReaderBlock } from '../../Reader/core'; + +import { ContainerProps } from './ContainerPropsSchema'; + +export default function ContainerReader({ style, props }: ContainerProps) { + const childrenIds = props?.childrenIds ?? []; + return ( + + {childrenIds.map((childId) => ( + + ))} + + ); +} diff --git a/packages/email-builder/src/blocks/EmailLayout/EmailLayoutPropsSchema.tsx b/packages/email-builder/src/blocks/EmailLayout/EmailLayoutPropsSchema.tsx new file mode 100644 index 0000000..d06e5da --- /dev/null +++ b/packages/email-builder/src/blocks/EmailLayout/EmailLayoutPropsSchema.tsx @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +const COLOR_SCHEMA = z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .nullable() + .optional(); + +const FONT_FAMILY_SCHEMA = z + .enum([ + 'MODERN_SANS', + 'BOOK_SANS', + 'ORGANIC_SANS', + 'GEOMETRIC_SANS', + 'HEAVY_SANS', + 'ROUNDED_SANS', + 'MODERN_SERIF', + 'BOOK_SERIF', + 'MONOSPACE', + ]) + .nullable() + .optional(); + +export const EmailLayoutPropsSchema = z.object({ + backdropColor: COLOR_SCHEMA, + canvasColor: COLOR_SCHEMA, + textColor: COLOR_SCHEMA, + fontFamily: FONT_FAMILY_SCHEMA, + childrenIds: z.array(z.string()).optional().nullable(), +}); + +export type EmailLayoutProps = z.infer; diff --git a/packages/email-builder/src/blocks/EmailLayout/EmailLayoutReader.tsx b/packages/email-builder/src/blocks/EmailLayout/EmailLayoutReader.tsx new file mode 100644 index 0000000..d10d428 --- /dev/null +++ b/packages/email-builder/src/blocks/EmailLayout/EmailLayoutReader.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import { ReaderBlock } from '../../Reader/core'; + +import { EmailLayoutProps } from './EmailLayoutPropsSchema'; + +function getFontFamily(fontFamily: EmailLayoutProps['fontFamily']) { + const f = fontFamily ?? 'MODERN_SANS'; + switch (f) { + case 'MODERN_SANS': + return '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif'; + case 'BOOK_SANS': + return 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif'; + case 'ORGANIC_SANS': + return 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif'; + case 'GEOMETRIC_SANS': + return 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif'; + case 'HEAVY_SANS': + return 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif'; + case 'ROUNDED_SANS': + return 'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif'; + case 'MODERN_SERIF': + return 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif'; + case 'BOOK_SERIF': + return '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif'; + case 'MONOSPACE': + return '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace'; + } +} + +export default function EmailLayoutReader(props: EmailLayoutProps) { + const childrenIds = props.childrenIds ?? []; + return ( +
+ + + + + + +
+ {childrenIds.map((childId) => ( + + ))} +
+
+ ); +} diff --git a/packages/email-builder/tsconfig.build.json b/packages/email-builder/tsconfig.build.json new file mode 100644 index 0000000..9dabb8e --- /dev/null +++ b/packages/email-builder/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["tests/**/*.spec.ts", "tests/**/*.spec.tsx", "jest.config.ts"] +} diff --git a/packages/email-builder/tsconfig.json b/packages/email-builder/tsconfig.json new file mode 100644 index 0000000..efac89d --- /dev/null +++ b/packages/email-builder/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "es2015", + "module": "esnext", + "outDir": "dist" + }, + "exclude": ["dist"] +}