Merge branch 'master' of https://github.com/okbel/e-comm-example into image-component

This commit is contained in:
Luis Alvarez 2020-10-22 19:00:19 -05:00
commit a54f1a2cb8
65 changed files with 846 additions and 353 deletions

View File

@ -1,5 +1,3 @@
@import './font.css';
:root {
--primary: white;
--primary-2: #f1f3f5;
@ -14,14 +12,17 @@
--hover: rgba(0, 0, 0, 0.075);
--cyan: #50e3c2;
--cyan: #22b8cf;
--green: #37b679;
--red: #da3c3c;
--pink: #ff0080;
--pink: #e64980;
--purple: #f81ce5;
--violet: #7928ca;
--blue: #0070f3;
--violet-light: #7048e8;
--violet: #5f3dc4;
--accents-0: #f8f9fa;
--accents-1: #f1f3f5;
--accents-2: #e9ecef;
@ -58,10 +59,6 @@
--accents-9: #f8f9fa;
}
.fit {
min-height: calc(100vh - 88px - 41px);
}
*,
*:before,
*:after {

3
assets/components.css Normal file
View File

@ -0,0 +1,3 @@
.fit {
min-height: calc(100vh - 88px - 41px);
}

8
assets/main.css Normal file
View File

@ -0,0 +1,8 @@
@import 'tailwindcss/base';
@import './font.css';
@import './base.css';
@import 'tailwindcss/components';
@import './components.css';
@import 'tailwindcss/utilities';

View File

@ -1,2 +0,0 @@
@tailwind base;
@tailwind components;

View File

@ -1 +0,0 @@
@tailwind utilities;

View File

@ -12,7 +12,7 @@ const Avatar: FC<Props> = ({}) => {
return (
<div
className="inline-block h-8 w-8 rounded-full border-2 border-accents-2"
className="inline-block h-8 w-8 rounded-full border-2 border-primary hover:border-secondary transition linear-out duration-100"
style={{
backgroundImage: `linear-gradient(140deg, ${bg[0]}, ${bg[1]} 100%)`,
}}

View File

@ -1,9 +0,0 @@
.separator {
@apply mx-3 bg-secondary;
width: 1px;
height: 20px;
}
.separator:before {
content: '';
}

View File

@ -1,6 +1,5 @@
import cn from 'classnames'
import { FC } from 'react'
import s from './Featurebar.module.css'
interface Props {
className?: string
@ -18,15 +17,16 @@ const Featurebar: FC<Props> = ({
hide,
}) => {
const rootClassName = cn(
'transition-transform transform duration-500 ease-out p-6 bg-primary text-base text-sm md:flex flex-row justify-center items-center font-medium fixed bottom-0 w-full z-10',
'transition-transform transform duration-500 text-center ease-out p-6 bg-primary text-base text-sm md:flex md:text-left flex-row justify-center items-center font-medium fixed bottom-0 w-full z-10',
{ 'translate-y-full': hide },
className
)
return (
<div className={rootClassName}>
<span>{title}</span>
<span className={s.separator} />
<span>{description}</span>
<span className="block md:inline">{title}</span>
<span className="block mb-6 md:inline md:mb-0 md:ml-2">
{description}
</span>
{action && action}
</div>
)

View File

@ -3,7 +3,8 @@ import cn from 'classnames'
import Link from 'next/link'
import type { Page } from '@lib/bigcommerce/api/operations/get-all-pages'
import getSlug from '@utils/get-slug'
import { Logo } from '@components/ui'
import { Logo, Container } from '@components/ui'
import { Github, DoubleChevron } from '@components/icon'
interface Props {
className?: string
@ -14,53 +15,100 @@ interface Props {
const LEGAL_PAGES = ['terms-of-use', 'shipping-returns', 'privacy-policy']
const Footer: FC<Props> = ({ className, pages }) => {
const rootClassName = cn(
'flex flex-col p-6 md:py-12 md:flex-row flex-wrap max-w-screen-xl m-auto',
className
)
const rootClassName = cn(className)
const { sitePages, legalPages } = getPages(pages)
return (
<div className="bg-black text-white">
<footer className={rootClassName}>
<Link href="/">
<a className="flex flex-initial items-center md:items-start font-bold md:mr-24">
<span className="rounded-full border border-gray-700 mr-2">
<Logo />
</span>
<span>ACME</span>
</a>
</Link>
<ul className="flex flex-initial flex-col divide-y divide-gray-700 md:divide-y-0 my-12 md:my-0 md:flex-1">
{sitePages.map((page) => (
<li key={page.url} className="py-3 md:py-0 md:pb-4">
<Link href={page.url!}>
<a className="text-gray-400 hover:text-white transition ease-in-out duration-100">
{page.name}
</a>
</Link>
</li>
))}
</ul>
<ul className="flex flex-initial flex-col divide-y divide-gray-700 md:divide-y-0 my-12 md:my-0 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-gray-400 hover:text-white transition ease-in-out duration-100">
{page.name}
</a>
</Link>
</li>
))}
</ul>
<small className="text-base">
&copy; 2020 ACME, Inc. All rights reserved.
</small>
</footer>
</div>
<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">
<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">
<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-gray-400 hover:text-white transition ease-in-out duration-100">
Home
</a>
</Link>
</li>
<li className="py-3 md:py-0 md:pb-4">
<Link href="/">
<a className="text-gray-400 hover:text-white transition ease-in-out duration-100">
Careers
</a>
</Link>
</li>
<li className="py-3 md:py-0 md:pb-4">
<Link href="/blog">
<a className="text-gray-400 hover:text-white transition ease-in-out duration-100">
Blog
</a>
</Link>
</li>
{sitePages.map((page) => (
<li key={page.url} className="py-3 md:py-0 md:pb-4">
<Link href={page.url!}>
<a className="text-gray-400 hover:text-white transition ease-in-out duration-100">
{page.name}
</a>
</Link>
</li>
))}
</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-gray-400 hover:text-white transition ease-in-out duration-100">
{page.name}
</a>
</Link>
</li>
))}
</ul>
</div>
<div className="col-span-1 lg:col-span-6 flex items-start lg:justify-end text-primary">
<div className="flex space-x-6 items-center h-10">
<Github />
<div className="h-10 px-2 rounded-md border border-accents-2 flex items-center space-x-2 justify-center">
<img className="" src="/flag-us.png" />
<span>English</span>
<span className="">
<DoubleChevron />
</span>
</div>
</div>
</div>
</div>
<div className="py-12 flex flex-col md:flex-row justify-between items-center space-y-4">
<div>
<span>&copy; 2020 ACME, Inc. All rights reserved.</span>
</div>
<div className="flex items-center text-accents-4">
<span>Crafted by</span>
<a href="https://vercel.com">
<img
src="/vercel.png"
alt="Vercel.com Logo"
className="inline-block h-6 ml-4"
/>
</a>
</div>
</div>
</Container>
</footer>
)
}

View File

@ -33,12 +33,12 @@ const Layout: FC<Props> = ({ children, pageProps }) => {
<Featurebar
title="This site uses cookies to improve your experience."
description="By clicking, you agree to our Privacy Policy."
hide={acceptedCookies}
action={
<Button className="mx-5" onClick={() => setAcceptedCookies(true)}>
Accept cookies
</Button>
}
className={cn({ ['translate-y-full']: acceptedCookies })}
/>
</div>
</CommerceProvider>

View File

@ -1,7 +1,6 @@
import s from './Navbar.module.css'
import { FC } from 'react'
import Link from 'next/link'
import { useTheme } from 'next-themes'
import { Logo } from '@components/ui'
import { Searchbar, UserNav } from '@components/core'
interface Props {

View File

@ -0,0 +1,16 @@
.dropdownMenu {
@apply fixed right-0 mt-7 origin-top-right outline-none bg-primary z-40 w-full h-full;
@screen lg {
@apply absolute border border-accents-1 shadow-lg w-56 h-auto;
}
& .link {
@apply flex cursor-pointer px-6 py-3 block hover:bg-accents-1 transition ease-in-out duration-150 text-base leading-6 font-medium text-gray-900 items-center;
text-transform: capitalize;
}
&.off {
@apply hidden;
}
}

View File

@ -0,0 +1,76 @@
import { FC } from 'react'
import Link from 'next/link'
import { useTheme } from 'next-themes'
import cn from 'classnames'
import s from './DropdownMenu.module.css'
import { Moon, Sun } from '@components/icon'
import { Menu, Transition } from '@headlessui/react'
import { usePreventScroll } from '@react-aria/overlays'
interface DropdownMenuProps {
onClose: () => void
open: boolean
}
const DropdownMenu: FC<DropdownMenuProps> = ({
onClose,
children,
open = false,
...props
}) => {
const { theme, setTheme } = useTheme()
usePreventScroll({
isDisabled: !open,
})
return (
<Transition
show={open}
enter="transition ease-out duration-100 z-20"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className={s.dropdownMenu}>
<Menu.Item>
{({ active }) => <a className={s.link}>My Purchases</a>}
</Menu.Item>
<Menu.Item>
{({ active }) => <a className={s.link}>My Account</a>}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
className={cn(s.link, 'justify-between')}
onClick={() =>
theme === 'dark' ? setTheme('light') : setTheme('dark')
}
>
<div>
Theme: <strong>{theme}</strong>{' '}
</div>
<div className="ml-3">
{theme == 'dark' ? (
<Moon width={20} height={20} />
) : (
<Sun width="20" height={20} />
)}
</div>
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a className={cn(s.link, 'border-t border-accents-2 mt-4')}>
Logout
</a>
)}
</Menu.Item>
</Menu.Items>
</Transition>
)
}
export default DropdownMenu

View File

@ -13,35 +13,14 @@
@apply mr-6 cursor-pointer relative transition ease-in-out duration-100 text-base flex items-center;
&:hover {
@apply text-accents-8;
@apply text-accents-8 transition scale-110 duration-100;
}
&:last-child {
@apply mr-0;
}
}
.dropdownMenu {
@apply bg-primary fixed right-0 z-50 w-full h-full;
@screen lg {
@apply absolute mt-3 right-0 w-screen;
max-width: 160px;
}
& .dropdownMenuContainer {
@apply flex-col py-6 bg-primary h-full justify-around;
@screen lg {
@apply border border-accents-1 shadow-lg py-2 h-auto;
}
}
& .link {
@apply cursor-pointer px-6 py-3 block space-y-1 hover:bg-accents-1 transition ease-in-out duration-150 text-base leading-6 font-medium text-gray-900;
}
&.off {
@apply hidden;
&:focus {
outline: none;
}
}

View File

@ -1,18 +1,12 @@
import Link from 'next/link'
import cn from 'classnames'
import s from './UserNav.module.css'
import { FC, useState, useRef, useCallback } from 'react'
import { useTheme } from 'next-themes'
import { FC, useRef } from 'react'
import { Avatar } from '@components/core'
import { Heart, Bag } from '@components/icon'
import { useUI } from '@components/ui/context'
import { FocusScope } from '@react-aria/focus'
import {
useOverlay,
DismissButton,
usePreventScroll,
} from '@react-aria/overlays'
import DropdownMenu from './DropdownMenu'
import { Menu } from '@headlessui/react'
import useCart from '@lib/bigcommerce/cart/use-cart'
interface Props {
@ -25,12 +19,18 @@ const countItems = (count: number, items: any[]) =>
const UserNav: FC<Props> = ({ className, children, ...props }) => {
const { data } = useCart()
const { openSidebar, closeSidebar, displaySidebar } = useUI()
const [displayDropdown, setDisplayDropdown] = useState(false)
const {
openSidebar,
closeSidebar,
displaySidebar,
displayDropdown,
openDropdown,
closeDropdown,
} = useUI()
const itemsCount = Object.values(data?.line_items ?? {}).reduce(countItems, 0)
let ref = useRef() as React.MutableRefObject<HTMLInputElement>
const toggleDropdown = () => setDisplayDropdown((v) => !v)
return (
<nav className={cn(s.root, className)}>
<div className={s.mainContainer}>
@ -51,71 +51,22 @@ const UserNav: FC<Props> = ({ className, children, ...props }) => {
<Heart />
</li>
</Link>
<li className={s.item} onClick={() => toggleDropdown()}>
<Avatar />
<li className={s.item}>
<Menu>
{({ open }) => (
<>
<Menu.Button className="inline-flex justify-center rounded-full">
<Avatar />
</Menu.Button>
<DropdownMenu onClose={closeDropdown} open={open} />
</>
)}
</Menu>
</li>
</ul>
</div>
<DismissButton onDismiss={() => setDisplayDropdown(false)} />
{displayDropdown && (
<DropdownMenu
onClose={() => setDisplayDropdown(false)}
innerRef={ref}
/>
)}
</nav>
)
}
interface DropdownMenuProps {
onClose: () => void
innerRef: React.MutableRefObject<HTMLInputElement>
}
const DropdownMenu: FC<DropdownMenuProps> = ({
onClose,
children,
innerRef,
...props
}) => {
const { theme, setTheme } = useTheme()
let { overlayProps } = useOverlay(
{
onClose: onClose,
isOpen: true,
},
innerRef
)
usePreventScroll()
return (
<FocusScope contain restoreFocus autoFocus>
<div className={cn(s.dropdownMenu)} ref={innerRef} {...overlayProps}>
<nav className={s.dropdownMenuContainer}>
<Link href="#">
<a className={s.link}>My Purchases</a>
</Link>
<Link href="#">
<a className={s.link}>My Account</a>
</Link>
<a
className={s.link}
onClick={() =>
theme === 'dark' ? setTheme('light') : setTheme('dark')
}
>
Theme: <strong>{theme}</strong>
</a>
<Link href="#">
<a className={cn(s.link, 'border-t border-accents-2 mt-4')}>
Logout
</a>
</Link>
</nav>
</div>
</FocusScope>
)
}
export default UserNav

View File

@ -0,0 +1,22 @@
const DoubleChevron = ({ ...props }) => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M16 8.90482L12 4L8 8.90482M8 15.0952L12 20L16 15.0952"
stroke="white"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
)
}
export default DoubleChevron

View File

@ -0,0 +1,20 @@
const Sun = ({ ...props }) => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 0C5.37 0 0 5.50583 0 12.3035C0 17.7478 3.435 22.3463 8.205 23.9765C8.805 24.0842 9.03 23.715 9.03 23.3921C9.03 23.0999 9.015 22.131 9.015 21.1005C6 21.6696 5.22 20.347 4.98 19.6549C4.845 19.3012 4.26 18.2092 3.75 17.917C3.33 17.6863 2.73 17.1173 3.735 17.1019C4.68 17.0865 5.355 17.9939 5.58 18.363C6.66 20.2239 8.385 19.701 9.075 19.3781C9.18 18.5783 9.495 18.04 9.84 17.7325C7.17 17.4249 4.38 16.3637 4.38 11.6576C4.38 10.3196 4.845 9.21227 5.61 8.35102C5.49 8.04343 5.07 6.78232 5.73 5.09058C5.73 5.09058 6.735 4.76762 9.03 6.3517C9.99 6.07487 11.01 5.93645 12.03 5.93645C13.05 5.93645 14.07 6.07487 15.03 6.3517C17.325 4.75224 18.33 5.09058 18.33 5.09058C18.99 6.78232 18.57 8.04343 18.45 8.35102C19.215 9.21227 19.68 10.3042 19.68 11.6576C19.68 16.3791 16.875 17.4249 14.205 17.7325C14.64 18.1169 15.015 18.8552 15.015 20.0086C15.015 21.6542 15 22.9768 15 23.3921C15 23.715 15.225 24.0995 15.825 23.9765C18.2072 23.1519 20.2773 21.5822 21.7438 19.4882C23.2103 17.3942 23.9994 14.8814 24 12.3035C24 5.50583 18.63 0 12 0Z"
fill="currentColor"
/>
</svg>
)
}
export default Sun

