Merge branch 'custom-checkout' into improvements

This commit is contained in:
B 2021-06-14 19:39:27 -03:00 committed by GitHub
commit b6357e9af1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
289 changed files with 47522 additions and 2655 deletions

View File

@ -13,3 +13,6 @@ NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
NEXT_PUBLIC_SWELL_STORE_ID=
NEXT_PUBLIC_SWELL_PUBLIC_KEY=
NEXT_PUBLIC_SALEOR_API_URL=
NEXT_PUBLIC_SALEOR_CHANNEL=

View File

@ -2,5 +2,13 @@
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
"useTabs": false,
"overrides": [
{
"files": ["framework/saleor/**/*"],
"options": {
"printWidth": 120
}
}
]
}

View File

@ -11,6 +11,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/)
- Swell Demo: https://swell.vercel.store/
- BigCommerce Demo: https://bigcommerce.vercel.store/
- Vendure Demo: https://vendure.vercel.store
- Saleor Demo: https://saleor.vercel.store/
## Features
@ -26,7 +27,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/)
## Integrations
Next.js Commerce integrates out-of-the-box with BigCommerce and Shopify. We plan to support all major ecommerce backends.
Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify and Saleor. We plan to support all major ecommerce backends.
## Considerations

27
codegen.bigcommerce.json Normal file
View File

@ -0,0 +1,27 @@
{
"schema": {
"https://buybutton.store/graphql": {
"headers": {
"Authorization": "Bearer xzy"
}
}
},
"documents": [
{
"./framework/bigcommerce/api/**/*.ts": {
"noRequire": true
}
}
],
"generates": {
"./framework/bigcommerce/schema.d.ts": {
"plugins": ["typescript", "typescript-operations"]
},
"./framework/bigcommerce/schema.graphql": {
"plugins": ["schema-ast"]
}
},
"hooks": {
"afterAllFileWrite": ["prettier --write"]
}
}

View File

