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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions app/api/TokenCache.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -19,25 +19,25 @@ 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')
}

return value
}

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)
}
}
123 changes: 94 additions & 29 deletions app/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { type AuthMiddlewareOptions, ClientBuilder, type HttpMiddlewareOptions } from '@commercetools/ts-client'
import { ClientBuilder, type HttpMiddlewareOptions } from '@commercetools/ts-client'
import {
type ByProjectKeyMeRequestBuilder,
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 = {
authUri?: string
Expand Down Expand Up @@ -50,9 +51,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 public: ByProjectKeyRequestBuilder
private readonly anonymousIdStorageKey = 'anonymous_id'

private public: ByProjectKeyRequestBuilder
private protected?: ByProjectKeyRequestBuilder
private current: ByProjectKeyRequestBuilder

Expand All @@ -74,7 +78,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
Expand All @@ -87,38 +91,53 @@ export class CtpApiClient {

public get hasToken(): boolean {
try {
return this.tokenCache.get().token !== ''
return this.protectedTokenCache.get().token !== ''
} catch {
return false
}
}

public async login(email: string, password: string): Promise<ClientResponse<Customer>> {
this.logout()
await this.public
.me()
.login()
.post({
body: {
email,
password,
activeCartSignInMode: 'MergeWithExistingCustomerCart',
updateProductData: true
}
})
.execute()

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<ClientResponse<Customer>> {
return await this.current.me().get().execute()
return await this.getCurrentCustomerBuilder().get().execute()
}

public async signup(payload: SignupPayload): Promise<ClientResponse<CustomerSignInResult>> {
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
return this.public
.me()
.signup()
.post({
Expand Down Expand Up @@ -154,22 +173,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()
Expand All @@ -179,7 +199,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({
Expand All @@ -188,7 +208,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()
Expand All @@ -198,6 +244,25 @@ export class CtpApiClient {
projectKey: this.projectKey
})
}

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()
82 changes: 80 additions & 2 deletions app/api/namespaces/cart.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,80 @@
// TODO: implement
export {}
import { ctpApiClient, type CtpApiClient } from '~/api/client'
import { type Cart, type ClientResponse, type ProductProjection } from '@commercetools/platform-sdk'

type CartApiProperties = {
client: CtpApiClient
}

export class CartApi {
private readonly client: CtpApiClient

private readonly cartIdStorageKey = 'cart_id'
private readonly currency = 'USD'

constructor({ client }: CartApiProperties) {
this.client = client
}

public async addProduct(product: ProductProjection, quantity: number = 1): Promise<ClientResponse<Cart>> {
const cart = await this.getCart()

return this.client
.getCurrentCustomerBuilder()
.carts()
.withId({ ID: cart.id })
.post({ body: { actions: [{ action: 'addLineItem', productId: product.id, quantity }], version: cart.version } })
.execute()
}

public async removeProduct(product: ProductProjection, quantity: number = 1): Promise<ClientResponse<Cart>> {
const cart = await this.getCart()
const lineItemId = cart.lineItems.find((lineItem) => lineItem.productId === product.id)?.id

if (lineItemId === undefined) {
throw new Error(`Could not find lineItem for product with ID ${product.id}`)
}

return this.client
.getCurrentCustomerBuilder()
.carts()
.withId({ ID: cart.id })
.post({
body: { actions: [{ action: 'removeLineItem', lineItemId, quantity }], version: cart.version }
})
.execute()
}

public async getCart(): Promise<Cart> {
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((cart) => cart.id === this.getCartIdFromStorage())

if (cart === undefined) {
throw new Error('Can not get cart')
}

return cart
}

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 })
9 changes: 1 addition & 8 deletions app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function RoutesPublic(): ReactElement {
</AccessPublic>
}
/>
<Route path={ROUTES.CART} element={<Cart />} />
</Route>
)
}
Expand All @@ -63,14 +64,6 @@ export function RoutesProtected(): ReactElement {
</AccessProtected>
}
/>
<Route
path={ROUTES.CART}
element={
<AccessProtected>
<Cart />
</AccessProtected>
}
/>
<Route
path={ROUTES.LOGOUT}
element={
Expand Down
2 changes: 1 addition & 1 deletion app/layouts/components/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const navItems: NavItem[] = [
{ to: ROUTES.HOME, label: 'Home', icon: Home },
{ to: ROUTES.CATALOG, label: 'Catalog', icon: FolderOpen },
{ to: ROUTES.ABOUT, label: 'About', icon: Info },
{ to: ROUTES.CART, label: 'Cart', icon: ShoppingCart, auth: true },
{ to: ROUTES.CART, label: 'Cart', icon: ShoppingCart },
{ to: ROUTES.PROFILE, label: 'Profile', icon: User2, auth: true },
{ to: ROUTES.LOGIN, label: 'Login', icon: LogIn, auth: false },
{ to: ROUTES.REGISTER, label: 'Register', icon: UserPlus, auth: false },
Expand Down
23 changes: 21 additions & 2 deletions app/pages/cart/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
import { type ReactElement } from 'react'
import { type ReactElement, useEffect } from 'react'
import { useTitle } from '~/hooks/useTitle'
import { cartApi } from '~/api/namespaces/cart'
import { productApi } from '~/api/namespaces/product'

export default function Routes(): ReactElement {
useTitle('Cart')

return <>Cart</>
useEffect(() => {
const cartExampleCalls = async (): Promise<void> => {
// TODO: prices should not be tied to any country
const product = await productApi.getProductById('fd15bf1a-59ac-47d9-8172-3a7621c6740d')

const cartAfterAdd = await cartApi.addProduct(product.body, 1)

console.log(cartAfterAdd.body)

const cartAfterRemove = await cartApi.removeProduct(product.body, 1)

console.log(cartAfterRemove.body)
}

void cartExampleCalls()
})

return <>Cart1</>
}
Loading