-
{currentCategory?.name}
+
{category?.name}
+
+ {someFiltersApplied && (
+
+ )}
+
+
+
+ {categoryProducts.totalCount} {categoryProducts.totalCount === 1 ? 'product' : 'products'}
+
+
+
+
+
- {categoryProducts?.map(
+ {categoryProducts?.items?.map(
(item) =>
item.slug &&
item.name && (
diff --git a/src/api/ecom-api.tsx b/src/api/ecom-api.tsx
index 57df6aa..b25adb6 100644
--- a/src/api/ecom-api.tsx
+++ b/src/api/ecom-api.tsx
@@ -11,6 +11,7 @@ import {
WIX_SESSION_TOKEN_COOKIE_KEY,
WIX_STORES_APP_ID,
} from './constants';
+import { getSortedProductsQuery } from './product-sorting';
import { EcomAPI, EcomApiErrorCodes, EcomAPIFailureResponse, EcomAPISuccessResponse, isEcomSDKError } from './types';
function getWixClientId() {
@@ -66,18 +67,30 @@ function createApi(): EcomAPI {
const wixClient = getWixClient();
return {
- async getProductsByCategory(categorySlug) {
+ async getProductsByCategory(categorySlug, { filters, sortBy } = {}) {
try {
const category = (await wixClient.collections.getCollectionBySlug(categorySlug)).collection;
if (!category) {
throw new Error('Category not found');
}
- let productsResponse = await wixClient.products
- .queryProducts()
- .hasSome('collectionIds', [category!._id])
- .limit(100)
- .find();
+ let query = wixClient.products.queryProducts().hasSome('collectionIds', [category._id]);
+
+ if (filters) {
+ if (filters.minPrice) {
+ query = query.ge('priceData.price', filters.minPrice);
+ }
+
+ if (filters.maxPrice) {
+ query = query.le('priceData.price', filters.maxPrice);
+ }
+ }
+
+ if (sortBy) {
+ query = getSortedProductsQuery(query, sortBy);
+ }
+
+ let productsResponse = await query.limit(100).find();
const allProducts = productsResponse.items;
// load all available products. if you have a lot of projects in your site
@@ -87,7 +100,7 @@ function createApi(): EcomAPI {
allProducts.push(...productsResponse.items);
}
- return successResponse(allProducts);
+ return successResponse({ items: allProducts, totalCount: productsResponse.totalCount ?? 0 });
} catch (e) {
return failureResponse(EcomApiErrorCodes.GetProductsFailure, getErrorMessage(e));
}
@@ -249,6 +262,26 @@ function createApi(): EcomAPI {
return failureResponse(EcomApiErrorCodes.GetOrderFailure);
}
},
+ async getProductPriceBounds(categorySlug: string) {
+ try {
+ const category = (await wixClient.collections.getCollectionBySlug(categorySlug)).collection;
+ if (!category) throw new Error('Category not found');
+
+ const query = wixClient.products.queryProducts().hasSome('collectionIds', [category._id]);
+
+ const [ascendingPrice, descendingPrice] = await Promise.all([
+ query.ascending('price').limit(1).find(),
+ query.descending('price').limit(1).find(),
+ ]);
+
+ const lowest = ascendingPrice.items[0]?.priceData?.price ?? 0;
+ const highest = descendingPrice.items[0]?.priceData?.price ?? 0;
+
+ return successResponse({ lowest, highest });
+ } catch (e) {
+ return failureResponse(EcomApiErrorCodes.GetProductsFailure, getErrorMessage(e));
+ }
+ },
};
}
diff --git a/src/api/product-filters.ts b/src/api/product-filters.ts
new file mode 100644
index 0000000..8494be7
--- /dev/null
+++ b/src/api/product-filters.ts
@@ -0,0 +1,54 @@
+import { useCallback, useMemo } from 'react';
+import { useSearchParams } from '@remix-run/react';
+import { IProductFilters, ProductFilter } from '~/api/types';
+
+export function useAppliedProductFilters() {
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const appliedFilters = useMemo(() => productFiltersFromSearchParams(searchParams), [searchParams]);
+
+ const someFiltersApplied =
+ Object.values(appliedFilters).length > 0 && Object.values(appliedFilters).some((value) => value !== undefined);
+
+ const clearFilters = useCallback(
+ (filters: ProductFilter[]) => {
+ setSearchParams(
+ (params) => {
+ filters.forEach((filter) => params.delete(filter));
+ return params;
+ },
+ { preventScrollReset: true }
+ );
+ },
+ [setSearchParams]
+ );
+
+ const clearAllFilters = useCallback(() => {
+ clearFilters(Object.values(ProductFilter));
+ }, [clearFilters]);
+
+ return {
+ appliedFilters,
+ someFiltersApplied,
+ clearFilters,
+ clearAllFilters,
+ };
+}
+
+export function productFiltersFromSearchParams(params: URLSearchParams): IProductFilters {
+ const minPrice = params.get(ProductFilter.minPrice);
+ const maxPrice = params.get(ProductFilter.maxPrice);
+ const minPriceNumber = Number(minPrice);
+ const maxPriceNumber = Number(maxPrice);
+ return {
+ minPrice: minPrice && !Number.isNaN(minPriceNumber) ? minPriceNumber : undefined,
+ maxPrice: maxPrice && !Number.isNaN(maxPriceNumber) ? maxPriceNumber : undefined,
+ };
+}
+
+export function searchParamsFromProductFilters({ minPrice, maxPrice }: IProductFilters): URLSearchParams {
+ const params = new URLSearchParams();
+ if (minPrice !== undefined) params.set(ProductFilter.minPrice, minPrice.toString());
+ if (maxPrice !== undefined) params.set(ProductFilter.maxPrice, maxPrice.toString());
+ return params;
+}
diff --git a/src/api/product-sorting.ts b/src/api/product-sorting.ts
new file mode 100644
index 0000000..4b5a35d
--- /dev/null
+++ b/src/api/product-sorting.ts
@@ -0,0 +1,33 @@
+import { products } from '@wix/stores';
+import { ProductSortBy } from './types';
+
+export const SORT_BY_SEARCH_PARAM = 'sortBy';
+
+export const DEFAULT_SORT_BY = ProductSortBy.newest;
+
+export function productSortByFromSearchParams(searchParams: URLSearchParams): ProductSortBy {
+ const value = searchParams.get(SORT_BY_SEARCH_PARAM);
+ return value && Object.values(ProductSortBy).includes(value as ProductSortBy)
+ ? (value as ProductSortBy)
+ : DEFAULT_SORT_BY;
+}
+
+export function getSortedProductsQuery(
+ query: products.ProductsQueryBuilder,
+ sortBy: ProductSortBy
+): products.ProductsQueryBuilder {
+ switch (sortBy) {
+ case ProductSortBy.newest:
+ // numericId is incremented when creating new products,
+ // so we can use it to sort products by creation date - from the newest to the oldest.
+ return query.descending('numericId');
+ case ProductSortBy.priceAsc:
+ return query.ascending('price');
+ case ProductSortBy.priceDesc:
+ return query.descending('price');
+ case ProductSortBy.nameAsc:
+ return query.ascending('name');
+ case ProductSortBy.nameDesc:
+ return query.descending('name');
+ }
+}
diff --git a/src/api/types.ts b/src/api/types.ts
index cf751fc..6d3866a 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -56,8 +56,45 @@ export function isEcomSDKError(error: unknown): error is EcomSDKError {
);
}
+export enum ProductFilter {
+ minPrice = 'minPrice',
+ maxPrice = 'maxPrice',
+}
+
+export interface IProductFilters {
+ /**
+ * Only products with a price greater than or equal to this value will be included.
+ */
+ [ProductFilter.minPrice]?: number;
+ /**
+ * Only products with a price less than or equal to this value will be included.
+ */
+ [ProductFilter.maxPrice]?: number;
+}
+
+export enum ProductSortBy {
+ newest = 'newest',
+ priceAsc = 'priceAsc',
+ priceDesc = 'priceDesc',
+ nameAsc = 'nameAsc',
+ nameDesc = 'nameDesc',
+}
+
+interface GetProductsByCategoryOptions {
+ filters?: IProductFilters;
+ sortBy?: ProductSortBy;
+}
+
export type EcomAPI = {
- getProductsByCategory: (categorySlug: string) => Promise
>;
+ getProductsByCategory: (
+ categorySlug: string,
+ options?: GetProductsByCategoryOptions
+ ) => Promise<
+ EcomAPIResponse<{
+ items: Product[];
+ totalCount: number;
+ }>
+ >;
getPromotedProducts: () => Promise>;
getProductBySlug: (slug: string) => Promise>;
getCart: () => Promise>;
@@ -69,4 +106,8 @@ export type EcomAPI = {
getAllCategories: () => Promise>;
getCategoryBySlug: (slug: string) => Promise>;
getOrder: (id: string) => Promise>;
+ /**
+ * Returns the lowest and the highest product price in the category.
+ */
+ getProductPriceBounds: (categorySlug: string) => Promise>;
};
diff --git a/src/components/accordion/accordion.module.scss b/src/components/accordion/accordion.module.scss
new file mode 100644
index 0000000..da63584
--- /dev/null
+++ b/src/components/accordion/accordion.module.scss
@@ -0,0 +1,63 @@
+.item:not(:last-child) {
+ border-bottom: 1px solid rgba(118, 113, 101, 0.3);
+}
+
+.header {
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+}
+
+.title {
+ font: var(--heading2);
+ font-size: 18px;
+}
+
+.toggleIcon {
+ width: 22px;
+ height: 22px;
+ color: var(--charcoal-black);
+}
+
+.content {
+ --transitionDuration: 0.4s;
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows var(--transitionDuration) ease-in-out;
+}
+
+.expanded {
+ grid-template-rows: 1fr;
+}
+
+.contentExpander {
+ overflow: auto;
+ transition: overflow 0s allow-discrete;
+}
+
+.expanded .contentExpander {
+ // Prevent clipping outline and box-shadow after transition.
+ overflow: visible;
+ transition-delay: var(--transitionDuration);
+}
+
+.contentInner {
+ padding-bottom: 25px;
+}
+
+.small {
+ .title {
+ font: var(--paragraph3);
+ }
+
+ .toggleIcon {
+ width: 20px;
+ height: 20px;
+ }
+
+ .contentInner {
+ padding-bottom: 16px;
+ }
+}
diff --git a/src/components/accordion/accordion.tsx b/src/components/accordion/accordion.tsx
new file mode 100644
index 0000000..2bcd823
--- /dev/null
+++ b/src/components/accordion/accordion.tsx
@@ -0,0 +1,55 @@
+import { useState } from 'react';
+import classNames from 'classnames';
+import { getClickableElementAttributes } from '~/utils';
+import { MinusIcon, PlusIcon } from '../icons';
+
+import styles from './accordion.module.scss';
+
+interface AccordionItem {
+ title: string;
+ content: React.ReactNode;
+}
+
+interface AccordionProps {
+ items: AccordionItem[];
+ className?: string;
+ small?: boolean;
+}
+
+export const Accordion = ({ items, className, small = false }: AccordionProps) => {
+ const [openItemIndex, setOpenItemIndex] = useState(0);
+
+ return (
+
+ {items.map((item, index) => {
+ const isOpen = openItemIndex === index;
+ return (
+
+
setOpenItemIndex(isOpen ? null : index))}
+ >
+
{item.title}
+
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+ })}
+
+ );
+};
diff --git a/src/components/applied-filter/applied-filter.module.scss b/src/components/applied-filter/applied-filter.module.scss
new file mode 100644
index 0000000..d80c714
--- /dev/null
+++ b/src/components/applied-filter/applied-filter.module.scss
@@ -0,0 +1,10 @@
+.root {
+ height: 32px;
+ padding: 0 8px 0 12px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: rgba(23, 17, 13, 0.1);
+ font: var(--paragraph3);
+ cursor: pointer;
+}
diff --git a/src/components/applied-filter/applied-filter.tsx b/src/components/applied-filter/applied-filter.tsx
new file mode 100644
index 0000000..a41dd1e
--- /dev/null
+++ b/src/components/applied-filter/applied-filter.tsx
@@ -0,0 +1,18 @@
+import { getClickableElementAttributes } from '~/utils';
+import { CloseIcon } from '../icons';
+
+import styles from './applied-filter.module.scss';
+
+interface AppliedFilterProps {
+ children: React.ReactNode;
+ onClick: () => void;
+}
+
+export const AppliedFilter = ({ children, onClick }: AppliedFilterProps) => {
+ return (
+
+ {children}
+
+
+ );
+};
diff --git a/src/components/applied-product-filters/applied-product-filters.module.scss b/src/components/applied-product-filters/applied-product-filters.module.scss
new file mode 100644
index 0000000..22a8999
--- /dev/null
+++ b/src/components/applied-product-filters/applied-product-filters.module.scss
@@ -0,0 +1,13 @@
+.root {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.clearAllButton {
+ margin-left: 8px;
+ font: var(--paragraph3);
+ text-decoration: underline;
+ cursor: pointer;
+}
diff --git a/src/components/applied-product-filters/applied-product-filters.tsx b/src/components/applied-product-filters/applied-product-filters.tsx
new file mode 100644
index 0000000..43e6d32
--- /dev/null
+++ b/src/components/applied-product-filters/applied-product-filters.tsx
@@ -0,0 +1,62 @@
+import { useMemo } from 'react';
+import classNames from 'classnames';
+import { ProductFilter, IProductFilters } from '~/api/types';
+import { formatPrice } from '~/utils';
+import { AppliedFilter } from '../applied-filter/applied-filter';
+
+import styles from './applied-product-filters.module.scss';
+
+interface AppliedProductFiltersProps {
+ appliedFilters: IProductFilters;
+ onClearFilters: (filters: ProductFilter[]) => void;
+ onClearAllFilters: () => void;
+ currency: string;
+ // Min and max prices in the current category.
+ // Used to replace missing bounds ("$5 - ?" or "? - $25") when only one filter bound is set.
+ minPriceInCategory: number;
+ maxPriceInCategory: number;
+ className?: string;
+}
+
+export const AppliedProductFilters = ({
+ appliedFilters,
+ onClearFilters,
+ onClearAllFilters,
+ currency,
+ minPriceInCategory,
+ maxPriceInCategory,
+ className,
+}: AppliedProductFiltersProps) => {
+ const { minPrice, maxPrice } = appliedFilters;
+
+ const priceFilter = useMemo(() => {
+ if (minPrice === undefined && maxPrice === undefined) {
+ return null;
+ } else {
+ return (
+
+ {formatPrice(minPrice ?? minPriceInCategory, currency)}–
+ {formatPrice(maxPrice ?? maxPriceInCategory, currency)}
+
+ );
+ }
+ }, [minPrice, maxPrice, currency, minPriceInCategory, maxPriceInCategory]);
+
+ return (
+
+ {priceFilter && (
+
{
+ onClearFilters([ProductFilter.minPrice, ProductFilter.maxPrice]);
+ }}
+ >
+ {priceFilter}
+
+ )}
+
+
+ Clear All
+
+
+ );
+};
diff --git a/src/components/icons/close-icon.tsx b/src/components/icons/close-icon.tsx
new file mode 100644
index 0000000..3c8bc3e
--- /dev/null
+++ b/src/components/icons/close-icon.tsx
@@ -0,0 +1,7 @@
+export const CloseIcon = (props: React.SVGProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts
new file mode 100644
index 0000000..19057ab
--- /dev/null
+++ b/src/components/icons/index.ts
@@ -0,0 +1,3 @@
+export * from './close-icon';
+export * from './minus-icon';
+export * from './plus-icon';
diff --git a/src/components/icons/minus-icon.tsx b/src/components/icons/minus-icon.tsx
new file mode 100644
index 0000000..eaaeea8
--- /dev/null
+++ b/src/components/icons/minus-icon.tsx
@@ -0,0 +1,7 @@
+export const MinusIcon = ({ className }: { className?: string }) => {
+ return (
+
+
+
+ );
+};
diff --git a/src/components/icons/plus-icon.tsx b/src/components/icons/plus-icon.tsx
new file mode 100644
index 0000000..0c56052
--- /dev/null
+++ b/src/components/icons/plus-icon.tsx
@@ -0,0 +1,7 @@
+export const PlusIcon = ({ className }: { className?: string }) => {
+ return (
+
+
+
+ );
+};
diff --git a/src/components/product-filters/product-filters.tsx b/src/components/product-filters/product-filters.tsx
new file mode 100644
index 0000000..0c66883
--- /dev/null
+++ b/src/components/product-filters/product-filters.tsx
@@ -0,0 +1,60 @@
+import { useCallback, useMemo } from 'react';
+import { productFiltersFromSearchParams, searchParamsFromProductFilters } from '~/api/product-filters';
+import { IProductFilters } from '~/api/types';
+import { formatPrice, mergeUrlSearchParams } from '~/utils';
+import { useSearchParamsOptimistic } from '~/utils/use-search-params-optimistic';
+import { Accordion } from '../accordion/accordion';
+import { RangeSlider } from '../range-slider/range-slider';
+
+interface ProductFiltersProps {
+ lowestPrice: number;
+ highestPrice: number;
+ currency: string;
+}
+
+export const ProductFilters = ({ lowestPrice, highestPrice, currency }: ProductFiltersProps) => {
+ const [searchParams, setSearchParams] = useSearchParamsOptimistic();
+
+ const filters = useMemo(() => productFiltersFromSearchParams(searchParams), [searchParams]);
+
+ const handleFiltersChange = (changed: Partial) => {
+ const newParams = searchParamsFromProductFilters({ ...filters, ...changed });
+ setSearchParams((params) => mergeUrlSearchParams(params, newParams), {
+ preventScrollReset: true,
+ });
+ };
+
+ const formatPriceValue = useCallback((price: number) => formatPrice(price, currency), [currency]);
+
+ return (
+ {
+ handleFiltersChange({
+ minPrice: Math.max(Math.floor(value), lowestPrice),
+ });
+ }}
+ onEndValueChange={(value) => {
+ handleFiltersChange({
+ maxPrice: Math.min(Math.ceil(value), highestPrice),
+ });
+ }}
+ minValue={lowestPrice}
+ maxValue={highestPrice}
+ formatValue={formatPriceValue}
+ />
+ ),
+ },
+ ]}
+ />
+ );
+};
diff --git a/src/components/product-sorting-select/product-sorting-select.module.scss b/src/components/product-sorting-select/product-sorting-select.module.scss
new file mode 100644
index 0000000..82d9646
--- /dev/null
+++ b/src/components/product-sorting-select/product-sorting-select.module.scss
@@ -0,0 +1,14 @@
+.root {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.title {
+ font: var(--paragraph3);
+}
+
+.select {
+ padding-right: 18px;
+}
diff --git a/src/components/product-sorting-select/product-sorting-select.tsx b/src/components/product-sorting-select/product-sorting-select.tsx
new file mode 100644
index 0000000..f2098f3
--- /dev/null
+++ b/src/components/product-sorting-select/product-sorting-select.tsx
@@ -0,0 +1,44 @@
+import { productSortByFromSearchParams, SORT_BY_SEARCH_PARAM } from '~/api/product-sorting';
+import { ProductSortBy } from '~/api/types';
+import { useSearchParamsOptimistic } from '~/utils/use-search-params-optimistic';
+import { Select } from '../select/select';
+import styles from './product-sorting-select.module.scss';
+
+const sortingOptions: { value: ProductSortBy; label: string }[] = [
+ { value: ProductSortBy.newest, label: 'Newest' },
+ { value: ProductSortBy.priceAsc, label: 'Price (low to high)' },
+ { value: ProductSortBy.priceDesc, label: 'Price (high to low)' },
+ { value: ProductSortBy.nameAsc, label: 'Name A-Z' },
+ { value: ProductSortBy.nameDesc, label: 'Name Z-A' },
+];
+
+export const ProductSortingSelect = () => {
+ const [searchParams, setSearchParams] = useSearchParamsOptimistic();
+
+ const sortBy = productSortByFromSearchParams(searchParams);
+
+ const handleChange = (sortBy: string) => {
+ setSearchParams(
+ (params) => {
+ params.set(SORT_BY_SEARCH_PARAM, sortBy);
+ return params;
+ },
+ { preventScrollReset: true }
+ );
+ };
+
+ return (
+
+ Sort By:
+ ({
+ name: option.label,
+ value: option.value,
+ }))}
+ />
+
+ );
+};
diff --git a/src/components/range-slider/range-slider.module.scss b/src/components/range-slider/range-slider.module.scss
new file mode 100644
index 0000000..997f3f0
--- /dev/null
+++ b/src/components/range-slider/range-slider.module.scss
@@ -0,0 +1,98 @@
+:where(.root) {
+ /* Default slider styles. Customize them by passing a className to the component. */
+ --track-color: #b2b2b2;
+ --selected-range-color: #0175ff;
+ --thumb-color: #0175ff;
+ --thumb-focus-ring-color: #0175ff;
+}
+
+.slidersContainer {
+ position: relative;
+ height: 12px;
+}
+
+.input {
+ appearance: none;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+}
+
+.trackInput::-webkit-slider-runnable-track {
+ width: 100%;
+ height: 2px;
+ background: linear-gradient(
+ to right,
+ var(--track-color) var(--start),
+ var(--selected-range-color) var(--start),
+ var(--selected-range-color) var(--end),
+ var(--track-color) var(--end)
+ );
+}
+
+.trackInput::-moz-range-track {
+ width: 100%;
+ height: 2px;
+ background: linear-gradient(
+ to right,
+ var(--track-color) var(--start),
+ var(--selected-range-color) var(--start),
+ var(--selected-range-color) var(--end),
+ var(--track-color) var(--end)
+ );
+}
+
+.trackInput::-webkit-slider-thumb {
+ visibility: hidden;
+}
+
+.trackInput::-moz-range-thumb {
+ visibility: hidden;
+}
+
+.thumbInput {
+ pointer-events: none;
+}
+
+.thumbInput::-webkit-slider-thumb {
+ appearance: none;
+ pointer-events: auto;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ border: none;
+ background: var(--thumb-color);
+ cursor: pointer;
+}
+
+.thumbInput::-moz-range-thumb {
+ appearance: none;
+ pointer-events: auto;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ border: none;
+ background: var(--thumb-color);
+ cursor: pointer;
+}
+
+.thumbInput:focus-visible {
+ box-shadow: none;
+}
+
+.thumbInput:focus-visible::-webkit-slider-thumb {
+ box-shadow: 0 0 0 1px #ffffff, 0 0 0 3px var(--thumb-focus-ring-color);
+}
+
+.thumbInput:focus-visible::-moz-range-thumb {
+ box-shadow: 0 0 0 1px #ffffff, 0 0 0 3px var(--thumb-focus-ring-color);
+}
+
+.values {
+ margin-top: 8px;
+ display: flex;
+ justify-content: space-between;
+}
diff --git a/src/components/range-slider/range-slider.tsx b/src/components/range-slider/range-slider.tsx
new file mode 100644
index 0000000..4dd68f1
--- /dev/null
+++ b/src/components/range-slider/range-slider.tsx
@@ -0,0 +1,131 @@
+import classNames from 'classnames';
+import styles from './range-slider.module.scss';
+
+interface RangeSlider {
+ startValue: number;
+ endValue: number;
+ onStartValueChange: (value: number) => void;
+ onEndValueChange: (value: number) => void;
+ /**
+ * The lowest permitted value.
+ */
+ minValue: number;
+ /**
+ * The highest permitted value.
+ */
+ maxValue: number;
+ /**
+ * The granularity that the values must adhere to. @default 1
+ */
+ step?: number | 'any';
+ /**
+ * Allows to format the displayed start and end values. For example, add a currency symbol,
+ * format with the specified number of decimal places, etc.
+ */
+ formatValue?: (value: number) => string;
+ startInputName?: string;
+ endInputName?: string;
+ className?: string;
+}
+
+/**
+ * A slider component for selecting a numeric range.
+ */
+export const RangeSlider = ({
+ startValue,
+ endValue,
+ onStartValueChange,
+ onEndValueChange,
+ minValue,
+ maxValue,
+ step,
+ formatValue = (value) => value.toString(),
+ startInputName,
+ endInputName,
+ className,
+}: RangeSlider) => {
+ const handleStartValueChange = (event: React.ChangeEvent) => {
+ const newStartValue = Number(event.target.value);
+ onStartValueChange(Math.min(newStartValue, endValue));
+ };
+
+ const handleEndValueChange = (event: React.ChangeEvent) => {
+ const newEndValue = Number(event.target.value);
+ onEndValueChange(Math.max(newEndValue, startValue));
+ };
+
+ const handleChangeByClickingOnTrack = (event: React.ChangeEvent) => {
+ const value = Number(event.target.value);
+ // Change the start or end value
+ // depending on which one is closer to the clicked value on the track.
+ const distToStart = Math.abs(value - startValue);
+ const distToEnd = Math.abs(value - endValue);
+ if (distToStart < distToEnd || (startValue === endValue && value < startValue)) {
+ onStartValueChange(value);
+ } else {
+ onEndValueChange(value);
+ }
+ };
+
+ const getValuePositionOnTrack = (value: number) => {
+ return `${((value - minValue) / (maxValue - minValue)) * 100}%`;
+ };
+
+ return (
+
+ );
+};
diff --git a/src/styles/common.scss b/src/styles/common.scss
index b9a29d0..5ae8aa2 100644
--- a/src/styles/common.scss
+++ b/src/styles/common.scss
@@ -85,3 +85,11 @@
padding-right: 10px;
font: var(--paragraph3);
}
+
+.rangeSlider {
+ --track-color: var(--grey);
+ --selected-range-color: var(--charcoal-black);
+ --thumb-color: var(--charcoal-black);
+ --thumb-focus-ring-color: var(--charcoal-black);
+ font: var(--paragraph3);
+}
diff --git a/src/utils/common.ts b/src/utils/common.ts
index 0b02c81..3d02962 100644
--- a/src/utils/common.ts
+++ b/src/utils/common.ts
@@ -56,3 +56,64 @@ export function routeLocationToUrl(location: Location, origin: string): URL {
url.hash = location.hash;
return url;
}
+
+/**
+ * It's important to add an appropriate role and a keyboard support
+ * for non-interactive HTML elements with click handlers, such as `
`.
+ * This function returns a basic set of attributes
+ * to make the clickable element focusable and handle keyboard events.
+ */
+export function getClickableElementAttributes(handler: () => void) {
+ return {
+ role: 'button',
+ tabIndex: 0,
+ onClick: handler,
+ onKeyUp: (event: React.KeyboardEvent) => {
+ if (event.code === 'Enter' || event.code === 'Space') {
+ handler();
+ }
+ },
+ };
+}
+
+/**
+ * Merges multiple URLSearchParams instances into one URLSearchParams.
+ *
+ * For entries with the same key, values from subsequent URLSearchParams
+ * instances will overwrite the earlier ones. For example:
+ * ```js
+ * const a = new URLSearchParams([['foo', '1'], ['foo', '2']])
+ * const b = new URLSearchParams([['foo', '3'], ['foo', '4']])
+ * const c = mergeUrlSearchParams(a, b);
+ * c.toString(); // 'foo=3&foo=4'
+ * ```
+ */
+export function mergeUrlSearchParams(...paramsArr: URLSearchParams[]): URLSearchParams {
+ const result = new URLSearchParams();
+
+ for (const params of paramsArr) {
+ const overriddenParams = new Set();
+
+ for (const [key, value] of params.entries()) {
+ if (result.has(key) && !overriddenParams.has(key)) {
+ result.delete(key);
+ overriddenParams.add(key);
+ }
+
+ result.append(key, value);
+ }
+ }
+
+ return result;
+}
+
+export function formatPrice(price: number, currency: string): string {
+ const formatter = Intl.NumberFormat('en-US', {
+ currency,
+ style: 'currency',
+ currencyDisplay: 'narrowSymbol',
+ minimumFractionDigits: 2,
+ });
+
+ return formatter.format(price);
+}
diff --git a/src/utils/use-search-params-optimistic.ts b/src/utils/use-search-params-optimistic.ts
new file mode 100644
index 0000000..d2a5093
--- /dev/null
+++ b/src/utils/use-search-params-optimistic.ts
@@ -0,0 +1,30 @@
+import { useNavigation, useSearchParams } from '@remix-run/react';
+import { useCallback, useEffect, useState } from 'react';
+import type { NavigateOptions } from 'react-router';
+
+/**
+ * Similar to `useSearchParams` from Remix, but allows to update search params optimistically.
+ */
+export function useSearchParamsOptimistic() {
+ const navigation = useNavigation();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const [optimisticSearchParams, setOptimisticSearchParams] = useState(searchParams);
+
+ const handleSearchParamsChange = useCallback(
+ (params: URLSearchParams | ((prevParams: URLSearchParams) => URLSearchParams), options?: NavigateOptions) => {
+ setOptimisticSearchParams(params);
+ setSearchParams(params, options);
+ },
+ [setSearchParams]
+ );
+
+ // Synchronize search params on back/forward browser button clicks.
+ useEffect(() => {
+ if (navigation.state !== 'loading') {
+ setOptimisticSearchParams(searchParams);
+ }
+ }, [navigation.state, searchParams]);
+
+ return [optimisticSearchParams, handleSearchParamsChange] as const;
+}
From 1e5ae95e1eb39d7f4345f35ed25a43dfbc597a41 Mon Sep 17 00:00:00 2001
From: Yurii Venher <79912799+yurii-ve@users.noreply.github.com>
Date: Thu, 31 Oct 2024 16:08:42 +0200
Subject: [PATCH 24/31] Sync latest changes from the ReClaim template (#102)
* Add lib folder, user session
* Update dependencies
* Update codux config
* Fix
* Define DEMO_STORE_WIX_CLIENT_ID in the src
* Add getStaticRoutes
* Prettier
* Prettier
* Add faker
* Update package-lock
* Put canonical links back
* Fix duplicate import
---
_codux/board-wrappers/component-wrapper.tsx | 5 +-
_codux/boards-global-setup.ts | 2 +-
.../components/cart/cart-item.board.tsx | 2 +-
.../components/cart/cart-view.board.tsx | 2 +-
.../error-component/error-component.board.tsx | 2 +-
.../boards/components/footer/footer.board.tsx | 2 +-
.../boards/components/header/header.board.tsx | 4 +-
.../hero-image/hero-image.board.tsx | 2 +-
.../order-summary/order-summary.board.tsx | 2 +-
.../boards/ui-kits/ui-kit-buttons.board.tsx | 14 +-
.../ui-kits/ui-kit-components.board.tsx | 4 +-
.../ui-kits/ui-kit-typograpgy.board.tsx | 2 +-
_codux/mocks/cart.ts | 2 +-
app/root.tsx | 71 +-
app/routes/_index/route.tsx | 20 +-
app/routes/about/route.tsx | 7 +-
app/routes/category.$categorySlug/route.tsx | 69 +-
app/routes/products.$productSlug/route.tsx | 57 +-
app/routes/thank-you/route.tsx | 17 +-
codux.config.json | 13 +-
.../cart => lib}/cart-open-context.tsx | 0
.../color-select/color-select.module.scss | 74 +
lib/components/color-select/color-select.tsx | 36 +
.../range-slider/range-slider.module.scss | 102 +
lib/components/range-slider/range-slider.tsx | 131 +
.../visual-effects/background-parallax.tsx | 114 +
lib/components/visual-effects/common.ts | 44 +
lib/components/visual-effects/fade-in.tsx | 22 +
lib/components/visual-effects/float-in.tsx | 30 +
lib/components/visual-effects/index.tsx | 4 +
lib/components/visual-effects/reveal.tsx | 34 +
.../ecom/api-context.tsx | 20 +-
src/api/ecom-api.tsx => lib/ecom/api.ts | 163 +-
lib/ecom/constants.ts | 1 +
src/api/api-hooks.ts => lib/ecom/hooks.ts | 77 +-
lib/ecom/index.ts | 7 +
lib/ecom/product-filters.ts | 32 +
{src/api => lib/ecom}/product-sorting.ts | 2 +-
lib/ecom/session.ts | 50 +
{src/api => lib/ecom}/types.ts | 22 +-
lib/hooks/index.ts | 3 +
.../hooks/use-applied-product-filters.ts | 24 +-
lib/hooks/use-product-details.ts | 78 +
lib/hooks/use-product-sorting.ts | 11 +
lib/hooks/use-products-page-results.ts | 84 +
lib/hooks/use-search-params-optimistic.ts | 30 +
lib/route-loaders/index.ts | 3 +
lib/route-loaders/product-details.ts | 15 +
lib/route-loaders/products.ts | 39 +
lib/route-loaders/thank-you.ts | 13 +
.../utils/cart-utils.ts | 12 +-
{src => lib}/utils/common.ts | 53 +-
{src => lib}/utils/index.ts | 2 +-
{src => lib}/utils/product-utils.ts | 35 +-
package-lock.json | 4415 +++++++----------
package.json | 58 +-
src/api/constants.ts | 5 -
src/api/wix-image.ts | 23 -
src/components/accordion/accordion.tsx | 2 +-
.../applied-filter/applied-filter.tsx | 2 +-
.../applied-product-filters.tsx | 4 +-
src/components/cart/cart-item/cart-item.tsx | 25 +-
src/components/cart/cart-view/cart-view.tsx | 2 +-
src/components/cart/cart.tsx | 12 +-
.../color-select/color-select.module.scss | 51 -
src/components/color-select/color-select.tsx | 40 -
src/components/drawer/drawer.tsx | 2 +-
src/components/header/header.tsx | 12 +-
src/components/hero-image/hero-image.tsx | 8 +-
.../product-additional-info.tsx | 2 +-
src/components/product-card/product-card.tsx | 4 +-
.../product-filters/product-filters.tsx | 7 +-
.../product-images/product-images.tsx | 10 +-
.../product-option/product-option.tsx | 14 +-
.../product-sorting-select.tsx | 7 +-
.../range-slider/range-slider.module.scss | 8 +-
src/components/site-footer/site-footer.tsx | 12 +-
.../site-wrapper/site-wrapper.module.scss | 1 +
src/components/site-wrapper/site-wrapper.tsx | 13 +-
src/constants.ts | 1 +
src/hooks/use-cart.ts | 36 -
src/router/config.ts | 28 -
src/styles/typography.scss | 4 +-
src/utils/cart-utils.ts | 9 -
src/utils/use-search-params-optimistic.ts | 2 +-
tsconfig.json | 3 +-
86 files changed, 3139 insertions(+), 3349 deletions(-)
rename {src/components/cart => lib}/cart-open-context.tsx (100%)
create mode 100644 lib/components/color-select/color-select.module.scss
create mode 100644 lib/components/color-select/color-select.tsx
create mode 100644 lib/components/range-slider/range-slider.module.scss
create mode 100644 lib/components/range-slider/range-slider.tsx
create mode 100644 lib/components/visual-effects/background-parallax.tsx
create mode 100644 lib/components/visual-effects/common.ts
create mode 100644 lib/components/visual-effects/fade-in.tsx
create mode 100644 lib/components/visual-effects/float-in.tsx
create mode 100644 lib/components/visual-effects/index.tsx
create mode 100644 lib/components/visual-effects/reveal.tsx
rename src/api/ecom-api-context-provider.tsx => lib/ecom/api-context.tsx (52%)
rename src/api/ecom-api.tsx => lib/ecom/api.ts (73%)
create mode 100644 lib/ecom/constants.ts
rename src/api/api-hooks.ts => lib/ecom/hooks.ts (58%)
create mode 100644 lib/ecom/index.ts
create mode 100644 lib/ecom/product-filters.ts
rename {src/api => lib/ecom}/product-sorting.ts (97%)
create mode 100644 lib/ecom/session.ts
rename {src/api => lib/ecom}/types.ts (89%)
create mode 100644 lib/hooks/index.ts
rename src/api/product-filters.ts => lib/hooks/use-applied-product-filters.ts (50%)
create mode 100644 lib/hooks/use-product-details.ts
create mode 100644 lib/hooks/use-product-sorting.ts
create mode 100644 lib/hooks/use-products-page-results.ts
create mode 100644 lib/hooks/use-search-params-optimistic.ts
create mode 100644 lib/route-loaders/index.ts
create mode 100644 lib/route-loaders/product-details.ts
create mode 100644 lib/route-loaders/products.ts
create mode 100644 lib/route-loaders/thank-you.ts
rename src/api/cart-helpers.ts => lib/utils/cart-utils.ts (53%)
rename {src => lib}/utils/common.ts (89%)
rename {src => lib}/utils/index.ts (100%)
rename {src => lib}/utils/product-utils.ts (91%)
delete mode 100644 src/api/constants.ts
delete mode 100644 src/api/wix-image.ts
delete mode 100644 src/components/color-select/color-select.module.scss
delete mode 100644 src/components/color-select/color-select.tsx
create mode 100644 src/constants.ts
delete mode 100644 src/hooks/use-cart.ts
delete mode 100644 src/router/config.ts
delete mode 100644 src/utils/cart-utils.ts
diff --git a/_codux/board-wrappers/component-wrapper.tsx b/_codux/board-wrappers/component-wrapper.tsx
index ce25006..f66879b 100644
--- a/_codux/board-wrappers/component-wrapper.tsx
+++ b/_codux/board-wrappers/component-wrapper.tsx
@@ -1,7 +1,6 @@
import { createRemixStub } from '@remix-run/testing';
import { PropsWithChildren } from 'react';
-import { EcomAPIContextProvider } from '~/api/ecom-api-context-provider';
-import { ROUTES } from '~/router/config';
+import { EcomAPIContextProvider } from '~/lib/ecom';
export interface ComponentWrapperProps extends PropsWithChildren {
loaderData?: Record;
@@ -11,7 +10,7 @@ export default function ComponentWrapper({ children, loaderData }: ComponentWrap
const RemixStub = createRemixStub([
{
Component: () => children,
- children: Object.values(ROUTES).map(({ path }) => ({ path })),
+ ErrorBoundary: () => children,
},
]);
diff --git a/_codux/boards-global-setup.ts b/_codux/boards-global-setup.ts
index 6c7b744..1e277a7 100644
--- a/_codux/boards-global-setup.ts
+++ b/_codux/boards-global-setup.ts
@@ -1 +1 @@
-import '~/styles/index.scss';
+import '~/src/styles/index.scss';
diff --git a/_codux/boards/components/cart/cart-item.board.tsx b/_codux/boards/components/cart/cart-item.board.tsx
index 267e301..2f46a89 100644
--- a/_codux/boards/components/cart/cart-item.board.tsx
+++ b/_codux/boards/components/cart/cart-item.board.tsx
@@ -1,6 +1,6 @@
import { createBoard } from '@wixc3/react-board';
import { cartItem, cartItemOutOfStock, cartItemWithDiscount } from '_codux/mocks/cart';
-import { CartItem } from '~/components/cart/cart-item/cart-item';
+import { CartItem } from '~/src/components/cart/cart-item/cart-item';
const noop = () => {};
diff --git a/_codux/boards/components/cart/cart-view.board.tsx b/_codux/boards/components/cart/cart-view.board.tsx
index b0d7500..28405a3 100644
--- a/_codux/boards/components/cart/cart-view.board.tsx
+++ b/_codux/boards/components/cart/cart-view.board.tsx
@@ -1,5 +1,5 @@
import { createBoard } from '@wixc3/react-board';
-import { CartView } from '~/components/cart/cart-view/cart-view';
+import { CartView } from '~/src/components/cart/cart-view/cart-view';
import { cart, cartTotals } from '_codux/mocks/cart';
const noop = () => {};
diff --git a/_codux/boards/components/error-component/error-component.board.tsx b/_codux/boards/components/error-component/error-component.board.tsx
index 7baef01..772ad06 100644
--- a/_codux/boards/components/error-component/error-component.board.tsx
+++ b/_codux/boards/components/error-component/error-component.board.tsx
@@ -1,5 +1,5 @@
import { createBoard } from '@wixc3/react-board';
-import { ErrorComponent } from '~/components/error-component/error-component';
+import { ErrorComponent } from '~/src/components/error-component/error-component';
export default createBoard({
name: 'ErrorComponent',
diff --git a/_codux/boards/components/footer/footer.board.tsx b/_codux/boards/components/footer/footer.board.tsx
index 65e0eb9..7ae6d52 100644
--- a/_codux/boards/components/footer/footer.board.tsx
+++ b/_codux/boards/components/footer/footer.board.tsx
@@ -1,5 +1,5 @@
import { createBoard } from '@wixc3/react-board';
-import { Footer } from '~/components/site-footer/site-footer';
+import { Footer } from '~/src/components/site-footer/site-footer';
export default createBoard({
name: 'Footer',
diff --git a/_codux/boards/components/header/header.board.tsx b/_codux/boards/components/header/header.board.tsx
index 76547d0..e98e150 100644
--- a/_codux/boards/components/header/header.board.tsx
+++ b/_codux/boards/components/header/header.board.tsx
@@ -1,7 +1,7 @@
import { createBoard } from '@wixc3/react-board';
import ComponentWrapper from '_codux/board-wrappers/component-wrapper';
-import { CartOpenContextProvider } from '~/components/cart/cart-open-context';
-import { Header } from '~/components/header/header';
+import { CartOpenContextProvider } from '~/lib/cart-open-context';
+import { Header } from '~/src/components/header/header';
export default createBoard({
name: 'Header',
diff --git a/_codux/boards/components/hero-image/hero-image.board.tsx b/_codux/boards/components/hero-image/hero-image.board.tsx
index 1f1571f..1285c46 100644
--- a/_codux/boards/components/hero-image/hero-image.board.tsx
+++ b/_codux/boards/components/hero-image/hero-image.board.tsx
@@ -1,5 +1,5 @@
import { createBoard } from '@wixc3/react-board';
-import { HeroImage } from '~/components/hero-image/hero-image';
+import { HeroImage } from '~/src/components/hero-image/hero-image';
export default createBoard({
name: 'Hero Image',
diff --git a/_codux/boards/components/order-summary/order-summary.board.tsx b/_codux/boards/components/order-summary/order-summary.board.tsx
index aa091c2..528c388 100644
--- a/_codux/boards/components/order-summary/order-summary.board.tsx
+++ b/_codux/boards/components/order-summary/order-summary.board.tsx
@@ -1,6 +1,6 @@
import { createBoard } from '@wixc3/react-board';
import { mockOrder } from '_codux/mocks/order';
-import { OrderSummary } from '~/components/order-summary/order-summary';
+import { OrderSummary } from '~/src/components/order-summary/order-summary';
export default createBoard({
name: 'OrderSummary',
diff --git a/_codux/boards/ui-kits/ui-kit-buttons.board.tsx b/_codux/boards/ui-kits/ui-kit-buttons.board.tsx
index 0ba1ede..5099840 100644
--- a/_codux/boards/ui-kits/ui-kit-buttons.board.tsx
+++ b/_codux/boards/ui-kits/ui-kit-buttons.board.tsx
@@ -2,13 +2,13 @@ import { NavLink } from '@remix-run/react';
import { createBoard, Variant } from '@wixc3/react-board';
import classNames from 'classnames';
import { MemoryRouter } from 'react-router-dom';
-import discordIcon from '~/assets/svg/discord.svg';
-import facebookIcon from '~/assets/svg/facebook.svg';
-import githubIcon from '~/assets/svg/github.svg';
-import mediumIcon from '~/assets/svg/medium.svg';
-import twitterxIcon from '~/assets/svg/twitterx.svg';
-import youtubeIcon from '~/assets/svg/youtube.svg';
-import styles from '~/styles/ui-kit-buttons.module.scss';
+import discordIcon from '~/src/assets/svg/discord.svg';
+import facebookIcon from '~/src/assets/svg/facebook.svg';
+import githubIcon from '~/src/assets/svg/github.svg';
+import mediumIcon from '~/src/assets/svg/medium.svg';
+import twitterxIcon from '~/src/assets/svg/twitterx.svg';
+import youtubeIcon from '~/src/assets/svg/youtube.svg';
+import styles from '~/src/styles/ui-kit-buttons.module.scss';
export default createBoard({
name: 'UI Kit - Buttons',
diff --git a/_codux/boards/ui-kits/ui-kit-components.board.tsx b/_codux/boards/ui-kits/ui-kit-components.board.tsx
index 6dff79b..c280bb1 100644
--- a/_codux/boards/ui-kits/ui-kit-components.board.tsx
+++ b/_codux/boards/ui-kits/ui-kit-components.board.tsx
@@ -1,7 +1,7 @@
import { createBoard, Variant } from '@wixc3/react-board';
import classNames from 'classnames';
-import { ProductCard } from '~/components/product-card/product-card';
-import styles from '~/styles/ui-kit-components.module.scss';
+import { ProductCard } from '~/src/components/product-card/product-card';
+import styles from '~/src/styles/ui-kit-components.module.scss';
export default createBoard({
name: 'UI Kit - Components',
diff --git a/_codux/boards/ui-kits/ui-kit-typograpgy.board.tsx b/_codux/boards/ui-kits/ui-kit-typograpgy.board.tsx
index 792cedd..12b8215 100644
--- a/_codux/boards/ui-kits/ui-kit-typograpgy.board.tsx
+++ b/_codux/boards/ui-kits/ui-kit-typograpgy.board.tsx
@@ -1,6 +1,6 @@
import { createBoard, Variant } from '@wixc3/react-board';
import classNames from 'classnames';
-import styles from '~/styles/ui-kit-typography.module.scss';
+import styles from '~/src/styles/ui-kit-typography.module.scss';
export default createBoard({
name: 'UI Kit - Typography',
diff --git a/_codux/mocks/cart.ts b/_codux/mocks/cart.ts
index 8905a5c..66f7b2c 100644
--- a/_codux/mocks/cart.ts
+++ b/_codux/mocks/cart.ts
@@ -1,6 +1,6 @@
import { faker } from '@faker-js/faker';
import { cart as wixEcomCart } from '@wix/ecom';
-import { Cart, CartItemDetails, CartTotals } from '~/api/types';
+import { Cart, CartItemDetails, CartTotals } from '~/lib/ecom';
export const cartItem = createCartItem();
export const cartItemWithDiscount = createCartItem({
diff --git a/app/root.tsx b/app/root.tsx
index a048032..69e1760 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -11,21 +11,36 @@ import {
useNavigation,
useRouteError,
} from '@remix-run/react';
+import { LoaderFunctionArgs } from '@remix-run/node';
+import { Tokens } from '@wix/sdk';
import { useEffect } from 'react';
-import { EcomAPIContextProvider } from '~/api/ecom-api-context-provider';
-import { CartOpenContextProvider } from '~/components/cart/cart-open-context';
-import { ErrorComponent } from '~/components/error-component/error-component';
-import { SiteWrapper } from '~/components/site-wrapper/site-wrapper';
-import { ROUTES } from '~/router/config';
-import '~/styles/index.scss';
-import { getErrorMessage, routeLocationToUrl } from '~/utils';
+import { CartOpenContextProvider } from '~/lib/cart-open-context';
+import { EcomAPIContextProvider } from '~/lib/ecom';
+import { initializeEcomSession, commitSession } from '~/lib/ecom/session';
+import { getErrorMessage, routeLocationToUrl } from '~/lib/utils';
+import { ErrorComponent } from '~/src/components/error-component/error-component';
+import { SiteWrapper } from '~/src/components/site-wrapper/site-wrapper';
-export async function loader() {
- return json({
- ENV: {
- WIX_CLIENT_ID: process?.env?.WIX_CLIENT_ID,
+import '~/src/styles/index.scss';
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const { wixEcomTokens, session, shouldUpdateSessionCookie } = await initializeEcomSession(request);
+
+ return json(
+ {
+ ENV: {
+ WIX_CLIENT_ID: process?.env?.WIX_CLIENT_ID,
+ },
+ wixEcomTokens,
},
- });
+ shouldUpdateSessionCookie
+ ? {
+ headers: {
+ 'Set-Cookie': await commitSession(session),
+ },
+ }
+ : undefined,
+ );
}
export function Layout({ children }: { children: React.ReactNode }) {
@@ -46,15 +61,29 @@ export function Layout({ children }: { children: React.ReactNode }) {
);
}
+interface ContentWrapperProps extends React.PropsWithChildren {
+ tokens?: Tokens;
+}
+
+function ContentWrapper({ children, tokens }: ContentWrapperProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
export default function App() {
- const data = useLoaderData();
+ const { ENV, wixEcomTokens } = useLoaderData();
if (typeof window !== 'undefined' && typeof window.ENV === 'undefined') {
- window.ENV = data.ENV;
+ window.ENV = ENV;
}
return (
-
+
);
@@ -83,18 +112,8 @@ export function ErrorBoundary() {
title={isPageNotFoundError ? 'Page Not Found' : 'Oops, something went wrong'}
message={isPageNotFoundError ? undefined : getErrorMessage(error)}
actionButtonText="Back to shopping"
- onActionButtonClick={() => navigate(ROUTES.category.to('all-products'))}
+ onActionButtonClick={() => navigate('/category/all-products')}
/>
);
}
-
-function ContentWrapper({ children }: React.PropsWithChildren) {
- return (
-
-
- {children}
-
-
- );
-}
diff --git a/app/routes/_index/route.tsx b/app/routes/_index/route.tsx
index 4049d54..8faf3f0 100644
--- a/app/routes/_index/route.tsx
+++ b/app/routes/_index/route.tsx
@@ -1,19 +1,19 @@
import { LinksFunction, LoaderFunctionArgs } from '@remix-run/node';
import { Link, MetaFunction, useLoaderData, useNavigate, json } from '@remix-run/react';
-import { getEcomApi } from '~/api/ecom-api';
-import { HeroImage } from '~/components/hero-image/hero-image';
-import { ProductCard } from '~/components/product-card/product-card';
-import { ROUTES } from '~/router/config';
-import { getUrlOriginWithPath, isOutOfStock } from '~/utils';
+import { initializeEcomApi } from '~/lib/ecom/session';
+import { isOutOfStock, removeQueryStringFromUrl } from '~/lib/utils';
+import { HeroImage } from '~/src/components/hero-image/hero-image';
+import { ProductCard } from '~/src/components/product-card/product-card';
import styles from './index.module.scss';
export const loader = async ({ request }: LoaderFunctionArgs) => {
- const productsResponse = await getEcomApi().getPromotedProducts();
+ const ecomApi = await initializeEcomApi(request);
+ const productsResponse = await ecomApi.getPromotedProducts();
if (productsResponse.status === 'failure') {
throw json(productsResponse.error);
}
- return { products: productsResponse.body, canonicalUrl: getUrlOriginWithPath(request.url) };
+ return { products: productsResponse.body, canonicalUrl: removeQueryStringFromUrl(request.url) };
};
export default function HomePage() {
@@ -29,14 +29,14 @@ export default function HomePage() {
bottomLabel="Get more for less on selected brands"
buttonLabel="Shop Now"
topLabelClassName={styles.topLabelHighlighted}
- onButtonClick={() => navigate(ROUTES.category.to())}
+ onButtonClick={() => navigate('/category/all-products')}
/>
Best Sellers
Shop our best seller items
{products?.map((product) =>
product.slug && product.name ? (
-
+
- ) : null
+ ) : null,
)}
diff --git a/app/routes/about/route.tsx b/app/routes/about/route.tsx
index 4935add..ef71289 100644
--- a/app/routes/about/route.tsx
+++ b/app/routes/about/route.tsx
@@ -1,10 +1,9 @@
-import { LinksFunction, MetaFunction } from '@remix-run/node';
-import { LoaderFunctionArgs } from 'react-router-dom';
-import { getUrlOriginWithPath } from '~/utils';
+import { LinksFunction, LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
+import { removeQueryStringFromUrl } from '~/lib/utils';
import styles from './about.module.scss';
export const loader = async ({ request }: LoaderFunctionArgs) => {
- return { canonicalUrl: getUrlOriginWithPath(request.url) };
+ return { canonicalUrl: removeQueryStringFromUrl(request.url) };
};
export default function AboutPage() {
diff --git a/app/routes/category.$categorySlug/route.tsx b/app/routes/category.$categorySlug/route.tsx
index c969a80..a037938 100644
--- a/app/routes/category.$categorySlug/route.tsx
+++ b/app/routes/category.$categorySlug/route.tsx
@@ -1,18 +1,23 @@
import { LinksFunction, LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
-import { isRouteErrorResponse, json, NavLink, useLoaderData, useNavigate, useRouteError } from '@remix-run/react';
+import { NavLink, useLoaderData, json, useRouteError, useNavigate, isRouteErrorResponse } from '@remix-run/react';
+import { GetStaticRoutes } from '@wixc3/define-remix-app';
import classNames from 'classnames';
-import { getEcomApi } from '~/api/ecom-api';
-import { productFiltersFromSearchParams, useAppliedProductFilters } from '~/api/product-filters';
-import { productSortByFromSearchParams } from '~/api/product-sorting';
-import { EcomApiErrorCodes } from '~/api/types';
-import { getImageHttpUrl } from '~/api/wix-image';
-import { AppliedProductFilters } from '~/components/applied-product-filters/applied-product-filters';
-import { ErrorComponent } from '~/components/error-component/error-component';
-import { ProductCard } from '~/components/product-card/product-card';
-import { ProductFilters } from '~/components/product-filters/product-filters';
-import { ProductSortingSelect } from '~/components/product-sorting-select/product-sorting-select';
-import { ROUTES } from '~/router/config';
-import { getErrorMessage, getUrlOriginWithPath, isOutOfStock } from '~/utils';
+import {
+ EcomApiErrorCodes,
+ createApi,
+ createWixClient,
+ productFiltersFromSearchParams,
+ productSortByFromSearchParams,
+} from '~/lib/ecom';
+import { useAppliedProductFilters } from '~/lib/hooks';
+import { initializeEcomApi } from '~/lib/ecom/session';
+import { getErrorMessage, isOutOfStock, removeQueryStringFromUrl } from '~/lib/utils';
+import { ProductCard } from '~/src/components/product-card/product-card';
+import { ErrorComponent } from '~/src/components/error-component/error-component';
+import { ProductFilters } from '~/src/components/product-filters/product-filters';
+import { AppliedProductFilters } from '~/src/components/applied-product-filters/applied-product-filters';
+import { ProductSortingSelect } from '~/src/components/product-sorting-select/product-sorting-select';
+
import styles from './category.module.scss';
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
@@ -21,18 +26,18 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
throw new Error('Missing category slug');
}
- const api = getEcomApi();
+ const ecomApi = await initializeEcomApi(request);
const url = new URL(request.url);
const [currentCategoryResponse, categoryProductsResponse, allCategoriesResponse, productPriceBoundsResponse] =
await Promise.all([
- api.getCategoryBySlug(categorySlug),
- api.getProductsByCategory(categorySlug, {
+ ecomApi.getCategoryBySlug(categorySlug),
+ ecomApi.getProductsByCategory(categorySlug, {
filters: productFiltersFromSearchParams(url.searchParams),
sortBy: productSortByFromSearchParams(url.searchParams),
}),
- api.getAllCategories(),
- api.getProductPriceBounds(categorySlug),
+ ecomApi.getAllCategories(),
+ ecomApi.getProductPriceBounds(categorySlug),
]);
if (currentCategoryResponse.status === 'failure') {
@@ -53,11 +58,21 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
categoryProducts: categoryProductsResponse.body,
allCategories: allCategoriesResponse.body,
productPriceBounds: productPriceBoundsResponse.body,
-
- canonicalUrl: getUrlOriginWithPath(request.url),
+ canonicalUrl: removeQueryStringFromUrl(request.url),
};
};
+export const getStaticRoutes: GetStaticRoutes = async () => {
+ const api = createApi(createWixClient());
+ const categories = await api.getAllCategories();
+
+ if (categories.status === 'failure') {
+ throw categories.error;
+ }
+
+ return categories.body.map((category) => `/category/${category.slug}`);
+};
+
export default function ProductsCategoryPage() {
const { categoryProducts, category, allCategories, productPriceBounds } = useLoaderData
();
@@ -76,7 +91,7 @@ export default function ProductsCategoryPage() {
category.slug ? (
classNames('linkButton', {
[styles.activeCategory]: isActive,
@@ -85,7 +100,7 @@ export default function ProductsCategoryPage() {
>
{category.name}
- ) : null
+ ) : null,
)}
@@ -126,20 +141,20 @@ export default function ProductsCategoryPage() {