1
0

Basket functionality, no DB transaction storing yet, and some more customization to make the theme uniform

This commit is contained in:
Ahmed Al-Taiar
2023-11-06 22:06:44 -05:00
parent 53e0070fd8
commit f6f01594ec
33 changed files with 539 additions and 122 deletions

View File

@ -13,7 +13,7 @@ export const theme = {
},
},
}
export const plugins = [require('daisyui')]
export const plugins = [require('daisyui'), require('tailwindcss-animate')]
export const daisyui = {
themes: ['light', 'dark'],
}

View File

@ -34,6 +34,7 @@
"daisyui": "^3.9.3",
"postcss": "^8.4.31",
"postcss-loader": "^7.3.3",
"tailwindcss": "^3.3.3"
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.7"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -25,6 +25,7 @@ const Routes = () => {
<Set wrap={NavbarLayout}>
<Route path="/" page={HomePage} name="home" />
<Route path="/part/{id:Int}" page={PartPage} name="partDetails" />
<Route path="/basket" page={BasketPage} name="basket" />
</Set>
<Route notfound page={NotFoundPage} />

View File

@ -11,7 +11,7 @@ interface Props {
}
const NavbarAccountIcon = ({ mobile, className }: Props) => {
const { isAuthenticated, currentUser, logOut, hasRole } = useAuth()
const { isAuthenticated, currentUser, logOut } = useAuth()
return isAuthenticated ? (
<div className={className}>
@ -21,12 +21,7 @@ const NavbarAccountIcon = ({ mobile, className }: Props) => {
}`}
>
<summary className="btn btn-ghost swap swap-rotate w-12 hover:shadow-lg">
<Icon
path={mdiAccount}
className={`h-8 w-8 ${
hasRole('admin') ? 'text-error' : 'text-base-content'
}`}
/>
<Icon path={mdiAccount} className="h-8 w-8 text-base-content" />
</summary>
<div className="dropdown-content flex w-auto flex-row items-center space-x-3 rounded-xl bg-base-100 p-3 shadow-lg">
<p className="whitespace-nowrap font-inter text-lg">

View File

@ -6,6 +6,7 @@ import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import PartForm from 'src/components/Part/PartForm'
import ToastNotification from 'src/components/ToastNotification'
export const QUERY = gql`
query EditPartById($id: Int!) {
@ -41,11 +42,15 @@ export const Failure = ({ error }: CellFailureProps) => (
export const Success = ({ part }: CellSuccessProps<EditPartById>) => {
const [updatePart, { loading, error }] = useMutation(UPDATE_PART_MUTATION, {
onCompleted: () => {
toast.success('Part updated')
toast.custom((t) => (
<ToastNotification toast={t} type="success" message="Part updated" />
))
navigate(routes.parts())
},
onError: (error) => {
toast.error(error.message)
toast.custom((t) => (
<ToastNotification toast={t} type="error" message={error.message} />
))
},
})

View File

@ -5,6 +5,7 @@ import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import PartForm from 'src/components/Part/PartForm'
import ToastNotification from 'src/components/ToastNotification'
const CREATE_PART_MUTATION = gql`
mutation CreatePartMutation($input: CreatePartInput!) {
@ -17,11 +18,15 @@ const CREATE_PART_MUTATION = gql`
const NewPart = () => {
const [createPart, { loading, error }] = useMutation(CREATE_PART_MUTATION, {
onCompleted: () => {
toast.success('Part created')
toast.custom((t) => (
<ToastNotification toast={t} type="success" message="Part created" />
))
navigate(routes.parts())
},
onError: (error) => {
toast.error(error.message)
toast.custom((t) => (
<ToastNotification toast={t} type="error" message={error.message} />
))
},
})

View File

@ -4,7 +4,8 @@ import { Link, routes, navigate } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { timeTag } from 'src/pages/lib/formatters'
import ToastNotification from 'src/components/ToastNotification'
import { timeTag } from 'src/lib/formatters'
const DELETE_PART_MUTATION = gql`
mutation DeletePartMutation($id: Int!) {
@ -21,11 +22,15 @@ interface Props {
const Part = ({ part }: Props) => {
const [deletePart] = useMutation(DELETE_PART_MUTATION, {
onCompleted: () => {
toast.success('Part deleted')
toast.custom((t) => (
<ToastNotification toast={t} type="success" message="Part deleted" />
))
navigate(routes.parts())
},
onError: (error) => {
toast.error(error.message)
toast.custom((t) => (
<ToastNotification toast={t} type="error" message={error.message} />
))
},
})

View File

@ -5,6 +5,7 @@ import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { QUERY } from 'src/components/Part/PartsCell'
import ToastNotification from 'src/components/ToastNotification'
import { timeTag, truncate } from 'src/lib/formatters'
const DELETE_PART_MUTATION = gql`
@ -18,13 +19,17 @@ const DELETE_PART_MUTATION = gql`
const PartsList = ({ parts }: FindParts) => {
const [deletePart] = useMutation(DELETE_PART_MUTATION, {
onCompleted: () => {
toast.success('Part deleted')
toast.custom((t) => (
<ToastNotification toast={t} type="success" message="Part deleted" />
))
},
onError: (error) => {
toast.error(error.message)
toast.custom((t) => (
<ToastNotification toast={t} type="error" message={error.message} />
))
},
// This refetches the query on the list page. Read more about other ways to
// update the cache over here:
// update the cache over here:ya
// https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates
refetchQueries: [{ query: QUERY }],
awaitRefetchQueries: true,
@ -44,7 +49,7 @@ const PartsList = ({ parts }: FindParts) => {
}
return (
<div className="rw-segment rw-table-wrapper-responsive">
<div className="rw-segment rw-table-wrapper-responsive font-inter">
<table className="rw-table">
<thead>
<tr>

View File

@ -2,17 +2,24 @@ import { useState } from 'react'
import { mdiAlert, mdiPlus, mdiMinus } from '@mdi/js'
import { Icon } from '@mdi/react'
import type { FindPartById } from 'types/graphql'
import type { FindPartDetailsById } from 'types/graphql'
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { addToBasket } from 'src/lib/basket'
import ToastNotification from '../ToastNotification'
export const QUERY = gql`
query FindPartById($id: Int!) {
query FindPartDetailsById($id: Int!) {
part: part(id: $id) {
id
name
description
availableStock
imageUrl
createdAt
}
}
`
@ -47,7 +54,7 @@ const image = (url: string, size: number) => {
return parts.join('/')
}
export const Success = ({ part }: CellSuccessProps<FindPartById>) => {
export const Success = ({ part }: CellSuccessProps<FindPartDetailsById>) => {
const [toTake, setToTake] = useState(1)
return (
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
@ -91,7 +98,31 @@ export const Success = ({ part }: CellSuccessProps<FindPartById>) => {
<Icon path={mdiPlus} className="h-6 w-6" />
</button>
</div>
<button className="btn btn-primary">Add to basket</button>
<button
className="btn btn-primary"
onClick={() => {
const newBasket = addToBasket(part, toTake)
if (typeof newBasket == 'string')
toast.custom((t) => (
<ToastNotification
toast={t}
type="error"
message={newBasket}
/>
))
else
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message={`Added ${toTake} ${part.name} to basket`}
/>
))
}}
>
Add to basket
</button>
</div>
</div>
</div>

View File

@ -38,6 +38,7 @@ export const QUERY = gql`
description
availableStock
imageUrl
createdAt
}
count
page

View File

@ -1,4 +1,9 @@
import { Link, routes } from '@redwoodjs/router'
import { toast } from '@redwoodjs/web/toast'
import { addToBasket } from 'src/lib/basket'
import ToastNotification from '../ToastNotification'
interface Props {
part: {
@ -7,6 +12,7 @@ interface Props {
description?: string
availableStock: number
imageUrl: string
createdAt: string
}
}
@ -53,13 +59,32 @@ const PartsGridUnit = ({ part }: Props) => {
className={`btn btn-primary ${
part.availableStock == 0 ? 'btn-disabled' : ''
}`}
onClick={() => {
const newBasket = addToBasket(part, 1)
if (typeof newBasket == 'string')
toast.custom((t) => (
<ToastNotification
toast={t}
type="error"
message={newBasket}
/>
))
else
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message={`Added 1 ${part.name} to basket`}
/>
))
}}
>
Add to basket
</button>
</div>
</div>
</div>
// TODO: add to basket funcionality
)
}

View File

@ -0,0 +1,25 @@
// Pass props to your component by passing an `args` object to your story
//
// ```tsx
// export const Primary: Story = {
// args: {
// propName: propValue
// }
// }
// ```
//
// See https://storybook.js.org/docs/react/writing-stories/args.
import type { Meta, StoryObj } from '@storybook/react'
import ToastNotification from './ToastNotification'
const meta: Meta<typeof ToastNotification> = {
component: ToastNotification,
}
export default meta
type Story = StoryObj<typeof ToastNotification>
export const Primary: Story = {}

View File

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import ToastNotification from './ToastNotification'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-components
describe('ToastNotification', () => {
it('renders successfully', () => {
expect(() => {
render(<ToastNotification />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,42 @@
import { mdiCloseCircle, mdiInformation, mdiCheckCircle } from '@mdi/js'
import { Icon } from '@mdi/react'
import { Toast } from '@redwoodjs/web/toast'
type NotificationType = 'success' | 'error' | 'info'
interface Props {
type: NotificationType
message: string
toast: Toast
}
const ToastNotification = ({ type, message, toast }: Props) => (
<div
className={`${
toast.visible
? 'duration-200 animate-in slide-in-from-top'
: 'duration-200 animate-out slide-out-to-top'
} pointer-events-auto flex w-full max-w-sm items-center rounded-2xl bg-base-100 shadow-lg`}
>
<Icon
className={`m-3 h-8 w-8 ${
type == 'success'
? 'text-success'
: type == 'error'
? 'text-error'
: 'text-info'
}`}
path={
type == 'success'
? mdiCheckCircle
: type == 'error'
? mdiCloseCircle
: mdiInformation
}
/>
<p className="font-inter">{message}</p>
</div>
)
export default ToastNotification

View File

@ -1,3 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}

View File

@ -11,7 +11,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Open+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<body class="no-scrollbar">
<!-- Please keep this div empty -->
<div id="redwood-app"></div>
</body>

View File

@ -1,11 +1,15 @@
import { mdiChip, mdiMenu } from '@mdi/js'
import { useState } from 'react'
import { mdiChip, mdiMenu, mdiBasket } from '@mdi/js'
import Icon from '@mdi/react'
import { Link, routes } from '@redwoodjs/router'
import { Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
import NavbarAccountIcon from 'src/components/NavbarAccountIcon/NavbarAccountIcon'
import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle'
import { getBasket } from 'src/lib/basket'
type NavBarLayoutProps = {
children?: React.ReactNode
@ -13,9 +17,11 @@ type NavBarLayoutProps = {
const NavBarLayout = ({ children }: NavBarLayoutProps) => {
const { hasRole } = useAuth()
const [basket] = useState(getBasket())
return (
<>
<Toaster />
<div className="navbar sticky top-0 z-50 bg-base-100 shadow-lg">
<div className="justify-start space-x-3">
<Icon
@ -47,6 +53,22 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
<></>
)}
<ThemeToggle />
<Link
to={routes.basket()}
className="items-cente btn btn-ghost hidden hover:shadow-lg lg:flex"
>
<div className="indicator">
{basket.length > 0 ? (
<span className="badge indicator-item badge-primary font-inter">
{basket.length}
</span>
) : (
<></>
)}
<Icon path={mdiBasket} className="h-8 w-8 text-base-content" />
</div>
</Link>
<NavbarAccountIcon mobile={false} className="hidden lg:block" />
<div className="lg:hidden">
<input
@ -94,6 +116,14 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
) : (
<></>
)}
<li>
<Link
to={routes.basket()}
className="btn btn-ghost w-full hover:shadow-lg"
>
<p className="font-inter text-base">Basket</p>
</Link>
</li>
</ul>
</div>
</div>

View File

@ -21,7 +21,7 @@ const ScaffoldLayout = ({
}: LayoutProps) => {
return (
<div className="rw-scaffold">
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
<Toaster />
<header className="rw-header">
<div className="space-x-3">
<Link to={routes.home()} className="btn btn-ghost">

57
web/src/lib/basket.ts Normal file
View File

@ -0,0 +1,57 @@
import { Part } from 'types/graphql'
const BASKET_KEY = 'basket'
export interface BasketItem {
part: Part
quantity: number
}
export const getBasket = (): BasketItem[] => {
const basketRaw = localStorage.getItem(BASKET_KEY)
const basket: BasketItem[] = basketRaw ? JSON.parse(basketRaw) : []
return basket
}
export const setBasket = (newBasket: BasketItem[]): BasketItem[] => {
localStorage.setItem(BASKET_KEY, JSON.stringify(newBasket))
return getBasket()
}
export const addToBasket = (
part: Part,
quantity: number
): BasketItem[] | string => {
const basket = getBasket()
const existingPartIndex = basket.findIndex((item) => item.part.id == part.id)
if (existingPartIndex !== -1) {
const basketPart = basket[existingPartIndex]
if (basketPart.quantity + quantity <= basketPart.part.availableStock)
basket[existingPartIndex].quantity += quantity
else return `Cannot exceed number of items left (${part.availableStock})`
} else basket.push({ part, quantity })
localStorage.setItem(BASKET_KEY, JSON.stringify(basket))
return basket
}
export const removeFromBasket = (index: number): BasketItem[] | string => {
const basket = getBasket()
if (index >= 0 && index < basket.length) basket.splice(index, 1)
else return 'Error: index out of bounds'
localStorage.setItem(BASKET_KEY, JSON.stringify(basket))
return basket
}
export const clearBasket = (): BasketItem[] => {
localStorage.removeItem(BASKET_KEY)
return []
}

View File

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react'
import BasketPage from './BasketPage'
const meta: Meta<typeof BasketPage> = {
component: BasketPage,
}
export default meta
type Story = StoryObj<typeof BasketPage>
export const Primary: Story = {}

View File

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import BasketPage from './BasketPage'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-pages-layouts
describe('BasketPage', () => {
it('renders successfully', () => {
expect(() => {
render(<BasketPage />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,172 @@
import { useState } from 'react'
import { mdiDelete, mdiMinus, mdiPlus } from '@mdi/js'
import Icon from '@mdi/react'
import { MetaTags } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
import ToastNotification from 'src/components/ToastNotification'
import {
clearBasket,
getBasket,
removeFromBasket,
setBasket,
} from 'src/lib/basket'
const thumbnail = (url: string) => {
if (url.includes('no_image.png')) return url
const parts = url.split('/')
parts.splice(3, 0, 'resize=width:160')
return parts.join('/')
}
const BasketPage = () => {
const { isAuthenticated } = useAuth()
const [basket, setBasketState] = useState(getBasket())
return (
<>
<MetaTags title="Basket" description="Basket page" />
<div className="m-8">
<h1 className="mb-8 font-inter text-3xl font-bold">Basket</h1>
<div className="space-y-3">
{basket.length > 0 ? (
basket.map((item, i) => (
<div
key={i}
className="flex max-w-5xl items-center rounded-xl bg-base-100 shadow-xl"
>
<img
alt={item.part.name}
className="hidden h-20 w-20 rounded-l-xl object-cover sm:flex"
src={thumbnail(item.part.imageUrl)}
/>
<div className="m-3 w-full items-center justify-between space-y-3 sm:flex sm:space-y-0">
<p className="overflow-hidden text-ellipsis whitespace-nowrap font-inter text-lg font-bold">
{item.part.name}
</p>
<div className="flex justify-between space-x-3">
<div className="join">
<button
className={`btn join-item ${
item.quantity == 1 ? 'btn-disabled' : ''
}`}
onClick={() => {
const newBasket = basket
newBasket[i].quantity -= 1
setBasketState(setBasket(newBasket))
}}
>
<Icon path={mdiMinus} className="h-6 w-6" />
</button>
<p className="btn join-item items-center font-inter text-lg">
{item.quantity}
</p>
<button
className={`btn join-item ${
item.quantity == item.part.availableStock
? 'btn-disabled'
: ''
}`}
onClick={() => {
const newBasket = basket
newBasket[i].quantity += 1
setBasketState(setBasket(newBasket))
}}
>
<Icon path={mdiPlus} className="h-6 w-6" />
</button>
</div>
<button
className="btn btn-ghost hover:shadow-lg"
onClick={() => {
const newBasket = removeFromBasket(i)
if (typeof newBasket == 'string')
toast.custom((t) => (
<ToastNotification
toast={t}
type="error"
message={newBasket}
/>
))
else {
setBasketState(newBasket)
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message={`Removed ${item.part.name} from basket`}
/>
))
}
}}
>
<Icon
path={mdiDelete}
className="h-8 w-8 text-base-content"
/>
</button>
</div>
</div>
</div>
))
) : (
<div className="flex">
<div className="alert w-auto shadow-lg">
<p className="text-center font-inter">
It&#39;s empty in here...
</p>
</div>
</div>
)}
{basket.length > 0 ? (
<div className="flex space-x-3 pt-3">
<button
onClick={() => {
setBasketState(clearBasket())
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message="Basket cleared"
/>
))
}}
className="btn font-inter"
>
Clear basket
</button>
<button
onClick={() => {
if (!isAuthenticated)
toast.custom((t) => (
<ToastNotification
toast={t}
type="error"
message="You must be logged in to do that"
/>
))
else {
toast.custom((t) => (
<ToastNotification toast={t} type="info" message="Todo" />
))
}
}}
className="btn btn-primary font-inter"
>
Checkout
</button>
</div>
) : (
<></>
)}
</div>
</div>
</>
)
}
export default BasketPage

View File

@ -6,6 +6,7 @@ import { MetaTags } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
import ToastNotification from 'src/components/ToastNotification'
const ForgotPasswordPage = () => {
const { isAuthenticated, forgotPassword } = useAuth()
@ -25,14 +26,20 @@ const ForgotPasswordPage = () => {
const response = await forgotPassword(data.email.toLowerCase())
if (response.error) {
toast.error(response.error)
toast.custom((t) => (
<ToastNotification toast={t} type="error" message={response.error} />
))
} else {
// The function `forgotPassword.handler` in api/src/functions/auth.js has
// been invoked, let the user know how to get the link to reset their
// password (sent in email, perhaps?)
toast.success(
'A link to reset your password was sent to ' + response.email
)
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message={`A link to reset your password was sent to ${response.email}`}
/>
))
navigate(routes.login())
}
}

View File

@ -1,5 +1,4 @@
import { useRef } from 'react'
import { useEffect } from 'react'
import { useRef, useEffect } from 'react'
import {
Form,
@ -13,6 +12,7 @@ import { MetaTags } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
import ToastNotification from 'src/components/ToastNotification'
const LoginPage = () => {
const { isAuthenticated, logIn } = useAuth()
@ -37,9 +37,13 @@ const LoginPage = () => {
if (response.message) {
toast(response.message)
} else if (response.error) {
toast.error(response.error)
toast.custom((t) => (
<ToastNotification toast={t} type="error" message={response.error} />
))
} else {
toast.success('Welcome back!')
toast.custom((t) => (
<ToastNotification toast={t} type="success" message="Welcome back!" />
))
}
}

View File

@ -6,6 +6,7 @@ import { MetaTags } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
import ToastNotification from 'src/components/ToastNotification'
const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
const { isAuthenticated, reauthenticate, validateResetToken, resetPassword } =
@ -23,7 +24,9 @@ const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
const response = await validateResetToken(resetToken)
if (response.error) {
setEnabled(false)
toast.error(response.error)
toast.custom((t) => (
<ToastNotification toast={t} type="error" message={response.error} />
))
} else {
setEnabled(true)
}
@ -43,9 +46,17 @@ const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
})
if (response.error) {
toast.error(response.error)
toast.custom((t) => (
<ToastNotification toast={t} type="error" message={response.error} />
))
} else {
toast.success('Password changed!')
toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message="Password changed!"
/>
))
await reauthenticate()
navigate(routes.login())
}

View File

@ -13,6 +13,7 @@ import { MetaTags } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
import ToastNotification from 'src/components/ToastNotification'
const SignupPage = () => {
const { isAuthenticated, signUp } = useAuth()
@ -37,14 +38,16 @@ const SignupPage = () => {
lastName: data.lastName,
})
if (response.message) {
toast(response.message)
} else if (response.error) {
toast.error(response.error)
} else {
// user is signed in automatically
toast.success('Welcome!')
}
if (response.message) toast(response.message)
else if (response.error)
toast.custom((t) => (
<ToastNotification toast={t} type="error" message={response.error} />
))
// user is signed in automatically
else
toast.custom((t) => (
<ToastNotification toast={t} type="success" message="Welcome!" />
))
}
return (