20
components/icon/Moon.tsx Normal file
View File

@ -0,0 +1,20 @@
const Moon = ({ ...props }) => {
return (
<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
shape-rendering="geometricPrecision"
{...props}
>
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
</svg>
)
}
export default Moon

28
components/icon/Sun.tsx Normal file
View File

@ -0,0 +1,28 @@
const Sun = ({ ...props }) => {
return (
<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
shape-rendering="geometricPrecision"
{...props}
>
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2" />
<path d="M12 21v2" />
<path d="M4.22 4.22l1.42 1.42" />
<path d="M18.36 18.36l1.42 1.42" />
<path d="M1 12h2" />
<path d="M21 12h2" />
<path d="M4.22 19.78l1.42-1.42" />
<path d="M18.36 5.64l1.42-1.42" />
</svg>
)
}
export default Sun

View File

@ -6,3 +6,7 @@ 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'

View File

@ -2,8 +2,12 @@
@apply relative w-full box-border overflow-hidden bg-no-repeat bg-center bg-cover transition ease-linear cursor-pointer;
&:hover {
& .squareBg {
@apply scale-75;
& .squareBg:before {
transform: scale(0.98);
}
& .product-image {
transform: scale(1.05);
}
& .productTitle > span,
@ -37,6 +41,10 @@
}
}
& .product-image {
@apply transform transition-transform duration-500;
}
&:nth-child(6n + 1) .squareBg {
@apply bg-violet;
}
@ -58,16 +66,23 @@
.productTitle > span,
.productPrice,
.wishlistButton {
@apply transition ease-in-out duration-300;
@apply transition ease-in-out duration-500;
}
.squareBg {
@apply transform absolute inset-0 z-0 bg-secondary;
@apply transform 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;
content: '';
background-image: url("data:image/svg+xml,%3Csvg width='48' height='46' viewBox='0 0 48 46' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cline opacity='0.1' x1='9.41421' y1='8' x2='21' y2='19.5858' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cline opacity='0.1' x1='1' y1='-1' x2='17.3848' y2='-1' transform='matrix(-0.707107 0.707107 0.707107 0.707107 40 8)' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cline opacity='0.1' x1='1' y1='-1' x2='17.3848' y2='-1' transform='matrix(0.707107 -0.707107 -0.707107 -0.707107 8 38)' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cline opacity='0.1' x1='38.5858' y1='38' x2='27' y2='26.4142' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A");
}
.simple {
& .squareBg {
@apply bg-gray-300 !important;
@apply bg-accents-0 !important;
}
& .productTitle {
@ -102,5 +117,5 @@
}
.wishlistButton {
@apply w-10 h-10 flex items-center justify-center bg-primary text-base font-semibold inline-block text-xs leading-6 cursor-pointer;
@apply w-10 h-10 flex ml-auto items-center justify-center bg-primary text-base font-semibold inline-block text-xs leading-6 cursor-pointer;
}

View File

@ -62,12 +62,14 @@ const ProductCard: FC<Props> = ({
<Heart />
</div>
</div>
<Image
src={src}
width={imgWidth}
height={imgHeight}
priority={priority}
/>
<div className="absolute z-10 inset-0 flex items-center justify-center">
<Image
src={src}
width={imgWidth}
height={imgHeight}
priority={priority}
/>
</div>
</a>
</Link>
)

View File

@ -7,7 +7,7 @@
}
.productDisplay {
@apply relative flex px-0 pb-0 relative box-border col-span-7;
@apply relative flex px-0 pb-0 relative box-border col-span-1 bg-violet;
margin-right: -2rem;
margin-left: -2rem;
min-height: 400px;
@ -17,7 +17,7 @@
}
@screen lg {
@apply mx-0;
@apply mx-0 col-span-7;
min-height: 100%;
height: 100%;
}
@ -34,7 +34,7 @@
}
.nameBox {
@apply absolute top-6 left-0 z-50;
@apply absolute top-6 left-10 z-10;
& .name {
@apply px-6 py-2 bg-primary text-primary font-bold;
@ -49,15 +49,16 @@
@screen md {
& .name,
& .price {
@apply bg-violet text-white;
@apply bg-violet-light text-white;
}
}
}
.sidebar {
@apply flex flex-col col-span-5;
@apply flex flex-col col-span-1;
@screen lg {
@apply col-span-5;
padding-top: 5rem;
}
}
@ -75,13 +76,8 @@
}
@screen lg {
height: 150%;
margin-top: -10%;
}
@screen xl {
height: 170%;
margin-top: -19%;
height: 100%;
margin-top: -8%;
}
}

