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

@ -1,12 +0,0 @@
-- CreateTable
CREATE TABLE "UserRole" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"userId" INTEGER,
CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "UserRole_name_userId_key" ON "UserRole"("name", "userId");

View File

@ -1,26 +0,0 @@
/*
Warnings:
- You are about to drop the column `name` on the `User` table. All the data in the column will be lost.
- Added the required column `firstName` to the `User` table without a default value. This is not possible if the table is not empty.
- Added the required column `lastName` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"hashedPassword" TEXT NOT NULL,
"salt" TEXT NOT NULL,
"resetToken" TEXT,
"resetTokenExpiresAt" DATETIME
);
INSERT INTO "new_User" ("email", "hashedPassword", "id", "resetToken", "resetTokenExpiresAt", "salt") SELECT "email", "hashedPassword", "id", "resetToken", "resetTokenExpiresAt", "salt" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -1,33 +0,0 @@
/*
Warnings:
- You are about to drop the `UserRole` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropIndex
DROP INDEX "UserRole_name_userId_key";
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "UserRole";
PRAGMA foreign_keys=on;
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"hashedPassword" TEXT NOT NULL,
"salt" TEXT NOT NULL,
"resetToken" TEXT,
"resetTokenExpiresAt" DATETIME,
"roles" TEXT NOT NULL DEFAULT 'user'
);
INSERT INTO "new_User" ("email", "firstName", "hashedPassword", "id", "lastName", "resetToken", "resetTokenExpiresAt", "salt") SELECT "email", "firstName", "hashedPassword", "id", "lastName", "resetToken", "resetTokenExpiresAt", "salt" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -11,12 +11,14 @@ CREATE TABLE "Part" (
-- CreateTable -- CreateTable
CREATE TABLE "User" ( CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT, "firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"email" TEXT NOT NULL, "email" TEXT NOT NULL,
"hashedPassword" TEXT NOT NULL, "hashedPassword" TEXT NOT NULL,
"salt" TEXT NOT NULL, "salt" TEXT NOT NULL,
"resetToken" TEXT, "resetToken" TEXT,
"resetTokenExpiresAt" DATETIME "resetTokenExpiresAt" DATETIME,
"roles" TEXT NOT NULL DEFAULT 'user'
); );
-- CreateIndex -- CreateIndex

View File

@ -51,7 +51,8 @@ export const schema = gql`
type Mutation { type Mutation {
createPart(input: CreatePartInput!): Part! @requireAuth createPart(input: CreatePartInput!): Part! @requireAuth
updatePart(id: Int!, input: UpdatePartInput!): Part! @requireAuth updatePart(id: Int!, input: UpdatePartInput!): Part!
deletePart(id: Int!): Part! @requireAuth @requireAuth(roles: "admin")
deletePart(id: Int!): Part! @requireAuth(roles: "admin")
} }
` `

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 = { export const daisyui = {
themes: ['light', 'dark'], themes: ['light', 'dark'],
} }

View File

@ -34,6 +34,7 @@
"daisyui": "^3.9.3", "daisyui": "^3.9.3",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"postcss-loader": "^7.3.3", "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}> <Set wrap={NavbarLayout}>
<Route path="/" page={HomePage} name="home" /> <Route path="/" page={HomePage} name="home" />
<Route path="/part/{id:Int}" page={PartPage} name="partDetails" /> <Route path="/part/{id:Int}" page={PartPage} name="partDetails" />
<Route path="/basket" page={BasketPage} name="basket" />
</Set> </Set>
<Route notfound page={NotFoundPage} /> <Route notfound page={NotFoundPage} />

View File

@ -11,7 +11,7 @@ interface Props {
} }
const NavbarAccountIcon = ({ mobile, className }: Props) => { const NavbarAccountIcon = ({ mobile, className }: Props) => {
const { isAuthenticated, currentUser, logOut, hasRole } = useAuth() const { isAuthenticated, currentUser, logOut } = useAuth()
return isAuthenticated ? ( return isAuthenticated ? (
<div className={className}> <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"> <summary className="btn btn-ghost swap swap-rotate w-12 hover:shadow-lg">
<Icon <Icon path={mdiAccount} className="h-8 w-8 text-base-content" />
path={mdiAccount}
className={`h-8 w-8 ${
hasRole('admin') ? 'text-error' : 'text-base-content'
}`}
/>
</summary> </summary>
<div className="dropdown-content flex w-auto flex-row items-center space-x-3 rounded-xl bg-base-100 p-3 shadow-lg"> <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"> <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 { toast } from '@redwoodjs/web/toast'
import PartForm from 'src/components/Part/PartForm' import PartForm from 'src/components/Part/PartForm'
import ToastNotification from 'src/components/ToastNotification'
export const QUERY = gql` export const QUERY = gql`
query EditPartById($id: Int!) { query EditPartById($id: Int!) {
@ -41,11 +42,15 @@ export const Failure = ({ error }: CellFailureProps) => (
export const Success = ({ part }: CellSuccessProps<EditPartById>) => { export const Success = ({ part }: CellSuccessProps<EditPartById>) => {
const [updatePart, { loading, error }] = useMutation(UPDATE_PART_MUTATION, { const [updatePart, { loading, error }] = useMutation(UPDATE_PART_MUTATION, {
onCompleted: () => { onCompleted: () => {
toast.success('Part updated') toast.custom((t) => (
<ToastNotification toast={t} type="success" message="Part updated" />
))
navigate(routes.parts()) navigate(routes.parts())
}, },
onError: (error) => { 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 { toast } from '@redwoodjs/web/toast'
import PartForm from 'src/components/Part/PartForm' import PartForm from 'src/components/Part/PartForm'
import ToastNotification from 'src/components/ToastNotification'
const CREATE_PART_MUTATION = gql` const CREATE_PART_MUTATION = gql`
mutation CreatePartMutation($input: CreatePartInput!) { mutation CreatePartMutation($input: CreatePartInput!) {
@ -17,11 +18,15 @@ const CREATE_PART_MUTATION = gql`
const NewPart = () => { const NewPart = () => {
const [createPart, { loading, error }] = useMutation(CREATE_PART_MUTATION, { const [createPart, { loading, error }] = useMutation(CREATE_PART_MUTATION, {
onCompleted: () => { onCompleted: () => {
toast.success('Part created') toast.custom((t) => (
<ToastNotification toast={t} type="success" message="Part created" />
))
navigate(routes.parts()) navigate(routes.parts())
}, },
onError: (error) => { 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 { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast' 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` const DELETE_PART_MUTATION = gql`
mutation DeletePartMutation($id: Int!) { mutation DeletePartMutation($id: Int!) {
@ -21,11 +22,15 @@ interface Props {
const Part = ({ part }: Props) => { const Part = ({ part }: Props) => {
const [deletePart] = useMutation(DELETE_PART_MUTATION, { const [deletePart] = useMutation(DELETE_PART_MUTATION, {
onCompleted: () => { onCompleted: () => {
toast.success('Part deleted') toast.custom((t) => (
<ToastNotification toast={t} type="success" message="Part deleted" />
))
navigate(routes.parts()) navigate(routes.parts())
}, },
onError: (error) => { 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 { toast } from '@redwoodjs/web/toast'
import { QUERY } from 'src/components/Part/PartsCell' import { QUERY } from 'src/components/Part/PartsCell'
import ToastNotification from 'src/components/ToastNotification'
import { timeTag, truncate } from 'src/lib/formatters' import { timeTag, truncate } from 'src/lib/formatters'
const DELETE_PART_MUTATION = gql` const DELETE_PART_MUTATION = gql`
@ -18,13 +19,17 @@ const DELETE_PART_MUTATION = gql`
const PartsList = ({ parts }: FindParts) => { const PartsList = ({ parts }: FindParts) => {
const [deletePart] = useMutation(DELETE_PART_MUTATION, { const [deletePart] = useMutation(DELETE_PART_MUTATION, {
onCompleted: () => { onCompleted: () => {
toast.success('Part deleted') toast.custom((t) => (
<ToastNotification toast={t} type="success" message="Part deleted" />
))
}, },
onError: (error) => { 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 // 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 // https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates
refetchQueries: [{ query: QUERY }], refetchQueries: [{ query: QUERY }],
awaitRefetchQueries: true, awaitRefetchQueries: true,
@ -44,7 +49,7 @@ const PartsList = ({ parts }: FindParts) => {
} }
return ( return (
<div className="rw-segment rw-table-wrapper-responsive"> <div className="rw-segment rw-table-wrapper-responsive font-inter">
<table className="rw-table"> <table className="rw-table">
<thead> <thead>
<tr> <tr>

View File

@ -2,17 +2,24 @@ import { useState } from 'react'
import { mdiAlert, mdiPlus, mdiMinus } from '@mdi/js' import { mdiAlert, mdiPlus, mdiMinus } from '@mdi/js'
import { Icon } from '@mdi/react' 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 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` export const QUERY = gql`
query FindPartById($id: Int!) { query FindPartDetailsById($id: Int!) {
part: part(id: $id) { part: part(id: $id) {
id
name name
description description
availableStock availableStock
imageUrl imageUrl
createdAt
} }
} }
` `
@ -47,7 +54,7 @@ const image = (url: string, size: number) => {
return parts.join('/') return parts.join('/')
} }
export const Success = ({ part }: CellSuccessProps<FindPartById>) => { export const Success = ({ part }: CellSuccessProps<FindPartDetailsById>) => {
const [toTake, setToTake] = useState(1) const [toTake, setToTake] = useState(1)
return ( return (
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2"> <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" /> <Icon path={mdiPlus} className="h-6 w-6" />
</button> </button>
</div> </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> </div>
</div> </div>

View File

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

View File

@ -1,4 +1,9 @@
import { Link, routes } from '@redwoodjs/router' import { Link, routes } from '@redwoodjs/router'
import { toast } from '@redwoodjs/web/toast'
import { addToBasket } from 'src/lib/basket'
import ToastNotification from '../ToastNotification'
interface Props { interface Props {
part: { part: {
@ -7,6 +12,7 @@ interface Props {
description?: string description?: string
availableStock: number availableStock: number
imageUrl: string imageUrl: string
createdAt: string
} }
} }
@ -53,13 +59,32 @@ const PartsGridUnit = ({ part }: Props) => {
className={`btn btn-primary ${ className={`btn btn-primary ${
part.availableStock == 0 ? 'btn-disabled' : '' 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 Add to basket
</button> </button>
</div> </div>
</div> </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 base;
@tailwind components; @tailwind components;
@tailwind utilities; @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"> <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> </head>
<body> <body class="no-scrollbar">
<!-- Please keep this div empty --> <!-- Please keep this div empty -->
<div id="redwood-app"></div> <div id="redwood-app"></div>
</body> </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 Icon from '@mdi/react'
import { Link, routes } from '@redwoodjs/router' import { Link, routes } from '@redwoodjs/router'
import { Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth' import { useAuth } from 'src/auth'
import NavbarAccountIcon from 'src/components/NavbarAccountIcon/NavbarAccountIcon' import NavbarAccountIcon from 'src/components/NavbarAccountIcon/NavbarAccountIcon'
import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle' import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle'
import { getBasket } from 'src/lib/basket'
type NavBarLayoutProps = { type NavBarLayoutProps = {
children?: React.ReactNode children?: React.ReactNode
@ -13,9 +17,11 @@ type NavBarLayoutProps = {
const NavBarLayout = ({ children }: NavBarLayoutProps) => { const NavBarLayout = ({ children }: NavBarLayoutProps) => {
const { hasRole } = useAuth() const { hasRole } = useAuth()
const [basket] = useState(getBasket())
return ( return (
<> <>
<Toaster />
<div className="navbar sticky top-0 z-50 bg-base-100 shadow-lg"> <div className="navbar sticky top-0 z-50 bg-base-100 shadow-lg">
<div className="justify-start space-x-3"> <div className="justify-start space-x-3">
<Icon <Icon
@ -47,6 +53,22 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
<></> <></>
)} )}
<ThemeToggle /> <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" /> <NavbarAccountIcon mobile={false} className="hidden lg:block" />
<div className="lg:hidden"> <div className="lg:hidden">
<input <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> </ul>
</div> </div>
</div> </div>

View File

@ -21,7 +21,7 @@ const ScaffoldLayout = ({
}: LayoutProps) => { }: LayoutProps) => {
return ( return (
<div className="rw-scaffold"> <div className="rw-scaffold">
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} /> <Toaster />
<header className="rw-header"> <header className="rw-header">
<div className="space-x-3"> <div className="space-x-3">
<Link to={routes.home()} className="btn btn-ghost"> <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 { toast, Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth' import { useAuth } from 'src/auth'
import ToastNotification from 'src/components/ToastNotification'
const ForgotPasswordPage = () => { const ForgotPasswordPage = () => {
const { isAuthenticated, forgotPassword } = useAuth() const { isAuthenticated, forgotPassword } = useAuth()
@ -25,14 +26,20 @@ const ForgotPasswordPage = () => {
const response = await forgotPassword(data.email.toLowerCase()) const response = await forgotPassword(data.email.toLowerCase())
if (response.error) { if (response.error) {
toast.error(response.error) toast.custom((t) => (
<ToastNotification toast={t} type="error" message={response.error} />
))
} else { } else {
// The function `forgotPassword.handler` in api/src/functions/auth.js has // 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 // been invoked, let the user know how to get the link to reset their
// password (sent in email, perhaps?) // password (sent in email, perhaps?)
toast.success( toast.custom((t) => (
'A link to reset your password was sent to ' + response.email <ToastNotification
) toast={t}
type="success"
message={`A link to reset your password was sent to ${response.email}`}
/>
))
navigate(routes.login()) navigate(routes.login())
} }
} }

View File

@ -1,5 +1,4 @@
import { useRef } from 'react' import { useRef, useEffect } from 'react'
import { useEffect } from 'react'
import { import {
Form, Form,
@ -13,6 +12,7 @@ import { MetaTags } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast' import { toast, Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth' import { useAuth } from 'src/auth'
import ToastNotification from 'src/components/ToastNotification'
const LoginPage = () => { const LoginPage = () => {
const { isAuthenticated, logIn } = useAuth() const { isAuthenticated, logIn } = useAuth()
@ -37,9 +37,13 @@ const LoginPage = () => {
if (response.message) { if (response.message) {
toast(response.message) toast(response.message)
} else if (response.error) { } else if (response.error) {
toast.error(response.error) toast.custom((t) => (
<ToastNotification toast={t} type="error" message={response.error} />
))
} else { } 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 { toast, Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth' import { useAuth } from 'src/auth'
import ToastNotification from 'src/components/ToastNotification'
const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => { const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
const { isAuthenticated, reauthenticate, validateResetToken, resetPassword } = const { isAuthenticated, reauthenticate, validateResetToken, resetPassword } =
@ -23,7 +24,9 @@ const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
const response = await validateResetToken(resetToken) const response = await validateResetToken(resetToken)
if (response.error) { if (response.error) {
setEnabled(false) setEnabled(false)
toast.error(response.error) toast.custom((t) => (
<ToastNotification toast={t} type="error" message={response.error} />
))
} else { } else {
setEnabled(true) setEnabled(true)
} }
@ -43,9 +46,17 @@ const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
}) })
if (response.error) { if (response.error) {
toast.error(response.error) toast.custom((t) => (
<ToastNotification toast={t} type="error" message={response.error} />
))
} else { } else {
toast.success('Password changed!') toast.custom((t) => (
<ToastNotification
toast={t}
type="success"
message="Password changed!"
/>
))
await reauthenticate() await reauthenticate()
navigate(routes.login()) navigate(routes.login())
} }

View File

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

View File

@ -19549,6 +19549,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tailwindcss-animate@npm:^1.0.7":
version: 1.0.7
resolution: "tailwindcss-animate@npm:1.0.7"
peerDependencies:
tailwindcss: "*"
checksum: ec7dbd1631076b97d66a1fbaaa06e0725fccfa63119221e8d87a997b02dcede98ad88bb1ef6665b968f5d260fcefb10592e0299ca70208d365b37761edf5e19a
languageName: node
linkType: hard
"tailwindcss@npm:^3.1, tailwindcss@npm:^3.3.3": "tailwindcss@npm:^3.1, tailwindcss@npm:^3.3.3":
version: 3.3.3 version: 3.3.3
resolution: "tailwindcss@npm:3.3.3" resolution: "tailwindcss@npm:3.3.3"
@ -20946,6 +20955,7 @@ __metadata:
react: 18.2.0 react: 18.2.0
react-dom: 18.2.0 react-dom: 18.2.0
tailwindcss: ^3.3.3 tailwindcss: ^3.3.3
tailwindcss-animate: ^1.0.7
theme-change: ^2.5.0 theme-change: ^2.5.0
languageName: unknown languageName: unknown
linkType: soft linkType: soft