chore: merges templates

This commit is contained in:
Jacob Fletcher
2023-10-08 16:55:48 -04:00
parent a0645bbae5
commit d8f6f86228
407 changed files with 28053 additions and 2881 deletions

View File

@@ -1,2 +1,2 @@
DATABASE_URI=mongodb://127.0.0.1/payload-template-blank
MONGODB_URI=mongodb://127.0.0.1/payload-template-blank
PAYLOAD_SECRET=YOUR_SECRET_HERE

View File

@@ -1,10 +1,11 @@
version: '3'
services:
payload:
image: node:18-alpine
ports:
- '3000:3000'
- "3000:3000"
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
@@ -18,7 +19,7 @@ services:
mongo:
image: mongo:latest
ports:
- '27017:27017'
- "27017:27017"
command:
- --storageEngine=wiredTiger
volumes:

View File

@@ -1,6 +1,4 @@
{
"$schema": "https://json.schemastore.org/nodemon.json",
"ext": "ts",
"exec": "ts-node src/server.ts -- -I",
"stdin": false
"exec": "ts-node src/server.ts"
}

View File

@@ -28,4 +28,4 @@
"ts-node": "^9.1.1",
"typescript": "^4.8.4"
}
}
}

View File

@@ -1,19 +1,12 @@
import path from 'path'
import { payloadCloud } from '@payloadcms/plugin-cloud'
import { mongooseAdapter } from '@payloadcms/db-mongodb' // database-adapter-import
import { webpackBundler } from '@payloadcms/bundler-webpack' // bundler-import
import { lexicalEditor } from '@payloadcms/richtext-lexical' // editor-import
import { buildConfig } from 'payload/config'
import path from 'path'
import Users from './collections/Users'
import { payloadCloud } from '@payloadcms/plugin-cloud'
export default buildConfig({
admin: {
user: Users.slug,
bundler: webpackBundler(), // bundler-config
},
editor: lexicalEditor({}), // editor-config
collections: [Users],
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
@@ -22,9 +15,4 @@ export default buildConfig({
schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql'),
},
plugins: [payloadCloud()],
// database-adapter-config-start
db: mongooseAdapter({
url: process.env.DATABASE_URI,
}),
// database-adapter-config-end
})

View File

@@ -13,6 +13,7 @@ const start = async () => {
// Initialize Payload
await payload.init({
secret: process.env.PAYLOAD_SECRET,
mongoURL: process.env.MONGODB_URI,
express: app,
onInit: async () => {
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"strict": false,
"esModuleInterop": true,
@@ -10,13 +14,21 @@
"rootDir": "./src",
"jsx": "react",
"paths": {
"payload/generated-types": ["./src/payload-types.ts"]
"payload/generated-types": [
"./src/payload-types.ts",
],
}
},
"include": ["src"],
"exclude": ["node_modules", "dist", "build"],
"include": [
"src"
],
"exclude": [
"node_modules",
"dist",
"build",
],
"ts-node": {
"transpileOnly": true,
"swc": true
"swc": true,
}
}

View File

@@ -3942,9 +3942,9 @@ graphql-type-json@^0.3.2:
integrity sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg==
graphql@^16.6.0:
version "16.7.1"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.7.1.tgz#11475b74a7bff2aefd4691df52a0eca0abd9b642"
integrity sha512-DRYR9tf+UGU0KOsMcKAlXeFfX89UiiIZ0dRU3mR0yJfu6OjZqUcp68NnFLnqQU5RexygFoDy1EW+ccOYcPfmHg==
version "16.8.1"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
gzip-size@^6.0.0:
version "6.0.0"
@@ -6072,9 +6072,9 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.2.15, postcss@^8.4.21, postcss@^8.4.24:
version "8.4.27"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.27.tgz#234d7e4b72e34ba5a92c29636734349e0d9c3057"
integrity sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==
version "8.4.31"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
dependencies:
nanoid "^3.3.6"
picocolors "^1.0.0"

View File

@@ -1,17 +1,17 @@
# Payload vars
PORT=3000
DATABASE_URI=mongodb://127.0.0.1/payload-template-ecommerce
MONGODB_URI=mongodb://127.0.0.1/payload-template-ecommerce
PAYLOAD_SECRET=712kjbkuh87234sflj98713b
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
STRIPE_SECRET_KEY=
PAYLOAD_PUBLIC_STRIPE_IS_TEST_KEY=true
STRIPE_WEBHOOKS_SIGNING_SECRET=
PAYLOAD_PUBLIC_DRAFT_SECRET=demo-draft-secret
REVALIDATION_KEY=demo-revalation-key
PAYLOAD_PUBLIC_DRAFT_SECRET=EXAMPLE_DRAFT_SECRET
REVALIDATION_KEY=EXAMPLE_REVALIDATION_KEY
# Next.js vars
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
NEXT_PUBLIC_IS_LIVE=
NEXT_PRIVATE_DRAFT_SECRET=demo-draft-secret
NEXT_PRIVATE_REVALIDATION_KEY=demo-revalation-key
NEXT_PRIVATE_DRAFT_SECRET=EXAMPLE_DRAFT_SECRET
NEXT_PRIVATE_REVALIDATION_KEY=EXAMPLE_REVALIDATION_KEY

View File

@@ -0,0 +1,8 @@
module.exports = {
printWidth: 100,
parser: 'typescript',
semi: false,
singleQuote: true,
trailingComma: 'all',
arrowParens: 'avoid',
}

View File

@@ -30,17 +30,17 @@ If you have not done so already, you need to have standalone copy of this repo o
#### Method 1 (recommended)
Go to Payload Cloud and [clone this template](https://payloadcms.com/new/clone/ecommerce). This will create a new repository on your GitHub account with this template's code which you can then clone to your own machine.
Go to Payload Cloud and [clone this template](https://payloadcms.com/new/clone/ecommerce). This will create a new repository on your GitHub account with this template's code which you can then clone to your own machine.
#### Method 2
Use the `create-payload-app` CLI to clone this template directly to your machine:
Use the `create-payload-app` CLI to clone this template directly to your machine:
npx create-payload-app my-project -t ecommerce
#### Method 3
Use the `git` CLI to clone this template directly to your machine:
Use the `git` CLI to clone this template directly to your machine:
git clone -n --depth=1 --filter=tree:0 https://github.com/payloadcms/payload my-project && cd my-project && git sparse-checkout set --no-cone templates/ecommerce && git checkout && rm -rf .git && git init && git add . && git mv -f templates/ecommerce/{.,}* . && git add . && git commit -m "Initial commit"
@@ -59,7 +59,7 @@ The Payload config is tailored specifically to the needs of most e-commerce busi
### Collections
See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend this functionality.
See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend this functionality.
- #### Users (Authentication)
@@ -145,7 +145,6 @@ Logged-in users can have their shopping carts saved to their profiles as they sh
Payload itself handles no currency exchange. All payments are processed and billed using [Stripe](https://stripe.com). This means you must have access to a Stripe account via an API key, see [Connect Stripe](#connect-stripe) for how to get one. When you create a product in Payload that you wish to sell, it must be connected to a Stripe product by selecting one from the field in the product's sidebar, see [Products](#products) for more details. Once set, data is automatically synced between the two platforms in the following ways:
1. Stripe to Payload using [Stripe Webhooks](https://stripe.com/docs/webhooks):
- `product.created`
- `product.updated`
- `price.updated`
@@ -229,10 +228,22 @@ Create unique product and page layouts for any type fo content using a powerful
Each block is fully designed and built into the front-end website that comes with this template. See [Website](#website) for more details.
## Draft Preview
All pages and products are draft-enabled so you can preview them before publishing them to your website. To do this, these collections use [Versions](https://payloadcms.com/docs/configuration/collections#versions) with `drafts` set to `true`. This means that when you create a new page or product, it will be saved as a draft and will not be visible on your website until you publish it. This also means that you can preview your draft before publishing it to your website. To do this, we automatically format a custom URL which redirects to your front-end to securely fetch the draft version of your content.
Since the front-end of this template is statically generated, this also means that pages and products will need to be regenerated as changes are made to published documents. To do this, we use an `afterChange` hook to regenerate the front-end when a document has changed and its `_status` is `published`.
For more details on how to extend this functionality, see the official [Draft Preview Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview).
## SEO
This template comes pre-configured with the official [Payload SEO Plugin](https://github.com/payloadcms/plugin-seo) for complete SEO control from the admin panel. All SEO data is fully integrated into the front-end website that comes with this template. See [Website](#website) for more details.
## Redirects
If you are migrating an existing site or moving content to a new URL, you can use the `redirects` collection to create a proper redirect from old URLs to new ones. This will ensure that proper request status codes are returned to search engines and that your users are not left with a broken link. This template comes pre-configured with the official [Payload Redirects Plugin](https://github.com/payloadcms/plugin-redirects) for complete redirect control from the admin panel. All redirects are fully integrated into the front-end website that comes with this template. See [Website](#website) for more details.
## Website
This template includes a beautifully designed, production-ready front-end built with the [Next.js App Router](https://nextjs.org), served right 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. If you prefer to host your website separately from Payload, 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).
@@ -245,15 +256,20 @@ Core features:
- [TypeScript](https://www.typescriptlang.org)
- [React Hook Form](https://react-hook-form.com)
- [Payload Admin Bar](https://github.com/payloadcms/payload-admin-bar)
- Complete authentication flow
- Fully built shopping cart
- Complete checkout flow
- Authentication
- Publication workflow
- Shopping cart
- Checkout
- Customer accounts
- Dark mode
- Pre-made layout building blocks
- Complete SEO integration
- Complete Stripe integration
- Fully built paywall
- SEO
- Redirects
- Paywall
### Cache
Although Next.js includes a robust set of caching strategies out of the box, Payload Cloud proxies and caches all files through Cloudflare using the [Official Cloud Plugin](https://github.com/payloadcms/plugin-cloud). This means that Next.js caching is not needed and is disabled by default. If you are hosting your app outside of Payload Cloud, you can easily reenable the Next.js caching mechanisms by removing the `no-store` directive from all fetch requests in `./src/app/_api` and then removing all instances of `export const dynamic = 'force-dynamic'` from pages files, such as `./src/app/(pages)/[slug]/page.tsx`. For more details, see the official [Next.js Caching Docs](https://nextjs.org/docs/app/building-your-application/caching).
### Eject
@@ -263,7 +279,7 @@ If you prefer another front-end framework or would like to use Payload as a stan
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 this example locally, follow the [Quick Start](#quick-start). Then [Connect Stripe](#connect-stripe) to enable payments, and [Seed](#seed) the database with a few products and pages.

View File

@@ -0,0 +1,36 @@
const policies = {
'default-src': ["'self'"],
'script-src': [
"'self'",
"'unsafe-inline'",
"'unsafe-eval'",
'https://checkout.stripe.com',
'https://js.stripe.com',
'https://maps.googleapis.com',
],
'child-src': ["'self'"],
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
'img-src': ["'self'", 'https://*.stripe.com', 'https://raw.githubusercontent.com'],
'font-src': ["'self'"],
'frame-src': [
"'self'",
'https://checkout.stripe.com',
'https://js.stripe.com',
'https://hooks.stripe.com',
],
'connect-src': [
"'self'",
'https://checkout.stripe.com',
'https://api.stripe.com',
'https://maps.googleapis.com',
],
}
module.exports = Object.entries(policies)
.map(([key, value]) => {
if (Array.isArray(value)) {
return `${key} ${value.join(' ')}`
}
return ''
})
.join('; ')

View File

@@ -1,10 +1,11 @@
version: '3'
services:
payload:
image: node:18-alpine
ports:
- '3000:3000'
- "3000:3000"
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
@@ -18,7 +19,7 @@ services:
mongo:
image: mongo:latest
ports:
- '27017:27017'
- "27017:27017"
command:
- --storageEngine=wiredTiger
volumes:

View File

@@ -6,16 +6,16 @@ import path from 'path'
// 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 files = ['./next.config.js', './next-env.d.ts', './redirects.js']
const directories = ['./src/app']
const eject = async (): Promise<void> => {
files.forEach((file) => {
files.forEach(file => {
fs.unlinkSync(path.join(__dirname, file))
})
directories.forEach((directory) => {
fs.rm(path.join(__dirname, directory), { recursive: true }, (err) => {
directories.forEach(directory => {
fs.rm(path.join(__dirname, directory), { recursive: true }, err => {
if (err) throw err
})
})

View File

@@ -1,33 +1,23 @@
/** @type {import('next').NextConfig} */
const ContentSecurityPolicy = `
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://checkout.stripe.com https://js.stripe.com https://maps.googleapis.com;
child-src 'self';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' https://*.stripe.com https://raw.githubusercontent.com;
font-src 'self';
frame-src 'self' https://checkout.stripe.com https://js.stripe.com https://hooks.stripe.com;
connect-src 'self' https://checkout.stripe.com https://api.stripe.com https://maps.googleapis.com;
`
const ContentSecurityPolicy = require('./csp')
const redirects = require('./redirects')
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
images: {
domains: ['localhost', process.env.NEXT_PUBLIC_SERVER_URL].filter(Boolean),
// remotePatterns: [
// {
// protocol: 'https',
// hostname: 'localhost',
// port: '3000',
// pathname: '/media/**',
// },
// ],
domains: ['localhost', process.env.NEXT_PUBLIC_SERVER_URL]
.filter(Boolean)
.map(url => url.replace(/https?:\/\//, '')),
},
redirects,
async headers() {
const headers = []
// Prevent search engines from indexing the site if it is not live
// This is useful for staging environments before they are ready to go live
// To allow robots to crawl the site, use the `NEXT_PUBLIC_IS_LIVE` env variable
// You may want to also use this variable to conditionally render any tracking scripts
if (!process.env.NEXT_PUBLIC_IS_LIVE) {
headers.push({
headers: [
@@ -40,12 +30,15 @@ const nextConfig = {
})
}
// Set the `Content-Security-Policy` header as a security measure to prevent XSS attacks
// It works by explicitly whitelisting trusted sources of content for your website
// This will block all inline scripts and styles except for those that are allowed
headers.push({
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: ContentSecurityPolicy.replace(/\s{2,}/g, ' ').trim(),
value: ContentSecurityPolicy,
},
],
})

View File

@@ -1,7 +1,8 @@
{
"$schema": "https://json.schemastore.org/nodemon.json",
"watch": ["server.ts"],
"exec": "ts-node --project tsconfig.server.json src/server.ts -- -I",
"exec": "ts-node --project tsconfig.server.json src/server.ts",
"ext": "js ts",
"stdin": false
"ignore" : [
"src/app"
]
}

View File

@@ -23,8 +23,9 @@
"dependencies": {
"@payloadcms/plugin-cloud": "^2.0.0",
"@payloadcms/plugin-nested-docs": "^1.0.4",
"@payloadcms/plugin-redirects": "^1.0.0",
"@payloadcms/plugin-seo": "^1.0.10",
"@payloadcms/plugin-stripe": "^0.0.13",
"@payloadcms/plugin-stripe": "^0.0.14",
"@stripe/react-stripe-js": "^1.16.3",
"@stripe/stripe-js": "^1.46.0",
"cross-env": "^7.0.3",

View File

@@ -0,0 +1,81 @@
// Note: This will not work in dev mode and will throw an error upon startup
// This is because the Payload APIs are not yet running when the Next.js server starts
// This is not a problem in production as Payload is booted up before building Next.js
// For this reason the errors can be silently ignored in dev mode
module.exports = async () => {
const internetExplorerRedirect = {
source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page
has: [
{
type: 'header',
key: 'user-agent',
value: '(.*Trident.*)', // all ie browsers
},
],
permanent: false,
destination: '/ie-incompatible.html',
}
try {
const redirectsRes = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/redirects?limit=1000&depth=1`,
)
const redirectsData = await redirectsRes.json()
const { docs } = redirectsData
let dynamicRedirects = []
if (docs) {
docs.forEach(doc => {
const { from, to: { type, url, reference } = {} } = doc
let source = from
.replace(process.env.NEXT_PUBLIC_SERVER_URL, '')
.split('?')[0]
.toLowerCase()
if (source.endsWith('/')) source = source.slice(0, -1) // a trailing slash will break this redirect
let destination = '/'
if (type === 'custom' && url) {
destination = url.replace(process.env.NEXT_PUBLIC_SERVER_URL, '')
}
if (
type === 'reference' &&
typeof reference.value === 'object' &&
reference?.value?._status === 'published'
) {
destination = `${process.env.NEXT_PUBLIC_SERVER_URL}/${
reference.relationTo !== 'pages' ? `${reference.relationTo}/` : ''
}${reference.value.slug}`
}
const redirect = {
source,
destination,
permanent: true,
}
if (source.startsWith('/') && destination && source !== destination) {
return dynamicRedirects.push(redirect)
}
return
})
}
const redirects = [internetExplorerRedirect, ...dynamicRedirects]
return redirects
} catch (error) {
if (process.env.NODE_ENV === 'production') {
console.error(`Error configuring redirects: ${error}`) // eslint-disable-line no-console
}
return []
}
}

View File

@@ -11,6 +11,14 @@ import { Blocks } from '../../_components/Blocks'
import { Hero } from '../../_components/Hero'
import { generateMeta } from '../../_utilities/generateMeta'
// Payload Cloud caches all files through Cloudflare, so we don't need Next.js to cache them as well
// This means that we can turn off Next.js data caching and instead rely solely on the Cloudflare CDN
// To do this, we include the `no-cache` header on the fetch requests used to get the data for this page
// But we also need to force Next.js to dynamically render this page on each request for preview mode to work
// See https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic
// If you are not using Payload Cloud then this line can be removed, see `../../../README.md#cache`
export const dynamic = 'force-dynamic'
export default async function Page({ params: { slug = 'home' } }) {
const { isEnabled: isDraftMode } = draftMode()

View File

@@ -1,4 +1,4 @@
@import '../../../_css/common';
@import "../../../_css/common";
.form {
margin-bottom: var(--base);

View File

@@ -143,7 +143,7 @@ const AccountForm: React.FC = () => {
label="Confirm Password"
required
register={register}
validate={(value) => value === password.current || 'The passwords do not match'}
validate={value => value === password.current || 'The passwords do not match'}
error={errors.passwordConfirm}
/>
</Fragment>

View File

@@ -31,7 +31,7 @@
color: var(--theme-warning-500);
}
:global([data-theme='light']) {
:global([data-theme="light"]) {
.warning {
color: var(--theme-error-500);
}
@@ -91,7 +91,7 @@
padding: calc(var(--base) * 0.25) calc(var(--base) * 0.25);
}
:global([data-theme='dark']) {
:global([data-theme="dark"]) {
.quantity {
background-color: var(--theme-elevation-150);
}

View File

@@ -18,7 +18,7 @@ import classes from './index.module.scss'
export const CartPage: React.FC<{
settings: Settings
page: Page
}> = (props) => {
}> = props => {
const { settings } = props
const { productsPage } = settings || {}
@@ -121,7 +121,7 @@ export const CartPage: React.FC<{
// fallback to empty string to avoid uncontrolled input error
// this allows the user to user their backspace key to clear the input
value={typeof quantity === 'number' ? quantity : ''}
onChange={(e) => {
onChange={e => {
addItemToCart({
product,
quantity: Number(e.target.value),

View File

@@ -15,6 +15,10 @@ import { CartPage } from './CartPage'
import classes from './index.module.scss'
// Force this page to be dynamic so that Next.js does not cache it
// See the note in '../[slug]/page.tsx' about this
export const dynamic = 'force-dynamic'
export default async function Cart() {
let page: Page | null = null

View File

@@ -1,4 +1,4 @@
@import '../../../_css/common';
@import "../../../_css/common";
.form {
display: flex;

View File

@@ -21,7 +21,7 @@ export const CheckoutForm: React.FC<{}> = () => {
const { cart, cartTotal } = useCart()
const handleSubmit = useCallback(
async (e) => {
async e => {
e.preventDefault()
setIsLoading(true)

View File

@@ -1,4 +1,4 @@
@import '../../../_css/common';
@import "../../../_css/common";
.items {
margin-bottom: var(--base);
@@ -27,7 +27,7 @@
color: var(--theme-warning-500);
}
:global([data-theme='light']) {
:global([data-theme="light"]) {
.warning {
color: var(--theme-error-500);
}

View File

@@ -25,7 +25,7 @@ const stripe = loadStripe(apiKey)
export const CheckoutPage: React.FC<{
settings: Settings
}> = (props) => {
}> = props => {
const {
settings: { productsPage },
} = props

View File

@@ -1,4 +1,4 @@
@import '../../_css/common';
@import "../../_css/common";
.checkoutPage {
margin-bottom: var(--block-padding);

View File

@@ -1,4 +1,4 @@
@import '../../../_css/common';
@import "../../../_css/common";
.form {
margin-bottom: var(--base);

View File

@@ -101,7 +101,7 @@ const CreateAccountForm: React.FC = () => {
label="Confirm Password"
required
register={register}
validate={(value) => value === password.current || 'The passwords do not match'}
validate={value => value === password.current || 'The passwords do not match'}
error={errors.passwordConfirm}
/>
<Button

View File

@@ -1,4 +1,4 @@
@import '../../_css/common';
@import "../../_css/common";
.createAccount {
margin-bottom: var(--block-padding);

View File

@@ -1,4 +1,4 @@
@import '../../../_css/common';
@import "../../../_css/common";
.form {
margin-bottom: var(--base);

View File

@@ -1,4 +1,4 @@
@import '../../_css/common';
@import "../../_css/common";
.login {
margin-bottom: var(--block-padding);

View File

@@ -8,7 +8,7 @@ import { useAuth } from '../../../_providers/Auth'
export const LogoutPage: React.FC<{
settings: Settings
}> = (props) => {
}> = props => {
const { settings } = props
const { productsPage } = settings || {}
const { logout } = useAuth()

View File

@@ -68,7 +68,7 @@
color: var(--theme-warning-500);
}
:global([data-theme='light']) {
:global([data-theme="light"]) {
.warning {
color: var(--theme-error-500);
}
@@ -120,3 +120,4 @@
flex-wrap: wrap;
gap: calc(var(--base) / 2);
}

View File

@@ -30,7 +30,7 @@ export default async function Order({ params: { id } }) {
'Content-Type': 'application/json',
Authorization: `JWT ${token}`,
},
})?.then(async (res) => {
})?.then(async res => {
if (!res.ok) notFound()
const json = await res.json()
if ('error' in json && json.error) notFound()

View File

@@ -31,14 +31,14 @@ export default async function Orders() {
},
cache: 'no-store',
})
?.then(async (res) => {
?.then(async res => {
if (!res.ok) notFound()
const json = await res.json()
if ('error' in json && json.error) notFound()
if ('errors' in json && json.errors) notFound()
return json
})
?.then((json) => json.docs)
?.then(json => json.docs)
} catch (error) {
// when deploying this template on Payload Cloud, this page needs to build before the APIs are live
// so swallow the error here and simply render the page with fallback data where necessary

View File

@@ -11,6 +11,10 @@ import { PaywallBlocks } from '../../../_components/PaywallBlocks'
import { ProductHero } from '../../../_heros/Product'
import { generateMeta } from '../../../_utilities/generateMeta'
// Force this page to be dynamic so that Next.js does not cache it
// See the note in '../../../[slug]/page.tsx' about this
export const dynamic = 'force-dynamic'
export default async function Product({ params: { slug } }) {
const { isEnabled: isDraftMode } = draftMode()
@@ -30,13 +34,54 @@ export default async function Product({ params: { slug } }) {
notFound()
}
const { layout } = product
const { layout, relatedProducts } = product
return (
<React.Fragment>
<ProductHero product={product} />
<Blocks blocks={layout} />
{product?.enablePaywall && <PaywallBlocks productSlug={slug as string} disableTopPadding />}
<Blocks
disableTopPadding
blocks={[
{
blockType: 'relatedProducts',
blockName: 'Related Product',
relationTo: 'products',
introContent: [
{
type: 'h4',
children: [
{
text: 'Related Products',
},
],
},
{
type: 'p',
children: [
{
text: 'The products displayed here are individually selected for this page. Admins can select any number of related products to display here and the layout will adjust accordingly. Alternatively, you could swap this out for the "Archive" block to automatically populate products by category complete with pagination. To manage related posts, ',
},
{
type: 'link',
url: `/admin/collections/products/${product.id}`,
children: [
{
text: 'navigate to the admin dashboard',
},
],
},
{
text: '.',
},
],
},
],
docs: relatedProducts,
},
]}
/>
</React.Fragment>
)
}

View File

@@ -1,4 +1,4 @@
@import '../../../_css/common';
@import "../../../_css/common";
.error {
color: red;
@@ -20,3 +20,4 @@
.message {
margin-bottom: var(--base);
}

View File

@@ -1,4 +1,4 @@
@import '../../_css/common';
@import "../../_css/common";
.recoverPassword {
margin-bottom: var(--block-padding);

View File

@@ -1,4 +1,4 @@
@import '../../../_css/common';
@import "../../../_css/common";
.form {
width: 66.66%;

View File

@@ -44,6 +44,7 @@ export const fetchDoc = async <T>(args: {
'Content-Type': 'application/json',
...(token?.value && draft ? { Authorization: `JWT ${token.value}` } : {}),
},
cache: 'no-store',
next: { tags: [`${collection}_${slug}`] },
body: JSON.stringify({
query: queryMap[collection].query,
@@ -53,8 +54,8 @@ export const fetchDoc = async <T>(args: {
},
}),
})
?.then((res) => res.json())
?.then((res) => {
?.then(res => res.json())
?.then(res => {
if (res.errors) throw new Error(res?.errors?.[0]?.message ?? 'Error fetching doc')
return res?.data?.[queryMap[collection].key]?.docs?.[0]
})

View File

@@ -40,13 +40,14 @@ export const fetchDocs = async <T>(
'Content-Type': 'application/json',
...(token?.value && draft ? { Authorization: `JWT ${token.value}` } : {}),
},
cache: 'no-store',
next: { tags: [collection] },
body: JSON.stringify({
query: queryMap[collection].query,
}),
})
?.then((res) => res.json())
?.then((res) => {
?.then(res => res.json())
?.then(res => {
if (res.errors) throw new Error(res?.errors?.[0]?.message ?? 'Error fetching docs')
return res?.data?.[queryMap[collection].key]?.docs

View File

@@ -9,15 +9,16 @@ export async function fetchSettings(): Promise<Settings> {
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
body: JSON.stringify({
query: SETTINGS_QUERY,
}),
})
?.then((res) => {
?.then(res => {
if (!res.ok) throw new Error('Error fetching doc')
return res.json()
})
?.then((res) => {
?.then(res => {
if (res?.errors) throw new Error(res?.errors[0]?.message || 'Error fetching settings')
return res.data?.Settings
})
@@ -33,15 +34,16 @@ export async function fetchHeader(): Promise<Header> {
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
body: JSON.stringify({
query: HEADER_QUERY,
}),
})
?.then((res) => {
?.then(res => {
if (!res.ok) throw new Error('Error fetching doc')
return res.json()
})
?.then((res) => {
?.then(res => {
if (res?.errors) throw new Error(res?.errors[0]?.message || 'Error fetching header')
return res.data?.Header
})
@@ -61,11 +63,11 @@ export async function fetchFooter(): Promise<Footer> {
query: FOOTER_QUERY,
}),
})
.then((res) => {
.then(res => {
if (!res.ok) throw new Error('Error fetching doc')
return res.json()
})
?.then((res) => {
?.then(res => {
if (res?.errors) throw new Error(res?.errors[0]?.message || 'Error fetching footer')
return res.data?.Footer
})

View File

@@ -21,6 +21,7 @@ export const getMe = async (args?: {
Authorization: `JWT ${token}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
body: JSON.stringify({
query: ME_QUERY,
}),

View File

@@ -11,7 +11,7 @@ export const ArchiveBlock: React.FC<
ArchiveBlockProps & {
id?: string
}
> = (props) => {
> = props => {
const {
introContent,
id,
@@ -37,7 +37,7 @@ export const ArchiveBlock: React.FC<
populatedDocsTotal={populatedDocsTotal}
categories={categories}
limit={limit}
sort="-publishedDate"
sort="-publishedOn"
/>
</div>
)

View File

@@ -10,7 +10,7 @@ $spacer-h: calc(var(--block-padding) / 2);
color: var(--theme-elevation-1000);
}
:global([data-theme='dark']) {
:global([data-theme="dark"]) {
.callToAction {
background-color: var(--theme-elevation-0);
color: var(--theme-elevation-1000);
@@ -22,7 +22,7 @@ $spacer-h: calc(var(--block-padding) / 2);
color: var(--theme-elevation-0);
}
:global([data-theme='dark']) {
:global([data-theme="dark"]) {
.invert {
background-color: var(--theme-elevation-900);
color: var(--theme-elevation-0);

View File

@@ -13,7 +13,7 @@ export const ContentBlock: React.FC<
Props & {
id?: string
}
> = (props) => {
> = props => {
const { columns } = props
return (

View File

@@ -4,5 +4,5 @@
.caption {
color: var(--theme-elevation-500);
margin-top: var(--base);
margin-top: var(--base)
}

View File

@@ -13,7 +13,7 @@ type Props = Extract<Page['layout'][0], { blockType: 'mediaBlock' }> & {
id?: string
}
export const MediaBlock: React.FC<Props> = (props) => {
export const MediaBlock: React.FC<Props> = props => {
const { media, position = 'default', staticImage } = props
let caption

View File

@@ -0,0 +1,58 @@
@import '../../_css/common';
.introContent {
position: relative;
margin-bottom: calc(var(--base) * 2);
@include mid-break {
margin-bottom: var(--base);
}
}
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
width: 100%;
gap: var(--base) 40px;
@include mid-break {
grid-template-columns: repeat(6, 1fr);
gap: calc(var(--base) / 2) var(--base);
}
}
.column {
grid-column-end: span 12;
@include mid-break {
grid-column-end: span 6;
}
@include small-break {
grid-column-end: span 6;
}
}
.cols-half {
grid-column-end: span 6;
@include mid-break {
grid-column-end: span 6;
}
@include small-break {
grid-column-end: span 6;
}
}
.cols-thirds {
grid-column-end: span 3;
@include mid-break {
grid-column-end: span 6;
}
@include small-break {
grid-column-end: span 6;
}
}

View File

@@ -0,0 +1,52 @@
import React from 'react'
import { Product } from '../../../payload/payload-types'
import { Card } from '../../_components/Card'
import { Gutter } from '../../_components/Gutter'
import RichText from '../../_components/RichText'
import classes from './index.module.scss'
export type RelatedProductsProps = {
blockType: 'relatedProducts'
blockName: string
introContent?: any
docs?: (string | Product)[]
relationTo: 'products'
}
export const RelatedProducts: React.FC<RelatedProductsProps> = props => {
const { introContent, docs, relationTo } = props
return (
<div className={classes.relatedProducts}>
{introContent && (
<Gutter className={classes.introContent}>
<RichText content={introContent} />
</Gutter>
)}
<Gutter>
<div className={classes.grid}>
{docs?.map((doc, index) => {
if (typeof doc === 'string') return null
return (
<div
key={index}
className={[
classes.column,
docs.length === 2 && classes['cols-half'],
docs.length >= 3 && classes['cols-thirds'],
]
.filter(Boolean)
.join(' ')}
>
<Card relationTo={relationTo} doc={doc} showCategories />
</div>
)
})}
</div>
</Gutter>
</div>
)
}

View File

@@ -14,7 +14,7 @@ export const AddToCartButton: React.FC<{
quantity?: number
className?: string
appearance?: Props['appearance']
}> = (props) => {
}> = props => {
const { product, quantity = 1, className, appearance = 'primary' } = props
const { cart, addItemToCart, isProductInCart, hasInitializedCart } = useCart()

View File

@@ -10,7 +10,7 @@
transition: opacity 150ms linear;
}
:global([data-theme='dark']) {
:global([data-theme="dark"]) {
.adminBar {
background-color: var(--theme-elevation-0);
}

View File

@@ -13,7 +13,7 @@ const Title: React.FC = () => <span>Dashboard</span>
export const AdminBar: React.FC<{
adminBarProps?: PayloadAdminBarProps
}> = (props) => {
}> = props => {
const { adminBarProps } = props || {}
const segments = useSelectedLayoutSegments()
const collection = segments?.[1] === 'products' ? 'products' : 'pages'

View File

@@ -3,7 +3,7 @@
color: var(--theme-bg);
}
:global([data-theme='dark']) {
:global([data-theme="dark"]) {
.invert {
background-color: var(--theme-elevation-100);
color: var(--theme-text);

View File

@@ -9,7 +9,7 @@ type Props = {
id?: string
}
export const BackgroundColor: React.FC<Props> = (props) => {
export const BackgroundColor: React.FC<Props> = props => {
const { id, className, children, invert } = props
return (

View File

@@ -5,6 +5,7 @@ import { ArchiveBlock } from '../../_blocks/ArchiveBlock'
import { CallToActionBlock } from '../../_blocks/CallToAction'
import { ContentBlock } from '../../_blocks/Content'
import { MediaBlock } from '../../_blocks/MediaBlock'
import { RelatedProducts, type RelatedProductsProps } from '../../_blocks/RelatedProducts'
import { toKebabCase } from '../../_utilities/toKebabCase'
import { BackgroundColor } from '../BackgroundColor/index'
import { VerticalPadding, VerticalPaddingOptions } from '../VerticalPadding/index'
@@ -14,12 +15,13 @@ const blockComponents = {
content: ContentBlock,
mediaBlock: MediaBlock,
archive: ArchiveBlock,
relatedProducts: RelatedProducts,
}
export const Blocks: React.FC<{
blocks: Page['layout']
blocks: (Page['layout'][0] | RelatedProductsProps)[]
disableTopPadding?: boolean
}> = (props) => {
}> = props => {
const { disableTopPadding, blocks } = props
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0

View File

@@ -1,6 +1,6 @@
@import '../../_css/common';
.card {
.card {
border: 1px var(--theme-elevation-200) solid;
border-radius: 4px;
height: 100%;

View File

@@ -1,3 +1,5 @@
'use client'
import React, { Fragment, useEffect, useState } from 'react'
import Link from 'next/link'
@@ -39,7 +41,7 @@ export const Card: React.FC<{
title?: string
relationTo?: 'products'
doc?: Product
}> = (props) => {
}> = props => {
const {
showCategories,
title: titleFromProps,

View File

@@ -9,7 +9,7 @@ import classes from './index.module.scss'
export const CartLink: React.FC<{
className?: string
}> = (props) => {
}> = props => {
const { className } = props
const { cart } = useCart()
const [length, setLength] = useState<number>()

View File

@@ -6,7 +6,7 @@
left: 0;
top: calc(var(--base) * -5);
@include mid-break {
top: calc(var(--base) * -2);
top: calc(var(--base) * -2);
}
}

View File

@@ -36,7 +36,7 @@ export type Props = {
categories?: ArchiveBlockProps['categories']
}
export const CollectionArchive: React.FC<Props> = (props) => {
export const CollectionArchive: React.FC<Props> = props => {
const {
className,
relationTo,
@@ -51,7 +51,7 @@ export const CollectionArchive: React.FC<Props> = (props) => {
const [results, setResults] = useState<Result>({
totalDocs: typeof populatedDocsTotal === 'number' ? populatedDocsTotal : 0,
docs: populatedDocs?.map((doc) => doc.value) || [],
docs: populatedDocs?.map(doc => doc.value) || [],
page: 1,
totalPages: 1,
hasPrevPage: false,
@@ -101,7 +101,7 @@ export const CollectionArchive: React.FC<Props> = (props) => {
in:
typeof catsFromProps === 'string'
? [catsFromProps]
: catsFromProps.map((cat) => cat.id).join(','),
: catsFromProps.map(cat => cat.id).join(','),
},
}
: {}),

View File

@@ -10,7 +10,7 @@
}
}
:global([data-theme='dark']) {
:global([data-theme="dark"]) {
.footer {
background-color: var(--theme-elevation-50);
color: var(--theme-elevation-1000);

View File

@@ -41,10 +41,16 @@ export async function Footer() {
return <CMSLink key={i} {...link} />
})}
<Link href="/admin">Admin</Link>
<Link href="https://github.com/payloadcms/payload/tree/master/templates/ecommerce">
<Link
href="https://github.com/payloadcms/payload/tree/master/templates/ecommerce"
target="_blank"
rel="noopener noreferrer"
>
Source Code
</Link>
<Link href="https://github.com/payloadcms/payload">Payload</Link>
<Link href="https://payloadcms.com" target="_blank" rel="noopener noreferrer">
Payload
</Link>
</nav>
</Gutter>
</footer>

View File

@@ -4,7 +4,7 @@ import classes from './index.module.scss'
export const HR: React.FC<{
className?: string
}> = (props) => {
}> = props => {
const { className } = props
return <hr className={[className, classes.hr].filter(Boolean).join(' ')} />

View File

@@ -25,6 +25,10 @@ export const HeaderNav: React.FC<{ header: HeaderType }> = ({ header }) => {
.filter(Boolean)
.join(' ')}
>
{navItems.map(({ link }, i) => {
return <CMSLink key={i} {...link} appearance="none" />
})}
<CartLink />
{user && <Link href="/account">Account</Link>}
{!user && (
<React.Fragment>
@@ -32,10 +36,6 @@ export const HeaderNav: React.FC<{ header: HeaderType }> = ({ header }) => {
<Link href="/create-account">Create Account</Link>
</React.Fragment>
)}
{navItems.map(({ link }, i) => {
return <CMSLink key={i} {...link} appearance="none" />
})}
<CartLink />
</nav>
)
}

View File

@@ -15,7 +15,7 @@
width: 150px;
}
:global([data-theme='light']) {
:global([data-theme="light"]) {
.logo {
filter: invert(1);
}

View File

@@ -11,7 +11,7 @@ const heroes = {
lowImpact: LowImpactHero,
}
export const Hero: React.FC<Page['hero']> = (props) => {
export const Hero: React.FC<Page['hero']> = props => {
const { type } = props || {}
if (!type || type === 'none') return null

View File

@@ -16,6 +16,7 @@
height: calc(var(--base) * 2);
line-height: calc(var(--base) * 2);
padding: 0 calc(var(--base) / 2);
font-size: inherit;
&:focus {
border: none;
@@ -31,7 +32,11 @@
}
}
:global([data-theme='dark']) {
.asterisk {
color: var(--color-error-500);
}
:global([data-theme="dark"]) {
.input {
background-color: var(--theme-elevation-150);
}

View File

@@ -11,6 +11,7 @@ type Props = {
error: any
type?: 'text' | 'number' | 'password' | 'email'
validate?: (value: string) => boolean | string
disabled?: boolean
}
export const Input: React.FC<Props> = ({
@@ -21,11 +22,13 @@ export const Input: React.FC<Props> = ({
error,
type = 'text',
validate,
disabled,
}) => {
return (
<div className={classes.inputWrap}>
<label htmlFor="name" className={classes.label}>
{`${label} ${required ? '*' : ''}`}
{label}
{required ? <span className={classes.asterisk}>&nbsp;*</span> : ''}
</label>
<input
className={[classes.input, error && classes.error].filter(Boolean).join(' ')}
@@ -42,6 +45,7 @@ export const Input: React.FC<Props> = ({
}
: {}),
})}
disabled={disabled}
/>
{error && (
<div className={classes.errorMessage}>

View File

@@ -1,29 +1,29 @@
@import '../../_css/common';
@keyframes shimmer {
0% {
opacity: 1;
}
50% {
opacity: 0.75;
}
100% {
opacity: 1;
}
0% {
opacity: 1;
}
50% {
opacity: .75;
}
100% {
opacity: 1;
}
}
.loading {
& > *:not(:last-child) {
margin-bottom: var(--base);
}
& > *:not(:last-child) {
margin-bottom: var(--base);
}
}
.shimmer {
width: 100%;
height: calc(var(--base) * 2.5); // same as input height `formInput`
background-color: var(--theme-elevation-200);
border-radius: 1px;
opacity: 1;
will-change: opacity;
animation: shimmer 1s infinite;
width: 100%;
height: calc(var(--base) * 2.5); // same as input height `formInput`
background-color: var(--theme-elevation-200);
border-radius: 1px;
opacity: 1;
will-change: opacity;
animation: shimmer 1s infinite;
}

View File

@@ -5,7 +5,7 @@ import classes from './index.module.scss'
export const LoadingShimmer: React.FC<{
number?: number
height?: number // in `base` units
}> = (props) => {
}> = props => {
const arrayFromNumber = Array.from(Array(props.number || 1).keys())
return (

View File

@@ -10,7 +10,7 @@ import classes from './index.module.scss'
const { breakpoints } = cssVariables
export const Image: React.FC<MediaProps> = (props) => {
export const Image: React.FC<MediaProps> = props => {
const {
imgClassName,
onClick,

View File

@@ -6,7 +6,7 @@ import { Props as MediaProps } from '../types'
import classes from './index.module.scss'
export const Video: React.FC<MediaProps> = (props) => {
export const Video: React.FC<MediaProps> = props => {
const { videoClassName, resource, onClick } = props
const videoRef = useRef<HTMLVideoElement>(null)

View File

@@ -4,7 +4,7 @@ import { Image } from './Image'
import { Props } from './types'
import { Video } from './Video'
export const Media: React.FC<Props> = (props) => {
export const Media: React.FC<Props> = props => {
const { className, resource, htmlElement = 'div' } = props
const isVideo = typeof resource !== 'string' && resource?.mimeType?.includes('video')

View File

@@ -26,7 +26,7 @@
color: var(--theme-success-900);
}
:global([data-theme='dark']) {
:global([data-theme="dark"]) {
.default {
background-color: var(--theme-elevation-900);
color: var(--theme-elevation-100);

View File

@@ -24,7 +24,7 @@ export const PageRange: React.FC<{
singular?: string
plural?: string
}
}> = (props) => {
}> = props => {
const {
className,
totalDocs,

View File

@@ -9,7 +9,7 @@ export const Pagination: React.FC<{
totalPages: number
onClick: (page: number) => void
className?: string
}> = (props) => {
}> = props => {
const { page, totalPages, onClick, className } = props
const hasNextPage = page < totalPages
const hasPrevPage = page > 1

View File

@@ -15,17 +15,21 @@ import { VerticalPadding } from '../VerticalPadding'
export const PaywallBlocks: React.FC<{
productSlug: string
disableTopPadding?: boolean
}> = (props) => {
}> = 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)
const isRequesting = React.useRef(false)
useEffect(() => {
if (!user || hasInitialized.current) return
if (!user || hasInitialized.current || isRequesting.current) return
hasInitialized.current = true
isRequesting.current = true
const start = Date.now()
const getPaywallContent = async () => {
setIsLoading(true)
@@ -44,13 +48,20 @@ export const PaywallBlocks: React.FC<{
},
}),
})
?.then((res) => res.json())
?.then((res) => res?.data?.Products.docs[0]?.paywall)
?.then(res => res.json())
?.then(res => res?.data?.Products.docs[0]?.paywall)
if (paywall) {
setBlocks(paywall)
}
// wait before setting `isLoading` to `false` to give the illusion of loading
// this is to prevent a flash of the loading shimmer on fast networks
const end = Date.now()
if (end - start < 1000) {
await new Promise(resolve => setTimeout(resolve, 500 - (end - start)))
}
setIsLoading(false)
} catch (error) {
console.error(error) // eslint-disable-line no-console

View File

@@ -43,7 +43,7 @@ export const Price: React.FC<{
product: Product
quantity?: number
button?: 'addToCart' | 'removeFromCart' | false
}> = (props) => {
}> = props => {
const { product, product: { priceJSON } = {}, button = 'addToCart', quantity } = props
const [price, setPrice] = useState<{

View File

@@ -8,7 +8,7 @@ import classes from './index.module.scss'
export const RemoveFromCartButton: React.FC<{
className?: string
product: Product
}> = (props) => {
}> = props => {
const { className, product } = props
const { deleteItemFromCart, isProductInCart } = useCart()

View File

@@ -5,21 +5,22 @@ import { useSearchParams } from 'next/navigation'
import { Message } from '../Message'
import classes from './index.module.scss'
export type Props = {
params?: string[]
message?: string
className?: string
onParams?: (paramValues: (string | string[])[]) => void
onParams?: (paramValues: ((string | null | undefined) | string[])[]) => void
}
export const RenderParamsComponent: React.FC<Props> = ({
params = ['error', 'message', 'success'],
message,
params = ['error', 'warning', 'success', 'message'],
className,
onParams,
}) => {
const searchParams = useSearchParams()
const paramValues = params.map((param) => searchParams.get(param)).filter(Boolean)
const paramValues = params.map(param => searchParams?.get(param))
useEffect(() => {
if (paramValues.length && onParams) {
@@ -30,9 +31,19 @@ export const RenderParamsComponent: React.FC<Props> = ({
if (paramValues.length) {
return (
<div className={className}>
{paramValues.map((paramValue) => (
<Message key={paramValue} message={(message || 'PARAM')?.replace('PARAM', paramValue)} />
))}
{paramValues.map((paramValue, index) => {
if (!paramValue) return null
return (
<Message
className={classes.renderParams}
key={paramValue}
{...{
[params[index]]: paramValue,
}}
/>
)
})}
</div>
)
}

View File

@@ -0,0 +1,9 @@
@import '../../_css/common.scss';
.renderParams {
margin-bottom: calc(var(--base) * 2);
@include mid-break {
margin-bottom: var(--base);
}
}

View File

@@ -6,7 +6,7 @@ import { Props, RenderParamsComponent } from './Component'
// To fix this, we wrap the component in a `Suspense` component
// See https://nextjs.org/docs/messages/deopted-into-client-rendering for more info
export const RenderParams: React.FC<Props> = (props) => {
export const RenderParams: React.FC<Props> = props => {
return (
<Suspense fallback={null}>
<RenderParamsComponent {...props} />

View File

@@ -12,4 +12,4 @@
.bottom-medium {
padding-bottom: calc(var(--block-padding) / 2);
}
}

View File

@@ -1,7 +1,7 @@
@use './queries.scss' as *;
@use './colors.scss' as *;
@use './type.scss' as *;
@import './theme.scss';
@import "./theme.scss";
:root {
--base: 24px;
@@ -32,8 +32,8 @@ html {
-webkit-font-smoothing: antialiased;
opacity: 0;
&[data-theme='dark'],
&[data-theme='light'] {
&[data-theme=dark],
&[data-theme=light] {
opacity: initial;
}
}
@@ -94,7 +94,7 @@ p {
margin: var(--base) 0;
@include mid-break {
margin: calc(var(--base) * 0.75) 0;
margin: calc(var(--base) * .75) 0;
}
}
@@ -108,12 +108,12 @@ a {
color: currentColor;
&:focus {
opacity: 0.8;
opacity: .8;
outline: none;
}
&:active {
opacity: 0.7;
opacity: .7;
outline: none;
}
}

View File

@@ -83,3 +83,5 @@
--color-error-900: rgb(51, 22, 24);
--color-error-950: rgb(25, 11, 12);
}

View File

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

View File

@@ -1,4 +1,4 @@
[data-theme='light'] {
[data-theme=light] {
--theme-success-50: var(--color-success-50);
--theme-success-100: var(--color-success-100);
--theme-success-150: var(--color-success-150);
@@ -117,7 +117,7 @@
}
}
[data-theme='dark'] {
[data-theme=dark] {
--theme-elevation-0: var(--color-base-1000);
--theme-elevation-50: var(--color-base-950);
--theme-elevation-100: var(--color-base-900);

View File

@@ -27,8 +27,14 @@ export const PRODUCT = `
${ARCHIVE_BLOCK}
}
priceJSON
${META}
enablePaywall
relatedProducts {
id
slug
title
${META}
}
${META}
}
}
}

View File

@@ -31,7 +31,7 @@
display: flex;
padding-top: var(--base);
flex-wrap: wrap;
margin: calc(var(--base) * -0.5);
margin: calc(var(--base) * -.5);
& > * {
margin: calc(var(--base) / 2);

View File

@@ -8,7 +8,7 @@ import RichText from '../../_components/RichText'
import classes from './index.module.scss'
export const MediumImpactHero: React.FC<Page['hero']> = (props) => {
export const MediumImpactHero: React.FC<Page['hero']> = props => {
const { richText, media, links } = props
return (

View File

@@ -38,7 +38,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
// used to track the single event of logging in or logging out
// useful for `useEffect` hooks that should only run once
const [status, setStatus] = useState<undefined | 'loggedOut' | 'loggedIn'>()
const create = useCallback<Create>(async (args) => {
const create = useCallback<Create>(async args => {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/create`, {
method: 'POST',
@@ -66,7 +66,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}, [])
const login = useCallback<Login>(async (args) => {
const login = useCallback<Login>(async args => {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/login`, {
method: 'POST',
@@ -142,7 +142,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
fetchMe()
}, [])
const forgotPassword = useCallback<ForgotPassword>(async (args) => {
const forgotPassword = useCallback<ForgotPassword>(async args => {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/forgot-password`, {
method: 'POST',
@@ -167,7 +167,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}, [])
const resetPassword = useCallback<ResetPassword>(async (args) => {
const resetPassword = useCallback<ResetPassword>(async args => {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/reset-password`, {
method: 'POST',

View File

@@ -32,7 +32,7 @@ const Context = createContext({} as CartContext)
export const useCart = () => useContext(Context)
const arrayHasItems = (array) => Array.isArray(array) && array.length > 0
const arrayHasItems = array => Array.isArray(array) && array.length > 0
// Step 1: Check local storage for a cart
// Step 2: If there is a cart, fetch the products and hydrate the cart
@@ -41,7 +41,7 @@ const arrayHasItems = (array) => Array.isArray(array) && array.length > 0
// Step 4B: Sync the cart to Payload and clear local storage
// Step 5: If the user is logged out, sync the cart to local storage only
export const CartProvider = (props) => {
export const CartProvider = props => {
// const { setTimedNotification } = useNotifications();
const { children } = props
const { user, status: authStatus } = useAuth()
@@ -138,7 +138,7 @@ export const CartProvider = (props) => {
const flattenedCart = {
...cart,
items: cart?.items
?.map((item) => {
?.map(item => {
if (!item?.product || typeof item?.product !== 'object') {
return null
}
@@ -203,7 +203,7 @@ export const CartProvider = (props) => {
)
// this method can be used to add new items AND update existing ones
const addItemToCart = useCallback((incomingItem) => {
const addItemToCart = useCallback(incomingItem => {
dispatchCart({
type: 'ADD_ITEM',
payload: incomingItem,

View File

@@ -36,7 +36,7 @@ export const ThemeSelector: React.FC = () => {
<label htmlFor="theme">
<select
id="theme"
onChange={(e) => onThemeChange(e.target.value as Theme & 'auto')}
onChange={e => onThemeChange(e.target.value as Theme & 'auto')}
ref={selectRef}
className={classes.select}
>

View File

@@ -7,8 +7,15 @@ export async function GET(request: NextRequest): Promise<unknown> {
const slug = request.nextUrl.searchParams.get('slug')
const secret = request.nextUrl.searchParams.get('secret')
if (secret !== process.env.NEXT_PRIVATE_REVALIDATION_KEY) {
return NextResponse.json({ revalidated: false, now: Date.now() })
if (
!secret ||
secret !== process.env.NEXT_PRIVATE_REVALIDATION_KEY ||
typeof collection !== 'string' ||
typeof slug !== 'string'
) {
// Do not indicate that the revalidation key is incorrect in the response
// This will protect this API route from being exploited
return new Response('Invalid request', { status: 400 })
}
if (typeof collection === 'string' && typeof slug === 'string') {

View File

@@ -15,6 +15,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<html lang="en" suppressHydrationWarning>
<head>
<InitTheme />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body>
<Providers>

View File

@@ -19,7 +19,7 @@ export const updateUserPurchases: AfterChangeHook<Order> = async ({ doc, req, op
id: orderedBy,
data: {
purchases: [
...(user?.purchases?.map((purchase) =>
...(user?.purchases?.map(purchase =>
typeof purchase === 'string' ? purchase : purchase.id,
) || []), // eslint-disable-line function-paren-newline
...(doc?.items?.map(({ product }) =>

Some files were not shown because too many files have changed in this diff Show More