diff --git a/public/assets/icons/arrow-left.svg b/public/assets/icons/arrow-left.svg index 16b35dea..52dc05c6 100644 --- a/public/assets/icons/arrow-left.svg +++ b/public/assets/icons/arrow-left.svg @@ -1,3 +1,3 @@ - + diff --git a/public/assets/icons/arrow-right.svg b/public/assets/icons/arrow-right.svg index e9746fd6..b25599ae 100644 --- a/public/assets/icons/arrow-right.svg +++ b/public/assets/icons/arrow-right.svg @@ -1,3 +1,3 @@ - + diff --git a/public/assets/icons/empty-cart.svg b/public/assets/icons/empty-cart.svg new file mode 100644 index 00000000..1bf192f3 --- /dev/null +++ b/public/assets/icons/empty-cart.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/components/events/EventCard/index.tsx b/src/components/events/EventCard/index.tsx index 37dffad8..436f7a64 100644 --- a/src/components/events/EventCard/index.tsx +++ b/src/components/events/EventCard/index.tsx @@ -1,41 +1,70 @@ import { CommunityLogo, Typography } from '@/components/common'; import EventModal from '@/components/events/EventModal'; import PointsDisplay from '@/components/events/PointsDisplay'; -import { PublicEvent } from '@/lib/types/apiResponses'; -import { formatEventDate } from '@/lib/utils'; +import { + PublicEvent, + PublicOrderPickupEvent, + PublicOrderPickupEventWithLinkedEvent, +} from '@/lib/types/apiResponses'; +import { formatEventDate, isOrderPickupEvent } from '@/lib/utils'; import Image from 'next/image'; import { useState } from 'react'; import styles from './style.module.scss'; interface EventCardProps { - event: PublicEvent; + event: PublicEvent | PublicOrderPickupEvent; attended: boolean; className?: string; showYear?: boolean; + borderless?: boolean; hideInfo?: boolean; } -const EventCard = ({ event, attended, className, showYear, hideInfo }: EventCardProps) => { - const { cover, title, start, end, location } = event; +const EventCard = ({ + event, + attended, + className, + showYear, + borderless, + hideInfo, +}: EventCardProps) => { + const { cover, title, start, end, location, committee } = isOrderPickupEvent(event) + ? { + ...(event.linkedEvent ?? {}), + ...event, + } + : event; + const [expanded, setExpanded] = useState(false); + const hasModal = !isOrderPickupEvent(event) || event.linkedEvent; const displayCover = cover || '/assets/graphics/store/hero-photo.jpg'; return ( <> - setExpanded(false)} - /> + {hasModal && ( + setExpanded(false)} + /> + )} +
- + {!isOrderPickupEvent(event) && ( + + )} Event Cover Image
- {hideInfo ? null : ( + {!hideInfo && (
- +
{ + const { cover, title, start, end, location, committee, pointValue, eventLink, description } = + isOrderPickupEvent(event) + ? { + ...(event.linkedEvent ?? {}), + ...event, + } + : event; + + const displayCover = cover || '/assets/graphics/store/hero-photo.jpg'; + const uuidForLink = isOrderPickupEvent(event) ? event.linkedEvent?.uuid : event.uuid; + const displayEventLink = + fixUrl(eventLink ?? '') || (uuidForLink && `https://acmucsd.com/events/${uuidForLink}`) || ''; + const isUpcomingEvent = new Date(start) > new Date(); + + return ( + <> +
+ {inModal && ( + + )} + {pointValue && } + Event Cover Image +
+
+
+
+ +
+ + {title} + + + {formatEventDate(start, end, true)} + + + {location} + +
+
+ + {isUpcomingEvent && !isOrderPickupEvent(event) ? : null} +
+ + + {description} + + {displayEventLink && ( + +
+ +
+ + {displayEventLink} + + + )} +
+ + ); +}; + +export default EventDetail; diff --git a/src/components/events/EventModal/style.module.scss b/src/components/events/EventDetail/style.module.scss similarity index 100% rename from src/components/events/EventModal/style.module.scss rename to src/components/events/EventDetail/style.module.scss diff --git a/src/components/events/EventModal/style.module.scss.d.ts b/src/components/events/EventDetail/style.module.scss.d.ts similarity index 100% rename from src/components/events/EventModal/style.module.scss.d.ts rename to src/components/events/EventDetail/style.module.scss.d.ts diff --git a/src/components/events/EventModal/index.tsx b/src/components/events/EventModal/index.tsx index 624b3444..a23364cb 100644 --- a/src/components/events/EventModal/index.tsx +++ b/src/components/events/EventModal/index.tsx @@ -1,13 +1,6 @@ -import { CommunityLogo, Modal, Typography } from '@/components/common'; -import CalendarButtons from '@/components/events/CalendarButtons'; -import PointsDisplay from '@/components/events/PointsDisplay'; +import { Modal } from '@/components/common'; +import EventDetail from '@/components/events/EventDetail'; import { PublicEvent } from '@/lib/types/apiResponses'; -import { fixUrl, formatEventDate } from '@/lib/utils'; -import CloseIcon from '@/public/assets/icons/close-icon.svg'; -import LinkIcon from '@/public/assets/icons/link.svg'; -import Image from 'next/image'; -import Link from 'next/link'; -import styles from './style.module.scss'; interface EventModalProps { open: boolean; @@ -17,64 +10,9 @@ interface EventModalProps { } const EventModal = ({ open, attended, event, onClose }: EventModalProps) => { - const { cover, title, start, end, location, description, eventLink } = event; - - const displayCover = cover || '/assets/graphics/store/hero-photo.jpg'; - const displayEventLink = fixUrl(eventLink) || `https://acmucsd.com/events/${event.uuid}`; - const isUpcomingEvent = new Date(start) > new Date(); - return ( -
- - - Event Cover Image -
-
-
-
- -
- - {title} - - - {formatEventDate(start, end, true)} - - - {location} - -
-
- - {isUpcomingEvent ? : null} -
- - - {description} - - -
- -
- - {displayEventLink} - - -
+
); }; diff --git a/src/components/store/CartItemCard/index.tsx b/src/components/store/CartItemCard/index.tsx new file mode 100644 index 00000000..336ed0e4 --- /dev/null +++ b/src/components/store/CartItemCard/index.tsx @@ -0,0 +1,102 @@ +import { Typography } from '@/components/common'; +import Diamonds from '@/components/store/Diamonds'; +import StoreConfirmModal from '@/components/store/StoreConfirmModal'; +import { config } from '@/lib'; +import type { UUID } from '@/lib/types'; +import { ClientCartItem } from '@/lib/types/client'; +import { capitalize, getDefaultMerchItemPhoto, validateClientCartItem } from '@/lib/utils'; +import Image from 'next/image'; +import Link from 'next/link'; +import styles from './style.module.scss'; + +interface CartItemCardProps { + item: ClientCartItem; + removeItem?: (optionUUID: UUID) => void; + removable: boolean; +} + +/** + * Card for items displayed on cart page + */ +const CartItemCard = ({ item, removeItem, removable }: CartItemCardProps) => { + const itemPage = `${config.store.itemRoute}${item.uuid}`; + + const unavailableReason = validateClientCartItem(item); + + const remover = removable && ( + + + Remove Item + + + } + title="Are you sure you want to remove this item?" + onConfirm={() => { + // remove with delay to so modal-closing animation can play + setTimeout(() => removeItem && removeItem(item.option.uuid), 100); + }} + confirmRemove + > + + + ); + + return ( +
+
+ +
+ {item.itemName} +
+ +
+
+
+
+ + + {item.itemName} + + + +
+ + {item.option.metadata && ( + + {capitalize(item.option.metadata?.type)}:  + + {item.option.metadata?.value} + + + )} + + Quantity:  + + {item.quantity} + + +
+
+ {remover} + {unavailableReason && removable && ( +
+
+ + {unavailableReason} + + {remover} +
+
+ )} +
+ ); +}; + +export default CartItemCard; diff --git a/src/components/store/CartItemCard/style.module.scss b/src/components/store/CartItemCard/style.module.scss new file mode 100644 index 00000000..a9d6ca49 --- /dev/null +++ b/src/components/store/CartItemCard/style.module.scss @@ -0,0 +1,112 @@ +@use 'src/styles/vars.scss' as vars; + +.cartItem { + --desktop-image-size: 150px; + --mobile-image-size: 100px; + display: grid; + gap: 1.5rem; + grid-template-areas: + "left right" + "left remove"; + grid-template-columns: var(--desktop-image-size) 1fr; + padding: 0.75rem 1rem; + position: relative; + + @media (max-width: vars.$breakpoint-md) { + grid-template-areas: + "left right" + "remove right"; + grid-template-columns: var(--mobile-image-size) 1fr; + } + + .leftCol { + align-items: center; + display: flex; + flex-direction: column; + gap: 1rem; + grid-area: left; + justify-content: space-between; + + .imageWrapper { + height: var(--desktop-image-size); + position: relative; + width: var(--desktop-image-size); + + @media (max-width: vars.$breakpoint-md) { + height: var(--mobile-image-size); + width: var(--mobile-image-size); + } + } + } + + .rightCol { + display: flex; + flex: 1; + flex-direction: column; + grid-area: right; + justify-content: space-between; + + .cartItemInfo { + align-self: stretch; + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 0.5rem; + + .title { + align-items: baseline; + display: flex; + gap: 1rem; + justify-content: space-between; + + @media (max-width: vars.$breakpoint-md) { + flex-direction: column; + } + + h3:hover { + text-decoration: underline; + } + + .price { + justify-self: flex-end; + white-space: nowrap; + } + } + } + } + + .removeBtn { + align-self: end; + background-color: transparent; + color: var(--theme-danger-1); + grid-area: remove; + padding: 0; + width: fit-content; + + @media (max-width: vars.$breakpoint-md) { + margin: auto; + } + } + + .unavailable { + align-items: center; + background-color: var(--theme-accent-line-1-transparent); + bottom: 0; + display: flex; + justify-content: center; + left: 0; + padding: 1rem; + position: absolute; + right: 0; + top: 0; + + > div { + align-items: center; + background-color: var(--theme-surface-2); + border-radius: 0.5rem; + display: flex; + flex-direction: column; + padding: 1rem; + } + } +} \ No newline at end of file diff --git a/src/components/store/CartItemCard/style.module.scss.d.ts b/src/components/store/CartItemCard/style.module.scss.d.ts new file mode 100644 index 00000000..7ad4bfab --- /dev/null +++ b/src/components/store/CartItemCard/style.module.scss.d.ts @@ -0,0 +1,17 @@ +export type Styles = { + cartItem: string; + cartItemInfo: string; + imageWrapper: string; + leftCol: string; + price: string; + removeBtn: string; + rightCol: string; + title: string; + unavailable: string; +}; + +export type ClassNames = keyof Styles; + +declare const styles: Styles; + +export default styles; diff --git a/src/components/store/PickupEventPicker/index.tsx b/src/components/store/PickupEventPicker/index.tsx new file mode 100644 index 00000000..ec93f237 --- /dev/null +++ b/src/components/store/PickupEventPicker/index.tsx @@ -0,0 +1,70 @@ +import { Typography } from '@/components/common'; +import EventCard from '@/components/events/EventCard'; +import { PublicOrderPickupEvent } from '@/lib/types/apiResponses'; +import ArrowLeft from '@/public/assets/icons/arrow-left.svg'; +import ArrowRight from '@/public/assets/icons/arrow-right.svg'; +import styles from './style.module.scss'; + +interface EventPickerProps { + events: PublicOrderPickupEvent[]; + eventIndex: number; + setEventIndex: (...args: any[]) => any; + active: boolean; +} + +const PickupEventPicker = ({ events, eventIndex, setEventIndex, active }: EventPickerProps) => { + return ( +
+
+ {events.length > 0 ? ( +
+ {events.map(event => ( + + ))} +
+ ) : ( +
+ + No upcoming pickup events. Check back later! + +
+ )} +
+ + {active && ( +
+ + {`${eventIndex + 1}/${ + events.length + }`} + +
+ )} +
+ ); +}; + +export default PickupEventPicker; diff --git a/src/components/store/PickupEventPicker/style.module.scss b/src/components/store/PickupEventPicker/style.module.scss new file mode 100644 index 00000000..185f4acd --- /dev/null +++ b/src/components/store/PickupEventPicker/style.module.scss @@ -0,0 +1,58 @@ +.eventPicker { + align-items: center; + display: flex; + flex-direction: column; + + .window { + --width: 320px; + height: 270px; + overflow: hidden; + position: relative; + width: var(--width); + + .slider { + display: flex; + position: absolute; + transition: transform 0.3s ease-in-out; + } + + .noEvents { + align-items: center; + color: var(--theme-text-on-background-3); + display: flex; + height: 100%; + text-align: center; + width: 100% + } + } + + .eventNavigation { + align-items: center; + align-self: stretch; + display: flex; + justify-content: space-between; + + button { + background-color: var(--theme-surface-1); + border-radius: 0.9375rem; + display: flex; + height: 1.875rem; + margin: 0.5rem 1rem; + padding: 0; + width: 1.875rem; + + svg { + color: var(--theme-text-on-background-3); + transition: color 0.25s ease; + } + + &:disabled { + cursor: default; + + svg { + color: transparent; + } + } + } + } +} diff --git a/src/components/store/PickupEventPicker/style.module.scss.d.ts b/src/components/store/PickupEventPicker/style.module.scss.d.ts new file mode 100644 index 00000000..46de78e2 --- /dev/null +++ b/src/components/store/PickupEventPicker/style.module.scss.d.ts @@ -0,0 +1,13 @@ +export type Styles = { + eventNavigation: string; + eventPicker: string; + noEvents: string; + slider: string; + window: string; +}; + +export type ClassNames = keyof Styles; + +declare const styles: Styles; + +export default styles; diff --git a/src/components/store/StoreConfirmModal/index.tsx b/src/components/store/StoreConfirmModal/index.tsx new file mode 100644 index 00000000..86b10bea --- /dev/null +++ b/src/components/store/StoreConfirmModal/index.tsx @@ -0,0 +1,80 @@ +import { Modal, Typography } from '@/components/common'; +import { PropsWithChildren, ReactElement, cloneElement, useState } from 'react'; +import styles from './style.module.scss'; + +interface StoreConfirmModalProps { + /** a button to open the modal on click */ + opener: ReactElement; + /** title for modal card */ + title: string; + /** called when confirm button is clicked */ + onConfirm?: (...args: any[]) => any; + /** called when modal is closed any way other than the confirm button */ + onCancel?: (...args: any[]) => any; + /** make confirm button red and say "remove" */ + confirmRemove?: boolean; +} + +/** + * A wrapper for a modal in store style with 'Confirm' and 'Go back' buttons + * @returns wrapper including opener and modal + */ +const StoreConfirmModal = ({ + opener, + title, + onConfirm = () => {}, + onCancel = () => {}, + confirmRemove = false, + children, +}: PropsWithChildren) => { + const [open, setOpen] = useState(false); + + const onClose = () => { + setOpen(false); + onCancel(); + }; + + return ( + <> + {cloneElement(opener, { + onClick: () => { + if (opener.props.onClick) opener.props.onClick(); + setOpen(true); + }, + })} + +
+
+ + {title} + +
+
+ {children} +
+ + +
+
+
+
+ + ); +}; + +export default StoreConfirmModal; diff --git a/src/components/store/StoreConfirmModal/style.module.scss b/src/components/store/StoreConfirmModal/style.module.scss new file mode 100644 index 00000000..27263faa --- /dev/null +++ b/src/components/store/StoreConfirmModal/style.module.scss @@ -0,0 +1,59 @@ +@use 'src/styles/vars.scss' as vars; + +.card { + align-items: center; + display: flex; + flex-direction: column; + + .header { + align-items: center; + background-color: var(--theme-surface-1); + display: flex; + height: 3.75rem; + justify-content: center; + padding: 1.5rem 1rem; + width: 100%; + + h1 { + text-align: center; + } + } + + .body { + align-items: center; + display: flex; + flex-direction: column; + gap: 2rem; + padding: 2rem 1rem; + width: 100%; + } + + .options { + display: flex; + gap: 1rem; + justify-content: center; + width: 100%; + + button { + border: 1px solid var(--theme-accent-line-2); + border-radius: 0.625rem; + color: vars.$light-background; + flex-grow: 1; + height: 2.5rem; + max-width: 12.5rem; + } + + .confirm { + background: var(--theme-primary-2); + } + + .confirmRemove { + background: vars.$danger-1; + } + + .cancel { + background: var(--theme-surface-1); + color: var(--theme-text-on-surface-1); + } + } +} diff --git a/src/components/store/StoreConfirmModal/style.module.scss.d.ts b/src/components/store/StoreConfirmModal/style.module.scss.d.ts new file mode 100644 index 00000000..48de4a76 --- /dev/null +++ b/src/components/store/StoreConfirmModal/style.module.scss.d.ts @@ -0,0 +1,15 @@ +export type Styles = { + body: string; + cancel: string; + card: string; + confirm: string; + confirmRemove: string; + header: string; + options: string; +}; + +export type ClassNames = keyof Styles; + +declare const styles: Styles; + +export default styles; diff --git a/src/components/store/index.ts b/src/components/store/index.ts index 4cec2f53..5efbeaaf 100644 --- a/src/components/store/index.ts +++ b/src/components/store/index.ts @@ -1,6 +1,7 @@ export { default as CartOptionsGroup } from '@/components/store/CartOptionsGroup'; export { default as ItemHeader } from '@/components/store/ItemHeader'; export { default as SizeSelector } from '@/components/store/SizeSelector'; +export { default as CartItemCard } from './CartItemCard'; export { default as CollectionSlider } from './CollectionSlider'; export { default as CreateButton } from './CreateButton'; export { default as Diamonds } from './Diamonds'; @@ -11,4 +12,6 @@ export { default as HiddenIcon } from './HiddenIcon'; export { default as ItemCard } from './ItemCard'; export { default as OrderCard } from './OrderCard'; export { default as OrdersDisplay } from './OrdersDisplay'; +export { default as PickupEventPicker } from './PickupEventPicker'; +export { default as StoreConfirmModal } from './StoreConfirmModal'; export { default as Navbar } from './StoreNavbar'; diff --git a/src/lib/api/ResumeAPI.ts b/src/lib/api/ResumeAPI.ts index 30dfc743..adddd0e0 100644 --- a/src/lib/api/ResumeAPI.ts +++ b/src/lib/api/ResumeAPI.ts @@ -1,5 +1,5 @@ import { config } from '@/lib'; -import { UUID } from '@/lib/types'; +import type { UUID } from '@/lib/types'; import { PatchResumeRequest } from '@/lib/types/apiRequests'; import type { PatchResumeResponse, diff --git a/src/lib/api/StoreAPI.ts b/src/lib/api/StoreAPI.ts index 179f32a2..e966f59a 100644 --- a/src/lib/api/StoreAPI.ts +++ b/src/lib/api/StoreAPI.ts @@ -11,6 +11,7 @@ import { MerchItem, MerchItemEdit, MerchItemOption, + PlaceMerchOrderRequest, } from '@/lib/types/apiRequests'; import type { CreateCollectionPhotoResponse, @@ -29,6 +30,7 @@ import type { GetOneMerchOrderResponse, GetOrderPickupEventResponse, GetOrderPickupEventsResponse, + PlaceMerchOrderResponse, PublicMerchCollection, PublicMerchCollectionPhoto, PublicMerchItem, @@ -48,8 +50,8 @@ import axios from 'axios'; * @returns Item info */ export const getItem = async ( - uuid: UUID, - token: string + token: string, + uuid: UUID ): Promise => { const requestUrl = `${config.api.baseUrl}${config.api.endpoints.store.item}/${uuid}`; @@ -396,6 +398,21 @@ export const getFutureOrderPickupEvents = async ( return response.data.pickupEvents; }; +export const placeMerchOrder = async ( + token: string, + data: PlaceMerchOrderRequest +): Promise => { + const requestUrl = `${config.api.baseUrl}${config.api.endpoints.store.order}`; + + const response = await axios.post(requestUrl, data, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + return response.data.order; +}; + export const getPastOrderPickupEvents = async ( token: string ): Promise => { diff --git a/src/lib/managers/StoreManager.ts b/src/lib/managers/StoreManager.ts new file mode 100644 index 00000000..2fd74900 --- /dev/null +++ b/src/lib/managers/StoreManager.ts @@ -0,0 +1,27 @@ +import { StoreAPI } from '@/lib/api'; +import { getClientCookie } from '@/lib/services/CookieService'; +import type { UUID } from '@/lib/types'; +import type { ClientCartItem } from '@/lib/types/client'; +import { CookieType } from '@/lib/types/enums'; + +//* disabling this until more functions are added +/* eslint-disable import/prefer-default-export */ + +/** + * Place a new merch order + * @param items array of ClientCartItem to order + * @param pickupEvent uuid of a pickup event + * @returns successfully created order + */ +export const placeMerchOrder = async (items: ClientCartItem[], pickupEvent: UUID) => { + const token = getClientCookie(CookieType.ACCESS_TOKEN); + if (!token) throw new Error('Missing access token'); + + return StoreAPI.placeMerchOrder(token, { + order: items.map(({ option: { uuid }, quantity }) => ({ + option: uuid, + quantity, + })), + pickupEvent, + }); +}; diff --git a/src/lib/managers/index.ts b/src/lib/managers/index.ts index ae3faefa..62016d75 100644 --- a/src/lib/managers/index.ts +++ b/src/lib/managers/index.ts @@ -2,3 +2,4 @@ export * as AdminEventManager from './AdminEventManager'; export * as AdminStoreManager from './AdminStoreManager'; export * as AuthManager from './AuthManager'; export * as EventManager from './EventManager'; +export * as StorageManager from './StoreManager'; diff --git a/src/lib/types/apiResponses.ts b/src/lib/types/apiResponses.ts index e0504558..01ba4ffc 100644 --- a/src/lib/types/apiResponses.ts +++ b/src/lib/types/apiResponses.ts @@ -453,6 +453,10 @@ export interface PublicOrderPickupEvent { linkedEvent?: PublicEvent; } +export interface PublicOrderPickupEventWithLinkedEvent extends PublicOrderPickupEvent { + linkedEvent: PublicEvent; +} + export interface GetOrderPickupEventsResponse extends ApiResponse { pickupEvents: PublicOrderPickupEvent[]; } diff --git a/src/lib/types/client.ts b/src/lib/types/client.ts new file mode 100644 index 00000000..a3a749a5 --- /dev/null +++ b/src/lib/types/client.ts @@ -0,0 +1,15 @@ +import { PublicMerchItemOption, PublicMerchItemWithPurchaseLimits } from '@/lib/types/apiResponses'; + +export interface CookieCartItem { + itemUUID: string; + optionUUID: string; + quantity: number; +} + +/** + * Similar to PublicCartItem but holds exactly one option + */ +export interface ClientCartItem extends Omit { + option: PublicMerchItemOption; + quantity: number; +} diff --git a/src/lib/types/enums.ts b/src/lib/types/enums.ts index b45c3cc0..27652c8e 100644 --- a/src/lib/types/enums.ts +++ b/src/lib/types/enums.ts @@ -1,6 +1,7 @@ export enum CookieType { USER = 'USER', ACCESS_TOKEN = 'ACCESS_TOKEN', + CART = 'CART', USER_PREVIEW_ENABLED = 'USER_PREVIEW_ENABLED', } diff --git a/src/lib/types/index.ts b/src/lib/types/index.d.ts similarity index 100% rename from src/lib/types/index.ts rename to src/lib/types/index.d.ts diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 9059bb4a..df982cac 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,15 +4,18 @@ import showToast from '@/lib/showToast'; import type { URL } from '@/lib/types'; import type { CustomErrorBody, + PublicEvent, PublicMerchCollection, PublicMerchCollectionPhoto, PublicMerchItem, PublicMerchItemPhoto, PublicOrderItem, PublicOrderItemWithQuantity, + PublicOrderPickupEvent, PublicProfile, ValidatorError, } from '@/lib/types/apiResponses'; +import { ClientCartItem } from '@/lib/types/client'; import NoImage from '@/public/assets/graphics/cat404.png'; import { AxiosError } from 'axios'; import { @@ -37,7 +40,7 @@ export const getNextNYears = (num: number) => { * @param errBody Obj with validator constraint errors * @returns List of all user-friendly error strings */ -const getMessagesFromError = (errBody: CustomErrorBody): string[] => { +export const getMessagesFromError = (errBody: CustomErrorBody): string[] => { // if error has no suberrors, just return top level error message if (!errBody.errors) return [errBody.message]; @@ -299,7 +302,9 @@ export const getDateRange = (sort: string | number) => { * Returns the default (first) photo for a merchandise item. * If there are no photos for this item, returns the default 404 image. */ -export const getDefaultMerchItemPhoto = (item?: PublicMerchItem): string => { +export const getDefaultMerchItemPhoto = ( + item: Pick | undefined +): string => { if (item && item.merchPhotos.length > 0) { // Get the photo with the smallest position. const defaultPhoto = item.merchPhotos.reduce((prevImage, currImage) => { @@ -366,6 +371,35 @@ export const fixUrl = (input: string, prefix?: string): string => { return `https://${input}`; }; +/** + * Check if a ClientCartItem is in stock and within lifetime/monthly limits + * @param item item to validate + * @returns an error message string if the item is unavailable, or null otherwise + */ +export const validateClientCartItem = (item: ClientCartItem): string | null => { + if (item.quantity > item.lifetimeRemaining) + return item.lifetimeRemaining === 0 + ? 'You have already reached your lifetime limit for this item' + : `You can only purchase ${item.lifetimeRemaining} more of this item`; + if (item.quantity > item.monthlyRemaining) + return item.monthlyRemaining === 0 + ? 'You have already reached your monthly limit for this item' + : `You can only purchase ${item.monthlyRemaining} more of this item this month`; + if (item.hidden) return 'Ordering this item has been temporarily disabled'; + if (item.option.quantity === 0) return 'This item is out of stock'; + if (item.quantity > item.option.quantity) + return `You have selected more of this item than is in stock (${item.option.quantity} left)`; + return null; +}; + +/** + * Type predicate distinguishes between PublicOrderPickupEvent and PublicEvent + * @returns true if event is PublicOrderPickupEvent + */ +export const isOrderPickupEvent = ( + event: PublicOrderPickupEvent | PublicEvent +): event is PublicOrderPickupEvent => 'status' in event; + /** * Condenses a list of ordered items into unique items with quantities. */ diff --git a/src/pages/debug.tsx b/src/pages/debug.tsx new file mode 100644 index 00000000..1d4f8182 --- /dev/null +++ b/src/pages/debug.tsx @@ -0,0 +1,64 @@ +import { Button, Typography } from '@/components/common'; +import { config, showToast } from '@/lib'; +import withAccessType from '@/lib/hoc/withAccessType'; +import { PermissionService } from '@/lib/services'; +import { setClientCookie } from '@/lib/services/CookieService'; +import { CookieCartItem } from '@/lib/types/client'; +import { CookieType } from '@/lib/types/enums'; +import { GetServerSideProps } from 'next'; + +const DebugPage = () => { + return ( +
+ Debug +
+ Store + +
+ ); +}; + +export default DebugPage; + +const getServerSidePropsFunc: GetServerSideProps = async () => { + if (!config.api.baseUrl.includes('testing')) + return { + notFound: true, + }; + + return { props: {} }; +}; + +export const getServerSideProps = withAccessType( + getServerSidePropsFunc, + PermissionService.loggedInUser +); diff --git a/src/pages/store/cart.tsx b/src/pages/store/cart.tsx new file mode 100644 index 00000000..9c998c12 --- /dev/null +++ b/src/pages/store/cart.tsx @@ -0,0 +1,319 @@ +import { Typography } from '@/components/common'; +import EventDetail from '@/components/events/EventDetail'; +import { + CartItemCard, + Diamonds, + Navbar, + PickupEventPicker, + StoreConfirmModal, +} from '@/components/store'; +import { config, showToast } from '@/lib'; +import { getFutureOrderPickupEvents, getItem } from '@/lib/api/StoreAPI'; +import { getCurrentUserAndRefreshCookie } from '@/lib/api/UserAPI'; +import withAccessType from '@/lib/hoc/withAccessType'; +import { placeMerchOrder } from '@/lib/managers/StoreManager'; +import { getServerCookie, setClientCookie, setServerCookie } from '@/lib/services/CookieService'; +import { loggedInUser } from '@/lib/services/PermissionService'; +import type { UUID } from '@/lib/types'; +import { + PrivateProfile, + PublicMerchItemOption, + PublicOrderPickupEvent, + PublicOrderPickupEventWithLinkedEvent, +} from '@/lib/types/apiResponses'; +import { ClientCartItem, CookieCartItem } from '@/lib/types/client'; +import { CookieType } from '@/lib/types/enums'; +import { reportError, validateClientCartItem } from '@/lib/utils'; +import EmptyCartIcon from '@/public/assets/icons/empty-cart.svg'; +import styles from '@/styles/pages/store/cart/index.module.scss'; +import { GetServerSideProps } from 'next'; +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; + +interface CartPageProps { + user: PrivateProfile; + savedCart: ClientCartItem[]; + pickupEvents: PublicOrderPickupEventWithLinkedEvent[]; +} + +enum CartState { + PRECHECKOUT, + CONFIRMING, + CONFIRMED, +} + +const clientCartToCookie = (items: ClientCartItem[]): string => + JSON.stringify( + items.map(item => ({ + itemUUID: item.uuid, + optionUUID: item.option.uuid, + quantity: item.quantity, + })) + ); + +const calculateOrderTotal = (cart: ClientCartItem[]): number => { + return cart.reduce((total, { quantity, option: { price } }) => total + quantity * price, 0); +}; + +const StoreCartPage = ({ user: { credits }, savedCart, pickupEvents }: CartPageProps) => { + const [cart, setCart] = useState(savedCart); + const [pickupIndex, setPickupIndex] = useState(0); + const [cartState, setCartState] = useState(CartState.PRECHECKOUT); + const [orderTotal, setOrderTotal] = useState(calculateOrderTotal(savedCart)); + const [liveCredits, setLiveCredits] = useState(credits); + + // update the cart cookie and order total + useEffect(() => { + setOrderTotal(calculateOrderTotal(cart)); + setClientCookie(CookieType.CART, clientCartToCookie(cart)); + }, [cart, credits]); + + const cartInvalid = useMemo(() => cart.some(item => validateClientCartItem(item)), [cart]); + + // prop for item cards to remove their item + const removeItem = (optionUUID: UUID) => { + setCart(cart => cart.filter(item => item.option.uuid !== optionUUID)); + }; + + // handle confirming an order + const placeOrder = () => + placeMerchOrder(cart, pickupEvents[pickupIndex]?.uuid ?? 'x') + .then(({ items, pickupEvent }) => { + showToast( + `Order with ${items.length} item${items.length === 1 ? '' : 's'} placed`, + `Pick up at ${pickupEvent.title}` + ); + setCartState(CartState.CONFIRMED); + setClientCookie(CookieType.CART, clientCartToCookie([])); + setLiveCredits(credits - orderTotal); + }) + .catch(err => { + reportError('Unable to place order', err); + }); + + return ( +
+ +
+ {/* Title */} + + {cartState !== CartState.CONFIRMED ? 'Your Cart' : 'Order Confirmation'} + + + {/* Place Order Button */} + {cart.length > 0 && cartState !== CartState.CONFIRMED ? ( + { + setCartState(CartState.CONFIRMING); + }} + disabled={orderTotal > credits || pickupEvents.length === 0 || cartInvalid} + > + + Place Order + + + } + onConfirm={placeOrder} + onCancel={() => setCartState(CartState.PRECHECKOUT)} + > + {pickupEvents[pickupIndex] && ( + + )} + + ) : ( +
+ )} + + {/* Confirmation */} + {cartState === CartState.CONFIRMED ? ( +
+ + You will receive a confirmation email with your order details soon. +
+ Please pick up your items at the event you selected. Thank you! +
+
+ + + + + + My Orders + + +
+
+ ) : ( +
+ )} + + {/* Items */} +
+
+ + {cart.length} {cart.length === 1 ? 'Item' : 'Items'} + +
+ {cart.map(item => ( + + ))} + {cart.length === 0 && ( +
+ + + Your cart is empty. Visit the ACM Store to add items! + + + + Go to Store + + +
+ )} +
+ + {/* Event Picker */} + {((cart.length > 0 && credits >= orderTotal && !cartInvalid) || + cartState === CartState.CONFIRMED) && ( +
+
+ + {cartState !== CartState.CONFIRMED ? 'Choose Pickup Event' : 'Pickup Event Details'} + +
+ +
+ )} + + {/* Points Summary */} +
+ + {cartState !== CartState.CONFIRMED ? 'Membership Points' : 'Balance Details'} + +
+
+ + {cartState !== CartState.CONFIRMED ? 'Current' : 'Previous'} Balance + + +
+
+ + Order Total + +
+ - +
+
+
+
+ + Remaining Balance + + +
+
+
+
+ {credits < orderTotal && ( + + You do not have enough membership points to checkout. Earn more membership points by + attending ACM events! + + )} + {cartInvalid && ( + + Some items in your cart are not available. Remove them to check out! + + )} +
+
+
+ ); +}; + +export default StoreCartPage; + +const getServerSidePropsFunc: GetServerSideProps = async ({ req, res }) => { + const AUTH_TOKEN = getServerCookie(CookieType.ACCESS_TOKEN, { req, res }); + + // recover saved items and reset cart to [] if it's invalid + let savedItems; + try { + const cartCookie = getServerCookie(CookieType.CART, { req, res }); + savedItems = JSON.parse(cartCookie); + if (!Array.isArray(savedItems)) throw new Error(); + } catch { + savedItems = []; + } + + const savedCartPromises = Promise.all( + savedItems.map(async ({ itemUUID, optionUUID, quantity }: CookieCartItem) => { + try { + const { options, ...publicItem } = await getItem(AUTH_TOKEN, itemUUID); + + // form the ClientCartItem from the PublicMerchItem + const clientCartItem: Partial = { ...publicItem, quantity }; + clientCartItem.option = options.find( + (option: PublicMerchItemOption) => option.uuid === optionUUID + ); + + // check for invalid cart cookie item + if (!clientCartItem.option || typeof quantity !== 'number') throw new Error(); + return clientCartItem as ClientCartItem; + } catch { + return undefined; + } + }) + ).then((items): ClientCartItem[] => items.filter(item => item !== undefined) as ClientCartItem[]); + + const pickupEventsPromise = getFutureOrderPickupEvents(AUTH_TOKEN).then(events => + events.filter(event => event.status !== 'CANCELLED') + ); + const userPromise = getCurrentUserAndRefreshCookie(AUTH_TOKEN, { req, res }); + + // gather the API request promises and await them concurrently + const [savedCart, pickupEvents, user] = await Promise.all([ + savedCartPromises, + pickupEventsPromise, + userPromise, + ]); + + // update the cart cookie if part of it was malformed + setServerCookie(CookieType.CART, clientCartToCookie(savedCart), { req, res }); + + return { + props: { + user, + savedCart, + pickupEvents, + }, + }; +}; + +export const getServerSideProps = withAccessType(getServerSidePropsFunc, loggedInUser); diff --git a/src/pages/store/item/[uuid].tsx b/src/pages/store/item/[uuid].tsx index c09daa0c..f7e89516 100644 --- a/src/pages/store/item/[uuid].tsx +++ b/src/pages/store/item/[uuid].tsx @@ -128,7 +128,7 @@ const getServerSidePropsFunc: GetServerSideProps = async ({ params, req, res }) const preview = CookieService.getServerCookie(CookieType.USER_PREVIEW_ENABLED, { req, res }); try { - const item = await StoreAPI.getItem(uuid, token); + const item = await StoreAPI.getItem(token, uuid); return { props: { uuid, diff --git a/src/pages/store/item/[uuid]/edit.tsx b/src/pages/store/item/[uuid]/edit.tsx index 7d49f7a8..43da16b4 100644 --- a/src/pages/store/item/[uuid]/edit.tsx +++ b/src/pages/store/item/[uuid]/edit.tsx @@ -32,7 +32,7 @@ const getServerSidePropsFunc: GetServerSideProps = async ({ params, req, res }) try { const [item, collections] = await Promise.all([ - StoreAPI.getItem(uuid, token), + StoreAPI.getItem(token, uuid), StoreAPI.getAllCollections(token), ]); return { props: { token, item, collections } }; diff --git a/src/pages/store/item/new.tsx b/src/pages/store/item/new.tsx index fdbf8e3b..c2c322a4 100644 --- a/src/pages/store/item/new.tsx +++ b/src/pages/store/item/new.tsx @@ -35,7 +35,7 @@ const getServerSidePropsFunc: GetServerSideProps = async ({ req, res, query }) = const token = CookieService.getServerCookie(CookieType.ACCESS_TOKEN, { req, res }); const [item, collections] = await Promise.all([ typeof query.duplicate === 'string' - ? StoreAPI.getItem(query.duplicate, token) + ? StoreAPI.getItem(token, query.duplicate) : query.collection ?? null, StoreAPI.getAllCollections(token), ]); diff --git a/src/styles/pages/store/cart/index.module.scss b/src/styles/pages/store/cart/index.module.scss new file mode 100644 index 00000000..f5f36d41 --- /dev/null +++ b/src/styles/pages/store/cart/index.module.scss @@ -0,0 +1,184 @@ +@use 'src/styles/vars.scss' as vars; + +.container { + display: flex; + flex-direction: column; + gap: 2rem; + margin: 0 auto; + max-width: 81rem; + + .content { + align-items: start; + display: grid; + grid-column-gap: 2rem; + grid-template-areas: + 'title placeOrder' + 'confirmation event' + 'items event' + 'items summary' + 'items warning'; + grid-template-columns: 1fr 20rem; + grid-template-rows: auto auto auto auto 1fr; + + @media screen and (max-width: vars.$breakpoint-md) { + grid-template-areas: + 'title' + 'placeOrder' + 'warning' + 'confirmation' + 'event' + 'items' + 'summary'; + grid-template-columns: 1fr; + } + + .storeButton { + align-items: center; + background-color: var(--theme-primary-2); + border-radius: 0.625rem; + color: vars.$light-background; + display: flex; + justify-content: center; + min-height: 2rem; + width: 100%; + } + + .title { + grid-area: title; + margin-bottom: 2rem; + } + + .placeOrder { + background: var(--primaries-primary-2, #62B0FF); + border: 1px solid var(--theme-accent-line-2, #E4E4E4); + border-radius: 10px; + color: vars.$light-background; + font-size: 20px; + font-weight: 700; + grid-area: placeOrder; + height: 2.5rem; + margin-bottom: 2rem; + + &:disabled { + background-color: transparent; + color: vars.$disabled; + cursor: not-allowed; + } + + .confirming { + background: vars.$success-1; + } + } + + .cartCard { + border-radius: 0.625rem; + box-shadow: 0 0.25rem 0.25rem var(--theme-shadow); + + .header { + align-items: center; + background-color: var(--theme-surface-1); + border-top-left-radius: 0.625rem; + border-top-right-radius: 0.625rem; + display: flex; + height: 3.75rem; + padding: 1.5rem 1rem; + } + } + + .confirmation { + align-items: center; + background-color: var(--theme-surface-1); + display: flex; + flex-direction: column; + gap: 1rem; + grid-area: confirmation; + margin-bottom: 2rem; + padding: 2rem; + text-align: center; + + div { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: center; + width: 100%; + + a { + flex-grow: 1; + max-width: 11.625rem; + } + } + } + + .items { + display: flex; + flex-direction: column; + grid-area: items; + margin-bottom: 2rem; + padding-bottom: 1rem; + + .emptyCart { + align-items: center; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 2rem 0; + text-align: center; + width: 100%; + + a { + margin: 0 1rem; + width: 12.5rem; + } + } + } + + .pointsCard { + grid-area: summary; + margin-bottom: 2rem; + + .points { + display: flex; + flex-direction: column; + gap: 0.5rem; + justify-content: space-around; + padding: 0.5rem 0.625rem; + + > div { + align-items: baseline; + display: flex; + justify-content: space-between; + + span { + text-align: end; + } + } + + hr { + border: 0.0625rem solid var(--theme-accent-line-1-transparent); + } + } + } + + .eventPicker { + grid-area: event; + margin-bottom: 0.5rem; + + @media (max-width: vars.$breakpoint-md) { + margin-bottom: 2rem; + } + } + + .warning { + display: flex; + flex-direction: column; + gap: 1rem; + grid-area: warning; + margin-bottom: 2rem; + + p { + color: var(--theme-danger-1); + } + } + } +} \ No newline at end of file diff --git a/src/styles/pages/store/cart/index.module.scss.d.ts b/src/styles/pages/store/cart/index.module.scss.d.ts new file mode 100644 index 00000000..547d4c34 --- /dev/null +++ b/src/styles/pages/store/cart/index.module.scss.d.ts @@ -0,0 +1,23 @@ +export type Styles = { + cartCard: string; + confirmation: string; + confirming: string; + container: string; + content: string; + emptyCart: string; + eventPicker: string; + header: string; + items: string; + placeOrder: string; + points: string; + pointsCard: string; + storeButton: string; + title: string; + warning: string; +}; + +export type ClassNames = keyof Styles; + +declare const styles: Styles; + +export default styles;