@ -1,23 +1,29 @@
{
"schema": {
"https://buybutton.store/graphql": {
"headers": {
"Authorization": "Bearer xzy"
}
}
"https://master.staging.saleor.cloud/graphql/": {}
},
"documents": [
{
"./framework/bigcommerce/api/**/*.ts": {
"./framework/saleor/utils/queries/get-all-products-query.ts": {
"noRequire": true
}
},
{
"./framework/saleor/utils/queries/get-all-products-paths-query.ts": {
"noRequire": true
}
},
{
"./framework/saleor/utils/queries/get-products.ts": {
"noRequire": true
}
}
],
"generates": {
"./framework/bigcommerce/schema.d.ts": {
"./framework/saleor/schema.d.ts": {
"plugins": ["typescript", "typescript-operations"]
},
"./framework/bigcommerce/schema.graphql": {
"./framework/saleor/schema.graphql": {
"plugins": ["schema-ast"]
}
},

View File

@ -1,5 +1,6 @@
{
"features": {
"customCheckout": true
"wishlist": false,
"customCheckout": false
}
}

View File

@ -23,6 +23,7 @@
height: 100%;
left: 30% !important;
top: 30% !important;
z-index: 1;
}
.productName {

View File

@ -80,7 +80,7 @@ const CartItem = ({
{...rest}
>
<div className="flex flex-row space-x-4 py-4">
<div className="w-16 h-16 bg-violet relative overflow-hidden cursor-pointer">
<div className="w-16 h-16 bg-violet relative overflow-hidden cursor-pointer z-0">
<Link href={`/product/${item.path}`}>
<Image
onClick={() => closeSidebarIfPresent()}

View File

@ -1,15 +1,11 @@
.root {
min-height: 100vh;
}
.root.empty {
@apply bg-secondary text-secondary;
}
.root.success {
@apply bg-green text-white;
}
.root.error {
@apply bg-red text-white;
}
.lineItemsList {
@apply py-4 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accent-2 border-accent-2;
}

View File

@ -1,3 +1,7 @@
.root {
min-height: calc(100vh - 322px);
}
.lineItemsList {
@apply py-4 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accent-2 border-accent-2;
}

View File

@ -29,7 +29,10 @@ const CheckoutSidebarView: FC = () => {
)
return (
<SidebarLayout handleBack={() => setSidebarView('CART_VIEW')}>
<SidebarLayout
className={s.root}
handleBack={() => setSidebarView('CART_VIEW')}
>
<div className="px-4 sm:px-6 flex-1">
<Link href="/cart">
<Text variant="sectionHeading">Checkout</Text>

View File

@ -1,7 +1,3 @@
.root {
@apply h-full flex flex-col relative w-full relative;
}
.fieldset {
@apply flex flex-col my-3;
}

View File

@ -1,7 +1,3 @@
.root {
@apply h-full flex flex-col relative w-full relative;
}
.fieldset {
@apply flex flex-col my-3;
}

View File

@ -14,7 +14,7 @@ const Avatar: FC<Props> = ({}) => {
<div
ref={ref}
style={{ backgroundImage: userAvatar }}
className="inline-block h-8 w-8 rounded-full border-2 border-primary hover:border-secondary focus:border-secondary transition linear-out duration-150"
className="inline-block h-8 w-8 rounded-full border-2 border-primary hover:border-secondary focus:border-secondary transition-colors ease-linear"
>
{/* Add an image - We're generating a gradient as placeholder <img></img> */}
</div>

View File

@ -1,3 +1,7 @@
.root {
@apply border-t border-accent-2;
}
.link {
& > svg {
@apply transform duration-75 ease-linear;

View File

@ -15,73 +15,50 @@ interface Props {
pages?: Page[]
}
const LEGAL_PAGES = ['terms-of-use', 'shipping-returns', 'privacy-policy']
const links = [
{
name: 'Home',
href: '/',
url: '/',
},
]
const Footer: FC<Props> = ({ className, pages }) => {
const { sitePages, legalPages } = usePages(pages)
const rootClassName = cn(className)
const { sitePages } = usePages(pages)
const rootClassName = cn(s.root, className)
return (
<footer className={rootClassName}>
<Container>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 border-b border-accent-2 py-6 text-primary bg-primary transition-colors duration-150">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 border-b border-accent-2 py-12 text-primary bg-primary transition-colors duration-150">
<div className="col-span-1 lg:col-span-2">
<Link href="/">
<a className="flex flex-initial items-center font-bold md:mr-24">
<span className="rounded-full border border-gray-700 mr-2">
<span className="rounded-full border border-accent-6 mr-2">
<Logo />
</span>
<span>ACME</span>
</a>
</Link>
</div>
<div className="col-span-1 lg:col-span-2">
<ul className="flex flex-initial flex-col md:flex-1">
{links.map(({ href, name }) => (
<li className="py-3 md:py-0 md:pb-4" key={href}>
<Link href={href}>
<a className="text-primary hover:text-accent-6 transition ease-in-out duration-150">
{name}
</a>
</Link>
</li>
))}
{sitePages.map((page) => (
<li key={page.url} className="py-3 md:py-0 md:pb-4">
<div className="col-span-1 lg:col-span-8">
<div className="grid md:grid-rows-4 md:grid-cols-3 md:grid-flow-col">
{[...links, ...sitePages].map((page) => (
<span key={page.url} className="py-3 md:py-0 md:pb-4">
<Link href={page.url!}>
<a className="text-primary hover:text-accent-6 transition ease-in-out duration-150">
<a className="text-accent-9 hover:text-accent-6 transition ease-in-out duration-150">
{page.name}
</a>
</Link>
</li>
</span>
))}
</ul>
</div>
<div className="col-span-1 lg:col-span-2">
<ul className="flex flex-initial flex-col md:flex-1">
{legalPages.map((page) => (
<li key={page.url} className="py-3 md:py-0 md:pb-4">
<Link href={page.url!}>
<a className="text-primary hover:text-accent-6 transition ease-in-out duration-150">
{page.name}
</a>
</Link>
</li>
))}
</ul>
</div>
<div className="col-span-1 lg:col-span-6 flex items-start lg:justify-end text-primary">
<div className="col-span-1 lg:col-span-2 flex items-start lg:justify-end text-primary">
<div className="flex space-x-6 items-center h-10">
<a
className={s.link}
aria-label="Github Repository"
href="https://github.com/vercel/commerce"
className={s.link}
>
<Github />
</a>
@ -117,34 +94,21 @@ const Footer: FC<Props> = ({ className, pages }) => {
function usePages(pages?: Page[]) {
const { locale } = useRouter()
const sitePages: Page[] = []
const legalPages: Page[] = []
if (pages) {
pages.forEach((page) => {
const slug = page.url && getSlug(page.url)
if (!slug) return
if (locale && !slug.startsWith(`${locale}/`)) return
if (isLegalPage(slug, locale)) {
legalPages.push(page)
} else {
sitePages.push(page)
}
})
}
return {
sitePages: sitePages.sort(bySortOrder),
legalPages: legalPages.sort(bySortOrder),
}
}
const isLegalPage = (slug: string, locale?: string) =>
locale
? LEGAL_PAGES.some((p) => `${locale}/${p}` === slug)
: LEGAL_PAGES.includes(slug)
// Sort pages by the sort order assigned in the BC dashboard
function bySortOrder(a: Page, b: Page) {
return (a.sort_order ?? 0) - (b.sort_order ?? 0)

View File

@ -49,21 +49,53 @@ interface Props {
}
}
const ModalView: FC<{ modalView: string; closeModal(): any }> = ({
modalView,
closeModal,
}) => {
return (
<Modal onClose={closeModal}>
{modalView === 'LOGIN_VIEW' && <LoginView />}
{modalView === 'SIGNUP_VIEW' && <SignUpView />}
{modalView === 'FORGOT_VIEW' && <ForgotPassword />}
</Modal>
)
}
const ModalUI: FC = () => {
const { displayModal, closeModal, modalView } = useUI()
return displayModal ? (
<ModalView modalView={modalView} closeModal={closeModal} />
) : null
}
const SidebarView: FC<{ sidebarView: string; closeSidebar(): any }> = ({
sidebarView,
closeSidebar,
}) => {
return (
<Sidebar onClose={closeSidebar}>
{sidebarView === 'CART_VIEW' && <CartSidebarView />}
{sidebarView === 'CHECKOUT_VIEW' && <CheckoutSidebarView />}
{sidebarView === 'PAYMENT_VIEW' && <PaymentMethodView />}
{sidebarView === 'SHIPPING_VIEW' && <ShippingView />}
</Sidebar>
)
}
const SidebarUI: FC = () => {
const { displaySidebar, closeSidebar, sidebarView } = useUI()
return displaySidebar ? (
<SidebarView sidebarView={sidebarView} closeSidebar={closeSidebar} />
) : null
}
const Layout: FC<Props> = ({
children,
pageProps: { categories = [], ...pageProps },
}) => {
const {
displaySidebar,
displayModal,
closeSidebar,
closeModal,
modalView,
sidebarView,
} = useUI()
const { acceptedCookies, onAcceptCookies } = useAcceptCookies()
const { locale = 'en-US' } = useRouter()
const navBarlinks = categories.slice(0, 2).map((c) => ({
label: c.name,
href: `/search/${c.slug}`,
@ -75,20 +107,8 @@ const Layout: FC<Props> = ({
<Navbar links={navBarlinks} />
<main className="fit">{children}</main>
<Footer pages={pageProps.pages} />
<Modal open={displayModal} onClose={closeModal}>
{modalView === 'LOGIN_VIEW' && <LoginView />}
{modalView === 'SIGNUP_VIEW' && <SignUpView />}
{modalView === 'FORGOT_VIEW' && <ForgotPassword />}
</Modal>
<Sidebar open={displaySidebar} onClose={closeSidebar}>
{sidebarView === 'CART_VIEW' && <CartSidebarView />}
{sidebarView === 'CHECKOUT_VIEW' && <CheckoutSidebarView />}
{sidebarView === 'PAYMENT_VIEW' && <PaymentMethodView />}
{sidebarView === 'SHIPPING_VIEW' && <ShippingView />}
</Sidebar>
<ModalUI />
<SidebarUI />
<FeatureBar
title="This site uses cookies to improve your experience. By clicking, you agree to our Privacy Policy."
hide={acceptedCookies}

View File

@ -1,8 +1,20 @@
.root {
@apply relative h-full flex flex-col w-full;
@apply relative h-full flex flex-col;
}
.header {
@apply pl-4 pr-6 pt-4 pb-4 lg:pt-5 flex items-center justify-between space-x-3;
margin-top: 1px;
@apply sticky top-0 pl-4 py-4 pr-6
flex items-center justify-between
bg-accent-0 box-border w-full z-10;
min-height: 66px;
}
.container {
@apply flex flex-col flex-1 box-border;
}
@screen lg {
.header {
min-height: 74px;
}
}

View File

@ -22,30 +22,27 @@ const SidebarLayout: FC<ComponentProps> = ({
<button
onClick={handleClose}
aria-label="Close"
className="hover:text-gray-500 transition ease-in-out duration-150 flex items-center focus:outline-none"
className="hover:text-accent-5 transition ease-in-out duration-150 flex items-center focus:outline-none"
>
<Cross className="h-6 w-6" />
<span className="ml-2 text-accent-7 text-sm hover:text-gray-500">
Close
</span>
<Cross className="h-6 w-6 hover:text-accent-3" />
<span className="ml-2 text-accent-7 text-sm ">Close</span>
</button>
)}
{handleBack && (
<button
onClick={handleBack}
aria-label="Go back"
className="hover:text-gray-500 transition ease-in-out duration-150 flex items-center focus:outline-none"
className="hover:text-accent-5 transition ease-in-out duration-150 flex items-center focus:outline-none"
>
<ChevronLeft className="h-6 w-6" />
<span className="ml-2 text-accent-7 text-xs hover:text-gray-500">
Back
</span>
<ChevronLeft className="h-6 w-6 hover:text-accent-3" />
<span className="ml-2 text-accent-7 text-xs">Back</span>
</button>
)}
<span className={s.nav}>
<UserNav />
</span>
</header>
{children}
<div className={s.container}>{children}</div>
</div>
)
}

View File

@ -24,7 +24,6 @@ const UserNav: FC<Props> = ({ className }) => {
return (
<nav className={cn(s.root, className)}>
<div className={s.mainContainer}>
<ul className={s.list}>
<li className={s.item} onClick={toggleSidebar}>
<Bag />
@ -39,6 +38,7 @@ const UserNav: FC<Props> = ({ className }) => {
</Link>
</li>
)}
{process.env.COMMERCE_CUSTOMER_ENABLED && (
<li className={s.item}>
{customer ? (
<DropdownMenu />
@ -52,8 +52,8 @@ const UserNav: FC<Props> = ({ className }) => {
</button>
)}
</li>
)}
</ul>
</div>
</nav>
)
}

View File

@ -1,23 +1,22 @@
const RightArrow = ({ ...props }) => {
const ArrowRight = ({ ...props }) => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5 12H19"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 5L19 12L12 19"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
@ -26,4 +25,4 @@ const RightArrow = ({ ...props }) => {
)
}
export default RightArrow
export default ArrowRight

View File

@ -2,18 +2,18 @@ export { default as Bag } from './Bag'
export { default as Heart } from './Heart'
export { default as Trash } from './Trash'
export { default as Cross } from './Cross'
export { default as ArrowLeft } from './ArrowLeft'
export { default as Plus } from './Plus'
export { default as Minus } from './Minus'
export { default as Check } from './Check'
export { default as Sun } from './Sun'
export { default as Moon } from './Moon'
export { default as Github } from './Github'
export { default as RightArrow } from './RightArrow'
export { default as Info } from './Info'
export { default as Vercel } from './Vercel'
export { default as MapPin } from './MapPin'
export { default as Star } from './Star'
export { default as ArrowLeft } from './ArrowLeft'
export { default as ArrowRight } from './ArrowRight'
export { default as CreditCard } from './CreditCard'
export { default as ChevronUp } from './ChevronUp'
export { default as ChevronLeft } from './ChevronLeft'

View File

@ -3,104 +3,112 @@
bg-no-repeat bg-center bg-cover transition-transform
ease-linear cursor-pointer inline-block bg-accent-1;
height: 100% !important;
}
&:hover {
.root:hover {
& .productImage {
transform: scale(1.2625);
}
& .productTitle > span,
& .productPrice,
& .header .name span,
& .header .price,
& .wishlistButton {
@apply bg-secondary text-secondary;
}
&:nth-child(6n + 1) .productTitle > span,
&:nth-child(6n + 1) .productPrice,
&:nth-child(6n + 1) .header .name span,
&:nth-child(6n + 1) .header .price,
&:nth-child(6n + 1) .wishlistButton {
@apply bg-violet text-white;
}
&:nth-child(6n + 5) .productTitle > span,
&:nth-child(6n + 5) .productPrice,
&:nth-child(6n + 5) .header .name span,
&:nth-child(6n + 5) .header .price,
&:nth-child(6n + 5) .wishlistButton {
@apply bg-blue text-white;
}
&:nth-child(6n + 3) .productTitle > span,
&:nth-child(6n + 3) .productPrice,
&:nth-child(6n + 3) .header .name span,
&:nth-child(6n + 3) .header .price,
&:nth-child(6n + 3) .wishlistButton {
@apply bg-pink text-white;
}
&:nth-child(6n + 6) .productTitle > span,
&:nth-child(6n + 6) .productPrice,
&:nth-child(6n + 6) .header .name span,
&:nth-child(6n + 6) .header .price,
&:nth-child(6n + 6) .wishlistButton {
@apply bg-cyan text-white;
}
}
.header {
@apply transition-colors ease-in-out duration-500
absolute top-0 left-0 z-20 pr-16;
}
.header .name {
@apply pt-0 max-w-full w-full leading-extra-loose
transition-colors ease-in-out duration-500;
font-size: 2rem;
letter-spacing: 0.4px;
}
.header .name span {
@apply py-4 px-6 bg-primary text-primary font-bold
transition-colors ease-in-out duration-500;
font-size: inherit;
letter-spacing: inherit;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.header .price {
@apply pt-2 px-6 pb-4 text-sm bg-primary text-accent-9
font-semibold inline-block tracking-wide
transition-colors ease-in-out duration-500;
}
.imageContainer {
@apply flex items-center justify-center overflow-hidden;
}
.imageContainer > div {
min-width: 100%;
}
.imageContainer .productImage {
@apply transform transition-transform duration-500
object-cover scale-120;
}
.root .wishlistButton {
@apply top-0 right-0 z-30 absolute;
}
.productTitle > span,
.productPrice {
@apply transition-colors ease-in-out duration-500;
/* Variant Simple */
.simple .header .name {
@apply pt-2 text-lg leading-10 -mt-1;
}
.simple {
& .productTitle {
@apply pt-2;
font-size: 1rem;
& span {
@apply leading-extra-loose;
}
}
& .productPrice {
.simple .header .price {
@apply text-sm;
}
}
.productTitle {
@apply pt-0 max-w-full w-full leading-extra-loose;
font-size: 2rem;
letter-spacing: 0.4px;
& span {
@apply py-4 px-6 bg-primary text-primary font-bold;
font-size: inherit;
letter-spacing: inherit;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
}
.productPrice {
@apply py-4 px-6 bg-primary text-primary font-semibold inline-block text-sm leading-6;
letter-spacing: 0.4px;
}
.imageContainer {
@apply flex items-center justify-center;
overflow: hidden;
& > div {
min-width: 100%;
}
}
.productImage {
@apply transform transition-transform duration-500 object-cover scale-120;
}
/* Variant Slim */
.slim {
@apply bg-transparent relative overflow-hidden box-border;
@apply bg-transparent relative overflow-hidden
box-border;
}
.slim .tag {
@apply bg-secondary text-secondary inline-block p-3 font-bold text-xl break-words;
.slim .header {
@apply absolute inset-0 flex items-center justify-end mr-8 z-20;
}
.slim span {
@apply bg-accent-9 text-accent-0 inline-block p-3
font-bold text-xl break-words;
}
.root:global(.secondary) .header span {
@apply bg-accent-0 text-accent-9;
}

View File

@ -5,42 +5,50 @@ import type { Product } from '@commerce/types/product'
import s from './ProductCard.module.css'
import Image, { ImageProps } from 'next/image'
import WishlistButton from '@components/wishlist/WishlistButton'
import usePrice from '@framework/product/use-price'
import ProductTag from '../ProductTag'
interface Props {
className?: string
product: Product
variant?: 'default' | 'slim' | 'simple'
imgProps?: Omit<ImageProps, 'src'>
noNameTag?: boolean
imgProps?: Omit<ImageProps, 'src'>
variant?: 'default' | 'slim' | 'simple'
}
const placeholderImg = '/product-img-placeholder.svg'
const ProductCard: FC<Props> = ({
className,
product,
imgProps,
variant = 'default',
className,
noNameTag = false,
variant = 'default',
...props
}) => (
<Link href={`/product/${product.slug}`} {...props}>
<a
className={cn(
}) => {
const { price } = usePrice({
amount: product.price.value,
baseAmount: product.price.retailPrice,
currencyCode: product.price.currencyCode!,
})
const rootClassName = cn(
s.root,
{ [s.slim]: variant === 'slim', [s.simple]: variant === 'simple' },
className
)}
>
)
return (
<Link href={`/product/${product.slug}`} {...props}>
<a className={rootClassName}>
{variant === 'slim' && (
<>
<div className="absolute inset-0 flex items-center justify-end mr-8 z-20">
<span className={s.tag}>{product.name}</span>
<div className={s.header}>
<span>{product.name}</span>
</div>
{product?.images && (
<Image
quality="85"
src={product.images[0].url || placeholderImg}
src={product.images[0]?.url || placeholderImg}
alt={product.name || 'Product Image'}
height={320}
width={320}
@ -57,24 +65,19 @@ const ProductCard: FC<Props> = ({
<WishlistButton
className={s.wishlistButton}
productId={product.id}
variant={product.variants[0] as any}
variant={product.variants[0]}
/>
)}
<div className="flex flex-row justify-between box-border w-full z-20 absolute ">
{!noNameTag && (
<div className="absolute top-0 left-0 pr-16 max-w-full">
<h3 className={s.productTitle}>
<div className={s.header}>
<h3 className={s.name}>
<span>{product.name}</span>
</h3>
<span className={s.productPrice}>
{product.price.value}
&nbsp;
{product.price.currencyCode}
</span>
<div className={s.price}>
{`${price} ${product.price?.currencyCode}`}
</div>
</div>
)}
</div>
<div className={s.imageContainer}>
{product?.images && (
<Image
@ -101,18 +104,10 @@ const ProductCard: FC<Props> = ({
variant={product.variants[0] as any}
/>
)}
<div className="flex flex-row justify-between box-border w-full z-20 absolute ">
<div className="absolute top-0 left-0 pr-16 max-w-full">
<h3 className={s.productTitle}>
<span>{product.name}</span>
</h3>
<span className={s.productPrice}>
{product.price.value}
&nbsp;
{product.price.currencyCode}
</span>
</div>
</div>
<ProductTag
name={product.name}
price={`${price} ${product.price?.currencyCode}`}
/>
<div className={s.imageContainer}>
{product?.images && (
<Image
@ -132,5 +127,6 @@ const ProductCard: FC<Props> = ({
</a>
</Link>
)
}
export default ProductCard

View File

@ -1,18 +1,15 @@
import { Swatch } from '@components/product'
import type { ProductOption } from '@commerce/types/product'
import { SelectedOptions } from '../helpers'
import React from 'react'
interface ProductOptionsProps {
options: ProductOption[]
selectedOptions: SelectedOptions
setSelectedOptions: React.Dispatch<React.SetStateAction<SelectedOptions>>
}
const ProductOptions: React.FC<ProductOptionsProps> = ({
options,
selectedOptions,
setSelectedOptions,
}) => {
const ProductOptions: React.FC<ProductOptionsProps> = React.memo(
({ options, selectedOptions, setSelectedOptions }) => {
return (
<div>
{options.map((opt) => (
@ -34,7 +31,8 @@ const ProductOptions: React.FC<ProductOptionsProps> = ({
setSelectedOptions((selectedOptions) => {
return {
...selectedOptions,
[opt.displayName.toLowerCase()]: v.label.toLowerCase(),
[opt.displayName.toLowerCase()]:
v.label.toLowerCase(),
}
})
}}
@ -47,5 +45,6 @@ const ProductOptions: React.FC<ProductOptionsProps> = ({
</div>
)
}
)
export default ProductOptions

View File

@ -0,0 +1,84 @@
.root {
@apply relative grid items-start gap-1 grid-cols-1 overflow-x-hidden;
min-height: auto;
}
.main {
@apply relative px-0 pb-0 box-border flex flex-col col-span-1;
min-height: 500px;
}
.header {
@apply transition-colors ease-in-out duration-500
absolute top-0 left-0 z-20 pr-16;
}
.header .name {
@apply pt-0 max-w-full w-full leading-extra-loose;
font-size: 2rem;
letter-spacing: 0.4px;
}
.header .name span {
@apply py-4 px-6 bg-primary text-primary font-bold;
font-size: inherit;
letter-spacing: inherit;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.header .price {
@apply pt-2 px-6 pb-4 text-sm bg-primary text-accent-9
font-semibold inline-block tracking-wide;
}
.sidebar {
@apply flex flex-col col-span-1 mx-auto max-w-8xl px-6 py-6 w-full h-full;
}
.sliderContainer {
@apply flex items-center justify-center overflow-x-hidden bg-violet;
}
.imageContainer {
@apply text-center;
}
.imageContainer > div,
.imageContainer > div > div {
@apply h-full;
}
.sliderContainer .img {
@apply w-full h-auto max-h-full object-cover;
}
.button {
width: 100%;
}
.wishlistButton {
@apply absolute z-30 top-0 right-0;
}
.relatedProductsGrid {
@apply grid grid-cols-2 py-2 gap-2 md:grid-cols-4 md:gap-7;
}
@screen lg {
.root {
@apply grid-cols-12;
}
.main {
@apply mx-0 col-span-8;
}
.sidebar {
@apply col-span-4 py-6;
}
.imageContainer {
max-height: 600px;
}
}

View File

@ -0,0 +1,87 @@
import s from './ProductSidebar.module.css'
import { useAddItem } from '@framework/cart'
import { FC, useEffect, useState } from 'react'
import { ProductOptions } from '@components/product'
import type { Product } from '@commerce/types/product'
import { Button, Text, Rating, Collapse, useUI } from '@components/ui'
import {
getProductVariant,
selectDefaultOptionFromProduct,
SelectedOptions,
} from '../helpers'
interface ProductSidebarProps {
product: Product
className?: string
}
const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => {
const addItem = useAddItem()
const { openSidebar } = useUI()
const [loading, setLoading] = useState(false)
const [selectedOptions, setSelectedOptions] = useState<SelectedOptions>({})
useEffect(() => {
selectDefaultOptionFromProduct(product, setSelectedOptions)
}, [])
const variant = getProductVariant(product, selectedOptions)
const addToCart = async () => {
setLoading(true)
try {
await addItem({
productId: String(product.id),
variantId: String(variant ? variant.id : product.variants[0].id),
})
openSidebar()
setLoading(false)
} catch (err) {
setLoading(false)
}
}
return (
<div className={className}>
<ProductOptions
options={product.options}
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
/>
<Text
className="pb-4 break-words w-full max-w-xl"
html={product.descriptionHtml || product.description}
/>
<div className="flex flex-row justify-between items-center">
<Rating value={4} />
<div className="text-accent-6 pr-1 font-medium text-sm">36 reviews</div>
</div>
<div>
<Button
aria-label="Add to Cart"
type="button"
className={s.button}
onClick={addToCart}
loading={loading}
disabled={variant?.availableForSale === false}
>
{variant?.availableForSale === false
? 'Not Available'
: 'Add To Cart'}
</Button>
</div>
<div className="mt-6">
<Collapse title="Care">
This is a limited edition production run. Printing starts when the
drop ends.
</Collapse>
<Collapse title="Details">
This is a limited edition production run. Printing starts when the
drop ends. Reminder: Bad Boys For Life. Shipping may take 10+ days due
to COVID-19.
</Collapse>
</div>
</div>
)
}
export default ProductSidebar

View File

@ -0,0 +1 @@
export { default } from './ProductSidebar'

View File

@ -12,36 +12,6 @@
opacity: 1;
}
.control {
@apply bg-violet absolute bottom-10 right-10 flex flex-row
border-accent-0 border text-accent-0 z-30 shadow-xl select-none;
height: 48px;
}
.leftControl,
.rightControl {
@apply px-8 cursor-pointer;
transition: background-color 0.2s ease;
}
.leftControl:hover,
.rightControl:hover {
background-color: var(--violet-dark);
}
.leftControl:focus,
.rightControl:focus {
@apply outline-none;
}
.rightControl {
@apply border-l;
}
.leftControl {
margin-right: -1px;
}
.thumb {
@apply transition-transform transition-colors
ease-linear duration-75 overflow-hidden inline-block
@ -67,8 +37,12 @@
overflow-x: auto;
white-space: nowrap;
height: 125px;
padding-bottom: 10px;
margin-bottom: -10px;
scrollbar-width: none;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.album::-webkit-scrollbar {
display: none;
}
@screen md {

View File

@ -10,9 +10,17 @@ import React, {
import cn from 'classnames'
import { a } from '@react-spring/web'
import s from './ProductSlider.module.css'
import { ChevronLeft, ChevronRight } from '@components/icons'
import ProductSliderControl from '../ProductSliderControl'
const ProductSlider: FC = ({ children }) => {
interface ProductSliderProps {
children: React.ReactNode[]
className?: string
}
const ProductSlider: React.FC<ProductSliderProps> = ({
children,
className = '',
}) => {
const [currentSlide, setCurrentSlide] = useState(0)
const [isMounted, setIsMounted] = useState(false)
const sliderContainerRef = useRef<HTMLDivElement>(null)
@ -73,30 +81,16 @@ const ProductSlider: FC = ({ children }) => {
}
}, [])
const onPrev = React.useCallback(() => slider.prev(), [slider])
const onNext = React.useCallback(() => slider.next(), [slider])
return (
<div className={s.root} ref={sliderContainerRef}>
<div className={cn(s.root, className)} ref={sliderContainerRef}>
<div
ref={ref}
className={cn(s.slider, { [s.show]: isMounted }, 'keen-slider')}
>
{slider && (
<div className={s.control}>
<button
className={cn(s.leftControl)}
onClick={slider.prev}
aria-label="Previous Product Image"
>
<ChevronLeft />
</button>
<button
className={cn(s.rightControl)}
onClick={slider.next}
aria-label="Next Product Image"
>
<ChevronRight />
</button>
</div>
)}
{slider && <ProductSliderControl onPrev={onPrev} onNext={onNext} />}
{Children.map(children, (child) => {
// Add the keen-slider__slide className to children
if (isValidElement(child)) {

View File

@ -0,0 +1,29 @@
.control {
@apply bg-violet absolute bottom-10 right-10 flex flex-row
border-accent-0 border text-accent-0 z-30 shadow-xl select-none;
height: 48px;
}
.leftControl,
.rightControl {
@apply px-9 cursor-pointer;
transition: background-color 0.2s ease;
}
.leftControl:hover,
.rightControl:hover {
background-color: var(--violet-dark);
}
.leftControl:focus,
.rightControl:focus {
@apply outline-none;
}
.rightControl {
@apply border-l border-accent-0;
}
.leftControl {
margin-right: -1px;
}

View File

@ -0,0 +1,31 @@
import cn from 'classnames'
import React from 'react'
import s from './ProductSliderControl.module.css'
import { ArrowLeft, ArrowRight } from '@components/icons'
interface ProductSliderControl {
onPrev: React.MouseEventHandler<HTMLButtonElement>
onNext: React.MouseEventHandler<HTMLButtonElement>
}
const ProductSliderControl: React.FC<ProductSliderControl> = React.memo(
({ onPrev, onNext }) => (
<div className={s.control}>
<button
className={cn(s.leftControl)}
onClick={onPrev}
aria-label="Previous Product Image"
>
<ArrowLeft />
</button>
<button
className={cn(s.rightControl)}
onClick={onNext}
aria-label="Next Product Image"
>
<ArrowRight />
</button>
</div>
)
)
export default ProductSliderControl

View File

@ -0,0 +1 @@
export { default } from './ProductSliderControl'

View File

@ -0,0 +1,30 @@
.root {
@apply transition-colors ease-in-out duration-500
absolute top-0 left-0 z-20 pr-16;
}
.root .name {
@apply pt-0 max-w-full w-full leading-extra-loose;
font-size: 2rem;
letter-spacing: 0.4px;
line-height: 2.2em;
}
.root .name span {
@apply py-4 px-6 bg-primary text-primary font-bold;
min-height: 70px;
font-size: inherit;
letter-spacing: inherit;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.root .name span.fontsizing {
display: flex;
padding-top: 1.5rem;
}
.root .price {
@apply pt-2 px-6 pb-4 text-sm bg-primary text-accent-9
font-semibold inline-block tracking-wide;
}

View File

@ -0,0 +1,36 @@
import cn from 'classnames'
import { inherits } from 'util'
import s from './ProductTag.module.css'
interface ProductTagProps {
className?: string
name: string
price: string
fontSize?: number
}
const ProductTag: React.FC<ProductTagProps> = ({
name,
price,
className = '',
fontSize = 32,
}) => {
return (
<div className={cn(s.root, className)}>
<h3 className={s.name}>
<span
className={cn({ [s.fontsizing]: fontSize < 32 })}
style={{
fontSize: `${fontSize}px`,
lineHeight: `${fontSize}px`,
}}
>
{name}
</span>
</h3>
<div className={s.price}>{price}</div>
</div>
)
}
export default ProductTag

View File

@ -0,0 +1 @@
export { default } from './ProductTag'

View File

@ -1,5 +1,6 @@
.root {
@apply relative grid items-start gap-1 grid-cols-1 overflow-x-hidden;
min-height: auto;
}
.main {
@ -7,20 +8,6 @@
min-height: 500px;
}
.nameBox {
@apply absolute top-0 left-0 z-20 pr-16;
}
.nameBox .name {
@apply px-6 py-2 bg-primary text-primary font-bold;
font-size: 1.8rem;
letter-spacing: 0.4px;
}
.nameBox .price {
@apply px-6 py-2 pb-4 bg-primary text-primary font-bold inline-block tracking-wide;
}
.sidebar {
@apply flex flex-col col-span-1 mx-auto max-w-8xl px-6 py-6 w-full h-full;
}
@ -50,25 +37,19 @@
@apply absolute z-30 top-0 right-0;
}
.relatedProductsGrid {
@apply grid grid-cols-2 py-2 gap-2 md:grid-cols-4 md:gap-7;
}
@screen lg {
.root {
@apply grid-cols-12;
min-height: 900px;
}
.main {
@apply mx-0 col-span-8;
}
.nameBox {
@apply left-0 pr-16;
}
.nameBox .name,
.nameBox .price {
@apply bg-accent-0 text-accent-9;
}
.sidebar {
@apply col-span-4 py-6;
}

View File

@ -2,91 +2,36 @@ import cn from 'classnames'
import Image from 'next/image'
import { NextSeo } from 'next-seo'
import s from './ProductView.module.css'
import { FC, useEffect, useState } from 'react'
import { FC } from 'react'
import type { Product } from '@commerce/types/product'
import usePrice from '@framework/product/use-price'
import {
getProductVariant,
selectDefaultOptionFromProduct,
SelectedOptions,
} from '../helpers'
import { useAddItem } from '@framework/cart'
import { WishlistButton } from '@components/wishlist'
import { ProductSlider, ProductCard, ProductOptions } from '@components/product'
import {
Button,
Container,
Text,
useUI,
Rating,
Collapse,
} from '@components/ui'
import { ProductSlider, ProductCard } from '@components/product'
import { Container, Text } from '@components/ui'
import ProductSidebar from '../ProductSidebar'
import ProductTag from '../ProductTag'
interface ProductViewProps {
product: Product
className?: string
relatedProducts: Product[]
children?: React.ReactNode
}
const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
const { openSidebar } = useUI()
const [loading, setLoading] = useState(false)
const [selectedOptions, setSelectedOptions] = useState<SelectedOptions>({})
const addItem = useAddItem()
const { price } = usePrice({
amount: product.price.value,
baseAmount: product.price.retailPrice,
currencyCode: product.price.currencyCode!,
})
useEffect(() => {
selectDefaultOptionFromProduct(product, setSelectedOptions)
}, [])
const variant = getProductVariant(product, selectedOptions)
const addToCart = async () => {
setLoading(true)
try {
await addItem({
productId: String(product.id),
variantId: String(variant ? variant.id : product.variants[0].id),
})
openSidebar()
setLoading(false)
} catch (err) {
setLoading(false)
}
}
return (
<>
<Container className="max-w-none w-full" clean>
<NextSeo
title={product.name}
description={product.description}
openGraph={{
type: 'website',
title: product.name,
description: product.description,
images: [
{
url: product.images[0]?.url!,
width: 800,
height: 600,
alt: product.name,
},
],
}}
/>
<div className={cn(s.root, 'fit')}>
<div className={cn(s.main, 'fit')}>
<div className={s.nameBox}>
<h1 className={s.name}>{product.name}</h1>
<div className={s.price}>
{`${price} ${product.price?.currencyCode}`}
</div>
</div>
<ProductTag
name={product.name}
price={`${price} ${product.price?.currencyCode}`}
fontSize={32}
/>
<div className={s.sliderContainer}>
<ProductSlider key={product.id}>
{product.images.map((image, i) => (
@ -112,53 +57,13 @@ const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
/>
)}
</div>
<div className={s.sidebar}>
<ProductOptions
options={product.options}
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
/>
<Text
className="pb-4 break-words w-full max-w-xl"
html={product.descriptionHtml || product.description}
/>
<div className="flex flex-row justify-between items-center">
<Rating value={2} />
<div className="text-accent-6 pr-1 font-medium select-none">
36 reviews
<ProductSidebar product={product} className={s.sidebar} />
</div>
</div>
<div>
<Button
aria-label="Add to Cart"
type="button"
className={s.button}
onClick={addToCart}
loading={loading}
disabled={variant?.availableForSale === false}
>
{variant?.availableForSale === false
? 'Not Available'
: 'Add To Cart'}
</Button>
</div>
<div className="mt-6">
<Collapse title="Care">
This is a limited edition production run. Printing starts when the
drop ends.
</Collapse>
<Collapse title="Details">
This is a limited edition production run. Printing starts when the
drop ends. Reminder: Bad Boys For Life. Shipping may take 10+ days
due to COVID-19.
</Collapse>
</div>
</div>
</div>
<hr className="mt-6" />
<section className="py-6 px-6 mb-10">
<hr className="mt-7 border-accent-2" />
<section className="py-12 px-6 mb-10">
<Text variant="sectionHeading">Related Products</Text>
<div className="grid grid-cols-2 py-2 gap-4 md:grid-cols-4 md:gap-20">
<div className={s.relatedProductsGrid}>
{relatedProducts.map((p) => (
<div
key={p.path}
@ -171,8 +76,8 @@ const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
variant="simple"
className="animated fadeIn"
imgProps={{
width: 182,
height: 182,
width: 300,
height: 300,
}}
/>
</div>
@ -180,6 +85,24 @@ const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
</div>
</section>
</Container>
<NextSeo
title={product.name}
description={product.description}
openGraph={{
type: 'website',
title: product.name,
description: product.description,
images: [
{
url: product.images[0]?.url!,
width: 800,
height: 600,
alt: product.name,
},
],
}}
/>
</>
)
}

View File

@ -3,7 +3,7 @@
composes: root from '@components/ui/Button/Button.module.css';
@apply h-10 w-10 bg-primary text-primary rounded-full mr-3 inline-flex
items-center justify-center cursor-pointer transition duration-150 ease-in-out
p-0 shadow-none border-gray-200 border box-border select-none;
p-0 shadow-none border-accent-3 border box-border select-none;
margin-right: calc(0.75rem - 1px);
overflow: hidden;
width: 48px;

View File

@ -1,5 +1,5 @@
import cn from 'classnames'
import { FC } from 'react'
import React from 'react'
import s from './Swatch.module.css'
import { Check } from '@components/icons'
import Button, { ButtonProps } from '@components/ui/Button'
@ -13,12 +13,13 @@ interface SwatchProps {
label?: string | null
}
const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
const Swatch: React.FC<Omit<ButtonProps, 'variant'> & SwatchProps> = React.memo(
({
active,
className,
color = '',
label = null,
variant = 'size',
active,
...props
}) => {
variant = variant?.toLowerCase()
@ -30,9 +31,9 @@ const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
const swatchClassName = cn(
s.swatch,
{
[s.color]: color,
[s.active]: active,
[s.size]: variant === 'size',
[s.color]: color,
[s.dark]: color ? isDark(color) : false,
[s.textLabel]: !color && label && label.length > 3,
},
@ -41,10 +42,10 @@ const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
return (
<Button
className={swatchClassName}
style={color ? { backgroundColor: color } : {}}
aria-label="Variant Swatch"
className={swatchClassName}
{...(label && color && { title: label })}
style={color ? { backgroundColor: color } : {}}
{...props}
>
{color && active && (
@ -56,5 +57,6 @@ const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
</Button>
)
}
)
export default Swatch

439
components/search.tsx Normal file
View File

@ -0,0 +1,439 @@
import cn from 'classnames'
import type { SearchPropsType } from '@lib/search-props'
import Link from 'next/link'
import { useState } from 'react'
import { useRouter } from 'next/router'
import { Layout } from '@components/common'
import { ProductCard } from '@components/product'
import type { Product } from '@commerce/types/product'
import { Container, Grid, Skeleton } from '@components/ui'
import useSearch from '@framework/product/use-search'
import getSlug from '@lib/get-slug'
import rangeMap from '@lib/range-map'
const SORT = Object.entries({
'trending-desc': 'Trending',
'latest-desc': 'Latest arrivals',
'price-asc': 'Price: Low to high',
'price-desc': 'Price: High to low',
})
import {
filterQuery,
getCategoryPath,
getDesignerPath,
useSearchMeta,
} from '@lib/search'
export default function Search({ categories, brands }: SearchPropsType) {
const [activeFilter, setActiveFilter] = useState('')
const [toggleFilter, setToggleFilter] = useState(false)
const router = useRouter()
const { asPath, locale } = router
const { q, sort } = router.query
// `q` can be included but because categories and designers can't be searched
// in the same way of products, it's better to ignore the search input if one
// of those is selected
const query = filterQuery({ sort })
const { pathname, category, brand } = useSearchMeta(asPath)
const activeCategory = categories.find((cat: any) => cat.slug === category)
const activeBrand = brands.find(
(b: any) => getSlug(b.node.path) === `brands/${brand}`
)?.node
const { data } = useSearch({
search: typeof q === 'string' ? q : '',
categoryId: activeCategory?.id,
brandId: (activeBrand as any)?.entityId,
sort: typeof sort === 'string' ? sort : '',
locale,
})
const handleClick = (event: any, filter: string) => {
if (filter !== activeFilter) {
setToggleFilter(true)
} else {
setToggleFilter(!toggleFilter)
}
setActiveFilter(filter)
}
return (
<Container>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 mt-3 mb-20">
<div className="col-span-8 lg:col-span-2 order-1 lg:order-none">
{/* Categories */}
<div className="relative inline-block w-full">
<div className="lg:hidden">
<span className="rounded-md shadow-sm">
<button
type="button"
onClick={(e) => handleClick(e, 'categories')}
className="flex justify-between w-full rounded-sm border border-accent-3 px-4 py-3 bg-accent-0 text-sm leading-5 font-medium text-accent-4 hover:text-accent-5 focus:outline-none focus:border-blue-300 focus:shadow-outline-normal active:bg-accent-1 active:text-accent-8 transition ease-in-out duration-150"
id="options-menu"
aria-haspopup="true"
aria-expanded="true"
>
{activeCategory?.name
? `Category: ${activeCategory?.name}`
: 'All Categories'}
<svg
className="-mr-1 ml-2 h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</span>
</div>
<div
className={`origin-top-left absolute lg:relative left-0 mt-2 w-full rounded-md shadow-lg lg:shadow-none z-10 mb-10 lg:block ${
activeFilter !== 'categories' || toggleFilter !== true
? 'hidden'
: ''
}`}
>
<div className="rounded-sm bg-accent-0 shadow-xs lg:bg-none lg:shadow-none">
<div
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
<ul>
<li
className={cn(
'block text-sm leading-5 text-accent-4 lg:text-base lg:no-underline lg:font-bold lg:tracking-wide hover:bg-accent-1 lg:hover:bg-transparent hover:text-accent-8 focus:outline-none focus:bg-accent-1 focus:text-accent-8',
{
underline: !activeCategory?.name,
}
)}
>
<Link
href={{ pathname: getCategoryPath('', brand), query }}
>
<a
onClick={(e) => handleClick(e, 'categories')}
className={
'block lg:inline-block px-4 py-2 lg:p-0 lg:my-2 lg:mx-4'
}
>
All Categories
</a>
</Link>
</li>
{categories.map((cat: any) => (
<li
key={cat.path}
className={cn(
'block text-sm leading-5 text-accent-4 hover:bg-accent-1 lg:hover:bg-transparent hover:text-accent-8 focus:outline-none focus:bg-accent-1 focus:text-accent-8',
{
underline: activeCategory?.id === cat.id,
}
)}
>
<Link
href={{
pathname: getCategoryPath(cat.path, brand),
query,
}}
>
<a
onClick={(e) => handleClick(e, 'categories')}
className={
'block lg:inline-block px-4 py-2 lg:p-0 lg:my-2 lg:mx-4'
}
>
{cat.name}
</a>
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
{/* Designs */}
<div className="relative inline-block w-full">
<div className="lg:hidden mt-3">
<span className="rounded-md shadow-sm">
<button
type="button"
onClick={(e) => handleClick(e, 'brands')}
className="flex justify-between w-full rounded-sm border border-accent-3 px-4 py-3 bg-accent-0 text-sm leading-5 font-medium text-accent-8 hover:text-accent-5 focus:outline-none focus:border-blue-300 focus:shadow-outline-normal active:bg-accent-1 active:text-accent-8 transition ease-in-out duration-150"
id="options-menu"
aria-haspopup="true"
aria-expanded="true"
>
{activeBrand?.name
? `Design: ${activeBrand?.name}`
: 'All Designs'}
<svg
className="-mr-1 ml-2 h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</span>
</div>
<div
className={`origin-top-left absolute lg:relative left-0 mt-2 w-full rounded-md shadow-lg lg:shadow-none z-10 mb-10 lg:block ${
activeFilter !== 'brands' || toggleFilter !== true
? 'hidden'
: ''
}`}
>
<div className="rounded-sm bg-accent-0 shadow-xs lg:bg-none lg:shadow-none">
<div
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
<ul>
<li
className={cn(
'block text-sm leading-5 text-accent-4 lg:text-base lg:no-underline lg:font-bold lg:tracking-wide hover:bg-accent-1 lg:hover:bg-transparent hover:text-accent-8 focus:outline-none focus:bg-accent-1 focus:text-accent-8',
{
underline: !activeBrand?.name,
}
)}
>
<Link
href={{
pathname: getDesignerPath('', category),
query,
}}
>
<a
onClick={(e) => handleClick(e, 'brands')}
className={
'block lg:inline-block px-4 py-2 lg:p-0 lg:my-2 lg:mx-4'
}
>
All Designers
</a>
</Link>
</li>
{brands.flatMap(({ node }: { node: any }) => (
<li
key={node.path}
className={cn(
'block text-sm leading-5 text-accent-4 hover:bg-accent-1 lg:hover:bg-transparent hover:text-accent-8 focus:outline-none focus:bg-accent-1 focus:text-accent-8',
{
// @ts-ignore Shopify - Fix this types
underline: activeBrand?.entityId === node.entityId,
}
)}
>
<Link
href={{
pathname: getDesignerPath(node.path, category),
query,
}}
>
<a
onClick={(e) => handleClick(e, 'brands')}
className={
'block lg:inline-block px-4 py-2 lg:p-0 lg:my-2 lg:mx-4'
}
>
{node.name}
</a>
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
{/* Products */}
<div className="col-span-8 order-3 lg:order-none">
{(q || activeCategory || activeBrand) && (
<div className="mb-12 transition ease-in duration-75">
{data ? (
<>
<span
className={cn('animated', {
fadeIn: data.found,
hidden: !data.found,
})}
>
Showing {data.products.length} results{' '}
{q && (
<>
for "<strong>{q}</strong>"
</>
)}
</span>
<span
className={cn('animated', {
fadeIn: !data.found,
hidden: data.found,
})}
>
{q ? (
<>
There are no products that match "<strong>{q}</strong>"
</>
) : (
<>
There are no products that match the selected category.
</>
)}
</span>
</>
) : q ? (
<>
Searching for: "<strong>{q}</strong>"
</>
) : (
<>Searching...</>
)}
</div>
)}
{data ? (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data.products.map((product: Product) => (
<ProductCard
variant="simple"
key={product.path}
className="animated fadeIn"
product={product}
imgProps={{
width: 480,
height: 480,
}}
/>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{rangeMap(12, (i) => (
<Skeleton key={i}>
<div className="w-60 h-60" />
</Skeleton>
))}
</div>
)}{' '}
</div>
{/* Sort */}
<div className="col-span-8 lg:col-span-2 order-2 lg:order-none">
<div className="relative inline-block w-full">
<div className="lg:hidden">
<span className="rounded-md shadow-sm">
<button
type="button"
onClick={(e) => handleClick(e, 'sort')}
className="flex justify-between w-full rounded-sm border border-accent-3 px-4 py-3 bg-accent-0 text-sm leading-5 font-medium text-accent-4 hover:text-accent-5 focus:outline-none focus:border-blue-300 focus:shadow-outline-normal active:bg-accent-1 active:text-accent-8 transition ease-in-out duration-150"
id="options-menu"
aria-haspopup="true"
aria-expanded="true"
>
{sort ? `Sort: ${sort}` : 'Relevance'}
<svg
className="-mr-1 ml-2 h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</span>
</div>
<div
className={`origin-top-left absolute lg:relative left-0 mt-2 w-full rounded-md shadow-lg lg:shadow-none z-10 mb-10 lg:block ${
activeFilter !== 'sort' || toggleFilter !== true ? 'hidden' : ''
}`}
>
<div className="rounded-sm bg-accent-0 shadow-xs lg:bg-none lg:shadow-none">
<div
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
<ul>
<li
className={cn(
'block text-sm leading-5 text-accent-4 lg:text-base lg:no-underline lg:font-bold lg:tracking-wide hover:bg-accent-1 lg:hover:bg-transparent hover:text-accent-8 focus:outline-none focus:bg-accent-1 focus:text-accent-8',
{
underline: !sort,
}
)}
>
<Link href={{ pathname, query: filterQuery({ q }) }}>
<a
onClick={(e) => handleClick(e, 'sort')}
className={
'block lg:inline-block px-4 py-2 lg:p-0 lg:my-2 lg:mx-4'
}
>
Relevance
</a>
</Link>
</li>
{SORT.map(([key, text]) => (
<li
key={key}
className={cn(
'block text-sm leading-5 text-accent-4 hover:bg-accent-1 lg:hover:bg-transparent hover:text-accent-8 focus:outline-none focus:bg-accent-1 focus:text-accent-8',
{
underline: sort === key,
}
)}
>
<Link
href={{
pathname,
query: filterQuery({ q, sort: key }),
}}
>
<a
onClick={(e) => handleClick(e, 'sort')}
className={
'block lg:inline-block px-4 py-2 lg:p-0 lg:my-2 lg:mx-4'
}
>
{text}
</a>
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</Container>
)
}
Search.Layout = Layout

View File

@ -1,9 +1,10 @@
.root {
@apply bg-accent-9 text-accent-0 cursor-pointer inline-flex
px-10 py-4 rounded-sm leading-6 transition ease-in-out duration-150
px-10 py-5 leading-6 transition ease-in-out duration-150
shadow-sm text-center justify-center uppercase
border border-transparent items-center text-sm font-semibold
tracking-wide;
max-height: 64px;
}
.root:hover {
@ -15,7 +16,7 @@
}
.root[data-active] {
@apply bg-gray-600;
@apply bg-accent-6;
}
.loading {
@ -42,3 +43,6 @@
-webkit-perspective: 1000;
-webkit-backface-visibility: hidden;
}
.progress {
}

View File

@ -7,7 +7,7 @@
}
.header .label {
@apply text-sm font-medium;
@apply text-base font-medium;
}
.content {

View File

@ -10,7 +10,7 @@ export interface CollapseProps {
children: ReactNode
}
const Collapse: FC<CollapseProps> = ({ title, children }) => {
const Collapse: FC<CollapseProps> = React.memo(({ title, children }) => {
const [isActive, setActive] = useState(false)
const [ref, { height: viewHeight }] = useMeasure()
@ -41,6 +41,6 @@ const Collapse: FC<CollapseProps> = ({ title, children }) => {
</a.div>
</div>
)
}
})
export default Collapse

View File

@ -18,9 +18,8 @@ const Container: FC<ContainerProps> = ({
'mx-auto max-w-8xl px-6': !clean,
})
let Component: React.ComponentType<
React.HTMLAttributes<HTMLDivElement>
> = el as any
let Component: React.ComponentType<React.HTMLAttributes<HTMLDivElement>> =
el as any
return <Component className={rootClassName}>{children}</Component>
}

View File

@ -1,7 +1,5 @@
.root {
@apply grid grid-cols-1 gap-0;
--row-height: calc(100vh - 88px);
min-height: var(--row-height);
@screen lg {
@apply grid-cols-3 grid-rows-2;

View File

@ -1,18 +1,30 @@
.root {
@apply flex flex-col py-32 mx-auto;
@apply flex flex-col py-16 mx-auto;
}
.headline {
@apply text-accent-0 font-extrabold text-5xl leading-none tracking-tight;
.title {
@apply text-accent-0 font-extrabold text-4xl leading-none tracking-tight;
}
@screen md {
.description {
@apply mt-4 text-xl leading-8 text-accent-2 mb-1 lg:max-w-4xl;
}
@screen lg {
.root {
@apply flex-row items-center justify-center;
@apply flex-row items-start justify-center py-32;
}
.title {
@apply text-5xl max-w-xl text-right leading-10 -mt-3;
line-height: 3.5rem;
}
.description {
@apply mt-0 ml-6;
}
}
.headline {
@apply text-6xl max-w-xl text-right leading-10 -mt-3;
line-height: 4rem;
@screen xl {
.title {
@apply text-6xl;
}
}

View File

@ -1,6 +1,6 @@
import React, { FC } from 'react'
import { Container } from '@components/ui'
import { RightArrow } from '@components/icons'
import { ArrowRight } from '@components/icons'
import s from './Hero.module.css'
import Link from 'next/link'
interface HeroProps {
@ -14,15 +14,13 @@ const Hero: FC<HeroProps> = ({ headline, description }) => {
<div className="bg-accent-9 border-b border-t border-accent-2">
<Container>
<div className={s.root}>
<h2 className={s.headline}>{headline}</h2>
<div className="md:ml-6">
<p className="mt-4 text-xl leading-8 text-accent-2 mb-1 lg:max-w-4xl">
{description}
</p>
<h2 className={s.title}>{headline}</h2>
<div className={s.description}>
<p>{description}</p>
<Link href="/">
<a className="text-accent-0 pt-3 font-bold hover:underline flex flex-row cursor-pointer w-max-content">
<a className="flex items-center text-accent-0 pt-3 font-bold hover:underline cursor-pointer w-max-content">
Read it here
<RightArrow width="20" heigh="20" className="ml-1" />
<ArrowRight width="20" heigh="20" className="ml-1" />
</a>
</Link>
</div>

View File

@ -1,23 +1,24 @@
.root {
@apply inline-flex text-center items-center leading-7;
}
& span {
@apply bg-accent-6 rounded-full h-2 w-2;
.root .dot {
@apply rounded-full h-2 w-2;
background-color: currentColor;
animation-name: blink;
animation-duration: 1.4s;
animation-iteration-count: infinite;
animation-fill-mode: both;
margin: 0 2px;
}
&:nth-of-type(2) {
.root .dot:nth-of-type(2) {
animation-delay: 0.2s;
}
&:nth-of-type(3) {
.root .dot::nth-of-type(3) {
animation-delay: 0.4s;
}
}
}
@keyframes blink {
0% {

View File

@ -3,9 +3,9 @@ import s from './LoadingDots.module.css'
const LoadingDots: React.FC = () => {
return (
<span className={s.root}>
<span />
<span />
<span />
<span className={s.dot} key={`dot_1`} />
<span className={s.dot} key={`dot_2`} />
<span className={s.dot} key={`dot_3`} />
</span>
)
}

View File

@ -1,16 +1,16 @@
.root {
@apply w-full relative;
height: 360px;
min-width: 100%;
@apply w-full min-w-full relative flex flex-row items-center overflow-hidden py-0;
max-height: 320px;
}
.container {
@apply flex flex-row items-center;
.root > div {
max-height: 320px;
padding: 0;
margin: 0;
}
.container > * {
@apply relative flex-1 px-16 py-4 h-full;
min-height: 360px;
.root > div > * > *:nth-child(2) * {
max-height: 100%;
}
.primary {

View File

@ -1,7 +1,7 @@
import cn from 'classnames'
import s from './Marquee.module.css'
import { FC, ReactNode, Component, Children, isValidElement } from 'react'
import Ticker from 'react-ticker'
import { FC, ReactNode, Component, Children } from 'react'
import { default as FastMarquee } from 'react-fast-marquee'
interface MarqueeProps {
className?: string
@ -24,26 +24,15 @@ const Marquee: FC<MarqueeProps> = ({
)
return (
<div className={rootClassName}>
<Ticker offset={80}>
{() => (
<div className={s.container}>
{Children.map(children, (child) => {
if (isValidElement(child)) {
return {
<FastMarquee gradient={false} className={rootClassName}>
{Children.map(children, (child) => ({
...child,
props: {
...child.props,
className: cn(child.props.className, `${variant}`),
},
}
}
return child
})}
</div>
)}
</Ticker>
</div>
}))}
</FastMarquee>
)
}

View File

@ -1,6 +1,7 @@
.root {
@apply fixed bg-primary text-primary flex items-center inset-0 z-50 justify-center;
background-color: rgba(0, 0, 0, 0.35);
@apply fixed bg-black bg-opacity-40 flex items-center inset-0 z-50 justify-center;
backdrop-filter: blur(0.8px);
-webkit-backdrop-filter: blur(0.8px);
}
.modal {
@ -10,3 +11,7 @@
.modal:focus {
@apply outline-none;
}
.close {
@apply hover:text-accent-5 transition ease-in-out duration-150 focus:outline-none absolute right-0 top-0 m-6;
}

View File

@ -1,22 +1,20 @@
import { FC, useRef, useEffect, useCallback } from 'react'
import Portal from '@reach/portal'
import s from './Modal.module.css'
import FocusTrap from '@lib/focus-trap'
import { Cross } from '@components/icons'
import {
disableBodyScroll,
enableBodyScroll,
clearAllBodyScrollLocks,
enableBodyScroll,
} from 'body-scroll-lock'
import FocusTrap from '@lib/focus-trap'
interface ModalProps {
className?: string
children?: any
open?: boolean
onClose: () => void
onEnter?: () => void | null
}
const Modal: FC<ModalProps> = ({ children, open, onClose, onEnter = null }) => {
const Modal: FC<ModalProps> = ({ children, onClose }) => {
const ref = useRef() as React.MutableRefObject<HTMLDivElement>
const handleKey = useCallback(
@ -30,36 +28,31 @@ const Modal: FC<ModalProps> = ({ children, open, onClose, onEnter = null }) => {
useEffect(() => {
if (ref.current) {
if (open) {
disableBodyScroll(ref.current)
disableBodyScroll(ref.current, { reserveScrollBarGap: true })
window.addEventListener('keydown', handleKey)
} else {
enableBodyScroll(ref.current)
}
}
return () => {
window.removeEventListener('keydown', handleKey)
clearAllBodyScrollLocks()
if (ref && ref.current) {
enableBodyScroll(ref.current)
}
}, [open, handleKey])
clearAllBodyScrollLocks()
window.removeEventListener('keydown', handleKey)
}
}, [handleKey])
return (
<Portal>
{open ? (
<div className={s.root}>
<div className={s.modal} role="dialog" ref={ref}>
<button
onClick={() => onClose()}
aria-label="Close panel"
className="hover:text-gray-500 transition ease-in-out duration-150 focus:outline-none absolute right-0 top-0 m-6"
className={s.close}
>
<Cross className="h-6 w-6" />
</button>
<FocusTrap focusFirst>{children}</FocusTrap>
</div>
</div>
) : null}
</Portal>
)
}

View File

@ -1,6 +1,8 @@
.actions {
@apply flex p-1 border-accent-2 border items-center justify-center w-12 text-accent-7;
@apply flex p-1 border-accent-2 border items-center justify-center
w-12 text-accent-7;
transition-property: border-color, background, color, transform, box-shadow;
transition-duration: 0.15s;
transition-timing-function: ease;
user-select: none;
@ -21,6 +23,5 @@
}
.input {
@apply bg-transparent px-4 w-full h-full focus:outline-none;
user-select: none;
@apply bg-transparent px-4 w-full h-full focus:outline-none select-none pointer-events-auto;
}

View File

@ -7,7 +7,7 @@ export interface RatingProps {
value: number
}
const Quantity: FC<RatingProps> = ({ value = 5 }) => {
const Quantity: React.FC<RatingProps> = React.memo(({ value = 5 }) => {
return (
<div className="flex flex-row py-6 text-accent-9">
{rangeMap(5, (i) => (
@ -22,6 +22,6 @@ const Quantity: FC<RatingProps> = ({ value = 5 }) => {
))}
</div>
)
}
})
export default Quantity

View File

@ -1,8 +1,14 @@
.root {
@apply fixed inset-0 overflow-hidden h-full z-50;
@apply fixed inset-0 h-full z-50 box-border;
}
.sidebar {
@apply h-full flex flex-col text-base bg-accent-0 shadow-xl overflow-y-auto;
min-width: 335px;
@apply h-full flex flex-col text-base bg-accent-0 shadow-xl overflow-y-auto overflow-x-hidden;
-webkit-overflow-scrolling: touch !important;
}
.backdrop {
@apply absolute inset-0 bg-black bg-opacity-40 duration-100 ease-linear;
backdrop-filter: blur(0.8px);
-webkit-backdrop-filter: blur(0.8px);
}

View File

@ -1,6 +1,6 @@
import s from './Sidebar.module.css'
import Portal from '@reach/portal'
import { FC, useEffect, useRef } from 'react'
import s from './Sidebar.module.css'
import cn from 'classnames'
import {
disableBodyScroll,
enableBodyScroll,
@ -9,47 +9,37 @@ import {
interface SidebarProps {
children: any
open: boolean
onClose: () => void
}
const Sidebar: FC<SidebarProps> = ({ children, open = false, onClose }) => {
const Sidebar: FC<SidebarProps> = ({ children, onClose }) => {
const ref = useRef() as React.MutableRefObject<HTMLDivElement>
useEffect(() => {
setTimeout(() => {
if (ref.current && open) {
window.document.body.style.overflow = 'hidden'
disableBodyScroll(ref.current)
} else {
window.document.body.style.overflow &&
setTimeout(() => (window.document.body.style.overflow = 'unset'), 30)
!!ref.current && enableBodyScroll(ref.current)
if (ref.current) {
disableBodyScroll(ref.current, { reserveScrollBarGap: true })
}
}, 30)
return () => {
if (ref && ref.current) {
enableBodyScroll(ref.current)
}
clearAllBodyScrollLocks()
}
}, [open])
}, [])
return (
<Portal>
{open && (
<div className={s.root} ref={ref}>
<div className={cn(s.root)}>
<div className="absolute inset-0 overflow-hidden">
<div
className="absolute inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={onClose}
/>
<section className="absolute inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16 outline-none">
<div className="h-full md:w-screen md:max-w-md">
<div className={s.sidebar}>{children}</div>
<div className={s.backdrop} onClick={onClose} />
<section className="absolute inset-y-0 right-0 max-w-full flex outline-none pl-10">
<div className="h-full w-full md:w-screen md:max-w-md">
<div className={s.sidebar} ref={ref}>
{children}
</div>
</div>
</section>
</div>
</div>
)}
</Portal>
)
}

View File

@ -3,7 +3,7 @@
}
.heading {
@apply text-5xl mb-12;
@apply text-5xl pt-1 pb-2 font-semibold tracking-wide cursor-pointer mb-2;
}
.pageHeading {
@ -11,5 +11,5 @@
}
.sectionHeading {
@apply pt-1 pb-2 text-2xl font-semibold tracking-wide cursor-pointer mb-2;
@apply pt-1 pb-2 text-2xl font-bold tracking-wide cursor-pointer mb-2;
}

View File

@ -1,14 +1,12 @@
import React, { FC, useMemo } from 'react'
import React, { FC, useCallback, useMemo } from 'react'
import { ThemeProvider } from 'next-themes'
export interface State {
displaySidebar: boolean
displayDropdown: boolean
displayModal: boolean
displayToast: boolean
sidebarView: string
modalView: string
toastText: string
userAvatar: string
}
@ -18,8 +16,6 @@ const initialState = {
displayModal: false,
modalView: 'LOGIN_VIEW',
sidebarView: 'CART_VIEW',
displayToast: false,
toastText: '',
userAvatar: '',
}
@ -30,16 +26,6 @@ type Action =
| {
type: 'CLOSE_SIDEBAR'
}
| {
type: 'OPEN_TOAST'
}
| {
type: 'CLOSE_TOAST'
}
| {
type: 'SET_TOAST_TEXT'
text: ToastText
}
| {
type: 'OPEN_DROPDOWN'
}
@ -74,8 +60,6 @@ type MODAL_VIEWS =
type SIDEBAR_VIEWS = 'CART_VIEW' | 'CHECKOUT_VIEW' | 'PAYMENT_METHOD_VIEW'
type ToastText = string
export const UIContext = React.createContext<State | any>(initialState)
UIContext.displayName = 'UIContext'
@ -119,18 +103,6 @@ function uiReducer(state: State, action: Action) {
displayModal: false,
}
}
case 'OPEN_TOAST': {
return {
...state,
displayToast: true,
}
}
case 'CLOSE_TOAST': {
return {
...state,
displayToast: false,
}
}
case 'SET_MODAL_VIEW': {
return {
...state,
@ -143,12 +115,6 @@ function uiReducer(state: State, action: Action) {
sidebarView: action.view,
}
}
case 'SET_TOAST_TEXT': {
return {
...state,
toastText: action.text,
}
}
case 'SET_USER_AVATAR': {
return {
...state,
@ -161,32 +127,58 @@ function uiReducer(state: State, action: Action) {
export const UIProvider: FC = (props) => {
const [state, dispatch] = React.useReducer(uiReducer, initialState)
const openSidebar = () => dispatch({ type: 'OPEN_SIDEBAR' })
const closeSidebar = () => dispatch({ type: 'CLOSE_SIDEBAR' })
const toggleSidebar = () =>
const openSidebar = useCallback(
() => dispatch({ type: 'OPEN_SIDEBAR' }),
[dispatch]
)
const closeSidebar = useCallback(
() => dispatch({ type: 'CLOSE_SIDEBAR' }),
[dispatch]
)
const toggleSidebar = useCallback(
() =>
state.displaySidebar
? dispatch({ type: 'CLOSE_SIDEBAR' })
: dispatch({ type: 'OPEN_SIDEBAR' })
const closeSidebarIfPresent = () =>
state.displaySidebar && dispatch({ type: 'CLOSE_SIDEBAR' })
: dispatch({ type: 'OPEN_SIDEBAR' }),
[dispatch, state.displaySidebar]
)
const closeSidebarIfPresent = useCallback(
() => state.displaySidebar && dispatch({ type: 'CLOSE_SIDEBAR' }),
[dispatch, state.displaySidebar]
)
const openDropdown = () => dispatch({ type: 'OPEN_DROPDOWN' })
const closeDropdown = () => dispatch({ type: 'CLOSE_DROPDOWN' })
const openDropdown = useCallback(
() => dispatch({ type: 'OPEN_DROPDOWN' }),
[dispatch]
)
const closeDropdown = useCallback(
() => dispatch({ type: 'CLOSE_DROPDOWN' }),
[dispatch]
)
const openModal = () => dispatch({ type: 'OPEN_MODAL' })
const closeModal = () => dispatch({ type: 'CLOSE_MODAL' })
const openModal = useCallback(
() => dispatch({ type: 'OPEN_MODAL' }),
[dispatch]
)
const closeModal = useCallback(
() => dispatch({ type: 'CLOSE_MODAL' }),
[dispatch]
)
const openToast = () => dispatch({ type: 'OPEN_TOAST' })
const closeToast = () => dispatch({ type: 'CLOSE_TOAST' })
const setUserAvatar = useCallback(
(value: string) => dispatch({ type: 'SET_USER_AVATAR', value }),
[dispatch]
)
const setUserAvatar = (value: string) =>
dispatch({ type: 'SET_USER_AVATAR', value })
const setModalView = useCallback(
(view: MODAL_VIEWS) => dispatch({ type: 'SET_MODAL_VIEW', view }),
[dispatch]
)
const setModalView = (view: MODAL_VIEWS) =>
dispatch({ type: 'SET_MODAL_VIEW', view })
const setSidebarView = (view: SIDEBAR_VIEWS) =>
dispatch({ type: 'SET_SIDEBAR_VIEW', view })
const setSidebarView = useCallback(
(view: SIDEBAR_VIEWS) => dispatch({ type: 'SET_SIDEBAR_VIEW', view }),
[dispatch]
)
const value = useMemo(
() => ({
@ -201,8 +193,6 @@ export const UIProvider: FC = (props) => {
closeModal,
setModalView,
setSidebarView,
openToast,
closeToast,
setUserAvatar,
}),
[state]

View File

@ -4,8 +4,8 @@ import {
CommerceAPIConfig,
getCommerceApi as commerceApi,
} from '@commerce/api'
import fetchGraphqlApi from './utils/fetch-graphql-api'
import fetchStoreApi from './utils/fetch-store-api'
import createFetchGraphqlApi from './utils/fetch-graphql-api'
import createFetchStoreApi from './utils/fetch-store-api'
import type { CartAPI } from './endpoints/cart'
import type { CustomerAPI } from './endpoints/customer'
@ -68,14 +68,14 @@ const config: BigcommerceConfig = {
customerCookie: 'SHOP_TOKEN',
cartCookie: process.env.BIGCOMMERCE_CART_COOKIE ?? 'bc_cartId',
cartCookieMaxAge: ONE_DAY * 30,
fetch: fetchGraphqlApi,
fetch: createFetchGraphqlApi(() => getCommerceApi().getConfig()),
applyLocale: true,
// REST API only
storeApiUrl: STORE_API_URL,
storeApiToken: STORE_API_TOKEN,
storeApiClientId: STORE_API_CLIENT_ID,
storeChannelId: STORE_CHANNEL_ID,
storeApiFetch: fetchStoreApi,
storeApiFetch: createFetchStoreApi(() => getCommerceApi().getConfig()),
}
const operations = {

View File

@ -1,15 +1,13 @@
import { FetcherError } from '@commerce/utils/errors'
import type { GraphQLFetcher } from '@commerce/api'
import { provider } from '..'
import type { BigcommerceConfig } from '../index'
import fetch from './fetch'
const fetchGraphqlApi: GraphQLFetcher = async (
query: string,
{ variables, preview } = {},
fetchOptions
) => {
const fetchGraphqlApi: (getConfig: () => BigcommerceConfig) => GraphQLFetcher =
(getConfig) =>
async (query: string, { variables, preview } = {}, fetchOptions) => {
// log.warn(query)
const { config } = provider
const config = getConfig()
const res = await fetch(config.commerceUrl + (preview ? '/preview' : ''), {
...fetchOptions,
method: 'POST',

View File

@ -1,13 +1,12 @@
import type { RequestInit, Response } from '@vercel/fetch'
import { provider } from '..'
import type { BigcommerceConfig } from '../index'
import { BigcommerceApiError, BigcommerceNetworkError } from './errors'
import fetch from './fetch'
export default async function fetchStoreApi<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const { config } = provider
const fetchStoreApi =
<T>(getConfig: () => BigcommerceConfig) =>
async (endpoint: string, options?: RequestInit): Promise<T> => {
const config = getConfig()
let res: Response
try {
@ -51,6 +50,7 @@ export default async function fetchStoreApi<T>(
// If something was removed, the response will be empty
return res.status === 204 ? null : await res.json()
}
export default fetchStoreApi
function getRawHeaders(res: Response) {
const headers: { [key: string]: string } = {}

View File

@ -4,7 +4,8 @@ import {
CommerceProvider as CoreCommerceProvider,
useCommerce as useCoreCommerce,
} from '@commerce'
import { bigcommerceProvider, BigcommerceProvider } from './provider'
import { bigcommerceProvider } from './provider'
import type { BigcommerceProvider } from './provider'
export { bigcommerceProvider }
export type { BigcommerceProvider }

View File

@ -72,9 +72,8 @@ export type APIProvider = {
operations: APIOperations<any>
}
export type CommerceAPI<
P extends APIProvider = APIProvider
> = CommerceAPICore<P> & AllOperations<P>
export type CommerceAPI<P extends APIProvider = APIProvider> =
CommerceAPICore<P> & AllOperations<P>
export class CommerceAPICore<P extends APIProvider = APIProvider> {
constructor(readonly provider: P) {}
@ -134,9 +133,9 @@ export function getEndpoint<
}
}
export const createEndpoint = <API extends GetAPISchema<any, any>>(
endpoint: API['endpoint']
) => <P extends APIProvider>(
export const createEndpoint =
<API extends GetAPISchema<any, any>>(endpoint: API['endpoint']) =>
<P extends APIProvider>(
commerce: CommerceAPI<P>,
context?: Partial<API['endpoint']> & {
config?: P['config']

View File

@ -7,7 +7,7 @@ const fs = require('fs')
const merge = require('deepmerge')
const prettier = require('prettier')
const PROVIDERS = ['bigcommerce', 'shopify', 'swell', 'vendure']
const PROVIDERS = ['bigcommerce', 'shopify', 'swell', 'vendure', 'local']
function getProviderName() {
return (
@ -18,7 +18,7 @@ function getProviderName() {
? 'shopify'
: process.env.NEXT_PUBLIC_SWELL_STORE_ID
? 'swell'
: null)
: 'local')
)
}
@ -40,7 +40,7 @@ function withCommerceConfig(nextConfig = {}) {
}
const commerceNextConfig = require(path.join('../', name, 'next.config'))
const config = merge(commerceNextConfig, nextConfig)
const config = merge(nextConfig, commerceNextConfig)
config.env = config.env || {}
@ -50,27 +50,11 @@ function withCommerceConfig(nextConfig = {}) {
// Update paths in `tsconfig.json` to point to the selected provider
if (config.commerce.updateTSConfig !== false) {
const tsconfigPath = path.join(process.cwd(), 'tsconfig.json')
const tsconfig = require(tsconfigPath)
tsconfig.compilerOptions.paths['@framework'] = [`framework/${name}`]
tsconfig.compilerOptions.paths['@framework/*'] = [`framework/${name}/*`]
// When running for production it may be useful to exclude the other providers
// from TS checking
if (process.env.VERCEL) {
const exclude = tsconfig.exclude.filter(
(item) => !item.startsWith('framework/')
)
tsconfig.exclude = PROVIDERS.reduce((exclude, current) => {
if (current !== name) exclude.push(`framework/${current}`)
return exclude
}, exclude)
}
const staticTsconfigPath = path.join(process.cwd(), 'tsconfig.json')
const tsconfig = require('../../tsconfig.js')
fs.writeFileSync(
tsconfigPath,
staticTsconfigPath,
prettier.format(JSON.stringify(tsconfig), { parser: 'json' })
)
}

View File

@ -3,6 +3,7 @@
A commerce provider is a headless e-commerce platform that integrates with the [Commerce Framework](./README.md). Right now we have the following providers:
- BigCommerce ([framework/bigcommerce](../bigcommerce))
- Saleor ([framework/saleor](../saleor))
- Shopify ([framework/shopify](../shopify))
Adding a commerce provider means adding a new folder in `framework` with a folder structure like the next one:
@ -57,7 +58,8 @@ import {
CommerceProvider as CoreCommerceProvider,
useCommerce as useCoreCommerce,
} from '@commerce'
import { bigcommerceProvider, BigcommerceProvider } from './provider'
import { bigcommerceProvider } from './provider'
import type { BigcommerceProvider } from './provider'
export { bigcommerceProvider }
export type { BigcommerceProvider }
@ -156,7 +158,9 @@ export const handler: SWRHook<
const data = cartId ? await fetch(options) : null
return data && normalizeCart(data)
},
useHook: ({ useData }) => (input) => {
useHook:
({ useData }) =>
(input) => {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
@ -217,7 +221,9 @@ export const handler: MutationHook<Cart, {}, CartItemBody> = {
return normalizeCart(data)
},
useHook: ({ fetch }) => () => {
useHook:
({ fetch }) =>
() => {
const { mutate } = useCart()
return useCallback(

View File

@ -165,15 +165,13 @@ export type AddItemHandler<T extends CartTypes = CartTypes> = AddItemHook<T> & {
body: { cartId: string }
}
export type UpdateItemHandler<
T extends CartTypes = CartTypes
> = UpdateItemHook<T> & {
export type UpdateItemHandler<T extends CartTypes = CartTypes> =
UpdateItemHook<T> & {
data: T['cart']
body: { cartId: string }
}
export type RemoveItemHandler<
T extends CartTypes = CartTypes
> = RemoveItemHook<T> & {
export type RemoveItemHandler<T extends CartTypes = CartTypes> =
RemoveItemHook<T> & {
body: { cartId: string }
}

View File

@ -11,10 +11,8 @@ type InferValue<Prop extends PropertyKey, Desc> = Desc extends {
? Record<Prop, T>
: never
type DefineProperty<
Prop extends PropertyKey,
Desc extends PropertyDescriptor
> = Desc extends { writable: any; set(val: any): any }
type DefineProperty<Prop extends PropertyKey, Desc extends PropertyDescriptor> =
Desc extends { writable: any; set(val: any): any }
? never
: Desc extends { writable: any; get(): any }
? never

View File

@ -0,0 +1 @@
COMMERCE_PROVIDER=local

View File

@ -0,0 +1 @@
# Next.js Local Provider

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1,42 @@
import type { CommerceAPI, CommerceAPIConfig } from '@commerce/api'
import { getCommerceApi as commerceApi } from '@commerce/api'
import createFetcher from './utils/fetch-local'
import getAllPages from './operations/get-all-pages'
import getPage from './operations/get-page'
import getSiteInfo from './operations/get-site-info'
import getCustomerWishlist from './operations/get-customer-wishlist'
import getAllProductPaths from './operations/get-all-product-paths'
import getAllProducts from './operations/get-all-products'
import getProduct from './operations/get-product'
export interface LocalConfig extends CommerceAPIConfig {}
const config: LocalConfig = {
commerceUrl: '',
apiToken: '',
cartCookie: '',
customerCookie: '',
cartCookieMaxAge: 2592000,
fetch: createFetcher(() => getCommerceApi().getConfig()),
}
const operations = {
getAllPages,
getPage,
getSiteInfo,
getCustomerWishlist,
getAllProductPaths,
getAllProducts,
getProduct,
}
export const provider = { config, operations }
export type Provider = typeof provider
export type LocalAPI<P extends Provider = Provider> = CommerceAPI<P | any>
export function getCommerceApi<P extends Provider>(
customProvider: P = provider as any
): LocalAPI<P> {
return commerceApi(customProvider as any)
}

View File

@ -0,0 +1,19 @@
export type Page = { url: string }
export type GetAllPagesResult = { pages: Page[] }
import type { LocalConfig } from '../index'
export default function getAllPagesOperation() {
function getAllPages({
config,
preview,
}: {
url?: string
config?: Partial<LocalConfig>
preview?: boolean
}): Promise<GetAllPagesResult> {
return Promise.resolve({
pages: [],
})
}
return getAllPages
}

View File

@ -0,0 +1,15 @@
import data from '../../data.json'
export type GetAllProductPathsResult = {
products: Array<{ path: string }>
}
export default function getAllProductPathsOperation() {
function getAllProductPaths(): Promise<GetAllProductPathsResult> {
return Promise.resolve({
products: data.products.map(({ path }) => ({ path })),
})
}
return getAllProductPaths
}

View File

@ -0,0 +1,25 @@
import { Product } from '@commerce/types/product'
import { GetAllProductsOperation } from '@commerce/types/product'
import type { OperationContext } from '@commerce/api/operations'
import type { LocalConfig, Provider } from '../index'
import data from '../../data.json'
export default function getAllProductsOperation({
commerce,
}: OperationContext<any>) {
async function getAllProducts<T extends GetAllProductsOperation>({
query = '',
variables,
config,
}: {
query?: string
variables?: T['variables']
config?: Partial<LocalConfig>
preview?: boolean
} = {}): Promise<{ products: Product[] | any[] }> {
return Promise.resolve({
products: data.products,
})
}
return getAllProducts
}

View File

@ -0,0 +1,6 @@
export default function getCustomerWishlistOperation() {
function getCustomerWishlist(): any {
return { wishlist: {} }
}
return getCustomerWishlist
}

View File

@ -0,0 +1,12 @@
export type Page = any
export type GetPageResult = { page?: Page }
export type PageVariables = {
id: number
}
export default function getPageOperation() {
function getPage(): Promise<GetPageResult> {
return Promise.resolve({})
}
return getPage
}

View File

@ -0,0 +1,26 @@
import type { LocalConfig } from '../index'
import { Product } from '@commerce/types/product'
import { GetProductOperation } from '@commerce/types/product'
import data from '../../data.json'
import type { OperationContext } from '@commerce/api/operations'
export default function getProductOperation({
commerce,
}: OperationContext<any>) {
async function getProduct<T extends GetProductOperation>({
query = '',
variables,
config,
}: {
query?: string
variables?: T['variables']
config?: Partial<LocalConfig>
preview?: boolean
} = {}): Promise<Product | {} | any> {
return Promise.resolve({
product: data.products[0],
})
}
return getProduct
}

View File

@ -0,0 +1,30 @@
import { OperationContext } from '@commerce/api/operations'
import { Category } from '@commerce/types/site'
import { LocalConfig } from '../index'
export type GetSiteInfoResult<
T extends { categories: any[]; brands: any[] } = {
categories: Category[]
brands: any[]
}
> = T
export default function getSiteInfoOperation({}: OperationContext<any>) {
function getSiteInfo({
query,
variables,
config: cfg,
}: {
query?: string
variables?: any
config?: Partial<LocalConfig>
preview?: boolean
} = {}): Promise<GetSiteInfoResult> {
return Promise.resolve({
categories: [],
brands: [],
})
}
return getSiteInfo
}

View File

@ -0,0 +1,6 @@
export { default as getPage } from './get-page'
export { default as getSiteInfo } from './get-site-info'
export { default as getAllPages } from './get-all-pages'
export { default as getProduct } from './get-product'
export { default as getAllProducts } from './get-all-products'
export { default as getAllProductPaths } from './get-all-product-paths'

View File

@ -0,0 +1,34 @@
import { FetcherError } from '@commerce/utils/errors'
import type { GraphQLFetcher } from '@commerce/api'
import type { LocalConfig } from '../index'
import fetch from './fetch'
const fetchGraphqlApi: (getConfig: () => LocalConfig) => GraphQLFetcher =
(getConfig) =>
async (query: string, { variables, preview } = {}, fetchOptions) => {
const config = getConfig()
const res = await fetch(config.commerceUrl, {
...fetchOptions,
method: 'POST',
headers: {
...fetchOptions?.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
})
const json = await res.json()
if (json.errors) {
throw new FetcherError({
errors: json.errors ?? [{ message: 'Failed to fetch for API' }],
status: res.status,
})
}
return { data: json.data, res }
}
export default fetchGraphqlApi

View File

@ -0,0 +1,3 @@
import zeitFetch from '@vercel/fetch'
export default zeitFetch()

View File

@ -0,0 +1,3 @@
export { default as useLogin } from './use-login'
export { default as useLogout } from './use-logout'
export { default as useSignup } from './use-signup'

View File

@ -0,0 +1,16 @@
import { MutationHook } from '@commerce/utils/types'
import useLogin, { UseLogin } from '@commerce/auth/use-login'
export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: '',
},
async fetcher() {
return null
},
useHook: () => () => {
return async function () {}
},
}

View File

@ -0,0 +1,17 @@
import { MutationHook } from '@commerce/utils/types'
import useLogout, { UseLogout } from '@commerce/auth/use-logout'
export default useLogout as UseLogout<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: '',
},
async fetcher() {
return null
},
useHook:
({ fetch }) =>
() =>
async () => {},
}

View File

@ -0,0 +1,19 @@
import { useCallback } from 'react'
import useCustomer from '../customer/use-customer'
import { MutationHook } from '@commerce/utils/types'
import useSignup, { UseSignup } from '@commerce/auth/use-signup'
export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: '',
},
async fetcher() {
return null
},
useHook:
({ fetch }) =>
() =>
() => {},
}

View File

@ -0,0 +1,4 @@
export { default as useCart } from './use-cart'
export { default as useAddItem } from './use-add-item'
export { default as useRemoveItem } from './use-remove-item'
export { default as useUpdateItem } from './use-update-item'

View File

@ -0,0 +1,17 @@
import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item'
import { MutationHook } from '@commerce/utils/types'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: '',
},
async fetcher({ input, options, fetch }) {},
useHook:
({ fetch }) =>
() => {
return async function addItem() {
return {}
}
},
}

View File

@ -0,0 +1,42 @@
import { useMemo } from 'react'
import { SWRHook } from '@commerce/utils/types'
import useCart, { UseCart } from '@commerce/cart/use-cart'
export default useCart as UseCart<typeof handler>
export const handler: SWRHook<any> = {
fetchOptions: {
query: '',
},
async fetcher() {
return {
id: '',
createdAt: '',
currency: { code: '' },
taxesIncluded: '',
lineItems: [],
lineItemsSubtotalPrice: '',
subtotalPrice: 0,
totalPrice: 0,
}
},
useHook:
({ useData }) =>
(input) => {
return useMemo(
() =>
Object.create(
{},
{
isEmpty: {
get() {
return true
},
enumerable: true,
},
}
),
[]
)
},
}

View File

@ -0,0 +1,18 @@
import { MutationHook } from '@commerce/utils/types'
import useRemoveItem, { UseRemoveItem } from '@commerce/cart/use-remove-item'
export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: '',
},
async fetcher({ input, options, fetch }) {},
useHook:
({ fetch }) =>
() => {
return async function removeItem(input) {
return {}
}
},
}

View File

@ -0,0 +1,18 @@
import { MutationHook } from '@commerce/utils/types'
import useUpdateItem, { UseUpdateItem } from '@commerce/cart/use-update-item'
export default useUpdateItem as UseUpdateItem<any>
export const handler: MutationHook<any> = {
fetchOptions: {
query: '',
},
async fetcher({ input, options, fetch }) {},
useHook:
({ fetch }) =>
() => {
return async function addItem() {
return {}
}
},
}

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