mirror of
https://github.com/vercel/commerce.git
synced 2025-07-15 08:51:21 +00:00
Merge branch 'custom-checkout' into improvements
This commit is contained in:
commit
b6357e9af1
.env.template.prettierrcREADME.mdcodegen.bigcommerce.jsoncodegen.jsoncommerce.config.jsonsearch.tsx
components
cart
checkout
CheckoutSidebarView
PaymentMethodView
ShippingView
common
Avatar
Footer
Layout
SidebarLayout
UserNav
icons
product
ProductCard
ProductOptions
ProductSidebar
ProductSlider
ProductSliderControl
ProductTag
ProductView
Swatch
ui
framework
bigcommerce
commerce
local
.env.templateREADME.mdindex.ts
api
endpoints
cart
catalog
checkout
customer
login
logout
signup
wishlist
operations
get-all-pages.tsget-all-product-paths.tsget-all-products.tsget-customer-wishlist.tsget-page.tsget-product.tsget-site-info.tsindex.ts
utils
auth
cart
@ -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=
|
||||||
|
10
.prettierrc
10
.prettierrc
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@ -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
27
codegen.bigcommerce.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
22
codegen.json
22
codegen.json
@ -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"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"features": {
|
"features": {
|
||||||
"customCheckout": true
|
"wishlist": false,
|
||||||
|
"customCheckout": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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()}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
<div className="col-span-1 lg:col-span-2">
|
<div className="col-span-1 lg:col-span-2 flex items-start lg:justify-end text-primary">
|
||||||
<ul className="flex flex-initial flex-col md:flex-1">
|
|
||||||
{legalPages.map((page) => (
|
|
||||||
<li key={page.url} className="py-3 md:py-0 md:pb-4">
|
|
||||||
<Link href={page.url!}>
|
|
||||||
<a className="text-primary hover:text-accent-6 transition ease-in-out duration-150">
|
|
||||||
{page.name}
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-1 lg:col-span-6 flex items-start lg:justify-end text-primary">
|
|
||||||
<div className="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
|
||||||
|
sitePages.push(page)
|
||||||
if (isLegalPage(slug, locale)) {
|
|
||||||
legalPages.push(page)
|
|
||||||
} else {
|
|
||||||
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)
|
||||||
|
@ -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}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
<UserNav />
|
<span className={s.nav}>
|
||||||
|
<UserNav />
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
{children}
|
<div className={s.container}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -24,21 +24,21 @@ 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 />
|
{itemsCount > 0 && <span className={s.bagCount}>{itemsCount}</span>}
|
||||||
{itemsCount > 0 && <span className={s.bagCount}>{itemsCount}</span>}
|
</li>
|
||||||
|
{process.env.COMMERCE_WISHLIST_ENABLED && (
|
||||||
|
<li className={s.item}>
|
||||||
|
<Link href="/wishlist">
|
||||||
|
<a onClick={closeSidebarIfPresent} aria-label="Wishlist">
|
||||||
|
<Heart />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
{process.env.COMMERCE_WISHLIST_ENABLED && (
|
)}
|
||||||
<li className={s.item}>
|
{process.env.COMMERCE_CUSTOMER_ENABLED && (
|
||||||
<Link href="/wishlist">
|
|
||||||
<a onClick={closeSidebarIfPresent} aria-label="Wishlist">
|
|
||||||
<Heart />
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
<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>
|
)}
|
||||||
</div>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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
|
@ -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'
|
||||||
|
@ -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,
|
|
||||||
& .productPrice,
|
|
||||||
& .wishlistButton {
|
|
||||||
@apply bg-secondary text-secondary;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(6n + 1) .productTitle > span,
|
|
||||||
&:nth-child(6n + 1) .productPrice,
|
|
||||||
&:nth-child(6n + 1) .wishlistButton {
|
|
||||||
@apply bg-violet text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(6n + 5) .productTitle > span,
|
|
||||||
&:nth-child(6n + 5) .productPrice,
|
|
||||||
&:nth-child(6n + 5) .wishlistButton {
|
|
||||||
@apply bg-blue text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(6n + 3) .productTitle > span,
|
|
||||||
&:nth-child(6n + 3) .productPrice,
|
|
||||||
&:nth-child(6n + 3) .wishlistButton {
|
|
||||||
@apply bg-pink text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(6n + 6) .productTitle > span,
|
|
||||||
&:nth-child(6n + 6) .productPrice,
|
|
||||||
&:nth-child(6n + 6) .wishlistButton {
|
|
||||||
@apply bg-cyan text-white;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& .header .name span,
|
||||||
|
& .header .price,
|
||||||
|
& .wishlistButton {
|
||||||
|
@apply bg-secondary text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(6n + 1) .header .name span,
|
||||||
|
&:nth-child(6n + 1) .header .price,
|
||||||
|
&:nth-child(6n + 1) .wishlistButton {
|
||||||
|
@apply bg-violet text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(6n + 5) .header .name span,
|
||||||
|
&:nth-child(6n + 5) .header .price,
|
||||||
|
&:nth-child(6n + 5) .wishlistButton {
|
||||||
|
@apply bg-blue text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(6n + 3) .header .name span,
|
||||||
|
&:nth-child(6n + 3) .header .price,
|
||||||
|
&:nth-child(6n + 3) .wishlistButton {
|
||||||
|
@apply bg-pink text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(6n + 6) .header .name span,
|
||||||
|
&:nth-child(6n + 6) .header .price,
|
||||||
|
&:nth-child(6n + 6) .wishlistButton {
|
||||||
|
@apply bg-cyan text-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
@apply transition-colors ease-in-out duration-500
|
||||||
|
absolute top-0 left-0 z-20 pr-16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .name {
|
||||||
|
@apply pt-0 max-w-full w-full leading-extra-loose
|
||||||
|
transition-colors ease-in-out duration-500;
|
||||||
|
font-size: 2rem;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .name span {
|
||||||
|
@apply py-4 px-6 bg-primary text-primary font-bold
|
||||||
|
transition-colors ease-in-out duration-500;
|
||||||
|
font-size: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .price {
|
||||||
|
@apply pt-2 px-6 pb-4 text-sm bg-primary text-accent-9
|
||||||
|
font-semibold inline-block tracking-wide
|
||||||
|
transition-colors ease-in-out duration-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageContainer {
|
||||||
|
@apply flex items-center justify-center overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageContainer > div {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageContainer .productImage {
|
||||||
|
@apply transform transition-transform duration-500
|
||||||
|
object-cover scale-120;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root .wishlistButton {
|
.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 text-sm;
|
||||||
@apply pt-2;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
& span {
|
|
||||||
@apply leading-extra-loose;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& .productPrice {
|
|
||||||
@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;
|
||||||
}
|
}
|
||||||
|
@ -5,132 +5,128 @@ 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,
|
||||||
s.root,
|
currencyCode: product.price.currencyCode!,
|
||||||
{ [s.slim]: variant === 'slim', [s.simple]: variant === 'simple' },
|
})
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{variant === 'slim' && (
|
|
||||||
<>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-end mr-8 z-20">
|
|
||||||
<span className={s.tag}>{product.name}</span>
|
|
||||||
</div>
|
|
||||||
{product?.images && (
|
|
||||||
<Image
|
|
||||||
quality="85"
|
|
||||||
src={product.images[0].url || placeholderImg}
|
|
||||||
alt={product.name || 'Product Image'}
|
|
||||||
height={320}
|
|
||||||
width={320}
|
|
||||||
layout="fixed"
|
|
||||||
{...imgProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{variant === 'simple' && (
|
const rootClassName = cn(
|
||||||
<>
|
s.root,
|
||||||
{process.env.COMMERCE_WISHLIST_ENABLED && (
|
{ [s.slim]: variant === 'slim', [s.simple]: variant === 'simple' },
|
||||||
<WishlistButton
|
className
|
||||||
className={s.wishlistButton}
|
)
|
||||||
productId={product.id}
|
|
||||||
variant={product.variants[0] as any}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-row justify-between box-border w-full z-20 absolute ">
|
return (
|
||||||
|
<Link href={`/product/${product.slug}`} {...props}>
|
||||||
|
<a className={rootClassName}>
|
||||||
|
{variant === 'slim' && (
|
||||||
|
<>
|
||||||
|
<div className={s.header}>
|
||||||
|
<span>{product.name}</span>
|
||||||
|
</div>
|
||||||
|
{product?.images && (
|
||||||
|
<Image
|
||||||
|
quality="85"
|
||||||
|
src={product.images[0]?.url || placeholderImg}
|
||||||
|
alt={product.name || 'Product Image'}
|
||||||
|
height={320}
|
||||||
|
width={320}
|
||||||
|
layout="fixed"
|
||||||
|
{...imgProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'simple' && (
|
||||||
|
<>
|
||||||
|
{process.env.COMMERCE_WISHLIST_ENABLED && (
|
||||||
|
<WishlistButton
|
||||||
|
className={s.wishlistButton}
|
||||||
|
productId={product.id}
|
||||||
|
variant={product.variants[0]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!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}`}
|
||||||
|
</div>
|
||||||
{product.price.currencyCode}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className={s.imageContainer}>
|
||||||
<div className={s.imageContainer}>
|
{product?.images && (
|
||||||
{product?.images && (
|
<Image
|
||||||
<Image
|
alt={product.name || 'Product Image'}
|
||||||
alt={product.name || 'Product Image'}
|
className={s.productImage}
|
||||||
className={s.productImage}
|
src={product.images[0].url || placeholderImg}
|
||||||
src={product.images[0].url || placeholderImg}
|
height={540}
|
||||||
height={540}
|
width={540}
|
||||||
width={540}
|
quality="85"
|
||||||
quality="85"
|
layout="responsive"
|
||||||
layout="responsive"
|
{...imgProps}
|
||||||
{...imgProps}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{variant === 'default' && (
|
|
||||||
<>
|
|
||||||
{process.env.COMMERCE_WISHLIST_ENABLED && (
|
|
||||||
<WishlistButton
|
|
||||||
className={s.wishlistButton}
|
|
||||||
productId={product.id}
|
|
||||||
variant={product.variants[0] as any}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-row justify-between box-border w-full z-20 absolute ">
|
|
||||||
<div className="absolute top-0 left-0 pr-16 max-w-full">
|
|
||||||
<h3 className={s.productTitle}>
|
|
||||||
<span>{product.name}</span>
|
|
||||||
</h3>
|
|
||||||
<span className={s.productPrice}>
|
|
||||||
{product.price.value}
|
|
||||||
|
|
||||||
{product.price.currencyCode}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
<div className={s.imageContainer}>
|
)}
|
||||||
{product?.images && (
|
|
||||||
<Image
|
{variant === 'default' && (
|
||||||
alt={product.name || 'Product Image'}
|
<>
|
||||||
className={s.productImage}
|
{process.env.COMMERCE_WISHLIST_ENABLED && (
|
||||||
src={product.images[0]?.url || placeholderImg}
|
<WishlistButton
|
||||||
height={540}
|
className={s.wishlistButton}
|
||||||
width={540}
|
productId={product.id}
|
||||||
quality="85"
|
variant={product.variants[0] as any}
|
||||||
layout="responsive"
|
|
||||||
{...imgProps}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
<ProductTag
|
||||||
</>
|
name={product.name}
|
||||||
)}
|
price={`${price} ${product.price?.currencyCode}`}
|
||||||
</a>
|
/>
|
||||||
</Link>
|
<div className={s.imageContainer}>
|
||||||
)
|
{product?.images && (
|
||||||
|
<Image
|
||||||
|
alt={product.name || 'Product Image'}
|
||||||
|
className={s.productImage}
|
||||||
|
src={product.images[0]?.url || placeholderImg}
|
||||||
|
height={540}
|
||||||
|
width={540}
|
||||||
|
quality="85"
|
||||||
|
layout="responsive"
|
||||||
|
{...imgProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default ProductCard
|
export default ProductCard
|
||||||
|
@ -1,51 +1,50 @@
|
|||||||
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,
|
return (
|
||||||
setSelectedOptions,
|
<div>
|
||||||
}) => {
|
{options.map((opt) => (
|
||||||
return (
|
<div className="pb-4" key={opt.displayName}>
|
||||||
<div>
|
<h2 className="uppercase font-medium text-sm tracking-wide">
|
||||||
{options.map((opt) => (
|
{opt.displayName}
|
||||||
<div className="pb-4" key={opt.displayName}>
|
</h2>
|
||||||
<h2 className="uppercase font-medium text-sm tracking-wide">
|
<div className="flex flex-row py-4">
|
||||||
{opt.displayName}
|
{opt.values.map((v, i: number) => {
|
||||||
</h2>
|
const active = selectedOptions[opt.displayName.toLowerCase()]
|
||||||
<div className="flex flex-row py-4">
|
return (
|
||||||
{opt.values.map((v, i: number) => {
|
<Swatch
|
||||||
const active = selectedOptions[opt.displayName.toLowerCase()]
|
key={`${opt.id}-${i}`}
|
||||||
return (
|
active={v.label.toLowerCase() === active}
|
||||||
<Swatch
|
variant={opt.displayName}
|
||||||
key={`${opt.id}-${i}`}
|
color={v.hexColors ? v.hexColors[0] : ''}
|
||||||
active={v.label.toLowerCase() === active}
|
label={v.label}
|
||||||
variant={opt.displayName}
|
onClick={() => {
|
||||||
color={v.hexColors ? v.hexColors[0] : ''}
|
setSelectedOptions((selectedOptions) => {
|
||||||
label={v.label}
|
return {
|
||||||
onClick={() => {
|
...selectedOptions,
|
||||||
setSelectedOptions((selectedOptions) => {
|
[opt.displayName.toLowerCase()]:
|
||||||
return {
|
v.label.toLowerCase(),
|
||||||
...selectedOptions,
|
}
|
||||||
[opt.displayName.toLowerCase()]: v.label.toLowerCase(),
|
})
|
||||||
}
|
}}
|
||||||
})
|
/>
|
||||||
}}
|
)
|
||||||
/>
|
})}
|
||||||
)
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
}
|
)
|
||||||
|
|
||||||
export default ProductOptions
|
export default ProductOptions
|
||||||
|
84
components/product/ProductSidebar/ProductSidebar.module.css
Normal file
84
components/product/ProductSidebar/ProductSidebar.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
87
components/product/ProductSidebar/ProductSidebar.tsx
Normal file
87
components/product/ProductSidebar/ProductSidebar.tsx
Normal 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
|
1
components/product/ProductSidebar/index.ts
Normal file
1
components/product/ProductSidebar/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './ProductSidebar'
|
@ -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 {
|
||||||
|
@ -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)) {
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -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
|
1
components/product/ProductSliderControl/index.ts
Normal file
1
components/product/ProductSliderControl/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './ProductSliderControl'
|
30
components/product/ProductTag/ProductTag.module.css
Normal file
30
components/product/ProductTag/ProductTag.module.css
Normal 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;
|
||||||
|
}
|
36
components/product/ProductTag/ProductTag.tsx
Normal file
36
components/product/ProductTag/ProductTag.tsx
Normal 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
|
1
components/product/ProductTag/index.ts
Normal file
1
components/product/ProductTag/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './ProductTag'
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -2,65 +2,89 @@ 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>
|
||||||
|
<div className={cn(s.root, 'fit')}>
|
||||||
|
<div className={cn(s.main, 'fit')}>
|
||||||
|
<ProductTag
|
||||||
|
name={product.name}
|
||||||
|
price={`${price} ${product.price?.currencyCode}`}
|
||||||
|
fontSize={32}
|
||||||
|
/>
|
||||||
|
<div className={s.sliderContainer}>
|
||||||
|
<ProductSlider key={product.id}>
|
||||||
|
{product.images.map((image, i) => (
|
||||||
|
<div key={image.url} className={s.imageContainer}>
|
||||||
|
<Image
|
||||||
|
className={s.img}
|
||||||
|
src={image.url!}
|
||||||
|
alt={image.alt || 'Product Image'}
|
||||||
|
width={600}
|
||||||
|
height={600}
|
||||||
|
priority={i === 0}
|
||||||
|
quality="85"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ProductSlider>
|
||||||
|
</div>
|
||||||
|
{process.env.COMMERCE_WISHLIST_ENABLED && (
|
||||||
|
<WishlistButton
|
||||||
|
className={s.wishlistButton}
|
||||||
|
productId={product.id}
|
||||||
|
variant={product.variants[0]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProductSidebar product={product} className={s.sidebar} />
|
||||||
|
</div>
|
||||||
|
<hr className="mt-7 border-accent-2" />
|
||||||
|
<section className="py-12 px-6 mb-10">
|
||||||
|
<Text variant="sectionHeading">Related Products</Text>
|
||||||
|
<div className={s.relatedProductsGrid}>
|
||||||
|
{relatedProducts.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.path}
|
||||||
|
className="animated fadeIn bg-accent-0 border border-accent-2"
|
||||||
|
>
|
||||||
|
<ProductCard
|
||||||
|
noNameTag
|
||||||
|
product={p}
|
||||||
|
key={p.path}
|
||||||
|
variant="simple"
|
||||||
|
className="animated fadeIn"
|
||||||
|
imgProps={{
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Container>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
title={product.name}
|
title={product.name}
|
||||||
description={product.description}
|
description={product.description}
|
||||||
@ -78,108 +102,7 @@ const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
|
|||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className={cn(s.root, 'fit')}>
|
</>
|
||||||
<div className={cn(s.main, 'fit')}>
|
|
||||||
<div className={s.nameBox}>
|
|
||||||
<h1 className={s.name}>{product.name}</h1>
|
|
||||||
<div className={s.price}>
|
|
||||||
{`${price} ${product.price?.currencyCode}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.sliderContainer}>
|
|
||||||
<ProductSlider key={product.id}>
|
|
||||||
{product.images.map((image, i) => (
|
|
||||||
<div key={image.url} className={s.imageContainer}>
|
|
||||||
<Image
|
|
||||||
className={s.img}
|
|
||||||
src={image.url!}
|
|
||||||
alt={image.alt || 'Product Image'}
|
|
||||||
width={600}
|
|
||||||
height={600}
|
|
||||||
priority={i === 0}
|
|
||||||
quality="85"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</ProductSlider>
|
|
||||||
</div>
|
|
||||||
{process.env.COMMERCE_WISHLIST_ENABLED && (
|
|
||||||
<WishlistButton
|
|
||||||
className={s.wishlistButton}
|
|
||||||
productId={product.id}
|
|
||||||
variant={product.variants[0]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={s.sidebar}>
|
|
||||||
<ProductOptions
|
|
||||||
options={product.options}
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
setSelectedOptions={setSelectedOptions}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
className="pb-4 break-words w-full max-w-xl"
|
|
||||||
html={product.descriptionHtml || product.description}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-row justify-between items-center">
|
|
||||||
<Rating value={2} />
|
|
||||||
<div className="text-accent-6 pr-1 font-medium select-none">
|
|
||||||
36 reviews
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
aria-label="Add to Cart"
|
|
||||||
type="button"
|
|
||||||
className={s.button}
|
|
||||||
onClick={addToCart}
|
|
||||||
loading={loading}
|
|
||||||
disabled={variant?.availableForSale === false}
|
|
||||||
>
|
|
||||||
{variant?.availableForSale === false
|
|
||||||
? 'Not Available'
|
|
||||||
: 'Add To Cart'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6">
|
|
||||||
<Collapse title="Care">
|
|
||||||
This is a limited edition production run. Printing starts when the
|
|
||||||
drop ends.
|
|
||||||
</Collapse>
|
|
||||||
<Collapse title="Details">
|
|
||||||
This is a limited edition production run. Printing starts when the
|
|
||||||
drop ends. Reminder: Bad Boys For Life. Shipping may take 10+ days
|
|
||||||
due to COVID-19.
|
|
||||||
</Collapse>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr className="mt-6" />
|
|
||||||
<section className="py-6 px-6 mb-10">
|
|
||||||
<Text variant="sectionHeading">Related Products</Text>
|
|
||||||
<div className="grid grid-cols-2 py-2 gap-4 md:grid-cols-4 md:gap-20">
|
|
||||||
{relatedProducts.map((p) => (
|
|
||||||
<div
|
|
||||||
key={p.path}
|
|
||||||
className="animated fadeIn bg-accent-0 border border-accent-2"
|
|
||||||
>
|
|
||||||
<ProductCard
|
|
||||||
noNameTag
|
|
||||||
product={p}
|
|
||||||
key={p.path}
|
|
||||||
variant="simple"
|
|
||||||
className="animated fadeIn"
|
|
||||||
imgProps={{
|
|
||||||
width: 182,
|
|
||||||
height: 182,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</Container>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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,48 +13,50 @@ 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(
|
||||||
className,
|
({
|
||||||
color = '',
|
active,
|
||||||
label = null,
|
className,
|
||||||
variant = 'size',
|
color = '',
|
||||||
active,
|
label = null,
|
||||||
...props
|
variant = 'size',
|
||||||
}) => {
|
...props
|
||||||
variant = variant?.toLowerCase()
|
}) => {
|
||||||
|
variant = variant?.toLowerCase()
|
||||||
|
|
||||||
if (label) {
|
if (label) {
|
||||||
label = label?.toLowerCase()
|
label = label?.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const swatchClassName = cn(
|
||||||
|
s.swatch,
|
||||||
|
{
|
||||||
|
[s.color]: color,
|
||||||
|
[s.active]: active,
|
||||||
|
[s.size]: variant === 'size',
|
||||||
|
[s.dark]: color ? isDark(color) : false,
|
||||||
|
[s.textLabel]: !color && label && label.length > 3,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-label="Variant Swatch"
|
||||||
|
className={swatchClassName}
|
||||||
|
{...(label && color && { title: label })}
|
||||||
|
style={color ? { backgroundColor: color } : {}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{color && active && (
|
||||||
|
<span>
|
||||||
|
<Check />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!color ? label : null}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
const swatchClassName = cn(
|
|
||||||
s.swatch,
|
|
||||||
{
|
|
||||||
[s.active]: active,
|
|
||||||
[s.size]: variant === 'size',
|
|
||||||
[s.color]: color,
|
|
||||||
[s.dark]: color ? isDark(color) : false,
|
|
||||||
[s.textLabel]: !color && label && label.length > 3,
|
|
||||||
},
|
|
||||||
className
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={swatchClassName}
|
|
||||||
style={color ? { backgroundColor: color } : {}}
|
|
||||||
aria-label="Variant Swatch"
|
|
||||||
{...(label && color && { title: label })}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{color && active && (
|
|
||||||
<span>
|
|
||||||
<Check />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!color ? label : null}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Swatch
|
export default Swatch
|
||||||
|
439
components/search.tsx
Normal file
439
components/search.tsx
Normal 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
|
@ -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 {
|
||||||
|
}
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header .label {
|
.header .label {
|
||||||
@apply text-sm font-medium;
|
@apply text-base font-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
animation-name: blink;
|
background-color: currentColor;
|
||||||
animation-duration: 1.4s;
|
animation-name: blink;
|
||||||
animation-iteration-count: infinite;
|
animation-duration: 1.4s;
|
||||||
animation-fill-mode: both;
|
animation-iteration-count: infinite;
|
||||||
margin: 0 2px;
|
animation-fill-mode: both;
|
||||||
|
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 {
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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) => ({
|
||||||
{() => (
|
...child,
|
||||||
<div className={s.container}>
|
props: {
|
||||||
{Children.map(children, (child) => {
|
...child.props,
|
||||||
if (isValidElement(child)) {
|
className: cn(child.props.className, `${variant}`),
|
||||||
return {
|
},
|
||||||
...child,
|
}))}
|
||||||
props: {
|
</FastMarquee>
|
||||||
...child.props,
|
|
||||||
className: cn(child.props.className, `${variant}`),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return child
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Ticker>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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) {
|
||||||
|
enableBodyScroll(ref.current)
|
||||||
|
}
|
||||||
clearAllBodyScrollLocks()
|
clearAllBodyScrollLocks()
|
||||||
|
window.removeEventListener('keydown', handleKey)
|
||||||
}
|
}
|
||||||
}, [open, handleKey])
|
}, [handleKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<div className={s.root}>
|
||||||
{open ? (
|
<div className={s.modal} role="dialog" ref={ref}>
|
||||||
<div className={s.root}>
|
<button
|
||||||
<div className={s.modal} role="dialog" ref={ref}>
|
onClick={() => onClose()}
|
||||||
<button
|
aria-label="Close panel"
|
||||||
onClick={() => onClose()}
|
className={s.close}
|
||||||
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"
|
<Cross className="h-6 w-6" />
|
||||||
>
|
</button>
|
||||||
<Cross className="h-6 w-6" />
|
<FocusTrap focusFirst>{children}</FocusTrap>
|
||||||
</button>
|
</div>
|
||||||
<FocusTrap focusFirst>{children}</FocusTrap>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Portal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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="absolute inset-0 overflow-hidden">
|
||||||
<div className={s.root} ref={ref}>
|
<div className={s.backdrop} onClick={onClose} />
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<section className="absolute inset-y-0 right-0 max-w-full flex outline-none pl-10">
|
||||||
<div
|
<div className="h-full w-full md:w-screen md:max-w-md">
|
||||||
className="absolute inset-0 bg-black bg-opacity-50 transition-opacity"
|
<div className={s.sidebar} ref={ref}>
|
||||||
onClick={onClose}
|
{children}
|
||||||
/>
|
</div>
|
||||||
<section className="absolute inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16 outline-none">
|
|
||||||
<div className="h-full md:w-screen md:max-w-md">
|
|
||||||
<div className={s.sidebar}>{children}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
)}
|
</div>
|
||||||
</Portal>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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]
|
||||||
state.displaySidebar
|
)
|
||||||
? dispatch({ type: 'CLOSE_SIDEBAR' })
|
const closeSidebar = useCallback(
|
||||||
: dispatch({ type: 'OPEN_SIDEBAR' })
|
() => dispatch({ type: 'CLOSE_SIDEBAR' }),
|
||||||
const closeSidebarIfPresent = () =>
|
[dispatch]
|
||||||
state.displaySidebar && dispatch({ type: 'CLOSE_SIDEBAR' })
|
)
|
||||||
|
const toggleSidebar = useCallback(
|
||||||
|
() =>
|
||||||
|
state.displaySidebar
|
||||||
|
? dispatch({ type: 'CLOSE_SIDEBAR' })
|
||||||
|
: dispatch({ type: 'OPEN_SIDEBAR' }),
|
||||||
|
[dispatch, state.displaySidebar]
|
||||||
|
)
|
||||||
|
const closeSidebarIfPresent = useCallback(
|
||||||
|
() => state.displaySidebar && dispatch({ type: 'CLOSE_SIDEBAR' }),
|
||||||
|
[dispatch, state.displaySidebar]
|
||||||
|
)
|
||||||
|
|
||||||
const openDropdown = () => dispatch({ type: 'OPEN_DROPDOWN' })
|
const 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]
|
||||||
|
@ -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 = {
|
||||||
|
@ -1,38 +1,36 @@
|
|||||||
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)
|
||||||
) => {
|
const config = getConfig()
|
||||||
// log.warn(query)
|
const res = await fetch(config.commerceUrl + (preview ? '/preview' : ''), {
|
||||||
const { config } = provider
|
...fetchOptions,
|
||||||
const res = await fetch(config.commerceUrl + (preview ? '/preview' : ''), {
|
method: 'POST',
|
||||||
...fetchOptions,
|
headers: {
|
||||||
method: 'POST',
|
Authorization: `Bearer ${config.apiToken}`,
|
||||||
headers: {
|
...fetchOptions?.headers,
|
||||||
Authorization: `Bearer ${config.apiToken}`,
|
'Content-Type': 'application/json',
|
||||||
...fetchOptions?.headers,
|
},
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify({
|
||||||
},
|
query,
|
||||||
body: JSON.stringify({
|
variables,
|
||||||
query,
|
}),
|
||||||
variables,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const json = await res.json()
|
|
||||||
if (json.errors) {
|
|
||||||
throw new FetcherError({
|
|
||||||
errors: json.errors ?? [{ message: 'Failed to fetch Bigcommerce API' }],
|
|
||||||
status: res.status,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.errors) {
|
||||||
|
throw new FetcherError({
|
||||||
|
errors: json.errors ?? [{ message: 'Failed to fetch Bigcommerce API' }],
|
||||||
|
status: res.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: json.data, res }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data: json.data, res }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default fetchGraphqlApi
|
export default fetchGraphqlApi
|
||||||
|
@ -1,56 +1,56 @@
|
|||||||
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 {
|
||||||
res = await fetch(config.storeApiUrl + endpoint, {
|
res = await fetch(config.storeApiUrl + endpoint, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Auth-Token': config.storeApiToken,
|
'X-Auth-Token': config.storeApiToken,
|
||||||
'X-Auth-Client': config.storeApiClientId,
|
'X-Auth-Client': config.storeApiClientId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new BigcommerceNetworkError(
|
throw new BigcommerceNetworkError(
|
||||||
`Fetch to Bigcommerce failed: ${error.message}`
|
`Fetch to Bigcommerce failed: ${error.message}`
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('Content-Type')
|
||||||
|
const isJSON = contentType?.includes('application/json')
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = isJSON ? await res.json() : await getTextOrNull(res)
|
||||||
|
const headers = getRawHeaders(res)
|
||||||
|
const msg = `Big Commerce API error (${
|
||||||
|
res.status
|
||||||
|
}) \nHeaders: ${JSON.stringify(headers, null, 2)}\n${
|
||||||
|
typeof data === 'string' ? data : JSON.stringify(data, null, 2)
|
||||||
|
}`
|
||||||
|
|
||||||
|
throw new BigcommerceApiError(msg, res, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status !== 204 && !isJSON) {
|
||||||
|
throw new BigcommerceApiError(
|
||||||
|
`Fetch to Bigcommerce API failed, expected JSON content but found: ${contentType}`,
|
||||||
|
res
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If something was removed, the response will be empty
|
||||||
|
return res.status === 204 ? null : await res.json()
|
||||||
}
|
}
|
||||||
|
export default fetchStoreApi
|
||||||
const contentType = res.headers.get('Content-Type')
|
|
||||||
const isJSON = contentType?.includes('application/json')
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = isJSON ? await res.json() : await getTextOrNull(res)
|
|
||||||
const headers = getRawHeaders(res)
|
|
||||||
const msg = `Big Commerce API error (${
|
|
||||||
res.status
|
|
||||||
}) \nHeaders: ${JSON.stringify(headers, null, 2)}\n${
|
|
||||||
typeof data === 'string' ? data : JSON.stringify(data, null, 2)
|
|
||||||
}`
|
|
||||||
|
|
||||||
throw new BigcommerceApiError(msg, res, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status !== 204 && !isJSON) {
|
|
||||||
throw new BigcommerceApiError(
|
|
||||||
`Fetch to Bigcommerce API failed, expected JSON content but found: ${contentType}`,
|
|
||||||
res
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If something was removed, the response will be empty
|
|
||||||
return res.status === 204 ? null : await res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRawHeaders(res: Response) {
|
function getRawHeaders(res: Response) {
|
||||||
const headers: { [key: string]: string } = {}
|
const headers: { [key: string]: string } = {}
|
||||||
|
@ -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 }
|
||||||
|
@ -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 => {
|
||||||
|
return getEndpoint(commerce, { ...endpoint, ...context })
|
||||||
}
|
}
|
||||||
): NextApiHandler => {
|
|
||||||
return getEndpoint(commerce, { ...endpoint, ...context })
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommerceAPIConfig {
|
export interface CommerceAPIConfig {
|
||||||
locale?: string
|
locale?: string
|
||||||
|
@ -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' })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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,24 +158,26 @@ 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:
|
||||||
const response = useData({
|
({ useData }) =>
|
||||||
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
|
(input) => {
|
||||||
})
|
const response = useData({
|
||||||
|
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
|
||||||
|
})
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.create(response, {
|
Object.create(response, {
|
||||||
isEmpty: {
|
isEmpty: {
|
||||||
get() {
|
get() {
|
||||||
return (response.data?.lineItems.length ?? 0) <= 0
|
return (response.data?.lineItems.length ?? 0) <= 0
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
},
|
},
|
||||||
enumerable: true,
|
}),
|
||||||
},
|
[response]
|
||||||
}),
|
)
|
||||||
[response]
|
},
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -217,18 +221,20 @@ export const handler: MutationHook<Cart, {}, CartItemBody> = {
|
|||||||
|
|
||||||
return normalizeCart(data)
|
return normalizeCart(data)
|
||||||
},
|
},
|
||||||
useHook: ({ fetch }) => () => {
|
useHook:
|
||||||
const { mutate } = useCart()
|
({ fetch }) =>
|
||||||
|
() => {
|
||||||
|
const { mutate } = useCart()
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async function addItem(input) {
|
async function addItem(input) {
|
||||||
const data = await fetch({ input })
|
const data = await fetch({ input })
|
||||||
await mutate(data, false)
|
await mutate(data, false)
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
[fetch, mutate]
|
[fetch, mutate]
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -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 }
|
}
|
||||||
}
|
|
||||||
|
@ -11,18 +11,16 @@ 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
|
? never
|
||||||
> = Desc extends { writable: any; set(val: any): any }
|
: Desc extends { writable: any; get(): any }
|
||||||
? never
|
? never
|
||||||
: Desc extends { writable: any; get(): any }
|
: Desc extends { writable: false }
|
||||||
? never
|
? Readonly<InferValue<Prop, Desc>>
|
||||||
: Desc extends { writable: false }
|
: Desc extends { writable: true }
|
||||||
? Readonly<InferValue<Prop, Desc>>
|
? InferValue<Prop, Desc>
|
||||||
: Desc extends { writable: true }
|
: Readonly<InferValue<Prop, Desc>>
|
||||||
? InferValue<Prop, Desc>
|
|
||||||
: Readonly<InferValue<Prop, Desc>>
|
|
||||||
|
|
||||||
export default function defineProperty<
|
export default function defineProperty<
|
||||||
Obj extends object,
|
Obj extends object,
|
||||||
|
1
framework/local/.env.template
Normal file
1
framework/local/.env.template
Normal file
@ -0,0 +1 @@
|
|||||||
|
COMMERCE_PROVIDER=local
|
1
framework/local/README.md
Normal file
1
framework/local/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Next.js Local Provider
|
1
framework/local/api/endpoints/cart/index.ts
Normal file
1
framework/local/api/endpoints/cart/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default function noopApi(...args: any[]): void {}
|
1
framework/local/api/endpoints/catalog/index.ts
Normal file
1
framework/local/api/endpoints/catalog/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default function noopApi(...args: any[]): void {}
|
1
framework/local/api/endpoints/catalog/products.ts
Normal file
1
framework/local/api/endpoints/catalog/products.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default function noopApi(...args: any[]): void {}
|
1
framework/local/api/endpoints/checkout/index.ts
Normal file
1
framework/local/api/endpoints/checkout/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default function noopApi(...args: any[]): void {}
|
1
framework/local/api/endpoints/customer/index.ts
Normal file
1
framework/local/api/endpoints/customer/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default function noopApi(...args: any[]): void {}
|
1
framework/local/api/endpoints/login/index.ts
Normal file
1
framework/local/api/endpoints/login/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default function noopApi(...args: any[]): void {}
|
1
framework/local/api/endpoints/logout/index.ts
Normal file
1
framework/local/api/endpoints/logout/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default function noopApi(...args: any[]): void {}
|
1
framework/local/api/endpoints/signup/index.ts
Normal file
1
framework/local/api/endpoints/signup/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default function noopApi(...args: any[]): void {}
|
1
framework/local/api/endpoints/wishlist/index.tsx
Normal file
1
framework/local/api/endpoints/wishlist/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default function noopApi(...args: any[]): void {}
|
42
framework/local/api/index.ts
Normal file
42
framework/local/api/index.ts
Normal 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)
|
||||||
|
}
|
19
framework/local/api/operations/get-all-pages.ts
Normal file
19
framework/local/api/operations/get-all-pages.ts
Normal 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
|
||||||
|
}
|
15
framework/local/api/operations/get-all-product-paths.ts
Normal file
15
framework/local/api/operations/get-all-product-paths.ts
Normal 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
|
||||||
|
}
|
25
framework/local/api/operations/get-all-products.ts
Normal file
25
framework/local/api/operations/get-all-products.ts
Normal 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
|
||||||
|
}
|
6
framework/local/api/operations/get-customer-wishlist.ts
Normal file
6
framework/local/api/operations/get-customer-wishlist.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default function getCustomerWishlistOperation() {
|
||||||
|
function getCustomerWishlist(): any {
|
||||||
|
return { wishlist: {} }
|
||||||
|
}
|
||||||
|
return getCustomerWishlist
|
||||||
|
}
|
12
framework/local/api/operations/get-page.ts
Normal file
12
framework/local/api/operations/get-page.ts
Normal 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
|
||||||
|
}
|
26
framework/local/api/operations/get-product.ts
Normal file
26
framework/local/api/operations/get-product.ts
Normal 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
|
||||||
|
}
|
30
framework/local/api/operations/get-site-info.ts
Normal file
30
framework/local/api/operations/get-site-info.ts
Normal 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
|
||||||
|
}
|
6
framework/local/api/operations/index.ts
Normal file
6
framework/local/api/operations/index.ts
Normal 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'
|
34
framework/local/api/utils/fetch-local.ts
Normal file
34
framework/local/api/utils/fetch-local.ts
Normal 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
|
3
framework/local/api/utils/fetch.ts
Normal file
3
framework/local/api/utils/fetch.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import zeitFetch from '@vercel/fetch'
|
||||||
|
|
||||||
|
export default zeitFetch()
|
3
framework/local/auth/index.ts
Normal file
3
framework/local/auth/index.ts
Normal 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'
|
16
framework/local/auth/use-login.tsx
Normal file
16
framework/local/auth/use-login.tsx
Normal 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 () {}
|
||||||
|
},
|
||||||
|
}
|
17
framework/local/auth/use-logout.tsx
Normal file
17
framework/local/auth/use-logout.tsx
Normal 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 () => {},
|
||||||
|
}
|
19
framework/local/auth/use-signup.tsx
Normal file
19
framework/local/auth/use-signup.tsx
Normal 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 }) =>
|
||||||
|
() =>
|
||||||
|
() => {},
|
||||||
|
}
|
4
framework/local/cart/index.ts
Normal file
4
framework/local/cart/index.ts
Normal 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'
|
17
framework/local/cart/use-add-item.tsx
Normal file
17
framework/local/cart/use-add-item.tsx
Normal 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 {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
42
framework/local/cart/use-cart.tsx
Normal file
42
framework/local/cart/use-cart.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
18
framework/local/cart/use-remove-item.tsx
Normal file
18
framework/local/cart/use-remove-item.tsx
Normal 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 {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
18
framework/local/cart/use-update-item.tsx
Normal file
18
framework/local/cart/use-update-item.tsx
Normal 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
Loading…
x
Reference in New Issue
Block a user