View File

@ -1,17 +1,17 @@
import { FC, useState, useEffect } from 'react'
import cn from 'classnames'
import Image from 'next/image'
import { NextSeo } from 'next-seo'
import type { ProductNode } from '@lib/bigcommerce/api/operations/get-product'
import useAddItem from '@lib/bigcommerce/cart/use-add-item'
import bcImageSrc from '@lib/bc-image-src'
import getPathname from '@lib/get-pathname'
import s from './ProductView.module.css'
import { FC, useState, useEffect } from 'react'
import { useUI } from '@components/ui/context'
import { Button, Container } from '@components/ui'
import { Swatch, ProductSlider } from '@components/product'
import { getProductOptions } from '../helpers'
import s from './ProductView.module.css'
import getPathname from '@lib/get-pathname'
import useAddItem from '@lib/bigcommerce/cart/use-add-item'
import { isDesktop } from '@lib/browser'
import type { ProductNode } from '@lib/bigcommerce/api/operations/get-product'
import { getProductOptions } from '../helpers'
import bcImageSrc from '@lib/bc-image-src'
interface Props {
className?: string
@ -73,7 +73,6 @@ const ProductView: FC<Props> = ({ product, className }) => {
/>
<div className={cn(s.root, 'fit')}>
<div className={cn(s.productDisplay, 'fit')}>
<div className={s.squareBg}></div>
<div className={s.nameBox}>
<h1 className={s.name}>{product.name}</h1>
<div className={s.price}>
@ -138,7 +137,7 @@ const ProductView: FC<Props> = ({ product, className }) => {
))}
<div className="pb-12">
<div
className="pb-14 break-words"
className="pb-14 break-words w-full"
dangerouslySetInnerHTML={{ __html: product.description }}
/>
<Button

