- {cart?.items.length === 0 || cart === null ? (
+ {cart?.items?.length === 0 || cart === null ? (
) : (
<>
diff --git a/app/_components/Globals/Spinner.tsx b/app/_components/Globals/Spinner.tsx
index ac37ada9..af8ce726 100644
--- a/app/_components/Globals/Spinner.tsx
+++ b/app/_components/Globals/Spinner.tsx
@@ -9,7 +9,7 @@ export const Spinner = ({ position = 'center', size = 10, className }: SpinnerPr
({
+ chosenOptions: {},
+ chosenOptionsId: [],
+ chosenVariant: { variantId: '', variantActive: true, variantLabel: '', variantStock: 0 }
});
export function useProductState(): {
productState: StateProduct;
updateProductProp: (property: string, value: unknown) => void;
+ updateProductState: (newState: StateProduct) => void;
} {
const [productState, setProductState] = useAtom(stateProduct);
@@ -36,7 +46,11 @@ export function useProductState(): {
});
};
- return { productState, updateProductProp };
+ const updateProductState = (newState: StateProduct) => {
+ setProductState(newState);
+ };
+
+ return { productState, updateProductProp, updateProductState };
}
/*****************************************************************************
diff --git a/app/_lib/Store.ts b/app/_lib/Store.ts
index 5873b5a1..a5398ce0 100644
--- a/app/_lib/Store.ts
+++ b/app/_lib/Store.ts
@@ -105,7 +105,8 @@ class Store {
sku: product.sku || null,
images: this.transformImages(product),
categories: product.category_index?.id,
- stock: product.stock_level
+ stock: product.stock_level,
+ reviewRating: product.review_rating
};
}
@@ -127,7 +128,7 @@ class Store {
* Convert Swell product options to generic format
****************************************************************************/
transformProductOptions(product: SwellProduct) {
- return product.options.map((option) => ({
+ return product.options?.map((option) => ({
label: option.name,
active: option.active,
values: option.values
@@ -139,9 +140,11 @@ class Store {
****************************************************************************/
transformProductVariants(product: SwellProduct) {
return product.variants?.results?.map((variant) => ({
+ id: variant.id,
name: variant.name,
active: variant.active,
- value_ids: variant.option_value_ids
+ value_ids: variant.option_value_ids,
+ stock_variant: variant.stock_level
}));
}
@@ -177,6 +180,59 @@ class Store {
limit
};
}
+
+ async getReviews(reviewInfo: { productId: string; limit?: number; page?: number }) {
+ const { productId, limit = 10, page } = reviewInfo;
+ const reviews = await swell.get('/products:reviews', {
+ parent_id: productId,
+ limit,
+ page
+ });
+ return reviews;
+ }
+
+ async postReview(reviewInfo: {
+ userId: string;
+ comments: string;
+ productId: string;
+ rating: number;
+ title: string;
+ }) {
+ const { userId, comments, productId, rating, title } = reviewInfo;
+
+ const reviews = await swell.post('/products:reviews', {
+ account_id: userId,
+ comments: comments,
+ parent_id: productId,
+ rating: rating,
+ title: title,
+ approved: true
+ });
+ return reviews;
+ }
+
+ async deleteReview(reviewId: string) {
+ const review = await swell.delete(`/products:reviews/${reviewId}`);
+ return review;
+ }
+
+ async editReview(
+ reviewId: string,
+ reviewInfo: {
+ comments: string;
+ rating: number;
+ title: string;
+ }
+ ) {
+ const { comments, rating, title } = reviewInfo;
+
+ const review = await swell.put(`/products:reviews/${reviewId}`, {
+ comments: comments,
+ rating: rating,
+ title: title
+ });
+ return review;
+ }
}
export default new Store();
diff --git a/app/_lib/SwellAPI.ts b/app/_lib/SwellAPI.ts
index 5ce789ce..d99041ec 100644
--- a/app/_lib/SwellAPI.ts
+++ b/app/_lib/SwellAPI.ts
@@ -1,5 +1,4 @@
import { cookies } from 'next/headers';
-import { redirect } from 'next/navigation';
import 'server-only';
import Store from './Store';
@@ -104,6 +103,7 @@ export const getLoggedUser = async (): Promise => {
name
}
product {
+ id
name
images {
file {
@@ -157,7 +157,7 @@ export const getUserInfo = async () => {
const user = await getLoggedUser();
if (!user?.session.accountId) {
- return redirect('/account/login');
+ return { authenticated: false, user: null, userId: null, orders: [], addresses: [], cards: [] };
}
const addresses = await getAddresses();
@@ -166,6 +166,7 @@ export const getUserInfo = async () => {
return {
authenticated: true,
user: user.account,
+ userId: user.session.accountId,
orders: user.orders.results || [],
addresses: addresses || [],
cards: cards || []
diff --git a/app/_types/Generic/Generic.d.ts b/app/_types/Generic/Generic.d.ts
index b0b09efd..077e33aa 100644
--- a/app/_types/Generic/Generic.d.ts
+++ b/app/_types/Generic/Generic.d.ts
@@ -1,5 +1,5 @@
interface Pagination {
- total: number;
+ total?: number;
pages: number[];
current: number;
limit: number;
diff --git a/app/_types/Generic/Products.d.ts b/app/_types/Generic/Products.d.ts
index 8b57310f..29604244 100644
--- a/app/_types/Generic/Products.d.ts
+++ b/app/_types/Generic/Products.d.ts
@@ -15,6 +15,7 @@ interface Product {
sku?: string | null;
categories?: string[];
stock?: number;
+ reviewRating?: number;
}
interface ProductImage {
@@ -29,9 +30,12 @@ interface ProductOption {
}
interface Variant {
+ id: string;
name: string;
active: boolean;
- value_ids: string[];
+ value_ids: Record | string[];
+ stock_variant: number;
+ variantActive: boolean;
}
interface GenericProductsList {
diff --git a/app/_types/Generic/Reviews.d.ts b/app/_types/Generic/Reviews.d.ts
new file mode 100644
index 00000000..9b053ab3
--- /dev/null
+++ b/app/_types/Generic/Reviews.d.ts
@@ -0,0 +1,21 @@
+type Reviews = {
+ count: number;
+ page_count: number;
+ page: number;
+ results: Review[];
+ pages: {
+ [key: string]: { start: number; end: number };
+ };
+};
+
+interface Review {
+ account_id: string;
+ comments: string;
+ parent_id: string;
+ rating: number;
+ title: string;
+ name: string;
+ date_created: string;
+ approved: boolean;
+ id: string;
+}
diff --git a/app/_types/Swell/Product.d.ts b/app/_types/Swell/Product.d.ts
index 546dbac4..62b258eb 100644
--- a/app/_types/Swell/Product.d.ts
+++ b/app/_types/Swell/Product.d.ts
@@ -34,6 +34,7 @@ interface SwellProduct {
sale_price?: number | null;
sku?: string;
category_index: CategoryIndex;
+ review_rating: number;
id: string;
}
@@ -103,4 +104,5 @@ interface SwellVariant {
date_updated: string;
sku?: string;
id: string;
+ stock_level: number;
}
diff --git a/app/_types/Swell/SwellAPI.d.ts b/app/_types/Swell/SwellAPI.d.ts
index 4f901ce7..6d22032d 100644
--- a/app/_types/Swell/SwellAPI.d.ts
+++ b/app/_types/Swell/SwellAPI.d.ts
@@ -37,6 +37,7 @@ interface SwellAPI_Order {
name: string;
};
product: {
+ id: string;
name: string;
price: number;
images: {
diff --git a/app/_types/Swell/swell-js_index.d.ts b/app/_types/Swell/swell-js_index.d.ts
index ae401255..9039d0cc 100644
--- a/app/_types/Swell/swell-js_index.d.ts
+++ b/app/_types/Swell/swell-js_index.d.ts
@@ -104,6 +104,7 @@ declare module 'swell-js' {
variant: { name: string; id: string };
taxTotal: number;
product: Product;
+ variant_id: string;
}
export interface CartItemSnakeCase {
@@ -120,6 +121,7 @@ declare module 'swell-js' {
variant: { name: string; id: string };
tax_total: number;
product: Product;
+ variant_id: string;
}
export type CartItem = CartItemCamelCase | CartItemSnakeCase;
diff --git a/app/account/(anon)/create-account/_components/RegisterForm.tsx b/app/account/(anon)/create-account/_components/RegisterForm.tsx
index 74a15ce4..58bceac7 100644
--- a/app/account/(anon)/create-account/_components/RegisterForm.tsx
+++ b/app/account/(anon)/create-account/_components/RegisterForm.tsx
@@ -1,7 +1,6 @@
'use client';
import Link from 'next/link';
-import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { AiOutlineEye, AiOutlineEyeInvisible } from 'react-icons/ai';
diff --git a/app/account/(anon)/reset-password/_components/ResetPasswordForm.tsx b/app/account/(anon)/reset-password/_components/ResetPasswordForm.tsx
index c1d235a1..61a5ad4d 100644
--- a/app/account/(anon)/reset-password/_components/ResetPasswordForm.tsx
+++ b/app/account/(anon)/reset-password/_components/ResetPasswordForm.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useRouter, useSearchParams } from 'next/navigation';
+import { useSearchParams } from 'next/navigation';
import { useRef, useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { AiOutlineEye, AiOutlineEyeInvisible } from 'react-icons/ai';
diff --git a/app/account/(user)/addresses/_components/AddressCard.tsx b/app/account/(user)/addresses/_components/AddressCard.tsx
index fd8e66ee..380d9eef 100644
--- a/app/account/(user)/addresses/_components/AddressCard.tsx
+++ b/app/account/(user)/addresses/_components/AddressCard.tsx
@@ -28,7 +28,7 @@ const AddressCard = ({ address }: Props) => {
setOpen(true)}>
-
+
{
setOpenModal={setOpenModal}
/>
{
void setOpenModal(true);
}}
>
-
+
diff --git a/app/account/(user)/layout.tsx b/app/account/(user)/layout.tsx
index e70250b2..c52f082e 100644
--- a/app/account/(user)/layout.tsx
+++ b/app/account/(user)/layout.tsx
@@ -1,3 +1,5 @@
+import { redirect } from 'next/navigation';
+
import EditProfileModal from './_components/EditProfileModal';
import AccountLink from './_components/Link';
import LogOutModal from './_components/LogOutModal';
@@ -9,6 +11,11 @@ import { getUserInfo } from '~/_lib/SwellAPI';
export default async function Layout({ children }: { children: React.ReactNode }) {
const { user: account } = await getUserInfo();
+ // Prevent access to account pages if user is not logged in
+ if (!account) {
+ return redirect('/account/login');
+ }
+
return (
diff --git a/app/account/(user)/payments/_components/PaymentsCard.tsx b/app/account/(user)/payments/_components/PaymentsCard.tsx
index 4a33496a..fbef5a0f 100644
--- a/app/account/(user)/payments/_components/PaymentsCard.tsx
+++ b/app/account/(user)/payments/_components/PaymentsCard.tsx
@@ -37,7 +37,7 @@ const PaymentCard = ({ card, defaultCard }: PaymentCardProps) => {
{defaultCard &&
Default
}
setOpenConfModal(true)}>
-
+
{
const [productAmount, setProductAmount] = useState(1);
const [pleaseSelectAllOptions, setPleaseSelectAllOptions] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isDisabled, setIsDisabled] = useState(false);
+
const { state } = useStore();
const { productState } = useProductState();
- const { setCart } = useGlobalState();
+ const { cart, setCart } = useGlobalState();
+
+ const { chosenOptions, chosenVariant } = productState;
+
+ const selectedStock =
+ // if there is variant information, we use the variant stock, otherwise we use the general product stock
+ product.variants && product.variants?.length > 0 ? chosenVariant?.variantStock : product.stock;
- const { chosenOptions } = productState;
+ const productHasVariant = product.variants && product.variants?.length > 0;
useEffect(() => {
product.options?.length === Object.keys(chosenOptions).length;
product.options?.length === Object.keys(chosenOptions).length && setPleaseSelectAllOptions('');
+
+ setProductAmount(1);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [chosenOptions, product.options?.length]);
+ useEffect(() => {
+ setIsDisabled(productAmount === selectedStock);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [product, productAmount, setProductAmount]);
+
const addProduct = async ({ product, quantity, toastifyMessage }: AddProductProps) => {
// Turn on spinner while waiting
setIsSubmitting(true);
-
// Add product to cart on Swell
await swell.cart
.addItem({
@@ -75,6 +92,26 @@ const AddToCart = ({ product, isAuthenticated }: ProductProp) => {
};
const handleAddToCart = () => {
+ // to check if the product (with no variant) is in the cart
+ const productInCart = cart && cart?.items?.find((item) => item.product_id.includes(product.id));
+ // to check if the variant is in the cart
+ const variantInCart =
+ cart &&
+ cart?.items?.find((item) =>
+ item.variant_id?.includes(chosenVariant.variantId ? chosenVariant.variantId : '')
+ );
+
+ // to check the stock of the product in the cart
+ if (productInCart && product.stock && productInCart?.quantity + productAmount > product.stock) {
+ return notifyFailure("The amount selected exceeds the stock available. We're sorry.");
+ }
+
+ const variantStock = chosenVariant.variantStock ?? 0;
+
+ if (variantInCart && variantInCart?.quantity + productAmount > variantStock) {
+ return notifyFailure("The amount selected exceeds the stock available. We're sorry.");
+ }
+
!isSubmitting &&
void addProduct({
product: product,
@@ -85,7 +122,7 @@ const AddToCart = ({ product, isAuthenticated }: ProductProp) => {
const buttonLabel = () => {
if (product.stock === 0) {
- return 'COMING SOON!';
+ return 'OUT OF STOCK';
}
if (isSubmitting) {
return ;
@@ -96,6 +133,15 @@ const AddToCart = ({ product, isAuthenticated }: ProductProp) => {
return 'UNAVAILABLE';
};
+ const handleProductAmount = () => {
+ if (selectedStock) {
+ if (productAmount < selectedStock) {
+ setProductAmount(productAmount + 1);
+ }
+ // if there is no stock information, there is no limit
+ } else setProductAmount(productAmount + 1);
+ };
+
return (
<>
@@ -110,14 +156,32 @@ const AddToCart = ({ product, isAuthenticated }: ProductProp) => {
/>
setProductAmount(productAmount + 1)}
+ className={`bg-gray hover:bg-gray-medium border border-gray border-b-0 hover:border-gray-300 p-1 ${
+ isDisabled ||
+ product.stock === 0 ||
+ (productHasVariant && !state.isVariantActive) ||
+ isSubmitting
+ ? 'opacity-50 hover:bg-gray hover:border-gray-300'
+ : ''
+ }`}
+ disabled={
+ isDisabled ||
+ product.stock === 0 ||
+ (productHasVariant && !state.isVariantActive) ||
+ isSubmitting
+ }
+ onClick={() => handleProductAmount()}
>
productAmount > 1 && setProductAmount(productAmount - 1)}
- className="bg-gray hover:bg-gray-medium border border-t-0 border-gray hover:border-gray-300 p-1"
+ disabled={productHasVariant && !state.isVariantActive}
+ className={`bg-gray hover:bg-gray-medium border border-t-0 border-gray hover:border-gray-300 p-1 ${
+ productHasVariant && !state.isVariantActive
+ ? 'opacity-50 hover:bg-gray hover:border-gray-300'
+ : ''
+ }`}
>
@@ -128,8 +192,12 @@ const AddToCart = ({ product, isAuthenticated }: ProductProp) => {
onClick={() => handleAddToCart()}
label={buttonLabel()}
variant="fill"
+ disabled={
+ product.stock === 0 || (productHasVariant && !state.isVariantActive) || isSubmitting
+ }
className={`font-bold py-3 px-5 md:min-w-[240px] ${
- state.isVariantActive || (product.options?.length === 0 && product.stock !== 0)
+ (state.isVariantActive && productHasVariant) ||
+ (product.options?.length === 0 && product?.stock !== 0)
? 'bg-black font-quicksand border text-white duration-200 cursor-pointer hover:bg-white hover:text-black'
: 'bg-gray-medium text-white font-quicksand border border-gray-medium'
}`}
diff --git a/app/products/[slug]/_components/ProductInfo/ProductInfo.tsx b/app/products/[slug]/_components/ProductInfo/ProductInfo.tsx
index 73f03388..fdfad905 100644
--- a/app/products/[slug]/_components/ProductInfo/ProductInfo.tsx
+++ b/app/products/[slug]/_components/ProductInfo/ProductInfo.tsx
@@ -16,12 +16,13 @@ interface ProductProp {
const ProductInfo = async ({ product }: ProductProp) => {
const auth = await isAuthenticated();
+ const productHasVariant = product.variants && product.variants?.length > 0;
return (
-
-
+ {!productHasVariant &&
}
+
diff --git a/app/products/[slug]/_components/ProductInfo/ProductOptions.tsx b/app/products/[slug]/_components/ProductInfo/ProductOptions.tsx
index 2125f2ea..dd697b85 100644
--- a/app/products/[slug]/_components/ProductInfo/ProductOptions.tsx
+++ b/app/products/[slug]/_components/ProductInfo/ProductOptions.tsx
@@ -2,64 +2,140 @@
import { useEffect, useState } from 'react';
-import { useStore, useProductState } from '~/_hooks/useStore';
+import ProductStock from './ProductStock';
+
+import { useProductState } from '~/_hooks/useStore';
interface ProductProp {
product: Product;
}
+interface Option {
+ label: string;
+ active: boolean;
+ values: Array<{ name: string; id: string }>;
+}
+
+interface OptionState {
+ currentOptionValue: { name: string; id: string } | null;
+ currentOption: Option | null;
+ selectedIds: Record
;
+}
+
const ProductOptions = ({ product }: ProductProp) => {
- const [selectedIds, setSelectedIds] = useState({});
- const { state, updateState } = useStore();
- const { productState, updateProductProp } = useProductState();
+ const { productState, updateProductState } = useProductState();
+ const [optionState, setOptionState] = useState({
+ currentOptionValue: null,
+ currentOption: null,
+ selectedIds: {}
+ });
- // Save only the variants with active states
- const activeProductVariants = product.variants?.filter((variant) => variant.active);
+ function findMatchingObject(variants: Variant[], valueIdsObject: Record) {
+ for (let i = 0; i < variants.length; i++) {
+ const item = variants[i];
+ const valueIds = Object.values(item.value_ids);
+ if (objectContainsAllValues(valueIds, valueIdsObject)) {
+ return item;
+ }
+ }
+ return null; // Return null if no matching object is found
+ }
- // Transform the selected products, from object into an array, to compare them with the set of active ids.
- const availableProductsId = Object.entries(selectedIds).map(([, value]) => value);
+ function objectContainsAllValues(valuesArray: string[], valueIdsObject: Record) {
+ for (const key in valueIdsObject) {
+ if (!valuesArray.includes(valueIdsObject[key])) {
+ return false;
+ }
+ }
+ return true;
+ }
// Find the first available (active) variant option and select it by default
useEffect(() => {
const firstActiveVariant = product.variants?.reverse().find((variant) => variant.active);
const firstActiveLabel = firstActiveVariant?.name.split(', ');
- let initialOptions = {};
+ const initialOptions: Record = {};
- product.options?.map((option, i) => {
- initialOptions = {
- ...initialOptions,
- [option.label]: firstActiveLabel ? firstActiveLabel[i] : ''
- };
+ product.options?.forEach((option, i) => {
+ initialOptions[option.label] = firstActiveLabel ? firstActiveLabel[i] : '';
+ });
- setSelectedIds((prev) => ({ ...prev, [option.label]: firstActiveVariant?.value_ids[i] }));
+ // Update selectedIds directly from the product prop
+ const selectedIds: Record = {};
+ product.options?.forEach((option, i) => {
+ if (option.active && firstActiveVariant) {
+ const valueIds = firstActiveVariant.value_ids;
+ if (typeof valueIds === 'object' && !Array.isArray(valueIds)) {
+ selectedIds[option.label] = valueIds[i];
+ }
+ }
});
- updateProductProp('chosenOptions', initialOptions);
+ setOptionState((prevState) => ({
+ ...prevState,
+ selectedIds
+ }));
+
+ const variantSelectedbyOptionsId =
+ product.variants && findMatchingObject(product.variants, selectedIds);
+
+ updateProductState({
+ ...productState,
+ chosenOptions: initialOptions,
+ chosenOptionsId: Object.values(selectedIds),
+ chosenVariant: {
+ variantLabel: variantSelectedbyOptionsId?.name,
+ variantId: variantSelectedbyOptionsId?.id,
+ variantActive: variantSelectedbyOptionsId?.active,
+ variantStock: variantSelectedbyOptionsId?.stock_variant
+ }
+ });
}, [product]);
- // Declare useEffect to 'listen' for variant selections
+ const handleClick = (value: { name: string; id: string }, option: Option) => {
+ setOptionState((prevState) => ({
+ ...prevState,
+ selectedIds: {
+ ...prevState.selectedIds,
+ [option.label]: value?.id
+ },
+ currentOptionValue: value,
+ currentOption: option
+ }));
+ };
+
useEffect(() => {
- // When the options have an item clicked we ask if the selected ids are the same as in the active variant
- if (availableProductsId.length === product.options?.length) {
- // Returns true or false if the selected ids are the same in the active variant
- const selectedIdsameAsActiveVariants = activeProductVariants?.map((variant) => {
- return variant.value_ids.every((id) => {
- return availableProductsId?.includes(id);
- });
- });
- // Set global state accordingly if its active or not
- if (selectedIdsameAsActiveVariants?.includes(true)) {
- updateState({ ...state, isVariantActive: true });
- } else {
- updateState({ ...state, isVariantActive: false });
+ if (!optionState.currentOption || !optionState.currentOptionValue) return;
+
+ const variantSelectedbyOptionsId =
+ product.variants && findMatchingObject(product.variants, optionState.selectedIds);
+
+ updateProductState({
+ ...productState,
+ chosenOptions: {
+ ...productState.chosenOptions,
+ [optionState.currentOption.label]: optionState.currentOptionValue.name
+ },
+ chosenOptionsId: Object.values({
+ ...productState.chosenOptions,
+ [optionState.currentOption.label]: optionState.currentOptionValue.name
+ }),
+ chosenVariant: {
+ ...productState.chosenVariant,
+ variantLabel: variantSelectedbyOptionsId?.name,
+ variantId: variantSelectedbyOptionsId?.id,
+ variantActive: variantSelectedbyOptionsId?.active,
+ variantStock: variantSelectedbyOptionsId?.stock_variant
}
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedIds]);
+ });
+ }, [optionState.selectedIds]);
return (
+ {productState.chosenVariant.variantActive && (
+
+ )}
{product.options?.map((option, index) => {
return (
@@ -67,38 +143,28 @@ const ProductOptions = ({ product }: ProductProp) => {
<>
{option.label}: