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_STORE_ID=
NEXT_PUBLIC_SWELL_PUBLIC_KEY= NEXT_PUBLIC_SWELL_PUBLIC_KEY=
NEXT_PUBLIC_SALEOR_API_URL=
NEXT_PUBLIC_SALEOR_CHANNEL=

View File

@ -2,5 +2,13 @@
"semi": false, "semi": false,
"singleQuote": true, "singleQuote": true,
"tabWidth": 2, "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/ - Swell Demo: https://swell.vercel.store/
- BigCommerce Demo: https://bigcommerce.vercel.store/ - BigCommerce Demo: https://bigcommerce.vercel.store/
- Vendure Demo: https://vendure.vercel.store - Vendure Demo: https://vendure.vercel.store
- Saleor Demo: https://saleor.vercel.store/
## Features ## Features
@ -26,7 +27,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/)
## Integrations ## 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 ## 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": { "schema": {
"https://buybutton.store/graphql": { "https://master.staging.saleor.cloud/graphql/": {}
"headers": {
"Authorization": "Bearer xzy"
}
}
}, },
"documents": [ "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 "noRequire": true
} }
} }
], ],
"generates": { "generates": {
"./framework/bigcommerce/schema.d.ts": { "./framework/saleor/schema.d.ts": {
"plugins": ["typescript", "typescript-operations"] "plugins": ["typescript", "typescript-operations"]
}, },
"./framework/bigcommerce/schema.graphql": { "./framework/saleor/schema.graphql": {
"plugins": ["schema-ast"] "plugins": ["schema-ast"]
} }
}, },

View File

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

View File

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

View File

