chore: adds front-end to ecommerce template (#2942)
This commit is contained in:
@@ -1,8 +1,15 @@
|
|||||||
|
# Payload vars
|
||||||
PORT=8000
|
PORT=8000
|
||||||
MONGODB_URI=mongodb://localhost/template-ecommerce
|
MONGODB_URI=mongodb://localhost/payload-template-ecommerce
|
||||||
PAYLOAD_SECRET=712kjbkuh87234sflj98713b
|
PAYLOAD_SECRET=712kjbkuh87234sflj98713b
|
||||||
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:8000
|
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:8000
|
||||||
PAYLOAD_PUBLIC_SITE_URL=http://localhost:3000
|
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
PAYLOAD_PUBLIC_STRIPE_IS_TEST_KEY=true
|
PAYLOAD_PUBLIC_STRIPE_IS_TEST_KEY=true
|
||||||
STRIPE_WEBHOOKS_ENDPOINT_SECRET=
|
STRIPE_WEBHOOKS_ENDPOINT_SECRET=
|
||||||
|
|
||||||
|
# Next.js vars
|
||||||
|
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_SERVER_URL=http://localhost:8000
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
|
NEXT_PUBLIC_IS_LIVE=
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
extends: ['@payloadcms'],
|
extends: ['plugin:@next/next/recommended', '@payloadcms'],
|
||||||
ignorePatterns: ['**/payload-types.ts'],
|
ignorePatterns: ['**/payload-types.ts'],
|
||||||
|
plugins: ['prettier'],
|
||||||
}
|
}
|
||||||
|
|||||||
2
templates/ecommerce/.gitignore
vendored
2
templates/ecommerce/.gitignore
vendored
@@ -4,3 +4,5 @@ dist
|
|||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
.next
|
||||||
|
.vercel
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Core features:
|
|||||||
- [Paywall](#paywall)
|
- [Paywall](#paywall)
|
||||||
- [Layout Builder](#layout-builder)
|
- [Layout Builder](#layout-builder)
|
||||||
- [SEO](#seo)
|
- [SEO](#seo)
|
||||||
|
- [Front-end](#front-end)
|
||||||
|
|
||||||
For details on how to get this template up and running locally, see the [development](#development) section.
|
For details on how to get this template up and running locally, see the [development](#development) section.
|
||||||
|
|
||||||
@@ -152,6 +153,32 @@ Products and pages can be built using a powerful layout builder. This allows you
|
|||||||
|
|
||||||
This template comes pre-configured with the official [Payload SEO Plugin](https://github.com/payloadcms/plugin-seo) for complete SEO control.
|
This template comes pre-configured with the official [Payload SEO Plugin](https://github.com/payloadcms/plugin-seo) for complete SEO control.
|
||||||
|
|
||||||
|
## Front-end
|
||||||
|
|
||||||
|
This template includes a fully-working [Next.js](https://nextjs.org) front-end that is served alongside your Payload app in a single Express server. This makes is so that you can deploy both apps simultaneously and host them together. If you prefer a different front-end framework, this pattern works for any framework that supports a custom server. You can easily [Eject](#eject) the front-end out from this template to swap in your own or to use it as a standalone CMS. For more details, see the official [Custom Server Example](https://github.com/payloadcms/payload/tree/master/examples/custom-server).
|
||||||
|
|
||||||
|
Core features:
|
||||||
|
|
||||||
|
- [Next.js](https://nextjs.org), [GraphQL](https://graphql.org), [TypeScript](https://www.typescriptlang.org)
|
||||||
|
- Complete authentication workflow
|
||||||
|
- Complete shopping experience
|
||||||
|
- Full shopping cart implementation
|
||||||
|
- Full checkout workflow
|
||||||
|
- Account dashboard
|
||||||
|
- Pre-made layout building blocks
|
||||||
|
- [Payload Admin Bar](https://github.com/payloadcms/payload-admin-bar)
|
||||||
|
- Complete SEO configuration
|
||||||
|
- Working Stripe integration
|
||||||
|
- Conditionally rendered paywall content
|
||||||
|
|
||||||
|
### Eject
|
||||||
|
|
||||||
|
If you prefer another front-end framework or would like to use Payload as a standalone CMS, you can easily eject the front-end from this template. To eject, simply run `yarn eject`. This will uninstall all Next.js related dependencies and delete all files and folders related to the Next.js front-end. It also removes all custom routing from your `server.ts` file and updates your `eslintrc.js`.
|
||||||
|
|
||||||
|
> Note: Your eject script may not work as expected if you've made significant modifications to your project. If you run into any issues, compare your project's dependencies and file structure with this template, see [./src/eject](./src/eject) for full details.
|
||||||
|
|
||||||
|
For more details on how setup a custom server, see the official [Custom Server Example](https://github.com/payloadcms/payload/tree/master/examples/custom-server).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
To spin up the template locally, follow these steps:
|
To spin up the template locally, follow these steps:
|
||||||
@@ -159,7 +186,7 @@ To spin up the template locally, follow these steps:
|
|||||||
1. First clone the repo
|
1. First clone the repo
|
||||||
1. Then `cd YOUR_PROJECT_REPO && cp .env.example .env`
|
1. Then `cd YOUR_PROJECT_REPO && cp .env.example .env`
|
||||||
1. Next `yarn && yarn dev` (or `docker-compose up`, see [Docker](#docker))
|
1. Next `yarn && yarn dev` (or `docker-compose up`, see [Docker](#docker))
|
||||||
1. Now `open http://localhost:8000/admin` to access the admin panel
|
1. Now `open http://localhost:3000/admin` to access the admin panel
|
||||||
1. Create your first admin user using the form on the page
|
1. Create your first admin user using the form on the page
|
||||||
|
|
||||||
That's it! Changes made in `./src` will be reflected in your app—but your database is blank and your app is not yet connected to Stripe, more details on that [here](#stripe). You can optionally seed the database with a few products and pages, more details on that [here](#seed).
|
That's it! Changes made in `./src` will be reflected in your app—but your database is blank and your app is not yet connected to Stripe, more details on that [here](#stripe). You can optionally seed the database with a few products and pages, more details on that [here](#seed).
|
||||||
@@ -198,7 +225,7 @@ That's it! The Docker instance will help you get up and running quickly while al
|
|||||||
|
|
||||||
### Seed
|
### Seed
|
||||||
|
|
||||||
To seed the database with a few products and pages you can run `yarn seed`.
|
To seed the database with a few products and pages you can run `yarn seed`. This template also comes with a `/api/seed` endpoint you can use to seed the database from the admin panel.
|
||||||
|
|
||||||
> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data.
|
> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data.
|
||||||
|
|
||||||
|
|||||||
60
templates/ecommerce/eject.ts
Normal file
60
templates/ecommerce/eject.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// Run this script to eject the front-end from this template
|
||||||
|
// This will remove all template-specific files and directories
|
||||||
|
// See `yarn eject` in `package.json` for the exact command
|
||||||
|
// See `./README.md#eject` for more information
|
||||||
|
|
||||||
|
const files = ['./next.config.js', './next-env.d.ts']
|
||||||
|
const directories = ['./src/pages', './src/public', './src/graphql', './src/css', './src/providers']
|
||||||
|
|
||||||
|
const eject = async (): Promise<void> => {
|
||||||
|
files.forEach(file => {
|
||||||
|
fs.unlinkSync(path.join(__dirname, file))
|
||||||
|
})
|
||||||
|
|
||||||
|
directories.forEach(directory => {
|
||||||
|
fs.rm(path.join(__dirname, directory), { recursive: true }, err => {
|
||||||
|
if (err) throw err
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// remove all components EXCEPT any Payload ones
|
||||||
|
const payloadComponents = ['BeforeDashboard']
|
||||||
|
const components = fs.readdirSync(path.join(__dirname, './src/components'))
|
||||||
|
components.forEach(component => {
|
||||||
|
if (!payloadComponents.includes(component)) {
|
||||||
|
fs.rm(path.join(__dirname, `./src/components/${component}`), { recursive: true }, err => {
|
||||||
|
if (err) throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// remove all blocks EXCEPT the associated Payload configs (`index.ts`)
|
||||||
|
const blocks = fs.readdirSync(path.join(__dirname, './src/blocks'))
|
||||||
|
blocks.forEach(block => {
|
||||||
|
const blockFiles = fs.readdirSync(path.join(__dirname, `./src/blocks/${block}`))
|
||||||
|
blockFiles.forEach(file => {
|
||||||
|
if (file !== 'index.ts') {
|
||||||
|
fs.rm(path.join(__dirname, `./src/blocks/${block}/${file}`), err => {
|
||||||
|
if (err) throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// create a new `./src/server.ts` file
|
||||||
|
// use contents from `./src/server.default.ts`
|
||||||
|
const serverFile = path.join(__dirname, './src/server.ts')
|
||||||
|
const serverDefaultFile = path.join(__dirname, './src/server.default.ts')
|
||||||
|
fs.copyFileSync(serverDefaultFile, serverFile)
|
||||||
|
|
||||||
|
// remove `'plugin:@next/next/recommended', ` from `./.eslintrc.js`
|
||||||
|
const eslintConfigFile = path.join(__dirname, './.eslintrc.js')
|
||||||
|
const eslintConfig = fs.readFileSync(eslintConfigFile, 'utf8')
|
||||||
|
const updatedEslintConfig = eslintConfig.replace(`'plugin:@next/next/recommended', `, '')
|
||||||
|
fs.writeFileSync(eslintConfigFile, updatedEslintConfig, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
eject()
|
||||||
5
templates/ecommerce/next-env.d.ts
vendored
Normal file
5
templates/ecommerce/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
34
templates/ecommerce/next.config.js
Normal file
34
templates/ecommerce/next.config.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
swcMinify: true,
|
||||||
|
images: {
|
||||||
|
domains: ['localhost', process.env.NEXT_PUBLIC_SERVER_URL],
|
||||||
|
// remotePatterns: [
|
||||||
|
// {
|
||||||
|
// protocol: 'https',
|
||||||
|
// hostname: 'localhost',
|
||||||
|
// port: '3000',
|
||||||
|
// pathname: '/media/**',
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
},
|
||||||
|
async headers() {
|
||||||
|
const headers = []
|
||||||
|
|
||||||
|
if (!process.env.NEXT_PUBLIC_IS_LIVE) {
|
||||||
|
headers.push({
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'X-Robots-Tag',
|
||||||
|
value: 'noindex',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source: '/:path*',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"ext": "ts",
|
"watch": ["server.ts"],
|
||||||
"exec": "ts-node src/server.ts"
|
"exec": "ts-node --project tsconfig.server.json src/server.ts",
|
||||||
|
"ext": "js ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
"stripe:webhooks": "stripe listen --forward-to localhost:8000/stripe/webhooks",
|
"stripe:webhooks": "stripe listen --forward-to localhost:8000/stripe/webhooks",
|
||||||
"seed": "rm -rf media && cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts ts-node src/server.ts",
|
"seed": "rm -rf media && cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts ts-node src/server.ts",
|
||||||
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
|
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
|
||||||
"build:server": "tsc",
|
"build:server": "tsc --project tsconfig.server.json",
|
||||||
"build": "yarn copyfiles && yarn build:payload && yarn build:server",
|
"build:next": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NEXT_BUILD=true node dist/server.js",
|
||||||
|
"build": "cross-env NODE_ENV=production yarn build:payload && yarn build:server && yarn copyfiles && yarn build:next",
|
||||||
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
|
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
|
||||||
|
"eject": "yarn remove next react react-dom @apollo/client apollo-link-http @next/eslint-plugin-next && ts-node eject.ts",
|
||||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
|
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
|
||||||
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
|
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
|
||||||
"generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema",
|
"generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema",
|
||||||
@@ -19,17 +21,28 @@
|
|||||||
"lint:fix": "eslint --fix --ext .ts,.tsx src"
|
"lint:fix": "eslint --fix --ext .ts,.tsx src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@apollo/client": "^3.7.16",
|
||||||
|
"@faceless-ui/css-grid": "^1.2.0",
|
||||||
"@payloadcms/plugin-cloud": "^2.0.0",
|
"@payloadcms/plugin-cloud": "^2.0.0",
|
||||||
"@payloadcms/plugin-nested-docs": "^1.0.4",
|
"@payloadcms/plugin-nested-docs": "^1.0.4",
|
||||||
"@payloadcms/plugin-seo": "^1.0.10",
|
"@payloadcms/plugin-seo": "^1.0.10",
|
||||||
"@payloadcms/plugin-stripe": "^0.0.13",
|
"@payloadcms/plugin-stripe": "^0.0.13",
|
||||||
|
"@stripe/react-stripe-js": "^1.16.3",
|
||||||
|
"@stripe/stripe-js": "^1.46.0",
|
||||||
|
"apollo-link-http": "^1.5.17",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
"eslint-import-resolver-alias": "^1.1.2",
|
"eslint-import-resolver-alias": "^1.1.2",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
|
"next": "^13.4.7",
|
||||||
"payload": "^1.8.2",
|
"payload": "^1.8.2",
|
||||||
"stripe": "^11.6.0"
|
"payload-admin-bar": "^1.0.6",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.45.1",
|
||||||
|
"stripe": "^10.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@next/eslint-plugin-next": "^13.1.6",
|
||||||
"@payloadcms/eslint-config": "^0.0.1",
|
"@payloadcms/eslint-config": "^0.0.1",
|
||||||
"@types/express": "^4.17.9",
|
"@types/express": "^4.17.9",
|
||||||
"@types/node": "18.11.3",
|
"@types/node": "18.11.3",
|
||||||
@@ -47,7 +60,7 @@
|
|||||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||||
"nodemon": "^2.0.6",
|
"nodemon": "^2.0.6",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.7.1",
|
||||||
"ts-node": "^9.1.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^4.8.4"
|
"typescript": "^4.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { AccessArgs } from 'payload/config'
|
|||||||
import { checkRole } from '../collections/Users/checkRole'
|
import { checkRole } from '../collections/Users/checkRole'
|
||||||
import type { User } from '../payload-types'
|
import type { User } from '../payload-types'
|
||||||
|
|
||||||
type isAdmin = (args: AccessArgs<any, User>) => boolean
|
type isAdmin = (args: AccessArgs<unknown, User>) => boolean
|
||||||
|
|
||||||
export const admins: isAdmin = ({ req: { user } }) => {
|
export const admins: isAdmin = ({ req: { user } }) => {
|
||||||
return checkRole(['admin'], user)
|
return checkRole(['admin'], user)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@import '../../css/common';
|
||||||
|
|
||||||
|
.archiveBlock {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.introContent {
|
||||||
|
margin-bottom: calc(var(--base) * 2);
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin-bottom: calc(var(--base) * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
templates/ecommerce/src/blocks/ArchiveBlock/index.tsx
Normal file
49
templates/ecommerce/src/blocks/ArchiveBlock/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Cell, Grid } from '@faceless-ui/css-grid'
|
||||||
|
|
||||||
|
import { CollectionArchive } from '../../components/CollectionArchive'
|
||||||
|
import { Gutter } from '../../components/Gutter'
|
||||||
|
import RichText from '../../components/RichText'
|
||||||
|
import { ArchiveBlockProps } from './types'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const ArchiveBlock: React.FC<
|
||||||
|
ArchiveBlockProps & {
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
> = props => {
|
||||||
|
const {
|
||||||
|
introContent,
|
||||||
|
id,
|
||||||
|
relationTo,
|
||||||
|
populateBy,
|
||||||
|
limit,
|
||||||
|
populatedDocs,
|
||||||
|
populatedDocsTotal,
|
||||||
|
categories,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id={`block-${id}`} className={classes.archiveBlock}>
|
||||||
|
{introContent && (
|
||||||
|
<Gutter className={classes.introContent}>
|
||||||
|
<Grid>
|
||||||
|
<Cell cols={12} colsM={8}>
|
||||||
|
<RichText content={introContent} />
|
||||||
|
</Cell>
|
||||||
|
</Grid>
|
||||||
|
</Gutter>
|
||||||
|
)}
|
||||||
|
<CollectionArchive
|
||||||
|
populateBy={populateBy}
|
||||||
|
relationTo={relationTo}
|
||||||
|
populatedDocs={populatedDocs}
|
||||||
|
populatedDocsTotal={populatedDocsTotal}
|
||||||
|
categories={categories}
|
||||||
|
limit={limit}
|
||||||
|
sort="-publishedDate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
templates/ecommerce/src/blocks/ArchiveBlock/types.ts
Normal file
3
templates/ecommerce/src/blocks/ArchiveBlock/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { Page } from '../../payload-types'
|
||||||
|
|
||||||
|
export type ArchiveBlockProps = Extract<Page['layout'][0], { blockType: 'archive' }>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
@use '../../css/queries.scss' as *;
|
||||||
|
|
||||||
|
$spacer-h: calc(var(--block-padding) / 2);
|
||||||
|
|
||||||
|
.callToAction {
|
||||||
|
padding-left: $spacer-h;
|
||||||
|
padding-right: $spacer-h;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background--white {
|
||||||
|
background-color: var(--color-base-1000);
|
||||||
|
color: var(--color-base-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: calc(var(--base) / 2);
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
padding-top: 12px
|
||||||
|
}
|
||||||
|
}
|
||||||
44
templates/ecommerce/src/blocks/CallToAction/index.tsx
Normal file
44
templates/ecommerce/src/blocks/CallToAction/index.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Cell, Grid } from '@faceless-ui/css-grid'
|
||||||
|
|
||||||
|
import { BackgroundColor } from '../../components/BackgroundColor'
|
||||||
|
import { Gutter } from '../../components/Gutter'
|
||||||
|
import { CMSLink } from '../../components/Link'
|
||||||
|
import RichText from '../../components/RichText'
|
||||||
|
import { VerticalPadding } from '../../components/VerticalPadding'
|
||||||
|
import { Page } from '../../payload-types'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
type Props = Extract<Page['layout'][0], { blockType: 'cta' }>
|
||||||
|
|
||||||
|
export const CallToActionBlock: React.FC<
|
||||||
|
Props & {
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
> = ({ ctaBackgroundColor, links, richText }) => {
|
||||||
|
const oppositeBackgroundColor = ctaBackgroundColor === 'white' ? 'black' : 'white'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Gutter>
|
||||||
|
<BackgroundColor color={oppositeBackgroundColor}>
|
||||||
|
<VerticalPadding className={classes.callToAction}>
|
||||||
|
<Grid>
|
||||||
|
<Cell cols={8} colsL={7} colsM={12}>
|
||||||
|
<div>
|
||||||
|
<RichText className={classes.richText} content={richText} />
|
||||||
|
</div>
|
||||||
|
</Cell>
|
||||||
|
<Cell start={10} cols={3} startL={9} colsL={4} startM={1} colsM={12}>
|
||||||
|
<div className={classes.linkGroup}>
|
||||||
|
{(links || []).map(({ link }, i) => {
|
||||||
|
return <CMSLink key={i} {...link} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Cell>
|
||||||
|
</Grid>
|
||||||
|
</VerticalPadding>
|
||||||
|
</BackgroundColor>
|
||||||
|
</Gutter>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
templates/ecommerce/src/blocks/Content/index.module.scss
Normal file
13
templates/ecommerce/src/blocks/Content/index.module.scss
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@import '../../css/common';
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
row-gap: calc(var(--base) * 2) !important;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
row-gap: var(--base) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
margin-top: var(--base);
|
||||||
|
}
|
||||||
45
templates/ecommerce/src/blocks/Content/index.tsx
Normal file
45
templates/ecommerce/src/blocks/Content/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Cell, Grid } from '@faceless-ui/css-grid'
|
||||||
|
|
||||||
|
import { Gutter } from '../../components/Gutter'
|
||||||
|
import { CMSLink } from '../../components/Link'
|
||||||
|
import RichText from '../../components/RichText'
|
||||||
|
import { Page } from '../../payload-types'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
type Props = Extract<Page['layout'][0], { blockType: 'content' }>
|
||||||
|
|
||||||
|
export const ContentBlock: React.FC<
|
||||||
|
Props & {
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
> = props => {
|
||||||
|
const { columns } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Gutter className={classes.content}>
|
||||||
|
<Grid className={classes.grid}>
|
||||||
|
{columns &&
|
||||||
|
columns.length > 0 &&
|
||||||
|
columns.map((col, index) => {
|
||||||
|
const { enableLink, richText, link, size } = col
|
||||||
|
|
||||||
|
let cols
|
||||||
|
|
||||||
|
if (size === 'oneThird') cols = 4
|
||||||
|
if (size === 'half') cols = 6
|
||||||
|
if (size === 'twoThirds') cols = 8
|
||||||
|
if (size === 'full') cols = 10
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Cell cols={cols} colsM={4} key={index}>
|
||||||
|
<RichText content={richText} />
|
||||||
|
{enableLink && <CMSLink className={classes.link} {...link} />}
|
||||||
|
</Cell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</Gutter>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.mediaBlock {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
margin-top: var(--base)
|
||||||
|
}
|
||||||
43
templates/ecommerce/src/blocks/MediaBlock/index.tsx
Normal file
43
templates/ecommerce/src/blocks/MediaBlock/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Gutter } from '../../components/Gutter'
|
||||||
|
import { Media } from '../../components/Media'
|
||||||
|
import RichText from '../../components/RichText'
|
||||||
|
import { Page } from '../../payload-types'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
type Props = Extract<Page['layout'][0], { blockType: 'mediaBlock' }>
|
||||||
|
|
||||||
|
export const MediaBlock: React.FC<
|
||||||
|
Props & {
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
> = props => {
|
||||||
|
const { media, position = 'default' } = props
|
||||||
|
|
||||||
|
let caption
|
||||||
|
if (media && typeof media === 'object') caption = media.caption
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.mediaBlock}>
|
||||||
|
{position === 'fullscreen' && (
|
||||||
|
<div>
|
||||||
|
<Media resource={media} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{position === 'default' && (
|
||||||
|
<Gutter>
|
||||||
|
<div>
|
||||||
|
<Media resource={media} />
|
||||||
|
</div>
|
||||||
|
</Gutter>
|
||||||
|
)}
|
||||||
|
{caption && (
|
||||||
|
<Gutter className={classes.caption}>
|
||||||
|
<RichText content={caption} />
|
||||||
|
</Gutter>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { CollectionConfig } from 'payload/types'
|
import type { CollectionConfig } from 'payload/types'
|
||||||
|
|
||||||
import { admins } from '../../access/admins'
|
import { admins } from '../../access/admins'
|
||||||
import { Archive } from '../../blocks/Archive'
|
import { Archive } from '../../blocks/ArchiveBlock'
|
||||||
import { CallToAction } from '../../blocks/CallToAction'
|
import { CallToAction } from '../../blocks/CallToAction'
|
||||||
import { Content } from '../../blocks/Content'
|
import { Content } from '../../blocks/Content'
|
||||||
import { MediaBlock } from '../../blocks/Media'
|
import { MediaBlock } from '../../blocks/MediaBlock'
|
||||||
import { hero } from '../../fields/hero'
|
import { hero } from '../../fields/hero'
|
||||||
import { slugField } from '../../fields/slug'
|
import { slugField } from '../../fields/slug'
|
||||||
import { populateArchiveBlock } from '../../hooks/populateArchiveBlock'
|
import { populateArchiveBlock } from '../../hooks/populateArchiveBlock'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { BeforeChangeHook } from 'payload/dist/globals/config/types'
|
|||||||
import Stripe from 'stripe'
|
import Stripe from 'stripe'
|
||||||
|
|
||||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
|
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
|
||||||
const stripe = new Stripe(stripeSecretKey || '', { apiVersion: '2022-11-15' })
|
const stripe = new Stripe(stripeSecretKey || '', { apiVersion: '2022-08-01' })
|
||||||
|
|
||||||
const logs = false
|
const logs = false
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { CollectionConfig } from 'payload/types'
|
import type { CollectionConfig } from 'payload/types'
|
||||||
|
|
||||||
import { admins } from '../../access/admins'
|
import { admins } from '../../access/admins'
|
||||||
import { Archive } from '../../blocks/Archive'
|
import { Archive } from '../../blocks/ArchiveBlock'
|
||||||
import { CallToAction } from '../../blocks/CallToAction'
|
import { CallToAction } from '../../blocks/CallToAction'
|
||||||
import { Content } from '../../blocks/Content'
|
import { Content } from '../../blocks/Content'
|
||||||
import { MediaBlock } from '../../blocks/Media'
|
import { MediaBlock } from '../../blocks/MediaBlock'
|
||||||
import { slugField } from '../../fields/slug'
|
import { slugField } from '../../fields/slug'
|
||||||
import { populateArchiveBlock } from '../../hooks/populateArchiveBlock'
|
import { populateArchiveBlock } from '../../hooks/populateArchiveBlock'
|
||||||
import { populatePublishedDate } from '../../hooks/populatePublishedDate'
|
import { populatePublishedDate } from '../../hooks/populatePublishedDate'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { BeforeChangeHook } from 'payload/dist/collections/config/types'
|
|||||||
import Stripe from 'stripe'
|
import Stripe from 'stripe'
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||||
apiVersion: '2022-11-15',
|
apiVersion: '2022-08-01',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createStripeCustomer: BeforeChangeHook = async ({ req, data, operation }) => {
|
export const createStripeCustomer: BeforeChangeHook = async ({ req, data, operation }) => {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.addToCartButton {
|
||||||
|
// cursor: pointer;
|
||||||
|
// background-color: transparent;
|
||||||
|
// padding: 0;
|
||||||
|
// border: none;
|
||||||
|
// font-size: inherit;
|
||||||
|
// line-height: inherit;
|
||||||
|
// text-decoration: underline;
|
||||||
|
// white-space: nowrap;
|
||||||
|
}
|
||||||
51
templates/ecommerce/src/components/AddToCartButton/index.tsx
Normal file
51
templates/ecommerce/src/components/AddToCartButton/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { Product } from '../../payload-types'
|
||||||
|
import { useCart } from '../../providers/Cart'
|
||||||
|
import { Button, Props } from '../Button'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const AddToCartButton: React.FC<{
|
||||||
|
product: Product
|
||||||
|
quantity?: number
|
||||||
|
className?: string
|
||||||
|
appearance?: Props['appearance']
|
||||||
|
}> = props => {
|
||||||
|
const { product, quantity = 1, className, appearance = 'primary' } = props
|
||||||
|
|
||||||
|
const { cart, addItemToCart, isProductInCart } = useCart()
|
||||||
|
|
||||||
|
const [showInCart, setShowInCart] = useState<boolean>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowInCart(isProductInCart(product))
|
||||||
|
}, [isProductInCart, product, cart])
|
||||||
|
|
||||||
|
if (showInCart) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
href="/cart"
|
||||||
|
label="View in cart"
|
||||||
|
el="link"
|
||||||
|
appearance={appearance}
|
||||||
|
className={[className, classes.addToCartButton].filter(Boolean).join(' ')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
appearance={appearance}
|
||||||
|
onClick={() => {
|
||||||
|
addItemToCart({
|
||||||
|
product,
|
||||||
|
quantity,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className={[className, classes.addToCartButton].filter(Boolean).join(' ')}
|
||||||
|
label="Add to cart"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.adminBar {
|
||||||
|
z-index: 10;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--color-base-1000);
|
||||||
|
color: var(--color-white);
|
||||||
|
padding: 5px 0;
|
||||||
|
font-size: calc(#{var(--html-font-size)} * 1px);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
& > *:not(:last-child) {
|
||||||
|
margin-right: 10px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
margin-right: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin-right: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockContainer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--light-gray);
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
46
templates/ecommerce/src/components/AdminBar/index.tsx
Normal file
46
templates/ecommerce/src/components/AdminBar/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { PayloadAdminBar, PayloadAdminBarProps } from 'payload-admin-bar'
|
||||||
|
|
||||||
|
import { useAuth } from '../../providers/Auth'
|
||||||
|
import { Gutter } from '../Gutter'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
const Title: React.FC = () => <span>Dashboard</span>
|
||||||
|
|
||||||
|
export const AdminBar: React.FC<{
|
||||||
|
adminBarProps: PayloadAdminBarProps
|
||||||
|
}> = props => {
|
||||||
|
const { adminBarProps } = props
|
||||||
|
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const isAdmin = user?.roles?.includes('admin')
|
||||||
|
|
||||||
|
if (!isAdmin) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[classes.adminBar, user && classes.show].filter(Boolean).join(' ')}>
|
||||||
|
<Gutter className={classes.blockContainer}>
|
||||||
|
<PayloadAdminBar
|
||||||
|
{...adminBarProps}
|
||||||
|
key={user?.id} // use key to get the admin bar to re-run its `me` request
|
||||||
|
cmsURL={process.env.NEXT_PUBLIC_SERVER_URL}
|
||||||
|
className={classes.payloadAdminBar}
|
||||||
|
classNames={{
|
||||||
|
user: classes.user,
|
||||||
|
logo: classes.logo,
|
||||||
|
controls: classes.controls,
|
||||||
|
}}
|
||||||
|
logo={<Title />}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 'unset',
|
||||||
|
padding: 0,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Gutter>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.white {
|
||||||
|
color: var(--color-base-1000);
|
||||||
|
background-color: var(--color-base-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.black {
|
||||||
|
color: var(--color-base-0);
|
||||||
|
background-color: var(--color-base-1000);
|
||||||
|
}
|
||||||
26
templates/ecommerce/src/components/BackgroundColor/index.tsx
Normal file
26
templates/ecommerce/src/components/BackgroundColor/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React, { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export type BackgroundColorType = 'white' | 'black'
|
||||||
|
|
||||||
|
export const BackgroundColorContext = createContext<BackgroundColorType>('white')
|
||||||
|
|
||||||
|
export const useBackgroundColor = (): BackgroundColorType => useContext(BackgroundColorContext)
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
color?: BackgroundColorType
|
||||||
|
className?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BackgroundColor: React.FC<Props> = props => {
|
||||||
|
const { id, className, children, color = 'white' } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id={id} className={[classes[color], className].filter(Boolean).join(' ')}>
|
||||||
|
<BackgroundColorContext.Provider value={color}>{children}</BackgroundColorContext.Provider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React, { Fragment, useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
export const SeedButton: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [seeded, setSeeded] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
async e => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (loading || seeded) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/seed')
|
||||||
|
setSeeded(true)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
[loading, seeded],
|
||||||
|
)
|
||||||
|
|
||||||
|
let message = ''
|
||||||
|
if (loading) message = ' (seeding...)'
|
||||||
|
if (seeded) message = ' (done!)'
|
||||||
|
if (error) message = ` (error: ${error})`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<a
|
||||||
|
href="/api/seed"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
Seed your database
|
||||||
|
</a>
|
||||||
|
{message}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { Banner } from 'payload/components'
|
import { Banner } from 'payload/components'
|
||||||
|
|
||||||
|
import { SeedButton } from './SeedButton'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
const baseClass = 'before-dashboard'
|
const baseClass = 'before-dashboard'
|
||||||
@@ -13,42 +16,48 @@ const BeforeDashboard: React.FC = () => {
|
|||||||
</Banner>
|
</Banner>
|
||||||
Here's what to do next:
|
Here's what to do next:
|
||||||
<ul className={`${baseClass}__instructions`}>
|
<ul className={`${baseClass}__instructions`}>
|
||||||
|
<li>
|
||||||
|
<SeedButton />
|
||||||
|
{' with a few products and pages to jump-start your new project, then '}
|
||||||
|
<a href="/">visit your website</a>
|
||||||
|
{' to see the results.'}
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Head over to GitHub and clone the new repository to your local machine (it will be under
|
Head over to GitHub and clone the new repository to your local machine (it will be under
|
||||||
the <i>GitHub Scope</i> that you selected when creating this project).
|
the <i>GitHub Scope</i> that you selected when creating this project).
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Build out your{' '}
|
{'Build out your '}
|
||||||
<a
|
<a
|
||||||
href="https://payloadcms.com/docs/configuration/collections"
|
href="https://payloadcms.com/docs/configuration/collections"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
collections
|
collections
|
||||||
</a>{' '}
|
</a>
|
||||||
and add more{' '}
|
{' and add more '}
|
||||||
<a
|
<a
|
||||||
href="https://payloadcms.com/docs/fields/overview"
|
href="https://payloadcms.com/docs/fields/overview"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
fields
|
fields
|
||||||
</a>{' '}
|
</a>
|
||||||
as needed. If you are new to Payload, we also recommend you check out the{' '}
|
{' as needed. If you are new to Payload, we also recommend you check out the '}
|
||||||
<a
|
<a
|
||||||
href="https://payloadcms.com/docs/getting-started/what-is-payload"
|
href="https://payloadcms.com/docs/getting-started/what-is-payload"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Getting Started
|
Getting Started
|
||||||
</a>{' '}
|
</a>
|
||||||
docs.
|
{' docs.'}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Commit and push your changes to the repository to trigger a redeployment of your project.
|
Commit and push your changes to the repository to trigger a redeployment of your project.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
Pro Tip: This block is a{' '}
|
{'Pro Tip: This block is a '}
|
||||||
<a
|
<a
|
||||||
href={'https://payloadcms.com/docs/admin/components#base-component-overrides'}
|
href={'https://payloadcms.com/docs/admin/components#base-component-overrides'}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
92
templates/ecommerce/src/components/Blocks/index.tsx
Normal file
92
templates/ecommerce/src/components/Blocks/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { Fragment } from 'react'
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import { ArchiveBlock } from '../../blocks/ArchiveBlock/index.tsx'
|
||||||
|
// @ts-ignore
|
||||||
|
import { CallToActionBlock } from '../../blocks/CallToAction/index.tsx'
|
||||||
|
// @ts-ignore
|
||||||
|
import { ContentBlock } from '../../blocks/Content/index.tsx'
|
||||||
|
import { MediaBlock } from '../../blocks/MediaBlock'
|
||||||
|
import { Page } from '../../payload-types'
|
||||||
|
import { toKebabCase } from '../../utilities/toKebabCase'
|
||||||
|
import { BackgroundColor } from '../BackgroundColor'
|
||||||
|
import { VerticalPadding, VerticalPaddingOptions } from '../VerticalPadding'
|
||||||
|
|
||||||
|
const blockComponents = {
|
||||||
|
cta: CallToActionBlock,
|
||||||
|
content: ContentBlock,
|
||||||
|
mediaBlock: MediaBlock,
|
||||||
|
archive: ArchiveBlock,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Blocks: React.FC<{
|
||||||
|
blocks: Page['layout']
|
||||||
|
disableTopPadding?: boolean
|
||||||
|
}> = props => {
|
||||||
|
const { disableTopPadding, blocks } = props
|
||||||
|
|
||||||
|
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0
|
||||||
|
|
||||||
|
if (hasBlocks) {
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{blocks.map((block, index) => {
|
||||||
|
const { blockName, blockType } = block
|
||||||
|
|
||||||
|
if (blockType && blockType in blockComponents) {
|
||||||
|
const Block = blockComponents[blockType]
|
||||||
|
const backgroundColor = 'backgroundColor' in block ? block.backgroundColor : 'white'
|
||||||
|
const prevBlock = blocks[index - 1]
|
||||||
|
const nextBlock = blocks[index + 1]
|
||||||
|
|
||||||
|
const prevBlockBackground =
|
||||||
|
prevBlock?.[`${prevBlock.blockType}`]?.backgroundColor || 'white'
|
||||||
|
const nextBlockBackground =
|
||||||
|
nextBlock?.[`${nextBlock.blockType}`]?.backgroundColor || 'white'
|
||||||
|
|
||||||
|
let paddingTop: VerticalPaddingOptions = 'large'
|
||||||
|
let paddingBottom: VerticalPaddingOptions = 'large'
|
||||||
|
|
||||||
|
if (backgroundColor && backgroundColor === prevBlockBackground) {
|
||||||
|
paddingTop = 'medium'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backgroundColor && backgroundColor === nextBlockBackground) {
|
||||||
|
paddingBottom = 'medium'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === blocks.length - 1) {
|
||||||
|
paddingBottom = 'large'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disableTopPadding && index === 0) {
|
||||||
|
paddingTop = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!disableTopPadding && index === 0) {
|
||||||
|
paddingTop = 'large'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Block) {
|
||||||
|
return (
|
||||||
|
<BackgroundColor key={index} color={backgroundColor}>
|
||||||
|
<VerticalPadding top={paddingTop} bottom={paddingBottom}>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<Block
|
||||||
|
// @ts-ignore
|
||||||
|
id={toKebabCase(blockName)}
|
||||||
|
{...block}
|
||||||
|
/>
|
||||||
|
</VerticalPadding>
|
||||||
|
</BackgroundColor>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
58
templates/ecommerce/src/components/Button/index.module.scss
Normal file
58
templates/ecommerce/src/components/Button/index.module.scss
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
@import '../../css/type.scss';
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-right: calc(var(--base) / 2);
|
||||||
|
width: var(--base);
|
||||||
|
height: var(--base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@extend %label;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 12px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary--white {
|
||||||
|
background-color: black;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary--black {
|
||||||
|
background-color: white;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary--white {
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: inset 0 0 0 1px black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary--black {
|
||||||
|
background-color: black;
|
||||||
|
box-shadow: inset 0 0 0 1px white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appearance--default {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
75
templates/ecommerce/src/components/Button/index.tsx
Normal file
75
templates/ecommerce/src/components/Button/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { ElementType } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { useBackgroundColor } from '../BackgroundColor'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
label: string
|
||||||
|
appearance?: 'default' | 'primary' | 'secondary'
|
||||||
|
el?: 'button' | 'link' | 'a'
|
||||||
|
onClick?: () => void
|
||||||
|
href?: string
|
||||||
|
newTab?: boolean
|
||||||
|
className?: string
|
||||||
|
type?: 'submit' | 'button'
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<Props> = ({
|
||||||
|
el: elFromProps = 'link',
|
||||||
|
label,
|
||||||
|
newTab,
|
||||||
|
href,
|
||||||
|
appearance,
|
||||||
|
className: classNameFromProps,
|
||||||
|
onClick,
|
||||||
|
type = 'button',
|
||||||
|
disabled,
|
||||||
|
}) => {
|
||||||
|
let el = elFromProps
|
||||||
|
const backgroundColor = useBackgroundColor()
|
||||||
|
const newTabProps = newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}
|
||||||
|
const className = [
|
||||||
|
classes.button,
|
||||||
|
classNameFromProps,
|
||||||
|
classes[`appearance--${appearance}`],
|
||||||
|
classes[`${appearance}--${backgroundColor}`],
|
||||||
|
classes.button,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className={classes.content}>
|
||||||
|
{/* <Chevron /> */}
|
||||||
|
<span className={classes.label}>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (onClick || type === 'submit') el = 'button'
|
||||||
|
|
||||||
|
if (el === 'link') {
|
||||||
|
return (
|
||||||
|
<Link href={href} className={className} {...newTabProps} onClick={onClick}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Element: ElementType = el
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Element
|
||||||
|
href={href}
|
||||||
|
className={className}
|
||||||
|
type={type}
|
||||||
|
{...newTabProps}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Element>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
templates/ecommerce/src/components/Card/index.module.scss
Normal file
78
templates/ecommerce/src/components/Card/index.module.scss
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
@import '../../css/common';
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centerAlign {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
margin-top: calc(var(--base) / 2);
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
margin-right: calc(var(--base) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leader {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: calc(var(--base) / 4);
|
||||||
|
|
||||||
|
& > *:not(:last-child) {
|
||||||
|
margin-right: var(--base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: calc(var(--base) / 2);
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hideImageOnMobile {
|
||||||
|
@include mid-break {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaWrapper {
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 5 / 4;
|
||||||
|
margin-bottom: calc(var(--base) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
background-color: var(--color-base-50);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
templates/ecommerce/src/components/Card/index.tsx
Normal file
110
templates/ecommerce/src/components/Card/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import React, { Fragment, useEffect, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { Product } from '../../payload-types'
|
||||||
|
import { Media } from '../Media'
|
||||||
|
import { Price } from '../Price'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
const priceFromJSON = (priceJSON): string => {
|
||||||
|
let price = ''
|
||||||
|
|
||||||
|
if (priceJSON) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(priceJSON)?.data[0]
|
||||||
|
const priceValue = parsed.unit_amount
|
||||||
|
const priceType = parsed.type
|
||||||
|
price = `${parsed.currency === 'usd' ? '$' : ''}${(priceValue / 100).toFixed(2)}`
|
||||||
|
if (priceType === 'recurring') {
|
||||||
|
price += `/${
|
||||||
|
parsed.recurring.interval_count > 1
|
||||||
|
? `${parsed.recurring.interval_count} ${parsed.recurring.interval}`
|
||||||
|
: parsed.recurring.interval
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Cannot parse priceJSON`) // eslint-disable-line no-console
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return price
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Card: React.FC<{
|
||||||
|
alignItems?: 'center'
|
||||||
|
className?: string
|
||||||
|
showCategories?: boolean
|
||||||
|
hideImagesOnMobile?: boolean
|
||||||
|
title?: string
|
||||||
|
relationTo?: 'products'
|
||||||
|
doc?: Product
|
||||||
|
}> = props => {
|
||||||
|
const {
|
||||||
|
showCategories,
|
||||||
|
title: titleFromProps,
|
||||||
|
doc,
|
||||||
|
doc: { slug, title, categories, meta, priceJSON } = {},
|
||||||
|
className,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const { description, image: metaImage } = meta || {}
|
||||||
|
|
||||||
|
const hasCategories = categories && Array.isArray(categories) && categories.length > 0
|
||||||
|
const titleToUse = titleFromProps || title
|
||||||
|
const sanitizedDescription = description?.replace(/\s/g, ' ') // replace non-breaking space with white space
|
||||||
|
const href = `/products/${slug}`
|
||||||
|
|
||||||
|
const [
|
||||||
|
price, // eslint-disable-line no-unused-vars
|
||||||
|
setPrice,
|
||||||
|
] = useState(() => priceFromJSON(priceJSON))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPrice(priceFromJSON(priceJSON))
|
||||||
|
}, [priceJSON])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[classes.card, className].filter(Boolean).join(' ')}>
|
||||||
|
<Link href={href} className={classes.mediaWrapper}>
|
||||||
|
{!metaImage && <div className={classes.placeholder}>No image</div>}
|
||||||
|
{metaImage && typeof metaImage !== 'string' && (
|
||||||
|
<Media imgClassName={classes.image} resource={metaImage} fill />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
{showCategories && hasCategories && (
|
||||||
|
<div className={classes.leader}>
|
||||||
|
{showCategories && hasCategories && (
|
||||||
|
<div>
|
||||||
|
{categories?.map((category, index) => {
|
||||||
|
const { title: titleFromCategory } = category
|
||||||
|
|
||||||
|
const categoryTitle = titleFromCategory || 'Untitled category'
|
||||||
|
|
||||||
|
const isLast = index === categories.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{categoryTitle}
|
||||||
|
{!isLast && <Fragment>, </Fragment>}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{titleToUse && (
|
||||||
|
<h4 className={classes.title}>
|
||||||
|
<Link href={href}>{titleToUse}</Link>
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<div className={classes.body}>
|
||||||
|
{description && <p className={classes.description}>{sanitizedDescription}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Price product={doc} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.cartLink {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity {
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
27
templates/ecommerce/src/components/CartLink/index.tsx
Normal file
27
templates/ecommerce/src/components/CartLink/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React, { Fragment, useEffect, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { useCart } from '../../providers/Cart'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const CartLink: React.FC<{
|
||||||
|
className?: string
|
||||||
|
}> = props => {
|
||||||
|
const { className } = props
|
||||||
|
const { cart } = useCart()
|
||||||
|
const [length, setLength] = useState<number>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLength(cart?.items?.length || 0)
|
||||||
|
}, [cart])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className={[classes.cartLink, className].filter(Boolean).join(' ')} href="/cart">
|
||||||
|
<Fragment>
|
||||||
|
Cart
|
||||||
|
{length > 0 && <small className={classes.quantity}>({length})</small>}
|
||||||
|
</Fragment>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@import "../../css/common";
|
||||||
|
|
||||||
|
.checkoutButton {
|
||||||
|
margin-top: var(--base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin-top: calc(var(--block-padding) - var(--base));
|
||||||
|
}
|
||||||
67
templates/ecommerce/src/components/CheckoutForm/index.tsx
Normal file
67
templates/ecommerce/src/components/CheckoutForm/index.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
|
||||||
|
|
||||||
|
import { Button } from '../Button'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const CheckoutForm: React.FC<{}> = () => {
|
||||||
|
const stripe = useStripe()
|
||||||
|
const elements = useElements()
|
||||||
|
const [error, setError] = React.useState(null)
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async e => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
error: stripeError,
|
||||||
|
// paymentIntent,
|
||||||
|
} = await stripe.confirmPayment({
|
||||||
|
elements,
|
||||||
|
// redirect: 'if_required',
|
||||||
|
confirmParams: {
|
||||||
|
return_url: `${process.env.NEXT_PUBLIC_SERVER_URL}/order-confirmation?clear_cart=true`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (stripeError) {
|
||||||
|
setError(stripeError.message)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternatively, you could handle the redirect yourself if `redirect: 'if_required'` is set
|
||||||
|
// but this doesn't work currently because if you clear the cart while in the checkout
|
||||||
|
// you will be redirected to the cart page before this redirect happens
|
||||||
|
// if (paymentIntent) {
|
||||||
|
// clearCart();
|
||||||
|
// Router.push(`/order-confirmation?payment_intent_client_secret=${paymentIntent.client_secret}`);
|
||||||
|
// }
|
||||||
|
} catch (err) {
|
||||||
|
setError('Something went wrong.')
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[stripe, elements],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{error && <div className={classes.error}>{error}</div>}
|
||||||
|
<PaymentElement />
|
||||||
|
<Button
|
||||||
|
className={classes.checkoutButton}
|
||||||
|
label={isLoading ? 'Loading...' : 'Checkout'}
|
||||||
|
type="submit"
|
||||||
|
appearance="primary"
|
||||||
|
disabled={!stripe || isLoading}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CheckoutForm
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
@import '../../css/common';
|
||||||
|
|
||||||
|
// this is to make up for the space taken by the fixed header, since the scroll method does not accept an offset parameter
|
||||||
|
.scrollRef {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(var(--base) * -5);
|
||||||
|
@include mid-break {
|
||||||
|
top: calc(var(--base) * -2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.introContent {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: calc(var(--base) * 2);
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin-bottom: var(--base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultCountWrapper {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: calc(var(--base) * 2);
|
||||||
|
@include mid-break {
|
||||||
|
margin-bottom: var(--base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageRange {
|
||||||
|
margin-bottom: var(--base);
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin-bottom: var(--base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
width: 100%;
|
||||||
|
row-gap: 40px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: calc(var(--base) * 2);
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin-top: var(--base);
|
||||||
|
}
|
||||||
|
}
|
||||||
199
templates/ecommerce/src/components/CollectionArchive/index.tsx
Normal file
199
templates/ecommerce/src/components/CollectionArchive/index.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Cell, Grid } from '@faceless-ui/css-grid'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import qs from 'qs'
|
||||||
|
|
||||||
|
import type { ArchiveBlockProps } from '../../blocks/ArchiveBlock/types'
|
||||||
|
import { Product } from '../../payload-types'
|
||||||
|
import { Card } from '../Card'
|
||||||
|
import { Gutter } from '../Gutter'
|
||||||
|
import { PageRange } from '../PageRange'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
type Result = {
|
||||||
|
totalDocs: number
|
||||||
|
docs: Product[]
|
||||||
|
page: number
|
||||||
|
totalPages: number
|
||||||
|
hasPrevPage: boolean
|
||||||
|
hasNextPage: boolean
|
||||||
|
nextPage: number
|
||||||
|
prevPage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
className?: string
|
||||||
|
relationTo?: 'products'
|
||||||
|
populateBy?: 'collection' | 'selection'
|
||||||
|
showPageRange?: boolean
|
||||||
|
onResultChange?: (result: Result) => void // eslint-disable-line no-unused-vars
|
||||||
|
sort?: string
|
||||||
|
limit?: number
|
||||||
|
populatedDocs?: ArchiveBlockProps['populatedDocs']
|
||||||
|
populatedDocsTotal?: ArchiveBlockProps['populatedDocsTotal']
|
||||||
|
categories?: ArchiveBlockProps['categories']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CollectionArchive: React.FC<Props> = props => {
|
||||||
|
const {
|
||||||
|
className,
|
||||||
|
relationTo,
|
||||||
|
showPageRange,
|
||||||
|
onResultChange,
|
||||||
|
sort = '-createdAt',
|
||||||
|
limit = 10,
|
||||||
|
populatedDocs,
|
||||||
|
populatedDocsTotal,
|
||||||
|
categories: catsFromProps,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const [results, setResults] = useState<Result>({
|
||||||
|
totalDocs: typeof populatedDocsTotal === 'number' ? populatedDocsTotal : 0,
|
||||||
|
docs: populatedDocs?.map(doc => doc.value) || [],
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
hasPrevPage: false,
|
||||||
|
hasNextPage: false,
|
||||||
|
prevPage: 1,
|
||||||
|
nextPage: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
// `query` contains both router AND search params
|
||||||
|
query: { categories: catsFromQuery, page } = {},
|
||||||
|
} = useRouter()
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const hasHydrated = useRef(false)
|
||||||
|
|
||||||
|
const scrollToRef = useCallback(() => {
|
||||||
|
const { current } = scrollRef
|
||||||
|
if (current) {
|
||||||
|
current.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof page !== 'undefined') {
|
||||||
|
scrollToRef()
|
||||||
|
}
|
||||||
|
}, [isLoading, scrollToRef, page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// hydrate the block with fresh content after first render
|
||||||
|
// don't show loader unless the request takes longer than x ms
|
||||||
|
// and don't show it during initial hydration
|
||||||
|
const timer: NodeJS.Timeout = setTimeout(() => {
|
||||||
|
if (hasHydrated) {
|
||||||
|
setIsLoading(true)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
const searchParams = qs.stringify(
|
||||||
|
{
|
||||||
|
sort,
|
||||||
|
where: {
|
||||||
|
...(catsFromProps?.length > 0
|
||||||
|
? {
|
||||||
|
categories: {
|
||||||
|
in:
|
||||||
|
typeof catsFromProps === 'string'
|
||||||
|
? [catsFromProps]
|
||||||
|
: catsFromProps.map(cat => cat.id).join(','),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(catsFromQuery?.length > 0
|
||||||
|
? {
|
||||||
|
categories: {
|
||||||
|
in:
|
||||||
|
typeof catsFromQuery === 'string'
|
||||||
|
? [catsFromQuery]
|
||||||
|
: catsFromQuery.map(cat => cat).join(','),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
depth: 1,
|
||||||
|
},
|
||||||
|
{ encode: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const makeRequest = async () => {
|
||||||
|
try {
|
||||||
|
const req = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/${relationTo}?${searchParams}`,
|
||||||
|
)
|
||||||
|
const json = await req.json()
|
||||||
|
clearTimeout(timer)
|
||||||
|
hasHydrated.current = true
|
||||||
|
|
||||||
|
const { docs } = json as { docs: Product[] }
|
||||||
|
|
||||||
|
if (docs && Array.isArray(docs)) {
|
||||||
|
setResults(json)
|
||||||
|
setIsLoading(false)
|
||||||
|
if (typeof onResultChange === 'function') {
|
||||||
|
onResultChange(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err) // eslint-disable-line no-console
|
||||||
|
setIsLoading(false)
|
||||||
|
setError(`Unable to load "${relationTo} archive" data at this time.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
makeRequest()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [page, catsFromProps, catsFromQuery, relationTo, onResultChange, sort, limit])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[classes.collectionArchive, className].filter(Boolean).join(' ')}>
|
||||||
|
<div ref={scrollRef} className={classes.scrollRef} />
|
||||||
|
{isLoading && <Gutter>Loading, please wait...</Gutter>}
|
||||||
|
{!isLoading && error && <Gutter>{error}</Gutter>}
|
||||||
|
{!isLoading && (
|
||||||
|
<Fragment>
|
||||||
|
{showPageRange !== false && (
|
||||||
|
<Gutter>
|
||||||
|
<Grid>
|
||||||
|
<Cell cols={6} colsM={4}>
|
||||||
|
<div className={classes.pageRange}>
|
||||||
|
<PageRange
|
||||||
|
totalDocs={results.totalDocs}
|
||||||
|
currentPage={results.page}
|
||||||
|
collection={relationTo}
|
||||||
|
limit={limit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Cell>
|
||||||
|
</Grid>
|
||||||
|
</Gutter>
|
||||||
|
)}
|
||||||
|
<Gutter>
|
||||||
|
<Grid className={classes.grid}>
|
||||||
|
{results.docs?.map((result, index) => {
|
||||||
|
return (
|
||||||
|
<Cell key={index} className={classes.row} cols={4} colsM={8}>
|
||||||
|
<Card relationTo="products" doc={result} showCategories />
|
||||||
|
</Cell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</Gutter>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
templates/ecommerce/src/components/Footer/index.module.scss
Normal file
27
templates/ecommerce/src/components/Footer/index.module.scss
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
@use '../../css/queries.scss' as *;
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: calc(var(--base) * 4) 0;
|
||||||
|
z-index: var(--header-z-index);
|
||||||
|
background-color: var(--color-base-1000);
|
||||||
|
color: var(--color-base-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: var(--base);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
templates/ecommerce/src/components/Footer/index.tsx
Normal file
30
templates/ecommerce/src/components/Footer/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { Footer as FooterType } from '../../payload-types'
|
||||||
|
import { Gutter } from '../Gutter'
|
||||||
|
import { CMSLink } from '../Link'
|
||||||
|
import { Logo } from '../Logo'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const Footer: React.FC<{ footer: FooterType }> = ({ footer }) => {
|
||||||
|
const navItems = footer?.navItems || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className={classes.footer}>
|
||||||
|
<Gutter className={classes.wrap}>
|
||||||
|
<Link href="/">
|
||||||
|
<Logo color="white" />
|
||||||
|
</Link>
|
||||||
|
<nav className={classes.nav}>
|
||||||
|
{navItems.map(({ link }, i) => {
|
||||||
|
return <CMSLink key={i} {...link} />
|
||||||
|
})}
|
||||||
|
<Link href="https://github.com/payloadcms/ecommere-example-website">Source code</Link>
|
||||||
|
<Link href="https://github.com/payloadcms/payload">Payload</Link>
|
||||||
|
</nav>
|
||||||
|
</Gutter>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.gutterLeft {
|
||||||
|
padding-left: var(--gutter-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gutterRight {
|
||||||
|
padding-right: var(--gutter-h);
|
||||||
|
}
|
||||||
28
templates/ecommerce/src/components/Gutter/index.tsx
Normal file
28
templates/ecommerce/src/components/Gutter/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React, { forwardRef, Ref } from 'react'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
left?: boolean
|
||||||
|
right?: boolean
|
||||||
|
className?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
ref?: Ref<HTMLDivElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Gutter: React.FC<Props> = forwardRef<HTMLDivElement, Props>((props, ref) => {
|
||||||
|
const { left = true, right = true, className, children } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={[left && classes.gutterLeft, right && classes.gutterRight, className]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Gutter.displayName = 'Gutter'
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Modal } from '@faceless-ui/modal'
|
||||||
|
|
||||||
|
import { Header } from '../../payload-types'
|
||||||
|
import { Gutter } from '../Gutter'
|
||||||
|
import { CMSLink } from '../Link'
|
||||||
|
|
||||||
|
import classes from './mobileMenuModal.module.scss'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
navItems: Header['navItems']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const slug = 'menu-modal'
|
||||||
|
|
||||||
|
export const MobileMenuModal: React.FC<Props> = ({ navItems }) => {
|
||||||
|
return (
|
||||||
|
<Modal slug={slug} className={classes.mobileMenuModal}>
|
||||||
|
<Gutter>
|
||||||
|
<div className={classes.mobileMenuItems}>
|
||||||
|
{navItems.map(({ link }, i) => {
|
||||||
|
return <CMSLink className={classes.menuItem} key={i} {...link} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Gutter>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
templates/ecommerce/src/components/Header/index.module.scss
Normal file
39
templates/ecommerce/src/components/Header/index.module.scss
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
@use '../../css/queries.scss' as *;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: var(--base) 0;
|
||||||
|
z-index: var(--header-z-index);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: var(--base);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileMenuToggler {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&[aria-expanded="true"] {
|
||||||
|
transform: rotate(-25deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
templates/ecommerce/src/components/Header/index.tsx
Normal file
48
templates/ecommerce/src/components/Header/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { ModalToggler } from '@faceless-ui/modal'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { Header as HeaderType } from '../../payload-types'
|
||||||
|
import { useAuth } from '../../providers/Auth'
|
||||||
|
import { CartLink } from '../CartLink'
|
||||||
|
import { Gutter } from '../Gutter'
|
||||||
|
import { MenuIcon } from '../icons/Menu'
|
||||||
|
import { CMSLink } from '../Link'
|
||||||
|
import { Logo } from '../Logo'
|
||||||
|
import { MobileMenuModal, slug as menuModalSlug } from './MobileMenuModal'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const Header: React.FC<{ header: HeaderType }> = ({ header }) => {
|
||||||
|
const navItems = header?.navItems || []
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className={classes.header}>
|
||||||
|
<Gutter className={classes.wrap}>
|
||||||
|
<Link href="/">
|
||||||
|
<Logo />
|
||||||
|
</Link>
|
||||||
|
<nav className={classes.nav}>
|
||||||
|
{navItems.map(({ link }, i) => {
|
||||||
|
return <CMSLink key={i} {...link} />
|
||||||
|
})}
|
||||||
|
{user && <Link href="/account">Account</Link>}
|
||||||
|
{!user && (
|
||||||
|
<React.Fragment>
|
||||||
|
<Link href="/login">Login</Link>
|
||||||
|
<Link href="/create-account">Create Account</Link>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
<CartLink />
|
||||||
|
</nav>
|
||||||
|
<ModalToggler slug={menuModalSlug} className={classes.mobileMenuToggler}>
|
||||||
|
<MenuIcon />
|
||||||
|
</ModalToggler>
|
||||||
|
</Gutter>
|
||||||
|
</header>
|
||||||
|
<MobileMenuModal navItems={navItems} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
@use '../../css/common.scss' as *;
|
||||||
|
|
||||||
|
.mobileMenuModal {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 1;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentContainer {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileMenuItems {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem {
|
||||||
|
@extend %h4;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
@import '../../../css/queries';
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding-top: calc(var(--gutter-h) / 2);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
padding-top: var(--gutter-h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media {
|
||||||
|
width: calc(100% + var(--gutter-h));
|
||||||
|
left: calc(var(--gutter-h) / -2);
|
||||||
|
margin-top: calc(var(--base) * 2);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
left: 0;
|
||||||
|
margin-top: var(--base);
|
||||||
|
margin-left: calc(var(--gutter-h) * -1);
|
||||||
|
width: calc(100% + var(--gutter-h) * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
padding-top: var(--base);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: calc(var(--base) * -.5);
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
margin: calc(var(--base) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
margin-top: var(--base);
|
||||||
|
left: calc(var(--gutter-h) / 2);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
left: var(--gutter-h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
45
templates/ecommerce/src/components/Hero/HighImpact/index.tsx
Normal file
45
templates/ecommerce/src/components/Hero/HighImpact/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React, { Fragment } from 'react'
|
||||||
|
import { Cell, Grid } from '@faceless-ui/css-grid'
|
||||||
|
|
||||||
|
import { Page } from '../../../payload-types'
|
||||||
|
import { Gutter } from '../../Gutter'
|
||||||
|
import { CMSLink } from '../../Link'
|
||||||
|
import { Media } from '../../Media'
|
||||||
|
import RichText from '../../RichText'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const HighImpactHero: React.FC<Page['hero']> = ({ richText, media, links }) => {
|
||||||
|
return (
|
||||||
|
<Gutter className={classes.hero}>
|
||||||
|
<Grid className={classes.content}>
|
||||||
|
<Cell cols={10} colsM={4}>
|
||||||
|
<RichText content={richText} />
|
||||||
|
{Array.isArray(links) && links.length > 0 && (
|
||||||
|
<ul className={classes.links}>
|
||||||
|
{links.map(({ link }, i) => {
|
||||||
|
return (
|
||||||
|
<li key={i}>
|
||||||
|
<CMSLink {...link} />
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Cell>
|
||||||
|
</Grid>
|
||||||
|
<div className={classes.media}>
|
||||||
|
{typeof media === 'object' && (
|
||||||
|
<Fragment>
|
||||||
|
<Media
|
||||||
|
resource={media}
|
||||||
|
// fill
|
||||||
|
imgClassName={classes.image}
|
||||||
|
/>
|
||||||
|
{media?.caption && <RichText content={media.caption} className={classes.caption} />}
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Gutter>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@use '../../../css/type.scss' as *;
|
||||||
|
|
||||||
|
.lowImpactHero {
|
||||||
|
}
|
||||||
23
templates/ecommerce/src/components/Hero/LowImpact/index.tsx
Normal file
23
templates/ecommerce/src/components/Hero/LowImpact/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Cell, Grid } from '@faceless-ui/css-grid'
|
||||||
|
|
||||||
|
import { Page } from '../../../payload-types'
|
||||||
|
import { Gutter } from '../../Gutter'
|
||||||
|
import RichText from '../../RichText'
|
||||||
|
import { VerticalPadding } from '../../VerticalPadding'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const LowImpactHero: React.FC<Page['hero']> = ({ richText }) => {
|
||||||
|
return (
|
||||||
|
<Gutter className={classes.lowImpactHero}>
|
||||||
|
<Grid>
|
||||||
|
<Cell cols={8} colsL={10}>
|
||||||
|
<VerticalPadding>
|
||||||
|
<RichText className={classes.richText} content={richText} />
|
||||||
|
</VerticalPadding>
|
||||||
|
</Cell>
|
||||||
|
</Grid>
|
||||||
|
</Gutter>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
@use '../../../css/common.scss' as *;
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding-top: calc(var(--base) * 3);
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
padding-top: var(--base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.richText {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: 100vw;
|
||||||
|
left: calc(var(--gutter-h) * -1);
|
||||||
|
height: 200px;
|
||||||
|
background: linear-gradient(to bottom, var(--color-base-100), transparent);
|
||||||
|
top: calc(100% + (var(--base) * 2));
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
margin-top: calc(var(--base) * 4);
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--base);
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
@include mid-break {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media {
|
||||||
|
position: relative;
|
||||||
|
width: calc(100% + var(--gutter-h));
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Cell, Grid } from '@faceless-ui/css-grid'
|
||||||
|
|
||||||
|
import { Page } from '../../../payload-types'
|
||||||
|
import { Gutter } from '../../Gutter'
|
||||||
|
import { CMSLink } from '../../Link'
|
||||||
|
import { Media } from '../../Media'
|
||||||
|
import RichText from '../../RichText'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const MediumImpactHero: React.FC<Page['hero']> = props => {
|
||||||
|
const { richText, media, links } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Gutter className={classes.hero}>
|
||||||
|
<Grid>
|
||||||
|
<Cell cols={5} colsM={4}>
|
||||||
|
<RichText className={classes.richText} content={richText} />
|
||||||
|
{Array.isArray(links) && (
|
||||||
|
<ul className={classes.links}>
|
||||||
|
{links.map(({ link }, i) => {
|
||||||
|
return (
|
||||||
|
<li key={i}>
|
||||||
|
<CMSLink className={classes.link} {...link} />
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Cell>
|
||||||
|
<Cell cols={7} colsM={4}>
|
||||||
|
{typeof media === 'object' && <Media className={classes.media} resource={media} />}
|
||||||
|
</Cell>
|
||||||
|
</Grid>
|
||||||
|
</Gutter>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
@use '../../../css/common.scss' as *;
|
||||||
|
|
||||||
|
.contentWrapper {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin-bottom: var(--base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories {
|
||||||
|
margin-bottom: calc(var(--base) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: var(--base);
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin-bottom: calc(var(--base) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: var(--base);
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin-bottom: calc(var(--base) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaWrapper {
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 5 / 4;
|
||||||
|
margin-bottom: calc(var(--base) / 2);
|
||||||
|
width: calc(100% + calc(var(--gutter-h) / 2));
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin-left: calc(var(--gutter-h) * -1);
|
||||||
|
width: calc(100% + var(--gutter-h) * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
background-color: var(--color-base-50);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addToCartButton {
|
||||||
|
margin-top: var(--base);
|
||||||
|
}
|
||||||
66
templates/ecommerce/src/components/Hero/Product/index.tsx
Normal file
66
templates/ecommerce/src/components/Hero/Product/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React, { Fragment } from 'react'
|
||||||
|
import { Cell, Grid } from '@faceless-ui/css-grid'
|
||||||
|
|
||||||
|
import { Product } from '../../../payload-types'
|
||||||
|
import { AddToCartButton } from '../../AddToCartButton'
|
||||||
|
import { BackgroundColor } from '../../BackgroundColor'
|
||||||
|
import { Gutter } from '../../Gutter'
|
||||||
|
import { Media } from '../../Media'
|
||||||
|
import { Price } from '../../Price'
|
||||||
|
import RichText from '../../RichText'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const ProductHero: React.FC<{
|
||||||
|
product: Product
|
||||||
|
}> = ({ product }) => {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
categories,
|
||||||
|
meta: { image: metaImage, description },
|
||||||
|
} = product
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Gutter className={classes.productHero}>
|
||||||
|
<BackgroundColor color="white">
|
||||||
|
<Grid>
|
||||||
|
<Cell cols={5} colsM={8}>
|
||||||
|
<div className={classes.content}>
|
||||||
|
<div className={classes.categories}>
|
||||||
|
{categories?.map((category, index) => {
|
||||||
|
const { title: categoryTitle } = category
|
||||||
|
|
||||||
|
const titleToUse = categoryTitle || 'Untitled category'
|
||||||
|
|
||||||
|
const isLast = index === categories.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{titleToUse}
|
||||||
|
{!isLast && <Fragment>, </Fragment>}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<h1 className={classes.title}>{title}</h1>
|
||||||
|
{description && <p className={classes.description}>{description}</p>}
|
||||||
|
<Price product={product} button={false} />
|
||||||
|
<AddToCartButton product={product} className={classes.addToCartButton} />
|
||||||
|
</div>
|
||||||
|
</Cell>
|
||||||
|
<Cell cols={7} colsM={8}>
|
||||||
|
<div className={classes.mediaWrapper}>
|
||||||
|
{!metaImage && <div className={classes.placeholder}>No image</div>}
|
||||||
|
{metaImage && typeof metaImage !== 'string' && (
|
||||||
|
<Media imgClassName={classes.image} resource={metaImage} fill />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{metaImage && typeof metaImage !== 'string' && metaImage?.caption && (
|
||||||
|
<RichText content={metaImage.caption} />
|
||||||
|
)}
|
||||||
|
</Cell>
|
||||||
|
</Grid>
|
||||||
|
</BackgroundColor>
|
||||||
|
</Gutter>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
templates/ecommerce/src/components/Hero/index.tsx
Normal file
24
templates/ecommerce/src/components/Hero/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Page } from '../../payload-types'
|
||||||
|
import { HighImpactHero } from './HighImpact'
|
||||||
|
import { LowImpactHero } from './LowImpact'
|
||||||
|
import { MediumImpactHero } from './MediumImpact'
|
||||||
|
|
||||||
|
const heroes = {
|
||||||
|
highImpact: HighImpactHero,
|
||||||
|
mediumImpact: MediumImpactHero,
|
||||||
|
lowImpact: LowImpactHero,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Hero: React.FC<Page['hero']> = props => {
|
||||||
|
const { type } = props || {}
|
||||||
|
|
||||||
|
if (!type || type === 'none') return null
|
||||||
|
|
||||||
|
const HeroToRender = heroes[type]
|
||||||
|
|
||||||
|
if (!HeroToRender) return null
|
||||||
|
|
||||||
|
return <HeroToRender {...props} />
|
||||||
|
}
|
||||||
24
templates/ecommerce/src/components/Input/index.module.scss
Normal file
24
templates/ecommerce/src/components/Input/index.module.scss
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.input {
|
||||||
|
margin-bottom: calc(var(--base) / 2);
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
font-family: system-ui;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: 0;
|
||||||
|
border: 1px solid #d8d8d8;
|
||||||
|
height: calc(var(--base) * 2);
|
||||||
|
line-height: calc(var(--base) * 2);
|
||||||
|
padding: 0 calc(var(--base) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
32
templates/ecommerce/src/components/Input/index.tsx
Normal file
32
templates/ecommerce/src/components/Input/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FieldValues, UseFormRegister } from 'react-hook-form'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
register: UseFormRegister<FieldValues & any>
|
||||||
|
required?: boolean
|
||||||
|
error: any
|
||||||
|
type?: 'text' | 'number' | 'password'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input: React.FC<Props> = ({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
register,
|
||||||
|
error,
|
||||||
|
type = 'text',
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={classes.input}>
|
||||||
|
<label htmlFor="name" className={classes.label}>
|
||||||
|
{`${label} ${required ? '*' : ''}`}
|
||||||
|
</label>
|
||||||
|
<input {...{ type }} {...register(name, { required })} />
|
||||||
|
{error && <div className={classes.error}>This field is required</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@import '../../css/type.scss';
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@extend %label;
|
||||||
|
}
|
||||||
7
templates/ecommerce/src/components/Label/index.tsx
Normal file
7
templates/ecommerce/src/components/Label/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
return <p className={classes.label}>{children}</p>
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@import '../../css/type.scss';
|
||||||
|
|
||||||
|
.largeBody {
|
||||||
|
@extend %large-body;
|
||||||
|
}
|
||||||
7
templates/ecommerce/src/components/LargeBody/index.tsx
Normal file
7
templates/ecommerce/src/components/LargeBody/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const LargeBody: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
return <p className={classes.largeBody}>{children}</p>
|
||||||
|
}
|
||||||
66
templates/ecommerce/src/components/Link/index.tsx
Normal file
66
templates/ecommerce/src/components/Link/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { Page } from '../../payload-types'
|
||||||
|
import { Button } from '../Button'
|
||||||
|
|
||||||
|
type CMSLinkType = {
|
||||||
|
type?: 'custom' | 'reference'
|
||||||
|
url?: string
|
||||||
|
newTab?: boolean
|
||||||
|
reference?: {
|
||||||
|
value: string | Page
|
||||||
|
relationTo: 'pages'
|
||||||
|
}
|
||||||
|
label?: string
|
||||||
|
appearance?: 'default' | 'primary' | 'secondary'
|
||||||
|
children?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CMSLink: React.FC<CMSLinkType> = ({
|
||||||
|
type,
|
||||||
|
url,
|
||||||
|
newTab,
|
||||||
|
reference,
|
||||||
|
label,
|
||||||
|
appearance,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const href =
|
||||||
|
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
|
||||||
|
? `/${reference.value.slug}`
|
||||||
|
: url
|
||||||
|
|
||||||
|
if (!appearance) {
|
||||||
|
const newTabProps = newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}
|
||||||
|
|
||||||
|
if (type === 'custom') {
|
||||||
|
return (
|
||||||
|
<a href={url} {...newTabProps} className={className}>
|
||||||
|
{label && label}
|
||||||
|
{children && children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link {...newTabProps} href={href} className={className}>
|
||||||
|
{label && label}
|
||||||
|
{children && children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonProps = {
|
||||||
|
newTab,
|
||||||
|
href,
|
||||||
|
appearance,
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Button className={className} {...buttonProps} />
|
||||||
|
}
|
||||||
54
templates/ecommerce/src/components/Logo/index.tsx
Normal file
54
templates/ecommerce/src/components/Logo/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const Logo: React.FC<{
|
||||||
|
color?: 'white' | 'black'
|
||||||
|
}> = props => {
|
||||||
|
const { color = 'black' } = props
|
||||||
|
|
||||||
|
const fill = color === 'white' ? `var(--color-base-0)` : `var(--color-base-1000)`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="123"
|
||||||
|
height="29"
|
||||||
|
viewBox="0 0 123 29"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M34.7441 22.9997H37.2741V16.3297H41.5981C44.7031 16.3297 46.9801 14.9037 46.9801 11.4537C46.9801 8.00369 44.7031 6.55469 41.5981 6.55469H34.7441V22.9997ZM37.2741 14.1447V8.73969H41.4831C43.3921 8.73969 44.3581 9.59069 44.3581 11.4537C44.3581 13.2937 43.3921 14.1447 41.4831 14.1447H37.2741Z"
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M51.3652 23.3217C53.2742 23.3217 54.6082 22.5627 55.3672 21.3437H55.4132C55.5512 22.6777 56.1492 23.1147 57.2762 23.1147C57.6442 23.1147 58.0352 23.0687 58.4262 22.9767V21.5967C58.2882 21.6197 58.2192 21.6197 58.1502 21.6197C57.7132 21.6197 57.5982 21.1827 57.5982 20.3317V14.9497C57.5982 11.9137 55.6662 10.9017 53.2512 10.9017C49.6632 10.9017 48.1912 12.6727 48.0762 14.9267H50.3762C50.4912 13.3627 51.1122 12.7187 53.1592 12.7187C54.8842 12.7187 55.3902 13.4317 55.3902 14.2827C55.3902 15.4327 54.2632 15.6627 52.4232 16.0077C49.5022 16.5597 47.5242 17.3417 47.5242 19.9637C47.5242 21.9647 49.0192 23.3217 51.3652 23.3217ZM49.8702 19.8027C49.8702 18.5837 50.7442 18.0087 52.8142 17.5947C54.0102 17.3417 55.0222 17.0887 55.3902 16.7437V18.4227C55.3902 20.4697 53.8952 21.5047 51.8712 21.5047C50.4682 21.5047 49.8702 20.9067 49.8702 19.8027Z"
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M61.4996 27.1167C63.3166 27.1167 64.4436 26.1737 65.5706 23.2757L70.2166 11.2697H67.8476L64.6276 20.2397H64.5816L61.1546 11.2697H58.6936L63.4316 22.8847C62.9716 24.7247 61.9136 25.1847 61.0166 25.1847C60.6486 25.1847 60.4416 25.1617 60.0506 25.1157V26.9557C60.6486 27.0707 60.9936 27.1167 61.4996 27.1167Z"
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<path d="M71.5939 22.9997H73.8479V6.55469H71.5939V22.9997Z" fill={fill} />
|
||||||
|
<path
|
||||||
|
d="M81.6221 23.3447C85.2791 23.3447 87.4871 20.7917 87.4871 17.1117C87.4871 13.4547 85.2791 10.9017 81.6451 10.9017C77.9651 10.9017 75.7571 13.4777 75.7571 17.1347C75.7571 20.8147 77.9651 23.3447 81.6221 23.3447ZM78.1031 17.1347C78.1031 14.6737 79.2071 12.7877 81.6451 12.7877C84.0371 12.7877 85.1411 14.6737 85.1411 17.1347C85.1411 19.5727 84.0371 21.4817 81.6451 21.4817C79.2071 21.4817 78.1031 19.5727 78.1031 17.1347Z"
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M92.6484 23.3217C94.5574 23.3217 95.8914 22.5627 96.6504 21.3437H96.6964C96.8344 22.6777 97.4324 23.1147 98.5594 23.1147C98.9274 23.1147 99.3184 23.0687 99.7094 22.9767V21.5967C99.5714 21.6197 99.5024 21.6197 99.4334 21.6197C98.9964 21.6197 98.8814 21.1827 98.8814 20.3317V14.9497C98.8814 11.9137 96.9494 10.9017 94.5344 10.9017C90.9464 10.9017 89.4744 12.6727 89.3594 14.9267H91.6594C91.7744 13.3627 92.3954 12.7187 94.4424 12.7187C96.1674 12.7187 96.6734 13.4317 96.6734 14.2827C96.6734 15.4327 95.5464 15.6627 93.7064 16.0077C90.7854 16.5597 88.8074 17.3417 88.8074 19.9637C88.8074 21.9647 90.3024 23.3217 92.6484 23.3217ZM91.1534 19.8027C91.1534 18.5837 92.0274 18.0087 94.0974 17.5947C95.2934 17.3417 96.3054 17.0887 96.6734 16.7437V18.4227C96.6734 20.4697 95.1784 21.5047 93.1544 21.5047C91.7514 21.5047 91.1534 20.9067 91.1534 19.8027Z"
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M106.181 23.3217C108.021 23.3217 109.148 22.4477 109.792 21.6197H109.838V22.9997H112.092V6.55469H109.838V12.6957H109.792C109.148 11.7757 108.021 10.9247 106.181 10.9247C103.191 10.9247 100.914 13.2707 100.914 17.1347C100.914 20.9987 103.191 23.3217 106.181 23.3217ZM103.26 17.1347C103.26 14.8347 104.341 12.8107 106.549 12.8107C108.573 12.8107 109.815 14.4667 109.815 17.1347C109.815 19.7797 108.573 21.4587 106.549 21.4587C104.341 21.4587 103.26 19.4347 103.26 17.1347Z"
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12.2464 2.33838L22.2871 8.83812V21.1752L14.7265 25.8854V13.5484L4.67383 7.05725L12.2464 2.33838Z"
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<path d="M11.477 25.2017V15.5747L3.90039 20.2936L11.477 25.2017Z" fill={fill} />
|
||||||
|
<path
|
||||||
|
d="M120.442 6.30273C119.086 6.30273 117.998 7.29978 117.998 8.75952C117.998 10.2062 119.086 11.1968 120.442 11.1968C121.791 11.1968 122.879 10.2062 122.879 8.75952C122.879 7.29978 121.791 6.30273 120.442 6.30273ZM120.442 10.7601C119.34 10.7601 118.48 9.95207 118.48 8.75952C118.48 7.54742 119.34 6.73935 120.442 6.73935C121.563 6.73935 122.397 7.54742 122.397 8.75952C122.397 9.95207 121.563 10.7601 120.442 10.7601ZM120.52 8.97457L121.048 9.9651H121.641L121.041 8.86378C121.367 8.72042 121.511 8.45975 121.511 8.17302C121.511 7.49528 121.054 7.36495 120.285 7.36495H119.49V9.9651H120.025V8.97457H120.52ZM120.37 7.78853C120.729 7.78853 120.976 7.86673 120.976 8.17953C120.976 8.43368 120.807 8.56402 120.403 8.56402H120.025V7.78853H120.37Z"
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.placeholder-color-light {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
background-color: var(--color-base-50);
|
||||||
|
}
|
||||||
73
templates/ecommerce/src/components/Media/Image/index.tsx
Normal file
73
templates/ecommerce/src/components/Media/Image/index.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import NextImage, { StaticImageData } from 'next/image'
|
||||||
|
|
||||||
|
import cssVariables from '../../../cssVariables'
|
||||||
|
import { Props as MediaProps } from '../types'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
const { breakpoints } = cssVariables
|
||||||
|
|
||||||
|
export const Image: React.FC<MediaProps> = props => {
|
||||||
|
const {
|
||||||
|
imgClassName,
|
||||||
|
onClick,
|
||||||
|
onLoad: onLoadFromProps,
|
||||||
|
resource,
|
||||||
|
priority,
|
||||||
|
fill,
|
||||||
|
src: srcFromProps,
|
||||||
|
alt: altFromProps,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true)
|
||||||
|
|
||||||
|
let width: number | undefined
|
||||||
|
let height: number | undefined
|
||||||
|
let alt = altFromProps
|
||||||
|
let src: StaticImageData | string = srcFromProps
|
||||||
|
|
||||||
|
if (!src && resource && typeof resource !== 'string') {
|
||||||
|
const {
|
||||||
|
width: fullWidth,
|
||||||
|
height: fullHeight,
|
||||||
|
filename: fullFilename,
|
||||||
|
alt: altFromResource,
|
||||||
|
} = resource
|
||||||
|
|
||||||
|
width = fullWidth
|
||||||
|
height = fullHeight
|
||||||
|
alt = altFromResource
|
||||||
|
|
||||||
|
const filename = fullFilename
|
||||||
|
|
||||||
|
src = `${process.env.NEXT_PUBLIC_SERVER_URL}/media/${filename}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: this is used by the browser to determine which image to download at different screen sizes
|
||||||
|
const sizes = Object.entries(breakpoints)
|
||||||
|
.map(([, value]) => `(max-width: ${value}px) ${value}px`)
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextImage
|
||||||
|
className={[isLoading && classes.placeholder, classes.image, imgClassName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
onClick={onClick}
|
||||||
|
onLoad={() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
if (typeof onLoadFromProps === 'function') {
|
||||||
|
onLoadFromProps()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fill={fill}
|
||||||
|
width={!fill ? width : undefined}
|
||||||
|
height={!fill ? height : undefined}
|
||||||
|
sizes={sizes}
|
||||||
|
priority={priority}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.video {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--color-base-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
43
templates/ecommerce/src/components/Media/Video/index.tsx
Normal file
43
templates/ecommerce/src/components/Media/Video/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { Props as MediaProps } from '../types'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const Video: React.FC<MediaProps> = props => {
|
||||||
|
const { videoClassName, resource, onClick } = props
|
||||||
|
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
// const [showFallback] = useState<boolean>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { current: video } = videoRef
|
||||||
|
if (video) {
|
||||||
|
video.addEventListener('suspend', () => {
|
||||||
|
// setShowFallback(true);
|
||||||
|
// console.warn('Video was suspended, rendering fallback image.')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (resource && typeof resource !== 'string') {
|
||||||
|
const { filename } = resource
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
playsInline
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
controls={false}
|
||||||
|
className={[classes.video, videoClassName].filter(Boolean).join(' ')}
|
||||||
|
onClick={onClick}
|
||||||
|
ref={videoRef}
|
||||||
|
>
|
||||||
|
<source src={`${process.env.NEXT_PUBLIC_API_URL}/media/${filename}`} />
|
||||||
|
</video>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
28
templates/ecommerce/src/components/Media/index.tsx
Normal file
28
templates/ecommerce/src/components/Media/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React, { ElementType, Fragment } from 'react'
|
||||||
|
|
||||||
|
import { Image } from './Image'
|
||||||
|
import { Props } from './types'
|
||||||
|
import { Video } from './Video'
|
||||||
|
|
||||||
|
export const Media: React.FC<Props> = props => {
|
||||||
|
const { className, resource, htmlElement = 'div' } = props
|
||||||
|
|
||||||
|
const isVideo = typeof resource !== 'string' && resource?.mimeType?.includes('video')
|
||||||
|
const Tag = (htmlElement as ElementType) || Fragment
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
{...(htmlElement !== null
|
||||||
|
? {
|
||||||
|
className,
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
|
{isVideo ? (
|
||||||
|
<Video {...props} />
|
||||||
|
) : (
|
||||||
|
<Image {...props} /> // eslint-disable-line
|
||||||
|
)}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
templates/ecommerce/src/components/Media/types.ts
Normal file
20
templates/ecommerce/src/components/Media/types.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ElementType, Ref } from 'react'
|
||||||
|
import type { StaticImageData } from 'next/image'
|
||||||
|
|
||||||
|
import type { Media as MediaType } from '../../payload-types'
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
src?: StaticImageData // for static media
|
||||||
|
alt?: string
|
||||||
|
resource?: string | MediaType // for Payload media
|
||||||
|
size?: string // for NextImage only
|
||||||
|
priority?: boolean // for NextImage only
|
||||||
|
fill?: boolean // for NextImage only
|
||||||
|
className?: string
|
||||||
|
imgClassName?: string
|
||||||
|
videoClassName?: string
|
||||||
|
htmlElement?: ElementType | null
|
||||||
|
onClick?: () => void
|
||||||
|
onLoad?: () => void
|
||||||
|
ref?: Ref<null | HTMLImageElement | HTMLVideoElement>
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@import '../../css/common';
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 var(--base(0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hyperlink {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
52
templates/ecommerce/src/components/PageRange/index.tsx
Normal file
52
templates/ecommerce/src/components/PageRange/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
const defaultLabels = {
|
||||||
|
singular: 'Doc',
|
||||||
|
plural: 'Docs',
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultCollectionLabels = {
|
||||||
|
products: {
|
||||||
|
singular: 'Product',
|
||||||
|
plural: 'Products',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageRange: React.FC<{
|
||||||
|
className?: string
|
||||||
|
totalDocs?: number
|
||||||
|
currentPage?: number
|
||||||
|
collection?: string
|
||||||
|
limit?: number
|
||||||
|
collectionLabels?: {
|
||||||
|
singular?: string
|
||||||
|
plural?: string
|
||||||
|
}
|
||||||
|
}> = props => {
|
||||||
|
const {
|
||||||
|
className,
|
||||||
|
totalDocs,
|
||||||
|
currentPage,
|
||||||
|
collection,
|
||||||
|
limit,
|
||||||
|
collectionLabels: collectionLabelsFromProps,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const indexStart = (currentPage ? currentPage - 1 : 1) * (limit || 1) + 1
|
||||||
|
let indexEnd = (currentPage || 1) * (limit || 1)
|
||||||
|
if (totalDocs && indexEnd > totalDocs) indexEnd = totalDocs
|
||||||
|
|
||||||
|
const { singular, plural } =
|
||||||
|
collectionLabelsFromProps || defaultCollectionLabels[collection] || defaultLabels || {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[className, classes.pageRange].filter(Boolean).join(' ')}>
|
||||||
|
{typeof totalDocs === 'undefined' || (totalDocs === 0 && 'Search produced no results')}
|
||||||
|
{typeof totalDocs !== 'undefined' &&
|
||||||
|
totalDocs > 0 &&
|
||||||
|
`Showing ${indexStart} - ${indexEnd} of ${totalDocs} ${totalDocs > 1 ? plural : singular}`}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
templates/ecommerce/src/components/PaywallBlocks/index.tsx
Normal file
64
templates/ecommerce/src/components/PaywallBlocks/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect } from 'react'
|
||||||
|
|
||||||
|
import { ARCHIVE_BLOCK, CALL_TO_ACTION, CONTENT, MEDIA_BLOCK } from '../../graphql/blocks'
|
||||||
|
import { Page } from '../../payload-types'
|
||||||
|
import { useAuth } from '../../providers/Auth'
|
||||||
|
import { Blocks } from '../Blocks'
|
||||||
|
|
||||||
|
export const PaywallBlocks: React.FC<{
|
||||||
|
productSlug: string
|
||||||
|
disableTopPadding?: boolean
|
||||||
|
}> = props => {
|
||||||
|
const { productSlug, disableTopPadding } = props
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false)
|
||||||
|
const [blocks, setBlocks] = React.useState<Page['layout']>()
|
||||||
|
const hasInitialized = React.useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || hasInitialized.current) return
|
||||||
|
hasInitialized.current = true
|
||||||
|
|
||||||
|
const getPaywallContent = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
const res = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/graphql`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `query {
|
||||||
|
Products(where: { slug: { equals: "${productSlug}" }}) {
|
||||||
|
docs {
|
||||||
|
paywall {
|
||||||
|
${CALL_TO_ACTION}
|
||||||
|
${CONTENT}
|
||||||
|
${MEDIA_BLOCK}
|
||||||
|
${ARCHIVE_BLOCK}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data } = await res.json()
|
||||||
|
const paywall = data.Products?.docs?.[0]?.paywall
|
||||||
|
|
||||||
|
if (paywall) {
|
||||||
|
setBlocks(paywall)
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
getPaywallContent()
|
||||||
|
}, [user, productSlug])
|
||||||
|
|
||||||
|
if (isLoading || !blocks || blocks.length === 0) return null
|
||||||
|
|
||||||
|
return <Blocks blocks={blocks} disableTopPadding={disableTopPadding} />
|
||||||
|
}
|
||||||
18
templates/ecommerce/src/components/Price/index.module.scss
Normal file
18
templates/ecommerce/src/components/Price/index.module.scss
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
@import '../../css/common';
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
margin-right: calc(var(--base) / 2);
|
||||||
|
}
|
||||||
60
templates/ecommerce/src/components/Price/index.tsx
Normal file
60
templates/ecommerce/src/components/Price/index.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { Product } from '../../payload-types'
|
||||||
|
import { AddToCartButton } from '../AddToCartButton'
|
||||||
|
import { RemoveFromCartButton } from '../RemoveFromCartButton'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const priceFromJSON = (priceJSON): string => {
|
||||||
|
let price = ''
|
||||||
|
|
||||||
|
if (priceJSON) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(priceJSON)?.data[0]
|
||||||
|
const priceValue = parsed.unit_amount
|
||||||
|
const priceType = parsed.type
|
||||||
|
|
||||||
|
price = (priceValue / 100).toLocaleString('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD', // TODO: use `parsed.currency`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (priceType === 'recurring') {
|
||||||
|
price += `/${
|
||||||
|
parsed.recurring.interval_count > 1
|
||||||
|
? `${parsed.recurring.interval_count} ${parsed.recurring.interval}`
|
||||||
|
: parsed.recurring.interval
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Cannot parse priceJSON`) // eslint-disable-line no-console
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return price
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Price: React.FC<{
|
||||||
|
product: Product
|
||||||
|
quantity?: number
|
||||||
|
button?: 'addToCart' | 'removeFromCart' | false
|
||||||
|
}> = props => {
|
||||||
|
const { product, product: { priceJSON } = {}, button = 'addToCart' } = props
|
||||||
|
|
||||||
|
const [price, setPrice] = useState(() => priceFromJSON(priceJSON))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPrice(priceFromJSON(priceJSON))
|
||||||
|
}, [priceJSON])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.actions}>
|
||||||
|
{typeof price !== 'undefined' && price !== '' && <p className={classes.price}>{price}</p>}
|
||||||
|
{button && button === 'addToCart' && (
|
||||||
|
<AddToCartButton product={product} appearance="default" />
|
||||||
|
)}
|
||||||
|
{button && button === 'removeFromCart' && <RemoveFromCartButton product={product} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.removeFromCartButton {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Product } from '../../payload-types'
|
||||||
|
import { useCart } from '../../providers/Cart'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export const RemoveFromCartButton: React.FC<{
|
||||||
|
className?: string
|
||||||
|
product: Product
|
||||||
|
}> = props => {
|
||||||
|
const { className, product } = props
|
||||||
|
|
||||||
|
const { deleteItemFromCart, isProductInCart } = useCart()
|
||||||
|
|
||||||
|
const productIsInCart = isProductInCart(product)
|
||||||
|
|
||||||
|
if (!productIsInCart) {
|
||||||
|
return <div>Item is not in the cart</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
deleteItemFromCart(product)
|
||||||
|
}}
|
||||||
|
className={[className, classes.removeFromCartButton].filter(Boolean).join(' ')}
|
||||||
|
>
|
||||||
|
Remove from cart
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.richText {
|
||||||
|
:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
templates/ecommerce/src/components/RichText/index.tsx
Normal file
19
templates/ecommerce/src/components/RichText/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import serialize from './serialize'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
const RichText: React.FC<{ className?: string; content: any }> = ({ className, content }) => {
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[classes.richText, className].filter(Boolean).join(' ')}>
|
||||||
|
{serialize(content)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RichText
|
||||||
111
templates/ecommerce/src/components/RichText/serialize.tsx
Normal file
111
templates/ecommerce/src/components/RichText/serialize.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import React, { Fragment } from 'react'
|
||||||
|
import escapeHTML from 'escape-html'
|
||||||
|
import { Text } from 'slate'
|
||||||
|
|
||||||
|
import { Label } from '../Label'
|
||||||
|
import { LargeBody } from '../LargeBody'
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-use-before-define
|
||||||
|
type Children = Leaf[]
|
||||||
|
|
||||||
|
type Leaf = {
|
||||||
|
type: string
|
||||||
|
value?: {
|
||||||
|
url: string
|
||||||
|
alt: string
|
||||||
|
}
|
||||||
|
children?: Children
|
||||||
|
url?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialize = (children: Children): React.ReactElement[] =>
|
||||||
|
children.map((node, i) => {
|
||||||
|
if (Text.isText(node)) {
|
||||||
|
let text = <span dangerouslySetInnerHTML={{ __html: escapeHTML(node.text) }} />
|
||||||
|
|
||||||
|
if (node.bold) {
|
||||||
|
text = <strong key={i}>{text}</strong>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.code) {
|
||||||
|
text = <code key={i}>{text}</code>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.italic) {
|
||||||
|
text = <em key={i}>{text}</em>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.underline) {
|
||||||
|
text = (
|
||||||
|
<span style={{ textDecoration: 'underline' }} key={i}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.strikethrough) {
|
||||||
|
text = (
|
||||||
|
<span style={{ textDecoration: 'line-through' }} key={i}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Fragment key={i}>{text}</Fragment>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case 'h1':
|
||||||
|
return <h1 key={i}>{serialize(node.children)}</h1>
|
||||||
|
case 'h2':
|
||||||
|
return <h2 key={i}>{serialize(node.children)}</h2>
|
||||||
|
case 'h3':
|
||||||
|
return <h3 key={i}>{serialize(node.children)}</h3>
|
||||||
|
case 'h4':
|
||||||
|
return <h4 key={i}>{serialize(node.children)}</h4>
|
||||||
|
case 'h5':
|
||||||
|
return <h5 key={i}>{serialize(node.children)}</h5>
|
||||||
|
case 'h6':
|
||||||
|
return <h6 key={i}>{serialize(node.children)}</h6>
|
||||||
|
case 'quote':
|
||||||
|
return <blockquote key={i}>{serialize(node.children)}</blockquote>
|
||||||
|
case 'ul':
|
||||||
|
return <ul key={i}>{serialize(node.children)}</ul>
|
||||||
|
case 'ol':
|
||||||
|
return <ol key={i}>{serialize(node.children)}</ol>
|
||||||
|
case 'li':
|
||||||
|
return <li key={i}>{serialize(node.children)}</li>
|
||||||
|
case 'link':
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={escapeHTML(node.url)}
|
||||||
|
key={i}
|
||||||
|
{...(node.newTab
|
||||||
|
? {
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer',
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
|
{serialize(node.children)}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'label':
|
||||||
|
return <Label key={i}>{serialize(node.children)}</Label>
|
||||||
|
|
||||||
|
case 'large-body': {
|
||||||
|
return <LargeBody key={i}>{serialize(node.children)}</LargeBody>
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <p key={i}>{serialize(node.children)}</p>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default serialize
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.top-large {
|
||||||
|
padding-top: var(--block-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-medium {
|
||||||
|
padding-top: calc(var(--block-padding) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-large {
|
||||||
|
padding-bottom: var(--block-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-medium {
|
||||||
|
padding-bottom: calc(var(--block-padding) / 2);
|
||||||
|
}
|
||||||
29
templates/ecommerce/src/components/VerticalPadding/index.tsx
Normal file
29
templates/ecommerce/src/components/VerticalPadding/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import classes from './index.module.scss'
|
||||||
|
|
||||||
|
export type VerticalPaddingOptions = 'large' | 'medium' | 'none'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
top?: VerticalPaddingOptions
|
||||||
|
bottom?: VerticalPaddingOptions
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerticalPadding: React.FC<Props> = ({
|
||||||
|
top = 'medium',
|
||||||
|
bottom = 'medium',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[className, classes[`top-${top}`], classes[`bottom-${bottom}`]]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const Chevron: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10.5 16L14.5 12.5L10.5 9" stroke="currentColor" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
templates/ecommerce/src/components/icons/Menu/index.tsx
Normal file
11
templates/ecommerce/src/components/icons/Menu/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const MenuIcon: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3.5" y="4.5" width="18" height="2" fill="currentColor" />
|
||||||
|
<rect x="3.5" y="11.5" width="18" height="2" fill="currentColor" />
|
||||||
|
<rect x="3.5" y="18.5" width="18" height="2" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
templates/ecommerce/src/css/app.scss
Normal file
129
templates/ecommerce/src/css/app.scss
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
@use './queries.scss' as *;
|
||||||
|
@use './colors.scss' as *;
|
||||||
|
@use './type.scss' as *;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--breakpoint-xs-width : #{$breakpoint-xs-width};
|
||||||
|
--breakpoint-s-width : #{$breakpoint-s-width};
|
||||||
|
--breakpoint-m-width : #{$breakpoint-m-width};
|
||||||
|
--breakpoint-l-width : #{$breakpoint-l-width};
|
||||||
|
--scrollbar-width: 17px;
|
||||||
|
|
||||||
|
--base: 24px;
|
||||||
|
--font-body: system-ui;
|
||||||
|
--font-mono: 'Roboto Mono', monospace;
|
||||||
|
|
||||||
|
--gutter-h: 180px;
|
||||||
|
--block-padding: 120px;
|
||||||
|
|
||||||
|
--header-z-index: 100;
|
||||||
|
--modal-z-index: 90;
|
||||||
|
|
||||||
|
@include large-break {
|
||||||
|
--gutter-h: 144px;
|
||||||
|
--block-padding: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
--gutter-h: 24px;
|
||||||
|
--block-padding: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////////////
|
||||||
|
// GLOBAL STYLES
|
||||||
|
/////////////////////////////
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
@extend %body;
|
||||||
|
background: var(--color-base-0);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--color-base-1000);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--color-success-500);
|
||||||
|
color: var(--color-base-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-selection {
|
||||||
|
background: var(--color-success-500);
|
||||||
|
color: var(--color-base-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
@extend %h1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
@extend %h2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
@extend %h3;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
@extend %h4;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
@extend %h5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
@extend %h6;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: var(--base) 0;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin: calc(var(--base) * .75) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding-left: var(--base);
|
||||||
|
margin: 0 0 var(--base);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: currentColor;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
opacity: .8;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: .7;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
83
templates/ecommerce/src/css/colors.scss
Normal file
83
templates/ecommerce/src/css/colors.scss
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
:root {
|
||||||
|
--color-base-0: rgb(255, 255, 255);
|
||||||
|
--color-base-50: rgb(245, 245, 245);
|
||||||
|
--color-base-100: rgb(235, 235, 235);
|
||||||
|
--color-base-150: rgb(221, 221, 221);
|
||||||
|
--color-base-200: rgb(208, 208, 208);
|
||||||
|
--color-base-250: rgb(195, 195, 195);
|
||||||
|
--color-base-300: rgb(181, 181, 181);
|
||||||
|
--color-base-350: rgb(168, 168, 168);
|
||||||
|
--color-base-400: rgb(154, 154, 154);
|
||||||
|
--color-base-450: rgb(141, 141, 141);
|
||||||
|
--color-base-500: rgb(128, 128, 128);
|
||||||
|
--color-base-550: rgb(114, 114, 114);
|
||||||
|
--color-base-600: rgb(101, 101, 101);
|
||||||
|
--color-base-650: rgb(87, 87, 87);
|
||||||
|
--color-base-700: rgb(74, 74, 74);
|
||||||
|
--color-base-750: rgb(60, 60, 60);
|
||||||
|
--color-base-800: rgb(47, 47, 47);
|
||||||
|
--color-base-850: rgb(34, 34, 34);
|
||||||
|
--color-base-900: rgb(20, 20, 20);
|
||||||
|
--color-base-950: rgb(7, 7, 7);
|
||||||
|
--color-base-1000: rgb(0, 0, 0);
|
||||||
|
|
||||||
|
--color-success-50: rgb(247, 255, 251);
|
||||||
|
--color-success-100: rgb(240, 255, 247);
|
||||||
|
--color-success-150: rgb(232, 255, 243);
|
||||||
|
--color-success-200: rgb(224, 255, 239);
|
||||||
|
--color-success-250: rgb(217, 255, 235);
|
||||||
|
--color-success-300: rgb(209, 255, 230);
|
||||||
|
--color-success-350: rgb(201, 255, 226);
|
||||||
|
--color-success-400: rgb(193, 255, 222);
|
||||||
|
--color-success-450: rgb(186, 255, 218);
|
||||||
|
--color-success-500: rgb(178, 255, 214);
|
||||||
|
--color-success-550: rgb(160, 230, 193);
|
||||||
|
--color-success-600: rgb(142, 204, 171);
|
||||||
|
--color-success-650: rgb(125, 179, 150);
|
||||||
|
--color-success-700: rgb(107, 153, 128);
|
||||||
|
--color-success-750: rgb(89, 128, 107);
|
||||||
|
--color-success-800: rgb(71, 102, 86);
|
||||||
|
--color-success-850: rgb(53, 77, 64);
|
||||||
|
--color-success-900: rgb(36, 51, 43);
|
||||||
|
--color-success-950: rgb(18, 25, 21);
|
||||||
|
|
||||||
|
--color-warning-50: rgb(255, 255, 246);
|
||||||
|
--color-warning-100: rgb(255, 255, 237);
|
||||||
|
--color-warning-150: rgb(254, 255, 228);
|
||||||
|
--color-warning-200: rgb(254, 255, 219);
|
||||||
|
--color-warning-250: rgb(254, 255, 210);
|
||||||
|
--color-warning-300: rgb(254, 255, 200);
|
||||||
|
--color-warning-350: rgb(254, 255, 191);
|
||||||
|
--color-warning-400: rgb(253, 255, 182);
|
||||||
|
--color-warning-450: rgb(253, 255, 173);
|
||||||
|
--color-warning-500: rgb(253, 255, 164);
|
||||||
|
--color-warning-550: rgb(228, 230, 148);
|
||||||
|
--color-warning-600: rgb(202, 204, 131);
|
||||||
|
--color-warning-650: rgb(177, 179, 115);
|
||||||
|
--color-warning-700: rgb(152, 153, 98);
|
||||||
|
--color-warning-750: rgb(127, 128, 82);
|
||||||
|
--color-warning-800: rgb(101, 102, 66);
|
||||||
|
--color-warning-850: rgb(76, 77, 49);
|
||||||
|
--color-warning-900: rgb(51, 51, 33);
|
||||||
|
--color-warning-950: rgb(25, 25, 16);
|
||||||
|
|
||||||
|
--color-error-50: rgb(255, 241, 241);
|
||||||
|
--color-error-100: rgb(255, 226, 228);
|
||||||
|
--color-error-150: rgb(255, 212, 214);
|
||||||
|
--color-error-200: rgb(255, 197, 200);
|
||||||
|
--color-error-250: rgb(255, 183, 187);
|
||||||
|
--color-error-300: rgb(255, 169, 173);
|
||||||
|
--color-error-350: rgb(255, 154, 159);
|
||||||
|
--color-error-400: rgb(255, 140, 145);
|
||||||
|
--color-error-450: rgb(255, 125, 132);
|
||||||
|
--color-error-500: rgb(255, 111, 118);
|
||||||
|
--color-error-550: rgb(230, 100, 106);
|
||||||
|
--color-error-600: rgb(204, 89, 94);
|
||||||
|
--color-error-650: rgb(179, 78, 83);
|
||||||
|
--color-error-700: rgb(153, 67, 71);
|
||||||
|
--color-error-750: rgb(128, 56, 59);
|
||||||
|
--color-error-800: rgb(102, 44, 47);
|
||||||
|
--color-error-850: rgb(77, 33, 35);
|
||||||
|
--color-error-900: rgb(51, 22, 24);
|
||||||
|
--color-error-950: rgb(25, 11, 12);
|
||||||
|
}
|
||||||
2
templates/ecommerce/src/css/common.scss
Normal file
2
templates/ecommerce/src/css/common.scss
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@forward './queries.scss';
|
||||||
|
@forward './type.scss';
|
||||||
32
templates/ecommerce/src/css/queries.scss
Normal file
32
templates/ecommerce/src/css/queries.scss
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
$breakpoint-xs-width: 400px;
|
||||||
|
$breakpoint-s-width: 768px;
|
||||||
|
$breakpoint-m-width: 1024px;
|
||||||
|
$breakpoint-l-width: 1440px;
|
||||||
|
|
||||||
|
////////////////////////////
|
||||||
|
// MEDIA QUERIES
|
||||||
|
/////////////////////////////
|
||||||
|
|
||||||
|
@mixin extra-small-break {
|
||||||
|
@media (max-width: #{$breakpoint-xs-width}) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin small-break {
|
||||||
|
@media (max-width: #{$breakpoint-s-width}) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin mid-break {
|
||||||
|
@media (max-width: #{$breakpoint-m-width}) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin large-break {
|
||||||
|
@media (max-width: #{$breakpoint-l-width}) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
templates/ecommerce/src/css/type.scss
Normal file
119
templates/ecommerce/src/css/type.scss
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
@use 'queries' as *;
|
||||||
|
|
||||||
|
/////////////////////////////
|
||||||
|
// HEADINGS
|
||||||
|
/////////////////////////////
|
||||||
|
|
||||||
|
%h1,
|
||||||
|
%h2,
|
||||||
|
%h3,
|
||||||
|
%h4,
|
||||||
|
%h5,
|
||||||
|
%h6 {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
%h1 {
|
||||||
|
margin: 40px 0;
|
||||||
|
font-size: 64px;
|
||||||
|
line-height: 70px;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin: 24px 0;
|
||||||
|
font-size: 42px;
|
||||||
|
line-height: 42px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
%h2 {
|
||||||
|
margin: 28px 0;
|
||||||
|
font-size: 48px;
|
||||||
|
line-height: 54px;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin: 22px 0;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
%h3 {
|
||||||
|
margin: 24px 0;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 40px;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
%h4 {
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
%h5 {
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
%h6 {
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////////////
|
||||||
|
// TYPE STYLES
|
||||||
|
/////////////////////////////
|
||||||
|
|
||||||
|
%body {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
%large-body {
|
||||||
|
font-size: 25px;
|
||||||
|
line-height: 32px;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
%label {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
templates/ecommerce/src/cssVariables.js
Normal file
7
templates/ecommerce/src/cssVariables.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
breakpoints: {
|
||||||
|
s: 768,
|
||||||
|
m: 1024,
|
||||||
|
l: 1679,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import Stripe from 'stripe'
|
|||||||
import type { User } from '../payload-types'
|
import type { User } from '../payload-types'
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||||
apiVersion: '2022-11-15',
|
apiVersion: '2022-08-01',
|
||||||
})
|
})
|
||||||
|
|
||||||
// This endpoint creates a PaymentIntent with the items in the cart using the "Invoices" API
|
// This endpoint creates a PaymentIntent with the items in the cart using the "Invoices" API
|
||||||
21
templates/ecommerce/src/endpoints/seed.ts
Normal file
21
templates/ecommerce/src/endpoints/seed.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { PayloadHandler } from 'payload/config'
|
||||||
|
|
||||||
|
import { seed as seedScript } from '../seed'
|
||||||
|
|
||||||
|
export const seed: PayloadHandler = async (req, res): Promise<void> => {
|
||||||
|
const { user, payload } = req
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedScript(payload)
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
payload.logger.error(message)
|
||||||
|
res.json({ error: message })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
/* eslint-disable import/no-extraneous-dependencies */
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
// eslint-disable-next-line no-use-before-define
|
// eslint-disable-next-line no-use-before-define
|
||||||
import React from 'react';
|
import React from 'react'
|
||||||
import { ElementButton } from 'payload/components/rich-text';
|
import { ElementButton } from 'payload/components/rich-text'
|
||||||
import Icon from '../Icon';
|
|
||||||
|
|
||||||
const baseClass = 'rich-text-label-button';
|
import Icon from '../Icon'
|
||||||
|
|
||||||
|
const baseClass = 'rich-text-label-button'
|
||||||
|
|
||||||
const ToolbarButton: React.FC<{ path: string }> = () => (
|
const ToolbarButton: React.FC<{ path: string }> = () => (
|
||||||
<ElementButton
|
<ElementButton className={baseClass} format="label">
|
||||||
className={baseClass}
|
|
||||||
format="label"
|
|
||||||
>
|
|
||||||
<Icon />
|
<Icon />
|
||||||
</ElementButton>
|
</ElementButton>
|
||||||
);
|
)
|
||||||
|
|
||||||
export default ToolbarButton;
|
export default ToolbarButton
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react'
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss'
|
||||||
|
|
||||||
const baseClass = 'rich-text-label';
|
const baseClass = 'rich-text-label'
|
||||||
|
|
||||||
const LabelElement: React.FC<{
|
const LabelElement: React.FC<{
|
||||||
attributes: any
|
attributes: any
|
||||||
element: any
|
element: any
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}> = ({ attributes, children }) => (
|
}> = ({ attributes, children }) => (
|
||||||
<div
|
<div {...attributes}>
|
||||||
{...attributes}
|
<span className={baseClass}>{children}</span>
|
||||||
>
|
|
||||||
<span className={baseClass}>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
export default LabelElement;
|
export default LabelElement
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
/* eslint-disable no-use-before-define */
|
/* eslint-disable no-use-before-define */
|
||||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
import React from 'react';
|
import React from 'react'
|
||||||
|
|
||||||
const Icon = () => (
|
const Icon = () => (
|
||||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M8.08884 15.2753L8.79758 17.7598H10.916L7.28663 6.41986H5.46413L1.75684 17.7598H3.88308L4.59962 15.2753H8.08884ZM5.10586 13.5385L6.36759 9.20812L7.59816 13.5385H5.10586Z" fill="currentColor" />
|
<path
|
||||||
<path d="M21.1778 15.2753L21.8865 17.7598H24.005L20.3756 6.41986H18.5531L14.8458 17.7598H16.972L17.6886 15.2753H21.1778ZM18.1948 13.5385L19.4565 9.20812L20.6871 13.5385H18.1948Z" fill="currentColor" />
|
d="M8.08884 15.2753L8.79758 17.7598H10.916L7.28663 6.41986H5.46413L1.75684 17.7598H3.88308L4.59962 15.2753H8.08884ZM5.10586 13.5385L6.36759 9.20812L7.59816 13.5385H5.10586Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21.1778 15.2753L21.8865 17.7598H24.005L20.3756 6.41986H18.5531L14.8458 17.7598H16.972L17.6886 15.2753H21.1778ZM18.1948 13.5385L19.4565 9.20812L20.6871 13.5385H18.1948Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
);
|
export default Icon
|
||||||
|
|
||||||
export default Icon;
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user