diff --git a/app/api/TokenCache.ts b/app/api/TokenCache.ts index 37ecfd8..c40fbb2 100644 --- a/app/api/TokenCache.ts +++ b/app/api/TokenCache.ts @@ -1,6 +1,6 @@ import { type TokenStore, type TokenCache } from '@commercetools/ts-client' -export class SessionStorageTokenCache implements TokenCache { +export class LocalStorageTokenCache implements TokenCache { private readonly key: string constructor(key: string) { @@ -19,14 +19,14 @@ export class SessionStorageTokenCache implements TokenCache { } public get(): TokenStore { - const raw = globalThis.sessionStorage.getItem(this.key) + const raw = globalThis.localStorage.getItem(this.key) const value: unknown = JSON.parse(String(raw)) if (value === null) { return { token: '', expirationTime: 0 } } - if (!SessionStorageTokenCache.isTokenStore(value)) { + if (!LocalStorageTokenCache.isTokenStore(value)) { throw new Error('Token cache not found') } @@ -34,10 +34,10 @@ export class SessionStorageTokenCache implements TokenCache { } public set(cache: TokenStore): void { - globalThis.sessionStorage.setItem(this.key, JSON.stringify(cache)) + globalThis.localStorage.setItem(this.key, JSON.stringify(cache)) } public remove(): void { - globalThis.sessionStorage.removeItem(this.key) + globalThis.localStorage.removeItem(this.key) } } diff --git a/app/api/client.ts b/app/api/client.ts index 28fc0bd..bf0f0d6 100644 --- a/app/api/client.ts +++ b/app/api/client.ts @@ -1,14 +1,16 @@ -import { type AuthMiddlewareOptions, ClientBuilder, type HttpMiddlewareOptions } from '@commercetools/ts-client' +import { ClientBuilder, type HttpMiddlewareOptions } from '@commercetools/ts-client' import { + type ByProjectKeyMeRequestBuilder, + type ApiRequest, type ByProjectKeyRequestBuilder, type ClientResponse, createApiBuilderFromCtpClient, type Customer, type CustomerSignInResult } from '@commercetools/platform-sdk' -import { SessionStorageTokenCache } from '~/api/TokenCache' +import { LocalStorageTokenCache } from '~/api/TokenCache' -type ApiClientProperties = { +type ApiClientProps = { authUri?: string baseUri?: string clientId?: string @@ -42,6 +44,8 @@ type SignupPayload = { password: string } +export const LANG = 'en-US' + export class CtpApiClient { private readonly authUri: string private readonly baseUri: string @@ -50,9 +54,12 @@ export class CtpApiClient { private readonly projectKey: string private readonly scopes: string - private readonly tokenCache = new SessionStorageTokenCache('token') + private readonly publicTokenCache = new LocalStorageTokenCache('public') + private readonly protectedTokenCache = new LocalStorageTokenCache('protected') + + private readonly anonymousIdStorageKey = 'anonymous_id' - private readonly public: ByProjectKeyRequestBuilder + private public: ByProjectKeyRequestBuilder private protected?: ByProjectKeyRequestBuilder private current: ByProjectKeyRequestBuilder @@ -63,7 +70,7 @@ export class CtpApiClient { clientSecret = String(import.meta.env.VITE_CTP_CLIENT_SECRET), projectKey = String(import.meta.env.VITE_CTP_PROJECT_KEY), scopes = String(import.meta.env.VITE_CTP_SCOPES) - }: ApiClientProperties = {}) { + }: ApiClientProps = {}) { this.authUri = authUri this.baseUri = baseUri this.clientId = clientId @@ -74,7 +81,7 @@ export class CtpApiClient { this.public = this.createPublic() if (this.hasToken) { - this.protected = this.createPublic(true) + this.protected = this.createProtectedWithToken() this.current = this.protected } else { this.current = this.public @@ -87,62 +94,101 @@ export class CtpApiClient { public get hasToken(): boolean { try { - return this.tokenCache.get().token !== '' + return this.protectedTokenCache.get().token !== '' } catch { return false } } + private static isAnonymousIdError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'statusCode' in error && + error.statusCode === 400 && + 'message' in error && + typeof error.message === 'string' && + error.message.includes('anonymousId') + ) + } + public async login(email: string, password: string): Promise> { - this.logout() + const request: () => ApiRequest = () => + this.public + .me() + .login() + .post({ + body: { + email, + password, + activeCartSignInMode: 'UseAsNewActiveCustomerCart', + updateProductData: true + } + }) + + try { + await request().execute() + } catch (error) { + await this.handleError({ error, request }) + } - this.protected = this.createProtected(email, password) + this.protected = this.createProtectedWithCredentials(email, password) this.current = this.protected return await this.getCurrentCustomer() } public logout(): void { - this.tokenCache.remove() - this.current = this.public + this.protectedTokenCache.remove() + this.publicTokenCache.remove() + this.public = this.createPublic() this.protected = undefined + this.current = this.public + } + + public getCurrentCustomerBuilder(): ByProjectKeyMeRequestBuilder { + return this.current.me() } public async getCurrentCustomer(): Promise> { - return await this.current.me().get().execute() + return await this.getCurrentCustomerBuilder().get().execute() } public async signup(payload: SignupPayload): Promise> { - this.logout() - const billingAddressIndex = payload.addresses.findIndex(({ type }) => type === CUSTOMER_ADDRESS_TYPE.BILLING) const shippingAddressIndex = payload.addresses.findIndex(({ type }) => type === CUSTOMER_ADDRESS_TYPE.SHIPPING) - return this.current - .me() - .signup() - .post({ - body: { - addresses: payload.addresses.map( - (address): Omit => ({ - city: address.city, - country: address.country, - firstName: payload.firstName, - lastName: payload.lastName, - postalCode: address.postalCode, - streetName: address.streetName - }) - ), - dateOfBirth: payload.dateOfBirth, - defaultBillingAddress: billingAddressIndex === -1 ? undefined : billingAddressIndex, - defaultShippingAddress: shippingAddressIndex === -1 ? undefined : shippingAddressIndex, - email: payload.email, - firstName: payload.firstName, - lastName: payload.lastName, - password: payload.password - } - }) - .execute() + const request: () => ApiRequest = () => + this.public + .me() + .signup() + .post({ + body: { + addresses: payload.addresses.map( + (address): Omit => ({ + city: address.city, + country: address.country, + firstName: payload.firstName, + lastName: payload.lastName, + postalCode: address.postalCode, + streetName: address.streetName + }) + ), + dateOfBirth: payload.dateOfBirth, + defaultBillingAddress: billingAddressIndex === -1 ? undefined : billingAddressIndex, + defaultShippingAddress: shippingAddressIndex === -1 ? undefined : shippingAddressIndex, + email: payload.email, + firstName: payload.firstName, + lastName: payload.lastName, + password: payload.password + } + }) + + try { + return await request().execute() + } catch (error) { + return await this.handleError({ error, request }) + } } private getHttpOptions(): HttpMiddlewareOptions { @@ -154,22 +200,23 @@ export class CtpApiClient { } } - private createPublic(withTokenCache: boolean = false): ByProjectKeyRequestBuilder { - const authOptions: AuthMiddlewareOptions = { - credentials: { clientId: this.clientId, clientSecret: this.clientSecret }, - host: this.authUri, - httpClient: fetch, - projectKey: this.projectKey, - scopes: [this.scopes] - } - - if (withTokenCache) { - authOptions.tokenCache = this.tokenCache - } + private createPublic(): ByProjectKeyRequestBuilder { + const anonymousId = this.getOrCreateAnonymousId() const client = new ClientBuilder() .withProjectKey(this.projectKey) - .withClientCredentialsFlow(authOptions) + .withAnonymousSessionFlow({ + credentials: { + clientId: this.clientId, + clientSecret: this.clientSecret, + anonymousId + }, + host: this.authUri, + httpClient: fetch, + projectKey: this.projectKey, + scopes: [this.scopes], + tokenCache: this.publicTokenCache + }) .withHttpMiddleware(this.getHttpOptions()) .withLoggerMiddleware() .build() @@ -179,7 +226,7 @@ export class CtpApiClient { }) } - private createProtected(email: string, password: string): ByProjectKeyRequestBuilder { + private createProtectedWithCredentials(email: string, password: string): ByProjectKeyRequestBuilder { const client = new ClientBuilder() .withProjectKey(this.projectKey) .withPasswordFlow({ @@ -188,7 +235,33 @@ export class CtpApiClient { httpClient: fetch, projectKey: this.projectKey, scopes: [this.scopes], - tokenCache: this.tokenCache + tokenCache: this.protectedTokenCache + }) + .withHttpMiddleware(this.getHttpOptions()) + .withLoggerMiddleware() + .build() + + return createApiBuilderFromCtpClient(client).withProjectKey({ + projectKey: this.projectKey + }) + } + + private createProtectedWithToken(): ByProjectKeyRequestBuilder { + const { refreshToken } = this.protectedTokenCache.get() + + if (refreshToken === undefined) { + throw new Error('Refresh token is missing') + } + + const client = new ClientBuilder() + .withProjectKey(this.projectKey) + .withRefreshTokenFlow({ + credentials: { clientId: this.clientId, clientSecret: this.clientSecret }, + host: this.authUri, + httpClient: fetch, + projectKey: this.projectKey, + tokenCache: this.protectedTokenCache, + refreshToken }) .withHttpMiddleware(this.getHttpOptions()) .withLoggerMiddleware() @@ -198,6 +271,42 @@ export class CtpApiClient { projectKey: this.projectKey }) } + + private async handleError({ + error, + request + }: { + error: unknown + request: () => ApiRequest + }): Promise> { + if (!CtpApiClient.isAnonymousIdError(error)) { + throw error + } + + console.log('Anonymous ID is already in use. Creating new anonymous ID...') + + this.logout() + return await request().execute() + } + + private getOrCreateAnonymousId(): string { + let id = this.getAnonymousIdFromStorage() + + if (id === null || !this.hasToken) { + id = crypto.randomUUID() + this.saveAnonymousIdToStorage(id) + } + + return id + } + + private getAnonymousIdFromStorage(): string | null { + return localStorage.getItem(this.anonymousIdStorageKey) + } + + private saveAnonymousIdToStorage(id: string): void { + localStorage.setItem(this.anonymousIdStorageKey, id) + } } export const ctpApiClient = new CtpApiClient() diff --git a/app/api/namespaces/cart.ts b/app/api/namespaces/cart.ts index 7f08d9b..23d0f72 100644 --- a/app/api/namespaces/cart.ts +++ b/app/api/namespaces/cart.ts @@ -1,2 +1,147 @@ -// TODO: implement -export {} +import { ctpApiClient, type CtpApiClient } from '~/api/client' +import { type Cart, type ClientResponse, type DiscountCodePagedQueryResponse } from '@commercetools/platform-sdk' + +type CartApiProps = { + client: CtpApiClient +} + +export class CartApi { + private readonly client: CtpApiClient + + private readonly cartIdStorageKey = 'cart_id' + private readonly currency = 'USD' + + constructor({ client }: CartApiProps) { + this.client = client + } + + public async addProduct(productId: string, quantity: number = 1): Promise> { + const cart = await this.getCart() + + return this.client + .getCurrentCustomerBuilder() + .carts() + .withId({ ID: cart.id }) + .post({ body: { actions: [{ action: 'addLineItem', productId, quantity }], version: cart.version } }) + .execute() + } + + public async removeProduct(productId: string, quantity: number = 1): Promise> { + const cart = await this.getCart() + const lineItemId = cart.lineItems.find((lineItem) => lineItem.productId === productId)?.id + + if (lineItemId === undefined) { + throw new Error(`Could not find lineItem for product with ID ${productId}`) + } + + return this.client + .getCurrentCustomerBuilder() + .carts() + .withId({ ID: cart.id }) + .post({ + body: { actions: [{ action: 'removeLineItem', lineItemId, quantity }], version: cart.version } + }) + .execute() + } + + public async updateProductQuantity(productId: string, quantity: number): Promise> { + const cart = await this.getCart() + const lineItemId = cart.lineItems.find((lineItem) => lineItem.productId === productId)?.id + + if (lineItemId === undefined) { + throw new Error(`Could not find lineItem for product with ID ${productId}`) + } + + return this.client + .getCurrentCustomerBuilder() + .carts() + .withId({ ID: cart.id }) + .post({ + body: { actions: [{ action: 'changeLineItemQuantity', lineItemId, quantity }], version: cart.version } + }) + .execute() + } + + public async clearCart(): Promise { + let cart = await this.getCart() + + for (const { id, quantity } of cart.lineItems) { + const { body } = await this.client + .getCurrentCustomerBuilder() + .carts() + .withId({ ID: cart.id }) + .post({ + body: { actions: [{ action: 'removeLineItem', lineItemId: id, quantity }], version: cart.version } + }) + .execute() + + cart = body + } + + return cart + } + + public async getCart(): Promise { + const carts = await this.client.getCurrentCustomerBuilder().carts().get().execute() + + if (carts.body.results.length === 0) { + const cart = await this.client + .getCurrentCustomerBuilder() + .carts() + .post({ body: { currency: this.currency } }) + .execute() + + this.saveCartIdToStorage(cart.body.id) + + return cart.body + } + + const cart = carts.body.results.find( + ({ id, cartState }) => id === this.getCartIdFromStorage() && cartState === 'Active' + ) + + if (cart !== undefined) { + return cart + } + + const nonEmptyCart = carts.body.results.find( + ({ lineItems, cartState }) => lineItems.length > 0 && cartState === 'Active' + ) + + if (nonEmptyCart !== undefined) { + return nonEmptyCart + } + + const activeCart = await this.client.getCurrentCustomerBuilder().activeCart().get().execute() + + return activeCart.body + } + + public async getDiscountCodes(): Promise> { + return this.client.root + .discountCodes() + .get({ queryArgs: { where: 'isActive = true' } }) + .execute() + } + + public async applyDiscountCode(code: string): Promise> { + const cart = await this.getCart() + + return this.client + .getCurrentCustomerBuilder() + .carts() + .withId({ ID: cart.id }) + .post({ body: { actions: [{ action: 'addDiscountCode', code }], version: cart.version } }) + .execute() + } + + private getCartIdFromStorage(): string | null { + return localStorage.getItem(this.cartIdStorageKey) + } + + private saveCartIdToStorage(id: string): void { + localStorage.setItem(this.cartIdStorageKey, id) + } +} + +export const cartApi = new CartApi({ client: ctpApiClient }) diff --git a/app/api/namespaces/product.ts b/app/api/namespaces/product.ts index bd0f47e..bc9c980 100644 --- a/app/api/namespaces/product.ts +++ b/app/api/namespaces/product.ts @@ -1,4 +1,4 @@ -import { ctpApiClient, type CtpApiClient } from '~/api/client' +import { ctpApiClient, type CtpApiClient, LANG } from '~/api/client' import { type ClientResponse, type ByProjectKeyProductProjectionsSearchRequestBuilder, @@ -8,7 +8,7 @@ import { type ProductProjection } from '@commercetools/platform-sdk' -type ProductApiProperties = { +type ProductApiProps = { client: CtpApiClient } @@ -68,6 +68,7 @@ export type ProductListAppliedSort = { value: 'asc' | 'desc' }[] +export const PRODUCT_LIST_ITEMS_PER_PAGE = 12 export const PRODUCT_LIST_FILTER_TRUE = 'T' export const PRODUCT_LIST_FILTER_FALSE = 'F' export const PRODUCT_LIST_FILTER_NONE = 'none' @@ -92,22 +93,29 @@ export const PRODUCT_LIST_DEFAULT_APPLIED_SORT: ProductListAppliedSort = [ export class ProductApi { private readonly client: CtpApiClient - constructor({ client }: ProductApiProperties) { + constructor({ client }: ProductApiProps) { this.client = client } + public static getPaginationQueryParameters(page: number): { limit: number; offset: number } { + return { + limit: PRODUCT_LIST_ITEMS_PER_PAGE, + offset: (page - 1) * PRODUCT_LIST_ITEMS_PER_PAGE + } + } + private static convertAttributeToFilter({ label, type, name }: AttributeDefinition): ProductListFilter { const options: ProductListFilterFromAttributes['options'] = type.name === 'set' && 'values' in type.elementType ? type.elementType.values.map((value) => ({ value: value.key, - label: typeof value.label === 'object' && 'en-US' in value.label ? value.label['en-US'] : 'Unknown label' + label: typeof value.label === 'object' && LANG in value.label ? value.label[LANG] : 'Unknown label' })) : [] return { key: name, - label: label['en-US'], + label: label[LANG], options, type: type.name } @@ -177,7 +185,7 @@ export class ProductApi { ] }), ...(sort.length > 0 && { sort: ProductApi.convertSortToQuery(sort) }), - ...(searchText.length > 0 && { 'text.en-US': searchText, fuzzy: true }) + ...(searchText.length > 0 && { [`text.${LANG}`]: searchText, fuzzy: true }) } }) .execute() diff --git a/app/api/namespaces/user.ts b/app/api/namespaces/user.ts index 7d0304a..ea7ead7 100644 --- a/app/api/namespaces/user.ts +++ b/app/api/namespaces/user.ts @@ -2,14 +2,14 @@ import { type Address, type ClientResponse, type Customer } from '@commercetools import { formatDateForSdk } from '~/utils/formatDate' import { ctpApiClient, type CtpApiClient } from '~/api/client' -type UserApiProperties = { +type UserApiProps = { client: CtpApiClient } export class UserApi { private readonly client: CtpApiClient - constructor({ client }: UserApiProperties) { + constructor({ client }: UserApiProps) { this.client = client } diff --git a/app/app.tsx b/app/app.tsx index 32d321d..3144ddf 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -48,6 +48,7 @@ export function RoutesPublic(): ReactElement { } /> + } /> ) } @@ -63,14 +64,6 @@ export function RoutesProtected(): ReactElement { } /> - - - - } - /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/components/Loading.tsx b/app/components/Loading.tsx index d9e5a06..aa1a997 100644 --- a/app/components/Loading.tsx +++ b/app/components/Loading.tsx @@ -2,9 +2,9 @@ import { type ReactElement } from 'react' export function Loading(): ReactElement { return ( -
-
-

Loading...

+
+
+

Loading...

) } diff --git a/app/components/product/ProductImage.tsx b/app/components/product/ProductImage.tsx new file mode 100644 index 0000000..e4d53e8 --- /dev/null +++ b/app/components/product/ProductImage.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react' +import { AspectRatio } from '../ui/AspectRatio' + +export function ProductImage({ imageUrl, alt }: { imageUrl: string | undefined; alt: string }): ReactElement { + return ( + + {imageUrl && {alt}} + + ) +} diff --git a/app/components/product/ProductPrice.test.tsx b/app/components/product/ProductPrice.test.tsx index 050faac..95df003 100644 --- a/app/components/product/ProductPrice.test.tsx +++ b/app/components/product/ProductPrice.test.tsx @@ -9,7 +9,8 @@ describe('ProductPrice', () => { renderWithProviders() - expect(screen.getByText('$1', { selector: 'div:not(.text-green-400)' })).toBeInTheDocument() - expect(screen.getByText('$0.5', { selector: '.text-green-400' })).toBeInTheDocument() + expect(screen.queryByTestId('start-price')).toHaveTextContent('$1') + + expect(screen.queryByTestId('discount-price')).toHaveTextContent('$0.5') }) }) diff --git a/app/components/product/ProductPrice.tsx b/app/components/product/ProductPrice.tsx index 819816c..c444409 100644 --- a/app/components/product/ProductPrice.tsx +++ b/app/components/product/ProductPrice.tsx @@ -1,23 +1,29 @@ import { type ReactElement } from 'react' import { formatProductItemPrice } from '~/utils/formatPrice' -type ProductPriceProperties = { +type ProductPriceProps = { startPrice: number discountPrice?: number } -export function ProductPrice({ startPrice, discountPrice }: ProductPriceProperties): ReactElement { +export function ProductPrice({ startPrice, discountPrice }: ProductPriceProps): ReactElement { const hasDiscount = discountPrice !== undefined && discountPrice < startPrice const formattedStartPrice = formatProductItemPrice(startPrice) + const formattedDiscountPrice = formatProductItemPrice(discountPrice) if (hasDiscount) { return (
-
+
{formattedStartPrice}
-
{formatProductItemPrice(discountPrice)}
+
+ {formattedDiscountPrice} +
) } diff --git a/app/components/ui/Alert.tsx b/app/components/ui/Alert.tsx index eea1a66..6d36ee1 100644 --- a/app/components/ui/Alert.tsx +++ b/app/components/ui/Alert.tsx @@ -1,6 +1,5 @@ -import { type ReactElement } from 'react' +import { type ComponentProps, type ReactElement } from 'react' import { cva, type VariantProps } from 'class-variance-authority' - import { cn } from '~/utils/ui' const alertVariants = cva( @@ -21,22 +20,22 @@ const alertVariants = cva( export function Alert({ className, variant, - ...properties -}: React.ComponentProps<'div'> & VariantProps): ReactElement { - return
+ ...props +}: ComponentProps<'div'> & VariantProps): ReactElement { + return
} -export function AlertTitle({ className, ...properties }: React.ComponentProps<'div'>): ReactElement { +export function AlertTitle({ className, ...props }: ComponentProps<'div'>): ReactElement { return (
) } -export function AlertDescription({ className, ...properties }: React.ComponentProps<'div'>): ReactElement { +export function AlertDescription({ className, ...props }: ComponentProps<'div'>): ReactElement { return (
) } diff --git a/app/components/ui/AlertDialog.tsx b/app/components/ui/AlertDialog.tsx new file mode 100644 index 0000000..9f20251 --- /dev/null +++ b/app/components/ui/AlertDialog.tsx @@ -0,0 +1,111 @@ +import { type ComponentProps, type ReactElement } from 'react' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' +import { cn } from '~/utils/ui' +import { buttonVariants } from '~/components/ui/Button' + +export function AlertDialog({ ...props }: ComponentProps): ReactElement { + return +} + +export function AlertDialogTrigger({ ...props }: ComponentProps): ReactElement { + return +} + +export function AlertDialogPortal({ ...props }: ComponentProps): ReactElement { + return +} + +export function AlertDialogOverlay({ + className, + ...props +}: ComponentProps): ReactElement { + return ( + + ) +} + +export function AlertDialogContent({ + className, + ...props +}: ComponentProps): ReactElement { + return ( + + + + + ) +} + +export function AlertDialogHeader({ className, ...props }: ComponentProps<'div'>): ReactElement { + return ( +
+ ) +} + +export function AlertDialogFooter({ className, ...props }: ComponentProps<'div'>): ReactElement { + return ( +
+ ) +} + +export function AlertDialogTitle({ + className, + ...props +}: ComponentProps): ReactElement { + return ( + + ) +} + +export function AlertDialogDescription({ + className, + ...props +}: ComponentProps): ReactElement { + return ( + + ) +} + +export function AlertDialogAction({ + className, + ...props +}: ComponentProps): ReactElement { + return +} + +export function AlertDialogCancel({ + className, + ...props +}: ComponentProps): ReactElement { + return +} diff --git a/app/components/ui/AspectRatio.tsx b/app/components/ui/AspectRatio.tsx index 3ad4998..6cf6ad2 100644 --- a/app/components/ui/AspectRatio.tsx +++ b/app/components/ui/AspectRatio.tsx @@ -1,6 +1,6 @@ import { Root } from '@radix-ui/react-aspect-ratio' import { type ComponentProps, type ReactElement } from 'react' -export function AspectRatio({ ...properties }: ComponentProps): ReactElement { - return +export function AspectRatio({ ...props }: ComponentProps): ReactElement { + return } diff --git a/app/components/ui/Badge.tsx b/app/components/ui/Badge.tsx index fbc25dc..f2dc2bb 100644 --- a/app/components/ui/Badge.tsx +++ b/app/components/ui/Badge.tsx @@ -25,9 +25,9 @@ export function Badge({ className, variant, asChild = false, - ...properties + ...props }: ComponentProps<'span'> & VariantProps & { asChild?: boolean }): ReactElement { const Comp = asChild ? Slot : 'span' - return + return } diff --git a/app/components/ui/Breadcrumb.tsx b/app/components/ui/Breadcrumb.tsx index 81378db..5e6f13d 100644 --- a/app/components/ui/Breadcrumb.tsx +++ b/app/components/ui/Breadcrumb.tsx @@ -3,11 +3,11 @@ import { Slot } from '@radix-ui/react-slot' import { ChevronRight, MoreHorizontal } from 'lucide-react' import { cn } from '~/utils/ui' -export function Breadcrumb({ ...properties }: ComponentProps<'nav'>): ReactElement { - return