@ -80,7 +80,7 @@ const CartItem = ({
{...rest} {...rest}
> >
<div className="flex flex-row space-x-4 py-4"> <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}`}> <Link href={`/product/${item.path}`}>
<Image <Image
onClick={() => closeSidebarIfPresent()} onClick={() => closeSidebarIfPresent()}

View File

@ -1,15 +1,11 @@
.root {
min-height: 100vh;
}
.root.empty { .root.empty {
@apply bg-secondary text-secondary; @apply bg-secondary text-secondary;
} }
.root.success {
@apply bg-green text-white;
}
.root.error {
@apply bg-red text-white;
}
.lineItemsList { .lineItemsList {
@apply py-4 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accent-2 border-accent-2; @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 { .lineItemsList {
@apply py-4 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accent-2 border-accent-2; @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 ( return (
<SidebarLayout handleBack={() => setSidebarView('CART_VIEW')}> <SidebarLayout
className={s.root}
handleBack={() => setSidebarView('CART_VIEW')}
>
<div className="px-4 sm:px-6 flex-1"> <div className="px-4 sm:px-6 flex-1">
<Link href="/cart"> <Link href="/cart">
<Text variant="sectionHeading">Checkout</Text> <Text variant="sectionHeading">Checkout</Text>

View File

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

View File

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

View File

@ -14,7 +14,7 @@ const Avatar: FC<Props> = ({}) => {
<div <div
ref={ref} ref={ref}
style={{ backgroundImage: userAvatar }} 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> */} {/* Add an image - We're generating a gradient as placeholder <img></img> */}
</div> </div>

View File

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

View File

@ -15,73 +15,50 @@ interface Props {
pages?: Page[] pages?: Page[]
} }
const LEGAL_PAGES = ['terms-of-use', 'shipping-returns', 'privacy-policy']
const links = [ const links = [
{ {
name: 'Home', name: 'Home',
href: '/', url: '/',
}, },
] ]
const Footer: FC<Props> = ({ className, pages }) => { const Footer: FC<Props> = ({ className, pages }) => {
const { sitePages, legalPages } = usePages(pages) const { sitePages } = usePages(pages)
const rootClassName = cn(className) const rootClassName = cn(s.root, className)
return ( return (
<footer className={rootClassName}> <footer className={rootClassName}>
<Container> <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"> <div className="col-span-1 lg:col-span-2">
<Link href="/"> <Link href="/">
<a className="flex flex-initial items-center font-bold md:mr-24"> <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 /> <Logo />
</span> </span>
<span>ACME</span> <span>ACME</span>
</a> </a>
</Link> </Link>
</div> </div>
<div className="col-span-1 lg:col-span-2"> <div className="col-span-1 lg:col-span-8">
<ul className="flex flex-initial flex-col md:flex-1"> <div className="grid md:grid-rows-4 md:grid-cols-3 md:grid-flow-col">
{links.map(({ href, name }) => ( {[...links, ...sitePages].map((page) => (
<li className="py-3 md:py-0 md:pb-4" key={href}> <span key={page.url} className="py-3 md:py-0 md:pb-4">
<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">
<Link href={page.url!}> <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} {page.name}
</a> </a>
</Link> </Link>
</li> </span>
))} ))}
</ul>
</div> </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>
<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"> <div className="flex space-x-6 items-center h-10">
<a <a
className={s.link}
aria-label="Github Repository" aria-label="Github Repository"
href="https://github.com/vercel/commerce" href="https://github.com/vercel/commerce"
className={s.link}
> >
<Github /> <Github />
</a> </a>
@ -117,34 +94,21 @@ const Footer: FC<Props> = ({ className, pages }) => {
function usePages(pages?: Page[]) { function usePages(pages?: Page[]) {
const { locale } = useRouter() const { locale } = useRouter()
const sitePages: Page[] = [] const sitePages: Page[] = []
const legalPages: Page[] = []
if (pages) { if (pages) {
pages.forEach((page) => { pages.forEach((page) => {
const slug = page.url && getSlug(page.url) const slug = page.url && getSlug(page.url)
if (!slug) return if (!slug) return
if (locale && !slug.startsWith(`${locale}/`)) return if (locale && !slug.startsWith(`${locale}/`)) return
if (isLegalPage(slug, locale)) {
legalPages.push(page)
} else {
sitePages.push(page) sitePages.push(page)
}
}) })
} }
return { return {
sitePages: sitePages.sort(bySortOrder), 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 // Sort pages by the sort order assigned in the BC dashboard
function bySortOrder(a: Page, b: Page) { function bySortOrder(a: Page, b: Page) {
return (a.sort_order ?? 0) - (b.sort_order ?? 0) 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> = ({ const Layout: FC<Props> = ({
children, children,
pageProps: { categories = [], ...pageProps }, pageProps: { categories = [], ...pageProps },
}) => { }) => {
const {
displaySidebar,
displayModal,
closeSidebar,
closeModal,
modalView,
sidebarView,
} = useUI()
const { acceptedCookies, onAcceptCookies } = useAcceptCookies() const { acceptedCookies, onAcceptCookies } = useAcceptCookies()
const { locale = 'en-US' } = useRouter() const { locale = 'en-US' } = useRouter()
const navBarlinks = categories.slice(0, 2).map((c) => ({ const navBarlinks = categories.slice(0, 2).map((c) => ({
label: c.name, label: c.name,
href: `/search/${c.slug}`, href: `/search/${c.slug}`,
@ -75,20 +107,8 @@ const Layout: FC<Props> = ({
<Navbar links={navBarlinks} /> <Navbar links={navBarlinks} />
<main className="fit">{children}</main> <main className="fit">{children}</main>
<Footer pages={pageProps.pages} /> <Footer pages={pageProps.pages} />
<ModalUI />
<Modal open={displayModal} onClose={closeModal}> <SidebarUI />
{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>
<FeatureBar <FeatureBar
title="This site uses cookies to improve your experience. By clicking, you agree to our Privacy Policy." title="This site uses cookies to improve your experience. By clicking, you agree to our Privacy Policy."
hide={acceptedCookies} hide={acceptedCookies}

View File

@ -1,8 +1,20 @@
.root { .root {
@apply relative h-full flex flex-col w-full; @apply relative h-full flex flex-col;
} }
.header { .header {
@apply pl-4 pr-6 pt-4 pb-4 lg:pt-5 flex items-center justify-between space-x-3; @apply sticky top-0 pl-4 py-4 pr-6
margin-top: 1px; 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 <button
onClick={handleClose} onClick={handleClose}
aria-label="Close" 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" /> <Cross className="h-6 w-6 hover:text-accent-3" />
<span className="ml-2 text-accent-7 text-sm hover:text-gray-500"> <span className="ml-2 text-accent-7 text-sm ">Close</span>
Close
</span>
</button> </button>
)} )}
{handleBack && ( {handleBack && (
<button <button
onClick={handleBack} onClick={handleBack}
aria-label="Go back" 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" /> <ChevronLeft className="h-6 w-6 hover:text-accent-3" />
<span className="ml-2 text-accent-7 text-xs hover:text-gray-500"> <span className="ml-2 text-accent-7 text-xs">Back</span>
Back
</span>
</button> </button>
)} )}
<span className={s.nav}>
<UserNav /> <UserNav />
</span>
</header> </header>
{children} <div className={s.container}>{children}</div>
</div> </div>
) )
} }

View File

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

View File

@ -1,23 +1,22 @@
const RightArrow = ({ ...props }) => { const ArrowRight = ({ ...props }) => {
return ( return (
<svg <svg
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
{...props} {...props}
> >
<path <path
d="M5 12H19" d="M5 12H19"
stroke="white"
strokeWidth="1.5" strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
<path <path
d="M12 5L19 12L12 19" d="M12 5L19 12L12 19"
stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="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 Heart } from './Heart'
export { default as Trash } from './Trash' export { default as Trash } from './Trash'
export { default as Cross } from './Cross' export { default as Cross } from './Cross'
export { default as ArrowLeft } from './ArrowLeft'
export { default as Plus } from './Plus' export { default as Plus } from './Plus'
export { default as Minus } from './Minus' export { default as Minus } from './Minus'
export { default as Check } from './Check' export { default as Check } from './Check'
export { default as Sun } from './Sun' export { default as Sun } from './Sun'
export { default as Moon } from './Moon' export { default as Moon } from './Moon'
export { default as Github } from './Github' export { default as Github } from './Github'
export { default as RightArrow } from './RightArrow'
export { default as Info } from './Info' export { default as Info } from './Info'
export { default as Vercel } from './Vercel' export { default as Vercel } from './Vercel'
export { default as MapPin } from './MapPin' export { default as MapPin } from './MapPin'
export { default as Star } from './Star' 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 CreditCard } from './CreditCard'
export { default as ChevronUp } from './ChevronUp' export { default as ChevronUp } from './ChevronUp'
export { default as ChevronLeft } from './ChevronLeft' export { default as ChevronLeft } from './ChevronLeft'

View File

@ -3,104 +3,112 @@
bg-no-repeat bg-center bg-cover transition-transform bg-no-repeat bg-center bg-cover transition-transform
ease-linear cursor-pointer inline-block bg-accent-1; ease-linear cursor-pointer inline-block bg-accent-1;
height: 100% !important; height: 100% !important;
}
&:hover { .root:hover {
& .productImage { & .productImage {
transform: scale(1.2625); transform: scale(1.2625);
} }
& .productTitle > span, & .header .name span,
& .productPrice, & .header .price,
& .wishlistButton { & .wishlistButton {
@apply bg-secondary text-secondary; @apply bg-secondary text-secondary;
} }
&:nth-child(6n + 1) .productTitle > span, &:nth-child(6n + 1) .header .name span,
&:nth-child(6n + 1) .productPrice, &:nth-child(6n + 1) .header .price,
&:nth-child(6n + 1) .wishlistButton { &:nth-child(6n + 1) .wishlistButton {
@apply bg-violet text-white; @apply bg-violet text-white;
} }
&:nth-child(6n + 5) .productTitle > span, &:nth-child(6n + 5) .header .name span,
&:nth-child(6n + 5) .productPrice, &:nth-child(6n + 5) .header .price,
&:nth-child(6n + 5) .wishlistButton { &:nth-child(6n + 5) .wishlistButton {
@apply bg-blue text-white; @apply bg-blue text-white;
} }
&:nth-child(6n + 3) .productTitle > span, &:nth-child(6n + 3) .header .name span,
&:nth-child(6n + 3) .productPrice, &:nth-child(6n + 3) .header .price,
&:nth-child(6n + 3) .wishlistButton { &:nth-child(6n + 3) .wishlistButton {
@apply bg-pink text-white; @apply bg-pink text-white;
} }
&:nth-child(6n + 6) .productTitle > span, &:nth-child(6n + 6) .header .name span,
&:nth-child(6n + 6) .productPrice, &:nth-child(6n + 6) .header .price,
&:nth-child(6n + 6) .wishlistButton { &:nth-child(6n + 6) .wishlistButton {
@apply bg-cyan text-white; @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 { .root .wishlistButton {
@apply top-0 right-0 z-30 absolute; @apply top-0 right-0 z-30 absolute;
} }
.productTitle > span, /* Variant Simple */
.productPrice { .simple .header .name {
@apply transition-colors ease-in-out duration-500; @apply pt-2 text-lg leading-10 -mt-1;
} }
.simple { .simple .header .price {
& .productTitle {
@apply pt-2;
font-size: 1rem;
& span {
@apply leading-extra-loose;
}
}
& .productPrice {
@apply text-sm; @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 { .slim {
@apply bg-transparent relative overflow-hidden box-border; @apply bg-transparent relative overflow-hidden
box-border;
} }
.slim .tag { .slim .header {
@apply bg-secondary text-secondary inline-block p-3 font-bold text-xl break-words; @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 s from './ProductCard.module.css'
import Image, { ImageProps } from 'next/image' import Image, { ImageProps } from 'next/image'
import WishlistButton from '@components/wishlist/WishlistButton' import WishlistButton from '@components/wishlist/WishlistButton'
import usePrice from '@framework/product/use-price'
import ProductTag from '../ProductTag'
interface Props { interface Props {
className?: string className?: string
product: Product product: Product
variant?: 'default' | 'slim' | 'simple'
imgProps?: Omit<ImageProps, 'src'>
noNameTag?: boolean noNameTag?: boolean
imgProps?: Omit<ImageProps, 'src'>
variant?: 'default' | 'slim' | 'simple'
} }
const placeholderImg = '/product-img-placeholder.svg' const placeholderImg = '/product-img-placeholder.svg'
const ProductCard: FC<Props> = ({ const ProductCard: FC<Props> = ({
className,
product, product,
imgProps, imgProps,
variant = 'default', className,
noNameTag = false, noNameTag = false,
variant = 'default',
...props ...props
}) => ( }) => {
<Link href={`/product/${product.slug}`} {...props}> const { price } = usePrice({
<a amount: product.price.value,
className={cn( baseAmount: product.price.retailPrice,
currencyCode: product.price.currencyCode!,
})
const rootClassName = cn(
s.root, s.root,
{ [s.slim]: variant === 'slim', [s.simple]: variant === 'simple' }, { [s.slim]: variant === 'slim', [s.simple]: variant === 'simple' },
className className
)} )
>
return (
<Link href={`/product/${product.slug}`} {...props}>
<a className={rootClassName}>
{variant === 'slim' && ( {variant === 'slim' && (
<> <>
<div className="absolute inset-0 flex items-center justify-end mr-8 z-20"> <div className={s.header}>
<span className={s.tag}>{product.name}</span> <span>{product.name}</span>
</div> </div>
{product?.images && ( {product?.images && (
<Image <Image
quality="85" quality="85"
src={product.images[0].url || placeholderImg} src={product.images[0]?.url || placeholderImg}
alt={product.name || 'Product Image'} alt={product.name || 'Product Image'}
height={320} height={320}
width={320} width={320}
@ -57,24 +65,19 @@ const ProductCard: FC<Props> = ({
<WishlistButton <WishlistButton
className={s.wishlistButton} className={s.wishlistButton}
productId={product.id} 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 && ( {!noNameTag && (
<div className="absolute top-0 left-0 pr-16 max-w-full"> <div className={s.header}>
<h3 className={s.productTitle}> <h3 className={s.name}>
<span>{product.name}</span> <span>{product.name}</span>
</h3> </h3>
<span className={s.productPrice}> <div className={s.price}>
{product.price.value} {`${price} ${product.price?.currencyCode}`}
&nbsp; </div>
{product.price.currencyCode}
</span>
</div> </div>
)} )}
</div>
<div className={s.imageContainer}> <div className={s.imageContainer}>
{product?.images && ( {product?.images && (
<Image <Image
@ -101,18 +104,10 @@ const ProductCard: FC<Props> = ({
variant={product.variants[0] as any} variant={product.variants[0] as any}
/> />
)} )}
<div className="flex flex-row justify-between box-border w-full z-20 absolute "> <ProductTag
<div className="absolute top-0 left-0 pr-16 max-w-full"> name={product.name}
<h3 className={s.productTitle}> price={`${price} ${product.price?.currencyCode}`}
<span>{product.name}</span> />
</h3>
<span className={s.productPrice}>
{product.price.value}
&nbsp;
{product.price.currencyCode}
</span>
</div>
</div>
<div className={s.imageContainer}> <div className={s.imageContainer}>
{product?.images && ( {product?.images && (
<Image <Image
@ -131,6 +126,7 @@ const ProductCard: FC<Props> = ({
)} )}
</a> </a>
</Link> </Link>
) )
}
export default ProductCard export default ProductCard

View File

@ -1,18 +1,15 @@
import { Swatch } from '@components/product' import { Swatch } from '@components/product'
import type { ProductOption } from '@commerce/types/product' import type { ProductOption } from '@commerce/types/product'
import { SelectedOptions } from '../helpers' import { SelectedOptions } from '../helpers'
import React from 'react'
interface ProductOptionsProps { interface ProductOptionsProps {
options: ProductOption[] options: ProductOption[]
selectedOptions: SelectedOptions selectedOptions: SelectedOptions
setSelectedOptions: React.Dispatch<React.SetStateAction<SelectedOptions>> setSelectedOptions: React.Dispatch<React.SetStateAction<SelectedOptions>>
} }
const ProductOptions: React.FC<ProductOptionsProps> = ({ const ProductOptions: React.FC<ProductOptionsProps> = React.memo(
options, ({ options, selectedOptions, setSelectedOptions }) => {
selectedOptions,
setSelectedOptions,
}) => {
return ( return (
<div> <div>
{options.map((opt) => ( {options.map((opt) => (
@ -34,7 +31,8 @@ const ProductOptions: React.FC<ProductOptionsProps> = ({
setSelectedOptions((selectedOptions) => { setSelectedOptions((selectedOptions) => {
return { return {
...selectedOptions, ...selectedOptions,
[opt.displayName.toLowerCase()]: v.label.toLowerCase(), [opt.displayName.toLowerCase()]:
v.label.toLowerCase(),
} }
}) })
}} }}
@ -46,6 +44,7 @@ const ProductOptions: React.FC<ProductOptionsProps> = ({
))} ))}
</div> </div>
) )
} }
)
export default ProductOptions 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; 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 { .thumb {
@apply transition-transform transition-colors @apply transition-transform transition-colors
ease-linear duration-75 overflow-hidden inline-block ease-linear duration-75 overflow-hidden inline-block
@ -67,8 +37,12 @@
overflow-x: auto; overflow-x: auto;
white-space: nowrap; white-space: nowrap;
height: 125px; height: 125px;
padding-bottom: 10px; scrollbar-width: none;
margin-bottom: -10px; }
/* Hide scrollbar for Chrome, Safari and Opera */
.album::-webkit-scrollbar {
display: none;
} }
@screen md { @screen md {

View File

@ -10,9 +10,17 @@ import React, {
import cn from 'classnames' import cn from 'classnames'
import { a } from '@react-spring/web' import { a } from '@react-spring/web'
import s from './ProductSlider.module.css' 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 [currentSlide, setCurrentSlide] = useState(0)
const [isMounted, setIsMounted] = useState(false) const [isMounted, setIsMounted] = useState(false)
const sliderContainerRef = useRef<HTMLDivElement>(null) 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 ( return (
<div className={s.root} ref={sliderContainerRef}> <div className={cn(s.root, className)} ref={sliderContainerRef}>
<div <div
ref={ref} ref={ref}
className={cn(s.slider, { [s.show]: isMounted }, 'keen-slider')} className={cn(s.slider, { [s.show]: isMounted }, 'keen-slider')}
> >
{slider && ( {slider && <ProductSliderControl onPrev={onPrev} onNext={onNext} />}
<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>
)}
{Children.map(children, (child) => { {Children.map(children, (child) => {
// Add the keen-slider__slide className to children // Add the keen-slider__slide className to children
if (isValidElement(child)) { 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 { .root {
@apply relative grid items-start gap-1 grid-cols-1 overflow-x-hidden; @apply relative grid items-start gap-1 grid-cols-1 overflow-x-hidden;
min-height: auto;
} }
.main { .main {
@ -7,20 +8,6 @@
min-height: 500px; 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 { .sidebar {
@apply flex flex-col col-span-1 mx-auto max-w-8xl px-6 py-6 w-full h-full; @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; @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 { @screen lg {
.root { .root {
@apply grid-cols-12; @apply grid-cols-12;
min-height: 900px;
} }
.main { .main {
@apply mx-0 col-span-8; @apply mx-0 col-span-8;
} }
.nameBox {
@apply left-0 pr-16;
}
.nameBox .name,
.nameBox .price {
@apply bg-accent-0 text-accent-9;
}
.sidebar { .sidebar {
@apply col-span-4 py-6; @apply col-span-4 py-6;
} }

View File

@ -2,91 +2,36 @@ import cn from 'classnames'
import Image from 'next/image' import Image from 'next/image'
import { NextSeo } from 'next-seo' import { NextSeo } from 'next-seo'
import s from './ProductView.module.css' import s from './ProductView.module.css'
import { FC, useEffect, useState } from 'react' import { FC } from 'react'
import type { Product } from '@commerce/types/product' import type { Product } from '@commerce/types/product'
import usePrice from '@framework/product/use-price' import usePrice from '@framework/product/use-price'
import {
getProductVariant,
selectDefaultOptionFromProduct,
SelectedOptions,
} from '../helpers'
import { useAddItem } from '@framework/cart'
import { WishlistButton } from '@components/wishlist' import { WishlistButton } from '@components/wishlist'
import { ProductSlider, ProductCard, ProductOptions } from '@components/product' import { ProductSlider, ProductCard } from '@components/product'
import { import { Container, Text } from '@components/ui'
Button, import ProductSidebar from '../ProductSidebar'
Container, import ProductTag from '../ProductTag'
Text,
useUI,
Rating,
Collapse,
} from '@components/ui'
interface ProductViewProps { interface ProductViewProps {
product: Product product: Product
className?: string
relatedProducts: Product[] relatedProducts: Product[]
children?: React.ReactNode
} }
const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => { 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({ const { price } = usePrice({
amount: product.price.value, amount: product.price.value,
baseAmount: product.price.retailPrice, baseAmount: product.price.retailPrice,
currencyCode: product.price.currencyCode!, 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 ( return (
<>
<Container className="max-w-none w-full" clean> <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.root, 'fit')}>
<div className={cn(s.main, 'fit')}> <div className={cn(s.main, 'fit')}>
<div className={s.nameBox}> <ProductTag
<h1 className={s.name}>{product.name}</h1> name={product.name}
<div className={s.price}> price={`${price} ${product.price?.currencyCode}`}
{`${price} ${product.price?.currencyCode}`} fontSize={32}
</div> />
</div>
<div className={s.sliderContainer}> <div className={s.sliderContainer}>
<ProductSlider key={product.id}> <ProductSlider key={product.id}>
{product.images.map((image, i) => ( {product.images.map((image, i) => (
@ -112,53 +57,13 @@ const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
/> />
)} )}
</div> </div>
<div className={s.sidebar}>
<ProductOptions <ProductSidebar product={product} className={s.sidebar} />
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
</div> </div>
</div> <hr className="mt-7 border-accent-2" />
<div> <section className="py-12 px-6 mb-10">
<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">
<Text variant="sectionHeading">Related Products</Text> <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) => ( {relatedProducts.map((p) => (
<div <div
key={p.path} key={p.path}
@ -171,8 +76,8 @@ const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
variant="simple" variant="simple"
className="animated fadeIn" className="animated fadeIn"
imgProps={{ imgProps={{
width: 182, width: 300,
height: 182, height: 300,
}} }}
/> />
</div> </div>
@ -180,6 +85,24 @@ const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
</div> </div>
</section> </section>
</Container> </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'; composes: root from '@components/ui/Button/Button.module.css';
@apply h-10 w-10 bg-primary text-primary rounded-full mr-3 inline-flex @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 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); margin-right: calc(0.75rem - 1px);
overflow: hidden; overflow: hidden;
width: 48px; width: 48px;

View File

@ -1,5 +1,5 @@
import cn from 'classnames' import cn from 'classnames'
import { FC } from 'react' import React from 'react'
import s from './Swatch.module.css' import s from './Swatch.module.css'
import { Check } from '@components/icons' import { Check } from '@components/icons'
import Button, { ButtonProps } from '@components/ui/Button' import Button, { ButtonProps } from '@components/ui/Button'
@ -13,14 +13,15 @@ interface SwatchProps {
label?: string | null label?: string | null
} }
const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({ const Swatch: React.FC<Omit<ButtonProps, 'variant'> & SwatchProps> = React.memo(
({
active,
className, className,
color = '', color = '',
label = null, label = null,
variant = 'size', variant = 'size',
active,
...props ...props
}) => { }) => {
variant = variant?.toLowerCase() variant = variant?.toLowerCase()
if (label) { if (label) {
@ -30,9 +31,9 @@ const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
const swatchClassName = cn( const swatchClassName = cn(
s.swatch, s.swatch,
{ {
[s.color]: color,
[s.active]: active, [s.active]: active,
[s.size]: variant === 'size', [s.size]: variant === 'size',
[s.color]: color,
[s.dark]: color ? isDark(color) : false, [s.dark]: color ? isDark(color) : false,
[s.textLabel]: !color && label && label.length > 3, [s.textLabel]: !color && label && label.length > 3,
}, },
@ -41,10 +42,10 @@ const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
return ( return (
<Button <Button
className={swatchClassName}
style={color ? { backgroundColor: color } : {}}
aria-label="Variant Swatch" aria-label="Variant Swatch"
className={swatchClassName}
{...(label && color && { title: label })} {...(label && color && { title: label })}
style={color ? { backgroundColor: color } : {}}
{...props} {...props}
> >
{color && active && ( {color && active && (
@ -55,6 +56,7 @@ const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
{!color ? label : null} {!color ? label : null}
</Button> </Button>
) )
} }
)
export default Swatch 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 { .root {
@apply bg-accent-9 text-accent-0 cursor-pointer inline-flex @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 shadow-sm text-center justify-center uppercase
border border-transparent items-center text-sm font-semibold border border-transparent items-center text-sm font-semibold
tracking-wide; tracking-wide;
max-height: 64px;
} }
.root:hover { .root:hover {
@ -15,7 +16,7 @@
} }
.root[data-active] { .root[data-active] {
@apply bg-gray-600; @apply bg-accent-6;
} }
.loading { .loading {
@ -42,3 +43,6 @@
-webkit-perspective: 1000; -webkit-perspective: 1000;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
} }
.progress {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
.root { .root {
@apply fixed bg-primary text-primary flex items-center inset-0 z-50 justify-center; @apply fixed bg-black bg-opacity-40 flex items-center inset-0 z-50 justify-center;
background-color: rgba(0, 0, 0, 0.35); backdrop-filter: blur(0.8px);
-webkit-backdrop-filter: blur(0.8px);
} }
.modal { .modal {
@ -10,3 +11,7 @@
.modal:focus { .modal:focus {
@apply outline-none; @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 { FC, useRef, useEffect, useCallback } from 'react'
import Portal from '@reach/portal'
import s from './Modal.module.css' import s from './Modal.module.css'
import FocusTrap from '@lib/focus-trap'
import { Cross } from '@components/icons' import { Cross } from '@components/icons'
import { import {
disableBodyScroll, disableBodyScroll,
enableBodyScroll,
clearAllBodyScrollLocks, clearAllBodyScrollLocks,
enableBodyScroll,
} from 'body-scroll-lock' } from 'body-scroll-lock'
import FocusTrap from '@lib/focus-trap'
interface ModalProps { interface ModalProps {
className?: string className?: string
children?: any children?: any
open?: boolean
onClose: () => void onClose: () => void
onEnter?: () => void | null 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 ref = useRef() as React.MutableRefObject<HTMLDivElement>
const handleKey = useCallback( const handleKey = useCallback(
@ -30,36 +28,31 @@ const Modal: FC<ModalProps> = ({ children, open, onClose, onEnter = null }) => {
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
if (open) { disableBodyScroll(ref.current, { reserveScrollBarGap: true })
disableBodyScroll(ref.current)
window.addEventListener('keydown', handleKey) window.addEventListener('keydown', handleKey)
} else {
enableBodyScroll(ref.current)
}
} }
return () => { return () => {
window.removeEventListener('keydown', handleKey) if (ref && ref.current) {
clearAllBodyScrollLocks() enableBodyScroll(ref.current)
} }
}, [open, handleKey]) clearAllBodyScrollLocks()
window.removeEventListener('keydown', handleKey)
}
}, [handleKey])
return ( return (
<Portal>
{open ? (
<div className={s.root}> <div className={s.root}>
<div className={s.modal} role="dialog" ref={ref}> <div className={s.modal} role="dialog" ref={ref}>
<button <button
onClick={() => onClose()} onClick={() => onClose()}
aria-label="Close panel" 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" /> <Cross className="h-6 w-6" />
</button> </button>
<FocusTrap focusFirst>{children}</FocusTrap> <FocusTrap focusFirst>{children}</FocusTrap>
</div> </div>
</div> </div>
) : null}
</Portal>
) )
} }

View File

@ -1,6 +1,8 @@
.actions { .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-property: border-color, background, color, transform, box-shadow;
transition-duration: 0.15s; transition-duration: 0.15s;
transition-timing-function: ease; transition-timing-function: ease;
user-select: none; user-select: none;
@ -21,6 +23,5 @@
} }
.input { .input {
@apply bg-transparent px-4 w-full h-full focus:outline-none; @apply bg-transparent px-4 w-full h-full focus:outline-none select-none pointer-events-auto;
user-select: none;
} }

View File

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

View File

@ -1,8 +1,14 @@
.root { .root {
@apply fixed inset-0 overflow-hidden h-full z-50; @apply fixed inset-0 h-full z-50 box-border;
} }
.sidebar { .sidebar {
@apply h-full flex flex-col text-base bg-accent-0 shadow-xl overflow-y-auto; @apply h-full flex flex-col text-base bg-accent-0 shadow-xl overflow-y-auto overflow-x-hidden;
min-width: 335px; -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 { FC, useEffect, useRef } from 'react'
import s from './Sidebar.module.css'
import cn from 'classnames'
import { import {
disableBodyScroll, disableBodyScroll,
enableBodyScroll, enableBodyScroll,
@ -9,47 +9,37 @@ import {
interface SidebarProps { interface SidebarProps {
children: any children: any
open: boolean
onClose: () => void onClose: () => void
} }
const Sidebar: FC<SidebarProps> = ({ children, open = false, onClose }) => { const Sidebar: FC<SidebarProps> = ({ children, onClose }) => {
const ref = useRef() as React.MutableRefObject<HTMLDivElement> const ref = useRef() as React.MutableRefObject<HTMLDivElement>
useEffect(() => { useEffect(() => {
setTimeout(() => { if (ref.current) {
if (ref.current && open) { disableBodyScroll(ref.current, { reserveScrollBarGap: true })
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)
} }
}, 30)
return () => { return () => {
if (ref && ref.current) {
enableBodyScroll(ref.current)
}
clearAllBodyScrollLocks() clearAllBodyScrollLocks()
} }
}, [open]) }, [])
return ( return (
<Portal> <div className={cn(s.root)}>
{open && (
<div className={s.root} ref={ref}>
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden">
<div <div className={s.backdrop} onClick={onClose} />
className="absolute inset-0 bg-black bg-opacity-50 transition-opacity" <section className="absolute inset-y-0 right-0 max-w-full flex outline-none pl-10">
onClick={onClose} <div className="h-full w-full md:w-screen md:max-w-md">
/> <div className={s.sidebar} ref={ref}>
<section className="absolute inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16 outline-none"> {children}
<div className="h-full md:w-screen md:max-w-md"> </div>
<div className={s.sidebar}>{children}</div>
</div> </div>
</section> </section>
</div> </div>
</div> </div>
)}
</Portal>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ const fs = require('fs')
const merge = require('deepmerge') const merge = require('deepmerge')
const prettier = require('prettier') const prettier = require('prettier')
const PROVIDERS = ['bigcommerce', 'shopify', 'swell', 'vendure'] const PROVIDERS = ['bigcommerce', 'shopify', 'swell', 'vendure', 'local']
function getProviderName() { function getProviderName() {
return ( return (
@ -18,7 +18,7 @@ function getProviderName() {
? 'shopify' ? 'shopify'
: process.env.NEXT_PUBLIC_SWELL_STORE_ID : process.env.NEXT_PUBLIC_SWELL_STORE_ID
? 'swell' ? 'swell'
: null) : 'local')
) )
} }
@ -40,7 +40,7 @@ function withCommerceConfig(nextConfig = {}) {
} }
const commerceNextConfig = require(path.join('../', name, 'next.config')) const commerceNextConfig = require(path.join('../', name, 'next.config'))
const config = merge(commerceNextConfig, nextConfig) const config = merge(nextConfig, commerceNextConfig)
config.env = config.env || {} config.env = config.env || {}
@ -50,27 +50,11 @@ function withCommerceConfig(nextConfig = {}) {
// Update paths in `tsconfig.json` to point to the selected provider // Update paths in `tsconfig.json` to point to the selected provider
if (config.commerce.updateTSConfig !== false) { if (config.commerce.updateTSConfig !== false) {
const tsconfigPath = path.join(process.cwd(), 'tsconfig.json') const staticTsconfigPath = path.join(process.cwd(), 'tsconfig.json')
const tsconfig = require(tsconfigPath) const tsconfig = require('../../tsconfig.js')
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)
}
fs.writeFileSync( fs.writeFileSync(
tsconfigPath, staticTsconfigPath,
prettier.format(JSON.stringify(tsconfig), { parser: 'json' }) 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: 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)) - BigCommerce ([framework/bigcommerce](../bigcommerce))
- Saleor ([framework/saleor](../saleor))
- Shopify ([framework/shopify](../shopify)) - Shopify ([framework/shopify](../shopify))
Adding a commerce provider means adding a new folder in `framework` with a folder structure like the next one: 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, CommerceProvider as CoreCommerceProvider,
useCommerce as useCoreCommerce, useCommerce as useCoreCommerce,
} from '@commerce' } from '@commerce'
import { bigcommerceProvider, BigcommerceProvider } from './provider' import { bigcommerceProvider } from './provider'
import type { BigcommerceProvider } from './provider'
export { bigcommerceProvider } export { bigcommerceProvider }
export type { BigcommerceProvider } export type { BigcommerceProvider }
@ -156,7 +158,9 @@ export const handler: SWRHook<
const data = cartId ? await fetch(options) : null const data = cartId ? await fetch(options) : null
return data && normalizeCart(data) return data && normalizeCart(data)
}, },
useHook: ({ useData }) => (input) => { useHook:
({ useData }) =>
(input) => {
const response = useData({ const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions }, swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
}) })
@ -217,7 +221,9 @@ export const handler: MutationHook<Cart, {}, CartItemBody> = {
return normalizeCart(data) return normalizeCart(data)
}, },
useHook: ({ fetch }) => () => { useHook:
({ fetch }) =>
() => {
const { mutate } = useCart() const { mutate } = useCart()
return useCallback( return useCallback(

View File

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

View File

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