chore: adds front-end to ecommerce template (#2942)

This commit is contained in:
Jacob Fletcher
2023-06-30 17:36:58 -04:00
committed by GitHub
parent 29d8bf0927
commit a8e47088bb
156 changed files with 7828 additions and 1064 deletions

View File

@@ -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=

View File

@@ -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'],
} }

View File

@@ -4,3 +4,5 @@ dist
node_modules node_modules
.DS_Store .DS_Store
.env .env
.next
.vercel

View File

@@ -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.

View 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
View 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.

View 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

View File

@@ -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"
} }

View File

@@ -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"
} }
} }

View File

@@ -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)

View File

@@ -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);
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
import type { Page } from '../../payload-types'
export type ArchiveBlockProps = Extract<Page['layout'][0], { blockType: 'archive' }>

View File

@@ -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
}
}

View 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>
)
}

View 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);
}

View 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>
)
}

View File

@@ -0,0 +1,7 @@
.mediaBlock {
position: relative;
}
.caption {
margin-top: var(--base)
}

View 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>
)
}

View File

@@ -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'

View File

@@ -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

View File

@@ -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'

View File

@@ -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 }) => {

View File

@@ -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;
}

View 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"
/>
)
}

View File

@@ -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;
}

View 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>
)
}

View File

@@ -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);
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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&apos;s what to do next: Here&apos;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"

View 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
}

View 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;
}

View 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>
)
}

View 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;
}
}

View 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>, &nbsp;</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>
)
}

View File

@@ -0,0 +1,9 @@
.cartLink {
cursor: pointer;
display: flex;
align-items: center;
}
.quantity {
margin-left: 2px;
}

View 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>
)
}

View File

@@ -0,0 +1,9 @@
@import "../../css/common";
.checkoutButton {
margin-top: var(--base);
}
.error {
margin-top: calc(var(--block-padding) - var(--base));
}

View 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

View File

@@ -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);
}
}

View 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>
)
}

View 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;
}
}

View 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>
)
}

View File

@@ -0,0 +1,7 @@
.gutterLeft {
padding-left: var(--gutter-h);
}
.gutterRight {
padding-right: var(--gutter-h);
}

View 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'

View File

@@ -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>
)
}

View 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;
}
}

View 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} />
</>
)
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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>
)
}

View File

@@ -0,0 +1,4 @@
@use '../../../css/type.scss' as *;
.lowImpactHero {
}

View 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>
)
}

View File

@@ -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));
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View 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>, &nbsp;</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>
)
}

View 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} />
}

View 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;
}

View 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>
)
}

View File

@@ -0,0 +1,5 @@
@import '../../css/type.scss';
.label {
@extend %label;
}

View 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>
}

View File

@@ -0,0 +1,5 @@
@import '../../css/type.scss';
.largeBody {
@extend %large-body;
}

View 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>
}

View 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} />
}

View 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>
)
}

View File

@@ -0,0 +1,7 @@
.placeholder-color-light {
background-color: rgba(0, 0, 0, 0.05);
}
.placeholder {
background-color: var(--color-base-50);
}

View 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}
/>
)
}

View File

@@ -0,0 +1,11 @@
.video {
max-width: 100%;
width: 100%;
background-color: var(--color-base-50);
}
.cover {
object-fit: cover;
width: 100%;
height: 100%;
}

View 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
}

View 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>
)
}

View 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>
}

View File

@@ -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;
}

View 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>
)
}

View 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} />
}

View 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);
}

View 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>
)
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,8 @@
.richText {
:first-child {
margin-top: 0;
}
:last-child {
margin-bottom: 0;
}
}

View 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

View 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

View File

@@ -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);
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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;
}

View 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);
}

View File

@@ -0,0 +1,2 @@
@forward './queries.scss';
@forward './type.scss';

View 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;
}
}

View 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;
}
}

View File

@@ -0,0 +1,7 @@
module.exports = {
breakpoints: {
s: 768,
m: 1024,
l: 1679,
},
}

View File

@@ -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

View 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 })
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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