Skip to content
This repository was archived by the owner on Jul 3, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 33 additions & 24 deletions app/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ export class CtpApiClient {

private readonly anonymousIdStorageKey = 'anonymous_id'

private public: ByProjectKeyRequestBuilder
private protected?: ByProjectKeyRequestBuilder
private current: ByProjectKeyRequestBuilder
private publicInstance: ByProjectKeyRequestBuilder
private protectedInstance?: ByProjectKeyRequestBuilder
private currentInstance: ByProjectKeyRequestBuilder

constructor({
authUri = String(import.meta.env.VITE_CTP_AUTH_URL),
Expand All @@ -78,18 +78,18 @@ export class CtpApiClient {
this.projectKey = projectKey
this.scopes = scopes

this.public = this.createPublic()
this.publicInstance = this.createPublicInstance()

if (this.hasToken) {
this.protected = this.createProtectedWithToken()
this.current = this.protected
this.protectedInstance = this.createProtectedWithTokenInstance()
this.currentInstance = this.protectedInstance ?? this.publicInstance
} else {
this.current = this.public
this.currentInstance = this.publicInstance
}
}

public get root(): ByProjectKeyRequestBuilder {
return this.current
return this.currentInstance
}

public get hasToken(): boolean {
Expand All @@ -114,7 +114,7 @@ export class CtpApiClient {

public async login(email: string, password: string): Promise<ClientResponse<Customer>> {
const request: () => ApiRequest<CustomerSignInResult> = () =>
this.public
this.publicInstance
.me()
.login()
.post({
Expand All @@ -129,25 +129,25 @@ export class CtpApiClient {
try {
await request().execute()
} catch (error) {
await this.handleError<CustomerSignInResult>({ error, request })
await this.handleAuthError<CustomerSignInResult>({ error, request })
}

this.protected = this.createProtectedWithCredentials(email, password)
this.current = this.protected
this.protectedInstance = this.createProtectedWithCredentialsInstance(email, password)
this.currentInstance = this.protectedInstance

return await this.getCurrentCustomer()
}

public logout(): void {
this.protectedTokenCache.remove()
this.publicTokenCache.remove()
this.public = this.createPublic()
this.protected = undefined
this.current = this.public
this.publicInstance = this.createPublicInstance()
this.protectedInstance = undefined
this.currentInstance = this.publicInstance
}

public getCurrentCustomerBuilder(): ByProjectKeyMeRequestBuilder {
return this.current.me()
return this.currentInstance.me()
}

public async getCurrentCustomer(): Promise<ClientResponse<Customer>> {
Expand All @@ -159,7 +159,7 @@ export class CtpApiClient {
const shippingAddressIndex = payload.addresses.findIndex(({ type }) => type === CUSTOMER_ADDRESS_TYPE.SHIPPING)

const request: () => ApiRequest<CustomerSignInResult> = () =>
this.public
this.publicInstance
.me()
.signup()
.post({
Expand Down Expand Up @@ -187,7 +187,7 @@ export class CtpApiClient {
try {
return await request().execute()
} catch (error) {
return await this.handleError<CustomerSignInResult>({ error, request })
return await this.handleAuthError<CustomerSignInResult>({ error, request })
}
}

Expand All @@ -200,7 +200,7 @@ export class CtpApiClient {
}
}

private createPublic(): ByProjectKeyRequestBuilder {
private createPublicInstance(): ByProjectKeyRequestBuilder {
const anonymousId = this.getOrCreateAnonymousId()

const client = new ClientBuilder()
Expand All @@ -226,7 +226,7 @@ export class CtpApiClient {
})
}

private createProtectedWithCredentials(email: string, password: string): ByProjectKeyRequestBuilder {
private createProtectedWithCredentialsInstance(email: string, password: string): ByProjectKeyRequestBuilder {
const client = new ClientBuilder()
.withProjectKey(this.projectKey)
.withPasswordFlow({
Expand All @@ -246,11 +246,12 @@ export class CtpApiClient {
})
}

private createProtectedWithToken(): ByProjectKeyRequestBuilder {
const { refreshToken } = this.protectedTokenCache.get()
private createProtectedWithTokenInstance(): ByProjectKeyRequestBuilder | undefined {
const refreshToken = this.getRefreshToken()

if (refreshToken === undefined) {
throw new Error('Refresh token is missing')
this.logout()
return undefined
}

const client = new ClientBuilder()
Expand All @@ -272,7 +273,7 @@ export class CtpApiClient {
})
}

private async handleError<T>({
private async handleAuthError<T>({
error,
request
}: {
Expand All @@ -289,6 +290,14 @@ export class CtpApiClient {
return await request().execute()
}

private getRefreshToken(): string | undefined {
try {
return this.protectedTokenCache.get().refreshToken
} catch {
return undefined
}
}

private getOrCreateAnonymousId(): string {
let id = this.getAnonymousIdFromStorage()

Expand Down
6 changes: 3 additions & 3 deletions app/layouts/components/Footer/FooterBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ type FooterProps = {
export function Footer({ categories }: FooterProps): ReactElement {
return (
<footer className="bg-white border-t text-sm text-muted-foreground">
<div className="w-full bg-neutral-900/90 text-white py-6 px-4">
<div className="container mx-auto flex flex-col md:flex-row md:items-center justify-between gap-4 text-center md:text-left">
<div className="w-full bg-neutral-900/90 text-white py-6">
<div className="container mx-auto max-w-[1280px] px-8 flex flex-col md:flex-row md:items-center justify-between gap-4 text-center md:text-left">
<div className="text-lg font-semibold">FURNITURE SHOP</div>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-6 text-base text-white/90 sm:items-center sm:text-right">
<a
Expand All @@ -32,7 +32,7 @@ export function Footer({ categories }: FooterProps): ReactElement {
</div>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-5 gap-8 py-10 container mx-auto ">
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-5 gap-8 px-4 py-10 container mx-auto max-w-[1280px]">
<InfoAndHelp />
<FooterCategories categories={categories} />
<SocialMedia />
Expand Down
2 changes: 1 addition & 1 deletion app/layouts/components/Footer/FooterCategories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function FooterCategories({ categories }: Props): ReactElement {
<ul className="space-y-2">
{categories.map((category) => (
<li key={category.id}>
<CategoryLink category={category} />
<CategoryLink category={category} defaultClassName="hover:underline" activeClassName="" />
</li>
))}
</ul>
Expand Down
4 changes: 2 additions & 2 deletions app/layouts/components/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type NavigationProps = {
}

type NavItem = {
to: string
to: ROUTES
label: string
icon: ElementType
auth?: boolean
Expand All @@ -40,7 +40,7 @@ export function Navigation({ isAuth }: NavigationProps): ReactElement {
<NavigationMenu>
<NavigationMenuList className="grid grid-cols-3 sm:flex justify-center gap-2">
{filteredItems.map(({ to, label, icon: Icon }) => {
const isCart = label === 'Cart'
const isCart = to === ROUTES.CART

return (
<NavigationMenuItem key={label}>
Expand Down
11 changes: 8 additions & 3 deletions app/pages/about/TeamMember.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ type TeamMemberProps = {
imageUrl: string
}

const TEAM_MEMBER_IMAGE_WIDTH = 300
const TEAM_MEMBER_IMAGE_HEIGHT = 500

export function TeamMember({
name,
location,
Expand All @@ -22,13 +25,15 @@ export function TeamMember({
github,
imageUrl
}: TeamMemberProps): ReactElement {
const imgClassName = `rounded-2xl md:w-[${TEAM_MEMBER_IMAGE_WIDTH}px] md:h-auto object-cover`

return (
<div className="max-w-7xl mx-auto flex flex-col md:flex-row gap-8 items-center">
<img
src={imageUrl}
width={300}
height={500}
className="rounded-2xl md:w-[300px] md:h-auto object-cover"
width={TEAM_MEMBER_IMAGE_WIDTH}
height={TEAM_MEMBER_IMAGE_HEIGHT}
className={imgClassName}
alt={name}
/>
<div className="flex flex-col text-left self-start">
Expand Down
13 changes: 11 additions & 2 deletions app/pages/cart/CartItem/CartItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ type CartItemProps = {
lineItem: LineItem
}

const MIN_QUANTITY = 1

export function CartItem({ lineItem }: CartItemProps): ReactElement {
const { name, productId, quantity, price, totalPrice, variant } = lineItem
const dispatch = useAppDispatch()
Expand All @@ -19,13 +21,20 @@ export function CartItem({ lineItem }: CartItemProps): ReactElement {
const isCartLoading = status === CART_TABLE_STATUS.LOADING

const handleQuantityChange = async (newQuantity: number): Promise<void> => {
if (isCartLoading || newQuantity < 1) return
if (isCartLoading || newQuantity < MIN_QUANTITY) {
return
}

await dispatch(updateQuantity({ productId, quantity: newQuantity })).unwrap()
}

const handleDeleteItem = async (event: MouseEvent<HTMLButtonElement>): Promise<void> => {
event.preventDefault()
if (isCartLoading) return

if (isCartLoading) {
return
}

await dispatch(removeProduct({ productId, quantity })).unwrap()
}

Expand Down
7 changes: 6 additions & 1 deletion app/pages/cart/CartTopPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ export function CartTopPanel({ onClearCart }: CartTopPanelProps): ReactElement {
return (
<Card className="w-full max-w-2xl">
<CardContent className="flex justify-between items-center gap-2 flex-wrap">
{cart && <CartTotalPrice totalPrice={cart?.totalPrice?.centAmount} discount={cart?.discountOnTotalPrice} />}
{cart && (
<CartTotalPrice
priceAfterDiscount={cart?.totalPrice?.centAmount}
discountOnTotal={cart?.discountOnTotalPrice}
/>
)}
<ClearCartButton onClearCart={onClearCart} />
</CardContent>
</Card>
Expand Down
12 changes: 6 additions & 6 deletions app/pages/cart/CartTotalPrice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import type { ReactElement } from 'react'
import { formatProductItemPrice } from '~/utils/formatPrice'

type CartTotalPriceProps = {
totalPrice?: number
discount?: DiscountOnTotalPrice
priceAfterDiscount?: number
discountOnTotal?: DiscountOnTotalPrice
}

export function CartTotalPrice({ totalPrice, discount }: CartTotalPriceProps): ReactElement {
const hasDiscount = discount !== undefined
const fullPrice = (totalPrice ?? 0) + (discount?.discountedAmount?.centAmount ?? 0)
const discountedPrice = totalPrice ?? 0
export function CartTotalPrice({ priceAfterDiscount, discountOnTotal }: CartTotalPriceProps): ReactElement {
const hasDiscount = discountOnTotal !== undefined
const fullPrice = (priceAfterDiscount ?? 0) + (discountOnTotal?.discountedAmount?.centAmount ?? 0)
const discountedPrice = priceAfterDiscount ?? 0

return (
<div className="text-center m-0 text-sm sm:text-base font-semibold justify-items-start">
Expand Down
6 changes: 3 additions & 3 deletions app/pages/catalog/FilterForm/FilterFormBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@ import {
type FilterFormBodyProps = {
filters: ProductListFilter[]
categories: ProductListCategory[]
fetch: UseCatalogDataResult['fetchProducts']
fetchProducts: UseCatalogDataResult['fetchProducts']
onApply: () => void
}

export function FilterFormBody({ filters, categories, fetch, onApply }: FilterFormBodyProps): ReactElement {
export function FilterFormBody({ filters, categories, fetchProducts, onApply }: FilterFormBodyProps): ReactElement {
const defaultValues = getDefaultFormValues(filters, sorts)
const form = useForm<FormValues>({ defaultValues })

const handleApply = (data: FormValues): Promise<void> => {
onApply()
return fetch(
return fetchProducts(
{ limit: PRODUCT_LIST_ITEMS_PER_PAGE },
convertFormValuesToAppliedFilters(data, filters),
convertFormValuesToSort(data, sorts)
Expand Down
9 changes: 5 additions & 4 deletions app/pages/catalog/PaginationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type PaginationControlsProps = {
export function PaginationControls({ page, totalPage, onPageChange }: PaginationControlsProps): ReactElement {
const isFirstPage = page === 1
const isLastPage = page === totalPage
const classNamesActive = 'cursor-pointer'
const classNamesDisabled = 'pointer-events-none opacity-50'

return (
Expand All @@ -26,13 +27,13 @@ export function PaginationControls({ page, totalPage, onPageChange }: Pagination
<PaginationItem>
<PaginationStart
onClick={() => !isFirstPage && onPageChange(1)}
className={isFirstPage ? classNamesDisabled : ''}
className={isFirstPage ? classNamesDisabled : classNamesActive}
/>
</PaginationItem>
<PaginationItem>
<PaginationPrevious
onClick={() => !isFirstPage && onPageChange(page - 1)}
className={isFirstPage ? classNamesDisabled : ''}
className={isFirstPage ? classNamesDisabled : classNamesActive}
/>
</PaginationItem>
<PaginationItem className="px-2.5 sm:pl-2.5 border rounded-sm">
Expand All @@ -41,13 +42,13 @@ export function PaginationControls({ page, totalPage, onPageChange }: Pagination
<PaginationItem>
<PaginationNext
onClick={() => !isLastPage && onPageChange(page + 1)}
className={isLastPage ? classNamesDisabled : ''}
className={isLastPage ? classNamesDisabled : classNamesActive}
/>
</PaginationItem>
<PaginationItem>
<PaginationEnd
onClick={() => !isLastPage && onPageChange(totalPage)}
className={isLastPage ? classNamesDisabled : ''}
className={isLastPage ? classNamesDisabled : classNamesActive}
/>
</PaginationItem>
</PaginationContent>
Expand Down
13 changes: 6 additions & 7 deletions app/pages/catalog/SearchForm/SearchFormBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,18 @@ import { type UseCatalogDataResult } from '../hooks/useCatalogData'
import { PRODUCT_LIST_ITEMS_PER_PAGE } from '~/api/namespaces/product'

type SearchFormBodyProps = {
fetch: UseCatalogDataResult['fetchProducts']
fetchProducts: UseCatalogDataResult['fetchProducts']
setSearch: (search: string) => void
onSearch: () => void
}

export function SearchFormBody({ fetch, setSearch, onSearch }: SearchFormBodyProps): ReactElement {
const { register, handleSubmit, getValues } = useForm<{ search: string }>()
export function SearchFormBody({ fetchProducts, setSearch, onSearch }: SearchFormBodyProps): ReactElement {
const { register, handleSubmit } = useForm<{ search: string }>()

const onSubmit = (): Promise<void> => {
const onSubmit = (data: { search: string }): Promise<void> => {
onSearch()
const { search } = getValues()
setSearch(search)
return fetch({ limit: PRODUCT_LIST_ITEMS_PER_PAGE }, [], [], search)
setSearch(data.search)
return fetchProducts({ limit: PRODUCT_LIST_ITEMS_PER_PAGE }, [], [], data.search)
}

return (
Expand Down
4 changes: 2 additions & 2 deletions app/pages/catalog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ export default function Catalog(): ReactElement {
<FilterFormBody
filters={data.filters}
categories={data.categories}
fetch={data.fetchProducts}
fetchProducts={data.fetchProducts}
onApply={data.resetCurrentPageAndSearchText}
/>
<div className="flex-grow">
<SearchFormBody
fetch={data.fetchProducts}
fetchProducts={data.fetchProducts}
setSearch={data.setSearchText}
onSearch={() => data.setCurrentPage(1)}
/>
Expand Down
Loading