mirror of
https://github.com/vercel/commerce.git
synced 2025-05-24 10:26:59 +00:00
New Release (#371)
* Custom Checkout Progress * Updates to Checkout * Custom Checkout Progress * Adding tabs * Adding Collapse * Adding Collapse * Improving Sidebar Scroll * Modif footer * Changes * More design updates * sidebar cart * More design updates * More design updates * More design updates * More design updates * Types * Types * Design Updates * More changes * More changes * More changes * Changes * Changes * Changes * New tailwind required changes * Sidebar Styling issues with Mobile * Latest changes - Normalizing cart * Styling Fixes * New changes * Changes * latest * Refactor and Renaming some UI Props * Adding Quantity Component * Adding Rating Component * Rating Component * More updates * User Select disabled, plus hidding horizontal scroll bars * Changes * Adding ProductOptions Component and more helpers * Styling updates * Styling updates * Fix for slim tags * Missmatch with RightArrow * Footer updates and some styles * Latest Updates * Latest Updates * Latest Updates * Removing Portal, since it's not needed. We might add it later I'd rather not to. * Removing Portal, since it's not needed. We might add it later I'd rather not to. * Sam backdrop filter * General UI Improvements * General UI Improvements * Search now with Geist Colors * Now with Geist Colors * Changes * Scroll for Mobile on IOs devises * LoadingDots Working (: * Changes * More Changes * Perf changes * More perf changes * Fade to the Nametags in the ProductCard * changes * Search issue ui * Search issue ui * Make sure to only refresh navbar and modals when required * Index revalidate * Fixed image issue * hide album scroll on windows * Fix scrollbar * Changing * Adding 404 with Layout * Removing Toast * Adding Assets * Adding Assets * Progress with LocalProvider * New productTag * Only images for the drop * changes * Empty SWRhooks * Adding Local Provider * Working local * Working view of a LocalProvider * More updates * Changes * Removed react-ticker * default to local if no env available * default to local if no env available * add missing `@` to css import * rewrite search rewrites to multiple pages * allow requests in getStaticProps to execute in parallel * make type import explicit * add a tsconfig.js file * use local provider in tsconfig.js * avoid a circular dependency * Saleor was not in the providers list * avoid circular dependency in bigcommerce * Adding more to the Local Provider (#366) * Adding more data * Adding more data * optimize assets (#370) * Optimize assets (#372) * optimize assets * remove assets * remove assets * cart enabled * Adding saleor * Changes with Webpack * Changes Co-authored-by: Luis Alvarez <luis@vercel.com> Co-authored-by: Tobias Koppers <tobias.koppers@googlemail.com> Co-authored-by: Shu Ding <g@shud.in>
This commit is contained in:
parent
3c9b90f453
commit
78cc378a72
README.mdsearch.tsx
assets
components
auth
cart
checkout
CheckoutSidebarView
PaymentMethodView
PaymentWidget
ShippingView
ShippingWidget
common
Avatar
FeatureBar
Footer
HomeAllProductsGrid
I18nWidget
Layout
Navbar
Searchbar
SidebarLayout
UserNav
icons
product
ProductCard
ProductOptions
ProductSidebar
ProductSlider
ProductSliderControl
ProductTag
ProductView
Swatch
helpers.tsindex.tsui
Button
Collapse
Container
Grid
Hero
Input
LoadingDots
Marquee
Modal
Quantity
Rating
Sidebar
Skeleton
@ -64,6 +64,11 @@ That's it!
|
||||
|
||||
Every provider defines the features that it supports under `framework/{provider}/commerce.config.json`
|
||||
|
||||
#### Features Available
|
||||
|
||||
- wishlist
|
||||
- customCheckout
|
||||
|
||||
#### How to turn Features on and off
|
||||
|
||||
> NOTE: The selected provider should support the feature that you are toggling. (This means that you can't turn wishlist on if the provider doesn't support this functionality out the box)
|
||||
@ -73,7 +78,8 @@ Every provider defines the features that it supports under `framework/{provider}
|
||||
```json
|
||||
{
|
||||
"features": {
|
||||
"wishlist": false
|
||||
"wishlist": false,
|
||||
"customCheckout": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -15,21 +15,28 @@
|
||||
--cyan: #22b8cf;
|
||||
--green: #37b679;
|
||||
--red: #da3c3c;
|
||||
--pink: #e64980;
|
||||
--purple: #f81ce5;
|
||||
--blue: #0070f3;
|
||||
--violet: #5f3dc4;
|
||||
--violet-light: #7048e8;
|
||||
--accents-0: #f8f9fa;
|
||||
--accents-1: #f1f3f5;
|
||||
--accents-2: #e9ecef;
|
||||
--accents-3: #dee2e6;
|
||||
--accents-4: #ced4da;
|
||||
--accents-5: #adb5bd;
|
||||
--accents-6: #868e96;
|
||||
--accents-7: #495057;
|
||||
--accents-8: #343a40;
|
||||
--accents-9: #212529;
|
||||
|
||||
--pink: #ff0080;
|
||||
--pink-light: #ff379c;
|
||||
|
||||
--magenta: #eb367f;
|
||||
|
||||
--violet: #7928ca;
|
||||
--violet-dark: #4c2889;
|
||||
|
||||
--accent-0: #fff;
|
||||
--accent-1: #fafafa;
|
||||
--accent-2: #eaeaea;
|
||||
--accent-3: #999999;
|
||||
--accent-4: #888888;
|
||||
--accent-5: #666666;
|
||||
--accent-6: #444444;
|
||||
--accent-7: #333333;
|
||||
--accent-8: #111111;
|
||||
--accent-9: #000;
|
||||
|
||||
--font-sans: -apple-system, system-ui, BlinkMacSystemFont, 'Helvetica Neue',
|
||||
'Helvetica', sans-serif;
|
||||
}
|
||||
@ -48,16 +55,16 @@
|
||||
--text-primary: white;
|
||||
--text-secondary: black;
|
||||
|
||||
--accents-0: #212529;
|
||||
--accents-1: #343a40;
|
||||
--accents-2: #495057;
|
||||
--accents-3: #868e96;
|
||||
--accents-4: #adb5bd;
|
||||
--accents-5: #ced4da;
|
||||
--accents-6: #dee2e6;
|
||||
--accents-7: #e9ecef;
|
||||
--accents-8: #f1f3f5;
|
||||
--accents-9: #f8f9fa;
|
||||
--accent-9: #fff;
|
||||
--accent-8: #fafafa;
|
||||
--accent-7: #eaeaea;
|
||||
--accent-6: #999999;
|
||||
--accent-5: #888888;
|
||||
--accent-4: #666666;
|
||||
--accent-3: #444444;
|
||||
--accent-2: #333333;
|
||||
--accent-1: #111111;
|
||||
--accent-0: #000;
|
||||
}
|
||||
|
||||
*,
|
||||
@ -84,6 +91,7 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--primary);
|
||||
color: var(--text-primary);
|
||||
overscroll-behavior-x: none;
|
||||
}
|
||||
|
||||
body {
|
||||
|
@ -61,7 +61,7 @@ const ForgotPassword: FC<Props> = () => {
|
||||
</div>
|
||||
|
||||
<span className="pt-3 text-center text-sm">
|
||||
<span className="text-accents-7">Do you have an account?</span>
|
||||
<span className="text-accent-7">Do you have an account?</span>
|
||||
{` `}
|
||||
<a
|
||||
className="text-accent-9 font-bold hover:underline cursor-pointer"
|
||||
|
@ -87,7 +87,7 @@ const LoginView: FC<Props> = () => {
|
||||
Log In
|
||||
</Button>
|
||||
<div className="pt-1 text-center text-sm">
|
||||
<span className="text-accents-7">Don't have an account?</span>
|
||||
<span className="text-accent-7">Don't have an account?</span>
|
||||
{` `}
|
||||
<a
|
||||
className="text-accent-9 font-bold hover:underline cursor-pointer"
|
||||
|
@ -76,7 +76,7 @@ const SignUpView: FC<Props> = () => {
|
||||
<Input placeholder="Last Name" onChange={setLastName} />
|
||||
<Input type="email" placeholder="Email" onChange={setEmail} />
|
||||
<Input type="password" placeholder="Password" onChange={setPassword} />
|
||||
<span className="text-accents-8">
|
||||
<span className="text-accent-8">
|
||||
<span className="inline-block align-middle ">
|
||||
<Info width="15" height="15" />
|
||||
</span>{' '}
|
||||
@ -97,7 +97,7 @@ const SignUpView: FC<Props> = () => {
|
||||
</div>
|
||||
|
||||
<span className="pt-1 text-center text-sm">
|
||||
<span className="text-accents-7">Do you have an account?</span>
|
||||
<span className="text-accent-7">Do you have an account?</span>
|
||||
{` `}
|
||||
<a
|
||||
className="text-accent-9 font-bold hover:underline cursor-pointer"
|
||||
|
@ -1,6 +1,14 @@
|
||||
.root {
|
||||
@apply flex flex-col py-4;
|
||||
}
|
||||
|
||||
.root:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.quantity {
|
||||
appearance: textfield;
|
||||
@apply w-8 border-accents-2 border mx-3 rounded text-center text-sm text-black;
|
||||
@apply w-8 border-accent-2 border mx-3 rounded text-center text-sm text-black;
|
||||
}
|
||||
|
||||
.quantity::-webkit-outer-spin-button,
|
||||
@ -15,4 +23,10 @@
|
||||
height: 100%;
|
||||
left: 30% !important;
|
||||
top: 30% !important;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.productName {
|
||||
@apply font-medium cursor-pointer pb-1;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { ChangeEvent, useEffect, useState } from 'react'
|
||||
import { ChangeEvent, FocusEventHandler, useEffect, useState } from 'react'
|
||||
import cn from 'classnames'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import s from './CartItem.module.css'
|
||||
import { Trash, Plus, Minus } from '@components/icons'
|
||||
import { Trash, Plus, Minus, Cross } from '@components/icons'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import type { LineItem } from '@commerce/types/cart'
|
||||
import usePrice from '@framework/product/use-price'
|
||||
import useUpdateItem from '@framework/cart/use-update-item'
|
||||
import useRemoveItem from '@framework/cart/use-remove-item'
|
||||
import Quantity from '@components/ui/Quantity'
|
||||
|
||||
type ItemOption = {
|
||||
name: string
|
||||
@ -19,13 +20,19 @@ type ItemOption = {
|
||||
|
||||
const CartItem = ({
|
||||
item,
|
||||
variant = 'default',
|
||||
currencyCode,
|
||||
...rest
|
||||
}: {
|
||||
variant?: 'default' | 'display'
|
||||
item: LineItem
|
||||
currencyCode: string
|
||||
}) => {
|
||||
const { closeSidebarIfPresent } = useUI()
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [quantity, setQuantity] = useState<number>(item.quantity)
|
||||
const removeItem = useRemoveItem()
|
||||
const updateItem = useUpdateItem({ item })
|
||||
|
||||
const { price } = usePrice({
|
||||
amount: item.variant.price * item.quantity,
|
||||
@ -33,48 +40,28 @@ const CartItem = ({
|
||||
currencyCode,
|
||||
})
|
||||
|
||||
const updateItem = useUpdateItem({ item })
|
||||
const removeItem = useRemoveItem()
|
||||
const [quantity, setQuantity] = useState<number | ''>(item.quantity)
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const handleChange = async ({
|
||||
target: { value },
|
||||
}: ChangeEvent<HTMLInputElement>) => {
|
||||
setQuantity(Number(value))
|
||||
await updateItem({ quantity: Number(value) })
|
||||
}
|
||||
|
||||
const updateQuantity = async (val: number) => {
|
||||
const increaseQuantity = async (n = 1) => {
|
||||
const val = Number(quantity) + n
|
||||
setQuantity(val)
|
||||
await updateItem({ quantity: val })
|
||||
}
|
||||
|
||||
const handleQuantity = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const val = !e.target.value ? '' : Number(e.target.value)
|
||||
|
||||
if (!val || (Number.isInteger(val) && val >= 0)) {
|
||||
setQuantity(val)
|
||||
}
|
||||
}
|
||||
const handleBlur = () => {
|
||||
const val = Number(quantity)
|
||||
|
||||
if (val !== item.quantity) {
|
||||
updateQuantity(val)
|
||||
}
|
||||
}
|
||||
const increaseQuantity = (n = 1) => {
|
||||
const val = Number(quantity) + n
|
||||
|
||||
if (Number.isInteger(val) && val >= 0) {
|
||||
setQuantity(val)
|
||||
updateQuantity(val)
|
||||
}
|
||||
}
|
||||
const handleRemove = async () => {
|
||||
setRemoving(true)
|
||||
|
||||
try {
|
||||
// If this action succeeds then there's no need to do `setRemoving(true)`
|
||||
// because the component will be removed from the view
|
||||
await removeItem(item)
|
||||
} catch (error) {
|
||||
setRemoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add a type for this
|
||||
const options = (item as any).options
|
||||
|
||||
@ -87,12 +74,13 @@ const CartItem = ({
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn('flex flex-row space-x-8 py-8', {
|
||||
'opacity-75 pointer-events-none': removing,
|
||||
className={cn(s.root, {
|
||||
'opacity-50 pointer-events-none': removing,
|
||||
})}
|
||||
{...rest}
|
||||
>
|
||||
<div className="w-16 h-16 bg-violet relative overflow-hidden cursor-pointer">
|
||||
<div className="flex flex-row space-x-4 py-4">
|
||||
<div className="w-16 h-16 bg-violet relative overflow-hidden cursor-pointer z-0">
|
||||
<Link href={`/product/${item.path}`}>
|
||||
<Image
|
||||
onClick={() => closeSidebarIfPresent()}
|
||||
@ -108,58 +96,54 @@ const CartItem = ({
|
||||
<div className="flex-1 flex flex-col text-base">
|
||||
<Link href={`/product/${item.path}`}>
|
||||
<span
|
||||
className={s.productName}
|
||||
onClick={() => closeSidebarIfPresent()}
|
||||
>
|
||||
<div
|
||||
className="font-bold text-lg cursor-pointer leading-6"
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.variant ? <span> {item.variant.name}</span> : ""}
|
||||
</span>
|
||||
</Link>
|
||||
{options && options.length > 0 ? (
|
||||
<div className="">
|
||||
{options && options.length > 0 && (
|
||||
<div className="flex items-center pb-1">
|
||||
{options.map((option: ItemOption, i: number) => (
|
||||
<span
|
||||
<div
|
||||
key={`${item.id}-${option.name}`}
|
||||
className="text-sm font-semibold text-accents-7"
|
||||
className="text-sm font-semibold text-accent-7 inline-flex items-center justify-center"
|
||||
>
|
||||
{option.name}
|
||||
{option.name === 'Color' ? (
|
||||
<span
|
||||
className="mx-2 rounded-full bg-transparent border w-5 h-5 p-1 text-accent-9 inline-flex items-center justify-center overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: `${option.value}`,
|
||||
}}
|
||||
></span>
|
||||
) : (
|
||||
<span className="mx-2 rounded-full bg-transparent border h-5 p-1 text-accent-9 inline-flex items-center justify-center overflow-hidden">
|
||||
{option.value}
|
||||
{i === options.length - 1 ? '' : ', '}
|
||||
</span>
|
||||
)}
|
||||
{i === options.length - 1 ? '' : <span className="mr-3" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center mt-3">
|
||||
<button type="button" onClick={() => increaseQuantity(-1)}>
|
||||
<Minus width={18} height={18} />
|
||||
</button>
|
||||
<label>
|
||||
<input
|
||||
type="number"
|
||||
max={99}
|
||||
min={0}
|
||||
className={s.quantity}
|
||||
value={quantity}
|
||||
onChange={handleQuantity}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={() => increaseQuantity(1)}>
|
||||
<Plus width={18} height={18} />
|
||||
</button>
|
||||
)}
|
||||
{variant === 'display' && (
|
||||
<div className="text-sm tracking-wider">{quantity}x</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between space-y-2 text-base">
|
||||
<div className="flex flex-col justify-between space-y-2 text-sm">
|
||||
<span>{price}</span>
|
||||
<button
|
||||
className="flex justify-end outline-none"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{variant === 'default' && (
|
||||
<Quantity
|
||||
value={quantity}
|
||||
handleRemove={handleRemove}
|
||||
handleChange={handleChange}
|
||||
increase={() => increaseQuantity(1)}
|
||||
decrease={() => increaseQuantity(-1)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
@ -1,15 +1,11 @@
|
||||
.root {
|
||||
@apply h-full flex flex-col;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.root.empty {
|
||||
@apply bg-secondary text-secondary;
|
||||
}
|
||||
|
||||
.root.success {
|
||||
@apply bg-green text-white;
|
||||
}
|
||||
|
||||
.root.error {
|
||||
@apply bg-red text-white;
|
||||
.lineItemsList {
|
||||
@apply py-4 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accent-2 border-accent-2;
|
||||
}
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { FC } from 'react'
|
||||
import cn from 'classnames'
|
||||
import Link from 'next/link'
|
||||
import CartItem from '../CartItem'
|
||||
import { FC } from 'react'
|
||||
import s from './CartSidebarView.module.css'
|
||||
import { Button } from '@components/ui'
|
||||
import { UserNav } from '@components/common'
|
||||
import CartItem from '../CartItem'
|
||||
import { Button, Text } from '@components/ui'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import { Bag, Cross, Check } from '@components/icons'
|
||||
import useCart from '@framework/cart/use-cart'
|
||||
import usePrice from '@framework/product/use-price'
|
||||
import SidebarLayout from '@components/common/SidebarLayout'
|
||||
|
||||
const CartSidebarView: FC = () => {
|
||||
const { closeSidebar } = useUI()
|
||||
const { closeSidebar, setSidebarView } = useUI()
|
||||
const { data, isLoading, isEmpty } = useCart()
|
||||
|
||||
const { price: subTotal } = usePrice(
|
||||
@ -27,33 +27,18 @@ const CartSidebarView: FC = () => {
|
||||
}
|
||||
)
|
||||
const handleClose = () => closeSidebar()
|
||||
const goToCheckout = () => setSidebarView('CHECKOUT_VIEW')
|
||||
|
||||
const error = null
|
||||
const success = null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(s.root, {
|
||||
<SidebarLayout
|
||||
className={cn({
|
||||
[s.empty]: error || success || isLoading || isEmpty,
|
||||
})}
|
||||
handleClose={handleClose}
|
||||
>
|
||||
<header className="px-4 pt-6 pb-4 sm:px-6">
|
||||
<div className="flex items-start justify-between space-x-3">
|
||||
<div className="h-7 flex items-center">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
aria-label="Close panel"
|
||||
className="hover:text-gray-500 transition ease-in-out duration-150"
|
||||
>
|
||||
<Cross className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<UserNav />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLoading || isEmpty ? (
|
||||
<div className="flex-1 px-4 flex flex-col justify-center items-center">
|
||||
<span className="border border-dashed border-primary rounded-full flex items-center justify-center w-16 h-16 p-12 bg-secondary text-secondary">
|
||||
@ -62,7 +47,7 @@ const CartSidebarView: FC = () => {
|
||||
<h2 className="pt-6 text-2xl font-bold tracking-wide text-center">
|
||||
Your cart is empty
|
||||
</h2>
|
||||
<p className="text-accents-3 px-10 text-center pt-2">
|
||||
<p className="text-accent-3 px-10 text-center pt-2">
|
||||
Biscuit oat cake wafer icing ice cream tiramisu pudding cupcake.
|
||||
</p>
|
||||
</div>
|
||||
@ -89,14 +74,11 @@ const CartSidebarView: FC = () => {
|
||||
<>
|
||||
<div className="px-4 sm:px-6 flex-1">
|
||||
<Link href="/cart">
|
||||
<h2
|
||||
className="pt-1 pb-4 text-2xl leading-7 font-bold text-base tracking-wide cursor-pointer inline-block"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<Text variant="sectionHeading" onClick={handleClose}>
|
||||
My Cart
|
||||
</h2>
|
||||
</Text>
|
||||
</Link>
|
||||
<ul className="py-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accents-3 border-t border-accents-3">
|
||||
<ul className={s.lineItemsList}>
|
||||
{data!.lineItems.map((item: any) => (
|
||||
<CartItem
|
||||
key={item.id}
|
||||
@ -107,9 +89,8 @@ const CartSidebarView: FC = () => {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 px-4 py-5 sm:px-6">
|
||||
<div className="border-t border-accents-3">
|
||||
<ul className="py-3">
|
||||
<div className="flex-shrink-0 px-6 py-6 sm:px-6 sticky z-20 bottom-0 w-full right-0 left-0 bg-accent-0 border-t text-sm">
|
||||
<ul className="pb-2">
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Subtotal</span>
|
||||
<span>{subTotal}</span>
|
||||
@ -119,22 +100,29 @@ const CartSidebarView: FC = () => {
|
||||
<span>Calculated at checkout</span>
|
||||
</li>
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Estimated Shipping</span>
|
||||
<span>Shipping</span>
|
||||
<span className="font-bold tracking-wide">FREE</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="flex justify-between border-t border-accents-3 py-3 font-bold mb-10">
|
||||
<div className="flex justify-between border-t border-accent-2 py-3 font-bold mb-2">
|
||||
<span>Total</span>
|
||||
<span>{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{process.env.COMMERCE_CUSTOMCHECKOUT_ENABLED ? (
|
||||
<Button Component="a" width="100%" onClick={goToCheckout}>
|
||||
Proceed to Checkout ({total})
|
||||
</Button>
|
||||
) : (
|
||||
<Button href="/checkout" Component="a" width="100%">
|
||||
Proceed to Checkout
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,7 @@
|
||||
.root {
|
||||
min-height: calc(100vh - 322px);
|
||||
}
|
||||
|
||||
.lineItemsList {
|
||||
@apply py-4 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accent-2 border-accent-2;
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
import cn from 'classnames'
|
||||
import Link from 'next/link'
|
||||
import { FC } from 'react'
|
||||
import CartItem from '@components/cart/CartItem'
|
||||
import { Button, Text } from '@components/ui'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import useCart from '@framework/cart/use-cart'
|
||||
import usePrice from '@framework/product/use-price'
|
||||
import ShippingWidget from '../ShippingWidget'
|
||||
import PaymentWidget from '../PaymentWidget'
|
||||
import SidebarLayout from '@components/common/SidebarLayout'
|
||||
import s from './CheckoutSidebarView.module.css'
|
||||
|
||||
const CheckoutSidebarView: FC = () => {
|
||||
const { setSidebarView } = useUI()
|
||||
const { data } = useCart()
|
||||
|
||||
const { price: subTotal } = usePrice(
|
||||
data && {
|
||||
amount: Number(data.subtotalPrice),
|
||||
currencyCode: data.currency.code,
|
||||
}
|
||||
)
|
||||
const { price: total } = usePrice(
|
||||
data && {
|
||||
amount: Number(data.totalPrice),
|
||||
currencyCode: data.currency.code,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarLayout
|
||||
className={s.root}
|
||||
handleBack={() => setSidebarView('CART_VIEW')}
|
||||
>
|
||||
<div className="px-4 sm:px-6 flex-1">
|
||||
<Link href="/cart">
|
||||
<Text variant="sectionHeading">Checkout</Text>
|
||||
</Link>
|
||||
|
||||
<PaymentWidget onClick={() => setSidebarView('PAYMENT_VIEW')} />
|
||||
<ShippingWidget onClick={() => setSidebarView('SHIPPING_VIEW')} />
|
||||
|
||||
<ul className={s.lineItemsList}>
|
||||
{data!.lineItems.map((item: any) => (
|
||||
<CartItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
currencyCode={data!.currency.code}
|
||||
variant="display"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 px-6 py-6 sm:px-6 sticky z-20 bottom-0 w-full right-0 left-0 bg-accent-0 border-t text-sm">
|
||||
<ul className="pb-2">
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Subtotal</span>
|
||||
<span>{subTotal}</span>
|
||||
</li>
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Taxes</span>
|
||||
<span>Calculated at checkout</span>
|
||||
</li>
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Shipping</span>
|
||||
<span className="font-bold tracking-wide">FREE</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="flex justify-between border-t border-accent-2 py-3 font-bold mb-2">
|
||||
<span>Total</span>
|
||||
<span>{total}</span>
|
||||
</div>
|
||||
<div>
|
||||
{/* Once data is correcly filled */}
|
||||
{/* <Button Component="a" width="100%">
|
||||
Confirm Purchase
|
||||
</Button> */}
|
||||
<Button Component="a" width="100%" variant="ghost" disabled>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default CheckoutSidebarView
|
1
components/checkout/CheckoutSidebarView/index.ts
Normal file
1
components/checkout/CheckoutSidebarView/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './CheckoutSidebarView'
|
@ -0,0 +1,17 @@
|
||||
.fieldset {
|
||||
@apply flex flex-col my-3;
|
||||
}
|
||||
|
||||
.fieldset .label {
|
||||
@apply text-accent-7 uppercase text-xs font-medium mb-2;
|
||||
}
|
||||
|
||||
.fieldset .input,
|
||||
.fieldset .select {
|
||||
@apply p-2 border border-accent-2 w-full text-sm font-normal;
|
||||
}
|
||||
|
||||
.fieldset .input:focus,
|
||||
.fieldset .select:focus {
|
||||
@apply outline-none shadow-outline-normal;
|
||||
}
|
84
components/checkout/PaymentMethodView/PaymentMethodView.tsx
Normal file
84
components/checkout/PaymentMethodView/PaymentMethodView.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { FC } from 'react'
|
||||
import cn from 'classnames'
|
||||
import { Button, Text } from '@components/ui'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import s from './PaymentMethodView.module.css'
|
||||
import SidebarLayout from '@components/common/SidebarLayout'
|
||||
|
||||
const PaymentMethodView: FC = () => {
|
||||
const { setSidebarView } = useUI()
|
||||
|
||||
return (
|
||||
<SidebarLayout handleBack={() => setSidebarView('CHECKOUT_VIEW')}>
|
||||
<div className="px-4 sm:px-6 flex-1">
|
||||
<Text variant="sectionHeading"> Payment Method</Text>
|
||||
<div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Cardholder Name</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className="grid gap-3 grid-flow-row grid-cols-12">
|
||||
<div className={cn(s.fieldset, 'col-span-7')}>
|
||||
<label className={s.label}>Card Number</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={cn(s.fieldset, 'col-span-3')}>
|
||||
<label className={s.label}>Expires</label>
|
||||
<input className={s.input} placeholder="MM/YY" />
|
||||
</div>
|
||||
<div className={cn(s.fieldset, 'col-span-2')}>
|
||||
<label className={s.label}>CVC</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-accent-2 my-6" />
|
||||
<div className="grid gap-3 grid-flow-row grid-cols-12">
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>First Name</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>Last Name</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Company (Optional)</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Street and House Number</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Apartment, Suite, Etc. (Optional)</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className="grid gap-3 grid-flow-row grid-cols-12">
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>Postal Code</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>City</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Country/Region</label>
|
||||
<select className={s.select}>
|
||||
<option>Hong Kong</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky z-20 bottom-0 w-full right-0 left-0 py-12 bg-accent-0 border-t border-accent-2 px-6">
|
||||
<Button Component="a" width="100%" variant="ghost">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentMethodView
|
1
components/checkout/PaymentMethodView/index.ts
Normal file
1
components/checkout/PaymentMethodView/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './PaymentMethodView'
|
@ -0,0 +1,4 @@
|
||||
.root {
|
||||
@apply border border-accent-2 px-6 py-5 mb-4 text-center
|
||||
flex items-center cursor-pointer hover:border-accent-4;
|
||||
}
|
29
components/checkout/PaymentWidget/PaymentWidget.tsx
Normal file
29
components/checkout/PaymentWidget/PaymentWidget.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { FC } from 'react'
|
||||
import s from './PaymentWidget.module.css'
|
||||
import { ChevronRight, CreditCard } from '@components/icons'
|
||||
|
||||
interface ComponentProps {
|
||||
onClick?: () => any
|
||||
}
|
||||
|
||||
const PaymentWidget: FC<ComponentProps> = ({ onClick }) => {
|
||||
/* Shipping Address
|
||||
Only available with checkout set to true -
|
||||
This means that the provider does offer checkout functionality. */
|
||||
return (
|
||||
<div onClick={onClick} className={s.root}>
|
||||
<div className="flex flex-1 items-center">
|
||||
<CreditCard className="w-5 flex" />
|
||||
<span className="ml-5 text-sm text-center font-medium">
|
||||
Add Payment Method
|
||||
</span>
|
||||
{/* <span>VISA #### #### #### 2345</span> */}
|
||||
</div>
|
||||
<div>
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentWidget
|
1
components/checkout/PaymentWidget/index.ts
Normal file
1
components/checkout/PaymentWidget/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './PaymentWidget'
|
21
components/checkout/ShippingView/ShippingView.module.css
Normal file
21
components/checkout/ShippingView/ShippingView.module.css
Normal file
@ -0,0 +1,21 @@
|
||||
.fieldset {
|
||||
@apply flex flex-col my-3;
|
||||
}
|
||||
|
||||
.fieldset .label {
|
||||
@apply text-accent-7 uppercase text-xs font-medium mb-2;
|
||||
}
|
||||
|
||||
.fieldset .input,
|
||||
.fieldset .select {
|
||||
@apply p-2 border border-accent-2 w-full text-sm font-normal;
|
||||
}
|
||||
|
||||
.fieldset .input:focus,
|
||||
.fieldset .select:focus {
|
||||
@apply outline-none shadow-outline-normal;
|
||||
}
|
||||
|
||||
.radio {
|
||||
@apply bg-black;
|
||||
}
|
78
components/checkout/ShippingView/ShippingView.tsx
Normal file
78
components/checkout/ShippingView/ShippingView.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { FC } from 'react'
|
||||
import cn from 'classnames'
|
||||
import s from './ShippingView.module.css'
|
||||
import Button from '@components/ui/Button'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import SidebarLayout from '@components/common/SidebarLayout'
|
||||
|
||||
const PaymentMethodView: FC = () => {
|
||||
const { setSidebarView } = useUI()
|
||||
|
||||
return (
|
||||
<SidebarLayout handleBack={() => setSidebarView('CHECKOUT_VIEW')}>
|
||||
<div className="px-4 sm:px-6 flex-1">
|
||||
<h2 className="pt-1 pb-8 text-2xl font-semibold tracking-wide cursor-pointer inline-block">
|
||||
Shipping
|
||||
</h2>
|
||||
<div>
|
||||
<div className="flex flex-row my-3 items-center">
|
||||
<input className={s.radio} type="radio" />
|
||||
<span className="ml-3 text-sm">Same as billing address</span>
|
||||
</div>
|
||||
<div className="flex flex-row my-3 items-center">
|
||||
<input className={s.radio} type="radio" />
|
||||
<span className="ml-3 text-sm">
|
||||
Use a different shipping address
|
||||
</span>
|
||||
</div>
|
||||
<hr className="border-accent-2 my-6" />
|
||||
<div className="grid gap-3 grid-flow-row grid-cols-12">
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>First Name</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>Last Name</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Company (Optional)</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Street and House Number</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Apartment, Suite, Etc. (Optional)</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className="grid gap-3 grid-flow-row grid-cols-12">
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>Postal Code</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>City</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Country/Region</label>
|
||||
<select className={s.select}>
|
||||
<option>Hong Kong</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky z-20 bottom-0 w-full right-0 left-0 py-12 bg-accent-0 border-t border-accent-2 px-6">
|
||||
<Button Component="a" width="100%" variant="ghost">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentMethodView
|
1
components/checkout/ShippingView/index.ts
Normal file
1
components/checkout/ShippingView/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ShippingView'
|
@ -0,0 +1,4 @@
|
||||
.root {
|
||||
@apply border border-accent-2 px-6 py-5 mb-4 text-center
|
||||
flex items-center cursor-pointer hover:border-accent-4;
|
||||
}
|
33
components/checkout/ShippingWidget/ShippingWidget.tsx
Normal file
33
components/checkout/ShippingWidget/ShippingWidget.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { FC } from 'react'
|
||||
import s from './ShippingWidget.module.css'
|
||||
import { ChevronRight, MapPin } from '@components/icons'
|
||||
import cn from 'classnames'
|
||||
|
||||
interface ComponentProps {
|
||||
onClick?: () => any
|
||||
}
|
||||
|
||||
const ShippingWidget: FC<ComponentProps> = ({ onClick }) => {
|
||||
/* Shipping Address
|
||||
Only available with checkout set to true -
|
||||
This means that the provider does offer checkout functionality. */
|
||||
return (
|
||||
<div onClick={onClick} className={s.root}>
|
||||
<div className="flex flex-1 items-center">
|
||||
<MapPin className="w-5 flex" />
|
||||
<span className="ml-5 text-sm text-center font-medium">
|
||||
Add Shipping Address
|
||||
</span>
|
||||
{/* <span>
|
||||
1046 Kearny Street.<br/>
|
||||
San Franssisco, California
|
||||
</span> */}
|
||||
</div>
|
||||
<div>
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShippingWidget
|
1
components/checkout/ShippingWidget/index.ts
Normal file
1
components/checkout/ShippingWidget/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ShippingWidget'
|
@ -14,7 +14,7 @@ const Avatar: FC<Props> = ({}) => {
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ backgroundImage: userAvatar }}
|
||||
className="inline-block h-8 w-8 rounded-full border-2 border-primary hover:border-secondary focus:border-secondary transition linear-out duration-150"
|
||||
className="inline-block h-8 w-8 rounded-full border-2 border-primary hover:border-secondary focus:border-secondary transition-colors ease-linear"
|
||||
>
|
||||
{/* Add an image - We're generating a gradient as placeholder <img></img> */}
|
||||
</div>
|
||||
|
@ -1,9 +1,7 @@
|
||||
.root {
|
||||
@apply text-center p-6 bg-primary text-sm flex-row justify-center items-center font-medium fixed bottom-0 w-full z-30 transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
@screen md {
|
||||
.root {
|
||||
@apply flex text-left;
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,7 @@
|
||||
.root {
|
||||
@apply border-t border-accent-2;
|
||||
}
|
||||
|
||||
.link {
|
||||
& > svg {
|
||||
@apply transform duration-75 ease-linear;
|
||||
|
@ -15,65 +15,50 @@ interface Props {
|
||||
pages?: Page[]
|
||||
}
|
||||
|
||||
const LEGAL_PAGES = ['terms-of-use', 'shipping-returns', 'privacy-policy']
|
||||
const links = [
|
||||
{
|
||||
name: 'Home',
|
||||
url: '/',
|
||||
},
|
||||
]
|
||||
|
||||
const Footer: FC<Props> = ({ className, pages }) => {
|
||||
const { sitePages, legalPages } = usePages(pages)
|
||||
const rootClassName = cn(className)
|
||||
const { sitePages } = usePages(pages)
|
||||
const rootClassName = cn(s.root, className)
|
||||
|
||||
return (
|
||||
<footer className={rootClassName}>
|
||||
<Container>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 border-b border-accents-2 py-12 text-primary bg-primary transition-colors duration-150">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 border-b border-accent-2 py-12 text-primary bg-primary transition-colors duration-150">
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<Link href="/">
|
||||
<a className="flex flex-initial items-center font-bold md:mr-24">
|
||||
<span className="rounded-full border border-gray-700 mr-2">
|
||||
<span className="rounded-full border border-accent-6 mr-2">
|
||||
<Logo />
|
||||
</span>
|
||||
<span>ACME</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<ul className="flex flex-initial flex-col md:flex-1">
|
||||
<li className="py-3 md:py-0 md:pb-4">
|
||||
<Link href="/">
|
||||
<a className="text-primary hover:text-accents-6 transition ease-in-out duration-150">
|
||||
Home
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
{sitePages.map((page) => (
|
||||
<li key={page.url} className="py-3 md:py-0 md:pb-4">
|
||||
<div className="col-span-1 lg:col-span-8">
|
||||
<div className="grid md:grid-rows-4 md:grid-cols-3 md:grid-flow-col">
|
||||
{[...links, ...sitePages].map((page) => (
|
||||
<span key={page.url} className="py-3 md:py-0 md:pb-4">
|
||||
<Link href={page.url!}>
|
||||
<a className="text-primary hover:text-accents-6 transition ease-in-out duration-150">
|
||||
<a className="text-accent-9 hover:text-accent-6 transition ease-in-out duration-150">
|
||||
{page.name}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</span>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<ul className="flex flex-initial flex-col md:flex-1">
|
||||
{legalPages.map((page) => (
|
||||
<li key={page.url} className="py-3 md:py-0 md:pb-4">
|
||||
<Link href={page.url!}>
|
||||
<a className="text-primary hover:text-accents-6 transition ease-in-out duration-150">
|
||||
{page.name}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="col-span-1 lg:col-span-6 flex items-start lg:justify-end text-primary">
|
||||
<div className="col-span-1 lg:col-span-2 flex items-start lg:justify-end text-primary">
|
||||
<div className="flex space-x-6 items-center h-10">
|
||||
<a
|
||||
className={s.link}
|
||||
aria-label="Github Repository"
|
||||
href="https://github.com/vercel/commerce"
|
||||
className={s.link}
|
||||
>
|
||||
<Github />
|
||||
</a>
|
||||
@ -81,12 +66,12 @@ const Footer: FC<Props> = ({ className, pages }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-12 flex flex-col md:flex-row justify-between items-center space-y-4">
|
||||
<div className="pt-6 pb-10 flex flex-col md:flex-row justify-between items-center space-y-4 text-accent-6 text-sm">
|
||||
<div>
|
||||
<span>© 2020 ACME, Inc. All rights reserved.</span>
|
||||
</div>
|
||||
<div className="flex items-center text-primary">
|
||||
<span className="text-primary">Crafted by</span>
|
||||
<div className="flex items-center text-primary text-sm">
|
||||
<span className="text-primary">Created by</span>
|
||||
<a
|
||||
rel="noopener"
|
||||
href="https://vercel.com"
|
||||
@ -95,7 +80,7 @@ const Footer: FC<Props> = ({ className, pages }) => {
|
||||
className="text-primary"
|
||||
>
|
||||
<Vercel
|
||||
className="inline-block h-6 ml-4 text-primary"
|
||||
className="inline-block h-6 ml-3 text-primary"
|
||||
alt="Vercel.com Logo"
|
||||
/>
|
||||
</a>
|
||||
@ -109,34 +94,21 @@ const Footer: FC<Props> = ({ className, pages }) => {
|
||||
function usePages(pages?: Page[]) {
|
||||
const { locale } = useRouter()
|
||||
const sitePages: Page[] = []
|
||||
const legalPages: Page[] = []
|
||||
|
||||
if (pages) {
|
||||
pages.forEach((page) => {
|
||||
const slug = page.url && getSlug(page.url)
|
||||
|
||||
if (!slug) return
|
||||
if (locale && !slug.startsWith(`${locale}/`)) return
|
||||
|
||||
if (isLegalPage(slug, locale)) {
|
||||
legalPages.push(page)
|
||||
} else {
|
||||
sitePages.push(page)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
sitePages: sitePages.sort(bySortOrder),
|
||||
legalPages: legalPages.sort(bySortOrder),
|
||||
}
|
||||
}
|
||||
|
||||
const isLegalPage = (slug: string, locale?: string) =>
|
||||
locale
|
||||
? LEGAL_PAGES.some((p) => `${locale}/${p}` === slug)
|
||||
: LEGAL_PAGES.includes(slug)
|
||||
|
||||
// Sort pages by the sort order assigned in the BC dashboard
|
||||
function bySortOrder(a: Page, b: Page) {
|
||||
return (a.sort_order ?? 0) - (b.sort_order ?? 0)
|
||||
|
@ -28,7 +28,7 @@ const HomeAllProductsGrid: FC<Props> = ({
|
||||
</Link>
|
||||
</li>
|
||||
{categories.map((cat: any) => (
|
||||
<li key={cat.path} className="py-1 text-accents-8 text-base">
|
||||
<li key={cat.path} className="py-1 text-accent-8 text-base">
|
||||
<Link href={getCategoryPath(cat.path)}>
|
||||
<a>{cat.name}</a>
|
||||
</Link>
|
||||
@ -42,7 +42,7 @@ const HomeAllProductsGrid: FC<Props> = ({
|
||||
</Link>
|
||||
</li>
|
||||
{brands.flatMap(({ node }: any) => (
|
||||
<li key={node.path} className="py-1 text-accents-8 text-base">
|
||||
<li key={node.path} className="py-1 text-accent-8 text-base">
|
||||
<Link href={getDesignerPath(node.path)}>
|
||||
<a>{node.name}</a>
|
||||
</Link>
|
||||
|
@ -3,11 +3,11 @@
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply h-10 px-2 rounded-md border border-accents-2 flex items-center justify-center;
|
||||
@apply h-10 px-2 rounded-md border border-accent-2 flex items-center justify-center transition-colors ease-linear;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
@apply border-accents-4 shadow-sm;
|
||||
@apply border-accent-3 shadow-sm;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
@ -16,16 +16,14 @@
|
||||
|
||||
.dropdownMenu {
|
||||
@apply fixed right-0 top-12 mt-2 origin-top-right outline-none bg-primary z-40 w-full h-full;
|
||||
}
|
||||
|
||||
@screen lg {
|
||||
.dropdownMenu {
|
||||
@apply absolute border border-accents-1 shadow-lg w-56 h-auto;
|
||||
@apply absolute border border-accent-1 shadow-lg w-56 h-auto;
|
||||
}
|
||||
}
|
||||
|
||||
@screen md {
|
||||
.closeButton {
|
||||
@screen md {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
@ -36,9 +34,13 @@
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
@apply bg-accents-1;
|
||||
@apply bg-accent-1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.icon.active {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
@ -59,7 +59,7 @@ const I18nWidget: FC = () => {
|
||||
/>
|
||||
{options && (
|
||||
<span className="cursor-pointer">
|
||||
<ChevronUp className={cn({ [s.icon]: display })} />
|
||||
<ChevronUp className={cn(s.icon, { [s.active]: display })} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
@ -1,15 +1,19 @@
|
||||
import React, { FC } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import dynamic from 'next/dynamic'
|
||||
import cn from 'classnames'
|
||||
import type { Page } from '@commerce/types/page'
|
||||
import type { Category } from '@commerce/types/site'
|
||||
import React, { FC } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/router'
|
||||
import { CommerceProvider } from '@framework'
|
||||
import { useAcceptCookies } from '@lib/hooks/useAcceptCookies'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import type { Page } from '@commerce/types/page'
|
||||
import { Navbar, Footer } from '@components/common'
|
||||
import { Sidebar, Button, Modal, LoadingDots } from '@components/ui'
|
||||
import type { Category } from '@commerce/types/site'
|
||||
import ShippingView from '@components/checkout/ShippingView'
|
||||
import CartSidebarView from '@components/cart/CartSidebarView'
|
||||
import { useAcceptCookies } from '@lib/hooks/useAcceptCookies'
|
||||
import { Sidebar, Button, Modal, LoadingDots } from '@components/ui'
|
||||
import PaymentMethodView from '@components/checkout/PaymentMethodView'
|
||||
import CheckoutSidebarView from '@components/checkout/CheckoutSidebarView'
|
||||
|
||||
import LoginView from '@components/auth/LoginView'
|
||||
import s from './Layout.module.css'
|
||||
|
||||
@ -45,15 +49,53 @@ interface Props {
|
||||
}
|
||||
}
|
||||
|
||||
const ModalView: FC<{ modalView: string; closeModal(): any }> = ({
|
||||
modalView,
|
||||
closeModal,
|
||||
}) => {
|
||||
return (
|
||||
<Modal onClose={closeModal}>
|
||||
{modalView === 'LOGIN_VIEW' && <LoginView />}
|
||||
{modalView === 'SIGNUP_VIEW' && <SignUpView />}
|
||||
{modalView === 'FORGOT_VIEW' && <ForgotPassword />}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const ModalUI: FC = () => {
|
||||
const { displayModal, closeModal, modalView } = useUI()
|
||||
return displayModal ? (
|
||||
<ModalView modalView={modalView} closeModal={closeModal} />
|
||||
) : null
|
||||
}
|
||||
|
||||
const SidebarView: FC<{ sidebarView: string; closeSidebar(): any }> = ({
|
||||
sidebarView,
|
||||
closeSidebar,
|
||||
}) => {
|
||||
return (
|
||||
<Sidebar onClose={closeSidebar}>
|
||||
{sidebarView === 'CART_VIEW' && <CartSidebarView />}
|
||||
{sidebarView === 'CHECKOUT_VIEW' && <CheckoutSidebarView />}
|
||||
{sidebarView === 'PAYMENT_VIEW' && <PaymentMethodView />}
|
||||
{sidebarView === 'SHIPPING_VIEW' && <ShippingView />}
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarUI: FC = () => {
|
||||
const { displaySidebar, closeSidebar, sidebarView } = useUI()
|
||||
return displaySidebar ? (
|
||||
<SidebarView sidebarView={sidebarView} closeSidebar={closeSidebar} />
|
||||
) : null
|
||||
}
|
||||
|
||||
const Layout: FC<Props> = ({
|
||||
children,
|
||||
pageProps: { categories = [], ...pageProps },
|
||||
}) => {
|
||||
const { displaySidebar, displayModal, closeSidebar, closeModal, modalView } =
|
||||
useUI()
|
||||
const { acceptedCookies, onAcceptCookies } = useAcceptCookies()
|
||||
const { locale = 'en-US' } = useRouter()
|
||||
|
||||
const navBarlinks = categories.slice(0, 2).map((c) => ({
|
||||
label: c.name,
|
||||
href: `/search/${c.slug}`,
|
||||
@ -65,17 +107,8 @@ const Layout: FC<Props> = ({
|
||||
<Navbar links={navBarlinks} />
|
||||
<main className="fit">{children}</main>
|
||||
<Footer pages={pageProps.pages} />
|
||||
|
||||
<Modal open={displayModal} onClose={closeModal}>
|
||||
{modalView === 'LOGIN_VIEW' && <LoginView />}
|
||||
{modalView === 'SIGNUP_VIEW' && <SignUpView />}
|
||||
{modalView === 'FORGOT_VIEW' && <ForgotPassword />}
|
||||
</Modal>
|
||||
|
||||
<Sidebar open={displaySidebar} onClose={closeSidebar}>
|
||||
<CartSidebarView />
|
||||
</Sidebar>
|
||||
|
||||
<ModalUI />
|
||||
<SidebarUI />
|
||||
<FeatureBar
|
||||
title="This site uses cookies to improve your experience. By clicking, you agree to our Privacy Policy."
|
||||
hide={acceptedCookies}
|
||||
|
@ -2,16 +2,26 @@
|
||||
@apply sticky top-0 bg-primary z-40 transition-all duration-150;
|
||||
}
|
||||
|
||||
.nav {
|
||||
@apply relative flex flex-row justify-between py-4 md:py-4;
|
||||
}
|
||||
|
||||
.navMenu {
|
||||
@apply hidden ml-6 space-x-4 lg:block;
|
||||
}
|
||||
|
||||
.link {
|
||||
@apply inline-flex items-center text-primary leading-6 font-medium transition ease-in-out duration-75 cursor-pointer text-accents-6;
|
||||
@apply inline-flex items-center leading-6
|
||||
transition ease-in-out duration-75 cursor-pointer
|
||||
text-accent-5;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
@apply text-accents-9;
|
||||
@apply text-accent-9;
|
||||
}
|
||||
|
||||
.link:focus {
|
||||
@apply outline-none text-accents-8;
|
||||
@apply outline-none text-accent-8;
|
||||
}
|
||||
|
||||
.logo {
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { FC } from 'react'
|
||||
import Link from 'next/link'
|
||||
import s from './Navbar.module.css'
|
||||
import NavbarRoot from './NavbarRoot'
|
||||
import { Logo, Container } from '@components/ui'
|
||||
import { Searchbar, UserNav } from '@components/common'
|
||||
import NavbarRoot from './NavbarRoot'
|
||||
import s from './Navbar.module.css'
|
||||
|
||||
interface Link {
|
||||
href: string
|
||||
@ -16,14 +16,14 @@ interface NavbarProps {
|
||||
const Navbar: FC<NavbarProps> = ({ links }) => (
|
||||
<NavbarRoot>
|
||||
<Container>
|
||||
<div className="relative flex flex-row justify-between py-4 align-center md:py-6">
|
||||
<div className={s.nav}>
|
||||
<div className="flex items-center flex-1">
|
||||
<Link href="/">
|
||||
<a className={s.logo} aria-label="Logo">
|
||||
<Logo />
|
||||
</a>
|
||||
</Link>
|
||||
<nav className="hidden ml-6 space-x-4 lg:block">
|
||||
<nav className={s.navMenu}>
|
||||
<Link href="/search">
|
||||
<a className={s.link}>All</a>
|
||||
</Link>
|
||||
@ -34,16 +34,13 @@ const Navbar: FC<NavbarProps> = ({ links }) => (
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="justify-center flex-1 hidden lg:flex">
|
||||
<Searchbar />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end flex-1 space-x-8">
|
||||
<div className="flex items-center justify-end flex-1 space-x-8">
|
||||
<UserNav />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex pb-4 lg:px-6 lg:hidden">
|
||||
<Searchbar id="mobile-search" />
|
||||
</div>
|
||||
|
@ -1,9 +1,13 @@
|
||||
.root {
|
||||
@apply relative text-sm bg-accent-0 text-base w-full transition-colors duration-150 border border-accent-2;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply bg-transparent px-3 py-2 appearance-none w-full transition duration-150 ease-in-out pr-10;
|
||||
|
||||
@screen sm {
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
@apply text-accent-3;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
@ -17,3 +21,9 @@
|
||||
.icon {
|
||||
@apply h-5 w-5;
|
||||
}
|
||||
|
||||
@screen sm {
|
||||
.input {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { FC, useEffect, useMemo } from 'react'
|
||||
import { FC, InputHTMLAttributes, useEffect, useMemo } from 'react'
|
||||
import cn from 'classnames'
|
||||
import s from './Searchbar.module.css'
|
||||
import { useRouter } from 'next/router'
|
||||
@ -15,23 +15,7 @@ const Searchbar: FC<Props> = ({ className, id = 'search' }) => {
|
||||
router.prefetch('/search')
|
||||
}, [])
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<div
|
||||
className={cn(
|
||||
'relative text-sm bg-accents-1 text-base w-full transition-colors duration-150',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<label className="hidden" htmlFor={id}>
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
className={s.input}
|
||||
placeholder="Search for products..."
|
||||
defaultValue={router.query.q}
|
||||
onKeyUp={(e) => {
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
@ -46,7 +30,20 @@ const Searchbar: FC<Props> = ({ className, id = 'search' }) => {
|
||||
{ shallow: true }
|
||||
)
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<div className={cn(s.root, className)}>
|
||||
<label className="hidden" htmlFor={id}>
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
className={s.input}
|
||||
placeholder="Search for products..."
|
||||
defaultValue={router.query.q}
|
||||
onKeyUp={handleKeyUp}
|
||||
/>
|
||||
<div className={s.iconContainer}>
|
||||
<svg className={s.icon} fill="currentColor" viewBox="0 0 20 20">
|
||||
|
20
components/common/SidebarLayout/SidebarLayout.module.css
Normal file
20
components/common/SidebarLayout/SidebarLayout.module.css
Normal file
@ -0,0 +1,20 @@
|
||||
.root {
|
||||
@apply relative h-full flex flex-col;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply sticky top-0 pl-4 py-4 pr-6
|
||||
flex items-center justify-between
|
||||
bg-accent-0 box-border w-full z-10;
|
||||
min-height: 66px;
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply flex flex-col flex-1 box-border;
|
||||
}
|
||||
|
||||
@screen lg {
|
||||
.header {
|
||||
min-height: 74px;
|
||||
}
|
||||
}
|
50
components/common/SidebarLayout/SidebarLayout.tsx
Normal file
50
components/common/SidebarLayout/SidebarLayout.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { FC } from 'react'
|
||||
import { Cross, ChevronLeft } from '@components/icons'
|
||||
import { UserNav } from '@components/common'
|
||||
import cn from 'classnames'
|
||||
import s from './SidebarLayout.module.css'
|
||||
|
||||
type ComponentProps = { className?: string } & (
|
||||
| { handleClose: () => any; handleBack?: never }
|
||||
| { handleBack: () => any; handleClose?: never }
|
||||
)
|
||||
|
||||
const SidebarLayout: FC<ComponentProps> = ({
|
||||
children,
|
||||
className,
|
||||
handleClose,
|
||||
handleBack,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(s.root, className)}>
|
||||
<header className={s.header}>
|
||||
{handleClose && (
|
||||
<button
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
className="hover:text-accent-5 transition ease-in-out duration-150 flex items-center focus:outline-none"
|
||||
>
|
||||
<Cross className="h-6 w-6 hover:text-accent-3" />
|
||||
<span className="ml-2 text-accent-7 text-sm ">Close</span>
|
||||
</button>
|
||||
)}
|
||||
{handleBack && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
aria-label="Go back"
|
||||
className="hover:text-accent-5 transition ease-in-out duration-150 flex items-center focus:outline-none"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6 hover:text-accent-3" />
|
||||
<span className="ml-2 text-accent-7 text-xs">Back</span>
|
||||
</button>
|
||||
)}
|
||||
<span className={s.nav}>
|
||||
<UserNav />
|
||||
</span>
|
||||
</header>
|
||||
<div className={s.container}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarLayout
|
1
components/common/SidebarLayout/index.ts
Normal file
1
components/common/SidebarLayout/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './SidebarLayout'
|
@ -1,10 +1,8 @@
|
||||
.dropdownMenu {
|
||||
@apply fixed right-0 mt-2 origin-top-right outline-none bg-primary z-40 w-full h-full;
|
||||
}
|
||||
|
||||
@screen lg {
|
||||
.dropdownMenu {
|
||||
@apply absolute top-10 border border-accents-1 shadow-lg w-56 h-auto;
|
||||
@apply absolute top-10 border border-accent-1 shadow-lg w-56 h-auto;
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,11 +12,11 @@
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
@apply bg-accents-1;
|
||||
@apply bg-accent-1;
|
||||
}
|
||||
|
||||
.link.active {
|
||||
@apply font-bold bg-accents-2;
|
||||
@apply font-bold bg-accent-2;
|
||||
}
|
||||
|
||||
.off {
|
||||
|
@ -109,7 +109,7 @@ const DropdownMenu: FC<DropdownMenuProps> = ({ open = false }) => {
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className={cn(s.link, 'border-t border-accents-2 mt-4')}
|
||||
className={cn(s.link, 'border-t border-accent-2 mt-4')}
|
||||
onClick={() => logout()}
|
||||
>
|
||||
Logout
|
||||
|
@ -10,7 +10,7 @@
|
||||
@apply mr-6 cursor-pointer relative transition ease-in-out duration-100 flex items-center outline-none text-primary;
|
||||
|
||||
&:hover {
|
||||
@apply text-accents-6 transition scale-110 duration-100;
|
||||
@apply text-accent-6 transition scale-110 duration-100;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
@ -24,7 +24,7 @@
|
||||
}
|
||||
|
||||
.bagCount {
|
||||
@apply border border-accents-1 bg-secondary text-secondary absolute rounded-full right-3 top-3 flex items-center justify-center font-bold text-xs;
|
||||
@apply border border-accent-1 bg-secondary text-secondary absolute rounded-full right-3 top-3 flex items-center justify-center font-bold text-xs;
|
||||
padding-left: 2.5px;
|
||||
padding-right: 2.5px;
|
||||
min-width: 1.25rem;
|
||||
|
@ -24,7 +24,6 @@ const UserNav: FC<Props> = ({ className }) => {
|
||||
|
||||
return (
|
||||
<nav className={cn(s.root, className)}>
|
||||
<div className={s.mainContainer}>
|
||||
<ul className={s.list}>
|
||||
<li className={s.item} onClick={toggleSidebar}>
|
||||
<Bag />
|
||||
@ -39,6 +38,7 @@ const UserNav: FC<Props> = ({ className }) => {
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{process.env.COMMERCE_CUSTOMER_ENABLED && (
|
||||
<li className={s.item}>
|
||||
{customer ? (
|
||||
<DropdownMenu />
|
||||
@ -52,8 +52,8 @@ const UserNav: FC<Props> = ({ className }) => {
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
@ -1,23 +1,22 @@
|
||||
const RightArrow = ({ ...props }) => {
|
||||
const ArrowRight = ({ ...props }) => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M5 12H19"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 5L19 12L12 19"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@ -26,4 +25,4 @@ const RightArrow = ({ ...props }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default RightArrow
|
||||
export default ArrowRight
|
20
components/icons/ChevronDown.tsx
Normal file
20
components/icons/ChevronDown.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
const ChevronDown = ({ ...props }) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
shapeRendering="geometricPrecision"
|
||||
{...props}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChevronDown
|
20
components/icons/ChevronLeft.tsx
Normal file
20
components/icons/ChevronLeft.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
const ChevronLeft = ({ ...props }) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
shapeRendering="geometricPrecision"
|
||||
{...props}
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChevronLeft
|
20
components/icons/ChevronRight.tsx
Normal file
20
components/icons/ChevronRight.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
const ChevronUp = ({ ...props }) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
shapeRendering="geometricPrecision"
|
||||
{...props}
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChevronUp
|
@ -10,6 +10,7 @@ const CreditCard = ({ ...props }) => {
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
shapeRendering="geometricPrecision"
|
||||
{...props}
|
||||
>
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<path d="M1 10h22" />
|
||||
|
16
components/icons/Star.tsx
Normal file
16
components/icons/Star.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
const Star = ({ ...props }) => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M12.43 8L10 0L7.57 8H0L6.18 12.41L3.83 20L10 15.31L16.18 20L13.83 12.41L20 8H12.43Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Star
|
@ -2,17 +2,21 @@ export { default as Bag } from './Bag'
|
||||
export { default as Heart } from './Heart'
|
||||
export { default as Trash } from './Trash'
|
||||
export { default as Cross } from './Cross'
|
||||
export { default as ArrowLeft } from './ArrowLeft'
|
||||
export { default as Plus } from './Plus'
|
||||
export { default as Minus } from './Minus'
|
||||
export { default as Check } from './Check'
|
||||
export { default as Sun } from './Sun'
|
||||
export { default as Moon } from './Moon'
|
||||
export { default as Github } from './Github'
|
||||
export { default as DoubleChevron } from './DoubleChevron'
|
||||
export { default as RightArrow } from './RightArrow'
|
||||
export { default as Info } from './Info'
|
||||
export { default as ChevronUp } from './ChevronUp'
|
||||
export { default as Vercel } from './Vercel'
|
||||
export { default as MapPin } from './MapPin'
|
||||
export { default as Star } from './Star'
|
||||
export { default as ArrowLeft } from './ArrowLeft'
|
||||
export { default as ArrowRight } from './ArrowRight'
|
||||
export { default as CreditCard } from './CreditCard'
|
||||
export { default as ChevronUp } from './ChevronUp'
|
||||
export { default as ChevronLeft } from './ChevronLeft'
|
||||
export { default as ChevronDown } from './ChevronDown'
|
||||
export { default as ChevronRight } from './ChevronRight'
|
||||
export { default as DoubleChevron } from './DoubleChevron'
|
||||
|
@ -1,136 +1,114 @@
|
||||
.root {
|
||||
@apply relative max-h-full w-full box-border overflow-hidden
|
||||
bg-no-repeat bg-center bg-cover transition-transform
|
||||
ease-linear cursor-pointer;
|
||||
ease-linear cursor-pointer inline-block bg-accent-1;
|
||||
height: 100% !important;
|
||||
|
||||
&:hover {
|
||||
& .squareBg:before {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
& .productImage {
|
||||
transform: scale(1.2625);
|
||||
}
|
||||
|
||||
& .productTitle > span,
|
||||
& .productPrice,
|
||||
& .header .name span,
|
||||
& .header .price,
|
||||
& .wishlistButton {
|
||||
@apply bg-secondary text-secondary;
|
||||
}
|
||||
|
||||
&:nth-child(6n + 1) .productTitle > span,
|
||||
&:nth-child(6n + 1) .productPrice,
|
||||
&:nth-child(6n + 1) .header .name span,
|
||||
&:nth-child(6n + 1) .header .price,
|
||||
&:nth-child(6n + 1) .wishlistButton {
|
||||
@apply bg-violet text-white;
|
||||
}
|
||||
|
||||
&:nth-child(6n + 5) .productTitle > span,
|
||||
&:nth-child(6n + 5) .productPrice,
|
||||
&:nth-child(6n + 5) .header .name span,
|
||||
&:nth-child(6n + 5) .header .price,
|
||||
&:nth-child(6n + 5) .wishlistButton {
|
||||
@apply bg-blue text-white;
|
||||
}
|
||||
|
||||
&:nth-child(6n + 3) .productTitle > span,
|
||||
&:nth-child(6n + 3) .productPrice,
|
||||
&:nth-child(6n + 3) .header .name span,
|
||||
&:nth-child(6n + 3) .header .price,
|
||||
&:nth-child(6n + 3) .wishlistButton {
|
||||
@apply bg-pink text-white;
|
||||
}
|
||||
|
||||
&:nth-child(6n + 6) .productTitle > span,
|
||||
&:nth-child(6n + 6) .productPrice,
|
||||
&:nth-child(6n + 6) .header .name span,
|
||||
&:nth-child(6n + 6) .header .price,
|
||||
&:nth-child(6n + 6) .wishlistButton {
|
||||
@apply bg-cyan text-white;
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(6n + 1) .squareBg {
|
||||
@apply bg-violet;
|
||||
.header {
|
||||
@apply transition-colors ease-in-out duration-500
|
||||
absolute top-0 left-0 z-20 pr-16;
|
||||
}
|
||||
|
||||
&:nth-child(6n + 5) .squareBg {
|
||||
@apply bg-blue;
|
||||
}
|
||||
|
||||
&:nth-child(6n + 3) .squareBg {
|
||||
@apply bg-pink;
|
||||
}
|
||||
|
||||
&:nth-child(6n + 6) .squareBg {
|
||||
@apply bg-cyan;
|
||||
}
|
||||
}
|
||||
|
||||
.squareBg,
|
||||
.productTitle > span,
|
||||
.productPrice,
|
||||
.wishlistButton {
|
||||
@apply transition-colors ease-in-out duration-500;
|
||||
}
|
||||
|
||||
.squareBg {
|
||||
@apply transition-colors absolute inset-0 z-0;
|
||||
background-color: #212529;
|
||||
}
|
||||
|
||||
.squareBg:before {
|
||||
@apply transition ease-in-out duration-500 bg-repeat-space w-full h-full block;
|
||||
background-image: url('/bg-products.svg');
|
||||
content: '';
|
||||
}
|
||||
|
||||
.simple {
|
||||
& .squareBg {
|
||||
@apply bg-accents-0 !important;
|
||||
background-image: url('/bg-products.svg');
|
||||
}
|
||||
|
||||
& .productTitle {
|
||||
@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;
|
||||
.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;
|
||||
}
|
||||
|
||||
& span {
|
||||
@apply py-4 px-6 bg-primary text-primary font-bold;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.productPrice {
|
||||
@apply py-4 px-6 bg-primary text-primary font-semibold inline-block text-sm leading-6;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.wishlistButton {
|
||||
@apply w-10 h-10 flex ml-auto items-center justify-center bg-primary text-primary font-semibold text-xs leading-6 cursor-pointer;
|
||||
.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;
|
||||
@apply flex items-center justify-center overflow-hidden;
|
||||
}
|
||||
|
||||
& > div {
|
||||
.imageContainer > div {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.imageContainer .productImage {
|
||||
@apply transform transition-transform duration-500
|
||||
object-cover scale-120;
|
||||
}
|
||||
|
||||
.productImage {
|
||||
@apply transform transition-transform duration-500 object-cover scale-120;
|
||||
.root .wishlistButton {
|
||||
@apply top-0 right-0 z-30 absolute;
|
||||
}
|
||||
|
||||
/* Variant Simple */
|
||||
.simple .header .name {
|
||||
@apply pt-2 text-lg leading-10 -mt-1;
|
||||
}
|
||||
|
||||
.simple .header .price {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
/* Variant Slim */
|
||||
.slim {
|
||||
@apply bg-transparent relative overflow-hidden
|
||||
box-border;
|
||||
}
|
||||
|
||||
.slim .header {
|
||||
@apply absolute inset-0 flex items-center justify-end mr-8 z-20;
|
||||
}
|
||||
|
||||
.slim span {
|
||||
@apply bg-accent-9 text-accent-0 inline-block p-3
|
||||
font-bold text-xl break-words;
|
||||
}
|
||||
|
||||
.root:global(.secondary) .header span {
|
||||
@apply bg-accent-0 text-accent-9;
|
||||
}
|
||||
|
@ -5,31 +5,45 @@ import type { Product } from '@commerce/types/product'
|
||||
import s from './ProductCard.module.css'
|
||||
import Image, { ImageProps } from 'next/image'
|
||||
import WishlistButton from '@components/wishlist/WishlistButton'
|
||||
|
||||
import usePrice from '@framework/product/use-price'
|
||||
import ProductTag from '../ProductTag'
|
||||
interface Props {
|
||||
className?: string
|
||||
product: Product
|
||||
variant?: 'slim' | 'simple'
|
||||
imgProps?: Omit<any, 'src'>
|
||||
noNameTag?: boolean
|
||||
imgProps?: Omit<ImageProps, 'src'>
|
||||
variant?: 'default' | 'slim' | 'simple'
|
||||
}
|
||||
|
||||
const placeholderImg = '/product-img-placeholder.svg'
|
||||
|
||||
const ProductCard: FC<Props> = ({
|
||||
className,
|
||||
product,
|
||||
variant,
|
||||
imgProps,
|
||||
className,
|
||||
noNameTag = false,
|
||||
variant = 'default',
|
||||
...props
|
||||
}) => (
|
||||
}) => {
|
||||
const { price } = usePrice({
|
||||
amount: product.price.value,
|
||||
baseAmount: product.price.retailPrice,
|
||||
currencyCode: product.price.currencyCode!,
|
||||
})
|
||||
|
||||
const rootClassName = cn(
|
||||
s.root,
|
||||
{ [s.slim]: variant === 'slim', [s.simple]: variant === 'simple' },
|
||||
className
|
||||
)
|
||||
|
||||
return (
|
||||
<Link href={`/product/${product.slug}`} {...props}>
|
||||
<a className={cn(s.root, { [s.simple]: variant === 'simple' }, className)}>
|
||||
{variant === 'slim' ? (
|
||||
<div className="relative overflow-hidden box-border">
|
||||
<div className="absolute inset-0 flex items-center justify-end mr-8 z-20">
|
||||
<span className="bg-black text-white inline-block p-3 font-bold text-xl break-words">
|
||||
{product.name}
|
||||
</span>
|
||||
<a className={rootClassName}>
|
||||
{variant === 'slim' && (
|
||||
<>
|
||||
<div className={s.header}>
|
||||
<span>{product.name}</span>
|
||||
</div>
|
||||
{product?.images && (
|
||||
<Image
|
||||
@ -42,21 +56,47 @@ const ProductCard: FC<Props> = ({
|
||||
{...imgProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
</>
|
||||
)}
|
||||
|
||||
{variant === 'simple' && (
|
||||
<>
|
||||
<div className={s.squareBg} />
|
||||
<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}>
|
||||
{process.env.COMMERCE_WISHLIST_ENABLED && (
|
||||
<WishlistButton
|
||||
className={s.wishlistButton}
|
||||
productId={product.id}
|
||||
variant={product.variants[0]}
|
||||
/>
|
||||
)}
|
||||
{!noNameTag && (
|
||||
<div className={s.header}>
|
||||
<h3 className={s.name}>
|
||||
<span>{product.name}</span>
|
||||
</h3>
|
||||
<span className={s.productPrice}>
|
||||
{product.price.value}
|
||||
|
||||
{product.price.currencyCode}
|
||||
</span>
|
||||
<div className={s.price}>
|
||||
{`${price} ${product.price?.currencyCode}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{variant === 'default' && (
|
||||
<>
|
||||
{process.env.COMMERCE_WISHLIST_ENABLED && (
|
||||
<WishlistButton
|
||||
className={s.wishlistButton}
|
||||
@ -64,7 +104,10 @@ const ProductCard: FC<Props> = ({
|
||||
variant={product.variants[0] as any}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ProductTag
|
||||
name={product.name}
|
||||
price={`${price} ${product.price?.currencyCode}`}
|
||||
/>
|
||||
<div className={s.imageContainer}>
|
||||
{product?.images && (
|
||||
<Image
|
||||
@ -84,5 +127,6 @@ const ProductCard: FC<Props> = ({
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductCard
|
||||
|
50
components/product/ProductOptions/ProductOptions.tsx
Normal file
50
components/product/ProductOptions/ProductOptions.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { Swatch } from '@components/product'
|
||||
import type { ProductOption } from '@commerce/types/product'
|
||||
import { SelectedOptions } from '../helpers'
|
||||
import React from 'react'
|
||||
interface ProductOptionsProps {
|
||||
options: ProductOption[]
|
||||
selectedOptions: SelectedOptions
|
||||
setSelectedOptions: React.Dispatch<React.SetStateAction<SelectedOptions>>
|
||||
}
|
||||
|
||||
const ProductOptions: React.FC<ProductOptionsProps> = React.memo(
|
||||
({ options, selectedOptions, setSelectedOptions }) => {
|
||||
return (
|
||||
<div>
|
||||
{options.map((opt) => (
|
||||
<div className="pb-4" key={opt.displayName}>
|
||||
<h2 className="uppercase font-medium text-sm tracking-wide">
|
||||
{opt.displayName}
|
||||
</h2>
|
||||
<div className="flex flex-row py-4">
|
||||
{opt.values.map((v, i: number) => {
|
||||
const active = selectedOptions[opt.displayName.toLowerCase()]
|
||||
return (
|
||||
<Swatch
|
||||
key={`${opt.id}-${i}`}
|
||||
active={v.label.toLowerCase() === active}
|
||||
variant={opt.displayName}
|
||||
color={v.hexColors ? v.hexColors[0] : ''}
|
||||
label={v.label}
|
||||
onClick={() => {
|
||||
setSelectedOptions((selectedOptions) => {
|
||||
return {
|
||||
...selectedOptions,
|
||||
[opt.displayName.toLowerCase()]:
|
||||
v.label.toLowerCase(),
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default ProductOptions
|
1
components/product/ProductOptions/index.ts
Normal file
1
components/product/ProductOptions/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './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'
|
@ -1,82 +1,64 @@
|
||||
.root {
|
||||
@apply relative w-full h-full;
|
||||
@apply relative w-full h-full select-none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slider {
|
||||
@apply relative h-full transition-opacity duration-150;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slider.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
@apply transition-transform transition-colors
|
||||
ease-linear duration-75 overflow-hidden inline-block
|
||||
cursor-pointer h-full;
|
||||
width: 125px;
|
||||
width: calc(100% / 3);
|
||||
}
|
||||
|
||||
.thumb.selected {
|
||||
@apply bg-white;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
height: 85% !important;
|
||||
}
|
||||
|
||||
.album {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@apply bg-violet-dark;
|
||||
box-sizing: content-box;
|
||||
overflow-y: hidden;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
height: 125px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.leftControl,
|
||||
.rightControl {
|
||||
@apply absolute top-1/2 -translate-x-1/2 z-20 w-16 h-16 flex items-center justify-center bg-hover-1 rounded-full;
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.album::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.leftControl:hover,
|
||||
.rightControl:hover {
|
||||
@apply bg-hover-2;
|
||||
}
|
||||
|
||||
.leftControl:hover,
|
||||
.rightControl:hover {
|
||||
@apply outline-none shadow-outline-normal;
|
||||
}
|
||||
|
||||
.leftControl {
|
||||
@apply bg-cover left-10;
|
||||
background-image: url('public/cursor-left.png');
|
||||
|
||||
@screen md {
|
||||
@apply left-6;
|
||||
}
|
||||
.thumb:hover {
|
||||
transform: scale(1.02);
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.rightControl {
|
||||
@apply bg-cover right-10;
|
||||
background-image: url('public/cursor-right.png');
|
||||
|
||||
@screen md {
|
||||
@apply right-6;
|
||||
}
|
||||
}
|
||||
|
||||
.control {
|
||||
@apply opacity-0 transition duration-150;
|
||||
}
|
||||
|
||||
.root:hover .control {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.positionIndicatorsContainer {
|
||||
@apply hidden;
|
||||
|
||||
@screen sm {
|
||||
@apply block absolute bottom-6 left-1/2;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.positionIndicator {
|
||||
@apply rounded-full p-2;
|
||||
}
|
||||
|
||||
.dot {
|
||||
@apply bg-hover-1 transition w-3 h-3 rounded-full;
|
||||
}
|
||||
|
||||
.positionIndicator:hover .dot {
|
||||
@apply bg-hover-2;
|
||||
}
|
||||
|
||||
.positionIndicator:focus {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
.positionIndicator:focus .dot {
|
||||
@apply shadow-outline-normal;
|
||||
}
|
||||
|
||||
.positionIndicatorActive .dot {
|
||||
.thumb.selected {
|
||||
@apply bg-white;
|
||||
}
|
||||
|
||||
.positionIndicatorActive:hover .dot {
|
||||
@apply bg-white;
|
||||
.album {
|
||||
height: 182px;
|
||||
}
|
||||
.thumb {
|
||||
width: 235px;
|
||||
}
|
||||
}
|
||||
|
@ -8,20 +8,42 @@ import React, {
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import cn from 'classnames'
|
||||
|
||||
import { a } from '@react-spring/web'
|
||||
import s from './ProductSlider.module.css'
|
||||
import ProductSliderControl from '../ProductSliderControl'
|
||||
|
||||
const ProductSlider: FC = ({ children }) => {
|
||||
interface ProductSliderProps {
|
||||
children: React.ReactNode[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ProductSlider: React.FC<ProductSliderProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
}) => {
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
const sliderContainerRef = useRef<HTMLDivElement>(null)
|
||||
const thumbsContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [ref, slider] = useKeenSlider<HTMLDivElement>({
|
||||
loop: true,
|
||||
slidesPerView: 1,
|
||||
mounted: () => setIsMounted(true),
|
||||
slideChanged(s) {
|
||||
setCurrentSlide(s.details().relativeSlide)
|
||||
const slideNumber = s.details().relativeSlide
|
||||
setCurrentSlide(slideNumber)
|
||||
|
||||
if (thumbsContainerRef.current) {
|
||||
const $el = document.getElementById(
|
||||
`thumb-${s.details().relativeSlide}`
|
||||
)
|
||||
if (slideNumber >= 3) {
|
||||
thumbsContainerRef.current.scrollLeft = $el!.offsetLeft
|
||||
} else {
|
||||
thumbsContainerRef.current.scrollLeft = 0
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@ -59,23 +81,16 @@ const ProductSlider: FC = ({ children }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onPrev = React.useCallback(() => slider.prev(), [slider])
|
||||
const onNext = React.useCallback(() => slider.next(), [slider])
|
||||
|
||||
return (
|
||||
<div className={s.root} ref={sliderContainerRef}>
|
||||
<button
|
||||
className={cn(s.leftControl, s.control)}
|
||||
onClick={slider?.prev}
|
||||
aria-label="Previous Product Image"
|
||||
/>
|
||||
<button
|
||||
className={cn(s.rightControl, s.control)}
|
||||
onClick={slider?.next}
|
||||
aria-label="Next Product Image"
|
||||
/>
|
||||
<div className={cn(s.root, className)} ref={sliderContainerRef}>
|
||||
<div
|
||||
ref={ref}
|
||||
className="keen-slider h-full transition-opacity duration-150"
|
||||
style={{ opacity: isMounted ? 1 : 0 }}
|
||||
className={cn(s.slider, { [s.show]: isMounted }, 'keen-slider')}
|
||||
>
|
||||
{slider && <ProductSliderControl onPrev={onPrev} onNext={onNext} />}
|
||||
{Children.map(children, (child) => {
|
||||
// Add the keen-slider__slide className to children
|
||||
if (isValidElement(child)) {
|
||||
@ -92,26 +107,28 @@ const ProductSlider: FC = ({ children }) => {
|
||||
return child
|
||||
})}
|
||||
</div>
|
||||
{slider && (
|
||||
<div className={cn(s.positionIndicatorsContainer)}>
|
||||
{[...Array(slider.details().size).keys()].map((idx) => {
|
||||
return (
|
||||
<button
|
||||
aria-label="Position indicator"
|
||||
key={idx}
|
||||
className={cn(s.positionIndicator, {
|
||||
[s.positionIndicatorActive]: currentSlide === idx,
|
||||
})}
|
||||
onClick={() => {
|
||||
|
||||
<a.div className={s.album} ref={thumbsContainerRef}>
|
||||
{slider &&
|
||||
Children.map(children, (child, idx) => {
|
||||
if (isValidElement(child)) {
|
||||
return {
|
||||
...child,
|
||||
props: {
|
||||
...child.props,
|
||||
className: cn(child.props.className, s.thumb, {
|
||||
[s.selected]: currentSlide === idx,
|
||||
}),
|
||||
id: `thumb-${idx}`,
|
||||
onClick: () => {
|
||||
slider.moveToSlideRelative(idx)
|
||||
}}
|
||||
>
|
||||
<div className={s.dot} />
|
||||
</button>
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return child
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</a.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -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,96 +1,60 @@
|
||||
.root {
|
||||
@apply relative grid items-start gap-8 grid-cols-1 overflow-x-hidden;
|
||||
|
||||
@screen lg {
|
||||
@apply grid-cols-12;
|
||||
}
|
||||
@apply relative grid items-start gap-1 grid-cols-1 overflow-x-hidden;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.productDisplay {
|
||||
@apply relative flex px-0 pb-0 box-border col-span-1 bg-violet;
|
||||
min-height: 600px;
|
||||
|
||||
@screen md {
|
||||
min-height: 700px;
|
||||
}
|
||||
|
||||
@screen lg {
|
||||
margin-right: -2rem;
|
||||
margin-left: -2rem;
|
||||
@apply mx-0 col-span-6;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.squareBg {
|
||||
@apply absolute inset-0 bg-violet z-0 h-full;
|
||||
}
|
||||
|
||||
.nameBox {
|
||||
@apply absolute top-6 left-0 z-20 pr-16;
|
||||
|
||||
@screen lg {
|
||||
@apply left-6 pr-16;
|
||||
}
|
||||
|
||||
& .name {
|
||||
@apply px-6 py-2 bg-primary text-primary font-bold;
|
||||
font-size: 2rem;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
& .price {
|
||||
@apply px-6 py-2 pb-4 bg-primary text-primary font-bold inline-block tracking-wide;
|
||||
}
|
||||
|
||||
@screen lg {
|
||||
& .name,
|
||||
& .price {
|
||||
@apply bg-violet-light text-white;
|
||||
}
|
||||
}
|
||||
.main {
|
||||
@apply relative px-0 pb-0 box-border flex flex-col col-span-1;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@apply flex flex-col col-span-1 mx-auto max-w-8xl px-6 w-full h-full;
|
||||
|
||||
@screen lg {
|
||||
@apply col-span-6 py-24 justify-between;
|
||||
}
|
||||
@apply flex flex-col col-span-1 mx-auto max-w-8xl px-6 py-6 w-full h-full;
|
||||
}
|
||||
|
||||
.sliderContainer {
|
||||
@apply absolute z-10 inset-0 flex items-center justify-center overflow-x-hidden;
|
||||
@apply flex items-center justify-center overflow-x-hidden bg-violet;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
& > div {
|
||||
@apply h-full;
|
||||
& > div {
|
||||
@apply h-full;
|
||||
}
|
||||
}
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.img {
|
||||
.imageContainer > div,
|
||||
.imageContainer > div > div {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.sliderContainer .img {
|
||||
@apply w-full h-auto max-h-full object-cover;
|
||||
}
|
||||
|
||||
.button {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
|
||||
@screen sm {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.wishlistButton {
|
||||
@apply absolute z-30 top-6 right-0 bg-primary text-primary w-10 h-10 flex items-center justify-center font-semibold leading-6 cursor-pointer;
|
||||
@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 {
|
||||
@apply right-12 text-white bg-violet;
|
||||
.root {
|
||||
@apply grid-cols-12;
|
||||
}
|
||||
|
||||
.main {
|
||||
@apply mx-0 col-span-8;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@apply col-span-4 py-6;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
max-height: 600px;
|
||||
}
|
||||
}
|
||||
|
@ -1,64 +1,90 @@
|
||||
import cn from 'classnames'
|
||||
import Image from 'next/image'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import s from './ProductView.module.css'
|
||||
import { Swatch, ProductSlider } from '@components/product'
|
||||
import { Button, Container, Text, useUI } from '@components/ui'
|
||||
import { FC } from 'react'
|
||||
import type { Product } from '@commerce/types/product'
|
||||
import usePrice from '@framework/product/use-price'
|
||||
import { useAddItem } from '@framework/cart'
|
||||
import { getVariant, SelectedOptions } from '../helpers'
|
||||
import WishlistButton from '@components/wishlist/WishlistButton'
|
||||
|
||||
interface Props {
|
||||
children?: any
|
||||
import { WishlistButton } from '@components/wishlist'
|
||||
import { ProductSlider, ProductCard } from '@components/product'
|
||||
import { Container, Text } from '@components/ui'
|
||||
import ProductSidebar from '../ProductSidebar'
|
||||
import ProductTag from '../ProductTag'
|
||||
interface ProductViewProps {
|
||||
product: Product
|
||||
className?: string
|
||||
relatedProducts: Product[]
|
||||
}
|
||||
|
||||
const ProductView: FC<Props> = ({ product }) => {
|
||||
// TODO: fix this missing argument issue
|
||||
/* @ts-ignore */
|
||||
const addItem = useAddItem()
|
||||
const ProductView: FC<ProductViewProps> = ({ product, relatedProducts }) => {
|
||||
const { price } = usePrice({
|
||||
amount: product.price.value,
|
||||
baseAmount: product.price.retailPrice,
|
||||
currencyCode: product.price.currencyCode!,
|
||||
})
|
||||
const { openSidebar } = useUI()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [choices, setChoices] = useState<SelectedOptions>({})
|
||||
|
||||
useEffect(() => {
|
||||
// Selects the default option
|
||||
const options = product.variants[0].options || []
|
||||
options.forEach((v) => {
|
||||
setChoices((choices) => ({
|
||||
...choices,
|
||||
[v.displayName.toLowerCase()]: v.values[0]?.label.toLowerCase(),
|
||||
}))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const variant = getVariant(product, choices)
|
||||
|
||||
const addToCart = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await addItem({
|
||||
productId: String(product.id),
|
||||
variantId: String(variant ? variant.id : product.variants[0].id),
|
||||
})
|
||||
openSidebar()
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container className="max-w-none w-full" clean>
|
||||
<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
|
||||
title={product.name}
|
||||
description={product.description}
|
||||
@ -76,97 +102,7 @@ const ProductView: FC<Props> = ({ product }) => {
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<div className={cn(s.root, 'fit')}>
|
||||
<div className={cn(s.productDisplay, '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={1050}
|
||||
height={1050}
|
||||
priority={i === 0}
|
||||
quality="85"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ProductSlider>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.sidebar}>
|
||||
<section>
|
||||
{product.options?.map((opt) => (
|
||||
<div className="pb-4" key={opt.displayName}>
|
||||
<h2 className="uppercase font-medium">{opt.displayName}</h2>
|
||||
<div className="flex flex-row py-4">
|
||||
{opt.values.map((v, i: number) => {
|
||||
const active = (choices as any)[
|
||||
opt.displayName.toLowerCase()
|
||||
]
|
||||
|
||||
return (
|
||||
<Swatch
|
||||
key={`${opt.id}-${i}`}
|
||||
active={v.label.toLowerCase() === active}
|
||||
variant={opt.displayName}
|
||||
color={v.hexColors ? v.hexColors[0] : ''}
|
||||
label={v.label}
|
||||
onClick={() => {
|
||||
setChoices((choices) => {
|
||||
return {
|
||||
...choices,
|
||||
[opt.displayName.toLowerCase()]:
|
||||
v.label.toLowerCase(),
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pb-14 break-words w-full max-w-xl">
|
||||
<Text html={product.descriptionHtml || product.description} />
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
{process.env.COMMERCE_WISHLIST_ENABLED && (
|
||||
<WishlistButton
|
||||
className={s.wishlistButton}
|
||||
productId={product.id}
|
||||
variant={product.variants[0]! as any}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,11 +1,13 @@
|
||||
.swatch {
|
||||
box-sizing: border-box;
|
||||
composes: root from 'components/ui/Button/Button.module.css';
|
||||
@apply h-12 w-12 bg-primary text-primary rounded-full mr-3 inline-flex
|
||||
composes: root from '@components/ui/Button/Button.module.css';
|
||||
@apply h-10 w-10 bg-primary text-primary rounded-full mr-3 inline-flex
|
||||
items-center justify-center cursor-pointer transition duration-150 ease-in-out
|
||||
p-0 shadow-none border-gray-200 border box-border;
|
||||
p-0 shadow-none border-accent-3 border box-border select-none;
|
||||
margin-right: calc(0.75rem - 1px);
|
||||
overflow: hidden;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.swatch::before,
|
||||
@ -35,7 +37,7 @@
|
||||
}
|
||||
|
||||
.active {
|
||||
@apply border-accents-9 border-2;
|
||||
@apply border-accent-9 border-2;
|
||||
padding-right: 1px;
|
||||
padding-left: 1px;
|
||||
}
|
||||
@ -46,7 +48,7 @@
|
||||
}
|
||||
|
||||
.active.textLabel {
|
||||
@apply border-accents-9 border-2;
|
||||
@apply border-accent-9 border-2;
|
||||
padding-right: calc(1rem - 1px);
|
||||
padding-left: calc(1rem - 1px);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import cn from 'classnames'
|
||||
import { FC } from 'react'
|
||||
import React from 'react'
|
||||
import s from './Swatch.module.css'
|
||||
import { Check } from '@components/icons'
|
||||
import Button, { ButtonProps } from '@components/ui/Button'
|
||||
@ -13,12 +13,13 @@ interface SwatchProps {
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
|
||||
const Swatch: React.FC<Omit<ButtonProps, 'variant'> & SwatchProps> = React.memo(
|
||||
({
|
||||
active,
|
||||
className,
|
||||
color = '',
|
||||
label = null,
|
||||
variant = 'size',
|
||||
active,
|
||||
...props
|
||||
}) => {
|
||||
variant = variant?.toLowerCase()
|
||||
@ -30,9 +31,9 @@ const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
|
||||
const swatchClassName = cn(
|
||||
s.swatch,
|
||||
{
|
||||
[s.color]: color,
|
||||
[s.active]: active,
|
||||
[s.size]: variant === 'size',
|
||||
[s.color]: color,
|
||||
[s.dark]: color ? isDark(color) : false,
|
||||
[s.textLabel]: !color && label && label.length > 3,
|
||||
},
|
||||
@ -41,10 +42,10 @@ const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={swatchClassName}
|
||||
style={color ? { backgroundColor: color } : {}}
|
||||
aria-label="Variant Swatch"
|
||||
className={swatchClassName}
|
||||
{...(label && color && { title: label })}
|
||||
style={color ? { backgroundColor: color } : {}}
|
||||
{...props}
|
||||
>
|
||||
{color && active && (
|
||||
@ -56,5 +57,6 @@ const Swatch: FC<Omit<ButtonProps, 'variant'> & SwatchProps> = ({
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default Swatch
|
||||
|
@ -1,7 +1,8 @@
|
||||
import type { Product } from '@commerce/types/product'
|
||||
export type SelectedOptions = Record<string, string | null>
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
export function getVariant(product: Product, opts: SelectedOptions) {
|
||||
export function getProductVariant(product: Product, opts: SelectedOptions) {
|
||||
const variant = product.variants.find((variant) => {
|
||||
return Object.entries(opts).every(([key, value]) =>
|
||||
variant.options.find((option) => {
|
||||
@ -16,3 +17,16 @@ export function getVariant(product: Product, opts: SelectedOptions) {
|
||||
})
|
||||
return variant
|
||||
}
|
||||
|
||||
export function selectDefaultOptionFromProduct(
|
||||
product: Product,
|
||||
updater: Dispatch<SetStateAction<SelectedOptions>>
|
||||
) {
|
||||
// Selects the default option
|
||||
product.variants[0].options?.forEach((v) => {
|
||||
updater((choices) => ({
|
||||
...choices,
|
||||
[v.displayName.toLowerCase()]: v.values[0].label.toLowerCase(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
@ -2,3 +2,4 @@ export { default as Swatch } from './Swatch'
|
||||
export { default as ProductView } from './ProductView'
|
||||
export { default as ProductCard } from './ProductCard'
|
||||
export { default as ProductSlider } from './ProductSlider'
|
||||
export { default as ProductOptions } from './ProductOptions'
|
||||
|
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,14 @@
|
||||
.root {
|
||||
@apply bg-secondary text-accents-1 cursor-pointer inline-flex px-10 rounded-sm leading-6 transition ease-in-out duration-150 shadow-sm font-semibold text-center justify-center uppercase py-4 border border-transparent items-center;
|
||||
@apply bg-accent-9 text-accent-0 cursor-pointer inline-flex
|
||||
px-10 py-5 leading-6 transition ease-in-out duration-150
|
||||
shadow-sm text-center justify-center uppercase
|
||||
border border-transparent items-center text-sm font-semibold
|
||||
tracking-wide;
|
||||
max-height: 64px;
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
@apply bg-accents-0 text-primary border border-secondary;
|
||||
@apply border-accent-9 bg-accent-6;
|
||||
}
|
||||
|
||||
.root:focus {
|
||||
@ -11,22 +16,33 @@
|
||||
}
|
||||
|
||||
.root[data-active] {
|
||||
@apply bg-gray-600;
|
||||
@apply bg-accent-6;
|
||||
}
|
||||
|
||||
.loading {
|
||||
@apply bg-accents-1 text-accents-3 border-accents-2 cursor-not-allowed;
|
||||
@apply bg-accent-1 text-accent-3 border-accent-2 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.slim {
|
||||
@apply py-2 transform-none normal-case;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
@apply border border-accent-2 bg-accent-0 text-accent-9 text-sm;
|
||||
}
|
||||
|
||||
.ghost:hover {
|
||||
@apply border-accent-9 bg-accent-9 text-accent-0;
|
||||
}
|
||||
|
||||
.disabled,
|
||||
.disabled:hover {
|
||||
@apply text-accents-4 border-accents-2 bg-accents-1 cursor-not-allowed;
|
||||
@apply text-accent-4 border-accent-2 bg-accent-1 cursor-not-allowed;
|
||||
filter: grayscale(1);
|
||||
-webkit-transform: translateZ(0);
|
||||
-webkit-perspective: 1000;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.progress {
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ import { LoadingDots } from '@components/ui'
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
href?: string
|
||||
className?: string
|
||||
variant?: 'flat' | 'slim'
|
||||
variant?: 'flat' | 'slim' | 'ghost'
|
||||
active?: boolean
|
||||
type?: 'submit' | 'reset' | 'button'
|
||||
Component?: string | JSXElementConstructor<any>
|
||||
@ -39,6 +39,7 @@ const Button: React.FC<ButtonProps> = forwardRef((props, buttonRef) => {
|
||||
const rootClassName = cn(
|
||||
s.root,
|
||||
{
|
||||
[s.ghost]: variant === 'ghost',
|
||||
[s.slim]: variant === 'slim',
|
||||
[s.loading]: loading,
|
||||
[s.disabled]: disabled,
|
||||
|
25
components/ui/Collapse/Collapse.module.css
Normal file
25
components/ui/Collapse/Collapse.module.css
Normal file
@ -0,0 +1,25 @@
|
||||
.root {
|
||||
@apply border-b border-accent-2 py-4 flex flex-col outline-none;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply flex flex-row items-center;
|
||||
}
|
||||
|
||||
.header .label {
|
||||
@apply text-base font-medium;
|
||||
}
|
||||
|
||||
.content {
|
||||
@apply pt-3 overflow-hidden pl-8;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@apply mr-3 text-accent-6;
|
||||
margin-left: -6px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.icon.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
46
components/ui/Collapse/Collapse.tsx
Normal file
46
components/ui/Collapse/Collapse.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import cn from 'classnames'
|
||||
import React, { FC, ReactNode, useState } from 'react'
|
||||
import s from './Collapse.module.css'
|
||||
import { ChevronRight } from '@components/icons'
|
||||
import { useSpring, a } from '@react-spring/web'
|
||||
import useMeasure from 'react-use-measure'
|
||||
|
||||
export interface CollapseProps {
|
||||
title: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const Collapse: FC<CollapseProps> = React.memo(({ title, children }) => {
|
||||
const [isActive, setActive] = useState(false)
|
||||
const [ref, { height: viewHeight }] = useMeasure()
|
||||
|
||||
const animProps = useSpring({
|
||||
height: isActive ? viewHeight : 0,
|
||||
config: { tension: 250, friction: 32, clamp: true, duration: 150 },
|
||||
opacity: isActive ? 1 : 0,
|
||||
})
|
||||
|
||||
const toggle = () => setActive((x) => !x)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={s.root}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isActive}
|
||||
onClick={toggle}
|
||||
>
|
||||
<div className={s.header}>
|
||||
<ChevronRight className={cn(s.icon, { [s.open]: isActive })} />
|
||||
<span className={s.label}>{title}</span>
|
||||
</div>
|
||||
<a.div style={{ overflow: 'hidden', ...animProps }}>
|
||||
<div ref={ref} className={s.content}>
|
||||
{children}
|
||||
</div>
|
||||
</a.div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default Collapse
|
2
components/ui/Collapse/index.ts
Normal file
2
components/ui/Collapse/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default } from './Collapse'
|
||||
export * from './Collapse'
|
@ -1,14 +1,19 @@
|
||||
import cn from 'classnames'
|
||||
import React, { FC } from 'react'
|
||||
|
||||
interface Props {
|
||||
interface ContainerProps {
|
||||
className?: string
|
||||
children?: any
|
||||
el?: HTMLElement
|
||||
clean?: boolean
|
||||
}
|
||||
|
||||
const Container: FC<Props> = ({ children, className, el = 'div', clean }) => {
|
||||
const Container: FC<ContainerProps> = ({
|
||||
children,
|
||||
className,
|
||||
el = 'div',
|
||||
clean,
|
||||
}) => {
|
||||
const rootClassName = cn(className, {
|
||||
'mx-auto max-w-8xl px-6': !clean,
|
||||
})
|
||||
|
@ -1,7 +1,9 @@
|
||||
.root {
|
||||
--row-height: calc(100vh - 88px);
|
||||
@apply grid grid-cols-1 gap-0;
|
||||
min-height: var(--row-height);
|
||||
|
||||
@screen lg {
|
||||
@apply grid-cols-3 grid-rows-2;
|
||||
}
|
||||
|
||||
& > * {
|
||||
@apply row-span-1 bg-transparent box-border overflow-hidden;
|
||||
@ -15,17 +17,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@screen lg {
|
||||
.root {
|
||||
@apply grid-cols-3 grid-rows-2;
|
||||
}
|
||||
|
||||
.root & > * {
|
||||
@apply col-span-1;
|
||||
height: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.default {
|
||||
& > * {
|
||||
@apply bg-transparent;
|
||||
@ -34,9 +25,17 @@
|
||||
|
||||
.layoutNormal {
|
||||
@apply gap-3;
|
||||
}
|
||||
|
||||
& > * {
|
||||
min-height: 325px;
|
||||
@screen md {
|
||||
.layoutNormal > * {
|
||||
max-height: min-content !important;
|
||||
}
|
||||
}
|
||||
|
||||
@screen lg {
|
||||
.layoutNormal > * {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,13 +51,12 @@
|
||||
}
|
||||
|
||||
&.filled {
|
||||
& > *:nth-child(6n + 1),
|
||||
& > *:nth-child(6n + 5) {
|
||||
& > *:nth-child(6n + 1) {
|
||||
@apply bg-violet;
|
||||
}
|
||||
|
||||
& > *:nth-child(6n + 5) {
|
||||
@apply bg-blue;
|
||||
& > *:nth-child(6n + 2) {
|
||||
@apply bg-accent-8;
|
||||
}
|
||||
|
||||
& > *:nth-child(6n + 3) {
|
||||
@ -83,12 +81,12 @@
|
||||
}
|
||||
|
||||
&.filled {
|
||||
& > *:nth-child(6n + 2) {
|
||||
@apply bg-blue;
|
||||
& > *:nth-child(6n + 1) {
|
||||
@apply bg-violet;
|
||||
}
|
||||
|
||||
& > *:nth-child(6n + 4) {
|
||||
@apply bg-violet;
|
||||
& > *:nth-child(6n + 2) {
|
||||
@apply bg-accent-8;
|
||||
}
|
||||
|
||||
& > *:nth-child(6n + 3) {
|
||||
|
@ -2,14 +2,14 @@ import cn from 'classnames'
|
||||
import { FC, ReactNode, Component } from 'react'
|
||||
import s from './Grid.module.css'
|
||||
|
||||
interface Props {
|
||||
interface GridProps {
|
||||
className?: string
|
||||
children?: ReactNode[] | Component[] | any[]
|
||||
layout?: 'A' | 'B' | 'C' | 'D' | 'normal'
|
||||
variant?: 'default' | 'filled'
|
||||
}
|
||||
|
||||
const Grid: FC<Props> = ({
|
||||
const Grid: FC<GridProps> = ({
|
||||
className,
|
||||
layout = 'A',
|
||||
children,
|
||||
|
@ -1,9 +1,30 @@
|
||||
.root {
|
||||
@apply mx-auto grid grid-cols-1 py-32 gap-4;
|
||||
@apply flex flex-col py-16 mx-auto;
|
||||
}
|
||||
|
||||
@screen md {
|
||||
.title {
|
||||
@apply text-accent-0 font-extrabold text-4xl leading-none tracking-tight;
|
||||
}
|
||||
|
||||
.description {
|
||||
@apply mt-4 text-xl leading-8 text-accent-2 mb-1 lg:max-w-4xl;
|
||||
}
|
||||
|
||||
@screen lg {
|
||||
.root {
|
||||
@apply grid-cols-2;
|
||||
@apply flex-row items-start justify-center py-32;
|
||||
}
|
||||
.title {
|
||||
@apply text-5xl max-w-xl text-right leading-10 -mt-3;
|
||||
line-height: 3.5rem;
|
||||
}
|
||||
.description {
|
||||
@apply mt-0 ml-6;
|
||||
}
|
||||
}
|
||||
|
||||
@screen xl {
|
||||
.title {
|
||||
@apply text-6xl;
|
||||
}
|
||||
}
|
||||
|
@ -1,30 +1,26 @@
|
||||
import React, { FC } from 'react'
|
||||
import { Container } from '@components/ui'
|
||||
import { RightArrow } from '@components/icons'
|
||||
import { ArrowRight } from '@components/icons'
|
||||
import s from './Hero.module.css'
|
||||
import Link from 'next/link'
|
||||
interface Props {
|
||||
interface HeroProps {
|
||||
className?: string
|
||||
headline: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const Hero: FC<Props> = ({ headline, description }) => {
|
||||
const Hero: FC<HeroProps> = ({ headline, description }) => {
|
||||
return (
|
||||
<div className="bg-black">
|
||||
<div className="bg-accent-9 border-b border-t border-accent-2">
|
||||
<Container>
|
||||
<div className={s.root}>
|
||||
<h2 className="text-4xl leading-10 font-extrabold text-white sm:text-5xl sm:leading-none sm:tracking-tight lg:text-6xl">
|
||||
{headline}
|
||||
</h2>
|
||||
<div className="flex flex-col justify-between">
|
||||
<p className="mt-5 text-xl leading-7 text-accent-2 text-white">
|
||||
{description}
|
||||
</p>
|
||||
<h2 className={s.title}>{headline}</h2>
|
||||
<div className={s.description}>
|
||||
<p>{description}</p>
|
||||
<Link href="/">
|
||||
<a className="text-white pt-3 font-bold hover:underline flex flex-row cursor-pointer w-max-content">
|
||||
<a className="flex items-center text-accent-0 pt-3 font-bold hover:underline cursor-pointer w-max-content">
|
||||
Read it here
|
||||
<RightArrow width="20" heigh="20" className="ml-1" />
|
||||
<ArrowRight width="20" heigh="20" className="ml-1" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
.root {
|
||||
@apply bg-primary py-2 px-6 w-full appearance-none transition duration-150 ease-in-out pr-10 border border-accents-3 text-accents-6;
|
||||
@apply bg-primary py-2 px-6 w-full appearance-none transition duration-150 ease-in-out pr-10 border border-accent-3 text-accent-6;
|
||||
}
|
||||
|
||||
.root:focus {
|
||||
|
@ -2,12 +2,12 @@ import cn from 'classnames'
|
||||
import s from './Input.module.css'
|
||||
import React, { InputHTMLAttributes } from 'react'
|
||||
|
||||
export interface Props extends InputHTMLAttributes<HTMLInputElement> {
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
className?: string
|
||||
onChange?: (...args: any[]) => any
|
||||
}
|
||||
|
||||
const Input: React.FC<Props> = (props) => {
|
||||
const Input: React.FC<InputProps> = (props) => {
|
||||
const { className, children, onChange, ...rest } = props
|
||||
|
||||
const rootClassName = cn(s.root, {}, className)
|
||||
|
@ -1,23 +1,24 @@
|
||||
.root {
|
||||
@apply inline-flex text-center items-center leading-7;
|
||||
}
|
||||
|
||||
& span {
|
||||
@apply bg-accents-6 rounded-full h-2 w-2;
|
||||
.root .dot {
|
||||
@apply rounded-full h-2 w-2;
|
||||
background-color: currentColor;
|
||||
animation-name: blink;
|
||||
animation-duration: 1.4s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-fill-mode: both;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
&:nth-of-type(2) {
|
||||
.root .dot:nth-of-type(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
&:nth-of-type(3) {
|
||||
.root .dot::nth-of-type(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
|
@ -3,9 +3,9 @@ import s from './LoadingDots.module.css'
|
||||
const LoadingDots: React.FC = () => {
|
||||
return (
|
||||
<span className={s.root}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span className={s.dot} key={`dot_1`} />
|
||||
<span className={s.dot} key={`dot_2`} />
|
||||
<span className={s.dot} key={`dot_3`} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
@ -1,22 +1,22 @@
|
||||
.root {
|
||||
@apply w-full relative;
|
||||
height: 320px;
|
||||
min-width: 100%;
|
||||
@apply w-full min-w-full relative flex flex-row items-center overflow-hidden py-0;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply flex flex-row items-center;
|
||||
.root > div {
|
||||
max-height: 320px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.container > * {
|
||||
@apply relative flex-1 px-16 py-4 h-full;
|
||||
min-height: 320px;
|
||||
.root > div > * > *:nth-child(2) * {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.primary {
|
||||
@apply bg-white;
|
||||
@apply bg-accent-0;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
@apply bg-black;
|
||||
@apply bg-accent-9;
|
||||
}
|
||||
|
@ -1,15 +1,15 @@
|
||||
import cn from 'classnames'
|
||||
import s from './Marquee.module.css'
|
||||
import { FC, ReactNode, Component } from 'react'
|
||||
import Ticker from 'react-ticker'
|
||||
import { FC, ReactNode, Component, Children } from 'react'
|
||||
import { default as FastMarquee } from 'react-fast-marquee'
|
||||
|
||||
interface Props {
|
||||
interface MarqueeProps {
|
||||
className?: string
|
||||
children?: ReactNode[] | Component[] | any[]
|
||||
variant?: 'primary' | 'secondary'
|
||||
}
|
||||
|
||||
const Marquee: FC<Props> = ({
|
||||
const Marquee: FC<MarqueeProps> = ({
|
||||
className = '',
|
||||
children,
|
||||
variant = 'primary',
|
||||
@ -24,11 +24,15 @@ const Marquee: FC<Props> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
<Ticker offset={80}>
|
||||
{() => <div className={s.container}>{children}</div>}
|
||||
</Ticker>
|
||||
</div>
|
||||
<FastMarquee gradient={false} className={rootClassName}>
|
||||
{Children.map(children, (child) => ({
|
||||
...child,
|
||||
props: {
|
||||
...child.props,
|
||||
className: cn(child.props.className, `${variant}`),
|
||||
},
|
||||
}))}
|
||||
</FastMarquee>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,17 @@
|
||||
.root {
|
||||
@apply fixed bg-primary text-primary flex items-center inset-0 z-50 justify-center;
|
||||
background-color: rgba(0, 0, 0, 0.35);
|
||||
@apply fixed bg-black bg-opacity-40 flex items-center inset-0 z-50 justify-center;
|
||||
backdrop-filter: blur(0.8px);
|
||||
-webkit-backdrop-filter: blur(0.8px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply bg-primary p-12 border border-accents-2 relative;
|
||||
@apply bg-primary p-12 border border-accent-2 relative;
|
||||
}
|
||||
|
||||
.modal:focus {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
.close {
|
||||
@apply hover:text-accent-5 transition ease-in-out duration-150 focus:outline-none absolute right-0 top-0 m-6;
|
||||
}
|
||||
|
@ -1,22 +1,20 @@
|
||||
import { FC, useRef, useEffect, useCallback } from 'react'
|
||||
import Portal from '@reach/portal'
|
||||
import s from './Modal.module.css'
|
||||
import FocusTrap from '@lib/focus-trap'
|
||||
import { Cross } from '@components/icons'
|
||||
import {
|
||||
disableBodyScroll,
|
||||
enableBodyScroll,
|
||||
clearAllBodyScrollLocks,
|
||||
enableBodyScroll,
|
||||
} from 'body-scroll-lock'
|
||||
import FocusTrap from '@lib/focus-trap'
|
||||
interface Props {
|
||||
interface ModalProps {
|
||||
className?: string
|
||||
children?: any
|
||||
open?: boolean
|
||||
onClose: () => void
|
||||
onEnter?: () => void | null
|
||||
}
|
||||
|
||||
const Modal: FC<Props> = ({ children, open, onClose, onEnter = null }) => {
|
||||
const Modal: FC<ModalProps> = ({ children, onClose }) => {
|
||||
const ref = useRef() as React.MutableRefObject<HTMLDivElement>
|
||||
|
||||
const handleKey = useCallback(
|
||||
@ -30,36 +28,31 @@ const Modal: FC<Props> = ({ children, open, onClose, onEnter = null }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
if (open) {
|
||||
disableBodyScroll(ref.current)
|
||||
disableBodyScroll(ref.current, { reserveScrollBarGap: true })
|
||||
window.addEventListener('keydown', handleKey)
|
||||
} else {
|
||||
enableBodyScroll(ref.current)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKey)
|
||||
clearAllBodyScrollLocks()
|
||||
if (ref && ref.current) {
|
||||
enableBodyScroll(ref.current)
|
||||
}
|
||||
}, [open, handleKey])
|
||||
clearAllBodyScrollLocks()
|
||||
window.removeEventListener('keydown', handleKey)
|
||||
}
|
||||
}, [handleKey])
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
{open ? (
|
||||
<div className={s.root}>
|
||||
<div className={s.modal} role="dialog" ref={ref}>
|
||||
<button
|
||||
onClick={() => onClose()}
|
||||
aria-label="Close panel"
|
||||
className="hover:text-gray-500 transition ease-in-out duration-150 focus:outline-none absolute right-0 top-0 m-6"
|
||||
className={s.close}
|
||||
>
|
||||
<Cross className="h-6 w-6" />
|
||||
</button>
|
||||
<FocusTrap focusFirst>{children}</FocusTrap>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
|
27
components/ui/Quantity/Quantity.module.css
Normal file
27
components/ui/Quantity/Quantity.module.css
Normal file
@ -0,0 +1,27 @@
|
||||
.actions {
|
||||
@apply flex p-1 border-accent-2 border items-center justify-center
|
||||
w-12 text-accent-7;
|
||||
transition-property: border-color, background, color, transform, box-shadow;
|
||||
|
||||
transition-duration: 0.15s;
|
||||
transition-timing-function: ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.actions:hover {
|
||||
@apply border bg-accent-1 border-accent-3 text-accent-9;
|
||||
transition: border-color;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.actions:focus {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
.actions:disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply bg-transparent px-4 w-full h-full focus:outline-none select-none pointer-events-auto;
|
||||
}
|
62
components/ui/Quantity/Quantity.tsx
Normal file
62
components/ui/Quantity/Quantity.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import React, { FC } from 'react'
|
||||
import s from './Quantity.module.css'
|
||||
import { Cross, Plus, Minus } from '@components/icons'
|
||||
import cn from 'classnames'
|
||||
export interface QuantityProps {
|
||||
value: number
|
||||
increase: () => any
|
||||
decrease: () => any
|
||||
handleRemove: React.MouseEventHandler<HTMLButtonElement>
|
||||
handleChange: React.ChangeEventHandler<HTMLInputElement>
|
||||
max?: number
|
||||
}
|
||||
|
||||
const Quantity: FC<QuantityProps> = ({
|
||||
value,
|
||||
increase,
|
||||
decrease,
|
||||
handleChange,
|
||||
handleRemove,
|
||||
max = 6,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-row h-9">
|
||||
<button className={s.actions} onClick={handleRemove}>
|
||||
<Cross width={20} height={20} />
|
||||
</button>
|
||||
<label className="w-full border-accent-2 border ml-2">
|
||||
<input
|
||||
className={s.input}
|
||||
onChange={(e) =>
|
||||
Number(e.target.value) < max + 1 ? handleChange(e) : () => {}
|
||||
}
|
||||
value={value}
|
||||
type="number"
|
||||
max={max}
|
||||
min="0"
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={decrease}
|
||||
className={s.actions}
|
||||
style={{ marginLeft: '-1px' }}
|
||||
disabled={value <= 1}
|
||||
>
|
||||
<Minus width={18} height={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={increase}
|
||||
className={cn(s.actions)}
|
||||
style={{ marginLeft: '-1px' }}
|
||||
disabled={value < 1 || value >= max}
|
||||
>
|
||||
<Plus width={18} height={18} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Quantity
|
2
components/ui/Quantity/index.ts
Normal file
2
components/ui/Quantity/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default } from './Quantity'
|
||||
export * from './Quantity'
|
0
components/ui/Rating/Rating.module.css
Normal file
0
components/ui/Rating/Rating.module.css
Normal file
27
components/ui/Rating/Rating.tsx
Normal file
27
components/ui/Rating/Rating.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React, { FC } from 'react'
|
||||
import rangeMap from '@lib/range-map'
|
||||
import { Star } from '@components/icons'
|
||||
import cn from 'classnames'
|
||||
|
||||
export interface RatingProps {
|
||||
value: number
|
||||
}
|
||||
|
||||
const Quantity: React.FC<RatingProps> = React.memo(({ value = 5 }) => {
|
||||
return (
|
||||
<div className="flex flex-row py-6 text-accent-9">
|
||||
{rangeMap(5, (i) => (
|
||||
<span
|
||||
key={`star_${i}`}
|
||||
className={cn('inline-block ml-1 ', {
|
||||
'text-accent-5': i >= Math.floor(value),
|
||||
})}
|
||||
>
|
||||
<Star />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default Quantity
|
2
components/ui/Rating/index.ts
Normal file
2
components/ui/Rating/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default } from './Rating'
|
||||
export * from './Rating'
|
@ -1,3 +1,14 @@
|
||||
.root {
|
||||
@apply fixed inset-0 overflow-hidden h-full z-50;
|
||||
@apply fixed inset-0 h-full z-50 box-border;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@apply h-full flex flex-col text-base bg-accent-0 shadow-xl overflow-y-auto overflow-x-hidden;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
@apply absolute inset-0 bg-black bg-opacity-40 duration-100 ease-linear;
|
||||
backdrop-filter: blur(0.8px);
|
||||
-webkit-backdrop-filter: blur(0.8px);
|
||||
}
|
||||
|
@ -1,54 +1,45 @@
|
||||
import s from './Sidebar.module.css'
|
||||
import Portal from '@reach/portal'
|
||||
import { FC, useEffect, useRef } from 'react'
|
||||
import s from './Sidebar.module.css'
|
||||
import cn from 'classnames'
|
||||
import {
|
||||
disableBodyScroll,
|
||||
enableBodyScroll,
|
||||
clearAllBodyScrollLocks,
|
||||
} from 'body-scroll-lock'
|
||||
|
||||
interface Props {
|
||||
interface SidebarProps {
|
||||
children: any
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Sidebar: FC<Props> = ({ children, open = false, onClose }) => {
|
||||
const Sidebar: FC<SidebarProps> = ({ children, onClose }) => {
|
||||
const ref = useRef() as React.MutableRefObject<HTMLDivElement>
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
if (open) {
|
||||
disableBodyScroll(ref.current)
|
||||
} else {
|
||||
enableBodyScroll(ref.current)
|
||||
}
|
||||
disableBodyScroll(ref.current, { reserveScrollBarGap: true })
|
||||
}
|
||||
return () => {
|
||||
if (ref && ref.current) {
|
||||
enableBodyScroll(ref.current)
|
||||
}
|
||||
clearAllBodyScrollLocks()
|
||||
}
|
||||
}, [open])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
{open ? (
|
||||
<div className={s.root} ref={ref}>
|
||||
<div className={cn(s.root)}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<section className="absolute inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16 outline-none">
|
||||
<div className="h-full md:w-screen md:max-w-md">
|
||||
<div className="h-full flex flex-col text-base bg-accents-1 shadow-xl overflow-y-auto">
|
||||
<div className={s.backdrop} onClick={onClose} />
|
||||
<section className="absolute inset-y-0 right-0 max-w-full flex outline-none pl-10">
|
||||
<div className="h-full w-full md:w-screen md:max-w-md">
|
||||
<div className={s.sidebar} ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -2,10 +2,10 @@
|
||||
@apply block;
|
||||
background-image: linear-gradient(
|
||||
270deg,
|
||||
var(--accents-1),
|
||||
var(--accents-2),
|
||||
var(--accents-2),
|
||||
var(--accents-1)
|
||||
var(--accent-0),
|
||||
var(--accent-2),
|
||||
var(--accent-0),
|
||||
var(--accent-1)
|
||||
);
|
||||
background-size: 400% 100%;
|
||||
animation: loading 8s ease-in-out infinite;
|
||||
@ -28,10 +28,10 @@
|
||||
z-index: 100;
|
||||
background-image: linear-gradient(
|
||||
270deg,
|
||||
var(--accents-1),
|
||||
var(--accents-2),
|
||||
var(--accents-2),
|
||||
var(--accents-1)
|
||||
var(--accent-0),
|
||||
var(--accent-2),
|
||||
var(--accent-0),
|
||||
var(--accent-1)
|
||||
);
|
||||
background-size: 400% 100%;
|
||||
animation: loading 8s ease-in-out infinite;
|
||||
|
@ -3,17 +3,17 @@ import cn from 'classnames'
|
||||
import px from '@lib/to-pixels'
|
||||
import s from './Skeleton.module.css'
|
||||
|
||||
interface Props {
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
boxHeight?: string | number
|
||||
style?: CSSProperties
|
||||
interface SkeletonProps {
|
||||
show?: boolean
|
||||
block?: boolean
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
boxHeight?: string | number
|
||||
}
|
||||
|
||||
const Skeleton: React.FC<Props> = ({
|
||||
const Skeleton: React.FC<SkeletonProps> = ({
|
||||
style,
|
||||
width,
|
||||
height,
|
||||
|
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