View File

@ -8,7 +8,7 @@ interface Props {
}
const Container: FC<Props> = ({ children, className, el = 'div' }) => {
const rootClassName = cn('mx-auto max-w-7xl px-6', className)
const rootClassName = cn('mx-auto max-w-8xl px-12', className)
let Component: React.ComponentType<React.HTMLAttributes<
HTMLDivElement

View File

@ -1,8 +1,8 @@
.root {
@apply fixed bg-black flex items-center inset-0 z-50 justify-center;
@apply fixed bg-primary text-primary flex items-center inset-0 z-50 justify-center;
background-color: rgba(0, 0, 0, 0.35);
}
.modal {
@apply bg-white p-12;
@apply bg-primary p-12 border border-accents-2;
}

View File

@ -2,13 +2,7 @@ import cn from 'classnames'
import { FC, useRef } from 'react'
import s from './Modal.module.css'
import { useDialog } from '@react-aria/dialog'
import {
useOverlay,
usePreventScroll,
useModal,
OverlayProvider,
OverlayContainer,
} from '@react-aria/overlays'
import { useOverlay, usePreventScroll, useModal } from '@react-aria/overlays'
import { FocusScope } from '@react-aria/focus'
interface Props {
@ -27,11 +21,14 @@ const Modal: FC<Props> = ({
}) => {
const rootClassName = cn(s.root, className)
let ref = useRef() as React.MutableRefObject<HTMLInputElement>
usePreventScroll()
let { modalProps } = useModal()
let { overlayProps } = useOverlay(props, ref)
let { dialogProps } = useDialog(props, ref)
usePreventScroll({
isDisabled: !show,
})
return (
<div className={rootClassName}>
<FocusScope contain restoreFocus autoFocus>

View File

@ -72,7 +72,7 @@ const Sidebar: FC<Props> = ({ className, children, show = true, close }) => {
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="h-full w-screen max-w-lg">
<div className="h-full w-screen max-w-xl">
<div className="h-full flex flex-col text-base bg-accents-1 shadow-xl overflow-y-auto">
{children}
</div>

View File

@ -1,5 +1,5 @@
.skeleton {
@apply block rounded;
@apply block;
background-image: linear-gradient(
270deg,
var(--accents-1),

View File

@ -4,10 +4,12 @@ import { SSRProvider, OverlayProvider } from 'react-aria'
export interface State {
displaySidebar: boolean
displayDropdown: boolean
}
const initialState = {
displaySidebar: false,
displayDropdown: false,
}
type Action =
@ -17,21 +19,61 @@ type Action =
| {
type: 'CLOSE_SIDEBAR'
}
| {
type: 'OPEN_DROPDOWN'
}
| {
type: 'CLOSE_DROPDOWN'
}
export const UIContext = React.createContext<State | any>(initialState)
UIContext.displayName = 'UIContext'
function uiReducer(state: State, action: Action) {
switch (action.type) {
case 'OPEN_SIDEBAR': {
return {
...state,
displaySidebar: true,
}
}
case 'CLOSE_SIDEBAR': {
return {
...state,
displaySidebar: false,
}
}
case 'OPEN_DROPDOWN': {
return {
...state,
displayDropdown: true,
}
}
case 'CLOSE_DROPDOWN': {
return {
...state,
displayDropdown: false,
}
}
}
}
export const UIProvider: FC = (props) => {
const [state, dispatch] = React.useReducer(uiReducer, initialState)
const openSidebar = () => dispatch({ type: 'OPEN_SIDEBAR' })
const closeSidebar = () => dispatch({ type: 'CLOSE_SIDEBAR' })
const openDropdown = () => dispatch({ type: 'OPEN_DROPDOWN' })
const closeDropdown = () => dispatch({ type: 'CLOSE_DROPDOWN' })
const value = {
...state,
openSidebar,
closeSidebar,
openDropdown,
closeDropdown,
}
return <UIContext.Provider value={value} {...props} />
@ -45,27 +87,6 @@ export const useUI = () => {
return context
}
function uiReducer(state: State, action: Action) {
switch (action.type) {
case 'OPEN_SIDEBAR': {
return !state.displaySidebar
? {
...state,
displaySidebar: true,
}
: state
}
case 'CLOSE_SIDEBAR': {
return state.displaySidebar
? {
...state,
displaySidebar: false,
}
: state
}
}
}
export const ManagedUIContext: FC = ({ children }) => (
<UIProvider>
<ThemeProvider>

View File

@ -26,7 +26,7 @@ const getLoggedInCustomer: CustomersHandlers['getLoggedInCustomer'] = async ({
res,
config,
}) => {
const data = await config.fetch<GetLoggedInCustomerQuery>(
const { data } = await config.fetch<GetLoggedInCustomerQuery>(
getLoggedInCustomerQuery
)
const { customer } = data

View File

@ -1,6 +1,9 @@
import { FetcherError } from '@lib/commerce/utils/errors'
import login from '../../operations/login'
import type { LoginHandlers } from '../login'
const invalidCredentials = /invalid credentials/i
const loginHandler: LoginHandlers['login'] = async ({
res,
body: { email, password },
@ -17,8 +20,28 @@ const loginHandler: LoginHandlers['login'] = async ({
// Passwords must be at least 7 characters and contain both alphabetic
// and numeric characters.
// TODO: Currently not working, fix this asap.
const loginData = await login({ variables: { email, password }, config })
try {
await login({ variables: { email, password }, config, res })
} catch (error) {
// Check if the email and password didn't match an existing account
if (
error instanceof FetcherError &&
invalidCredentials.test(error.message)
) {
return res.status(401).json({
data: null,
errors: [
{
message:
'Cannot find an account that matches the provided credentials',
code: 'invalid_credentials',
},
],
})
}
throw error
}
res.status(200).json({ data: null })
}

View File

@ -0,0 +1,23 @@
import { serialize } from 'cookie'
import { LogoutHandlers } from '../logout'
const logoutHandler: LogoutHandlers['logout'] = async ({
res,
body: { redirectTo },
config,
}) => {
// Remove the cookie
res.setHeader(
'Set-Cookie',
serialize(config.customerCookie, '', { maxAge: -1, path: '/' })
)
// Only allow redirects to a relative URL
if (redirectTo?.startsWith('/')) {
res.redirect(redirectTo)
} else {
res.status(200).json({ data: null })
}
}
export default logoutHandler

View File

@ -18,51 +18,45 @@ const signup: SignupHandlers['signup'] = async ({
// Passwords must be at least 7 characters and contain both alphabetic
// and numeric characters.
let result: { data?: any } = {}
try {
await config.storeApiFetch('/v3/customers', {
method: 'POST',
body: JSON.stringify([
{
first_name: firstName,
last_name: lastName,
email,
authentication: {
new_password: password,
},
},
]),
})
} catch (error) {
if (error instanceof BigcommerceApiError && error.status === 422) {
const hasEmailError = '0.email' in error.data?.errors
// try {
// result = await config.storeApiFetch('/v3/customers', {
// method: 'POST',
// body: JSON.stringify([
// {
// first_name: firstName,
// last_name: lastName,
// email,
// authentication: {
// new_password: password,
// },
// },
// ]),
// })
// } catch (error) {
// if (error instanceof BigcommerceApiError && error.status === 422) {
// const hasEmailError = '0.email' in error.data?.errors
// If there's an error with the email, it most likely means it's duplicated
if (hasEmailError) {
return res.status(400).json({
data: null,
errors: [
{
message: 'The email is already in use',
code: 'duplicated_email',
},
],
})
}
}
// // If there's an error with the email, it most likely means it's duplicated
// if (hasEmailError) {
// return res.status(400).json({
// data: null,
// errors: [
// {
// message: 'The email is already in use',
// code: 'duplicated_email',
// },
// ],
// })
// }
// }
throw error
}
// throw error
// }
// Login the customer right after creating it
await login({ variables: { email, password }, res, config })
console.log('DATA', result.data)
// TODO: Currently not working, fix this asap.
const loginData = await login({ variables: { email, password }, config })
console.log('LOGIN DATA', loginData)
res.status(200).json({ data: result.data ?? null })
res.status(200).json({ data: null })
}
export default signup

View File

@ -0,0 +1,42 @@
import createApiHandler, {
BigcommerceApiHandler,
BigcommerceHandler,
} from '../utils/create-api-handler'
import isAllowedMethod from '../utils/is-allowed-method'
import { BigcommerceApiError } from '../utils/errors'
import logout from './handlers/logout'
export type LogoutHandlers = {
logout: BigcommerceHandler<null, { redirectTo?: string }>
}
const METHODS = ['GET']
const logoutApi: BigcommerceApiHandler<null, LogoutHandlers> = async (
req,
res,
config,
handlers
) => {
if (!isAllowedMethod(req, res, METHODS)) return
try {
const redirectTo = req.query.redirect_to
const body = typeof redirectTo === 'string' ? { redirectTo } : {}
return await handlers['logout']({ req, res, config, body })
} catch (error) {
console.error(error)
const message =
error instanceof BigcommerceApiError
? 'An unexpected error ocurred with the Bigcommerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
const handlers = { logout }
export default createApiHandler(logoutApi, handlers, {})

View File

@ -66,9 +66,12 @@ if (!(STORE_API_URL && STORE_API_TOKEN && STORE_API_CLIENT_ID)) {
export class Config {
private config: BigcommerceConfig
constructor(config: BigcommerceConfigOptions) {
constructor(config: Omit<BigcommerceConfigOptions, 'customerCookie'>) {
this.config = {
...config,
// The customerCookie is not customizable for now, BC sets the cookie and it's
// not important to rename it
customerCookie: 'SHOP_TOKEN',
imageVariables: this.getImageVariables(config.images),
}
}

View File

@ -49,9 +49,9 @@ async function getAllProductPaths({
config = getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const data = await config.fetch<RecursivePartial<GetAllProductPathsQuery>>(
query
)
const { data } = await config.fetch<
RecursivePartial<GetAllProductPathsQuery>
>(query)
const products = data.site?.products?.edges
return {

View File

@ -111,7 +111,7 @@ async function getAllProducts({
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const data = await config.fetch<RecursivePartial<GetAllProductsQuery>>(
const { data } = await config.fetch<RecursivePartial<GetAllProductsQuery>>(
query,
{ variables }
)

View File

@ -71,9 +71,10 @@ async function getProduct({
...vars,
path: slug ? `/${slug}/` : vars.path!,
}
const data = await config.fetch<RecursivePartial<GetProductQuery>>(query, {
variables,
})
const { data } = await config.fetch<RecursivePartial<GetProductQuery>>(
query,
{ variables }
)
const product = data.site?.route?.node
if (product?.__typename === 'Product') {

View File

@ -90,9 +90,10 @@ async function getSiteInfo({
config = getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const data = await config.fetch<RecursivePartial<GetSiteInfoQuery>>(query, {
variables,
})
const { data } = await config.fetch<RecursivePartial<GetSiteInfoQuery>>(
query,
{ variables }
)
const categories = data.site?.categoryTree
const brands = data.site?.brands?.edges

View File

@ -1,8 +1,10 @@
import type { ServerResponse } from 'http'
import type {
LoginMutation,
LoginMutationVariables,
} from 'lib/bigcommerce/schema'
import type { RecursivePartial } from '../utils/types'
import concatHeader from '../utils/concat-cookie'
import { BigcommerceConfig, getConfig } from '..'
export const loginMutation = /* GraphQL */ `
@ -20,28 +22,49 @@ export type LoginVariables = LoginMutationVariables
async function login(opts: {
variables: LoginVariables
config?: BigcommerceConfig
res: ServerResponse
}): Promise<LoginResult>
async function login<T extends { result?: any }, V = any>(opts: {
query: string
variables: V
res: ServerResponse
config?: BigcommerceConfig
}): Promise<LoginResult<T>>
async function login({
query = loginMutation,
variables,
res: response,
config,
}: {
query?: string
variables: LoginVariables
res: ServerResponse
config?: BigcommerceConfig
}): Promise<LoginResult> {
config = getConfig(config)
const data = await config.fetch<RecursivePartial<LoginMutation>>(query, {
variables,
})
const { data, res } = await config.fetch<RecursivePartial<LoginMutation>>(
query,
{ variables }
)
// Bigcommerce returns a Set-Cookie header with the auth cookie
let cookie = res.headers.get('Set-Cookie')
if (cookie && typeof cookie === 'string') {
// In development, don't set a secure cookie or the browser will ignore it
if (process.env.NODE_ENV !== 'production') {
cookie = cookie.replace('; Secure', '')
// SameSite=none can't be set unless the cookie is Secure
cookie = cookie.replace('; SameSite=none', '; SameSite=lax')
}
response.setHeader(
'Set-Cookie',
concatHeader(response.getHeader('Set-Cookie'), cookie)!
)
}
return {
result: data.login?.result,

View File

@ -0,0 +1,14 @@
type Header = string | number | string[] | undefined
export default function concatHeader(prev: Header, val: Header) {
if (!val) return prev
if (!prev) return val
if (Array.isArray(prev)) return prev.concat(String(val))
prev = String(prev)
if (Array.isArray(val)) return [prev].concat(val)
return [prev, String(val)]
}

View File

@ -14,7 +14,7 @@ export type BigcommerceApiHandler<
options: Options
) => void | Promise<void>
export type BigcommerceHandler<T = any, Body = any> = (options: {
export type BigcommerceHandler<T = any, Body = null> = (options: {
req: NextApiRequest
res: NextApiResponse<BigcommerceApiResponse<T>>
config: BigcommerceConfig

View File

@ -1,18 +1,22 @@
import { CommerceAPIFetchOptions } from 'lib/commerce/api'
import { FetcherError } from '@lib/commerce/utils/errors'
import type { GraphQLFetcher } from 'lib/commerce/api'
import { getConfig } from '..'
import log from '@lib/logger'
export default async function fetchGraphqlApi<Q, V = any>(
const fetchGraphqlApi: GraphQLFetcher = async (
query: string,
{ variables, preview }: CommerceAPIFetchOptions<V> = {}
): Promise<Q> {
{ variables, preview } = {},
fetchOptions
) => {
// log.warn(query)
const config = getConfig()
const res = await fetch(config.commerceUrl + (preview ? '/preview' : ''), {
...fetchOptions,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiToken}`,
...fetchOptions?.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
@ -20,22 +24,15 @@ export default async function fetchGraphqlApi<Q, V = any>(
}),
})
// console.log('HEADERS', getRawHeaders(res))
const json = await res.json()
if (json.errors) {
console.error(json.errors)
throw new Error('Failed to fetch BigCommerce API')
throw new FetcherError({
errors: json.errors ?? [{ message: 'Failed to fetch Bigcommerce API' }],
status: res.status,
})
}
return json.data
return { data: json.data, res }
}
function getRawHeaders(res: Response) {
const headers: { [key: string]: string } = {}
res.headers.forEach((value, key) => {
headers[key] = value
})
return headers
}
export default fetchGraphqlApi

View File

@ -1,5 +1,6 @@
import { useCallback } from 'react'
import { HookFetcher } from '@lib/commerce/utils/types'
import type { HookFetcher } from '@lib/commerce/utils/types'
import { CommerceError } from '@lib/commerce/utils/errors'
import useCartAddItem from '@lib/commerce/cart/use-add-item'
import type { ItemBody, AddItemBody } from '../api/cart'
import useCart, { Cart } from './use-cart'
@ -20,9 +21,9 @@ export const fetcher: HookFetcher<Cart, AddItemBody> = (
item.quantity &&
(!Number.isInteger(item.quantity) || item.quantity! < 1)
) {
throw new Error(
'The item quantity has to be a valid integer greater than 0'
)
throw new CommerceError({
message: 'The item quantity has to be a valid integer greater than 0',
})
}
return fetch({

View File

@ -1,6 +1,7 @@
import { useCallback } from 'react'
import debounce from 'lodash.debounce'
import { HookFetcher } from '@lib/commerce/utils/types'
import type { HookFetcher } from '@lib/commerce/utils/types'
import { CommerceError } from '@lib/commerce/utils/errors'
import useCartUpdateItem from '@lib/commerce/cart/use-update-item'
import type { ItemBody, UpdateItemBody } from '../api/cart'
import { fetcher as removeFetcher } from './use-remove-item'
@ -24,7 +25,9 @@ export const fetcher: HookFetcher<Cart | null, UpdateItemBody> = (
return removeFetcher(null, { itemId }, fetch)
}
} else if (item.quantity) {
throw new Error('The item quantity has to be a valid integer')
throw new CommerceError({
message: 'The item quantity has to be a valid integer',
})
}
return fetch({

View File

@ -4,6 +4,7 @@ import {
CommerceProvider as CoreCommerceProvider,
useCommerce as useCoreCommerce,
} from 'lib/commerce'
import { FetcherError } from '@lib/commerce/utils/errors'
async function getText(res: Response) {
try {
@ -16,9 +17,9 @@ async function getText(res: Response) {
async function getError(res: Response) {
if (res.headers.get('Content-Type')?.includes('application/json')) {
const data = await res.json()
return data.errors[0]
return new FetcherError({ errors: data.errors, status: res.status })
}
return { message: await getText(res) }
return new FetcherError({ message: await getText(res), status: res.status })
}
export const bigcommerceConfig: CommerceConfig = {

View File

@ -1,5 +1,6 @@
import { useCallback } from 'react'
import { HookFetcher } from '@lib/commerce/utils/types'
import type { HookFetcher } from '@lib/commerce/utils/types'
import { CommerceError } from '@lib/commerce/utils/errors'
import useCommerceLogin from '@lib/commerce/use-login'
import type { LoginBody } from './api/customers/login'
@ -16,9 +17,10 @@ export const fetcher: HookFetcher<null, LoginBody> = (
fetch
) => {
if (!(email && password)) {
throw new Error(
'A first name, last name, email and password are required to login'
)
throw new CommerceError({
message:
'A first name, last name, email and password are required to login',
})
}
return fetch({

View File

@ -0,0 +1,35 @@
import { useCallback } from 'react'
import type { HookFetcher } from '@lib/commerce/utils/types'
import useCommerceLogout from '@lib/commerce/use-logout'
const defaultOpts = {
url: '/api/bigcommerce/customers/logout',
method: 'GET',
}
export const fetcher: HookFetcher<null> = (options, _, fetch) => {
return fetch({
...defaultOpts,
...options,
})
}
export function extendHook(customFetcher: typeof fetcher) {
const useLogout = () => {
const fn = useCommerceLogout<null>(defaultOpts, customFetcher)
return useCallback(
async function login() {
const data = await fn(null)
return data
},
[fn]
)
}
useLogout.extend = extendHook
return useLogout
}
export default extendHook(fetcher)

View File

@ -1,5 +1,6 @@
import { useCallback } from 'react'
import { HookFetcher } from '@lib/commerce/utils/types'
import type { HookFetcher } from '@lib/commerce/utils/types'
import { CommerceError } from '@lib/commerce/utils/errors'
import useCommerceSignup from '@lib/commerce/use-signup'
import type { SignupBody } from './api/customers/signup'
@ -16,9 +17,10 @@ export const fetcher: HookFetcher<null, SignupBody> = (
fetch
) => {
if (!(firstName && lastName && email && password)) {
throw new Error(
'A first name, last name, email and password are required to signup'
)
throw new CommerceError({
message:
'A first name, last name, email and password are required to signup',
})
}
return fetch({

View File

@ -3,14 +3,30 @@ export interface CommerceAPIConfig {
apiToken: string
cartCookie: string
cartCookieMaxAge: number
fetch<Q, V = any>(
customerCookie: string
fetch<Data = any, Variables = any>(
query: string,
queryData?: CommerceAPIFetchOptions<V>
): Promise<Q>
queryData?: CommerceAPIFetchOptions<Variables>,
fetchOptions?: RequestInit
): Promise<GraphQLFetcherResult<Data>>
}
export interface CommerceAPIFetchOptions<V> {
variables?: V
export type GraphQLFetcher<
Data extends GraphQLFetcherResult = GraphQLFetcherResult,
Variables = any
> = (
query: string,
queryData?: CommerceAPIFetchOptions<Variables>,
fetchOptions?: RequestInit
) => Promise<Data>
export interface GraphQLFetcherResult<Data = any> {
data: Data
res: Response
}
export interface CommerceAPIFetchOptions<Variables> {
variables?: Variables
preview?: boolean
}

View File

@ -21,7 +21,7 @@ export type CommerceConfig = { fetcher: Fetcher<any> } & Omit<
>
export type CommerceContextValue = {
fetcherRef: MutableRefObject<any>
fetcherRef: MutableRefObject<Fetcher<any>>
locale: string
cartCookie: string
}

View File

@ -0,0 +1,5 @@
import useAction from './utils/use-action'
const useLogout = useAction
export default useLogout

View File

@ -0,0 +1,40 @@
export type ErrorData = {
message: string
code?: string
}
export type ErrorProps = {
code?: string
} & (
| { message: string; errors?: never }
| { message?: never; errors: ErrorData[] }
)
export class CommerceError extends Error {
code?: string
errors: ErrorData[]
constructor({ message, code, errors }: ErrorProps) {
const error: ErrorData = message
? { message, ...(code ? { code } : {}) }
: errors![0]
super(error.message)
this.errors = message ? [error] : errors!
if (error.code) this.code = error.code
}
}
export class FetcherError extends CommerceError {
status: number
constructor(
options: {
status: number
} & ErrorProps
) {
super(options)
this.status = options.status
}
}

View File

@ -9,7 +9,7 @@ export type FetcherOptions = {
body?: any
}
export type HookFetcher<T, Input> = (
export type HookFetcher<T, Input = null> = (
options: HookFetcherOptions | null,
input: Input,
fetch: Fetcher<T>
@ -22,5 +22,3 @@ export type HookFetcherOptions = {
}
export type HookInput = [string, string | number | undefined][]
export type HookDeps = string | number | undefined[]

View File

@ -2,7 +2,7 @@ import { useCallback } from 'react'
import type { HookFetcher, HookFetcherOptions } from './types'
import { useCommerce } from '..'
export default function useAction<T, Input>(
export default function useAction<T, Input = null>(
options: HookFetcherOptions,
fetcher: HookFetcher<T, Input>
) {

View File

@ -23,6 +23,12 @@ module.exports = {
source: '/checkout',
destination: '/api/bigcommerce/checkout',
},
// The logout is also an action so this route is not required, but it's also another way
// you can allow a logout!
{
source: '/logout',
destination: '/api/bigcommerce/customers/logout?redirect_to=/',
},
]
},
}

View File

@ -34,6 +34,7 @@
"next-seo": "^4.11.0",
"next-themes": "^0.0.4",
"nextjs-progressbar": "^0.0.6",
"postcss-import": "^13.0.0",
"postcss-nesting": "^7.0.1",
"react": "^16.14.0",
"react-aria": "^3.0.0",
@ -43,7 +44,8 @@
"react-swipeable-views": "^0.13.9",
"react-swipeable-views-utils": "^0.14.0-alpha.0",
"react-ticker": "^1.2.2",
"swr": "^0.3.3"
"swr": "^0.3.3",
"tailwindcss": "^1.9"
},
"devDependencies": {
"@graphql-codegen/cli": "^1.17.10",
@ -65,7 +67,6 @@
"postcss-flexbugs-fixes": "^4.2.1",
"postcss-preset-env": "^6.7.0",
"prettier": "^2.1.2",
"tailwindcss": "^1.8.10",
"typescript": "^4.0.3"
},
"resolutions": {

View File

@ -1,6 +1,4 @@
import '@assets/global.css'
import '@assets/tailwind.css'
import '@assets/utils.css'
import '@assets/main.css'
// To be removed
import 'animate.css'

View File

@ -0,0 +1,3 @@
import logoutApi from '@lib/bigcommerce/api/customers/logout'
export default logoutApi()

View File

@ -1,28 +1,75 @@
import useSignup from '@lib/bigcommerce/use-signup'
import { Layout } from '@components/core'
import { Logo, Modal, Button } from '@components/ui'
import useLogin from '@lib/bigcommerce/use-login'
import useLogout from '@lib/bigcommerce/use-logout'
export default function Login() {
const signup = useSignup()
const login = useLogin()
const logout = useLogout()
// TODO: use this method. It can take more than 5 seconds to do a signup
const handleSignup = async () => {
// TODO: validate the password and email before calling the signup
// Passwords must be at least 7 characters and contain both alphabetic
// and numeric characters.
try {
await signup({
// This account already exists, so it will throw the "duplicated_email" error
email: 'luis@vercel.com',
firstName: 'Luis',
lastName: 'Alvarez',
password: 'luis123',
})
} catch (error) {
if (error.code === 'duplicated_email') {
// TODO: handle duplicated email
}
// Show a generic error saying that something bad happened, try again later
}
}
const handleLogin = async () => {
// TODO: validate the password and email before calling the signup
// Passwords must be at least 7 characters and contain both alphabetic
// and numeric characters.
try {
await login({
email: 'luis@vercel.com',
// This is an invalid password so it will throw the "invalid_credentials" error
password: 'luis1234', // will work with `luis123`
})
} catch (error) {
if (error.code === 'invalid_credentials') {
// The email and password didn't match an existing account
}
// Show a generic error saying that something bad happened, try again later
}
}
return (
<div className="pb-20">
<Modal close={() => {}}>
<div className="h-80 w-80 flex flex-col justify-between py-3 px-3">
<div className="flex justify-center pb-12">
<div className="flex justify-center pb-12 ">
<Logo width="64px" height="64px" />
</div>
<div className="flex flex-col space-y-3">
<div className="border border-accents-3 text-accents-6">
<input
placeholder="Email"
className="focus:outline-none focus:shadow-outline-gray border-none py-2 px-6 w-full appearance-none transition duration-150 ease-in-out placeholder-accents-5 pr-10"
className="focus:outline-none bg-primary focus:shadow-outline-gray border-none py-2 px-6 w-full appearance-none transition duration-150 ease-in-out placeholder-accents-5 pr-10"
/>
</div>
<div className="border border-accents-3 text-accents-6">
<input
placeholder="Password"
className="focus:outline-none focus:shadow-outline-gray border-none py-2 px-6 w-full appearance-none transition duration-150 ease-in-out placeholder-accents-5 pr-10"
className="bg-primary focus:outline-none focus:shadow-outline-gray border-none py-2 px-6 w-full appearance-none transition duration-150 ease-in-out placeholder-accents-5 pr-10"
/>
</div>
<Button variant="slim">Log In</Button>
<Button variant="slim" onClick={handleSignup}>
Log In
</Button>
<span className="pt-3 text-center text-sm">
<span className="text-accents-7">Don't have an account?</span>
{` `}

BIN
public/flag-us.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

BIN
public/vercel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -6,6 +6,9 @@ module.exports = {
purge: ['./components/**/*.{js,ts,jsx,tsx}', './pages/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
maxWidth: {
'8xl': '1920px',
},
backgroundOpacity: {
075: '0.75',
},
@ -26,6 +29,7 @@ module.exports = {
'accents-8': 'var(--accents-8)',
'accents-9': 'var(--accents-9)',
violet: 'var(--violet)',
'violet-light': 'var(--violet-light)',
pink: 'var(--pink)',
cyan: 'var(--cyan)',
blue: 'var(--blue)',

View File

@ -6106,6 +6106,11 @@ picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
pify@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
pkg-dir@^4.1.0, pkg-dir@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
@ -6275,6 +6280,15 @@ postcss-image-set-function@^3.0.1:
postcss "^7.0.2"
postcss-values-parser "^2.0.0"
postcss-import@^13.0.0:
version "13.0.0"
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-13.0.0.tgz#d6960cd9e3de5464743b04dd8cd9d870662f8b8c"
integrity sha512-LPUbm3ytpYopwQQjqgUH4S3EM/Gb9QsaSPP/5vnoi+oKVy3/mIk2sc0Paqw7RL57GpScm9MdIMUypw2znWiBpg==
dependencies:
postcss-value-parser "^4.0.0"
read-cache "^1.0.0"
resolve "^1.1.7"
postcss-initial@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/postcss-initial/-/postcss-initial-3.0.2.tgz#f018563694b3c16ae8eaabe3c585ac6319637b2d"
@ -6489,7 +6503,7 @@ postcss-value-parser@^3.3.0:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281"
integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==
postcss-value-parser@^4.1.0:
postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb"
integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==
@ -6832,6 +6846,13 @@ react@^16.14.0:
object-assign "^4.1.1"
prop-types "^15.6.2"
read-cache@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
integrity sha1-5mTvMRYRZsl1HNvo28+GtftY93Q=
dependencies:
pify "^2.3.0"
read-pkg-up@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"
@ -7092,7 +7113,7 @@ resolve-url@^0.2.1:
resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
resolve@^1.10.0, resolve@^1.14.2, resolve@^1.3.2, resolve@^1.8.1:
resolve@^1.1.7, resolve@^1.10.0, resolve@^1.14.2, resolve@^1.3.2, resolve@^1.8.1:
version "1.18.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.18.1.tgz#018fcb2c5b207d2a6424aee361c5a266da8f4130"
integrity sha512-lDfCPaMKfOJXjy0dPayzPdF1phampNWr3qFCjAu+rw/qbQmr5jWH5xN2hwh9QKfw9E5v4hwV7A+jrCmL8yjjqA==
@ -7661,7 +7682,7 @@ symbol-observable@^1.0.4, symbol-observable@^1.1.0:
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
tailwindcss@^1.8.10:
tailwindcss@^1.9:
version "1.9.5"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-1.9.5.tgz#3339b790a68bc1f09a8efd8eb94cb05aed5235c2"
integrity sha512-Je5t1fAfyW333YTpSxF+8uJwbnrkpyBskDtZYgSMMKQbNp6QUhEKJ4g/JIevZjD2Zidz9VxLraEUq/yWOx6nQg==