diff --git a/.prettierrc.mjs b/.prettierrc.mjs index de24f2e35..5057b7572 100644 --- a/.prettierrc.mjs +++ b/.prettierrc.mjs @@ -11,6 +11,8 @@ const config = { ...apiConfig, ...webConfig, ...uiConfig, + // Required for .tsx files - parser was misinterpreting JSX as regex + importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'], }; export default config; diff --git a/.storybook/main.ts b/.storybook/main.ts index b0f56c8ef..aacf026d9 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -29,7 +29,13 @@ const config: StorybookConfig = { '../packages/ui/**/*.stories.@(js|jsx|mjs|ts|tsx)', ], staticDirs: ['./public'], - addons: ['@storybook/addon-docs', '@storybook/addon-a11y', '@storybook/addon-themes', '@storybook/addon-vitest'], + addons: [ + '@storybook/addon-docs', + '@storybook/addon-a11y', + '@storybook/addon-themes', + '@storybook/addon-vitest', + 'msw-storybook-addon', + ], framework: { name: '@storybook/nextjs-vite', options: {}, @@ -73,6 +79,7 @@ const config: StorybookConfig = { '@o2s/configs.integrations/live-preview': path.resolve(__dirname, './mocks/live-preview.mock.ts'), '@o2s/framework/sdk': path.resolve(__dirname, '../packages/framework/src/sdk.ts'), '@o2s/framework/modules': path.resolve(__dirname, '../packages/framework/src/index.ts'), + '@o2s/utils.api-harmonization': path.resolve(__dirname, './mocks/utils.api-harmonization.mock.ts'), }, }, ssr: { diff --git a/.storybook/mocks/data/cart-data.ts b/.storybook/mocks/data/cart-data.ts new file mode 100644 index 000000000..2ecd71e71 --- /dev/null +++ b/.storybook/mocks/data/cart-data.ts @@ -0,0 +1,219 @@ +/** + * Shared mock cart and checkout data for Storybook. + * Used by cart and checkout block SDK mocks. + */ + +const MOCK_CART = { + id: 'storybook-cart-1', + customerId: 'cust-001', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + currency: 'EUR' as const, + regionId: 'reg-1', + items: { + data: [ + { + id: 'item-1', + sku: 'SKU-001', + quantity: 2, + price: { value: 49.99, currency: 'EUR' as const }, + subtotal: { value: 99.98, currency: 'EUR' as const }, + discountTotal: { value: 0, currency: 'EUR' as const }, + total: { value: 99.98, currency: 'EUR' as const }, + unit: 'PCS' as const, + currency: 'EUR' as const, + product: { + id: 'prod-1', + sku: 'SKU-001', + name: 'Wireless Noise-Cancelling Headphones', + description: 'Premium over-ear headphones with active noise cancellation and 30h battery life', + shortDescription: 'Over-ear ANC headphones', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/prd-004_1.jpg', + width: 800, + height: 800, + alt: 'Wireless Noise-Cancelling Headphones', + }, + price: { value: 49.99, currency: 'EUR' as const }, + link: '/products/sample', + type: 'PHYSICAL' as const, + category: 'General', + tags: [], + }, + }, + { + id: 'item-2', + sku: 'SKU-002', + quantity: 1, + price: { value: 105, currency: 'EUR' as const }, + subtotal: { value: 105, currency: 'EUR' as const }, + discountTotal: { value: 0, currency: 'EUR' as const }, + total: { value: 105, currency: 'EUR' as const }, + unit: 'PCS' as const, + currency: 'EUR' as const, + product: { + id: 'prod-2', + sku: 'SKU-002', + name: 'USB-C Charging Cable (2m)', + description: 'Braided USB-C to USB-C fast charging cable, 2 meters', + shortDescription: 'USB-C charging cable', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/prd-005_1.jpg', + width: 800, + height: 800, + alt: 'USB-C Charging Cable', + }, + price: { value: 105, currency: 'EUR' as const }, + link: '/products/another', + type: 'PHYSICAL' as const, + category: 'General', + tags: [], + }, + }, + ], + total: 2, + }, + subtotal: { value: 204.98, currency: 'EUR' as const }, + discountTotal: { value: 0, currency: 'EUR' as const }, + taxTotal: { value: 47.14, currency: 'EUR' as const }, + shippingTotal: { value: 0, currency: 'EUR' as const }, + total: { value: 252.12, currency: 'EUR' as const }, + billingAddress: { + firstName: 'John', + lastName: 'Doe', + companyName: 'ACME Inc.', + taxId: '1234567890', + country: 'PL', + streetName: 'Main Street', + streetNumber: '123', + city: 'Warsaw', + postalCode: '00-001', + email: 'john@example.com', + phone: '+48 123 456 789', + }, + shippingAddress: { + firstName: 'John', + lastName: 'Doe', + country: 'PL', + streetName: 'Main Street', + streetNumber: '123', + city: 'Warsaw', + postalCode: '00-001', + phone: '+48 123 456 789', + }, + shippingMethod: { + id: 'standard', + name: 'Standard Shipping', + description: '3-5 business days', + total: { value: 0, currency: 'EUR' as const }, + subtotal: { value: 0, currency: 'EUR' as const }, + }, + paymentMethod: { + id: 'card', + name: 'Credit Card', + description: 'Pay with Visa, Mastercard', + }, + promotions: [ + { + id: 'promo-1', + code: 'SAVE10', + name: '10% Off', + type: 'PERCENTAGE' as const, + value: '10', + }, + ], + notes: '', + email: 'john@example.com', +}; + +/** Empty cart for EmptyCart story variant - use cartId "storybook-cart-empty" */ +const MOCK_EMPTY_CART = { + ...MOCK_CART, + id: 'storybook-cart-empty', + items: { + data: [], + total: 0, + }, + subtotal: { value: 0, currency: 'EUR' as const }, + discountTotal: { value: 0, currency: 'EUR' as const }, + taxTotal: { value: 0, currency: 'EUR' as const }, + shippingTotal: { value: 0, currency: 'EUR' as const }, + total: { value: 0, currency: 'EUR' as const }, + billingAddress: undefined, + shippingAddress: undefined, + shippingMethod: undefined, + paymentMethod: undefined, + promotions: undefined, +}; + +const MOCK_SHIPPING_OPTIONS = { + data: [ + { + id: 'standard', + name: 'Standard Shipping', + description: '3-5 business days', + total: { value: 0, currency: 'EUR' as const }, + subtotal: { value: 0, currency: 'EUR' as const }, + }, + { + id: 'express', + name: 'Express Shipping', + description: '1-2 business days', + total: { value: 15, currency: 'EUR' as const }, + subtotal: { value: 15, currency: 'EUR' as const }, + }, + ], + total: 2, +}; + +const MOCK_PAYMENT_PROVIDERS = { + data: [ + { + id: 'card', + name: 'Credit Card', + description: 'Pay with Visa, Mastercard', + }, + { + id: 'blik', + name: 'BLIK', + description: 'Instant mobile payment', + }, + ], +}; + +const MOCK_CHECKOUT_SUMMARY = { + cart: MOCK_CART, + shippingAddress: MOCK_CART.shippingAddress, + billingAddress: MOCK_CART.billingAddress, + shippingMethod: MOCK_CART.shippingMethod, + paymentMethod: MOCK_CART.paymentMethod, + totals: { + subtotal: MOCK_CART.subtotal, + shipping: MOCK_CART.shippingTotal ?? { value: 0, currency: 'EUR' as const }, + tax: MOCK_CART.taxTotal ?? { value: 0, currency: 'EUR' as const }, + discount: MOCK_CART.discountTotal ?? { value: 0, currency: 'EUR' as const }, + total: MOCK_CART.total, + }, + notes: MOCK_CART.notes, + email: MOCK_CART.email, +}; + +const MOCK_PLACE_ORDER_RESPONSE = { + order: { + id: 'ord-storybook-1', + total: MOCK_CART.total, + currency: 'EUR', + status: 'PENDING', + paymentStatus: 'PENDING', + }, + paymentRedirectUrl: undefined, +}; + +export { + MOCK_CART, + MOCK_EMPTY_CART, + MOCK_SHIPPING_OPTIONS, + MOCK_PAYMENT_PROVIDERS, + MOCK_CHECKOUT_SUMMARY, + MOCK_PLACE_ORDER_RESPONSE, +}; diff --git a/.storybook/mocks/handlers/cart-handlers.ts b/.storybook/mocks/handlers/cart-handlers.ts new file mode 100644 index 000000000..4d04cbfb1 --- /dev/null +++ b/.storybook/mocks/handlers/cart-handlers.ts @@ -0,0 +1,52 @@ +/** + * MSW handlers for cart and checkout API endpoints. + * Used by cart and checkout block stories. + * Path patterns match /api/carts/* and /api/checkout/* (NEXT_PUBLIC_API_URL base). + */ +import { HttpResponse, http } from 'msw'; + +import { + MOCK_CART, + MOCK_CHECKOUT_SUMMARY, + MOCK_EMPTY_CART, + MOCK_PAYMENT_PROVIDERS, + MOCK_PLACE_ORDER_RESPONSE, + MOCK_SHIPPING_OPTIONS, +} from '../data/cart-data'; + +/** Returns full cart for Default stories, empty cart for EmptyCart (cartId contains 'empty') */ +const getCartResponse = (url: string) => { + const cartIdMatch = url.match(/\/carts\/([^/]+)/); + const cartId = cartIdMatch?.[1] ?? ''; + return cartId.includes('empty') ? MOCK_EMPTY_CART : MOCK_CART; +}; + +// Match /api/carts/:id (GET), /api/carts/:id/items/:itemId (PATCH/DELETE), /api/carts/:id/promotions (POST/DELETE) +const CART_PATH = /\/api\/carts\/[^/]+/; +const CHECKOUT_SUMMARY_PATH = /\/api\/checkout\/[^/]+\/summary/; +const CHECKOUT_PLACE_ORDER_PATH = /\/api\/checkout\/[^/]+\/place-order/; +const CHECKOUT_SHIPPING_OPTIONS_PATH = /\/api\/checkout\/[^/]+\/shipping-options/; +const CHECKOUT_ADDRESSES_PATH = /\/api\/checkout\/[^/]+\/addresses/; +const CHECKOUT_SHIPPING_METHOD_PATH = /\/api\/checkout\/[^/]+\/shipping-method/; +const CHECKOUT_PAYMENT_PATH = /\/api\/checkout\/[^/]+\/payment/; +const PAYMENTS_PROVIDERS_PATH = /\/api\/payments\/providers/; + +export const cartHandlers = [ + http.get(CART_PATH, ({ request }) => HttpResponse.json(getCartResponse(request.url))), + http.patch(CART_PATH, ({ request }) => HttpResponse.json(getCartResponse(request.url))), + http.delete(CART_PATH, ({ request }) => HttpResponse.json(getCartResponse(request.url))), + http.post(CART_PATH, ({ request }) => HttpResponse.json(getCartResponse(request.url))), +]; + +export const checkoutHandlers = [ + http.get(CHECKOUT_SUMMARY_PATH, () => HttpResponse.json(MOCK_CHECKOUT_SUMMARY)), + http.post(CHECKOUT_PLACE_ORDER_PATH, () => HttpResponse.json(MOCK_PLACE_ORDER_RESPONSE)), + http.get(CHECKOUT_SHIPPING_OPTIONS_PATH, () => HttpResponse.json(MOCK_SHIPPING_OPTIONS)), + http.post(CHECKOUT_ADDRESSES_PATH, ({ request }) => HttpResponse.json(getCartResponse(request.url))), + http.post(CHECKOUT_SHIPPING_METHOD_PATH, ({ request }) => HttpResponse.json(getCartResponse(request.url))), + http.post(CHECKOUT_PAYMENT_PATH, ({ request }) => HttpResponse.json(getCartResponse(request.url))), +]; + +export const paymentsHandlers = [http.get(PAYMENTS_PROVIDERS_PATH, () => HttpResponse.json(MOCK_PAYMENT_PROVIDERS))]; + +export const cartAndCheckoutHandlers = [...cartHandlers, ...checkoutHandlers, ...paymentsHandlers]; diff --git a/.storybook/mocks/utils.api-harmonization.mock.ts b/.storybook/mocks/utils.api-harmonization.mock.ts new file mode 100644 index 000000000..e803dd554 --- /dev/null +++ b/.storybook/mocks/utils.api-harmonization.mock.ts @@ -0,0 +1,23 @@ +/** + * Browser-safe mock for @o2s/utils.api-harmonization. + * Exports only Models (Block, Headers) - avoids jsonwebtoken which uses Node.js util.inherits. + * The full package includes Utils.Auth that imports jsonwebtoken, causing "util.inherits is not a function" in Storybook. + */ + +export class Block { + id!: string; +} + +export class AppHeaders { + 'x-locale'!: string; + 'x-client-timezone'?: string; + 'x-currency'?: string; + 'authorization'?: string; +} + +export const Models = { + Block: { Block }, + Headers: { AppHeaders }, +}; + +export const Utils = {} as Record; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index d99873d3e..be1a3c882 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,20 +1,26 @@ -import React from 'react'; -import { NextIntlClientProvider } from 'next-intl'; -import type { Preview } from '@storybook/nextjs-vite'; -import { createRouter } from '@storybook/nextjs-vite/router.mock'; -import { createNavigation } from '@storybook/nextjs-vite/navigation.mock'; import { Markdown, Title, useOf } from '@storybook/addon-docs/blocks'; import { withThemeByClassName } from '@storybook/addon-themes'; +import type { Preview } from '@storybook/nextjs-vite'; +import { createNavigation } from '@storybook/nextjs-vite/navigation.mock'; +import { createRouter } from '@storybook/nextjs-vite/router.mock'; +import { initialize, mswLoader } from 'msw-storybook-addon'; +import { NextIntlClientProvider } from 'next-intl'; +import React from 'react'; import { GlobalProvider } from '@o2s/ui/providers/GlobalProvider'; + import { AppSpinner } from '@o2s/ui/components/AppSpinner'; + import { Toaster } from '@o2s/ui/elements/toaster'; import { TooltipProvider } from '@o2s/ui/elements/tooltip'; +import messages from '../apps/frontend/src/i18n/messages/en.json'; +import '../apps/frontend/src/styles/global.css'; + import { globalProviderConfig, globalProviderCurrentTheme, globalProviderLabels, globalProviderThemes } from './data'; +import { cartAndCheckoutHandlers } from './mocks/handlers/cart-handlers'; -import '../apps/frontend/src/styles/global.css'; -import messages from '../apps/frontend/src/i18n/messages/en.json' +initialize(); createRouter({}); createNavigation({}); @@ -23,38 +29,50 @@ const ReadmeDocsPage = () => { const { story } = useOf('story', ['story']); const { preparedMeta } = useOf('meta', ['meta']); const readme = story.parameters?.readme ?? preparedMeta.parameters?.readme; + const docContent = typeof readme === 'string' ? {readme} : null; return ( <> - {typeof readme === 'string' ? <Markdown>{readme}</Markdown> : null} + {docContent} </> ); }; const preview: Preview = { - parameters: { - docs: { - page: ReadmeDocsPage, - }, - nextjs: { - appDirectory: true, - }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, + loaders: [mswLoader], + parameters: { + msw: { + handlers: cartAndCheckoutHandlers, + }, + docs: { + page: ReadmeDocsPage, + }, + nextjs: { + appDirectory: true, + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, }, - }, - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: 'todo' - } - }, + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo', + }, + }, decorators: [ + (Story) => { + // Set cartId for cart/checkout blocks - MSW handlers return mock data + if (typeof window !== 'undefined') { + window.localStorage.setItem('cartId', 'storybook-cart-1'); + } + return <Story />; + }, withThemeByClassName({ themes: { default: 'theme-default', @@ -63,9 +81,15 @@ const preview: Preview = { defaultTheme: 'default', }), (Story) => { - return( + return ( <NextIntlClientProvider locale="en" messages={messages}> - <GlobalProvider config={globalProviderConfig} labels={globalProviderLabels} themes={globalProviderThemes} currentTheme={globalProviderCurrentTheme} locale="en"> + <GlobalProvider + config={globalProviderConfig} + labels={globalProviderLabels} + themes={globalProviderThemes} + currentTheme={globalProviderCurrentTheme} + locale="en" + > <TooltipProvider> <Story /> @@ -74,9 +98,9 @@ const preview: Preview = { </TooltipProvider> </GlobalProvider> </NextIntlClientProvider> - ) - } - ] + ); + }, + ], }; export default preview; diff --git a/.storybook/public/mockServiceWorker.js b/.storybook/public/mockServiceWorker.js new file mode 100644 index 000000000..2162ea0dd --- /dev/null +++ b/.storybook/public/mockServiceWorker.js @@ -0,0 +1,336 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.12.10'; +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +addEventListener('install', function () { + self.skipWaiting(); +}); + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id'); + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now(); + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); +}); + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event); + const requestCloneForEvents = event.request.clone(); + const response = await getResponse(event, client, requestId, requestInterceptedAt); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents); + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ); + } + + return response; +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise<Client | undefined>} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (activeClientIds.has(event.clientId)) { + return client; + } + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise<Response>} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone(); + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers); + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept'); + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()); + const filteredValues = values.filter((value) => value !== 'msw/passthrough'); + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')); + } else { + headers.delete('accept'); + } + } + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array<Transferable>} transferrables + * @returns {Promise<any>} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); + }); +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + }; +} diff --git a/apps/api-harmonization/.env.local b/apps/api-harmonization/.env.local index f6f552b39..8671e0b66 100644 --- a/apps/api-harmonization/.env.local +++ b/apps/api-harmonization/.env.local @@ -11,6 +11,7 @@ LOG_FORMAT=text DEFAULT_LOCALE=en DEFAULT_CURRENCY=EUR +DEFAULT_REGION_ID= DEFAULT_PRODUCT_UNIT=PCS SUPPORTED_CURRENCIES=EUR,USD,PLN diff --git a/apps/api-harmonization/CHANGELOG.md b/apps/api-harmonization/CHANGELOG.md index 09e7a1aab..9766514fc 100644 --- a/apps/api-harmonization/CHANGELOG.md +++ b/apps/api-harmonization/CHANGELOG.md @@ -1,5 +1,133 @@ # @o2s/api-harmonization +## 1.18.1 + +### Patch Changes + +- 338cb01: Introduce typed header name constants (`HeaderName`) using `as const` and + replace selected magic header strings in API harmonization and frontend code. + + Update SDK header typing to use `AppHeaders` for stronger request typing. + +- Updated dependencies [fadbc63] +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/blocks.article@1.5.2 + - @o2s/blocks.article-list@1.6.2 + - @o2s/blocks.article-search@1.6.2 + - @o2s/blocks.bento-grid@0.6.2 + - @o2s/blocks.category@1.6.2 + - @o2s/blocks.category-list@1.6.2 + - @o2s/blocks.cta-section@0.6.2 + - @o2s/blocks.document-list@0.6.2 + - @o2s/blocks.faq@1.5.2 + - @o2s/blocks.feature-section@0.6.2 + - @o2s/blocks.feature-section-grid@0.5.2 + - @o2s/blocks.featured-service-list@1.4.2 + - @o2s/blocks.hero-section@0.6.2 + - @o2s/blocks.invoice-list@1.6.2 + - @o2s/blocks.media-section@0.6.2 + - @o2s/blocks.notification-details@1.5.2 + - @o2s/blocks.notification-list@1.6.2 + - @o2s/blocks.notification-summary@1.3.2 + - @o2s/blocks.order-details@1.5.2 + - @o2s/blocks.order-list@1.6.2 + - @o2s/blocks.orders-summary@1.5.2 + - @o2s/blocks.payments-history@1.4.2 + - @o2s/blocks.payments-summary@1.4.2 + - @o2s/blocks.pricing-section@0.6.2 + - @o2s/blocks.product-details@0.3.1 + - @o2s/blocks.product-list@0.5.1 + - @o2s/blocks.quick-links@1.5.2 + - @o2s/blocks.recommended-products@0.3.1 + - @o2s/blocks.service-details@1.4.2 + - @o2s/blocks.service-list@1.5.2 + - @o2s/blocks.surveyjs-form@1.4.2 + - @o2s/blocks.ticket-details@1.5.2 + - @o2s/blocks.ticket-list@1.7.2 + - @o2s/blocks.ticket-recent@1.4.2 + - @o2s/blocks.ticket-summary@1.3.2 + - @o2s/blocks.user-account@1.4.2 + - @o2s/utils.api-harmonization@0.3.3 + - @o2s/modules.surveyjs@0.4.4 + - @o2s/integrations.mocked@1.21.1 + +## 1.18.0 + +### Minor Changes + +- 375cd90: feat(framework, integrations): add variantId to AddCartItemBody and cart item models, add viewCartLabel and cartPath to CMS block models. Implement variantId-based cart operations in Medusa integration. Localize CMS mappers (EN/DE/PL) for Contentful and Strapi. + +### Patch Changes + +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/blocks.article-list@1.6.1 + - @o2s/blocks.article-search@1.6.1 + - @o2s/blocks.article@1.5.1 + - @o2s/blocks.bento-grid@0.6.1 + - @o2s/blocks.category-list@1.6.1 + - @o2s/blocks.category@1.6.1 + - @o2s/blocks.cta-section@0.6.1 + - @o2s/blocks.document-list@0.6.1 + - @o2s/blocks.faq@1.5.1 + - @o2s/blocks.feature-section-grid@0.5.1 + - @o2s/blocks.feature-section@0.6.1 + - @o2s/blocks.featured-service-list@1.4.1 + - @o2s/blocks.hero-section@0.6.1 + - @o2s/blocks.invoice-list@1.6.1 + - @o2s/blocks.media-section@0.6.1 + - @o2s/blocks.notification-details@1.5.1 + - @o2s/blocks.notification-list@1.6.1 + - @o2s/blocks.notification-summary@1.3.1 + - @o2s/blocks.order-details@1.5.1 + - @o2s/blocks.order-list@1.6.1 + - @o2s/blocks.orders-summary@1.5.1 + - @o2s/blocks.payments-history@1.4.1 + - @o2s/blocks.payments-summary@1.4.1 + - @o2s/blocks.pricing-section@0.6.1 + - @o2s/blocks.product-details@0.3.0 + - @o2s/blocks.product-list@0.5.0 + - @o2s/blocks.quick-links@1.5.1 + - @o2s/blocks.recommended-products@0.3.0 + - @o2s/blocks.service-details@1.4.1 + - @o2s/blocks.service-list@1.5.1 + - @o2s/blocks.surveyjs-form@1.4.1 + - @o2s/blocks.ticket-details@1.5.1 + - @o2s/blocks.ticket-list@1.7.1 + - @o2s/blocks.ticket-recent@1.4.1 + - @o2s/blocks.ticket-summary@1.3.1 + - @o2s/blocks.user-account@1.4.1 + - @o2s/framework@1.20.0 + - @o2s/integrations.mocked@1.21.0 + - @o2s/modules.surveyjs@0.4.3 + - @o2s/telemetry@1.2.2 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + +## 1.17.0 + +### Minor Changes + +- 5d36519: Extended framework with e-commerce models: Address (companyName, taxId), Cart, Checkout and Order Confirmation CMS blocks. Added Mocked and Medusa integration support for cart, checkout flow, and guest order retrieval. +- 0e61431: feat: update page model and integration to support redirects + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] + - @o2s/framework@1.19.0 + - @o2s/integrations.mocked@1.20.0 + - @o2s/utils.frontend@0.5.0 + ## 1.16.0 ### Minor Changes diff --git a/apps/api-harmonization/package.json b/apps/api-harmonization/package.json index d9886c6b4..683ff40be 100644 --- a/apps/api-harmonization/package.json +++ b/apps/api-harmonization/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/api-harmonization", - "version": "1.16.0", + "version": "1.18.1", "description": "", "author": "", "private": true, @@ -76,7 +76,7 @@ "compression": "^1.8.1", "cookie": "^1.1.1", "cookie-parser": "^1.4.7", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "jwt-decode": "^4.0.0", @@ -111,7 +111,7 @@ "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", "cross-env": "^10.1.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", "jest": "^30.2.0", diff --git a/apps/api-harmonization/src/app.module.ts b/apps/api-harmonization/src/app.module.ts index 744ba66a3..1ac3d0221 100644 --- a/apps/api-harmonization/src/app.module.ts +++ b/apps/api-harmonization/src/app.module.ts @@ -32,8 +32,13 @@ import * as ArticleList from '@o2s/blocks.article-list/api-harmonization'; import * as ArticleSearch from '@o2s/blocks.article-search/api-harmonization'; import * as Article from '@o2s/blocks.article/api-harmonization'; import * as BentoGrid from '@o2s/blocks.bento-grid/api-harmonization'; +import * as Cart from '@o2s/blocks.cart/api-harmonization'; import * as CategoryList from '@o2s/blocks.category-list/api-harmonization'; import * as Category from '@o2s/blocks.category/api-harmonization'; +import * as CheckoutBillingPayment from '@o2s/blocks.checkout-billing-payment/api-harmonization'; +import * as CheckoutCompanyData from '@o2s/blocks.checkout-company-data/api-harmonization'; +import * as CheckoutShippingAddress from '@o2s/blocks.checkout-shipping-address/api-harmonization'; +import * as CheckoutSummary from '@o2s/blocks.checkout-summary/api-harmonization'; import * as CtaSection from '@o2s/blocks.cta-section/api-harmonization'; import * as Faq from '@o2s/blocks.faq/api-harmonization'; import * as FeatureSectionGrid from '@o2s/blocks.feature-section-grid/api-harmonization'; @@ -45,6 +50,7 @@ import * as MediaSection from '@o2s/blocks.media-section/api-harmonization'; import * as NotificationDetails from '@o2s/blocks.notification-details/api-harmonization'; import * as NotificationList from '@o2s/blocks.notification-list/api-harmonization'; import * as NotificationSummary from '@o2s/blocks.notification-summary/api-harmonization'; +import * as OrderConfirmation from '@o2s/blocks.order-confirmation/api-harmonization'; import * as OrderDetails from '@o2s/blocks.order-details/api-harmonization'; import * as OrderList from '@o2s/blocks.order-list/api-harmonization'; import * as OrdersSummary from '@o2s/blocks.orders-summary/api-harmonization'; @@ -162,6 +168,12 @@ export const AuthModuleBaseModule = AuthModule.Module.register(AppConfig); TicketSummary.Module.register(AppConfig), ProductDetails.Module.register(AppConfig), RecommendedProducts.Module.register(AppConfig), + OrderConfirmation.Module.register(AppConfig), + CheckoutBillingPayment.Module.register(AppConfig), + CheckoutCompanyData.Module.register(AppConfig), + CheckoutShippingAddress.Module.register(AppConfig), + CheckoutSummary.Module.register(AppConfig), + Cart.Module.register(AppConfig), HeroSection.Module.register(AppConfig), BentoGrid.Module.register(AppConfig), FeatureSection.Module.register(AppConfig), diff --git a/apps/api-harmonization/src/main.ts b/apps/api-harmonization/src/main.ts index 6ac40a495..7cd8fc2f1 100644 --- a/apps/api-harmonization/src/main.ts +++ b/apps/api-harmonization/src/main.ts @@ -9,9 +9,13 @@ import process from 'node:process'; import { LoggerService } from '@o2s/utils.logger'; +import { HeaderName } from '@o2s/framework/headers'; + import { AppConfig } from './app.config'; import { AppModule } from './app.module'; +const H = HeaderName; + async function bootstrap() { const logLevel = (process.env.LOG_LEVEL === 'info' ? 'log' : process.env.LOG_LEVEL) as LogLevel; const logLevels = [logLevel]; @@ -40,9 +44,9 @@ async function bootstrap() { 'Cache-Control', 'Pragma', 'Expires', - 'x-locale', - 'x-currency', - 'x-client-timezone', + H.Locale, + H.Currency, + H.ClientTimezone, ], methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], }); diff --git a/apps/api-harmonization/src/middleware/context-headers.middleware.ts b/apps/api-harmonization/src/middleware/context-headers.middleware.ts index 4a79d3d9b..4ac4df996 100644 --- a/apps/api-harmonization/src/middleware/context-headers.middleware.ts +++ b/apps/api-harmonization/src/middleware/context-headers.middleware.ts @@ -2,6 +2,10 @@ import { Injectable, NestMiddleware } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NextFunction, Request, Response } from 'express'; +import { HeaderName } from '@o2s/framework/headers'; + +const H = HeaderName; + @Injectable() export class ContextHeadersMiddleware implements NestMiddleware { private readonly defaultCurrency: string; @@ -26,8 +30,8 @@ export class ContextHeadersMiddleware implements NestMiddleware { } use(req: Request, res: Response, next: NextFunction) { - const currency = (req.headers['x-currency'] as string) || this.defaultCurrency; - const locale = (req.headers['x-locale'] as string) || this.defaultLocale; + const currency = (req.headers[H.Currency] as string) || this.defaultCurrency; + const locale = (req.headers[H.Locale] as string) || this.defaultLocale; if (!this.isValidCurrency(currency)) { return res.status(400).json({ @@ -43,9 +47,9 @@ export class ContextHeadersMiddleware implements NestMiddleware { }); } - res.setHeader('Access-Control-Expose-Headers', 'x-currency, x-locale'); - res.setHeader('x-currency', currency); - res.setHeader('x-locale', locale); + res.setHeader('Access-Control-Expose-Headers', `${H.Currency}, ${H.Locale}`); + res.setHeader(H.Currency, currency); + res.setHeader(H.Locale, locale); next(); } diff --git a/apps/api-harmonization/src/modules/login-page/login-page.controller.ts b/apps/api-harmonization/src/modules/login-page/login-page.controller.ts index 7d89f4528..65002c1c2 100644 --- a/apps/api-harmonization/src/modules/login-page/login-page.controller.ts +++ b/apps/api-harmonization/src/modules/login-page/login-page.controller.ts @@ -1,7 +1,7 @@ import { URL } from '.'; import { Controller, Get, Headers } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders } from '@o2s/framework/headers'; import { LoginPageService } from './login-page.service'; @@ -10,7 +10,7 @@ export class LoginPageController { constructor(protected readonly service: LoginPageService) {} @Get() - getLoginPage(@Headers() headers: Models.Headers.AppHeaders) { + getLoginPage(@Headers() headers: AppHeaders) { return this.service.getLoginPage(headers); } } diff --git a/apps/api-harmonization/src/modules/login-page/login-page.service.ts b/apps/api-harmonization/src/modules/login-page/login-page.service.ts index 1d4508399..21c206d62 100644 --- a/apps/api-harmonization/src/modules/login-page/login-page.service.ts +++ b/apps/api-harmonization/src/modules/login-page/login-page.service.ts @@ -2,17 +2,19 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapLoginPage } from './login-page.mapper'; import { LoginPage } from './login-page.model'; +const H = HeaderName; + @Injectable() export class LoginPageService { constructor(private readonly cmsService: CMS.Service) {} - getLoginPage(headers: Models.Headers.AppHeaders): Observable<LoginPage> { - const loginPage = this.cmsService.getLoginPage({ locale: headers['x-locale'] }); + getLoginPage(headers: AppHeaders): Observable<LoginPage> { + const loginPage = this.cmsService.getLoginPage({ locale: headers[H.Locale] }); return forkJoin([loginPage]).pipe( map(([loginPage]) => { diff --git a/apps/api-harmonization/src/modules/not-found-page/not-found-page.controller.ts b/apps/api-harmonization/src/modules/not-found-page/not-found-page.controller.ts index d310eafca..ee175cacc 100644 --- a/apps/api-harmonization/src/modules/not-found-page/not-found-page.controller.ts +++ b/apps/api-harmonization/src/modules/not-found-page/not-found-page.controller.ts @@ -1,7 +1,7 @@ import { URL } from '.'; import { Controller, Get, Headers } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders } from '@o2s/framework/headers'; import { NotFoundPageService } from './not-found-page.service'; @@ -10,7 +10,7 @@ export class NotFoundPageController { constructor(protected readonly service: NotFoundPageService) {} @Get() - getNotFoundPage(@Headers() headers: Models.Headers.AppHeaders) { + getNotFoundPage(@Headers() headers: AppHeaders) { return this.service.getNotFoundPage(headers); } } diff --git a/apps/api-harmonization/src/modules/not-found-page/not-found-page.service.ts b/apps/api-harmonization/src/modules/not-found-page/not-found-page.service.ts index 6a77dbc90..845d846cb 100644 --- a/apps/api-harmonization/src/modules/not-found-page/not-found-page.service.ts +++ b/apps/api-harmonization/src/modules/not-found-page/not-found-page.service.ts @@ -2,17 +2,19 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapNotFoundPage } from './not-found-page.mapper'; import { NotFoundPage } from './not-found-page.model'; +const H = HeaderName; + @Injectable() export class NotFoundPageService { constructor(private readonly cmsService: CMS.Service) {} - getNotFoundPage(headers: Models.Headers.AppHeaders): Observable<NotFoundPage> { - return this.cmsService.getNotFoundPage({ locale: headers['x-locale'] }).pipe( + getNotFoundPage(headers: AppHeaders): Observable<NotFoundPage> { + return this.cmsService.getNotFoundPage({ locale: headers[H.Locale] }).pipe( map((notFoundPage) => { if (!notFoundPage) { throw new NotFoundException(); diff --git a/apps/api-harmonization/src/modules/organizations/organizations.controller.ts b/apps/api-harmonization/src/modules/organizations/organizations.controller.ts index 9ddcbe14b..e5a9ff641 100644 --- a/apps/api-harmonization/src/modules/organizations/organizations.controller.ts +++ b/apps/api-harmonization/src/modules/organizations/organizations.controller.ts @@ -1,9 +1,9 @@ import { URL } from '.'; import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { GetCustomersQuery } from './organizations.request'; @@ -16,7 +16,7 @@ export class OrganizationsController { @Get() @Auth.Decorators.Permissions({ resource: 'organizations', actions: ['view'] }) - getCustomers(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetCustomersQuery) { + getCustomers(@Headers() headers: AppHeaders, @Query() query: GetCustomersQuery) { return this.service.getCustomers(query, headers); } } diff --git a/apps/api-harmonization/src/modules/organizations/organizations.model.ts b/apps/api-harmonization/src/modules/organizations/organizations.model.ts index f2e3f7788..9149783a4 100644 --- a/apps/api-harmonization/src/modules/organizations/organizations.model.ts +++ b/apps/api-harmonization/src/modules/organizations/organizations.model.ts @@ -1,8 +1,6 @@ -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; - import { Models } from '@o2s/framework/modules'; -export class CustomerList extends ApiModels.Block.Block { +export class CustomerList extends Models.Block.Block { title?: string; description?: string; items!: Models.Customer.Customer[]; diff --git a/apps/api-harmonization/src/modules/organizations/organizations.service.ts b/apps/api-harmonization/src/modules/organizations/organizations.service.ts index 032d16ddf..454e9d805 100644 --- a/apps/api-harmonization/src/modules/organizations/organizations.service.ts +++ b/apps/api-harmonization/src/modules/organizations/organizations.service.ts @@ -2,12 +2,14 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { CMS, Organizations } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapCustomerList } from './organizations.mapper'; import { CustomerList } from './organizations.model'; import { GetCustomersQuery } from './organizations.request'; +const H = HeaderName; + @Injectable() export class OrganizationsService { constructor( @@ -15,8 +17,8 @@ export class OrganizationsService { private readonly organizationsService: Organizations.Service, ) {} - getCustomers(query: GetCustomersQuery, headers: Models.Headers.AppHeaders): Observable<CustomerList> { - const cms = this.cmsService.getOrganizationList({ locale: headers['x-locale'] }); + getCustomers(query: GetCustomersQuery, headers: AppHeaders): Observable<CustomerList> { + const cms = this.cmsService.getOrganizationList({ locale: headers[H.Locale] }); // Pass authorization token to filter organizations by current user const organizations = this.organizationsService.getOrganizationList( { @@ -24,7 +26,7 @@ export class OrganizationsService { limit: query.limit || 1000, offset: query.offset || 0, }, - headers.authorization, + headers[H.Authorization], ); return forkJoin([organizations, cms]).pipe( @@ -33,7 +35,7 @@ export class OrganizationsService { throw new NotFoundException(); } - return mapCustomerList(organizations, cms, headers['x-locale']); + return mapCustomerList(organizations, cms, headers[H.Locale]); }), ); } diff --git a/apps/api-harmonization/src/modules/page/page.controller.ts b/apps/api-harmonization/src/modules/page/page.controller.ts index bcd700dda..53748bbe3 100644 --- a/apps/api-harmonization/src/modules/page/page.controller.ts +++ b/apps/api-harmonization/src/modules/page/page.controller.ts @@ -1,8 +1,9 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; + import { URL } from './'; import { GetInitQuery, GetPageQuery } from './page.request'; import { PageService } from './page.service'; @@ -13,12 +14,12 @@ export class PageController { constructor(protected readonly service: PageService) {} @Get('/init') - getInit(@Query() query: GetInitQuery, @Headers() headers: Models.Headers.AppHeaders) { + getInit(@Query() query: GetInitQuery, @Headers() headers: AppHeaders) { return this.service.getInit(query, headers); } @Get() - getPage(@Query() query: GetPageQuery, @Headers() headers: Models.Headers.AppHeaders) { + getPage(@Query() query: GetPageQuery, @Headers() headers: AppHeaders) { return this.service.getPage(query, headers); } } diff --git a/apps/api-harmonization/src/modules/page/page.mapper.ts b/apps/api-harmonization/src/modules/page/page.mapper.ts index 7158a31ad..25f44ddad 100644 --- a/apps/api-harmonization/src/modules/page/page.mapper.ts +++ b/apps/api-harmonization/src/modules/page/page.mapper.ts @@ -31,6 +31,7 @@ export const mapPage = ( }, locales, theme: page.theme, + redirect: page.redirect, }, data: { alternativeUrls, diff --git a/apps/api-harmonization/src/modules/page/page.model.ts b/apps/api-harmonization/src/modules/page/page.model.ts index a7be6f31d..90d0735bc 100644 --- a/apps/api-harmonization/src/modules/page/page.model.ts +++ b/apps/api-harmonization/src/modules/page/page.model.ts @@ -4,8 +4,13 @@ import * as ArticleList from '@o2s/blocks.article-list/api-harmonization'; import * as ArticleSearch from '@o2s/blocks.article-search/api-harmonization'; import * as Article from '@o2s/blocks.article/api-harmonization'; import * as BentoGrid from '@o2s/blocks.bento-grid/api-harmonization'; +import * as Cart from '@o2s/blocks.cart/api-harmonization'; import * as CategoryList from '@o2s/blocks.category-list/api-harmonization'; import * as Category from '@o2s/blocks.category/api-harmonization'; +import * as CheckoutBillingPayment from '@o2s/blocks.checkout-billing-payment/api-harmonization'; +import * as CheckoutCompanyData from '@o2s/blocks.checkout-company-data/api-harmonization'; +import * as CheckoutShippingAddress from '@o2s/blocks.checkout-shipping-address/api-harmonization'; +import * as CheckoutSummary from '@o2s/blocks.checkout-summary/api-harmonization'; import * as CtaSection from '@o2s/blocks.cta-section/api-harmonization'; import * as Faq from '@o2s/blocks.faq/api-harmonization'; import * as FeatureSectionGrid from '@o2s/blocks.feature-section-grid/api-harmonization'; @@ -17,6 +22,7 @@ import * as MediaSection from '@o2s/blocks.media-section/api-harmonization'; import * as NotificationDetails from '@o2s/blocks.notification-details/api-harmonization'; import * as NotificationList from '@o2s/blocks.notification-list/api-harmonization'; import * as NotificationSummary from '@o2s/blocks.notification-summary/api-harmonization'; +import * as OrderConfirmation from '@o2s/blocks.order-confirmation/api-harmonization'; import * as OrderDetails from '@o2s/blocks.order-details/api-harmonization'; import * as OrderList from '@o2s/blocks.order-list/api-harmonization'; import * as OrdersSummary from '@o2s/blocks.orders-summary/api-harmonization'; @@ -64,6 +70,7 @@ export class Metadata { seo!: Models.SEO.Page; locales!: string[]; theme?: string; + redirect?: string; } export class Breadcrumb { @@ -87,6 +94,12 @@ export class PageData { export type Blocks = // BLOCK REGISTER + | Cart.Model.CartBlock['__typename'] + | CheckoutSummary.Model.CheckoutSummaryBlock['__typename'] + | CheckoutShippingAddress.Model.CheckoutShippingAddressBlock['__typename'] + | CheckoutCompanyData.Model.CheckoutCompanyDataBlock['__typename'] + | CheckoutBillingPayment.Model.CheckoutBillingPaymentBlock['__typename'] + | OrderConfirmation.Model.OrderConfirmationBlock['__typename'] | RecommendedProducts.Model.RecommendedProductsBlock['__typename'] | ProductDetails.Model.ProductDetailsBlock['__typename'] | ProductList.Model.ProductListBlock['__typename'] diff --git a/apps/api-harmonization/src/modules/page/page.service.ts b/apps/api-harmonization/src/modules/page/page.service.ts index 07bf80016..d78e247f0 100644 --- a/apps/api-harmonization/src/modules/page/page.service.ts +++ b/apps/api-harmonization/src/modules/page/page.service.ts @@ -3,12 +3,14 @@ import { ConfigService } from '@nestjs/config'; import { Articles, Auth, CMS } from '@o2s/configs.integrations'; import { Observable, concatMap, forkJoin, map, of, switchMap } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapArticle, mapInit, mapPage } from './page.mapper'; import { Init, NotFound, Page } from './page.model'; import { GetInitQuery, GetPageQuery } from './page.request'; +const H = HeaderName; + @Injectable() export class PageService { private readonly SUPPORTED_LOCALES: string[]; @@ -44,19 +46,19 @@ export class PageService { ); } - getInit(query: GetInitQuery, headers: Models.Headers.AppHeaders): Observable<Init> { - const userRoles = this.authService.getRoles(headers['authorization']); + getInit(query: GetInitQuery, headers: AppHeaders): Observable<Init> { + const userRoles = this.authService.getRoles(headers[H.Authorization]); - return this.cmsService.getAppConfig({ referrer: query.referrer, locale: headers['x-locale'] }).pipe( + return this.cmsService.getAppConfig({ referrer: query.referrer, locale: headers[H.Locale] }).pipe( switchMap((appConfig) => { const header = this.cmsService.getHeader({ id: appConfig.header || '', - locale: headers['x-locale'], + locale: headers[H.Locale], }); const footer = this.cmsService.getFooter({ id: appConfig.footer || '', - locale: headers['x-locale'], + locale: headers[H.Locale], }); return forkJoin([header, footer]).pipe( @@ -75,14 +77,14 @@ export class PageService { ); } - getPage(query: GetPageQuery, headers: Models.Headers.AppHeaders): Observable<Page | NotFound> { - const page = this.cmsService.getPage({ slug: query.slug, locale: headers['x-locale'] }); - const userRoles = this.authService.getRoles(headers['authorization']); + getPage(query: GetPageQuery, headers: AppHeaders): Observable<Page | NotFound> { + const page = this.cmsService.getPage({ slug: query.slug, locale: headers[H.Locale] }); + const userRoles = this.authService.getRoles(headers[H.Authorization]); return forkJoin([page]).pipe( concatMap(([page]) => { if (!page) { - return this.articlesService.getArticle({ slug: query.slug, locale: headers['x-locale'] }).pipe( + return this.articlesService.getArticle({ slug: query.slug, locale: headers[H.Locale] }).pipe( concatMap((article) => { if (!article) { throw new NotFoundException(); @@ -102,29 +104,25 @@ export class PageService { ); } - private processPage = (page: CMS.Model.Page.Page, query: GetPageQuery, headers: Models.Headers.AppHeaders) => { + private processPage = (page: CMS.Model.Page.Page, query: GetPageQuery, headers: AppHeaders) => { const alternatePages = this.cmsService - .getAlternativePages({ id: page.id, slug: query.slug, locale: headers['x-locale'] }) + .getAlternativePages({ id: page.id, slug: query.slug, locale: headers[H.Locale] }) .pipe(map((pages) => pages.filter((p) => p.id === page.id))); return forkJoin([of(page), alternatePages]).pipe( map(([page, alternatePages]) => { const alternates = alternatePages?.filter((p) => p?.id === page.id); - return mapPage(page, headers['x-locale'], alternates); + return mapPage(page, headers[H.Locale], alternates); }), ); }; - private processArticle = ( - article: Articles.Model.Article, - query: GetPageQuery, - headers: Models.Headers.AppHeaders, - ) => { + private processArticle = (article: Articles.Model.Article, query: GetPageQuery, headers: AppHeaders) => { if (!article.category) { throw new NotFoundException(); } - const category = this.articlesService.getCategory({ id: article.category.id, locale: headers['x-locale'] }); + const category = this.articlesService.getCategory({ id: article.category.id, locale: headers[H.Locale] }); return forkJoin([category]).pipe( map(([category]) => { @@ -133,7 +131,7 @@ export class PageService { // Remove article slug (last segment) and category slug (second to last) const basePath = slugParts.length > 2 ? '/' + slugParts.slice(0, -2).join('/') : '/'; - return mapArticle(article, category, headers['x-locale'], basePath); + return mapArticle(article, category, headers[H.Locale], basePath); }), ); }; diff --git a/apps/api-harmonization/turbo.json b/apps/api-harmonization/turbo.json index 8e30f48ed..9310d3aba 100644 --- a/apps/api-harmonization/turbo.json +++ b/apps/api-harmonization/turbo.json @@ -13,6 +13,7 @@ "LOG_FORMAT", "DEFAULT_LOCALE", "DEFAULT_CURRENCY", + "DEFAULT_REGION_ID", "DEFAULT_PRODUCT_UNIT", "SUPPORTED_CURRENCIES", "SUPPORTED_LOCALES", diff --git a/apps/docs/CHANGELOG.md b/apps/docs/CHANGELOG.md index 886c16ec7..8f2f0172b 100644 --- a/apps/docs/CHANGELOG.md +++ b/apps/docs/CHANGELOG.md @@ -1,5 +1,31 @@ # @o2s/docs +## 2.2.1 + +### Patch Changes + +- 338cb01: Update the CMS integration extension guide to use a helper-based model extension pattern instead of manual model re-exports. +- 338cb01: Fix telemetry documentation wording to reference O2S instead of Medusa. +- 338cb01: Fix outdated `@dxp` namespace examples in docs to match the current `@o2s` package naming used across the project. +- 338cb01: Fix inconsistent integration installation commands in docs by standardizing + integration package installation to `@o2s/configs.integrations`. + + Add a central integrations installation table that explains expected commands. + +## 2.2.0 + +### Minor Changes + +- 375cd90: docs: add variantId parameter to cart API documentation, update Medusa integration docs with variantId requirement +- a2d9ea4: docs: enhance product starters section with CLI scaffolding link for custom frontend setup + +## 2.1.0 + +### Minor Changes + +- a1659cf: feat: update HomepageStartersSection with new CLI features and improved styling +- 5d36519: Updated documentation with e-commerce blocks and checkout flow information. + ## 2.0.0 ### Major Changes diff --git a/apps/docs/blog/articles/building-composable-frontends-with-strapi-and-nextjs/index.md b/apps/docs/blog/articles/building-composable-frontends-with-strapi-and-nextjs/index.md index 385dfceb1..9e65aba65 100644 --- a/apps/docs/blog/articles/building-composable-frontends-with-strapi-and-nextjs/index.md +++ b/apps/docs/blog/articles/building-composable-frontends-with-strapi-and-nextjs/index.md @@ -345,8 +345,10 @@ There are too many queries to list here, but you can always take a look at [our Data fetching happens through NestJS, where we've introduced a dedicated service for CMS content that exposes methods for each CMS block: ```typescript -export class CmsService implements CMS.Service { - constructor(private readonly graphqlService: GraphqlService) {} +export class CmsService extends CMS.Service { + constructor(private readonly graphqlService: GraphqlService) { + super(); + } private getBlock = (options: CMS.Request.GetCmsEntryParams) => { const component = this.graphqlService.getComponent({ diff --git a/apps/docs/blog/releases/dxp/1.0.0.md b/apps/docs/blog/releases/dxp/1.0.0.md index a476109b3..8a3d9ebb4 100644 --- a/apps/docs/blog/releases/dxp/1.0.0.md +++ b/apps/docs/blog/releases/dxp/1.0.0.md @@ -52,7 +52,7 @@ Take a look at our [**DXP Frontend Starter chapter**](../../../docs/app-starters To get started, you can use our NPM script to create a new project: ```shell -npx create-o2s-app@latest my-dxp-portal --template dxp +npx create-o2s-app my-dxp-portal --template dxp ``` which, just like in O2S, will create a pre-configured template with all the necessary components, integrations, and best practices to accelerate your development and is ready for your own customizations. diff --git a/apps/docs/docs/app-starters/dxp/overview.md b/apps/docs/docs/app-starters/dxp/overview.md index 928b5ae13..05dde4a63 100644 --- a/apps/docs/docs/app-starters/dxp/overview.md +++ b/apps/docs/docs/app-starters/dxp/overview.md @@ -13,7 +13,7 @@ Its features can be extended with Blocks or integrations O2S provides or your ow To create a new DXP application: ```shell -npx create-o2s-app@latest my-dxp-portal --template dxp +npx create-o2s-app my-dxp-portal --template dxp ``` This command automatically scaffolds the DXP starter with its default blocks and integrations — no interactive prompts needed. diff --git a/apps/docs/docs/app-starters/o2s/blocks.md b/apps/docs/docs/app-starters/o2s/blocks.md index e9cabeddd..65a3af33e 100644 --- a/apps/docs/docs/app-starters/o2s/blocks.md +++ b/apps/docs/docs/app-starters/o2s/blocks.md @@ -16,7 +16,10 @@ and more complex ones that integrate several different data sources, like: - user invoices, orders and notifications, - payment history and summary, -- tickets and ticket submissions. +- tickets and ticket submissions, +- shopping cart management, +- checkout flow (company data, shipping address, billing & payment, order summary), +- order confirmation. :::tip For a full list of available blocks, together with their configuration, variants and usage examples, see [our Storybook](https://openselfservice-storybook.vercel.app). diff --git a/apps/docs/docs/app-starters/o2s/overview.md b/apps/docs/docs/app-starters/o2s/overview.md index 3cb6b8dfc..6f9f06794 100644 --- a/apps/docs/docs/app-starters/o2s/overview.md +++ b/apps/docs/docs/app-starters/o2s/overview.md @@ -11,7 +11,7 @@ The O2S Customer Portal starter is our default template for building customer se To create a new customer portal application: ```shell -npx create-o2s-app@latest my-customer-portal --template o2s +npx create-o2s-app my-customer-portal --template o2s ``` This command automatically scaffolds the O2S Customer Portal with its default blocks and integrations — no interactive prompts needed. diff --git a/apps/docs/docs/getting-started/installation.md b/apps/docs/docs/getting-started/installation.md index 5d52c5719..f19e56ba1 100644 --- a/apps/docs/docs/getting-started/installation.md +++ b/apps/docs/docs/getting-started/installation.md @@ -21,9 +21,9 @@ The CLI will prompt you to choose between `o2s`, `dxp` starters or a `fully cust You can also specify a template directly: ```shell -npx create-o2s-app@latest my-project --template o2s -npx create-o2s-app@latest my-project --template dxp -npx create-o2s-app@latest my-project --template custom +npx create-o2s-app my-project --template o2s +npx create-o2s-app my-project --template dxp +npx create-o2s-app my-project --template custom ``` The `custom` template launches an **interactive CLI wizard** that lets you pick blocks, integrations, and configure environment variables step by step. Use it when neither the `o2s` nor `dxp` preset fully matches your use case — for example, when you only need a subset of blocks, want to swap integrations, or are building something custom. You start from a blank slate and hand-pick exactly what goes into your project. @@ -40,7 +40,7 @@ The `custom` template launches an **interactive CLI wizard** that lets you pick **Non-interactive example:** ```shell -npx create-o2s-app@latest my-portal \ +npx create-o2s-app my-portal \ --template o2s \ --blocks ticket-list,invoice-list \ --integrations zendesk,strapi-cms \ diff --git a/apps/docs/docs/getting-started/telemetry.md b/apps/docs/docs/getting-started/telemetry.md index 1af653539..25b94e85e 100644 --- a/apps/docs/docs/getting-started/telemetry.md +++ b/apps/docs/docs/getting-started/telemetry.md @@ -8,7 +8,7 @@ O2S collects completely anonymous telemetry data about general usage. Participat ## What data is collected -The following data is being collected on your Medusa application: +The following data is being collected in your O2S application: - unique machine ID, - information about the operating system and CI system that the application is running on, diff --git a/apps/docs/docs/guides/create-new-block/integrations.md b/apps/docs/docs/guides/create-new-block/integrations.md index b29da124c..ebd405029 100644 --- a/apps/docs/docs/guides/create-new-block/integrations.md +++ b/apps/docs/docs/guides/create-new-block/integrations.md @@ -48,31 +48,40 @@ export class TicketsSummaryBlock extends Models.Block.Block { } ``` -We also need to re-export the original models from the base module from the framework. Let's open the `./src/modules/cms/cms.model.ts` and, in addition to the models related to the new block, we need to also add all the others: +We also need to keep all original models from the base CMS module from the framework and add only the new one. Instead of manually re-exporting every model, we can create a tiny helper and merge the model object in one place. + +Let's create `./src/modules/cms/extend-cms-model.ts`: ```typescript import { CMS } from '@o2s/framework/modules'; -export * as TicketsSummaryBlock from './models/block/tickets-summary.model'; - -export import Page = CMS.Model.Page; -export import LoginPage = CMS.Model.LoginPage; -export import Footer = CMS.Model.Footer; -export import Header = CMS.Model.Header; -export import FaqBlock = CMS.Model.FaqBlock; -export import TicketListBlock = CMS.Model.TicketListBlock; -export import TicketDetailsBlock = CMS.Model.TicketDetailsBlock; -export import NotificationListBlock = CMS.Model.NotificationListBlock; -export import NotificationDetailsBlock = CMS.Model.NotificationDetailsBlock; -export import InvoiceListBlock = CMS.Model.InvoiceListBlock; -export import PaymentsSummaryBlock = CMS.Model.PaymentsSummaryBlock; -export import PaymentsHistoryBlock = CMS.Model.PaymentsHistoryBlock; -export import ArticleListBlock = CMS.Model.ArticleListBlock; -export import ArticleDetailsBlock = CMS.Model.ArticleDetailsBlock; +type CmsModel = typeof CMS.Model; + +export const extendCmsModel = <T extends Record<string, unknown>>(extensions: T): CmsModel & T => { + return { + ...CMS.Model, + ...extensions, + }; +}; +``` + +Now let's open the `./src/modules/cms/cms.model.ts`: + +```typescript +import { CMS } from '@o2s/framework/modules'; + +import { extendCmsModel } from './extend-cms-model'; +import * as TicketsSummaryBlock from './models/blocks/tickets-summary.model'; + +export const Model = extendCmsModel({ + TicketsSummaryBlock, +}); + +export { TicketsSummaryBlock }; ``` :::info -We realize that having to explicitly re-export every model might be very cumbersome, especially for more complex modules like the CMS. We plan to improve this behaviour to make it more developer-friendly. +This removes boilerplate and makes adding another custom model a one-line change in `extendCmsModel(...)`. ::: The last step is to edit the main entrypoint of this package `./src/modules/cms/index.ts` and export the necessary service, model and request: @@ -84,7 +93,7 @@ export { CmsService as Service } from './cms.service'; export import Request = CMS.Request; -export * as Model from './cms.model'; +export { Model } from './cms.model'; ``` ## Add method to service diff --git a/apps/docs/docs/guides/deployment/docker.md b/apps/docs/docs/guides/deployment/docker.md index 14e1914f2..fbaf75a17 100644 --- a/apps/docs/docs/guides/deployment/docker.md +++ b/apps/docs/docs/guides/deployment/docker.md @@ -80,7 +80,7 @@ docker run --rm -p 3001:3001 --network app_network \ o2s-api ``` -**Note:** The Dockerfiles use Turborepo's `turbo prune` with `@dxp/frontend` and `@dxp/api-harmonization` package names internally. This is handled automatically by the build process. +**Note:** The Dockerfiles use Turborepo's `turbo prune` with `@o2s/frontend` and `@o2s/api-harmonization` package names internally. This is handled automatically by the build process. ## Example Dockerfiles diff --git a/apps/docs/docs/guides/integrations/adding-new-integrations.md b/apps/docs/docs/guides/integrations/adding-new-integrations.md index 1b5df3fb0..bd6102a60 100644 --- a/apps/docs/docs/guides/integrations/adding-new-integrations.md +++ b/apps/docs/docs/guides/integrations/adding-new-integrations.md @@ -55,10 +55,10 @@ export * as Tickets from './tickets'; Within each module you need to create at least three files: -1. A service that implements all required methods, where you will place your logic related to API communication: +1. A service that extends the module base service and implements all required methods, where you will place your logic related to API communication: ```typescript title="./src/modules/notifications/notifications.service.ts" @Injectable() - export class NotificationsService implements Notifications.Service { + export class NotificationsService extends Notifications.Service { getNotification (options: Notifications.Request.GetNotificationParams) { ... } @@ -106,10 +106,12 @@ You can achieve that by following a few steps: import { CMS, Cache } from '@o2s/framework/modules'; @Injectable() - export class CmsService implements CMS.Service { + export class CmsService extends CMS.Service { constructor( private readonly cacheService: Cache.Service, - ) {} + ) { + super(); + } } ``` diff --git a/apps/docs/docs/guides/using-generators.md b/apps/docs/docs/guides/using-generators.md index 0415e7b2f..9df2637ed 100644 --- a/apps/docs/docs/guides/using-generators.md +++ b/apps/docs/docs/guides/using-generators.md @@ -55,9 +55,9 @@ You can also create a whole new integration by using the `integration` generator 1. Ask you which modules you want included in the integration. 2. Ask which project templates should include this integration (`o2s`, `dxp`, or leave empty for custom-only). This sets the `o2sTemplate` field in `package.json`, used by the `create-o2s-app` CLI wizard. 3. Create a new folder in the `packages/integrations` directory. -3. Initialize a new project for this new integration, with all the necessary files (like `package.json`, linter and prettier configs, and so on). -4. For each module you chose, it will create appropriate folder within the `packages/api/integrations/src/module` directory. -5. Inside those folders it will create the necessary files that compose a module: +4. Initialize a new project for this new integration, with all the necessary files (like `package.json`, linter and prettier configs, and so on). +5. For each module you chose, it will create appropriate folder within the `packages/api/integrations/src/module` directory. +6. Inside those folders it will create the necessary files that compose a module: - controller, - service, - mapper. diff --git a/apps/docs/docs/integrations/cache/redis/how-to-setup.md b/apps/docs/docs/integrations/cache/redis/how-to-setup.md index 3006fe236..199ccc09d 100644 --- a/apps/docs/docs/integrations/cache/redis/how-to-setup.md +++ b/apps/docs/docs/integrations/cache/redis/how-to-setup.md @@ -7,7 +7,7 @@ sidebar_position: 150 ## Install ```shell -npm install @o2s/integrations.redis --workspace=@o2s/api +npm install @o2s/integrations.redis --workspace=@o2s/configs.integrations ``` ## Set up Redis instance diff --git a/apps/docs/docs/integrations/cache/redis/overview.md b/apps/docs/docs/integrations/cache/redis/overview.md index 217e1eadd..999f149f0 100644 --- a/apps/docs/docs/integrations/cache/redis/overview.md +++ b/apps/docs/docs/integrations/cache/redis/overview.md @@ -15,7 +15,7 @@ this package provides caching capabilities using [Redis](https://redis.io/). It ## Installation ```shell -npm install @o2s/integrations.redis --workspace=@o2s/api +npm install @o2s/integrations.redis --workspace=@o2s/configs.integrations ``` ## Environment variables diff --git a/apps/docs/docs/integrations/cms/contentful/blocks.md b/apps/docs/docs/integrations/cms/contentful/blocks.md index 65067151b..147815409 100644 --- a/apps/docs/docs/integrations/cms/contentful/blocks.md +++ b/apps/docs/docs/integrations/cms/contentful/blocks.md @@ -14,45 +14,51 @@ This document provides an overview of block implementation status in the Content The following table shows the implementation status of all blocks available in the O2S framework: -| Block | Status | Contentful Type | Notes | -| --------------------- | ------ | ----------------- | ----------------- | -| article-list | ✅ | BlockArticleList | Fully implemented | -| category-list | ✅ | BlockCategoryList | Fully implemented | -| faq | ✅ | BlockFaq | Fully implemented | -| quick-links | ✅ | BlockQuickLinks | Fully implemented | -| ticket-list | ✅ | BlockTicketList | Fully implemented | -| category | ❌ | - | Not implemented | -| article-search | ❌ | - | Not implemented | -| featured-service-list | ❌ | - | Not implemented | -| invoice-details | ❌ | - | Not implemented | -| invoice-list | ❌ | - | Not implemented | -| notification-details | ❌ | - | Not implemented | -| notification-list | ❌ | - | Not implemented | -| order-details | ❌ | - | Not implemented | -| order-list | ❌ | - | Not implemented | -| orders-summary | ❌ | - | Not implemented | -| payments-history | ❌ | - | Not implemented | -| payments-summary | ❌ | - | Not implemented | -| product-details | ❌ | - | Not implemented | -| product-list | ❌ | - | Not implemented | -| recommended-products | ❌ | - | Not implemented | -| service-details | ❌ | - | Not implemented | -| service-list | ❌ | - | Not implemented | -| surveyjs-form | ❌ | - | Not implemented | -| ticket-details | ❌ | - | Not implemented | -| ticket-recent | ❌ | - | Not implemented | -| user-account | ❌ | - | Not implemented | -| article | ❌ | - | Not implemented | -| bento-grid | ❌ | - | Not implemented | -| cta-section | ❌ | - | Not implemented | -| document-list | ❌ | - | Not implemented | -| feature-section | ❌ | - | Not implemented | -| feature-section-grid | ❌ | - | Not implemented | -| hero-section | ❌ | - | Not implemented | -| media-section | ❌ | - | Not implemented | -| notification-summary | ❌ | - | Not implemented | -| pricing-section | ❌ | - | Not implemented | -| ticket-summary | ❌ | - | Not implemented | +| Block | Status | Contentful Type | Notes | +| ------------------------- | ------ | ----------------- | ----------------- | +| article-list | ✅ | BlockArticleList | Fully implemented | +| category-list | ✅ | BlockCategoryList | Fully implemented | +| faq | ✅ | BlockFaq | Fully implemented | +| quick-links | ✅ | BlockQuickLinks | Fully implemented | +| ticket-list | ✅ | BlockTicketList | Fully implemented | +| category | ❌ | - | Not implemented | +| article-search | ❌ | - | Not implemented | +| featured-service-list | ❌ | - | Not implemented | +| invoice-details | ❌ | - | Not implemented | +| invoice-list | ❌ | - | Not implemented | +| notification-details | ❌ | - | Not implemented | +| notification-list | ❌ | - | Not implemented | +| order-details | ❌ | - | Not implemented | +| order-list | ❌ | - | Not implemented | +| orders-summary | ❌ | - | Not implemented | +| payments-history | ❌ | - | Not implemented | +| payments-summary | ❌ | - | Not implemented | +| cart | ❌ | - | Not implemented | +| checkout-billing-payment | ❌ | - | Not implemented | +| checkout-company-data | ❌ | - | Not implemented | +| checkout-shipping-address | ❌ | - | Not implemented | +| checkout-summary | ❌ | - | Not implemented | +| order-confirmation | ❌ | - | Not implemented | +| product-details | ❌ | - | Not implemented | +| product-list | ❌ | - | Not implemented | +| recommended-products | ❌ | - | Not implemented | +| service-details | ❌ | - | Not implemented | +| service-list | ❌ | - | Not implemented | +| surveyjs-form | ❌ | - | Not implemented | +| ticket-details | ❌ | - | Not implemented | +| ticket-recent | ❌ | - | Not implemented | +| user-account | ❌ | - | Not implemented | +| article | ❌ | - | Not implemented | +| bento-grid | ❌ | - | Not implemented | +| cta-section | ❌ | - | Not implemented | +| document-list | ❌ | - | Not implemented | +| feature-section | ❌ | - | Not implemented | +| feature-section-grid | ❌ | - | Not implemented | +| hero-section | ❌ | - | Not implemented | +| media-section | ❌ | - | Not implemented | +| notification-summary | ❌ | - | Not implemented | +| pricing-section | ❌ | - | Not implemented | +| ticket-summary | ❌ | - | Not implemented | ## Mocked blocks @@ -71,6 +77,12 @@ The following blocks return static mock data instead of fetching content from Co - `orders-summary` - Returns mock orders summary - `payments-history` - Returns mock payment history - `payments-summary` - Returns mock payments summary +- `cart` - Returns mock cart block configuration (data comes from carts service) +- `checkout-billing-payment` - Returns mock checkout billing & payment block configuration +- `checkout-company-data` - Returns mock checkout company data block configuration +- `checkout-shipping-address` - Returns mock checkout shipping address block configuration +- `checkout-summary` - Returns mock checkout summary block configuration +- `order-confirmation` - Returns mock order confirmation block configuration - `product-details` - Returns mock product details - `product-list` - Returns mock product list - `recommended-products` - Returns mock recommended products diff --git a/apps/docs/docs/integrations/cms/contentful/how-to-setup.md b/apps/docs/docs/integrations/cms/contentful/how-to-setup.md index 8e0f26b4b..cae146102 100644 --- a/apps/docs/docs/integrations/cms/contentful/how-to-setup.md +++ b/apps/docs/docs/integrations/cms/contentful/how-to-setup.md @@ -13,10 +13,10 @@ The first step is to install the Contentful integration package in your workspac Install the package using npm with the following command: ```shell -npm install @o2s/integrations.contentful-cms --workspace=@o2s/configs.integrations --workspace=@o2s/frontend +npm install @o2s/integrations.contentful-cms --workspace=@o2s/configs.integrations ``` -This command installs the integration package in both the integrations configuration workspace and the frontend workspace, ensuring that all necessary dependencies are available where they're needed. +This command installs the integration package in the integrations config workspace, where integration packages are configured and resolved. ## Configuration diff --git a/apps/docs/docs/integrations/cms/contentful/overview.md b/apps/docs/docs/integrations/cms/contentful/overview.md index 22aa3b76a..cbb855a4f 100644 --- a/apps/docs/docs/integrations/cms/contentful/overview.md +++ b/apps/docs/docs/integrations/cms/contentful/overview.md @@ -20,7 +20,7 @@ this package provides a full integration with [Contentful CMS](https://www.conte First, install the Contentful integration package: ```shell -npm install @o2s/integrations.contentful-cms --workspace=@o2s/configs.integrations --workspace=@o2s/frontend +npm install @o2s/integrations.contentful-cms --workspace=@o2s/configs.integrations ``` ## Environment variables diff --git a/apps/docs/docs/integrations/cms/strapi/blocks.md b/apps/docs/docs/integrations/cms/strapi/blocks.md index 4393ed144..45b7b7710 100644 --- a/apps/docs/docs/integrations/cms/strapi/blocks.md +++ b/apps/docs/docs/integrations/cms/strapi/blocks.md @@ -16,44 +16,50 @@ All blocks in the Strapi integration are fully implemented with: The following table shows the implementation status of all blocks available in the O2S framework: -| Block | Status | Strapi Type | Notes | -| --------------------- | ------ | -------------------------------------- | ----------------- | -| article | ✅ | Article (separate module) | Fully implemented | -| article-list | ✅ | ComponentComponentsArticleList | Fully implemented | -| article-search | ✅ | ComponentComponentsArticleSearch | Fully implemented | -| category | ✅ | ComponentComponentsCategory | Fully implemented | -| category-list | ✅ | ComponentComponentsCategoryList | Fully implemented | -| faq | ✅ | ComponentComponentsFaq | Fully implemented | -| featured-service-list | ✅ | ComponentComponentsFeaturedServiceList | Fully implemented | -| invoice-list | ✅ | ComponentComponentsInvoiceList | Fully implemented | -| notification-list | ✅ | ComponentComponentsNotificationList | Fully implemented | -| order-details | ✅ | ComponentComponentsOrderDetails | Fully implemented | -| order-list | ✅ | ComponentComponentsOrderList | Fully implemented | -| orders-summary | ✅ | ComponentComponentsOrdersSummary | Fully implemented | -| payments-history | ✅ | ComponentComponentsPaymentsHistory | Fully implemented | -| payments-summary | ✅ | ComponentComponentsPaymentsSummary | Fully implemented | -| quick-links | ✅ | ComponentComponentsQuickLinks | Fully implemented | -| service-details | ✅ | ComponentComponentsServiceDetails | Fully implemented | -| service-list | ✅ | ComponentComponentsServiceList | Fully implemented | -| surveyjs-form | ✅ | ComponentComponentsSurveyJS | Fully implemented | -| ticket-details | ✅ | ComponentComponentsTicketDetails | Fully implemented | -| ticket-list | ✅ | ComponentComponentsTicketList | Fully implemented | -| ticket-recent | ✅ | ComponentComponentsTicketRecent | Fully implemented | -| user-account | ✅ | ComponentComponentsUserAccount | Fully implemented | -| product-details | ❌ | - | Not implemented | -| product-list | ❌ | - | Not implemented | -| recommended-products | ❌ | - | Not implemented | -| bento-grid | ❌ | - | Not implemented | -| cta-section | ❌ | - | Not implemented | -| document-list | ❌ | - | Not implemented | -| feature-section | ❌ | - | Not implemented | -| feature-section-grid | ❌ | - | Not implemented | -| hero-section | ❌ | - | Not implemented | -| media-section | ❌ | - | Not implemented | -| notification-summary | ❌ | - | Not implemented | -| notification-details | ❌ | - | Not implemented | -| pricing-section | ❌ | - | Not implemented | -| ticket-summary | ❌ | - | Not implemented | +| Block | Status | Strapi Type | Notes | +| ------------------------- | ------ | -------------------------------------- | ----------------- | +| article | ✅ | Article (separate module) | Fully implemented | +| article-list | ✅ | ComponentComponentsArticleList | Fully implemented | +| article-search | ✅ | ComponentComponentsArticleSearch | Fully implemented | +| category | ✅ | ComponentComponentsCategory | Fully implemented | +| category-list | ✅ | ComponentComponentsCategoryList | Fully implemented | +| faq | ✅ | ComponentComponentsFaq | Fully implemented | +| featured-service-list | ✅ | ComponentComponentsFeaturedServiceList | Fully implemented | +| invoice-list | ✅ | ComponentComponentsInvoiceList | Fully implemented | +| notification-list | ✅ | ComponentComponentsNotificationList | Fully implemented | +| order-details | ✅ | ComponentComponentsOrderDetails | Fully implemented | +| order-list | ✅ | ComponentComponentsOrderList | Fully implemented | +| orders-summary | ✅ | ComponentComponentsOrdersSummary | Fully implemented | +| payments-history | ✅ | ComponentComponentsPaymentsHistory | Fully implemented | +| payments-summary | ✅ | ComponentComponentsPaymentsSummary | Fully implemented | +| quick-links | ✅ | ComponentComponentsQuickLinks | Fully implemented | +| service-details | ✅ | ComponentComponentsServiceDetails | Fully implemented | +| service-list | ✅ | ComponentComponentsServiceList | Fully implemented | +| surveyjs-form | ✅ | ComponentComponentsSurveyJS | Fully implemented | +| ticket-details | ✅ | ComponentComponentsTicketDetails | Fully implemented | +| ticket-list | ✅ | ComponentComponentsTicketList | Fully implemented | +| ticket-recent | ✅ | ComponentComponentsTicketRecent | Fully implemented | +| user-account | ✅ | ComponentComponentsUserAccount | Fully implemented | +| cart | ❌ | - | Not implemented | +| checkout-billing-payment | ❌ | - | Not implemented | +| checkout-company-data | ❌ | - | Not implemented | +| checkout-shipping-address | ❌ | - | Not implemented | +| checkout-summary | ❌ | - | Not implemented | +| order-confirmation | ❌ | - | Not implemented | +| product-details | ❌ | - | Not implemented | +| product-list | ❌ | - | Not implemented | +| recommended-products | ❌ | - | Not implemented | +| bento-grid | ❌ | - | Not implemented | +| cta-section | ❌ | - | Not implemented | +| document-list | ❌ | - | Not implemented | +| feature-section | ❌ | - | Not implemented | +| feature-section-grid | ❌ | - | Not implemented | +| hero-section | ❌ | - | Not implemented | +| media-section | ❌ | - | Not implemented | +| notification-summary | ❌ | - | Not implemented | +| notification-details | ❌ | - | Not implemented | +| pricing-section | ❌ | - | Not implemented | +| ticket-summary | ❌ | - | Not implemented | ## Blocks with mock data diff --git a/apps/docs/docs/integrations/cms/strapi/how-to-setup.md b/apps/docs/docs/integrations/cms/strapi/how-to-setup.md index f3c285516..60232759b 100644 --- a/apps/docs/docs/integrations/cms/strapi/how-to-setup.md +++ b/apps/docs/docs/integrations/cms/strapi/how-to-setup.md @@ -13,10 +13,10 @@ The first step is to install the Strapi integration package in your workspace. T Install the package using npm with the following command: ```shell -npm install @o2s/integrations.strapi-cms --workspace=@o2s/api +npm install @o2s/integrations.strapi-cms --workspace=@o2s/configs.integrations ``` -This command installs the integration package in the API workspace, ensuring that all necessary dependencies are available where they're needed. +This command installs the integration package in the integrations config workspace, where integration packages are configured and resolved. ## Configuration @@ -100,16 +100,49 @@ The content model includes predefined content types that are compatible with the - Template content types for defining page layouts - Block content types for reusable content components -To help you get straight to building, we have prepared an export of both the content model and example content from Strapi. This will significantly simplify the process of setting up your own CMS server by providing you with a predefined structure and sample data to kickstart your project. +To help you get straight to building, we provide **ready-made exports of the content model and example content** in a separate companion repository. -With this export, you receive a ready-to-use content model that mirrors O2S's structure, ensuring everything is organized and optimized from the start. From there, you can fully customize both the content model and data to suit your specific needs. +### About the `openselfservice-resources` repository -To import the content model, go to our resource repository where you can find detailed instructions. Depending on which starter you use, this is one of: +We intentionally keep content model exports and example data **outside of the main `openselfservice` repo**: -- [**Open Self Service** resources](https://github.com/o2sdev/openselfservice-resources/tree/main/packages/cms/strapi/o2s) -- [**DXP Starter Kit** resources](https://github.com/o2sdev/openselfservice-resources/tree/main/packages/cms/strapi/dxp) +- the main repo focuses on **application code, framework and integrations**, +- the [`openselfservice-resources`](https://github.com/o2sdev/openselfservice-resources) repo acts as a base of **resources for external tools** integrated with O2S – for example CMS configuration (content models, plugins, sample content), initial products and assets for commerce engines, or CI/CD templates for deploying O2S. -Follow the instructions in the repository to import the content model into your Strapi instance. This will set up all the necessary content types and their relationships that the integration expects to work with. +This separation keeps the main codebase lightweight while still giving you a convenient, ready-to-use starting point for your own CMS setup. You are free to fork or copy the resources and adapt them to your own project. + +### Choosing the right content model export + +Depending on which starter you use, you should pick one of the following folders in the resources repo: + +- [**Open Self Service** resources](https://github.com/o2sdev/openselfservice-resources/tree/main/packages/cms/strapi/o2s) – default O2S demo / starter setup, +- [**DXP Starter Kit** resources](https://github.com/o2sdev/openselfservice-resources/tree/main/packages/cms/strapi/dxp) – extended DXP starter variant. + +Each folder includes: + +- Strapi export of the **content model** (content types and relations), +- optional **example content** to quickly get a working demo. + +### Step-by-step: importing the content model + +1. **Clone the resources repository (optional but recommended)** + ```bash + git clone https://github.com/o2sdev/openselfservice-resources.git + cd openselfservice-resources + ``` +2. **Locate the Strapi export for your starter** + - for the default O2S setup: `packages/cms/strapi/o2s`, + - for the DXP starter: `packages/cms/strapi/dxp`. +3. **Follow the README in that folder** + The folder contains instructions specific to the given export (e.g. how to use Strapi’s import/export capabilities for content types and data). +4. **Import the content model into your Strapi instance** + After following the export-specific steps, your Strapi instance should have all required content types (pages, templates, blocks) and relationships expected by the O2S integration. +5. **(Optional) Customize the model and content** + Treat the imported model as a starting point. You can adjust fields, add new types or modify sample content to match your use case – just remember that some changes may require updating queries or types on the O2S side. + +We provide **content models and example data**, but we **do not manage the full configuration** of your Strapi server (deployment, auth, plugins, backups, etc.). Those aspects stay in your control. + +Once the content model is imported and your Strapi instance is running, you can proceed to generate TypeScript types. ## Generate TS types diff --git a/apps/docs/docs/integrations/cms/strapi/overview.md b/apps/docs/docs/integrations/cms/strapi/overview.md index 2acfe991b..4e9178a00 100644 --- a/apps/docs/docs/integrations/cms/strapi/overview.md +++ b/apps/docs/docs/integrations/cms/strapi/overview.md @@ -19,7 +19,7 @@ this package provides a full integration with [Strapi CMS](https://strapi.io/), First, install the Strapi integration package: ```shell -npm install @o2s/integrations.strapi-cms --workspace=@o2s/api +npm install @o2s/integrations.strapi-cms --workspace=@o2s/configs.integrations ``` ## Environment variables @@ -34,11 +34,22 @@ You can obtain this value from your Strapi instance settings - it should be the ## Content model import -To start, go to our resource repository where you can find the instructions on how to import the content model into your own Strapi instance: +For Strapi we ship a **predefined content model and sample data** that matches how O2S expects pages, templates and blocks to be structured. +These resources live in a separate companion repository: - [**Open Self Service** resources](https://github.com/o2sdev/openselfservice-resources/tree/main/packages/cms/strapi/o2s) - [**DXP Starter Kit** resources](https://github.com/o2sdev/openselfservice-resources/tree/main/packages/cms/strapi/dxp) +The `openselfservice-resources` repo is intentionally separate from the main `openselfservice` codebase – it only contains exports and example data for external tools (like Strapi), so that your application repository stays focused on framework code and integrations. + +To import the content model into your own Strapi instance: + +1. Choose the folder that matches your starter (`o2s` or `dxp`) in the resources repo. +2. Follow the README in that folder to import the Strapi export (content types and optional data). +3. Start your Strapi instance with the imported model. + +For a detailed, step‑by‑step guide (including rationale, repository layout and troubleshooting), see **[How to set up](./how-to-setup.md)**. + ## Code generation After setting up your Strapi instance and configuring the environment variables, you need to generate TypeScript types from your GraphQL schema. Run: diff --git a/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md b/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md index b28a621ce..b51856250 100644 --- a/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md +++ b/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md @@ -24,9 +24,10 @@ O2S maps `regionId` (in request bodies) to Medusa's `region_id`. You must create Medusa uses **product variants** for cart line items, not products directly. Each variant has its own ID, SKU, price, and options (size, color, etc.). -- **O2S** uses the `sku` field when adding items to cart. -- **Medusa** expects `variant_id` — the integration maps O2S `sku` to Medusa `variant_id`. -- You must use the **variant ID** from the product catalog (e.g., from `GET /products/:id` response), not the product ID. +- **O2S framework** defines `sku` (required) and `variantId` (optional) in `AddCartItemBody`. +- **Medusa integration** requires `variantId` — it maps directly to Medusa's `variant_id`. If `variantId` is not provided, the request will fail with a `BadRequestException`. +- The frontend components resolve `variantId` from the product catalog and pass it alongside `sku` when adding items to cart. +- You can get variant IDs from the product catalog (e.g., from `GET /products/:id` response). ### Currency diff --git a/apps/docs/docs/integrations/commerce/medusa-js/features.md b/apps/docs/docs/integrations/commerce/medusa-js/features.md index 36c0b8ca0..0b10c72e3 100644 --- a/apps/docs/docs/integrations/commerce/medusa-js/features.md +++ b/apps/docs/docs/integrations/commerce/medusa-js/features.md @@ -45,6 +45,13 @@ The integration provides full cart management via the Medusa Store API (`sdk.sto - `getCartList` and `getCurrentCart` are not implemented — the Store API does not support listing carts - `deleteCart` is a no-op (logs only; Store API has no delete endpoint) +### Promotions {#promotions} + +The integration supports cart promotions via the Medusa Store API: + +- **Backend:** `applyPromotion` and `removePromotion` are implemented; promotions are mapped to O2S `Carts.Model.Promotion` (types: `FIXED_AMOUNT`, `PERCENTAGE`, `FREE_SHIPPING`) +- **Frontend component:** `CartPromoCode` from `@o2s/ui/components/Cart/CartPromoCode` is ready for use but is **not included** in the cart block. You can embed it in your preferred place (cart page, checkout sidebar, etc.) by providing `promotions`, `labels`, `onApply`, and `onRemove` props. Use the Carts API (`POST /carts/:cartId/promotions`, `DELETE /carts/:cartId/promotions/:code`) for apply/remove operations. + ### Checkout Flow {#checkout-flow} Complete checkout orchestration from cart to order: diff --git a/apps/docs/docs/integrations/commerce/medusa-js/how-to-setup.md b/apps/docs/docs/integrations/commerce/medusa-js/how-to-setup.md index 8307d14c2..713b79e20 100644 --- a/apps/docs/docs/integrations/commerce/medusa-js/how-to-setup.md +++ b/apps/docs/docs/integrations/commerce/medusa-js/how-to-setup.md @@ -237,6 +237,7 @@ Configure the following environment variables in your API Harmonization server: | MEDUSAJS_PUBLISHABLE_API_KEY | string | The publishable API key for Store API operations | yes | | MEDUSAJS_ADMIN_API_KEY | string | The admin API key for Admin API operations | yes | | DEFAULT_CURRENCY | string | The default currency code (e.g., `EUR`, `USD`, `PLN`) | yes | +| DEFAULT_REGION_ID | string | The default Medusa region ID for cart creation (fallback when not provided by frontend) | no | You can obtain these values from your Medusa Admin Panel: @@ -251,6 +252,7 @@ MEDUSAJS_BASE_URL=http://localhost:9000 MEDUSAJS_PUBLISHABLE_API_KEY=pk_xxxxxxxx MEDUSAJS_ADMIN_API_KEY=sk_xxxxxxxx DEFAULT_CURRENCY=EUR +DEFAULT_REGION_ID=reg_xxxxxxxx ``` ## Verify installation diff --git a/apps/docs/docs/integrations/commerce/medusa-js/usage.md b/apps/docs/docs/integrations/commerce/medusa-js/usage.md index 69fca9123..197bf06b3 100644 --- a/apps/docs/docs/integrations/commerce/medusa-js/usage.md +++ b/apps/docs/docs/integrations/commerce/medusa-js/usage.md @@ -186,12 +186,13 @@ Authorization: Bearer {token} | Field | Type | Description | Required | | -------- | ------ | --------------------------------------------------- | -------- | -| cartId | string | Existing cart ID (omit to create new cart) | No | -| sku | string | Variant ID from product catalog (Medusa variant_id) | Yes | -| quantity | number | Quantity | Yes | -| currency | string | Required when creating new cart | No | -| regionId | string | Required when creating new cart | No | -| metadata | object | Optional metadata | No | +| cartId | string | Existing cart ID (omit to create new cart) | No | +| sku | string | Product variant SKU | Yes | +| variantId | string | Medusa variant ID — maps directly to Medusa `variant_id` | Yes (Medusa) | +| quantity | number | Quantity | Yes | +| currency | string | Required when creating new cart | No | +| regionId | string | Required when creating new cart | No | +| metadata | object | Optional metadata | No | **Example:** diff --git a/apps/docs/docs/integrations/mocked/mocked.md b/apps/docs/docs/integrations/mocked/mocked.md index 0e1267212..bb72f8586 100644 --- a/apps/docs/docs/integrations/mocked/mocked.md +++ b/apps/docs/docs/integrations/mocked/mocked.md @@ -8,10 +8,10 @@ In order to enable a very quick set-up of O2S, we have prepared an integration t ## Requirements -This integration is automatically installed when you bootstrap a new portal with `npx create-o2s-app@latest my-portal`. If you need to install it manually in an existing workspace (e.g. inside the API Harmonization server), run: +This integration is automatically installed when you bootstrap a new portal with `npx create-o2s-app my-portal`. If you need to install it manually in an existing workspace, run: ```shell -npm install @o2s/integrations.mocked --workspace=@o2s/api +npm install @o2s/integrations.mocked --workspace=@o2s/configs.integrations ``` ## Supported modules @@ -22,6 +22,8 @@ This integration handles the following base modules from the framework: - auth - billing-accounts - cache +- carts +- checkout - cms - invoices - notifications diff --git a/apps/docs/docs/integrations/overview.md b/apps/docs/docs/integrations/overview.md index 9466cb942..11277ae97 100644 --- a/apps/docs/docs/integrations/overview.md +++ b/apps/docs/docs/integrations/overview.md @@ -14,6 +14,22 @@ For each integration we have prepared a description about: - what data sources and libraries are used, - what models and endpoints it extends (if any). +## Installation commands + +Most integration packages should be installed in `@o2s/configs.integrations`, because this workspace imports and exposes selected integration implementations for the rest of the app. + +| Integration package | Install command | +| ---------------------------------- | ------------------------------------------------------------------------------------ | +| `@o2s/integrations.strapi-cms` | `npm install @o2s/integrations.strapi-cms --workspace=@o2s/configs.integrations` | +| `@o2s/integrations.contentful-cms` | `npm install @o2s/integrations.contentful-cms --workspace=@o2s/configs.integrations` | +| `@o2s/integrations.zendesk` | `npm install @o2s/integrations.zendesk --workspace=@o2s/configs.integrations` | +| `@o2s/integrations.redis` | `npm install @o2s/integrations.redis --workspace=@o2s/configs.integrations` | +| `@o2s/integrations.medusajs` | `npm install @o2s/integrations.medusajs --workspace=@o2s/configs.integrations` | +| `@o2s/integrations.algolia` | `npm install @o2s/integrations.algolia --workspace=@o2s/configs.integrations` | +| `@o2s/integrations.mocked` | `npm install @o2s/integrations.mocked --workspace=@o2s/configs.integrations` | + +`@o2s/modules.surveyjs` is a module package (not an integration package), so it has different installation requirements. See the [SurveyJS module overview](./forms/surveyjs/overview.md) for setup and usage details. + import DocLinkTiles from '@site/src/components/DocLinkTiles'; ## Available integrations diff --git a/apps/docs/docs/main-components/blocks/usage.md b/apps/docs/docs/main-components/blocks/usage.md index edfd06563..c2efbcda9 100644 --- a/apps/docs/docs/main-components/blocks/usage.md +++ b/apps/docs/docs/main-components/blocks/usage.md @@ -11,7 +11,7 @@ Because a block is treated as a NPM package, it can be simply installed and impo A block needs to be installed into both the API Harmonization server and the Frontend app. You can either do that twice in their appropriate folders, or once on the root level of the project: ```shell -npm install @dxp/blocks.block-name --workspace=@o2s/api-harmonization --workspace=@o2s/frontend +npm install @o2s/blocks.block-name --workspace=@o2s/api-harmonization --workspace=@o2s/frontend ``` ## Using a block in apps diff --git a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-carts.md b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-carts.md index b3bd8ce6c..c67caffef 100644 --- a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-carts.md +++ b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-carts.md @@ -91,6 +91,7 @@ addCartItem( | --------- | -------- | ------------------------------- | | cartId | string | Existing cart ID (optional) | | sku | string | Product variant SKU (required) | +| variantId | string | Product variant ID (optional) | | quantity | number | Quantity (required) | | currency | Currency | Required when creating new cart | | regionId | string | Required when creating new cart | diff --git a/apps/docs/docs/overview/data-flow.md b/apps/docs/docs/overview/data-flow.md index e8b90ce44..287485be9 100644 --- a/apps/docs/docs/overview/data-flow.md +++ b/apps/docs/docs/overview/data-flow.md @@ -108,7 +108,7 @@ When a request from the frontend app (no matter whether for a page or a componen 3. The calls to the integration happen, which can be handled by various packages (either internal ones, or installed as dependencies). Inside such integrations different modules can be created in order to fetch the data from other external APIs. ```typescript title="fetching ticket list from an external API" - export class TicketService implements Tickets.Service { + export class TicketService extends Tickets.Service { getTicketList(options) { const tickets = this.httpClient.get(TICKETS_API_URL, { params: options }); diff --git a/apps/docs/package.json b/apps/docs/package.json index 6f9a2896e..71c96fa48 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/docs", - "version": "2.0.0", + "version": "2.2.1", "private": true, "scripts": { "docusaurus": "docusaurus", @@ -22,7 +22,7 @@ "@docusaurus/plugin-google-tag-manager": "^3.9.2", "@docusaurus/preset-classic": "3.9.2", "@docusaurus/theme-mermaid": "3.9.2", - "@easyops-cn/docusaurus-search-local": "^0.55.0", + "@easyops-cn/docusaurus-search-local": "^0.55.1", "@mdx-js/react": "^3.1.1", "@radix-ui/react-accordion": "^1.2.12", "@vercel/analytics": "^1.6.1", @@ -48,7 +48,7 @@ "@tailwindcss/postcss": "^4.2.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "autoprefixer": "^10.4.27", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "prettier": "^3.8.1", "tailwindcss": "^4.2.1", "typescript": "5.9.3" diff --git a/apps/docs/src/components/HomepageStartersSection/index.tsx b/apps/docs/src/components/HomepageStartersSection/index.tsx index ae6f665d5..b3e62fdf4 100644 --- a/apps/docs/src/components/HomepageStartersSection/index.tsx +++ b/apps/docs/src/components/HomepageStartersSection/index.tsx @@ -35,9 +35,15 @@ const STARTERS: HoverCardProps[] = [ { title: 'Build your own', description: - "Soon, with our CLI you'll be able to scaffold fully tailored customer self-service frontends — composed of modular UI components, blocks and integrations.", - gradient: 'var(--color-violet)', - badge: 'Coming soon', + 'With our CLI you can scaffold fully tailored customer self-service frontends — composed of modular UI components, blocks and integrations.', + href: '/docs/getting-started/installation', + ctaLabel: 'Get Started', + gradient: + 'linear-gradient(90deg, rgba(0, 19, 96, 0.4) 0%, rgba(0, 19, 96, 0.4) 100%), linear-gradient(311.86deg, var(--color-celadon) 1.526%, var(--color-violet) 69.661%)', + backgroundImage: { + url: '/img/homepage/starters-card-cli.wizard.png', + alt: 'CLI Wizard', + }, }, ]; diff --git a/apps/docs/src/pages/product/features.tsx b/apps/docs/src/pages/product/features.tsx index c87fef764..4c886795a 100644 --- a/apps/docs/src/pages/product/features.tsx +++ b/apps/docs/src/pages/product/features.tsx @@ -232,6 +232,10 @@ const functionalBlocks = [ title: 'Order list & details', image: '/img/featurespage/img-orders.png', }, + { + title: 'Cart, checkout & order confirmation', + image: '/img/featurespage/img-checkout.png', + }, ], }, { diff --git a/apps/docs/src/pages/product/starters.tsx b/apps/docs/src/pages/product/starters.tsx index 921ae45bf..c1dc738c8 100644 --- a/apps/docs/src/pages/product/starters.tsx +++ b/apps/docs/src/pages/product/starters.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import Link from '@docusaurus/Link'; + import RefreshCwIcon from '@site/src/assets/icons/RefreshCw.svg'; import RocketIcon from '@site/src/assets/icons/Rocket.svg'; import CircleCheckIcon from '@site/src/assets/icons/circle-check.svg'; @@ -60,7 +62,7 @@ const digitalPortalStarter: StarterInfoSectionProps = { self-service-oriented. </> ), - cliCommand: 'npx create-o2s-app@latest my-dxp-app --template dxp', + cliCommand: 'npx create-o2s-app my-dxp-app --template dxp', accordionItems: [ { title: 'Feature list', @@ -185,7 +187,7 @@ const customerPortalStarter: StarterInfoSectionProps = { content, and billing. </> ), - cliCommand: 'npx create-o2s-app@latest my-portal --template o2s', + cliCommand: 'npx create-o2s-app my-portal --template o2s', accordionItems: [ { title: 'Feature list', @@ -336,7 +338,11 @@ export default function ProductStarters() { description={ <Body> Use one of our pre-configured starter kits to launch your project — then extend - it with your own blocks and integrations. + it with your own blocks and integrations. Need full control?{' '} + <Link href="/docs/getting-started/installation" className="text-highlighted"> + Scaffold a custom frontend with our CLI + </Link> + , choosing exactly the blocks and integrations you want. </Body> } /> diff --git a/apps/docs/static/img/featurespage/img-checkout.png b/apps/docs/static/img/featurespage/img-checkout.png new file mode 100644 index 000000000..042b99b5e Binary files /dev/null and b/apps/docs/static/img/featurespage/img-checkout.png differ diff --git a/apps/docs/static/img/homepage/starters-card-cli.wizard.png b/apps/docs/static/img/homepage/starters-card-cli.wizard.png new file mode 100644 index 000000000..bf7843a57 Binary files /dev/null and b/apps/docs/static/img/homepage/starters-card-cli.wizard.png differ diff --git a/apps/frontend/CHANGELOG.md b/apps/frontend/CHANGELOG.md index 199b1949e..c8cb9b71b 100644 --- a/apps/frontend/CHANGELOG.md +++ b/apps/frontend/CHANGELOG.md @@ -1,5 +1,141 @@ # @o2s/frontend +## 1.18.1 + +### Patch Changes + +- 338cb01: Introduce typed header name constants (`HeaderName`) using `as const` and + replace selected magic header strings in API harmonization and frontend code. + + Update SDK header typing to use `AppHeaders` for stronger request typing. + +- 338cb01: Replace the `renderBlocks` switch with a typed block registry to enforce + compile-time coverage of all `Modules.Page.Model.Blocks` entries. + + Keep runtime handling for unknown block types with a warning log and `null` + fallback. + +- 338cb01: Add a warning log in `renderBlocks` when an unknown block type is encountered. + + This makes missing block registration visible during development instead of silently rendering `null`. + +- Updated dependencies [fadbc63] +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/blocks.article@1.5.2 + - @o2s/blocks.article-list@1.6.2 + - @o2s/blocks.article-search@1.6.2 + - @o2s/blocks.bento-grid@0.6.2 + - @o2s/blocks.category@1.6.2 + - @o2s/blocks.category-list@1.6.2 + - @o2s/blocks.cta-section@0.6.2 + - @o2s/blocks.document-list@0.6.2 + - @o2s/blocks.faq@1.5.2 + - @o2s/blocks.feature-section@0.6.2 + - @o2s/blocks.feature-section-grid@0.5.2 + - @o2s/blocks.featured-service-list@1.4.2 + - @o2s/blocks.hero-section@0.6.2 + - @o2s/blocks.invoice-list@1.6.2 + - @o2s/blocks.media-section@0.6.2 + - @o2s/blocks.notification-details@1.5.2 + - @o2s/blocks.notification-list@1.6.2 + - @o2s/blocks.notification-summary@1.3.2 + - @o2s/blocks.order-details@1.5.2 + - @o2s/blocks.order-list@1.6.2 + - @o2s/blocks.orders-summary@1.5.2 + - @o2s/blocks.payments-history@1.4.2 + - @o2s/blocks.payments-summary@1.4.2 + - @o2s/blocks.pricing-section@0.6.2 + - @o2s/blocks.product-details@0.3.1 + - @o2s/blocks.product-list@0.5.1 + - @o2s/blocks.quick-links@1.5.2 + - @o2s/blocks.recommended-products@0.3.1 + - @o2s/blocks.service-details@1.4.2 + - @o2s/blocks.service-list@1.5.2 + - @o2s/blocks.surveyjs-form@1.4.2 + - @o2s/blocks.ticket-details@1.5.2 + - @o2s/blocks.ticket-list@1.7.2 + - @o2s/blocks.ticket-recent@1.4.2 + - @o2s/blocks.ticket-summary@1.3.2 + - @o2s/blocks.user-account@1.4.2 + - @o2s/utils.api-harmonization@0.3.3 + - @o2s/integrations.mocked@1.21.1 + +## 1.18.0 + +### Minor Changes + +- 375cd90: feat(blocks, ui): add variantId support to cart item handling, enhance add-to-cart toast with product name and cart link action across ProductDetails, ProductList and RecommendedProducts blocks + +### Patch Changes + +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/blocks.article-list@1.6.1 + - @o2s/blocks.article-search@1.6.1 + - @o2s/blocks.article@1.5.1 + - @o2s/blocks.bento-grid@0.6.1 + - @o2s/blocks.category-list@1.6.1 + - @o2s/blocks.category@1.6.1 + - @o2s/blocks.cta-section@0.6.1 + - @o2s/blocks.document-list@0.6.1 + - @o2s/blocks.faq@1.5.1 + - @o2s/blocks.feature-section-grid@0.5.1 + - @o2s/blocks.feature-section@0.6.1 + - @o2s/blocks.featured-service-list@1.4.1 + - @o2s/blocks.hero-section@0.6.1 + - @o2s/blocks.invoice-list@1.6.1 + - @o2s/blocks.media-section@0.6.1 + - @o2s/blocks.notification-details@1.5.1 + - @o2s/blocks.notification-list@1.6.1 + - @o2s/blocks.notification-summary@1.3.1 + - @o2s/blocks.order-details@1.5.1 + - @o2s/blocks.order-list@1.6.1 + - @o2s/blocks.orders-summary@1.5.1 + - @o2s/blocks.payments-history@1.4.1 + - @o2s/blocks.payments-summary@1.4.1 + - @o2s/blocks.pricing-section@0.6.1 + - @o2s/blocks.product-details@0.3.0 + - @o2s/blocks.product-list@0.5.0 + - @o2s/blocks.quick-links@1.5.1 + - @o2s/blocks.recommended-products@0.3.0 + - @o2s/blocks.service-details@1.4.1 + - @o2s/blocks.service-list@1.5.1 + - @o2s/blocks.surveyjs-form@1.4.1 + - @o2s/blocks.ticket-details@1.5.1 + - @o2s/blocks.ticket-list@1.7.1 + - @o2s/blocks.ticket-recent@1.4.1 + - @o2s/blocks.ticket-summary@1.3.1 + - @o2s/blocks.user-account@1.4.1 + - @o2s/framework@1.20.0 + - @o2s/integrations.mocked@1.21.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/ui@1.13.0 + +## 1.17.0 + +### Minor Changes + +- 0e61431: feat: update page model and integration to support redirects +- 5d36519: Added new blocks: Cart, Checkout (Summary, Shipping Address, Company Data, Billing Payment) and Order Confirmation. Includes checkout forms validation (Formik + Yup), error handling, promo code support in cart, and new UI components (StepIndicator, RadioTile, AddressFields, CartSummary, QuantityInput, FormField). + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] +- Updated dependencies [5d36519] + - @o2s/framework@1.19.0 + - @o2s/integrations.mocked@1.20.0 + - @o2s/utils.frontend@0.5.0 + - @o2s/ui@1.12.0 + ## 1.16.0 ### Minor Changes diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 1b1c187ec..92d31c81a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/frontend", - "version": "1.16.0", + "version": "1.18.1", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -14,13 +14,18 @@ "generate:component": "turbo gen web-component" }, "dependencies": { - "@contentful/live-preview": "^4.9.6", + "@contentful/live-preview": "^4.9.10", "@o2s/blocks.article": "*", "@o2s/blocks.article-list": "*", "@o2s/blocks.article-search": "*", "@o2s/blocks.bento-grid": "*", + "@o2s/blocks.cart": "*", "@o2s/blocks.category": "*", "@o2s/blocks.category-list": "*", + "@o2s/blocks.checkout-billing-payment": "*", + "@o2s/blocks.checkout-company-data": "*", + "@o2s/blocks.checkout-shipping-address": "*", + "@o2s/blocks.checkout-summary": "*", "@o2s/blocks.cta-section": "*", "@o2s/blocks.document-list": "*", "@o2s/blocks.faq": "*", @@ -33,6 +38,7 @@ "@o2s/blocks.notification-details": "*", "@o2s/blocks.notification-list": "*", "@o2s/blocks.notification-summary": "*", + "@o2s/blocks.order-confirmation": "*", "@o2s/blocks.order-details": "*", "@o2s/blocks.order-list": "*", "@o2s/blocks.orders-summary": "*", @@ -87,9 +93,9 @@ "@types/react-autosuggest": "^10.1.11", "@types/react-dom": "19.2.3", "dotenv": "^17.3.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "eslint-config-next": "16.1.6", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "prettier": "^3.8.1", "sass": "^1.97.3", "shx": "^0.4.0", @@ -101,4 +107,4 @@ "@types/react": "19.2.14", "@types/react-dom": "19.2.3" } -} +} \ No newline at end of file diff --git a/apps/frontend/src/api/modules/login-page.ts b/apps/frontend/src/api/modules/login-page.ts index 71a366c47..6ff626438 100644 --- a/apps/frontend/src/api/modules/login-page.ts +++ b/apps/frontend/src/api/modules/login-page.ts @@ -1,8 +1,7 @@ import { Modules } from '@o2s/api-harmonization'; import { URL } from '@o2s/api-harmonization/modules/login-page/login-page.url'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { getApiHeaders } from '../../utils/api'; @@ -11,7 +10,7 @@ const API_URL = URL; export const loginPage = (sdk: Sdk) => ({ modules: { - getLoginPage: (headers: Models.Headers.AppHeaders): Promise<Modules.LoginPage.Model.LoginPage> => + getLoginPage: (headers: AppHeaders): Promise<Modules.LoginPage.Model.LoginPage> => sdk.makeRequest({ method: 'get', url: `${API_URL}`, diff --git a/apps/frontend/src/api/modules/not-found-page.ts b/apps/frontend/src/api/modules/not-found-page.ts index 70ba648e4..9c1da3bad 100644 --- a/apps/frontend/src/api/modules/not-found-page.ts +++ b/apps/frontend/src/api/modules/not-found-page.ts @@ -1,8 +1,7 @@ import { Modules } from '@o2s/api-harmonization'; import { URL } from '@o2s/api-harmonization/modules/not-found-page/not-found-page.url'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { getApiHeaders } from '../../utils/api'; @@ -12,7 +11,7 @@ const API_URL = URL; export const notFoundPage = (sdk: Sdk) => ({ modules: { getNotFoundPage: ( - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Modules.NotFoundPage.Model.NotFoundPage> => { return sdk.makeRequest({ diff --git a/apps/frontend/src/api/modules/organizations.ts b/apps/frontend/src/api/modules/organizations.ts index b6bee90c5..6dc4f9b32 100644 --- a/apps/frontend/src/api/modules/organizations.ts +++ b/apps/frontend/src/api/modules/organizations.ts @@ -1,8 +1,7 @@ import { Modules } from '@o2s/api-harmonization'; import { URL } from '@o2s/api-harmonization/modules/organizations/organizations.url'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { getApiHeaders } from '../../utils/api'; @@ -13,7 +12,7 @@ export const organizations = (sdk: Sdk) => ({ modules: { getCustomers: ( query: Modules.Organizations.Request.GetCustomersQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization: string, ): Promise<Modules.Organizations.Model.CustomerList> => sdk.makeRequest({ diff --git a/apps/frontend/src/api/modules/page.ts b/apps/frontend/src/api/modules/page.ts index 3f56c9ce2..7ac178be5 100644 --- a/apps/frontend/src/api/modules/page.ts +++ b/apps/frontend/src/api/modules/page.ts @@ -1,8 +1,7 @@ import { Modules } from '@o2s/api-harmonization'; import { URL } from '@o2s/api-harmonization/modules/page/page.url'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { getApiHeaders } from '../../utils/api'; @@ -13,7 +12,7 @@ export const page = (sdk: Sdk) => ({ modules: { getInit: ( params: Modules.Page.Request.GetInitQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Modules.Page.Model.Init> => sdk.makeRequest({ @@ -32,7 +31,7 @@ export const page = (sdk: Sdk) => ({ }), getPage: ( params: Modules.Page.Request.GetPageQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Modules.Page.Model.Page> => sdk.makeRequest({ diff --git a/apps/frontend/src/api/sdk.ts b/apps/frontend/src/api/sdk.ts index 89675c71d..3315d682f 100644 --- a/apps/frontend/src/api/sdk.ts +++ b/apps/frontend/src/api/sdk.ts @@ -3,8 +3,6 @@ import { Modules } from '@o2s/api-harmonization'; // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; import { extendSdk, getSdk } from '@o2s/framework/sdk'; diff --git a/apps/frontend/src/app/[locale]/[[...slug]]/page.tsx b/apps/frontend/src/app/[locale]/[[...slug]]/page.tsx index 50b82245c..d7e0e22c1 100644 --- a/apps/frontend/src/app/[locale]/[[...slug]]/page.tsx +++ b/apps/frontend/src/app/[locale]/[[...slug]]/page.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import { setRequestLocale } from 'next-intl/server'; import { headers } from 'next/headers'; -import { notFound } from 'next/navigation'; +import { notFound, redirect } from 'next/navigation'; import React from 'react'; import { GlobalProvider } from '@o2s/ui/providers/GlobalProvider'; @@ -96,6 +96,10 @@ export default async function Page({ params }: Props) { session?.accessToken, ); + if (meta.redirect) { + redirect(`/${locale}${meta.redirect}`); + } + if (session?.user && session?.error === 'RefreshTokenError') { return await signIn(); } diff --git a/apps/frontend/src/blocks/renderBlocks.tsx b/apps/frontend/src/blocks/renderBlocks.tsx index 660eb80e4..8ce468067 100644 --- a/apps/frontend/src/blocks/renderBlocks.tsx +++ b/apps/frontend/src/blocks/renderBlocks.tsx @@ -5,8 +5,13 @@ import * as ArticleList from '@o2s/blocks.article-list/frontend'; import * as ArticleSearch from '@o2s/blocks.article-search/frontend'; import * as Article from '@o2s/blocks.article/frontend'; import * as BentoGrid from '@o2s/blocks.bento-grid/frontend'; +import * as Cart from '@o2s/blocks.cart/frontend'; import * as CategoryList from '@o2s/blocks.category-list/frontend'; import * as Category from '@o2s/blocks.category/frontend'; +import * as CheckoutBillingPayment from '@o2s/blocks.checkout-billing-payment/frontend'; +import * as CheckoutCompanyData from '@o2s/blocks.checkout-company-data/frontend'; +import * as CheckoutShippingAddress from '@o2s/blocks.checkout-shipping-address/frontend'; +import * as CheckoutSummary from '@o2s/blocks.checkout-summary/frontend'; import * as CtaSection from '@o2s/blocks.cta-section/frontend'; import * as Faq from '@o2s/blocks.faq/frontend'; import * as FeatureSectionGrid from '@o2s/blocks.feature-section-grid/frontend'; @@ -18,6 +23,7 @@ import * as MediaSection from '@o2s/blocks.media-section/frontend'; import * as NotificationDetails from '@o2s/blocks.notification-details/frontend'; import * as NotificationList from '@o2s/blocks.notification-list/frontend'; import * as NotificationSummary from '@o2s/blocks.notification-summary/frontend'; +import * as OrderConfirmation from '@o2s/blocks.order-confirmation/frontend'; import * as OrderDetails from '@o2s/blocks.order-details/frontend'; import * as OrderList from '@o2s/blocks.order-list/frontend'; import * as OrdersSummary from '@o2s/blocks.orders-summary/frontend'; @@ -41,7 +47,7 @@ import { getLocale } from 'next-intl/server'; import { draftMode } from 'next/headers'; import React from 'react'; -import { CMS } from '@o2s/framework/modules'; +import { CMS, Models } from '@o2s/framework/modules'; import { Container } from '@o2s/ui/components/Container'; @@ -51,16 +57,11 @@ import { routing } from '@/i18n'; import { onSignOut } from '../actions/signOut'; -interface BlockProps { - id: string; - slug: string[]; - locale: string; - accessToken: string | undefined; - userId: string | undefined; +type BlockProps = Models.BlockProps.FullBlockProps<typeof routing> & { routing: typeof routing; - hasPriority?: boolean; - isDraftModeEnabled?: boolean; -} +}; + +type BlockRenderer = (blockProps: BlockProps) => React.ReactNode; export const renderBlocks = async (blocks: CMS.Model.Page.SlotBlock[], slug: string[]) => { const session = await auth(); @@ -97,80 +98,60 @@ export const renderBlocks = async (blocks: CMS.Model.Page.SlotBlock[], slug: str }); }; +const BLOCK_REGISTRY = { + TicketListBlock: (blockProps) => <TicketList.Renderer {...blockProps} />, + TicketRecentBlock: (blockProps) => <TickeRecent.Renderer {...blockProps} />, + TicketDetailsBlock: (blockProps) => <TicketDetails.Renderer {...blockProps} />, + NotificationListBlock: (blockProps) => <NotificationList.Renderer {...blockProps} />, + NotificationDetailsBlock: (blockProps) => <NotificationDetails.Renderer {...blockProps} />, + FaqBlock: (blockProps) => <Faq.Renderer {...blockProps} />, + InvoiceListBlock: (blockProps) => <InvoiceList.Renderer {...blockProps} />, + PaymentsSummaryBlock: (blockProps) => <PaymentsSummary.Renderer {...blockProps} />, + PaymentsHistoryBlock: (blockProps) => <PaymentsHistory.Renderer {...blockProps} />, + UserAccountBlock: (blockProps) => <UserAccount.Renderer {...blockProps} onSignOut={onSignOut} />, + ServiceListBlock: (blockProps) => <ServiceList.Renderer {...blockProps} />, + ServiceDetailsBlock: (blockProps) => <ServiceDetails.Renderer {...blockProps} />, + SurveyJsBlock: (blockProps) => <SurveyJsForm.Renderer {...blockProps} />, + OrderListBlock: (blockProps) => <OrderList.Renderer {...blockProps} />, + OrdersSummaryBlock: (blockProps) => <OrdersSummary.Renderer {...blockProps} />, + OrderDetailsBlock: (blockProps) => <OrderDetails.Renderer {...blockProps} />, + QuickLinksBlock: (blockProps) => <QuickLinks.Renderer {...blockProps} />, + CategoryListBlock: (blockProps) => <CategoryList.Renderer {...blockProps} />, + ArticleListBlock: (blockProps) => <ArticleList.Renderer {...blockProps} />, + CategoryBlock: (blockProps) => <Category.Renderer {...blockProps} renderBlocks={renderBlocks} />, + ArticleBlock: (blockProps) => <Article.Renderer {...blockProps} />, + ArticleSearchBlock: (blockProps) => <ArticleSearch.Renderer {...blockProps} />, + FeaturedServiceListBlock: (blockProps) => <FeaturedServiceList.Renderer {...blockProps} />, + ProductListBlock: (blockProps) => <ProductList.Renderer {...blockProps} />, + NotificationSummaryBlock: (blockProps) => <NotificationSummary.Renderer {...blockProps} />, + TicketSummaryBlock: (blockProps) => <TicketSummary.Renderer {...blockProps} />, + ProductDetailsBlock: (blockProps) => <ProductDetails.Renderer {...blockProps} />, + RecommendedProductsBlock: (blockProps) => <RecommendedProducts.Renderer {...blockProps} />, + OrderConfirmationBlock: (blockProps) => <OrderConfirmation.Renderer {...blockProps} />, + CheckoutBillingPaymentBlock: (blockProps) => <CheckoutBillingPayment.Renderer {...blockProps} />, + CheckoutCompanyDataBlock: (blockProps) => <CheckoutCompanyData.Renderer {...blockProps} />, + CheckoutShippingAddressBlock: (blockProps) => <CheckoutShippingAddress.Renderer {...blockProps} />, + CheckoutSummaryBlock: (blockProps) => <CheckoutSummary.Renderer {...blockProps} />, + CartBlock: (blockProps) => <Cart.Renderer {...blockProps} />, + HeroSectionBlock: (blockProps) => <HeroSection.Renderer {...blockProps} />, + BentoGridBlock: (blockProps) => <BentoGrid.Renderer {...blockProps} />, + FeatureSectionBlock: (blockProps) => <FeatureSection.Renderer {...blockProps} />, + CtaSectionBlock: (blockProps) => <CtaSection.Renderer {...blockProps} />, + MediaSectionBlock: (blockProps) => <MediaSection.Renderer {...blockProps} />, + PricingSectionBlock: (blockProps) => <PricingSection.Renderer {...blockProps} />, + FeatureSectionGridBlock: (blockProps) => <FeatureSectionGrid.Renderer {...blockProps} />, + // BLOCK REGISTER +} satisfies Record<Modules.Page.Model.Blocks, BlockRenderer>; + +const isRegisteredBlock = (typename: string): typename is keyof typeof BLOCK_REGISTRY => { + return typename in BLOCK_REGISTRY; +}; + const renderBlock = (typename: string, blockProps: BlockProps) => { - switch (typename as Modules.Page.Model.Blocks) { - case 'TicketListBlock': - return <TicketList.Renderer {...blockProps} />; - case 'TicketRecentBlock': - return <TickeRecent.Renderer {...blockProps} />; - case 'TicketDetailsBlock': - return <TicketDetails.Renderer {...blockProps} />; - case 'NotificationListBlock': - return <NotificationList.Renderer {...blockProps} />; - case 'NotificationDetailsBlock': - return <NotificationDetails.Renderer {...blockProps} />; - case 'FaqBlock': - return <Faq.Renderer {...blockProps} />; - case 'InvoiceListBlock': - return <InvoiceList.Renderer {...blockProps} />; - case 'PaymentsSummaryBlock': - return <PaymentsSummary.Renderer {...blockProps} />; - case 'PaymentsHistoryBlock': - return <PaymentsHistory.Renderer {...blockProps} />; - case 'UserAccountBlock': - return <UserAccount.Renderer {...blockProps} onSignOut={onSignOut} />; - case 'ServiceListBlock': - return <ServiceList.Renderer {...blockProps} />; - case 'ServiceDetailsBlock': - return <ServiceDetails.Renderer {...blockProps} />; - case 'SurveyJsBlock': - return <SurveyJsForm.Renderer {...blockProps} />; - case 'OrderListBlock': - return <OrderList.Renderer {...blockProps} />; - case 'OrdersSummaryBlock': - return <OrdersSummary.Renderer {...blockProps} />; - case 'OrderDetailsBlock': - return <OrderDetails.Renderer {...blockProps} />; - case 'QuickLinksBlock': - return <QuickLinks.Renderer {...blockProps} />; - case 'CategoryListBlock': - return <CategoryList.Renderer {...blockProps} />; - case 'ArticleListBlock': - return <ArticleList.Renderer {...blockProps} />; - case 'CategoryBlock': - return <Category.Renderer {...blockProps} renderBlocks={renderBlocks} />; - case 'ArticleBlock': - return <Article.Renderer {...blockProps} />; - case 'ArticleSearchBlock': - return <ArticleSearch.Renderer {...blockProps} />; - case 'FeaturedServiceListBlock': - return <FeaturedServiceList.Renderer {...blockProps} />; - case 'ProductListBlock': - return <ProductList.Renderer {...blockProps} />; - case 'NotificationSummaryBlock': - return <NotificationSummary.Renderer {...blockProps} />; - case 'TicketSummaryBlock': - return <TicketSummary.Renderer {...blockProps} />; - case 'ProductDetailsBlock': - return <ProductDetails.Renderer {...blockProps} />; - case 'RecommendedProductsBlock': - return <RecommendedProducts.Renderer {...blockProps} />; - case 'HeroSectionBlock': - return <HeroSection.Renderer {...blockProps} />; - case 'BentoGridBlock': - return <BentoGrid.Renderer {...blockProps} />; - case 'FeatureSectionBlock': - return <FeatureSection.Renderer {...blockProps} />; - case 'CtaSectionBlock': - return <CtaSection.Renderer {...blockProps} />; - case 'MediaSectionBlock': - return <MediaSection.Renderer {...blockProps} />; - case 'PricingSectionBlock': - return <PricingSection.Renderer {...blockProps} />; - case 'FeatureSectionGridBlock': - return <FeatureSectionGrid.Renderer {...blockProps} />; - // BLOCK REGISTER - default: - return null; + if (!isRegisteredBlock(typename)) { + console.warn(`[O2S] Unknown block type: "${typename}". Register it in renderBlocks.tsx`); + return null; } + + return BLOCK_REGISTRY[typename](blockProps); }; diff --git a/apps/frontend/src/containers/Header/CartInfo/CartInfo.tsx b/apps/frontend/src/containers/Header/CartInfo/CartInfo.tsx new file mode 100644 index 000000000..26f2c5125 --- /dev/null +++ b/apps/frontend/src/containers/Header/CartInfo/CartInfo.tsx @@ -0,0 +1,17 @@ +import { ShoppingCart } from 'lucide-react'; + +import { Button } from '@o2s/ui/elements/button'; + +import { Link as NextLink } from '@/i18n'; + +import { CartInfoProps } from './CartInfo.types'; + +export const CartInfo = ({ data }: CartInfoProps) => { + return ( + <Button asChild variant="tertiary" className="w-10 h-10" aria-label={data.label}> + <NextLink href={data.url}> + <ShoppingCart className="w-4 h-4" /> + </NextLink> + </Button> + ); +}; diff --git a/apps/frontend/src/containers/Header/CartInfo/CartInfo.types.ts b/apps/frontend/src/containers/Header/CartInfo/CartInfo.types.ts new file mode 100644 index 000000000..1d206a977 --- /dev/null +++ b/apps/frontend/src/containers/Header/CartInfo/CartInfo.types.ts @@ -0,0 +1,6 @@ +export interface CartInfoProps { + data: { + url: string; + label: string; + }; +} diff --git a/apps/frontend/src/containers/Header/DesktopNavigation/DesktopNavigation.tsx b/apps/frontend/src/containers/Header/DesktopNavigation/DesktopNavigation.tsx index da24cacbd..91ac30fed 100644 --- a/apps/frontend/src/containers/Header/DesktopNavigation/DesktopNavigation.tsx +++ b/apps/frontend/src/containers/Header/DesktopNavigation/DesktopNavigation.tsx @@ -29,6 +29,7 @@ export function DesktopNavigation({ logoSlot, contextSlot, localeSlot, + cartSlot, notificationSlot, userSlot, items, @@ -230,6 +231,9 @@ export function DesktopNavigation({ </Button> )} + {/* Cart Button */} + {cartSlot} + {/* Notification Button */} {notificationSlot} diff --git a/apps/frontend/src/containers/Header/DesktopNavigation/DesktopNavigation.types.ts b/apps/frontend/src/containers/Header/DesktopNavigation/DesktopNavigation.types.ts index 20e47061d..463072a9e 100644 --- a/apps/frontend/src/containers/Header/DesktopNavigation/DesktopNavigation.types.ts +++ b/apps/frontend/src/containers/Header/DesktopNavigation/DesktopNavigation.types.ts @@ -6,6 +6,7 @@ export interface DesktopNavigationProps { logoSlot?: ReactNode; contextSlot?: ReactNode; localeSlot?: ReactNode; + cartSlot?: ReactNode; notificationSlot?: ReactNode; userSlot?: ReactNode; items: CMS.Model.Header.Header['items']; diff --git a/apps/frontend/src/containers/Header/Header.tsx b/apps/frontend/src/containers/Header/Header.tsx index 45d562f0a..73b68be24 100644 --- a/apps/frontend/src/containers/Header/Header.tsx +++ b/apps/frontend/src/containers/Header/Header.tsx @@ -14,6 +14,7 @@ import { Link as NextLink } from '@/i18n'; import { LocaleSwitcher } from '../Auth/Toolbar/LocaleSwitcher'; import { ContextSwitcher } from '../ContextSwitcher/ContextSwitcher'; +import { CartInfo } from './CartInfo/CartInfo'; import { DesktopNavigation } from './DesktopNavigation/DesktopNavigation'; import { HeaderProps } from './Header.types'; import { MobileNavigation } from './MobileNavigation/MobileNavigation'; @@ -66,6 +67,14 @@ export const Header: React.FC<HeaderProps> = ({ return <NotificationInfo data={{ url: data.notification.url, label: data.notification.label }} />; }, [isSignedIn, data.notification]); + const CartSlot = useMemo(() => { + if (!data.cart?.url || !data.cart?.label) { + return null; + } + + return <CartInfo data={{ url: data.cart.url, label: data.cart.label }} />; + }, [data.cart]); + const LocaleSlot = useMemo( () => <LocaleSwitcher label={data.languageSwitcherLabel} alternativeUrls={alternativeUrls} />, [data.languageSwitcherLabel, alternativeUrls], @@ -79,11 +88,12 @@ export const Header: React.FC<HeaderProps> = ({ return ( <header className="flex flex-col gap-4"> <> - <div className="md:block hidden"> + <div className="lg:block hidden"> <DesktopNavigation logoSlot={LogoSlot} contextSlot={ContextSwitchSlot} localeSlot={LocaleSlot} + cartSlot={CartSlot} notificationSlot={NotificationSlot} userSlot={UserSlot} items={data.items} @@ -91,11 +101,12 @@ export const Header: React.FC<HeaderProps> = ({ shouldIncludeSignInButton={shouldIncludeSignInButton} /> </div> - <div className="md:hidden"> + <div className="lg:hidden"> <MobileNavigation logoSlot={LogoSlot} contextSlot={ContextSwitchSlot} localeSlot={LocaleSlot} + cartSlot={CartSlot} notificationSlot={NotificationSlot} userSlot={UserSlot} items={data.items} diff --git a/apps/frontend/src/containers/Header/MobileNavigation/MobileNavigation.tsx b/apps/frontend/src/containers/Header/MobileNavigation/MobileNavigation.tsx index 1dde1d561..f04825106 100644 --- a/apps/frontend/src/containers/Header/MobileNavigation/MobileNavigation.tsx +++ b/apps/frontend/src/containers/Header/MobileNavigation/MobileNavigation.tsx @@ -25,6 +25,7 @@ export function MobileNavigation({ logoSlot, contextSlot, localeSlot, + cartSlot, notificationSlot, userSlot, items, @@ -124,6 +125,9 @@ export function MobileNavigation({ </Button> )} + {/* Cart Button */} + {cartSlot} + {/* Notification Button */} {notificationSlot} diff --git a/apps/frontend/src/containers/Header/MobileNavigation/MobileNavigation.types.ts b/apps/frontend/src/containers/Header/MobileNavigation/MobileNavigation.types.ts index 9467e953c..53bd59524 100644 --- a/apps/frontend/src/containers/Header/MobileNavigation/MobileNavigation.types.ts +++ b/apps/frontend/src/containers/Header/MobileNavigation/MobileNavigation.types.ts @@ -6,6 +6,7 @@ export interface MobileNavigationProps { logoSlot?: ReactNode; contextSlot?: ReactNode; localeSlot?: ReactNode; + cartSlot?: ReactNode; notificationSlot?: ReactNode; userSlot?: ReactNode; items: CMS.Model.Header.Header['items']; diff --git a/docker-compose.yml b/docker-compose.yml index e43e3689b..4b9718cef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,6 +62,7 @@ services: DEFAULT_LOCALE: en DEFAULT_CURRENCY: EUR + DEFAULT_REGION_ID: '' DEFAULT_PRODUCT_UNIT: PCS SUPPORTED_CURRENCIES: EUR,USD,PLN diff --git a/package-lock.json b/package-lock.json index 813430392..eaab89b2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,14 +24,14 @@ "unzipper": "^0.12.3" }, "devDependencies": { - "@commitlint/cli": "^20.4.2", - "@commitlint/config-conventional": "^20.4.2", + "@commitlint/cli": "^20.4.4", + "@commitlint/config-conventional": "^20.4.4", "@playwright/test": "^1.58.2", - "@storybook/addon-a11y": "^10.2.13", - "@storybook/addon-docs": "^10.2.13", - "@storybook/addon-themes": "^10.2.13", - "@storybook/addon-vitest": "^10.2.13", - "@storybook/nextjs-vite": "^10.2.13", + "@storybook/addon-a11y": "^10.2.19", + "@storybook/addon-docs": "^10.2.19", + "@storybook/addon-themes": "^10.2.19", + "@storybook/addon-vitest": "^10.2.19", + "@storybook/nextjs-vite": "^10.2.19", "@turbo/gen": "^2.8.11", "@types/cli-progress": "^3.11.6", "@types/fs-extra": "^11.0.4", @@ -41,15 +41,17 @@ "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", "dotenv": "^17.3.1", - "fs-extra": "^11.3.3", + "fs-extra": "^11.3.4", "husky": "^9.1.7", "lint-staged": "^16.2.7", - "npm-check-updates": "^19.6.2", + "msw": "^2.12.11", + "msw-storybook-addon": "^2.0.6", + "npm-check-updates": "^19.6.3", "playwright": "^1.58.2", "prettier": "^3.8.1", "rimraf": "^6.1.3", "simple-git": "^3.32.3", - "storybook": "^10.2.13", + "storybook": "^10.2.19", "syncpack": "^14.0.0-canary.1", "turbo": "^2.8.11", "typescript": "5.9.3", @@ -65,7 +67,7 @@ }, "apps/api-harmonization": { "name": "@o2s/api-harmonization", - "version": "1.16.0", + "version": "1.18.0", "license": "MIT", "dependencies": { "@nestjs/axios": "^4.0.1", @@ -121,7 +123,7 @@ "compression": "^1.8.1", "cookie": "^1.1.1", "cookie-parser": "^1.4.7", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "jwt-decode": "^4.0.0", @@ -156,7 +158,7 @@ "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", "cross-env": "^10.1.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", "jest": "^30.2.0", @@ -515,14 +517,14 @@ }, "apps/docs": { "name": "@o2s/docs", - "version": "2.0.0", + "version": "2.2.0", "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/plugin-google-gtag": "^3.9.2", "@docusaurus/plugin-google-tag-manager": "^3.9.2", "@docusaurus/preset-classic": "3.9.2", "@docusaurus/theme-mermaid": "3.9.2", - "@easyops-cn/docusaurus-search-local": "^0.55.0", + "@easyops-cn/docusaurus-search-local": "^0.55.1", "@mdx-js/react": "^3.1.1", "@radix-ui/react-accordion": "^1.2.12", "@vercel/analytics": "^1.6.1", @@ -548,7 +550,7 @@ "@tailwindcss/postcss": "^4.2.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "autoprefixer": "^10.4.27", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "prettier": "^3.8.1", "tailwindcss": "^4.2.1", "typescript": "5.9.3" @@ -597,15 +599,20 @@ }, "apps/frontend": { "name": "@o2s/frontend", - "version": "1.16.0", + "version": "1.18.0", "dependencies": { - "@contentful/live-preview": "^4.9.6", + "@contentful/live-preview": "^4.9.10", "@o2s/blocks.article": "*", "@o2s/blocks.article-list": "*", "@o2s/blocks.article-search": "*", "@o2s/blocks.bento-grid": "*", + "@o2s/blocks.cart": "*", "@o2s/blocks.category": "*", "@o2s/blocks.category-list": "*", + "@o2s/blocks.checkout-billing-payment": "*", + "@o2s/blocks.checkout-company-data": "*", + "@o2s/blocks.checkout-shipping-address": "*", + "@o2s/blocks.checkout-summary": "*", "@o2s/blocks.cta-section": "*", "@o2s/blocks.document-list": "*", "@o2s/blocks.faq": "*", @@ -618,6 +625,7 @@ "@o2s/blocks.notification-details": "*", "@o2s/blocks.notification-list": "*", "@o2s/blocks.notification-summary": "*", + "@o2s/blocks.order-confirmation": "*", "@o2s/blocks.order-details": "*", "@o2s/blocks.order-list": "*", "@o2s/blocks.orders-summary": "*", @@ -672,9 +680,9 @@ "@types/react-autosuggest": "^10.1.11", "@types/react-dom": "19.2.3", "dotenv": "^17.3.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "eslint-config-next": "16.1.6", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "prettier": "^3.8.1", "sass": "^1.97.3", "shx": "^0.4.0", @@ -741,15 +749,15 @@ } }, "node_modules/@algolia/abtesting": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.15.1.tgz", - "integrity": "sha512-2yuIC48rUuHGhU1U5qJ9kJHaxYpJ0jpDHJVI5ekOxSMYXlH4+HP+pA31G820lsAznfmu2nzDV7n5RO44zIY1zw==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.15.2.tgz", + "integrity": "sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" + "@algolia/client-common": "5.49.2", + "@algolia/requester-browser-xhr": "5.49.2", + "@algolia/requester-fetch": "5.49.2", + "@algolia/requester-node-http": "5.49.2" }, "engines": { "node": ">= 14.0.0" @@ -788,99 +796,99 @@ } }, "node_modules/@algolia/client-abtesting": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.49.1.tgz", - "integrity": "sha512-h6M7HzPin+45/l09q0r2dYmocSSt2MMGOOk5c4O5K/bBBlEwf1BKfN6z+iX4b8WXcQQhf7rgQwC52kBZJt/ZZw==", + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.49.2.tgz", + "integrity": "sha512-XyvKCm0RRmovMI/ChaAVjTwpZhXdbgt3iZofK914HeEHLqD1MUFFVLz7M0+Ou7F56UkHXwRbpHwb9xBDNopprQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" + "@algolia/client-common": "5.49.2", + "@algolia/requester-browser-xhr": "5.49.2", + "@algolia/requester-fetch": "5.49.2", + "@algolia/requester-node-http": "5.49.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.49.1.tgz", - "integrity": "sha512-048T9/Z8OeLmTk8h76QUqaNFp7Rq2VgS2Zm6Y2tNMYGQ1uNuzePY/udB5l5krlXll7ZGflyCjFvRiOtlPZpE9g==", + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.49.2.tgz", + "integrity": "sha512-jq/3qvtmj3NijZlhq7A1B0Cl41GfaBpjJxcwukGsYds6aMSCWrEAJ9pUqw/C9B3hAmILYKl7Ljz3N9SFvekD3Q==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" + "@algolia/client-common": "5.49.2", + "@algolia/requester-browser-xhr": "5.49.2", + "@algolia/requester-fetch": "5.49.2", + "@algolia/requester-node-http": "5.49.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.49.1.tgz", - "integrity": "sha512-vp5/a9ikqvf3mn9QvHN8PRekn8hW34aV9eX+O0J5mKPZXeA6Pd5OQEh2ZWf7gJY6yyfTlLp5LMFzQUAU+Fpqpg==", + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.49.2.tgz", + "integrity": "sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw==", "license": "MIT", "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-insights": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.49.1.tgz", - "integrity": "sha512-B6N7PgkvYrul3bntTz/l6uXnhQ2bvP+M7NqTcayh681tSqPaA5cJCUBp/vrP7vpPRpej4Eeyx2qz5p0tE/2N2g==", + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.49.2.tgz", + "integrity": "sha512-z14wfFs1T3eeYbCArC8pvntAWsPo9f6hnUGoj8IoRUJTwgJiiySECkm8bmmV47/x0oGHfsVn3kBdjMX0yq0sNA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" + "@algolia/client-common": "5.49.2", + "@algolia/requester-browser-xhr": "5.49.2", + "@algolia/requester-fetch": "5.49.2", + "@algolia/requester-node-http": "5.49.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.49.1.tgz", - "integrity": "sha512-v+4DN+lkYfBd01Hbnb9ZrCHe7l+mvihyx218INRX/kaCXROIWUDIT1cs3urQxfE7kXBFnLsqYeOflQALv/gA5w==", + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.49.2.tgz", + "integrity": "sha512-GpRf7yuuAX93+Qt0JGEJZwgtL0MFdjFO9n7dn8s2pA9mTjzl0Sc5+uTk1VPbIAuf7xhCP9Mve+URGb6J+EYxgA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" + "@algolia/client-common": "5.49.2", + "@algolia/requester-browser-xhr": "5.49.2", + "@algolia/requester-fetch": "5.49.2", + "@algolia/requester-node-http": "5.49.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.49.1.tgz", - "integrity": "sha512-Un11cab6ZCv0W+Jiak8UktGIqoa4+gSNgEZNfG8m8eTsXGqwIEr370H3Rqwj87zeNSlFpH2BslMXJ/cLNS1qtg==", + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.49.2.tgz", + "integrity": "sha512-HZwApmNkp0DiAjZcLYdQLddcG4Agb88OkojiAHGgcm5DVXobT5uSZ9lmyrbw/tmQBJwgu2CNw4zTyXoIB7YbPA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" + "@algolia/client-common": "5.49.2", + "@algolia/requester-browser-xhr": "5.49.2", + "@algolia/requester-fetch": "5.49.2", + "@algolia/requester-node-http": "5.49.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.49.1.tgz", - "integrity": "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==", + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.49.2.tgz", + "integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" + "@algolia/client-common": "5.49.2", + "@algolia/requester-browser-xhr": "5.49.2", + "@algolia/requester-fetch": "5.49.2", + "@algolia/requester-node-http": "5.49.2" }, "engines": { "node": ">= 14.0.0" @@ -893,81 +901,81 @@ "license": "MIT" }, "node_modules/@algolia/ingestion": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.49.1.tgz", - "integrity": "sha512-b5hUXwDqje0Y4CpU6VL481DXgPgxpTD5sYMnfQTHKgUispGnaCLCm2/T9WbJo1YNUbX3iHtYDArp804eD6CmRQ==", + "version": "1.49.2", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.49.2.tgz", + "integrity": "sha512-YYJRjaZ2bqk923HxE4um7j/Cm3/xoSkF2HC2ZweOF8cXL3sqnlndSUYmCaxHFjNPWLaSHk2IfssX6J/tdKTULw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" + "@algolia/client-common": "5.49.2", + "@algolia/requester-browser-xhr": "5.49.2", + "@algolia/requester-fetch": "5.49.2", + "@algolia/requester-node-http": "5.49.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.49.1.tgz", - "integrity": "sha512-bvrXwZ0WsL3rN6Q4m4QqxsXFCo6WAew7sAdrpMQMK4Efn4/W920r9ptOuckejOSSvyLr9pAWgC5rsHhR2FYuYw==", + "version": "1.49.2", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.49.2.tgz", + "integrity": "sha512-9WgH+Dha39EQQyGKCHlGYnxW/7W19DIrEbCEbnzwAMpGAv1yTWCHMPXHxYa+LcL3eCp2V/5idD1zHNlIKmHRHg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" + "@algolia/client-common": "5.49.2", + "@algolia/requester-browser-xhr": "5.49.2", + "@algolia/requester-fetch": "5.49.2", + "@algolia/requester-node-http": "5.49.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.49.1.tgz", - "integrity": "sha512-h2yz3AGeGkQwNgbLmoe3bxYs8fac4An1CprKTypYyTU/k3Q+9FbIvJ8aS1DoBKaTjSRZVoyQS7SZQio6GaHbZw==", + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.49.2.tgz", + "integrity": "sha512-K7Gp5u+JtVYgaVpBxF5rGiM+Ia8SsMdcAJMTDV93rwh00DKNllC19o1g+PwrDjDvyXNrnTEbofzbTs2GLfFyKA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" + "@algolia/client-common": "5.49.2", + "@algolia/requester-browser-xhr": "5.49.2", + "@algolia/requester-fetch": "5.49.2", + "@algolia/requester-node-http": "5.49.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.49.1.tgz", - "integrity": "sha512-2UPyRuUR/qpqSqH8mxFV5uBZWEpxhGPHLlx9Xf6OVxr79XO2ctzZQAhsmTZ6X22x+N8MBWpB9UEky7YU2HGFgA==", + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.49.2.tgz", + "integrity": "sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1" + "@algolia/client-common": "5.49.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.49.1.tgz", - "integrity": "sha512-N+xlE4lN+wpuT+4vhNEwPVlrfN+DWAZmSX9SYhbz986Oq8AMsqdntOqUyiOXVxYsQtfLwmiej24vbvJGYv1Qtw==", + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.49.2.tgz", + "integrity": "sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1" + "@algolia/client-common": "5.49.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.49.1.tgz", - "integrity": "sha512-zA5bkUOB5PPtTr182DJmajCiizHp0rCJQ0Chf96zNFvkdESKYlDeYA3tQ7r2oyHbu/8DiohAQ5PZ85edctzbXA==", + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.49.2.tgz", + "integrity": "sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.49.1" + "@algolia/client-common": "5.49.2" }, "engines": { "node": ">= 14.0.0" @@ -3833,17 +3841,17 @@ } }, "node_modules/@commitlint/cli": { - "version": "20.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.4.2.tgz", - "integrity": "sha512-YjYSX2yj/WsVoxh9mNiymfFS2ADbg2EK4+1WAsMuckwKMCqJ5PDG0CJU/8GvmHWcv4VRB2V02KqSiecRksWqZQ==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.4.4.tgz", + "integrity": "sha512-GLMNQHYGcn0ohL2HMlAnXcD1PS2vqBBGbYKlhrRPOYsWiRoLWtrewsR3uKRb9v/IdS+qOS0vqJQ64n1g8VPKFw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/format": "^20.4.0", - "@commitlint/lint": "^20.4.2", - "@commitlint/load": "^20.4.0", - "@commitlint/read": "^20.4.0", - "@commitlint/types": "^20.4.0", + "@commitlint/format": "^20.4.4", + "@commitlint/lint": "^20.4.4", + "@commitlint/load": "^20.4.4", + "@commitlint/read": "^20.4.4", + "@commitlint/types": "^20.4.4", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, @@ -3855,27 +3863,27 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "20.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.4.2.tgz", - "integrity": "sha512-rwkTF55q7Q+6dpSKUmJoScV0f3EpDlWKw2UPzklkLS4o5krMN1tPWAVOgHRtyUTMneIapLeQwaCjn44Td6OzBQ==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.4.4.tgz", + "integrity": "sha512-Usg+XXbPNG2GtFWTgRURNWCge1iH1y6jQIvvklOdAbyn2t8ajfVwZCnf5t5X4gUsy17BOiY+myszGsSMIvhOVA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.0", - "conventional-changelog-conventionalcommits": "^9.1.0" + "@commitlint/types": "^20.4.4", + "conventional-changelog-conventionalcommits": "^9.2.0" }, "engines": { "node": ">=v18" } }, "node_modules/@commitlint/config-validator": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.4.0.tgz", - "integrity": "sha512-zShmKTF+sqyNOfAE0vKcqnpvVpG0YX8F9G/ZIQHI2CoKyK+PSdladXMSns400aZ5/QZs+0fN75B//3Q5CHw++w==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.4.4.tgz", + "integrity": "sha512-K8hMS9PTLl7EYe5vWtSFQ/sgsV2PHUOtEnosg8k3ZQxCyfKD34I4C7FxWEfRTR54rFKeUYmM3pmRQqBNQeLdlw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.0", + "@commitlint/types": "^20.4.4", "ajv": "^8.11.0" }, "engines": { @@ -3883,9 +3891,9 @@ } }, "node_modules/@commitlint/config-validator/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -3907,13 +3915,13 @@ "license": "MIT" }, "node_modules/@commitlint/ensure": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.4.1.tgz", - "integrity": "sha512-WLQqaFx1pBooiVvBrA1YfJNFqZF8wS/YGOtr5RzApDbV9tQ52qT5VkTsY65hFTnXhW8PcDfZLaknfJTmPejmlw==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.4.4.tgz", + "integrity": "sha512-QivV0M1MGL867XCaF+jJkbVXEPKBALhUUXdjae66hes95aY1p3vBJdrcl3x8jDv2pdKWvIYIz+7DFRV/v0dRkA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.0", + "@commitlint/types": "^20.4.4", "lodash.camelcase": "^4.3.0", "lodash.kebabcase": "^4.1.1", "lodash.snakecase": "^4.1.1", @@ -3935,13 +3943,13 @@ } }, "node_modules/@commitlint/format": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.4.0.tgz", - "integrity": "sha512-i3ki3WR0rgolFVX6r64poBHXM1t8qlFel1G1eCBvVgntE3fCJitmzSvH5JD/KVJN/snz6TfaX2CLdON7+s4WVQ==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.4.4.tgz", + "integrity": "sha512-jLi/JBA4GEQxc5135VYCnkShcm1/rarbXMn2Tlt3Si7DHiiNKHm4TaiJCLnGbZ1r8UfwDRk+qrzZ80kwh08Aow==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.0", + "@commitlint/types": "^20.4.4", "picocolors": "^1.1.1" }, "engines": { @@ -3949,13 +3957,13 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.4.1.tgz", - "integrity": "sha512-In5EO4JR1lNsAv1oOBBO24V9ND1IqdAJDKZiEpdfjDl2HMasAcT7oA+5BKONv1pRoLG380DGPE2W2RIcUwdgLA==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.4.4.tgz", + "integrity": "sha512-y76rT8yq02x+pMDBI2vY4y/ByAwmJTkta/pASbgo8tldBiKLduX8/2NCRTSCjb3SumE5FBeopERKx3oMIm8RTQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.0", + "@commitlint/types": "^20.4.4", "semver": "^7.6.0" }, "engines": { @@ -3963,33 +3971,33 @@ } }, "node_modules/@commitlint/lint": { - "version": "20.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.4.2.tgz", - "integrity": "sha512-buquzNRtFng6xjXvBU1abY/WPEEjCgUipNQrNmIWe8QuJ6LWLtei/LDBAzEe5ASm45+Q9L2Xi3/GVvlj50GAug==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.4.4.tgz", + "integrity": "sha512-svOEW+RptcNpXKE7UllcAsV0HDIdOck9reC2TP1QA6K5Fo0xxQV+QPjV8Zqx9g6X/hQBkF2S9ZQZ78Xrv1Eiog==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^20.4.1", - "@commitlint/parse": "^20.4.1", - "@commitlint/rules": "^20.4.2", - "@commitlint/types": "^20.4.0" + "@commitlint/is-ignored": "^20.4.4", + "@commitlint/parse": "^20.4.4", + "@commitlint/rules": "^20.4.4", + "@commitlint/types": "^20.4.4" }, "engines": { "node": ">=v18" } }, "node_modules/@commitlint/load": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.4.0.tgz", - "integrity": "sha512-Dauup/GfjwffBXRJUdlX/YRKfSVXsXZLnINXKz0VZkXdKDcaEILAi9oflHGbfydonJnJAbXEbF3nXPm9rm3G6A==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.4.4.tgz", + "integrity": "sha512-kvFrzvoIACa/fMjXEP0LNEJB1joaH3q3oeMJsLajXE5IXjYrNGVcW1ZFojXUruVJ7odTZbC3LdE/6+ONW4f2Dg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.4.0", + "@commitlint/config-validator": "^20.4.4", "@commitlint/execute-rule": "^20.0.0", - "@commitlint/resolve-extends": "^20.4.0", - "@commitlint/types": "^20.4.0", - "cosmiconfig": "^9.0.0", + "@commitlint/resolve-extends": "^20.4.4", + "@commitlint/types": "^20.4.4", + "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "is-plain-obj": "^4.1.0", "lodash.mergewith": "^4.6.2", @@ -4000,9 +4008,9 @@ } }, "node_modules/@commitlint/load/node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4055,9 +4063,9 @@ } }, "node_modules/@commitlint/message": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.0.tgz", - "integrity": "sha512-B5lGtvHgiLAIsK5nLINzVW0bN5hXv+EW35sKhYHE8F7V9Uz1fR4tx3wt7mobA5UNhZKUNgB/+ldVMQE6IHZRyA==", + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.3.tgz", + "integrity": "sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==", "dev": true, "license": "MIT", "engines": { @@ -4065,30 +4073,30 @@ } }, "node_modules/@commitlint/parse": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.4.1.tgz", - "integrity": "sha512-XNtZjeRcFuAfUnhYrCY02+mpxwY4OmnvD3ETbVPs25xJFFz1nRo/25nHj+5eM+zTeRFvWFwD4GXWU2JEtoK1/w==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.4.4.tgz", + "integrity": "sha512-AjfgOgrjEozeQNzhFu1KL5N0nDx4JZmswVJKNfOTLTUGp6xODhZHCHqb//QUHKOzx36If5DQ7tci2o7szYxu1A==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.0", - "conventional-changelog-angular": "^8.1.0", - "conventional-commits-parser": "^6.2.1" + "@commitlint/types": "^20.4.4", + "conventional-changelog-angular": "^8.2.0", + "conventional-commits-parser": "^6.3.0" }, "engines": { "node": ">=v18" } }, "node_modules/@commitlint/read": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.4.0.tgz", - "integrity": "sha512-QfpFn6/I240ySEGv7YWqho4vxqtPpx40FS7kZZDjUJ+eHxu3azfhy7fFb5XzfTqVNp1hNoI3tEmiEPbDB44+cg==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.4.4.tgz", + "integrity": "sha512-jvgdAQDdEY6L8kCxOo21IWoiAyNFzvrZb121wU2eBxI1DzWAUZgAq+a8LlJRbT0Qsj9INhIPVWgdaBbEzlF0dQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/top-level": "^20.4.0", - "@commitlint/types": "^20.4.0", - "git-raw-commits": "^4.0.0", + "@commitlint/top-level": "^20.4.3", + "@commitlint/types": "^20.4.4", + "git-raw-commits": "^5.0.0", "minimist": "^1.2.8", "tinyexec": "^1.0.0" }, @@ -4097,14 +4105,14 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.4.0.tgz", - "integrity": "sha512-ay1KM8q0t+/OnlpqXJ+7gEFQNlUtSU5Gxr8GEwnVf2TPN3+ywc5DzL3JCxmpucqxfHBTFwfRMXxPRRnR5Ki20g==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.4.4.tgz", + "integrity": "sha512-pyOf+yX3c3m/IWAn2Jop+7s0YGKPQ8YvQaxt9IQxnLIM3yZAlBdkKiQCT14TnrmZTkVGTXiLtckcnFTXYwlY0A==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.4.0", - "@commitlint/types": "^20.4.0", + "@commitlint/config-validator": "^20.4.4", + "@commitlint/types": "^20.4.4", "global-directory": "^4.0.1", "import-meta-resolve": "^4.0.0", "lodash.mergewith": "^4.6.2", @@ -4125,16 +4133,16 @@ } }, "node_modules/@commitlint/rules": { - "version": "20.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.4.2.tgz", - "integrity": "sha512-oz83pnp5Yq6uwwTAabuVQPNlPfeD2Y5ZjMb7Wx8FSUlu4sLYJjbBWt8031Z0osCFPfHzAwSYrjnfDFKtuSMdKg==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.4.4.tgz", + "integrity": "sha512-PmUp8QPLICn9w05dAx5r1rdOYoTk7SkfusJJh5tP3TqHwo2mlQ9jsOm8F0HSXU9kuLfgTEGNrunAx/dlK/RyPQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^20.4.1", - "@commitlint/message": "^20.4.0", + "@commitlint/ensure": "^20.4.4", + "@commitlint/message": "^20.4.3", "@commitlint/to-lines": "^20.0.0", - "@commitlint/types": "^20.4.0" + "@commitlint/types": "^20.4.4" }, "engines": { "node": ">=v18" @@ -4151,9 +4159,9 @@ } }, "node_modules/@commitlint/top-level": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.0.tgz", - "integrity": "sha512-NDzq8Q6jmFaIIBC/GG6n1OQEaHdmaAAYdrZRlMgW6glYWGZ+IeuXmiymDvQNXPc82mVxq2KiE3RVpcs+1OeDeA==", + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.3.tgz", + "integrity": "sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4164,13 +4172,13 @@ } }, "node_modules/@commitlint/types": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.4.0.tgz", - "integrity": "sha512-aO5l99BQJ0X34ft8b0h7QFkQlqxC6e7ZPVmBKz13xM9O8obDaM1Cld4sQlJDXXU/VFuUzQ30mVtHjVz74TuStw==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.4.4.tgz", + "integrity": "sha512-dwTGzyAblFXHJNBOgrTuO5Ee48ioXpS5XPRLLatxhQu149DFAHUcB3f0Q5eea3RM4USSsP1+WVT2dBtLVod4fg==", "dev": true, "license": "MIT", "dependencies": { - "conventional-commits-parser": "^6.2.1", + "conventional-commits-parser": "^6.3.0", "picocolors": "^1.1.1" }, "engines": { @@ -4188,9 +4196,9 @@ } }, "node_modules/@contentful/live-preview": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@contentful/live-preview/-/live-preview-4.9.6.tgz", - "integrity": "sha512-CSOzMLRlIK5MzbLfBTJQHfmgzXo68Sgl4WebPXOGVlCrNPn75PgFJnIiIlVlTz+H5m+T15bfVJmIOvbE3wputA==", + "version": "4.9.10", + "resolved": "https://registry.npmjs.org/@contentful/live-preview/-/live-preview-4.9.10.tgz", + "integrity": "sha512-zrp4cRJd4cyHsvDvFFC8C20T0WHgnRyYmqH3tEPjKwjXbI+XBPxxBBkH7btm6tcJibz+tEvkDCuXQDYZ488m8w==", "license": "MIT", "dependencies": { "@contentful/content-source-maps": "^0.12.1", @@ -4246,6 +4254,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@conventional-changelog/git-client": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.6.0.tgz", + "integrity": "sha512-T+uPDciKf0/ioNNDpMGc8FDsehJClZP0yR3Q5MN6wE/Y/1QZ7F+80OgznnTCOlMEG4AV0LvH2UJi3C/nBnaBUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/child-process-utils": "^1.0.0", + "@simple-libs/stream-utils": "^1.2.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.3.0" + }, + "peerDependenciesMeta": { + "conventional-commits-filter": { + "optional": true + }, + "conventional-commits-parser": { + "optional": true + } + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -6412,9 +6447,9 @@ } }, "node_modules/@easyops-cn/docusaurus-search-local": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@easyops-cn/docusaurus-search-local/-/docusaurus-search-local-0.55.0.tgz", - "integrity": "sha512-pmyG+e9KZmo4wrufsneeoE2KG2zH9tbRGi0crJFY0kPxOTGSLeuU5w058Qzgpz8vZNui6i59lKjrlQtnXNBgog==", + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/@easyops-cn/docusaurus-search-local/-/docusaurus-search-local-0.55.1.tgz", + "integrity": "sha512-jmBKj1J+tajqNrCvECwKCQYTWwHVZDGApy8lLOYEPe+Dm0/f3Ccdw8BP5/OHNpltr7WDNY2roQXn+TWn2f1kig==", "license": "MIT", "dependencies": { "@docusaurus/plugin-content-docs": "^2 || ^3", @@ -7048,15 +7083,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7074,9 +7109,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -7113,20 +7148,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -7136,6 +7171,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -7161,9 +7213,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -7174,9 +7226,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -12160,9 +12212,9 @@ } }, "node_modules/@medusajs/js-sdk": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/@medusajs/js-sdk/-/js-sdk-2.13.2.tgz", - "integrity": "sha512-xuumyd35oX2TeIsN5awmZZivg7NR9F89Y9QUuapoQjuT6NyjrTvGQZ5CzUkdYig1Mh5j73exQUJk5Oy7J9hjeQ==", + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/@medusajs/js-sdk/-/js-sdk-2.13.4.tgz", + "integrity": "sha512-OpkGSi0jz5XbywKCGtHhD96zTnE9fVVb+tOuxJWL42cUcdWXOUZ/q4nes7r1bp+JXb67yeDglptd3rdrjtR6cg==", "license": "MIT", "dependencies": { "fetch-event-stream": "^0.1.5", @@ -12190,6 +12242,24 @@ "moo": "^0.5.1" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@napi-rs/nice": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", @@ -13512,6 +13582,10 @@ "resolved": "packages/blocks/bento-grid", "link": true }, + "node_modules/@o2s/blocks.cart": { + "resolved": "packages/blocks/cart", + "link": true + }, "node_modules/@o2s/blocks.category": { "resolved": "packages/blocks/category", "link": true @@ -13520,6 +13594,22 @@ "resolved": "packages/blocks/category-list", "link": true }, + "node_modules/@o2s/blocks.checkout-billing-payment": { + "resolved": "packages/blocks/checkout-billing-payment", + "link": true + }, + "node_modules/@o2s/blocks.checkout-company-data": { + "resolved": "packages/blocks/checkout-company-data", + "link": true + }, + "node_modules/@o2s/blocks.checkout-shipping-address": { + "resolved": "packages/blocks/checkout-shipping-address", + "link": true + }, + "node_modules/@o2s/blocks.checkout-summary": { + "resolved": "packages/blocks/checkout-summary", + "link": true + }, "node_modules/@o2s/blocks.cta-section": { "resolved": "packages/blocks/cta-section", "link": true @@ -13568,6 +13658,10 @@ "resolved": "packages/blocks/notification-summary", "link": true }, + "node_modules/@o2s/blocks.order-confirmation": { + "resolved": "packages/blocks/order-confirmation", + "link": true + }, "node_modules/@o2s/blocks.order-details": { "resolved": "packages/blocks/order-details", "link": true @@ -13780,6 +13874,31 @@ "node": ">=20.0" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -16965,6 +17084,35 @@ "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" }, + "node_modules/@simple-libs/child-process-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", + "integrity": "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -17034,9 +17182,9 @@ "license": "MIT" }, "node_modules/@storybook/addon-a11y": { - "version": "10.2.13", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.2.13.tgz", - "integrity": "sha512-zuR1n1xgWoieEnr6E5xdTR40BI61IBQahgmsRpTvqRffL3mxAs5aFoORDmA5pZWI2LE9URdMkY85h218ijuLiw==", + "version": "10.2.19", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.2.19.tgz", + "integrity": "sha512-SJGf1ghCoRVlwyiRwz5GiHuNvu7C5iCDNIRJW8WGOJlnoQa3rYaY7WJ/8a/eT9N8buIscL9AYWudhh5zsI1W3g==", "dev": true, "license": "MIT", "dependencies": { @@ -17048,20 +17196,20 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.13" + "storybook": "^10.2.19" } }, "node_modules/@storybook/addon-docs": { - "version": "10.2.13", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.13.tgz", - "integrity": "sha512-puMxpJbt/CuodLIbKDxWrW1ZgADYomfNHWEKp2d2l2eJjp17rADx0h3PABuNbX+YHbJwYcDdqluSnQwMysFEOA==", + "version": "10.2.19", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.19.tgz", + "integrity": "sha512-tXugthdzjX5AkGWDSP4pnRgA/CWlOaEKp/+y9JOGXHLQmm1GHjW+4brNvNkKbjBl06LALXwlcTOyU4lyVRDLAw==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.2.13", + "@storybook/csf-plugin": "10.2.19", "@storybook/icons": "^2.0.1", - "@storybook/react-dom-shim": "10.2.13", + "@storybook/react-dom-shim": "10.2.19", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -17071,13 +17219,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.13" + "storybook": "^10.2.19" } }, "node_modules/@storybook/addon-themes": { - "version": "10.2.13", - "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-10.2.13.tgz", - "integrity": "sha512-ueOGGy7ZXgFp+GFo67HfWSCoNIv1+z+nHiSUmkZP/GHZ/1yiD/w8Sv0bEI1HjD/whCdoOzDKNcVXfiJAFdHoGw==", + "version": "10.2.19", + "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-10.2.19.tgz", + "integrity": "sha512-TzcX/aqzZrQUypDATywLOenVoa1CTXBthODoY9odLsLLrxVaoeqsAdulkmOjeppKR1FigcERyIjIWPB8W48dag==", "dev": true, "license": "MIT", "dependencies": { @@ -17088,13 +17236,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.13" + "storybook": "^10.2.19" } }, "node_modules/@storybook/addon-vitest": { - "version": "10.2.13", - "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.2.13.tgz", - "integrity": "sha512-qQD3xzxc31cQHS0loF9enGWi5sgA6zBTbaJ0HuSUNGO81iwfLSALh8L/1vrD5NfN2vlBeUMTsgv3EkCuLfe9EQ==", + "version": "10.2.19", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.2.19.tgz", + "integrity": "sha512-mx7B8QBT4YFLJ6+30rVxRfJNMDrIfVjJBWtdhP4mbZCS2IuoKAqf28KaC4ZZ4/UWAxjA/8VSQrV9CpXArepMlg==", "dev": true, "license": "MIT", "dependencies": { @@ -17109,7 +17257,7 @@ "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", - "storybook": "^10.2.13", + "storybook": "^10.2.19", "vitest": "^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { @@ -17128,13 +17276,13 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "10.2.13", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.2.13.tgz", - "integrity": "sha512-UMlPPPBa5ZbcaCXSKrFIi4tTEb0W72JTByqlJ5cGtDXGkN2uX69aL5n2JLIP0F4NzRRl6rNTeu9tGPPcD4r/CA==", + "version": "10.2.19", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.2.19.tgz", + "integrity": "sha512-a59xALzM9GeYh6p+wzAeBbDyIe+qyrC4nxS3QNzb5i2ZOhrq1iIpvnDaOWe80NC8mV3IlqUEGY8Uawkf//1Rmg==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "10.2.13", + "@storybook/csf-plugin": "10.2.19", "ts-dedent": "^2.0.0" }, "funding": { @@ -17142,8 +17290,8 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.13", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + "storybook": "^10.2.19", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@storybook/builder-webpack5/node_modules/ajv": { @@ -17219,9 +17367,9 @@ } }, "node_modules/@storybook/csf-plugin": { - "version": "10.2.13", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.13.tgz", - "integrity": "sha512-gUCR7PmyrWYj3dIJJgxOm25dcXFolPIUPmug3z90Aaon7YPXw3pUN+dNDx8KqDJqRK1WDIB4HaefgYZIm5V7iA==", + "version": "10.2.19", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.19.tgz", + "integrity": "sha512-BpjYIOdyQn/Rm6MjUAc5Gl8HlARZrskD/OhUNShiOh2fznb523dHjiE5mbU1kKM/+L1uvRlEqqih40rTx+xCrg==", "dev": true, "license": "MIT", "dependencies": { @@ -17234,7 +17382,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "^10.2.13", + "storybook": "^10.2.19", "vite": "*", "webpack": "*" }, @@ -17272,15 +17420,15 @@ } }, "node_modules/@storybook/nextjs-vite": { - "version": "10.2.13", - "resolved": "https://registry.npmjs.org/@storybook/nextjs-vite/-/nextjs-vite-10.2.13.tgz", - "integrity": "sha512-jsx7lIHkg6EZw1CkEGPFwiiOmyU2Jlg621uMKkA/zXfvvnV/OBv+xYRu/qvKwD9XsAmPqfcSs/SPEA+X8G4+FA==", + "version": "10.2.19", + "resolved": "https://registry.npmjs.org/@storybook/nextjs-vite/-/nextjs-vite-10.2.19.tgz", + "integrity": "sha512-K8w3L9dprm1XathTYWSMx6KBsyyBs07GkrHz0SPpalvibmre0i9YzTGSD0LpdSKtjGfwpsvwunY3RajMs66FvA==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/builder-vite": "10.2.13", - "@storybook/react": "10.2.13", - "@storybook/react-vite": "10.2.13", + "@storybook/builder-vite": "10.2.19", + "@storybook/react": "10.2.19", + "@storybook/react-vite": "10.2.19", "styled-jsx": "5.1.6", "vite-plugin-storybook-nextjs": "^3.1.9" }, @@ -17292,8 +17440,8 @@ "next": "^14.1.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.13", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + "storybook": "^10.2.19", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -17302,14 +17450,14 @@ } }, "node_modules/@storybook/react": { - "version": "10.2.13", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.2.13.tgz", - "integrity": "sha512-gavZbGMkrjR53a6gSaBJPCelXQf8Rumpej9Jm6HdrAYlEJgFssPah5Frbar9yVCZiXiZkFLfAu7RkZzZhnGyZg==", + "version": "10.2.19", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.2.19.tgz", + "integrity": "sha512-gm2qxLyYSsGp7fee5i+d8jSVUKMla8yRaTJ1wxPEnyaJMd0QUu6U2v3p2rW7PH1DWop3D6NqWOY8kmZjmSZKlA==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.2.13", + "@storybook/react-dom-shim": "10.2.19", "react-docgen": "^8.0.2" }, "funding": { @@ -17319,7 +17467,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.13", + "storybook": "^10.2.19", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -17329,9 +17477,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.2.13", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.13.tgz", - "integrity": "sha512-ZSduoB10qTI0V9z22qeULmQLsvTs8d/rtJi03qbVxpPiMRor86AmyAaBrfhGGmWBxWQZpOGQQm6yIT2YLoPs7w==", + "version": "10.2.19", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.19.tgz", + "integrity": "sha512-BXCEfBGVBRYBTYeBeH/PJsy0Bq5MERe/HiaylR+ah/XrvIr2Z9bkne1J8yYiXCjiyq5HQa7Bj11roz0+vyUaEw==", "dev": true, "license": "MIT", "funding": { @@ -17341,20 +17489,20 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.13" + "storybook": "^10.2.19" } }, "node_modules/@storybook/react-vite": { - "version": "10.2.13", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.2.13.tgz", - "integrity": "sha512-SHpp3sK0kUb+bch4L9uo+EBScwbI3vsKEJqFf8f7oRXbPXocI5RwLoQ8Pw8IseIF4x9bYiPM8JRHtLJb3kFIxQ==", + "version": "10.2.19", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.2.19.tgz", + "integrity": "sha512-2/yMKrK4IqMIZicRpPMoIg+foBuWnkaEWt0R4V4hjErDj/SC3D9ov+GUqhjKJ81TegijhKzNpwnSD7Nf87haKw==", "dev": true, "license": "MIT", "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.4", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "10.2.13", - "@storybook/react": "10.2.13", + "@storybook/builder-vite": "10.2.19", + "@storybook/react": "10.2.19", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.0", @@ -17368,8 +17516,8 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.13", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + "storybook": "^10.2.19", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@storybook/react-vite/node_modules/tsconfig-paths": { @@ -19444,6 +19592,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/string-template": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/string-template/-/string-template-1.0.7.tgz", @@ -21557,25 +21712,25 @@ } }, "node_modules/algoliasearch": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.49.1.tgz", - "integrity": "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==", - "license": "MIT", - "dependencies": { - "@algolia/abtesting": "1.15.1", - "@algolia/client-abtesting": "5.49.1", - "@algolia/client-analytics": "5.49.1", - "@algolia/client-common": "5.49.1", - "@algolia/client-insights": "5.49.1", - "@algolia/client-personalization": "5.49.1", - "@algolia/client-query-suggestions": "5.49.1", - "@algolia/client-search": "5.49.1", - "@algolia/ingestion": "1.49.1", - "@algolia/monitoring": "1.49.1", - "@algolia/recommend": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.49.2.tgz", + "integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==", + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.15.2", + "@algolia/client-abtesting": "5.49.2", + "@algolia/client-analytics": "5.49.2", + "@algolia/client-common": "5.49.2", + "@algolia/client-insights": "5.49.2", + "@algolia/client-personalization": "5.49.2", + "@algolia/client-query-suggestions": "5.49.2", + "@algolia/client-search": "5.49.2", + "@algolia/ingestion": "1.49.2", + "@algolia/monitoring": "1.49.2", + "@algolia/recommend": "5.49.2", + "@algolia/requester-browser-xhr": "5.49.2", + "@algolia/requester-fetch": "5.49.2", + "@algolia/requester-node-http": "5.49.2" }, "engines": { "node": ">= 14.0.0" @@ -22111,9 +22266,9 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -24613,9 +24768,9 @@ } }, "node_modules/conventional-changelog-angular": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.1.0.tgz", - "integrity": "sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.0.tgz", + "integrity": "sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==", "dev": true, "license": "ISC", "dependencies": { @@ -24626,9 +24781,9 @@ } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.1.0.tgz", - "integrity": "sha512-MnbEysR8wWa8dAEvbj5xcBgJKQlX/m0lhS8DsyAAWDHdfs2faDJxTgzRYlRYpXSe7UiKrIIlB4TrBKU9q9DgkA==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.0.tgz", + "integrity": "sha512-kYFx6gAyjSIMwNtASkI3ZE99U1fuVDJr0yTYgVy+I2QG46zNZfl2her+0+eoviG82c5WQvW1jMt1eOQTeJLodA==", "dev": true, "license": "ISC", "dependencies": { @@ -24639,12 +24794,13 @@ } }, "node_modules/conventional-commits-parser": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.2.1.tgz", - "integrity": "sha512-20pyHgnO40rvfI0NGF/xiEoFMkXDtkF8FwHvk5BokoFoCuTQRI8vrNCNFWUOfuolKJMm1tPCHc8GgYEtr1XRNA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.3.0.tgz", + "integrity": "sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==", "dev": true, "license": "MIT", "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { @@ -25927,19 +26083,6 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, - "node_modules/dargs": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", - "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -26027,9 +26170,9 @@ "license": "MIT" }, "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, "node_modules/debounce": { @@ -27347,25 +27490,25 @@ } }, "node_modules/eslint": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", - "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.3", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -27384,7 +27527,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -27907,6 +28050,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -27962,9 +28122,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -29026,9 +29186,10 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==" + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "license": "ISC" }, "node_modules/fn.name": { "version": "1.1.0", @@ -29379,9 +29540,9 @@ "license": "MIT" }, "node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -29616,21 +29777,33 @@ } }, "node_modules/git-raw-commits": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", - "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", + "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", "dev": true, "license": "MIT", "dependencies": { - "dargs": "^8.0.0", - "meow": "^12.0.1", - "split2": "^4.0.0" + "@conventional-changelog/git-client": "^2.6.0", + "meow": "^13.0.0" }, "bin": { - "git-raw-commits": "cli.mjs" + "git-raw-commits": "src/cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" + } + }, + "node_modules/git-raw-commits/node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/github-slugger": { @@ -30335,6 +30508,13 @@ "upper-case": "^1.1.3" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/helmet": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", @@ -31607,6 +31787,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-npm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz", @@ -38082,6 +38269,101 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/msw": { + "version": "2.12.11", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.11.tgz", + "integrity": "sha512-dVg20zi2I2EvnwH/+WupzsOC2mCa7qsIhyMAWtfRikn6RKtwL9+7SaF1IQ5LyZry4tlUtf6KyTVhnlQiZXozTQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw-storybook-addon": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-2.0.6.tgz", + "integrity": "sha512-ExCwDbcJoM2V3iQU+fZNp+axVfNc7DWMRh4lyTXebDO8IbpUNYKGFUrA8UqaeWiRGKVuS7+fU+KXEa9b0OP6uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.0.1" + }, + "peerDependencies": { + "msw": "^2.0.0" + } + }, + "node_modules/msw/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/msw/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/multer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", @@ -38591,9 +38873,9 @@ } }, "node_modules/npm-check-updates": { - "version": "19.6.2", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-19.6.2.tgz", - "integrity": "sha512-fxoQAhn90dx/pLOHY0w3U0IzW02DGDgkNwjTsPpHexo3niLxkL3xAggTQNjODMwTtWoRhP7rhnm7ji0hw/F1kA==", + "version": "19.6.3", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-19.6.3.tgz", + "integrity": "sha512-VAt9Bp26eLaymZ0nZyh5n/by+YZIuegXlvWR0yv1zBqd984f8VnEnBbn+1lS3nN5LyEjn62BJ+yYgzNSpb6Gzg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -39340,6 +39622,13 @@ "integrity": "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==", "license": "MIT" }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -40159,9 +40448,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -43270,6 +43559,13 @@ "node": ">= 4" } }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -44792,16 +45088,6 @@ "wbuf": "^1.7.3" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/sponge-case": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-1.0.1.tgz", @@ -44890,9 +45176,9 @@ } }, "node_modules/storybook": { - "version": "10.2.13", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.13.tgz", - "integrity": "sha512-heMfJjOfbHvL+wlCAwFZlSxcakyJ5yQDam6e9k2RRArB1veJhRnsjO6lO1hOXjJYrqxfHA/ldIugbBVlCDqfvQ==", + "version": "10.2.19", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.19.tgz", + "integrity": "sha512-UUm5eGSm6BLhkcFP0WbxkmAHJZfVN2ViLpIZOqiIPS++q32VYn+CLFC0lrTYTDqYvaG7i4BK4uowXYujzE4NdQ==", "dev": true, "license": "MIT", "dependencies": { @@ -44999,6 +45285,13 @@ "text-decoder": "^1.1.0" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -46357,6 +46650,26 @@ "upper-case": "^1.0.3" } }, + "node_modules/tldts": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz", + "integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.24" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz", + "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -46426,6 +46739,19 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -47653,6 +47979,16 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/unzipper": { "version": "0.12.3", "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", @@ -50110,7 +50446,7 @@ }, "packages/blocks/article": { "name": "@o2s/blocks.article", - "version": "1.5.0", + "version": "1.5.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50128,7 +50464,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50150,7 +50486,7 @@ }, "packages/blocks/article-list": { "name": "@o2s/blocks.article-list", - "version": "1.6.0", + "version": "1.6.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50168,7 +50504,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50190,7 +50526,7 @@ }, "packages/blocks/article-search": { "name": "@o2s/blocks.article-search", - "version": "1.6.0", + "version": "1.6.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50209,7 +50545,208 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", + "prettier": "^3.8.1", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/throttle-debounce": "^5.0.2", + "next": "^16.0.5", + "next-intl": "^4.1.0", + "react": "^19", + "react-dom": "^19", + "rxjs": "^7", + "tailwindcss": "^4" + } + }, + "packages/blocks/bento-grid": { + "name": "@o2s/blocks.bento-grid", + "version": "0.6.1", + "license": "MIT", + "dependencies": { + "@o2s/configs.integrations": "*", + "@o2s/framework": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/utils.logger": "*" + }, + "devDependencies": { + "@o2s/eslint-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "dotenv-cli": "^11.0.0", + "eslint": "^9.39.4", + "prettier": "^3.8.1", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "@types/react": "^19", + "@types/react-dom": "^19", + "next": "^16.0.5", + "next-intl": "^4.1.0", + "react": "^19", + "react-dom": "^19", + "rxjs": "^7", + "tailwindcss": "^4" + } + }, + "packages/blocks/cart": { + "name": "@o2s/blocks.cart", + "version": "0.1.1", + "license": "MIT", + "dependencies": { + "@o2s/configs.integrations": "*", + "@o2s/framework": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/utils.logger": "*" + }, + "devDependencies": { + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "dotenv-cli": "^11.0.0", + "eslint": "^9.39.4", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "@types/react": "^19", + "@types/react-dom": "^19", + "next": "^16.0.5", + "next-intl": "^4.1.0", + "react": "^19", + "react-dom": "^19", + "rxjs": "^7", + "tailwindcss": "^4" + } + }, + "packages/blocks/cart-summary": { + "name": "@o2s/blocks.cart-summary", + "version": "0.0.1", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@o2s/configs.integrations": "*", + "@o2s/framework": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/utils.logger": "*" + }, + "devDependencies": { + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "dotenv-cli": "^11.0.0", + "eslint": "^9.39.1", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "@types/react": "^19", + "@types/react-dom": "^19", + "next": "^16.0.5", + "next-intl": "^4.1.0", + "react": "^19", + "react-dom": "^19", + "rxjs": "^7", + "tailwindcss": "^4" + } + }, + "packages/blocks/category": { + "name": "@o2s/blocks.category", + "version": "1.6.1", + "license": "MIT", + "dependencies": { + "@o2s/configs.integrations": "*", + "@o2s/framework": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/utils.logger": "*" + }, + "devDependencies": { + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "dotenv-cli": "^11.0.0", + "eslint": "^9.39.4", + "prettier": "^3.8.1", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "@types/react": "^19", + "@types/react-dom": "^19", + "next": "^16.0.5", + "next-intl": "^4.1.0", + "react": "^19", + "react-dom": "^19", + "rxjs": "^7", + "tailwindcss": "^4" + } + }, + "packages/blocks/category-list": { + "name": "@o2s/blocks.category-list", + "version": "1.6.1", + "license": "MIT", + "dependencies": { + "@o2s/configs.integrations": "*", + "@o2s/framework": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/utils.logger": "*" + }, + "devDependencies": { + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "dotenv-cli": "^11.0.0", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50221,7 +50758,6 @@ "@nestjs/core": "^11", "@types/react": "^19", "@types/react-dom": "^19", - "@types/throttle-debounce": "^5.0.2", "next": "^16.0.5", "next-intl": "^4.1.0", "react": "^19", @@ -50230,9 +50766,9 @@ "tailwindcss": "^4" } }, - "packages/blocks/bento-grid": { - "name": "@o2s/blocks.bento-grid", - "version": "0.6.0", + "packages/blocks/checkout-billing-payment": { + "name": "@o2s/blocks.checkout-billing-payment", + "version": "0.1.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50240,17 +50776,20 @@ "@o2s/ui": "*", "@o2s/utils.api-harmonization": "*", "@o2s/utils.frontend": "*", - "@o2s/utils.logger": "*" + "@o2s/utils.logger": "*", + "formik": "^2.4.9", + "yup": "^1.7.1" }, "devDependencies": { "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", - "prettier": "^3.8.1", + "eslint": "^9.39.4", + "prettier": "^3.6.2", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" }, @@ -50269,9 +50808,52 @@ "tailwindcss": "^4" } }, - "packages/blocks/category": { - "name": "@o2s/blocks.category", - "version": "1.6.0", + "packages/blocks/checkout-company-data": { + "name": "@o2s/blocks.checkout-company-data", + "version": "0.1.1", + "license": "MIT", + "dependencies": { + "@o2s/configs.integrations": "*", + "@o2s/framework": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/utils.logger": "*", + "formik": "^2.4.9", + "yup": "^1.7.1" + }, + "devDependencies": { + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "dotenv-cli": "^11.0.0", + "eslint": "^9.39.4", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "@types/react": "^19", + "@types/react-dom": "^19", + "next": "^16.0.5", + "next-intl": "^4.1.0", + "react": "^19", + "react-dom": "^19", + "rxjs": "^7", + "tailwindcss": "^4" + } + }, + "packages/blocks/checkout-notes": { + "name": "@o2s/blocks.checkout-notes", + "version": "0.0.1", + "extraneous": true, "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50289,8 +50871,8 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", - "prettier": "^3.8.1", + "eslint": "^9.39.1", + "prettier": "^3.6.2", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" }, @@ -50309,9 +50891,51 @@ "tailwindcss": "^4" } }, - "packages/blocks/category-list": { - "name": "@o2s/blocks.category-list", - "version": "1.6.0", + "packages/blocks/checkout-shipping-address": { + "name": "@o2s/blocks.checkout-shipping-address", + "version": "0.1.1", + "license": "MIT", + "dependencies": { + "@o2s/configs.integrations": "*", + "@o2s/framework": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/utils.logger": "*", + "formik": "^2.4.9", + "yup": "^1.7.1" + }, + "devDependencies": { + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "dotenv-cli": "^11.0.0", + "eslint": "^9.39.4", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "@types/react": "^19", + "@types/react-dom": "^19", + "next": "^16.0.5", + "next-intl": "^4.1.0", + "react": "^19", + "react-dom": "^19", + "rxjs": "^7", + "tailwindcss": "^4" + } + }, + "packages/blocks/checkout-summary": { + "name": "@o2s/blocks.checkout-summary", + "version": "0.1.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50329,8 +50953,8 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", - "prettier": "^3.8.1", + "eslint": "^9.39.4", + "prettier": "^3.6.2", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" }, @@ -50351,7 +50975,7 @@ }, "packages/blocks/cta-section": { "name": "@o2s/blocks.cta-section", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50368,7 +50992,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50390,7 +51014,7 @@ }, "packages/blocks/document-list": { "name": "@o2s/blocks.document-list", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50407,7 +51031,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50429,7 +51053,7 @@ }, "packages/blocks/faq": { "name": "@o2s/blocks.faq", - "version": "1.5.0", + "version": "1.5.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50447,7 +51071,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50469,7 +51093,7 @@ }, "packages/blocks/feature-section": { "name": "@o2s/blocks.feature-section", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50486,7 +51110,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50508,7 +51132,7 @@ }, "packages/blocks/feature-section-grid": { "name": "@o2s/blocks.feature-section-grid", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50525,7 +51149,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50547,7 +51171,7 @@ }, "packages/blocks/featured-service-list": { "name": "@o2s/blocks.featured-service-list", - "version": "1.4.0", + "version": "1.4.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50565,7 +51189,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50587,7 +51211,7 @@ }, "packages/blocks/hero-section": { "name": "@o2s/blocks.hero-section", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50604,7 +51228,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50626,7 +51250,7 @@ }, "packages/blocks/invoice-list": { "name": "@o2s/blocks.invoice-list", - "version": "1.6.0", + "version": "1.6.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50644,7 +51268,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50664,9 +51288,50 @@ "tailwindcss": "^4" } }, + "packages/blocks/kpis": { + "name": "@o2s/blocks.kpis", + "version": "0.0.1", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@o2s/configs.integrations": "*", + "@o2s/framework": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/utils.logger": "*" + }, + "devDependencies": { + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "dotenv-cli": "^11.0.0", + "eslint": "^9.39.1", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "@types/react": "^19", + "@types/react-dom": "^19", + "next": "^16.0.5", + "next-intl": "^4.1.0", + "react": "^19", + "react-dom": "^19", + "rxjs": "^7", + "tailwindcss": "^4" + } + }, "packages/blocks/media-section": { "name": "@o2s/blocks.media-section", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50683,7 +51348,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50705,7 +51370,7 @@ }, "packages/blocks/notification-details": { "name": "@o2s/blocks.notification-details", - "version": "1.5.0", + "version": "1.5.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50723,7 +51388,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50745,7 +51410,7 @@ }, "packages/blocks/notification-list": { "name": "@o2s/blocks.notification-list", - "version": "1.6.0", + "version": "1.6.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50763,7 +51428,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50785,7 +51450,7 @@ }, "packages/blocks/notification-summary": { "name": "@o2s/blocks.notification-summary", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50802,7 +51467,7 @@ "@o2s/typescript-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50822,9 +51487,49 @@ "tailwindcss": "^4" } }, + "packages/blocks/order-confirmation": { + "name": "@o2s/blocks.order-confirmation", + "version": "0.1.1", + "license": "MIT", + "dependencies": { + "@o2s/configs.integrations": "*", + "@o2s/framework": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/utils.logger": "*" + }, + "devDependencies": { + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "dotenv-cli": "^11.0.0", + "eslint": "^9.39.4", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "@types/react": "^19", + "@types/react-dom": "^19", + "next": "^16.0.5", + "next-intl": "^4.1.0", + "react": "^19", + "react-dom": "^19", + "rxjs": "^7", + "tailwindcss": "^4" + } + }, "packages/blocks/order-details": { "name": "@o2s/blocks.order-details", - "version": "1.5.0", + "version": "1.5.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50842,7 +51547,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50864,7 +51569,7 @@ }, "packages/blocks/order-list": { "name": "@o2s/blocks.order-list", - "version": "1.6.0", + "version": "1.6.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50882,7 +51587,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50904,7 +51609,7 @@ }, "packages/blocks/orders-summary": { "name": "@o2s/blocks.orders-summary", - "version": "1.5.0", + "version": "1.5.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50922,7 +51627,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50944,7 +51649,7 @@ }, "packages/blocks/payments-history": { "name": "@o2s/blocks.payments-history", - "version": "1.4.0", + "version": "1.4.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50962,7 +51667,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -50984,7 +51689,7 @@ }, "packages/blocks/payments-summary": { "name": "@o2s/blocks.payments-summary", - "version": "1.4.0", + "version": "1.4.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51002,7 +51707,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51024,7 +51729,7 @@ }, "packages/blocks/pricing-section": { "name": "@o2s/blocks.pricing-section", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51041,7 +51746,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51063,7 +51768,7 @@ }, "packages/blocks/product-details": { "name": "@o2s/blocks.product-details", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51081,7 +51786,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "react-hook-form": "^7.71.2", "tsc-alias": "^1.8.16", @@ -51105,7 +51810,7 @@ }, "packages/blocks/product-list": { "name": "@o2s/blocks.product-list", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51122,7 +51827,7 @@ "@o2s/typescript-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51144,7 +51849,7 @@ }, "packages/blocks/quick-links": { "name": "@o2s/blocks.quick-links", - "version": "1.5.0", + "version": "1.5.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51162,7 +51867,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51184,7 +51889,7 @@ }, "packages/blocks/recommended-products": { "name": "@o2s/blocks.recommended-products", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51202,7 +51907,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51224,7 +51929,7 @@ }, "packages/blocks/service-details": { "name": "@o2s/blocks.service-details", - "version": "1.4.0", + "version": "1.4.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51242,7 +51947,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51264,7 +51969,7 @@ }, "packages/blocks/service-list": { "name": "@o2s/blocks.service-list", - "version": "1.5.0", + "version": "1.5.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51282,7 +51987,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51304,7 +52009,7 @@ }, "packages/blocks/surveyjs-form": { "name": "@o2s/blocks.surveyjs-form", - "version": "1.4.0", + "version": "1.4.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51323,7 +52028,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "survey-core": "^2.5.12", "survey-react-ui": "^2.5.12", @@ -51347,7 +52052,7 @@ }, "packages/blocks/ticket-details": { "name": "@o2s/blocks.ticket-details", - "version": "1.5.0", + "version": "1.5.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51365,7 +52070,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51387,7 +52092,7 @@ }, "packages/blocks/ticket-list": { "name": "@o2s/blocks.ticket-list", - "version": "1.7.0", + "version": "1.7.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51405,7 +52110,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51427,7 +52132,7 @@ }, "packages/blocks/ticket-recent": { "name": "@o2s/blocks.ticket-recent", - "version": "1.4.0", + "version": "1.4.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51445,7 +52150,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51467,7 +52172,7 @@ }, "packages/blocks/ticket-summary": { "name": "@o2s/blocks.ticket-summary", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51484,7 +52189,7 @@ "@o2s/typescript-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51506,7 +52211,7 @@ }, "packages/blocks/user-account": { "name": "@o2s/blocks.user-account", - "version": "1.4.0", + "version": "1.4.1", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51524,7 +52229,7 @@ "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51545,14 +52250,14 @@ } }, "packages/cli/create-o2s-app": { - "version": "2.0.0", + "version": "4.1.0", "license": "MIT", "dependencies": { - "@o2s/telemetry": "^1.2.1", + "@o2s/telemetry": "^1.2.2", "@types/prompts": "^2.4.9", "cli-progress": "^3.12.0", "commander": "^14.0.3", - "fs-extra": "^11.3.0", + "fs-extra": "^11.3.4", "kleur": "^3.0.3", "prompts": "^2.4.2", "simple-git": "^3.32.3", @@ -51568,7 +52273,7 @@ "@o2s/typescript-config": "*", "@types/cli-progress": "^3.11.6", "@types/fs-extra": "^11.0.4", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "typescript": "^5.9.3" } @@ -51578,7 +52283,7 @@ "version": "1.1.1", "license": "MIT", "devDependencies": { - "@eslint/js": "^9.39.3", + "@eslint/js": "^9.39.4", "@next/eslint-plugin-next": "^16.1.6", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", @@ -51607,7 +52312,7 @@ "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -51681,7 +52386,7 @@ }, "packages/framework": { "name": "@o2s/framework", - "version": "1.18.0", + "version": "1.20.0", "license": "MIT", "dependencies": { "@o2s/utils.logger": "*", @@ -51700,7 +52405,7 @@ "@types/express": "^5.0.6", "@types/qs": "^6.14.0", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "express": "5.2.1", "jsonwebtoken": "^9.0.3", "prettier": "^3.8.1", @@ -51756,12 +52461,12 @@ }, "packages/integrations/algolia": { "name": "@o2s/integrations.algolia", - "version": "1.6.0", + "version": "1.6.1", "license": "MIT", "dependencies": { "@o2s/framework": "*", "@o2s/utils.logger": "*", - "algoliasearch": "^5.49.1" + "algoliasearch": "^5.49.2" }, "devDependencies": { "@o2s/eslint-config": "*", @@ -51770,7 +52475,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", @@ -51786,14 +52491,14 @@ }, "packages/integrations/contentful-cms": { "name": "@o2s/integrations.contentful-cms", - "version": "0.7.0", + "version": "0.8.0", "license": "MIT", "dependencies": { - "@contentful/live-preview": "^4.9.6", + "@contentful/live-preview": "^4.9.10", "@o2s/framework": "*", "@o2s/utils.logger": "*", "contentful": "^11.10.5", - "flatted": "^3.3.3", + "flatted": "^3.4.1", "graphql": "16.13.0", "graphql-request": "7.4.0", "graphql-tag": "2.12.6" @@ -51812,7 +52517,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", @@ -51861,11 +52566,11 @@ }, "packages/integrations/medusajs": { "name": "@o2s/integrations.medusajs", - "version": "1.9.0", + "version": "1.11.0", "license": "MIT", "dependencies": { - "@medusajs/js-sdk": "^2.13.2", - "@medusajs/types": "^2.13.2", + "@medusajs/js-sdk": "^2.13.4", + "@medusajs/types": "^2.13.4", "@o2s/framework": "*", "@o2s/utils.logger": "*", "slugify": "^1.6.6" @@ -51877,7 +52582,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", @@ -51892,9 +52597,9 @@ } }, "packages/integrations/medusajs/node_modules/@medusajs/types": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/@medusajs/types/-/types-2.13.2.tgz", - "integrity": "sha512-T/FDH9rhmAlp00M7HnGb98wd/xFId4IKPF21Nr8uMzvZC8/H3d8SoOrrt08coxSyM/danA8qivJi3Q0xKkQ1Pg==", + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/@medusajs/types/-/types-2.13.4.tgz", + "integrity": "sha512-tp9jXAwTWC8eN29QcYe9GK78iprJ+K9YnwAfneUdZgn++wHaqnn/gAkrJxRmEiFlB/xgwlDT+49xzbBEqPLTYw==", "license": "MIT", "dependencies": { "bignumber.js": "^9.1.2" @@ -51917,7 +52622,7 @@ }, "packages/integrations/mocked": { "name": "@o2s/integrations.mocked", - "version": "1.19.0", + "version": "1.21.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -51939,7 +52644,7 @@ "@o2s/vitest-config": "*", "@types/jsonwebtoken": "^9.0.10", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "express": "5.2.1", "prettier": "^3.8.1", "shx": "^0.4.0", @@ -51963,7 +52668,7 @@ }, "packages/integrations/mocked-dxp": { "name": "@o2s/integrations.mocked-dxp", - "version": "0.0.1", + "version": "1.1.1", "license": "MIT", "dependencies": { "@o2s/framework": "*", @@ -51978,7 +52683,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.2", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -52029,7 +52734,7 @@ }, "packages/integrations/redis": { "name": "@o2s/integrations.redis", - "version": "1.4.0", + "version": "1.4.1", "license": "MIT", "dependencies": { "@o2s/framework": "*", @@ -52043,7 +52748,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", @@ -52059,12 +52764,12 @@ }, "packages/integrations/strapi-cms": { "name": "@o2s/integrations.strapi-cms", - "version": "2.12.0", + "version": "2.13.0", "license": "MIT", "dependencies": { "@o2s/framework": "*", "@o2s/utils.logger": "*", - "flatted": "^3.3.3", + "flatted": "^3.4.1", "graphql": "16.13.0", "graphql-request": "7.4.0", "graphql-tag": "2.12.6" @@ -52083,7 +52788,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", @@ -52103,14 +52808,14 @@ }, "packages/integrations/zendesk": { "name": "@o2s/integrations.zendesk", - "version": "3.1.0", + "version": "3.1.1", "license": "MIT", "dependencies": { "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.1.14", "@o2s/framework": "*", "@o2s/utils.logger": "*", - "axios": "^1.13.5", + "axios": "^1.13.6", "html-to-text": "^9.0.5", "rxjs": "^7.8.2" }, @@ -52122,7 +52827,7 @@ "@o2s/vitest-config": "*", "@types/html-to-text": "^9.0.4", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "tsx": "^4.21.0", @@ -52132,7 +52837,7 @@ }, "packages/modules/surveyjs": { "name": "@o2s/modules.surveyjs", - "version": "0.4.2", + "version": "0.4.3", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -52151,7 +52856,7 @@ "@o2s/typescript-config": "*", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -52179,7 +52884,7 @@ }, "packages/telemetry": { "name": "@o2s/telemetry", - "version": "1.2.1", + "version": "1.2.2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -52196,7 +52901,7 @@ "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "@types/configstore": "^6.0.2", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -52405,7 +53110,7 @@ }, "packages/ui": { "name": "@o2s/ui", - "version": "1.11.0", + "version": "1.13.0", "license": "MIT", "dependencies": { "@o2s/framework": "*", @@ -52456,7 +53161,7 @@ "@types/eslint": "^9.6.1", "@types/node": "^24.10.15", "@types/throttle-debounce": "^5.0.2", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "formik": "^2.4.9", "prettier": "^3.8.1", "sass": "^1.97.3", @@ -52511,7 +53216,7 @@ }, "packages/utils/api-harmonization": { "name": "@o2s/utils.api-harmonization", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "dependencies": { "@o2s/framework": "*" @@ -52522,7 +53227,7 @@ "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" @@ -52534,7 +53239,7 @@ }, "packages/utils/frontend": { "name": "@o2s/utils.frontend", - "version": "0.4.1", + "version": "0.5.1", "license": "MIT", "dependencies": { "@o2s/framework": "*" @@ -52545,7 +53250,7 @@ "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "react-string-replace": "^2.0.1", "tsc-alias": "^1.8.16", @@ -52558,10 +53263,10 @@ }, "packages/utils/logger": { "name": "@o2s/utils.logger", - "version": "1.2.2", + "version": "1.2.3", "license": "MIT", "dependencies": { - "axios": "^1.13.5", + "axios": "^1.13.6", "express": "5.2.1", "jwt-decode": "^4.0.0", "winston": "^3.19.0" @@ -52573,7 +53278,7 @@ "@o2s/typescript-config": "*", "@types/express": "^5.0.6", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", diff --git a/package.json b/package.json index 70945414f..2272f1bcc 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "engines": { "node": ">=22" }, - "packageManager": "npm@11.11.0", + "packageManager": "npm@11.11.1", "workspaces": [ "apps/*", "packages/*", @@ -45,14 +45,14 @@ "packages/modules/*" ], "devDependencies": { - "@commitlint/cli": "^20.4.2", - "@commitlint/config-conventional": "^20.4.2", + "@commitlint/cli": "^20.4.4", + "@commitlint/config-conventional": "^20.4.4", "@playwright/test": "^1.58.2", - "@storybook/addon-a11y": "^10.2.13", - "@storybook/addon-docs": "^10.2.13", - "@storybook/addon-themes": "^10.2.13", - "@storybook/addon-vitest": "^10.2.13", - "@storybook/nextjs-vite": "^10.2.13", + "@storybook/addon-a11y": "^10.2.19", + "@storybook/addon-docs": "^10.2.19", + "@storybook/addon-themes": "^10.2.19", + "@storybook/addon-vitest": "^10.2.19", + "@storybook/nextjs-vite": "^10.2.19", "@turbo/gen": "^2.8.11", "@types/cli-progress": "^3.11.6", "@types/fs-extra": "^11.0.4", @@ -62,15 +62,17 @@ "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", "dotenv": "^17.3.1", - "fs-extra": "^11.3.3", + "fs-extra": "^11.3.4", "husky": "^9.1.7", "lint-staged": "^16.2.7", - "npm-check-updates": "^19.6.2", + "msw": "^2.12.11", + "msw-storybook-addon": "^2.0.6", + "npm-check-updates": "^19.6.3", "playwright": "^1.58.2", "prettier": "^3.8.1", "rimraf": "^6.1.3", "simple-git": "^3.32.3", - "storybook": "^10.2.13", + "storybook": "^10.2.19", "syncpack": "^14.0.0-canary.1", "turbo": "^2.8.11", "typescript": "5.9.3", @@ -88,4 +90,4 @@ "prompts": "^2.4.2", "unzipper": "^0.12.3" } -} \ No newline at end of file +} diff --git a/packages/blocks/article-list/CHANGELOG.md b/packages/blocks/article-list/CHANGELOG.md index 98b07d675..8e2aa0c05 100644 --- a/packages/blocks/article-list/CHANGELOG.md +++ b/packages/blocks/article-list/CHANGELOG.md @@ -1,5 +1,46 @@ # @o2s/blocks.article-list +## 1.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- fadbc63: Align renderer prop types with runtime usage across blocks. + + Restore missing `isDraftModeEnabled` and `userId` coverage in renderer prop contracts and rename the misnamed notification details renderer prop type for consistency. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.6.0 ### Minor Changes diff --git a/packages/blocks/article-list/package.json b/packages/blocks/article-list/package.json index bb3218e65..831792582 100644 --- a/packages/blocks/article-list/package.json +++ b/packages/blocks/article-list/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.article-list", - "version": "1.6.0", + "version": "1.6.2", "private": false, "license": "MIT", "description": "Block for displaying a list of articles.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/article-list/src/api-harmonization/article-list.controller.ts b/packages/blocks/article-list/src/api-harmonization/article-list.controller.ts index d0ea02097..7dad14f06 100644 --- a/packages/blocks/article-list/src/api-harmonization/article-list.controller.ts +++ b/packages/blocks/article-list/src/api-harmonization/article-list.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { GetArticleListBlockQuery } from './article-list.request'; @@ -16,7 +16,7 @@ export class ArticleListController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getArticleListBlock(@Headers() headers: ApiModels.Headers.AppHeaders, @Query() query: GetArticleListBlockQuery) { + getArticleListBlock(@Headers() headers: AppHeaders, @Query() query: GetArticleListBlockQuery) { return this.service.getArticleListBlock(query, headers); } } diff --git a/packages/blocks/article-list/src/api-harmonization/article-list.service.ts b/packages/blocks/article-list/src/api-harmonization/article-list.service.ts index e97e2502d..007d452e2 100644 --- a/packages/blocks/article-list/src/api-harmonization/article-list.service.ts +++ b/packages/blocks/article-list/src/api-harmonization/article-list.service.ts @@ -2,12 +2,14 @@ import { Injectable } from '@nestjs/common'; import { Articles, CMS } from '@o2s/configs.integrations'; import { Observable, catchError, concatMap, forkJoin, map, of } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapArticleList } from './article-list.mapper'; import { ArticleListBlock } from './article-list.model'; import { GetArticleListBlockQuery } from './article-list.request'; +const H = HeaderName; + @Injectable() export class ArticleListService { constructor( @@ -15,28 +17,25 @@ export class ArticleListService { private readonly articlesService: Articles.Service, ) {} - getArticleListBlock( - query: GetArticleListBlockQuery, - headers: ApiModels.Headers.AppHeaders, - ): Observable<ArticleListBlock> { - const cms = this.cmsService.getArticleListBlock({ ...query, locale: headers['x-locale'] }); + getArticleListBlock(query: GetArticleListBlockQuery, headers: AppHeaders): Observable<ArticleListBlock> { + const cms = this.cmsService.getArticleListBlock({ ...query, locale: headers[H.Locale] }); return forkJoin([cms]).pipe( concatMap(([cms]) => { const articles = this.articlesService.getArticleList({ limit: cms.articlesToShow || 4, - locale: headers['x-locale'], + locale: headers[H.Locale], ids: cms.articleIds, category: cms.categoryId, }); const category = cms.categoryId - ? this.articlesService.getCategory({ id: cms.categoryId, locale: headers['x-locale'] }).pipe( + ? this.articlesService.getCategory({ id: cms.categoryId, locale: headers[H.Locale] }).pipe( catchError(() => of(undefined as Articles.Model.Category | undefined)), // If category not found, continue without it ) : of(undefined); return forkJoin([articles, category]).pipe( - map(([articles, category]) => mapArticleList(cms, articles, category, headers['x-locale'])), + map(([articles, category]) => mapArticleList(cms, articles, category, headers[H.Locale])), ); }), ); diff --git a/packages/blocks/article-list/src/frontend/ArticleList.types.ts b/packages/blocks/article-list/src/frontend/ArticleList.types.ts index e17f74658..9960e7a78 100644 --- a/packages/blocks/article-list/src/frontend/ArticleList.types.ts +++ b/packages/blocks/article-list/src/frontend/ArticleList.types.ts @@ -1,18 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/article-list.client'; -export interface ArticleListProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; - isDraftModeEnabled?: boolean; -} +export type ArticleListProps = Models.BlockProps.BlockWithDraftModeProps<ReturnType<typeof defineRouting>>; export type ArticleListPureProps = ArticleListProps & Model.ArticleListBlock; -export interface ArticleListRendererProps extends Omit<ArticleListProps, ''> { - slug: string[]; -} +export type ArticleListRendererProps = Models.BlockProps.BlockWithDraftModeProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/article-list/src/sdk/article-list.ts b/packages/blocks/article-list/src/sdk/article-list.ts index d9f75b3f0..08f1f6bf7 100644 --- a/packages/blocks/article-list/src/sdk/article-list.ts +++ b/packages/blocks/article-list/src/sdk/article-list.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/article-list.client'; @@ -12,7 +12,7 @@ export const articleList = (sdk: Sdk) => ({ blocks: { getArticleList: ( query: Request.GetArticleListBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.ArticleListBlock> => sdk.makeRequest({ diff --git a/packages/blocks/article-list/src/sdk/index.ts b/packages/blocks/article-list/src/sdk/index.ts index fa46c49be..5309385af 100644 --- a/packages/blocks/article-list/src/sdk/index.ts +++ b/packages/blocks/article-list/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { articleList } from './article-list'; diff --git a/packages/blocks/article-search/CHANGELOG.md b/packages/blocks/article-search/CHANGELOG.md index 295fb54a6..4951071f0 100644 --- a/packages/blocks/article-search/CHANGELOG.md +++ b/packages/blocks/article-search/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.article-search +## 1.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.6.0 ### Minor Changes diff --git a/packages/blocks/article-search/package.json b/packages/blocks/article-search/package.json index a4bedb0bf..98cdbae93 100644 --- a/packages/blocks/article-search/package.json +++ b/packages/blocks/article-search/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.article-search", - "version": "1.6.0", + "version": "1.6.2", "private": false, "license": "MIT", "description": "Block for searching articles.", @@ -52,7 +52,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/article-search/src/api-harmonization/article-search.controller.ts b/packages/blocks/article-search/src/api-harmonization/article-search.controller.ts index 85630dbd3..b09cae0d3 100644 --- a/packages/blocks/article-search/src/api-harmonization/article-search.controller.ts +++ b/packages/blocks/article-search/src/api-harmonization/article-search.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { GetArticleSearchBlockQuery, SearchArticlesQuery } from './article-search.request'; @@ -16,16 +16,13 @@ export class ArticleSearchController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getArticleSearchBlock( - @Headers() headers: ApiModels.Headers.AppHeaders, - @Query() query: GetArticleSearchBlockQuery, - ) { + getArticleSearchBlock(@Headers() headers: AppHeaders, @Query() query: GetArticleSearchBlockQuery) { return this.service.getArticleSearchBlock(query, headers); } @Get('articles') @Auth.Decorators.Roles({ roles: [] }) - searchArticles(@Headers() headers: ApiModels.Headers.AppHeaders, @Query() query: SearchArticlesQuery) { + searchArticles(@Headers() headers: AppHeaders, @Query() query: SearchArticlesQuery) { return this.service.searchArticles(query, headers); } } diff --git a/packages/blocks/article-search/src/api-harmonization/article-search.service.ts b/packages/blocks/article-search/src/api-harmonization/article-search.service.ts index 003540263..38b6ae917 100644 --- a/packages/blocks/article-search/src/api-harmonization/article-search.service.ts +++ b/packages/blocks/article-search/src/api-harmonization/article-search.service.ts @@ -2,12 +2,14 @@ import { Injectable } from '@nestjs/common'; import { Articles, CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapArticleSearch, mapArticles } from './article-search.mapper'; import { ArticleList, ArticleSearchBlock } from './article-search.model'; import { GetArticleSearchBlockQuery, SearchArticlesQuery } from './article-search.request'; +const H = HeaderName; + @Injectable() export class ArticleSearchService { constructor( @@ -15,17 +17,14 @@ export class ArticleSearchService { private readonly articlesService: Articles.Service, ) {} - getArticleSearchBlock( - query: GetArticleSearchBlockQuery, - headers: ApiModels.Headers.AppHeaders, - ): Observable<ArticleSearchBlock> { - const cms = this.cmsService.getArticleSearchBlock({ ...query, locale: headers['x-locale'] }); - return forkJoin([cms]).pipe(map(([cms]) => mapArticleSearch(cms, headers['x-locale']))); + getArticleSearchBlock(query: GetArticleSearchBlockQuery, headers: AppHeaders): Observable<ArticleSearchBlock> { + const cms = this.cmsService.getArticleSearchBlock({ ...query, locale: headers[H.Locale] }); + return forkJoin([cms]).pipe(map(([cms]) => mapArticleSearch(cms, headers[H.Locale]))); } - searchArticles(query: SearchArticlesQuery, headers: ApiModels.Headers.AppHeaders): Observable<ArticleList> { + searchArticles(query: SearchArticlesQuery, headers: AppHeaders): Observable<ArticleList> { return this.articlesService - .searchArticles({ ...query, locale: headers['x-locale'] }) + .searchArticles({ ...query, locale: headers[H.Locale] }) .pipe(map((articles) => mapArticles(articles, query.basePath))); } } diff --git a/packages/blocks/article-search/src/frontend/ArticleSearch.types.ts b/packages/blocks/article-search/src/frontend/ArticleSearch.types.ts index d052ee57b..8dddb29a0 100644 --- a/packages/blocks/article-search/src/frontend/ArticleSearch.types.ts +++ b/packages/blocks/article-search/src/frontend/ArticleSearch.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/article-search.client'; -export interface ArticleSearchProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type ArticleSearchProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type ArticleSearchPureProps = ArticleSearchProps & Model.ArticleSearchBlock; -export type ArticleSearchRendererProps = Omit<ArticleSearchProps, ''> & { - slug: string[]; -}; +export type ArticleSearchRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/article-search/src/sdk/article-search.ts b/packages/blocks/article-search/src/sdk/article-search.ts index a3990d3db..d48b796e5 100644 --- a/packages/blocks/article-search/src/sdk/article-search.ts +++ b/packages/blocks/article-search/src/sdk/article-search.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/article-search.client'; @@ -12,7 +12,7 @@ export const articleSearch = (sdk: Sdk) => ({ blocks: { getArticleSearch: ( query: Request.GetArticleSearchBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.ArticleSearchBlock> => sdk.makeRequest({ @@ -31,7 +31,7 @@ export const articleSearch = (sdk: Sdk) => ({ }), searchArticles: ( query: Request.SearchArticlesQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.ArticleList> => sdk.makeRequest({ diff --git a/packages/blocks/article-search/src/sdk/index.ts b/packages/blocks/article-search/src/sdk/index.ts index 2c01f21da..d7b944b3c 100644 --- a/packages/blocks/article-search/src/sdk/index.ts +++ b/packages/blocks/article-search/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { articleSearch } from './article-search'; diff --git a/packages/blocks/article/CHANGELOG.md b/packages/blocks/article/CHANGELOG.md index 78730318c..65f6fee39 100644 --- a/packages/blocks/article/CHANGELOG.md +++ b/packages/blocks/article/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.article +## 1.5.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.5.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.5.0 ### Minor Changes diff --git a/packages/blocks/article/package.json b/packages/blocks/article/package.json index ba7621720..960a67517 100644 --- a/packages/blocks/article/package.json +++ b/packages/blocks/article/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.article", - "version": "1.5.0", + "version": "1.5.2", "private": false, "license": "MIT", "description": "A block displaying a single article.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/article/src/api-harmonization/article.controller.ts b/packages/blocks/article/src/api-harmonization/article.controller.ts index b05b97175..711b91dfa 100644 --- a/packages/blocks/article/src/api-harmonization/article.controller.ts +++ b/packages/blocks/article/src/api-harmonization/article.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class ArticleController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getArticleBlock(@Headers() headers: ApiModels.Headers.AppHeaders, @Query() query: GetArticleBlockQuery) { + getArticleBlock(@Headers() headers: AppHeaders, @Query() query: GetArticleBlockQuery) { return this.service.getArticleBlock(query, headers); } } diff --git a/packages/blocks/article/src/api-harmonization/article.service.ts b/packages/blocks/article/src/api-harmonization/article.service.ts index 2f55b6c18..7a9c454fa 100644 --- a/packages/blocks/article/src/api-harmonization/article.service.ts +++ b/packages/blocks/article/src/api-harmonization/article.service.ts @@ -2,12 +2,14 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { Articles, CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapArticle } from './article.mapper'; import { ArticleBlock } from './article.model'; import { GetArticleBlockQuery } from './article.request'; +const H = HeaderName; + @Injectable() export class ArticleService { constructor( @@ -15,9 +17,9 @@ export class ArticleService { private readonly articlesService: Articles.Service, ) {} - getArticleBlock(query: GetArticleBlockQuery, headers: ApiModels.Headers.AppHeaders): Observable<ArticleBlock> { - const cms = this.cmsService.getAppConfig({ locale: headers['x-locale'] }); - const article = this.articlesService.getArticle({ slug: query.slug, locale: headers['x-locale'] }); + getArticleBlock(query: GetArticleBlockQuery, headers: AppHeaders): Observable<ArticleBlock> { + const cms = this.cmsService.getAppConfig({ locale: headers[H.Locale] }); + const article = this.articlesService.getArticle({ slug: query.slug, locale: headers[H.Locale] }); return forkJoin([cms, article]).pipe( map(([cms, article]) => { @@ -25,7 +27,7 @@ export class ArticleService { throw new NotFoundException(); } - return mapArticle(cms, article, headers['x-locale'], headers['x-client-timezone'] || ''); + return mapArticle(cms, article, headers[H.Locale], headers[H.ClientTimezone] || ''); }), ); } diff --git a/packages/blocks/article/src/frontend/Article.types.ts b/packages/blocks/article/src/frontend/Article.types.ts index bc2ce4369..a72e7c8e1 100644 --- a/packages/blocks/article/src/frontend/Article.types.ts +++ b/packages/blocks/article/src/frontend/Article.types.ts @@ -1,18 +1,13 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/article.client'; -export interface ArticleProps { - id: string; +export interface ArticleProps extends Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>> { slug: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; } export type ArticlePureProps = ArticleProps & Model.ArticleBlock; -export interface ArticleRendererProps extends Omit<ArticleProps, 'slug'> { - slug: string[]; -} +export type ArticleRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/article/src/sdk/article.ts b/packages/blocks/article/src/sdk/article.ts index 13b32597c..6444aca53 100644 --- a/packages/blocks/article/src/sdk/article.ts +++ b/packages/blocks/article/src/sdk/article.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/article.client'; @@ -12,7 +12,7 @@ export const article = (sdk: Sdk) => ({ blocks: { getArticle: ( query: Request.GetArticleBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.ArticleBlock> => sdk.makeRequest({ diff --git a/packages/blocks/article/src/sdk/index.ts b/packages/blocks/article/src/sdk/index.ts index f0a9700bd..f84b2db8e 100644 --- a/packages/blocks/article/src/sdk/index.ts +++ b/packages/blocks/article/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { article } from './article'; diff --git a/packages/blocks/bento-grid/CHANGELOG.md b/packages/blocks/bento-grid/CHANGELOG.md index 20148ab69..da98d3ae0 100644 --- a/packages/blocks/bento-grid/CHANGELOG.md +++ b/packages/blocks/bento-grid/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.bento-grid +## 0.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 0.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 0.6.0 ### Minor Changes diff --git a/packages/blocks/bento-grid/package.json b/packages/blocks/bento-grid/package.json index 4773e25c7..c5ec4e820 100644 --- a/packages/blocks/bento-grid/package.json +++ b/packages/blocks/bento-grid/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.bento-grid", - "version": "0.6.0", + "version": "0.6.2", "private": false, "license": "MIT", "description": "A simple block displaying static content in the form of an BentoGrid.", @@ -50,7 +50,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/bento-grid/src/api-harmonization/bento-grid.controller.ts b/packages/blocks/bento-grid/src/api-harmonization/bento-grid.controller.ts index fc48b6569..c3f7b372f 100644 --- a/packages/blocks/bento-grid/src/api-harmonization/bento-grid.controller.ts +++ b/packages/blocks/bento-grid/src/api-harmonization/bento-grid.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class BentoGridController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getBentoGridBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetBentoGridBlockQuery) { + getBentoGridBlock(@Headers() headers: AppHeaders, @Query() query: GetBentoGridBlockQuery) { return this.service.getBentoGridBlock(query, headers); } } diff --git a/packages/blocks/bento-grid/src/api-harmonization/bento-grid.model.ts b/packages/blocks/bento-grid/src/api-harmonization/bento-grid.model.ts index d2d937310..1cedf3512 100644 --- a/packages/blocks/bento-grid/src/api-harmonization/bento-grid.model.ts +++ b/packages/blocks/bento-grid/src/api-harmonization/bento-grid.model.ts @@ -1,8 +1,8 @@ import { CMS } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -export class BentoGridBlock extends Models.Block.Block { +export class BentoGridBlock extends ApiModels.Block.Block { __typename!: 'BentoGridBlock'; preTitle?: CMS.Model.BentoGridBlock.BentoGridBlock['preTitle']; title?: CMS.Model.BentoGridBlock.BentoGridBlock['title']; diff --git a/packages/blocks/bento-grid/src/api-harmonization/bento-grid.service.ts b/packages/blocks/bento-grid/src/api-harmonization/bento-grid.service.ts index 983cac96e..e8441c567 100644 --- a/packages/blocks/bento-grid/src/api-harmonization/bento-grid.service.ts +++ b/packages/blocks/bento-grid/src/api-harmonization/bento-grid.service.ts @@ -2,19 +2,21 @@ import { Injectable } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapBentoGrid } from './bento-grid.mapper'; import { BentoGridBlock } from './bento-grid.model'; import { GetBentoGridBlockQuery } from './bento-grid.request'; +const H = HeaderName; + @Injectable() export class BentoGridService { constructor(private readonly cmsService: CMS.Service) {} - getBentoGridBlock(query: GetBentoGridBlockQuery, headers: Models.Headers.AppHeaders): Observable<BentoGridBlock> { - const cms = this.cmsService.getBentoGridBlock({ ...query, locale: headers['x-locale'] }); + getBentoGridBlock(query: GetBentoGridBlockQuery, headers: AppHeaders): Observable<BentoGridBlock> { + const cms = this.cmsService.getBentoGridBlock({ ...query, locale: headers[H.Locale] }); - return forkJoin([cms]).pipe(map(([cms]) => mapBentoGrid(cms, headers['x-locale']))); + return forkJoin([cms]).pipe(map(([cms]) => mapBentoGrid(cms, headers[H.Locale]))); } } diff --git a/packages/blocks/bento-grid/src/frontend/BentoGrid.types.ts b/packages/blocks/bento-grid/src/frontend/BentoGrid.types.ts index b93d07c12..f02f96af3 100644 --- a/packages/blocks/bento-grid/src/frontend/BentoGrid.types.ts +++ b/packages/blocks/bento-grid/src/frontend/BentoGrid.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/bento-grid.client'; -export interface BentoGridProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type BentoGridProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type BentoGridPureProps = BentoGridProps & Model.BentoGridBlock; -export type BentoGridRendererProps = Omit<BentoGridProps, ''> & { - slug: string[]; -}; +export type BentoGridRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/bento-grid/src/sdk/bento-grid.ts b/packages/blocks/bento-grid/src/sdk/bento-grid.ts index b59496af7..880d6f690 100644 --- a/packages/blocks/bento-grid/src/sdk/bento-grid.ts +++ b/packages/blocks/bento-grid/src/sdk/bento-grid.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/bento-grid.client'; @@ -12,7 +12,7 @@ export const bentoGrid = (sdk: Sdk) => ({ blocks: { getBentoGrid: ( query: Request.GetBentoGridBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.BentoGridBlock> => sdk.makeRequest({ diff --git a/packages/blocks/bento-grid/src/sdk/index.ts b/packages/blocks/bento-grid/src/sdk/index.ts index 28921bd10..648224da6 100644 --- a/packages/blocks/bento-grid/src/sdk/index.ts +++ b/packages/blocks/bento-grid/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { bentoGrid } from './bento-grid'; diff --git a/packages/blocks/cart/.gitignore b/packages/blocks/cart/.gitignore new file mode 100644 index 000000000..29986a380 --- /dev/null +++ b/packages/blocks/cart/.gitignore @@ -0,0 +1,57 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +/tsconfig.tsbuildinfo diff --git a/packages/blocks/cart/.prettierrc.mjs b/packages/blocks/cart/.prettierrc.mjs new file mode 100644 index 000000000..93b66d398 --- /dev/null +++ b/packages/blocks/cart/.prettierrc.mjs @@ -0,0 +1,25 @@ +import apiConfig from "@o2s/prettier-config/api.mjs"; +import frontendConfig from "@o2s/prettier-config/frontend.mjs"; + +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +const config = { + overrides: [ + { + files: "./src/api-harmonization/**/*", + options: apiConfig, + }, + { + files: "./src/frontend/**/*", + options: frontendConfig, + }, + { + files: "./src/sdk/**/*", + options: frontendConfig, + }, + ], +}; + +export default config; diff --git a/packages/blocks/cart/CHANGELOG.md b/packages/blocks/cart/CHANGELOG.md new file mode 100644 index 000000000..65ffa4ebf --- /dev/null +++ b/packages/blocks/cart/CHANGELOG.md @@ -0,0 +1,32 @@ +# @o2s/blocks.cart + +## 0.1.1 + +### Patch Changes + +- c1a5460: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + +## 0.1.0 + +### Minor Changes + +- 5d36519: Added new blocks: Cart, Checkout (Summary, Shipping Address, Company Data, Billing Payment) and Order Confirmation. Includes checkout forms validation (Formik + Yup), error handling, promo code support in cart, and new UI components (StepIndicator, RadioTile, AddressFields, CartSummary, QuantityInput, FormField). + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] +- Updated dependencies [5d36519] + - @o2s/framework@1.19.0 + - @o2s/utils.frontend@0.5.0 + - @o2s/ui@1.12.0 diff --git a/packages/blocks/cart/README.md b/packages/blocks/cart/README.md new file mode 100644 index 000000000..4349eacd6 --- /dev/null +++ b/packages/blocks/cart/README.md @@ -0,0 +1,141 @@ +# @o2s/blocks.cart + +Block for displaying and managing the shopping cart. + +The cart block shows the current cart contents with items, quantities, prices, and summary. Users can update quantities, remove items, and proceed to checkout. Cart data is fetched client-side using `cartId` from localStorage. Ideal for e-commerce checkout flows. + +- **Cart display** – Items, quantities, prices, subtotal, tax, total +- **Quantity updates** – Increase/decrease item quantities +- **Empty state** – Message when cart has no items + +Content editors place the block via CMS. Developers connect Carts and Checkout integrations (e.g. mocked, Medusa.js). + +## Installation + +```bash +npm install @o2s/blocks.cart +``` + +## Usage + +### Backend (API Harmonization) + +Register the block in `app.module.ts`: + +```typescript +import * as Cart from "@o2s/blocks.cart/api-harmonization"; +import { AppConfig } from "./app.config"; + +@Module({ + imports: [Cart.Module.register(AppConfig)], +}) +export class AppModule {} +``` + +### Frontend + +Register the block in `renderBlocks.tsx`: + +```typescript +import * as Cart from '@o2s/blocks.cart/frontend'; + +export const renderBlocks = async (blocks: CMS.Model.Page.SlotBlock[]) => { + return blocks.map((block) => { + if (block.type === 'cart') { + return ( + <Cart.Renderer + key={block.id} + id={block.id} + slug={slug} + locale={locale} + accessToken={session?.accessToken} + userId={session?.user?.id} + routing={routing} + /> + ); + } + // ... other blocks + }); +}; +``` + +### SDK + +Use the SDK to fetch cart block config or cart data: + +```typescript +import { sdk } from "@o2s/blocks.cart/sdk"; + +// Block config (from CMS) +const block = await sdk.blocks.getCart( + { id: "block-id" }, + { "x-locale": "en" }, + accessToken, +); + +// Cart data (by cartId - typically from localStorage) +const cart = await sdk.cart.getCart(cartId, { "x-locale": "en" }, accessToken); +``` + +## Configuration + +This block requires the following integrations to be configured in `AppConfig`: + +```typescript +import { Carts, CMS } from "@o2s/configs.integrations"; + +export const AppConfig: ApiConfig = { + integrations: { + carts: Carts.CartsIntegrationConfig, // Required + cms: CMS.CmsIntegrationConfig, // Required + }, +}; +``` + +## Environment Variables + +The required environment variables depend on which integrations you're using: + +- **For mocked integration**: No additional environment variables needed +- **For MedusaJS integration**: See `@o2s/integrations.medusajs` documentation + +## API + +### Block Endpoint + +``` +GET /api/blocks/cart?id={blockId} +``` + +**Headers:** + +- `x-locale`: Content locale (required) + +**Response:** Cart block config (labels, errors, empty state, etc.) + +### Carts API + +Cart data is fetched via the Carts integration (`GET /carts/:cartId`). The block uses `cartId` from `localStorage` (key: `cartId`). + +## Related Blocks + +- `@o2s/blocks.checkout-company-data` - Company details step +- `@o2s/blocks.checkout-shipping-address` - Shipping address step +- `@o2s/blocks.checkout-billing-payment` - Payment step +- `@o2s/blocks.checkout-summary` - Order summary step +- `@o2s/blocks.order-confirmation` - Order confirmation page + +## About Blocks in O2S + +Blocks are self-contained, reusable UI components that combine harmonizing and frontend components into a single package. Each block is independently packaged as an NPM module and includes three primary parts: API Harmonization Module, Frontend Components, and SDK Methods. Blocks allow you to quickly add or remove functionality without impacting other components of the application. + +- **See all blocks**: [Blocks Documentation](https://www.openselfservice.com/docs/main-components/blocks/) +- **View this block in Storybook**: [cart](https://storybook-o2s.openselfservice.com/?path=/story/blocks-cart--default) + +## About O2S + +**Part of [Open Self Service (O2S)](https://www.openselfservice.com/)** - an open-source framework for building composable customer self-service portals. O2S simplifies integration of multiple headless APIs into a scalable frontend, providing an API-agnostic architecture with a normalization layer. + +- **Website**: [https://www.openselfservice.com/](https://www.openselfservice.com/) +- **GitHub**: [https://github.com/o2sdev/openselfservice](https://github.com/o2sdev/openselfservice) +- **Documentation**: [https://www.openselfservice.com/docs](https://www.openselfservice.com/docs) diff --git a/packages/blocks/cart/eslint.config.mjs b/packages/blocks/cart/eslint.config.mjs new file mode 100644 index 000000000..223f2af08 --- /dev/null +++ b/packages/blocks/cart/eslint.config.mjs @@ -0,0 +1,18 @@ +import { config as apiConfig } from '@o2s/eslint-config/api'; +import { config as frontendConfig } from '@o2s/eslint-config/frontend-block'; +import { defineConfig } from 'eslint/config'; + +export default defineConfig([ + { + files: ['src/api-harmonization/**/*'], + extends: [apiConfig], + }, + { + files: ['src/frontend/**/*'], + extends: [frontendConfig], + }, + { + files: ['src/sdk/**/*'], + extends: [frontendConfig], + }, +]); diff --git a/packages/blocks/cart/lint-staged.config.mjs b/packages/blocks/cart/lint-staged.config.mjs new file mode 100644 index 000000000..b47bd93b9 --- /dev/null +++ b/packages/blocks/cart/lint-staged.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/lint-staged-config/base'; + +export default config; diff --git a/packages/blocks/cart/package.json b/packages/blocks/cart/package.json new file mode 100644 index 000000000..8c91cc789 --- /dev/null +++ b/packages/blocks/cart/package.json @@ -0,0 +1,58 @@ +{ + "name": "@o2s/blocks.cart", + "version": "0.1.1", + "private": false, + "license": "MIT", + "description": "Shopping cart block with item management, quantity controls and order summary.", + "exports": { + "./api-harmonization": "./dist/api-harmonization/api-harmonization/index.js", + "./frontend": "./dist/frontend/frontend/index.js", + "./sdk": "./dist/sdk/sdk/index.js", + "./client": "./dist/api-harmonization/api-harmonization/cart.client.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build tsconfig.json --preserveWatchOutput && tsc-alias", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit && eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"" + }, + "dependencies": { + "@o2s/framework": "*", + "@o2s/utils.logger": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/configs.integrations": "*" + }, + "devDependencies": { + "dotenv-cli": "^11.0.0", + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "eslint": "^9.39.4", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "react": "^19", + "react-dom": "^19", + "tailwindcss": "^4", + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "rxjs": "^7", + "next": "^16.0.5", + "next-intl": "^4.1.0" + } +} diff --git a/packages/blocks/cart/src/api-harmonization/cart.client.ts b/packages/blocks/cart/src/api-harmonization/cart.client.ts new file mode 100644 index 000000000..af7f9fff0 --- /dev/null +++ b/packages/blocks/cart/src/api-harmonization/cart.client.ts @@ -0,0 +1,4 @@ +export const URL = '/blocks/cart'; + +export * as Model from './cart.model'; +export * as Request from './cart.request'; diff --git a/packages/blocks/cart/src/api-harmonization/cart.controller.ts b/packages/blocks/cart/src/api-harmonization/cart.controller.ts new file mode 100644 index 000000000..83a8e5c62 --- /dev/null +++ b/packages/blocks/cart/src/api-harmonization/cart.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; + +import { Models } from '@o2s/utils.api-harmonization'; +import { LoggerService } from '@o2s/utils.logger'; + +import { Auth } from '@o2s/framework/modules'; + +import { URL } from './'; +import { GetCartBlockQuery } from './cart.request'; +import { CartService } from './cart.service'; + +@Controller(URL) +@UseInterceptors(LoggerService) +export class CartController { + constructor(protected readonly service: CartService) {} + + @Get() + @Auth.Decorators.Roles({ roles: [] }) + // Optional: Add permission-based access control + // @Auth.Decorators.Permissions({ resource: 'resource-name', actions: ['view'] }) + getCartBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetCartBlockQuery) { + return this.service.getCartBlock(query, headers); + } +} diff --git a/packages/blocks/cart/src/api-harmonization/cart.mapper.ts b/packages/blocks/cart/src/api-harmonization/cart.mapper.ts new file mode 100644 index 000000000..fa0c5938c --- /dev/null +++ b/packages/blocks/cart/src/api-harmonization/cart.mapper.ts @@ -0,0 +1,20 @@ +import { CMS, Models } from '@o2s/framework/modules'; + +import { CartBlock } from './cart.model'; + +export const mapCart = (cms: CMS.Model.CartBlock.CartBlock): CartBlock => { + return { + __typename: 'CartBlock', + id: cms.id, + title: cms.title, + subtitle: cms.subtitle, + defaultCurrency: cms.defaultCurrency as Models.Price.Currency, + labels: cms.labels, + errors: cms.errors, + actions: cms.actions, + summaryLabels: cms.summaryLabels, + checkoutButton: cms.checkoutButton, + continueShopping: cms.continueShopping, + empty: cms.empty, + }; +}; diff --git a/packages/blocks/cart/src/api-harmonization/cart.model.ts b/packages/blocks/cart/src/api-harmonization/cart.model.ts new file mode 100644 index 000000000..30f76e34b --- /dev/null +++ b/packages/blocks/cart/src/api-harmonization/cart.model.ts @@ -0,0 +1,42 @@ +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; + +import { CMS, Models } from '@o2s/framework/modules'; + +export class CartBlock extends ApiModels.Block.Block { + __typename!: 'CartBlock'; + title!: string; + subtitle?: string; + defaultCurrency!: Models.Price.Currency; + labels!: CMS.Model.CartBlock.CartBlock['labels']; + errors!: CMS.Model.CartBlock.CartBlock['errors']; + actions!: CMS.Model.CartBlock.CartBlock['actions']; + summaryLabels!: CMS.Model.CartBlock.CartBlock['summaryLabels']; + checkoutButton?: CMS.Model.CartBlock.CartBlock['checkoutButton']; + continueShopping?: CMS.Model.CartBlock.CartBlock['continueShopping']; + empty!: CMS.Model.CartBlock.CartBlock['empty']; +} + +/** Product info embedded in cart item for display */ +export interface CartBlockItemProduct { + name: string; + subtitle?: string; + image?: { url: string; alt?: string }; + link?: string; +} + +/** Cart item with optional product info */ +export interface CartBlockItem { + id: string; + productId: string; + quantity: number; + price: Models.Price.Price; + total: Models.Price.Price; + product?: CartBlockItemProduct; +} + +/** Cart totals (subtotal, tax, total) */ +export interface CartBlockTotals { + subtotal: Models.Price.Price; + tax: Models.Price.Price; + total: Models.Price.Price; +} diff --git a/packages/blocks/cart/src/api-harmonization/cart.module.ts b/packages/blocks/cart/src/api-harmonization/cart.module.ts new file mode 100644 index 000000000..728e4539a --- /dev/null +++ b/packages/blocks/cart/src/api-harmonization/cart.module.ts @@ -0,0 +1,25 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { CMS } from '@o2s/configs.integrations'; + +import * as Framework from '@o2s/framework/modules'; + +import { CartController } from './cart.controller'; +import { CartService } from './cart.service'; + +@Module({}) +export class CartBlockModule { + static register(_config: Framework.ApiConfig): DynamicModule { + return { + module: CartBlockModule, + providers: [ + CartService, + { + provide: CMS.Service, + useExisting: Framework.CMS.Service, + }, + ], + controllers: [CartController], + exports: [CartService], + }; + } +} diff --git a/packages/blocks/cart/src/api-harmonization/cart.request.ts b/packages/blocks/cart/src/api-harmonization/cart.request.ts new file mode 100644 index 000000000..1fab1ae9d --- /dev/null +++ b/packages/blocks/cart/src/api-harmonization/cart.request.ts @@ -0,0 +1,5 @@ +import { CMS } from '@o2s/framework/modules'; + +export class GetCartBlockQuery implements Omit<CMS.Request.GetCmsEntryParams, 'locale'> { + id!: string; +} diff --git a/packages/blocks/cart/src/api-harmonization/cart.service.spec.ts b/packages/blocks/cart/src/api-harmonization/cart.service.spec.ts new file mode 100644 index 000000000..484241f0c --- /dev/null +++ b/packages/blocks/cart/src/api-harmonization/cart.service.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CMS } from '@o2s/configs.integrations'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CartService } from './cart.service'; + +describe('CartService', () => { + let service: CartService; + let cmsService: CMS.Service; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CartService, + { + provide: CMS.Service, + useValue: { + getCartBlock: vi.fn().mockReturnValue({ + id: 'cart-1', + }), + }, + }, + ], + }).compile(); + + service = module.get<CartService>(CartService); + cmsService = module.get<CMS.Service>(CMS.Service); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should have cmsService injected', () => { + expect(cmsService).toBeDefined(); + }); +}); diff --git a/packages/blocks/cart/src/api-harmonization/cart.service.ts b/packages/blocks/cart/src/api-harmonization/cart.service.ts new file mode 100644 index 000000000..a2e24fa72 --- /dev/null +++ b/packages/blocks/cart/src/api-harmonization/cart.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { CMS } from '@o2s/configs.integrations'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; + +import { mapCart } from './cart.mapper'; +import { CartBlock } from './cart.model'; +import { GetCartBlockQuery } from './cart.request'; + +const H = HeaderName; + +@Injectable() +export class CartService { + constructor(private readonly cmsService: CMS.Service) {} + + getCartBlock(query: GetCartBlockQuery, headers: AppHeaders): Observable<CartBlock> { + return this.cmsService.getCartBlock({ ...query, locale: headers[H.Locale] }).pipe(map(mapCart)); + } +} diff --git a/packages/blocks/cart/src/api-harmonization/index.ts b/packages/blocks/cart/src/api-harmonization/index.ts new file mode 100644 index 000000000..b007f297a --- /dev/null +++ b/packages/blocks/cart/src/api-harmonization/index.ts @@ -0,0 +1,8 @@ +export const URL = '/blocks/cart'; + +export { CartBlockModule as Module } from './cart.module'; +export { CartService as Service } from './cart.service'; +export { CartController as Controller } from './cart.controller'; + +export * as Model from './cart.model'; +export * as Request from './cart.request'; diff --git a/packages/blocks/cart/src/frontend/Cart.client.stories.tsx b/packages/blocks/cart/src/frontend/Cart.client.stories.tsx new file mode 100644 index 000000000..9ee7d6ce5 --- /dev/null +++ b/packages/blocks/cart/src/frontend/Cart.client.stories.tsx @@ -0,0 +1,97 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { defineRouting } from 'next-intl/routing'; +import React from 'react'; + +import readme from '../../README.md?raw'; + +import { CartPure } from './Cart.client'; + +const routing = defineRouting({ + locales: ['en'], + defaultLocale: 'en', + pathnames: {}, +}); + +const baseBlock = { + __typename: 'CartBlock' as const, + id: 'cart-1', + title: 'Cart', + subtitle: 'Review and edit your order', + defaultCurrency: 'EUR' as const, + labels: { + itemTotal: 'Total', + unknownProductName: 'Product', + }, + errors: { + loadError: 'Failed to load cart. Please try again.', + updateError: 'Failed to update cart. Please try again.', + }, + actions: { + increaseQuantity: 'Increase quantity', + decreaseQuantity: 'Decrease quantity', + quantity: 'Quantity', + remove: 'Remove', + }, + summaryLabels: { + title: 'Summary', + subtotalLabel: 'Subtotal', + taxLabel: 'VAT', + totalLabel: 'Total', + }, + checkoutButton: { + label: 'Proceed to checkout', + path: '#', + icon: 'ShoppingCart', + }, + continueShopping: { + label: 'Continue shopping', + path: '#', + }, + empty: { + title: 'Your cart is empty', + description: 'Add products to place an order', + continueShopping: { + label: 'Go to shop', + path: '#', + }, + }, +}; + +const meta = { + title: 'Blocks/Cart', + component: CartPure, + tags: ['autodocs'], + parameters: { readme }, +} satisfies Meta<typeof CartPure>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default: Story = { + args: { + ...baseBlock, + id: 'cart-1', + locale: 'en', + routing, + }, +}; + +const EMPTY_CART_ID = 'storybook-cart-empty'; + +export const EmptyCart: Story = { + args: { + ...baseBlock, + id: 'cart-1', + locale: 'en', + routing, + }, + decorators: [ + (Story) => { + if (typeof window !== 'undefined') { + window.localStorage.setItem('cartId', EMPTY_CART_ID); + } + return <Story />; + }, + ], +}; diff --git a/packages/blocks/cart/src/frontend/Cart.client.tsx b/packages/blocks/cart/src/frontend/Cart.client.tsx new file mode 100644 index 000000000..f1c8a1293 --- /dev/null +++ b/packages/blocks/cart/src/frontend/Cart.client.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { createNavigation } from 'next-intl/navigation'; +import React, { useEffect, useState, useTransition } from 'react'; + +import { Carts } from '@o2s/framework/modules'; + +import { toast } from '@o2s/ui/hooks/use-toast'; + +import { CartItem } from '@o2s/ui/components/Cart/CartItem'; +import { CartSummary } from '@o2s/ui/components/Cart/CartSummary'; +import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; + +import { Button } from '@o2s/ui/elements/button'; +import { Skeleton } from '@o2s/ui/elements/skeleton'; +import { Typography } from '@o2s/ui/elements/typography'; + +import { sdk } from '../sdk'; + +import { CartPureProps } from './Cart.types'; + +const CART_ID_KEY = 'cartId'; + +export const CartPure: React.FC<Readonly<CartPureProps>> = ({ + locale, + accessToken, + routing, + title, + subtitle, + defaultCurrency, + labels, + errors, + actions, + summaryLabels, + checkoutButton, + continueShopping, + empty, +}) => { + const { Link: LinkComponent } = createNavigation(routing); + + const [cart, setCart] = useState<Carts.Model.Cart | undefined>(); + const [isInitialLoadPending, startInitialLoadTransition] = useTransition(); + const [isMutationPending, startMutationTransition] = useTransition(); + + useEffect(() => { + const cartId = localStorage.getItem(CART_ID_KEY); + if (!cartId) return; + + startInitialLoadTransition(async () => { + try { + const data = await sdk.cart.getCart(cartId, { 'x-locale': locale }, accessToken); + setCart(data); + } catch { + toast({ variant: 'destructive', description: errors?.loadError }); + } + }); + }, [locale, accessToken, errors?.loadError]); + + const updateQuantity = (itemId: string, newQuantity: number) => { + const cartId = localStorage.getItem(CART_ID_KEY); + if (!cartId) return; + + startMutationTransition(async () => { + try { + const updated = await sdk.cart.updateCartItem( + cartId, + itemId, + { quantity: newQuantity }, + { 'x-locale': locale }, + accessToken, + ); + setCart(updated); + } catch { + toast({ variant: 'destructive', description: errors?.updateError }); + } + }); + }; + + const removeItem = (itemId: string) => { + const cartId = localStorage.getItem(CART_ID_KEY); + if (!cartId) return; + + startMutationTransition(async () => { + try { + const updated = await sdk.cart.removeCartItem(cartId, itemId, { 'x-locale': locale }, accessToken); + setCart(updated); + } catch { + toast({ variant: 'destructive', description: errors?.updateError }); + } + }); + }; + + if (isInitialLoadPending) { + return ( + <div className="w-full flex flex-col gap-8 md:gap-12"> + <div className="flex flex-col gap-2"> + <Typography variant="h1">{title}</Typography> + {subtitle && ( + <Typography variant="large" className="text-muted-foreground"> + {subtitle} + </Typography> + )} + </div> + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> + <div className="lg:col-span-2 flex flex-col gap-4"> + <Skeleton variant="rounded" className="h-32" /> + <Skeleton variant="rounded" className="h-32" /> + <Skeleton variant="rounded" className="h-32" /> + </div> + <div className="lg:col-span-1"> + <Skeleton variant="rounded" className="h-64" /> + </div> + </div> + </div> + ); + } + + const zero = { value: 0, currency: defaultCurrency }; + + if ((cart?.items?.data?.length ?? 0) === 0 && !isMutationPending) { + return ( + <div className="w-full flex flex-col gap-8 md:gap-12 items-center justify-center py-12"> + <DynamicIcon name="ShoppingCart" size={64} className="text-muted-foreground" /> + <Typography variant="h2">{empty.title}</Typography> + <Typography variant="large" className="text-muted-foreground text-center max-w-md"> + {empty.description} + </Typography> + {empty.continueShopping && ( + <LinkComponent href={empty.continueShopping.path}> + <Button variant="default" size="lg"> + {empty.continueShopping.label} + </Button> + </LinkComponent> + )} + </div> + ); + } + + return ( + <div className="w-full flex flex-col gap-8 md:gap-12"> + <div className="flex flex-col gap-2"> + <Typography variant="h1">{title}</Typography> + {subtitle && ( + <Typography variant="large" className="text-muted-foreground"> + {subtitle} + </Typography> + )} + </div> + + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> + {/* Cart Items */} + <div className="lg:col-span-2 relative"> + {isMutationPending && ( + <div className="absolute inset-0 bg-background/50 flex items-center justify-center z-10 rounded-lg"> + <DynamicIcon name="Loader2" size={32} className="animate-spin text-primary" /> + </div> + )} + <ul className="flex flex-col gap-4"> + {cart?.items?.data?.map((item) => ( + <li key={item.id}> + <CartItem + id={item.id} + productId={item.product.id} + productUrl={item.product.link} + name={item.product.name} + subtitle={item.product.shortDescription} + image={item.product.image} + quantity={item.quantity} + price={item.price} + total={item.total} + labels={{ + itemTotal: labels.itemTotal, + ...actions, + }} + onRemove={removeItem} + onQuantityChange={updateQuantity} + LinkComponent={LinkComponent} + /> + </li> + ))} + </ul> + </div> + + {/* Cart Summary */} + <div className="lg:col-span-1 flex flex-col-reverse lg:flex-col gap-4"> + <CartSummary + subtotal={cart?.subtotal ?? zero} + tax={cart?.taxTotal ?? zero} + total={cart?.total ?? zero} + discountTotal={cart?.discountTotal} + shippingTotal={cart?.shippingTotal} + promotions={cart?.promotions} + labels={summaryLabels} + LinkComponent={LinkComponent} + primaryButton={ + checkoutButton + ? { + label: checkoutButton.label, + icon: checkoutButton.icon, + action: { type: 'link', url: checkoutButton.path }, + } + : undefined + } + secondaryButton={ + continueShopping + ? { + label: continueShopping.label, + action: { type: 'link', url: continueShopping.path }, + } + : undefined + } + /> + </div> + </div> + </div> + ); +}; diff --git a/packages/blocks/cart/src/frontend/Cart.renderer.tsx b/packages/blocks/cart/src/frontend/Cart.renderer.tsx new file mode 100644 index 000000000..f127f1d11 --- /dev/null +++ b/packages/blocks/cart/src/frontend/Cart.renderer.tsx @@ -0,0 +1,32 @@ +import { useLocale } from 'next-intl'; +import React, { Suspense } from 'react'; + +import { Loading } from '@o2s/ui/components/Loading'; + +import { Cart } from './Cart.server'; +import { CartRendererProps } from './Cart.types'; + +export const CartRenderer: React.FC<CartRendererProps> = ({ id, accessToken, routing, hasPriority = false }) => { + const locale = useLocale(); + + return ( + <Suspense + key={id} + fallback={ + <div className="w-full flex flex-col gap-8"> + <Loading bars={1} /> + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> + <div className="lg:col-span-2 flex flex-col gap-4"> + <Loading bars={3} /> + </div> + <div className="lg:col-span-1"> + <Loading bars={4} /> + </div> + </div> + </div> + } + > + <Cart id={id} accessToken={accessToken} locale={locale} routing={routing} hasPriority={hasPriority} /> + </Suspense> + ); +}; diff --git a/packages/blocks/cart/src/frontend/Cart.server.tsx b/packages/blocks/cart/src/frontend/Cart.server.tsx new file mode 100644 index 000000000..76f1f1304 --- /dev/null +++ b/packages/blocks/cart/src/frontend/Cart.server.tsx @@ -0,0 +1,36 @@ +import dynamic from 'next/dynamic'; +import React from 'react'; + +import type { Model } from '../api-harmonization/cart.client'; +import { sdk } from '../sdk'; + +import { CartProps } from './Cart.types'; + +export const CartDynamic = dynamic(() => import('./Cart.client').then((module) => module.CartPure)); + +export const Cart: React.FC<CartProps> = async ({ id, accessToken, locale, routing, hasPriority }) => { + let data: Model.CartBlock; + try { + data = await sdk.blocks.getCart( + { + id, + }, + { 'x-locale': locale }, + accessToken, + ); + } catch (error) { + console.error('Error fetching Cart block', error); + return null; + } + + return ( + <CartDynamic + {...data} + id={id} + accessToken={accessToken} + locale={locale} + routing={routing} + hasPriority={hasPriority} + /> + ); +}; diff --git a/packages/blocks/cart/src/frontend/Cart.types.ts b/packages/blocks/cart/src/frontend/Cart.types.ts new file mode 100644 index 000000000..4085655cd --- /dev/null +++ b/packages/blocks/cart/src/frontend/Cart.types.ts @@ -0,0 +1,17 @@ +import { defineRouting } from 'next-intl/routing'; + +import type { Model } from '../api-harmonization/cart.client'; + +export interface CartProps { + id: string; + accessToken?: string; + locale: string; + routing: ReturnType<typeof defineRouting>; + hasPriority?: boolean; +} + +export type CartPureProps = CartProps & Model.CartBlock; + +export type CartRendererProps = Omit<CartProps, 'locale'> & { + slug: string[]; +}; diff --git a/packages/blocks/cart/src/frontend/index.ts b/packages/blocks/cart/src/frontend/index.ts new file mode 100644 index 000000000..2e57f0509 --- /dev/null +++ b/packages/blocks/cart/src/frontend/index.ts @@ -0,0 +1,5 @@ +export { CartPure as Client } from './Cart.client'; +export { Cart as Server } from './Cart.server'; +export { CartRenderer as Renderer } from './Cart.renderer'; + +export * as Types from './Cart.types'; diff --git a/packages/blocks/cart/src/sdk/cart.ts b/packages/blocks/cart/src/sdk/cart.ts new file mode 100644 index 000000000..cb46451f1 --- /dev/null +++ b/packages/blocks/cart/src/sdk/cart.ts @@ -0,0 +1,84 @@ +import { Models } from '@o2s/utils.api-harmonization'; +import { Utils } from '@o2s/utils.frontend'; + +import { Carts } from '@o2s/framework/modules'; +import { Sdk } from '@o2s/framework/sdk'; + +import { Model, Request, URL } from '../api-harmonization/cart.client'; + +const API_URL = URL; +const CARTS_API_URL = '/carts'; + +export const cart = (sdk: Sdk) => ({ + blocks: { + getCart: ( + query: Request.GetCartBlockQuery, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Model.CartBlock> => + sdk.makeRequest({ + method: 'get', + url: `${API_URL}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + params: query, + }), + }, + cart: { + getCart: ( + cartId: string, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Carts.Model.Cart> => + sdk.makeRequest({ + method: 'get', + url: `${CARTS_API_URL}/${cartId}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}), + }, + }), + + updateCartItem: ( + cartId: string, + itemId: string, + body: { quantity?: number; metadata?: Record<string, unknown>; locale?: string }, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Carts.Model.Cart> => + sdk.makeRequest({ + method: 'patch', + url: `${CARTS_API_URL}/${cartId}/items/${itemId}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}), + }, + data: body, + }), + + removeCartItem: ( + cartId: string, + itemId: string, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Carts.Model.Cart> => + sdk.makeRequest({ + method: 'delete', + url: `${CARTS_API_URL}/${cartId}/items/${itemId}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}), + }, + }), + }, +}); diff --git a/packages/blocks/cart/src/sdk/index.ts b/packages/blocks/cart/src/sdk/index.ts new file mode 100644 index 000000000..c6c81d0d3 --- /dev/null +++ b/packages/blocks/cart/src/sdk/index.ts @@ -0,0 +1,35 @@ +// these unused imports are necessary for TypeScript to properly resolve API methods +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Models } from '@o2s/utils.api-harmonization'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Carts } from '@o2s/framework/modules'; +import { extendSdk, getSdk } from '@o2s/framework/sdk'; + +import { cart } from './cart'; + +const API_URL = + (typeof window === 'undefined' ? process.env.NEXT_PUBLIC_API_URL_INTERNAL : process.env.NEXT_PUBLIC_API_URL) || + process.env.NEXT_PUBLIC_API_URL; + +const internalSdk = getSdk({ + apiUrl: API_URL!, + logger: { + // @ts-expect-error missing types + level: process.env.NEXT_PUBLIC_LOG_LEVEL, + // @ts-expect-error missing types + format: process.env.NEXT_PUBLIC_LOG_FORMAT, + colorsEnabled: process.env.NEXT_PUBLIC_LOG_COLORS_ENABLED === 'true', + }, +}); + +export const sdk = extendSdk(internalSdk, { + blocks: { + getCart: cart(internalSdk).blocks.getCart, + }, + cart: { + getCart: cart(internalSdk).cart.getCart, + updateCartItem: cart(internalSdk).cart.updateCartItem, + removeCartItem: cart(internalSdk).cart.removeCartItem, + }, +}); diff --git a/packages/blocks/cart/tsconfig.api.json b/packages/blocks/cart/tsconfig.api.json new file mode 100644 index 000000000..0f9f79f8e --- /dev/null +++ b/packages/blocks/cart/tsconfig.api.json @@ -0,0 +1,14 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "outDir": "./dist/api-harmonization", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src/api-harmonization", + }, + "include": ["src/api-harmonization"] +} diff --git a/packages/blocks/cart/tsconfig.frontend.json b/packages/blocks/cart/tsconfig.frontend.json new file mode 100644 index 000000000..fb15bd256 --- /dev/null +++ b/packages/blocks/cart/tsconfig.frontend.json @@ -0,0 +1,22 @@ +{ + "extends": "@o2s/typescript-config/frontend.json", + "compilerOptions": { + "outDir": "./dist/frontend", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "noEmit": false, + "jsx": "react", + "baseUrl": "./src/frontend" + }, + "include": [ + "src/frontend", + "src/api-harmonization/cart.client.ts", + "src/api-harmonization/cart.model.ts", + "src/api-harmonization/cart.request.ts", + "src/sdk" + ] +} diff --git a/packages/blocks/cart/tsconfig.json b/packages/blocks/cart/tsconfig.json new file mode 100644 index 000000000..c3031c1dd --- /dev/null +++ b/packages/blocks/cart/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@o2s/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src", + }, + "references": [ + { "path": "./tsconfig.frontend.json" }, + { "path": "./tsconfig.api.json" }, + { "path": "./tsconfig.sdk.json" } + ] +} diff --git a/packages/blocks/cart/tsconfig.sdk.json b/packages/blocks/cart/tsconfig.sdk.json new file mode 100644 index 000000000..bd3c4b6b8 --- /dev/null +++ b/packages/blocks/cart/tsconfig.sdk.json @@ -0,0 +1,19 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "outDir": "./dist/sdk", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src/sdk" + }, + "include": [ + "src/sdk", + "src/api-harmonization/cart.client.ts", + "src/api-harmonization/cart.model.ts", + "src/api-harmonization/cart.request.ts" + ] +} diff --git a/packages/blocks/cart/vitest.config.mjs b/packages/blocks/cart/vitest.config.mjs new file mode 100644 index 000000000..82be23c07 --- /dev/null +++ b/packages/blocks/cart/vitest.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/vitest-config/block'; + +export default config; diff --git a/packages/blocks/category-list/CHANGELOG.md b/packages/blocks/category-list/CHANGELOG.md index b0b5b7619..c6c4fc4bc 100644 --- a/packages/blocks/category-list/CHANGELOG.md +++ b/packages/blocks/category-list/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.category-list +## 1.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.6.0 ### Minor Changes diff --git a/packages/blocks/category-list/package.json b/packages/blocks/category-list/package.json index 69d000c62..8a04a60de 100644 --- a/packages/blocks/category-list/package.json +++ b/packages/blocks/category-list/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.category-list", - "version": "1.6.0", + "version": "1.6.2", "private": false, "license": "MIT", "description": "Block for displaying a list of categories.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/category-list/src/api-harmonization/category-list.controller.ts b/packages/blocks/category-list/src/api-harmonization/category-list.controller.ts index 88d0603e5..ec0e38c63 100644 --- a/packages/blocks/category-list/src/api-harmonization/category-list.controller.ts +++ b/packages/blocks/category-list/src/api-harmonization/category-list.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { GetCategoryListBlockQuery } from './category-list.request'; @@ -16,7 +16,7 @@ export class CategoryListController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getCategoryListBlock(@Headers() headers: ApiModels.Headers.AppHeaders, @Query() query: GetCategoryListBlockQuery) { + getCategoryListBlock(@Headers() headers: AppHeaders, @Query() query: GetCategoryListBlockQuery) { return this.service.getCategoryListBlock(query, headers); } } diff --git a/packages/blocks/category-list/src/api-harmonization/category-list.service.ts b/packages/blocks/category-list/src/api-harmonization/category-list.service.ts index 986ef6174..d0eaf8278 100644 --- a/packages/blocks/category-list/src/api-harmonization/category-list.service.ts +++ b/packages/blocks/category-list/src/api-harmonization/category-list.service.ts @@ -2,12 +2,14 @@ import { Injectable } from '@nestjs/common'; import { Articles, CMS } from '@o2s/configs.integrations'; import { Observable, catchError, concatMap, forkJoin, map, of } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapCategoryList } from './category-list.mapper'; import { CategoryListBlock } from './category-list.model'; import { GetCategoryListBlockQuery } from './category-list.request'; +const H = HeaderName; + @Injectable() export class CategoryListService { constructor( @@ -15,18 +17,15 @@ export class CategoryListService { private readonly articlesService: Articles.Service, ) {} - getCategoryListBlock( - query: GetCategoryListBlockQuery, - headers: ApiModels.Headers.AppHeaders, - ): Observable<CategoryListBlock> { - const cms$ = this.cmsService.getCategoryListBlock({ ...query, locale: headers['x-locale'] }); + getCategoryListBlock(query: GetCategoryListBlockQuery, headers: AppHeaders): Observable<CategoryListBlock> { + const cms$ = this.cmsService.getCategoryListBlock({ ...query, locale: headers[H.Locale] }); return forkJoin([cms$]).pipe( concatMap(([cms]) => { if (cms.categoryIds) { return forkJoin( cms.categoryIds.map((categoryId: string) => - this.articlesService.getCategory({ id: categoryId, locale: headers['x-locale'] }).pipe( + this.articlesService.getCategory({ id: categoryId, locale: headers[H.Locale] }).pipe( catchError(() => of(null)), // Return null if category not found ), ), @@ -36,15 +35,15 @@ export class CategoryListService { const validCategories = categories.filter( (cat): cat is Articles.Model.Category => cat !== null, ); - return mapCategoryList(cms, validCategories, headers['x-locale']); + return mapCategoryList(cms, validCategories, headers[H.Locale]); }), ); } else { return this.articlesService .getCategoryList({ - locale: headers['x-locale'], + locale: headers[H.Locale], }) - .pipe(map((categories) => mapCategoryList(cms, categories.data, headers['x-locale']))); + .pipe(map((categories) => mapCategoryList(cms, categories.data, headers[H.Locale]))); } }), ); diff --git a/packages/blocks/category-list/src/frontend/CategoryList.types.ts b/packages/blocks/category-list/src/frontend/CategoryList.types.ts index 8e407cdd4..738c51d46 100644 --- a/packages/blocks/category-list/src/frontend/CategoryList.types.ts +++ b/packages/blocks/category-list/src/frontend/CategoryList.types.ts @@ -1,14 +1,9 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/category-list.client'; -export interface CategoryListProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; - isDraftModeEnabled?: boolean; -} +export type CategoryListProps = Models.BlockProps.BlockWithDraftModeProps<ReturnType<typeof defineRouting>>; export type CategoryListPureProps = CategoryListProps & Model.CategoryListBlock; diff --git a/packages/blocks/category-list/src/sdk/category-list.ts b/packages/blocks/category-list/src/sdk/category-list.ts index a09281b6a..bbdad5d8b 100644 --- a/packages/blocks/category-list/src/sdk/category-list.ts +++ b/packages/blocks/category-list/src/sdk/category-list.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/category-list.client'; @@ -12,7 +12,7 @@ export const categoryList = (sdk: Sdk) => ({ blocks: { getCategoryList: ( query: Request.GetCategoryListBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.CategoryListBlock> => sdk.makeRequest({ diff --git a/packages/blocks/category-list/src/sdk/index.ts b/packages/blocks/category-list/src/sdk/index.ts index 877e48afc..d62644b25 100644 --- a/packages/blocks/category-list/src/sdk/index.ts +++ b/packages/blocks/category-list/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { categoryList } from './category-list'; diff --git a/packages/blocks/category/CHANGELOG.md b/packages/blocks/category/CHANGELOG.md index 4ae7b101d..260a2505b 100644 --- a/packages/blocks/category/CHANGELOG.md +++ b/packages/blocks/category/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.category +## 1.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.6.0 ### Minor Changes diff --git a/packages/blocks/category/package.json b/packages/blocks/category/package.json index 5730c769a..6449da25f 100644 --- a/packages/blocks/category/package.json +++ b/packages/blocks/category/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.category", - "version": "1.6.0", + "version": "1.6.2", "private": false, "license": "MIT", "description": "A block displaying a category with its articles, including pagination and filtering capabilities.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/category/src/api-harmonization/category.controller.ts b/packages/blocks/category/src/api-harmonization/category.controller.ts index 68098d643..4f0cc0dbf 100644 --- a/packages/blocks/category/src/api-harmonization/category.controller.ts +++ b/packages/blocks/category/src/api-harmonization/category.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { GetCategoryBlockArticlesQuery, GetCategoryBlockQuery } from './category.request'; @@ -16,16 +16,13 @@ export class CategoryController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getCategoryBlock(@Headers() headers: ApiModels.Headers.AppHeaders, @Query() query: GetCategoryBlockQuery) { + getCategoryBlock(@Headers() headers: AppHeaders, @Query() query: GetCategoryBlockQuery) { return this.service.getCategoryBlock(query, headers); } @Get('articles') @Auth.Decorators.Roles({ roles: [] }) - getCategoryArticles( - @Headers() headers: ApiModels.Headers.AppHeaders, - @Query() query: GetCategoryBlockArticlesQuery, - ) { + getCategoryArticles(@Headers() headers: AppHeaders, @Query() query: GetCategoryBlockArticlesQuery) { return this.service.getCategoryArticles(query, headers); } } diff --git a/packages/blocks/category/src/api-harmonization/category.service.ts b/packages/blocks/category/src/api-harmonization/category.service.ts index 0b6fe9436..83dece31d 100644 --- a/packages/blocks/category/src/api-harmonization/category.service.ts +++ b/packages/blocks/category/src/api-harmonization/category.service.ts @@ -2,12 +2,14 @@ import { Injectable } from '@nestjs/common'; import { Articles, CMS } from '@o2s/configs.integrations'; import { Observable, concatMap, forkJoin, map } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapCategory, mapCategoryArticles } from './category.mapper'; import { CategoryArticles, CategoryBlock } from './category.model'; import { GetCategoryBlockArticlesQuery, GetCategoryBlockQuery } from './category.request'; +const H = HeaderName; + @Injectable() export class CategoryService { constructor( @@ -15,28 +17,25 @@ export class CategoryService { private readonly articlesService: Articles.Service, ) {} - getCategoryBlock(query: GetCategoryBlockQuery, headers: ApiModels.Headers.AppHeaders): Observable<CategoryBlock> { - const cms = this.cmsService.getCategoryBlock({ ...query, locale: headers['x-locale'] }); + getCategoryBlock(query: GetCategoryBlockQuery, headers: AppHeaders): Observable<CategoryBlock> { + const cms = this.cmsService.getCategoryBlock({ ...query, locale: headers[H.Locale] }); return forkJoin([cms]).pipe( concatMap(([cms]) => { return forkJoin([ - this.articlesService.getCategory({ id: cms.categoryId, locale: headers['x-locale'] }), + this.articlesService.getCategory({ id: cms.categoryId, locale: headers[H.Locale] }), this.articlesService.getArticleList({ limit: query.limit || 6, - locale: headers['x-locale'], + locale: headers[H.Locale], category: cms.categoryId, }), - ]).pipe(map(([category, articles]) => mapCategory(cms, category, articles, headers['x-locale']))); + ]).pipe(map(([category, articles]) => mapCategory(cms, category, articles, headers[H.Locale]))); }), ); } - getCategoryArticles( - query: GetCategoryBlockArticlesQuery, - headers: ApiModels.Headers.AppHeaders, - ): Observable<CategoryArticles> { - const cms = this.cmsService.getCategoryBlock({ ...query, locale: headers['x-locale'] }); + getCategoryArticles(query: GetCategoryBlockArticlesQuery, headers: AppHeaders): Observable<CategoryArticles> { + const cms = this.cmsService.getCategoryBlock({ ...query, locale: headers[H.Locale] }); return forkJoin([cms]).pipe( concatMap(([cms]) => { @@ -44,10 +43,10 @@ export class CategoryService { this.articlesService.getArticleList({ limit: query.limit || 6, offset: query.offset || 0, - locale: headers['x-locale'], + locale: headers[H.Locale], category: cms.categoryId, }), - ]).pipe(map(([articles]) => mapCategoryArticles(cms, articles, headers['x-locale']))); + ]).pipe(map(([articles]) => mapCategoryArticles(cms, articles, headers[H.Locale]))); }), ); } diff --git a/packages/blocks/category/src/frontend/Category.types.ts b/packages/blocks/category/src/frontend/Category.types.ts index eaf94247e..569fb0904 100644 --- a/packages/blocks/category/src/frontend/Category.types.ts +++ b/packages/blocks/category/src/frontend/Category.types.ts @@ -1,17 +1,12 @@ import { defineRouting } from 'next-intl/routing'; import React from 'react'; +import type { Models } from '@o2s/framework/modules'; import { CMS } from '@o2s/framework/modules'; import type { Model } from '../api-harmonization/category.client'; -export interface CategoryProps { - id: string; - slug: string[]; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; +export interface CategoryProps extends Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>> { renderBlocks: (blocks: CMS.Model.Page.SlotBlock[], slug: string[]) => React.ReactNode; } diff --git a/packages/blocks/category/src/sdk/category.ts b/packages/blocks/category/src/sdk/category.ts index 283ac7c7f..2cef0420d 100644 --- a/packages/blocks/category/src/sdk/category.ts +++ b/packages/blocks/category/src/sdk/category.ts @@ -1,6 +1,6 @@ -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/category.client'; @@ -12,7 +12,7 @@ export const category = (sdk: Sdk) => ({ blocks: { getCategory: ( query: Request.GetCategoryBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.CategoryBlock> => sdk.makeRequest({ @@ -31,7 +31,7 @@ export const category = (sdk: Sdk) => ({ }), getCategoryArticles: ( query: Request.GetCategoryBlockArticlesQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.CategoryArticles> => sdk.makeRequest({ diff --git a/packages/blocks/category/src/sdk/index.ts b/packages/blocks/category/src/sdk/index.ts index a26fe4038..6628b4536 100644 --- a/packages/blocks/category/src/sdk/index.ts +++ b/packages/blocks/category/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { category } from './category'; diff --git a/packages/blocks/checkout-billing-payment/.gitignore b/packages/blocks/checkout-billing-payment/.gitignore new file mode 100644 index 000000000..29986a380 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/.gitignore @@ -0,0 +1,57 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +/tsconfig.tsbuildinfo diff --git a/packages/blocks/checkout-billing-payment/.prettierrc.mjs b/packages/blocks/checkout-billing-payment/.prettierrc.mjs new file mode 100644 index 000000000..93b66d398 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/.prettierrc.mjs @@ -0,0 +1,25 @@ +import apiConfig from "@o2s/prettier-config/api.mjs"; +import frontendConfig from "@o2s/prettier-config/frontend.mjs"; + +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +const config = { + overrides: [ + { + files: "./src/api-harmonization/**/*", + options: apiConfig, + }, + { + files: "./src/frontend/**/*", + options: frontendConfig, + }, + { + files: "./src/sdk/**/*", + options: frontendConfig, + }, + ], +}; + +export default config; diff --git a/packages/blocks/checkout-billing-payment/CHANGELOG.md b/packages/blocks/checkout-billing-payment/CHANGELOG.md new file mode 100644 index 000000000..6a4136ccd --- /dev/null +++ b/packages/blocks/checkout-billing-payment/CHANGELOG.md @@ -0,0 +1,32 @@ +# @o2s/blocks.checkout-billing-payment + +## 0.1.1 + +### Patch Changes + +- c1a5460: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + +## 0.1.0 + +### Minor Changes + +- 5d36519: Added new blocks: Cart, Checkout (Summary, Shipping Address, Company Data, Billing Payment) and Order Confirmation. Includes checkout forms validation (Formik + Yup), error handling, promo code support in cart, and new UI components (StepIndicator, RadioTile, AddressFields, CartSummary, QuantityInput, FormField). + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] +- Updated dependencies [5d36519] + - @o2s/framework@1.19.0 + - @o2s/utils.frontend@0.5.0 + - @o2s/ui@1.12.0 diff --git a/packages/blocks/checkout-billing-payment/README.md b/packages/blocks/checkout-billing-payment/README.md new file mode 100644 index 000000000..7a34422bf --- /dev/null +++ b/packages/blocks/checkout-billing-payment/README.md @@ -0,0 +1,87 @@ +# @o2s/blocks.checkout-billing-payment + +Block for the payment step in the checkout flow. + +The checkout-billing-payment block lets users select a payment method. Data is fetched client-side using `cartId` from localStorage. Part of the multi-step checkout flow. + +- **Payment method** – Select from available providers (card, BLIK, etc.) +- **Cart summary** – Subtotal, tax, shipping, total alongside the form +- **Provider integration** – Uses Payments integration for available methods + +Content editors place the block via CMS. Developers connect Carts, Checkout, Payments, and CMS integrations. + +## Installation + +```bash +npm install @o2s/blocks.checkout-billing-payment +``` + +## Usage + +### Backend (API Harmonization) + +Register the block in `app.module.ts`: + +```typescript +import * as CheckoutBillingPayment from "@o2s/blocks.checkout-billing-payment/api-harmonization"; +import { AppConfig } from "./app.config"; + +@Module({ + imports: [CheckoutBillingPayment.Module.register(AppConfig)], +}) +export class AppModule {} +``` + +### Frontend + +Register the block in `renderBlocks.tsx`: + +```typescript +import * as CheckoutBillingPayment from '@o2s/blocks.checkout-billing-payment/frontend'; + +export const renderBlocks = async (blocks: CMS.Model.Page.SlotBlock[]) => { + return blocks.map((block) => { + if (block.type === 'checkout-billing-payment') { + return ( + <CheckoutBillingPayment.Renderer + key={block.id} + id={block.id} + slug={slug} + locale={locale} + accessToken={session?.accessToken} + userId={session?.user?.id} + routing={routing} + /> + ); + } + // ... other blocks + }); +}; +``` + +## Configuration + +This block requires Carts, Checkout, Payments, and CMS integrations in `AppConfig`. + +## Related Blocks + +- `@o2s/blocks.cart` - Shopping cart +- `@o2s/blocks.checkout-company-data` - Company details step +- `@o2s/blocks.checkout-shipping-address` - Shipping address step +- `@o2s/blocks.checkout-summary` - Order summary step +- `@o2s/blocks.order-confirmation` - Order confirmation page + +## About Blocks in O2S + +Blocks are self-contained, reusable UI components that combine harmonizing and frontend components into a single package. Each block is independently packaged as an NPM module and includes three primary parts: API Harmonization Module, Frontend Components, and SDK Methods. Blocks allow you to quickly add or remove functionality without impacting other components of the application. + +- **See all blocks**: [Blocks Documentation](https://www.openselfservice.com/docs/main-components/blocks/) +- **View this block in Storybook**: [checkout-billing-payment](https://storybook-o2s.openselfservice.com/?path=/story/blocks-checkoutbillingpayment--default) + +## About O2S + +**Part of [Open Self Service (O2S)](https://www.openselfservice.com/)** - an open-source framework for building composable customer self-service portals. O2S simplifies integration of multiple headless APIs into a scalable frontend, providing an API-agnostic architecture with a normalization layer. + +- **Website**: [https://www.openselfservice.com/](https://www.openselfservice.com/) +- **GitHub**: [https://github.com/o2sdev/openselfservice](https://github.com/o2sdev/openselfservice) +- **Documentation**: [https://www.openselfservice.com/docs](https://www.openselfservice.com/docs) diff --git a/packages/blocks/checkout-billing-payment/eslint.config.mjs b/packages/blocks/checkout-billing-payment/eslint.config.mjs new file mode 100644 index 000000000..223f2af08 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/eslint.config.mjs @@ -0,0 +1,18 @@ +import { config as apiConfig } from '@o2s/eslint-config/api'; +import { config as frontendConfig } from '@o2s/eslint-config/frontend-block'; +import { defineConfig } from 'eslint/config'; + +export default defineConfig([ + { + files: ['src/api-harmonization/**/*'], + extends: [apiConfig], + }, + { + files: ['src/frontend/**/*'], + extends: [frontendConfig], + }, + { + files: ['src/sdk/**/*'], + extends: [frontendConfig], + }, +]); diff --git a/packages/blocks/checkout-billing-payment/lint-staged.config.mjs b/packages/blocks/checkout-billing-payment/lint-staged.config.mjs new file mode 100644 index 000000000..b47bd93b9 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/lint-staged.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/lint-staged-config/base'; + +export default config; diff --git a/packages/blocks/checkout-billing-payment/package.json b/packages/blocks/checkout-billing-payment/package.json new file mode 100644 index 000000000..6557b5afe --- /dev/null +++ b/packages/blocks/checkout-billing-payment/package.json @@ -0,0 +1,60 @@ +{ + "name": "@o2s/blocks.checkout-billing-payment", + "version": "0.1.1", + "private": false, + "license": "MIT", + "description": "Checkout step for selecting a payment method with cart summary preview.", + "exports": { + "./api-harmonization": "./dist/api-harmonization/api-harmonization/index.js", + "./frontend": "./dist/frontend/frontend/index.js", + "./sdk": "./dist/sdk/sdk/index.js", + "./client": "./dist/api-harmonization/api-harmonization/checkout-billing-payment.client.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build tsconfig.json --preserveWatchOutput && tsc-alias", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit && eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"" + }, + "dependencies": { + "@o2s/framework": "*", + "@o2s/utils.logger": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/configs.integrations": "*", + "formik": "^2.4.9", + "yup": "^1.7.1" + }, + "devDependencies": { + "dotenv-cli": "^11.0.0", + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "eslint": "^9.39.4", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "react": "^19", + "react-dom": "^19", + "tailwindcss": "^4", + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "rxjs": "^7", + "next": "^16.0.5", + "next-intl": "^4.1.0" + } +} diff --git a/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.client.ts b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.client.ts new file mode 100644 index 000000000..710100987 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.client.ts @@ -0,0 +1,4 @@ +export const URL = '/blocks/checkout-billing-payment'; + +export * as Model from './checkout-billing-payment.model'; +export * as Request from './checkout-billing-payment.request'; diff --git a/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.controller.ts b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.controller.ts new file mode 100644 index 000000000..5092c1ec6 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; + +import { Models } from '@o2s/utils.api-harmonization'; +import { LoggerService } from '@o2s/utils.logger'; + +import { Auth } from '@o2s/framework/modules'; + +import { URL } from './'; +import { GetCheckoutBillingPaymentBlockQuery } from './checkout-billing-payment.request'; +import { CheckoutBillingPaymentService } from './checkout-billing-payment.service'; + +@Controller(URL) +@UseInterceptors(LoggerService) +export class CheckoutBillingPaymentController { + constructor(protected readonly service: CheckoutBillingPaymentService) {} + + @Get() + @Auth.Decorators.Roles({ roles: [] }) + // Optional: Add permission-based access control + // @Auth.Decorators.Permissions({ resource: 'resource-name', actions: ['view'] }) + getCheckoutBillingPaymentBlock( + @Headers() headers: Models.Headers.AppHeaders, + @Query() query: GetCheckoutBillingPaymentBlockQuery, + ) { + return this.service.getCheckoutBillingPaymentBlock(query, headers); + } +} diff --git a/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.mapper.ts b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.mapper.ts new file mode 100644 index 000000000..f738c1436 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.mapper.ts @@ -0,0 +1,21 @@ +import { CMS } from '@o2s/configs.integrations'; + +import type { CheckoutBillingPaymentBlock } from './checkout-billing-payment.model'; + +export const mapCheckoutBillingPayment = ( + cms: CMS.Model.CheckoutBillingPaymentBlock.CheckoutBillingPaymentBlock, +): CheckoutBillingPaymentBlock => { + return { + __typename: 'CheckoutBillingPaymentBlock', + id: cms.id, + title: cms.title, + subtitle: cms.subtitle, + fields: cms.fields, + buttons: cms.buttons, + errors: cms.errors, + summaryLabels: cms.summaryLabels, + stepIndicator: cms.stepIndicator, + cartPath: cms.cartPath, + orderConfirmationPath: cms.orderConfirmationPath, + }; +}; diff --git a/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.model.ts b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.model.ts new file mode 100644 index 000000000..de094b135 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.model.ts @@ -0,0 +1,16 @@ +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; + +import { CMS } from '@o2s/framework/modules'; + +export class CheckoutBillingPaymentBlock extends ApiModels.Block.Block { + __typename!: 'CheckoutBillingPaymentBlock'; + title!: string; + subtitle?: string; + fields!: CMS.Model.CheckoutBillingPaymentBlock.CheckoutBillingPaymentBlock['fields']; + buttons!: CMS.Model.CheckoutBillingPaymentBlock.CheckoutBillingPaymentBlock['buttons']; + errors!: CMS.Model.CheckoutBillingPaymentBlock.CheckoutBillingPaymentBlock['errors']; + summaryLabels!: CMS.Model.CheckoutBillingPaymentBlock.CheckoutBillingPaymentBlock['summaryLabels']; + stepIndicator?: CMS.Model.CheckoutBillingPaymentBlock.CheckoutBillingPaymentBlock['stepIndicator']; + cartPath?: CMS.Model.CheckoutBillingPaymentBlock.CheckoutBillingPaymentBlock['cartPath']; + orderConfirmationPath!: CMS.Model.CheckoutBillingPaymentBlock.CheckoutBillingPaymentBlock['orderConfirmationPath']; +} diff --git a/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.module.ts b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.module.ts new file mode 100644 index 000000000..5d7828ed5 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.module.ts @@ -0,0 +1,25 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { CMS } from '@o2s/configs.integrations'; + +import * as Framework from '@o2s/framework/modules'; + +import { CheckoutBillingPaymentController } from './checkout-billing-payment.controller'; +import { CheckoutBillingPaymentService } from './checkout-billing-payment.service'; + +@Module({}) +export class CheckoutBillingPaymentBlockModule { + static register(_config: Framework.ApiConfig): DynamicModule { + return { + module: CheckoutBillingPaymentBlockModule, + providers: [ + CheckoutBillingPaymentService, + { + provide: CMS.Service, + useExisting: Framework.CMS.Service, + }, + ], + controllers: [CheckoutBillingPaymentController], + exports: [CheckoutBillingPaymentService], + }; + } +} diff --git a/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.request.ts b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.request.ts new file mode 100644 index 000000000..bb43c9d5e --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.request.ts @@ -0,0 +1,5 @@ +import { CMS } from '@o2s/framework/modules'; + +export class GetCheckoutBillingPaymentBlockQuery implements Omit<CMS.Request.GetCmsEntryParams, 'locale'> { + id!: string; +} diff --git a/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.service.spec.ts b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.service.spec.ts new file mode 100644 index 000000000..4faffbe97 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.service.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CMS } from '@o2s/configs.integrations'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CheckoutBillingPaymentService } from './checkout-billing-payment.service'; + +describe('CheckoutBillingPaymentService', () => { + let service: CheckoutBillingPaymentService; + let cmsService: CMS.Service; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CheckoutBillingPaymentService, + { + provide: CMS.Service, + useValue: { + getCheckoutBillingPaymentBlock: vi.fn().mockReturnValue({ + title: 'Test Block', + }), + }, + }, + ], + }).compile(); + + service = module.get<CheckoutBillingPaymentService>(CheckoutBillingPaymentService); + cmsService = module.get<CMS.Service>(CMS.Service); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should have cmsService injected', () => { + expect(cmsService).toBeDefined(); + }); +}); diff --git a/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.service.ts b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.service.ts new file mode 100644 index 000000000..9672fe48c --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/api-harmonization/checkout-billing-payment.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { CMS } from '@o2s/configs.integrations'; +import { Observable, forkJoin, map } from 'rxjs'; + +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; + +// import { Auth } from '@o2s/framework/modules'; + +import { mapCheckoutBillingPayment } from './checkout-billing-payment.mapper'; +import { CheckoutBillingPaymentBlock } from './checkout-billing-payment.model'; +import { GetCheckoutBillingPaymentBlockQuery } from './checkout-billing-payment.request'; + +const H = HeaderName; + +@Injectable() +export class CheckoutBillingPaymentService { + constructor( + private readonly cmsService: CMS.Service, + // Optional: Inject Auth.Service when you need to add permission flags to the response + // private readonly authService: Auth.Service, + ) {} + + getCheckoutBillingPaymentBlock( + query: GetCheckoutBillingPaymentBlockQuery, + headers: AppHeaders, + ): Observable<CheckoutBillingPaymentBlock> { + const cms = this.cmsService.getCheckoutBillingPaymentBlock({ ...query, locale: headers[H.Locale] }); + + return forkJoin([cms]).pipe( + map(([cms]) => { + return mapCheckoutBillingPayment(cms); + }), + ); + } +} diff --git a/packages/blocks/checkout-billing-payment/src/api-harmonization/index.ts b/packages/blocks/checkout-billing-payment/src/api-harmonization/index.ts new file mode 100644 index 000000000..1c2fa2653 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/api-harmonization/index.ts @@ -0,0 +1,8 @@ +export const URL = '/blocks/checkout-billing-payment'; + +export { CheckoutBillingPaymentBlockModule as Module } from './checkout-billing-payment.module'; +export { CheckoutBillingPaymentService as Service } from './checkout-billing-payment.service'; +export { CheckoutBillingPaymentController as Controller } from './checkout-billing-payment.controller'; + +export * as Model from './checkout-billing-payment.model'; +export * as Request from './checkout-billing-payment.request'; diff --git a/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.client.stories.tsx b/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.client.stories.tsx new file mode 100644 index 000000000..373c6973c --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.client.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { defineRouting } from 'next-intl/routing'; + +import readme from '../../README.md?raw'; + +import { CheckoutBillingPaymentPure } from './CheckoutBillingPayment.client'; + +const routing = defineRouting({ + locales: ['en'], + defaultLocale: 'en', + pathnames: {}, +}); + +const baseBlock = { + __typename: 'CheckoutBillingPaymentBlock' as const, + id: 'checkout-billing-payment-1', + stepIndicator: { + steps: ['Company details', 'Delivery', 'Payment', 'Summary'], + currentStep: 3, + }, + title: 'Billing and payment', + subtitle: 'Select payment method', + fields: { + paymentMethod: { + label: 'Payment method', + placeholder: 'Select method', + required: true, + }, + }, + buttons: { + back: { + label: 'Back', + path: '#', + }, + next: { + label: 'Next', + path: '#', + }, + }, + errors: { + required: 'This field is required', + cartNotFound: 'Cart not found', + submitError: 'Something went wrong. Please try again.', + }, + summaryLabels: { + title: 'Summary', + subtotalLabel: 'Subtotal', + taxLabel: 'VAT (23%)', + totalLabel: 'Total', + discountLabel: 'Discount', + shippingLabel: 'Shipping', + freeLabel: 'Free', + }, + totals: { + subtotal: { value: 204.97, currency: 'EUR' as const }, + tax: { value: 47.14, currency: 'EUR' as const }, + total: { value: 252.11, currency: 'EUR' as const }, + }, + orderConfirmationPath: '/order-confirmation', +}; + +const meta = { + title: 'Blocks/CheckoutBillingPayment', + component: CheckoutBillingPaymentPure, + tags: ['autodocs'], + parameters: { readme }, +} satisfies Meta<typeof CheckoutBillingPaymentPure>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default: Story = { + args: { + ...baseBlock, + id: 'checkout-billing-payment-1', + locale: 'en', + routing, + }, +}; diff --git a/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.client.tsx b/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.client.tsx new file mode 100644 index 000000000..f0be61a79 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.client.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { ErrorMessage, Field, FieldProps, Form, Formik } from 'formik'; +import { createNavigation } from 'next-intl/navigation'; +import React, { useEffect, useState, useTransition } from 'react'; +import { object as YupObject, string as YupString } from 'yup'; + +import { Carts, Models, Payments } from '@o2s/framework/modules'; + +import { useToast } from '@o2s/ui/hooks/use-toast'; + +import { CartSummary, CartSummarySkeleton } from '@o2s/ui/components/Cart/CartSummary'; +import { StepIndicator } from '@o2s/ui/components/Checkout/StepIndicator'; +import { RadioTileGroup } from '@o2s/ui/components/RadioTile'; + +import { Typography } from '@o2s/ui/elements/typography'; + +import { sdk } from '../sdk'; + +import { CheckoutBillingPaymentPureProps } from './CheckoutBillingPayment.types'; + +const CART_ID_KEY = 'cartId'; +const FORM_ID = 'checkout-billing-form'; + +export const CheckoutBillingPaymentPure: React.FC<Readonly<CheckoutBillingPaymentPureProps>> = ({ + locale, + accessToken, + routing, + title, + subtitle, + stepIndicator, + fields, + buttons, + errors, + summaryLabels, + cartPath, + orderConfirmationPath, +}) => { + const { Link: LinkComponent, useRouter } = createNavigation(routing); + const router = useRouter(); + const { toast } = useToast(); + + const [totals, setTotals] = useState< + | { + subtotal: Models.Price.Price; + tax: Models.Price.Price; + total: Models.Price.Price; + discountTotal?: Models.Price.Price; + shippingTotal?: Models.Price.Price; + } + | undefined + >(); + const [cartPromotions, setCartPromotions] = useState<Carts.Model.Promotion[] | undefined>(); + const [paymentProviders, setPaymentProviders] = useState<Payments.Model.PaymentProvider[]>([]); + const [isInitialLoadPending, startInitialLoadTransition] = useTransition(); + const [isSubmitPending, startSubmitTransition] = useTransition(); + const [initialFormValues, setInitialFormValues] = useState({ + paymentMethod: '', + }); + + useEffect(() => { + const cartId = localStorage.getItem(CART_ID_KEY); + if (!cartId) { + toast({ description: errors?.cartNotFound, variant: 'destructive' }); + router.replace(cartPath ?? '/'); + return; + } + + startInitialLoadTransition(async () => { + try { + const cart = await sdk.carts.getCart(cartId, { 'x-locale': locale }, accessToken); + if (cart.subtotal && cart.taxTotal && cart.total) { + setTotals({ + subtotal: cart.subtotal, + tax: cart.taxTotal, + total: cart.total, + discountTotal: cart.discountTotal, + shippingTotal: cart.shippingMethod?.total, + }); + } + setCartPromotions(cart.promotions); + const providers = await sdk.payments.getProviders( + { ...(cart.regionId && { regionId: cart.regionId }) }, + { 'x-locale': locale }, + accessToken, + ); + setPaymentProviders(providers.data ?? []); + if (cart.paymentMethod) { + setInitialFormValues({ paymentMethod: cart.paymentMethod.id }); + } + } catch { + toast({ description: errors?.cartNotFound, variant: 'destructive' }); + router.replace(cartPath ?? '/'); + } + }); + }, [locale, accessToken, toast, errors?.cartNotFound, router, cartPath]); + + const validationSchema = YupObject().shape({ + paymentMethod: fields.paymentMethod.required ? YupString().required(errors.required) : YupString(), + }); + + const handleSubmit = (values: { paymentMethod: string }) => { + const cartId = localStorage.getItem(CART_ID_KEY); + if (!cartId) return; + + startSubmitTransition(async () => { + try { + const returnUrl = `${window.location.origin}${orderConfirmationPath}`; + const cancelUrl = window.location.href; + await sdk.checkout.setPayment( + cartId, + { + providerId: values.paymentMethod, + returnUrl, + cancelUrl, + }, + { 'x-locale': locale }, + accessToken, + ); + router.push(buttons.next.path); + } catch { + toast({ variant: 'destructive', description: errors.submitError }); + } + }); + }; + + return ( + <div className="w-full flex flex-col gap-8"> + {stepIndicator && <StepIndicator steps={stepIndicator.steps} currentStep={stepIndicator.currentStep} />} + <div className="flex flex-col gap-2"> + <Typography variant="h1">{title}</Typography> + {subtitle && ( + <Typography variant="large" className="text-muted-foreground"> + {subtitle} + </Typography> + )} + </div> + + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> + <div className="lg:col-span-2"> + <Formik + initialValues={initialFormValues} + enableReinitialize + validationSchema={validationSchema} + onSubmit={handleSubmit} + validateOnBlur={true} + validateOnMount={false} + validateOnChange={false} + > + {() => ( + <Form id={FORM_ID} className="w-full flex flex-col gap-6"> + <fieldset className="flex flex-col gap-2"> + <legend className="text-sm font-medium leading-none mb-2"> + {fields.paymentMethod.label} + {fields.paymentMethod.required && <span className="text-destructive"> *</span>} + </legend> + <Field name="paymentMethod"> + {({ field, form: { touched, errors, setFieldValue } }: FieldProps<string>) => ( + <> + <RadioTileGroup + value={field.value} + onValueChange={(value) => setFieldValue('paymentMethod', value)} + hasError={!!(touched.paymentMethod && errors.paymentMethod)} + options={paymentProviders.map((provider) => ({ + id: provider.id, + label: provider.name, + }))} + /> + <ErrorMessage name="paymentMethod"> + {(msg) => ( + <Typography variant="small" className="text-destructive"> + {msg} + </Typography> + )} + </ErrorMessage> + </> + )} + </Field> + </fieldset> + </Form> + )} + </Formik> + </div> + + <div className="lg:col-span-1"> + {isInitialLoadPending ? ( + <CartSummarySkeleton /> + ) : totals ? ( + <CartSummary + subtotal={totals.subtotal} + tax={totals.tax} + total={totals.total} + discountTotal={totals.discountTotal} + shippingTotal={totals.shippingTotal} + promotions={cartPromotions} + labels={summaryLabels} + LinkComponent={LinkComponent} + primaryButton={{ + label: buttons.next.label, + disabled: isSubmitPending, + action: { type: 'submit', form: FORM_ID }, + }} + secondaryButton={{ + label: buttons.back.label, + action: { type: 'link', url: buttons.back.path }, + }} + /> + ) : null} + </div> + </div> + </div> + ); +}; diff --git a/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.renderer.tsx b/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.renderer.tsx new file mode 100644 index 000000000..b04f10b25 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.renderer.tsx @@ -0,0 +1,36 @@ +import { useLocale } from 'next-intl'; +import React, { Suspense } from 'react'; + +import { Loading } from '@o2s/ui/components/Loading'; + +import { CheckoutBillingPayment } from './CheckoutBillingPayment.server'; +import { CheckoutBillingPaymentRendererProps } from './CheckoutBillingPayment.types'; + +export const CheckoutBillingPaymentRenderer: React.FC<CheckoutBillingPaymentRendererProps> = ({ + id, + accessToken, + routing, +}) => { + const locale = useLocale(); + + return ( + <Suspense + key={id} + fallback={ + <div className="w-full flex flex-col gap-8"> + <Loading bars={1} /> + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> + <div className="lg:col-span-2"> + <Loading bars={4} /> + </div> + <div className="lg:col-span-1"> + <Loading bars={4} /> + </div> + </div> + </div> + } + > + <CheckoutBillingPayment id={id} accessToken={accessToken} locale={locale} routing={routing} /> + </Suspense> + ); +}; diff --git a/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.server.tsx b/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.server.tsx new file mode 100644 index 000000000..f7a217ebf --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.server.tsx @@ -0,0 +1,36 @@ +import dynamic from 'next/dynamic'; +import React from 'react'; + +import type { Model } from '../api-harmonization/checkout-billing-payment.client'; +import { sdk } from '../sdk'; + +import { CheckoutBillingPaymentProps } from './CheckoutBillingPayment.types'; + +export const CheckoutBillingPaymentDynamic = dynamic(() => + import('./CheckoutBillingPayment.client').then((module) => module.CheckoutBillingPaymentPure), +); + +export const CheckoutBillingPayment: React.FC<CheckoutBillingPaymentProps> = async ({ + id, + accessToken, + locale, + routing, +}) => { + let data: Model.CheckoutBillingPaymentBlock; + try { + data = await sdk.blocks.getCheckoutBillingPayment( + { + id, + }, + { 'x-locale': locale }, + accessToken, + ); + } catch (error) { + console.error('Error fetching CheckoutBillingPayment block', error); + return null; + } + + return ( + <CheckoutBillingPaymentDynamic {...data} id={id} accessToken={accessToken} locale={locale} routing={routing} /> + ); +}; diff --git a/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.types.ts b/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.types.ts new file mode 100644 index 000000000..ac121c4e3 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/frontend/CheckoutBillingPayment.types.ts @@ -0,0 +1,16 @@ +import { defineRouting } from 'next-intl/routing'; + +import type { Model } from '../api-harmonization/checkout-billing-payment.client'; + +export interface CheckoutBillingPaymentProps { + id: string; + accessToken?: string; + locale: string; + routing: ReturnType<typeof defineRouting>; +} + +export type CheckoutBillingPaymentPureProps = CheckoutBillingPaymentProps & Model.CheckoutBillingPaymentBlock; + +export type CheckoutBillingPaymentRendererProps = Omit<CheckoutBillingPaymentProps, ''> & { + slug: string[]; +}; diff --git a/packages/blocks/checkout-billing-payment/src/frontend/index.ts b/packages/blocks/checkout-billing-payment/src/frontend/index.ts new file mode 100644 index 000000000..7fb48ea72 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/frontend/index.ts @@ -0,0 +1,5 @@ +export { CheckoutBillingPaymentPure as Client } from './CheckoutBillingPayment.client'; +export { CheckoutBillingPayment as Server } from './CheckoutBillingPayment.server'; +export { CheckoutBillingPaymentRenderer as Renderer } from './CheckoutBillingPayment.renderer'; + +export * as Types from './CheckoutBillingPayment.types'; diff --git a/packages/blocks/checkout-billing-payment/src/sdk/checkout-billing-payment.ts b/packages/blocks/checkout-billing-payment/src/sdk/checkout-billing-payment.ts new file mode 100644 index 000000000..b179b0e26 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/sdk/checkout-billing-payment.ts @@ -0,0 +1,99 @@ +import { Models } from '@o2s/utils.api-harmonization'; +import { Utils } from '@o2s/utils.frontend'; + +import { Carts, Checkout, Payments } from '@o2s/framework/modules'; +import { Sdk } from '@o2s/framework/sdk'; + +import { Model, Request, URL } from '../api-harmonization/checkout-billing-payment.client'; + +const API_URL = URL; +const CARTS_API_URL = '/carts'; +const CHECKOUT_API_URL = '/checkout'; +const PAYMENTS_API_URL = '/payments'; + +export const checkoutBillingPayment = (sdk: Sdk) => ({ + blocks: { + getCheckoutBillingPayment: ( + query: Request.GetCheckoutBillingPaymentBlockQuery, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Model.CheckoutBillingPaymentBlock> => + sdk.makeRequest({ + method: 'get', + url: `${API_URL}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + params: query, + }), + }, + carts: { + getCart: ( + cartId: string, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Carts.Model.Cart> => + sdk.makeRequest({ + method: 'get', + url: `${CARTS_API_URL}/${cartId}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + }), + }, + checkout: { + setPayment: ( + cartId: string, + body: Checkout.Request.SetPaymentBody, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Payments.Model.PaymentSession> => + sdk.makeRequest({ + method: 'post', + url: `${CHECKOUT_API_URL}/${cartId}/payment`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + data: body, + }), + }, + payments: { + getProviders: ( + params: Payments.Request.GetProvidersParams, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Payments.Model.PaymentProviders> => + sdk.makeRequest({ + method: 'get', + url: `${PAYMENTS_API_URL}/providers`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + params, + }), + }, +}); diff --git a/packages/blocks/checkout-billing-payment/src/sdk/index.ts b/packages/blocks/checkout-billing-payment/src/sdk/index.ts new file mode 100644 index 000000000..e9a563d4c --- /dev/null +++ b/packages/blocks/checkout-billing-payment/src/sdk/index.ts @@ -0,0 +1,39 @@ +// these unused imports are necessary for TypeScript to properly resolve API methods +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Models } from '@o2s/utils.api-harmonization'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Carts, Checkout, Payments } from '@o2s/framework/modules'; +import { extendSdk, getSdk } from '@o2s/framework/sdk'; + +import { checkoutBillingPayment } from './checkout-billing-payment'; + +const API_URL = + (typeof window === 'undefined' ? process.env.NEXT_PUBLIC_API_URL_INTERNAL : process.env.NEXT_PUBLIC_API_URL) || + process.env.NEXT_PUBLIC_API_URL; + +const internalSdk = getSdk({ + apiUrl: API_URL!, + logger: { + // @ts-expect-error missing types + level: process.env.NEXT_PUBLIC_LOG_LEVEL, + // @ts-expect-error missing types + format: process.env.NEXT_PUBLIC_LOG_FORMAT, + colorsEnabled: process.env.NEXT_PUBLIC_LOG_COLORS_ENABLED === 'true', + }, +}); + +export const sdk = extendSdk(internalSdk, { + blocks: { + getCheckoutBillingPayment: checkoutBillingPayment(internalSdk).blocks.getCheckoutBillingPayment, + }, + carts: { + getCart: checkoutBillingPayment(internalSdk).carts.getCart, + }, + checkout: { + setPayment: checkoutBillingPayment(internalSdk).checkout.setPayment, + }, + payments: { + getProviders: checkoutBillingPayment(internalSdk).payments.getProviders, + }, +}); diff --git a/packages/blocks/checkout-billing-payment/tsconfig.api.json b/packages/blocks/checkout-billing-payment/tsconfig.api.json new file mode 100644 index 000000000..0f9f79f8e --- /dev/null +++ b/packages/blocks/checkout-billing-payment/tsconfig.api.json @@ -0,0 +1,14 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "outDir": "./dist/api-harmonization", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src/api-harmonization", + }, + "include": ["src/api-harmonization"] +} diff --git a/packages/blocks/checkout-billing-payment/tsconfig.frontend.json b/packages/blocks/checkout-billing-payment/tsconfig.frontend.json new file mode 100644 index 000000000..bbf67886f --- /dev/null +++ b/packages/blocks/checkout-billing-payment/tsconfig.frontend.json @@ -0,0 +1,22 @@ +{ + "extends": "@o2s/typescript-config/frontend.json", + "compilerOptions": { + "outDir": "./dist/frontend", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "noEmit": false, + "jsx": "react", + "baseUrl": "./src/frontend" + }, + "include": [ + "src/frontend", + "src/api-harmonization/checkout-billing-payment.client.ts", + "src/api-harmonization/checkout-billing-payment.model.ts", + "src/api-harmonization/checkout-billing-payment.request.ts", + "src/sdk" + ] +} diff --git a/packages/blocks/checkout-billing-payment/tsconfig.json b/packages/blocks/checkout-billing-payment/tsconfig.json new file mode 100644 index 000000000..c3031c1dd --- /dev/null +++ b/packages/blocks/checkout-billing-payment/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@o2s/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src", + }, + "references": [ + { "path": "./tsconfig.frontend.json" }, + { "path": "./tsconfig.api.json" }, + { "path": "./tsconfig.sdk.json" } + ] +} diff --git a/packages/blocks/checkout-billing-payment/tsconfig.sdk.json b/packages/blocks/checkout-billing-payment/tsconfig.sdk.json new file mode 100644 index 000000000..03e5639e5 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/tsconfig.sdk.json @@ -0,0 +1,19 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "outDir": "./dist/sdk", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src/sdk" + }, + "include": [ + "src/sdk", + "src/api-harmonization/checkout-billing-payment.client.ts", + "src/api-harmonization/checkout-billing-payment.model.ts", + "src/api-harmonization/checkout-billing-payment.request.ts" + ] +} diff --git a/packages/blocks/checkout-billing-payment/vitest.config.mjs b/packages/blocks/checkout-billing-payment/vitest.config.mjs new file mode 100644 index 000000000..82be23c07 --- /dev/null +++ b/packages/blocks/checkout-billing-payment/vitest.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/vitest-config/block'; + +export default config; diff --git a/packages/blocks/checkout-company-data/.gitignore b/packages/blocks/checkout-company-data/.gitignore new file mode 100644 index 000000000..29986a380 --- /dev/null +++ b/packages/blocks/checkout-company-data/.gitignore @@ -0,0 +1,57 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +/tsconfig.tsbuildinfo diff --git a/packages/blocks/checkout-company-data/.prettierrc.mjs b/packages/blocks/checkout-company-data/.prettierrc.mjs new file mode 100644 index 000000000..93b66d398 --- /dev/null +++ b/packages/blocks/checkout-company-data/.prettierrc.mjs @@ -0,0 +1,25 @@ +import apiConfig from "@o2s/prettier-config/api.mjs"; +import frontendConfig from "@o2s/prettier-config/frontend.mjs"; + +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +const config = { + overrides: [ + { + files: "./src/api-harmonization/**/*", + options: apiConfig, + }, + { + files: "./src/frontend/**/*", + options: frontendConfig, + }, + { + files: "./src/sdk/**/*", + options: frontendConfig, + }, + ], +}; + +export default config; diff --git a/packages/blocks/checkout-company-data/CHANGELOG.md b/packages/blocks/checkout-company-data/CHANGELOG.md new file mode 100644 index 000000000..e904d75ff --- /dev/null +++ b/packages/blocks/checkout-company-data/CHANGELOG.md @@ -0,0 +1,32 @@ +# @o2s/blocks.checkout-company-data + +## 0.1.1 + +### Patch Changes + +- c1a5460: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + +## 0.1.0 + +### Minor Changes + +- 5d36519: Added new blocks: Cart, Checkout (Summary, Shipping Address, Company Data, Billing Payment) and Order Confirmation. Includes checkout forms validation (Formik + Yup), error handling, promo code support in cart, and new UI components (StepIndicator, RadioTile, AddressFields, CartSummary, QuantityInput, FormField). + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] +- Updated dependencies [5d36519] + - @o2s/framework@1.19.0 + - @o2s/utils.frontend@0.5.0 + - @o2s/ui@1.12.0 diff --git a/packages/blocks/checkout-company-data/README.md b/packages/blocks/checkout-company-data/README.md new file mode 100644 index 000000000..febfe9746 --- /dev/null +++ b/packages/blocks/checkout-company-data/README.md @@ -0,0 +1,87 @@ +# @o2s/blocks.checkout-company-data + +Block for the company details step in the checkout flow. + +The checkout-company-data block collects company information (name, tax ID, billing address) for the order. Data is fetched client-side using `cartId` from localStorage. Part of the multi-step checkout flow. + +- **Company form** – Company name, tax ID, billing address +- **Cart summary** – Subtotal, tax, total alongside the form +- **Validation** – Tax ID, postal code, email validation + +Content editors place the block via CMS. Developers connect Carts, Checkout, and CMS integrations. + +## Installation + +```bash +npm install @o2s/blocks.checkout-company-data +``` + +## Usage + +### Backend (API Harmonization) + +Register the block in `app.module.ts`: + +```typescript +import * as CheckoutCompanyData from "@o2s/blocks.checkout-company-data/api-harmonization"; +import { AppConfig } from "./app.config"; + +@Module({ + imports: [CheckoutCompanyData.Module.register(AppConfig)], +}) +export class AppModule {} +``` + +### Frontend + +Register the block in `renderBlocks.tsx`: + +```typescript +import * as CheckoutCompanyData from '@o2s/blocks.checkout-company-data/frontend'; + +export const renderBlocks = async (blocks: CMS.Model.Page.SlotBlock[]) => { + return blocks.map((block) => { + if (block.type === 'checkout-company-data') { + return ( + <CheckoutCompanyData.Renderer + key={block.id} + id={block.id} + slug={slug} + locale={locale} + accessToken={session?.accessToken} + userId={session?.user?.id} + routing={routing} + /> + ); + } + // ... other blocks + }); +}; +``` + +## Configuration + +This block requires Carts, Checkout, and CMS integrations in `AppConfig`. + +## Related Blocks + +- `@o2s/blocks.cart` - Shopping cart +- `@o2s/blocks.checkout-shipping-address` - Shipping address step +- `@o2s/blocks.checkout-billing-payment` - Payment step +- `@o2s/blocks.checkout-summary` - Order summary step +- `@o2s/blocks.order-confirmation` - Order confirmation page + +## About Blocks in O2S + +Blocks are self-contained, reusable UI components that combine harmonizing and frontend components into a single package. Each block is independently packaged as an NPM module and includes three primary parts: API Harmonization Module, Frontend Components, and SDK Methods. Blocks allow you to quickly add or remove functionality without impacting other components of the application. + +- **See all blocks**: [Blocks Documentation](https://www.openselfservice.com/docs/main-components/blocks/) +- **View this block in Storybook**: [checkout-company-data](https://storybook-o2s.openselfservice.com/?path=/story/blocks-checkoutcompanydata--default) + +## About O2S + +**Part of [Open Self Service (O2S)](https://www.openselfservice.com/)** - an open-source framework for building composable customer self-service portals. O2S simplifies integration of multiple headless APIs into a scalable frontend, providing an API-agnostic architecture with a normalization layer. + +- **Website**: [https://www.openselfservice.com/](https://www.openselfservice.com/) +- **GitHub**: [https://github.com/o2sdev/openselfservice](https://github.com/o2sdev/openselfservice) +- **Documentation**: [https://www.openselfservice.com/docs](https://www.openselfservice.com/docs) diff --git a/packages/blocks/checkout-company-data/eslint.config.mjs b/packages/blocks/checkout-company-data/eslint.config.mjs new file mode 100644 index 000000000..223f2af08 --- /dev/null +++ b/packages/blocks/checkout-company-data/eslint.config.mjs @@ -0,0 +1,18 @@ +import { config as apiConfig } from '@o2s/eslint-config/api'; +import { config as frontendConfig } from '@o2s/eslint-config/frontend-block'; +import { defineConfig } from 'eslint/config'; + +export default defineConfig([ + { + files: ['src/api-harmonization/**/*'], + extends: [apiConfig], + }, + { + files: ['src/frontend/**/*'], + extends: [frontendConfig], + }, + { + files: ['src/sdk/**/*'], + extends: [frontendConfig], + }, +]); diff --git a/packages/blocks/checkout-company-data/lint-staged.config.mjs b/packages/blocks/checkout-company-data/lint-staged.config.mjs new file mode 100644 index 000000000..b47bd93b9 --- /dev/null +++ b/packages/blocks/checkout-company-data/lint-staged.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/lint-staged-config/base'; + +export default config; diff --git a/packages/blocks/checkout-company-data/package.json b/packages/blocks/checkout-company-data/package.json new file mode 100644 index 000000000..ff99372b5 --- /dev/null +++ b/packages/blocks/checkout-company-data/package.json @@ -0,0 +1,60 @@ +{ + "name": "@o2s/blocks.checkout-company-data", + "version": "0.1.1", + "private": false, + "license": "MIT", + "description": "Checkout step for company details, tax ID, billing address, order notes, and cart summary preview.", + "exports": { + "./api-harmonization": "./dist/api-harmonization/api-harmonization/index.js", + "./frontend": "./dist/frontend/frontend/index.js", + "./sdk": "./dist/sdk/sdk/index.js", + "./client": "./dist/api-harmonization/api-harmonization/checkout-company-data.client.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build tsconfig.json --preserveWatchOutput && tsc-alias", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit && eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"" + }, + "dependencies": { + "@o2s/framework": "*", + "@o2s/utils.logger": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/configs.integrations": "*", + "formik": "^2.4.9", + "yup": "^1.7.1" + }, + "devDependencies": { + "dotenv-cli": "^11.0.0", + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "eslint": "^9.39.4", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "react": "^19", + "react-dom": "^19", + "tailwindcss": "^4", + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "rxjs": "^7", + "next": "^16.0.5", + "next-intl": "^4.1.0" + } +} diff --git a/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.client.ts b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.client.ts new file mode 100644 index 000000000..40c1c8541 --- /dev/null +++ b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.client.ts @@ -0,0 +1,4 @@ +export const URL = '/blocks/checkout-company-data'; + +export * as Model from './checkout-company-data.model'; +export * as Request from './checkout-company-data.request'; diff --git a/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.controller.ts b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.controller.ts new file mode 100644 index 000000000..567b602f6 --- /dev/null +++ b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; + +import { Models } from '@o2s/utils.api-harmonization'; +import { LoggerService } from '@o2s/utils.logger'; + +import { Auth } from '@o2s/framework/modules'; + +import { URL } from './'; +import { GetCheckoutCompanyDataBlockQuery } from './checkout-company-data.request'; +import { CheckoutCompanyDataService } from './checkout-company-data.service'; + +@Controller(URL) +@UseInterceptors(LoggerService) +export class CheckoutCompanyDataController { + constructor(protected readonly service: CheckoutCompanyDataService) {} + + @Get() + @Auth.Decorators.Roles({ roles: [] }) + // Optional: Add permission-based access control + // @Auth.Decorators.Permissions({ resource: 'resource-name', actions: ['view'] }) + getCheckoutCompanyDataBlock( + @Headers() headers: Models.Headers.AppHeaders, + @Query() query: GetCheckoutCompanyDataBlockQuery, + ) { + return this.service.getCheckoutCompanyDataBlock(query, headers); + } +} diff --git a/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.mapper.ts b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.mapper.ts new file mode 100644 index 000000000..2f76aebb3 --- /dev/null +++ b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.mapper.ts @@ -0,0 +1,21 @@ +import { CMS } from '@o2s/configs.integrations'; + +import type { CheckoutCompanyDataBlock } from './checkout-company-data.model'; + +export const mapCheckoutCompanyData = ( + cms: CMS.Model.CheckoutCompanyDataBlock.CheckoutCompanyDataBlock, +): CheckoutCompanyDataBlock => { + return { + __typename: 'CheckoutCompanyDataBlock', + id: cms.id, + title: cms.title, + subtitle: cms.subtitle, + fields: cms.fields, + buttons: cms.buttons, + errors: cms.errors, + summaryLabels: cms.summaryLabels, + stepIndicator: cms.stepIndicator, + billingInfoNote: cms.billingInfoNote, + cartPath: cms.cartPath, + }; +}; diff --git a/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.model.ts b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.model.ts new file mode 100644 index 000000000..eca7659ac --- /dev/null +++ b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.model.ts @@ -0,0 +1,16 @@ +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; + +import { CMS } from '@o2s/framework/modules'; + +export class CheckoutCompanyDataBlock extends ApiModels.Block.Block { + __typename!: 'CheckoutCompanyDataBlock'; + title!: string; + subtitle?: string; + fields!: CMS.Model.CheckoutCompanyDataBlock.CheckoutCompanyDataBlock['fields']; + buttons!: CMS.Model.CheckoutCompanyDataBlock.CheckoutCompanyDataBlock['buttons']; + errors!: CMS.Model.CheckoutCompanyDataBlock.CheckoutCompanyDataBlock['errors']; + summaryLabels!: CMS.Model.CheckoutCompanyDataBlock.CheckoutCompanyDataBlock['summaryLabels']; + stepIndicator!: CMS.Model.CheckoutCompanyDataBlock.CheckoutCompanyDataBlock['stepIndicator']; + billingInfoNote?: CMS.Model.CheckoutCompanyDataBlock.CheckoutCompanyDataBlock['billingInfoNote']; + cartPath!: CMS.Model.CheckoutCompanyDataBlock.CheckoutCompanyDataBlock['cartPath']; +} diff --git a/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.module.ts b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.module.ts new file mode 100644 index 000000000..0f5f76e47 --- /dev/null +++ b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.module.ts @@ -0,0 +1,25 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { CMS } from '@o2s/configs.integrations'; + +import * as Framework from '@o2s/framework/modules'; + +import { CheckoutCompanyDataController } from './checkout-company-data.controller'; +import { CheckoutCompanyDataService } from './checkout-company-data.service'; + +@Module({}) +export class CheckoutCompanyDataBlockModule { + static register(_config: Framework.ApiConfig): DynamicModule { + return { + module: CheckoutCompanyDataBlockModule, + providers: [ + CheckoutCompanyDataService, + { + provide: CMS.Service, + useExisting: Framework.CMS.Service, + }, + ], + controllers: [CheckoutCompanyDataController], + exports: [CheckoutCompanyDataService], + }; + } +} diff --git a/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.request.ts b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.request.ts new file mode 100644 index 000000000..72076cdf1 --- /dev/null +++ b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.request.ts @@ -0,0 +1,5 @@ +import { CMS } from '@o2s/framework/modules'; + +export class GetCheckoutCompanyDataBlockQuery implements Omit<CMS.Request.GetCmsEntryParams, 'locale'> { + id!: string; +} diff --git a/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.service.spec.ts b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.service.spec.ts new file mode 100644 index 000000000..700f495f6 --- /dev/null +++ b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.service.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CMS } from '@o2s/configs.integrations'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CheckoutCompanyDataService } from './checkout-company-data.service'; + +describe('CheckoutCompanyDataService', () => { + let service: CheckoutCompanyDataService; + let cmsService: CMS.Service; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CheckoutCompanyDataService, + { + provide: CMS.Service, + useValue: { + getCheckoutCompanyDataBlock: vi.fn().mockReturnValue({ + title: 'Test Block', + }), + }, + }, + ], + }).compile(); + + service = module.get<CheckoutCompanyDataService>(CheckoutCompanyDataService); + cmsService = module.get<CMS.Service>(CMS.Service); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should have cmsService injected', () => { + expect(cmsService).toBeDefined(); + }); +}); diff --git a/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.service.ts b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.service.ts new file mode 100644 index 000000000..81d5c790f --- /dev/null +++ b/packages/blocks/checkout-company-data/src/api-harmonization/checkout-company-data.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { CMS } from '@o2s/configs.integrations'; +import { Observable, forkJoin, map } from 'rxjs'; + +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; + +import { mapCheckoutCompanyData } from './checkout-company-data.mapper'; +import { CheckoutCompanyDataBlock } from './checkout-company-data.model'; +import { GetCheckoutCompanyDataBlockQuery } from './checkout-company-data.request'; + +const H = HeaderName; + +@Injectable() +export class CheckoutCompanyDataService { + constructor( + private readonly cmsService: CMS.Service, + // Optional: Inject Auth.Service when you need to add permission flags to the response + // private readonly authService: Auth.Service, + ) {} + + getCheckoutCompanyDataBlock( + query: GetCheckoutCompanyDataBlockQuery, + headers: AppHeaders, + ): Observable<CheckoutCompanyDataBlock> { + const cms = this.cmsService.getCheckoutCompanyDataBlock({ ...query, locale: headers[H.Locale] }); + + return forkJoin([cms]).pipe( + map(([cms]) => { + const result = mapCheckoutCompanyData(cms); + + // Optional: Add permission flags to the response + // if (headers.authorization) { + // const permissions = this.authService.canPerformActions(headers.authorization, 'resource-name', [ + // 'view', + // 'edit', + // ]); + // result.permissions = { + // view: permissions.view ?? false, + // edit: permissions.edit ?? false, + // }; + // } + + return result; + }), + ); + } +} diff --git a/packages/blocks/checkout-company-data/src/api-harmonization/index.ts b/packages/blocks/checkout-company-data/src/api-harmonization/index.ts new file mode 100644 index 000000000..74f38ff76 --- /dev/null +++ b/packages/blocks/checkout-company-data/src/api-harmonization/index.ts @@ -0,0 +1,8 @@ +export const URL = '/blocks/checkout-company-data'; + +export { CheckoutCompanyDataBlockModule as Module } from './checkout-company-data.module'; +export { CheckoutCompanyDataService as Service } from './checkout-company-data.service'; +export { CheckoutCompanyDataController as Controller } from './checkout-company-data.controller'; + +export * as Model from './checkout-company-data.model'; +export * as Request from './checkout-company-data.request'; diff --git a/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.client.stories.tsx b/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.client.stories.tsx new file mode 100644 index 000000000..75d4026ad --- /dev/null +++ b/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.client.stories.tsx @@ -0,0 +1,139 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { defineRouting } from 'next-intl/routing'; + +import readme from '../../README.md?raw'; + +import { CheckoutCompanyDataPure } from './CheckoutCompanyData.client'; + +const routing = defineRouting({ + locales: ['en'], + defaultLocale: 'en', + pathnames: {}, +}); + +const baseBlock = { + __typename: 'CheckoutCompanyDataBlock' as const, + id: 'checkout-company-data-1', + stepIndicator: { + steps: ['Company details', 'Delivery', 'Payment', 'Summary'], + currentStep: 1, + }, + title: 'Company details', + subtitle: 'Fill in your company details', + fields: { + firstName: { + label: 'First name', + placeholder: 'e.g. John', + required: false, + }, + lastName: { + label: 'Last name', + placeholder: 'e.g. Doe', + required: false, + }, + email: { + label: 'Email', + placeholder: 'e.g. john@example.com', + required: true, + }, + phone: { + label: 'Phone', + placeholder: 'e.g. +48 123 456 789', + required: false, + }, + companyName: { + label: 'Company name', + placeholder: 'e.g. ACME Inc.', + required: true, + }, + taxId: { + label: 'Tax ID', + placeholder: 'XXXXXXXXXX', + required: true, + }, + address: { + streetName: { + label: 'Street name', + placeholder: 'e.g. Main Street', + required: true, + }, + streetNumber: { + label: 'Number', + placeholder: 'e.g. 123', + required: true, + }, + apartment: { + label: 'Apartment / suite', + placeholder: 'e.g. 4B', + required: false, + }, + city: { + label: 'City', + placeholder: 'City', + required: true, + }, + postalCode: { + label: 'Postal code', + placeholder: 'XX-XXX', + required: true, + }, + country: { + label: 'Country', + placeholder: 'Country', + required: true, + }, + }, + }, + buttons: { + back: { + label: 'Back to cart', + path: '#', + }, + next: { + label: 'Next', + path: '#', + }, + }, + errors: { + required: 'This field is required', + invalidTaxId: 'Invalid tax ID', + invalidPostalCode: 'Invalid postal code', + invalidEmail: 'Invalid email address', + cartNotFound: 'Cart not found', + submitError: 'Something went wrong. Please try again.', + }, + summaryLabels: { + title: 'Summary', + subtotalLabel: 'Subtotal', + taxLabel: 'VAT (23%)', + totalLabel: 'Total', + discountLabel: 'Discount', + shippingLabel: 'Shipping', + freeLabel: 'Free', + }, + billingInfoNote: { + icon: 'Info', + text: 'This address will appear on your invoice.', + }, + cartPath: '/cart', +}; + +const meta = { + title: 'Blocks/CheckoutCompanyData', + component: CheckoutCompanyDataPure, + tags: ['autodocs'], + parameters: { readme }, +} satisfies Meta<typeof CheckoutCompanyDataPure>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default: Story = { + args: { + ...baseBlock, + id: 'checkout-company-data-1', + locale: 'en', + routing, + }, +}; diff --git a/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.client.tsx b/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.client.tsx new file mode 100644 index 000000000..21c24335e --- /dev/null +++ b/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.client.tsx @@ -0,0 +1,292 @@ +'use client'; + +import { Field, FieldProps, Form, Formik } from 'formik'; +import { createNavigation } from 'next-intl/navigation'; +import React, { useEffect, useState, useTransition } from 'react'; +import { object as YupObject, string as YupString } from 'yup'; + +import { Carts, Models } from '@o2s/framework/modules'; + +import { useToast } from '@o2s/ui/hooks/use-toast'; + +import { CartSummary, CartSummarySkeleton } from '@o2s/ui/components/Cart/CartSummary'; +import { AddressFields } from '@o2s/ui/components/Checkout/AddressFields'; +import { FormField } from '@o2s/ui/components/Checkout/FormField'; +import { StepIndicator } from '@o2s/ui/components/Checkout/StepIndicator'; +import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; + +import { Label } from '@o2s/ui/elements/label'; +import { Separator } from '@o2s/ui/elements/separator'; +import { Textarea } from '@o2s/ui/elements/textarea'; +import { Typography } from '@o2s/ui/elements/typography'; + +import { sdk } from '../sdk'; + +import { CheckoutCompanyDataPureProps } from './CheckoutCompanyData.types'; + +const CART_ID_KEY = 'cartId'; +const FORM_ID = 'checkout-company-form'; + +export const CheckoutCompanyDataPure: React.FC<Readonly<CheckoutCompanyDataPureProps>> = ({ + locale, + accessToken, + routing, + title, + subtitle, + stepIndicator, + fields, + buttons, + errors, + summaryLabels, + billingInfoNote, + cartPath, +}) => { + const { Link: LinkComponent, useRouter } = createNavigation(routing); + const router = useRouter(); + const { toast } = useToast(); + + const [totals, setTotals] = useState< + | { + subtotal: Models.Price.Price; + tax: Models.Price.Price; + total: Models.Price.Price; + discountTotal?: Models.Price.Price; + shippingTotal?: Models.Price.Price; + } + | undefined + >(); + const [cartPromotions, setCartPromotions] = useState<Carts.Model.Promotion[] | undefined>(); + const [isInitialLoadPending, startInitialLoadTransition] = useTransition(); + const [isSubmitPending, startSubmitTransition] = useTransition(); + const [initialFormValues, setInitialFormValues] = useState({ + firstName: '', + lastName: '', + email: '', + phone: '', + companyName: '', + taxId: '', + streetName: '', + streetNumber: '', + apartment: '', + city: '', + postalCode: '', + country: '', + notes: '', + }); + + useEffect(() => { + const cartId = localStorage.getItem(CART_ID_KEY); + if (!cartId) { + toast({ description: errors.cartNotFound, variant: 'destructive' }); + router.replace(cartPath); + return; + } + + startInitialLoadTransition(async () => { + try { + const cart = await sdk.carts.getCart(cartId, { 'x-locale': locale }, accessToken); + if (cart.subtotal && cart.taxTotal && cart.total) { + setTotals({ + subtotal: cart.subtotal, + tax: cart.taxTotal, + total: cart.total, + discountTotal: cart.discountTotal, + shippingTotal: cart.shippingMethod?.total, + }); + } + setCartPromotions(cart.promotions); + setInitialFormValues((prev) => ({ + ...prev, + notes: cart.notes ?? '', + email: cart.email ?? prev.email, + ...(cart.billingAddress + ? { + firstName: cart.billingAddress.firstName ?? '', + lastName: cart.billingAddress.lastName ?? '', + email: cart.billingAddress.email ?? cart.email ?? '', + phone: cart.billingAddress.phone ?? '', + companyName: cart.billingAddress.companyName ?? '', + taxId: cart.billingAddress.taxId ?? '', + streetName: cart.billingAddress.streetName ?? '', + streetNumber: cart.billingAddress.streetNumber ?? '', + apartment: cart.billingAddress.apartment ?? '', + city: cart.billingAddress.city ?? '', + postalCode: cart.billingAddress.postalCode ?? '', + country: cart.billingAddress.country ?? '', + } + : {}), + })); + } catch { + toast({ description: errors.cartNotFound, variant: 'destructive' }); + router.replace(cartPath); + } + }); + }, [locale, accessToken, toast, errors.cartNotFound, router, cartPath]); + + const handleSubmit = (values: typeof initialFormValues) => { + const cartId = localStorage.getItem(CART_ID_KEY); + if (!cartId) { + toast({ description: errors.cartNotFound, variant: 'destructive' }); + return; + } + + startSubmitTransition(async () => { + try { + await sdk.checkout.setAddresses( + cartId, + { + billingAddress: { + firstName: values.firstName || undefined, + lastName: values.lastName || undefined, + email: values.email || undefined, + phone: values.phone || undefined, + companyName: values.companyName, + taxId: values.taxId, + streetName: values.streetName, + streetNumber: values.streetNumber || undefined, + apartment: values.apartment || undefined, + city: values.city, + postalCode: values.postalCode, + country: values.country, + }, + notes: values.notes || undefined, + email: values.email || undefined, + }, + { 'x-locale': locale }, + accessToken, + ); + router.push(buttons.next.path); + } catch { + toast({ variant: 'destructive', description: errors.submitError }); + } + }); + }; + + const validationSchema = YupObject().shape({ + firstName: fields.firstName.required ? YupString().required(errors.required) : YupString(), + lastName: fields.lastName.required ? YupString().required(errors.required) : YupString(), + email: fields.email.required + ? YupString().required(errors.required).email(errors.invalidEmail) + : YupString().email(errors.invalidEmail).optional(), + phone: fields.phone.required ? YupString().required(errors.required) : YupString(), + companyName: fields.companyName.required ? YupString().required(errors.required) : YupString(), + taxId: fields.taxId.required ? YupString().required(errors.required) : YupString(), + streetName: fields.address.streetName.required ? YupString().required(errors.required) : YupString(), + streetNumber: fields.address.streetNumber.required ? YupString().required(errors.required) : YupString(), + apartment: YupString(), + city: fields.address.city.required ? YupString().required(errors.required) : YupString(), + postalCode: fields.address.postalCode.required ? YupString().required(errors.required) : YupString(), + country: fields.address.country.required ? YupString().required(errors.required) : YupString(), + }); + + return ( + <div className="w-full flex flex-col gap-8"> + <StepIndicator steps={stepIndicator.steps} currentStep={stepIndicator.currentStep} /> + <div className="flex flex-col gap-2"> + <Typography variant="h1">{title}</Typography> + {subtitle && ( + <Typography variant="large" className="text-muted-foreground"> + {subtitle} + </Typography> + )} + </div> + + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> + <div className="lg:col-span-2"> + <Formik + initialValues={initialFormValues} + enableReinitialize + validationSchema={validationSchema} + onSubmit={handleSubmit} + validateOnBlur={true} + validateOnMount={false} + validateOnChange={false} + > + {() => ( + <Form id={FORM_ID} className="w-full flex flex-col gap-6"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <FormField name="firstName" field={fields.firstName} /> + <FormField name="lastName" field={fields.lastName} /> + <FormField name="email" type="email" field={fields.email} /> + <FormField name="phone" type="tel" field={fields.phone} /> + <div className="md:col-span-2"> + <FormField name="companyName" field={fields.companyName} /> + </div> + <div className="md:col-span-2"> + <FormField name="taxId" field={fields.taxId} /> + </div> + <div className="md:col-span-2 w-full"> + <AddressFields fields={fields.address} locale={locale} /> + </div> + </div> + + {billingInfoNote && ( + <div className="flex items-start gap-3 p-4 bg-muted rounded-lg border border-border"> + {billingInfoNote.icon && ( + <DynamicIcon + name={billingInfoNote.icon} + size={16} + className="mt-0.5 text-muted-foreground shrink-0" + /> + )} + <Typography variant="small" className="text-muted-foreground"> + {billingInfoNote.text} + </Typography> + </div> + )} + + {fields.notes && ( + <> + <Separator /> + <div className="flex flex-col gap-2"> + <Label htmlFor="notes"> + {fields.notes.label} + {fields.notes.required && <span className="text-destructive"> *</span>} + </Label> + <Field name="notes"> + {({ field }: FieldProps<string>) => ( + <Textarea + id="notes" + {...field} + placeholder={fields.notes!.placeholder} + rows={4} + /> + )} + </Field> + </div> + </> + )} + </Form> + )} + </Formik> + </div> + + <div className="lg:col-span-1"> + {isInitialLoadPending ? ( + <CartSummarySkeleton /> + ) : totals ? ( + <CartSummary + subtotal={totals.subtotal} + tax={totals.tax} + total={totals.total} + discountTotal={totals.discountTotal} + shippingTotal={totals.shippingTotal} + promotions={cartPromotions} + labels={summaryLabels} + LinkComponent={LinkComponent} + primaryButton={{ + label: buttons.next.label, + disabled: isSubmitPending, + action: { type: 'submit', form: FORM_ID }, + }} + secondaryButton={{ + label: buttons.back.label, + action: { type: 'link', url: buttons.back.path }, + }} + /> + ) : null} + </div> + </div> + </div> + ); +}; diff --git a/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.renderer.tsx b/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.renderer.tsx new file mode 100644 index 000000000..e51193efc --- /dev/null +++ b/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.renderer.tsx @@ -0,0 +1,36 @@ +import { useLocale } from 'next-intl'; +import React, { Suspense } from 'react'; + +import { Loading } from '@o2s/ui/components/Loading'; + +import { CheckoutCompanyData } from './CheckoutCompanyData.server'; +import { CheckoutCompanyDataRendererProps } from './CheckoutCompanyData.types'; + +export const CheckoutCompanyDataRenderer: React.FC<CheckoutCompanyDataRendererProps> = ({ + id, + accessToken, + routing, +}) => { + const locale = useLocale(); + + return ( + <Suspense + key={id} + fallback={ + <div className="w-full flex flex-col gap-8"> + <Loading bars={1} /> + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> + <div className="lg:col-span-2"> + <Loading bars={4} /> + </div> + <div className="lg:col-span-1"> + <Loading bars={4} /> + </div> + </div> + </div> + } + > + <CheckoutCompanyData id={id} accessToken={accessToken} locale={locale} routing={routing} /> + </Suspense> + ); +}; diff --git a/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.server.tsx b/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.server.tsx new file mode 100644 index 000000000..510c77305 --- /dev/null +++ b/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.server.tsx @@ -0,0 +1,29 @@ +import dynamic from 'next/dynamic'; +import React from 'react'; + +import type { Model } from '../api-harmonization/checkout-company-data.client'; +import { sdk } from '../sdk'; + +import { CheckoutCompanyDataProps } from './CheckoutCompanyData.types'; + +export const CheckoutCompanyDataDynamic = dynamic(() => + import('./CheckoutCompanyData.client').then((module) => module.CheckoutCompanyDataPure), +); + +export const CheckoutCompanyData: React.FC<CheckoutCompanyDataProps> = async ({ id, accessToken, locale, routing }) => { + let data: Model.CheckoutCompanyDataBlock; + try { + data = await sdk.blocks.getCheckoutCompanyData( + { + id, + }, + { 'x-locale': locale }, + accessToken, + ); + } catch (error) { + console.error('Error fetching CheckoutCompanyData block', error); + return null; + } + + return <CheckoutCompanyDataDynamic {...data} id={id} accessToken={accessToken} locale={locale} routing={routing} />; +}; diff --git a/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.types.ts b/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.types.ts new file mode 100644 index 000000000..77fcbc168 --- /dev/null +++ b/packages/blocks/checkout-company-data/src/frontend/CheckoutCompanyData.types.ts @@ -0,0 +1,16 @@ +import { defineRouting } from 'next-intl/routing'; + +import type { Model } from '../api-harmonization/checkout-company-data.client'; + +export interface CheckoutCompanyDataProps { + id: string; + accessToken?: string; + locale: string; + routing: ReturnType<typeof defineRouting>; +} + +export type CheckoutCompanyDataPureProps = CheckoutCompanyDataProps & Model.CheckoutCompanyDataBlock; + +export type CheckoutCompanyDataRendererProps = Omit<CheckoutCompanyDataProps, ''> & { + slug: string[]; +}; diff --git a/packages/blocks/checkout-company-data/src/frontend/index.ts b/packages/blocks/checkout-company-data/src/frontend/index.ts new file mode 100644 index 000000000..61b2fbe0b --- /dev/null +++ b/packages/blocks/checkout-company-data/src/frontend/index.ts @@ -0,0 +1,5 @@ +export { CheckoutCompanyDataPure as Client } from './CheckoutCompanyData.client'; +export { CheckoutCompanyData as Server } from './CheckoutCompanyData.server'; +export { CheckoutCompanyDataRenderer as Renderer } from './CheckoutCompanyData.renderer'; + +export * as Types from './CheckoutCompanyData.types'; diff --git a/packages/blocks/checkout-company-data/src/sdk/checkout-company-data.ts b/packages/blocks/checkout-company-data/src/sdk/checkout-company-data.ts new file mode 100644 index 000000000..73a784c61 --- /dev/null +++ b/packages/blocks/checkout-company-data/src/sdk/checkout-company-data.ts @@ -0,0 +1,77 @@ +import { Models } from '@o2s/utils.api-harmonization'; +import { Utils } from '@o2s/utils.frontend'; + +import { Carts, Checkout } from '@o2s/framework/modules'; +import { Sdk } from '@o2s/framework/sdk'; + +import { Model, Request, URL } from '../api-harmonization/checkout-company-data.client'; + +const API_URL = URL; +const CARTS_API_URL = '/carts'; +const CHECKOUT_API_URL = '/checkout'; + +export const checkoutCompanyData = (sdk: Sdk) => ({ + blocks: { + getCheckoutCompanyData: ( + query: Request.GetCheckoutCompanyDataBlockQuery, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Model.CheckoutCompanyDataBlock> => + sdk.makeRequest({ + method: 'get', + url: `${API_URL}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + params: query, + }), + }, + carts: { + getCart: ( + cartId: string, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Carts.Model.Cart> => + sdk.makeRequest({ + method: 'get', + url: `${CARTS_API_URL}/${cartId}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + }), + }, + checkout: { + setAddresses: ( + cartId: string, + body: Checkout.Request.SetAddressesBody, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Carts.Model.Cart> => + sdk.makeRequest({ + method: 'post', + url: `${CHECKOUT_API_URL}/${cartId}/addresses`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + data: body, + }), + }, +}); diff --git a/packages/blocks/checkout-company-data/src/sdk/index.ts b/packages/blocks/checkout-company-data/src/sdk/index.ts new file mode 100644 index 000000000..f67ebbbb7 --- /dev/null +++ b/packages/blocks/checkout-company-data/src/sdk/index.ts @@ -0,0 +1,36 @@ +// these unused imports are necessary for TypeScript to properly resolve API methods +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Models } from '@o2s/utils.api-harmonization'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Carts, Checkout } from '@o2s/framework/modules'; +import { extendSdk, getSdk } from '@o2s/framework/sdk'; + +import { checkoutCompanyData } from './checkout-company-data'; + +const API_URL = + (typeof window === 'undefined' ? process.env.NEXT_PUBLIC_API_URL_INTERNAL : process.env.NEXT_PUBLIC_API_URL) || + process.env.NEXT_PUBLIC_API_URL; + +const internalSdk = getSdk({ + apiUrl: API_URL!, + logger: { + // @ts-expect-error missing types + level: process.env.NEXT_PUBLIC_LOG_LEVEL, + // @ts-expect-error missing types + format: process.env.NEXT_PUBLIC_LOG_FORMAT, + colorsEnabled: process.env.NEXT_PUBLIC_LOG_COLORS_ENABLED === 'true', + }, +}); + +export const sdk = extendSdk(internalSdk, { + blocks: { + getCheckoutCompanyData: checkoutCompanyData(internalSdk).blocks.getCheckoutCompanyData, + }, + carts: { + getCart: checkoutCompanyData(internalSdk).carts.getCart, + }, + checkout: { + setAddresses: checkoutCompanyData(internalSdk).checkout.setAddresses, + }, +}); diff --git a/packages/blocks/checkout-company-data/tsconfig.api.json b/packages/blocks/checkout-company-data/tsconfig.api.json new file mode 100644 index 000000000..0f9f79f8e --- /dev/null +++ b/packages/blocks/checkout-company-data/tsconfig.api.json @@ -0,0 +1,14 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "outDir": "./dist/api-harmonization", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src/api-harmonization", + }, + "include": ["src/api-harmonization"] +} diff --git a/packages/blocks/checkout-company-data/tsconfig.frontend.json b/packages/blocks/checkout-company-data/tsconfig.frontend.json new file mode 100644 index 000000000..9ce7218c4 --- /dev/null +++ b/packages/blocks/checkout-company-data/tsconfig.frontend.json @@ -0,0 +1,22 @@ +{ + "extends": "@o2s/typescript-config/frontend.json", + "compilerOptions": { + "outDir": "./dist/frontend", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "noEmit": false, + "jsx": "react", + "baseUrl": "./src/frontend" + }, + "include": [ + "src/frontend", + "src/api-harmonization/checkout-company-data.client.ts", + "src/api-harmonization/checkout-company-data.model.ts", + "src/api-harmonization/checkout-company-data.request.ts", + "src/sdk" + ] +} diff --git a/packages/blocks/checkout-company-data/tsconfig.json b/packages/blocks/checkout-company-data/tsconfig.json new file mode 100644 index 000000000..c3031c1dd --- /dev/null +++ b/packages/blocks/checkout-company-data/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@o2s/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src", + }, + "references": [ + { "path": "./tsconfig.frontend.json" }, + { "path": "./tsconfig.api.json" }, + { "path": "./tsconfig.sdk.json" } + ] +} diff --git a/packages/blocks/checkout-company-data/tsconfig.sdk.json b/packages/blocks/checkout-company-data/tsconfig.sdk.json new file mode 100644 index 000000000..34756fb08 --- /dev/null +++ b/packages/blocks/checkout-company-data/tsconfig.sdk.json @@ -0,0 +1,19 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "outDir": "./dist/sdk", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src/sdk" + }, + "include": [ + "src/sdk", + "src/api-harmonization/checkout-company-data.client.ts", + "src/api-harmonization/checkout-company-data.model.ts", + "src/api-harmonization/checkout-company-data.request.ts" + ] +} diff --git a/packages/blocks/checkout-company-data/vitest.config.mjs b/packages/blocks/checkout-company-data/vitest.config.mjs new file mode 100644 index 000000000..82be23c07 --- /dev/null +++ b/packages/blocks/checkout-company-data/vitest.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/vitest-config/block'; + +export default config; diff --git a/packages/blocks/checkout-shipping-address/.gitignore b/packages/blocks/checkout-shipping-address/.gitignore new file mode 100644 index 000000000..29986a380 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/.gitignore @@ -0,0 +1,57 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +/tsconfig.tsbuildinfo diff --git a/packages/blocks/checkout-shipping-address/.prettierrc.mjs b/packages/blocks/checkout-shipping-address/.prettierrc.mjs new file mode 100644 index 000000000..93b66d398 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/.prettierrc.mjs @@ -0,0 +1,25 @@ +import apiConfig from "@o2s/prettier-config/api.mjs"; +import frontendConfig from "@o2s/prettier-config/frontend.mjs"; + +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +const config = { + overrides: [ + { + files: "./src/api-harmonization/**/*", + options: apiConfig, + }, + { + files: "./src/frontend/**/*", + options: frontendConfig, + }, + { + files: "./src/sdk/**/*", + options: frontendConfig, + }, + ], +}; + +export default config; diff --git a/packages/blocks/checkout-shipping-address/CHANGELOG.md b/packages/blocks/checkout-shipping-address/CHANGELOG.md new file mode 100644 index 000000000..a5e737446 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/CHANGELOG.md @@ -0,0 +1,32 @@ +# @o2s/blocks.checkout-shipping-address + +## 0.1.1 + +### Patch Changes + +- c1a5460: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + +## 0.1.0 + +### Minor Changes + +- 5d36519: Added new blocks: Cart, Checkout (Summary, Shipping Address, Company Data, Billing Payment) and Order Confirmation. Includes checkout forms validation (Formik + Yup), error handling, promo code support in cart, and new UI components (StepIndicator, RadioTile, AddressFields, CartSummary, QuantityInput, FormField). + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] +- Updated dependencies [5d36519] + - @o2s/framework@1.19.0 + - @o2s/utils.frontend@0.5.0 + - @o2s/ui@1.12.0 diff --git a/packages/blocks/checkout-shipping-address/README.md b/packages/blocks/checkout-shipping-address/README.md new file mode 100644 index 000000000..d7b137ed9 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/README.md @@ -0,0 +1,88 @@ +# @o2s/blocks.checkout-shipping-address + +Block for the shipping address step in the checkout flow. + +The checkout-shipping-address block collects shipping address and shipping method. Users can use the same as billing address or enter a different one. Data is fetched client-side using `cartId` from localStorage. Part of the multi-step checkout flow. + +- **Address form** – Shipping address fields +- **Same as billing** – Option to copy billing address +- **Shipping method** – Select delivery option +- **Cart summary** – Subtotal, tax, total alongside the form + +Content editors place the block via CMS. Developers connect Carts, Checkout, and CMS integrations. + +## Installation + +```bash +npm install @o2s/blocks.checkout-shipping-address +``` + +## Usage + +### Backend (API Harmonization) + +Register the block in `app.module.ts`: + +```typescript +import * as CheckoutShippingAddress from "@o2s/blocks.checkout-shipping-address/api-harmonization"; +import { AppConfig } from "./app.config"; + +@Module({ + imports: [CheckoutShippingAddress.Module.register(AppConfig)], +}) +export class AppModule {} +``` + +### Frontend + +Register the block in `renderBlocks.tsx`: + +```typescript +import * as CheckoutShippingAddress from '@o2s/blocks.checkout-shipping-address/frontend'; + +export const renderBlocks = async (blocks: CMS.Model.Page.SlotBlock[]) => { + return blocks.map((block) => { + if (block.type === 'checkout-shipping-address') { + return ( + <CheckoutShippingAddress.Renderer + key={block.id} + id={block.id} + slug={slug} + locale={locale} + accessToken={session?.accessToken} + userId={session?.user?.id} + routing={routing} + /> + ); + } + // ... other blocks + }); +}; +``` + +## Configuration + +This block requires Carts, Checkout, and CMS integrations in `AppConfig`. + +## Related Blocks + +- `@o2s/blocks.cart` - Shopping cart +- `@o2s/blocks.checkout-company-data` - Company details step +- `@o2s/blocks.checkout-billing-payment` - Payment step +- `@o2s/blocks.checkout-summary` - Order summary step +- `@o2s/blocks.order-confirmation` - Order confirmation page + +## About Blocks in O2S + +Blocks are self-contained, reusable UI components that combine harmonizing and frontend components into a single package. Each block is independently packaged as an NPM module and includes three primary parts: API Harmonization Module, Frontend Components, and SDK Methods. Blocks allow you to quickly add or remove functionality without impacting other components of the application. + +- **See all blocks**: [Blocks Documentation](https://www.openselfservice.com/docs/main-components/blocks/) +- **View this block in Storybook**: [checkout-shipping-address](https://storybook-o2s.openselfservice.com/?path=/story/blocks-checkoutshippingaddress--default) + +## About O2S + +**Part of [Open Self Service (O2S)](https://www.openselfservice.com/)** - an open-source framework for building composable customer self-service portals. O2S simplifies integration of multiple headless APIs into a scalable frontend, providing an API-agnostic architecture with a normalization layer. + +- **Website**: [https://www.openselfservice.com/](https://www.openselfservice.com/) +- **GitHub**: [https://github.com/o2sdev/openselfservice](https://github.com/o2sdev/openselfservice) +- **Documentation**: [https://www.openselfservice.com/docs](https://www.openselfservice.com/docs) diff --git a/packages/blocks/checkout-shipping-address/eslint.config.mjs b/packages/blocks/checkout-shipping-address/eslint.config.mjs new file mode 100644 index 000000000..223f2af08 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/eslint.config.mjs @@ -0,0 +1,18 @@ +import { config as apiConfig } from '@o2s/eslint-config/api'; +import { config as frontendConfig } from '@o2s/eslint-config/frontend-block'; +import { defineConfig } from 'eslint/config'; + +export default defineConfig([ + { + files: ['src/api-harmonization/**/*'], + extends: [apiConfig], + }, + { + files: ['src/frontend/**/*'], + extends: [frontendConfig], + }, + { + files: ['src/sdk/**/*'], + extends: [frontendConfig], + }, +]); diff --git a/packages/blocks/checkout-shipping-address/lint-staged.config.mjs b/packages/blocks/checkout-shipping-address/lint-staged.config.mjs new file mode 100644 index 000000000..b47bd93b9 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/lint-staged.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/lint-staged-config/base'; + +export default config; diff --git a/packages/blocks/checkout-shipping-address/package.json b/packages/blocks/checkout-shipping-address/package.json new file mode 100644 index 000000000..52f7ba8af --- /dev/null +++ b/packages/blocks/checkout-shipping-address/package.json @@ -0,0 +1,60 @@ +{ + "name": "@o2s/blocks.checkout-shipping-address", + "version": "0.1.1", + "private": false, + "license": "MIT", + "description": "Checkout step for recipient details, shipping address, delivery method, and cart summary preview.", + "exports": { + "./api-harmonization": "./dist/api-harmonization/api-harmonization/index.js", + "./frontend": "./dist/frontend/frontend/index.js", + "./sdk": "./dist/sdk/sdk/index.js", + "./client": "./dist/api-harmonization/api-harmonization/checkout-shipping-address.client.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build tsconfig.json --preserveWatchOutput && tsc-alias", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit && eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"" + }, + "dependencies": { + "@o2s/framework": "*", + "@o2s/utils.logger": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/configs.integrations": "*", + "formik": "^2.4.9", + "yup": "^1.7.1" + }, + "devDependencies": { + "dotenv-cli": "^11.0.0", + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "eslint": "^9.39.4", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "react": "^19", + "react-dom": "^19", + "tailwindcss": "^4", + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "rxjs": "^7", + "next": "^16.0.5", + "next-intl": "^4.1.0" + } +} diff --git a/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.client.ts b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.client.ts new file mode 100644 index 000000000..56a3fa791 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.client.ts @@ -0,0 +1,4 @@ +export const URL = '/blocks/checkout-shipping-address'; + +export * as Model from './checkout-shipping-address.model'; +export * as Request from './checkout-shipping-address.request'; diff --git a/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.controller.ts b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.controller.ts new file mode 100644 index 000000000..c668da6d7 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; + +import { Models } from '@o2s/utils.api-harmonization'; +import { LoggerService } from '@o2s/utils.logger'; + +import { Auth } from '@o2s/framework/modules'; + +import { URL } from './'; +import { GetCheckoutShippingAddressBlockQuery } from './checkout-shipping-address.request'; +import { CheckoutShippingAddressService } from './checkout-shipping-address.service'; + +@Controller(URL) +@UseInterceptors(LoggerService) +export class CheckoutShippingAddressController { + constructor(protected readonly service: CheckoutShippingAddressService) {} + + @Get() + @Auth.Decorators.Roles({ roles: [] }) + // Optional: Add permission-based access control + // @Auth.Decorators.Permissions({ resource: 'resource-name', actions: ['view'] }) + getCheckoutShippingAddressBlock( + @Headers() headers: Models.Headers.AppHeaders, + @Query() query: GetCheckoutShippingAddressBlockQuery, + ) { + return this.service.getCheckoutShippingAddressBlock(query, headers); + } +} diff --git a/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.mapper.ts b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.mapper.ts new file mode 100644 index 000000000..b8fe2e792 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.mapper.ts @@ -0,0 +1,20 @@ +import { CMS } from '@o2s/configs.integrations'; + +import type { CheckoutShippingAddressBlock } from './checkout-shipping-address.model'; + +export const mapCheckoutShippingAddress = ( + cms: CMS.Model.CheckoutShippingAddressBlock.CheckoutShippingAddressBlock, +): CheckoutShippingAddressBlock => { + return { + __typename: 'CheckoutShippingAddressBlock', + id: cms.id, + title: cms.title, + subtitle: cms.subtitle, + fields: cms.fields, + buttons: cms.buttons, + errors: cms.errors, + summaryLabels: cms.summaryLabels, + stepIndicator: cms.stepIndicator, + cartPath: cms.cartPath, + }; +}; diff --git a/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.model.ts b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.model.ts new file mode 100644 index 000000000..0bed3937c --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.model.ts @@ -0,0 +1,15 @@ +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; + +import { CMS } from '@o2s/framework/modules'; + +export class CheckoutShippingAddressBlock extends ApiModels.Block.Block { + __typename!: 'CheckoutShippingAddressBlock'; + title!: string; + subtitle?: string; + fields!: CMS.Model.CheckoutShippingAddressBlock.CheckoutShippingAddressBlock['fields']; + buttons!: CMS.Model.CheckoutShippingAddressBlock.CheckoutShippingAddressBlock['buttons']; + errors!: CMS.Model.CheckoutShippingAddressBlock.CheckoutShippingAddressBlock['errors']; + summaryLabels!: CMS.Model.CheckoutShippingAddressBlock.CheckoutShippingAddressBlock['summaryLabels']; + stepIndicator!: CMS.Model.CheckoutShippingAddressBlock.CheckoutShippingAddressBlock['stepIndicator']; + cartPath!: CMS.Model.CheckoutShippingAddressBlock.CheckoutShippingAddressBlock['cartPath']; +} diff --git a/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.module.ts b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.module.ts new file mode 100644 index 000000000..bc233888e --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.module.ts @@ -0,0 +1,25 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { CMS } from '@o2s/configs.integrations'; + +import * as Framework from '@o2s/framework/modules'; + +import { CheckoutShippingAddressController } from './checkout-shipping-address.controller'; +import { CheckoutShippingAddressService } from './checkout-shipping-address.service'; + +@Module({}) +export class CheckoutShippingAddressBlockModule { + static register(_config: Framework.ApiConfig): DynamicModule { + return { + module: CheckoutShippingAddressBlockModule, + providers: [ + CheckoutShippingAddressService, + { + provide: CMS.Service, + useExisting: Framework.CMS.Service, + }, + ], + controllers: [CheckoutShippingAddressController], + exports: [CheckoutShippingAddressService], + }; + } +} diff --git a/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.request.ts b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.request.ts new file mode 100644 index 000000000..8bcc24b39 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.request.ts @@ -0,0 +1,5 @@ +import { CMS } from '@o2s/framework/modules'; + +export class GetCheckoutShippingAddressBlockQuery implements Omit<CMS.Request.GetCmsEntryParams, 'locale'> { + id!: string; +} diff --git a/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.service.spec.ts b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.service.spec.ts new file mode 100644 index 000000000..2add69b61 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.service.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CMS } from '@o2s/configs.integrations'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CheckoutShippingAddressService } from './checkout-shipping-address.service'; + +describe('CheckoutShippingAddressService', () => { + let service: CheckoutShippingAddressService; + let cmsService: CMS.Service; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CheckoutShippingAddressService, + { + provide: CMS.Service, + useValue: { + getCheckoutShippingAddressBlock: vi.fn().mockReturnValue({ + title: 'Test Block', + }), + }, + }, + ], + }).compile(); + + service = module.get<CheckoutShippingAddressService>(CheckoutShippingAddressService); + cmsService = module.get<CMS.Service>(CMS.Service); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should have cmsService injected', () => { + expect(cmsService).toBeDefined(); + }); +}); diff --git a/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.service.ts b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.service.ts new file mode 100644 index 000000000..ffa29b4e5 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/api-harmonization/checkout-shipping-address.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { CMS } from '@o2s/configs.integrations'; +import { Observable, forkJoin, map } from 'rxjs'; + +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; + +// import { Auth } from '@o2s/framework/modules'; + +import { mapCheckoutShippingAddress } from './checkout-shipping-address.mapper'; +import { CheckoutShippingAddressBlock } from './checkout-shipping-address.model'; +import { GetCheckoutShippingAddressBlockQuery } from './checkout-shipping-address.request'; + +const H = HeaderName; + +@Injectable() +export class CheckoutShippingAddressService { + constructor( + private readonly cmsService: CMS.Service, + // Optional: Inject Auth.Service when you need to add permission flags to the response + // private readonly authService: Auth.Service, + ) {} + + getCheckoutShippingAddressBlock( + query: GetCheckoutShippingAddressBlockQuery, + headers: AppHeaders, + ): Observable<CheckoutShippingAddressBlock> { + const cms = this.cmsService.getCheckoutShippingAddressBlock({ ...query, locale: headers[H.Locale] }); + + return forkJoin([cms]).pipe( + map(([cms]) => { + const result = mapCheckoutShippingAddress(cms); + + // Optional: Add permission flags to the response + // if (headers.authorization) { + // const permissions = this.authService.canPerformActions(headers.authorization, 'resource-name', [ + // 'view', + // 'edit', + // ]); + // result.permissions = { + // view: permissions.view ?? false, + // edit: permissions.edit ?? false, + // }; + // } + + return result; + }), + ); + } +} diff --git a/packages/blocks/checkout-shipping-address/src/api-harmonization/index.ts b/packages/blocks/checkout-shipping-address/src/api-harmonization/index.ts new file mode 100644 index 000000000..c58cc1f24 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/api-harmonization/index.ts @@ -0,0 +1,8 @@ +export const URL = '/blocks/checkout-shipping-address'; + +export { CheckoutShippingAddressBlockModule as Module } from './checkout-shipping-address.module'; +export { CheckoutShippingAddressService as Service } from './checkout-shipping-address.service'; +export { CheckoutShippingAddressController as Controller } from './checkout-shipping-address.controller'; + +export * as Model from './checkout-shipping-address.model'; +export * as Request from './checkout-shipping-address.request'; diff --git a/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.client.stories.tsx b/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.client.stories.tsx new file mode 100644 index 000000000..57aa7f835 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.client.stories.tsx @@ -0,0 +1,125 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { defineRouting } from 'next-intl/routing'; + +import readme from '../../README.md?raw'; + +import { CheckoutShippingAddressPure } from './CheckoutShippingAddress.client'; + +const routing = defineRouting({ + locales: ['en'], + defaultLocale: 'en', + pathnames: {}, +}); + +const baseBlock = { + __typename: 'CheckoutShippingAddressBlock' as const, + id: 'checkout-shipping-address-1', + stepIndicator: { + steps: ['Company details', 'Delivery', 'Payment', 'Summary'], + currentStep: 2, + }, + title: 'Shipping address', + subtitle: 'Select shipping method', + fields: { + sameAsBillingAddress: { + label: 'Same as billing address', + }, + firstName: { + label: 'First name', + placeholder: 'e.g. John', + required: false, + }, + lastName: { + label: 'Last name', + placeholder: 'e.g. Doe', + required: false, + }, + phone: { + label: 'Phone', + placeholder: 'e.g. +48 123 456 789', + required: false, + }, + address: { + streetName: { + label: 'Street name', + placeholder: 'e.g. Main Street', + required: true, + }, + streetNumber: { + label: 'Number', + placeholder: 'e.g. 123', + required: true, + }, + apartment: { + label: 'Apartment / suite', + placeholder: 'e.g. 4B', + required: false, + }, + city: { + label: 'City', + placeholder: 'City', + required: true, + }, + postalCode: { + label: 'Postal code', + placeholder: 'XX-XXX', + required: true, + }, + country: { + label: 'Country', + placeholder: 'Country', + required: true, + }, + }, + shippingMethod: { + label: 'Shipping method', + required: true, + }, + }, + buttons: { + back: { + label: 'Back', + path: '#', + }, + next: { + label: 'Next', + path: '#', + }, + }, + errors: { + required: 'This field is required', + invalidPostalCode: 'Invalid postal code', + cartNotFound: 'Cart not found', + submitError: 'Something went wrong. Please try again.', + }, + summaryLabels: { + title: 'Summary', + subtotalLabel: 'Subtotal', + taxLabel: 'VAT (23%)', + totalLabel: 'Total', + discountLabel: 'Discount', + shippingLabel: 'Shipping', + freeLabel: 'Free', + }, + cartPath: '/cart', +}; + +const meta = { + title: 'Blocks/CheckoutShippingAddress', + component: CheckoutShippingAddressPure, + tags: ['autodocs'], + parameters: { readme }, +} satisfies Meta<typeof CheckoutShippingAddressPure>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default: Story = { + args: { + ...baseBlock, + id: 'checkout-shipping-address-1', + locale: 'en', + routing, + }, +}; diff --git a/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.client.tsx b/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.client.tsx new file mode 100644 index 000000000..c8fc8102f --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.client.tsx @@ -0,0 +1,356 @@ +'use client'; + +import { ErrorMessage, Field, FieldProps, Form, Formik } from 'formik'; +import { createNavigation } from 'next-intl/navigation'; +import React, { useEffect, useState, useTransition } from 'react'; +import { boolean as YupBoolean, object as YupObject, string as YupString } from 'yup'; + +import { Carts, Models, Orders } from '@o2s/framework/modules'; + +import { useToast } from '@o2s/ui/hooks/use-toast'; + +import { CartSummary, CartSummarySkeleton } from '@o2s/ui/components/Cart/CartSummary'; +import { AddressFields } from '@o2s/ui/components/Checkout/AddressFields'; +import { FormField } from '@o2s/ui/components/Checkout/FormField'; +import { StepIndicator } from '@o2s/ui/components/Checkout/StepIndicator'; +import { Price } from '@o2s/ui/components/Price'; +import { RadioTileGroup } from '@o2s/ui/components/RadioTile'; + +import { Checkbox } from '@o2s/ui/elements/checkbox'; +import { Label } from '@o2s/ui/elements/label'; +import { Separator } from '@o2s/ui/elements/separator'; +import { Typography } from '@o2s/ui/elements/typography'; + +import { sdk } from '../sdk'; + +import { CheckoutShippingAddressPureProps } from './CheckoutShippingAddress.types'; + +const CART_ID_KEY = 'cartId'; +const FORM_ID = 'checkout-shipping-form'; + +export const CheckoutShippingAddressPure: React.FC<Readonly<CheckoutShippingAddressPureProps>> = ({ + locale, + accessToken, + routing, + title, + subtitle, + stepIndicator, + fields, + buttons, + errors, + summaryLabels, + cartPath, +}) => { + const { Link: LinkComponent, useRouter } = createNavigation(routing); + const router = useRouter(); + const { toast } = useToast(); + + const [totals, setTotals] = useState< + | { + subtotal: Models.Price.Price; + tax: Models.Price.Price; + total: Models.Price.Price; + discountTotal?: Models.Price.Price; + shippingTotal?: Models.Price.Price; + } + | undefined + >(); + const [shippingOptions, setShippingOptions] = useState<Orders.Model.ShippingMethod[]>([]); + const [cartPromotions, setCartPromotions] = useState<Carts.Model.Promotion[] | undefined>(); + const [isInitialLoadPending, startInitialLoadTransition] = useTransition(); + const [isSubmitPending, startSubmitTransition] = useTransition(); + const [initialFormValues, setInitialFormValues] = useState({ + firstName: '', + lastName: '', + phone: '', + streetName: '', + streetNumber: '', + apartment: '', + city: '', + postalCode: '', + country: '', + sameAsBillingAddress: false, + shippingMethod: '', + }); + + useEffect(() => { + const cartId = localStorage.getItem(CART_ID_KEY); + if (!cartId) { + toast({ description: errors.cartNotFound, variant: 'destructive' }); + router.replace(cartPath); + return; + } + + startInitialLoadTransition(async () => { + try { + const [cart, options] = await Promise.all([ + sdk.carts.getCart(cartId, { 'x-locale': locale }, accessToken), + sdk.checkout.getShippingOptions(cartId, { 'x-locale': locale }, accessToken), + ]); + if (cart.subtotal && cart.taxTotal && cart.total) { + setTotals({ + subtotal: cart.subtotal, + tax: cart.taxTotal, + total: cart.total, + discountTotal: cart.discountTotal, + shippingTotal: cart.shippingMethod?.total, + }); + } + setShippingOptions(options.data ?? []); + setCartPromotions(cart.promotions); + const sameAsBilling = cart.metadata?.sameAsBillingAddress === true; + const sourceAddress = sameAsBilling ? cart.billingAddress : cart.shippingAddress; + setInitialFormValues((prev) => ({ + ...prev, + sameAsBillingAddress: sameAsBilling, + ...(sourceAddress + ? { + firstName: sourceAddress.firstName ?? '', + lastName: sourceAddress.lastName ?? '', + phone: sourceAddress.phone ?? '', + streetName: sourceAddress.streetName ?? '', + streetNumber: sourceAddress.streetNumber ?? '', + apartment: sourceAddress.apartment ?? '', + city: sourceAddress.city ?? '', + postalCode: sourceAddress.postalCode ?? '', + country: sourceAddress.country ?? '', + } + : {}), + ...(cart.shippingMethod ? { shippingMethod: cart.shippingMethod.id } : {}), + })); + } catch { + toast({ description: errors.cartNotFound, variant: 'destructive' }); + router.replace(cartPath); + } + }); + }, [locale, accessToken, toast, errors.cartNotFound, router, cartPath]); + + const handleSubmit = (values: typeof initialFormValues) => { + startSubmitTransition(async () => { + const cartId = localStorage.getItem(CART_ID_KEY); + if (!cartId) return; + try { + await sdk.checkout.setAddresses( + cartId, + { + sameAsBillingAddress: values.sameAsBillingAddress, + ...(!values.sameAsBillingAddress && { + shippingAddress: { + firstName: values.firstName || undefined, + lastName: values.lastName || undefined, + phone: values.phone || undefined, + streetName: values.streetName, + streetNumber: values.streetNumber || undefined, + apartment: values.apartment || undefined, + city: values.city, + postalCode: values.postalCode, + country: values.country, + }, + }), + }, + { 'x-locale': locale }, + accessToken, + ); + await sdk.checkout.setShippingMethod( + cartId, + { shippingOptionId: values.shippingMethod }, + { 'x-locale': locale }, + accessToken, + ); + setTotals( + (prev) => + prev && { + ...prev, + shippingTotal: shippingOptions.find((o) => o.id === values.shippingMethod)?.total, + }, + ); + router.push(buttons.next.path); + } catch { + toast({ variant: 'destructive', description: errors.submitError }); + } + }); + }; + + const validationSchema = YupObject().shape({ + firstName: YupString().when('sameAsBillingAddress', { + is: false, + then: (schema) => (fields.firstName.required ? schema.required(errors.required) : schema), + }), + lastName: YupString().when('sameAsBillingAddress', { + is: false, + then: (schema) => (fields.lastName.required ? schema.required(errors.required) : schema), + }), + phone: YupString().when('sameAsBillingAddress', { + is: false, + then: (schema) => (fields.phone.required ? schema.required(errors.required) : schema), + }), + streetName: YupString().when('sameAsBillingAddress', { + is: false, + then: (schema) => (fields.address.streetName.required ? schema.required(errors.required) : schema), + }), + streetNumber: YupString().when('sameAsBillingAddress', { + is: false, + then: (schema) => (fields.address.streetNumber.required ? schema.required(errors.required) : schema), + }), + apartment: YupString(), + city: YupString().when('sameAsBillingAddress', { + is: false, + then: (schema) => (fields.address.city.required ? schema.required(errors.required) : schema), + }), + postalCode: YupString().when('sameAsBillingAddress', { + is: false, + then: (schema) => (fields.address.postalCode.required ? schema.required(errors.required) : schema), + }), + country: YupString().when('sameAsBillingAddress', { + is: false, + then: (schema) => (fields.address.country.required ? schema.required(errors.required) : schema), + }), + sameAsBillingAddress: YupBoolean(), + shippingMethod: fields.shippingMethod.required ? YupString().required(errors.required) : YupString(), + }); + + return ( + <div className="w-full flex flex-col gap-8"> + <StepIndicator steps={stepIndicator.steps} currentStep={stepIndicator.currentStep} /> + <div className="flex flex-col gap-2"> + <Typography variant="h1">{title}</Typography> + {subtitle && ( + <Typography variant="large" className="text-muted-foreground"> + {subtitle} + </Typography> + )} + </div> + + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> + <div className="lg:col-span-2"> + <Formik + initialValues={initialFormValues} + enableReinitialize + validationSchema={validationSchema} + onSubmit={handleSubmit} + validateOnBlur={true} + validateOnMount={false} + validateOnChange={false} + > + {({ values, setFieldValue }) => ( + <Form id={FORM_ID} className="w-full flex flex-col gap-6"> + <Separator /> + + <div className="flex flex-col gap-4"> + <Field name="sameAsBillingAddress"> + {({ field }: FieldProps<boolean>) => ( + <div className="flex items-center gap-2"> + <Checkbox + id="sameAsBillingAddress" + checked={field.value} + onCheckedChange={(checked) => { + const isChecked = checked === true; + setFieldValue('sameAsBillingAddress', isChecked); + if (!isChecked) { + setFieldValue('firstName', ''); + setFieldValue('lastName', ''); + setFieldValue('phone', ''); + setFieldValue('streetName', ''); + setFieldValue('streetNumber', ''); + setFieldValue('apartment', ''); + setFieldValue('city', ''); + setFieldValue('postalCode', ''); + setFieldValue('country', ''); + } + }} + /> + <Label htmlFor="sameAsBillingAddress" className="cursor-pointer"> + {fields.sameAsBillingAddress.label} + </Label> + </div> + )} + </Field> + + {!values.sameAsBillingAddress && ( + <> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <FormField name="firstName" field={fields.firstName} /> + <FormField name="lastName" field={fields.lastName} /> + </div> + <FormField name="phone" type="tel" field={fields.phone} /> + <AddressFields fields={fields.address} locale={locale} /> + </> + )} + </div> + + <Separator /> + + <fieldset className="flex flex-col gap-2"> + <legend className="text-sm font-medium leading-none mb-2"> + {fields.shippingMethod.label} + {fields.shippingMethod.required && <span className="text-destructive"> *</span>} + </legend> + <Field name="shippingMethod"> + {({ + field, + form: { touched, errors, setFieldValue: setFV }, + }: FieldProps<string>) => ( + <> + <RadioTileGroup + value={field.value} + onValueChange={(value) => setFV('shippingMethod', value)} + hasError={!!(touched.shippingMethod && errors.shippingMethod)} + options={shippingOptions.map((option) => ({ + id: option.id, + label: option.name, + description: option.description, + extra: option.total ? ( + <Typography + variant="small" + className="text-muted-foreground" + > + <Price price={option.total} /> + </Typography> + ) : undefined, + }))} + /> + <ErrorMessage name="shippingMethod"> + {(msg) => ( + <Typography variant="small" className="text-destructive"> + {msg} + </Typography> + )} + </ErrorMessage> + </> + )} + </Field> + </fieldset> + </Form> + )} + </Formik> + </div> + + <div className="lg:col-span-1"> + {isInitialLoadPending ? ( + <CartSummarySkeleton /> + ) : totals ? ( + <CartSummary + subtotal={totals.subtotal} + tax={totals.tax} + total={totals.total} + discountTotal={totals.discountTotal} + shippingTotal={totals.shippingTotal} + promotions={cartPromotions} + labels={summaryLabels} + LinkComponent={LinkComponent} + primaryButton={{ + label: buttons.next.label, + disabled: isSubmitPending, + action: { type: 'submit', form: FORM_ID }, + }} + secondaryButton={{ + label: buttons.back.label, + action: { type: 'link', url: buttons.back.path }, + }} + /> + ) : null} + </div> + </div> + </div> + ); +}; diff --git a/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.renderer.tsx b/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.renderer.tsx new file mode 100644 index 000000000..46fc49bce --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.renderer.tsx @@ -0,0 +1,36 @@ +import { useLocale } from 'next-intl'; +import React, { Suspense } from 'react'; + +import { Loading } from '@o2s/ui/components/Loading'; + +import { CheckoutShippingAddress } from './CheckoutShippingAddress.server'; +import { CheckoutShippingAddressRendererProps } from './CheckoutShippingAddress.types'; + +export const CheckoutShippingAddressRenderer: React.FC<CheckoutShippingAddressRendererProps> = ({ + id, + accessToken, + routing, +}) => { + const locale = useLocale(); + + return ( + <Suspense + key={id} + fallback={ + <div className="w-full flex flex-col gap-8"> + <Loading bars={1} /> + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> + <div className="lg:col-span-2"> + <Loading bars={4} /> + </div> + <div className="lg:col-span-1"> + <Loading bars={4} /> + </div> + </div> + </div> + } + > + <CheckoutShippingAddress id={id} accessToken={accessToken} locale={locale} routing={routing} /> + </Suspense> + ); +}; diff --git a/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.server.tsx b/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.server.tsx new file mode 100644 index 000000000..2ba1e34fa --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.server.tsx @@ -0,0 +1,36 @@ +import dynamic from 'next/dynamic'; +import React from 'react'; + +import type { Model } from '../api-harmonization/checkout-shipping-address.client'; +import { sdk } from '../sdk'; + +import { CheckoutShippingAddressProps } from './CheckoutShippingAddress.types'; + +export const CheckoutShippingAddressDynamic = dynamic(() => + import('./CheckoutShippingAddress.client').then((module) => module.CheckoutShippingAddressPure), +); + +export const CheckoutShippingAddress: React.FC<CheckoutShippingAddressProps> = async ({ + id, + accessToken, + locale, + routing, +}) => { + let data: Model.CheckoutShippingAddressBlock; + try { + data = await sdk.blocks.getCheckoutShippingAddress( + { + id, + }, + { 'x-locale': locale }, + accessToken, + ); + } catch (error) { + console.error('Error fetching CheckoutShippingAddress block', error); + return null; + } + + return ( + <CheckoutShippingAddressDynamic {...data} id={id} accessToken={accessToken} locale={locale} routing={routing} /> + ); +}; diff --git a/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.types.ts b/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.types.ts new file mode 100644 index 000000000..cc3a07515 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/frontend/CheckoutShippingAddress.types.ts @@ -0,0 +1,16 @@ +import { defineRouting } from 'next-intl/routing'; + +import type { Model } from '../api-harmonization/checkout-shipping-address.client'; + +export interface CheckoutShippingAddressProps { + id: string; + accessToken?: string; + locale: string; + routing: ReturnType<typeof defineRouting>; +} + +export type CheckoutShippingAddressPureProps = CheckoutShippingAddressProps & Model.CheckoutShippingAddressBlock; + +export type CheckoutShippingAddressRendererProps = Omit<CheckoutShippingAddressProps, ''> & { + slug: string[]; +}; diff --git a/packages/blocks/checkout-shipping-address/src/frontend/index.ts b/packages/blocks/checkout-shipping-address/src/frontend/index.ts new file mode 100644 index 000000000..1957f727b --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/frontend/index.ts @@ -0,0 +1,5 @@ +export { CheckoutShippingAddressPure as Client } from './CheckoutShippingAddress.client'; +export { CheckoutShippingAddress as Server } from './CheckoutShippingAddress.server'; +export { CheckoutShippingAddressRenderer as Renderer } from './CheckoutShippingAddress.renderer'; + +export * as Types from './CheckoutShippingAddress.types'; diff --git a/packages/blocks/checkout-shipping-address/src/sdk/checkout-shipping-address.ts b/packages/blocks/checkout-shipping-address/src/sdk/checkout-shipping-address.ts new file mode 100644 index 000000000..6edabda7c --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/sdk/checkout-shipping-address.ts @@ -0,0 +1,115 @@ +import { Models } from '@o2s/utils.api-harmonization'; +import { Utils } from '@o2s/utils.frontend'; + +import { Carts, Checkout } from '@o2s/framework/modules'; +import { Sdk } from '@o2s/framework/sdk'; + +import { Model, Request, URL } from '../api-harmonization/checkout-shipping-address.client'; + +const API_URL = URL; +const CARTS_API_URL = '/carts'; +const CHECKOUT_API_URL = '/checkout'; + +export const checkoutShippingAddress = (sdk: Sdk) => ({ + blocks: { + getCheckoutShippingAddress: ( + query: Request.GetCheckoutShippingAddressBlockQuery, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Model.CheckoutShippingAddressBlock> => + sdk.makeRequest({ + method: 'get', + url: `${API_URL}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + params: query, + }), + }, + carts: { + getCart: ( + cartId: string, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Carts.Model.Cart> => + sdk.makeRequest({ + method: 'get', + url: `${CARTS_API_URL}/${cartId}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + }), + }, + checkout: { + setAddresses: ( + cartId: string, + body: Checkout.Request.SetAddressesBody, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Carts.Model.Cart> => + sdk.makeRequest({ + method: 'post', + url: `${CHECKOUT_API_URL}/${cartId}/addresses`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + data: body, + }), + setShippingMethod: ( + cartId: string, + body: Checkout.Request.SetShippingMethodBody, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Carts.Model.Cart> => + sdk.makeRequest({ + method: 'post', + url: `${CHECKOUT_API_URL}/${cartId}/shipping-method`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + data: body, + }), + getShippingOptions: ( + cartId: string, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Checkout.Model.ShippingOptions> => + sdk.makeRequest({ + method: 'get', + url: `${CHECKOUT_API_URL}/${cartId}/shipping-options`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + }), + }, +}); diff --git a/packages/blocks/checkout-shipping-address/src/sdk/index.ts b/packages/blocks/checkout-shipping-address/src/sdk/index.ts new file mode 100644 index 000000000..9fa517587 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/src/sdk/index.ts @@ -0,0 +1,38 @@ +// these unused imports are necessary for TypeScript to properly resolve API methods +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Models } from '@o2s/utils.api-harmonization'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Carts, Checkout } from '@o2s/framework/modules'; +import { extendSdk, getSdk } from '@o2s/framework/sdk'; + +import { checkoutShippingAddress } from './checkout-shipping-address'; + +const API_URL = + (typeof window === 'undefined' ? process.env.NEXT_PUBLIC_API_URL_INTERNAL : process.env.NEXT_PUBLIC_API_URL) || + process.env.NEXT_PUBLIC_API_URL; + +const internalSdk = getSdk({ + apiUrl: API_URL!, + logger: { + // @ts-expect-error missing types + level: process.env.NEXT_PUBLIC_LOG_LEVEL, + // @ts-expect-error missing types + format: process.env.NEXT_PUBLIC_LOG_FORMAT, + colorsEnabled: process.env.NEXT_PUBLIC_LOG_COLORS_ENABLED === 'true', + }, +}); + +export const sdk = extendSdk(internalSdk, { + blocks: { + getCheckoutShippingAddress: checkoutShippingAddress(internalSdk).blocks.getCheckoutShippingAddress, + }, + carts: { + getCart: checkoutShippingAddress(internalSdk).carts.getCart, + }, + checkout: { + setAddresses: checkoutShippingAddress(internalSdk).checkout.setAddresses, + setShippingMethod: checkoutShippingAddress(internalSdk).checkout.setShippingMethod, + getShippingOptions: checkoutShippingAddress(internalSdk).checkout.getShippingOptions, + }, +}); diff --git a/packages/blocks/checkout-shipping-address/tsconfig.api.json b/packages/blocks/checkout-shipping-address/tsconfig.api.json new file mode 100644 index 000000000..0f9f79f8e --- /dev/null +++ b/packages/blocks/checkout-shipping-address/tsconfig.api.json @@ -0,0 +1,14 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "outDir": "./dist/api-harmonization", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src/api-harmonization", + }, + "include": ["src/api-harmonization"] +} diff --git a/packages/blocks/checkout-shipping-address/tsconfig.frontend.json b/packages/blocks/checkout-shipping-address/tsconfig.frontend.json new file mode 100644 index 000000000..6e459f768 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/tsconfig.frontend.json @@ -0,0 +1,22 @@ +{ + "extends": "@o2s/typescript-config/frontend.json", + "compilerOptions": { + "outDir": "./dist/frontend", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "noEmit": false, + "jsx": "react", + "baseUrl": "./src/frontend" + }, + "include": [ + "src/frontend", + "src/api-harmonization/checkout-shipping-address.client.ts", + "src/api-harmonization/checkout-shipping-address.model.ts", + "src/api-harmonization/checkout-shipping-address.request.ts", + "src/sdk" + ] +} diff --git a/packages/blocks/checkout-shipping-address/tsconfig.json b/packages/blocks/checkout-shipping-address/tsconfig.json new file mode 100644 index 000000000..c3031c1dd --- /dev/null +++ b/packages/blocks/checkout-shipping-address/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@o2s/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src", + }, + "references": [ + { "path": "./tsconfig.frontend.json" }, + { "path": "./tsconfig.api.json" }, + { "path": "./tsconfig.sdk.json" } + ] +} diff --git a/packages/blocks/checkout-shipping-address/tsconfig.sdk.json b/packages/blocks/checkout-shipping-address/tsconfig.sdk.json new file mode 100644 index 000000000..7bf44d5ef --- /dev/null +++ b/packages/blocks/checkout-shipping-address/tsconfig.sdk.json @@ -0,0 +1,19 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "outDir": "./dist/sdk", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src/sdk" + }, + "include": [ + "src/sdk", + "src/api-harmonization/checkout-shipping-address.client.ts", + "src/api-harmonization/checkout-shipping-address.model.ts", + "src/api-harmonization/checkout-shipping-address.request.ts" + ] +} diff --git a/packages/blocks/checkout-shipping-address/vitest.config.mjs b/packages/blocks/checkout-shipping-address/vitest.config.mjs new file mode 100644 index 000000000..82be23c07 --- /dev/null +++ b/packages/blocks/checkout-shipping-address/vitest.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/vitest-config/block'; + +export default config; diff --git a/packages/blocks/checkout-summary/.gitignore b/packages/blocks/checkout-summary/.gitignore new file mode 100644 index 000000000..29986a380 --- /dev/null +++ b/packages/blocks/checkout-summary/.gitignore @@ -0,0 +1,57 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +/tsconfig.tsbuildinfo diff --git a/packages/blocks/checkout-summary/.prettierrc.mjs b/packages/blocks/checkout-summary/.prettierrc.mjs new file mode 100644 index 000000000..93b66d398 --- /dev/null +++ b/packages/blocks/checkout-summary/.prettierrc.mjs @@ -0,0 +1,25 @@ +import apiConfig from "@o2s/prettier-config/api.mjs"; +import frontendConfig from "@o2s/prettier-config/frontend.mjs"; + +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +const config = { + overrides: [ + { + files: "./src/api-harmonization/**/*", + options: apiConfig, + }, + { + files: "./src/frontend/**/*", + options: frontendConfig, + }, + { + files: "./src/sdk/**/*", + options: frontendConfig, + }, + ], +}; + +export default config; diff --git a/packages/blocks/checkout-summary/CHANGELOG.md b/packages/blocks/checkout-summary/CHANGELOG.md new file mode 100644 index 000000000..a9f9b683d --- /dev/null +++ b/packages/blocks/checkout-summary/CHANGELOG.md @@ -0,0 +1,32 @@ +# @o2s/blocks.checkout-summary + +## 0.1.1 + +### Patch Changes + +- c1a5460: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + +## 0.1.0 + +### Minor Changes + +- 5d36519: Added new blocks: Cart, Checkout (Summary, Shipping Address, Company Data, Billing Payment) and Order Confirmation. Includes checkout forms validation (Formik + Yup), error handling, promo code support in cart, and new UI components (StepIndicator, RadioTile, AddressFields, CartSummary, QuantityInput, FormField). + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] +- Updated dependencies [5d36519] + - @o2s/framework@1.19.0 + - @o2s/utils.frontend@0.5.0 + - @o2s/ui@1.12.0 diff --git a/packages/blocks/checkout-summary/README.md b/packages/blocks/checkout-summary/README.md new file mode 100644 index 000000000..8dfe42d26 --- /dev/null +++ b/packages/blocks/checkout-summary/README.md @@ -0,0 +1,88 @@ +# @o2s/blocks.checkout-summary + +Block for the order summary step in the checkout flow. + +The checkout-summary block displays the full order review before placing the order. Shows products, company details, shipping and billing address, payment method, active promo codes, order notes, and totals. Data is fetched client-side using `cartId` from localStorage. Part of the multi-step checkout flow. + +- **Order review** – Products, company, shipping, payment +- **Place order** – Confirm and place the order +- **Notes** – Optional comment and special instructions +- **Cart summary** – Subtotal, tax, shipping, total + +Content editors place the block via CMS. Developers connect Carts, Checkout, and CMS integrations. + +## Installation + +```bash +npm install @o2s/blocks.checkout-summary +``` + +## Usage + +### Backend (API Harmonization) + +Register the block in `app.module.ts`: + +```typescript +import * as CheckoutSummary from "@o2s/blocks.checkout-summary/api-harmonization"; +import { AppConfig } from "./app.config"; + +@Module({ + imports: [CheckoutSummary.Module.register(AppConfig)], +}) +export class AppModule {} +``` + +### Frontend + +Register the block in `renderBlocks.tsx`: + +```typescript +import * as CheckoutSummary from '@o2s/blocks.checkout-summary/frontend'; + +export const renderBlocks = async (blocks: CMS.Model.Page.SlotBlock[]) => { + return blocks.map((block) => { + if (block.type === 'checkout-summary') { + return ( + <CheckoutSummary.Renderer + key={block.id} + id={block.id} + slug={slug} + locale={locale} + accessToken={session?.accessToken} + userId={session?.user?.id} + routing={routing} + /> + ); + } + // ... other blocks + }); +}; +``` + +## Configuration + +This block requires Carts, Checkout, and CMS integrations in `AppConfig`. + +## Related Blocks + +- `@o2s/blocks.cart` - Shopping cart +- `@o2s/blocks.checkout-company-data` - Company details step +- `@o2s/blocks.checkout-shipping-address` - Shipping address step +- `@o2s/blocks.checkout-billing-payment` - Payment step +- `@o2s/blocks.order-confirmation` - Order confirmation page + +## About Blocks in O2S + +Blocks are self-contained, reusable UI components that combine harmonizing and frontend components into a single package. Each block is independently packaged as an NPM module and includes three primary parts: API Harmonization Module, Frontend Components, and SDK Methods. Blocks allow you to quickly add or remove functionality without impacting other components of the application. + +- **See all blocks**: [Blocks Documentation](https://www.openselfservice.com/docs/main-components/blocks/) +- **View this block in Storybook**: [checkout-summary](https://storybook-o2s.openselfservice.com/?path=/story/blocks-checkoutsummary--default) + +## About O2S + +**Part of [Open Self Service (O2S)](https://www.openselfservice.com/)** - an open-source framework for building composable customer self-service portals. O2S simplifies integration of multiple headless APIs into a scalable frontend, providing an API-agnostic architecture with a normalization layer. + +- **Website**: [https://www.openselfservice.com/](https://www.openselfservice.com/) +- **GitHub**: [https://github.com/o2sdev/openselfservice](https://github.com/o2sdev/openselfservice) +- **Documentation**: [https://www.openselfservice.com/docs](https://www.openselfservice.com/docs) diff --git a/packages/blocks/checkout-summary/eslint.config.mjs b/packages/blocks/checkout-summary/eslint.config.mjs new file mode 100644 index 000000000..223f2af08 --- /dev/null +++ b/packages/blocks/checkout-summary/eslint.config.mjs @@ -0,0 +1,18 @@ +import { config as apiConfig } from '@o2s/eslint-config/api'; +import { config as frontendConfig } from '@o2s/eslint-config/frontend-block'; +import { defineConfig } from 'eslint/config'; + +export default defineConfig([ + { + files: ['src/api-harmonization/**/*'], + extends: [apiConfig], + }, + { + files: ['src/frontend/**/*'], + extends: [frontendConfig], + }, + { + files: ['src/sdk/**/*'], + extends: [frontendConfig], + }, +]); diff --git a/packages/blocks/checkout-summary/lint-staged.config.mjs b/packages/blocks/checkout-summary/lint-staged.config.mjs new file mode 100644 index 000000000..b47bd93b9 --- /dev/null +++ b/packages/blocks/checkout-summary/lint-staged.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/lint-staged-config/base'; + +export default config; diff --git a/packages/blocks/checkout-summary/package.json b/packages/blocks/checkout-summary/package.json new file mode 100644 index 000000000..35646df69 --- /dev/null +++ b/packages/blocks/checkout-summary/package.json @@ -0,0 +1,58 @@ +{ + "name": "@o2s/blocks.checkout-summary", + "version": "0.1.1", + "private": false, + "license": "MIT", + "description": "Final checkout step for reviewing cart items, addresses, totals, and placing the order.", + "exports": { + "./api-harmonization": "./dist/api-harmonization/api-harmonization/index.js", + "./frontend": "./dist/frontend/frontend/index.js", + "./sdk": "./dist/sdk/sdk/index.js", + "./client": "./dist/api-harmonization/api-harmonization/checkout-summary.client.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build tsconfig.json --preserveWatchOutput && tsc-alias", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit && eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"" + }, + "dependencies": { + "@o2s/framework": "*", + "@o2s/utils.logger": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/configs.integrations": "*" + }, + "devDependencies": { + "dotenv-cli": "^11.0.0", + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "eslint": "^9.39.4", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "react": "^19", + "react-dom": "^19", + "tailwindcss": "^4", + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "rxjs": "^7", + "next": "^16.0.5", + "next-intl": "^4.1.0" + } +} diff --git a/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.client.ts b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.client.ts new file mode 100644 index 000000000..4427327e3 --- /dev/null +++ b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.client.ts @@ -0,0 +1,4 @@ +export const URL = '/blocks/checkout-summary'; + +export * as Model from './checkout-summary.model'; +export * as Request from './checkout-summary.request'; diff --git a/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.controller.ts b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.controller.ts new file mode 100644 index 000000000..82cf5a512 --- /dev/null +++ b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; + +import { Models } from '@o2s/utils.api-harmonization'; +import { LoggerService } from '@o2s/utils.logger'; + +import { Auth } from '@o2s/framework/modules'; + +import { URL } from './'; +import { GetCheckoutSummaryBlockQuery } from './checkout-summary.request'; +import { CheckoutSummaryService } from './checkout-summary.service'; + +@Controller(URL) +@UseInterceptors(LoggerService) +export class CheckoutSummaryController { + constructor(protected readonly service: CheckoutSummaryService) {} + + @Get() + @Auth.Decorators.Roles({ roles: [] }) + // Optional: Add permission-based access control + // @Auth.Decorators.Permissions({ resource: 'resource-name', actions: ['view'] }) + getCheckoutSummaryBlock( + @Headers() headers: Models.Headers.AppHeaders, + @Query() query: GetCheckoutSummaryBlockQuery, + ) { + return this.service.getCheckoutSummaryBlock(query, headers); + } +} diff --git a/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.mapper.ts b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.mapper.ts new file mode 100644 index 000000000..16c2218f3 --- /dev/null +++ b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.mapper.ts @@ -0,0 +1,19 @@ +import { CMS } from '@o2s/configs.integrations'; + +import type { CheckoutSummaryBlock } from './checkout-summary.model'; + +export const mapCheckoutSummary = (cms: CMS.Model.CheckoutSummaryBlock.CheckoutSummaryBlock): CheckoutSummaryBlock => { + return { + __typename: 'CheckoutSummaryBlock', + id: cms.id, + title: cms.title, + subtitle: cms.subtitle, + sections: cms.sections, + buttons: cms.buttons, + errors: cms.errors, + loading: cms.loading, + placeholders: cms.placeholders, + stepIndicator: cms.stepIndicator, + cartPath: cms.cartPath, + }; +}; diff --git a/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.model.ts b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.model.ts new file mode 100644 index 000000000..ecb0c98c5 --- /dev/null +++ b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.model.ts @@ -0,0 +1,16 @@ +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; + +import { CMS } from '@o2s/framework/modules'; + +export class CheckoutSummaryBlock extends ApiModels.Block.Block { + __typename!: 'CheckoutSummaryBlock'; + title!: string; + subtitle?: string; + sections!: CMS.Model.CheckoutSummaryBlock.CheckoutSummaryBlock['sections']; + buttons!: CMS.Model.CheckoutSummaryBlock.CheckoutSummaryBlock['buttons']; + errors!: CMS.Model.CheckoutSummaryBlock.CheckoutSummaryBlock['errors']; + loading!: CMS.Model.CheckoutSummaryBlock.CheckoutSummaryBlock['loading']; + placeholders!: CMS.Model.CheckoutSummaryBlock.CheckoutSummaryBlock['placeholders']; + stepIndicator!: CMS.Model.CheckoutSummaryBlock.CheckoutSummaryBlock['stepIndicator']; + cartPath!: CMS.Model.CheckoutSummaryBlock.CheckoutSummaryBlock['cartPath']; +} diff --git a/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.module.ts b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.module.ts new file mode 100644 index 000000000..6358774e1 --- /dev/null +++ b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.module.ts @@ -0,0 +1,25 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { CMS } from '@o2s/configs.integrations'; + +import * as Framework from '@o2s/framework/modules'; + +import { CheckoutSummaryController } from './checkout-summary.controller'; +import { CheckoutSummaryService } from './checkout-summary.service'; + +@Module({}) +export class CheckoutSummaryBlockModule { + static register(_config: Framework.ApiConfig): DynamicModule { + return { + module: CheckoutSummaryBlockModule, + providers: [ + CheckoutSummaryService, + { + provide: CMS.Service, + useExisting: Framework.CMS.Service, + }, + ], + controllers: [CheckoutSummaryController], + exports: [CheckoutSummaryService], + }; + } +} diff --git a/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.request.ts b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.request.ts new file mode 100644 index 000000000..0892b129e --- /dev/null +++ b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.request.ts @@ -0,0 +1,5 @@ +import { CMS } from '@o2s/framework/modules'; + +export class GetCheckoutSummaryBlockQuery implements Omit<CMS.Request.GetCmsEntryParams, 'locale'> { + id!: string; +} diff --git a/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.service.spec.ts b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.service.spec.ts new file mode 100644 index 000000000..2cfe86a77 --- /dev/null +++ b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.service.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CMS } from '@o2s/configs.integrations'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CheckoutSummaryService } from './checkout-summary.service'; + +describe('CheckoutSummaryService', () => { + let service: CheckoutSummaryService; + let cmsService: CMS.Service; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CheckoutSummaryService, + { + provide: CMS.Service, + useValue: { + getCheckoutSummaryBlock: vi.fn().mockReturnValue({ + title: 'Test Block', + }), + }, + }, + ], + }).compile(); + + service = module.get<CheckoutSummaryService>(CheckoutSummaryService); + cmsService = module.get<CMS.Service>(CMS.Service); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should have cmsService injected', () => { + expect(cmsService).toBeDefined(); + }); +}); diff --git a/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.service.ts b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.service.ts new file mode 100644 index 000000000..84e5b7436 --- /dev/null +++ b/packages/blocks/checkout-summary/src/api-harmonization/checkout-summary.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { CMS } from '@o2s/configs.integrations'; +import { Observable, forkJoin, map } from 'rxjs'; + +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; + +// import { Auth } from '@o2s/framework/modules'; + +import { mapCheckoutSummary } from './checkout-summary.mapper'; +import { CheckoutSummaryBlock } from './checkout-summary.model'; +import { GetCheckoutSummaryBlockQuery } from './checkout-summary.request'; + +const H = HeaderName; + +@Injectable() +export class CheckoutSummaryService { + constructor( + private readonly cmsService: CMS.Service, + // Optional: Inject Auth.Service when you need to add permission flags to the response + // private readonly authService: Auth.Service, + ) {} + + getCheckoutSummaryBlock( + query: GetCheckoutSummaryBlockQuery, + headers: AppHeaders, + ): Observable<CheckoutSummaryBlock> { + const cms = this.cmsService.getCheckoutSummaryBlock({ ...query, locale: headers[H.Locale] }); + + return forkJoin([cms]).pipe( + map(([cms]) => { + return mapCheckoutSummary(cms); + }), + ); + } +} diff --git a/packages/blocks/checkout-summary/src/api-harmonization/index.ts b/packages/blocks/checkout-summary/src/api-harmonization/index.ts new file mode 100644 index 000000000..c207a682b --- /dev/null +++ b/packages/blocks/checkout-summary/src/api-harmonization/index.ts @@ -0,0 +1,8 @@ +export const URL = '/blocks/checkout-summary'; + +export { CheckoutSummaryBlockModule as Module } from './checkout-summary.module'; +export { CheckoutSummaryService as Service } from './checkout-summary.service'; +export { CheckoutSummaryController as Controller } from './checkout-summary.controller'; + +export * as Model from './checkout-summary.model'; +export * as Request from './checkout-summary.request'; diff --git a/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.client.stories.tsx b/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.client.stories.tsx new file mode 100644 index 000000000..7cedc3021 --- /dev/null +++ b/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.client.stories.tsx @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { defineRouting } from 'next-intl/routing'; + +import readme from '../../README.md?raw'; + +import { CheckoutSummaryPure } from './CheckoutSummary.client'; + +const routing = defineRouting({ + locales: ['en'], + defaultLocale: 'en', + pathnames: {}, +}); + +const baseBlock = { + __typename: 'CheckoutSummaryBlock' as const, + id: 'checkout-summary-1', + stepIndicator: { + steps: ['Company details', 'Delivery', 'Payment', 'Summary'], + currentStep: 4, + }, + title: 'Order summary', + subtitle: 'Review and place your order', + sections: { + products: { + title: 'Products', + labels: { quantity: 'Quantity', price: 'Unit price', total: 'Total' }, + }, + company: { + title: 'Company details', + addressLabel: 'Billing address', + companyNameLabel: 'Company name', + taxIdLabel: 'Tax ID', + }, + shipping: { + title: 'Shipping address', + addressLabel: 'Address', + methodLabel: 'Shipping method:', + }, + billing: { + title: 'Payment', + addressLabel: 'Billing address', + methodLabel: 'Payment method:', + }, + summary: { + title: 'Summary', + subtotalLabel: 'Subtotal', + taxLabel: 'VAT', + discountLabel: 'Discount', + shippingLabel: 'Shipping', + freeLabel: 'Free', + totalLabel: 'Total', + activePromoCodesTitle: 'Active discount codes', + notesTitle: 'Notes', + }, + }, + cartPath: '/cart', + buttons: { + back: { label: 'Back', path: '#' }, + confirm: { label: 'Place order', path: '#' }, + }, + errors: { + cartNotFound: 'Cart not found', + placeOrderError: 'Something went wrong while placing your order. Please try again.', + loadError: 'Something went wrong while loading the summary. Please complete the previous steps and try again.', + }, + loading: { confirming: 'Placing order...' }, + placeholders: { + companyData: 'Company details not provided', + shippingAddress: 'Shipping address not provided', + sameAsBillingAddress: 'Same as billing address', + billingAddress: 'Billing address not provided', + }, +}; + +const meta = { + title: 'Blocks/CheckoutSummary', + component: CheckoutSummaryPure, + tags: ['autodocs'], + parameters: { readme }, +} satisfies Meta<typeof CheckoutSummaryPure>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default: Story = { + args: { + ...baseBlock, + id: 'checkout-summary-1', + locale: 'en', + routing, + }, +}; diff --git a/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.client.tsx b/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.client.tsx new file mode 100644 index 000000000..f979e44e0 --- /dev/null +++ b/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.client.tsx @@ -0,0 +1,345 @@ +'use client'; + +import { createNavigation } from 'next-intl/navigation'; +import React, { useEffect, useState, useTransition } from 'react'; + +import { Utils } from '@o2s/utils.frontend'; + +import { Checkout } from '@o2s/framework/modules'; + +import { useToast } from '@o2s/ui/hooks/use-toast'; + +import { CartSummary, CartSummarySkeleton } from '@o2s/ui/components/Cart/CartSummary'; +import { StepIndicator } from '@o2s/ui/components/Checkout/StepIndicator'; +import { Image } from '@o2s/ui/components/Image'; +import { Price } from '@o2s/ui/components/Price'; + +import { Skeleton } from '@o2s/ui/elements/skeleton'; +import { Typography } from '@o2s/ui/elements/typography'; + +import { sdk } from '../sdk'; + +import { CheckoutSummaryPureProps } from './CheckoutSummary.types'; + +const CART_ID_KEY = 'cartId'; + +export const CheckoutSummaryPure: React.FC<Readonly<CheckoutSummaryPureProps>> = ({ + locale, + accessToken, + routing, + title, + subtitle, + stepIndicator, + sections, + buttons, + errors, + cartPath, + loading: loadingLabels, + placeholders, +}) => { + const { Link: LinkComponent, useRouter } = createNavigation(routing); + const router = useRouter(); + const { toast } = useToast(); + + const [summaryData, setSummaryData] = useState<Checkout.Model.CheckoutSummary | undefined>(); + const [isInitialLoadPending, startInitialLoadTransition] = useTransition(); + const [isSubmitPending, startSubmitTransition] = useTransition(); + + useEffect(() => { + const cartId = localStorage.getItem(CART_ID_KEY); + if (!cartId) { + toast({ description: errors.cartNotFound, variant: 'destructive' }); + router.replace(cartPath); + return; + } + + startInitialLoadTransition(async () => { + try { + const data = await sdk.checkout.getCheckoutSummary(cartId, { 'x-locale': locale }, accessToken); + setSummaryData(data); + } catch (error) { + const err = error as { status?: number; response?: { status?: number } }; + const status = err.status ?? err.response?.status; + if (status === 401 || status === 404) { + toast({ description: errors.cartNotFound, variant: 'destructive' }); + router.replace(cartPath); + } else { + toast({ description: errors.loadError, variant: 'destructive' }); + } + } + }); + }, [locale, accessToken, toast, errors.cartNotFound, errors.loadError, router, cartPath]); + + const handleConfirm = () => { + const cartId = localStorage.getItem(CART_ID_KEY); + if (!cartId) { + toast({ description: errors.cartNotFound, variant: 'destructive' }); + router.replace(cartPath); + return; + } + + startSubmitTransition(async () => { + try { + const email = summaryData?.billingAddress?.email || summaryData?.email; + const result = await sdk.checkout.placeOrder(cartId, { email }, { 'x-locale': locale }, accessToken); + + if (result.order?.id) { + const redirectUrl = result.paymentRedirectUrl || `${buttons.confirm.path}/${result.order.id}`; + localStorage.removeItem(CART_ID_KEY); + window.location.href = redirectUrl; + return; + } + } catch { + toast({ + variant: 'destructive', + description: errors.placeOrderError, + }); + } + }); + }; + + const billingAddress = summaryData?.billingAddress; + const shippingAddress = summaryData?.shippingAddress; + const shippingMethod = summaryData?.shippingMethod; + const paymentMethod = summaryData?.paymentMethod; + const items = summaryData?.cart.items?.data ?? []; + const totals = summaryData?.totals; + const promotions = summaryData?.cart.promotions ?? []; + const { formatStreetAddress } = Utils.FormatAddress; + + return ( + <div className="w-full flex flex-col gap-8"> + <StepIndicator steps={stepIndicator.steps} currentStep={stepIndicator.currentStep} /> + <div className="flex flex-col gap-2"> + <Typography variant="h1">{title}</Typography> + {subtitle && ( + <Typography variant="large" className="text-muted-foreground"> + {subtitle} + </Typography> + )} + </div> + + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> + {/* Left column - Order details */} + <div className="lg:col-span-2 flex flex-col gap-6"> + {/* Products */} + <div className="flex flex-col gap-2"> + <Typography variant="h2">{sections.products.title}</Typography> + {isInitialLoadPending ? ( + <div className="flex flex-col gap-3"> + <Skeleton className="h-24 w-full" /> + <Skeleton className="h-24 w-full" /> + </div> + ) : items.length > 0 ? ( + <ul className="flex flex-col gap-4"> + {items.map((item) => { + const product = item.product; + const itemTotal = item.total; + + return ( + <li + key={item.id} + className="flex flex-row gap-4 p-4 bg-card rounded-lg border border-border" + > + {product?.image && ( + <div className="relative w-24 h-24 sm:w-32 sm:h-32 shrink-0 rounded-md overflow-hidden bg-muted"> + <Image + src={product.image.url} + alt={product.image.alt ?? product.name} + fill + sizes="(max-width: 640px) 96px, 128px" + className="object-cover object-center" + /> + </div> + )} + <div className="min-w-0 flex-1 flex flex-col gap-2"> + <Typography variant="h3">{product?.name}</Typography> + <div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-2 h-full"> + <div className="flex flex-col gap-1 text-sm text-muted-foreground"> + <span> + {sections.products.labels.quantity}: {item.quantity} + </span> + <span className="whitespace-nowrap"> + {sections.products.labels.price}:{' '} + <Price price={item.price} /> + </span> + </div> + <div className="ml-auto flex flex-col items-end shrink-0"> + <Typography variant="small" className="text-muted-foreground"> + {sections.products.labels.total} + </Typography> + <Typography + variant="h3" + className="text-primary whitespace-nowrap" + > + <Price price={itemTotal} /> + </Typography> + </div> + </div> + </div> + </li> + ); + })} + </ul> + ) : null} + </div> + + {/* Company data */} + <div className="flex flex-col gap-2"> + <Typography variant="h2">{sections.company.title}</Typography> + {isInitialLoadPending ? ( + <Skeleton className="h-24 w-full" /> + ) : billingAddress ? ( + <div className="flex flex-col p-4 bg-card rounded-lg border border-border"> + {(billingAddress.firstName || billingAddress.lastName) && ( + <Typography variant="small"> + {[billingAddress.firstName, billingAddress.lastName].filter(Boolean).join(' ')} + </Typography> + )} + {(billingAddress.email || summaryData?.email) && ( + <Typography variant="small"> + {billingAddress.email || summaryData?.email} + </Typography> + )} + {billingAddress.phone && ( + <Typography variant="small">{billingAddress.phone}</Typography> + )} + {billingAddress.companyName && ( + <Typography variant="small">{billingAddress.companyName}</Typography> + )} + {billingAddress.taxId && ( + <Typography variant="small"> + <strong>{sections.company.taxIdLabel}: </strong> + {billingAddress.taxId} + </Typography> + )} + </div> + ) : ( + <Typography variant="body">{placeholders.companyData}</Typography> + )} + </div> + + {/* Shipping address */} + <div className="flex flex-col gap-2"> + <Typography variant="h2">{sections.shipping.title}</Typography> + {isInitialLoadPending ? ( + <Skeleton className="h-24 w-full" /> + ) : shippingAddress ? ( + <div className="flex flex-col gap-2 p-4 bg-card rounded-lg border border-border"> + <div> + <Typography variant="small" className="mb-1 font-bold"> + {sections.shipping.addressLabel} + </Typography> + <Typography variant="small">{formatStreetAddress(shippingAddress)}</Typography> + <Typography variant="small"> + {shippingAddress.postalCode} {shippingAddress.city} + </Typography> + <Typography variant="small"> + {Utils.FormatCountry.formatCountryCode(shippingAddress.country, locale)} + </Typography> + {(shippingAddress.firstName || shippingAddress.lastName) && ( + <Typography variant="small" className="mt-2"> + {[shippingAddress.firstName, shippingAddress.lastName] + .filter(Boolean) + .join(' ')} + </Typography> + )} + {shippingAddress.phone && ( + <Typography variant="small">{shippingAddress.phone}</Typography> + )} + </div> + {shippingMethod && ( + <div className="mt-2 pt-2 border-t border-border"> + <Typography variant="small"> + <strong>{sections.shipping.methodLabel}</strong> {shippingMethod.name} + </Typography> + </div> + )} + </div> + ) : ( + <Typography variant="body">{placeholders.shippingAddress}</Typography> + )} + </div> + + {/* Payment */} + <div className="flex flex-col gap-2"> + <Typography variant="h2">{sections.billing.title}</Typography> + {isInitialLoadPending ? ( + <Skeleton className="h-24 w-full" /> + ) : billingAddress || paymentMethod ? ( + <div className="flex flex-col gap-2 p-4 bg-card rounded-lg border border-border"> + {billingAddress && ( + <div> + <Typography variant="small" className="mb-1 font-bold"> + {sections.company.addressLabel} + </Typography> + <Typography variant="small">{formatStreetAddress(billingAddress)}</Typography> + <Typography variant="small"> + {billingAddress.postalCode} {billingAddress.city} + </Typography> + <Typography variant="small"> + {Utils.FormatCountry.formatCountryCode(billingAddress.country, locale)} + </Typography> + </div> + )} + {paymentMethod && ( + <div className={billingAddress ? 'mt-2 pt-2 border-t border-border' : ''}> + <Typography variant="small"> + <strong>{sections.billing.methodLabel}</strong> {paymentMethod.name} + </Typography> + </div> + )} + </div> + ) : ( + <Typography variant="body">{placeholders.billingAddress}</Typography> + )} + </div> + </div> + + {/* Right column - Summary */} + <div className="lg:col-span-1"> + <div className="sticky top-6"> + {isInitialLoadPending ? ( + <CartSummarySkeleton /> + ) : totals ? ( + <CartSummary + subtotal={totals.subtotal} + tax={totals.tax} + total={totals.total} + discountTotal={totals.discount} + shippingTotal={shippingMethod ? totals.shipping : undefined} + promotions={promotions} + notes={ + summaryData?.notes + ? { title: sections.summary.notesTitle, content: summaryData.notes } + : undefined + } + labels={{ + title: sections.summary.title, + subtotalLabel: sections.summary.subtotalLabel, + taxLabel: sections.summary.taxLabel, + totalLabel: sections.summary.totalLabel, + discountLabel: sections.summary.discountLabel, + shippingLabel: sections.summary.shippingLabel, + freeLabel: sections.summary.freeLabel, + activePromoCodesTitle: sections.summary.activePromoCodesTitle, + }} + primaryButton={{ + label: isSubmitPending ? loadingLabels.confirming : buttons.confirm.label, + icon: isSubmitPending ? 'Loader2' : undefined, + disabled: isSubmitPending || isInitialLoadPending, + action: { type: 'click', onClick: handleConfirm }, + }} + secondaryButton={{ + label: buttons.back.label, + action: { type: 'link', url: buttons.back.path }, + }} + LinkComponent={LinkComponent} + /> + ) : null} + </div> + </div> + </div> + </div> + ); +}; diff --git a/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.renderer.tsx b/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.renderer.tsx new file mode 100644 index 000000000..98bcd3788 --- /dev/null +++ b/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.renderer.tsx @@ -0,0 +1,32 @@ +import { useLocale } from 'next-intl'; +import React, { Suspense } from 'react'; + +import { Loading } from '@o2s/ui/components/Loading'; + +import { CheckoutSummary } from './CheckoutSummary.server'; +import { CheckoutSummaryRendererProps } from './CheckoutSummary.types'; + +export const CheckoutSummaryRenderer: React.FC<CheckoutSummaryRendererProps> = ({ id, accessToken, routing }) => { + const locale = useLocale(); + + return ( + <Suspense + key={id} + fallback={ + <div className="w-full flex flex-col gap-8"> + <Loading bars={1} /> + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> + <div className="lg:col-span-2"> + <Loading bars={4} /> + </div> + <div className="lg:col-span-1"> + <Loading bars={4} /> + </div> + </div> + </div> + } + > + <CheckoutSummary id={id} accessToken={accessToken} locale={locale} routing={routing} /> + </Suspense> + ); +}; diff --git a/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.server.tsx b/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.server.tsx new file mode 100644 index 000000000..681c8f098 --- /dev/null +++ b/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.server.tsx @@ -0,0 +1,29 @@ +import dynamic from 'next/dynamic'; +import React from 'react'; + +import type { Model } from '../api-harmonization/checkout-summary.client'; +import { sdk } from '../sdk'; + +import { CheckoutSummaryProps } from './CheckoutSummary.types'; + +export const CheckoutSummaryDynamic = dynamic(() => + import('./CheckoutSummary.client').then((module) => module.CheckoutSummaryPure), +); + +export const CheckoutSummary: React.FC<CheckoutSummaryProps> = async ({ id, accessToken, locale, routing }) => { + let data: Model.CheckoutSummaryBlock; + try { + data = await sdk.blocks.getCheckoutSummary( + { + id, + }, + { 'x-locale': locale }, + accessToken, + ); + } catch (error) { + console.error('Error fetching CheckoutSummary block', error); + return null; + } + + return <CheckoutSummaryDynamic {...data} id={id} accessToken={accessToken} locale={locale} routing={routing} />; +}; diff --git a/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.types.ts b/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.types.ts new file mode 100644 index 000000000..3d8a33b9d --- /dev/null +++ b/packages/blocks/checkout-summary/src/frontend/CheckoutSummary.types.ts @@ -0,0 +1,16 @@ +import { defineRouting } from 'next-intl/routing'; + +import type { Model } from '../api-harmonization/checkout-summary.client'; + +export interface CheckoutSummaryProps { + id: string; + accessToken?: string; + locale: string; + routing: ReturnType<typeof defineRouting>; +} + +export type CheckoutSummaryPureProps = CheckoutSummaryProps & Model.CheckoutSummaryBlock; + +export type CheckoutSummaryRendererProps = Omit<CheckoutSummaryProps, ''> & { + slug: string[]; +}; diff --git a/packages/blocks/checkout-summary/src/frontend/index.ts b/packages/blocks/checkout-summary/src/frontend/index.ts new file mode 100644 index 000000000..2e41b06f9 --- /dev/null +++ b/packages/blocks/checkout-summary/src/frontend/index.ts @@ -0,0 +1,5 @@ +export { CheckoutSummaryPure as Client } from './CheckoutSummary.client'; +export { CheckoutSummary as Server } from './CheckoutSummary.server'; +export { CheckoutSummaryRenderer as Renderer } from './CheckoutSummary.renderer'; + +export * as Types from './CheckoutSummary.types'; diff --git a/packages/blocks/checkout-summary/src/sdk/checkout-summary.ts b/packages/blocks/checkout-summary/src/sdk/checkout-summary.ts new file mode 100644 index 000000000..1b861c7bd --- /dev/null +++ b/packages/blocks/checkout-summary/src/sdk/checkout-summary.ts @@ -0,0 +1,74 @@ +import { Models } from '@o2s/utils.api-harmonization'; +import { Utils } from '@o2s/utils.frontend'; + +import { Checkout } from '@o2s/framework/modules'; +import { Sdk } from '@o2s/framework/sdk'; + +import { Model, Request, URL } from '../api-harmonization/checkout-summary.client'; + +const API_URL = URL; +const CHECKOUT_API_URL = '/checkout'; + +export const checkoutSummary = (sdk: Sdk) => ({ + blocks: { + getCheckoutSummary: ( + query: Request.GetCheckoutSummaryBlockQuery, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Model.CheckoutSummaryBlock> => + sdk.makeRequest({ + method: 'get', + url: `${API_URL}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + params: query, + }), + }, + checkout: { + getCheckoutSummary: ( + cartId: string, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Checkout.Model.CheckoutSummary> => + sdk.makeRequest({ + method: 'get', + url: `${CHECKOUT_API_URL}/${cartId}/summary`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + }), + placeOrder: ( + cartId: string, + body: Checkout.Request.PlaceOrderBody, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Checkout.Model.PlaceOrderResponse> => + sdk.makeRequest({ + method: 'post', + url: `${CHECKOUT_API_URL}/${cartId}/place-order`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + data: body, + }), + }, +}); diff --git a/packages/blocks/checkout-summary/src/sdk/index.ts b/packages/blocks/checkout-summary/src/sdk/index.ts new file mode 100644 index 000000000..69bbf4645 --- /dev/null +++ b/packages/blocks/checkout-summary/src/sdk/index.ts @@ -0,0 +1,34 @@ +// these unused imports are necessary for TypeScript to properly resolve API methods +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Models } from '@o2s/utils.api-harmonization'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Checkout } from '@o2s/framework/modules'; +import { extendSdk, getSdk } from '@o2s/framework/sdk'; + +import { checkoutSummary } from './checkout-summary'; + +const API_URL = + (typeof window === 'undefined' ? process.env.NEXT_PUBLIC_API_URL_INTERNAL : process.env.NEXT_PUBLIC_API_URL) || + process.env.NEXT_PUBLIC_API_URL; + +const internalSdk = getSdk({ + apiUrl: API_URL!, + logger: { + // @ts-expect-error missing types + level: process.env.NEXT_PUBLIC_LOG_LEVEL, + // @ts-expect-error missing types + format: process.env.NEXT_PUBLIC_LOG_FORMAT, + colorsEnabled: process.env.NEXT_PUBLIC_LOG_COLORS_ENABLED === 'true', + }, +}); + +export const sdk = extendSdk(internalSdk, { + blocks: { + getCheckoutSummary: checkoutSummary(internalSdk).blocks.getCheckoutSummary, + }, + checkout: { + getCheckoutSummary: checkoutSummary(internalSdk).checkout.getCheckoutSummary, + placeOrder: checkoutSummary(internalSdk).checkout.placeOrder, + }, +}); diff --git a/packages/blocks/checkout-summary/tsconfig.api.json b/packages/blocks/checkout-summary/tsconfig.api.json new file mode 100644 index 000000000..0f9f79f8e --- /dev/null +++ b/packages/blocks/checkout-summary/tsconfig.api.json @@ -0,0 +1,14 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "outDir": "./dist/api-harmonization", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src/api-harmonization", + }, + "include": ["src/api-harmonization"] +} diff --git a/packages/blocks/checkout-summary/tsconfig.frontend.json b/packages/blocks/checkout-summary/tsconfig.frontend.json new file mode 100644 index 000000000..1ae55a2cf --- /dev/null +++ b/packages/blocks/checkout-summary/tsconfig.frontend.json @@ -0,0 +1,22 @@ +{ + "extends": "@o2s/typescript-config/frontend.json", + "compilerOptions": { + "outDir": "./dist/frontend", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "noEmit": false, + "jsx": "react", + "baseUrl": "./src/frontend" + }, + "include": [ + "src/frontend", + "src/api-harmonization/checkout-summary.client.ts", + "src/api-harmonization/checkout-summary.model.ts", + "src/api-harmonization/checkout-summary.request.ts", + "src/sdk" + ] +} diff --git a/packages/blocks/checkout-summary/tsconfig.json b/packages/blocks/checkout-summary/tsconfig.json new file mode 100644 index 000000000..c3031c1dd --- /dev/null +++ b/packages/blocks/checkout-summary/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@o2s/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src", + }, + "references": [ + { "path": "./tsconfig.frontend.json" }, + { "path": "./tsconfig.api.json" }, + { "path": "./tsconfig.sdk.json" } + ] +} diff --git a/packages/blocks/checkout-summary/tsconfig.sdk.json b/packages/blocks/checkout-summary/tsconfig.sdk.json new file mode 100644 index 000000000..478013ecd --- /dev/null +++ b/packages/blocks/checkout-summary/tsconfig.sdk.json @@ -0,0 +1,19 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "outDir": "./dist/sdk", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src/sdk" + }, + "include": [ + "src/sdk", + "src/api-harmonization/checkout-summary.client.ts", + "src/api-harmonization/checkout-summary.model.ts", + "src/api-harmonization/checkout-summary.request.ts" + ] +} diff --git a/packages/blocks/checkout-summary/vitest.config.mjs b/packages/blocks/checkout-summary/vitest.config.mjs new file mode 100644 index 000000000..82be23c07 --- /dev/null +++ b/packages/blocks/checkout-summary/vitest.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/vitest-config/block'; + +export default config; diff --git a/packages/blocks/cta-section/CHANGELOG.md b/packages/blocks/cta-section/CHANGELOG.md index 6605af959..936300d07 100644 --- a/packages/blocks/cta-section/CHANGELOG.md +++ b/packages/blocks/cta-section/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.cta-section +## 0.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 0.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 0.6.0 ### Minor Changes diff --git a/packages/blocks/cta-section/package.json b/packages/blocks/cta-section/package.json index c367bc270..7ca390827 100644 --- a/packages/blocks/cta-section/package.json +++ b/packages/blocks/cta-section/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.cta-section", - "version": "0.6.0", + "version": "0.6.2", "private": false, "license": "MIT", "description": "A simple block displaying static content in the form of an CtaSection.", @@ -50,7 +50,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/cta-section/src/api-harmonization/cta-section.controller.ts b/packages/blocks/cta-section/src/api-harmonization/cta-section.controller.ts index 3b6b0a349..4a704dfeb 100644 --- a/packages/blocks/cta-section/src/api-harmonization/cta-section.controller.ts +++ b/packages/blocks/cta-section/src/api-harmonization/cta-section.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class CtaSectionController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getCtaSectionBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetCtaSectionBlockQuery) { + getCtaSectionBlock(@Headers() headers: AppHeaders, @Query() query: GetCtaSectionBlockQuery) { return this.service.getCtaSectionBlock(query, headers); } } diff --git a/packages/blocks/cta-section/src/api-harmonization/cta-section.model.ts b/packages/blocks/cta-section/src/api-harmonization/cta-section.model.ts index d75a20280..25cf0db49 100644 --- a/packages/blocks/cta-section/src/api-harmonization/cta-section.model.ts +++ b/packages/blocks/cta-section/src/api-harmonization/cta-section.model.ts @@ -1,8 +1,8 @@ import { CMS } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -export class CtaSectionBlock extends Models.Block.Block { +export class CtaSectionBlock extends ApiModels.Block.Block { __typename!: 'CtaSectionBlock'; preTitle?: CMS.Model.CtaSectionBlock.CtaSectionBlock['preTitle']; title!: CMS.Model.CtaSectionBlock.CtaSectionBlock['title']; diff --git a/packages/blocks/cta-section/src/api-harmonization/cta-section.service.ts b/packages/blocks/cta-section/src/api-harmonization/cta-section.service.ts index db434a4ae..0d545bd24 100644 --- a/packages/blocks/cta-section/src/api-harmonization/cta-section.service.ts +++ b/packages/blocks/cta-section/src/api-harmonization/cta-section.service.ts @@ -2,22 +2,21 @@ import { Injectable } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapCtaSection } from './cta-section.mapper'; import { CtaSectionBlock } from './cta-section.model'; import { GetCtaSectionBlockQuery } from './cta-section.request'; +const H = HeaderName; + @Injectable() export class CtaSectionService { constructor(private readonly cmsService: CMS.Service) {} - getCtaSectionBlock( - query: GetCtaSectionBlockQuery, - headers: Models.Headers.AppHeaders, - ): Observable<CtaSectionBlock> { - const cms = this.cmsService.getCtaSectionBlock({ ...query, locale: headers['x-locale'] }); + getCtaSectionBlock(query: GetCtaSectionBlockQuery, headers: AppHeaders): Observable<CtaSectionBlock> { + const cms = this.cmsService.getCtaSectionBlock({ ...query, locale: headers[H.Locale] }); - return forkJoin([cms]).pipe(map(([cms]) => mapCtaSection(cms, headers['x-locale']))); + return forkJoin([cms]).pipe(map(([cms]) => mapCtaSection(cms, headers[H.Locale]))); } } diff --git a/packages/blocks/cta-section/src/frontend/CtaSection.types.ts b/packages/blocks/cta-section/src/frontend/CtaSection.types.ts index e88c22c22..d615b6d60 100644 --- a/packages/blocks/cta-section/src/frontend/CtaSection.types.ts +++ b/packages/blocks/cta-section/src/frontend/CtaSection.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/cta-section.client'; -export interface CtaSectionProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type CtaSectionProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type CtaSectionPureProps = CtaSectionProps & Model.CtaSectionBlock; -export type CtaSectionRendererProps = Omit<CtaSectionProps, ''> & { - slug: string[]; -}; +export type CtaSectionRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/cta-section/src/sdk/cta-section.ts b/packages/blocks/cta-section/src/sdk/cta-section.ts index 79d1d98b8..6e9cc4c58 100644 --- a/packages/blocks/cta-section/src/sdk/cta-section.ts +++ b/packages/blocks/cta-section/src/sdk/cta-section.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/cta-section.client'; @@ -12,7 +12,7 @@ export const ctaSection = (sdk: Sdk) => ({ blocks: { getCtaSection: ( query: Request.GetCtaSectionBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.CtaSectionBlock> => sdk.makeRequest({ diff --git a/packages/blocks/cta-section/src/sdk/index.ts b/packages/blocks/cta-section/src/sdk/index.ts index 86d4d8379..2700cb70e 100644 --- a/packages/blocks/cta-section/src/sdk/index.ts +++ b/packages/blocks/cta-section/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { ctaSection } from './cta-section'; diff --git a/packages/blocks/document-list/CHANGELOG.md b/packages/blocks/document-list/CHANGELOG.md index c060aea66..b2993516d 100644 --- a/packages/blocks/document-list/CHANGELOG.md +++ b/packages/blocks/document-list/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.document-list +## 0.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 0.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 0.6.0 ### Minor Changes diff --git a/packages/blocks/document-list/package.json b/packages/blocks/document-list/package.json index f411f659c..79bdf755a 100644 --- a/packages/blocks/document-list/package.json +++ b/packages/blocks/document-list/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.document-list", - "version": "0.6.0", + "version": "0.6.2", "private": false, "license": "MIT", "description": "A simple block displaying static content in the form of an DocumentList.", @@ -50,7 +50,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/document-list/src/api-harmonization/document-list.controller.ts b/packages/blocks/document-list/src/api-harmonization/document-list.controller.ts index 19dd8d09d..036edddf9 100644 --- a/packages/blocks/document-list/src/api-harmonization/document-list.controller.ts +++ b/packages/blocks/document-list/src/api-harmonization/document-list.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class DocumentListController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getDocumentListBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetDocumentListBlockQuery) { + getDocumentListBlock(@Headers() headers: AppHeaders, @Query() query: GetDocumentListBlockQuery) { return this.service.getDocumentListBlock(query, headers); } } diff --git a/packages/blocks/document-list/src/api-harmonization/document-list.model.ts b/packages/blocks/document-list/src/api-harmonization/document-list.model.ts index 37c19a968..84e8f28b5 100644 --- a/packages/blocks/document-list/src/api-harmonization/document-list.model.ts +++ b/packages/blocks/document-list/src/api-harmonization/document-list.model.ts @@ -1,8 +1,8 @@ import { CMS } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -export class DocumentListBlock extends Models.Block.Block { +export class DocumentListBlock extends ApiModels.Block.Block { __typename!: 'DocumentListBlock'; title!: CMS.Model.DocumentListBlock.DocumentListBlock['title']; description?: CMS.Model.DocumentListBlock.DocumentListBlock['description']; diff --git a/packages/blocks/document-list/src/api-harmonization/document-list.service.ts b/packages/blocks/document-list/src/api-harmonization/document-list.service.ts index 38dfdcd09..f86373eb5 100644 --- a/packages/blocks/document-list/src/api-harmonization/document-list.service.ts +++ b/packages/blocks/document-list/src/api-harmonization/document-list.service.ts @@ -2,22 +2,21 @@ import { Injectable } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapDocumentList } from './document-list.mapper'; import { DocumentListBlock } from './document-list.model'; import { GetDocumentListBlockQuery } from './document-list.request'; +const H = HeaderName; + @Injectable() export class DocumentListService { constructor(private readonly cmsService: CMS.Service) {} - getDocumentListBlock( - query: GetDocumentListBlockQuery, - headers: Models.Headers.AppHeaders, - ): Observable<DocumentListBlock> { - const cms = this.cmsService.getDocumentListBlock({ ...query, locale: headers['x-locale'] }); + getDocumentListBlock(query: GetDocumentListBlockQuery, headers: AppHeaders): Observable<DocumentListBlock> { + const cms = this.cmsService.getDocumentListBlock({ ...query, locale: headers[H.Locale] }); - return forkJoin([cms]).pipe(map(([cms]) => mapDocumentList(cms, headers['x-locale']))); + return forkJoin([cms]).pipe(map(([cms]) => mapDocumentList(cms, headers[H.Locale]))); } } diff --git a/packages/blocks/document-list/src/frontend/DocumentList.types.ts b/packages/blocks/document-list/src/frontend/DocumentList.types.ts index 3e8a92b63..5a470f0a3 100644 --- a/packages/blocks/document-list/src/frontend/DocumentList.types.ts +++ b/packages/blocks/document-list/src/frontend/DocumentList.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/document-list.client'; -export interface DocumentListProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type DocumentListProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type DocumentListPureProps = DocumentListProps & Model.DocumentListBlock; -export type DocumentListRendererProps = Omit<DocumentListProps, ''> & { - slug: string[]; -}; +export type DocumentListRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/document-list/src/sdk/document-list.ts b/packages/blocks/document-list/src/sdk/document-list.ts index d506a2641..e9df31bf2 100644 --- a/packages/blocks/document-list/src/sdk/document-list.ts +++ b/packages/blocks/document-list/src/sdk/document-list.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/document-list.client'; @@ -12,7 +12,7 @@ export const documentList = (sdk: Sdk) => ({ blocks: { getDocumentList: ( query: Request.GetDocumentListBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.DocumentListBlock> => sdk.makeRequest({ diff --git a/packages/blocks/document-list/src/sdk/index.ts b/packages/blocks/document-list/src/sdk/index.ts index c5c9ecf57..e40804475 100644 --- a/packages/blocks/document-list/src/sdk/index.ts +++ b/packages/blocks/document-list/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { documentList } from './document-list'; diff --git a/packages/blocks/faq/CHANGELOG.md b/packages/blocks/faq/CHANGELOG.md index f00cd0b0a..bd26bdb0f 100644 --- a/packages/blocks/faq/CHANGELOG.md +++ b/packages/blocks/faq/CHANGELOG.md @@ -1,5 +1,46 @@ # @o2s/blocks.faq +## 1.5.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- fadbc63: Align renderer prop types with runtime usage across blocks. + + Restore missing `isDraftModeEnabled` and `userId` coverage in renderer prop contracts and rename the misnamed notification details renderer prop type for consistency. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.5.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.5.0 ### Minor Changes diff --git a/packages/blocks/faq/package.json b/packages/blocks/faq/package.json index d72e2749f..c17b04c7d 100644 --- a/packages/blocks/faq/package.json +++ b/packages/blocks/faq/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.faq", - "version": "1.5.0", + "version": "1.5.2", "private": false, "license": "MIT", "description": "A simple block displaying static content in the form of an FAQ.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/faq/src/api-harmonization/faq.controller.ts b/packages/blocks/faq/src/api-harmonization/faq.controller.ts index 021415997..95de91196 100644 --- a/packages/blocks/faq/src/api-harmonization/faq.controller.ts +++ b/packages/blocks/faq/src/api-harmonization/faq.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class FaqController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getFaqBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetFaqBlockQuery) { + getFaqBlock(@Headers() headers: AppHeaders, @Query() query: GetFaqBlockQuery) { return this.service.getFaqBlock(query, headers); } } diff --git a/packages/blocks/faq/src/api-harmonization/faq.model.ts b/packages/blocks/faq/src/api-harmonization/faq.model.ts index b20fb9144..82031fa14 100644 --- a/packages/blocks/faq/src/api-harmonization/faq.model.ts +++ b/packages/blocks/faq/src/api-harmonization/faq.model.ts @@ -1,12 +1,12 @@ import { CMS } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -export class FaqBlock extends Models.Block.Block { +export class FaqBlock extends ApiModels.Block.Block { __typename!: 'FaqBlock'; title!: CMS.Model.FaqBlock.FaqBlock['title']; subtitle?: CMS.Model.FaqBlock.FaqBlock['subtitle']; items!: CMS.Model.FaqBlock.FaqBlock['items']; banner?: CMS.Model.FaqBlock.FaqBoxWithButton; - meta?: CMS.Model.FaqBlock.FaqBlock['meta']; + declare meta?: CMS.Model.FaqBlock.FaqBlock['meta']; } diff --git a/packages/blocks/faq/src/api-harmonization/faq.service.ts b/packages/blocks/faq/src/api-harmonization/faq.service.ts index 08131a299..5cdf4c394 100644 --- a/packages/blocks/faq/src/api-harmonization/faq.service.ts +++ b/packages/blocks/faq/src/api-harmonization/faq.service.ts @@ -2,20 +2,22 @@ import { Injectable } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapFaq } from './faq.mapper'; import { FaqBlock } from './faq.model'; import { GetFaqBlockQuery } from './faq.request'; +const H = HeaderName; + @Injectable() export class FaqService { constructor(private readonly cmsService: CMS.Service) {} - getFaqBlock(query: GetFaqBlockQuery, headers: Models.Headers.AppHeaders): Observable<FaqBlock> { + getFaqBlock(query: GetFaqBlockQuery, headers: AppHeaders): Observable<FaqBlock> { const cms = this.cmsService.getFaqBlock({ ...query, - locale: headers['x-locale'], + locale: headers[H.Locale], }); return forkJoin([cms]).pipe(map(([cms]) => mapFaq(cms))); diff --git a/packages/blocks/faq/src/frontend/Faq.types.ts b/packages/blocks/faq/src/frontend/Faq.types.ts index 378f32c4d..88d39193e 100644 --- a/packages/blocks/faq/src/frontend/Faq.types.ts +++ b/packages/blocks/faq/src/frontend/Faq.types.ts @@ -1,18 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/faq.client'; -export interface FaqProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; - isDraftModeEnabled?: boolean; -} +export type FaqProps = Models.BlockProps.BlockWithDraftModeProps<ReturnType<typeof defineRouting>>; export type FaqPureProps = FaqProps & Model.FaqBlock; -export type FaqRendererProps = Omit<FaqProps, ''> & { - slug: string[]; -}; +export type FaqRendererProps = Models.BlockProps.BlockWithDraftModeProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/faq/src/sdk/faq.ts b/packages/blocks/faq/src/sdk/faq.ts index 667fc6323..8ab0d3d68 100644 --- a/packages/blocks/faq/src/sdk/faq.ts +++ b/packages/blocks/faq/src/sdk/faq.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/faq.client'; @@ -12,7 +12,7 @@ export const faq = (sdk: Sdk) => ({ blocks: { getFaq: ( query: Request.GetFaqBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.FaqBlock> => sdk.makeRequest({ diff --git a/packages/blocks/faq/src/sdk/index.ts b/packages/blocks/faq/src/sdk/index.ts index 08b6ee067..df0f2f0b0 100644 --- a/packages/blocks/faq/src/sdk/index.ts +++ b/packages/blocks/faq/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { faq } from './faq'; diff --git a/packages/blocks/feature-section-grid/CHANGELOG.md b/packages/blocks/feature-section-grid/CHANGELOG.md index 781cc3026..3e0b02cfe 100644 --- a/packages/blocks/feature-section-grid/CHANGELOG.md +++ b/packages/blocks/feature-section-grid/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.feature-section-grid +## 0.5.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 0.5.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 0.5.0 ### Minor Changes diff --git a/packages/blocks/feature-section-grid/package.json b/packages/blocks/feature-section-grid/package.json index 0205eb221..d4f8fd7e7 100644 --- a/packages/blocks/feature-section-grid/package.json +++ b/packages/blocks/feature-section-grid/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.feature-section-grid", - "version": "0.5.0", + "version": "0.5.2", "private": false, "license": "MIT", "description": "A simple block displaying static content in the form of an FeatureSectionGrid.", @@ -50,7 +50,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/feature-section-grid/src/api-harmonization/feature-section-grid.controller.ts b/packages/blocks/feature-section-grid/src/api-harmonization/feature-section-grid.controller.ts index 4d7f6e96b..71addabf2 100644 --- a/packages/blocks/feature-section-grid/src/api-harmonization/feature-section-grid.controller.ts +++ b/packages/blocks/feature-section-grid/src/api-harmonization/feature-section-grid.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,10 +16,7 @@ export class FeatureSectionGridController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getFeatureSectionGridBlock( - @Headers() headers: Models.Headers.AppHeaders, - @Query() query: GetFeatureSectionGridBlockQuery, - ) { + getFeatureSectionGridBlock(@Headers() headers: AppHeaders, @Query() query: GetFeatureSectionGridBlockQuery) { return this.service.getFeatureSectionGridBlock(query, headers); } } diff --git a/packages/blocks/feature-section-grid/src/api-harmonization/feature-section-grid.model.ts b/packages/blocks/feature-section-grid/src/api-harmonization/feature-section-grid.model.ts index 18643d102..5509c6234 100644 --- a/packages/blocks/feature-section-grid/src/api-harmonization/feature-section-grid.model.ts +++ b/packages/blocks/feature-section-grid/src/api-harmonization/feature-section-grid.model.ts @@ -1,8 +1,8 @@ import { CMS } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -export class FeatureSectionGridBlock extends Models.Block.Block { +export class FeatureSectionGridBlock extends ApiModels.Block.Block { __typename!: 'FeatureSectionGridBlock'; preTitle?: CMS.Model.FeatureSectionGridBlock.FeatureSectionGridBlock['preTitle']; title!: CMS.Model.FeatureSectionGridBlock.FeatureSectionGridBlock['title']; diff --git a/packages/blocks/feature-section-grid/src/api-harmonization/feature-section-grid.service.ts b/packages/blocks/feature-section-grid/src/api-harmonization/feature-section-grid.service.ts index 0dce11224..ee5ec6879 100644 --- a/packages/blocks/feature-section-grid/src/api-harmonization/feature-section-grid.service.ts +++ b/packages/blocks/feature-section-grid/src/api-harmonization/feature-section-grid.service.ts @@ -2,22 +2,24 @@ import { Injectable } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapFeatureSectionGrid } from './feature-section-grid.mapper'; import { FeatureSectionGridBlock } from './feature-section-grid.model'; import { GetFeatureSectionGridBlockQuery } from './feature-section-grid.request'; +const H = HeaderName; + @Injectable() export class FeatureSectionGridService { constructor(private readonly cmsService: CMS.Service) {} getFeatureSectionGridBlock( query: GetFeatureSectionGridBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, ): Observable<FeatureSectionGridBlock> { - const cms = this.cmsService.getFeatureSectionGridBlock({ ...query, locale: headers['x-locale'] }); + const cms = this.cmsService.getFeatureSectionGridBlock({ ...query, locale: headers[H.Locale] }); - return forkJoin([cms]).pipe(map(([cms]) => mapFeatureSectionGrid(cms, headers['x-locale']))); + return forkJoin([cms]).pipe(map(([cms]) => mapFeatureSectionGrid(cms, headers[H.Locale]))); } } diff --git a/packages/blocks/feature-section-grid/src/frontend/FeatureSectionGrid.types.ts b/packages/blocks/feature-section-grid/src/frontend/FeatureSectionGrid.types.ts index 544eda2ac..b32e60c52 100644 --- a/packages/blocks/feature-section-grid/src/frontend/FeatureSectionGrid.types.ts +++ b/packages/blocks/feature-section-grid/src/frontend/FeatureSectionGrid.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/feature-section-grid.client'; -export interface FeatureSectionGridProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type FeatureSectionGridProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type FeatureSectionGridPureProps = FeatureSectionGridProps & Model.FeatureSectionGridBlock; -export type FeatureSectionGridRendererProps = Omit<FeatureSectionGridProps, ''> & { - slug: string[]; -}; +export type FeatureSectionGridRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/feature-section-grid/src/sdk/feature-section-grid.ts b/packages/blocks/feature-section-grid/src/sdk/feature-section-grid.ts index 8de28fdf3..8e0d27239 100644 --- a/packages/blocks/feature-section-grid/src/sdk/feature-section-grid.ts +++ b/packages/blocks/feature-section-grid/src/sdk/feature-section-grid.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/feature-section-grid.client'; @@ -12,7 +12,7 @@ export const featureSectionGrid = (sdk: Sdk) => ({ blocks: { getFeatureSectionGrid: ( query: Request.GetFeatureSectionGridBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.FeatureSectionGridBlock> => sdk.makeRequest({ diff --git a/packages/blocks/feature-section-grid/src/sdk/index.ts b/packages/blocks/feature-section-grid/src/sdk/index.ts index 8d7dadd4c..bc3072cb4 100644 --- a/packages/blocks/feature-section-grid/src/sdk/index.ts +++ b/packages/blocks/feature-section-grid/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { featureSectionGrid } from './feature-section-grid'; diff --git a/packages/blocks/feature-section/CHANGELOG.md b/packages/blocks/feature-section/CHANGELOG.md index 162cfdce3..fc81f296f 100644 --- a/packages/blocks/feature-section/CHANGELOG.md +++ b/packages/blocks/feature-section/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.feature-section +## 0.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 0.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 0.6.0 ### Minor Changes diff --git a/packages/blocks/feature-section/package.json b/packages/blocks/feature-section/package.json index 97fc238a5..a80dc9703 100644 --- a/packages/blocks/feature-section/package.json +++ b/packages/blocks/feature-section/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.feature-section", - "version": "0.6.0", + "version": "0.6.2", "private": false, "license": "MIT", "description": "A simple block displaying static content in the form of an FeatureSection.", @@ -50,7 +50,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/feature-section/src/api-harmonization/feature-section.controller.ts b/packages/blocks/feature-section/src/api-harmonization/feature-section.controller.ts index 3acb72623..78c334b9d 100644 --- a/packages/blocks/feature-section/src/api-harmonization/feature-section.controller.ts +++ b/packages/blocks/feature-section/src/api-harmonization/feature-section.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class FeatureSectionController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getFeatureSectionBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetFeatureSectionBlockQuery) { + getFeatureSectionBlock(@Headers() headers: AppHeaders, @Query() query: GetFeatureSectionBlockQuery) { return this.service.getFeatureSectionBlock(query, headers); } } diff --git a/packages/blocks/feature-section/src/api-harmonization/feature-section.model.ts b/packages/blocks/feature-section/src/api-harmonization/feature-section.model.ts index ae4b64391..073a8bd78 100644 --- a/packages/blocks/feature-section/src/api-harmonization/feature-section.model.ts +++ b/packages/blocks/feature-section/src/api-harmonization/feature-section.model.ts @@ -1,8 +1,8 @@ import { CMS } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -export class FeatureSectionBlock extends Models.Block.Block { +export class FeatureSectionBlock extends ApiModels.Block.Block { __typename!: 'FeatureSectionBlock'; preTitle?: CMS.Model.FeatureSectionBlock.FeatureSectionBlock['preTitle']; title!: CMS.Model.FeatureSectionBlock.FeatureSectionBlock['title']; diff --git a/packages/blocks/feature-section/src/api-harmonization/feature-section.service.ts b/packages/blocks/feature-section/src/api-harmonization/feature-section.service.ts index f5daaa6b3..ecd3254df 100644 --- a/packages/blocks/feature-section/src/api-harmonization/feature-section.service.ts +++ b/packages/blocks/feature-section/src/api-harmonization/feature-section.service.ts @@ -2,22 +2,21 @@ import { Injectable } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapFeatureSection } from './feature-section.mapper'; import { FeatureSectionBlock } from './feature-section.model'; import { GetFeatureSectionBlockQuery } from './feature-section.request'; +const H = HeaderName; + @Injectable() export class FeatureSectionService { constructor(private readonly cmsService: CMS.Service) {} - getFeatureSectionBlock( - query: GetFeatureSectionBlockQuery, - headers: Models.Headers.AppHeaders, - ): Observable<FeatureSectionBlock> { - const cms = this.cmsService.getFeatureSectionBlock({ ...query, locale: headers['x-locale'] }); + getFeatureSectionBlock(query: GetFeatureSectionBlockQuery, headers: AppHeaders): Observable<FeatureSectionBlock> { + const cms = this.cmsService.getFeatureSectionBlock({ ...query, locale: headers[H.Locale] }); - return forkJoin([cms]).pipe(map(([cms]) => mapFeatureSection(cms, headers['x-locale']))); + return forkJoin([cms]).pipe(map(([cms]) => mapFeatureSection(cms, headers[H.Locale]))); } } diff --git a/packages/blocks/feature-section/src/frontend/FeatureSection.types.ts b/packages/blocks/feature-section/src/frontend/FeatureSection.types.ts index 16fe75f9b..1c93f1586 100644 --- a/packages/blocks/feature-section/src/frontend/FeatureSection.types.ts +++ b/packages/blocks/feature-section/src/frontend/FeatureSection.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/feature-section.client'; -export interface FeatureSectionProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type FeatureSectionProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type FeatureSectionPureProps = FeatureSectionProps & Model.FeatureSectionBlock; -export type FeatureSectionRendererProps = Omit<FeatureSectionProps, ''> & { - slug: string[]; -}; +export type FeatureSectionRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/feature-section/src/sdk/feature-section.ts b/packages/blocks/feature-section/src/sdk/feature-section.ts index b34703c9c..998a2173b 100644 --- a/packages/blocks/feature-section/src/sdk/feature-section.ts +++ b/packages/blocks/feature-section/src/sdk/feature-section.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/feature-section.client'; @@ -12,7 +12,7 @@ export const featureSection = (sdk: Sdk) => ({ blocks: { getFeatureSection: ( query: Request.GetFeatureSectionBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.FeatureSectionBlock> => sdk.makeRequest({ diff --git a/packages/blocks/feature-section/src/sdk/index.ts b/packages/blocks/feature-section/src/sdk/index.ts index 0e347b1a7..a2f4c3fce 100644 --- a/packages/blocks/feature-section/src/sdk/index.ts +++ b/packages/blocks/feature-section/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { featureSection } from './feature-section'; diff --git a/packages/blocks/featured-service-list/CHANGELOG.md b/packages/blocks/featured-service-list/CHANGELOG.md index d771d375c..7c8119f88 100644 --- a/packages/blocks/featured-service-list/CHANGELOG.md +++ b/packages/blocks/featured-service-list/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.featured-service-list +## 1.4.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.4.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.4.0 ### Minor Changes diff --git a/packages/blocks/featured-service-list/package.json b/packages/blocks/featured-service-list/package.json index 12ab955d9..971c3960f 100644 --- a/packages/blocks/featured-service-list/package.json +++ b/packages/blocks/featured-service-list/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.featured-service-list", - "version": "1.4.0", + "version": "1.4.2", "private": false, "license": "MIT", "description": "A block displaying a list of featured services.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/featured-service-list/src/api-harmonization/featured-service-list.controller.ts b/packages/blocks/featured-service-list/src/api-harmonization/featured-service-list.controller.ts index 996e8f152..a44fc48d2 100644 --- a/packages/blocks/featured-service-list/src/api-harmonization/featured-service-list.controller.ts +++ b/packages/blocks/featured-service-list/src/api-harmonization/featured-service-list.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,10 +16,7 @@ export class FeaturedServiceListController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getFeaturedServiceListBlock( - @Headers() headers: ApiModels.Headers.AppHeaders, - @Query() query: GetFeaturedServiceListBlockQuery, - ) { + getFeaturedServiceListBlock(@Headers() headers: AppHeaders, @Query() query: GetFeaturedServiceListBlockQuery) { return this.service.getFeaturedServiceListBlock(query, headers); } } diff --git a/packages/blocks/featured-service-list/src/api-harmonization/featured-service-list.model.ts b/packages/blocks/featured-service-list/src/api-harmonization/featured-service-list.model.ts index ba58f71bf..f1ebef10b 100644 --- a/packages/blocks/featured-service-list/src/api-harmonization/featured-service-list.model.ts +++ b/packages/blocks/featured-service-list/src/api-harmonization/featured-service-list.model.ts @@ -1,6 +1,8 @@ +import { Products } from '@o2s/configs.integrations'; + import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -import { Models, Products } from '@o2s/framework/modules'; +import { Models } from '@o2s/framework/modules'; export class FeaturedServiceListBlock extends ApiModels.Block.Block { __typename!: 'FeaturedServiceListBlock'; diff --git a/packages/blocks/featured-service-list/src/api-harmonization/featured-service-list.service.ts b/packages/blocks/featured-service-list/src/api-harmonization/featured-service-list.service.ts index 3b8445c31..21ff587ff 100644 --- a/packages/blocks/featured-service-list/src/api-harmonization/featured-service-list.service.ts +++ b/packages/blocks/featured-service-list/src/api-harmonization/featured-service-list.service.ts @@ -2,12 +2,14 @@ import { Injectable } from '@nestjs/common'; import { CMS, Resources } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapFeaturedServiceList } from './featured-service-list.mapper'; import { FeaturedServiceListBlock } from './featured-service-list.model'; import { GetFeaturedServiceListBlockQuery } from './featured-service-list.request'; +const H = HeaderName; + @Injectable() export class FeaturedServiceListService { constructor( @@ -17,9 +19,9 @@ export class FeaturedServiceListService { getFeaturedServiceListBlock( query: GetFeaturedServiceListBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, ): Observable<FeaturedServiceListBlock> { - const cms = this.cmsService.getFeaturedServiceListBlock({ ...query, locale: headers['x-locale'] }); + const cms = this.cmsService.getFeaturedServiceListBlock({ ...query, locale: headers[H.Locale] }); const featuredServices = this.resourceService.getFeaturedServiceList(); return forkJoin([cms, featuredServices]).pipe( diff --git a/packages/blocks/featured-service-list/src/frontend/FeaturedServiceList.client.tsx b/packages/blocks/featured-service-list/src/frontend/FeaturedServiceList.client.tsx index b5c8e66c9..a8e73ec43 100644 --- a/packages/blocks/featured-service-list/src/frontend/FeaturedServiceList.client.tsx +++ b/packages/blocks/featured-service-list/src/frontend/FeaturedServiceList.client.tsx @@ -37,10 +37,7 @@ export const FeaturedServiceListPure: React.FC<FeaturedServiceListPureProps> = ( description={service.shortDescription} image={service.image} price={service.price} - link={{ - label: component.detailsLabel, - url: service.link, - }} + link={service.link} action={ <TooltipHover trigger={(setIsOpen) => ( diff --git a/packages/blocks/featured-service-list/src/frontend/FeaturedServiceList.types.ts b/packages/blocks/featured-service-list/src/frontend/FeaturedServiceList.types.ts index 10a79ebc7..8a490547b 100644 --- a/packages/blocks/featured-service-list/src/frontend/FeaturedServiceList.types.ts +++ b/packages/blocks/featured-service-list/src/frontend/FeaturedServiceList.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/featured-service-list.client'; -export interface FeaturedServiceListProps { - id: string; - locale: string; - accessToken?: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type FeaturedServiceListProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type FeaturedServiceListPureProps = FeaturedServiceListProps & Model.FeaturedServiceListBlock; -export interface FeaturedServiceListRendererProps extends Omit<FeaturedServiceListProps, ''> { - slug: string[]; -} +export type FeaturedServiceListRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/featured-service-list/src/sdk/featured-service-list.ts b/packages/blocks/featured-service-list/src/sdk/featured-service-list.ts index 5a1f4fff5..120af27e7 100644 --- a/packages/blocks/featured-service-list/src/sdk/featured-service-list.ts +++ b/packages/blocks/featured-service-list/src/sdk/featured-service-list.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request, URL } from '../api-harmonization/featured-service-list.client'; @@ -11,7 +11,7 @@ export const featuredServiceList = (sdk: Sdk) => ({ blocks: { getFeaturedServiceList: ( query: Request.GetFeaturedServiceListBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.FeaturedServiceListBlock> => sdk.makeRequest({ diff --git a/packages/blocks/featured-service-list/src/sdk/index.ts b/packages/blocks/featured-service-list/src/sdk/index.ts index b458826ed..033d00516 100644 --- a/packages/blocks/featured-service-list/src/sdk/index.ts +++ b/packages/blocks/featured-service-list/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { featuredServiceList } from './featured-service-list'; diff --git a/packages/blocks/hero-section/CHANGELOG.md b/packages/blocks/hero-section/CHANGELOG.md index 99cee8b2e..a4d94a4c6 100644 --- a/packages/blocks/hero-section/CHANGELOG.md +++ b/packages/blocks/hero-section/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.hero-section +## 0.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 0.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 0.6.0 ### Minor Changes diff --git a/packages/blocks/hero-section/package.json b/packages/blocks/hero-section/package.json index 9f9f3505d..a45cd1e3c 100644 --- a/packages/blocks/hero-section/package.json +++ b/packages/blocks/hero-section/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.hero-section", - "version": "0.6.0", + "version": "0.6.2", "private": false, "license": "MIT", "description": "A simple block displaying static content in the form of an HeroSection.", @@ -50,7 +50,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/hero-section/src/api-harmonization/hero-section.controller.ts b/packages/blocks/hero-section/src/api-harmonization/hero-section.controller.ts index 01a9e249d..e83d06df2 100644 --- a/packages/blocks/hero-section/src/api-harmonization/hero-section.controller.ts +++ b/packages/blocks/hero-section/src/api-harmonization/hero-section.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class HeroSectionController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getHeroSectionBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetHeroSectionBlockQuery) { + getHeroSectionBlock(@Headers() headers: AppHeaders, @Query() query: GetHeroSectionBlockQuery) { return this.service.getHeroSectionBlock(query, headers); } } diff --git a/packages/blocks/hero-section/src/api-harmonization/hero-section.model.ts b/packages/blocks/hero-section/src/api-harmonization/hero-section.model.ts index 22096a1be..a2070109f 100644 --- a/packages/blocks/hero-section/src/api-harmonization/hero-section.model.ts +++ b/packages/blocks/hero-section/src/api-harmonization/hero-section.model.ts @@ -1,8 +1,8 @@ import { CMS } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -export class HeroSectionBlock extends Models.Block.Block { +export class HeroSectionBlock extends ApiModels.Block.Block { __typename!: 'HeroSectionBlock'; preTitle?: CMS.Model.HeroSectionBlock.HeroSectionBlock['preTitle']; title!: CMS.Model.HeroSectionBlock.HeroSectionBlock['title']; diff --git a/packages/blocks/hero-section/src/api-harmonization/hero-section.service.ts b/packages/blocks/hero-section/src/api-harmonization/hero-section.service.ts index 9d844f61e..e3d371314 100644 --- a/packages/blocks/hero-section/src/api-harmonization/hero-section.service.ts +++ b/packages/blocks/hero-section/src/api-harmonization/hero-section.service.ts @@ -2,22 +2,21 @@ import { Injectable } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapHeroSection } from './hero-section.mapper'; import { HeroSectionBlock } from './hero-section.model'; import { GetHeroSectionBlockQuery } from './hero-section.request'; +const H = HeaderName; + @Injectable() export class HeroSectionService { constructor(private readonly cmsService: CMS.Service) {} - getHeroSectionBlock( - query: GetHeroSectionBlockQuery, - headers: Models.Headers.AppHeaders, - ): Observable<HeroSectionBlock> { - const cms = this.cmsService.getHeroSectionBlock({ ...query, locale: headers['x-locale'] }); + getHeroSectionBlock(query: GetHeroSectionBlockQuery, headers: AppHeaders): Observable<HeroSectionBlock> { + const cms = this.cmsService.getHeroSectionBlock({ ...query, locale: headers[H.Locale] }); - return forkJoin([cms]).pipe(map(([cms]) => mapHeroSection(cms, headers['x-locale']))); + return forkJoin([cms]).pipe(map(([cms]) => mapHeroSection(cms, headers[H.Locale]))); } } diff --git a/packages/blocks/hero-section/src/frontend/HeroSection.types.ts b/packages/blocks/hero-section/src/frontend/HeroSection.types.ts index cb18ec850..70b17e267 100644 --- a/packages/blocks/hero-section/src/frontend/HeroSection.types.ts +++ b/packages/blocks/hero-section/src/frontend/HeroSection.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/hero-section.client'; -export interface HeroSectionProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type HeroSectionProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type HeroSectionPureProps = HeroSectionProps & Model.HeroSectionBlock; -export type HeroSectionRendererProps = Omit<HeroSectionProps, ''> & { - slug: string[]; -}; +export type HeroSectionRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/hero-section/src/sdk/hero-section.ts b/packages/blocks/hero-section/src/sdk/hero-section.ts index 493118485..6ecba2495 100644 --- a/packages/blocks/hero-section/src/sdk/hero-section.ts +++ b/packages/blocks/hero-section/src/sdk/hero-section.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/hero-section.client'; @@ -12,7 +12,7 @@ export const heroSection = (sdk: Sdk) => ({ blocks: { getHeroSection: ( query: Request.GetHeroSectionBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.HeroSectionBlock> => sdk.makeRequest({ diff --git a/packages/blocks/hero-section/src/sdk/index.ts b/packages/blocks/hero-section/src/sdk/index.ts index a515fb6f9..533072b37 100644 --- a/packages/blocks/hero-section/src/sdk/index.ts +++ b/packages/blocks/hero-section/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { heroSection } from './hero-section'; diff --git a/packages/blocks/invoice-list/CHANGELOG.md b/packages/blocks/invoice-list/CHANGELOG.md index 1cb62cdcd..d747eecdc 100644 --- a/packages/blocks/invoice-list/CHANGELOG.md +++ b/packages/blocks/invoice-list/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.invoice-list +## 1.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.6.0 ### Minor Changes diff --git a/packages/blocks/invoice-list/package.json b/packages/blocks/invoice-list/package.json index e2dfed19f..e548f5297 100644 --- a/packages/blocks/invoice-list/package.json +++ b/packages/blocks/invoice-list/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.invoice-list", - "version": "1.6.0", + "version": "1.6.2", "private": false, "license": "MIT", "description": "A block for displaying and managing invoice list.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/invoice-list/src/api-harmonization/invoice-list.controller.ts b/packages/blocks/invoice-list/src/api-harmonization/invoice-list.controller.ts index 43b41bd7a..aab62e70f 100644 --- a/packages/blocks/invoice-list/src/api-harmonization/invoice-list.controller.ts +++ b/packages/blocks/invoice-list/src/api-harmonization/invoice-list.controller.ts @@ -2,9 +2,9 @@ import { Controller, Get, Headers, Param, Query, Res, UseInterceptors } from '@n import type { Response } from 'express'; import { Observable, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -18,7 +18,7 @@ export class InvoiceListController { @Get() @Auth.Decorators.Permissions({ resource: 'invoices', actions: ['view'] }) - getInvoiceListBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetInvoiceListBlockQuery) { + getInvoiceListBlock(@Headers() headers: AppHeaders, @Query() query: GetInvoiceListBlockQuery) { return this.service.getInvoiceListBlock(query, headers); } diff --git a/packages/blocks/invoice-list/src/api-harmonization/invoice-list.service.ts b/packages/blocks/invoice-list/src/api-harmonization/invoice-list.service.ts index 0df8ca0d8..8ac134b49 100644 --- a/packages/blocks/invoice-list/src/api-harmonization/invoice-list.service.ts +++ b/packages/blocks/invoice-list/src/api-harmonization/invoice-list.service.ts @@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common'; import { CMS, Invoices } from '@o2s/configs.integrations'; import { Observable, concatMap, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapInvoiceList } from './invoice-list.mapper'; import { InvoiceListBlock } from './invoice-list.model'; import { GetInvoiceListBlockQuery } from './invoice-list.request'; +const H = HeaderName; + @Injectable() export class InvoiceListService { constructor( @@ -18,11 +19,8 @@ export class InvoiceListService { private readonly authService: Auth.Service, ) {} - getInvoiceListBlock( - query: GetInvoiceListBlockQuery, - headers: Models.Headers.AppHeaders, - ): Observable<InvoiceListBlock> { - const cms = this.cmsService.getInvoiceListBlock({ ...query, locale: headers['x-locale'] }); + getInvoiceListBlock(query: GetInvoiceListBlockQuery, headers: AppHeaders): Observable<InvoiceListBlock> { + const cms = this.cmsService.getInvoiceListBlock({ ...query, locale: headers[H.Locale] }); return forkJoin([cms]).pipe( concatMap(([cms]) => { @@ -32,24 +30,26 @@ export class InvoiceListService { ...query, limit: query.limit || cms.pagination?.limit || 1, offset: query.offset || 0, - locale: headers['x-locale'], + locale: headers[H.Locale], }) .pipe( map((invoices) => { const result = mapInvoiceList( invoices, cms, - headers['x-locale'], - headers['x-client-timezone'] || '', + headers[H.Locale], + headers[H.ClientTimezone] || '', ); // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions( - headers.authorization, - 'invoices', - ['view', 'create', 'pay', 'delete'], - ); + const authorization = headers[H.Authorization]; + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'invoices', [ + 'view', + 'create', + 'pay', + 'delete', + ]); result.permissions = { view: permissions.view ?? false, diff --git a/packages/blocks/invoice-list/src/frontend/InvoiceList.types.ts b/packages/blocks/invoice-list/src/frontend/InvoiceList.types.ts index 10446b0c9..ebcde5e3a 100644 --- a/packages/blocks/invoice-list/src/frontend/InvoiceList.types.ts +++ b/packages/blocks/invoice-list/src/frontend/InvoiceList.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/invoice-list.client'; -export interface InvoiceListProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type InvoiceListProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type InvoiceListPureProps = InvoiceListProps & Model.InvoiceListBlock; -export type InvoiceListRendererProps = Omit<InvoiceListProps, ''> & { - slug: string[]; -}; +export type InvoiceListRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/invoice-list/src/sdk/index.ts b/packages/blocks/invoice-list/src/sdk/index.ts index b03b5773a..d8fb20422 100644 --- a/packages/blocks/invoice-list/src/sdk/index.ts +++ b/packages/blocks/invoice-list/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { invoiceList } from './invoice-list'; diff --git a/packages/blocks/invoice-list/src/sdk/invoice-list.ts b/packages/blocks/invoice-list/src/sdk/invoice-list.ts index 18639800e..6fba1d0ca 100644 --- a/packages/blocks/invoice-list/src/sdk/invoice-list.ts +++ b/packages/blocks/invoice-list/src/sdk/invoice-list.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/invoice-list.client'; @@ -12,7 +12,7 @@ export const invoiceList = (sdk: Sdk) => ({ blocks: { getInvoiceList: ( query: Request.GetInvoiceListBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.InvoiceListBlock> => sdk.makeRequest({ @@ -29,7 +29,7 @@ export const invoiceList = (sdk: Sdk) => ({ }, params: query, }), - getInvoicePdf: (id: string, headers: Models.Headers.AppHeaders, authorization?: string): Promise<Blob> => + getInvoicePdf: (id: string, headers: AppHeaders, authorization?: string): Promise<Blob> => sdk.makeRequest({ method: 'get', url: `${API_URL}/${id}/pdf`, diff --git a/packages/blocks/media-section/CHANGELOG.md b/packages/blocks/media-section/CHANGELOG.md index f9ca63511..532042716 100644 --- a/packages/blocks/media-section/CHANGELOG.md +++ b/packages/blocks/media-section/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.media-section +## 0.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 0.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 0.6.0 ### Minor Changes diff --git a/packages/blocks/media-section/package.json b/packages/blocks/media-section/package.json index 95127108d..63422e89e 100644 --- a/packages/blocks/media-section/package.json +++ b/packages/blocks/media-section/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.media-section", - "version": "0.6.0", + "version": "0.6.2", "private": false, "license": "MIT", "description": "A simple block displaying static content in the form of an MediaSection.", @@ -50,7 +50,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/media-section/src/api-harmonization/media-section.controller.ts b/packages/blocks/media-section/src/api-harmonization/media-section.controller.ts index 273d6a189..6d604f63a 100644 --- a/packages/blocks/media-section/src/api-harmonization/media-section.controller.ts +++ b/packages/blocks/media-section/src/api-harmonization/media-section.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class MediaSectionController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getMediaSectionBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetMediaSectionBlockQuery) { + getMediaSectionBlock(@Headers() headers: AppHeaders, @Query() query: GetMediaSectionBlockQuery) { return this.service.getMediaSectionBlock(query, headers); } } diff --git a/packages/blocks/media-section/src/api-harmonization/media-section.model.ts b/packages/blocks/media-section/src/api-harmonization/media-section.model.ts index ec97d2adf..640eac4d4 100644 --- a/packages/blocks/media-section/src/api-harmonization/media-section.model.ts +++ b/packages/blocks/media-section/src/api-harmonization/media-section.model.ts @@ -1,8 +1,8 @@ import { CMS } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -export class MediaSectionBlock extends Models.Block.Block { +export class MediaSectionBlock extends ApiModels.Block.Block { __typename!: 'MediaSectionBlock'; preTitle?: CMS.Model.MediaSectionBlock.MediaSectionBlock['preTitle']; title?: CMS.Model.MediaSectionBlock.MediaSectionBlock['title']; diff --git a/packages/blocks/media-section/src/api-harmonization/media-section.service.ts b/packages/blocks/media-section/src/api-harmonization/media-section.service.ts index 9952027ec..73f9f070f 100644 --- a/packages/blocks/media-section/src/api-harmonization/media-section.service.ts +++ b/packages/blocks/media-section/src/api-harmonization/media-section.service.ts @@ -2,22 +2,21 @@ import { Injectable } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapMediaSection } from './media-section.mapper'; import { MediaSectionBlock } from './media-section.model'; import { GetMediaSectionBlockQuery } from './media-section.request'; +const H = HeaderName; + @Injectable() export class MediaSectionService { constructor(private readonly cmsService: CMS.Service) {} - getMediaSectionBlock( - query: GetMediaSectionBlockQuery, - headers: Models.Headers.AppHeaders, - ): Observable<MediaSectionBlock> { - const cms = this.cmsService.getMediaSectionBlock({ ...query, locale: headers['x-locale'] }); + getMediaSectionBlock(query: GetMediaSectionBlockQuery, headers: AppHeaders): Observable<MediaSectionBlock> { + const cms = this.cmsService.getMediaSectionBlock({ ...query, locale: headers[H.Locale] }); - return forkJoin([cms]).pipe(map(([cms]) => mapMediaSection(cms, headers['x-locale']))); + return forkJoin([cms]).pipe(map(([cms]) => mapMediaSection(cms, headers[H.Locale]))); } } diff --git a/packages/blocks/media-section/src/frontend/MediaSection.types.ts b/packages/blocks/media-section/src/frontend/MediaSection.types.ts index d4fb95104..ca3e58ee2 100644 --- a/packages/blocks/media-section/src/frontend/MediaSection.types.ts +++ b/packages/blocks/media-section/src/frontend/MediaSection.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/media-section.client'; -export interface MediaSectionProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type MediaSectionProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type MediaSectionPureProps = MediaSectionProps & Model.MediaSectionBlock; -export type MediaSectionRendererProps = Omit<MediaSectionProps, ''> & { - slug: string[]; -}; +export type MediaSectionRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/media-section/src/sdk/index.ts b/packages/blocks/media-section/src/sdk/index.ts index 47e9b914e..59ca62979 100644 --- a/packages/blocks/media-section/src/sdk/index.ts +++ b/packages/blocks/media-section/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { mediaSection } from './media-section'; diff --git a/packages/blocks/media-section/src/sdk/media-section.ts b/packages/blocks/media-section/src/sdk/media-section.ts index 254005cec..355ecbce5 100644 --- a/packages/blocks/media-section/src/sdk/media-section.ts +++ b/packages/blocks/media-section/src/sdk/media-section.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/media-section.client'; @@ -12,7 +12,7 @@ export const mediaSection = (sdk: Sdk) => ({ blocks: { getMediaSection: ( query: Request.GetMediaSectionBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.MediaSectionBlock> => sdk.makeRequest({ diff --git a/packages/blocks/notification-details/CHANGELOG.md b/packages/blocks/notification-details/CHANGELOG.md index fbf854394..68a19cb49 100644 --- a/packages/blocks/notification-details/CHANGELOG.md +++ b/packages/blocks/notification-details/CHANGELOG.md @@ -1,5 +1,46 @@ # @o2s/blocks.notification-details +## 1.5.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- fadbc63: Align renderer prop types with runtime usage across blocks. + + Restore missing `isDraftModeEnabled` and `userId` coverage in renderer prop contracts and rename the misnamed notification details renderer prop type for consistency. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.5.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.5.0 ### Minor Changes diff --git a/packages/blocks/notification-details/package.json b/packages/blocks/notification-details/package.json index 33b33752d..e7f0c5587 100644 --- a/packages/blocks/notification-details/package.json +++ b/packages/blocks/notification-details/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.notification-details", - "version": "1.5.0", + "version": "1.5.2", "private": false, "license": "MIT", "description": "A block displaying and managing notification details.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/notification-details/src/api-harmonization/notification-details.controller.ts b/packages/blocks/notification-details/src/api-harmonization/notification-details.controller.ts index e7aca3a55..aed9ca440 100644 --- a/packages/blocks/notification-details/src/api-harmonization/notification-details.controller.ts +++ b/packages/blocks/notification-details/src/api-harmonization/notification-details.controller.ts @@ -1,8 +1,8 @@ import { Body, Controller, Get, Headers, Param, Post, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -21,7 +21,7 @@ export class NotificationDetailsController { @Get(':id') @Auth.Decorators.Permissions({ resource: 'notifications', actions: ['view'] }) getNotificationDetailsBlock( - @Headers() headers: Models.Headers.AppHeaders, + @Headers() headers: AppHeaders, @Query() query: GetNotificationDetailsBlockQuery, @Param() params: GetNotificationDetailsBlockParams, ) { diff --git a/packages/blocks/notification-details/src/api-harmonization/notification-details.service.ts b/packages/blocks/notification-details/src/api-harmonization/notification-details.service.ts index 2d9838908..912c1b974 100644 --- a/packages/blocks/notification-details/src/api-harmonization/notification-details.service.ts +++ b/packages/blocks/notification-details/src/api-harmonization/notification-details.service.ts @@ -2,8 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { CMS, Notifications } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapNotificationDetails } from './notification-details.mapper'; @@ -14,6 +13,8 @@ import { MarkNotificationAsBlockBody, } from './notification-details.request'; +const H = HeaderName; + @Injectable() export class NotificationDetailsService { constructor( @@ -25,10 +26,10 @@ export class NotificationDetailsService { getNotificationDetailsBlock( params: GetNotificationDetailsBlockParams, query: GetNotificationDetailsBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, ): Observable<NotificationDetailsBlock> { - const cms = this.cmsService.getNotificationDetailsBlock({ ...query, locale: headers['x-locale'] }); - const notification = this.notificationService.getNotification({ ...params, locale: headers['x-locale'] }); + const cms = this.cmsService.getNotificationDetailsBlock({ ...query, locale: headers[H.Locale] }); + const notification = this.notificationService.getNotification({ ...params, locale: headers[H.Locale] }); return forkJoin([notification, cms]).pipe( map(([notification, cms]) => { @@ -39,13 +40,14 @@ export class NotificationDetailsService { const result = mapNotificationDetails( notification, cms, - headers['x-locale'], - headers['x-client-timezone'] || '', + headers[H.Locale], + headers[H.ClientTimezone] || '', ); // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions(headers.authorization, 'notifications', [ + const authorization = headers[H.Authorization]; + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'notifications', [ 'view', 'mark_read', 'delete', diff --git a/packages/blocks/notification-details/src/frontend/NotificationDetails.renderer.tsx b/packages/blocks/notification-details/src/frontend/NotificationDetails.renderer.tsx index d3ef99a43..1cae8648e 100644 --- a/packages/blocks/notification-details/src/frontend/NotificationDetails.renderer.tsx +++ b/packages/blocks/notification-details/src/frontend/NotificationDetails.renderer.tsx @@ -5,9 +5,9 @@ import { Container } from '@o2s/ui/components/Container'; import { Loading } from '@o2s/ui/components/Loading'; import { NotificationDetails } from './NotificationDetails.server'; -import { FaqRendererProps } from './NotificationDetails.types'; +import { NotificationDetailsRendererProps } from './NotificationDetails.types'; -export const NotificationDetailsRenderer: React.FC<FaqRendererProps> = ({ +export const NotificationDetailsRenderer: React.FC<NotificationDetailsRendererProps> = ({ slug, id, accessToken, diff --git a/packages/blocks/notification-details/src/frontend/NotificationDetails.types.ts b/packages/blocks/notification-details/src/frontend/NotificationDetails.types.ts index 3d5067cf1..b6abdce7d 100644 --- a/packages/blocks/notification-details/src/frontend/NotificationDetails.types.ts +++ b/packages/blocks/notification-details/src/frontend/NotificationDetails.types.ts @@ -1,18 +1,13 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/notification-details.client'; -export interface NotificationDetailsProps { - id: string; +export interface NotificationDetailsProps extends Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>> { notificationId: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; } export type NotificationDetailsPureProps = NotificationDetailsProps & Model.NotificationDetailsBlock; -export type FaqRendererProps = Omit<NotificationDetailsProps, 'notificationId'> & { - slug: string[]; -}; +export type NotificationDetailsRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/notification-details/src/sdk/index.ts b/packages/blocks/notification-details/src/sdk/index.ts index 383745be1..5f8ca808f 100644 --- a/packages/blocks/notification-details/src/sdk/index.ts +++ b/packages/blocks/notification-details/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { notificationDetails } from './notification-details'; diff --git a/packages/blocks/notification-details/src/sdk/notification-details.ts b/packages/blocks/notification-details/src/sdk/notification-details.ts index 1c8aa633e..8d90459ad 100644 --- a/packages/blocks/notification-details/src/sdk/notification-details.ts +++ b/packages/blocks/notification-details/src/sdk/notification-details.ts @@ -1,6 +1,6 @@ -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/notification-details.client'; @@ -13,7 +13,7 @@ export const notificationDetails = (sdk: Sdk) => ({ getNotificationDetails: ( params: Request.GetNotificationDetailsBlockParams, query: Request.GetNotificationDetailsBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.NotificationDetailsBlock> => sdk.makeRequest({ @@ -32,7 +32,7 @@ export const notificationDetails = (sdk: Sdk) => ({ }), markNotificationAs: ( body: Request.MarkNotificationAsBlockBody, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<void> => sdk.makeRequest({ diff --git a/packages/blocks/notification-list/CHANGELOG.md b/packages/blocks/notification-list/CHANGELOG.md index 7dce5af1f..4143ac5ff 100644 --- a/packages/blocks/notification-list/CHANGELOG.md +++ b/packages/blocks/notification-list/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.notification-list +## 1.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.6.0 ### Minor Changes diff --git a/packages/blocks/notification-list/package.json b/packages/blocks/notification-list/package.json index 016910165..fbe46a265 100644 --- a/packages/blocks/notification-list/package.json +++ b/packages/blocks/notification-list/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.notification-list", - "version": "1.6.0", + "version": "1.6.2", "private": false, "license": "MIT", "description": "A block displaying and managing notifications.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/notification-list/src/api-harmonization/notification-list.controller.ts b/packages/blocks/notification-list/src/api-harmonization/notification-list.controller.ts index 0bc0cd035..b55cb3085 100644 --- a/packages/blocks/notification-list/src/api-harmonization/notification-list.controller.ts +++ b/packages/blocks/notification-list/src/api-harmonization/notification-list.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,10 +16,7 @@ export class NotificationListController { @Get() @Auth.Decorators.Permissions({ resource: 'notifications', actions: ['view'] }) - getNotificationListBlock( - @Headers() headers: Models.Headers.AppHeaders, - @Query() query: GetNotificationListBlockQuery, - ) { + getNotificationListBlock(@Headers() headers: AppHeaders, @Query() query: GetNotificationListBlockQuery) { return this.service.getNotificationListBlock(query, headers); } } diff --git a/packages/blocks/notification-list/src/api-harmonization/notification-list.service.ts b/packages/blocks/notification-list/src/api-harmonization/notification-list.service.ts index d88577a8f..971d85e7f 100644 --- a/packages/blocks/notification-list/src/api-harmonization/notification-list.service.ts +++ b/packages/blocks/notification-list/src/api-harmonization/notification-list.service.ts @@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common'; import { CMS, Notifications } from '@o2s/configs.integrations'; import { Observable, concatMap, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapNotificationList } from './notification-list.mapper'; import { NotificationListBlock } from './notification-list.model'; import { GetNotificationListBlockQuery } from './notification-list.request'; +const H = HeaderName; + @Injectable() export class NotificationListService { constructor( @@ -20,9 +21,10 @@ export class NotificationListService { getNotificationListBlock( query: GetNotificationListBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, ): Observable<NotificationListBlock> { - const cms = this.cmsService.getNotificationListBlock({ ...query, locale: headers['x-locale'] }); + const authorization = headers[H.Authorization]; + const cms = this.cmsService.getNotificationListBlock({ ...query, locale: headers[H.Locale] }); return forkJoin([cms]).pipe( concatMap(([cms]) => { @@ -32,24 +34,24 @@ export class NotificationListService { ...query, limit: query.limit || cms.pagination?.limit || 1, offset: query.offset || 0, - locale: headers['x-locale'], + locale: headers[H.Locale], }) .pipe( map((notifications) => { const result = mapNotificationList( notifications, cms, - headers['x-locale'], - headers['x-client-timezone'] || '', + headers[H.Locale], + headers[H.ClientTimezone] || '', ); // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions( - headers.authorization, - 'notifications', - ['view', 'mark_read', 'delete'], - ); + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'notifications', [ + 'view', + 'mark_read', + 'delete', + ]); result.permissions = { view: permissions.view ?? false, diff --git a/packages/blocks/notification-list/src/frontend/NotificationList.types.ts b/packages/blocks/notification-list/src/frontend/NotificationList.types.ts index 4699faed9..3bf5056b2 100644 --- a/packages/blocks/notification-list/src/frontend/NotificationList.types.ts +++ b/packages/blocks/notification-list/src/frontend/NotificationList.types.ts @@ -1,18 +1,14 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/notification-list.client'; -export interface NotificationListProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; +export interface NotificationListProps extends Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>> { enableRowSelection?: boolean; } export type NotificationListPureProps = NotificationListProps & Model.NotificationListBlock; -export type NotificationListRendererProps = Omit<NotificationListProps, ''> & { - slug: string[]; -}; +export type NotificationListRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>> & + Pick<NotificationListProps, 'enableRowSelection'>; diff --git a/packages/blocks/notification-list/src/sdk/index.ts b/packages/blocks/notification-list/src/sdk/index.ts index f24498562..ac34f3f81 100644 --- a/packages/blocks/notification-list/src/sdk/index.ts +++ b/packages/blocks/notification-list/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { notificationList } from './notification-list'; diff --git a/packages/blocks/notification-list/src/sdk/notification-list.ts b/packages/blocks/notification-list/src/sdk/notification-list.ts index 12d4a426d..11476861b 100644 --- a/packages/blocks/notification-list/src/sdk/notification-list.ts +++ b/packages/blocks/notification-list/src/sdk/notification-list.ts @@ -1,6 +1,6 @@ -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/notification-list.client'; @@ -12,7 +12,7 @@ export const notificationList = (sdk: Sdk) => ({ blocks: { getNotificationList: ( query: Request.GetNotificationListBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.NotificationListBlock> => sdk.makeRequest({ diff --git a/packages/blocks/notification-summary/CHANGELOG.md b/packages/blocks/notification-summary/CHANGELOG.md index dcb864dd2..80f50050a 100644 --- a/packages/blocks/notification-summary/CHANGELOG.md +++ b/packages/blocks/notification-summary/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.notification-summary +## 1.3.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.3.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.3.0 ### Minor Changes diff --git a/packages/blocks/notification-summary/package.json b/packages/blocks/notification-summary/package.json index 5dcd06ca3..cac65d542 100644 --- a/packages/blocks/notification-summary/package.json +++ b/packages/blocks/notification-summary/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.notification-summary", - "version": "1.3.0", + "version": "1.3.2", "private": false, "license": "MIT", "description": "Displays a dynamic NotificationSummary showing notification counts grouped by priority.", @@ -50,7 +50,7 @@ "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/notification-summary/src/api-harmonization/notification-summary.controller.ts b/packages/blocks/notification-summary/src/api-harmonization/notification-summary.controller.ts index f5e4059b0..15d117f4e 100644 --- a/packages/blocks/notification-summary/src/api-harmonization/notification-summary.controller.ts +++ b/packages/blocks/notification-summary/src/api-harmonization/notification-summary.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,10 +16,7 @@ export class NotificationSummaryController { @Get() @Auth.Decorators.Permissions({ resource: 'notifications', actions: ['view'] }) - getNotificationSummaryBlock( - @Headers() headers: Models.Headers.AppHeaders, - @Query() query: GetNotificationSummaryBlockQuery, - ) { + getNotificationSummaryBlock(@Headers() headers: AppHeaders, @Query() query: GetNotificationSummaryBlockQuery) { return this.service.getNotificationSummaryBlock(query, headers); } } diff --git a/packages/blocks/notification-summary/src/api-harmonization/notification-summary.model.ts b/packages/blocks/notification-summary/src/api-harmonization/notification-summary.model.ts index aa469b246..948be8b9f 100644 --- a/packages/blocks/notification-summary/src/api-harmonization/notification-summary.model.ts +++ b/packages/blocks/notification-summary/src/api-harmonization/notification-summary.model.ts @@ -1,4 +1,4 @@ -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { Notifications } from '@o2s/framework/modules'; @@ -9,7 +9,7 @@ export class NotificationSummaryInfoCard { description?: string; variant!: Notifications.Model.NotificationPriority; } -export class NotificationSummaryBlock extends Models.Block.Block { +export class NotificationSummaryBlock extends ApiModels.Block.Block { __typename!: 'NotificationSummaryBlock'; layout?: 'vertical' | 'horizontal'; infoCards!: NotificationSummaryInfoCard[]; diff --git a/packages/blocks/notification-summary/src/api-harmonization/notification-summary.service.ts b/packages/blocks/notification-summary/src/api-harmonization/notification-summary.service.ts index 4bb7cfb12..4de547ed7 100644 --- a/packages/blocks/notification-summary/src/api-harmonization/notification-summary.service.ts +++ b/packages/blocks/notification-summary/src/api-harmonization/notification-summary.service.ts @@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common'; import { CMS, Notifications } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapNotificationSummary } from './notification-summary.mapper'; import { NotificationSummaryBlock } from './notification-summary.model'; import { GetNotificationSummaryBlockQuery } from './notification-summary.request'; +const H = HeaderName; + @Injectable() export class NotificationSummaryService { constructor( @@ -20,22 +21,23 @@ export class NotificationSummaryService { getNotificationSummaryBlock( query: GetNotificationSummaryBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, ): Observable<NotificationSummaryBlock> { - const cms = this.cmsService.getNotificationSummaryBlock({ ...query, locale: headers['x-locale'] }); + const cms = this.cmsService.getNotificationSummaryBlock({ ...query, locale: headers[H.Locale] }); const notifications = this.notificationService.getNotificationList({ limit: 1000, offset: 0, - locale: headers['x-locale'], + locale: headers[H.Locale], }); return forkJoin([notifications, cms]).pipe( map(([notifications, cms]) => { - const result = mapNotificationSummary(cms, notifications, headers['x-locale']); + const result = mapNotificationSummary(cms, notifications, headers[H.Locale]); + const authorization = headers[H.Authorization]; // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions(headers.authorization, 'notifications', [ + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'notifications', [ 'view', 'mark_read', ]); diff --git a/packages/blocks/notification-summary/src/frontend/NotificationSummary.types.ts b/packages/blocks/notification-summary/src/frontend/NotificationSummary.types.ts index 19beba1f8..71c931c53 100644 --- a/packages/blocks/notification-summary/src/frontend/NotificationSummary.types.ts +++ b/packages/blocks/notification-summary/src/frontend/NotificationSummary.types.ts @@ -1,16 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/notification-summary.client'; -export interface NotificationSummaryProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; -} +export type NotificationSummaryProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type NotificationSummaryPureProps = NotificationSummaryProps & Model.NotificationSummaryBlock; -export type NotificationSummaryRendererProps = Omit<NotificationSummaryProps, ''> & { - slug: string[]; -}; +export type NotificationSummaryRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/notification-summary/src/sdk/index.ts b/packages/blocks/notification-summary/src/sdk/index.ts index 4e2bd516a..cbf872fce 100644 --- a/packages/blocks/notification-summary/src/sdk/index.ts +++ b/packages/blocks/notification-summary/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { notificationSummary } from './notification-summary'; diff --git a/packages/blocks/notification-summary/src/sdk/notification-summary.ts b/packages/blocks/notification-summary/src/sdk/notification-summary.ts index a03da4b44..7ce07592e 100644 --- a/packages/blocks/notification-summary/src/sdk/notification-summary.ts +++ b/packages/blocks/notification-summary/src/sdk/notification-summary.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request, URL } from '../api-harmonization/notification-summary.client'; @@ -11,7 +11,7 @@ export const notificationSummary = (sdk: Sdk) => ({ blocks: { getNotificationSummary: ( query: Request.GetNotificationSummaryBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.NotificationSummaryBlock> => sdk.makeRequest({ diff --git a/packages/blocks/order-confirmation/.gitignore b/packages/blocks/order-confirmation/.gitignore new file mode 100644 index 000000000..29986a380 --- /dev/null +++ b/packages/blocks/order-confirmation/.gitignore @@ -0,0 +1,57 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +/tsconfig.tsbuildinfo diff --git a/packages/blocks/order-confirmation/.prettierrc.mjs b/packages/blocks/order-confirmation/.prettierrc.mjs new file mode 100644 index 000000000..93b66d398 --- /dev/null +++ b/packages/blocks/order-confirmation/.prettierrc.mjs @@ -0,0 +1,25 @@ +import apiConfig from "@o2s/prettier-config/api.mjs"; +import frontendConfig from "@o2s/prettier-config/frontend.mjs"; + +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +const config = { + overrides: [ + { + files: "./src/api-harmonization/**/*", + options: apiConfig, + }, + { + files: "./src/frontend/**/*", + options: frontendConfig, + }, + { + files: "./src/sdk/**/*", + options: frontendConfig, + }, + ], +}; + +export default config; diff --git a/packages/blocks/order-confirmation/CHANGELOG.md b/packages/blocks/order-confirmation/CHANGELOG.md new file mode 100644 index 000000000..e5fdc6ce0 --- /dev/null +++ b/packages/blocks/order-confirmation/CHANGELOG.md @@ -0,0 +1,32 @@ +# @o2s/blocks.order-confirmation + +## 0.1.1 + +### Patch Changes + +- c1a5460: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + +## 0.1.0 + +### Minor Changes + +- 5d36519: Added new blocks: Cart, Checkout (Summary, Shipping Address, Company Data, Billing Payment) and Order Confirmation. Includes checkout forms validation (Formik + Yup), error handling, promo code support in cart, and new UI components (StepIndicator, RadioTile, AddressFields, CartSummary, QuantityInput, FormField). + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] +- Updated dependencies [5d36519] + - @o2s/framework@1.19.0 + - @o2s/utils.frontend@0.5.0 + - @o2s/ui@1.12.0 diff --git a/packages/blocks/order-confirmation/README.md b/packages/blocks/order-confirmation/README.md new file mode 100644 index 000000000..77a69b336 --- /dev/null +++ b/packages/blocks/order-confirmation/README.md @@ -0,0 +1,89 @@ +# @o2s/blocks.order-confirmation + +Block for displaying the order confirmation page after successful checkout. + +The order-confirmation block shows order details, items, totals, shipping and billing addresses after the customer completes the checkout. Typically displayed on a dedicated confirmation page (e.g. `/checkout/confirmation/:orderId`). Data is fetched server-side by orderId from the URL. + +- **Order summary** – Items, subtotal, tax, discount, shipping, total +- **Addresses** – Shipping and billing address display +- **Status** – Order status badge +- **Actions** – View orders, continue shopping + +Content editors place the block via CMS. Developers connect Orders and CMS integrations. + +## Installation + +```bash +npm install @o2s/blocks.order-confirmation +``` + +## Usage + +### Backend (API Harmonization) + +Register the block in `app.module.ts`: + +```typescript +import * as OrderConfirmation from "@o2s/blocks.order-confirmation/api-harmonization"; +import { AppConfig } from "./app.config"; + +@Module({ + imports: [OrderConfirmation.Module.register(AppConfig)], +}) +export class AppModule {} +``` + +### Frontend + +Register the block in `renderBlocks.tsx`: + +```typescript +import * as OrderConfirmation from '@o2s/blocks.order-confirmation/frontend'; + +export const renderBlocks = async (blocks: CMS.Model.Page.SlotBlock[]) => { + return blocks.map((block) => { + if (block.type === 'order-confirmation') { + return ( + <OrderConfirmation.Renderer + key={block.id} + id={block.id} + slug={slug} + locale={locale} + accessToken={session?.accessToken} + userId={session?.user?.id} + routing={routing} + orderId={orderId} + /> + ); + } + // ... other blocks + }); +}; +``` + +## Configuration + +This block requires Orders and CMS integrations in `AppConfig`. + +## Related Blocks + +- `@o2s/blocks.cart` - Shopping cart +- `@o2s/blocks.checkout-company-data` - Company details step +- `@o2s/blocks.checkout-shipping-address` - Shipping address step +- `@o2s/blocks.checkout-billing-payment` - Payment step +- `@o2s/blocks.checkout-summary` - Order summary step (places the order) + +## About Blocks in O2S + +Blocks are self-contained, reusable UI components that combine harmonizing and frontend components into a single package. Each block is independently packaged as an NPM module and includes three primary parts: API Harmonization Module, Frontend Components, and SDK Methods. Blocks allow you to quickly add or remove functionality without impacting other components of the application. + +- **See all blocks**: [Blocks Documentation](https://www.openselfservice.com/docs/main-components/blocks/) +- **View this block in Storybook**: [order-confirmation](https://storybook-o2s.openselfservice.com/?path=/story/blocks-orderconfirmation--default) + +## About O2S + +**Part of [Open Self Service (O2S)](https://www.openselfservice.com/)** - an open-source framework for building composable customer self-service portals. O2S simplifies integration of multiple headless APIs into a scalable frontend, providing an API-agnostic architecture with a normalization layer. + +- **Website**: [https://www.openselfservice.com/](https://www.openselfservice.com/) +- **GitHub**: [https://github.com/o2sdev/openselfservice](https://github.com/o2sdev/openselfservice) +- **Documentation**: [https://www.openselfservice.com/docs](https://www.openselfservice.com/docs) diff --git a/packages/blocks/order-confirmation/eslint.config.mjs b/packages/blocks/order-confirmation/eslint.config.mjs new file mode 100644 index 000000000..223f2af08 --- /dev/null +++ b/packages/blocks/order-confirmation/eslint.config.mjs @@ -0,0 +1,18 @@ +import { config as apiConfig } from '@o2s/eslint-config/api'; +import { config as frontendConfig } from '@o2s/eslint-config/frontend-block'; +import { defineConfig } from 'eslint/config'; + +export default defineConfig([ + { + files: ['src/api-harmonization/**/*'], + extends: [apiConfig], + }, + { + files: ['src/frontend/**/*'], + extends: [frontendConfig], + }, + { + files: ['src/sdk/**/*'], + extends: [frontendConfig], + }, +]); diff --git a/packages/blocks/order-confirmation/lint-staged.config.mjs b/packages/blocks/order-confirmation/lint-staged.config.mjs new file mode 100644 index 000000000..b47bd93b9 --- /dev/null +++ b/packages/blocks/order-confirmation/lint-staged.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/lint-staged-config/base'; + +export default config; diff --git a/packages/blocks/order-confirmation/package.json b/packages/blocks/order-confirmation/package.json new file mode 100644 index 000000000..d9245c130 --- /dev/null +++ b/packages/blocks/order-confirmation/package.json @@ -0,0 +1,58 @@ +{ + "name": "@o2s/blocks.order-confirmation", + "version": "0.1.1", + "private": false, + "license": "MIT", + "description": "Order confirmation page displaying order details, addresses, and totals after successful purchase.", + "exports": { + "./api-harmonization": "./dist/api-harmonization/api-harmonization/index.js", + "./frontend": "./dist/frontend/frontend/index.js", + "./sdk": "./dist/sdk/sdk/index.js", + "./client": "./dist/api-harmonization/api-harmonization/order-confirmation.client.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build tsconfig.json --preserveWatchOutput && tsc-alias", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit && eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"" + }, + "dependencies": { + "@o2s/framework": "*", + "@o2s/utils.logger": "*", + "@o2s/ui": "*", + "@o2s/utils.api-harmonization": "*", + "@o2s/utils.frontend": "*", + "@o2s/configs.integrations": "*" + }, + "devDependencies": { + "dotenv-cli": "^11.0.0", + "@o2s/eslint-config": "*", + "@o2s/lint-staged-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", + "concurrently": "^9.2.1", + "eslint": "^9.39.4", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "react": "^19", + "react-dom": "^19", + "tailwindcss": "^4", + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.16", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11", + "rxjs": "^7", + "next": "^16.0.5", + "next-intl": "^4.1.0" + } +} diff --git a/packages/blocks/order-confirmation/src/api-harmonization/index.ts b/packages/blocks/order-confirmation/src/api-harmonization/index.ts new file mode 100644 index 000000000..284aaa8a7 --- /dev/null +++ b/packages/blocks/order-confirmation/src/api-harmonization/index.ts @@ -0,0 +1,8 @@ +export const URL = '/blocks/order-confirmation'; + +export { OrderConfirmationBlockModule as Module } from './order-confirmation.module'; +export { OrderConfirmationService as Service } from './order-confirmation.service'; +export { OrderConfirmationController as Controller } from './order-confirmation.controller'; + +export * as Model from './order-confirmation.model'; +export * as Request from './order-confirmation.request'; diff --git a/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.client.ts b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.client.ts new file mode 100644 index 000000000..cd065c33f --- /dev/null +++ b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.client.ts @@ -0,0 +1,4 @@ +export const URL = '/blocks/order-confirmation'; + +export * as Model from './order-confirmation.model'; +export * as Request from './order-confirmation.request'; diff --git a/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.controller.ts b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.controller.ts new file mode 100644 index 000000000..0ac3218e1 --- /dev/null +++ b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; + +import { Models } from '@o2s/utils.api-harmonization'; +import { LoggerService } from '@o2s/utils.logger'; + +import { Auth } from '@o2s/framework/modules'; + +import { URL } from './'; +import { GetOrderConfirmationBlockQuery } from './order-confirmation.request'; +import { OrderConfirmationService } from './order-confirmation.service'; + +@Controller(URL) +@UseInterceptors(LoggerService) +export class OrderConfirmationController { + constructor(protected readonly service: OrderConfirmationService) {} + + @Get() + @Auth.Decorators.Roles({ roles: [] }) + // Optional: Add permission-based access control + // @Auth.Decorators.Permissions({ resource: 'resource-name', actions: ['view'] }) + getOrderConfirmationBlock( + @Headers() headers: Models.Headers.AppHeaders, + @Query() query: GetOrderConfirmationBlockQuery, + ) { + return this.service.getOrderConfirmationBlock(query, headers); + } +} diff --git a/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.mapper.ts b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.mapper.ts new file mode 100644 index 000000000..b1ab7efaf --- /dev/null +++ b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.mapper.ts @@ -0,0 +1,89 @@ +import { CMS, Orders } from '@o2s/configs.integrations'; + +import type { + OrderConfirmationAddress, + OrderConfirmationBlock, + OrderConfirmationShippingMethod, +} from './order-confirmation.model'; + +const mapAddress = (addr: Orders.Model.Order['shippingAddress']): OrderConfirmationAddress | undefined => { + if (!addr) return undefined; + return { + firstName: addr.firstName, + lastName: addr.lastName, + email: addr.email, + phone: addr.phone, + streetName: addr.streetName, + streetNumber: addr.streetNumber, + apartment: addr.apartment, + postalCode: addr.postalCode, + city: addr.city, + country: addr.country, + companyName: addr.companyName, + taxId: addr.taxId, + }; +}; + +const mapShippingMethod = (method: Orders.Model.ShippingMethod): OrderConfirmationShippingMethod => ({ + name: method.name, + total: method.total, +}); + +export const mapOrderConfirmation = ( + cms: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock, + order: Orders.Model.Order, + _locale: string, +): OrderConfirmationBlock => { + const currency = order.currency; + const zeroPrice = { value: 0, currency }; + + return { + __typename: 'OrderConfirmationBlock', + id: cms.id, + title: cms.title, + subtitle: cms.subtitle, + orderNumberLabel: cms.orderNumberLabel, + productsTitle: cms.productsTitle, + productsCountLabel: cms.productsCountLabel, + summaryTitle: cms.summaryTitle, + subtotalLabel: cms.subtotalLabel, + taxLabel: cms.taxLabel, + discountLabel: cms.discountLabel, + shippingLabel: cms.shippingLabel, + totalLabel: cms.totalLabel, + shippingSection: cms.shippingSection, + billingSection: cms.billingSection, + message: cms.message, + buttons: cms.buttons, + viewOrdersPath: cms.viewOrdersPath, + continueShoppingPath: cms.continueShoppingPath, + statusLabels: cms.statusLabels, + errors: cms.errors ?? { loadError: '', orderNotFound: '' }, + order: { + id: order.id, + createdAt: order.createdAt, + status: order.status, + paymentStatus: order.paymentStatus, + items: { + data: order.items.data.map((item) => ({ + id: item.id, + productId: item.productId, + quantity: item.quantity, + price: item.price, + total: item.total || zeroPrice, + productName: item.product.name, + })), + total: order.items.total, + }, + subtotal: order.subtotal || zeroPrice, + tax: order.tax, + discountTotal: order.discountTotal, + shippingTotal: order.shippingTotal, + total: order.total, + shippingAddress: mapAddress(order.shippingAddress), + billingAddress: mapAddress(order.billingAddress), + shippingMethods: order.shippingMethods?.map(mapShippingMethod), + email: order.email, + }, + }; +}; diff --git a/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.model.ts b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.model.ts new file mode 100644 index 000000000..99a6972c1 --- /dev/null +++ b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.model.ts @@ -0,0 +1,78 @@ +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; + +import { CMS, Models, Orders } from '@o2s/framework/modules'; + +/** Order item for confirmation display */ +export interface OrderConfirmationItem { + id: string; + productId: string; + quantity: number; + price: Models.Price.Price; + total: Models.Price.Price; + productName?: string; +} + +/** Address for order confirmation display */ +export interface OrderConfirmationAddress { + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + streetName: string; + streetNumber?: string; + apartment?: string; + postalCode: string; + city: string; + country: string; + companyName?: string; + taxId?: string; +} + +/** Shipping method for order confirmation display */ +export interface OrderConfirmationShippingMethod { + name: string; + total?: Models.Price.Price; +} + +export class OrderConfirmationBlock extends ApiModels.Block.Block { + __typename!: 'OrderConfirmationBlock'; + title!: string; + subtitle?: string; + orderNumberLabel!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['orderNumberLabel']; + productsTitle!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['productsTitle']; + productsCountLabel!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['productsCountLabel']; + summaryTitle!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['summaryTitle']; + subtotalLabel!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['subtotalLabel']; + taxLabel!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['taxLabel']; + discountLabel!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['discountLabel']; + shippingLabel!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['shippingLabel']; + totalLabel!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['totalLabel']; + shippingSection!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['shippingSection']; + billingSection!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['billingSection']; + message?: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['message']; + buttons!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['buttons']; + viewOrdersPath!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['viewOrdersPath']; + continueShoppingPath!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['continueShoppingPath']; + statusLabels!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['statusLabels']; + errors!: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock['errors']; + /** Order data - from API by orderId */ + order!: { + id: string; + createdAt?: string; + status?: Orders.Model.OrderStatus; + paymentStatus?: Orders.Model.PaymentStatus; + items: { + data: OrderConfirmationItem[]; + total: number; + }; + subtotal: Models.Price.Price; + tax?: Models.Price.Price; + discountTotal?: Models.Price.Price; + shippingTotal?: Models.Price.Price; + total: Models.Price.Price; + shippingAddress?: OrderConfirmationAddress; + billingAddress?: OrderConfirmationAddress; + shippingMethods?: OrderConfirmationShippingMethod[]; + email?: string; + }; +} diff --git a/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.module.ts b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.module.ts new file mode 100644 index 000000000..b45ed7a1d --- /dev/null +++ b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.module.ts @@ -0,0 +1,29 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { CMS, Orders } from '@o2s/configs.integrations'; + +import * as Framework from '@o2s/framework/modules'; + +import { OrderConfirmationController } from './order-confirmation.controller'; +import { OrderConfirmationService } from './order-confirmation.service'; + +@Module({}) +export class OrderConfirmationBlockModule { + static register(_config: Framework.ApiConfig): DynamicModule { + return { + module: OrderConfirmationBlockModule, + providers: [ + OrderConfirmationService, + { + provide: CMS.Service, + useExisting: Framework.CMS.Service, + }, + { + provide: Orders.Service, + useExisting: Framework.Orders.Service, + }, + ], + controllers: [OrderConfirmationController], + exports: [OrderConfirmationService], + }; + } +} diff --git a/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.request.ts b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.request.ts new file mode 100644 index 000000000..9cb07e2b3 --- /dev/null +++ b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.request.ts @@ -0,0 +1,7 @@ +import { CMS } from '@o2s/framework/modules'; + +export class GetOrderConfirmationBlockQuery implements Omit<CMS.Request.GetCmsEntryParams, 'locale'> { + id!: string; + /** Order ID from URL - used to fetch order data (API/mock) */ + orderId?: string; +} diff --git a/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.service.spec.ts b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.service.spec.ts new file mode 100644 index 000000000..eb04345a1 --- /dev/null +++ b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.service.spec.ts @@ -0,0 +1,42 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CMS, Orders } from '@o2s/configs.integrations'; +import { of } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OrderConfirmationService } from './order-confirmation.service'; + +describe('OrderConfirmationService', () => { + let service: OrderConfirmationService; + let cmsService: CMS.Service; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OrderConfirmationService, + { + provide: CMS.Service, + useValue: { + getOrderConfirmationBlock: vi.fn().mockReturnValue(of({ title: 'Test Block' })), + }, + }, + { + provide: Orders.Service, + useValue: { + getOrder: vi.fn().mockReturnValue(of(undefined)), + }, + }, + ], + }).compile(); + + service = module.get<OrderConfirmationService>(OrderConfirmationService); + cmsService = module.get<CMS.Service>(CMS.Service); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should have cmsService injected', () => { + expect(cmsService).toBeDefined(); + }); +}); diff --git a/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.service.ts b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.service.ts new file mode 100644 index 000000000..247efdfd7 --- /dev/null +++ b/packages/blocks/order-confirmation/src/api-harmonization/order-confirmation.service.ts @@ -0,0 +1,45 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CMS, Orders } from '@o2s/configs.integrations'; +import { Observable, forkJoin, map, throwError } from 'rxjs'; + +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; + +// import { Auth } from '@o2s/framework/modules'; + +import { mapOrderConfirmation } from './order-confirmation.mapper'; +import { OrderConfirmationBlock } from './order-confirmation.model'; +import { GetOrderConfirmationBlockQuery } from './order-confirmation.request'; + +const H = HeaderName; + +@Injectable() +export class OrderConfirmationService { + constructor( + private readonly cmsService: CMS.Service, + private readonly ordersService: Orders.Service, + // Optional: Inject Auth.Service when you need to add permission flags to the response + // private readonly authService: Auth.Service, + ) {} + + getOrderConfirmationBlock( + query: GetOrderConfirmationBlockQuery, + headers: AppHeaders, + ): Observable<OrderConfirmationBlock> { + if (!query.orderId) { + return throwError(() => new NotFoundException('Order ID is required')); + } + + return forkJoin([ + this.cmsService.getOrderConfirmationBlock({ ...query, locale: headers[H.Locale] }), + this.ordersService.getOrder({ id: query.orderId }, headers[H.Authorization]), + ]).pipe( + map(([cms, order]) => { + if (!order) { + throw new NotFoundException(`Order with ID ${query.orderId} not found`); + } + + return mapOrderConfirmation(cms, order, headers[H.Locale]); + }), + ); + } +} diff --git a/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.client.stories.tsx b/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.client.stories.tsx new file mode 100644 index 000000000..3e99f77ff --- /dev/null +++ b/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.client.stories.tsx @@ -0,0 +1,137 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { defineRouting } from 'next-intl/routing'; + +import readme from '../../README.md?raw'; + +import { OrderConfirmationPure } from './OrderConfirmation.client'; + +const routing = defineRouting({ + locales: ['en'], + defaultLocale: 'en', + pathnames: {}, +}); + +const baseBlock = { + __typename: 'OrderConfirmationBlock' as const, + id: 'order-confirmation-1', + title: 'Order placed successfully!', + subtitle: 'Thank you for your order', + orderNumberLabel: 'Order number:', + productsTitle: 'Products', + productsCountLabel: 'pcs', + summaryTitle: 'Order summary', + subtotalLabel: 'Subtotal:', + taxLabel: 'VAT:', + discountLabel: 'Discount:', + shippingLabel: 'Shipping:', + totalLabel: 'Total:', + errors: { + loadError: 'Failed to load order. Please try again.', + orderNotFound: 'Order not found or unavailable.', + }, + shippingSection: { + title: 'Shipping address', + addressLabel: 'Address', + methodLabel: 'Method', + }, + billingSection: { + title: 'Payment', + addressLabel: 'Address', + taxIdLabel: 'Tax ID', + }, + message: 'Order confirmation has been sent to your email address.', + buttons: { + viewOrders: 'View orders', + continueShopping: 'Continue shopping', + }, + viewOrdersPath: '#', + continueShoppingPath: '#', + statusLabels: { + PENDING: 'Pending', + COMPLETED: 'Completed', + SHIPPED: 'Shipped', + CANCELLED: 'Cancelled', + ARCHIVED: 'Archived', + REQUIRES_ACTION: 'Requires action', + UNKNOWN: 'Unknown', + }, + order: { + id: 'ORD-12345', + status: 'PENDING' as const, + items: { + data: [ + { + id: 'item-1', + productId: 'PRIM-001', + quantity: 2, + price: { value: 89.99, currency: 'EUR' as const }, + total: { value: 179.98, currency: 'EUR' as const }, + productName: 'CLARIS S Filter Cartridge', + }, + { + id: 'item-2', + productId: 'PRIM-002', + quantity: 1, + price: { value: 24.99, currency: 'EUR' as const }, + total: { value: 24.99, currency: 'EUR' as const }, + productName: 'Cleaning solution', + }, + ], + total: 3, + }, + subtotal: { value: 204.97, currency: 'EUR' as const }, + tax: { value: 47.14, currency: 'EUR' as const }, + discountTotal: { value: 20.5, currency: 'EUR' as const }, + shippingTotal: { value: 15, currency: 'EUR' as const }, + total: { value: 252.11, currency: 'EUR' as const }, + shippingAddress: { + streetName: 'Main St', + streetNumber: '123', + postalCode: '00-001', + city: 'Warsaw', + country: 'Poland', + }, + billingAddress: { + streetName: 'Main St', + streetNumber: '123', + companyName: 'Acme Corp', + taxId: 'PL1234567890', + postalCode: '00-001', + city: 'Warsaw', + country: 'Poland', + }, + shippingMethods: [{ name: 'Standard Shipping', total: { value: 15, currency: 'EUR' as const } }], + }, +}; + +const meta = { + title: 'Blocks/OrderConfirmation', + component: OrderConfirmationPure, + tags: ['autodocs'], + parameters: { readme }, +} satisfies Meta<typeof OrderConfirmationPure>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default: Story = { + args: { + ...baseBlock, + id: 'order-confirmation-1', + orderId: 'ORD-12345', + locale: 'en', + routing, + }, +}; + +export const WithoutMessage: Story = { + args: { + ...baseBlock, + id: 'order-confirmation-1', + orderId: 'ORD-12345', + locale: 'en', + routing, + message: undefined, + }, +}; diff --git a/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.client.tsx b/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.client.tsx new file mode 100644 index 000000000..26d52b40e --- /dev/null +++ b/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.client.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { createNavigation } from 'next-intl/navigation'; +import React from 'react'; + +import { Mappings, Utils } from '@o2s/utils.frontend'; + +import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; +import { Price } from '@o2s/ui/components/Price'; + +import { Badge } from '@o2s/ui/elements/badge'; +import { Button } from '@o2s/ui/elements/button'; +import { Separator } from '@o2s/ui/elements/separator'; +import { Typography } from '@o2s/ui/elements/typography'; + +import { OrderConfirmationPureProps } from './OrderConfirmation.types'; + +export const OrderConfirmationPure: React.FC<Readonly<OrderConfirmationPureProps>> = ({ + locale, + accessToken: _accessToken, + routing, + orderId: _orderId, + title, + subtitle, + orderNumberLabel, + productsTitle, + productsCountLabel, + summaryTitle, + subtotalLabel, + taxLabel, + discountLabel, + shippingLabel, + totalLabel, + shippingSection, + billingSection, + message, + buttons, + viewOrdersPath, + continueShoppingPath, + statusLabels, + order, +}) => { + const { Link: LinkComponent } = createNavigation(routing); + const { formatStreetAddress } = Utils.FormatAddress; + + if (!order) { + return null; + } + + const shippingAddress = order.shippingAddress; + const billingAddress = order.billingAddress; + const shippingMethods = order.shippingMethods; + + return ( + <div className="w-full max-w-2xl mx-auto flex flex-col gap-8"> + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-3"> + <div className="flex h-20 w-20 items-center justify-center rounded-full bg-green-500/10"> + <DynamicIcon name="CircleCheckBig" size={40} className="text-green-500" /> + </div> + <div className="flex flex-col gap-1"> + <Typography variant="h1">{title}</Typography> + <div className="flex flex-wrap items-center gap-2"> + <Typography variant="body" className="text-muted-foreground"> + {orderNumberLabel} <strong>{order.id}</strong> + </Typography> + {order.status && ( + <Badge variant={Mappings.OrderBadge.orderBadgeVariants[order.status] ?? 'outline'}> + {statusLabels[order.status] ?? order.status} + </Badge> + )} + </div> + {subtitle && ( + <Typography variant="body" className="text-muted-foreground mt-1"> + {subtitle} + </Typography> + )} + </div> + </div> + </div> + + <div className="flex flex-col gap-6 rounded-lg border border-border bg-card p-6"> + {/* Products Section */} + <div className="flex flex-col gap-3"> + <Typography variant="h2"> + {productsTitle} + {productsCountLabel && ` (${order.items.total})`} + </Typography> + <ul className="flex flex-col gap-1"> + {order.items.data.map((item) => ( + <li key={item.id} className="flex items-center justify-between text-sm"> + <span> + {item.productName ?? item.productId} × {item.quantity} + </span> + <Price price={item.total} /> + </li> + ))} + </ul> + </div> + + {shippingSection && (shippingAddress || (shippingMethods && shippingMethods.length > 0)) && ( + <> + <Separator /> + <div className="flex flex-col gap-3"> + <Typography variant="h2">{shippingSection.title}</Typography> + <div className="flex flex-col gap-1"> + {shippingAddress && ( + <> + {shippingSection.addressLabel && ( + <Typography variant="small" className="font-bold text-muted-foreground"> + {shippingSection.addressLabel} + </Typography> + )} + <Typography variant="small">{formatStreetAddress(shippingAddress)}</Typography> + <Typography variant="small"> + {shippingAddress.postalCode} {shippingAddress.city} + </Typography> + <Typography variant="small"> + {Utils.FormatCountry.formatCountryCode(shippingAddress.country, locale)} + </Typography> + {(shippingAddress.firstName || shippingAddress.lastName) && ( + <Typography variant="small" className="mt-2"> + {[shippingAddress.firstName, shippingAddress.lastName] + .filter(Boolean) + .join(' ')} + </Typography> + )} + {shippingAddress.phone && ( + <Typography variant="small">{shippingAddress.phone}</Typography> + )} + </> + )} + {shippingMethods && shippingMethods.length > 0 && ( + <Typography variant="small" className="mt-2"> + <strong>{shippingSection.methodLabel}</strong>{' '} + {shippingMethods.map((m) => m.name).join(', ')} + </Typography> + )} + </div> + </div> + </> + )} + + {billingSection && billingAddress && ( + <> + <Separator /> + <div className="flex flex-col gap-3"> + <Typography variant="h2">{billingSection.title}</Typography> + <div className="flex flex-col gap-1"> + {billingSection.addressLabel && ( + <Typography variant="small" className="font-bold text-muted-foreground"> + {billingSection.addressLabel} + </Typography> + )} + {billingAddress.companyName && ( + <Typography variant="small">{billingAddress.companyName}</Typography> + )} + {billingAddress.taxId && ( + <Typography variant="small" className="text-muted-foreground"> + {billingSection.taxIdLabel ? `${billingSection.taxIdLabel}: ` : ''} + {billingAddress.taxId} + </Typography> + )} + <Typography variant="small">{formatStreetAddress(billingAddress)}</Typography> + <Typography variant="small"> + {billingAddress.postalCode} {billingAddress.city} + </Typography> + <Typography variant="small"> + {Utils.FormatCountry.formatCountryCode(billingAddress.country, locale)} + </Typography> + {(billingAddress.firstName || billingAddress.lastName) && ( + <Typography variant="small" className="mt-2"> + {[billingAddress.firstName, billingAddress.lastName].filter(Boolean).join(' ')} + </Typography> + )} + {(billingAddress.email || order.email) && ( + <Typography variant="small">{billingAddress.email || order.email}</Typography> + )} + {billingAddress.phone && ( + <Typography variant="small">{billingAddress.phone}</Typography> + )} + </div> + </div> + </> + )} + + <Separator /> + + {/* Summary Section */} + <div className="flex flex-col gap-3"> + <Typography variant="h2">{summaryTitle}</Typography> + <div className="flex flex-col gap-1"> + <div className="flex items-center justify-between"> + <Typography variant="small" className="text-muted-foreground"> + {subtotalLabel} + </Typography> + <Typography variant="small"> + <Price price={order.subtotal} /> + </Typography> + </div> + {order.tax && ( + <div className="flex items-center justify-between"> + <Typography variant="small" className="text-muted-foreground"> + {taxLabel} + </Typography> + <Typography variant="small"> + <Price price={order.tax} /> + </Typography> + </div> + )} + {shippingLabel && order.shippingTotal && order.shippingTotal.value > 0 && ( + <div className="flex items-center justify-between"> + <Typography variant="small" className="text-muted-foreground"> + {shippingLabel} + </Typography> + <Typography variant="small"> + <Price price={order.shippingTotal} /> + </Typography> + </div> + )} + {discountLabel && order.discountTotal && order.discountTotal.value > 0 && ( + <div className="flex items-center justify-between"> + <Typography variant="small" className="text-muted-foreground"> + {discountLabel} + </Typography> + <Typography variant="small" className="text-green-600"> + -<Price price={order.discountTotal} /> + </Typography> + </div> + )} + </div> + <Separator /> + <div className="flex items-center justify-between"> + <Typography variant="h3">{totalLabel}</Typography> + <Typography variant="h2" className="text-primary"> + <Price price={order.total} /> + </Typography> + </div> + </div> + </div> + + {message && ( + <div className="rounded-lg bg-muted/50 p-4 flex items-start gap-3"> + <DynamicIcon name="Info" size={20} className="text-muted-foreground shrink-0 mt-0.5" /> + <Typography variant="small" className="text-muted-foreground"> + {message} + {(order.email || billingAddress?.email) && ( + <> + {' '} + <strong>{order.email || billingAddress?.email}</strong> + </> + )} + </Typography> + </div> + )} + + <div className="flex flex-col sm:flex-row gap-3"> + <Button asChild variant="outline" className="w-full sm:w-auto"> + <LinkComponent href={continueShoppingPath}>{buttons.continueShopping}</LinkComponent> + </Button> + <Button asChild variant="default" className="w-full sm:w-auto"> + <LinkComponent href={viewOrdersPath}>{buttons.viewOrders}</LinkComponent> + </Button> + </div> + </div> + ); +}; diff --git a/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.renderer.tsx b/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.renderer.tsx new file mode 100644 index 000000000..badc5e2f5 --- /dev/null +++ b/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.renderer.tsx @@ -0,0 +1,40 @@ +import { useLocale } from 'next-intl'; +import React, { Suspense } from 'react'; + +import { Container } from '@o2s/ui/components/Container'; +import { Loading } from '@o2s/ui/components/Loading'; + +import { OrderConfirmation } from './OrderConfirmation.server'; +import { OrderConfirmationRendererProps } from './OrderConfirmation.types'; + +export const OrderConfirmationRenderer: React.FC<OrderConfirmationRendererProps> = ({ + slug, + id, + accessToken, + routing, +}) => { + const locale = useLocale(); + const orderId = slug?.[slug.length - 1] ?? slug?.[1]; + + if (!orderId) { + return null; + } + + return ( + <Suspense + key={id} + fallback={ + <div className="w-full flex flex-col gap-8"> + <Container variant="narrow"> + <Loading bars={1} /> + </Container> + <Container variant="narrow"> + <Loading bars={8} /> + </Container> + </div> + } + > + <OrderConfirmation id={id} orderId={orderId} accessToken={accessToken} locale={locale} routing={routing} /> + </Suspense> + ); +}; diff --git a/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.server.tsx b/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.server.tsx new file mode 100644 index 000000000..046f7e1e8 --- /dev/null +++ b/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.server.tsx @@ -0,0 +1,60 @@ +import dynamic from 'next/dynamic'; +import { redirect } from 'next/navigation'; +import React from 'react'; + +import type { Model } from '../api-harmonization/order-confirmation.client'; +import { sdk } from '../sdk'; + +import { OrderConfirmationProps } from './OrderConfirmation.types'; +import { OrderConfirmationError } from './OrderConfirmationError.client'; + +export const OrderConfirmationDynamic = dynamic(() => + import('./OrderConfirmation.client').then((module) => module.OrderConfirmationPure), +); + +function getHttpStatus(err: unknown): number | undefined { + const e = err as { status?: number; response?: { status?: number } }; + return e?.status ?? e?.response?.status; +} + +function getErrorMessage(err: unknown): string | undefined { + const e = err as { message?: string; data?: { message?: string } }; + return e?.message ?? e?.data?.message; +} + +export const OrderConfirmation: React.FC<OrderConfirmationProps> = async ({ + id, + orderId, + accessToken, + locale, + routing, +}) => { + let data: Model.OrderConfirmationBlock; + try { + data = await sdk.blocks.getOrderConfirmation( + { + id, + orderId, + }, + { 'x-locale': locale }, + accessToken, + ); + } catch (error) { + const status = getHttpStatus(error); + if (status === 401 || status === 404) { + redirect('/orders'); + } + return <OrderConfirmationError routing={routing} message={getErrorMessage(error)} redirectPath="/" />; + } + + return ( + <OrderConfirmationDynamic + {...data} + id={id} + orderId={orderId} + accessToken={accessToken} + locale={locale} + routing={routing} + /> + ); +}; diff --git a/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.types.ts b/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.types.ts new file mode 100644 index 000000000..cafac5392 --- /dev/null +++ b/packages/blocks/order-confirmation/src/frontend/OrderConfirmation.types.ts @@ -0,0 +1,17 @@ +import { defineRouting } from 'next-intl/routing'; + +import type { Model } from '../api-harmonization/order-confirmation.client'; + +export interface OrderConfirmationProps { + id: string; + orderId: string; + accessToken?: string; + locale: string; + routing: ReturnType<typeof defineRouting>; +} + +export type OrderConfirmationPureProps = OrderConfirmationProps & Model.OrderConfirmationBlock; + +export type OrderConfirmationRendererProps = Omit<OrderConfirmationProps, 'orderId'> & { + slug: string[]; +}; diff --git a/packages/blocks/order-confirmation/src/frontend/OrderConfirmationError.client.tsx b/packages/blocks/order-confirmation/src/frontend/OrderConfirmationError.client.tsx new file mode 100644 index 000000000..50c6c0a29 --- /dev/null +++ b/packages/blocks/order-confirmation/src/frontend/OrderConfirmationError.client.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { createNavigation } from 'next-intl/navigation'; +import React from 'react'; + +import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; + +import { Button } from '@o2s/ui/elements/button'; +import { Typography } from '@o2s/ui/elements/typography'; + +import type { OrderConfirmationProps } from './OrderConfirmation.types'; + +export interface OrderConfirmationErrorProps extends Pick<OrderConfirmationProps, 'routing'> { + message?: string; + redirectPath?: string; +} + +const DEFAULT_MESSAGE = 'Something went wrong. Please try again.'; + +export const OrderConfirmationError: React.FC<Readonly<OrderConfirmationErrorProps>> = ({ + message = DEFAULT_MESSAGE, + redirectPath = '/', + routing, +}) => { + const { Link: LinkComponent } = createNavigation(routing); + + return ( + <div className="w-full max-w-2xl mx-auto flex flex-col gap-8 items-center justify-center py-12"> + <div className="flex h-20 w-20 items-center justify-center rounded-full bg-destructive/10"> + <DynamicIcon name="AlertCircle" size={40} className="text-destructive" /> + </div> + <div className="flex flex-col gap-2 text-center"> + <Typography variant="h2">Error</Typography> + <Typography variant="body" className="text-muted-foreground"> + {message} + </Typography> + </div> + <Button asChild variant="default"> + <LinkComponent href={redirectPath}>Go back</LinkComponent> + </Button> + </div> + ); +}; diff --git a/packages/blocks/order-confirmation/src/frontend/index.ts b/packages/blocks/order-confirmation/src/frontend/index.ts new file mode 100644 index 000000000..b83c3902d --- /dev/null +++ b/packages/blocks/order-confirmation/src/frontend/index.ts @@ -0,0 +1,5 @@ +export { OrderConfirmationPure as Client } from './OrderConfirmation.client'; +export { OrderConfirmation as Server } from './OrderConfirmation.server'; +export { OrderConfirmationRenderer as Renderer } from './OrderConfirmation.renderer'; + +export * as Types from './OrderConfirmation.types'; diff --git a/packages/blocks/order-confirmation/src/sdk/index.ts b/packages/blocks/order-confirmation/src/sdk/index.ts new file mode 100644 index 000000000..6d2d095fe --- /dev/null +++ b/packages/blocks/order-confirmation/src/sdk/index.ts @@ -0,0 +1,28 @@ +// this unused import is necessary for TypeScript to properly resolve API methods +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Models } from '@o2s/utils.api-harmonization'; + +import { extendSdk, getSdk } from '@o2s/framework/sdk'; + +import { orderConfirmation } from './order-confirmation'; + +const API_URL = + (typeof window === 'undefined' ? process.env.NEXT_PUBLIC_API_URL_INTERNAL : process.env.NEXT_PUBLIC_API_URL) || + process.env.NEXT_PUBLIC_API_URL; + +const internalSdk = getSdk({ + apiUrl: API_URL!, + logger: { + // @ts-expect-error missing types + level: process.env.NEXT_PUBLIC_LOG_LEVEL, + // @ts-expect-error missing types + format: process.env.NEXT_PUBLIC_LOG_FORMAT, + colorsEnabled: process.env.NEXT_PUBLIC_LOG_COLORS_ENABLED === 'true', + }, +}); + +export const sdk = extendSdk(internalSdk, { + blocks: { + getOrderConfirmation: orderConfirmation(internalSdk).blocks.getOrderConfirmation, + }, +}); diff --git a/packages/blocks/order-confirmation/src/sdk/order-confirmation.ts b/packages/blocks/order-confirmation/src/sdk/order-confirmation.ts new file mode 100644 index 000000000..f6edd3cd5 --- /dev/null +++ b/packages/blocks/order-confirmation/src/sdk/order-confirmation.ts @@ -0,0 +1,32 @@ +import { Models } from '@o2s/utils.api-harmonization'; +import { Utils } from '@o2s/utils.frontend'; + +import { Sdk } from '@o2s/framework/sdk'; + +import { Model, Request, URL } from '../api-harmonization/order-confirmation.client'; + +const API_URL = URL; + +export const orderConfirmation = (sdk: Sdk) => ({ + blocks: { + getOrderConfirmation: ( + query: Request.GetOrderConfirmationBlockQuery, + headers: Models.Headers.AppHeaders, + authorization?: string, + ): Promise<Model.OrderConfirmationBlock> => + sdk.makeRequest({ + method: 'get', + url: `${API_URL}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + params: query, + }), + }, +}); diff --git a/packages/blocks/order-confirmation/tsconfig.api.json b/packages/blocks/order-confirmation/tsconfig.api.json new file mode 100644 index 000000000..0f9f79f8e --- /dev/null +++ b/packages/blocks/order-confirmation/tsconfig.api.json @@ -0,0 +1,14 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "outDir": "./dist/api-harmonization", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src/api-harmonization", + }, + "include": ["src/api-harmonization"] +} diff --git a/packages/blocks/order-confirmation/tsconfig.frontend.json b/packages/blocks/order-confirmation/tsconfig.frontend.json new file mode 100644 index 000000000..9e2d3ebeb --- /dev/null +++ b/packages/blocks/order-confirmation/tsconfig.frontend.json @@ -0,0 +1,22 @@ +{ + "extends": "@o2s/typescript-config/frontend.json", + "compilerOptions": { + "outDir": "./dist/frontend", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "noEmit": false, + "jsx": "react", + "baseUrl": "./src/frontend" + }, + "include": [ + "src/frontend", + "src/api-harmonization/order-confirmation.client.ts", + "src/api-harmonization/order-confirmation.model.ts", + "src/api-harmonization/order-confirmation.request.ts", + "src/sdk" + ] +} diff --git a/packages/blocks/order-confirmation/tsconfig.json b/packages/blocks/order-confirmation/tsconfig.json new file mode 100644 index 000000000..c3031c1dd --- /dev/null +++ b/packages/blocks/order-confirmation/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@o2s/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src", + }, + "references": [ + { "path": "./tsconfig.frontend.json" }, + { "path": "./tsconfig.api.json" }, + { "path": "./tsconfig.sdk.json" } + ] +} diff --git a/packages/blocks/order-confirmation/tsconfig.sdk.json b/packages/blocks/order-confirmation/tsconfig.sdk.json new file mode 100644 index 000000000..70d763e7b --- /dev/null +++ b/packages/blocks/order-confirmation/tsconfig.sdk.json @@ -0,0 +1,19 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "outDir": "./dist/sdk", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": "./src/sdk" + }, + "include": [ + "src/sdk", + "src/api-harmonization/order-confirmation.client.ts", + "src/api-harmonization/order-confirmation.model.ts", + "src/api-harmonization/order-confirmation.request.ts" + ] +} diff --git a/packages/blocks/order-confirmation/vitest.config.mjs b/packages/blocks/order-confirmation/vitest.config.mjs new file mode 100644 index 000000000..82be23c07 --- /dev/null +++ b/packages/blocks/order-confirmation/vitest.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/vitest-config/block'; + +export default config; diff --git a/packages/blocks/order-details/CHANGELOG.md b/packages/blocks/order-details/CHANGELOG.md index fbcfcc389..d0f8e9d92 100644 --- a/packages/blocks/order-details/CHANGELOG.md +++ b/packages/blocks/order-details/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.order-details +## 1.5.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.5.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.5.0 ### Minor Changes diff --git a/packages/blocks/order-details/package.json b/packages/blocks/order-details/package.json index cd880ac70..2b1453b1b 100644 --- a/packages/blocks/order-details/package.json +++ b/packages/blocks/order-details/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.order-details", - "version": "1.5.0", + "version": "1.5.2", "private": false, "license": "MIT", "description": "A block displaying details for a specific order, including items, status, and history.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/order-details/src/api-harmonization/order-details.controller.ts b/packages/blocks/order-details/src/api-harmonization/order-details.controller.ts index 3f32c6a94..f90aefe2d 100644 --- a/packages/blocks/order-details/src/api-harmonization/order-details.controller.ts +++ b/packages/blocks/order-details/src/api-harmonization/order-details.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Param, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -17,7 +17,7 @@ export class OrderDetailsController { @Get(':id') @Auth.Decorators.Permissions({ resource: 'orders', actions: ['view'] }) getOrderDetailsBlock( - @Headers() headers: ApiModels.Headers.AppHeaders, + @Headers() headers: AppHeaders, @Query() query: GetOrderDetailsBlockQuery, @Param() params: GetOrderDetailsBlockParams, ) { diff --git a/packages/blocks/order-details/src/api-harmonization/order-details.service.ts b/packages/blocks/order-details/src/api-harmonization/order-details.service.ts index 5be0fd95c..391be1335 100644 --- a/packages/blocks/order-details/src/api-harmonization/order-details.service.ts +++ b/packages/blocks/order-details/src/api-harmonization/order-details.service.ts @@ -3,14 +3,15 @@ import { ConfigService } from '@nestjs/config'; import { CMS, Orders } from '@o2s/configs.integrations'; import { Observable, concatMap, forkJoin, map } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapOrderDetails } from './order-details.mapper'; import { OrderDetailsBlock } from './order-details.model'; import { GetOrderDetailsBlockParams, GetOrderDetailsBlockQuery } from './order-details.request'; +const H = HeaderName; + @Injectable() export class OrderDetailsService { private readonly defaultProductUnit: string; @@ -27,9 +28,10 @@ export class OrderDetailsService { getOrderDetailsBlock( params: GetOrderDetailsBlockParams, query: GetOrderDetailsBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, ): Observable<OrderDetailsBlock> { - const cms = this.cmsService.getOrderDetailsBlock({ ...query, locale: headers['x-locale'] }); + const authorization = headers[H.Authorization]; + const cms = this.cmsService.getOrderDetailsBlock({ ...query, locale: headers[H.Locale] }); return forkJoin([cms]).pipe( concatMap(([cms]) => { @@ -41,7 +43,7 @@ export class OrderDetailsService { offset: query.offset || 0, sort: query.sort || '', }, - headers['authorization'], + authorization, ) .pipe( map((order) => { @@ -51,18 +53,19 @@ export class OrderDetailsService { const result = mapOrderDetails( cms, order, - headers['x-locale'], - headers['x-client-timezone'] || '', + headers[H.Locale], + headers[H.ClientTimezone] || '', this.defaultProductUnit, ); // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions( - headers.authorization, - 'orders', - ['view', 'edit', 'cancel', 'track'], - ); + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'orders', [ + 'view', + 'edit', + 'cancel', + 'track', + ]); result.permissions = { view: permissions.view ?? false, diff --git a/packages/blocks/order-details/src/frontend/OrderDetails.types.ts b/packages/blocks/order-details/src/frontend/OrderDetails.types.ts index b23076e6f..41b11b5a9 100644 --- a/packages/blocks/order-details/src/frontend/OrderDetails.types.ts +++ b/packages/blocks/order-details/src/frontend/OrderDetails.types.ts @@ -1,24 +1,19 @@ import { VariantProps } from 'class-variance-authority'; import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import { baseVariant } from '@o2s/ui/lib/utils'; import type { Model } from '../api-harmonization/order-details.client'; -export interface OrderDetailsProps { - id: string; +export interface OrderDetailsProps extends Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>> { orderId: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; } export type OrderDetailsPureProps = OrderDetailsProps & Model.OrderDetailsBlock; -export interface OrderDetailsRendererProps extends Omit<OrderDetailsProps, 'orderId'> { - slug: string[]; -} +export type OrderDetailsRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; export type Action = { variant: VariantProps<typeof baseVariant>['variant']; diff --git a/packages/blocks/order-details/src/sdk/index.ts b/packages/blocks/order-details/src/sdk/index.ts index e559b91e5..ece86c3d9 100644 --- a/packages/blocks/order-details/src/sdk/index.ts +++ b/packages/blocks/order-details/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { orderDetails } from './order-details'; diff --git a/packages/blocks/order-details/src/sdk/order-details.ts b/packages/blocks/order-details/src/sdk/order-details.ts index 9331ba035..5b8f22398 100644 --- a/packages/blocks/order-details/src/sdk/order-details.ts +++ b/packages/blocks/order-details/src/sdk/order-details.ts @@ -1,6 +1,6 @@ -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/order-details.client'; @@ -13,7 +13,7 @@ export const orderDetails = (sdk: Sdk) => ({ getOrderDetails: ( params: Request.GetOrderDetailsBlockParams, query: Request.GetOrderDetailsBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.OrderDetailsBlock> => sdk.makeRequest({ @@ -30,7 +30,7 @@ export const orderDetails = (sdk: Sdk) => ({ }, params: query, }), - getOrderPdf: (id: string, headers: ApiModels.Headers.AppHeaders, authorization?: string): Promise<Blob> => + getOrderPdf: (id: string, headers: AppHeaders, authorization?: string): Promise<Blob> => sdk.makeRequest({ method: 'get', url: `${API_URL}/documents/${id}/pdf`, diff --git a/packages/blocks/order-list/CHANGELOG.md b/packages/blocks/order-list/CHANGELOG.md index a0c9b1143..12247241b 100644 --- a/packages/blocks/order-list/CHANGELOG.md +++ b/packages/blocks/order-list/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.order-list +## 1.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.6.0 ### Minor Changes diff --git a/packages/blocks/order-list/package.json b/packages/blocks/order-list/package.json index 015280860..f29a1235d 100644 --- a/packages/blocks/order-list/package.json +++ b/packages/blocks/order-list/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.order-list", - "version": "1.6.0", + "version": "1.6.2", "private": false, "license": "MIT", "description": "A block displaying a list of orders.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/order-list/src/api-harmonization/order-list.controller.ts b/packages/blocks/order-list/src/api-harmonization/order-list.controller.ts index a5279d57a..ee8d44bbb 100644 --- a/packages/blocks/order-list/src/api-harmonization/order-list.controller.ts +++ b/packages/blocks/order-list/src/api-harmonization/order-list.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class OrderListController { @Get() @Auth.Decorators.Permissions({ resource: 'orders', actions: ['view'] }) - getOrderListBlock(@Headers() headers: ApiModels.Headers.AppHeaders, @Query() query: GetOrderListBlockQuery) { + getOrderListBlock(@Headers() headers: AppHeaders, @Query() query: GetOrderListBlockQuery) { return this.service.getOrderListBlock(query, headers); } } diff --git a/packages/blocks/order-list/src/api-harmonization/order-list.service.ts b/packages/blocks/order-list/src/api-harmonization/order-list.service.ts index bc77d34b9..a5b19e45f 100644 --- a/packages/blocks/order-list/src/api-harmonization/order-list.service.ts +++ b/packages/blocks/order-list/src/api-harmonization/order-list.service.ts @@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common'; import { CMS, Orders } from '@o2s/configs.integrations'; import { Observable, concatMap, forkJoin, map } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapOrderList } from './order-list.mapper'; import { OrderListBlock } from './order-list.model'; import { GetOrderListBlockQuery } from './order-list.request'; +const H = HeaderName; + @Injectable() export class OrderListService { constructor( @@ -18,11 +19,9 @@ export class OrderListService { private readonly authService: Auth.Service, ) {} - getOrderListBlock( - query: GetOrderListBlockQuery, - headers: ApiModels.Headers.AppHeaders, - ): Observable<OrderListBlock> { - const cms = this.cmsService.getOrderListBlock({ ...query, locale: headers['x-locale'] }); + getOrderListBlock(query: GetOrderListBlockQuery, headers: AppHeaders): Observable<OrderListBlock> { + const authorization = headers[H.Authorization]; + const cms = this.cmsService.getOrderListBlock({ ...query, locale: headers[H.Locale] }); return forkJoin([cms]).pipe( concatMap(([cms]) => { @@ -33,26 +32,27 @@ export class OrderListService { ...query, limit: query.limit || cms.pagination?.limit || 1, offset: query.offset || 0, - locale: headers['x-locale'], + locale: headers[H.Locale], }, - headers['authorization'], + authorization, ) .pipe( map((orders) => { const result = mapOrderList( orders, cms, - headers['x-locale'], - headers['x-client-timezone'] || '', + headers[H.Locale], + headers[H.ClientTimezone] || '', ); // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions( - headers.authorization, - 'orders', - ['view', 'create', 'cancel', 'track'], - ); + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'orders', [ + 'view', + 'create', + 'cancel', + 'track', + ]); result.permissions = { view: permissions.view ?? false, diff --git a/packages/blocks/order-list/src/frontend/OrderList.types.ts b/packages/blocks/order-list/src/frontend/OrderList.types.ts index b77e2e8f0..ed6029a8a 100644 --- a/packages/blocks/order-list/src/frontend/OrderList.types.ts +++ b/packages/blocks/order-list/src/frontend/OrderList.types.ts @@ -1,18 +1,14 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/order-list.client'; -export interface OrderListProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; +export interface OrderListProps extends Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>> { enableRowSelection?: boolean; } -export interface OrderListRendererProps extends Omit<OrderListProps, ''> { - slug: string[]; -} +export type OrderListRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>> & + Pick<OrderListProps, 'enableRowSelection'>; export type OrderListPureProps = OrderListProps & Model.OrderListBlock; diff --git a/packages/blocks/order-list/src/sdk/index.ts b/packages/blocks/order-list/src/sdk/index.ts index 3ed33c7dc..d8088d6f1 100644 --- a/packages/blocks/order-list/src/sdk/index.ts +++ b/packages/blocks/order-list/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { orderList } from './order-list'; diff --git a/packages/blocks/order-list/src/sdk/order-list.ts b/packages/blocks/order-list/src/sdk/order-list.ts index db78cfc03..fd5b258a0 100644 --- a/packages/blocks/order-list/src/sdk/order-list.ts +++ b/packages/blocks/order-list/src/sdk/order-list.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/order-list.client'; @@ -10,7 +10,7 @@ export const orderList = (sdk: Sdk) => ({ blocks: { getOrderList: ( query: Request.GetOrderListBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.OrderListBlock> => sdk.makeRequest({ diff --git a/packages/blocks/orders-summary/CHANGELOG.md b/packages/blocks/orders-summary/CHANGELOG.md index 477922e7e..b6e9bb9c1 100644 --- a/packages/blocks/orders-summary/CHANGELOG.md +++ b/packages/blocks/orders-summary/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.orders-summary +## 1.5.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.5.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.5.0 ### Minor Changes diff --git a/packages/blocks/orders-summary/package.json b/packages/blocks/orders-summary/package.json index a644612a5..3a2ff7665 100644 --- a/packages/blocks/orders-summary/package.json +++ b/packages/blocks/orders-summary/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.orders-summary", - "version": "1.5.0", + "version": "1.5.2", "private": false, "license": "MIT", "description": "A block displaying a summary of orders, including statistics and recent activity.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/orders-summary/src/api-harmonization/orders-summary.controller.ts b/packages/blocks/orders-summary/src/api-harmonization/orders-summary.controller.ts index a6e5500dd..d571d006f 100644 --- a/packages/blocks/orders-summary/src/api-harmonization/orders-summary.controller.ts +++ b/packages/blocks/orders-summary/src/api-harmonization/orders-summary.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,10 +16,7 @@ export class OrdersSummaryController { @Get() @Auth.Decorators.Permissions({ resource: 'orders', actions: ['view'] }) - getOrdersSummaryBlock( - @Headers() headers: ApiModels.Headers.AppHeaders, - @Query() query: GetOrdersSummaryBlockQuery, - ) { + getOrdersSummaryBlock(@Headers() headers: AppHeaders, @Query() query: GetOrdersSummaryBlockQuery) { return this.service.getOrdersSummaryBlock(query, headers); } } diff --git a/packages/blocks/orders-summary/src/api-harmonization/orders-summary.service.ts b/packages/blocks/orders-summary/src/api-harmonization/orders-summary.service.ts index a1cb696f5..50b1ff09d 100644 --- a/packages/blocks/orders-summary/src/api-harmonization/orders-summary.service.ts +++ b/packages/blocks/orders-summary/src/api-harmonization/orders-summary.service.ts @@ -3,14 +3,15 @@ import { CMS, Orders } from '@o2s/configs.integrations'; import dayjs from 'dayjs'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapOrdersSummary } from './orders-summary.mapper'; import { OrdersSummaryBlock } from './orders-summary.model'; import { GetOrdersSummaryBlockQuery } from './orders-summary.request'; +const H = HeaderName; + @Injectable() export class OrdersSummaryService { constructor( @@ -19,32 +20,30 @@ export class OrdersSummaryService { private readonly authService: Auth.Service, ) {} - getOrdersSummaryBlock( - query: GetOrdersSummaryBlockQuery, - headers: ApiModels.Headers.AppHeaders, - ): Observable<OrdersSummaryBlock> { - const cms = this.cmsService.getOrdersSummaryBlock({ ...query, locale: headers['x-locale'] }); + getOrdersSummaryBlock(query: GetOrdersSummaryBlockQuery, headers: AppHeaders): Observable<OrdersSummaryBlock> { + const authorization = headers[H.Authorization]; + const cms = this.cmsService.getOrdersSummaryBlock({ ...query, locale: headers[H.Locale] }); const ordersCurrent = this.orderService.getOrderList( { ...query, limit: 1000, - locale: headers['x-locale'], + locale: headers[H.Locale], dateFrom: dayjs(query.dateFrom).toDate(), dateTo: dayjs(query.dateTo).toDate(), }, - headers['authorization'], + authorization, ); const ordersPrevious = this.orderService.getOrderList( { ...query, limit: 1000, - locale: headers['x-locale'], + locale: headers[H.Locale], dateFrom: dayjs(query.dateFrom).subtract(1, 'year').toDate(), dateTo: dayjs(query.dateTo).subtract(1, 'year').toDate(), }, - headers['authorization'], + authorization, ); const diff = Math.abs( @@ -59,15 +58,12 @@ export class OrdersSummaryService { ordersPrevious, query.range, diff, - headers['x-locale'], + headers[H.Locale], ); // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions(headers.authorization, 'orders', [ - 'view', - 'create', - ]); + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'orders', ['view', 'create']); result.permissions = { view: permissions.view ?? false, diff --git a/packages/blocks/orders-summary/src/frontend/OrdersSummary.types.ts b/packages/blocks/orders-summary/src/frontend/OrdersSummary.types.ts index 8ec09d52b..e5e072496 100644 --- a/packages/blocks/orders-summary/src/frontend/OrdersSummary.types.ts +++ b/packages/blocks/orders-summary/src/frontend/OrdersSummary.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/orders-summary.client'; -export interface OrdersSummaryProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type OrdersSummaryProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type OrdersSummaryPureProps = OrdersSummaryProps & Model.OrdersSummaryBlock; -export interface OrdersSummaryRendererProps extends Omit<OrdersSummaryProps, ''> { - slug: string[]; -} +export type OrdersSummaryRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/orders-summary/src/sdk/index.ts b/packages/blocks/orders-summary/src/sdk/index.ts index a35b9bf3e..3cf95717c 100644 --- a/packages/blocks/orders-summary/src/sdk/index.ts +++ b/packages/blocks/orders-summary/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { ordersSummary } from './orders-summary'; diff --git a/packages/blocks/orders-summary/src/sdk/orders-summary.ts b/packages/blocks/orders-summary/src/sdk/orders-summary.ts index b7e222981..c1f1563ef 100644 --- a/packages/blocks/orders-summary/src/sdk/orders-summary.ts +++ b/packages/blocks/orders-summary/src/sdk/orders-summary.ts @@ -1,6 +1,6 @@ -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/orders-summary.client'; @@ -12,7 +12,7 @@ export const ordersSummary = (sdk: Sdk) => ({ blocks: { getOrdersSummary: ( query: Request.GetOrdersSummaryBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.OrdersSummaryBlock> => sdk.makeRequest({ diff --git a/packages/blocks/payments-history/CHANGELOG.md b/packages/blocks/payments-history/CHANGELOG.md index a15c4a8f6..df0330ce9 100644 --- a/packages/blocks/payments-history/CHANGELOG.md +++ b/packages/blocks/payments-history/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.payments-history +## 1.4.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.4.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.4.0 ### Minor Changes diff --git a/packages/blocks/payments-history/package.json b/packages/blocks/payments-history/package.json index af87e0e84..71ee47ced 100644 --- a/packages/blocks/payments-history/package.json +++ b/packages/blocks/payments-history/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.payments-history", - "version": "1.4.0", + "version": "1.4.2", "private": false, "license": "MIT", "description": "A block displaying payment history.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/payments-history/src/api-harmonization/payments-history.controller.ts b/packages/blocks/payments-history/src/api-harmonization/payments-history.controller.ts index 8df17f010..01c5bafd7 100644 --- a/packages/blocks/payments-history/src/api-harmonization/payments-history.controller.ts +++ b/packages/blocks/payments-history/src/api-harmonization/payments-history.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './payments-history.client'; @@ -16,10 +16,7 @@ export class PaymentsHistoryController { @Get() @Auth.Decorators.Permissions({ resource: 'invoices', actions: ['view'] }) - getPaymentsHistoryBlock( - @Headers() headers: Models.Headers.AppHeaders, - @Query() query: GetPaymentsHistoryBlockQuery, - ) { + getPaymentsHistoryBlock(@Headers() headers: AppHeaders, @Query() query: GetPaymentsHistoryBlockQuery) { return this.service.getPaymentsHistoryBlock(query, headers); } } diff --git a/packages/blocks/payments-history/src/api-harmonization/payments-history.service.ts b/packages/blocks/payments-history/src/api-harmonization/payments-history.service.ts index 90be99993..c22e7742e 100644 --- a/packages/blocks/payments-history/src/api-harmonization/payments-history.service.ts +++ b/packages/blocks/payments-history/src/api-harmonization/payments-history.service.ts @@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common'; import { CMS, Invoices } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapPaymentsHistory } from './payments-history.mapper'; import { PaymentsHistoryBlock } from './payments-history.model'; import { GetPaymentsHistoryBlockQuery } from './payments-history.request'; +const H = HeaderName; + @Injectable() export class PaymentsHistoryService { constructor( @@ -20,21 +21,19 @@ export class PaymentsHistoryService { getPaymentsHistoryBlock( query: GetPaymentsHistoryBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, ): Observable<PaymentsHistoryBlock> { - const cms = this.cmsService.getPaymentsHistoryBlock({ ...query, locale: headers['x-locale'] }); + const cms = this.cmsService.getPaymentsHistoryBlock({ ...query, locale: headers[H.Locale] }); const invoices = this.invoiceService.getInvoiceList(query); return forkJoin([cms, invoices]).pipe( map(([cms, invoices]) => { - const result = mapPaymentsHistory(cms, invoices, headers['x-locale']); + const result = mapPaymentsHistory(cms, invoices, headers[H.Locale]); + const authorization = headers[H.Authorization]; // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions(headers.authorization, 'invoices', [ - 'view', - 'pay', - ]); + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'invoices', ['view', 'pay']); result.permissions = { view: permissions.view ?? false, diff --git a/packages/blocks/payments-history/src/frontend/PaymentsHistory.types.ts b/packages/blocks/payments-history/src/frontend/PaymentsHistory.types.ts index 635363f3d..37cfa62fb 100644 --- a/packages/blocks/payments-history/src/frontend/PaymentsHistory.types.ts +++ b/packages/blocks/payments-history/src/frontend/PaymentsHistory.types.ts @@ -1,10 +1,7 @@ +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/payments-history.client'; -export interface PaymentsHistoryProps { - id: string; - accessToken?: string; - locale: string; - hasPriority?: boolean; -} +export type PaymentsHistoryProps = Models.BlockProps.BaseBlockProps; export type PaymentsHistoryPureProps = PaymentsHistoryProps & Model.PaymentsHistoryBlock; diff --git a/packages/blocks/payments-history/src/sdk/index.ts b/packages/blocks/payments-history/src/sdk/index.ts index 5e1c7f18d..5d766b57a 100644 --- a/packages/blocks/payments-history/src/sdk/index.ts +++ b/packages/blocks/payments-history/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { paymentsHistory } from './payments-history'; diff --git a/packages/blocks/payments-history/src/sdk/payments-history.ts b/packages/blocks/payments-history/src/sdk/payments-history.ts index 6e1154177..f52051025 100644 --- a/packages/blocks/payments-history/src/sdk/payments-history.ts +++ b/packages/blocks/payments-history/src/sdk/payments-history.ts @@ -1,6 +1,6 @@ -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/payments-history.client'; @@ -12,7 +12,7 @@ export const paymentsHistory = (sdk: Sdk) => ({ blocks: { getPaymentsHistory: ( params: Request.GetPaymentsHistoryBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.PaymentsHistoryBlock> => sdk.makeRequest({ diff --git a/packages/blocks/payments-summary/CHANGELOG.md b/packages/blocks/payments-summary/CHANGELOG.md index a672f51b5..4e3bfbfad 100644 --- a/packages/blocks/payments-summary/CHANGELOG.md +++ b/packages/blocks/payments-summary/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.payments-summary +## 1.4.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.4.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.4.0 ### Minor Changes diff --git a/packages/blocks/payments-summary/package.json b/packages/blocks/payments-summary/package.json index 335dc1fef..0f7d9d222 100644 --- a/packages/blocks/payments-summary/package.json +++ b/packages/blocks/payments-summary/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.payments-summary", - "version": "1.4.0", + "version": "1.4.2", "private": false, "license": "MIT", "description": "A block displaying a summary of payments, including overdue and to-be-paid amounts.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/payments-summary/src/api-harmonization/payments-summary.controller.ts b/packages/blocks/payments-summary/src/api-harmonization/payments-summary.controller.ts index 6947757cd..8f0eef4d0 100644 --- a/packages/blocks/payments-summary/src/api-harmonization/payments-summary.controller.ts +++ b/packages/blocks/payments-summary/src/api-harmonization/payments-summary.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -17,10 +17,7 @@ export class PaymentsSummaryController { @Get() @Auth.Decorators.Roles({ roles: ['selfservice_org_user'] }) @Auth.Decorators.Permissions({ resource: 'invoices', actions: ['view'] }) - getPaymentsSummaryBlock( - @Headers() headers: Models.Headers.AppHeaders, - @Query() query: GetPaymentsSummaryBlockQuery, - ) { + getPaymentsSummaryBlock(@Headers() headers: AppHeaders, @Query() query: GetPaymentsSummaryBlockQuery) { return this.service.getPaymentsSummaryBlock(query, headers); } } diff --git a/packages/blocks/payments-summary/src/api-harmonization/payments-summary.service.ts b/packages/blocks/payments-summary/src/api-harmonization/payments-summary.service.ts index 3c2276c39..d2d632fb3 100644 --- a/packages/blocks/payments-summary/src/api-harmonization/payments-summary.service.ts +++ b/packages/blocks/payments-summary/src/api-harmonization/payments-summary.service.ts @@ -3,14 +3,15 @@ import { ConfigService } from '@nestjs/config'; import { CMS, Invoices } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth, Models } from '@o2s/framework/modules'; import { mapPaymentsSummary } from './payments-summary.mapper'; import { PaymentsSummaryBlock } from './payments-summary.model'; import { GetPaymentsSummaryBlockQuery } from './payments-summary.request'; +const H = HeaderName; + @Injectable() export class PaymentsSummaryService { private readonly defaultCurrency: Models.Price.Currency; @@ -26,21 +27,19 @@ export class PaymentsSummaryService { getPaymentsSummaryBlock( query: GetPaymentsSummaryBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, ): Observable<PaymentsSummaryBlock> { - const cms = this.cmsService.getPaymentsSummaryBlock({ ...query, locale: headers['x-locale'] }); + const cms = this.cmsService.getPaymentsSummaryBlock({ ...query, locale: headers[H.Locale] }); const invoices = this.invoiceService.getInvoiceList(query); return forkJoin([invoices, cms]).pipe( map(([invoices, cms]) => { - const result = mapPaymentsSummary(cms, invoices, headers['x-locale'], this.defaultCurrency); + const result = mapPaymentsSummary(cms, invoices, headers[H.Locale], this.defaultCurrency); + const authorization = headers[H.Authorization]; // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions(headers.authorization, 'invoices', [ - 'view', - 'pay', - ]); + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'invoices', ['view', 'pay']); result.permissions = { view: permissions.view ?? false, diff --git a/packages/blocks/payments-summary/src/frontend/PaymentsSummary.types.ts b/packages/blocks/payments-summary/src/frontend/PaymentsSummary.types.ts index 8804d0c31..db36a4500 100644 --- a/packages/blocks/payments-summary/src/frontend/PaymentsSummary.types.ts +++ b/packages/blocks/payments-summary/src/frontend/PaymentsSummary.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/payments-summary.client'; -export interface PaymentsSummaryProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type PaymentsSummaryProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type PaymentsSummaryPureProps = PaymentsSummaryProps & Model.PaymentsSummaryBlock; -export interface PaymentsSummaryRendererProps extends Omit<PaymentsSummaryProps, ''> { - slug: string[]; -} +export type PaymentsSummaryRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/payments-summary/src/sdk/index.ts b/packages/blocks/payments-summary/src/sdk/index.ts index 053916414..3a2653379 100644 --- a/packages/blocks/payments-summary/src/sdk/index.ts +++ b/packages/blocks/payments-summary/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { paymentsSummary } from './payments-summary'; diff --git a/packages/blocks/payments-summary/src/sdk/payments-summary.ts b/packages/blocks/payments-summary/src/sdk/payments-summary.ts index 412f26817..e1bafeabe 100644 --- a/packages/blocks/payments-summary/src/sdk/payments-summary.ts +++ b/packages/blocks/payments-summary/src/sdk/payments-summary.ts @@ -1,6 +1,6 @@ -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/payments-summary.client'; @@ -12,7 +12,7 @@ export const paymentsSummary = (sdk: Sdk) => ({ blocks: { getPaymentsSummary: ( query: Request.GetPaymentsSummaryBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.PaymentsSummaryBlock> => sdk.makeRequest({ diff --git a/packages/blocks/pricing-section/CHANGELOG.md b/packages/blocks/pricing-section/CHANGELOG.md index e634e4b21..74a21da4c 100644 --- a/packages/blocks/pricing-section/CHANGELOG.md +++ b/packages/blocks/pricing-section/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.pricing-section +## 0.6.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 0.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 0.6.0 ### Minor Changes diff --git a/packages/blocks/pricing-section/package.json b/packages/blocks/pricing-section/package.json index fef734055..4608af9bf 100644 --- a/packages/blocks/pricing-section/package.json +++ b/packages/blocks/pricing-section/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.pricing-section", - "version": "0.6.0", + "version": "0.6.2", "private": false, "license": "MIT", "description": "A simple block displaying static content in the form of an PricingSection.", @@ -50,7 +50,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/pricing-section/src/api-harmonization/pricing-section.controller.ts b/packages/blocks/pricing-section/src/api-harmonization/pricing-section.controller.ts index 7d1482cda..e047ab868 100644 --- a/packages/blocks/pricing-section/src/api-harmonization/pricing-section.controller.ts +++ b/packages/blocks/pricing-section/src/api-harmonization/pricing-section.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class PricingSectionController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getPricingSectionBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetPricingSectionBlockQuery) { + getPricingSectionBlock(@Headers() headers: AppHeaders, @Query() query: GetPricingSectionBlockQuery) { return this.service.getPricingSectionBlock(query, headers); } } diff --git a/packages/blocks/pricing-section/src/api-harmonization/pricing-section.model.ts b/packages/blocks/pricing-section/src/api-harmonization/pricing-section.model.ts index b32d0f2bb..83bcf57b3 100644 --- a/packages/blocks/pricing-section/src/api-harmonization/pricing-section.model.ts +++ b/packages/blocks/pricing-section/src/api-harmonization/pricing-section.model.ts @@ -1,8 +1,8 @@ import { CMS } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -export class PricingSectionBlock extends Models.Block.Block { +export class PricingSectionBlock extends ApiModels.Block.Block { __typename!: 'PricingSectionBlock'; title?: CMS.Model.PricingSectionBlock.PricingSectionBlock['title']; subtitle?: CMS.Model.PricingSectionBlock.PricingSectionBlock['subtitle']; diff --git a/packages/blocks/pricing-section/src/api-harmonization/pricing-section.service.ts b/packages/blocks/pricing-section/src/api-harmonization/pricing-section.service.ts index b3be8287f..925fa60fd 100644 --- a/packages/blocks/pricing-section/src/api-harmonization/pricing-section.service.ts +++ b/packages/blocks/pricing-section/src/api-harmonization/pricing-section.service.ts @@ -2,22 +2,21 @@ import { Injectable } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapPricingSection } from './pricing-section.mapper'; import { PricingSectionBlock } from './pricing-section.model'; import { GetPricingSectionBlockQuery } from './pricing-section.request'; +const H = HeaderName; + @Injectable() export class PricingSectionService { constructor(private readonly cmsService: CMS.Service) {} - getPricingSectionBlock( - query: GetPricingSectionBlockQuery, - headers: Models.Headers.AppHeaders, - ): Observable<PricingSectionBlock> { - const cms = this.cmsService.getPricingSectionBlock({ ...query, locale: headers['x-locale'] }); + getPricingSectionBlock(query: GetPricingSectionBlockQuery, headers: AppHeaders): Observable<PricingSectionBlock> { + const cms = this.cmsService.getPricingSectionBlock({ ...query, locale: headers[H.Locale] }); - return forkJoin([cms]).pipe(map(([cms]) => mapPricingSection(cms, headers['x-locale']))); + return forkJoin([cms]).pipe(map(([cms]) => mapPricingSection(cms, headers[H.Locale]))); } } diff --git a/packages/blocks/pricing-section/src/frontend/PricingSection.types.ts b/packages/blocks/pricing-section/src/frontend/PricingSection.types.ts index f9e96cad6..1636f0362 100644 --- a/packages/blocks/pricing-section/src/frontend/PricingSection.types.ts +++ b/packages/blocks/pricing-section/src/frontend/PricingSection.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/pricing-section.client'; -export interface PricingSectionProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type PricingSectionProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type PricingSectionPureProps = PricingSectionProps & Model.PricingSectionBlock; -export type PricingSectionRendererProps = Omit<PricingSectionProps, ''> & { - slug: string[]; -}; +export type PricingSectionRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/pricing-section/src/sdk/index.ts b/packages/blocks/pricing-section/src/sdk/index.ts index 8b80a7617..dca76af8b 100644 --- a/packages/blocks/pricing-section/src/sdk/index.ts +++ b/packages/blocks/pricing-section/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { pricingSection } from './pricing-section'; diff --git a/packages/blocks/pricing-section/src/sdk/pricing-section.ts b/packages/blocks/pricing-section/src/sdk/pricing-section.ts index b207d7633..d2613d039 100644 --- a/packages/blocks/pricing-section/src/sdk/pricing-section.ts +++ b/packages/blocks/pricing-section/src/sdk/pricing-section.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/pricing-section.client'; @@ -12,7 +12,7 @@ export const pricingSection = (sdk: Sdk) => ({ blocks: { getPricingSection: ( query: Request.GetPricingSectionBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.PricingSectionBlock> => sdk.makeRequest({ diff --git a/packages/blocks/product-details/CHANGELOG.md b/packages/blocks/product-details/CHANGELOG.md index ffb88a9c4..58852a30f 100644 --- a/packages/blocks/product-details/CHANGELOG.md +++ b/packages/blocks/product-details/CHANGELOG.md @@ -1,5 +1,46 @@ # @o2s/blocks.product-details +## 0.3.1 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 0.3.0 + +### Minor Changes + +- 375cd90: feat(blocks, ui): add variantId support to cart item handling, enhance add-to-cart toast with product name and cart link action across ProductDetails, ProductList and RecommendedProducts blocks + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 0.2.0 ### Minor Changes diff --git a/packages/blocks/product-details/package.json b/packages/blocks/product-details/package.json index d6ed1d446..550eecc7c 100644 --- a/packages/blocks/product-details/package.json +++ b/packages/blocks/product-details/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.product-details", - "version": "0.2.0", + "version": "0.3.1", "private": false, "license": "MIT", "description": "A block for displaying comprehensive product information including title, images, price, description.", @@ -52,7 +52,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "react-hook-form": "^7.71.2", "tsc-alias": "^1.8.16", diff --git a/packages/blocks/product-details/src/api-harmonization/product-details.controller.ts b/packages/blocks/product-details/src/api-harmonization/product-details.controller.ts index abdcce643..b91e3cddc 100644 --- a/packages/blocks/product-details/src/api-harmonization/product-details.controller.ts +++ b/packages/blocks/product-details/src/api-harmonization/product-details.controller.ts @@ -1,8 +1,9 @@ import { Controller, Get, Headers, Param, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; + import { URL } from './'; import type { GetProductDetailsBlockQuery } from './product-details.request'; import { ProductDetailsService } from './product-details.service'; @@ -16,7 +17,7 @@ export class ProductDetailsController { getProductDetails( @Param('id') id: string, @Query() query: GetProductDetailsBlockQuery, - @Headers() headers: Models.Headers.AppHeaders, + @Headers() headers: AppHeaders, ) { return this.service.getProductDetails(id, undefined, query, headers); } @@ -26,7 +27,7 @@ export class ProductDetailsController { @Param('id') id: string, @Param('variantSlug') variantSlug: string, @Query() query: GetProductDetailsBlockQuery, - @Headers() headers: Models.Headers.AppHeaders, + @Headers() headers: AppHeaders, ) { return this.service.getProductDetails(id, variantSlug, query, headers); } diff --git a/packages/blocks/product-details/src/api-harmonization/product-details.mapper.ts b/packages/blocks/product-details/src/api-harmonization/product-details.mapper.ts index e6a9f6a45..bb3d17352 100644 --- a/packages/blocks/product-details/src/api-harmonization/product-details.mapper.ts +++ b/packages/blocks/product-details/src/api-harmonization/product-details.mapper.ts @@ -47,7 +47,6 @@ export const mapProductDetails = ( }; const labels: Model.Labels = { - actionButton: cms.labels.actionButtonLabel, specifications: cms.labels.specificationsTitle, description: cms.labels.descriptionTitle, download: cms.labels.downloadLabel, @@ -55,21 +54,17 @@ export const mapProductDetails = ( offer: cms.labels.offerLabel, variant: cms.labels.variantLabel, outOfStock: cms.labels.outOfStockLabel, + addToCart: cms.labels.addToCartLabel, + addToCartSuccess: cms.labels.addToCartSuccess, + addToCartError: cms.labels.addToCartError, + viewCart: cms.labels.viewCartLabel, }; return { __typename: 'ProductDetailsBlock', id: product.id, product: mappedProduct, - actionButton: - labels.actionButton && product.link - ? { - label: labels.actionButton, - href: product.link, - variant: 'default', - icon: 'MessageCircle', - } - : undefined, labels, + cartPath: cms.cartPath, }; }; diff --git a/packages/blocks/product-details/src/api-harmonization/product-details.model.ts b/packages/blocks/product-details/src/api-harmonization/product-details.model.ts index aaccf3131..e86241857 100644 --- a/packages/blocks/product-details/src/api-harmonization/product-details.model.ts +++ b/packages/blocks/product-details/src/api-harmonization/product-details.model.ts @@ -33,15 +33,7 @@ export type Product = Products.Model.Product & { detailedSpecs?: DetailedSpec[]; }; -export type ActionButton = { - label: string; - href: string; - variant?: 'default' | 'secondary' | 'destructive' | 'outline'; - icon?: string; -}; - export type Labels = { - actionButton?: string; download?: string; specifications: string; description: string; @@ -49,11 +41,15 @@ export type Labels = { offer: string; variant?: string; outOfStock?: string; + addToCart: string; + addToCartSuccess: string; + addToCartError: string; + viewCart?: string; }; export type ProductDetailsBlock = ApiModels.Block.Block & { __typename: 'ProductDetailsBlock'; product: Product; - actionButton?: ActionButton; labels: Labels; + cartPath?: string; }; diff --git a/packages/blocks/product-details/src/api-harmonization/product-details.service.spec.ts b/packages/blocks/product-details/src/api-harmonization/product-details.service.spec.ts index 760b93559..f28eaef8d 100644 --- a/packages/blocks/product-details/src/api-harmonization/product-details.service.spec.ts +++ b/packages/blocks/product-details/src/api-harmonization/product-details.service.spec.ts @@ -21,12 +21,14 @@ describe('ProductDetailsService', () => { of({ id: 'product-details-1', labels: { - actionButtonLabel: 'Request Quote', specificationsTitle: 'Specifications', descriptionTitle: 'Description', downloadLabel: 'Download Brochure', priceLabel: 'Price', offerLabel: 'Offer', + addToCartLabel: 'Add to Cart', + addToCartSuccess: 'Product added to cart', + addToCartError: 'Failed to add product to cart', }, }), ), diff --git a/packages/blocks/product-details/src/api-harmonization/product-details.service.ts b/packages/blocks/product-details/src/api-harmonization/product-details.service.ts index 8324c07ef..58c661ed0 100644 --- a/packages/blocks/product-details/src/api-harmonization/product-details.service.ts +++ b/packages/blocks/product-details/src/api-harmonization/product-details.service.ts @@ -2,12 +2,14 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { CMS, Products } from '@o2s/configs.integrations'; import { Observable, forkJoin, map, of, switchMap } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapProductDetails } from './product-details.mapper'; import * as Model from './product-details.model'; import * as Request from './product-details.request'; +const H = HeaderName; + @Injectable() export class ProductDetailsService { constructor( @@ -19,9 +21,9 @@ export class ProductDetailsService { id: string, variantSlug: string | undefined, query: Request.GetProductDetailsBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, ): Observable<Model.ProductDetailsBlock> { - const locale = query.locale || headers['x-locale'] || 'en'; + const locale = query.locale || headers[H.Locale] || 'en'; const cms = this.cmsService.getProductDetailsBlock({ id: query.id, @@ -38,7 +40,7 @@ export class ProductDetailsService { basePath: cmsData.basePath, variantOptionGroups: cmsData.variantOptionGroups, }, - headers['authorization'], + headers[H.Authorization], ); return forkJoin([of(cmsData), product]).pipe( diff --git a/packages/blocks/product-details/src/frontend/ProductDetails.client.stories.tsx b/packages/blocks/product-details/src/frontend/ProductDetails.client.stories.tsx index 1596920de..9f0829488 100644 --- a/packages/blocks/product-details/src/frontend/ProductDetails.client.stories.tsx +++ b/packages/blocks/product-details/src/frontend/ProductDetails.client.stories.tsx @@ -107,38 +107,34 @@ export const Default: Story = { ], location: 'Chicago, IL', }, - actionButton: { - label: 'Request Quote', - href: 'https://example.com/products/xl-2000', - variant: 'default', - icon: 'MessageCircle', - }, labels: { specifications: 'Specifications', description: 'Description', download: 'Download Brochure', price: 'Price', offer: 'Offer', + addToCart: 'Add to Cart', + addToCartSuccess: 'Product added to cart', + addToCartError: 'Failed to add product to cart', }, hasPriority: false, }, }; -export const WithSecondaryButton: Story = { +export const OutOfStock: Story = { args: { ...Default.args, - actionButton: { - label: 'Add to Cart', - href: 'https://example.com/products/xl-2000', - variant: 'secondary', - icon: 'ShoppingCart', + product: { + ...Default.args.product, + variants: [ + { + id: 'var-1', + title: 'Default', + slug: 'default', + inStock: false, + }, + ], + variantId: 'var-1', }, }, }; - -export const WithoutActionButton: Story = { - args: { - ...Default.args, - actionButton: undefined, - }, -}; diff --git a/packages/blocks/product-details/src/frontend/ProductDetails.client.tsx b/packages/blocks/product-details/src/frontend/ProductDetails.client.tsx index 3a2bc0ab1..ebef757fa 100644 --- a/packages/blocks/product-details/src/frontend/ProductDetails.client.tsx +++ b/packages/blocks/product-details/src/frontend/ProductDetails.client.tsx @@ -1,19 +1,24 @@ 'use client'; import { CircleAlert } from 'lucide-react'; -import { useTranslations } from 'next-intl'; import { createNavigation } from 'next-intl/navigation'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useTransition } from 'react'; + +import { Utils } from '@o2s/utils.frontend'; + +import { toast } from '@o2s/ui/hooks/use-toast'; import { Price } from '@o2s/ui/components/Price'; import { ProductGallery } from '@o2s/ui/components/ProductGallery'; -import { TooltipHover } from '@o2s/ui/components/TooltipHover'; import { Alert, AlertDescription } from '@o2s/ui/elements/alert'; import { Button } from '@o2s/ui/elements/button'; import { Separator } from '@o2s/ui/elements/separator'; +import { ToastAction } from '@o2s/ui/elements/toast'; import { Typography } from '@o2s/ui/elements/typography'; +import { sdk } from '../sdk'; + import { ProductDetailsPureProps } from './ProductDetails.types'; import { OptionGroupsSelector } from './components/OptionGroupsSelector'; import { PriceSection } from './components/PriceSection'; @@ -59,13 +64,14 @@ function getAvailableValuesForGroup( export const ProductDetailsPure: React.FC<ProductDetailsPureProps> = ({ locale, + accessToken, routing, hasPriority, productId, ...component }) => { - const { product, labels, actionButton } = component; - const t = useTranslations(); + const { product, labels } = component; + const [isAddingToCart, startAddToCartTransition] = useTransition(); const { useRouter } = createNavigation(routing); const router = useRouter(); @@ -126,6 +132,53 @@ export const ProductDetailsPure: React.FC<ProductDetailsPureProps> = ({ const isOutOfStock = !currentVariantInStock; + const handleAddToCart = useCallback(() => { + startAddToCartTransition(async () => { + try { + const cartId = localStorage.getItem('cartId'); + const result = await sdk.cart.addCartItem( + { + cartId: cartId || undefined, + sku: product.sku, + variantId: product.variantId, + quantity: 1, + currency: product.price.currency, + }, + { 'x-locale': locale }, + accessToken, + ); + if (!cartId && result?.id) { + localStorage.setItem('cartId', result.id); + } + toast({ + description: Utils.StringReplace.reactStringReplace(labels.addToCartSuccess, { + productName: product.name, + }), + action: + labels.viewCart && component.cartPath ? ( + <ToastAction altText={labels.viewCart} onClick={() => router.push(component.cartPath!)}> + {labels.viewCart} + </ToastAction> + ) : undefined, + }); + } catch { + toast({ variant: 'destructive', description: labels.addToCartError }); + } + }); + }, [ + product.sku, + product.variantId, + product.price.currency, + product.name, + locale, + accessToken, + labels.addToCartSuccess, + labels.addToCartError, + labels.viewCart, + component.cartPath, + router, + ]); + return ( <div className="w-full flex flex-col gap-8 md:gap-12"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> @@ -216,41 +269,34 @@ export const ProductDetailsPure: React.FC<ProductDetailsPureProps> = ({ <PriceSection price={product.price} priceLabel={labels.price} - actionButton={actionButton} + onAddToCart={handleAddToCart} + addToCartLabel={labels.addToCart} isOutOfStock={isOutOfStock} + isAddingToCart={isAddingToCart} /> </div> </div> </div> - {actionButton && ( - <> - <div className="lg:hidden fixed bottom-0 left-0 right-0 bg-background border-t border-border p-4 shadow-lg z-100"> - <div className="flex flex-col gap-2 max-w-7xl ml-auto mr-4"> - <div className="flex items-center justify-end gap-2 mb-2"> - <Typography className="text-muted-foreground">{labels.price}</Typography> - <Typography variant="large" className="font-bold text-primary"> - <Price price={product.price} /> - </Typography> - </div> - <TooltipHover - trigger={(setIsOpen) => ( - <Button - variant={actionButton.variant || 'default'} - size="default" - className="w-full" - onClick={() => setIsOpen(true)} - disabled={isOutOfStock} - > - {actionButton.label} - </Button> - )} - content={<p>{t('general.comingSoon')}</p>} - /> - </div> + <div className="lg:hidden fixed bottom-0 left-0 right-0 bg-background border-t border-border p-4 shadow-lg z-100"> + <div className="flex flex-col gap-2 max-w-7xl ml-auto mr-4"> + <div className="flex items-center justify-end gap-2 mb-2"> + <Typography className="text-muted-foreground">{labels.price}</Typography> + <Typography variant="large" className="font-bold text-primary"> + <Price price={product.price} /> + </Typography> </div> - </> - )} + <Button + variant="default" + size="default" + className="w-full" + onClick={handleAddToCart} + disabled={isOutOfStock || isAddingToCart} + > + {labels.addToCart} + </Button> + </div> + </div> </div> ); }; diff --git a/packages/blocks/product-details/src/frontend/ProductDetails.renderer.tsx b/packages/blocks/product-details/src/frontend/ProductDetails.renderer.tsx index b26353082..6c476493a 100644 --- a/packages/blocks/product-details/src/frontend/ProductDetails.renderer.tsx +++ b/packages/blocks/product-details/src/frontend/ProductDetails.renderer.tsx @@ -9,6 +9,7 @@ import { ProductDetailsRendererProps } from './ProductDetails.types'; export const ProductDetailsRenderer: React.FC<ProductDetailsRendererProps> = ({ id, slug, + accessToken, routing, locale: propLocale, hasPriority, @@ -42,6 +43,7 @@ export const ProductDetailsRenderer: React.FC<ProductDetailsRendererProps> = ({ id={id} productId={slug[1]} variantSlug={slug[2]} + accessToken={accessToken} locale={locale} routing={routing} hasPriority={hasPriority} diff --git a/packages/blocks/product-details/src/frontend/ProductDetails.server.tsx b/packages/blocks/product-details/src/frontend/ProductDetails.server.tsx index 2947db4e4..399006307 100644 --- a/packages/blocks/product-details/src/frontend/ProductDetails.server.tsx +++ b/packages/blocks/product-details/src/frontend/ProductDetails.server.tsx @@ -14,6 +14,7 @@ export const ProductDetails: React.FC<ProductDetailsProps> = async ({ id, productId, variantSlug, + accessToken, locale, routing, hasPriority, @@ -35,6 +36,7 @@ export const ProductDetails: React.FC<ProductDetailsProps> = async ({ {...data} id={id} productId={productId} + accessToken={accessToken} locale={locale} routing={routing} hasPriority={hasPriority} diff --git a/packages/blocks/product-details/src/frontend/ProductDetails.types.ts b/packages/blocks/product-details/src/frontend/ProductDetails.types.ts index e41e5dfeb..d5cfe813a 100644 --- a/packages/blocks/product-details/src/frontend/ProductDetails.types.ts +++ b/packages/blocks/product-details/src/frontend/ProductDetails.types.ts @@ -1,18 +1,15 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import * as Client from '../api-harmonization/product-details.client'; -export interface ProductDetailsProps { - id: string; +export interface ProductDetailsProps extends Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>> { productId: string; variantSlug?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; } export type ProductDetailsPureProps = ProductDetailsProps & Client.Model.ProductDetailsBlock; -export type ProductDetailsRendererProps = Omit<ProductDetailsProps, 'productId'> & { - slug: string[]; -}; +export type ProductDetailsRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>> & + Pick<ProductDetailsProps, 'variantSlug'>; diff --git a/packages/blocks/product-details/src/frontend/components/PriceSection.tsx b/packages/blocks/product-details/src/frontend/components/PriceSection.tsx index bca1d0a53..de8cf8464 100644 --- a/packages/blocks/product-details/src/frontend/components/PriceSection.tsx +++ b/packages/blocks/product-details/src/frontend/components/PriceSection.tsx @@ -1,11 +1,8 @@ -import { useTranslations } from 'next-intl'; import React from 'react'; import type { Models } from '@o2s/framework/modules'; -import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; import { Price } from '@o2s/ui/components/Price'; -import { TooltipHover } from '@o2s/ui/components/TooltipHover'; import { Button } from '@o2s/ui/elements/button'; import { Separator } from '@o2s/ui/elements/separator'; @@ -14,24 +11,22 @@ import { Typography } from '@o2s/ui/elements/typography'; interface PriceSectionProps { price: Models.Price.Price; priceLabel: string; - actionButton?: { - label: string; - variant?: 'default' | 'secondary' | 'destructive' | 'outline'; - icon?: string; - }; + onAddToCart: () => void; + addToCartLabel: string; isOutOfStock?: boolean; + isAddingToCart?: boolean; className?: string; } export const PriceSection: React.FC<PriceSectionProps> = ({ price, priceLabel, - actionButton, + onAddToCart, + addToCartLabel, isOutOfStock = false, + isAddingToCart = false, className, }) => { - const t = useTranslations(); - return ( <div className={className}> <div className="flex flex-col gap-1 mb-4 items-end"> @@ -40,30 +35,18 @@ export const PriceSection: React.FC<PriceSectionProps> = ({ <Price price={price} /> </Typography> </div> - {actionButton && ( - <> - <Separator /> - <div className="flex flex-col gap-3 mt-6"> - <TooltipHover - trigger={(setIsOpen) => ( - <Button - variant={actionButton.variant || 'default'} - size="lg" - className="w-full" - onClick={() => setIsOpen(true)} - disabled={isOutOfStock} - > - {actionButton.icon && ( - <DynamicIcon name={actionButton.icon} size={20} className="mr-2" /> - )} - {actionButton.label} - </Button> - )} - content={<p>{t('general.comingSoon')}</p>} - /> - </div> - </> - )} + <Separator /> + <div className="flex flex-col gap-3 mt-6"> + <Button + variant="default" + size="lg" + className="w-full" + onClick={onAddToCart} + disabled={isOutOfStock || isAddingToCart} + > + {addToCartLabel} + </Button> + </div> </div> ); }; diff --git a/packages/blocks/product-details/src/sdk/index.ts b/packages/blocks/product-details/src/sdk/index.ts index 34f37b191..61e9deda1 100644 --- a/packages/blocks/product-details/src/sdk/index.ts +++ b/packages/blocks/product-details/src/sdk/index.ts @@ -1,7 +1,9 @@ -// this unused import is necessary for TypeScript to properly resolve API methods +// these unused imports are necessary for TypeScript to properly resolve API methods // eslint-disable-next-line @typescript-eslint/no-unused-vars import { Models } from '@o2s/utils.api-harmonization'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Carts } from '@o2s/framework/modules'; import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { productDetails } from './product-details'; @@ -25,4 +27,7 @@ export const sdk = extendSdk(internalSdk, { blocks: { getProductDetails: productDetails(internalSdk).blocks.getProductDetails, }, + cart: { + addCartItem: productDetails(internalSdk).cart.addCartItem, + }, }); diff --git a/packages/blocks/product-details/src/sdk/product-details.ts b/packages/blocks/product-details/src/sdk/product-details.ts index 688975034..834bf484b 100644 --- a/packages/blocks/product-details/src/sdk/product-details.ts +++ b/packages/blocks/product-details/src/sdk/product-details.ts @@ -1,16 +1,19 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; +import { Carts } from '@o2s/framework/modules'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request, URL } from '../api-harmonization/product-details.client'; +const CARTS_API_URL = '/carts'; + export const productDetails = (sdk: Sdk) => ({ blocks: { getProductDetails: ( params: Request.GetProductDetailsBlockParams, query?: Request.GetProductDetailsBlockQuery, - headers?: Models.Headers.AppHeaders, + headers?: AppHeaders, authorization?: string, ): Promise<Model.ProductDetailsBlock> => { const urlPath = params.variantSlug ? `${URL}/${params.id}/${params.variantSlug}` : `${URL}/${params.id}`; @@ -31,4 +34,21 @@ export const productDetails = (sdk: Sdk) => ({ }); }, }, + cart: { + addCartItem: ( + body: Carts.Request.AddCartItemBody, + headers: AppHeaders, + authorization?: string, + ): Promise<Carts.Model.Cart> => + sdk.makeRequest({ + method: 'post', + url: `${CARTS_API_URL}/items`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}), + }, + data: body, + }), + }, }); diff --git a/packages/blocks/product-list/CHANGELOG.md b/packages/blocks/product-list/CHANGELOG.md index 3dd2a4466..ee40e313c 100644 --- a/packages/blocks/product-list/CHANGELOG.md +++ b/packages/blocks/product-list/CHANGELOG.md @@ -1,5 +1,46 @@ # @o2s/blocks.product-list +## 0.5.1 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 0.5.0 + +### Minor Changes + +- 375cd90: feat(blocks, ui): add variantId support to cart item handling, enhance add-to-cart toast with product name and cart link action across ProductDetails, ProductList and RecommendedProducts blocks + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 0.4.0 ### Minor Changes diff --git a/packages/blocks/product-list/package.json b/packages/blocks/product-list/package.json index 2f9ab0ba5..63590f142 100644 --- a/packages/blocks/product-list/package.json +++ b/packages/blocks/product-list/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.product-list", - "version": "0.4.0", + "version": "0.5.1", "private": false, "license": "MIT", "description": "A block for displaying and filtering a list of products with grid and table views.", @@ -50,7 +50,7 @@ "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/product-list/src/api-harmonization/product-list.controller.ts b/packages/blocks/product-list/src/api-harmonization/product-list.controller.ts index be92fcc49..a41c9ea9e 100644 --- a/packages/blocks/product-list/src/api-harmonization/product-list.controller.ts +++ b/packages/blocks/product-list/src/api-harmonization/product-list.controller.ts @@ -1,8 +1,9 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; + import { URL } from './'; import { GetProductListBlockQuery } from './product-list.request'; import { ProductListService } from './product-list.service'; @@ -13,7 +14,7 @@ export class ProductListController { constructor(protected readonly service: ProductListService) {} @Get() - getProductListBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetProductListBlockQuery) { + getProductListBlock(@Headers() headers: AppHeaders, @Query() query: GetProductListBlockQuery) { return this.service.getProductListBlock(query, headers); } } diff --git a/packages/blocks/product-list/src/api-harmonization/product-list.mapper.ts b/packages/blocks/product-list/src/api-harmonization/product-list.mapper.ts index 4c924f9e2..ea91edb1f 100644 --- a/packages/blocks/product-list/src/api-harmonization/product-list.mapper.ts +++ b/packages/blocks/product-list/src/api-harmonization/product-list.mapper.ts @@ -19,6 +19,7 @@ export const mapProductList = ( fieldMapping: cms.fieldMapping, noResults: cms.noResults, labels: cms.labels, + cartPath: cms.cartPath, products: { total: products.total, data: products.data.map((product) => mapProduct(product, cms)), @@ -33,6 +34,7 @@ const mapProduct = (product: Products.Model.Product, cms: CMS.Model.ProductListB __typename: 'ProductItem', id: product.id, sku: product.sku, + variantId: product.variantId, name: product.name, description: product.description, shortDescription: product.shortDescription, diff --git a/packages/blocks/product-list/src/api-harmonization/product-list.model.ts b/packages/blocks/product-list/src/api-harmonization/product-list.model.ts index 952e2c90d..5b724e9b8 100644 --- a/packages/blocks/product-list/src/api-harmonization/product-list.model.ts +++ b/packages/blocks/product-list/src/api-harmonization/product-list.model.ts @@ -28,7 +28,12 @@ export class ProductListBlock extends ApiModels.Block.Block { showMoreFilters: string; hideMoreFilters: string; noActiveFilters: string; + addToCartLabel?: string; + addToCartSuccess?: string; + addToCartError?: string; + viewCartLabel?: string; }; + cartPath?: string; permissions?: { view: boolean; }; @@ -43,6 +48,7 @@ export class ProductItem { __typename!: 'ProductItem'; id!: string; sku!: string; + variantId?: string; name!: string; description!: string; shortDescription?: string; diff --git a/packages/blocks/product-list/src/api-harmonization/product-list.service.ts b/packages/blocks/product-list/src/api-harmonization/product-list.service.ts index e777ab8ab..c5250329b 100644 --- a/packages/blocks/product-list/src/api-harmonization/product-list.service.ts +++ b/packages/blocks/product-list/src/api-harmonization/product-list.service.ts @@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common'; import { CMS, Products } from '@o2s/configs.integrations'; import { Observable, concatMap, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapProductList } from './product-list.mapper'; import { ProductListBlock } from './product-list.model'; import { GetProductListBlockQuery } from './product-list.request'; +const H = HeaderName; + @Injectable() export class ProductListService { constructor( @@ -18,11 +19,8 @@ export class ProductListService { private readonly authService: Auth.Service, ) {} - getProductListBlock( - query: GetProductListBlockQuery, - headers: Models.Headers.AppHeaders, - ): Observable<ProductListBlock> { - const cms = this.cmsService.getProductListBlock({ ...query, locale: headers['x-locale'] }); + getProductListBlock(query: GetProductListBlockQuery, headers: AppHeaders): Observable<ProductListBlock> { + const cms = this.cmsService.getProductListBlock({ ...query, locale: headers[H.Locale] }); return forkJoin([cms]).pipe( concatMap(([cms]) => { @@ -34,12 +32,12 @@ export class ProductListService { offset: query.offset || 0, type: 'PHYSICAL' as Products.Model.ProductType, category: query.category, - locale: headers['x-locale'], + locale: headers[H.Locale], basePath: cms.basePath, }, - headers['authorization'], + headers[H.Authorization], ) - .pipe(map((products) => mapProductList(products, cms, headers['x-locale']))); + .pipe(map((products) => mapProductList(products, cms, headers[H.Locale]))); }), ); } diff --git a/packages/blocks/product-list/src/frontend/ProductList.client.stories.tsx b/packages/blocks/product-list/src/frontend/ProductList.client.stories.tsx index 425150f08..047c09097 100644 --- a/packages/blocks/product-list/src/frontend/ProductList.client.stories.tsx +++ b/packages/blocks/product-list/src/frontend/ProductList.client.stories.tsx @@ -145,6 +145,9 @@ export const Default: Story = { showMoreFilters: 'Show more filters', hideMoreFilters: 'Hide more filters', noActiveFilters: 'No active filters', + addToCartLabel: 'Add to Cart', + addToCartSuccess: 'Product added to cart', + addToCartError: 'Failed to add product to cart', }, products: { total: 6, diff --git a/packages/blocks/product-list/src/frontend/ProductList.client.tsx b/packages/blocks/product-list/src/frontend/ProductList.client.tsx index d2ffeb095..bc71b3cd8 100644 --- a/packages/blocks/product-list/src/frontend/ProductList.client.tsx +++ b/packages/blocks/product-list/src/frontend/ProductList.client.tsx @@ -1,8 +1,14 @@ 'use client'; -import { ArrowRight } from 'lucide-react'; +import { ArrowRight, ShoppingCart } from 'lucide-react'; import { createNavigation } from 'next-intl/navigation'; -import React, { useState, useTransition } from 'react'; +import React, { useCallback, useState, useTransition } from 'react'; + +import { Utils } from '@o2s/utils.frontend'; + +import type { Models } from '@o2s/framework/modules'; + +import { toast } from '@o2s/ui/hooks/use-toast'; import { ProductCard, ProductCardBadge } from '@o2s/ui/components/Cards/ProductCard'; import { DataList } from '@o2s/ui/components/DataList'; @@ -14,6 +20,7 @@ import { Pagination } from '@o2s/ui/components/Pagination'; import { Button } from '@o2s/ui/elements/button'; import { LoadingOverlay } from '@o2s/ui/elements/loading-overlay'; import { Separator } from '@o2s/ui/elements/separator'; +import { ToastAction } from '@o2s/ui/elements/toast'; import type { Model } from '../api-harmonization/product-list.client'; import { sdk } from '../sdk'; @@ -21,7 +28,8 @@ import { sdk } from '../sdk'; import { ProductListPureProps } from './ProductList.types'; export const ProductListPure: React.FC<ProductListPureProps> = ({ locale, accessToken, routing, ...component }) => { - const { Link: LinkComponent } = createNavigation(routing); + const { Link: LinkComponent, useRouter } = createNavigation(routing); + const router = useRouter(); const initialFilters = { id: component.id, @@ -40,6 +48,58 @@ export const ProductListPure: React.FC<ProductListPureProps> = ({ locale, access const [selectedRows, setSelectedRows] = useState<Set<string | number>>(new Set()); const [isPending, startTransition] = useTransition(); + const [isAddingToCart, startAddToCartTransition] = useTransition(); + + const handleAddToCart = useCallback( + (sku: string, currency: Models.Price.Currency, variantId?: string) => { + const productName = data.products.data.find((p) => p.sku === sku)?.name ?? sku; + startAddToCartTransition(async () => { + try { + const cartId = localStorage.getItem('cartId'); + const result = await sdk.cart.addCartItem( + { + cartId: cartId || undefined, + sku, + variantId, + quantity: 1, + currency, + }, + { 'x-locale': locale }, + accessToken, + ); + if (!cartId && result?.id) { + localStorage.setItem('cartId', result.id); + } + toast({ + description: Utils.StringReplace.reactStringReplace(data.labels.addToCartSuccess ?? '', { + productName, + }), + action: + data.labels.viewCartLabel && data.cartPath ? ( + <ToastAction + altText={data.labels.viewCartLabel} + onClick={() => router.push(data.cartPath!)} + > + {data.labels.viewCartLabel} + </ToastAction> + ) : undefined, + }); + } catch { + toast({ variant: 'destructive', description: data.labels.addToCartError }); + } + }); + }, + [ + locale, + accessToken, + data.labels.addToCartSuccess, + data.labels.addToCartError, + data.labels.viewCartLabel, + data.cartPath, + data.products.data, + router, + ], + ); const handleFilter = (data: Partial<typeof initialFilters>) => { startTransition(async () => { @@ -164,10 +224,26 @@ export const ProductListPure: React.FC<ProductListPureProps> = ({ locale, access description={product.shortDescription || product.description} image={product.image} price={product.price} - link={{ - label: data.detailsLabel || 'View Details', - url: product.detailsUrl, - }} + link={product.detailsUrl} + action={ + data.labels.addToCartLabel ? ( + <Button + variant="secondary" + size="sm" + disabled={isAddingToCart} + onClick={() => + handleAddToCart( + product.sku, + product.price.currency, + product.variantId, + ) + } + > + <ShoppingCart className="h-4 w-4 mr-2" /> + {data.labels.addToCartLabel} + </Button> + ) : undefined + } LinkComponent={LinkComponent} /> </li> diff --git a/packages/blocks/product-list/src/frontend/ProductList.types.ts b/packages/blocks/product-list/src/frontend/ProductList.types.ts index 8c7fc2d98..0081dd281 100644 --- a/packages/blocks/product-list/src/frontend/ProductList.types.ts +++ b/packages/blocks/product-list/src/frontend/ProductList.types.ts @@ -1,17 +1,14 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/product-list.client'; -export interface ProductListProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; +export interface ProductListProps extends Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>> { enableRowSelection?: boolean; } export type ProductListPureProps = ProductListProps & Model.ProductListBlock; -export type ProductListRendererProps = Omit<ProductListProps, ''> & { - slug: string[]; -}; +export type ProductListRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>> & + Pick<ProductListProps, 'enableRowSelection'>; diff --git a/packages/blocks/product-list/src/sdk/index.ts b/packages/blocks/product-list/src/sdk/index.ts index b7eff2d4a..b97bef13f 100644 --- a/packages/blocks/product-list/src/sdk/index.ts +++ b/packages/blocks/product-list/src/sdk/index.ts @@ -1,7 +1,9 @@ -// this unused import is necessary for TypeScript to properly resolve API methods +// these unused imports are necessary for TypeScript to properly resolve API methods // eslint-disable-next-line @typescript-eslint/no-unused-vars import { Models } from '@o2s/utils.api-harmonization'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Carts } from '@o2s/framework/modules'; import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { productList } from './product-list'; @@ -25,4 +27,7 @@ export const sdk = extendSdk(internalSdk, { blocks: { getProductList: productList(internalSdk).blocks.getProductList, }, + cart: { + addCartItem: productList(internalSdk).cart.addCartItem, + }, }); diff --git a/packages/blocks/product-list/src/sdk/product-list.ts b/packages/blocks/product-list/src/sdk/product-list.ts index 5eec8d1a1..f02e7fb57 100644 --- a/packages/blocks/product-list/src/sdk/product-list.ts +++ b/packages/blocks/product-list/src/sdk/product-list.ts @@ -1,18 +1,20 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; +import { Carts } from '@o2s/framework/modules'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/product-list.client'; import { URL } from '../api-harmonization/product-list.url'; const API_URL = URL; +const CARTS_API_URL = '/carts'; export const productList = (sdk: Sdk) => ({ blocks: { getProductList: ( query: Request.GetProductListBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.ProductListBlock> => sdk.makeRequest({ @@ -30,4 +32,21 @@ export const productList = (sdk: Sdk) => ({ params: query, }), }, + cart: { + addCartItem: ( + body: Carts.Request.AddCartItemBody, + headers: AppHeaders, + authorization?: string, + ): Promise<Carts.Model.Cart> => + sdk.makeRequest({ + method: 'post', + url: `${CARTS_API_URL}/items`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}), + }, + data: body, + }), + }, }); diff --git a/packages/blocks/quick-links/CHANGELOG.md b/packages/blocks/quick-links/CHANGELOG.md index 47486e072..6ec699803 100644 --- a/packages/blocks/quick-links/CHANGELOG.md +++ b/packages/blocks/quick-links/CHANGELOG.md @@ -1,5 +1,46 @@ # @o2s/blocks.quick-links +## 1.5.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- fadbc63: Align renderer prop types with runtime usage across blocks. + + Restore missing `isDraftModeEnabled` and `userId` coverage in renderer prop contracts and rename the misnamed notification details renderer prop type for consistency. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.5.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.5.0 ### Minor Changes diff --git a/packages/blocks/quick-links/package.json b/packages/blocks/quick-links/package.json index d629c8034..2fd761846 100644 --- a/packages/blocks/quick-links/package.json +++ b/packages/blocks/quick-links/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.quick-links", - "version": "1.5.0", + "version": "1.5.2", "private": false, "license": "MIT", "description": "A block displaying quick links to important pages or resources.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/quick-links/src/api-harmonization/quick-links.controller.ts b/packages/blocks/quick-links/src/api-harmonization/quick-links.controller.ts index a655deede..8b9e73eb1 100644 --- a/packages/blocks/quick-links/src/api-harmonization/quick-links.controller.ts +++ b/packages/blocks/quick-links/src/api-harmonization/quick-links.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class QuickLinksController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getQuickLinksBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetQuickLinksBlockQuery) { + getQuickLinksBlock(@Headers() headers: AppHeaders, @Query() query: GetQuickLinksBlockQuery) { return this.service.getQuickLinksBlock(query, headers); } } diff --git a/packages/blocks/quick-links/src/api-harmonization/quick-links.model.ts b/packages/blocks/quick-links/src/api-harmonization/quick-links.model.ts index ef8bbfed6..684e47b65 100644 --- a/packages/blocks/quick-links/src/api-harmonization/quick-links.model.ts +++ b/packages/blocks/quick-links/src/api-harmonization/quick-links.model.ts @@ -1,8 +1,8 @@ import { CMS } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -export class QuickLinksBlock extends Models.Block.Block { +export class QuickLinksBlock extends ApiModels.Block.Block { __typename!: 'QuickLinksBlock'; title?: CMS.Model.QuickLinksBlock.QuickLinksBlock['title']; description?: CMS.Model.QuickLinksBlock.QuickLinksBlock['description']; diff --git a/packages/blocks/quick-links/src/api-harmonization/quick-links.service.ts b/packages/blocks/quick-links/src/api-harmonization/quick-links.service.ts index b4d989380..f7c95ab6c 100644 --- a/packages/blocks/quick-links/src/api-harmonization/quick-links.service.ts +++ b/packages/blocks/quick-links/src/api-harmonization/quick-links.service.ts @@ -2,22 +2,21 @@ import { Injectable } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapQuickLinks } from './quick-links.mapper'; import { QuickLinksBlock } from './quick-links.model'; import { GetQuickLinksBlockQuery } from './quick-links.request'; +const H = HeaderName; + @Injectable() export class QuickLinksService { constructor(private readonly cmsService: CMS.Service) {} - getQuickLinksBlock( - query: GetQuickLinksBlockQuery, - headers: Models.Headers.AppHeaders, - ): Observable<QuickLinksBlock> { - const cms = this.cmsService.getQuickLinksBlock({ ...query, locale: headers['x-locale'] }); + getQuickLinksBlock(query: GetQuickLinksBlockQuery, headers: AppHeaders): Observable<QuickLinksBlock> { + const cms = this.cmsService.getQuickLinksBlock({ ...query, locale: headers[H.Locale] }); - return forkJoin([cms]).pipe(map(([cms]) => mapQuickLinks(cms, headers['x-locale']))); + return forkJoin([cms]).pipe(map(([cms]) => mapQuickLinks(cms, headers[H.Locale]))); } } diff --git a/packages/blocks/quick-links/src/frontend/QuickLinks.types.ts b/packages/blocks/quick-links/src/frontend/QuickLinks.types.ts index c3e526f33..7e5cc2080 100644 --- a/packages/blocks/quick-links/src/frontend/QuickLinks.types.ts +++ b/packages/blocks/quick-links/src/frontend/QuickLinks.types.ts @@ -1,18 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/quick-links.client'; -export interface QuickLinksProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; - isDraftModeEnabled?: boolean; -} +export type QuickLinksProps = Models.BlockProps.BlockWithDraftModeProps<ReturnType<typeof defineRouting>>; export type QuickLinksPureProps = QuickLinksProps & Model.QuickLinksBlock; -export type QuickLinksRendererProps = Omit<QuickLinksProps, ''> & { - slug: string[]; -}; +export type QuickLinksRendererProps = Models.BlockProps.BlockWithDraftModeProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/quick-links/src/sdk/index.ts b/packages/blocks/quick-links/src/sdk/index.ts index 44e55123c..1d506d76c 100644 --- a/packages/blocks/quick-links/src/sdk/index.ts +++ b/packages/blocks/quick-links/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { quickLinks } from './quick-links'; diff --git a/packages/blocks/quick-links/src/sdk/quick-links.ts b/packages/blocks/quick-links/src/sdk/quick-links.ts index 7c820b53f..faf328a67 100644 --- a/packages/blocks/quick-links/src/sdk/quick-links.ts +++ b/packages/blocks/quick-links/src/sdk/quick-links.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/quick-links.client'; @@ -12,7 +12,7 @@ export const quickLinks = (sdk: Sdk) => ({ blocks: { getQuickLinks: ( query: Request.GetQuickLinksBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.QuickLinksBlock> => sdk.makeRequest({ diff --git a/packages/blocks/recommended-products/CHANGELOG.md b/packages/blocks/recommended-products/CHANGELOG.md index ef16a7291..b73c1aa31 100644 --- a/packages/blocks/recommended-products/CHANGELOG.md +++ b/packages/blocks/recommended-products/CHANGELOG.md @@ -1,5 +1,46 @@ # @o2s/blocks.recommended-products +## 0.3.1 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 0.3.0 + +### Minor Changes + +- 375cd90: feat(blocks, ui): add variantId support to cart item handling, enhance add-to-cart toast with product name and cart link action across ProductDetails, ProductList and RecommendedProducts blocks + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 0.2.0 ### Minor Changes diff --git a/packages/blocks/recommended-products/package.json b/packages/blocks/recommended-products/package.json index 094c0f48c..41236a9dd 100644 --- a/packages/blocks/recommended-products/package.json +++ b/packages/blocks/recommended-products/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.recommended-products", - "version": "0.2.0", + "version": "0.3.1", "private": false, "license": "MIT", "description": "A simple block displaying static content in the form of an RecommendedProducts.", @@ -52,7 +52,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/recommended-products/src/api-harmonization/recommended-products.controller.ts b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.controller.ts index dd747e041..668aa7846 100644 --- a/packages/blocks/recommended-products/src/api-harmonization/recommended-products.controller.ts +++ b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,10 +16,7 @@ export class RecommendedProductsController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getRecommendedProductsBlock( - @Headers() headers: Models.Headers.AppHeaders, - @Query() query: GetRecommendedProductsBlockQuery, - ) { + getRecommendedProductsBlock(@Headers() headers: AppHeaders, @Query() query: GetRecommendedProductsBlockQuery) { return this.service.getRecommendedProductsBlock(query, headers); } } diff --git a/packages/blocks/recommended-products/src/api-harmonization/recommended-products.mapper.ts b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.mapper.ts index 08986da49..f888fbea5 100644 --- a/packages/blocks/recommended-products/src/api-harmonization/recommended-products.mapper.ts +++ b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.mapper.ts @@ -10,6 +10,10 @@ export const mapRecommendedProducts = ( const labels: Model.Labels = { title: cms.labels?.title, detailsLabel: cms.labels?.detailsLabel, + addToCartLabel: cms.labels?.addToCartLabel, + addToCartSuccess: cms.labels?.addToCartSuccess, + addToCartError: cms.labels?.addToCartError, + viewCartLabel: cms.labels?.viewCartLabel, }; return { @@ -17,5 +21,6 @@ export const mapRecommendedProducts = ( id: cms.id, products, labels, + cartPath: cms.cartPath, }; }; diff --git a/packages/blocks/recommended-products/src/api-harmonization/recommended-products.model.ts b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.model.ts index d326ebf47..01905515f 100644 --- a/packages/blocks/recommended-products/src/api-harmonization/recommended-products.model.ts +++ b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.model.ts @@ -9,6 +9,8 @@ export type Badge = { export type ProductSummary = { id: string; + sku: string; + variantId?: string; name: string; description?: string; image: Products.Model.Product['image']; @@ -20,10 +22,15 @@ export type ProductSummary = { export type Labels = { title?: string; detailsLabel?: string; + addToCartLabel?: string; + addToCartSuccess?: string; + addToCartError?: string; + viewCartLabel?: string; }; export type RecommendedProductsBlock = ApiModels.Block.Block & { __typename: 'RecommendedProductsBlock'; products: ProductSummary[]; labels: Labels; + cartPath?: string; }; diff --git a/packages/blocks/recommended-products/src/api-harmonization/recommended-products.service.ts b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.service.ts index ad7c02212..08bb2b58f 100644 --- a/packages/blocks/recommended-products/src/api-harmonization/recommended-products.service.ts +++ b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.service.ts @@ -2,12 +2,14 @@ import { Injectable } from '@nestjs/common'; import { CMS, Products } from '@o2s/configs.integrations'; import { Observable, map, switchMap } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapRecommendedProducts } from './recommended-products.mapper'; import * as Model from './recommended-products.model'; import { GetRecommendedProductsBlockQuery } from './recommended-products.request'; +const H = HeaderName; + @Injectable() export class RecommendedProductsService { constructor( @@ -17,9 +19,9 @@ export class RecommendedProductsService { getRecommendedProductsBlock( query: GetRecommendedProductsBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, ): Observable<Model.RecommendedProductsBlock> { - const locale = headers['x-locale'] || 'en'; + const locale = headers[H.Locale] || 'en'; const cmsBlock$ = this.cmsService.getRecommendedProductsBlock({ id: query.id, locale, @@ -46,6 +48,8 @@ export class RecommendedProductsService { }) .map((product: Products.Model.Product) => ({ id: product.id, + sku: product.sku, + variantId: product.variantId, name: product.name, description: product.shortDescription, image: product.image!, diff --git a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.stories.tsx b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.stories.tsx index e710f7c3d..2354964b4 100644 --- a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.stories.tsx +++ b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.stories.tsx @@ -33,6 +33,7 @@ export const Default: Story = { products: [ { id: 'PRD-005', + sku: 'AG-125-A22', name: 'Cordless Angle Grinder', description: 'Cordless angle grinder with 22V battery platform', image: { @@ -53,6 +54,7 @@ export const Default: Story = { }, { id: 'PRD-006', + sku: 'PD-S', name: 'Laser Measurement', description: 'Laser measurement device for distance measurements', image: { @@ -70,6 +72,7 @@ export const Default: Story = { }, { id: 'PRD-007', + sku: 'SFC-22-A', name: 'Cordless Drill Driver', description: 'Cordless drill driver with 22V battery platform', image: { @@ -87,6 +90,7 @@ export const Default: Story = { }, { id: 'PRD-008', + sku: 'CAL-PRO', name: 'Professional Calibration', description: 'Professional calibration service for industrial equipment', image: { @@ -109,7 +113,12 @@ export const Default: Story = { labels: { title: 'Recommended Products', detailsLabel: 'Details', + addToCartLabel: 'Add to Cart', + addToCartSuccess: '{productName} added to cart', + addToCartError: 'Failed to add product to cart', + viewCartLabel: 'View Cart', }, + cartPath: '/cart', }, }; @@ -117,6 +126,7 @@ export const WithCustomTitle: Story = { args: { ...Default.args, labels: { + ...Default.args.labels, title: 'You Might Also Like', detailsLabel: 'View Details', }, @@ -127,6 +137,8 @@ export const WithoutTitle: Story = { args: { ...Default.args, labels: { + ...Default.args.labels, + title: undefined, detailsLabel: 'Details', }, }, @@ -138,6 +150,7 @@ export const SingleProduct: Story = { products: [ { id: 'PRD-005', + sku: 'AG-125-A22', name: 'Cordless Angle Grinder', description: 'Cordless angle grinder with 22V battery platform', image: { diff --git a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.tsx b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.tsx index 77faf7b2e..3e624bccf 100644 --- a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.tsx +++ b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.tsx @@ -1,16 +1,85 @@ 'use client'; import { createNavigation } from 'next-intl/navigation'; -import React from 'react'; +import React, { useCallback, useTransition } from 'react'; + +import { Utils } from '@o2s/utils.frontend'; + +import type { Models } from '@o2s/framework/modules'; + +import { toast } from '@o2s/ui/hooks/use-toast'; import { ProductCarousel } from '@o2s/ui/components/ProductCarousel'; +import { ToastAction } from '@o2s/ui/elements/toast'; + +import { sdk } from '../sdk'; + import { RecommendedProductsPureProps } from './RecommendedProducts.types'; -export const RecommendedProductsPure: React.FC<RecommendedProductsPureProps> = ({ locale, routing, ...component }) => { - const { Link: LinkComponent } = createNavigation(routing); +export const RecommendedProductsPure: React.FC<RecommendedProductsPureProps> = ({ + locale, + accessToken, + routing, + ...component +}) => { + const { Link: LinkComponent, useRouter } = createNavigation(routing); + const router = useRouter(); const { products, labels } = component; + const [isAddingToCart, startAddToCartTransition] = useTransition(); + + const handleAddToCart = useCallback( + (sku: string, currency: Models.Price.Currency, variantId?: string) => { + const productName = products.find((p) => p.sku === sku)?.name ?? sku; + startAddToCartTransition(async () => { + try { + const cartId = localStorage.getItem('cartId'); + const result = await sdk.cart.addCartItem( + { + cartId: cartId || undefined, + sku, + variantId, + quantity: 1, + currency, + }, + { 'x-locale': locale }, + accessToken, + ); + if (!cartId && result?.id) { + localStorage.setItem('cartId', result.id); + } + toast({ + description: Utils.StringReplace.reactStringReplace(labels.addToCartSuccess ?? '', { + productName, + }), + action: + labels.viewCartLabel && component.cartPath ? ( + <ToastAction + altText={labels.viewCartLabel} + onClick={() => router.push(component.cartPath!)} + > + {labels.viewCartLabel} + </ToastAction> + ) : undefined, + }); + } catch { + toast({ variant: 'destructive', description: labels.addToCartError }); + } + }); + }, + [ + locale, + accessToken, + labels.addToCartSuccess, + labels.addToCartError, + labels.viewCartLabel, + component.cartPath, + products, + router, + ], + ); + if (!products || products.length === 0) { return null; } @@ -20,7 +89,9 @@ export const RecommendedProductsPure: React.FC<RecommendedProductsPureProps> = ( products={products} title={labels.title} LinkComponent={LinkComponent} - linkDetailsLabel={labels.detailsLabel} + addToCartLabel={labels.addToCartLabel} + onAddToCart={labels.addToCartLabel ? handleAddToCart : undefined} + isAddingToCart={isAddingToCart} keyboardControlMode="managed" carouselConfig={{ loop: true }} /> diff --git a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.renderer.tsx b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.renderer.tsx index a84c5030c..e19b7b2bf 100644 --- a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.renderer.tsx +++ b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.renderer.tsx @@ -10,6 +10,7 @@ import { RecommendedProductsRendererProps } from './RecommendedProducts.types'; export const RecommendedProductsRenderer: React.FC<RecommendedProductsRendererProps> = ({ id, excludeProductId, + accessToken, routing, locale: propLocale, }) => { @@ -25,7 +26,13 @@ export const RecommendedProductsRenderer: React.FC<RecommendedProductsRendererPr </Container> } > - <RecommendedProducts id={id} excludeProductId={excludeProductId} locale={locale} routing={routing} /> + <RecommendedProducts + id={id} + excludeProductId={excludeProductId} + accessToken={accessToken} + locale={locale} + routing={routing} + /> </Suspense> ); }; diff --git a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.server.tsx b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.server.tsx index 08d2c7839..8612832df 100644 --- a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.server.tsx +++ b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.server.tsx @@ -13,6 +13,7 @@ export const RecommendedProductsDynamic = dynamic(() => export const RecommendedProducts: React.FC<RecommendedProductsProps> = async ({ id, excludeProductId, + accessToken, locale, routing, }) => { @@ -35,6 +36,7 @@ export const RecommendedProducts: React.FC<RecommendedProductsProps> = async ({ {...data} id={id} excludeProductId={excludeProductId} + accessToken={accessToken} locale={locale} routing={routing} /> diff --git a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.types.ts b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.types.ts index ef7e7fd6a..69c17f8e3 100644 --- a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.types.ts +++ b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.types.ts @@ -1,18 +1,20 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import { Model } from '../api-harmonization/recommended-products.client'; -export interface RecommendedProductsProps { - id: string; +export interface RecommendedProductsProps extends Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>> { excludeProductId?: string; limit?: number; - locale: string; - routing: ReturnType<typeof defineRouting>; } export type RecommendedProductsPureProps = RecommendedProductsProps & Model.RecommendedProductsBlock; -export type RecommendedProductsRendererProps = Omit<RecommendedProductsProps, 'locale'> & { - slug: string[]; - locale?: string; -}; +export type RecommendedProductsRendererProps = Omit< + Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>, + 'locale' +> & + Pick<RecommendedProductsProps, 'excludeProductId' | 'limit'> & { + locale?: string; + }; diff --git a/packages/blocks/recommended-products/src/sdk/index.ts b/packages/blocks/recommended-products/src/sdk/index.ts index 8e1257231..1ea89b29f 100644 --- a/packages/blocks/recommended-products/src/sdk/index.ts +++ b/packages/blocks/recommended-products/src/sdk/index.ts @@ -1,7 +1,9 @@ -// this unused import is necessary for TypeScript to properly resolve API methods +// these unused imports are necessary for TypeScript to properly resolve API methods // eslint-disable-next-line @typescript-eslint/no-unused-vars import { Models } from '@o2s/utils.api-harmonization'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Carts } from '@o2s/framework/modules'; import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { recommendedProducts } from './recommended-products'; @@ -25,4 +27,7 @@ export const sdk = extendSdk(internalSdk, { blocks: { getRecommendedProducts: recommendedProducts(internalSdk).blocks.getRecommendedProducts, }, + cart: { + addCartItem: recommendedProducts(internalSdk).cart.addCartItem, + }, }); diff --git a/packages/blocks/recommended-products/src/sdk/recommended-products.ts b/packages/blocks/recommended-products/src/sdk/recommended-products.ts index dbfb32233..a731f4ca7 100644 --- a/packages/blocks/recommended-products/src/sdk/recommended-products.ts +++ b/packages/blocks/recommended-products/src/sdk/recommended-products.ts @@ -1,16 +1,19 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; +import { Carts } from '@o2s/framework/modules'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request, URL } from '../api-harmonization/recommended-products.client'; +const CARTS_API_URL = '/carts'; + export const recommendedProducts = (sdk: Sdk) => ({ blocks: { getRecommendedProducts: ( params: { id: string }, query?: Omit<Request.GetRecommendedProductsBlockQuery, 'id'>, - headers?: Models.Headers.AppHeaders, + headers?: AppHeaders, authorization?: string, ): Promise<Model.RecommendedProductsBlock> => sdk.makeRequest({ @@ -31,4 +34,21 @@ export const recommendedProducts = (sdk: Sdk) => ({ }, }), }, + cart: { + addCartItem: ( + body: Carts.Request.AddCartItemBody, + headers: AppHeaders, + authorization?: string, + ): Promise<Carts.Model.Cart> => + sdk.makeRequest({ + method: 'post', + url: `${CARTS_API_URL}/items`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}), + }, + data: body, + }), + }, }); diff --git a/packages/blocks/service-details/CHANGELOG.md b/packages/blocks/service-details/CHANGELOG.md index 49d9a8431..41f7a1bf9 100644 --- a/packages/blocks/service-details/CHANGELOG.md +++ b/packages/blocks/service-details/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.service-details +## 1.4.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.4.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.4.0 ### Minor Changes diff --git a/packages/blocks/service-details/package.json b/packages/blocks/service-details/package.json index c8dd8ddac..8f37aa61d 100644 --- a/packages/blocks/service-details/package.json +++ b/packages/blocks/service-details/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.service-details", - "version": "1.4.0", + "version": "1.4.2", "private": false, "license": "MIT", "description": "A block displaying details for a service.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/service-details/src/api-harmonization/service-details.controller.ts b/packages/blocks/service-details/src/api-harmonization/service-details.controller.ts index af6205bb5..473cd8387 100644 --- a/packages/blocks/service-details/src/api-harmonization/service-details.controller.ts +++ b/packages/blocks/service-details/src/api-harmonization/service-details.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Param, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -17,7 +17,7 @@ export class ServiceDetailsController { @Get(':id') @Auth.Decorators.Permissions({ resource: 'services', actions: ['view'] }) getServiceDetailsBlock( - @Headers() headers: ApiModels.Headers.AppHeaders, + @Headers() headers: AppHeaders, @Query() query: GetServiceDetailsBlockQuery, @Param() params: GetServiceDetailsBlockParams, ) { diff --git a/packages/blocks/service-details/src/api-harmonization/service-details.service.ts b/packages/blocks/service-details/src/api-harmonization/service-details.service.ts index 12a05eaf9..2e63ae0ea 100644 --- a/packages/blocks/service-details/src/api-harmonization/service-details.service.ts +++ b/packages/blocks/service-details/src/api-harmonization/service-details.service.ts @@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common'; import { CMS, Resources } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapServiceDetails } from './service-details.mapper'; import { ServiceDetailsBlock } from './service-details.model'; import { GetServiceDetailsBlockParams, GetServiceDetailsBlockQuery } from './service-details.request'; +const H = HeaderName; + @Injectable() export class ServiceDetailsService { constructor( @@ -21,18 +22,19 @@ export class ServiceDetailsService { getServiceDetailsBlock( params: GetServiceDetailsBlockParams, query: GetServiceDetailsBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, ): Observable<ServiceDetailsBlock> { - const cms = this.cmsService.getServiceDetailsBlock({ ...query, locale: headers['x-locale'] }); - const service = this.resourceService.getService({ ...params, locale: headers['x-locale'] }); + const cms = this.cmsService.getServiceDetailsBlock({ ...query, locale: headers[H.Locale] }); + const service = this.resourceService.getService({ ...params, locale: headers[H.Locale] }); return forkJoin([cms, service]).pipe( map(([cms, service]) => { - const result = mapServiceDetails(cms, service, headers['x-locale'], headers['x-client-timezone'] || ''); + const result = mapServiceDetails(cms, service, headers[H.Locale], headers[H.ClientTimezone] || ''); // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions(headers.authorization, 'services', ['view']); + const authorization = headers[H.Authorization]; + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'services', ['view']); result.permissions = { view: permissions.view ?? false, diff --git a/packages/blocks/service-details/src/frontend/ServiceDetails.types.ts b/packages/blocks/service-details/src/frontend/ServiceDetails.types.ts index 4edf79524..9667d77d8 100644 --- a/packages/blocks/service-details/src/frontend/ServiceDetails.types.ts +++ b/packages/blocks/service-details/src/frontend/ServiceDetails.types.ts @@ -1,18 +1,13 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/service-details.client'; -export interface ServiceDetailsProps { - id: string; +export interface ServiceDetailsProps extends Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>> { serviceId: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; } export type ServiceDetailsPureProps = ServiceDetailsProps & Model.ServiceDetailsBlock; -export type ServiceDetailsRendererProps = Omit<ServiceDetailsProps, 'serviceId'> & { - slug: string[]; -}; +export type ServiceDetailsRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/service-details/src/sdk/index.ts b/packages/blocks/service-details/src/sdk/index.ts index 14a34024a..2ed9adf1c 100644 --- a/packages/blocks/service-details/src/sdk/index.ts +++ b/packages/blocks/service-details/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { serviceDetails } from './service-details'; diff --git a/packages/blocks/service-details/src/sdk/service-details.ts b/packages/blocks/service-details/src/sdk/service-details.ts index eceaa2deb..151dc3733 100644 --- a/packages/blocks/service-details/src/sdk/service-details.ts +++ b/packages/blocks/service-details/src/sdk/service-details.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/service-details.client'; @@ -13,7 +13,7 @@ export const serviceDetails = (sdk: Sdk) => ({ getServiceDetails: ( params: Request.GetServiceDetailsBlockParams, query: Request.GetServiceDetailsBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.ServiceDetailsBlock> => sdk.makeRequest({ diff --git a/packages/blocks/service-list/CHANGELOG.md b/packages/blocks/service-list/CHANGELOG.md index e3318c05b..992bc6b90 100644 --- a/packages/blocks/service-list/CHANGELOG.md +++ b/packages/blocks/service-list/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.service-list +## 1.5.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.5.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.5.0 ### Minor Changes diff --git a/packages/blocks/service-list/package.json b/packages/blocks/service-list/package.json index ea99f08e3..b19792918 100644 --- a/packages/blocks/service-list/package.json +++ b/packages/blocks/service-list/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.service-list", - "version": "1.5.0", + "version": "1.5.2", "private": false, "license": "MIT", "description": "A block displaying a list of services.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/service-list/src/api-harmonization/service-list.controller.ts b/packages/blocks/service-list/src/api-harmonization/service-list.controller.ts index 36c3641b3..be1b0ff02 100644 --- a/packages/blocks/service-list/src/api-harmonization/service-list.controller.ts +++ b/packages/blocks/service-list/src/api-harmonization/service-list.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class ServiceListController { @Get() @Auth.Decorators.Permissions({ resource: 'services', actions: ['view'] }) - getServiceListBlock(@Headers() headers: ApiModels.Headers.AppHeaders, @Query() query: GetServiceListBlockQuery) { + getServiceListBlock(@Headers() headers: AppHeaders, @Query() query: GetServiceListBlockQuery) { return this.service.getServiceListBlock(query, headers); } } diff --git a/packages/blocks/service-list/src/api-harmonization/service-list.service.ts b/packages/blocks/service-list/src/api-harmonization/service-list.service.ts index cda228c8d..2af3f64cb 100644 --- a/packages/blocks/service-list/src/api-harmonization/service-list.service.ts +++ b/packages/blocks/service-list/src/api-harmonization/service-list.service.ts @@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common'; import { CMS, Resources } from '@o2s/configs.integrations'; import { Observable, concatMap, forkJoin, map } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapServiceList } from './service-list.mapper'; import { ServiceListBlock } from './service-list.model'; import { GetServiceListBlockQuery } from './service-list.request'; +const H = HeaderName; + @Injectable() export class ServiceListService { constructor( @@ -18,11 +19,9 @@ export class ServiceListService { private readonly authService: Auth.Service, ) {} - getServiceListBlock( - query: GetServiceListBlockQuery, - headers: ApiModels.Headers.AppHeaders, - ): Observable<ServiceListBlock> { - const cms = this.cmsService.getServiceListBlock({ ...query, locale: headers['x-locale'] }); + getServiceListBlock(query: GetServiceListBlockQuery, headers: AppHeaders): Observable<ServiceListBlock> { + const authorization = headers[H.Authorization]; + const cms = this.cmsService.getServiceListBlock({ ...query, locale: headers[H.Locale] }); return forkJoin([cms]).pipe( concatMap(([cms]) => { @@ -37,24 +36,22 @@ export class ServiceListService { category: query.category, sort: query.sort, }, - headers['authorization'] || '', + authorization || '', ) .pipe( map((services) => { const result = mapServiceList( services, cms, - headers['x-locale'], - headers['x-client-timezone'] || '', + headers[H.Locale], + headers[H.ClientTimezone] || '', ); // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions( - headers.authorization, - 'services', - ['view'], - ); + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'services', [ + 'view', + ]); result.permissions = { view: permissions.view ?? false, diff --git a/packages/blocks/service-list/src/frontend/ServiceList.client.tsx b/packages/blocks/service-list/src/frontend/ServiceList.client.tsx index 5371a14eb..eeff3b0fd 100644 --- a/packages/blocks/service-list/src/frontend/ServiceList.client.tsx +++ b/packages/blocks/service-list/src/frontend/ServiceList.client.tsx @@ -95,10 +95,7 @@ export const ServiceListPure: React.FC<ServiceListPureProps> = ({ locale, access description={service.product.shortDescription} image={service.product.image} price={service.contract.price} - link={{ - label: data.detailsLabel, - url: service.detailsUrl, - }} + link={service.detailsUrl} status={{ label: service.contract.status.label, variant: diff --git a/packages/blocks/service-list/src/frontend/ServiceList.types.ts b/packages/blocks/service-list/src/frontend/ServiceList.types.ts index 0351d62fc..bfdc15905 100644 --- a/packages/blocks/service-list/src/frontend/ServiceList.types.ts +++ b/packages/blocks/service-list/src/frontend/ServiceList.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/service-list.client'; -export interface ServiceListProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type ServiceListProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type ServiceListPureProps = ServiceListProps & Model.ServiceListBlock; -export interface ServiceListRendererProps extends Omit<ServiceListProps, ''> { - slug: string[]; -} +export type ServiceListRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/service-list/src/sdk/index.ts b/packages/blocks/service-list/src/sdk/index.ts index cd466e130..4420b3a4d 100644 --- a/packages/blocks/service-list/src/sdk/index.ts +++ b/packages/blocks/service-list/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { serviceList } from './service-list'; diff --git a/packages/blocks/service-list/src/sdk/service-list.ts b/packages/blocks/service-list/src/sdk/service-list.ts index ac6081b02..9f74516d3 100644 --- a/packages/blocks/service-list/src/sdk/service-list.ts +++ b/packages/blocks/service-list/src/sdk/service-list.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/service-list.client'; @@ -12,7 +12,7 @@ export const serviceList = (sdk: Sdk) => ({ blocks: { getServiceList: ( query: Request.GetServiceListBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.ServiceListBlock> => sdk.makeRequest({ diff --git a/packages/blocks/surveyjs-form/CHANGELOG.md b/packages/blocks/surveyjs-form/CHANGELOG.md index e97c12acf..640778f46 100644 --- a/packages/blocks/surveyjs-form/CHANGELOG.md +++ b/packages/blocks/surveyjs-form/CHANGELOG.md @@ -1,5 +1,44 @@ # @o2s/blocks.surveyjs-form +## 1.4.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + - @o2s/modules.surveyjs@0.4.4 + +## 1.4.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/modules.surveyjs@0.4.3 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.4.0 ### Minor Changes diff --git a/packages/blocks/surveyjs-form/package.json b/packages/blocks/surveyjs-form/package.json index d2c3c5ef0..669f6c8b1 100644 --- a/packages/blocks/surveyjs-form/package.json +++ b/packages/blocks/surveyjs-form/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.surveyjs-form", - "version": "1.4.0", + "version": "1.4.2", "private": false, "license": "MIT", "description": "A block displaying a SurveyJS-based form that can be submitted.", @@ -52,7 +52,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "survey-core": "^2.5.12", "survey-react-ui": "^2.5.12", diff --git a/packages/blocks/surveyjs-form/src/api-harmonization/surveyjs.controller.ts b/packages/blocks/surveyjs-form/src/api-harmonization/surveyjs.controller.ts index 5ee18d815..71e007a5f 100644 --- a/packages/blocks/surveyjs-form/src/api-harmonization/surveyjs.controller.ts +++ b/packages/blocks/surveyjs-form/src/api-harmonization/surveyjs.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class SurveyjsController { @Get() @Auth.Decorators.Roles({ roles: [] }) - getSurveyJSBlock(@Headers() headers: ApiModels.Headers.AppHeaders, @Query() query: GetSurveyjsBlockQuery) { + getSurveyJSBlock(@Headers() headers: AppHeaders, @Query() query: GetSurveyjsBlockQuery) { return this.service.getSurveyjsBlock(query, headers); } } diff --git a/packages/blocks/surveyjs-form/src/api-harmonization/surveyjs.service.ts b/packages/blocks/surveyjs-form/src/api-harmonization/surveyjs.service.ts index 4b1850061..55b561ea9 100644 --- a/packages/blocks/surveyjs-form/src/api-harmonization/surveyjs.service.ts +++ b/packages/blocks/surveyjs-form/src/api-harmonization/surveyjs.service.ts @@ -2,19 +2,21 @@ import { Injectable } from '@nestjs/common'; import { CMS } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { mapSurveyjs } from './surveyjs.mapper'; import { SurveyjsBlock } from './surveyjs.model'; import { GetSurveyjsBlockQuery } from './surveyjs.request'; +const H = HeaderName; + @Injectable() export class SurveyjsService { constructor(private readonly cmsService: CMS.Service) {} - getSurveyjsBlock(query: GetSurveyjsBlockQuery, headers: ApiModels.Headers.AppHeaders): Observable<SurveyjsBlock> { - const cms = this.cmsService.getSurveyJsBlock({ ...query, locale: headers['x-locale'] }); + getSurveyjsBlock(query: GetSurveyjsBlockQuery, headers: AppHeaders): Observable<SurveyjsBlock> { + const cms = this.cmsService.getSurveyJsBlock({ ...query, locale: headers[H.Locale] }); - return forkJoin([cms]).pipe(map(([cms]) => mapSurveyjs(cms, headers['x-locale']))); + return forkJoin([cms]).pipe(map(([cms]) => mapSurveyjs(cms, headers[H.Locale]))); } } diff --git a/packages/blocks/surveyjs-form/src/frontend/SurveyJs.types.ts b/packages/blocks/surveyjs-form/src/frontend/SurveyJs.types.ts index b89c9686f..a2247efaa 100644 --- a/packages/blocks/surveyjs-form/src/frontend/SurveyJs.types.ts +++ b/packages/blocks/surveyjs-form/src/frontend/SurveyJs.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/surveyjs.client'; -export interface SurveyJsFormProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type SurveyJsFormProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type SurveyJsFormPureProps = SurveyJsFormProps & Model.SurveyjsBlock; -export interface SurveyJsFormRendererProps extends Omit<SurveyJsFormProps, ''> { - slug: string[]; -} +export type SurveyJsFormRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/surveyjs-form/src/sdk/index.ts b/packages/blocks/surveyjs-form/src/sdk/index.ts index cb60822b0..48ca24cb5 100644 --- a/packages/blocks/surveyjs-form/src/sdk/index.ts +++ b/packages/blocks/surveyjs-form/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { surveyjs } from './surveyjs'; diff --git a/packages/blocks/surveyjs-form/src/sdk/surveyjs.ts b/packages/blocks/surveyjs-form/src/sdk/surveyjs.ts index db52855fe..1753025f8 100644 --- a/packages/blocks/surveyjs-form/src/sdk/surveyjs.ts +++ b/packages/blocks/surveyjs-form/src/sdk/surveyjs.ts @@ -1,6 +1,6 @@ -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/surveyjs.client'; @@ -12,7 +12,7 @@ export const surveyjs = (sdk: Sdk) => ({ blocks: { getSurveyjsBlock: ( query: Request.GetSurveyjsBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.SurveyjsBlock> => sdk.makeRequest({ diff --git a/packages/blocks/ticket-details/CHANGELOG.md b/packages/blocks/ticket-details/CHANGELOG.md index 75e29ec4b..9d6f3ef57 100644 --- a/packages/blocks/ticket-details/CHANGELOG.md +++ b/packages/blocks/ticket-details/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.ticket-details +## 1.5.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.5.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.5.0 ### Minor Changes diff --git a/packages/blocks/ticket-details/package.json b/packages/blocks/ticket-details/package.json index 795654852..afbc63275 100644 --- a/packages/blocks/ticket-details/package.json +++ b/packages/blocks/ticket-details/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.ticket-details", - "version": "1.5.0", + "version": "1.5.2", "private": false, "license": "MIT", "description": "A block for displaying and managing ticket details.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/ticket-details/src/api-harmonization/ticket-details.controller.ts b/packages/blocks/ticket-details/src/api-harmonization/ticket-details.controller.ts index eee2d85ef..f0884a91e 100644 --- a/packages/blocks/ticket-details/src/api-harmonization/ticket-details.controller.ts +++ b/packages/blocks/ticket-details/src/api-harmonization/ticket-details.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Param, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -17,7 +17,7 @@ export class TicketDetailsController { @Get(':id') @Auth.Decorators.Permissions({ resource: 'tickets', actions: ['view'] }) getTicketDetailsBlock( - @Headers() headers: Models.Headers.AppHeaders, + @Headers() headers: AppHeaders, @Query() query: GetTicketDetailsBlockQuery, @Param() params: GetTicketDetailsBlockParams, ) { diff --git a/packages/blocks/ticket-details/src/api-harmonization/ticket-details.model.ts b/packages/blocks/ticket-details/src/api-harmonization/ticket-details.model.ts index d6eeb7c19..276d1a9d8 100644 --- a/packages/blocks/ticket-details/src/api-harmonization/ticket-details.model.ts +++ b/packages/blocks/ticket-details/src/api-harmonization/ticket-details.model.ts @@ -1,8 +1,8 @@ import { Tickets } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -export class TicketDetailsBlock extends Models.Block.Block { +export class TicketDetailsBlock extends ApiModels.Block.Block { __typename!: 'TicketDetailsBlock'; data!: Ticket; permissions?: { diff --git a/packages/blocks/ticket-details/src/api-harmonization/ticket-details.service.ts b/packages/blocks/ticket-details/src/api-harmonization/ticket-details.service.ts index c5a22b221..a9435ac7e 100644 --- a/packages/blocks/ticket-details/src/api-harmonization/ticket-details.service.ts +++ b/packages/blocks/ticket-details/src/api-harmonization/ticket-details.service.ts @@ -2,14 +2,15 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { CMS, Tickets } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapTicketDetails } from './ticket-details.mapper'; import { TicketDetailsBlock } from './ticket-details.model'; import { GetTicketDetailsBlockParams, GetTicketDetailsBlockQuery } from './ticket-details.request'; +const H = HeaderName; + @Injectable() export class TicketDetailsService { constructor( @@ -21,10 +22,10 @@ export class TicketDetailsService { getTicketDetailsBlock( params: GetTicketDetailsBlockParams, query: GetTicketDetailsBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, ): Observable<TicketDetailsBlock> { - const cms = this.cmsService.getTicketDetailsBlock({ ...query, locale: headers['x-locale'] }); - const ticket = this.ticketService.getTicket({ ...params, locale: headers['x-locale'] }); + const cms = this.cmsService.getTicketDetailsBlock({ ...query, locale: headers[H.Locale] }); + const ticket = this.ticketService.getTicket({ ...params, locale: headers[H.Locale] }); return forkJoin([ticket, cms]).pipe( map(([ticket, cms]) => { @@ -32,11 +33,12 @@ export class TicketDetailsService { throw new NotFoundException(); } - const result = mapTicketDetails(ticket, cms, headers['x-locale'], headers['x-client-timezone'] || ''); + const result = mapTicketDetails(ticket, cms, headers[H.Locale], headers[H.ClientTimezone] || ''); // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions(headers.authorization, 'tickets', [ + const authorization = headers[H.Authorization]; + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'tickets', [ 'view', 'edit', 'close', diff --git a/packages/blocks/ticket-details/src/frontend/TicketDetails.types.ts b/packages/blocks/ticket-details/src/frontend/TicketDetails.types.ts index 855e73fe9..6be983e24 100644 --- a/packages/blocks/ticket-details/src/frontend/TicketDetails.types.ts +++ b/packages/blocks/ticket-details/src/frontend/TicketDetails.types.ts @@ -1,18 +1,13 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/ticket-details.client'; -export interface TicketDetailsProps { - id: string; +export interface TicketDetailsProps extends Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>> { ticketId: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; } export type TicketDetailsPureProps = TicketDetailsProps & Model.TicketDetailsBlock; -export type TicketDetailsRendererProps = Omit<TicketDetailsProps, 'ticketId'> & { - slug: string[]; -}; +export type TicketDetailsRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/ticket-details/src/sdk/index.ts b/packages/blocks/ticket-details/src/sdk/index.ts index 46f80fd6d..7aa1263b6 100644 --- a/packages/blocks/ticket-details/src/sdk/index.ts +++ b/packages/blocks/ticket-details/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { ticketDetails } from './ticket-details'; diff --git a/packages/blocks/ticket-details/src/sdk/ticket-details.ts b/packages/blocks/ticket-details/src/sdk/ticket-details.ts index 06fe1fc7c..2eeaabd50 100644 --- a/packages/blocks/ticket-details/src/sdk/ticket-details.ts +++ b/packages/blocks/ticket-details/src/sdk/ticket-details.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/ticket-details.client'; @@ -13,7 +13,7 @@ export const ticketDetails = (sdk: Sdk) => ({ getTicketDetails: ( params: Request.GetTicketDetailsBlockParams, query: Request.GetTicketDetailsBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.TicketDetailsBlock> => sdk.makeRequest({ diff --git a/packages/blocks/ticket-list/CHANGELOG.md b/packages/blocks/ticket-list/CHANGELOG.md index 775143f14..44704cf4e 100644 --- a/packages/blocks/ticket-list/CHANGELOG.md +++ b/packages/blocks/ticket-list/CHANGELOG.md @@ -1,5 +1,46 @@ # @o2s/blocks.ticket-list +## 1.7.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- fadbc63: Align renderer prop types with runtime usage across blocks. + + Restore missing `isDraftModeEnabled` and `userId` coverage in renderer prop contracts and rename the misnamed notification details renderer prop type for consistency. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.7.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.7.0 ### Minor Changes diff --git a/packages/blocks/ticket-list/package.json b/packages/blocks/ticket-list/package.json index 50ca1f6a5..40f01fdb7 100644 --- a/packages/blocks/ticket-list/package.json +++ b/packages/blocks/ticket-list/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.ticket-list", - "version": "1.7.0", + "version": "1.7.2", "private": false, "license": "MIT", "description": "A block for displaying and managing user cases and tickets.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/ticket-list/src/api-harmonization/ticket-list.controller.ts b/packages/blocks/ticket-list/src/api-harmonization/ticket-list.controller.ts index c6887c3cd..5b0b197dc 100644 --- a/packages/blocks/ticket-list/src/api-harmonization/ticket-list.controller.ts +++ b/packages/blocks/ticket-list/src/api-harmonization/ticket-list.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './ticket-list.client'; @@ -16,7 +16,7 @@ export class TicketListController { @Get() @Auth.Decorators.Permissions({ resource: 'tickets', actions: ['view'] }) - getTicketListBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetTicketListBlockQuery) { + getTicketListBlock(@Headers() headers: AppHeaders, @Query() query: GetTicketListBlockQuery) { return this.service.getTicketListBlock(query, headers); } } diff --git a/packages/blocks/ticket-list/src/api-harmonization/ticket-list.model.ts b/packages/blocks/ticket-list/src/api-harmonization/ticket-list.model.ts index f499e5c24..b283875a7 100644 --- a/packages/blocks/ticket-list/src/api-harmonization/ticket-list.model.ts +++ b/packages/blocks/ticket-list/src/api-harmonization/ticket-list.model.ts @@ -29,7 +29,7 @@ export class TicketListBlock extends ApiModels.Block.Block { ticketId?: string; }; initialFilters?: Partial<Tickets.Model.Ticket & { sort?: string }>; - meta?: CMS.Model.TicketListBlock.TicketListBlock['meta']; + declare meta?: CMS.Model.TicketListBlock.TicketListBlock['meta']; cardHeaderSlots?: { top?: string; left?: string; diff --git a/packages/blocks/ticket-list/src/api-harmonization/ticket-list.service.ts b/packages/blocks/ticket-list/src/api-harmonization/ticket-list.service.ts index b73c40832..7b40b3f44 100644 --- a/packages/blocks/ticket-list/src/api-harmonization/ticket-list.service.ts +++ b/packages/blocks/ticket-list/src/api-harmonization/ticket-list.service.ts @@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common'; import { CMS, Tickets } from '@o2s/configs.integrations'; import { Observable, concatMap, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapTicketList } from './ticket-list.mapper'; import { TicketListBlock } from './ticket-list.model'; import { GetTicketListBlockQuery } from './ticket-list.request'; +const H = HeaderName; + @Injectable() export class TicketListService { constructor( @@ -18,11 +19,9 @@ export class TicketListService { private readonly authService: Auth.Service, ) {} - getTicketListBlock( - query: GetTicketListBlockQuery, - headers: Models.Headers.AppHeaders, - ): Observable<TicketListBlock> { - const cms = this.cmsService.getTicketListBlock({ ...query, locale: headers['x-locale'] }); + getTicketListBlock(query: GetTicketListBlockQuery, headers: AppHeaders): Observable<TicketListBlock> { + const authorization = headers[H.Authorization]; + const cms = this.cmsService.getTicketListBlock({ ...query, locale: headers[H.Locale] }); return forkJoin([cms]).pipe( concatMap(([cms]) => { @@ -32,24 +31,24 @@ export class TicketListService { ...query, limit: query.limit || cms.pagination?.limit || 1, offset: query.offset || 0, - locale: headers['x-locale'], + locale: headers[H.Locale], }) .pipe( map((tickets) => { const result = mapTicketList( tickets, cms, - headers['x-locale'], - headers['x-client-timezone'] || '', + headers[H.Locale], + headers[H.ClientTimezone] || '', ); // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions( - headers.authorization, - 'tickets', - ['view', 'create', 'delete'], - ); + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'tickets', [ + 'view', + 'create', + 'delete', + ]); result.permissions = { view: permissions.view ?? false, diff --git a/packages/blocks/ticket-list/src/frontend/TicketList.types.ts b/packages/blocks/ticket-list/src/frontend/TicketList.types.ts index 434d7b938..eca21a69e 100644 --- a/packages/blocks/ticket-list/src/frontend/TicketList.types.ts +++ b/packages/blocks/ticket-list/src/frontend/TicketList.types.ts @@ -1,25 +1,20 @@ import { VariantProps } from 'class-variance-authority'; import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import { baseVariant } from '@o2s/ui/lib/utils'; import type { Model } from '../api-harmonization/ticket-list.client'; -export interface TicketListProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; - isDraftModeEnabled?: boolean; +export interface TicketListProps extends Models.BlockProps.BlockWithDraftModeProps<ReturnType<typeof defineRouting>> { enableRowSelection?: boolean; } export type TicketListPureProps = TicketListProps & Model.TicketListBlock; -export type TicketListRendererProps = Omit<TicketListProps, ''> & { - slug: string[]; -}; +export type TicketListRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>> & + Pick<TicketListProps, 'enableRowSelection' | 'isDraftModeEnabled'>; export type Action = { url: string; diff --git a/packages/blocks/ticket-list/src/sdk/index.ts b/packages/blocks/ticket-list/src/sdk/index.ts index 81b6f47ae..57635cb0d 100644 --- a/packages/blocks/ticket-list/src/sdk/index.ts +++ b/packages/blocks/ticket-list/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { ticketList } from './ticket-list'; diff --git a/packages/blocks/ticket-list/src/sdk/ticket-list.ts b/packages/blocks/ticket-list/src/sdk/ticket-list.ts index 1da1b6e18..b45b82b9e 100644 --- a/packages/blocks/ticket-list/src/sdk/ticket-list.ts +++ b/packages/blocks/ticket-list/src/sdk/ticket-list.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/ticket-list.client'; @@ -12,7 +12,7 @@ export const ticketList = (sdk: Sdk) => ({ blocks: { getTicketList: ( query: Request.GetTicketListBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.TicketListBlock> => sdk.makeRequest({ diff --git a/packages/blocks/ticket-recent/CHANGELOG.md b/packages/blocks/ticket-recent/CHANGELOG.md index 90ffed983..c54c7badf 100644 --- a/packages/blocks/ticket-recent/CHANGELOG.md +++ b/packages/blocks/ticket-recent/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.ticket-recent +## 1.4.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.4.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.4.0 ### Minor Changes diff --git a/packages/blocks/ticket-recent/package.json b/packages/blocks/ticket-recent/package.json index ff03e9b0e..6642e997d 100644 --- a/packages/blocks/ticket-recent/package.json +++ b/packages/blocks/ticket-recent/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.ticket-recent", - "version": "1.4.0", + "version": "1.4.2", "private": false, "license": "MIT", "description": "A block displaying recent tickets and their comments.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/ticket-recent/src/api-harmonization/ticket-recent.controller.ts b/packages/blocks/ticket-recent/src/api-harmonization/ticket-recent.controller.ts index ac6f77f56..760895047 100644 --- a/packages/blocks/ticket-recent/src/api-harmonization/ticket-recent.controller.ts +++ b/packages/blocks/ticket-recent/src/api-harmonization/ticket-recent.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class TicketRecentController { @Get() @Auth.Decorators.Permissions({ resource: 'tickets', actions: ['view'] }) - getTicketRecentBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetTicketRecentBlockQuery) { + getTicketRecentBlock(@Headers() headers: AppHeaders, @Query() query: GetTicketRecentBlockQuery) { return this.service.getTicketRecentBlock(query, headers); } } diff --git a/packages/blocks/ticket-recent/src/api-harmonization/ticket-recent.model.ts b/packages/blocks/ticket-recent/src/api-harmonization/ticket-recent.model.ts index 18c25b3fd..bcd1f9e79 100644 --- a/packages/blocks/ticket-recent/src/api-harmonization/ticket-recent.model.ts +++ b/packages/blocks/ticket-recent/src/api-harmonization/ticket-recent.model.ts @@ -1,8 +1,8 @@ import { Tickets } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; -export class TicketRecentBlock extends Models.Block.Block { +export class TicketRecentBlock extends ApiModels.Block.Block { __typename!: 'TicketRecentBlock'; title?: string; noResults?: string; diff --git a/packages/blocks/ticket-recent/src/api-harmonization/ticket-recent.service.ts b/packages/blocks/ticket-recent/src/api-harmonization/ticket-recent.service.ts index 6371c47f1..e6a1612bd 100644 --- a/packages/blocks/ticket-recent/src/api-harmonization/ticket-recent.service.ts +++ b/packages/blocks/ticket-recent/src/api-harmonization/ticket-recent.service.ts @@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common'; import { CMS, Tickets } from '@o2s/configs.integrations'; import { Observable, concatMap, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapTicketRecent } from './ticket-recent.mapper'; import { TicketRecentBlock } from './ticket-recent.model'; import { GetTicketRecentBlockQuery } from './ticket-recent.request'; +const H = HeaderName; + @Injectable() export class TicketRecentService { constructor( @@ -18,32 +19,29 @@ export class TicketRecentService { private readonly authService: Auth.Service, ) {} - getTicketRecentBlock( - query: GetTicketRecentBlockQuery, - headers: Models.Headers.AppHeaders, - ): Observable<TicketRecentBlock> { - const cms = this.cmsService.getTicketRecentBlock({ ...query, locale: headers['x-locale'] }); + getTicketRecentBlock(query: GetTicketRecentBlockQuery, headers: AppHeaders): Observable<TicketRecentBlock> { + const authorization = headers[H.Authorization]; + const cms = this.cmsService.getTicketRecentBlock({ ...query, locale: headers[H.Locale] }); return forkJoin([cms]).pipe( concatMap(([cms]) => { return this.ticketsService - .getTicketList({ ...query, limit: cms.limit, locale: headers['x-locale'] }) + .getTicketList({ ...query, limit: cms.limit, locale: headers[H.Locale] }) .pipe( map((tickets) => { const result = mapTicketRecent( cms, tickets, - headers['x-locale'], - headers['x-client-timezone'] || '', + headers[H.Locale], + headers[H.ClientTimezone] || '', ); // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions( - headers.authorization, - 'tickets', - ['view', 'create'], - ); + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'tickets', [ + 'view', + 'create', + ]); result.permissions = { view: permissions.view ?? false, diff --git a/packages/blocks/ticket-recent/src/frontend/TicketRecent.types.ts b/packages/blocks/ticket-recent/src/frontend/TicketRecent.types.ts index 70dcbe391..4e7eb715f 100644 --- a/packages/blocks/ticket-recent/src/frontend/TicketRecent.types.ts +++ b/packages/blocks/ticket-recent/src/frontend/TicketRecent.types.ts @@ -1,17 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/ticket-recent.client'; -export interface TicketRecentProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - hasPriority?: boolean; -} +export type TicketRecentProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type TicketRecentPureProps = TicketRecentProps & Model.TicketRecentBlock; -export type TicketRecentRendererProps = Omit<TicketRecentProps, ''> & { - slug: string[]; -}; +export type TicketRecentRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/ticket-recent/src/sdk/index.ts b/packages/blocks/ticket-recent/src/sdk/index.ts index f3f6caf66..a600171fa 100644 --- a/packages/blocks/ticket-recent/src/sdk/index.ts +++ b/packages/blocks/ticket-recent/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { ticketRecent } from './ticket-recent'; diff --git a/packages/blocks/ticket-recent/src/sdk/ticket-recent.ts b/packages/blocks/ticket-recent/src/sdk/ticket-recent.ts index b2ebae6cb..3670f1188 100644 --- a/packages/blocks/ticket-recent/src/sdk/ticket-recent.ts +++ b/packages/blocks/ticket-recent/src/sdk/ticket-recent.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/ticket-recent.client'; @@ -12,7 +12,7 @@ export const ticketRecent = (sdk: Sdk) => ({ blocks: { getTicketRecent: ( query: Request.GetTicketRecentBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.TicketRecentBlock> => sdk.makeRequest({ diff --git a/packages/blocks/ticket-summary/CHANGELOG.md b/packages/blocks/ticket-summary/CHANGELOG.md index 285516159..6478086bb 100644 --- a/packages/blocks/ticket-summary/CHANGELOG.md +++ b/packages/blocks/ticket-summary/CHANGELOG.md @@ -1,5 +1,42 @@ # @o2s/blocks.ticket-summary +## 1.3.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.3.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.3.0 ### Minor Changes diff --git a/packages/blocks/ticket-summary/package.json b/packages/blocks/ticket-summary/package.json index f6cee72c7..8175f9d7b 100644 --- a/packages/blocks/ticket-summary/package.json +++ b/packages/blocks/ticket-summary/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.ticket-summary", - "version": "1.3.0", + "version": "1.3.2", "private": false, "license": "MIT", "description": "Displays a dynamic TicketSummary showing ticket counts grouped by status.", @@ -50,7 +50,7 @@ "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/ticket-summary/src/api-harmonization/ticket-summary.controller.ts b/packages/blocks/ticket-summary/src/api-harmonization/ticket-summary.controller.ts index 557d869db..6c8ce95e5 100644 --- a/packages/blocks/ticket-summary/src/api-harmonization/ticket-summary.controller.ts +++ b/packages/blocks/ticket-summary/src/api-harmonization/ticket-summary.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class TicketSummaryController { @Get() @Auth.Decorators.Permissions({ resource: 'tickets', actions: ['view'] }) - getTicketSummaryBlock(@Headers() headers: Models.Headers.AppHeaders, @Query() query: GetTicketSummaryBlockQuery) { + getTicketSummaryBlock(@Headers() headers: AppHeaders, @Query() query: GetTicketSummaryBlockQuery) { return this.service.getTicketSummaryBlock(query, headers); } } diff --git a/packages/blocks/ticket-summary/src/api-harmonization/ticket-summary.model.ts b/packages/blocks/ticket-summary/src/api-harmonization/ticket-summary.model.ts index 22fad6347..fbc76f928 100644 --- a/packages/blocks/ticket-summary/src/api-harmonization/ticket-summary.model.ts +++ b/packages/blocks/ticket-summary/src/api-harmonization/ticket-summary.model.ts @@ -1,4 +1,4 @@ -import { Models } from '@o2s/utils.api-harmonization'; +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; export class TicketSummaryInfoCard { title!: string; @@ -8,7 +8,7 @@ export class TicketSummaryInfoCard { variant?: 'OPEN' | 'IN_PROGRESS' | 'CLOSED'; } -export class TicketSummaryBlock extends Models.Block.Block { +export class TicketSummaryBlock extends ApiModels.Block.Block { __typename!: 'TicketSummaryBlock'; layout?: 'vertical' | 'horizontal'; infoCards!: TicketSummaryInfoCard[]; diff --git a/packages/blocks/ticket-summary/src/api-harmonization/ticket-summary.service.ts b/packages/blocks/ticket-summary/src/api-harmonization/ticket-summary.service.ts index c45e176a3..51c734658 100644 --- a/packages/blocks/ticket-summary/src/api-harmonization/ticket-summary.service.ts +++ b/packages/blocks/ticket-summary/src/api-harmonization/ticket-summary.service.ts @@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common'; import { CMS, Tickets } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapTicketSummary } from './ticket-summary.mapper'; import { TicketSummaryBlock } from './ticket-summary.model'; import { GetTicketSummaryBlockQuery } from './ticket-summary.request'; +const H = HeaderName; + @Injectable() export class TicketSummaryService { constructor( @@ -18,24 +19,22 @@ export class TicketSummaryService { private readonly authService: Auth.Service, ) {} - getTicketSummaryBlock( - query: GetTicketSummaryBlockQuery, - headers: Models.Headers.AppHeaders, - ): Observable<TicketSummaryBlock> { - const cms = this.cmsService.getTicketSummaryBlock({ ...query, locale: headers['x-locale'] }); + getTicketSummaryBlock(query: GetTicketSummaryBlockQuery, headers: AppHeaders): Observable<TicketSummaryBlock> { + const cms = this.cmsService.getTicketSummaryBlock({ ...query, locale: headers[H.Locale] }); const tickets = this.ticketService.getTicketList({ limit: 1000, offset: 0, - locale: headers['x-locale'], + locale: headers[H.Locale], }); return forkJoin([tickets, cms]).pipe( map(([tickets, cms]) => { - const result = mapTicketSummary(cms, tickets, headers['x-locale']); + const result = mapTicketSummary(cms, tickets, headers[H.Locale]); + const authorization = headers[H.Authorization]; // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions(headers.authorization, 'tickets', [ + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'tickets', [ 'view', 'create', ]); diff --git a/packages/blocks/ticket-summary/src/frontend/TicketSummary.types.ts b/packages/blocks/ticket-summary/src/frontend/TicketSummary.types.ts index 14a541331..a05b0a01b 100644 --- a/packages/blocks/ticket-summary/src/frontend/TicketSummary.types.ts +++ b/packages/blocks/ticket-summary/src/frontend/TicketSummary.types.ts @@ -1,16 +1,11 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/ticket-summary.client'; -export interface TicketSummaryProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; -} +export type TicketSummaryProps = Models.BlockProps.BaseBlockProps<ReturnType<typeof defineRouting>>; export type TicketSummaryPureProps = TicketSummaryProps & Model.TicketSummaryBlock; -export type TicketSummaryRendererProps = Omit<TicketSummaryProps, ''> & { - slug: string[]; -}; +export type TicketSummaryRendererProps = Models.BlockProps.BlockWithSlugProps<ReturnType<typeof defineRouting>>; diff --git a/packages/blocks/ticket-summary/src/sdk/index.ts b/packages/blocks/ticket-summary/src/sdk/index.ts index 40756ff99..58e2c2bdd 100644 --- a/packages/blocks/ticket-summary/src/sdk/index.ts +++ b/packages/blocks/ticket-summary/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { ticketSummary } from './ticket-summary'; diff --git a/packages/blocks/ticket-summary/src/sdk/ticket-summary.ts b/packages/blocks/ticket-summary/src/sdk/ticket-summary.ts index 72d776d91..c68408cd5 100644 --- a/packages/blocks/ticket-summary/src/sdk/ticket-summary.ts +++ b/packages/blocks/ticket-summary/src/sdk/ticket-summary.ts @@ -1,6 +1,6 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request, URL } from '../api-harmonization/ticket-summary.client'; @@ -11,7 +11,7 @@ export const ticketSummary = (sdk: Sdk) => ({ blocks: { getTicketSummary: ( query: Request.GetTicketSummaryBlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.TicketSummaryBlock> => sdk.makeRequest({ diff --git a/packages/blocks/user-account/CHANGELOG.md b/packages/blocks/user-account/CHANGELOG.md index 36e468295..874f07276 100644 --- a/packages/blocks/user-account/CHANGELOG.md +++ b/packages/blocks/user-account/CHANGELOG.md @@ -1,5 +1,46 @@ # @o2s/blocks.user-account +## 1.4.2 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- fadbc63: Align renderer prop types with runtime usage across blocks. + + Restore missing `isDraftModeEnabled` and `userId` coverage in renderer prop contracts and rename the misnamed notification details renderer prop type for consistency. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 1.4.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 1.4.0 ### Minor Changes diff --git a/packages/blocks/user-account/package.json b/packages/blocks/user-account/package.json index 74b339f02..7558fa376 100644 --- a/packages/blocks/user-account/package.json +++ b/packages/blocks/user-account/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/blocks.user-account", - "version": "1.4.0", + "version": "1.4.2", "private": false, "license": "MIT", "description": "A block displaying and managing user account information.", @@ -51,7 +51,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/blocks/user-account/src/api-harmonization/user-account.controller.ts b/packages/blocks/user-account/src/api-harmonization/user-account.controller.ts index 5da0fea4f..9f5ea9b37 100644 --- a/packages/blocks/user-account/src/api-harmonization/user-account.controller.ts +++ b/packages/blocks/user-account/src/api-harmonization/user-account.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { LoggerService } from '@o2s/utils.logger'; +import { AppHeaders } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { URL } from './'; @@ -16,7 +16,7 @@ export class UserAccountController { @Get() @Auth.Decorators.Permissions({ resource: 'settings', actions: ['view'] }) - getUserAccountBlock(@Headers() headers: ApiModels.Headers.AppHeaders, @Query() query: GetUserAccountBlockQuery) { + getUserAccountBlock(@Headers() headers: AppHeaders, @Query() query: GetUserAccountBlockQuery) { return this.service.getUserAccountBlock(query, headers); } } diff --git a/packages/blocks/user-account/src/api-harmonization/user-account.service.ts b/packages/blocks/user-account/src/api-harmonization/user-account.service.ts index c5c53204c..e9af904ec 100644 --- a/packages/blocks/user-account/src/api-harmonization/user-account.service.ts +++ b/packages/blocks/user-account/src/api-harmonization/user-account.service.ts @@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common'; import { CMS, Users } from '@o2s/configs.integrations'; import { Observable, forkJoin, map } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; - +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { mapUserAccount } from './user-account.mapper'; import { UserAccountBlock } from './user-account.model'; import { GetUserAccountBlockQuery } from './user-account.request'; +const H = HeaderName; + @Injectable() export class UserAccountService { constructor( @@ -18,23 +19,18 @@ export class UserAccountService { private readonly authService: Auth.Service, ) {} - getUserAccountBlock( - query: GetUserAccountBlockQuery, - headers: ApiModels.Headers.AppHeaders, - ): Observable<UserAccountBlock> { - const cms = this.cmsService.getUserAccountBlock({ id: query.id, locale: headers['x-locale'] }); - const user = this.usersService.getUser({ id: query.userId }, headers.authorization); + getUserAccountBlock(query: GetUserAccountBlockQuery, headers: AppHeaders): Observable<UserAccountBlock> { + const authorization = headers[H.Authorization]; + const cms = this.cmsService.getUserAccountBlock({ id: query.id, locale: headers[H.Locale] }); + const user = this.usersService.getUser({ id: query.userId }, authorization); return forkJoin([cms, user]).pipe( map(([cms, user]) => { - const result = mapUserAccount(cms, headers['x-locale'], user); + const result = mapUserAccount(cms, headers[H.Locale], user); // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions(headers.authorization, 'settings', [ - 'view', - 'edit', - ]); + if (authorization) { + const permissions = this.authService.canPerformActions(authorization, 'settings', ['view', 'edit']); result.permissions = { view: permissions.view ?? false, diff --git a/packages/blocks/user-account/src/frontend/UserAccount.types.ts b/packages/blocks/user-account/src/frontend/UserAccount.types.ts index fea560b85..78f14c014 100644 --- a/packages/blocks/user-account/src/frontend/UserAccount.types.ts +++ b/packages/blocks/user-account/src/frontend/UserAccount.types.ts @@ -1,19 +1,15 @@ import { defineRouting } from 'next-intl/routing'; +import type { Models } from '@o2s/framework/modules'; + import type { Model } from '../api-harmonization/user-account.client'; -export interface UserAccountProps { - id: string; - accessToken?: string; - locale: string; - routing: ReturnType<typeof defineRouting>; - userId?: string; +export interface UserAccountProps extends Models.BlockProps.BlockWithUserIdProps<ReturnType<typeof defineRouting>> { onSignOut: () => void; - hasPriority?: boolean; } export type UserAccountPureProps = UserAccountProps & Model.UserAccountBlock; -export type UserAccountRendererProps = Omit<UserAccountProps, ''> & { - slug: string[]; +export type UserAccountRendererProps = Models.BlockProps.BlockWithUserIdProps<ReturnType<typeof defineRouting>> & { + onSignOut: () => void; }; diff --git a/packages/blocks/user-account/src/sdk/index.ts b/packages/blocks/user-account/src/sdk/index.ts index 4358d5818..251dce0da 100644 --- a/packages/blocks/user-account/src/sdk/index.ts +++ b/packages/blocks/user-account/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { userAccount } from './user-account'; diff --git a/packages/blocks/user-account/src/sdk/user-account.ts b/packages/blocks/user-account/src/sdk/user-account.ts index 7c9772235..8c45205e9 100644 --- a/packages/blocks/user-account/src/sdk/user-account.ts +++ b/packages/blocks/user-account/src/sdk/user-account.ts @@ -1,6 +1,6 @@ -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request } from '../api-harmonization/user-account.client'; @@ -12,7 +12,7 @@ export const userAccount = (sdk: Sdk) => ({ blocks: { getUserAccount: ( query: Request.GetUserAccountBlockQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.UserAccountBlock> => sdk.makeRequest({ diff --git a/packages/cli/create-o2s-app/CHANGELOG.md b/packages/cli/create-o2s-app/CHANGELOG.md index 7e105f879..89ce60aa2 100644 --- a/packages/cli/create-o2s-app/CHANGELOG.md +++ b/packages/cli/create-o2s-app/CHANGELOG.md @@ -1,5 +1,38 @@ # create-o2s-app +## 4.1.1 + +### Patch Changes + +- 338cb01: Use the `create-o2s-app/base` branch as the source for `eject-block` so ejected blocks match production-ready templates. +- 338cb01: Show a post-generation block summary and next steps at the end of generated changes output. +- 8c57c81: chore(deps): update dependencies + +## 4.1.0 + +### Minor Changes + +- a2d9ea4: CLI scaffolding and logging enhancements (branding header, summary box, docs links, block warnings) + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [98b2e68] + - @o2s/telemetry@1.2.2 + +## 4.0.0 + +### Major Changes + +- a1659cf: added fix related to dxp starter and mocked data + +### Minor Changes + +- a1659cf: fix: update CLI commands in logger to reflect new watch commands for development +- a1659cf: fix: update CLI custom installation process + ## 3.0.0 ### Major Changes diff --git a/packages/cli/create-o2s-app/README.md b/packages/cli/create-o2s-app/README.md index 9920b87bf..71291f67d 100644 --- a/packages/cli/create-o2s-app/README.md +++ b/packages/cli/create-o2s-app/README.md @@ -22,21 +22,21 @@ At the end, the wizard scaffolds your project and installs dependencies. ## Templates -| Template | Description | -|----------|-------------| -| `o2s` | Full O2S Customer Portal — ticket management, invoices, notifications, orders and more | -| `dxp` | DXP Frontend Starter — knowledge base, marketing portal, Digital Experience Platform | -| `custom` | Start from scratch — choose only the blocks and integrations you need | +| Template | Description | +| -------- | -------------------------------------------------------------------------------------- | +| `o2s` | Full O2S Customer Portal — ticket management, invoices, notifications, orders and more | +| `dxp` | DXP Frontend Starter — knowledge base, marketing portal, Digital Experience Platform | +| `custom` | Start from scratch — choose only the blocks and integrations you need | ## Options -| Option | Description | -|--------|-------------| -| `--template <template>` | Template to use: `o2s`, `dxp`, or `custom` | -| `--blocks <blocks>` | Comma-separated list of block names (skips block selection prompt) | +| Option | Description | +| ------------------------------- | ------------------------------------------------------------------------------ | +| `--template <template>` | Template to use: `o2s`, `dxp`, or `custom` | +| `--blocks <blocks>` | Comma-separated list of block names (skips block selection prompt) | | `--integrations <integrations>` | Comma-separated list of integration names (skips integration selection prompt) | -| `--skip-install` | Skip the `npm install` step | -| `--directory <dir>` | Destination directory (defaults to project name) | +| `--skip-install` | Skip the `npm install` step | +| `--directory <dir>` | Destination directory (defaults to project name) | ## Non-Interactive Mode @@ -44,18 +44,18 @@ Pass all required options as CLI flags to skip the interactive prompts: ```bash # Create an O2S portal with specific blocks and integrations -npx create-o2s-app@latest my-portal \ +npx create-o2s-app my-portal \ --template o2s \ --blocks ticket-list,invoice-list \ --integrations zendesk,strapi-cms # Create a DXP starter, skip install -npx create-o2s-app@latest my-dxp \ +npx create-o2s-app my-dxp \ --template dxp \ --skip-install # Custom setup with only selected blocks -npx create-o2s-app@latest my-custom \ +npx create-o2s-app my-custom \ --template custom \ --blocks article-list,article-details \ --integrations strapi-cms diff --git a/packages/cli/create-o2s-app/package.json b/packages/cli/create-o2s-app/package.json index 1a1d51ed8..1fd7e5e33 100644 --- a/packages/cli/create-o2s-app/package.json +++ b/packages/cli/create-o2s-app/package.json @@ -1,6 +1,6 @@ { "name": "create-o2s-app", - "version": "3.0.0", + "version": "4.1.1", "private": false, "license": "MIT", "description": "CLI tool to scaffold new O2S applications with Next.js and NestJS setup.", @@ -32,11 +32,11 @@ "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"" }, "dependencies": { - "@o2s/telemetry": "^1.2.1", + "@o2s/telemetry": "^1.2.2", "@types/prompts": "^2.4.9", "cli-progress": "^3.12.0", "commander": "^14.0.3", - "fs-extra": "^11.3.0", + "fs-extra": "^11.3.4", "kleur": "^3.0.3", "prompts": "^2.4.2", "simple-git": "^3.32.3", @@ -49,7 +49,7 @@ "@o2s/typescript-config": "*", "@types/cli-progress": "^3.11.6", "@types/fs-extra": "^11.0.4", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "typescript": "^5.9.3" } diff --git a/packages/cli/create-o2s-app/src/constants.ts b/packages/cli/create-o2s-app/src/constants.ts index 474c5b253..17cdae624 100644 --- a/packages/cli/create-o2s-app/src/constants.ts +++ b/packages/cli/create-o2s-app/src/constants.ts @@ -4,7 +4,8 @@ export const GITHUB_REPO_URL = 'https://github.com/o2sdev/openselfservice'; export const GITHUB_BRANCH = 'create-o2s-app/base'; export const PROJECT_PREFIX = 'o2s'; export const STORYBOOK_URL = 'https://storybook-o2s.openselfservice.com/'; -export const DOCS_URL = 'https://docs.openselfservice.com/getting-started'; +export const DOCS_URL = 'https://www.openselfservice.com/docs/getting-started/installation'; +export const DOCS_INTEGRATIONS_URL = 'https://www.openselfservice.com/docs/integrations'; export const BLOCKS_PATH = 'packages/blocks'; export const INTEGRATIONS_PATH = 'packages/integrations'; diff --git a/packages/cli/create-o2s-app/src/index.ts b/packages/cli/create-o2s-app/src/index.ts index 5415d8f71..e7e3b2e52 100644 --- a/packages/cli/create-o2s-app/src/index.ts +++ b/packages/cli/create-o2s-app/src/index.ts @@ -2,7 +2,7 @@ import { cleanupTempDir, cloneRepository } from './scaffold/clone'; import { scaffold } from './scaffold/index'; import { TemplateType } from './types'; -import { printError, printSummary } from './utils/logger'; +import { printBanner, printError, printSummary } from './utils/logger'; import { runWizard } from './wizard/index'; import * as telemetry from '@o2s/telemetry'; import { Command } from 'commander'; @@ -11,7 +11,7 @@ const program = new Command(); program .name('create-o2s-app') - .description('Create a new O2S project with interactive setup wizard') + .description('Scaffold your custom frontend') .argument('[name]', 'Name of the new project') .option('--directory [directory]', 'Specify the destination directory') .option('--template [template]', 'Template: o2s | dxp | custom') @@ -19,6 +19,8 @@ program .option('--integrations [integrations]', 'Comma-separated list of integration names (for non-interactive mode)') .option('--skip-install', 'Skip npm install step') .action(async (name, options) => { + printBanner(); + telemetry.sendEvent('o2s', 'create-o2s-app', 'create-project'); await telemetry.flushEvents(); @@ -51,8 +53,8 @@ program printSummary( targetDir, answers.template, - answers.selectedBlocks.length, - answers.selectedIntegrations.length, + answers.selectedBlocks, + answers.selectedIntegrations, uncoveredModules, options.skipInstall, ); diff --git a/packages/cli/create-o2s-app/src/scaffold/index.ts b/packages/cli/create-o2s-app/src/scaffold/index.ts index c45d5649a..4932e40a2 100644 --- a/packages/cli/create-o2s-app/src/scaffold/index.ts +++ b/packages/cli/create-o2s-app/src/scaffold/index.ts @@ -1,3 +1,4 @@ +import { INTEGRATIONS_PATH } from '../constants'; import { WizardAnswers } from '../types'; import { cleanupProject } from './cleanup'; import { generateEnvFiles } from './generate-env'; @@ -12,6 +13,33 @@ import { transformRenderBlocks } from './transform-render-blocks'; import * as fs from 'fs-extra'; import * as path from 'path'; +const readIntegrationVersions = async ( + projectDir: string, + selectedIntegrations: string[], +): Promise<Record<string, string>> => { + const versions: Record<string, string> = {}; + + for (const name of selectedIntegrations) { + const pkgPath = path.join(projectDir, INTEGRATIONS_PATH, name, 'package.json'); + + if (!(await fs.pathExists(pkgPath))) { + throw new Error( + `Integration "${name}" not found at expected path: ${pkgPath}. Ensure the integration exists in the template.`, + ); + } + + const pkg = await fs.readJson(pkgPath); + + if (!pkg.version) { + throw new Error(`Integration "${name}" has no version field in ${pkgPath}.`); + } + + versions[name] = pkg.version; + } + + return versions; +}; + export const scaffold = async ( tempDir: string, answers: WizardAnswers, @@ -33,10 +61,13 @@ export const scaffold = async ( console.log(`Creating project in "${targetDir}"...`); await fs.move(tempDir, targetDir); - // Step 2: Remove unneeded directories (all packages/* are removed; deps come from npm registry) + // Step 2: Read integration versions before cleanup removes the source directories + const integrationVersions = await readIntegrationVersions(targetDir, selectedIntegrations); + + // Step 3: Remove unneeded directories (all packages/* are removed; deps come from npm registry) await cleanupProject(targetDir); - // Step 3: Remove unselected block/integration references from app source files + // Step 4: Remove unselected block/integration references from app source files console.log('Configuring project for selected blocks...'); await Promise.all([ transformAppModule(targetDir, selectedBlocks), @@ -45,24 +76,25 @@ export const scaffold = async ( transformAppsPackageJson(targetDir, selectedBlocks, selectedIntegrations), ]); - // Step 4: Clean up root package.json (remove workspace entries for deleted dirs) + // Step 5: Clean up root package.json (remove workspace entries for deleted dirs) await transformRootPackageJson(targetDir); - // Step 5: Configure integration source files and generate environment + // Step 6: Configure integration source files and generate environment console.log('Configuring integrations...'); const uncoveredModules = await transformIntegrationConfigs( targetDir, selectedIntegrations, conflictResolutions, integrationModules, + integrationVersions, ); await generateEnvFiles(targetDir, envVars, selectedIntegrations); warnUnconfiguredModules(uncoveredModules); - // Step 6: Clean symlinks from package-lock.json and install dependencies - await cleanPackageLock(targetDir); + // Step 7: Clean symlinks from package-lock.json and install dependencies + await cleanPackageLock(targetDir, selectedIntegrations); if (!skipInstall) { - await installDependencies(targetDir); + await installDependencies(targetDir, selectedIntegrations); } return { targetDir, uncoveredModules }; diff --git a/packages/cli/create-o2s-app/src/scaffold/install.ts b/packages/cli/create-o2s-app/src/scaffold/install.ts index a86d0ed1e..6567841cd 100644 --- a/packages/cli/create-o2s-app/src/scaffold/install.ts +++ b/packages/cli/create-o2s-app/src/scaffold/install.ts @@ -1,13 +1,18 @@ +import { ALWAYS_REMOVE_DIRS } from '../constants'; import { execSync } from 'child_process'; import * as fs from 'fs-extra'; import * as path from 'path'; /** - * Remove symlink entries ("link": true) from package-lock.json. - * Without this, npm install would recreate empty folders for removed packages - * (framework, integrations, blocks etc.) because the lock file still references them. + * Clean package-lock.json by removing: + * - Local workspace symlink entries ("link": true) + * - Entries for directories removed during cleanup (packages/blocks/*, packages/integrations/*, etc.) + * - References to unselected integrations in nested dependency lists + * + * Without this, npm install would either recreate empty folders for removed + * workspace packages or install unselected integrations from the registry. */ -export const cleanPackageLock = async (projectDir: string): Promise<void> => { +export const cleanPackageLock = async (projectDir: string, selectedIntegrations: string[]): Promise<void> => { const lockFilePath = path.join(projectDir, 'package-lock.json'); if (!(await fs.pathExists(lockFilePath))) return; @@ -15,10 +20,43 @@ export const cleanPackageLock = async (projectDir: string): Promise<void> => { const file = await fs.readFile(lockFilePath, 'utf8'); const json = JSON.parse(file); + const isUnselectedIntegration = (name: string): boolean => { + const match = name.match(/^@o2s\/integrations\.(.+)$/); + return !!match && !!match[1] && !selectedIntegrations.includes(match[1]); + }; + + const isRemovedDir = (key: string): boolean => { + return ALWAYS_REMOVE_DIRS.some((dir) => key === dir || key.startsWith(`${dir}/`)); + }; + if (json?.packages) { for (const [key, value] of Object.entries(json.packages)) { - if (value && typeof value === 'object' && (value as { link?: boolean }).link) { + if (!value || typeof value !== 'object') continue; + const entry = value as Record<string, unknown>; + + // Remove local workspace symlinks + if (entry.link) { + delete json.packages[key]; + continue; + } + + // Remove stale entries: deleted directories and workspace packages + // whose package.json was modified during scaffolding. + // npm install will regenerate correct entries from the actual files on disk. + if (isRemovedDir(key) || key.startsWith('packages/')) { delete json.packages[key]; + continue; + } + + // Remove unselected integrations from nested dependencies + for (const section of ['dependencies', 'devDependencies'] as const) { + const deps = entry[section] as Record<string, string> | undefined; + if (!deps) continue; + for (const depName of Object.keys(deps)) { + if (isUnselectedIntegration(depName)) { + delete deps[depName]; + } + } } } } @@ -26,8 +64,8 @@ export const cleanPackageLock = async (projectDir: string): Promise<void> => { await fs.writeFile(lockFilePath, JSON.stringify(json, null, 4)); }; -export const installDependencies = async (projectDir: string): Promise<void> => { - await cleanPackageLock(projectDir); +export const installDependencies = async (projectDir: string, selectedIntegrations: string[]): Promise<void> => { + await cleanPackageLock(projectDir, selectedIntegrations); console.log(); console.log('Installing dependencies...'); diff --git a/packages/cli/create-o2s-app/src/scaffold/transform-integration-configs.ts b/packages/cli/create-o2s-app/src/scaffold/transform-integration-configs.ts index 1820d7d5c..d15f19d80 100644 --- a/packages/cli/create-o2s-app/src/scaffold/transform-integration-configs.ts +++ b/packages/cli/create-o2s-app/src/scaffold/transform-integration-configs.ts @@ -31,15 +31,23 @@ const buildModuleIntegrationMap = ( return moduleMap; }; -const updateConfigsPackageJson = async (projectDir: string, selectedIntegrations: string[]): Promise<void> => { +const updateConfigsPackageJson = async ( + projectDir: string, + selectedIntegrations: string[], + integrationVersions: Record<string, string>, +): Promise<void> => { const pkgPath = path.join(projectDir, CONFIGS_PACKAGE_JSON_PATH); if (!(await fs.pathExists(pkgPath))) return; const pkg = await fs.readJson(pkgPath); + if (!pkg.dependencies) { + pkg.dependencies = {}; + } + // Remove integration deps that were not selected - for (const key of Object.keys((pkg.dependencies || {}) as Record<string, string>)) { + for (const key of Object.keys(pkg.dependencies as Record<string, string>)) { if (key.startsWith('@o2s/integrations.')) { const name = key.replace('@o2s/integrations.', ''); if (!selectedIntegrations.includes(name)) { @@ -48,6 +56,17 @@ const updateConfigsPackageJson = async (projectDir: string, selectedIntegrations } } + // Add selected integrations that are not yet in dependencies + for (const name of selectedIntegrations) { + const packageName = `@o2s/integrations.${name}`; + if (!pkg.dependencies[packageName]) { + const version = integrationVersions[name]; + if (version) { + pkg.dependencies[packageName] = `^${version}`; + } + } + } + await fs.writeJson(pkgPath, pkg, { spaces: 4 }); }; @@ -56,12 +75,10 @@ export const transformIntegrationConfigs = async ( selectedIntegrations: string[], conflictResolutions: ConflictResolution[], integrationModules: Record<string, string[]>, + integrationVersions: Record<string, string>, ): Promise<string[]> => { - const moduleMap = buildModuleIntegrationMap(selectedIntegrations, conflictResolutions, integrationModules); - - if (moduleMap.size === 0) { - return []; - } + // Always update configs package.json to add/remove integration dependencies + await updateConfigsPackageJson(projectDir, selectedIntegrations, integrationVersions); const modelsDir = path.join(projectDir, CONFIGS_MODELS_PATH); @@ -69,6 +86,9 @@ export const transformIntegrationConfigs = async ( return []; } + // Replace mocked imports with selected integration imports + const moduleMap = buildModuleIntegrationMap(selectedIntegrations, conflictResolutions, integrationModules); + for (const [module, integration] of moduleMap.entries()) { const filePath = path.join(modelsDir, `${module}.ts`); @@ -80,8 +100,6 @@ export const transformIntegrationConfigs = async ( await fs.writeFile(filePath, updatedContent, 'utf-8'); } - await updateConfigsPackageJson(projectDir, selectedIntegrations); - // Detect model files that still import mocked but mocked is not selected. // Skip only when 'mocked' (full) is selected — it covers all modules. // 'mocked-dxp' covers only cms/articles/search, so uncovered detection runs normally. diff --git a/packages/cli/create-o2s-app/src/utils/logger.ts b/packages/cli/create-o2s-app/src/utils/logger.ts index e69f52a4a..e5593ad18 100644 --- a/packages/cli/create-o2s-app/src/utils/logger.ts +++ b/packages/cli/create-o2s-app/src/utils/logger.ts @@ -1,58 +1,164 @@ -import { DOCS_URL } from '../constants'; +import { DOCS_INTEGRATIONS_URL, DOCS_URL, STORYBOOK_URL } from '../constants'; import { TemplateType } from '../types'; import { TEMPLATES } from '../wizard/templates'; import kleur from 'kleur'; +import path from 'path'; + +export const printBanner = (): void => { + const title = 'Open Self Service CLI'; + const desc = 'Scaffold your custom composable frontend'; + const contentWidth = 46; + + const topLine = ` ${kleur.blue('╭' + '─'.repeat(contentWidth) + '╮')}`; + const empty = ` ${kleur.blue('│')}${' '.repeat(contentWidth)}${kleur.blue('│')}`; + const bottomLine = ` ${kleur.blue('╰' + '─'.repeat(contentWidth) + '╯')}`; + + const titlePad = Math.floor((contentWidth - title.length) / 2); + const titleRightPad = contentWidth - title.length - titlePad; + const titleLine = ` ${kleur.blue('│')}${' '.repeat(titlePad)}${kleur.bold().white(title)}${' '.repeat(titleRightPad)}${kleur.blue('│')}`; + + const descPad = Math.floor((contentWidth - desc.length) / 2); + const descRightPad = contentWidth - desc.length - descPad; + const descLine = ` ${kleur.blue('│')}${' '.repeat(descPad)}${kleur.dim().white(desc)}${' '.repeat(descRightPad)}${kleur.blue('│')}`; + + console.log(); + console.log(topLine); + console.log(empty); + console.log(titleLine); + console.log(descLine); + console.log(empty); + console.log(bottomLine); + console.log(); +}; export const printSummary = ( targetDir: string, template: TemplateType, - blockCount: number, - integrationCount: number, + selectedBlocks: string[], + selectedIntegrations: string[], uncoveredModules: string[] = [], skipInstall = false, ): void => { const templateName = TEMPLATES[template].name; + const relativeDir = path.relative(process.cwd(), targetDir) || '.'; - console.log(); - console.log(kleur.green().bold('Project created successfully!')); - console.log(); - console.log(` Location: ${kleur.cyan(targetDir)}`); - console.log(` Template: ${templateName}`); - console.log(` Blocks: ${blockCount} blocks`); - console.log(` Integrations: ${integrationCount} integrations`); - console.log(); + // Collect all content lines to measure max width + const lines: Array<{ text: string; plain: string }> = []; + + const addLine = (text: string, plain: string): void => { + lines.push({ text, plain }); + }; + const addEmpty = (): void => { + lines.push({ text: '', plain: '' }); + }; + + addLine(` ${kleur.green().bold('Project created successfully!')}`, ' Project created successfully!'); + addEmpty(); + addLine(` Location: ${kleur.cyan(`./${relativeDir}`)}`, ` Location: ./${relativeDir}`); + addLine(` Template: ${templateName}`, ` Template: ${templateName}`); + addEmpty(); + + addLine( + ` ${kleur.bold('Blocks')} ${kleur.dim(`(${selectedBlocks.length})`)}`, + ` Blocks (${selectedBlocks.length})`, + ); + for (const block of selectedBlocks) { + addLine(` ${kleur.cyan('•')} ${block}`, ` • ${block}`); + } + addEmpty(); + + addLine( + ` ${kleur.bold('Integrations')} ${kleur.dim(`(${selectedIntegrations.length})`)}`, + ` Integrations (${selectedIntegrations.length})`, + ); + for (const integration of selectedIntegrations) { + addLine(` ${kleur.yellow('•')} ${integration}`, ` • ${integration}`); + } if (uncoveredModules.length > 0) { - console.log(kleur.yellow().bold('⚠ Warning: the following modules have no integration assigned:')); - console.log(); + addEmpty(); + addLine(` ${kleur.yellow().bold('⚠ Uncovered modules')}`, ' ⚠ Uncovered modules'); for (const mod of uncoveredModules) { - console.log(kleur.yellow(` - ${mod}`)); + addLine(` ${kleur.red('•')} ${mod}`, ` • ${mod}`); } - console.log(); - console.log(kleur.red(' The project will NOT start until these modules are configured.')); - console.log(' To fix this, edit packages/configs/integrations/src/models/<module>.ts'); - console.log(' and replace the @o2s/integrations.mocked import with a real integration.'); - console.log(); - console.log(kleur.bold('Next steps:')); - console.log(); - console.log(` cd ${targetDir}`); - console.log(kleur.dim(' # 1. Configure the modules listed above')); - console.log(kleur.dim(' # 2. Add the required env vars to apps/api-harmonization/.env.local')); - console.log(kleur.dim(' # 3. npm run dev')); + addEmpty(); + addLine( + ` ${kleur.red('The project will NOT start until these are configured.')}`, + ' The project will NOT start until these are configured.', + ); + addLine( + ` ${kleur.dim('Edit packages/configs/integrations/src/models/<module>.ts')}`, + ' Edit packages/configs/integrations/src/models/<module>.ts', + ); + } + + addEmpty(); + addLine(` ${kleur.bold('Next steps:')}`, ' Next steps:'); + addEmpty(); + addLine(` ${kleur.white(`cd ./${relativeDir}`)}`, ` cd ./${relativeDir}`); + + if (uncoveredModules.length > 0) { + addLine( + ` ${kleur.dim('# 1. Configure the uncovered modules above')}`, + ' # 1. Configure the uncovered modules above', + ); + addLine( + ` ${kleur.dim('# 2. Add required env vars to apps/api-harmonization/.env.local')}`, + ' # 2. Add required env vars to apps/api-harmonization/.env.local', + ); + addLine( + ` ${kleur.dim('# 3. Start development (two terminals):')}`, + ' # 3. Start development (two terminals):', + ); + addLine(` ${kleur.dim('npm run watch:deps')}`, ' npm run watch:deps'); + addLine(` ${kleur.dim('npm run watch:apps')}`, ' npm run watch:apps'); } else { - console.log(kleur.bold('Next steps:')); - console.log(); - console.log(` cd ${targetDir}`); if (skipInstall) { - console.log(kleur.dim(' # Install dependencies (npm install was skipped)')); - console.log(' npm install'); + addLine( + ` ${kleur.dim('# Install dependencies (npm install was skipped)')}`, + ' # Install dependencies (npm install was skipped)', + ); + addLine(' npm install', ' npm install'); } - console.log(' npm run dev'); + addLine( + ` npm run watch:deps ${kleur.dim('# Terminal 1: watch & build packages')}`, + ' npm run watch:deps # Terminal 1: watch & build packages', + ); + addLine( + ` npm run watch:apps ${kleur.dim('# Terminal 2: run frontend + API')}`, + ' npm run watch:apps # Terminal 2: run frontend + API', + ); } + addEmpty(); + addLine(` ${kleur.bold('Useful links:')}`, ' Useful links:'); + addLine( + ` ${kleur.dim('Docs & installation:')} ${kleur.cyan(DOCS_URL)}`, + ` Docs & installation: ${DOCS_URL}`, + ); + addLine( + ` ${kleur.dim('Storybook (blocks):')} ${kleur.cyan(STORYBOOK_URL)}`, + ` Storybook (blocks): ${STORYBOOK_URL}`, + ); + addLine( + ` ${kleur.dim('Integrations guide:')} ${kleur.cyan(DOCS_INTEGRATIONS_URL)}`, + ` Integrations guide: ${DOCS_INTEGRATIONS_URL}`, + ); + + // Calculate box width + const maxPlainWidth = Math.max(...lines.map((l) => l.plain.length)); + const boxWidth = maxPlainWidth + 4; // 2 padding each side + + // Print box console.log(); - console.log(kleur.bold('Documentation:')); - console.log(kleur.cyan(` ${DOCS_URL}`)); + console.log(` ${kleur.blue('╭' + '─'.repeat(boxWidth) + '╮')}`); + + for (const line of lines) { + const rightPad = boxWidth - line.plain.length; + console.log(` ${kleur.blue('│')}${line.text}${' '.repeat(rightPad)}${kleur.blue('│')}`); + } + + console.log(` ${kleur.blue('╰' + '─'.repeat(boxWidth) + '╯')}`); console.log(); }; diff --git a/packages/cli/create-o2s-app/src/wizard/prompts.ts b/packages/cli/create-o2s-app/src/wizard/prompts.ts index 440dba97f..fcfcd571b 100644 --- a/packages/cli/create-o2s-app/src/wizard/prompts.ts +++ b/packages/cli/create-o2s-app/src/wizard/prompts.ts @@ -95,10 +95,30 @@ export const runWizardPrompts = async ( let selectedIntegrations: string[]; if (template === 'custom') { + const tipLines = [ + `${kleur.blue().bold('Tip:')} Not sure which blocks to pick?`, + `Browse our Storybook to preview all available components:`, + kleur.cyan(STORYBOOK_URL), + ]; + const tipWidth = Math.max( + 'Tip: Not sure which blocks to pick?'.length, + 'Browse our Storybook to preview all available components:'.length, + STORYBOOK_URL.length, + ); + const boxWidth = tipWidth + 4; + console.log(); - console.log(kleur.cyan().bold(' Tip:') + ' Not sure which blocks to pick?'); - console.log(` Browse our Storybook to preview all available components:`); - console.log(kleur.cyan(` ${STORYBOOK_URL}`)); + console.log(` ${kleur.blue('╭' + '─'.repeat(boxWidth) + '╮')}`); + console.log( + ` ${kleur.blue('│')} ${tipLines[0]}${' '.repeat(boxWidth - 2 - 'Tip: Not sure which blocks to pick?'.length)}${kleur.blue('│')}`, + ); + console.log( + ` ${kleur.blue('│')} ${tipLines[1]}${' '.repeat(boxWidth - 2 - 'Browse our Storybook to preview all available components:'.length)}${kleur.blue('│')}`, + ); + console.log( + ` ${kleur.blue('│')} ${tipLines[2]}${' '.repeat(boxWidth - 2 - STORYBOOK_URL.length)}${kleur.blue('│')}`, + ); + console.log(` ${kleur.blue('╰' + '─'.repeat(boxWidth) + '╯')}`); console.log(); selectedBlocks = await promptBlockSelection(availableBlocks, []); diff --git a/packages/configs/eslint-config/package.json b/packages/configs/eslint-config/package.json index 341d5b09c..5c3a879a3 100644 --- a/packages/configs/eslint-config/package.json +++ b/packages/configs/eslint-config/package.json @@ -19,7 +19,7 @@ "./ui": "./ui.js" }, "devDependencies": { - "@eslint/js": "^9.39.3", + "@eslint/js": "^9.39.4", "@next/eslint-plugin-next": "^16.1.6", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", diff --git a/packages/configs/integrations/package.json b/packages/configs/integrations/package.json index 85ddb1b03..431254a21 100644 --- a/packages/configs/integrations/package.json +++ b/packages/configs/integrations/package.json @@ -32,7 +32,7 @@ "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/framework/CHANGELOG.md b/packages/framework/CHANGELOG.md index c7e5ecdfb..cce089068 100644 --- a/packages/framework/CHANGELOG.md +++ b/packages/framework/CHANGELOG.md @@ -1,5 +1,45 @@ # @o2s/framework +## 1.20.1 + +### Patch Changes + +- fadbc63: Extract shared block prop types into framework models and migrate block frontend props to the common `BlockWith*` helpers. + + This removes duplicated `slug`, `userId`, and `isDraftModeEnabled` definitions and keeps renderer props aligned across blocks. + +- 338cb01: Introduce typed header name constants (`HeaderName`) using `as const` and + replace selected magic header strings in API harmonization and frontend code. + + Update SDK header typing to use `AppHeaders` for stronger request typing. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +## 1.20.0 + +### Minor Changes + +- 375cd90: feat(framework, integrations): add variantId to AddCartItemBody and cart item models, add viewCartLabel and cartPath to CMS block models. Implement variantId-based cart operations in Medusa integration. Localize CMS mappers (EN/DE/PL) for Contentful and Strapi. + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [daf592e] +- Updated dependencies [98b2e68] + - @o2s/utils.logger@1.2.3 + +## 1.19.0 + +### Minor Changes + +- 5d36519: Extended framework with e-commerce models: Address (companyName, taxId), Cart, Checkout and Order Confirmation CMS blocks. Added Mocked and Medusa integration support for cart, checkout flow, and guest order retrieval. +- 0e61431: feat: update page model and integration to support redirects + ## 1.18.0 ### Minor Changes diff --git a/packages/framework/package.json b/packages/framework/package.json index 9abce7747..f1dc7bd80 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/framework", - "version": "1.18.0", + "version": "1.20.1", "private": false, "license": "MIT", "description": "Core O2S framework providing modules, SDK, and ApiConfig for building composable customer portals.", @@ -29,6 +29,10 @@ "./sdk": { "import": "./dist/src/sdk.js", "require": "./dist/src/sdk.js" + }, + "./headers": { + "import": "./dist/src/headers.js", + "require": "./dist/src/headers.js" } }, "files": [ @@ -58,7 +62,7 @@ "@types/express": "^5.0.6", "@types/qs": "^6.14.0", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "express": "5.2.1", "jsonwebtoken": "^9.0.3", "prettier": "^3.8.1", diff --git a/packages/framework/src/headers.ts b/packages/framework/src/headers.ts new file mode 100644 index 000000000..f7ee38c0c --- /dev/null +++ b/packages/framework/src/headers.ts @@ -0,0 +1 @@ +export * from './utils/models/headers'; diff --git a/packages/framework/src/modules/billing-accounts/billing-accounts.controller.ts b/packages/framework/src/modules/billing-accounts/billing-accounts.controller.ts index 27472e8da..d4748082e 100644 --- a/packages/framework/src/modules/billing-accounts/billing-accounts.controller.ts +++ b/packages/framework/src/modules/billing-accounts/billing-accounts.controller.ts @@ -6,7 +6,9 @@ import { LoggerService } from '@o2s/utils.logger'; import { BillingAccount, BillingAccounts } from './billing-accounts.model'; import { GetBillingAccountParams, GetBillingAccountsListQuery } from './billing-accounts.request'; import { BillingAccountService } from './billing-accounts.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('/billing-accounts') @UseInterceptors(LoggerService) @@ -18,7 +20,7 @@ export class BillingAccountController { @Query() query: GetBillingAccountsListQuery, @Headers() headers: AppHeaders, ): Observable<BillingAccounts> { - return this.billingAccountService.getBillingAccounts(query, headers.authorization); + return this.billingAccountService.getBillingAccounts(query, headers[H.Authorization]); } @Get(':id') @@ -26,6 +28,6 @@ export class BillingAccountController { @Param() params: GetBillingAccountParams, @Headers() headers: AppHeaders, ): Observable<BillingAccount> { - return this.billingAccountService.getBillingAccount(params, headers.authorization); + return this.billingAccountService.getBillingAccount(params, headers[H.Authorization]); } } diff --git a/packages/framework/src/modules/carts/carts.controller.ts b/packages/framework/src/modules/carts/carts.controller.ts index de6342280..3d3637eb2 100644 --- a/packages/framework/src/modules/carts/carts.controller.ts +++ b/packages/framework/src/modules/carts/carts.controller.ts @@ -4,7 +4,9 @@ import { LoggerService } from '@o2s/utils.logger'; import { Request } from './'; import { CartService } from './carts.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('/carts') @UseInterceptors(LoggerService) @@ -13,22 +15,22 @@ export class CartsController { @Get('current') getCurrentCart(@Headers() headers: AppHeaders) { - return this.cartService.getCurrentCart(headers.authorization); + return this.cartService.getCurrentCart(headers[H.Authorization]); } @Get(':id') getCart(@Param() params: Request.GetCartParams, @Headers() headers: AppHeaders) { - return this.cartService.getCart(params, headers.authorization); + return this.cartService.getCart(params, headers[H.Authorization]); } @Get() getCartList(@Query() query: Request.GetCartListQuery, @Headers() headers: AppHeaders) { - return this.cartService.getCartList(query, headers.authorization); + return this.cartService.getCartList(query, headers[H.Authorization]); } @Post() createCart(@Body() body: Request.CreateCartBody, @Headers() headers: AppHeaders) { - return this.cartService.createCart(body, headers.authorization); + return this.cartService.createCart(body, headers[H.Authorization]); } @Patch(':id') @@ -37,18 +39,18 @@ export class CartsController { @Body() body: Request.UpdateCartBody, @Headers() headers: AppHeaders, ) { - return this.cartService.updateCart(params, body, headers.authorization); + return this.cartService.updateCart(params, body, headers[H.Authorization]); } @Delete(':id') deleteCart(@Param() params: Request.DeleteCartParams, @Headers() headers: AppHeaders) { - return this.cartService.deleteCart(params, headers.authorization); + return this.cartService.deleteCart(params, headers[H.Authorization]); } // Cart item operations @Post('items') addCartItem(@Body() body: Request.AddCartItemBody, @Headers() headers: AppHeaders) { - return this.cartService.addCartItem(body, headers.authorization); + return this.cartService.addCartItem(body, headers[H.Authorization]); } @Patch(':cartId/items/:itemId') @@ -57,12 +59,12 @@ export class CartsController { @Body() body: Request.UpdateCartItemBody, @Headers() headers: AppHeaders, ) { - return this.cartService.updateCartItem(params, body, headers.authorization); + return this.cartService.updateCartItem(params, body, headers[H.Authorization]); } @Delete(':cartId/items/:itemId') removeCartItem(@Param() params: Request.RemoveCartItemParams, @Headers() headers: AppHeaders) { - return this.cartService.removeCartItem(params, headers.authorization); + return this.cartService.removeCartItem(params, headers[H.Authorization]); } // Promotion operations @@ -72,11 +74,11 @@ export class CartsController { @Body() body: Request.ApplyPromotionBody, @Headers() headers: AppHeaders, ) { - return this.cartService.applyPromotion(params, body, headers.authorization); + return this.cartService.applyPromotion(params, body, headers[H.Authorization]); } @Delete(':cartId/promotions/:code') removePromotion(@Param() params: Request.RemovePromotionParams, @Headers() headers: AppHeaders) { - return this.cartService.removePromotion(params, headers.authorization); + return this.cartService.removePromotion(params, headers[H.Authorization]); } } diff --git a/packages/framework/src/modules/carts/carts.request.ts b/packages/framework/src/modules/carts/carts.request.ts index f12cc738b..5a2d92215 100644 --- a/packages/framework/src/modules/carts/carts.request.ts +++ b/packages/framework/src/modules/carts/carts.request.ts @@ -47,6 +47,7 @@ export class DeleteCartParams { export class AddCartItemBody { cartId?: string; // Optional - if provided, use existing cart; if not, auto-create/find active cart sku!: string; + variantId?: string; // Optional - integration-specific variant identifier (e.g. Medusa variant UUID); quantity!: number; currency?: Price.Currency; // Required if creating new cart regionId?: string; // Required if creating new cart (for Medusa.js) @@ -96,6 +97,7 @@ export class UpdateCartAddressesParams { } export class UpdateCartAddressesBody { + sameAsBillingAddress?: boolean; // Copy billing address as shipping address shippingAddressId?: string; // Use saved address (authenticated users only) shippingAddress?: Address.Address; // Or provide new address billingAddressId?: string; // Use saved address (authenticated users only) diff --git a/packages/framework/src/modules/checkout/checkout.controller.ts b/packages/framework/src/modules/checkout/checkout.controller.ts index 0e8a9eb23..3043442b7 100644 --- a/packages/framework/src/modules/checkout/checkout.controller.ts +++ b/packages/framework/src/modules/checkout/checkout.controller.ts @@ -4,7 +4,9 @@ import { LoggerService } from '@o2s/utils.logger'; import { Request } from './'; import { CheckoutService } from './checkout.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('/checkout') @UseInterceptors(LoggerService) @@ -17,7 +19,7 @@ export class CheckoutController { @Body() body: Request.SetAddressesBody, @Headers() headers: AppHeaders, ) { - return this.checkoutService.setAddresses(params, body, headers.authorization); + return this.checkoutService.setAddresses(params, body, headers[H.Authorization]); } @Post(':cartId/shipping-method') @@ -26,7 +28,7 @@ export class CheckoutController { @Body() body: Request.SetShippingMethodBody, @Headers() headers: AppHeaders, ) { - return this.checkoutService.setShippingMethod(params, body, headers.authorization); + return this.checkoutService.setShippingMethod(params, body, headers[H.Authorization]); } @Post(':cartId/payment') @@ -35,22 +37,22 @@ export class CheckoutController { @Body() body: Request.SetPaymentBody, @Headers() headers: AppHeaders, ) { - return this.checkoutService.setPayment(params, body, headers.authorization); + return this.checkoutService.setPayment(params, body, headers[H.Authorization]); } @Get(':cartId/shipping-options') getShippingOptions(@Param() params: Request.GetShippingOptionsParams, @Headers() headers: AppHeaders) { return this.checkoutService.getShippingOptions( - { ...params, locale: headers['x-locale'] }, - headers.authorization, + { ...params, locale: headers[H.Locale] }, + headers[H.Authorization], ); } @Get(':cartId/summary') getCheckoutSummary(@Param() params: Request.GetCheckoutSummaryParams, @Headers() headers: AppHeaders) { return this.checkoutService.getCheckoutSummary( - { ...params, locale: headers['x-locale'] }, - headers.authorization, + { ...params, locale: headers[H.Locale] }, + headers[H.Authorization], ); } @@ -60,7 +62,11 @@ export class CheckoutController { @Body() body: Request.PlaceOrderBody, @Headers() headers: AppHeaders, ) { - return this.checkoutService.placeOrder(params, body, headers.authorization); + return this.checkoutService.placeOrder( + { ...params, locale: headers[H.Locale] }, + body, + headers[H.Authorization], + ); } @Post(':cartId/complete') @@ -69,6 +75,6 @@ export class CheckoutController { @Body() body: Request.CompleteCheckoutBody, @Headers() headers: AppHeaders, ) { - return this.checkoutService.completeCheckout(params, body, headers.authorization); + return this.checkoutService.completeCheckout(params, body, headers[H.Authorization]); } } diff --git a/packages/framework/src/modules/checkout/checkout.request.ts b/packages/framework/src/modules/checkout/checkout.request.ts index 3f19fad44..2f20b541a 100644 --- a/packages/framework/src/modules/checkout/checkout.request.ts +++ b/packages/framework/src/modules/checkout/checkout.request.ts @@ -5,6 +5,7 @@ export class SetAddressesParams { } export class SetAddressesBody { + sameAsBillingAddress?: boolean; // Copy billing address as shipping address shippingAddressId?: string; // Use saved address (authenticated users only) shippingAddress?: Address.Address; // Or provide new address billingAddressId?: string; // Use saved address (authenticated users only) @@ -44,6 +45,7 @@ export class GetCheckoutSummaryParams { export class PlaceOrderParams { cartId!: string; + locale?: string; // From x-locale header } export class PlaceOrderBody { diff --git a/packages/framework/src/modules/cms/cms.model.ts b/packages/framework/src/modules/cms/cms.model.ts index 44280657d..a0404b741 100644 --- a/packages/framework/src/modules/cms/cms.model.ts +++ b/packages/framework/src/modules/cms/cms.model.ts @@ -46,4 +46,12 @@ export * as MediaSectionBlock from './models/blocks/media-section.model'; export * as PricingSectionBlock from './models/blocks/pricing-section.model'; export * as ProductDetailsBlock from './models/blocks/product-details.model'; export * as RecommendedProductsBlock from './models/blocks/recommended-products.model'; +export * as OrderConfirmationBlock from './models/blocks/order-confirmation.model'; +export * as CheckoutBillingPaymentBlock from './models/blocks/checkout-billing-payment.model'; +export * as CartSummaryBlock from './models/blocks/cart-summary.model'; +export * as CheckoutCompanyDataBlock from './models/blocks/checkout-company-data.model'; +export * as CheckoutNotesBlock from './models/blocks/checkout-notes.model'; +export * as CheckoutShippingAddressBlock from './models/blocks/checkout-shipping-address.model'; +export * as CheckoutSummaryBlock from './models/blocks/checkout-summary.model'; +export * as CartBlock from './models/blocks/cart.model'; // BLOCK IMPORT diff --git a/packages/framework/src/modules/cms/models/blocks/cart-summary.model.ts b/packages/framework/src/modules/cms/models/blocks/cart-summary.model.ts new file mode 100644 index 000000000..8356c7c24 --- /dev/null +++ b/packages/framework/src/modules/cms/models/blocks/cart-summary.model.ts @@ -0,0 +1,5 @@ +import { Block } from '@/utils/models'; + +export class CartSummaryBlock extends Block.Block { + title?: string; +} diff --git a/packages/framework/src/modules/cms/models/blocks/cart.model.ts b/packages/framework/src/modules/cms/models/blocks/cart.model.ts new file mode 100644 index 000000000..48eba92f9 --- /dev/null +++ b/packages/framework/src/modules/cms/models/blocks/cart.model.ts @@ -0,0 +1,47 @@ +import { Block } from '@/utils/models'; + +export class CartBlock extends Block.Block { + title!: string; + subtitle?: string; + defaultCurrency!: string; + labels!: { + itemTotal: string; + unknownProductName: string; + }; + errors!: { + loadError: string; + updateError: string; + }; + actions!: { + increaseQuantity: string; + decreaseQuantity: string; + quantity: string; + remove: string; + }; + summaryLabels!: { + title: string; + subtotalLabel: string; + taxLabel: string; + totalLabel: string; + discountLabel?: string; + shippingLabel?: string; + freeLabel?: string; + }; + checkoutButton?: { + label: string; + path: string; + icon?: string; + }; + continueShopping?: { + label: string; + path: string; + }; + empty!: { + title: string; + description: string; + continueShopping?: { + label: string; + path: string; + }; + }; +} diff --git a/packages/framework/src/modules/cms/models/blocks/checkout-billing-payment.model.ts b/packages/framework/src/modules/cms/models/blocks/checkout-billing-payment.model.ts new file mode 100644 index 000000000..27583fe35 --- /dev/null +++ b/packages/framework/src/modules/cms/models/blocks/checkout-billing-payment.model.ts @@ -0,0 +1,34 @@ +import { Block } from '@/utils/models'; + +export class CheckoutBillingPaymentBlock extends Block.Block { + title!: string; + subtitle?: string; + fields!: { + paymentMethod: { + label: string; + placeholder?: string; + required: boolean; + }; + }; + buttons!: { + back: { label: string; path: string }; + next: { label: string; path: string }; + }; + errors!: { + required: string; + cartNotFound: string; + submitError: string; + }; + summaryLabels!: { + title: string; + subtotalLabel: string; + taxLabel: string; + totalLabel: string; + discountLabel?: string; + shippingLabel?: string; + freeLabel?: string; + }; + stepIndicator?: { steps: string[]; currentStep: number }; + cartPath?: string; + orderConfirmationPath!: string; +} diff --git a/packages/framework/src/modules/cms/models/blocks/checkout-company-data.model.ts b/packages/framework/src/modules/cms/models/blocks/checkout-company-data.model.ts new file mode 100644 index 000000000..9f20c9e62 --- /dev/null +++ b/packages/framework/src/modules/cms/models/blocks/checkout-company-data.model.ts @@ -0,0 +1,53 @@ +import { Block } from '@/utils/models'; + +interface CheckoutFieldConfig { + label: string; + placeholder?: string; + required: boolean; +} + +export class CheckoutCompanyDataBlock extends Block.Block { + title!: string; + subtitle?: string; + fields!: { + firstName: CheckoutFieldConfig; + lastName: CheckoutFieldConfig; + email: CheckoutFieldConfig; + phone: CheckoutFieldConfig; + companyName: CheckoutFieldConfig; + taxId: CheckoutFieldConfig; + notes?: CheckoutFieldConfig; + address: { + streetName: CheckoutFieldConfig; + streetNumber: CheckoutFieldConfig; + apartment: CheckoutFieldConfig; + city: CheckoutFieldConfig; + postalCode: CheckoutFieldConfig; + country: CheckoutFieldConfig; + }; + }; + buttons!: { + back: { label: string; path: string }; + next: { label: string; path: string }; + }; + errors!: { + required: string; + invalidTaxId: string; + invalidPostalCode: string; + invalidEmail: string; + cartNotFound: string; + submitError: string; + }; + summaryLabels!: { + title: string; + subtotalLabel: string; + taxLabel: string; + totalLabel: string; + discountLabel: string; + shippingLabel: string; + freeLabel: string; + }; + stepIndicator!: { steps: string[]; currentStep: number }; + billingInfoNote?: { icon?: string; text: string }; + cartPath!: string; +} diff --git a/packages/framework/src/modules/cms/models/blocks/checkout-notes.model.ts b/packages/framework/src/modules/cms/models/blocks/checkout-notes.model.ts new file mode 100644 index 000000000..4f1ec7fbc --- /dev/null +++ b/packages/framework/src/modules/cms/models/blocks/checkout-notes.model.ts @@ -0,0 +1,5 @@ +import { Block } from '@/utils/models'; + +export class CheckoutNotesBlock extends Block.Block { + title?: string; +} diff --git a/packages/framework/src/modules/cms/models/blocks/checkout-shipping-address.model.ts b/packages/framework/src/modules/cms/models/blocks/checkout-shipping-address.model.ts new file mode 100644 index 000000000..b9bcebfa7 --- /dev/null +++ b/packages/framework/src/modules/cms/models/blocks/checkout-shipping-address.model.ts @@ -0,0 +1,48 @@ +import { Block } from '@/utils/models'; + +interface CheckoutFieldConfig { + label: string; + placeholder?: string; + required: boolean; +} + +export class CheckoutShippingAddressBlock extends Block.Block { + title!: string; + subtitle?: string; + fields!: { + sameAsBillingAddress: { label: string }; + firstName: CheckoutFieldConfig; + lastName: CheckoutFieldConfig; + phone: CheckoutFieldConfig; + address: { + streetName: CheckoutFieldConfig; + streetNumber: CheckoutFieldConfig; + apartment: CheckoutFieldConfig; + city: CheckoutFieldConfig; + postalCode: CheckoutFieldConfig; + country: CheckoutFieldConfig; + }; + shippingMethod: CheckoutFieldConfig; + }; + buttons!: { + back: { label: string; path: string }; + next: { label: string; path: string }; + }; + errors!: { + required: string; + invalidPostalCode: string; + cartNotFound: string; + submitError: string; + }; + summaryLabels!: { + title: string; + subtotalLabel: string; + taxLabel: string; + totalLabel: string; + discountLabel: string; + shippingLabel: string; + freeLabel: string; + }; + stepIndicator!: { steps: string[]; currentStep: number }; + cartPath!: string; +} diff --git a/packages/framework/src/modules/cms/models/blocks/checkout-summary.model.ts b/packages/framework/src/modules/cms/models/blocks/checkout-summary.model.ts new file mode 100644 index 000000000..3c0d358d7 --- /dev/null +++ b/packages/framework/src/modules/cms/models/blocks/checkout-summary.model.ts @@ -0,0 +1,59 @@ +import { Block } from '@/utils/models'; + +export class CheckoutSummaryBlock extends Block.Block { + title!: string; + subtitle?: string; + sections!: { + products: { + title: string; + labels: { quantity: string; price: string; total: string }; + }; + company: { + title: string; + companyNameLabel: string; + taxIdLabel: string; + addressLabel: string; + }; + shipping: { + title: string; + addressLabel: string; + methodLabel: string; + }; + billing: { + title: string; + addressLabel: string; + methodLabel: string; + }; + summary: { + title: string; + subtotalLabel: string; + taxLabel: string; + discountLabel: string; + shippingLabel: string; + freeLabel: string; + totalLabel: string; + activePromoCodesTitle: string; + notesTitle: string; + }; + }; + buttons!: { + confirm: { label: string; path: string }; + back: { label: string; path: string }; + }; + errors!: { + cartNotFound: string; + placeOrderError: string; + loadError: string; + }; + loading!: { + confirming: string; + }; + placeholders!: { + companyData: string; + shippingAddress: string; + sameAsBillingAddress: string; + billingAddress: string; + }; + stepIndicator!: { steps: string[]; currentStep: number }; + cartPath!: string; +} diff --git a/packages/framework/src/modules/cms/models/blocks/order-confirmation.model.ts b/packages/framework/src/modules/cms/models/blocks/order-confirmation.model.ts new file mode 100644 index 000000000..eadf688a7 --- /dev/null +++ b/packages/framework/src/modules/cms/models/blocks/order-confirmation.model.ts @@ -0,0 +1,37 @@ +import { Block } from '@/utils/models'; + +export class OrderConfirmationBlock extends Block.Block { + title!: string; + subtitle?: string; + orderNumberLabel!: string; + productsTitle!: string; + productsCountLabel!: string; + summaryTitle!: string; + subtotalLabel!: string; + taxLabel!: string; + discountLabel!: string; + shippingLabel!: string; + totalLabel!: string; + shippingSection!: { + title: string; + addressLabel: string; + methodLabel: string; + }; + billingSection!: { + title: string; + addressLabel: string; + taxIdLabel: string; + }; + message?: string; + buttons!: { + viewOrders: string; + continueShopping: string; + }; + viewOrdersPath!: string; + continueShoppingPath!: string; + statusLabels!: Record<string, string>; + errors!: { + loadError: string; + orderNotFound: string; + }; +} diff --git a/packages/framework/src/modules/cms/models/blocks/product-details.model.ts b/packages/framework/src/modules/cms/models/blocks/product-details.model.ts index a7b5ecbd2..f96e218ba 100644 --- a/packages/framework/src/modules/cms/models/blocks/product-details.model.ts +++ b/packages/framework/src/modules/cms/models/blocks/product-details.model.ts @@ -1,7 +1,6 @@ import { Block } from '@/utils/models'; export type Labels = { - actionButtonLabel?: string; downloadLabel?: string; specificationsTitle: string; descriptionTitle: string; @@ -9,6 +8,10 @@ export type Labels = { offerLabel: string; variantLabel?: string; outOfStockLabel?: string; + addToCartLabel: string; + addToCartSuccess: string; + addToCartError: string; + viewCartLabel?: string; }; export type AttributeConfig = { @@ -47,6 +50,7 @@ export class ProductDetailsBlock extends Block.Block { title?: string; labels!: Labels; basePath?: string; + cartPath?: string; /** * Configuration of product attributes to display. * The block will filter and format product.attributes based on this configuration. diff --git a/packages/framework/src/modules/cms/models/blocks/product-list.model.ts b/packages/framework/src/modules/cms/models/blocks/product-list.model.ts index 4feb0865a..4b7c65ea6 100644 --- a/packages/framework/src/modules/cms/models/blocks/product-list.model.ts +++ b/packages/framework/src/modules/cms/models/blocks/product-list.model.ts @@ -20,8 +20,13 @@ export class ProductListBlock extends Block.Block { showMoreFilters: string; hideMoreFilters: string; noActiveFilters: string; + addToCartLabel?: string; + addToCartSuccess?: string; + addToCartError?: string; + viewCartLabel?: string; }; detailsLabel?: string; detailsUrl!: string; basePath?: string; + cartPath?: string; } diff --git a/packages/framework/src/modules/cms/models/blocks/recommended-products.model.ts b/packages/framework/src/modules/cms/models/blocks/recommended-products.model.ts index 066ad5182..bc725303c 100644 --- a/packages/framework/src/modules/cms/models/blocks/recommended-products.model.ts +++ b/packages/framework/src/modules/cms/models/blocks/recommended-products.model.ts @@ -3,9 +3,14 @@ import { Block } from '@/utils/models'; export type Labels = { title?: string; detailsLabel?: string; + addToCartLabel?: string; + addToCartSuccess?: string; + addToCartError?: string; + viewCartLabel?: string; }; export class RecommendedProductsBlock extends Block.Block { basePath?: string; + cartPath?: string; labels!: Labels; } diff --git a/packages/framework/src/modules/cms/models/header.model.ts b/packages/framework/src/modules/cms/models/header.model.ts index ef55e5d81..023476c1b 100644 --- a/packages/framework/src/modules/cms/models/header.model.ts +++ b/packages/framework/src/modules/cms/models/header.model.ts @@ -9,6 +9,10 @@ export class Header { url: string; label: string; }; + cart?: { + url: string; + label: string; + }; languageSwitcherLabel!: string; mobileMenuLabel!: { open: string; diff --git a/packages/framework/src/modules/cms/models/page.model.ts b/packages/framework/src/modules/cms/models/page.model.ts index d8e15ae45..ae770efaf 100644 --- a/packages/framework/src/modules/cms/models/page.model.ts +++ b/packages/framework/src/modules/cms/models/page.model.ts @@ -24,6 +24,7 @@ export class Page { /** Role-based access control (e.g., ['ORG_USER', 'ORG_ADMIN']) */ roles?: string[]; theme?: string; + redirect?: string; } export abstract class Template { diff --git a/packages/framework/src/modules/customers/customers.controller.ts b/packages/framework/src/modules/customers/customers.controller.ts index a324045c5..a0eea8015 100644 --- a/packages/framework/src/modules/customers/customers.controller.ts +++ b/packages/framework/src/modules/customers/customers.controller.ts @@ -4,7 +4,9 @@ import { LoggerService } from '@o2s/utils.logger'; import { Request } from './'; import { CustomerService } from './customers.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('/customers/addresses') @UseInterceptors(LoggerService) @@ -13,17 +15,17 @@ export class CustomersController { @Get() getAddresses(@Headers() headers: AppHeaders) { - return this.customerService.getAddresses(headers.authorization); + return this.customerService.getAddresses(headers[H.Authorization]); } @Get(':id') getAddress(@Param() params: Request.GetAddressParams, @Headers() headers: AppHeaders) { - return this.customerService.getAddress(params, headers.authorization); + return this.customerService.getAddress(params, headers[H.Authorization]); } @Post() createAddress(@Body() body: Request.CreateAddressBody, @Headers() headers: AppHeaders) { - return this.customerService.createAddress(body, headers.authorization); + return this.customerService.createAddress(body, headers[H.Authorization]); } @Patch(':id') @@ -32,16 +34,16 @@ export class CustomersController { @Body() body: Request.UpdateAddressBody, @Headers() headers: AppHeaders, ) { - return this.customerService.updateAddress(params, body, headers.authorization); + return this.customerService.updateAddress(params, body, headers[H.Authorization]); } @Delete(':id') deleteAddress(@Param() params: Request.DeleteAddressParams, @Headers() headers: AppHeaders) { - return this.customerService.deleteAddress(params, headers.authorization); + return this.customerService.deleteAddress(params, headers[H.Authorization]); } @Post(':id/default') setDefaultAddress(@Param() params: Request.SetDefaultAddressParams, @Headers() headers: AppHeaders) { - return this.customerService.setDefaultAddress(params, headers.authorization); + return this.customerService.setDefaultAddress(params, headers[H.Authorization]); } } diff --git a/packages/framework/src/modules/invoices/invoices.controller.ts b/packages/framework/src/modules/invoices/invoices.controller.ts index fd5e5e442..d6bc7ff78 100644 --- a/packages/framework/src/modules/invoices/invoices.controller.ts +++ b/packages/framework/src/modules/invoices/invoices.controller.ts @@ -7,7 +7,9 @@ import { LoggerService } from '@o2s/utils.logger'; import { GetInvoiceListQuery, GetInvoiceParams } from './invoices.request'; import { InvoiceService } from './invoices.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('/invoices') @UseInterceptors(LoggerService) @@ -19,12 +21,12 @@ export class InvoiceController { @Query() query: GetInvoiceListQuery, @Headers() headers: AppHeaders, ): Observable<Invoices.Model.Invoices> { - return this.invoiceService.getInvoiceList(query, headers.authorization); + return this.invoiceService.getInvoiceList(query, headers[H.Authorization]); } @Get(':id') getInvoice(@Param() params: GetInvoiceParams, @Headers() headers: AppHeaders): Observable<Invoices.Model.Invoice> { - return this.invoiceService.getInvoice(params, headers.authorization); + return this.invoiceService.getInvoice(params, headers[H.Authorization]); } @Get(':id/pdf') @@ -33,7 +35,7 @@ export class InvoiceController { @Headers() headers: AppHeaders, @Res() res: Response, ): Observable<void> { - return this.invoiceService.getInvoicePdf(params, headers.authorization).pipe( + return this.invoiceService.getInvoicePdf(params, headers[H.Authorization]).pipe( map((pdf) => { res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="invoice-${params.id}.pdf"`); diff --git a/packages/framework/src/modules/notifications/notifications.controller.ts b/packages/framework/src/modules/notifications/notifications.controller.ts index ad4c24456..ec5200da3 100644 --- a/packages/framework/src/modules/notifications/notifications.controller.ts +++ b/packages/framework/src/modules/notifications/notifications.controller.ts @@ -4,7 +4,9 @@ import { LoggerService } from '@o2s/utils.logger'; import { Request } from './'; import { NotificationService } from './notifications.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('/notifications') @UseInterceptors(LoggerService) @@ -13,16 +15,16 @@ export class NotificationsController { @Get(':id') getNotification(@Param() params: Request.GetNotificationParams, @Headers() headers: AppHeaders) { - return this.notificationService.getNotification(params, headers.authorization); + return this.notificationService.getNotification(params, headers[H.Authorization]); } @Get() getNotificationList(@Query() query: Request.GetNotificationListQuery, @Headers() headers: AppHeaders) { - return this.notificationService.getNotificationList(query, headers.authorization); + return this.notificationService.getNotificationList(query, headers[H.Authorization]); } @Post() markNotificationAs(@Body() request: Request.MarkNotificationAsRequest, @Headers() headers: AppHeaders) { - return this.notificationService.markAs(request, headers.authorization); + return this.notificationService.markAs(request, headers[H.Authorization]); } } diff --git a/packages/framework/src/modules/orders/orders.controller.ts b/packages/framework/src/modules/orders/orders.controller.ts index 627503ca0..1a2cde9c8 100644 --- a/packages/framework/src/modules/orders/orders.controller.ts +++ b/packages/framework/src/modules/orders/orders.controller.ts @@ -4,7 +4,9 @@ import { LoggerService } from '@o2s/utils.logger'; import { Request } from './'; import { OrderService } from './orders.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('/orders') @UseInterceptors(LoggerService) @@ -13,11 +15,11 @@ export class OrdersController { @Get(':id') getOrder(@Param() params: Request.GetOrderParams, @Headers() headers: AppHeaders) { - return this.orderService.getOrder(params, headers.authorization); + return this.orderService.getOrder(params, headers[H.Authorization]); } @Get() getOrderList(@Query() query: Request.GetOrderListQuery, @Headers() headers: AppHeaders) { - return this.orderService.getOrderList(query, headers.authorization); + return this.orderService.getOrderList(query, headers[H.Authorization]); } } diff --git a/packages/framework/src/modules/organizations/organizations.controller.ts b/packages/framework/src/modules/organizations/organizations.controller.ts index b565539d2..9d6b80d7e 100644 --- a/packages/framework/src/modules/organizations/organizations.controller.ts +++ b/packages/framework/src/modules/organizations/organizations.controller.ts @@ -4,7 +4,9 @@ import { LoggerService } from '@o2s/utils.logger'; import { Request } from './'; import { OrganizationService } from './organizations.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('organizations') @UseInterceptors(LoggerService) @@ -13,16 +15,16 @@ export class OrganizationController { @Get(':id') getOrganization(@Param() params: Request.GetOrganizationParams, @Headers() headers: AppHeaders) { - return this.organizationService.getOrganization(params, headers.authorization); + return this.organizationService.getOrganization(params, headers[H.Authorization]); } @Get() getOrganizations(@Query() options: Request.OrganizationsListQuery, @Headers() headers: AppHeaders) { - return this.organizationService.getOrganizationList(options, headers.authorization); + return this.organizationService.getOrganizationList(options, headers[H.Authorization]); } @Get('/membership/:orgId/:userId') checkMembership(@Param() params: Request.CheckMembershipParams, @Headers() headers: AppHeaders) { - return this.organizationService.checkMembership(params, headers.authorization); + return this.organizationService.checkMembership(params, headers[H.Authorization]); } } diff --git a/packages/framework/src/modules/payments/payments.controller.ts b/packages/framework/src/modules/payments/payments.controller.ts index 1d3102520..62fb39d9b 100644 --- a/packages/framework/src/modules/payments/payments.controller.ts +++ b/packages/framework/src/modules/payments/payments.controller.ts @@ -4,7 +4,9 @@ import { LoggerService } from '@o2s/utils.logger'; import { Request } from './'; import { PaymentService } from './payments.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('/payments') @UseInterceptors(LoggerService) @@ -13,17 +15,17 @@ export class PaymentsController { @Get('providers') getProviders(@Query() params: Request.GetProvidersParams, @Headers() headers: AppHeaders) { - return this.paymentService.getProviders({ ...params, locale: headers['x-locale'] }, headers.authorization); + return this.paymentService.getProviders({ ...params, locale: headers[H.Locale] }, headers[H.Authorization]); } @Post('sessions') createSession(@Body() body: Request.CreateSessionBody, @Headers() headers: AppHeaders) { - return this.paymentService.createSession(body, headers.authorization); + return this.paymentService.createSession(body, headers[H.Authorization]); } @Get('sessions/:id') getSession(@Param() params: Request.GetSessionParams, @Headers() headers: AppHeaders) { - return this.paymentService.getSession(params, headers.authorization); + return this.paymentService.getSession(params, headers[H.Authorization]); } @Patch('sessions/:id') @@ -32,11 +34,11 @@ export class PaymentsController { @Body() body: Request.UpdateSessionBody, @Headers() headers: AppHeaders, ) { - return this.paymentService.updateSession(params, body, headers.authorization); + return this.paymentService.updateSession(params, body, headers[H.Authorization]); } @Delete('sessions/:id') cancelSession(@Param() params: Request.CancelSessionParams, @Headers() headers: AppHeaders) { - return this.paymentService.cancelSession(params, headers.authorization); + return this.paymentService.cancelSession(params, headers[H.Authorization]); } } diff --git a/packages/framework/src/modules/payments/payments.request.ts b/packages/framework/src/modules/payments/payments.request.ts index 268fdd32c..f3cf16434 100644 --- a/packages/framework/src/modules/payments/payments.request.ts +++ b/packages/framework/src/modules/payments/payments.request.ts @@ -1,5 +1,5 @@ export class GetProvidersParams { - regionId!: string; + regionId?: string; locale?: string; // From x-locale header } diff --git a/packages/framework/src/modules/products/products.controller.ts b/packages/framework/src/modules/products/products.controller.ts index 6956d068f..a4ca5a84d 100644 --- a/packages/framework/src/modules/products/products.controller.ts +++ b/packages/framework/src/modules/products/products.controller.ts @@ -6,7 +6,9 @@ import { LoggerService } from '@o2s/utils.logger'; import { Product, Products } from './products.model'; import { GetProductListQuery, GetProductParams, GetRelatedProductListParams } from './products.request'; import { ProductService } from './products.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('/products') @UseInterceptors(LoggerService) @@ -15,12 +17,12 @@ export class ProductsController { @Get() getProductList(@Query() query: GetProductListQuery, @Headers() headers: AppHeaders): Observable<Products> { - return this.productService.getProductList(query, headers.authorization); + return this.productService.getProductList(query, headers[H.Authorization]); } @Get(':id') getProduct(@Param() params: GetProductParams, @Headers() headers: AppHeaders): Observable<Product> { - return this.productService.getProduct(params, headers.authorization); + return this.productService.getProduct(params, headers[H.Authorization]); } @Get(':id/variants/:variantId/related-products') @@ -28,6 +30,6 @@ export class ProductsController { @Param() params: GetRelatedProductListParams, @Headers() headers: AppHeaders, ): Observable<Products> { - return this.productService.getRelatedProductList(params, headers.authorization); + return this.productService.getRelatedProductList(params, headers[H.Authorization]); } } diff --git a/packages/framework/src/modules/resources/resources.controller.ts b/packages/framework/src/modules/resources/resources.controller.ts index 631c94a32..ad570e545 100644 --- a/packages/framework/src/modules/resources/resources.controller.ts +++ b/packages/framework/src/modules/resources/resources.controller.ts @@ -14,7 +14,9 @@ import { GetServiceParams, } from './resources.request'; import { ResourceService } from './resources.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('/resources') @UseInterceptors(LoggerService) @@ -23,7 +25,7 @@ export class ResourceController { @Post(':id/purchase') purchaseResource(@Param() params: GetResourceParams, @Headers() headers: AppHeaders) { - return this.resourceService.purchaseOrActivateResource(params, headers.authorization); + return this.resourceService.purchaseOrActivateResource(params, headers[H.Authorization]); } @Get('services') @@ -37,7 +39,7 @@ export class ResourceController { @Get('services/:id') getService(@Param() params: GetServiceParams, @Headers() headers: AppHeaders): Observable<Service> { - return this.resourceService.getService(params, headers.authorization); + return this.resourceService.getService(params, headers[H.Authorization]); } @Get('services/featured') @@ -56,7 +58,7 @@ export class ResourceController { @Get('assets/:id') getAsset(@Param() params: GetAssetParams, @Headers() headers: AppHeaders): Observable<Asset> { - return this.resourceService.getAsset(params, headers.authorization); + return this.resourceService.getAsset(params, headers[H.Authorization]); } @Get('assets/:id/compatible-services') diff --git a/packages/framework/src/modules/tickets/tickets.controller.ts b/packages/framework/src/modules/tickets/tickets.controller.ts index 0a2a5c0a9..1e99264c6 100644 --- a/packages/framework/src/modules/tickets/tickets.controller.ts +++ b/packages/framework/src/modules/tickets/tickets.controller.ts @@ -4,7 +4,9 @@ import { LoggerService } from '@o2s/utils.logger'; import { Request } from './'; import { TicketService } from './tickets.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('/tickets') @UseInterceptors(LoggerService) @@ -13,16 +15,16 @@ export class TicketsController { @Get(':id') getTicket(@Param() params: Request.GetTicketParams, @Headers() headers: AppHeaders) { - return this.ticketService.getTicket(params, headers.authorization); + return this.ticketService.getTicket(params, headers[H.Authorization]); } @Get() getTicketList(@Query() query: Request.GetTicketListQuery, @Headers() headers: AppHeaders) { - return this.ticketService.getTicketList(query, headers.authorization); + return this.ticketService.getTicketList(query, headers[H.Authorization]); } @Post() createTicket(@Body() body: Request.PostTicketBody, @Headers() headers: AppHeaders) { - return this.ticketService.createTicket(body, headers.authorization); + return this.ticketService.createTicket(body, headers[H.Authorization]); } } diff --git a/packages/framework/src/modules/users/users.controller.ts b/packages/framework/src/modules/users/users.controller.ts index 6d9b4d9c2..578d3df89 100644 --- a/packages/framework/src/modules/users/users.controller.ts +++ b/packages/framework/src/modules/users/users.controller.ts @@ -4,7 +4,9 @@ import { LoggerService } from '@o2s/utils.logger'; import { Request } from './'; import { UserService } from './users.service'; -import { AppHeaders } from '@/utils/models/headers'; +import { AppHeaders, HeaderName } from '@/utils/models/headers'; + +const H = HeaderName; @Controller('/users') @UseInterceptors(LoggerService) @@ -13,17 +15,17 @@ export class UserController { @Get('/me') getCurrentUser(@Headers() headers: AppHeaders) { - return this.userService.getCurrentUser(headers.authorization); + return this.userService.getCurrentUser(headers[H.Authorization]); } @Get(':id') getUser(@Param() params: Request.GetUserParams, @Headers() headers: AppHeaders) { - return this.userService.getUser(params, headers.authorization); + return this.userService.getUser(params, headers[H.Authorization]); } @Patch('/me') updateCurrentUser(@Body() body: Request.PostUserBody, @Headers() headers: AppHeaders) { - return this.userService.updateCurrentUser(body, headers.authorization); + return this.userService.updateCurrentUser(body, headers[H.Authorization]); } @Patch(':id') @@ -32,26 +34,26 @@ export class UserController { @Body() body: Request.PostUserBody, @Headers() headers: AppHeaders, ) { - return this.userService.updateUser(params, body, headers.authorization); + return this.userService.updateUser(params, body, headers[H.Authorization]); } @Get('/me/customers') getCustomersForCurrentUser(@Headers() headers: AppHeaders) { - return this.userService.getCurrentUserCustomers(headers.authorization); + return this.userService.getCurrentUserCustomers(headers[H.Authorization]); } @Get('/me/customers/:id') getCustomerForCurrentUserById(@Param() params: Request.GetCustomerParams, @Headers() headers: AppHeaders) { - return this.userService.getCurrentUserCustomer(params, headers.authorization); + return this.userService.getCurrentUserCustomer(params, headers[H.Authorization]); } @Delete('/me') deleteCurrentUser(@Headers() headers: AppHeaders) { - return this.userService.deleteCurrentUser(headers.authorization); + return this.userService.deleteCurrentUser(headers[H.Authorization]); } @Delete(':id') deleteUser(@Param() params: Request.GetUserParams, @Headers() headers: AppHeaders) { - return this.userService.deleteUser(params, headers.authorization); + return this.userService.deleteUser(params, headers[H.Authorization]); } } diff --git a/packages/framework/src/sdk.ts b/packages/framework/src/sdk.ts index 06bb2ce7a..b25928124 100644 --- a/packages/framework/src/sdk.ts +++ b/packages/framework/src/sdk.ts @@ -6,11 +6,12 @@ import { createTicket, getTicket, getTickets } from './api/tickets'; import { getCustomerForCurrentUserById, getDefaultCustomerForCurrentUser, getUser } from './api/users'; import { createInterceptors } from './interceptors'; import { LoggerConfig } from './utils/logger'; +import { AppHeaders } from './utils/models/headers'; export interface CompatRequestConfig { url?: string; method?: string; - headers?: Record<string, string>; + headers?: Partial<AppHeaders> & Record<string, string>; params?: unknown; data?: unknown; [key: string]: unknown; diff --git a/packages/framework/src/utils/models/address.ts b/packages/framework/src/utils/models/address.ts index 26f144ac3..7e45890ef 100644 --- a/packages/framework/src/utils/models/address.ts +++ b/packages/framework/src/utils/models/address.ts @@ -1,6 +1,8 @@ export class Address { firstName?: string; lastName?: string; + companyName?: string; + taxId?: string; country!: string; district?: string; region?: string; diff --git a/packages/framework/src/utils/models/block-props.ts b/packages/framework/src/utils/models/block-props.ts new file mode 100644 index 000000000..769b00e79 --- /dev/null +++ b/packages/framework/src/utils/models/block-props.ts @@ -0,0 +1,24 @@ +export interface BaseBlockProps<TRouting = unknown> { + id: string; + locale: string; + accessToken?: string; + routing?: TRouting; + hasPriority?: boolean; +} + +export interface BlockWithSlugProps<TRouting = unknown> extends BaseBlockProps<TRouting> { + slug: string[]; +} + +export interface BlockWithUserIdProps<TRouting = unknown> extends BaseBlockProps<TRouting> { + userId?: string; +} + +export interface BlockWithDraftModeProps<TRouting = unknown> extends BaseBlockProps<TRouting> { + isDraftModeEnabled?: boolean; +} + +export interface FullBlockProps<TRouting = unknown> extends BlockWithSlugProps<TRouting> { + userId?: string; + isDraftModeEnabled?: boolean; +} diff --git a/packages/framework/src/utils/models/headers.ts b/packages/framework/src/utils/models/headers.ts index e394bea3f..ab47fa4e8 100644 --- a/packages/framework/src/utils/models/headers.ts +++ b/packages/framework/src/utils/models/headers.ts @@ -1,6 +1,15 @@ +export const HeaderName = { + Locale: 'x-locale', + ClientTimezone: 'x-client-timezone', + Currency: 'x-currency', + Authorization: 'authorization', +} as const; + +export type HeaderName = (typeof HeaderName)[keyof typeof HeaderName]; + export class AppHeaders { - 'x-locale'!: string; - 'x-client-timezone'?: string; - 'x-currency'?: string; - 'authorization'?: string; + [HeaderName.Locale]!: string; + [HeaderName.ClientTimezone]?: string; + [HeaderName.Currency]?: string; + [HeaderName.Authorization]?: string; } diff --git a/packages/framework/src/utils/models/index.ts b/packages/framework/src/utils/models/index.ts index c67e5dc97..a10752b49 100644 --- a/packages/framework/src/utils/models/index.ts +++ b/packages/framework/src/utils/models/index.ts @@ -20,3 +20,4 @@ export * as CardWithImage from './card-with-image'; export * as Badge from './badge'; export * as PricingCard from './pricing-card'; export * as Document from './document'; +export * as BlockProps from './block-props'; diff --git a/packages/integrations/algolia/CHANGELOG.md b/packages/integrations/algolia/CHANGELOG.md index 5d46165dc..24ad98a3b 100644 --- a/packages/integrations/algolia/CHANGELOG.md +++ b/packages/integrations/algolia/CHANGELOG.md @@ -1,5 +1,29 @@ # @o2s/integrations.algolia +## 1.6.2 + +### Patch Changes + +- 924f413: chore(deps): update dependencies +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + +## 1.6.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.logger@1.2.3 + ## 1.6.0 ### Minor Changes diff --git a/packages/integrations/algolia/package.json b/packages/integrations/algolia/package.json index 8cdb01b87..f46be95d6 100644 --- a/packages/integrations/algolia/package.json +++ b/packages/integrations/algolia/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/integrations.algolia", - "version": "1.6.0", + "version": "1.6.2", "private": false, "license": "MIT", "description": "Algolia integration for O2S, providing search functionality.", @@ -37,7 +37,7 @@ "dependencies": { "@o2s/framework": "*", "@o2s/utils.logger": "*", - "algoliasearch": "^5.49.1" + "algoliasearch": "^5.49.2" }, "devDependencies": { "@o2s/eslint-config": "*", @@ -46,7 +46,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", diff --git a/packages/integrations/contentful-cms/CHANGELOG.md b/packages/integrations/contentful-cms/CHANGELOG.md index c5cd38ddf..fa60114b2 100644 --- a/packages/integrations/contentful-cms/CHANGELOG.md +++ b/packages/integrations/contentful-cms/CHANGELOG.md @@ -1,5 +1,41 @@ # @o2s/integrations.contentful-cms +## 0.8.1 + +### Patch Changes + +- feb0a8c: chore(deps): update dependencies +- 338cb01: Migrate integration services from `implements` to `extends` and add `super()` where needed + to keep constructor metadata compatible with NestJS dependency injection. + + Update documentation examples to reflect the new `extends ...Service` pattern. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + +## 0.8.0 + +### Minor Changes + +- 375cd90: feat(framework, integrations): add variantId to AddCartItemBody and cart item models, add viewCartLabel and cartPath to CMS block models. Implement variantId-based cart operations in Medusa integration. Localize CMS mappers (EN/DE/PL) for Contentful and Strapi. + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 9f72807: chore(deps): update dependencies +- 0300d8e: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- d05b09b: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.logger@1.2.3 + ## 0.7.0 ### Minor Changes diff --git a/packages/integrations/contentful-cms/package.json b/packages/integrations/contentful-cms/package.json index b2fa3ad65..161d61169 100644 --- a/packages/integrations/contentful-cms/package.json +++ b/packages/integrations/contentful-cms/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/integrations.contentful-cms", - "version": "0.7.0", + "version": "0.8.1", "private": false, "license": "MIT", "description": "Contentful CMS integration for O2S, providing content management functionality via GraphQL.", @@ -38,11 +38,11 @@ "generate": "graphql-codegen && prettier --write \"generated/**/*.{ts,tsx}\"" }, "dependencies": { - "@contentful/live-preview": "^4.9.6", + "@contentful/live-preview": "^4.9.10", "@o2s/framework": "*", "@o2s/utils.logger": "*", "contentful": "^11.10.5", - "flatted": "^3.3.3", + "flatted": "^3.4.1", "graphql": "16.13.0", "graphql-request": "7.4.0", "graphql-tag": "2.12.6" @@ -61,7 +61,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", diff --git a/packages/integrations/contentful-cms/src/modules/cms/cms.service.ts b/packages/integrations/contentful-cms/src/modules/cms/cms.service.ts index 1dbb7844c..eaec0acd9 100644 --- a/packages/integrations/contentful-cms/src/modules/cms/cms.service.ts +++ b/packages/integrations/contentful-cms/src/modules/cms/cms.service.ts @@ -74,13 +74,15 @@ function toBooleanPreview(preview?: boolean | string): boolean { } @Injectable() -export class CmsService implements CMS.Service { +export class CmsService extends CMS.Service { constructor( private readonly graphqlService: GraphqlService, private readonly restDeliveryService: RestDeliveryService, private readonly config: ConfigService, private readonly cacheService: Cache.Service, - ) {} + ) { + super(); + } /** * Universal error handler for Contentful GraphQL errors diff --git a/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts b/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts index 24482d7a1..9841a2ec8 100644 --- a/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts +++ b/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts @@ -55,17 +55,57 @@ export const mapProductDetailsBlock = (locale: string): CMS.Model.ProductDetails ], }; - return { - id: 'product-details-1', - labels: { - actionButtonLabel: 'Request Quote', + const labelsMap: Record<string, CMS.Model.ProductDetailsBlock.Labels> = { + en: { specificationsTitle: 'Specifications', descriptionTitle: 'Description', downloadLabel: 'Download Brochure', priceLabel: 'Price', offerLabel: 'Offer', variantLabel: 'Variant', + addToCartLabel: 'Add to Cart', + addToCartSuccess: '{productName} added to cart', + addToCartError: 'Failed to add product to cart', + viewCartLabel: 'View Cart', + }, + de: { + specificationsTitle: 'Spezifikationen', + descriptionTitle: 'Beschreibung', + downloadLabel: 'Broschüre herunterladen', + priceLabel: 'Preis', + offerLabel: 'Angebot', + variantLabel: 'Variante', + addToCartLabel: 'In den Warenkorb', + addToCartSuccess: '{productName} zum Warenkorb hinzugefügt', + addToCartError: 'Fehler beim Hinzufügen zum Warenkorb', + viewCartLabel: 'Warenkorb anzeigen', }, + pl: { + specificationsTitle: 'Specyfikacja', + descriptionTitle: 'Opis', + downloadLabel: 'Pobierz broszurę', + priceLabel: 'Cena', + offerLabel: 'Oferta', + variantLabel: 'Wariant', + addToCartLabel: 'Dodaj do koszyka', + addToCartSuccess: '{productName} dodany do koszyka', + addToCartError: 'Nie udało się dodać produktu do koszyka', + viewCartLabel: 'Zobacz koszyk', + }, + }; + + const cartPathMap: Record<string, string> = { + en: '/cart', + de: '/warenkorb', + pl: '/koszyk', + }; + + const labels = labelsMap[locale] ?? labelsMap['en']!; + + return { + id: 'product-details-1', + labels, + cartPath: cartPathMap[locale] || cartPathMap['en'], basePath: basePathMap[locale] || basePathMap['en'], attributes: attributesMap[locale] || attributesMap['en'], variantOptionGroups: variantOptionGroupsMap[locale] || variantOptionGroupsMap['en'], diff --git a/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts b/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts index 34e56bd2a..8616d3fb9 100644 --- a/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts +++ b/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts @@ -37,6 +37,11 @@ export const mapProductListBlock = (_locale: string): CMS.Model.ProductListBlock showMoreFilters: 'Show more filters', hideMoreFilters: 'Hide more filters', noActiveFilters: 'No active filters', + addToCartLabel: 'Add to Cart', + addToCartSuccess: '{productName} added to cart', + addToCartError: 'Failed to add product to cart', + viewCartLabel: 'View Cart', }, + cartPath: '/cart', }; }; diff --git a/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts b/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts index c544de0fa..6b7cb90bb 100644 --- a/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts +++ b/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts @@ -12,9 +12,14 @@ export const mapRecommendedProductsBlock = ( return { id: 'recommended-products-1', basePath: basePathMap[locale] || basePathMap.en, + cartPath: '/cart', labels: { title: 'Recommended Products', detailsLabel: 'Details', + addToCartLabel: 'Add to Cart', + addToCartSuccess: '{productName} added to cart', + addToCartError: 'Failed to add product to cart', + viewCartLabel: 'View Cart', }, }; }; diff --git a/packages/integrations/medusajs/CHANGELOG.md b/packages/integrations/medusajs/CHANGELOG.md index 778f10130..359638139 100644 --- a/packages/integrations/medusajs/CHANGELOG.md +++ b/packages/integrations/medusajs/CHANGELOG.md @@ -1,5 +1,48 @@ # @o2s/integrations.medusajs +## 1.11.1 + +### Patch Changes + +- dd58165: chore(deps): update dependencies +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + +## 1.11.0 + +### Minor Changes + +- 375cd90: feat(framework, integrations): add variantId to AddCartItemBody and cart item models, add viewCartLabel and cartPath to CMS block models. Implement variantId-based cart operations in Medusa integration. Localize CMS mappers (EN/DE/PL) for Contentful and Strapi. + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- fa91166: chore(deps): update dependencies +- 028ce47: chore(deps): update dependencies +- 3c0129d: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.logger@1.2.3 + +## 1.10.0 + +### Minor Changes + +- 5d36519: Extended framework with e-commerce models: Address (companyName, taxId), Cart, Checkout and Order Confirmation CMS blocks. Added Mocked and Medusa integration support for cart, checkout flow, and guest order retrieval. + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] + - @o2s/framework@1.19.0 + ## 1.9.0 ### Minor Changes diff --git a/packages/integrations/medusajs/package.json b/packages/integrations/medusajs/package.json index 0394170dd..5a71c3e03 100644 --- a/packages/integrations/medusajs/package.json +++ b/packages/integrations/medusajs/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/integrations.medusajs", - "version": "1.9.0", + "version": "1.11.1", "private": false, "license": "MIT", "description": "MedusaJS integration for O2S, providing ecommerce functionality including products, orders, and carts.", @@ -42,8 +42,8 @@ "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"" }, "dependencies": { - "@medusajs/js-sdk": "^2.13.2", - "@medusajs/types": "^2.13.2", + "@medusajs/js-sdk": "^2.13.4", + "@medusajs/types": "^2.13.4", "@o2s/framework": "*", "@o2s/utils.logger": "*", "slugify": "^1.6.6" @@ -55,7 +55,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts index 1e1fa3cc1..1a2d41dec 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts @@ -12,6 +12,7 @@ function minimalCart(overrides: Record<string, unknown> = {}): HttpTypes.StoreCa created_at: new Date('2024-01-01'), updated_at: new Date('2024-01-02'), items: [], + item_subtotal: 9000, subtotal: 9000, total: 10000, discount_total: 0, @@ -82,12 +83,36 @@ describe('carts.mapper', () => { expect(result.shippingAddress?.country).toBe('PL'); expect(result.shippingAddress?.city).toBe('Warsaw'); expect(result.shippingAddress?.streetName).toBe('Street'); + expect(result.shippingAddress?.streetNumber).toBe('1'); expect(result.billingAddress).toBeDefined(); expect(result.billingAddress?.country).toBe('PL'); expect(result.billingAddress?.city).toBe('Warsaw'); expect(result.billingAddress?.streetName).toBe('Billing St'); }); + it('should restore streetNumber, taxId, apartment from billing_address (address_2 + metadata)', () => { + const cart = minimalCart({ + billing_address: { + first_name: 'Jane', + last_name: 'Doe', + country_code: 'PL', + address_1: 'Marszałkowska', + address_2: '12', + city: 'Warsaw', + postal_code: '00-002', + metadata: { + taxId: '1234567890', + apartment: 'Apt 3', + }, + }, + }); + const result = mapCart(cart, defaultCurrency); + expect(result.billingAddress?.streetName).toBe('Marszałkowska'); + expect(result.billingAddress?.streetNumber).toBe('12'); + expect(result.billingAddress?.apartment).toBe('Apt 3'); + expect(result.billingAddress?.taxId).toBe('1234567890'); + }); + it('should map line items with sku from variant_sku, quantity, unit_price', () => { const cart = minimalCart({ items: [ diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts index d54191983..ebb691f1c 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts @@ -37,7 +37,7 @@ export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Ca data: cart.items?.map((item) => mapCartItem(item, currency)) ?? [], total: cart.items?.length ?? 0, }, - subtotal: mapPrice(cart.subtotal, currency, `Cart ${cart.id} subtotal`), + subtotal: mapPrice(cart.item_subtotal, currency, `Cart ${cart.id} subtotal`), discountTotal: mapPrice(cart.discount_total, currency, `Cart ${cart.id} discountTotal`), taxTotal: mapPrice(cart.tax_total, currency, `Cart ${cart.id} taxTotal`), shippingTotal: mapPrice(cart.shipping_total, currency, `Cart ${cart.id} shippingTotal`), @@ -48,7 +48,7 @@ export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Ca paymentMethod: mapPaymentMethodFromMetadata(asRecord(cart.metadata)), promotions: mapPromotions(cart), metadata: asRecord(cart.metadata), - notes: undefined, + notes: asRecord(cart.metadata)?.notes as string | undefined, email: cart.email ?? undefined, }; }; @@ -114,7 +114,7 @@ const mapShippingMethod = ( currency: Models.Price.Currency, ): Orders.Model.ShippingMethod => { return { - id: method.id, + id: method.shipping_option_id ?? method.id, name: method.name ?? '', description: method.description ?? '', total: mapPrice(method.total, currency, `Cart shipping method ${method.id} total`), diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts index 52574eaf8..f9f3276ec 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts @@ -9,6 +9,7 @@ import { Auth, Carts } from '@o2s/framework/modules'; import { CartsService } from './carts.service'; const DEFAULT_CURRENCY = 'EUR'; +const DEFAULT_REGION_ID = 'reg_default'; const minimalCartItem = { id: 'item_1', @@ -34,6 +35,7 @@ const minimalCart = { created_at: new Date('2024-01-01'), updated_at: new Date('2024-01-02'), items: [], + item_subtotal: 9000, subtotal: 9000, total: 10000, discount_total: 0, @@ -61,7 +63,11 @@ describe('CartsService', () => { }; client: { fetch: ReturnType<typeof vi.fn> }; }; - let mockMedusaJsService: { getSdk: ReturnType<typeof vi.fn>; getStoreApiHeaders: ReturnType<typeof vi.fn> }; + let mockMedusaJsService: { + getSdk: ReturnType<typeof vi.fn>; + getStoreApiHeaders: ReturnType<typeof vi.fn>; + getMedusaAdminApiHeaders: ReturnType<typeof vi.fn>; + }; let mockAuthService: { getCustomerId: ReturnType<typeof vi.fn> }; let mockConfig: { get: ReturnType<typeof vi.fn> }; let mockLogger: { debug: ReturnType<typeof vi.fn> }; @@ -86,10 +92,15 @@ describe('CartsService', () => { mockMedusaJsService = { getSdk: vi.fn(() => mockSdk), getStoreApiHeaders: vi.fn(() => ({})), + getMedusaAdminApiHeaders: vi.fn(() => ({ Authorization: 'Basic xxx' })), }; mockAuthService = { getCustomerId: vi.fn() }; mockConfig = { - get: vi.fn((key: string) => (key === 'DEFAULT_CURRENCY' ? DEFAULT_CURRENCY : '')), + get: vi.fn((key: string) => { + if (key === 'DEFAULT_CURRENCY') return DEFAULT_CURRENCY; + if (key === 'DEFAULT_REGION_ID') return DEFAULT_REGION_ID; + return ''; + }), }; mockLogger = { debug: vi.fn() }; mockCustomersService = {}; @@ -128,7 +139,7 @@ describe('CartsService', () => { expect(mockSdk.store.cart.retrieve).toHaveBeenCalledWith( 'cart_1', - { fields: '*items,*shipping_methods' }, + { fields: '*items,*shipping_methods,*billing_address,*shipping_address' }, expect.any(Object), ); expect(result).toBeDefined(); @@ -163,28 +174,31 @@ describe('CartsService', () => { expect(mockSdk.store.cart.create).toHaveBeenCalledWith( { currency_code: 'eur', region_id: 'reg_1', metadata: undefined }, - { fields: '*items,*shipping_methods' }, + { fields: '*items,*shipping_methods,*billing_address,*shipping_address' }, expect.any(Object), ); }); }); describe('addCartItem', () => { - it('should throw BadRequestException when sku is missing', () => { - expect(() => service.addCartItem({ quantity: 1 } as Carts.Request.AddCartItemBody, 'Bearer token')).toThrow( - BadRequestException, - ); + it('should throw BadRequestException when variantId is missing', () => { + expect(() => + service.addCartItem({ sku: 'SKU1', quantity: 1 } as Carts.Request.AddCartItemBody, 'Bearer token'), + ).toThrow(BadRequestException); }); it('should throw BadRequestException when cartId absent and currency missing', async () => { await expect( firstValueFrom( - service.addCartItem({ sku: 'SKU1', quantity: 1 } as Carts.Request.AddCartItemBody, 'Bearer token'), + service.addCartItem( + { sku: 'SKU1', variantId: 'var_1', quantity: 1 } as Carts.Request.AddCartItemBody, + 'Bearer token', + ), ), ).rejects.toThrow(BadRequestException); }); - it('should retrieve then createLineItem when cartId provided', async () => { + it('should retrieve then createLineItem when cartId provided with variantId', async () => { mockSdk.store.cart.retrieve.mockResolvedValue({ cart: minimalCart }); mockSdk.store.cart.createLineItem.mockResolvedValue({ cart: { ...minimalCart, items: [minimalCartItem] }, @@ -193,22 +207,23 @@ describe('CartsService', () => { const result = await firstValueFrom( service.addCartItem( - { cartId: 'cart_1', sku: 'SKU1', quantity: 2 } as Carts.Request.AddCartItemBody, + { cartId: 'cart_1', sku: 'SKU1', variantId: 'var_1', quantity: 2 } as Carts.Request.AddCartItemBody, 'Bearer token', ), ); expect(mockSdk.store.cart.retrieve).toHaveBeenCalledWith( 'cart_1', - { fields: '*items,*shipping_methods' }, + { fields: '*items,*shipping_methods,*billing_address,*shipping_address' }, expect.any(Object), ); expect(mockSdk.store.cart.createLineItem).toHaveBeenCalledWith( 'cart_1', - { variant_id: 'SKU1', quantity: 2, metadata: undefined }, - { fields: '*items,*shipping_methods' }, + { variant_id: 'var_1', quantity: 2, metadata: undefined }, + { fields: '*items,*shipping_methods,*billing_address,*shipping_address' }, expect.any(Object), ); + expect(mockSdk.client.fetch).not.toHaveBeenCalled(); expect(result).toBeDefined(); expect(result?.id).toBe('cart_1'); }); @@ -220,7 +235,7 @@ describe('CartsService', () => { const result = await firstValueFrom( service.addCartItem( - { cartId: 'cart_1', sku: 'SKU1', quantity: 1 } as Carts.Request.AddCartItemBody, + { cartId: 'cart_1', sku: 'SKU1', variantId: 'var_1', quantity: 1 } as Carts.Request.AddCartItemBody, 'Bearer token', ), ); @@ -228,27 +243,58 @@ describe('CartsService', () => { expect(result?.id).toBe('cart_1'); }); - it('should create new cart for guest when no cartId (createCartAndAddItem)', async () => { + it('should create new cart for guest when no cartId (createCartAndAddItem) with defaultRegionId fallback', async () => { mockSdk.store.cart.create.mockResolvedValue({ cart: { ...minimalCart, id: 'cart_new' } }); mockSdk.store.cart.createLineItem.mockResolvedValue({ cart: { ...minimalCart, id: 'cart_new' } }); mockAuthService.getCustomerId.mockReturnValue(undefined); const result = await firstValueFrom( service.addCartItem( - { sku: 'SKU1', quantity: 2, currency: 'EUR' } as Carts.Request.AddCartItemBody, + { sku: 'SKU1', variantId: 'var_1', quantity: 2, currency: 'EUR' } as Carts.Request.AddCartItemBody, undefined, ), ); expect(mockSdk.store.cart.create).toHaveBeenCalledWith( - { currency_code: 'eur', region_id: undefined, metadata: undefined }, - { fields: '*items,*shipping_methods' }, + { currency_code: 'eur', region_id: DEFAULT_REGION_ID, metadata: undefined }, + { fields: '*items,*shipping_methods,*billing_address,*shipping_address' }, + expect.any(Object), + ); + expect(mockSdk.store.cart.createLineItem).toHaveBeenCalledWith( + 'cart_new', + { variant_id: 'var_1', quantity: 2, metadata: undefined }, + { fields: '*items,*shipping_methods,*billing_address,*shipping_address' }, expect.any(Object), ); - expect(mockSdk.store.cart.createLineItem).toHaveBeenCalled(); + expect(mockSdk.client.fetch).not.toHaveBeenCalled(); expect(result?.id).toBe('cart_new'); }); + it('should use explicit regionId over defaultRegionId when provided', async () => { + mockSdk.store.cart.create.mockResolvedValue({ cart: { ...minimalCart, id: 'cart_reg' } }); + mockSdk.store.cart.createLineItem.mockResolvedValue({ cart: { ...minimalCart, id: 'cart_reg' } }); + mockAuthService.getCustomerId.mockReturnValue(undefined); + + await firstValueFrom( + service.addCartItem( + { + sku: 'SKU1', + variantId: 'var_1', + quantity: 1, + currency: 'EUR', + regionId: 'reg_explicit', + } as Carts.Request.AddCartItemBody, + undefined, + ), + ); + + expect(mockSdk.store.cart.create).toHaveBeenCalledWith( + { currency_code: 'eur', region_id: 'reg_explicit', metadata: undefined }, + { fields: '*items,*shipping_methods,*billing_address,*shipping_address' }, + expect.any(Object), + ); + }); + it('should create new cart for authenticated user when no cartId', async () => { mockSdk.store.cart.create.mockResolvedValue({ cart: { ...minimalCart, id: 'cart_auth' } }); mockSdk.store.cart.createLineItem.mockResolvedValue({ cart: { ...minimalCart, id: 'cart_auth' } }); @@ -256,7 +302,7 @@ describe('CartsService', () => { const result = await firstValueFrom( service.addCartItem( - { sku: 'SKU1', quantity: 1, currency: 'EUR' } as Carts.Request.AddCartItemBody, + { sku: 'SKU1', variantId: 'var_1', quantity: 1, currency: 'EUR' } as Carts.Request.AddCartItemBody, 'Bearer token', ), ); @@ -278,7 +324,7 @@ describe('CartsService', () => { 'cart_1', 'item_1', { quantity: 3, metadata: undefined }, - { fields: '*items,*shipping_methods' }, + { fields: '*items,*shipping_methods,*billing_address,*shipping_address' }, expect.any(Object), ); expect(result?.id).toBe('cart_1'); @@ -298,7 +344,7 @@ describe('CartsService', () => { expect(mockSdk.store.cart.deleteLineItem).toHaveBeenCalledWith( 'cart_1', 'item_1', - { fields: '*items,*shipping_methods' }, + { fields: '*items,*shipping_methods,*billing_address,*shipping_address' }, expect.any(Object), ); expect(result?.id).toBe('cart_1'); @@ -339,7 +385,7 @@ describe('CartsService', () => { email: 'user@test.com', metadata: expect.objectContaining({ notes: 'Gift wrap', custom: 'value' }), }), - { fields: '*items,*shipping_methods' }, + { fields: '*items,*shipping_methods,*billing_address,*shipping_address' }, expect.any(Object), ); expect(result?.id).toBe('cart_1'); @@ -381,7 +427,7 @@ describe('CartsService', () => { shipping_address: expect.objectContaining({ first_name: 'John', country_code: 'pl' }), billing_address: expect.objectContaining({ address_1: 'Billing St' }), }), - { fields: '*items,*shipping_methods' }, + { fields: '*items,*shipping_methods,*billing_address,*shipping_address' }, expect.any(Object), ); expect(result).toBeDefined(); @@ -422,7 +468,7 @@ describe('CartsService', () => { expect(mockSdk.store.cart.addShippingMethod).toHaveBeenCalledWith( 'cart_1', { option_id: 'opt_1' }, - { fields: '*items,*shipping_methods' }, + { fields: '*items,*shipping_methods,*billing_address,*shipping_address' }, expect.any(Object), ); expect(result).toBeDefined(); diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index d240edd48..8f0e1b1fd 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -18,6 +18,7 @@ import { Auth, Carts, Customers } from '@o2s/framework/modules'; import { Service as MedusaJsService } from '@/modules/medusajs'; +import { verifyResourceAccess } from '../../utils/customer-access'; import { handleHttpError } from '../../utils/handle-http-error'; import { mapAddressToMedusa } from '../customers/customers.mapper'; @@ -27,8 +28,9 @@ import { mapCart } from './carts.mapper'; export class CartsService extends Carts.Service { private readonly sdk: Medusa; private readonly defaultCurrency: string; + private readonly defaultRegionId: string | undefined; - private readonly cartItemsFields = '*items,*shipping_methods'; + private readonly cartItemsFields = '*items,*shipping_methods,*billing_address,*shipping_address'; constructor( private readonly config: ConfigService, @@ -40,6 +42,7 @@ export class CartsService extends Carts.Service { super(); this.sdk = this.medusaJsService.getSdk(); this.defaultCurrency = this.config.get('DEFAULT_CURRENCY') || ''; + this.defaultRegionId = this.config.get('DEFAULT_REGION_ID') || undefined; if (!this.defaultCurrency) { throw new InternalServerErrorException('DEFAULT_CURRENCY is not defined'); @@ -54,19 +57,16 @@ export class CartsService extends Carts.Service { this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( - map((response: HttpTypes.StoreCartResponse) => { + switchMap((response: HttpTypes.StoreCartResponse) => { const cart = mapCart(response.cart, this.defaultCurrency); - // Verify ownership for customer carts - if ( - cart.customerId && - authorization && - cart.customerId !== this.authService.getCustomerId(authorization) - ) { - throw new UnauthorizedException('Unauthorized to access this cart'); - } - - return cart; + return verifyResourceAccess( + this.sdk, + this.authService, + this.medusaJsService.getMedusaAdminApiHeaders(), + cart.customerId, + authorization, + ).pipe(map(() => cart)); }), catchError((error) => { if (error.status === 404) { @@ -145,8 +145,8 @@ export class CartsService extends Carts.Service { } addCartItem(data: Carts.Request.AddCartItemBody, authorization?: string): Observable<Carts.Model.Cart> { - if (!data.sku) { - throw new BadRequestException('SKU is required for Medusa carts'); + if (!data.variantId) { + throw new BadRequestException('variantId is required for Medusa carts'); } const customerId = authorization ? this.authService.getCustomerId(authorization) : undefined; @@ -164,15 +164,22 @@ export class CartsService extends Carts.Service { switchMap((response: HttpTypes.StoreCartResponse) => { const cart = mapCart(response.cart, this.defaultCurrency); - if (cart.customerId && authorization && cart.customerId !== customerId) { - return throwError(() => new UnauthorizedException('Unauthorized to access this cart')); + if (cart.customerId) { + if (!authorization) { + return throwError( + () => new UnauthorizedException('Authentication required to access this cart'), + ); + } + if (cart.customerId !== customerId) { + return throwError(() => new UnauthorizedException('Unauthorized to access this cart')); + } } return from( this.sdk.store.cart.createLineItem( cartId, { - variant_id: data.sku, + variant_id: data.variantId!, quantity: data.quantity, metadata: data.metadata, }, @@ -192,7 +199,7 @@ export class CartsService extends Carts.Service { return this.createCartAndAddItem( data.currency, - data.sku, + data.variantId, data.quantity, data.regionId, data.metadata, @@ -321,7 +328,7 @@ export class CartsService extends Carts.Service { private createCartAndAddItem( currency: string, - sku: string, + variantId: string, quantity: number, regionId?: string, metadata?: Record<string, unknown>, @@ -331,7 +338,7 @@ export class CartsService extends Carts.Service { this.sdk.store.cart.create( { currency_code: currency.toLowerCase(), - region_id: regionId, + region_id: regionId || this.defaultRegionId, metadata, }, { fields: this.cartItemsFields }, @@ -343,7 +350,7 @@ export class CartsService extends Carts.Service { this.sdk.store.cart.createLineItem( createResponse.cart.id, { - variant_id: sku, + variant_id: variantId, quantity, metadata, }, @@ -404,8 +411,19 @@ export class CartsService extends Carts.Service { // Resolve both addresses in parallel return forkJoin([shippingAddress$, billingAddress$]).pipe( switchMap(([shippingAddress, billingAddress]) => { + // Handle sameAsBillingAddress: copy billing address to shipping + let resolvedShipping = shippingAddress; + if (data.sameAsBillingAddress && !resolvedShipping) { + // Prefer the new billing address from request; fall back to cart's existing billing + resolvedShipping = billingAddress + ? billingAddress + : cart.billingAddress + ? mapAddressToMedusa(cart.billingAddress) + : null; + } + // At least one address must be provided - if (!shippingAddress && !billingAddress) { + if (!resolvedShipping && !billingAddress) { return throwError( () => new BadRequestException('At least one address (shipping or billing) is required'), ); @@ -424,13 +442,11 @@ export class CartsService extends Carts.Service { cartUpdate.email = data.email; } - // Set addresses (use shipping as billing if billing not provided) - if (shippingAddress) { - cartUpdate.shipping_address = shippingAddress; - cartUpdate.billing_address = billingAddress ?? shippingAddress; - } else if (billingAddress) { - // If only billing provided, use it for both - cartUpdate.shipping_address = billingAddress; + // Set addresses independently — each only updates its own field + if (resolvedShipping) { + cartUpdate.shipping_address = resolvedShipping; + } + if (billingAddress) { cartUpdate.billing_address = billingAddress; } diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts index afc1384a1..020633fe7 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts @@ -247,6 +247,7 @@ describe('CheckoutService', () => { payment_status: 'captured', currency_code: 'eur', total: 10000, + item_subtotal: 9000, subtotal: 9000, tax_total: 1000, discount_total: 0, @@ -300,6 +301,7 @@ describe('CheckoutService', () => { payment_status: 'captured', currency_code: 'eur', total: 10000, + item_subtotal: 9000, subtotal: 9000, tax_total: 1000, discount_total: 0, diff --git a/packages/integrations/medusajs/src/modules/customers/customers.mapper.spec.ts b/packages/integrations/medusajs/src/modules/customers/customers.mapper.spec.ts index bf16ce84d..ace119e55 100644 --- a/packages/integrations/medusajs/src/modules/customers/customers.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/customers/customers.mapper.spec.ts @@ -34,7 +34,7 @@ describe('customers.mapper', () => { expect(result.isDefault).toBe(true); expect(result.address.firstName).toBe('John'); expect(result.address.lastName).toBe('Doe'); - expect(result.address.country).toBe('pl'); + expect(result.address.country).toBe('PL'); expect(result.address.streetName).toBe('Street'); expect(result.address.streetNumber).toBe('1'); expect(result.address.city).toBe('Warsaw'); @@ -88,14 +88,14 @@ describe('customers.mapper', () => { expect(result.last_name).toBe('Doe'); expect(result.country_code).toBe('pl'); expect(result.address_1).toBe('Main St'); - expect(result.address_2).toBe('10'); // streetNumber takes precedence over apartment + expect(result.address_2).toBe('10'); // address_2 = streetNumber expect(result.city).toBe('Krakow'); expect(result.postal_code).toBe('30-001'); expect(result.province).toBe('Lesser Poland'); expect(result.phone).toBe('+48123456789'); }); - it('should use apartment when streetNumber is empty for address_2', () => { + it('should put apartment in metadata when streetNumber is empty', () => { const address = { firstName: 'J', lastName: 'D', @@ -109,7 +109,26 @@ describe('customers.mapper', () => { phone: undefined, }; const result = mapAddressToMedusa(address as never); - expect(result.address_2).toBe('Apt 2'); + expect(result.address_2).toBeUndefined(); + expect(result.metadata).toEqual({ apartment: 'Apt 2' }); + }); + + it('should include taxId and apartment in metadata when present', () => { + const address = { + firstName: 'J', + lastName: 'D', + country: 'pl', + streetName: 'Marszałkowska', + streetNumber: '12', + apartment: 'Apt 3', + taxId: '1234567890', + city: 'Warsaw', + postalCode: '00-001', + }; + const result = mapAddressToMedusa(address as never); + expect(result.address_1).toBe('Marszałkowska'); + expect(result.address_2).toBe('12'); + expect(result.metadata).toEqual({ taxId: '1234567890', apartment: 'Apt 3' }); }); }); }); diff --git a/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts b/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts index fb0889986..d2260210d 100644 --- a/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts +++ b/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts @@ -14,7 +14,7 @@ export function mapCustomerAddress( address: { firstName: medusaAddress.first_name ?? undefined, lastName: medusaAddress.last_name ?? undefined, - country: medusaAddress.country_code || '', + country: medusaAddress.country_code?.toUpperCase() || '', streetName: medusaAddress.address_1 || '', streetNumber: medusaAddress.address_2 ?? undefined, city: medusaAddress.city || '', @@ -41,16 +41,28 @@ export function mapCustomerAddresses( }; } -export function mapAddressToMedusa(address: Models.Address.Address): HttpTypes.StoreCreateCustomerAddress { +/** + * Maps O2S Address to Medusa format. + * address_1 = streetName, address_2 = streetNumber, metadata = { taxId, apartment } + */ +export function mapAddressToMedusa(address: Models.Address.Address): HttpTypes.StoreCreateCustomerAddress & { + metadata?: Record<string, string>; +} { + const metadata: Record<string, string> = {}; + if (address.taxId?.trim()) metadata.taxId = address.taxId.trim(); + if (address.apartment?.trim()) metadata.apartment = address.apartment.trim(); + return { first_name: (address.firstName || '').trim(), last_name: (address.lastName || '').trim(), + company: address.companyName?.trim() || undefined, address_1: (address.streetName || '').trim(), - address_2: (address.streetNumber || address.apartment || '').trim() || undefined, + address_2: address.streetNumber?.trim() || undefined, city: (address.city || '').trim(), country_code: (address.country || '').toLowerCase().trim(), // Medusa.js requires lowercase ISO country codes postal_code: (address.postalCode || '').trim(), province: address.region?.trim() || undefined, phone: address.phone?.trim() || undefined, + ...(Object.keys(metadata).length > 0 ? { metadata } : {}), }; } diff --git a/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts b/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts index c94f8adba..c191eda46 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts @@ -11,6 +11,7 @@ function minimalOrder(overrides: Record<string, unknown> = {}): HttpTypes.StoreO customer_id: 'cust_1', currency_code: 'eur', total: 10000, + item_subtotal: 9000, subtotal: 9000, tax_total: 1000, discount_total: 0, diff --git a/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts b/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts index 952d48d86..af5a88179 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts @@ -20,7 +20,7 @@ export const mapOrder = (order: HttpTypes.StoreOrder, defaultCurrency: string): return { id: order.id, total: mapPrice(order.total, currency, `Order ${order.id} total`), - subtotal: mapPrice(order.subtotal, currency, `Order ${order.id} subtotal`), + subtotal: mapPrice(order.item_subtotal, currency, `Order ${order.id} subtotal`), shippingTotal: mapPrice(order.shipping_total, currency, `Order ${order.id} shippingTotal`), discountTotal: mapPrice(order.discount_total, currency, `Order ${order.id} discountTotal`), tax: mapPrice(order.tax_total, currency, `Order ${order.id} tax`), diff --git a/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts b/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts index ee4ab254f..f2de404fd 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts @@ -15,6 +15,7 @@ const minimalOrder = { customer_id: 'cust_1', currency_code: 'eur', total: 10000, + item_subtotal: 9000, subtotal: 9000, tax_total: 1000, discount_total: 0, @@ -29,10 +30,19 @@ const minimalOrder = { shipping_methods: [], }; +const guestOrder = { ...minimalOrder, id: 'order_guest', customer_id: null }; + describe('OrdersService', () => { let service: OrdersService; - let mockSdk: { store: { order: { retrieve: ReturnType<typeof vi.fn>; list: ReturnType<typeof vi.fn> } } }; - let mockMedusaJsService: { getSdk: ReturnType<typeof vi.fn>; getStoreApiHeaders: ReturnType<typeof vi.fn> }; + let mockSdk: { + store: { order: { retrieve: ReturnType<typeof vi.fn>; list: ReturnType<typeof vi.fn> } }; + admin: { customer: { retrieve: ReturnType<typeof vi.fn> } }; + }; + let mockMedusaJsService: { + getSdk: ReturnType<typeof vi.fn>; + getStoreApiHeaders: ReturnType<typeof vi.fn>; + getMedusaAdminApiHeaders: ReturnType<typeof vi.fn>; + }; let mockAuthService: { getCustomerId: ReturnType<typeof vi.fn> }; let mockConfig: { get: ReturnType<typeof vi.fn> }; let mockLogger: { debug: ReturnType<typeof vi.fn> }; @@ -46,10 +56,16 @@ describe('OrdersService', () => { list: vi.fn(), }, }, + admin: { + customer: { + retrieve: vi.fn(), + }, + }, }; mockMedusaJsService = { getSdk: vi.fn(() => mockSdk), getStoreApiHeaders: vi.fn(() => ({})), + getMedusaAdminApiHeaders: vi.fn(() => ({ Authorization: 'Basic xxx' })), }; mockAuthService = { getCustomerId: vi.fn() }; mockConfig = { @@ -82,29 +98,63 @@ describe('OrdersService', () => { }); describe('getOrder', () => { - it('should throw UnauthorizedException when authorization is missing', () => { - expect(() => service.getOrder({ id: 'order_1' }, undefined)).toThrow(UnauthorizedException); - expect(mockLogger.debug).toHaveBeenCalledWith('Authorization token not found'); - }); - - it('should call sdk.store.order.retrieve and return mapped order', async () => { - mockSdk.store.order.retrieve.mockResolvedValue({ order: minimalOrder }); + it('should return guest order for guest (no authorization)', async () => { + mockSdk.store.order.retrieve.mockResolvedValue({ order: guestOrder }); - const result = await firstValueFrom(service.getOrder({ id: 'order_1' }, 'Bearer token')); + const result = await firstValueFrom(service.getOrder({ id: 'order_guest' }, undefined)); expect(mockSdk.store.order.retrieve).toHaveBeenCalledWith( - 'order_1', + 'order_guest', expect.objectContaining({ fields: expect.any(String) }), expect.any(Object), ); + expect(result).toBeDefined(); + expect(result?.id).toBe('order_guest'); + expect(result?.customerId).toBeUndefined(); + }); + + it('should return guest order for authenticated user', async () => { + mockSdk.store.order.retrieve.mockResolvedValue({ order: guestOrder }); + + const result = await firstValueFrom(service.getOrder({ id: 'order_guest' }, 'Bearer token')); + + expect(result).toBeDefined(); + expect(result?.id).toBe('order_guest'); + }); + + it('should throw UnauthorizedException when guest tries to get customer order', async () => { + mockSdk.store.order.retrieve.mockResolvedValue({ order: minimalOrder }); + mockSdk.admin.customer.retrieve.mockResolvedValue({ customer: { has_account: true } }); + + await expect(firstValueFrom(service.getOrder({ id: 'order_1' }, undefined))).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should return customer order when authenticated user matches customerId', async () => { + mockSdk.store.order.retrieve.mockResolvedValue({ order: minimalOrder }); + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + + const result = await firstValueFrom(service.getOrder({ id: 'order_1' }, 'Bearer token')); + expect(result).toBeDefined(); expect(result?.id).toBe('order_1'); expect(result?.status).toBe('COMPLETED'); expect(result?.paymentStatus).toBe('CAPTURED'); }); + it('should throw UnauthorizedException when authenticated user tries to get another customer order', async () => { + mockSdk.store.order.retrieve.mockResolvedValue({ order: minimalOrder }); + mockAuthService.getCustomerId.mockReturnValue('cust_other'); + + await expect(firstValueFrom(service.getOrder({ id: 'order_1' }, 'Bearer token'))).rejects.toThrow( + UnauthorizedException, + ); + }); + it('should throw NotFoundException when SDK returns 404', async () => { mockSdk.store.order.retrieve.mockRejectedValue({ status: 404 }); + mockAuthService.getCustomerId.mockReturnValue('cust_1'); await expect(firstValueFrom(service.getOrder({ id: 'missing' }, 'Bearer token'))).rejects.toThrow( NotFoundException, diff --git a/packages/integrations/medusajs/src/modules/orders/orders.service.ts b/packages/integrations/medusajs/src/modules/orders/orders.service.ts index fa96f83d3..f205e6fe5 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.service.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.ts @@ -2,7 +2,7 @@ import Medusa from '@medusajs/js-sdk'; import { HttpTypes, OrderStatus } from '@medusajs/types'; import { Inject, Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Observable, catchError, from, map } from 'rxjs'; +import { Observable, catchError, from, map, switchMap } from 'rxjs'; import { LoggerService } from '@o2s/utils.logger'; @@ -10,6 +10,7 @@ import { Auth, Orders } from '@o2s/framework/modules'; import { Service as MedusaJsService } from '@/modules/medusajs'; +import { verifyResourceAccess } from '../../utils/customer-access'; import { handleHttpError } from '../../utils/handle-http-error'; import { mapOrder, mapOrders } from './orders.mapper'; @@ -28,7 +29,8 @@ export class OrdersService extends Orders.Service { private readonly additionalOrderListFields = '+total,+subtotal,+tax_total,+discount_total,+shipping_total,+shipping_subtotal,+tax_total,+items.product.*'; - private readonly additionalOrderDetailsFields = 'items.product.*'; + // customer_id required for authorization check (guest vs customer order access) + private readonly additionalOrderDetailsFields = '+customer_id,items.product.*'; constructor( private readonly config: ConfigService, @@ -47,7 +49,8 @@ export class OrdersService extends Orders.Service { /** * Retrieves an order by ID using Medusa Store API. - * Store API automatically verifies the order belongs to the authenticated customer. + * - Guest orders (no customer_id): accessible to anyone with order ID (order confirmation flow). + * - Customer orders: require authorization and order.customerId must match the authenticated customer. * * @requires Medusa auth plugin must be configured to accept SSO tokens. */ @@ -55,11 +58,6 @@ export class OrdersService extends Orders.Service { params: Orders.Request.GetOrderParams, authorization: string | undefined, ): Observable<Orders.Model.Order | undefined> { - if (!authorization) { - this.logger.debug('Authorization token not found'); - throw new UnauthorizedException('Unauthorized'); - } - const query: HttpTypes.SelectParams = { fields: this.additionalOrderDetailsFields, }; @@ -67,7 +65,17 @@ export class OrdersService extends Orders.Service { return from( this.sdk.store.order.retrieve(params.id, query, this.medusaJsService.getStoreApiHeaders(authorization)), ).pipe( - map((response: { order: HttpTypes.StoreOrder }) => mapOrder(response.order, this.defaultCurrency)), + switchMap((response: { order: HttpTypes.StoreOrder }) => { + const order = mapOrder(response.order, this.defaultCurrency); + + return verifyResourceAccess( + this.sdk, + this.authService, + this.medusaJsService.getMedusaAdminApiHeaders(), + order.customerId, + authorization, + ).pipe(map(() => order)); + }), catchError((error) => { return handleHttpError(error); }), diff --git a/packages/integrations/medusajs/src/modules/products/products.service.ts b/packages/integrations/medusajs/src/modules/products/products.service.ts index 4ed73ce3b..05e39bc8c 100644 --- a/packages/integrations/medusajs/src/modules/products/products.service.ts +++ b/packages/integrations/medusajs/src/modules/products/products.service.ts @@ -37,10 +37,10 @@ export class ProductsService extends Products.Service { '*variants,*variants.prices,*variants.options,*categories,*tags,*images,+variants.inventory_quantity,+variants.manage_inventory,+variants.allow_backorder'; // Store API fields for retrieving product with variants (includes inventory_quantity) private readonly storeRetrieveFields = - '*variants,*variants.options,*variants.options.option,+variants.inventory_quantity,+variants.manage_inventory,+variants.allow_backorder'; + '*variants,*variants.prices,*variants.options,*variants.options.option,+variants.inventory_quantity,+variants.manage_inventory,+variants.allow_backorder'; // Store API fields for retrieving product with variant details (weight, height, etc.) private readonly productDetailFields = - '*variants,+variants.weight,+variants.height,+variants.width,+variants.length,+variants.material,+variants.origin_country,+variants.hs_code,+variants.mid_code,+variants.metadata,+variants.prices,*variants.options,*variants.options.option,+metadata,+handle,+,+images,+tags'; + '*variants,*variants.prices,+variants.weight,+variants.height,+variants.width,+variants.length,+variants.material,+variants.origin_country,+variants.hs_code,+variants.mid_code,+variants.metadata,*variants.options,*variants.options.option,+metadata,+handle,+,+images,+tags'; constructor( private readonly config: ConfigService, diff --git a/packages/integrations/medusajs/src/utils/address.ts b/packages/integrations/medusajs/src/utils/address.ts index b6d1c8e65..02fc3d3e7 100644 --- a/packages/integrations/medusajs/src/utils/address.ts +++ b/packages/integrations/medusajs/src/utils/address.ts @@ -1,11 +1,14 @@ import { Models } from '@o2s/framework/modules'; /** - * Common address fields shared across Medusa address types + * Common address fields shared across Medusa address types. + * Medusa uses address_1 for street name, address_2 for street number. + * taxId and apartment are stored in metadata. */ interface MedusaAddressFields { first_name?: string | null; last_name?: string | null; + company?: string | null; country_code?: string | null; province?: string | null; address_1?: string | null; @@ -13,26 +16,31 @@ interface MedusaAddressFields { city?: string | null; postal_code?: string | null; phone?: string | null; + metadata?: Record<string, unknown> | null; } /** * Maps a Medusa address (from StoreCartAddress, StoreOrderAddress, AddressDTO, etc.) to the framework Address model. - * Handles all Medusa address types by accepting the common fields interface. + * address_1 = streetName, address_2 = streetNumber, metadata = { taxId, apartment } */ export function mapAddress(address?: MedusaAddressFields | null): Models.Address.Address | undefined { if (!address) { return undefined; } + const meta = address.metadata ?? {}; + return { firstName: address.first_name ?? undefined, lastName: address.last_name ?? undefined, - country: address.country_code ?? '', + companyName: address.company ?? undefined, + taxId: typeof meta.taxId === 'string' ? meta.taxId : undefined, + country: address.country_code?.toUpperCase() ?? '', district: address.province ?? '', region: address.province ?? '', streetName: address.address_1 ?? '', - streetNumber: undefined, // Medusa does not store street number separately - apartment: address.address_2 ?? undefined, + streetNumber: address.address_2?.trim() || undefined, + apartment: typeof meta.apartment === 'string' ? meta.apartment : undefined, city: address.city ?? '', postalCode: address.postal_code ?? '', phone: address.phone ?? undefined, diff --git a/packages/integrations/medusajs/src/utils/customer-access.ts b/packages/integrations/medusajs/src/utils/customer-access.ts new file mode 100644 index 000000000..896fe7867 --- /dev/null +++ b/packages/integrations/medusajs/src/utils/customer-access.ts @@ -0,0 +1,49 @@ +import Medusa from '@medusajs/js-sdk'; +import { UnauthorizedException } from '@nestjs/common'; +import { Observable, from, map } from 'rxjs'; + +import { Auth } from '@o2s/framework/modules'; + +/** + * Checks if a resource (cart/order) with a given customerId can be accessed. + * + * Medusa assigns a customer_id to both registered and guest customers. + * Guest customers (has_account=false) are created automatically when an email is provided. + * This helper uses Admin API to check has_account and enforce ownership: + * + * - No customerId on resource → accessible to anyone + * - No authorization token → check if customer is guest (has_account=false), if so allow access + * - Authorization provided → customerId must match the authenticated user + */ +export const verifyResourceAccess = ( + sdk: Medusa, + authService: Auth.Service, + adminHeaders: Record<string, string>, + customerId: string | undefined, + authorization: string | undefined, +): Observable<void> => { + // No customer on resource — public access + if (!customerId) { + return from(Promise.resolve()); + } + + // Authorized user — check ownership directly (no Admin API call needed) + if (authorization) { + const tokenCustomerId = authService.getCustomerId(authorization); + if (customerId !== tokenCustomerId) { + throw new UnauthorizedException('Unauthorized'); + } + return from(Promise.resolve()); + } + + // No authorization — check if this is a guest customer via Admin API + return from(sdk.admin.customer.retrieve(customerId, {}, adminHeaders)).pipe( + map((response) => { + const customer = response.customer as { has_account?: boolean }; + if (customer.has_account !== false) { + throw new UnauthorizedException('Authentication required to access this resource'); + } + // Guest customer (has_account=false) — allow access + }), + ); +}; diff --git a/packages/integrations/medusajs/src/utils/handle-http-error.spec.ts b/packages/integrations/medusajs/src/utils/handle-http-error.spec.ts index 7cf7e088c..5d7a7e0db 100644 --- a/packages/integrations/medusajs/src/utils/handle-http-error.spec.ts +++ b/packages/integrations/medusajs/src/utils/handle-http-error.spec.ts @@ -26,6 +26,15 @@ describe('handleHttpError', () => { expect(() => handleHttpError({ status: 401 })).toThrow('Unauthorized'); }); + it('should return observable that errors with BadRequestException when error.status is 400', async () => { + const obs = handleHttpError({ status: 400, message: 'The promotion code SDF is invalid' }); + + await expect(firstValueFrom(obs)).rejects.toThrow(BadRequestException); + await expect(firstValueFrom(handleHttpError({ status: 400, message: 'Invalid code' }))).rejects.toThrow( + 'Invalid code', + ); + }); + it('should return observable that errors with InternalServerErrorException for other status', async () => { const obs = handleHttpError({ status: 500, message: 'Server error' }); diff --git a/packages/integrations/medusajs/src/utils/handle-http-error.ts b/packages/integrations/medusajs/src/utils/handle-http-error.ts index 1fb190a5f..266c59e0a 100644 --- a/packages/integrations/medusajs/src/utils/handle-http-error.ts +++ b/packages/integrations/medusajs/src/utils/handle-http-error.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ForbiddenException, HttpException, InternalServerErrorException, @@ -18,6 +19,8 @@ export const handleHttpError = (error: any) => { throw new ForbiddenException('Forbidden'); } else if (error.status === 401) { throw new UnauthorizedException('Unauthorized'); + } else if (error.status === 400) { + return throwError(() => new BadRequestException(error.message || error.data?.message || 'Bad request')); } return throwError(() => new InternalServerErrorException(error.message)); }; diff --git a/packages/integrations/mocked-dxp/CHANGELOG.md b/packages/integrations/mocked-dxp/CHANGELOG.md index 16e1cf666..9a0f62306 100644 --- a/packages/integrations/mocked-dxp/CHANGELOG.md +++ b/packages/integrations/mocked-dxp/CHANGELOG.md @@ -1,5 +1,32 @@ # @o2s/integrations.mocked-dxp +## 1.1.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/integrations.mocked@1.21.0 + - @o2s/utils.logger@1.2.3 + +## 1.1.0 + +### Minor Changes + +- 0e61431: feat: added redirection for homepage dxp mock + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] + - @o2s/framework@1.19.0 + - @o2s/integrations.mocked@1.20.0 + ## 1.0.0 ### Major Changes diff --git a/packages/integrations/mocked-dxp/package.json b/packages/integrations/mocked-dxp/package.json index 464efe442..ff289ceb4 100644 --- a/packages/integrations/mocked-dxp/package.json +++ b/packages/integrations/mocked-dxp/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/integrations.mocked-dxp", - "version": "1.0.0", + "version": "1.1.1", "private": false, "license": "MIT", "description": "Mocked DXP integration for O2S, providing in-memory data for DXP demo flows.", @@ -51,7 +51,7 @@ "@o2s/vitest-config": "*", "@o2s/telemetry": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.2", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/integrations/mocked-dxp/src/modules/cms/mappers/mocks/pages/home.page.ts b/packages/integrations/mocked-dxp/src/modules/cms/mappers/mocks/pages/home.page.ts index 341d8f58d..8fa89cc67 100644 --- a/packages/integrations/mocked-dxp/src/modules/cms/mappers/mocks/pages/home.page.ts +++ b/packages/integrations/mocked-dxp/src/modules/cms/mappers/mocks/pages/home.page.ts @@ -41,6 +41,7 @@ export const PAGE_HOME_EN: CMS.Model.Page.Page = { }, }, hasOwnTitle: true, + redirect: '/personal', template: { __typename: 'OneColumnTemplate', slots: { @@ -92,6 +93,7 @@ export const PAGE_HOME_DE: CMS.Model.Page.Page = { }, }, hasOwnTitle: false, + redirect: '/personlich', template: { __typename: 'OneColumnTemplate', slots: { @@ -143,6 +145,7 @@ export const PAGE_HOME_PL: CMS.Model.Page.Page = { }, }, hasOwnTitle: false, + redirect: '/indywidualny', template: { __typename: 'OneColumnTemplate', slots: { diff --git a/packages/integrations/mocked/CHANGELOG.md b/packages/integrations/mocked/CHANGELOG.md index 6ab99933a..4a4e35718 100644 --- a/packages/integrations/mocked/CHANGELOG.md +++ b/packages/integrations/mocked/CHANGELOG.md @@ -1,5 +1,53 @@ # @o2s/integrations.mocked +## 1.21.1 + +### Patch Changes + +- 338cb01: Migrate integration services from `implements` to `extends` and add `super()` where needed + to keep constructor metadata compatible with NestJS dependency injection. + + Update documentation examples to reflect the new `extends ...Service` pattern. + +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + +## 1.21.0 + +### Minor Changes + +- 375cd90: feat(framework, integrations): add variantId to AddCartItemBody and cart item models, add viewCartLabel and cartPath to CMS block models. Implement variantId-based cart operations in Medusa integration. Localize CMS mappers (EN/DE/PL) for Contentful and Strapi. + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.logger@1.2.3 + +## 1.20.0 + +### Minor Changes + +- 5d36519: Extended framework with e-commerce models: Address (companyName, taxId), Cart, Checkout and Order Confirmation CMS blocks. Added Mocked and Medusa integration support for cart, checkout flow, and guest order retrieval. + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] + - @o2s/framework@1.19.0 + ## 1.19.0 ### Minor Changes diff --git a/packages/integrations/mocked/package.json b/packages/integrations/mocked/package.json index 8be19520f..eafe7da54 100644 --- a/packages/integrations/mocked/package.json +++ b/packages/integrations/mocked/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/integrations.mocked", - "version": "1.19.0", + "version": "1.21.1", "private": false, "license": "MIT", "description": "Mocked integration for O2S development and testing, providing in-memory data with Prisma.", @@ -66,7 +66,7 @@ "@o2s/telemetry": "*", "@types/jsonwebtoken": "^9.0.10", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "express": "5.2.1", "prettier": "^3.8.1", "shx": "^0.4.0", diff --git a/packages/integrations/mocked/src/modules/articles/articles.service.ts b/packages/integrations/mocked/src/modules/articles/articles.service.ts index 208b8cb9f..d1b8cd0ee 100644 --- a/packages/integrations/mocked/src/modules/articles/articles.service.ts +++ b/packages/integrations/mocked/src/modules/articles/articles.service.ts @@ -6,8 +6,10 @@ import { Articles, Search } from '@o2s/framework/modules'; import { mapArticle, mapCategories, mapCategory } from './articles.mapper'; @Injectable() -export class ArticlesService implements Articles.Service { - constructor(private readonly searchService: Search.Service) {} +export class ArticlesService extends Articles.Service { + constructor(private readonly searchService: Search.Service) { + super(); + } getCategory(options: Articles.Request.GetCategoryParams): Observable<Articles.Model.Category> { return defer(() => of(mapCategory(options.locale, options.id))); diff --git a/packages/integrations/mocked/src/modules/auth/auth.guard.spec.ts b/packages/integrations/mocked/src/modules/auth/auth.guard.spec.ts index c969a88fd..3d5ac3788 100644 --- a/packages/integrations/mocked/src/modules/auth/auth.guard.spec.ts +++ b/packages/integrations/mocked/src/modules/auth/auth.guard.spec.ts @@ -5,11 +5,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { LoggerService } from '@o2s/utils.logger'; +import { HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { PermissionsGuard, RolesGuard } from './auth.guard'; import { AuthService } from './auth.service'; +const H = HeaderName; + describe('Auth Guards', () => { let reflector: Reflector; let logger: LoggerService; @@ -65,7 +68,7 @@ describe('Auth Guards', () => { }); it('should throw UnauthorizedException if token verification fails', async () => { - request.headers['authorization'] = 'Bearer invalid'; + request.headers[H.Authorization] = 'Bearer invalid'; vi.mocked(reflector.getAllAndMerge).mockReturnValue({ roles: ['admin'] }); vi.mocked(authService.verifyToken).mockRejectedValue(new Error('Invalid token')); @@ -74,7 +77,7 @@ describe('Auth Guards', () => { }); it('should throw UnauthorizedException if token is revoked', async () => { - request.headers['authorization'] = 'Bearer valid'; + request.headers[H.Authorization] = 'Bearer valid'; const mockToken = { jti: '123' }; @@ -86,7 +89,7 @@ describe('Auth Guards', () => { }); it('should use existing verified token if present', async () => { - request.headers['authorization'] = 'Bearer valid'; + request.headers[H.Authorization] = 'Bearer valid'; request['x-decoded-token'] = { jti: '123' }; vi.mocked(reflector.getAllAndMerge).mockReturnValue({ roles: ['admin'] }); @@ -100,7 +103,7 @@ describe('Auth Guards', () => { }); it('should validate roles with ANY mode (default)', async () => { - request.headers['authorization'] = 'Bearer valid'; + request.headers[H.Authorization] = 'Bearer valid'; vi.mocked(reflector.getAllAndMerge).mockReturnValue({ roles: ['admin', 'editor'], @@ -116,7 +119,7 @@ describe('Auth Guards', () => { }); it('should fail validation with ANY mode if no roles match', async () => { - request.headers['authorization'] = 'Bearer valid'; + request.headers[H.Authorization] = 'Bearer valid'; vi.mocked(reflector.getAllAndMerge).mockReturnValue({ roles: ['admin', 'editor'] }); vi.mocked(authService.verifyToken).mockResolvedValue({}); @@ -129,7 +132,7 @@ describe('Auth Guards', () => { }); it('should validate roles with ALL mode', async () => { - request.headers['authorization'] = 'Bearer valid'; + request.headers[H.Authorization] = 'Bearer valid'; vi.mocked(reflector.getAllAndMerge).mockReturnValue({ roles: ['admin', 'editor'], @@ -145,7 +148,7 @@ describe('Auth Guards', () => { }); it('should fail validation with ALL mode if not all roles match', async () => { - request.headers['authorization'] = 'Bearer valid'; + request.headers[H.Authorization] = 'Bearer valid'; vi.mocked(reflector.getAllAndMerge).mockReturnValue({ roles: ['admin', 'editor'], @@ -183,7 +186,7 @@ describe('Auth Guards', () => { }); it('should validate permissions with ALL mode (default)', async () => { - request.headers['authorization'] = 'Bearer valid'; + request.headers[H.Authorization] = 'Bearer valid'; vi.mocked(reflector.getAllAndMerge).mockReturnValue({ resource: 'res', @@ -203,7 +206,7 @@ describe('Auth Guards', () => { }); it('should fail validation with ALL mode if one action is missing', async () => { - request.headers['authorization'] = 'Bearer valid'; + request.headers[H.Authorization] = 'Bearer valid'; vi.mocked(reflector.getAllAndMerge).mockReturnValue({ resource: 'res', actions: ['read', 'write'] }); vi.mocked(authService.verifyToken).mockResolvedValue({}); @@ -217,7 +220,7 @@ describe('Auth Guards', () => { }); it('should validate permissions with ANY mode', async () => { - request.headers['authorization'] = 'Bearer valid'; + request.headers[H.Authorization] = 'Bearer valid'; vi.mocked(reflector.getAllAndMerge).mockReturnValue({ resource: 'res', @@ -237,7 +240,7 @@ describe('Auth Guards', () => { }); it('should fail validation with ANY mode if all actions are missing', async () => { - request.headers['authorization'] = 'Bearer valid'; + request.headers[H.Authorization] = 'Bearer valid'; vi.mocked(reflector.getAllAndMerge).mockReturnValue({ resource: 'res', diff --git a/packages/integrations/mocked/src/modules/auth/auth.guard.ts b/packages/integrations/mocked/src/modules/auth/auth.guard.ts index 33f68a170..a250f2d86 100644 --- a/packages/integrations/mocked/src/modules/auth/auth.guard.ts +++ b/packages/integrations/mocked/src/modules/auth/auth.guard.ts @@ -3,10 +3,13 @@ import { Reflector } from '@nestjs/core'; import { LoggerService } from '@o2s/utils.logger'; +import { HeaderName } from '@o2s/framework/headers'; import { Auth } from '@o2s/framework/modules'; import { Jwt } from './auth.model'; +const H = HeaderName; + @Injectable() export class RolesGuard implements Auth.Guards.RoleGuard { constructor( @@ -28,7 +31,7 @@ export class RolesGuard implements Auth.Guards.RoleGuard { const MatchingMode = roleMetadata.mode || Auth.Model.MatchingMode.ANY; const request = context.switchToHttp().getRequest(); - const authHeader = request.headers['authorization']; + const authHeader = request.headers[H.Authorization]; if (!authHeader) { throw new UnauthorizedException('Missing authorization token'); @@ -85,7 +88,7 @@ export class PermissionsGuard implements Auth.Guards.PermissionGuard { } const request = context.switchToHttp().getRequest(); - const authHeader = request.headers['authorization']; + const authHeader = request.headers[H.Authorization]; if (!authHeader) { throw new UnauthorizedException('Missing authorization token'); diff --git a/packages/integrations/mocked/src/modules/billing-accounts/billing-accounts.service.ts b/packages/integrations/mocked/src/modules/billing-accounts/billing-accounts.service.ts index 5e9ad3685..b17444725 100644 --- a/packages/integrations/mocked/src/modules/billing-accounts/billing-accounts.service.ts +++ b/packages/integrations/mocked/src/modules/billing-accounts/billing-accounts.service.ts @@ -7,7 +7,11 @@ import { mapBillingAccount, mapBillingAccounts } from './billing-accounts.mapper import { responseDelay } from '@/utils/delay'; @Injectable() -export class BillingAccountService implements BillingAccounts.Service { +export class BillingAccountService extends BillingAccounts.Service { + constructor() { + super(); + } + getBillingAccount( params: BillingAccounts.Request.GetBillingAccountParams, ): Observable<BillingAccounts.Model.BillingAccount> { diff --git a/packages/integrations/mocked/src/modules/cache/cache.service.ts b/packages/integrations/mocked/src/modules/cache/cache.service.ts index 9d51904c3..5ce95d928 100644 --- a/packages/integrations/mocked/src/modules/cache/cache.service.ts +++ b/packages/integrations/mocked/src/modules/cache/cache.service.ts @@ -3,7 +3,11 @@ import { Injectable } from '@nestjs/common'; import { Cache } from '@o2s/framework/modules'; @Injectable() -export class CacheService implements Cache.Service { +export class CacheService extends Cache.Service { + constructor() { + super(); + } + del(_key: string): Promise<void> { return Promise.resolve(undefined); } diff --git a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts index 1700f08d2..840943f36 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts @@ -1,7 +1,7 @@ import { Carts, Models, Products } from '@o2s/framework/modules'; import { getMockProviderById, getPaymentMethodDisplay } from '../payments/mocks/providers.mock'; -import { mapProductBySku } from '../products/products.mapper'; +import { mapProductBySku, mapProductByVariantId } from '../products/products.mapper'; // Read payment method stored in metadata by setPayment const mapPaymentMethodFromMetadata = (metadata: Record<string, unknown>): Carts.Model.PaymentMethod | undefined => { @@ -239,14 +239,20 @@ export const addCartItem = ( let product: Products.Model.Product; try { - product = mapProductBySku(data.sku, locale); + if (data.variantId) { + const found = mapProductByVariantId(data.variantId, locale); + if (!found) return undefined; + product = found; + } else { + product = mapProductBySku(data.sku, locale); + } } catch { return undefined; } const cart = cartsStore[cartIndex]!; - const existingIndex = cart.items.data.findIndex((item) => matchesSku(item, data.sku)); + const existingIndex = cart.items.data.findIndex((item) => matchesSku(item, product.sku)); if (existingIndex !== -1) { const item = cart.items.data[existingIndex]!; diff --git a/packages/integrations/mocked/src/modules/carts/carts.service.ts b/packages/integrations/mocked/src/modules/carts/carts.service.ts index 7dc154bf5..43a8f2b52 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.service.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.service.ts @@ -21,11 +21,13 @@ import { import { responseDelay } from '@/utils/delay'; @Injectable() -export class CartsService implements Carts.Service { +export class CartsService extends Carts.Service { constructor( private readonly authService: Auth.Service, private readonly customersService: Customers.Service, - ) {} + ) { + super(); + } getCart( params: Carts.Request.GetCartParams, @@ -33,8 +35,12 @@ export class CartsService implements Carts.Service { ): Observable<Carts.Model.Cart | undefined> { const cart = mapCart(params); + if (!cart) { + throw new NotFoundException('Cart not found'); + } + // Customer carts require authorization - if (cart?.customerId) { + if (cart.customerId) { if (!authorization) { throw new UnauthorizedException('Authentication required to access this cart'); } @@ -432,12 +438,16 @@ export class CartsService implements Carts.Service { return resolveAddresses$().pipe( switchMap(({ shippingAddress, billingAddress }) => { + const resolvedShippingAddress = + data.sameAsBillingAddress === true ? existingCart.billingAddress : shippingAddress; + const updateData: Carts.Request.UpdateCartBody = { notes: data.notes, email: data.email, metadata: { ...existingCart.metadata, - ...(shippingAddress && { shippingAddress }), + sameAsBillingAddress: data.sameAsBillingAddress ?? false, + ...(resolvedShippingAddress && { shippingAddress: resolvedShippingAddress }), ...(billingAddress && { billingAddress }), }, }; diff --git a/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts b/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts index dc13c1bf3..d8ca9d00f 100644 --- a/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts +++ b/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts @@ -79,11 +79,24 @@ export function mapCheckoutSummary( export function mapPlaceOrderResponse( order: Orders.Model.Order, - paymentSession?: Payments.Model.PaymentSession, + _paymentSession?: Payments.Model.PaymentSession, + locale?: string, ): Checkout.Model.PlaceOrderResponse { + // Localized order confirmation paths matching page definitions + const orderConfirmationPaths: Record<string, string> = { + en: '/order-confirmation', + de: '/bestellbestaetigung', + pl: '/potwierdzenie-zamowienia', + }; + + // Normalize locale (e.g., 'en-US' -> 'en', 'pl-PL' -> 'pl') + const normalizedLocale: string = (locale || 'en').toLowerCase().split('-')[0] || 'en'; + const basePath = orderConfirmationPaths[normalizedLocale] || orderConfirmationPaths.en; + return { order, - paymentRedirectUrl: paymentSession?.redirectUrl, + // For mocked integration, always redirect to localized order-confirmation page + paymentRedirectUrl: `${basePath}/${order.id}`, }; } diff --git a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts index 78cfce7b7..ad49cf6cd 100644 --- a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts @@ -4,17 +4,20 @@ import { map, switchMap } from 'rxjs/operators'; import { Carts, Checkout, Payments } from '@o2s/framework/modules'; +import { deleteCart } from '../carts/carts.mapper'; import { MOCKED_ORDERS, mapOrderFromCart } from '../orders/orders.mapper'; import { mapCheckoutSummary, mapPlaceOrderResponse, mapShippingOptions } from './checkout.mapper'; import { responseDelay } from '@/utils/delay'; @Injectable() -export class CheckoutService implements Checkout.Service { +export class CheckoutService extends Checkout.Service { constructor( private readonly cartsService: Carts.Service, private readonly paymentsService: Payments.Service, - ) {} + ) { + super(); + } setAddresses( params: Checkout.Request.SetAddressesParams, @@ -172,14 +175,15 @@ export class CheckoutService implements Checkout.Service { // Get email (from request body or cart) const email = data?.email || cart.email; - // Create order from cart + // Create order from cart and remove it from in-memory store const order = mapOrderFromCart(cart, email); MOCKED_ORDERS.push(order); + deleteCart({ id: params.cartId }); // Get payment session for redirect URL return this.paymentsService .getSession({ id: paymentSessionId }, authorization) - .pipe(map((session) => mapPlaceOrderResponse(order, session))); + .pipe(map((session) => mapPlaceOrderResponse(order, session, params.locale))); }), responseDelay(), ); diff --git a/packages/integrations/mocked/src/modules/cms/cms.service.ts b/packages/integrations/mocked/src/modules/cms/cms.service.ts index 3d1fa25fa..f779cfe6d 100644 --- a/packages/integrations/mocked/src/modules/cms/cms.service.ts +++ b/packages/integrations/mocked/src/modules/cms/cms.service.ts @@ -6,7 +6,12 @@ import { CMS } from '@o2s/framework/modules'; import { mapArticleListBlock } from './mappers/blocks/cms.article-list.mapper'; import { mapArticleSearchBlock } from './mappers/blocks/cms.article-search.mapper'; import { mapBentoGridBlock } from './mappers/blocks/cms.bento-grid.mapper'; +import { mapCartBlock } from './mappers/blocks/cms.cart.mapper'; import { mapCategoryListBlock } from './mappers/blocks/cms.category-list.mapper'; +import { mapCheckoutBillingPaymentBlock } from './mappers/blocks/cms.checkout-billing-payment.mapper'; +import { mapCheckoutCompanyDataBlock } from './mappers/blocks/cms.checkout-company-data.mapper'; +import { mapCheckoutShippingAddressBlock } from './mappers/blocks/cms.checkout-shipping-address.mapper'; +import { mapCheckoutSummaryBlock } from './mappers/blocks/cms.checkout-summary.mapper'; import { mapCtaSectionBlock } from './mappers/blocks/cms.cta-section.mapper'; import { mapDocumentListBlock } from './mappers/blocks/cms.document-list.mapper'; import { mapFaqBlock } from './mappers/blocks/cms.faq.mapper'; @@ -20,6 +25,7 @@ import { mapMediaSectionBlock } from './mappers/blocks/cms.media-section.mapper' import { mapNotificationDetailsBlock } from './mappers/blocks/cms.notification-details.mapper'; import { mapNotificationListBlock } from './mappers/blocks/cms.notification-list.mapper'; import { mapNotificationSummaryBlock } from './mappers/blocks/cms.notification-summary.mapper'; +import { mapOrderConfirmationBlock } from './mappers/blocks/cms.order-confirmation.mapper'; import { mapOrderDetailsBlock } from './mappers/blocks/cms.order-details.mapper'; import { mapOrderListBlock } from './mappers/blocks/cms.order-list.mapper'; import { mapOrdersSummaryBlock } from './mappers/blocks/cms.orders-summary.mapper'; @@ -52,7 +58,11 @@ import { mapSurvey } from './mappers/cms.survey.mapper'; import { responseDelay } from '@/utils/delay'; @Injectable() -export class CmsService implements CMS.Service { +export class CmsService extends CMS.Service { + constructor() { + super(); + } + getEntry<T>(_options: CMS.Request.GetCmsEntryParams) { return of<T>({} as T); } @@ -252,4 +262,28 @@ export class CmsService implements CMS.Service { getPricingSectionBlock(options: CMS.Request.GetCmsEntryParams) { return of(mapPricingSectionBlock(options)).pipe(responseDelay()); } + + getCartBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapCartBlock(options)).pipe(responseDelay()); + } + + getCheckoutCompanyDataBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapCheckoutCompanyDataBlock(options)).pipe(responseDelay()); + } + + getCheckoutShippingAddressBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapCheckoutShippingAddressBlock(options)).pipe(responseDelay()); + } + + getCheckoutBillingPaymentBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapCheckoutBillingPaymentBlock(options)).pipe(responseDelay()); + } + + getCheckoutSummaryBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapCheckoutSummaryBlock(options)).pipe(responseDelay()); + } + + getOrderConfirmationBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapOrderConfirmationBlock(options)).pipe(responseDelay()); + } } diff --git a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.cart.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.cart.mapper.ts new file mode 100644 index 000000000..7339a6c2f --- /dev/null +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.cart.mapper.ts @@ -0,0 +1,153 @@ +import { CMS } from '@o2s/framework/modules'; + +const MOCK_CART_BLOCK_EN: CMS.Model.CartBlock.CartBlock = { + id: 'cart-1', + title: 'Cart', + subtitle: 'Review and edit your order', + defaultCurrency: 'PLN', + labels: { + itemTotal: 'Total', + unknownProductName: 'Product', + }, + errors: { + loadError: 'Failed to load cart. Please try again.', + updateError: 'Failed to update cart. Please try again.', + }, + actions: { + increaseQuantity: 'Increase quantity', + decreaseQuantity: 'Decrease quantity', + quantity: 'Quantity', + remove: 'Remove', + }, + summaryLabels: { + title: 'Summary', + subtotalLabel: 'Subtotal', + taxLabel: 'VAT', + totalLabel: 'Total', + discountLabel: 'Discount', + shippingLabel: 'Shipping', + freeLabel: 'Free', + }, + checkoutButton: { + label: 'Proceed to checkout', + path: '/checkout/company-data', + icon: 'ShoppingCart', + }, + continueShopping: { + label: 'Continue shopping', + path: '/products', + }, + empty: { + title: 'Your cart is empty', + description: 'Add products to place an order', + continueShopping: { + label: 'Go to shop', + path: '/products', + }, + }, +}; + +const MOCK_CART_BLOCK_DE: CMS.Model.CartBlock.CartBlock = { + id: 'cart-1', + title: 'Warenkorb', + subtitle: 'Überprüfen und bearbeiten Sie Ihre Bestellung', + defaultCurrency: 'PLN', + labels: { + itemTotal: 'Summe', + unknownProductName: 'Produkt', + }, + errors: { + loadError: 'Der Warenkorb konnte nicht geladen werden. Bitte versuchen Sie es erneut.', + updateError: 'Der Warenkorb konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.', + }, + actions: { + increaseQuantity: 'Menge erhöhen', + decreaseQuantity: 'Menge verringern', + quantity: 'Menge', + remove: 'Entfernen', + }, + summaryLabels: { + title: 'Zusammenfassung', + subtotalLabel: 'Nettosumme', + taxLabel: 'MwSt.', + totalLabel: 'Bruttosumme', + discountLabel: 'Rabatt', + shippingLabel: 'Versand', + freeLabel: 'Kostenlos', + }, + checkoutButton: { + label: 'Zur Kasse', + path: '/kasse/firmendaten', + icon: 'ShoppingCart', + }, + continueShopping: { + label: 'Weiter einkaufen', + path: '/produkte', + }, + empty: { + title: 'Ihr Warenkorb ist leer', + description: 'Fügen Sie Produkte hinzu, um eine Bestellung aufzugeben', + continueShopping: { + label: 'Zum Shop', + path: '/produkte', + }, + }, +}; + +const MOCK_CART_BLOCK_PL: CMS.Model.CartBlock.CartBlock = { + id: 'cart-1', + title: 'Koszyk', + subtitle: 'Przejrzyj i edytuj swoje zamówienie', + defaultCurrency: 'PLN', + labels: { + itemTotal: 'Suma', + unknownProductName: 'Produkt', + }, + errors: { + loadError: 'Nie udało się załadować koszyka. Spróbuj ponownie.', + updateError: 'Nie udało się zaktualizować koszyka. Spróbuj ponownie.', + }, + actions: { + increaseQuantity: 'Zwiększ ilość', + decreaseQuantity: 'Zmniejsz ilość', + quantity: 'Ilość', + remove: 'Usuń', + }, + summaryLabels: { + title: 'Podsumowanie', + subtotalLabel: 'Suma netto', + taxLabel: 'VAT', + totalLabel: 'Suma brutto', + discountLabel: 'Rabat', + shippingLabel: 'Dostawa', + freeLabel: 'Gratis', + }, + checkoutButton: { + label: 'Przejdź do kasy', + path: '/zamowienie/dane-firmy', + icon: 'ShoppingCart', + }, + continueShopping: { + label: 'Kontynuuj zakupy', + path: '/produkty', + }, + empty: { + title: 'Twój koszyk jest pusty', + description: 'Dodaj produkty, aby złożyć zamówienie', + continueShopping: { + label: 'Przejdź do sklepu', + path: '/produkty', + }, + }, +}; + +export const mapCartBlock = (options: CMS.Request.GetCmsEntryParams): CMS.Model.CartBlock.CartBlock => { + switch (options.locale) { + case 'pl': + return MOCK_CART_BLOCK_PL; + case 'de': + return MOCK_CART_BLOCK_DE; + default: + return MOCK_CART_BLOCK_EN; + } +}; diff --git a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.checkout-billing-payment.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.checkout-billing-payment.mapper.ts new file mode 100644 index 000000000..603cff8df --- /dev/null +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.checkout-billing-payment.mapper.ts @@ -0,0 +1,125 @@ +import { CMS } from '@o2s/framework/modules'; + +const MOCK_CHECKOUT_BILLING_PAYMENT_EN: CMS.Model.CheckoutBillingPaymentBlock.CheckoutBillingPaymentBlock = { + id: 'checkout-billing-payment-1', + title: 'Payment', + subtitle: 'Select your payment method', + fields: { + paymentMethod: { + label: 'Payment method', + placeholder: 'Select payment method', + required: true, + }, + }, + buttons: { + back: { label: 'Back', path: '/checkout/shipping-address' }, + next: { label: 'Next', path: '/checkout/summary' }, + }, + errors: { + required: 'This field is required', + cartNotFound: 'Your cart is no longer available.', + submitError: 'Something went wrong. Please try again.', + }, + summaryLabels: { + title: 'Summary', + subtotalLabel: 'Subtotal', + taxLabel: 'VAT', + totalLabel: 'Total', + discountLabel: 'Discount', + shippingLabel: 'Shipping', + freeLabel: 'Free', + }, + stepIndicator: { + steps: ['Company details', 'Delivery', 'Payment', 'Summary'], + currentStep: 3, + }, + cartPath: '/cart', + orderConfirmationPath: '/order-confirmation', +}; + +const MOCK_CHECKOUT_BILLING_PAYMENT_DE: CMS.Model.CheckoutBillingPaymentBlock.CheckoutBillingPaymentBlock = { + id: 'checkout-billing-payment-1', + title: 'Zahlung', + subtitle: 'Wählen Sie Ihre Zahlungsmethode', + fields: { + paymentMethod: { + label: 'Zahlungsmethode', + placeholder: 'Zahlungsmethode wählen', + required: true, + }, + }, + buttons: { + back: { label: 'Zurück', path: '/kasse/lieferadresse' }, + next: { label: 'Weiter', path: '/kasse/zusammenfassung' }, + }, + errors: { + required: 'Dieses Feld ist erforderlich', + cartNotFound: 'Ihr Warenkorb ist nicht mehr verfügbar.', + submitError: 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.', + }, + summaryLabels: { + title: 'Zusammenfassung', + subtotalLabel: 'Nettosumme', + taxLabel: 'MwSt.', + totalLabel: 'Gesamt', + discountLabel: 'Rabatt', + shippingLabel: 'Versand', + freeLabel: 'Kostenlos', + }, + stepIndicator: { + steps: ['Firmendaten', 'Lieferung', 'Zahlung', 'Zusammenfassung'], + currentStep: 3, + }, + cartPath: '/warenkorb', + orderConfirmationPath: '/bestellbestaetigung', +}; + +const MOCK_CHECKOUT_BILLING_PAYMENT_PL: CMS.Model.CheckoutBillingPaymentBlock.CheckoutBillingPaymentBlock = { + id: 'checkout-billing-payment-1', + title: 'Płatność', + subtitle: 'Wybierz metodę płatności', + fields: { + paymentMethod: { + label: 'Metoda płatności', + placeholder: 'Wybierz metodę płatności', + required: true, + }, + }, + buttons: { + back: { label: 'Wstecz', path: '/zamowienie/adres-dostawy' }, + next: { label: 'Dalej', path: '/zamowienie/podsumowanie' }, + }, + errors: { + required: 'To pole jest wymagane', + cartNotFound: 'Twój koszyk jest niedostępny.', + submitError: 'Wystąpił błąd. Spróbuj ponownie.', + }, + summaryLabels: { + title: 'Podsumowanie', + subtotalLabel: 'Wartość netto', + taxLabel: 'VAT', + totalLabel: 'Razem', + discountLabel: 'Rabat', + shippingLabel: 'Dostawa', + freeLabel: 'Gratis', + }, + stepIndicator: { + steps: ['Dane firmy', 'Dostawa', 'Płatność', 'Podsumowanie'], + currentStep: 3, + }, + cartPath: '/koszyk', + orderConfirmationPath: '/potwierdzenie-zamowienia', +}; + +export const mapCheckoutBillingPaymentBlock = ( + options: CMS.Request.GetCmsEntryParams, +): CMS.Model.CheckoutBillingPaymentBlock.CheckoutBillingPaymentBlock => { + switch (options.locale) { + case 'pl': + return MOCK_CHECKOUT_BILLING_PAYMENT_PL; + case 'de': + return MOCK_CHECKOUT_BILLING_PAYMENT_DE; + default: + return MOCK_CHECKOUT_BILLING_PAYMENT_EN; + } +}; diff --git a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.checkout-company-data.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.checkout-company-data.mapper.ts new file mode 100644 index 000000000..46d383eb2 --- /dev/null +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.checkout-company-data.mapper.ts @@ -0,0 +1,329 @@ +import { CMS } from '@o2s/framework/modules'; + +const MOCK_CHECKOUT_COMPANY_DATA_EN: CMS.Model.CheckoutCompanyDataBlock.CheckoutCompanyDataBlock = { + id: 'checkout-company-data-1', + title: 'Company details', + subtitle: 'Fill in your company details', + fields: { + firstName: { + label: 'First name', + placeholder: 'e.g. John', + required: false, + }, + lastName: { + label: 'Last name', + placeholder: 'e.g. Doe', + required: false, + }, + email: { + label: 'Email', + placeholder: 'e.g. john@example.com', + required: true, + }, + phone: { + label: 'Phone', + placeholder: 'e.g. +48 123 456 789', + required: false, + }, + companyName: { + label: 'Company name', + placeholder: 'e.g. ACME Inc.', + required: true, + }, + taxId: { + label: 'Tax ID', + placeholder: 'XXXXXXXXXX', + required: true, + }, + notes: { + label: 'Order notes', + placeholder: 'Any additional information about your order...', + required: false, + }, + address: { + streetName: { + label: 'Street name', + placeholder: 'e.g. Main Street', + required: true, + }, + streetNumber: { + label: 'Number', + placeholder: 'e.g. 123', + required: true, + }, + apartment: { + label: 'Apartment / suite', + placeholder: 'e.g. 4B', + required: false, + }, + city: { + label: 'City', + placeholder: 'City', + required: true, + }, + postalCode: { + label: 'Postal code', + placeholder: 'XX-XXX', + required: true, + }, + country: { + label: 'Country', + placeholder: 'Country', + required: true, + }, + }, + }, + buttons: { + back: { label: 'Back to cart', path: '/cart' }, + next: { label: 'Next', path: '/checkout/shipping-address' }, + }, + errors: { + required: 'This field is required', + invalidTaxId: 'Invalid tax ID', + invalidPostalCode: 'Invalid postal code', + invalidEmail: 'Invalid email address', + cartNotFound: 'Your cart is no longer available.', + submitError: 'Something went wrong. Please try again.', + }, + summaryLabels: { + title: 'Summary', + subtotalLabel: 'Subtotal', + taxLabel: 'VAT', + totalLabel: 'Total', + discountLabel: 'Discount', + shippingLabel: 'Shipping', + freeLabel: 'Free', + }, + stepIndicator: { + steps: ['Company details', 'Delivery', 'Payment', 'Summary'], + currentStep: 1, + }, + billingInfoNote: { + icon: 'Info', + text: 'This address will appear on your invoice.', + }, + cartPath: '/cart', +}; + +const MOCK_CHECKOUT_COMPANY_DATA_DE: CMS.Model.CheckoutCompanyDataBlock.CheckoutCompanyDataBlock = { + id: 'checkout-company-data-1', + title: 'Firmendaten', + subtitle: 'Geben Sie Ihre Firmendaten ein', + fields: { + firstName: { + label: 'Vorname', + placeholder: 'z.B. Max', + required: false, + }, + lastName: { + label: 'Nachname', + placeholder: 'z.B. Mustermann', + required: false, + }, + email: { + label: 'E-Mail', + placeholder: 'z.B. max@beispiel.de', + required: true, + }, + phone: { + label: 'Telefon', + placeholder: 'z.B. +49 123 456 789', + required: false, + }, + companyName: { + label: 'Firmenname', + placeholder: 'z.B. ACME GmbH', + required: true, + }, + taxId: { + label: 'USt-IdNr. / Steuer-ID', + placeholder: 'XXXXXXXXXX', + required: true, + }, + notes: { + label: 'Bestellhinweise', + placeholder: 'Zusätzliche Informationen zu Ihrer Bestellung...', + required: false, + }, + address: { + streetName: { + label: 'Straße', + placeholder: 'z.B. Musterstraße', + required: true, + }, + streetNumber: { + label: 'Hausnummer', + placeholder: 'z.B. 1', + required: true, + }, + apartment: { + label: 'Zusatz', + placeholder: 'z.B. 2. OG', + required: false, + }, + city: { + label: 'Stadt', + placeholder: 'Berlin', + required: true, + }, + postalCode: { + label: 'Postleitzahl', + placeholder: 'XXXXX', + required: true, + }, + country: { + label: 'Land', + placeholder: 'Deutschland', + required: true, + }, + }, + }, + buttons: { + back: { label: 'Zurück zum Warenkorb', path: '/warenkorb' }, + next: { label: 'Weiter', path: '/kasse/lieferadresse' }, + }, + errors: { + required: 'Dieses Feld ist erforderlich', + invalidTaxId: 'Ungültige Steuer-ID', + invalidPostalCode: 'Ungültige Postleitzahl', + invalidEmail: 'Ungültige E-Mail-Adresse', + cartNotFound: 'Ihr Warenkorb ist nicht mehr verfügbar.', + submitError: 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.', + }, + summaryLabels: { + title: 'Zusammenfassung', + subtotalLabel: 'Nettosumme', + taxLabel: 'MwSt.', + totalLabel: 'Gesamt', + discountLabel: 'Rabatt', + shippingLabel: 'Versand', + freeLabel: 'Kostenlos', + }, + stepIndicator: { + steps: ['Firmendaten', 'Lieferung', 'Zahlung', 'Zusammenfassung'], + currentStep: 1, + }, + billingInfoNote: { + icon: 'Info', + text: 'Diese Adresse erscheint auf Ihrer Rechnung.', + }, + cartPath: '/warenkorb', +}; + +const MOCK_CHECKOUT_COMPANY_DATA_PL: CMS.Model.CheckoutCompanyDataBlock.CheckoutCompanyDataBlock = { + id: 'checkout-company-data-1', + title: 'Dane firmy', + subtitle: 'Wypełnij dane firmowe', + fields: { + firstName: { + label: 'Imię', + placeholder: 'np. Jan', + required: false, + }, + lastName: { + label: 'Nazwisko', + placeholder: 'np. Kowalski', + required: false, + }, + email: { + label: 'Email', + placeholder: 'np. jan@example.com', + required: true, + }, + phone: { + label: 'Telefon', + placeholder: 'np. +48 123 456 789', + required: false, + }, + companyName: { + label: 'Nazwa firmy', + placeholder: 'np. ACME Sp. z o.o.', + required: true, + }, + taxId: { + label: 'NIP', + placeholder: 'XXXXXXXXXX', + required: true, + }, + notes: { + label: 'Uwagi do zamówienia', + placeholder: 'Dodatkowe informacje do zamówienia...', + required: false, + }, + address: { + streetName: { + label: 'Ulica', + placeholder: 'np. ul. Przykładowa', + required: true, + }, + streetNumber: { + label: 'Numer', + placeholder: 'np. 1', + required: true, + }, + apartment: { + label: 'Lokal', + placeholder: 'np. 4B', + required: false, + }, + city: { + label: 'Miasto', + placeholder: 'Warszawa', + required: true, + }, + postalCode: { + label: 'Kod pocztowy', + placeholder: 'XX-XXX', + required: true, + }, + country: { + label: 'Kraj', + placeholder: 'Polska', + required: true, + }, + }, + }, + buttons: { + back: { label: 'Wróć do koszyka', path: '/koszyk' }, + next: { label: 'Dalej', path: '/zamowienie/adres-dostawy' }, + }, + errors: { + required: 'To pole jest wymagane', + invalidTaxId: 'Nieprawidłowy NIP', + invalidPostalCode: 'Nieprawidłowy kod pocztowy', + invalidEmail: 'Nieprawidłowy adres email', + cartNotFound: 'Twój koszyk jest niedostępny.', + submitError: 'Wystąpił błąd. Spróbuj ponownie.', + }, + summaryLabels: { + title: 'Podsumowanie', + subtotalLabel: 'Wartość netto', + taxLabel: 'VAT', + totalLabel: 'Razem', + discountLabel: 'Rabat', + shippingLabel: 'Dostawa', + freeLabel: 'Gratis', + }, + stepIndicator: { + steps: ['Dane firmy', 'Dostawa', 'Płatność', 'Podsumowanie'], + currentStep: 1, + }, + billingInfoNote: { + icon: 'Info', + text: 'Ten adres będzie widoczny na fakturze.', + }, + cartPath: '/koszyk', +}; + +export const mapCheckoutCompanyDataBlock = ( + options: CMS.Request.GetCmsEntryParams, +): CMS.Model.CheckoutCompanyDataBlock.CheckoutCompanyDataBlock => { + switch (options.locale) { + case 'pl': + return MOCK_CHECKOUT_COMPANY_DATA_PL; + case 'de': + return MOCK_CHECKOUT_COMPANY_DATA_DE; + default: + return MOCK_CHECKOUT_COMPANY_DATA_EN; + } +}; diff --git a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.checkout-shipping-address.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.checkout-shipping-address.mapper.ts new file mode 100644 index 000000000..15a73edc8 --- /dev/null +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.checkout-shipping-address.mapper.ts @@ -0,0 +1,272 @@ +import { CMS } from '@o2s/framework/modules'; + +const MOCK_CHECKOUT_SHIPPING_ADDRESS_EN: CMS.Model.CheckoutShippingAddressBlock.CheckoutShippingAddressBlock = { + id: 'checkout-shipping-address-1', + title: 'Shipping', + subtitle: 'Select shipping method and delivery address', + fields: { + sameAsBillingAddress: { + label: 'Same as billing address', + }, + firstName: { + label: 'First name', + placeholder: 'e.g. John', + required: false, + }, + lastName: { + label: 'Last name', + placeholder: 'e.g. Doe', + required: false, + }, + phone: { + label: 'Phone', + placeholder: 'e.g. +48 123 456 789', + required: false, + }, + address: { + streetName: { + label: 'Street name', + placeholder: 'e.g. Main Street', + required: true, + }, + streetNumber: { + label: 'Number', + placeholder: 'e.g. 123', + required: true, + }, + apartment: { + label: 'Apartment / suite', + placeholder: 'e.g. 4B', + required: false, + }, + city: { + label: 'City', + placeholder: 'City', + required: true, + }, + postalCode: { + label: 'Postal code', + placeholder: 'XX-XXX', + required: true, + }, + country: { + label: 'Country', + placeholder: 'Country', + required: true, + }, + }, + shippingMethod: { + label: 'Shipping method', + required: true, + }, + }, + buttons: { + back: { label: 'Back', path: '/checkout/company-data' }, + next: { label: 'Next', path: '/checkout/billing-payment' }, + }, + errors: { + required: 'This field is required', + invalidPostalCode: 'Invalid postal code', + cartNotFound: 'Your cart is no longer available.', + submitError: 'Something went wrong. Please try again.', + }, + summaryLabels: { + title: 'Summary', + subtotalLabel: 'Subtotal', + taxLabel: 'VAT', + totalLabel: 'Total', + discountLabel: 'Discount', + shippingLabel: 'Shipping', + freeLabel: 'Free', + }, + stepIndicator: { + steps: ['Company details', 'Delivery', 'Payment', 'Summary'], + currentStep: 2, + }, + cartPath: '/cart', +}; + +const MOCK_CHECKOUT_SHIPPING_ADDRESS_DE: CMS.Model.CheckoutShippingAddressBlock.CheckoutShippingAddressBlock = { + id: 'checkout-shipping-address-1', + title: 'Lieferung', + subtitle: 'Wählen Sie Versandart und Lieferadresse', + fields: { + sameAsBillingAddress: { + label: 'Gleich wie Rechnungsadresse', + }, + firstName: { + label: 'Vorname', + placeholder: 'z.B. Max', + required: false, + }, + lastName: { + label: 'Nachname', + placeholder: 'z.B. Mustermann', + required: false, + }, + phone: { + label: 'Telefon', + placeholder: 'z.B. +49 123 456 789', + required: false, + }, + address: { + streetName: { + label: 'Straße', + placeholder: 'z.B. Musterstraße', + required: true, + }, + streetNumber: { + label: 'Hausnummer', + placeholder: 'z.B. 1', + required: true, + }, + apartment: { + label: 'Zusatz', + placeholder: 'z.B. 2. OG', + required: false, + }, + city: { + label: 'Stadt', + placeholder: 'Berlin', + required: true, + }, + postalCode: { + label: 'Postleitzahl', + placeholder: 'XXXXX', + required: true, + }, + country: { + label: 'Land', + placeholder: 'Deutschland', + required: true, + }, + }, + shippingMethod: { + label: 'Versandart', + required: true, + }, + }, + buttons: { + back: { label: 'Zurück', path: '/kasse/firmendaten' }, + next: { label: 'Weiter', path: '/kasse/rechnung-zahlung' }, + }, + errors: { + required: 'Dieses Feld ist erforderlich', + invalidPostalCode: 'Ungültige Postleitzahl', + cartNotFound: 'Ihr Warenkorb ist nicht mehr verfügbar.', + submitError: 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.', + }, + summaryLabels: { + title: 'Zusammenfassung', + subtotalLabel: 'Nettosumme', + taxLabel: 'MwSt.', + totalLabel: 'Gesamt', + discountLabel: 'Rabatt', + shippingLabel: 'Versand', + freeLabel: 'Kostenlos', + }, + stepIndicator: { + steps: ['Firmendaten', 'Lieferung', 'Zahlung', 'Zusammenfassung'], + currentStep: 2, + }, + cartPath: '/warenkorb', +}; + +const MOCK_CHECKOUT_SHIPPING_ADDRESS_PL: CMS.Model.CheckoutShippingAddressBlock.CheckoutShippingAddressBlock = { + id: 'checkout-shipping-address-1', + title: 'Dostawa', + subtitle: 'Wybierz metodę dostawy i adres', + fields: { + sameAsBillingAddress: { + label: 'Ten sam adres co adres rozliczeniowy', + }, + firstName: { + label: 'Imię', + placeholder: 'np. Jan', + required: false, + }, + lastName: { + label: 'Nazwisko', + placeholder: 'np. Kowalski', + required: false, + }, + phone: { + label: 'Telefon', + placeholder: 'np. +48 123 456 789', + required: false, + }, + address: { + streetName: { + label: 'Ulica', + placeholder: 'np. ul. Przykładowa', + required: true, + }, + streetNumber: { + label: 'Numer', + placeholder: 'np. 1', + required: true, + }, + apartment: { + label: 'Lokal', + placeholder: 'np. 4B', + required: false, + }, + city: { + label: 'Miasto', + placeholder: 'Warszawa', + required: true, + }, + postalCode: { + label: 'Kod pocztowy', + placeholder: 'XX-XXX', + required: true, + }, + country: { + label: 'Kraj', + placeholder: 'Polska', + required: true, + }, + }, + shippingMethod: { + label: 'Metoda dostawy', + required: true, + }, + }, + buttons: { + back: { label: 'Wstecz', path: '/zamowienie/dane-firmy' }, + next: { label: 'Dalej', path: '/zamowienie/platnosc' }, + }, + errors: { + required: 'To pole jest wymagane', + invalidPostalCode: 'Nieprawidłowy kod pocztowy', + cartNotFound: 'Twój koszyk jest niedostępny.', + submitError: 'Wystąpił błąd. Spróbuj ponownie.', + }, + summaryLabels: { + title: 'Podsumowanie', + subtotalLabel: 'Wartość netto', + taxLabel: 'VAT', + totalLabel: 'Razem', + discountLabel: 'Rabat', + shippingLabel: 'Dostawa', + freeLabel: 'Gratis', + }, + stepIndicator: { + steps: ['Dane firmy', 'Dostawa', 'Płatność', 'Podsumowanie'], + currentStep: 2, + }, + cartPath: '/koszyk', +}; + +export const mapCheckoutShippingAddressBlock = ( + options: CMS.Request.GetCmsEntryParams, +): CMS.Model.CheckoutShippingAddressBlock.CheckoutShippingAddressBlock => { + switch (options.locale) { + case 'pl': + return MOCK_CHECKOUT_SHIPPING_ADDRESS_PL; + case 'de': + return MOCK_CHECKOUT_SHIPPING_ADDRESS_DE; + default: + return MOCK_CHECKOUT_SHIPPING_ADDRESS_EN; + } +}; diff --git a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.checkout-summary.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.checkout-summary.mapper.ts new file mode 100644 index 000000000..c929543ed --- /dev/null +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.checkout-summary.mapper.ts @@ -0,0 +1,201 @@ +import { CMS } from '@o2s/framework/modules'; + +const MOCK_CHECKOUT_SUMMARY_EN: CMS.Model.CheckoutSummaryBlock.CheckoutSummaryBlock = { + id: 'checkout-summary-1', + title: 'Order summary', + subtitle: 'Review and place your order', + sections: { + products: { + title: 'Products', + labels: { quantity: 'Quantity', price: 'Unit price', total: 'Total' }, + }, + company: { + title: 'Company details', + companyNameLabel: 'Company name', + taxIdLabel: 'Tax ID', + addressLabel: 'Billing address', + }, + shipping: { + title: 'Shipping', + addressLabel: 'Address', + methodLabel: 'Shipping method:', + }, + billing: { + title: 'Payment', + addressLabel: 'Billing address', + methodLabel: 'Payment method:', + }, + summary: { + title: 'Summary', + subtotalLabel: 'Subtotal', + taxLabel: 'VAT', + discountLabel: 'Discount', + shippingLabel: 'Shipping', + freeLabel: 'Free', + totalLabel: 'Total', + activePromoCodesTitle: 'Active discount codes', + notesTitle: 'Notes', + }, + }, + errors: { + cartNotFound: 'Your cart is no longer available.', + placeOrderError: 'Something went wrong while placing your order. Please try again.', + loadError: 'Something went wrong while loading the summary. Please complete the previous steps and try again.', + }, + buttons: { + confirm: { label: 'Place order', path: '/order-confirmation' }, + back: { label: 'Back', path: '/checkout/billing-payment' }, + }, + loading: { + confirming: 'Placing order...', + }, + placeholders: { + companyData: 'Company details not provided', + shippingAddress: 'Shipping address not provided', + sameAsBillingAddress: 'Same as billing address', + billingAddress: 'Billing address not provided', + }, + stepIndicator: { + steps: ['Company details', 'Delivery', 'Payment', 'Summary'], + currentStep: 4, + }, + cartPath: '/cart', +}; + +const MOCK_CHECKOUT_SUMMARY_DE: CMS.Model.CheckoutSummaryBlock.CheckoutSummaryBlock = { + id: 'checkout-summary-1', + title: 'Bestellübersicht', + subtitle: 'Überprüfen und bestellen', + sections: { + products: { + title: 'Produkte', + labels: { quantity: 'Menge', price: 'Stückpreis', total: 'Gesamt' }, + }, + company: { + title: 'Firmendaten', + companyNameLabel: 'Firmenname', + taxIdLabel: 'Steuer-ID', + addressLabel: 'Rechnungsadresse', + }, + shipping: { + title: 'Lieferung', + addressLabel: 'Adresse', + methodLabel: 'Versandart:', + }, + billing: { + title: 'Zahlung', + addressLabel: 'Rechnungsadresse', + methodLabel: 'Zahlungsmethode:', + }, + summary: { + title: 'Zusammenfassung', + subtotalLabel: 'Nettosumme', + taxLabel: 'MwSt.', + discountLabel: 'Rabatt', + shippingLabel: 'Versand', + freeLabel: 'Kostenlos', + totalLabel: 'Gesamt', + activePromoCodesTitle: 'Aktive Rabattcodes', + notesTitle: 'Anmerkungen', + }, + }, + errors: { + cartNotFound: 'Ihr Warenkorb ist nicht mehr verfügbar.', + placeOrderError: 'Beim Aufgeben der Bestellung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.', + loadError: + 'Beim Laden der Zusammenfassung ist ein Fehler aufgetreten. Bitte schließen Sie die vorherigen Schritte ab und versuchen Sie es erneut.', + }, + buttons: { + confirm: { label: 'Bestellung aufgeben', path: '/bestellbestaetigung' }, + back: { label: 'Zurück', path: '/kasse/rechnung-zahlung' }, + }, + loading: { + confirming: 'Bestellung wird aufgegeben...', + }, + placeholders: { + companyData: 'Firmendaten nicht angegeben', + shippingAddress: 'Lieferadresse nicht angegeben', + sameAsBillingAddress: 'Gleich wie Rechnungsadresse', + billingAddress: 'Rechnungsadresse nicht angegeben', + }, + stepIndicator: { + steps: ['Firmendaten', 'Lieferung', 'Zahlung', 'Zusammenfassung'], + currentStep: 4, + }, + cartPath: '/warenkorb', +}; + +const MOCK_CHECKOUT_SUMMARY_PL: CMS.Model.CheckoutSummaryBlock.CheckoutSummaryBlock = { + id: 'checkout-summary-1', + title: 'Podsumowanie zamówienia', + subtitle: 'Sprawdź i złóż zamówienie', + sections: { + products: { + title: 'Produkty', + labels: { quantity: 'Ilość', price: 'Cena jedn.', total: 'Razem' }, + }, + company: { + title: 'Dane firmy', + companyNameLabel: 'Nazwa firmy', + taxIdLabel: 'NIP', + addressLabel: 'Adres rozliczeniowy', + }, + shipping: { + title: 'Dostawa', + addressLabel: 'Adres', + methodLabel: 'Metoda dostawy:', + }, + billing: { + title: 'Płatność', + addressLabel: 'Adres rozliczeniowy', + methodLabel: 'Metoda płatności:', + }, + summary: { + title: 'Podsumowanie', + subtotalLabel: 'Wartość netto', + taxLabel: 'VAT', + discountLabel: 'Rabat', + shippingLabel: 'Dostawa', + freeLabel: 'Gratis', + totalLabel: 'Razem', + activePromoCodesTitle: 'Aktywne kody rabatowe', + notesTitle: 'Uwagi', + }, + }, + errors: { + cartNotFound: 'Twój koszyk jest niedostępny.', + placeOrderError: 'Wystąpił błąd podczas składania zamówienia. Spróbuj ponownie.', + loadError: 'Wystąpił błąd podczas ładowania podsumowania. Uzupełnij poprzednie kroki i spróbuj ponownie.', + }, + buttons: { + confirm: { label: 'Złóż zamówienie', path: '/potwierdzenie-zamowienia' }, + back: { label: 'Wstecz', path: '/zamowienie/platnosc' }, + }, + loading: { + confirming: 'Składanie zamówienia...', + }, + placeholders: { + companyData: 'Dane firmy nie zostały podane', + shippingAddress: 'Adres dostawy nie został podany', + sameAsBillingAddress: 'Ten sam adres co rozliczeniowy', + billingAddress: 'Adres rozliczeniowy nie został podany', + }, + stepIndicator: { + steps: ['Dane firmy', 'Dostawa', 'Płatność', 'Podsumowanie'], + currentStep: 4, + }, + cartPath: '/koszyk', +}; + +export const mapCheckoutSummaryBlock = ( + options: CMS.Request.GetCmsEntryParams, +): CMS.Model.CheckoutSummaryBlock.CheckoutSummaryBlock => { + switch (options.locale) { + case 'pl': + return MOCK_CHECKOUT_SUMMARY_PL; + case 'de': + return MOCK_CHECKOUT_SUMMARY_DE; + default: + return MOCK_CHECKOUT_SUMMARY_EN; + } +}; diff --git a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.order-confirmation.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.order-confirmation.mapper.ts new file mode 100644 index 000000000..ef7a8c959 --- /dev/null +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.order-confirmation.mapper.ts @@ -0,0 +1,149 @@ +import { CMS } from '@o2s/framework/modules'; + +const MOCK_ORDER_CONFIRMATION_EN: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock = { + id: 'order-confirmation-1', + title: 'Order placed successfully!', + subtitle: 'Thank you for your order', + orderNumberLabel: 'Order number:', + productsTitle: 'Products', + productsCountLabel: 'pcs', + summaryTitle: 'Order summary', + subtotalLabel: 'Subtotal:', + taxLabel: 'VAT:', + discountLabel: 'Discount:', + shippingLabel: 'Shipping:', + totalLabel: 'Total:', + shippingSection: { + title: 'Shipping', + addressLabel: 'Address', + methodLabel: 'Method', + }, + billingSection: { + title: 'Payment', + addressLabel: 'Address', + taxIdLabel: 'Tax ID', + }, + message: 'Order confirmation has been sent to your email address.', + buttons: { + viewOrders: 'View orders', + continueShopping: 'Continue shopping', + }, + viewOrdersPath: '/orders', + continueShoppingPath: '/products', + statusLabels: { + PENDING: 'Pending', + COMPLETED: 'Completed', + SHIPPED: 'Shipped', + CANCELLED: 'Cancelled', + ARCHIVED: 'Archived', + REQUIRES_ACTION: 'Requires action', + UNKNOWN: 'Unknown', + }, + errors: { + loadError: 'Failed to load order. Please try again.', + orderNotFound: 'Order not found or unavailable.', + }, +}; + +const MOCK_ORDER_CONFIRMATION_DE: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock = { + id: 'order-confirmation-1', + title: 'Bestellung wurde aufgegeben!', + subtitle: 'Vielen Dank für Ihren Einkauf', + orderNumberLabel: 'Bestellnummer:', + productsTitle: 'Produkte', + productsCountLabel: 'Stk.', + summaryTitle: 'Bestellübersicht', + subtotalLabel: 'Nettosumme:', + taxLabel: 'MwSt.:', + discountLabel: 'Rabatt:', + shippingLabel: 'Versand:', + totalLabel: 'Bruttosumme:', + shippingSection: { + title: 'Lieferung', + addressLabel: 'Adresse', + methodLabel: 'Methode', + }, + billingSection: { + title: 'Zahlung', + addressLabel: 'Adresse', + taxIdLabel: 'Steuer-ID', + }, + message: 'Die Bestellbestätigung wurde an Ihre E-Mail-Adresse gesendet.', + buttons: { + viewOrders: 'Bestellungen', + continueShopping: 'Weiter einkaufen', + }, + viewOrdersPath: '/bestellungen', + continueShoppingPath: '/produkte', + statusLabels: { + PENDING: 'Ausstehend', + COMPLETED: 'Abgeschlossen', + SHIPPED: 'Versandt', + CANCELLED: 'Storniert', + ARCHIVED: 'Archiviert', + REQUIRES_ACTION: 'Aktion erforderlich', + UNKNOWN: 'Unbekannt', + }, + errors: { + loadError: 'Bestellung konnte nicht geladen werden. Bitte versuchen Sie es erneut.', + orderNotFound: 'Bestellung nicht gefunden oder nicht verfügbar.', + }, +}; + +const MOCK_ORDER_CONFIRMATION_PL: CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock = { + id: 'order-confirmation-1', + title: 'Zamówienie zostało złożone!', + subtitle: 'Dziękujemy za zakupy', + orderNumberLabel: 'Numer zamówienia:', + productsTitle: 'Produkty', + productsCountLabel: 'szt.', + summaryTitle: 'Podsumowanie zamówienia', + subtotalLabel: 'Wartość netto:', + taxLabel: 'VAT:', + discountLabel: 'Rabat:', + shippingLabel: 'Dostawa:', + totalLabel: 'Wartość brutto:', + shippingSection: { + title: 'Dostawa', + addressLabel: 'Adres', + methodLabel: 'Metoda', + }, + billingSection: { + title: 'Płatność', + addressLabel: 'Adres', + taxIdLabel: 'NIP', + }, + message: 'Potwierdzenie zamówienia zostało wysłane na Twój adres email.', + buttons: { + viewOrders: 'Lista zamówień', + continueShopping: 'Kontynuuj zakupy', + }, + viewOrdersPath: '/zamowienia', + continueShoppingPath: '/produkty', + statusLabels: { + PENDING: 'Oczekujące', + COMPLETED: 'Zrealizowane', + SHIPPED: 'Wysłane', + CANCELLED: 'Anulowane', + ARCHIVED: 'Zarchiwizowane', + REQUIRES_ACTION: 'Wymaga działania', + UNKNOWN: 'Nieznany', + }, + errors: { + loadError: 'Nie udało się załadować zamówienia. Spróbuj ponownie.', + orderNotFound: 'Zamówienie nie zostało znalezione lub jest niedostępne.', + }, +}; + +export const mapOrderConfirmationBlock = ( + options: CMS.Request.GetCmsEntryParams, +): CMS.Model.OrderConfirmationBlock.OrderConfirmationBlock => { + switch (options.locale) { + case 'pl': + return MOCK_ORDER_CONFIRMATION_PL; + case 'de': + return MOCK_ORDER_CONFIRMATION_DE; + default: + return MOCK_ORDER_CONFIRMATION_EN; + } +}; diff --git a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts index 5f98dd128..94fd9ab8b 100644 --- a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts @@ -3,7 +3,6 @@ import { CMS } from '@o2s/framework/modules'; const MOCK_PRODUCT_DETAILS_BLOCK_EN: CMS.Model.ProductDetailsBlock.ProductDetailsBlock = { id: 'product-details-1', labels: { - actionButtonLabel: 'Request Quote', specificationsTitle: 'Details', descriptionTitle: 'Description', downloadLabel: 'Download Brochure', @@ -11,8 +10,13 @@ const MOCK_PRODUCT_DETAILS_BLOCK_EN: CMS.Model.ProductDetailsBlock.ProductDetail offerLabel: 'Offer', variantLabel: 'Variant', outOfStockLabel: 'Out of Stock', + addToCartLabel: 'Add to Cart', + addToCartSuccess: '{productName} added to cart', + addToCartError: 'Failed to add product to cart', + viewCartLabel: 'View Cart', }, basePath: '/products', + cartPath: '/cart', attributes: [ { key: 'weight', label: 'Weight (kg)', showInKeySpecs: true, icon: 'Weight' }, { key: 'height', label: 'Height (cm)', showInKeySpecs: true, icon: 'Ruler' }, @@ -31,7 +35,6 @@ const MOCK_PRODUCT_DETAILS_BLOCK_EN: CMS.Model.ProductDetailsBlock.ProductDetail const MOCK_PRODUCT_DETAILS_BLOCK_DE: CMS.Model.ProductDetailsBlock.ProductDetailsBlock = { id: 'product-details-1', labels: { - actionButtonLabel: 'Angebot anfordern', specificationsTitle: 'Produktdetails', descriptionTitle: 'Beschreibung', downloadLabel: 'Broschüre herunterladen', @@ -39,8 +42,13 @@ const MOCK_PRODUCT_DETAILS_BLOCK_DE: CMS.Model.ProductDetailsBlock.ProductDetail offerLabel: 'Angebot', variantLabel: 'Variante', outOfStockLabel: 'Nicht auf Lager', + addToCartLabel: 'In den Warenkorb', + addToCartSuccess: '{productName} zum Warenkorb hinzugefügt', + addToCartError: 'Fehler beim Hinzufügen zum Warenkorb', + viewCartLabel: 'Warenkorb anzeigen', }, basePath: '/produkte', + cartPath: '/warenkorb', attributes: [ { key: 'weight', label: 'Gewicht (kg)', showInKeySpecs: true, icon: 'Weight' }, { key: 'height', label: 'Höhe (cm)', showInKeySpecs: true, icon: 'Ruler' }, @@ -59,7 +67,6 @@ const MOCK_PRODUCT_DETAILS_BLOCK_DE: CMS.Model.ProductDetailsBlock.ProductDetail const MOCK_PRODUCT_DETAILS_BLOCK_PL: CMS.Model.ProductDetailsBlock.ProductDetailsBlock = { id: 'product-details-1', labels: { - actionButtonLabel: 'Zapytaj o ofertę', specificationsTitle: 'Szczegóły', descriptionTitle: 'Opis', downloadLabel: 'Pobierz broszurę', @@ -67,8 +74,13 @@ const MOCK_PRODUCT_DETAILS_BLOCK_PL: CMS.Model.ProductDetailsBlock.ProductDetail offerLabel: 'Oferta', variantLabel: 'Wariant', outOfStockLabel: 'Brak na magazynie', + addToCartLabel: 'Dodaj do koszyka', + addToCartSuccess: '{productName} dodany do koszyka', + addToCartError: 'Nie udało się dodać produktu do koszyka', + viewCartLabel: 'Zobacz koszyk', }, basePath: '/produkty', + cartPath: '/koszyk', attributes: [ { key: 'weight', label: 'Waga (kg)', showInKeySpecs: true, icon: 'Weight' }, { key: 'height', label: 'Wysokość (cm)', showInKeySpecs: true, icon: 'Ruler' }, diff --git a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts index c7b0194b0..a243a1ea5 100644 --- a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts @@ -107,7 +107,12 @@ const MOCK_PRODUCT_LIST_BLOCK_EN: CMS.Model.ProductListBlock.ProductListBlock = showMoreFilters: 'Show more filters', hideMoreFilters: 'Hide more filters', noActiveFilters: 'No active filters', + addToCartLabel: 'Add to Cart', + addToCartSuccess: '{productName} added to cart', + addToCartError: 'Failed to add product to cart', + viewCartLabel: 'View Cart', }, + cartPath: '/cart', }; const MOCK_PRODUCT_LIST_BLOCK_DE: CMS.Model.ProductListBlock.ProductListBlock = { @@ -217,7 +222,12 @@ const MOCK_PRODUCT_LIST_BLOCK_DE: CMS.Model.ProductListBlock.ProductListBlock = showMoreFilters: 'Mehr Filter anzeigen', hideMoreFilters: 'Mehr Filter ausblenden', noActiveFilters: 'Keine aktiven Filter', + addToCartLabel: 'In den Warenkorb', + addToCartSuccess: '{productName} zum Warenkorb hinzugefügt', + addToCartError: 'Fehler beim Hinzufügen zum Warenkorb', + viewCartLabel: 'Warenkorb anzeigen', }, + cartPath: '/warenkorb', }; const MOCK_PRODUCT_LIST_BLOCK_PL: CMS.Model.ProductListBlock.ProductListBlock = { @@ -327,7 +337,12 @@ const MOCK_PRODUCT_LIST_BLOCK_PL: CMS.Model.ProductListBlock.ProductListBlock = showMoreFilters: 'Pokaż więcej filtrów', hideMoreFilters: 'Ukryj więcej filtrów', noActiveFilters: 'Brak aktywnych filtrów', + addToCartLabel: 'Dodaj do koszyka', + addToCartSuccess: '{productName} dodany do koszyka', + addToCartError: 'Nie udało się dodać produktu do koszyka', + viewCartLabel: 'Zobacz koszyk', }, + cartPath: '/koszyk', }; export const mapProductListBlock = (locale: string): CMS.Model.ProductListBlock.ProductListBlock => { diff --git a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts index 892c1b848..0f8b220bf 100644 --- a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts @@ -6,7 +6,12 @@ const MOCK_RECOMMENDED_PRODUCTS_BLOCK_EN: CMS.Model.RecommendedProductsBlock.Rec labels: { title: 'Recommended Products', detailsLabel: 'Details', + addToCartLabel: 'Add to Cart', + addToCartSuccess: '{productName} added to cart', + addToCartError: 'Failed to add product to cart', + viewCartLabel: 'View Cart', }, + cartPath: '/cart', }; const MOCK_RECOMMENDED_PRODUCTS_BLOCK_DE: CMS.Model.RecommendedProductsBlock.RecommendedProductsBlock = { @@ -15,7 +20,12 @@ const MOCK_RECOMMENDED_PRODUCTS_BLOCK_DE: CMS.Model.RecommendedProductsBlock.Rec labels: { title: 'Empfohlene Produkte', detailsLabel: 'Details', + addToCartLabel: 'In den Warenkorb', + addToCartSuccess: '{productName} zum Warenkorb hinzugefügt', + addToCartError: 'Fehler beim Hinzufügen zum Warenkorb', + viewCartLabel: 'Warenkorb anzeigen', }, + cartPath: '/warenkorb', }; const MOCK_RECOMMENDED_PRODUCTS_BLOCK_PL: CMS.Model.RecommendedProductsBlock.RecommendedProductsBlock = { @@ -24,7 +34,12 @@ const MOCK_RECOMMENDED_PRODUCTS_BLOCK_PL: CMS.Model.RecommendedProductsBlock.Rec labels: { title: 'Rekomendowane produkty', detailsLabel: 'Szczegóły', + addToCartLabel: 'Dodaj do koszyka', + addToCartSuccess: '{productName} dodany do koszyka', + addToCartError: 'Nie udało się dodać produktu do koszyka', + viewCartLabel: 'Zobacz koszyk', }, + cartPath: '/koszyk', }; export const mapRecommendedProductsBlock = ( diff --git a/packages/integrations/mocked/src/modules/cms/mappers/cms.header.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/cms.header.mapper.ts index 2368d319c..b60a4c96d 100644 --- a/packages/integrations/mocked/src/modules/cms/mappers/cms.header.mapper.ts +++ b/packages/integrations/mocked/src/modules/cms/mappers/cms.header.mapper.ts @@ -91,6 +91,10 @@ const MOCK_HEADER_LOGON_EN: CMS.Model.Header.Header = { url: '/notifications', label: 'Notifications', }, + cart: { + url: '/cart', + label: 'Cart', + }, contextSwitcher: { closeLabel: 'Close', showContextSwitcher: true, @@ -185,6 +189,10 @@ const MOCK_HEADER_LOGON_DE: CMS.Model.Header.Header = { url: '/benachrichtigungen', label: 'Benachrichtigungen', }, + cart: { + url: '/warenkorb', + label: 'Warenkorb', + }, contextSwitcher: { closeLabel: 'Schließen', showContextSwitcher: true, @@ -279,6 +287,10 @@ const MOCK_HEADER_LOGON_PL: CMS.Model.Header.Header = { url: '/powiadomienia', label: 'Powiadomienia', }, + cart: { + url: '/koszyk', + label: 'Koszyk', + }, contextSwitcher: { closeLabel: 'Zamknij', showContextSwitcher: true, diff --git a/packages/integrations/mocked/src/modules/cms/mappers/cms.page.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/cms.page.mapper.ts index 69787d20c..92b0db878 100644 --- a/packages/integrations/mocked/src/modules/cms/mappers/cms.page.mapper.ts +++ b/packages/integrations/mocked/src/modules/cms/mappers/cms.page.mapper.ts @@ -1,5 +1,6 @@ import { CMS } from '@o2s/framework/modules'; +import { PAGE_CART_DE, PAGE_CART_EN, PAGE_CART_PL } from './mocks/pages/cart.page'; import { PAGE_ACCESSORIES_DE, PAGE_ACCESSORIES_EN, @@ -23,6 +24,20 @@ import { PAGE_ZENDESK_WARRANTY_AND_REPAIR_EN, PAGE_ZENDESK_WARRANTY_AND_REPAIR_PL, } from './mocks/pages/category.page'; +import { + PAGE_CHECKOUT_BILLING_PAYMENT_DE, + PAGE_CHECKOUT_BILLING_PAYMENT_EN, + PAGE_CHECKOUT_BILLING_PAYMENT_PL, + PAGE_CHECKOUT_COMPANY_DATA_DE, + PAGE_CHECKOUT_COMPANY_DATA_EN, + PAGE_CHECKOUT_COMPANY_DATA_PL, + PAGE_CHECKOUT_SHIPPING_ADDRESS_DE, + PAGE_CHECKOUT_SHIPPING_ADDRESS_EN, + PAGE_CHECKOUT_SHIPPING_ADDRESS_PL, + PAGE_CHECKOUT_SUMMARY_DE, + PAGE_CHECKOUT_SUMMARY_EN, + PAGE_CHECKOUT_SUMMARY_PL, +} from './mocks/pages/checkout.page'; import { PAGE_DASHBOARD_DE, PAGE_DASHBOARD_EN, PAGE_DASHBOARD_PL } from './mocks/pages/dashboard.page'; import { PAGE_INVOICE_LIST_DE, PAGE_INVOICE_LIST_EN, PAGE_INVOICE_LIST_PL } from './mocks/pages/invoice-list.page'; import { @@ -40,6 +55,11 @@ import { PAGE_NOTIFICATION_LIST_EN, PAGE_NOTIFICATION_LIST_PL, } from './mocks/pages/notification-list.page'; +import { + PAGE_ORDER_CONFIRMATION_DE, + PAGE_ORDER_CONFIRMATION_EN, + PAGE_ORDER_CONFIRMATION_PL, +} from './mocks/pages/order-confirmation.page'; import { PAGE_ORDER_DETAILS_DE, PAGE_ORDER_DETAILS_EN, PAGE_ORDER_DETAILS_PL } from './mocks/pages/order-details.page'; import { PAGE_ORDER_LIST_DE, PAGE_ORDER_LIST_EN, PAGE_ORDER_LIST_PL } from './mocks/pages/order-list.page'; import { @@ -224,6 +244,60 @@ export const mapPage = (slug: string, locale: string): CMS.Model.Page.Page | und updatedAt: '2025-01-01', }; + case '/cart': + return PAGE_CART_EN; + case '/warenkorb': + return PAGE_CART_DE; + case '/koszyk': + return PAGE_CART_PL; + + case '/checkout/company-data': + return PAGE_CHECKOUT_COMPANY_DATA_EN; + case '/kasse/firmendaten': + return PAGE_CHECKOUT_COMPANY_DATA_DE; + case '/zamowienie/dane-firmy': + return PAGE_CHECKOUT_COMPANY_DATA_PL; + + case '/checkout/shipping-address': + return PAGE_CHECKOUT_SHIPPING_ADDRESS_EN; + case '/kasse/lieferadresse': + return PAGE_CHECKOUT_SHIPPING_ADDRESS_DE; + case '/zamowienie/adres-dostawy': + return PAGE_CHECKOUT_SHIPPING_ADDRESS_PL; + + case '/checkout/billing-payment': + return PAGE_CHECKOUT_BILLING_PAYMENT_EN; + case '/kasse/rechnung-zahlung': + return PAGE_CHECKOUT_BILLING_PAYMENT_DE; + case '/zamowienie/platnosc': + return PAGE_CHECKOUT_BILLING_PAYMENT_PL; + + case '/checkout/summary': + return PAGE_CHECKOUT_SUMMARY_EN; + case '/kasse/zusammenfassung': + return PAGE_CHECKOUT_SUMMARY_DE; + case '/zamowienie/podsumowanie': + return PAGE_CHECKOUT_SUMMARY_PL; + + case slug.match(/\/order-confirmation\/.+/)?.[0]: + case slug.match(/\/potwierdzenie-zamowienia\/.+/)?.[0]: + case slug.match(/\/bestellbestaetigung\/.+/)?.[0]: { + const orderId = slug.match(/(.+)\/(.+)/)?.[2] ?? ''; + const page = + locale === 'pl' + ? PAGE_ORDER_CONFIRMATION_PL + : locale === 'de' + ? PAGE_ORDER_CONFIRMATION_DE + : PAGE_ORDER_CONFIRMATION_EN; + const pathPrefix = + locale === 'pl' + ? '/potwierdzenie-zamowienia' + : locale === 'de' + ? '/bestellbestaetigung' + : '/order-confirmation'; + return { ...page, slug: `${pathPrefix}/${orderId}`, updatedAt: '2025-01-01' }; + } + case '/contact-us': return PAGE_CONTACT_US_EN; case '/kontaktiere-uns': @@ -324,6 +398,12 @@ export const getAllPages = (locale: string): CMS.Model.Page.Page[] => { PAGE_REQUEST_DEVICE_MAINTENANCE_PL, PAGE_ORDER_LIST_PL, PAGE_ORDER_DETAILS_PL, + PAGE_CART_PL, + PAGE_CHECKOUT_COMPANY_DATA_PL, + PAGE_CHECKOUT_SHIPPING_ADDRESS_PL, + PAGE_CHECKOUT_BILLING_PAYMENT_PL, + PAGE_CHECKOUT_SUMMARY_PL, + PAGE_ORDER_CONFIRMATION_PL, PAGE_WARRANTY_AND_REPAIR_PL, PAGE_MAINTENANCE_PL, PAGE_SAFETY_PL, @@ -349,6 +429,12 @@ export const getAllPages = (locale: string): CMS.Model.Page.Page[] => { PAGE_REQUEST_DEVICE_MAINTENANCE_DE, PAGE_ORDER_LIST_DE, PAGE_ORDER_DETAILS_DE, + PAGE_CART_DE, + PAGE_CHECKOUT_COMPANY_DATA_DE, + PAGE_CHECKOUT_SHIPPING_ADDRESS_DE, + PAGE_CHECKOUT_BILLING_PAYMENT_DE, + PAGE_CHECKOUT_SUMMARY_DE, + PAGE_ORDER_CONFIRMATION_DE, PAGE_WARRANTY_AND_REPAIR_DE, PAGE_MAINTENANCE_DE, PAGE_SAFETY_DE, @@ -374,6 +460,12 @@ export const getAllPages = (locale: string): CMS.Model.Page.Page[] => { PAGE_REQUEST_DEVICE_MAINTENANCE_EN, PAGE_ORDER_LIST_EN, PAGE_ORDER_DETAILS_EN, + PAGE_CART_EN, + PAGE_CHECKOUT_COMPANY_DATA_EN, + PAGE_CHECKOUT_SHIPPING_ADDRESS_EN, + PAGE_CHECKOUT_BILLING_PAYMENT_EN, + PAGE_CHECKOUT_SUMMARY_EN, + PAGE_ORDER_CONFIRMATION_EN, PAGE_WARRANTY_AND_REPAIR_EN, PAGE_MAINTENANCE_EN, PAGE_SAFETY_EN, @@ -433,6 +525,24 @@ export const getAlternativePages = (id: string, slug: string, locale: string): C PAGE_ORDER_DETAILS_EN, PAGE_ORDER_DETAILS_DE, PAGE_ORDER_DETAILS_PL, + PAGE_CART_EN, + PAGE_CART_DE, + PAGE_CART_PL, + PAGE_CHECKOUT_COMPANY_DATA_EN, + PAGE_CHECKOUT_COMPANY_DATA_DE, + PAGE_CHECKOUT_COMPANY_DATA_PL, + PAGE_CHECKOUT_SHIPPING_ADDRESS_EN, + PAGE_CHECKOUT_SHIPPING_ADDRESS_DE, + PAGE_CHECKOUT_SHIPPING_ADDRESS_PL, + PAGE_CHECKOUT_BILLING_PAYMENT_EN, + PAGE_CHECKOUT_BILLING_PAYMENT_DE, + PAGE_CHECKOUT_BILLING_PAYMENT_PL, + PAGE_CHECKOUT_SUMMARY_EN, + PAGE_CHECKOUT_SUMMARY_DE, + PAGE_CHECKOUT_SUMMARY_PL, + PAGE_ORDER_CONFIRMATION_EN, + PAGE_ORDER_CONFIRMATION_DE, + PAGE_ORDER_CONFIRMATION_PL, PAGE_WARRANTY_AND_REPAIR_EN, PAGE_WARRANTY_AND_REPAIR_DE, PAGE_WARRANTY_AND_REPAIR_PL, diff --git a/packages/integrations/mocked/src/modules/cms/mappers/mocks/pages/cart.page.ts b/packages/integrations/mocked/src/modules/cms/mappers/mocks/pages/cart.page.ts new file mode 100644 index 000000000..7f0480aa0 --- /dev/null +++ b/packages/integrations/mocked/src/modules/cms/mappers/mocks/pages/cart.page.ts @@ -0,0 +1,100 @@ +import { CMS } from '@o2s/framework/modules'; + +export const PAGE_CART_EN: CMS.Model.Page.Page = { + id: 'cart-1', + slug: '/cart', + locale: 'en', + seo: { + noIndex: false, + noFollow: false, + title: 'Cart', + description: 'Your shopping cart', + keywords: [], + image: { + url: 'https://picsum.photos/150', + width: 150, + height: 150, + alt: 'Placeholder', + }, + }, + hasOwnTitle: true, + template: { + __typename: 'OneColumnTemplate', + slots: { + main: [ + { + __typename: 'CartBlock', + id: 'cart-1', + }, + ], + }, + }, + updatedAt: '2025-01-01', + createdAt: '2025-01-01', +}; + +export const PAGE_CART_DE: CMS.Model.Page.Page = { + id: 'cart-1', + slug: '/warenkorb', + locale: 'de', + seo: { + noIndex: false, + noFollow: false, + title: 'Warenkorb', + description: 'Ihr Warenkorb', + keywords: [], + image: { + url: 'https://picsum.photos/150', + width: 150, + height: 150, + alt: 'Placeholder', + }, + }, + hasOwnTitle: true, + template: { + __typename: 'OneColumnTemplate', + slots: { + main: [ + { + __typename: 'CartBlock', + id: 'cart-1', + }, + ], + }, + }, + updatedAt: '2025-01-01', + createdAt: '2025-01-01', +}; + +export const PAGE_CART_PL: CMS.Model.Page.Page = { + id: 'cart-1', + slug: '/koszyk', + locale: 'pl', + seo: { + noIndex: false, + noFollow: false, + title: 'Koszyk', + description: 'Twój koszyk zakupów', + keywords: [], + image: { + url: 'https://picsum.photos/150', + width: 150, + height: 150, + alt: 'Placeholder', + }, + }, + hasOwnTitle: true, + template: { + __typename: 'OneColumnTemplate', + slots: { + main: [ + { + __typename: 'CartBlock', + id: 'cart-1', + }, + ], + }, + }, + updatedAt: '2025-01-01', + createdAt: '2025-01-01', +}; diff --git a/packages/integrations/mocked/src/modules/cms/mappers/mocks/pages/checkout.page.ts b/packages/integrations/mocked/src/modules/cms/mappers/mocks/pages/checkout.page.ts new file mode 100644 index 000000000..ad91caa20 --- /dev/null +++ b/packages/integrations/mocked/src/modules/cms/mappers/mocks/pages/checkout.page.ts @@ -0,0 +1,158 @@ +import { CMS } from '@o2s/framework/modules'; + +const createCheckoutPage = ( + id: string, + slug: string, + locale: string, + title: string, + description: string, + blockTypename: string, + blockId: string, +): CMS.Model.Page.Page => ({ + id, + slug, + locale, + seo: { + noIndex: false, + noFollow: false, + title, + description, + keywords: [], + image: { + url: 'https://picsum.photos/150', + width: 150, + height: 150, + alt: 'Placeholder', + }, + }, + hasOwnTitle: true, + template: { + __typename: 'OneColumnTemplate', + slots: { + main: [ + { + __typename: blockTypename as CMS.Model.Page.SlotBlock['__typename'], + id: blockId, + }, + ], + }, + }, + updatedAt: '2025-01-01', + createdAt: '2025-01-01', +}); + +// Checkout 1: Company Data +export const PAGE_CHECKOUT_COMPANY_DATA_EN = createCheckoutPage( + 'checkout-company-data-1', + '/checkout/company-data', + 'en', + 'Company data', + 'Enter your company details', + 'CheckoutCompanyDataBlock', + 'checkout-company-data-1', +); +export const PAGE_CHECKOUT_COMPANY_DATA_DE = createCheckoutPage( + 'checkout-company-data-1', + '/kasse/firmendaten', + 'de', + 'Firmendaten', + 'Geben Sie Ihre Firmendaten ein', + 'CheckoutCompanyDataBlock', + 'checkout-company-data-1', +); +export const PAGE_CHECKOUT_COMPANY_DATA_PL = createCheckoutPage( + 'checkout-company-data-1', + '/zamowienie/dane-firmy', + 'pl', + 'Dane firmy', + 'Wprowadź dane firmy', + 'CheckoutCompanyDataBlock', + 'checkout-company-data-1', +); + +// Checkout 2: Shipping Address +export const PAGE_CHECKOUT_SHIPPING_ADDRESS_EN = createCheckoutPage( + 'checkout-shipping-address-1', + '/checkout/shipping-address', + 'en', + 'Shipping address', + 'Enter your shipping address', + 'CheckoutShippingAddressBlock', + 'checkout-shipping-address-1', +); +export const PAGE_CHECKOUT_SHIPPING_ADDRESS_DE = createCheckoutPage( + 'checkout-shipping-address-1', + '/kasse/lieferadresse', + 'de', + 'Lieferadresse', + 'Geben Sie Ihre Lieferadresse ein', + 'CheckoutShippingAddressBlock', + 'checkout-shipping-address-1', +); +export const PAGE_CHECKOUT_SHIPPING_ADDRESS_PL = createCheckoutPage( + 'checkout-shipping-address-1', + '/zamowienie/adres-dostawy', + 'pl', + 'Adres dostawy', + 'Wprowadź adres dostawy', + 'CheckoutShippingAddressBlock', + 'checkout-shipping-address-1', +); + +// Checkout 3: Billing & Payment +export const PAGE_CHECKOUT_BILLING_PAYMENT_EN = createCheckoutPage( + 'checkout-billing-payment-1', + '/checkout/billing-payment', + 'en', + 'Billing & payment', + 'Enter billing and payment details', + 'CheckoutBillingPaymentBlock', + 'checkout-billing-payment-1', +); +export const PAGE_CHECKOUT_BILLING_PAYMENT_DE = createCheckoutPage( + 'checkout-billing-payment-1', + '/kasse/rechnung-zahlung', + 'de', + 'Rechnung & Zahlung', + 'Geben Sie Rechnungs- und Zahlungsdetails ein', + 'CheckoutBillingPaymentBlock', + 'checkout-billing-payment-1', +); +export const PAGE_CHECKOUT_BILLING_PAYMENT_PL = createCheckoutPage( + 'checkout-billing-payment-1', + '/zamowienie/platnosc', + 'pl', + 'Płatność i rozliczenie', + 'Wprowadź dane rozliczeniowe i płatności', + 'CheckoutBillingPaymentBlock', + 'checkout-billing-payment-1', +); + +// Checkout 4: Summary +export const PAGE_CHECKOUT_SUMMARY_EN = createCheckoutPage( + 'checkout-summary-1', + '/checkout/summary', + 'en', + 'Order summary', + 'Review and place your order', + 'CheckoutSummaryBlock', + 'checkout-summary-1', +); +export const PAGE_CHECKOUT_SUMMARY_DE = createCheckoutPage( + 'checkout-summary-1', + '/kasse/zusammenfassung', + 'de', + 'Bestellübersicht', + 'Überprüfen und bestellen', + 'CheckoutSummaryBlock', + 'checkout-summary-1', +); +export const PAGE_CHECKOUT_SUMMARY_PL = createCheckoutPage( + 'checkout-summary-1', + '/zamowienie/podsumowanie', + 'pl', + 'Podsumowanie zamówienia', + 'Sprawdź i złóż zamówienie', + 'CheckoutSummaryBlock', + 'checkout-summary-1', +); diff --git a/packages/integrations/mocked/src/modules/cms/mappers/mocks/pages/order-confirmation.page.ts b/packages/integrations/mocked/src/modules/cms/mappers/mocks/pages/order-confirmation.page.ts new file mode 100644 index 000000000..53375c1a1 --- /dev/null +++ b/packages/integrations/mocked/src/modules/cms/mappers/mocks/pages/order-confirmation.page.ts @@ -0,0 +1,100 @@ +import { CMS } from '@o2s/framework/modules'; + +export const PAGE_ORDER_CONFIRMATION_EN: CMS.Model.Page.Page = { + id: 'order-confirmation-1', + slug: '/order-confirmation/(.+)', + locale: 'en', + seo: { + noIndex: false, + noFollow: false, + title: 'Order confirmation', + description: 'Your order has been placed', + keywords: [], + image: { + url: 'https://picsum.photos/150', + width: 150, + height: 150, + alt: 'Placeholder', + }, + }, + hasOwnTitle: true, + template: { + __typename: 'OneColumnTemplate', + slots: { + main: [ + { + __typename: 'OrderConfirmationBlock', + id: 'order-confirmation-1', + }, + ], + }, + }, + updatedAt: '2025-01-01', + createdAt: '2025-01-01', +}; + +export const PAGE_ORDER_CONFIRMATION_DE: CMS.Model.Page.Page = { + id: 'order-confirmation-1', + slug: '/bestellbestaetigung/(.+)', + locale: 'de', + seo: { + noIndex: false, + noFollow: false, + title: 'Bestellbestätigung', + description: 'Ihre Bestellung wurde aufgegeben', + keywords: [], + image: { + url: 'https://picsum.photos/150', + width: 150, + height: 150, + alt: 'Placeholder', + }, + }, + hasOwnTitle: true, + template: { + __typename: 'OneColumnTemplate', + slots: { + main: [ + { + __typename: 'OrderConfirmationBlock', + id: 'order-confirmation-1', + }, + ], + }, + }, + updatedAt: '2025-01-01', + createdAt: '2025-01-01', +}; + +export const PAGE_ORDER_CONFIRMATION_PL: CMS.Model.Page.Page = { + id: 'order-confirmation-1', + slug: '/potwierdzenie-zamowienia/(.+)', + locale: 'pl', + seo: { + noIndex: false, + noFollow: false, + title: 'Potwierdzenie zamówienia', + description: 'Twoje zamówienie zostało złożone', + keywords: [], + image: { + url: 'https://picsum.photos/150', + width: 150, + height: 150, + alt: 'Placeholder', + }, + }, + hasOwnTitle: true, + template: { + __typename: 'OneColumnTemplate', + slots: { + main: [ + { + __typename: 'OrderConfirmationBlock', + id: 'order-confirmation-1', + }, + ], + }, + }, + updatedAt: '2025-01-01', + createdAt: '2025-01-01', +}; diff --git a/packages/integrations/mocked/src/modules/customers/customers.service.ts b/packages/integrations/mocked/src/modules/customers/customers.service.ts index 3d10c270a..c71300843 100644 --- a/packages/integrations/mocked/src/modules/customers/customers.service.ts +++ b/packages/integrations/mocked/src/modules/customers/customers.service.ts @@ -13,10 +13,12 @@ import { getMockAddresses } from './mocks/addresses.mock'; import { responseDelay } from '@/utils/delay'; @Injectable() -export class CustomersService implements Customers.Service { +export class CustomersService extends Customers.Service { private addresses: Customers.Model.CustomerAddress[] = [...getMockAddresses()]; - constructor(private readonly authService: Auth.Service) {} + constructor(private readonly authService: Auth.Service) { + super(); + } getAddresses(authorization: string | undefined): Observable<Customers.Model.CustomerAddresses> { if (!authorization) { diff --git a/packages/integrations/mocked/src/modules/invoices/invoices.service.ts b/packages/integrations/mocked/src/modules/invoices/invoices.service.ts index d1ae31662..32f4c4ea9 100644 --- a/packages/integrations/mocked/src/modules/invoices/invoices.service.ts +++ b/packages/integrations/mocked/src/modules/invoices/invoices.service.ts @@ -9,7 +9,11 @@ import { mapInvoice, mapInvoices } from './invoices.mapper'; import { responseDelay } from '@/utils/delay'; @Injectable() -export class InvoicesService implements Invoices.Service { +export class InvoicesService extends Invoices.Service { + constructor() { + super(); + } + getInvoiceList(query: Invoices.Request.GetInvoiceListQuery): Observable<Invoices.Model.Invoices> { return of(mapInvoices(query)).pipe(responseDelay()); } diff --git a/packages/integrations/mocked/src/modules/notifications/notifications.service.ts b/packages/integrations/mocked/src/modules/notifications/notifications.service.ts index be7b4b086..77b27eeee 100644 --- a/packages/integrations/mocked/src/modules/notifications/notifications.service.ts +++ b/packages/integrations/mocked/src/modules/notifications/notifications.service.ts @@ -8,7 +8,11 @@ import * as CustomNotifications from './notifications.model'; import { responseDelay } from '@/utils/delay'; @Injectable() -export class NotificationsService implements Notifications.Service { +export class NotificationsService extends Notifications.Service { + constructor() { + super(); + } + getNotification( params: Notifications.Request.GetNotificationParams, ): Observable<CustomNotifications.Notification | undefined> { diff --git a/packages/integrations/mocked/src/modules/orders/orders.service.ts b/packages/integrations/mocked/src/modules/orders/orders.service.ts index 29d34a48d..20567ed1e 100644 --- a/packages/integrations/mocked/src/modules/orders/orders.service.ts +++ b/packages/integrations/mocked/src/modules/orders/orders.service.ts @@ -7,8 +7,10 @@ import { mapOrder, mapOrders } from './orders.mapper'; import { responseDelay } from '@/utils/delay'; @Injectable() -export class OrdersService implements Orders.Service { - constructor(private readonly authService: Auth.Service) {} +export class OrdersService extends Orders.Service { + constructor(private readonly authService: Auth.Service) { + super(); + } getOrderList( query: Orders.Request.GetOrderListQuery, @@ -31,10 +33,24 @@ export class OrdersService implements Orders.Service { params: Orders.Request.GetOrderParams, authorization: string | undefined, ): Observable<Orders.Model.Order | undefined> { - if (!authorization) { - throw new UnauthorizedException('Unauthorized'); + const order = mapOrder(params); + + if (!order) { + return of(undefined).pipe(responseDelay()); + } + + if (order.customerId) { + // Order belongs to a customer — only that customer can access it + if (!authorization) { + throw new UnauthorizedException('Unauthorized'); + } + const customerId = this.authService.getCustomerId(authorization); + if (order.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized'); + } } + // Guest order (no customerId) — accessible to anyone with the order ID - return of(mapOrder(params)).pipe(responseDelay()); + return of(order).pipe(responseDelay()); } } diff --git a/packages/integrations/mocked/src/modules/organizations/organizations.service.ts b/packages/integrations/mocked/src/modules/organizations/organizations.service.ts index bceae5c57..b9830b4e8 100644 --- a/packages/integrations/mocked/src/modules/organizations/organizations.service.ts +++ b/packages/integrations/mocked/src/modules/organizations/organizations.service.ts @@ -7,7 +7,11 @@ import { checkMembership, mapOrganization, mapOrganizationsForUser } from './org import { responseDelay } from '@/utils/delay'; @Injectable() -export class OrganizationsService implements Organizations.Service { +export class OrganizationsService extends Organizations.Service { + constructor() { + super(); + } + getOrganizationList( options: Organizations.Request.OrganizationsListQuery, authorization?: string, diff --git a/packages/integrations/mocked/src/modules/payments/payments.service.ts b/packages/integrations/mocked/src/modules/payments/payments.service.ts index 5ea9c8801..94c035215 100644 --- a/packages/integrations/mocked/src/modules/payments/payments.service.ts +++ b/packages/integrations/mocked/src/modules/payments/payments.service.ts @@ -8,9 +8,13 @@ import { createPaymentSession, mapPaymentProviders, mapPaymentSession, updatePay import { responseDelay } from '@/utils/delay'; @Injectable() -export class PaymentsService implements Payments.Service { +export class PaymentsService extends Payments.Service { private sessions: Payments.Model.PaymentSession[] = []; + constructor() { + super(); + } + getProviders( params: Payments.Request.GetProvidersParams, _authorization: string | undefined, diff --git a/packages/integrations/mocked/src/modules/products/products.mapper.ts b/packages/integrations/mocked/src/modules/products/products.mapper.ts index 7a9636d7a..f11da334a 100644 --- a/packages/integrations/mocked/src/modules/products/products.mapper.ts +++ b/packages/integrations/mocked/src/modules/products/products.mapper.ts @@ -105,6 +105,17 @@ export const mapProductBySku = (sku: string, locale?: string): Products.Model.Pr return product; }; +export const mapProductByVariantId = (variantId: string, locale?: string): Products.Model.Product | undefined => { + let productsSource = MOCK_PRODUCTS_EN; + if (locale === 'pl') productsSource = MOCK_PRODUCTS_PL; + else if (locale === 'de') productsSource = MOCK_PRODUCTS_DE; + + const product = productsSource.find((p) => p.variants?.some((v) => v.id === variantId)); + if (!product) return undefined; + + return mapProduct(product.id, locale, variantId); +}; + export const mapProducts = (options: Products.Request.GetProductListQuery): Products.Model.Products => { const { sort, locale, offset = 0, limit = 12 } = options; @@ -134,6 +145,7 @@ export const mapProducts = (options: Products.Request.GetProductListQuery): Prod ...product, ...overrides, variantId: firstVariant.id, + sku: `${product.sku}-${firstVariant.slug.toUpperCase()}`, link: `${product.link}/${firstVariant.slug}`, }; } diff --git a/packages/integrations/mocked/src/modules/products/products.service.ts b/packages/integrations/mocked/src/modules/products/products.service.ts index a0d31099d..14a6e7432 100644 --- a/packages/integrations/mocked/src/modules/products/products.service.ts +++ b/packages/integrations/mocked/src/modules/products/products.service.ts @@ -7,7 +7,11 @@ import { mapProduct, mapProducts, mapRelatedProducts } from './products.mapper'; import { responseDelay } from '@/utils/delay'; @Injectable() -export class ProductsService implements Products.Service { +export class ProductsService extends Products.Service { + constructor() { + super(); + } + getProductList( query: Products.Request.GetProductListQuery, _authorization?: string, diff --git a/packages/integrations/mocked/src/modules/resources/resources.service.ts b/packages/integrations/mocked/src/modules/resources/resources.service.ts index 2d2b29566..9d2f91c2d 100644 --- a/packages/integrations/mocked/src/modules/resources/resources.service.ts +++ b/packages/integrations/mocked/src/modules/resources/resources.service.ts @@ -14,7 +14,11 @@ import { import { responseDelay } from '@/utils/delay'; @Injectable() -export class ResourcesService implements Resources.Service { +export class ResourcesService extends Resources.Service { + constructor() { + super(); + } + purchaseOrActivateService(_params: Resources.Request.GetServiceParams): Observable<void> { throw new Error('Method not implemented.'); } diff --git a/packages/integrations/mocked/src/modules/tickets/tickets.service.ts b/packages/integrations/mocked/src/modules/tickets/tickets.service.ts index cb9bf952c..5ccea748d 100644 --- a/packages/integrations/mocked/src/modules/tickets/tickets.service.ts +++ b/packages/integrations/mocked/src/modules/tickets/tickets.service.ts @@ -7,7 +7,11 @@ import { mapTicket, mapTickets } from './tickets.mapper'; import { responseDelay } from '@/utils/delay'; @Injectable() -export class TicketService implements Tickets.Service { +export class TicketService extends Tickets.Service { + constructor() { + super(); + } + getTicket(options: Tickets.Request.GetTicketParams) { return of(mapTicket(options.id, options.locale)).pipe(responseDelay()); } diff --git a/packages/integrations/mocked/src/modules/users/users.service.ts b/packages/integrations/mocked/src/modules/users/users.service.ts index 8b8a868dc..c0c5b712e 100644 --- a/packages/integrations/mocked/src/modules/users/users.service.ts +++ b/packages/integrations/mocked/src/modules/users/users.service.ts @@ -8,7 +8,11 @@ import { mapUser, mapUsers } from './users.mapper'; import { responseDelay } from '@/utils/delay'; @Injectable() -export class UserService implements Users.Service { +export class UserService extends Users.Service { + constructor() { + super(); + } + getCurrentUser(_authentication?: string): Observable<Users.Model.User | undefined> { return of(mapUser()).pipe(responseDelay()); } diff --git a/packages/integrations/redis/CHANGELOG.md b/packages/integrations/redis/CHANGELOG.md index d8ecb6a1d..4ddf6827b 100644 --- a/packages/integrations/redis/CHANGELOG.md +++ b/packages/integrations/redis/CHANGELOG.md @@ -1,5 +1,33 @@ # @o2s/integrations.redis +## 1.4.2 + +### Patch Changes + +- 338cb01: Migrate integration services from `implements` to `extends` and add `super()` where needed + to keep constructor metadata compatible with NestJS dependency injection. + + Update documentation examples to reflect the new `extends ...Service` pattern. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + +## 1.4.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.logger@1.2.3 + ## 1.4.0 ### Minor Changes diff --git a/packages/integrations/redis/package.json b/packages/integrations/redis/package.json index 7d502f266..2cbaf7fc6 100644 --- a/packages/integrations/redis/package.json +++ b/packages/integrations/redis/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/integrations.redis", - "version": "1.4.0", + "version": "1.4.2", "private": false, "license": "MIT", "description": "Redis integration for O2S, providing caching functionality.", @@ -48,7 +48,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", diff --git a/packages/integrations/redis/src/modules/cache/cache.service.ts b/packages/integrations/redis/src/modules/cache/cache.service.ts index 38fd24be5..a183d7ea0 100644 --- a/packages/integrations/redis/src/modules/cache/cache.service.ts +++ b/packages/integrations/redis/src/modules/cache/cache.service.ts @@ -7,7 +7,7 @@ import { LoggerService } from '@o2s/utils.logger'; import { Cache } from '@o2s/framework/modules'; @Injectable() -export class RedisCacheService implements Cache.Service { +export class RedisCacheService extends Cache.Service { private readonly isEnabled: boolean = false; private readonly expires: number = 300; private client!: RedisClientType; @@ -16,6 +16,7 @@ export class RedisCacheService implements Cache.Service { @Inject(LoggerService) private readonly logger: LoggerService, private readonly configService: ConfigService, ) { + super(); this.isEnabled = this.configService.get('CACHE_ENABLED') === 'true'; this.expires = this.configService.get('CACHE_TTL') || 300; diff --git a/packages/integrations/strapi-cms/CHANGELOG.md b/packages/integrations/strapi-cms/CHANGELOG.md index 26d5289c7..fda20d681 100644 --- a/packages/integrations/strapi-cms/CHANGELOG.md +++ b/packages/integrations/strapi-cms/CHANGELOG.md @@ -1,5 +1,43 @@ # @o2s/integrations.strapi-cms +## 2.13.1 + +### Patch Changes + +- 338cb01: Clarify Strapi integration documentation around external `openselfservice-resources` repository: + explain why content models and example data live in a separate resources repo, how to pick + the right export (`o2s` vs `dxp`), and provide a step-by-step flow for importing the content + model into a Strapi instance. +- feb0a8c: chore(deps): update dependencies +- 338cb01: Migrate integration services from `implements` to `extends` and add `super()` where needed + to keep constructor metadata compatible with NestJS dependency injection. + + Update documentation examples to reflect the new `extends ...Service` pattern. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + +## 2.13.0 + +### Minor Changes + +- 375cd90: feat(framework, integrations): add variantId to AddCartItemBody and cart item models, add viewCartLabel and cartPath to CMS block models. Implement variantId-based cart operations in Medusa integration. Localize CMS mappers (EN/DE/PL) for Contentful and Strapi. + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- d05b09b: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.logger@1.2.3 + ## 2.12.0 ### Minor Changes diff --git a/packages/integrations/strapi-cms/package.json b/packages/integrations/strapi-cms/package.json index 3d8dca0ba..121ee0c4f 100644 --- a/packages/integrations/strapi-cms/package.json +++ b/packages/integrations/strapi-cms/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/integrations.strapi-cms", - "version": "2.12.0", + "version": "2.13.1", "private": false, "license": "MIT", "description": "Strapi CMS integration for O2S, providing content management functionality via GraphQL.", @@ -41,7 +41,7 @@ "dependencies": { "@o2s/framework": "*", "@o2s/utils.logger": "*", - "flatted": "^3.3.3", + "flatted": "^3.4.1", "graphql": "16.13.0", "graphql-request": "7.4.0", "graphql-tag": "2.12.6" @@ -60,7 +60,7 @@ "@o2s/typescript-config": "*", "@o2s/vitest-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", diff --git a/packages/integrations/strapi-cms/src/modules/articles/articles.service.ts b/packages/integrations/strapi-cms/src/modules/articles/articles.service.ts index 17a1f2b2f..b2a1e07a5 100644 --- a/packages/integrations/strapi-cms/src/modules/articles/articles.service.ts +++ b/packages/integrations/strapi-cms/src/modules/articles/articles.service.ts @@ -4,12 +4,12 @@ import { Observable, forkJoin, map } from 'rxjs'; import { Articles, CMS, Search } from '@o2s/framework/modules'; -import { Service as GraphqlService } from '@/modules/graphql'; +import { GraphqlService } from '@/modules/graphql/graphql.service'; import { mapArticle, mapArticles, mapCategories, mapCategory } from './articles.mapper'; @Injectable() -export class ArticlesService implements Articles.Service { +export class ArticlesService extends Articles.Service { baseUrl: string; searchIndexName: string; constructor( @@ -18,6 +18,7 @@ export class ArticlesService implements Articles.Service { private readonly searchService: Search.Service, private readonly cmsService: CMS.Service, ) { + super(); this.baseUrl = this.config.get('CMS_STRAPI_BASE_URL')!; this.searchIndexName = this.config.get('SEARCH_ARTICLES_INDEX_NAME')!; diff --git a/packages/integrations/strapi-cms/src/modules/cms/cms.service.ts b/packages/integrations/strapi-cms/src/modules/cms/cms.service.ts index 550b25fa7..fbdd9028d 100644 --- a/packages/integrations/strapi-cms/src/modules/cms/cms.service.ts +++ b/packages/integrations/strapi-cms/src/modules/cms/cms.service.ts @@ -9,7 +9,7 @@ import { CMS, Cache } from '@o2s/framework/modules'; import { PageFragment } from '@/generated/strapi'; -import { Service as GraphqlService } from '@/modules/graphql'; +import { GraphqlService } from '@/modules/graphql/graphql.service'; import { mapArticleListBlock } from './mappers/blocks/cms.article-list.mapper'; import { mapArticleSearchBlock } from './mappers/blocks/cms.article-search.mapper'; @@ -49,7 +49,7 @@ import { mapPage } from './mappers/cms.page.mapper'; import { mapSurvey } from './mappers/cms.survey.mapper'; @Injectable() -export class CmsService implements CMS.Service { +export class CmsService extends CMS.Service { baseUrl: string; constructor( @@ -57,6 +57,7 @@ export class CmsService implements CMS.Service { private readonly config: ConfigService, private readonly cacheService: Cache.Service, ) { + super(); this.baseUrl = this.config.get('CMS_STRAPI_BASE_URL')!; } diff --git a/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts b/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts index d6dfdc7da..8660e1ba2 100644 --- a/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts +++ b/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts @@ -1,17 +1,56 @@ import { CMS } from '@o2s/framework/modules'; -export const mapProductDetailsBlock = (_locale: string): CMS.Model.ProductDetailsBlock.ProductDetailsBlock => { +export const mapProductDetailsBlock = (locale: string): CMS.Model.ProductDetailsBlock.ProductDetailsBlock => { // TODO: Implement proper mapping from Strapi // For now, return a basic structure with labels - return { - id: 'product-details-1', - labels: { - actionButtonLabel: 'Request Quote', + + const labelsMap: Record<string, CMS.Model.ProductDetailsBlock.Labels> = { + en: { specificationsTitle: 'Specifications', descriptionTitle: 'Description', downloadLabel: 'Download Brochure', priceLabel: 'Price', offerLabel: 'Offer', + addToCartLabel: 'Add to Cart', + addToCartSuccess: '{productName} added to cart', + addToCartError: 'Failed to add product to cart', + viewCartLabel: 'View Cart', }, + de: { + specificationsTitle: 'Spezifikationen', + descriptionTitle: 'Beschreibung', + downloadLabel: 'Broschüre herunterladen', + priceLabel: 'Preis', + offerLabel: 'Angebot', + addToCartLabel: 'In den Warenkorb', + addToCartSuccess: '{productName} zum Warenkorb hinzugefügt', + addToCartError: 'Fehler beim Hinzufügen zum Warenkorb', + viewCartLabel: 'Warenkorb anzeigen', + }, + pl: { + specificationsTitle: 'Specyfikacja', + descriptionTitle: 'Opis', + downloadLabel: 'Pobierz broszurę', + priceLabel: 'Cena', + offerLabel: 'Oferta', + addToCartLabel: 'Dodaj do koszyka', + addToCartSuccess: '{productName} dodany do koszyka', + addToCartError: 'Nie udało się dodać produktu do koszyka', + viewCartLabel: 'Zobacz koszyk', + }, + }; + + const cartPathMap: Record<string, string> = { + en: '/cart', + de: '/warenkorb', + pl: '/koszyk', + }; + + const labels = labelsMap[locale] ?? labelsMap['en']!; + + return { + id: 'product-details-1', + labels, + cartPath: cartPathMap[locale] || cartPathMap['en'], }; }; diff --git a/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts b/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts index c515e6dae..ff0c041f1 100644 --- a/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts +++ b/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts @@ -37,6 +37,11 @@ export const mapProductListBlock = (_locale: string): CMS.Model.ProductListBlock showMoreFilters: 'Show more filters', hideMoreFilters: 'Hide more filters', noActiveFilters: 'No active filters', + addToCartLabel: 'Add to Cart', + addToCartSuccess: '{productName} added to cart', + addToCartError: 'Failed to add product to cart', + viewCartLabel: 'View Cart', }, + cartPath: '/cart', }; }; diff --git a/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts b/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts index a4b6ddf8c..c925695fe 100644 --- a/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts +++ b/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts @@ -12,9 +12,14 @@ export const mapRecommendedProductsBlock = ( return { id: 'recommended-products-1', basePath: basePathByLocale[locale] || basePathByLocale.en, + cartPath: '/cart', labels: { title: 'Recommended Products', detailsLabel: 'Details', + addToCartLabel: 'Add to Cart', + addToCartSuccess: '{productName} added to cart', + addToCartError: 'Failed to add product to cart', + viewCartLabel: 'View Cart', }, }; }; diff --git a/packages/integrations/zendesk/CHANGELOG.md b/packages/integrations/zendesk/CHANGELOG.md index 2bac1e0b3..f75924660 100644 --- a/packages/integrations/zendesk/CHANGELOG.md +++ b/packages/integrations/zendesk/CHANGELOG.md @@ -1,5 +1,19 @@ # @o2s/integrations.zendesk +## 3.1.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- daf592e: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.logger@1.2.3 + ## 3.1.0 ### Minor Changes diff --git a/packages/integrations/zendesk/package.json b/packages/integrations/zendesk/package.json index ca3984518..9060255fa 100644 --- a/packages/integrations/zendesk/package.json +++ b/packages/integrations/zendesk/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/integrations.zendesk", - "version": "3.1.0", + "version": "3.1.1", "private": false, "license": "MIT", "description": "Zendesk integration for O2S, providing ticket management and support functionality.", @@ -44,7 +44,7 @@ "@nestjs/common": "^11.1.14", "@o2s/framework": "*", "@o2s/utils.logger": "*", - "axios": "^1.13.5", + "axios": "^1.13.6", "html-to-text": "^9.0.5", "rxjs": "^7.8.2" }, @@ -56,7 +56,7 @@ "@o2s/typescript-config": "*", "@types/html-to-text": "^9.0.4", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "tsx": "^4.21.0", diff --git a/packages/integrations/zendesk/scripts/oas/help-center-oas.yaml b/packages/integrations/zendesk/scripts/oas/help-center-oas.yaml index 8a32a5798..dd9735c69 100644 --- a/packages/integrations/zendesk/scripts/oas/help-center-oas.yaml +++ b/packages/integrations/zendesk/scripts/oas/help-center-oas.yaml @@ -4548,266 +4548,6 @@ paths: examples: default: $ref: '#/components/examples/MissingTranslationsResponseExample' - /api/v2/help_center/community/posts: - get: - operationId: ListPostsInternal - tags: - - Posts - summary: List Posts (legacy) - parameters: - - name: filter_by - in: query - description: Filter the results using the provided value - schema: - type: string - enum: - - planned - - not_planned - - completed - - answered - - none - - name: sort_by - in: query - description: Sorts the results using the provided value - schema: - type: string - enum: - - created_at - - edited_at - - updated_at - - recent_activity - - votes - - comments - responses: - "200": - description: OK Response - content: - application/json: - schema: - $ref: '#/components/schemas/PostsResponse' - examples: - default: - $ref: '#/components/examples/PostsResponseExample' - /api/v2/help_center/community/posts/{post_id}: - parameters: - - $ref: '#/components/parameters/PostId' - get: - operationId: ShowPostInternal - tags: - - Posts - summary: Show Post (legacy) - description: | - Gets information about a given post. - - #### Allowed for - - * Anonymous users - - #### Sideloads - The following sideloads are supported: - - | Name | Will sideload - |-------------|-------------- - | users | authors - | topics | topics - responses: - "200": - description: OK Response - content: - application/json: - schema: - $ref: '#/components/schemas/PostResponse' - examples: - default: - $ref: '#/components/examples/PostResponseExample' - put: - operationId: UpdatePostInternal - tags: - - Posts - summary: Update Post (legacy) - description: | - #### Allowed for - - * Agents - * The end user who created the post - responses: - "200": - description: OK Response - content: - application/json: - schema: - $ref: '#/components/schemas/PostResponse' - examples: - default: - $ref: '#/components/examples/PostResponseExample' - delete: - operationId: DeletePostInternal - tags: - - Posts - summary: Delete Post (legacy) - description: | - #### Allowed for - - * Agents - * The end user who created the post - responses: - "204": - description: Default success response - /api/v2/help_center/community/posts/{post_id}/comments/{post_comment_id}: - parameters: - - $ref: '#/components/parameters/PostId' - - $ref: '#/components/parameters/PostCommentId' - get: - operationId: ShowPostCommentInternal - tags: - - Post Comments - summary: Show Post Comment - description: | - Shows information about the specified comment. - - #### Allowed for - - * End users - - #### Sideloads - - The following sideloads are supported: - - | Name | Will sideload - |--------|-------------- - | users | The comment's author - | posts | The comment's post - responses: - "200": - description: OK Response - content: - application/json: - schema: - $ref: '#/components/schemas/PostCommentResponse' - examples: - default: - $ref: '#/components/examples/PostCommentResponseExample' - put: - operationId: UpdatePostCommentInternal - tags: - - Post Comments - summary: Update Post Comment - description: | - Updates the specified comment. - - #### Allowed for - - * Agents - * The end user who created the comment - responses: - "200": - description: OK Response - content: - application/json: - schema: - $ref: '#/components/schemas/PostCommentResponse' - examples: - default: - $ref: '#/components/examples/PostCommentResponseExample' - delete: - operationId: DeletePostCommentInternal - tags: - - Post Comments - summary: Delete Post Comment - description: | - Deletes the specified comment. - - #### Allowed for - - * Agents - * The end user who created the comment - responses: - "204": - description: Default success response - /api/v2/help_center/community/topics/{topic_id}/posts: - parameters: - - $ref: '#/components/parameters/TopicId' - get: - operationId: ListPostsByTopicInternal - tags: - - Posts - summary: List Posts in Topic (legacy) - parameters: - - name: filter_by - in: query - description: Filter the results using the provided value - schema: - type: string - enum: - - planned - - not_planned - - completed - - answered - - none - - name: sort_by - in: query - description: Sorts the results using the provided value - schema: - type: string - enum: - - created_at - - edited_at - - updated_at - - recent_activity - - votes - - comments - responses: - "200": - description: OK Response - content: - application/json: - schema: - $ref: '#/components/schemas/PostsResponse' - examples: - default: - $ref: '#/components/examples/PostsResponseExample' - /api/v2/help_center/community/users/{user_id}/posts: - parameters: - - $ref: '#/components/parameters/UserId' - get: - operationId: ListPostsByUserInternal - tags: - - Posts - summary: List Posts by User (legacy) - parameters: - - name: filter_by - in: query - description: Filter the results using the provided value - schema: - type: string - enum: - - planned - - not_planned - - completed - - answered - - none - - name: sort_by - in: query - description: Sorts the results using the provided value - schema: - type: string - enum: - - created_at - - edited_at - - updated_at - - recent_activity - - votes - - comments - responses: - "200": - description: OK Response - content: - application/json: - schema: - $ref: '#/components/schemas/PostsResponse' - examples: - default: - $ref: '#/components/examples/PostsResponseExample' /api/v2/help_center/community_posts/search: get: operationId: CommunityPostSearch @@ -6249,231 +5989,6 @@ paths: responses: "204": description: Default success response - /hc/api/internal/generative_answers: - post: - operationId: GenerativeAnswersHelpCenter - tags: - - Generative Answers - summary: Generate Answer for Help Center - description: | - Generates an answer for end-user search queries in Help Center. Accepts encrypted content references and returns a generated answer with source attribution. - - The endpoint is accessible to anonymous and authenticated end users. Queries must be at least 2 words (except for certain CJK languages). Returns up to 3 source contents used to generate the answer. - - #### Allowed for - - * Anonymous users - * End users - parameters: - - name: contents_data - in: query - description: Encrypted contents data. - required: true - schema: - type: string - example: BAh7DjoHaWRJIh8wMUo3ME0wRDZRNTAzNFRNNllGMDhQQVJQSgY6BkVUOg9hY2NvdW50X2lkaQSpphUBOgl0eXBlSSIcZXh0ZXJuYWxfY29udGVudF9yZWNvcmQGOwZUOgh1cmxJIn1odHRwczovL3N1cHBvcnQuemVuZGVzay5jb20vaGMvZW4tdXMvY29tbXVuaXR5L3Bvc3RzLzM2MDA0Njc1OTgzNy1Ib3ctdG8tbGVhdmUtZmVlZGJhY2stZm9yLUZlZGVyYXRlZC1IZWxwLUNlbnRlci1zZWFyY2gGOwZUOg5zZWFyY2hfaWRJIik2MDJkNGExMS05ZmRlLTQxZTgtOGM3NC00M2YzNjJjZGRjMjkGOwZGOglyYW5raQY6C2xvY2FsZUkiCmVuLXVzBjsGVDoKcXVlcnlJIhpXaGF0IGlzIGEgaGVscCBjZW50ZXIGOwZUOhJyZXN1bHRzX2NvdW50aQo - responses: - "200": - description: OK Response - content: - application/json: - schema: - $ref: '#/components/schemas/GenerativeAnswersHelpCenterResponse' - example: - answer_found: true - generated_answer: To deactivate your Help Center, go to the settings and select 'Deactivate'. - generated_answer_formatted: To deactivate your Help Center, go to the settings and select **Deactivate**. - llm_confidence_level: HIGH - search_id: 51D5084D-5C39-4522-B653-74BEDA56F618 - source_contents: - - id: "8201616519805" - title: Deactivating your Help Center - url: https://{subdomain}.zendesk-staging.com/hc/de/articles/8201616519805-Deactivating-your-Help-Center - "400": - description: Bad Request - content: - application/json: - schema: - type: object - properties: - error: - type: string - description: Error message describing the issue with the request. - example: - error: query parameter must be at least 2 words - "403": - description: Forbidden - content: - application/json: - schema: - type: object - properties: - error: - type: string - description: Error message describing the issue with the request. - example: - error: feature not available - /hc/api/internal/generative_answers_feedback: - post: - operationId: GenerativeAnswersFeedback - tags: - - Generative Answers - summary: Submits feedback for a generated answer - description: | - Submits feedback for a generated answer. This feedback can be used to improve the quality of future answers generated by the system. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/GenerativeAnswersFeedbackRequest' - example: - channel: HELP_CENTER - feedback_category: OTHER - is_helpful: false - search_id: 28cac105-aea1-407c-b485-c0f16c8ced0c - text_feedback: The answer was not relevant to my query. - responses: - "204": - description: Feedback submitted successfully - "400": - description: Bad Request - content: - application/json: - schema: - $ref: '#/components/schemas/BadRequestErrorResponse' - example: - errors: - feedback_category: is invalid - "403": - description: Forbidden - content: - application/json: - schema: - type: object - properties: - error: - type: string - description: Error message describing the issue with the request. - example: - error: feature not available - /hc/api/internal/generative_answers_knowledge: - post: - operationId: GenerativeAnswersKnowledge - tags: - - Generative Answers - summary: Generate Answer for Knowledge - description: | - Generates an answer for agents using Knowledge in the ticket workspace. Accepts a structured request with ticket context and content references, returning an answer localized to the ticket requester's language. - - The endpoint validates content references and returns only the cited source content. Used by the Agent Workspace for knowledge-assisted ticket responses. - - #### Allowed for - - * Agents - * Help Center managers - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/GenerativeAnswersKnowledgeRequest' - example: - contents: - - id: "8201616519805" - locale: en-us - search_id: 51D5084D-5C39-4522-B653-74BEDA56F618 - type: article - url: https://{subdomain}.zendesk-staging.com/hc/de/articles/8201616519805-Deactivating-your-Help-Center - query: How to deactivate help center - ticket_id: 1 - responses: - "200": - description: OK Response - content: - application/json: - schema: - $ref: '#/components/schemas/GenerativeAnswersKnowledgeResponse' - example: - answer: To deactivate your Help Center, go to the settings and select 'Deactivate'. - llm_confidence_level: HIGH - solved: true - source_contents: - - id: "8201616519805" - locale: en-us - search_id: 51D5084D-5C39-4522-B653-74BEDA56F618 - title: Deactivating your Help Center - type: article - url: https://{subdomain}.zendesk-staging.com/hc/de/articles/8201616519805-Deactivating-your-Help-Center - "400": - description: Bad Request - content: - application/json: - schema: - type: object - properties: - error: - type: string - description: Error message describing the issue with the request. - example: - error: contents must be an array - "403": - description: Forbidden - content: - application/json: - schema: - type: object - properties: - error: - type: string - description: Error message describing the issue with permissions. - example: - error: feature not available - /hc/api/internal/generative_answers_knowledge/actions: - post: - operationId: GenerativeAnswersKnowledgeActions - tags: - - Generative Answers - summary: Submits an action for a generated answer - description: | - Submits an action for a generated answer. This action indicates that user has perfomed further actions on the generated answer. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/GenerativeAnswersKnowledgeActionsRequest' - example: - knowledge_action: COPIED_TO_CONVERSATION - search_id: 51D5084D-5C39-4522-B653-74BEDA56F618 - ticket_nice_id: "1" - responses: - "204": - description: Action performed successfully - "400": - description: Bad Request - content: - application/json: - schema: - type: object - properties: - error: - type: string - description: Error message describing the issue with the request. - example: - error: 'required properties missing: knowledge_action' - "403": - description: Forbidden - content: - application/json: - schema: - type: object - properties: - error: - type: string - description: Error message describing the issue with the request. - example: - error: feature not available components: schemas: ArticleAttachmentObject: @@ -7125,6 +6640,45 @@ components: url: type: string description: URL to access the content. + HelpCenterGenerativeSearchResponse: + type: object + properties: + answer: + type: string + description: The AI-generated answer for the query. May be null if no answer was generated. + nullable: true + answer_found: + type: boolean + description: Indicates whether an answer was successfully generated. + citations: + type: array + description: List of source contents cited in the generated answer. + items: + type: object + properties: + id: + type: string + description: Unique identifier for the cited content. + locale: + type: string + description: Locale of the cited content (e.g., "en-us"). + title: + type: string + description: Title of the cited content. + type: + type: string + description: Type of the cited content. + enum: + - article + - community_post + - external_content_record + url: + type: string + description: URL to access the cited content. For articles and community posts, includes brand-specific subdomain. + nullable: true + id: + type: string + description: Unique identifier for the search request. HelpCenterLocalesResponse: type: object properties: @@ -7142,6 +6696,185 @@ components: properties: current_session: $ref: '#/components/schemas/HelpCenterSessionObject' + KnowledgeQuickAnswersRequest: + type: object + properties: + filters: + type: object + description: Optional filters to narrow down search results. + properties: + article_category_ids: + type: array + description: Array of article category IDs to filter results. + items: + type: integer + article_section_ids: + type: array + description: Array of article section IDs to filter results. + items: + type: integer + brand_ids: + type: array + description: Array of brand IDs to filter results. + items: + type: integer + external_source_ids: + type: array + description: Array of external source IDs to filter results. + items: + type: string + external_type_ids: + type: array + description: Array of external type IDs to filter results. + items: + type: string + locales: + type: array + description: Array of locale codes to filter results (e.g., ["en-us", "de"]). + items: + type: string + record_types: + type: array + description: Array of record types to filter results. + items: + type: string + enum: + - article + - external_content + query: + type: string + description: The search query from the agent. + search_id: + type: string + description: Unique identifier for tracking the search request. + ticket_id: + type: integer + description: The ID of the ticket for which the answer is being generated. + required: + - query + - ticket_id + - search_id + KnowledgeQuickAnswersResponse: + type: object + properties: + answer: + type: string + description: The AI-generated answer for the query. May be null if no answer was generated. + nullable: true + answer_found: + type: boolean + description: Indicates whether an answer was successfully generated. + citations: + type: array + description: List of source contents cited in the generated answer. + items: + type: object + properties: + id: + type: string + description: Unique identifier for the cited content. + locale: + type: string + description: Locale of the cited content (e.g., "en-us"). + title: + type: string + description: Title of the cited content. + type: + type: string + description: Type of the cited content. + enum: + - article + - community_post + - external_content_record + url: + type: string + description: URL to access the cited content. For articles and community posts, includes brand-specific subdomain. + nullable: true + id: + type: string + description: Unique identifier for the search request. + KnowledgeSuggestionsRequest: + type: object + properties: + filters: + type: object + description: Optional filters to narrow down search results. + properties: + article_category_ids: + type: array + description: Array of article category IDs to filter results. + items: + type: integer + article_section_ids: + type: array + description: Array of article section IDs to filter results. + items: + type: integer + brand_ids: + type: array + description: Array of brand IDs to filter results. + items: + type: integer + external_source_ids: + type: array + description: Array of external source IDs to filter results. + items: + type: string + external_type_ids: + type: array + description: Array of external type IDs to filter results. + items: + type: string + locales: + type: array + description: Array of locale codes to filter results (e.g., ["en-us", "de"]). + items: + type: string + record_types: + type: array + description: Array of record types to filter results. + items: + type: string + enum: + - article + - external_content + query: + type: string + description: The search query for finding relevant knowledge suggestions. + required: + - query + KnowledgeSuggestionsResponse: + type: object + properties: + id: + type: string + description: Unique identifier for the suggestions request. + records: + type: array + description: List of suggested knowledge records matching the query. Automatically deduplicated when multiple chunks from the same article are returned. + items: + type: object + properties: + id: + type: string + description: Unique identifier for the suggested record. + locale: + type: string + description: Locale of the record (e.g., "en-us"). + title: + type: string + description: Title of the suggested record. + type: + type: string + description: Type of the suggested record. + enum: + - article + - community_post + - external_content_record + url: + type: string + description: URL to access the record. For articles and community posts, includes brand-specific subdomain. May be null for external content records. + nullable: true LabelObject: type: object properties: diff --git a/packages/modules/surveyjs/CHANGELOG.md b/packages/modules/surveyjs/CHANGELOG.md index bfd2c9c81..edd064a73 100644 --- a/packages/modules/surveyjs/CHANGELOG.md +++ b/packages/modules/surveyjs/CHANGELOG.md @@ -1,5 +1,38 @@ # @o2s/modules.surveyjs +## 0.4.4 + +### Patch Changes + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- 338cb01: Refactor header access to use `HeaderName` constants instead of literal header keys across framework controllers, block harmonization services, and mocked auth guards. + + This unifies header handling, reduces string-key typos, and aligns modules with the typed headers approach exposed by `@o2s/framework/headers`. + +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + - @o2s/utils.api-harmonization@0.3.3 + +## 0.4.3 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [daf592e] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + - @o2s/utils.api-harmonization@0.3.2 + - @o2s/utils.frontend@0.5.1 + - @o2s/utils.logger@1.2.3 + - @o2s/ui@1.13.0 + ## 0.4.2 ### Patch Changes diff --git a/packages/modules/surveyjs/package.json b/packages/modules/surveyjs/package.json index f011a780e..41d5ceaa1 100644 --- a/packages/modules/surveyjs/package.json +++ b/packages/modules/surveyjs/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/modules.surveyjs", - "version": "0.4.2", + "version": "0.4.4", "private": false, "license": "MIT", "description": "A module that handles fetching, rendering ans submitting SurveyJS-based forms.", @@ -51,7 +51,7 @@ "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/modules/surveyjs/src/api-harmonization/surveyjs.controller.ts b/packages/modules/surveyjs/src/api-harmonization/surveyjs.controller.ts index e7d2d1426..e8bdf52ae 100644 --- a/packages/modules/surveyjs/src/api-harmonization/surveyjs.controller.ts +++ b/packages/modules/surveyjs/src/api-harmonization/surveyjs.controller.ts @@ -1,13 +1,15 @@ import { Body, Controller, Get, Headers, Post, Query } from '@nestjs/common'; import { Observable } from 'rxjs'; -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; import { URL } from './index'; import { SurveyJs } from './surveyjs.model'; import { SurveyJsQuery, SurveyJsSubmitPayload } from './surveyjs.request'; import { SurveyjsService } from './surveyjs.service'; +const H = HeaderName; + @Controller(URL) export class SurveyjsController { constructor(private readonly surveyjsService: SurveyjsService) {} @@ -18,7 +20,7 @@ export class SurveyjsController { } @Post() - submitSurvey(@Body() body: SurveyJsSubmitPayload, @Headers() headers: ApiModels.Headers.AppHeaders) { - return this.surveyjsService.submitSurvey(body, headers['authorization']); + submitSurvey(@Body() body: SurveyJsSubmitPayload, @Headers() headers: AppHeaders) { + return this.surveyjsService.submitSurvey(body, headers[H.Authorization]); } } diff --git a/packages/modules/surveyjs/src/sdk/index.ts b/packages/modules/surveyjs/src/sdk/index.ts index 186bf6625..412325ae9 100644 --- a/packages/modules/surveyjs/src/sdk/index.ts +++ b/packages/modules/surveyjs/src/sdk/index.ts @@ -1,7 +1,4 @@ // this unused import is necessary for TypeScript to properly resolve API methods -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; - import { extendSdk, getSdk } from '@o2s/framework/sdk'; import { surveyjs } from './surveyjs'; diff --git a/packages/modules/surveyjs/src/sdk/surveyjs.ts b/packages/modules/surveyjs/src/sdk/surveyjs.ts index d2649d3e8..73692fb5e 100644 --- a/packages/modules/surveyjs/src/sdk/surveyjs.ts +++ b/packages/modules/surveyjs/src/sdk/surveyjs.ts @@ -1,6 +1,6 @@ -import { Models as ApiModels } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; +import { AppHeaders } from '@o2s/framework/headers'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request, URL } from '../api-harmonization/surveyjs.client'; @@ -11,7 +11,7 @@ export const surveyjs = (sdk: Sdk) => ({ modules: { getSurvey: ( params: Request.SurveyJsQuery, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.SurveyJs> => sdk.makeRequest({ @@ -31,7 +31,7 @@ export const surveyjs = (sdk: Sdk) => ({ submitSurvey: ( params: Request.SurveyJsSubmitPayload, - headers: ApiModels.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<void> => { return sdk.makeRequest({ diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index 83b1ba213..8d154fd92 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,5 +1,12 @@ # @o2s/telemetry +## 1.2.2 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies + ## 1.2.1 ### Patch Changes diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 92a84b1c8..0827b2535 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/telemetry", - "version": "1.2.1", + "version": "1.2.2", "private": false, "license": "MIT", "description": "Optional telemetry package for O2S CLI tools to collect anonymous usage data.", @@ -43,7 +43,7 @@ "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "@types/configstore": "^6.0.2", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index c26f66df2..fcdb18431 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,5 +1,30 @@ # @o2s/ui +## 1.13.0 + +### Minor Changes + +- 375cd90: feat(blocks, ui): add variantId support to cart item handling, enhance add-to-cart toast with product name and cart link action across ProductDetails, ProductList and RecommendedProducts blocks + +### Patch Changes + +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + +## 1.12.0 + +### Minor Changes + +- 5d36519: Added new blocks: Cart, Checkout (Summary, Shipping Address, Company Data, Billing Payment) and Order Confirmation. Includes checkout forms validation (Formik + Yup), error handling, promo code support in cart, and new UI components (StepIndicator, RadioTile, AddressFields, CartSummary, QuantityInput, FormField). + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] + - @o2s/framework@1.19.0 + ## 1.11.0 ### Minor Changes diff --git a/packages/ui/package.json b/packages/ui/package.json index fb6a52fd2..edf77e217 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/ui", - "version": "1.11.0", + "version": "1.13.0", "private": true, "license": "MIT", "description": "Shared UI component library for O2S blocks, built on Radix UI and Tailwind CSS.", @@ -38,7 +38,7 @@ "@types/eslint": "^9.6.1", "@types/node": "^24.10.15", "@types/throttle-debounce": "^5.0.2", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "formik": "^2.4.9", "prettier": "^3.8.1", "sass": "^1.97.3", diff --git a/packages/ui/src/components/Cards/ProductCard/ProductCard.stories.tsx b/packages/ui/src/components/Cards/ProductCard/ProductCard.stories.tsx index 2f54351db..49da51c53 100644 --- a/packages/ui/src/components/Cards/ProductCard/ProductCard.stories.tsx +++ b/packages/ui/src/components/Cards/ProductCard/ProductCard.stories.tsx @@ -49,10 +49,7 @@ const sampleTags: ProductCardBadge[] = [ const sampleStatus: ProductCardBadge = { label: 'In Stock', variant: 'outline' }; -const sampleLink = { - label: 'View Details', - url: '/products/sample-product', -}; +const sampleLink = '/products/sample-product'; const sampleDescription = '<p>This is a sample product description. It provides information about the product features and benefits.</p>'; diff --git a/packages/ui/src/components/Cards/ProductCard/ProductCard.tsx b/packages/ui/src/components/Cards/ProductCard/ProductCard.tsx index 62f906fde..1df36407e 100644 --- a/packages/ui/src/components/Cards/ProductCard/ProductCard.tsx +++ b/packages/ui/src/components/Cards/ProductCard/ProductCard.tsx @@ -7,7 +7,6 @@ import { Price } from '@o2s/ui/components/Price'; import { RichText } from '@o2s/ui/components/RichText'; import { Badge } from '@o2s/ui/elements/badge'; -import { Link } from '@o2s/ui/elements/link'; import { Separator } from '@o2s/ui/elements/separator'; import { Typography } from '@o2s/ui/elements/typography'; @@ -25,7 +24,12 @@ export const ProductCard: React.FC<ProductCardProps> = ({ LinkComponent, }) => { return ( - <div className={cn('flex flex-col bg-card rounded-lg border border-border shadow-sm relative w-full h-full')}> + <div + className={cn( + 'flex flex-col bg-card rounded-lg border border-border shadow-sm relative w-full h-full', + link && 'transition-shadow hover:shadow-md', + )} + > {/* Image section */} {image?.url && image?.alt && ( <div className="relative overflow-hidden h-[180px] shrink-0 rounded-t-lg"> @@ -43,9 +47,20 @@ export const ProductCard: React.FC<ProductCardProps> = ({ {/* Content section */} <div className="flex flex-col flex-1"> <div className="flex flex-col gap-4"> - <Typography variant="highlightedSmall" className="line-clamp-2"> - {title} - </Typography> + {link ? ( + <LinkComponent + href={link} + className="after:absolute after:inset-0 after:content-[''] focus-visible:outline-none [&:focus-visible]:ring-2 [&:focus-visible]:ring-ring [&:focus-visible]:ring-offset-2 rounded-lg" + > + <Typography variant="highlightedSmall" className="line-clamp-2"> + {title} + </Typography> + </LinkComponent> + ) : ( + <Typography variant="highlightedSmall" className="line-clamp-2"> + {title} + </Typography> + )} {tags && tags.length > 0 && ( <ul @@ -72,32 +87,20 @@ export const ProductCard: React.FC<ProductCardProps> = ({ <Separator /> {/* Footer section */} - <div className="flex items-start sm:items-center justify-between gap-6 sm:flex-row flex-col w-full"> - <div className="flex flex-row gap-2 w-full sm:justify-start justify-between items-center"> + <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 w-full"> + <div className="flex flex-row gap-2 items-center"> <Typography variant="highlightedSmall" className="shrink-0"> <Price price={price} /> </Typography> {status && ( - <div className=""> - <Badge key={status.label} variant={status.variant} className="w-fit"> - {status.label} - </Badge> - </div> + <Badge key={status.label} variant={status.variant} className="w-fit"> + {status.label} + </Badge> )} </div> - {(action || link) && ( - <div className="flex sm:items-center gap-4 sm:flex-row flex-col w-full justify-end"> - {action} - - {link && ( - <Link asChild variant="primary" size="default"> - <LinkComponent href={link.url}>{link.label}</LinkComponent> - </Link> - )} - </div> - )} + {action && <div className="relative z-10 [&>*]:w-full [&>*]:sm:w-auto">{action}</div>} </div> </div> </div> diff --git a/packages/ui/src/components/Cards/ProductCard/ProductCard.types.ts b/packages/ui/src/components/Cards/ProductCard/ProductCard.types.ts index 242c07715..acd05016b 100644 --- a/packages/ui/src/components/Cards/ProductCard/ProductCard.types.ts +++ b/packages/ui/src/components/Cards/ProductCard/ProductCard.types.ts @@ -12,10 +12,7 @@ export interface ProductCardProps { price?: Models.Price.Price; tags?: ProductCardBadge[]; status?: ProductCardBadge; - link?: { - label: string; - url: string; - }; + link?: string; image?: Models.Media.Media; action?: React.ReactNode; LinkComponent: FrontendModels.Link.LinkComponent; diff --git a/packages/ui/src/components/Carousel/Carousel.tsx b/packages/ui/src/components/Carousel/Carousel.tsx index 877d5f98a..bce6edb30 100644 --- a/packages/ui/src/components/Carousel/Carousel.tsx +++ b/packages/ui/src/components/Carousel/Carousel.tsx @@ -98,11 +98,11 @@ export const Carousel: React.FC<Readonly<CarouselProps>> = ({ </Swiper> {showNavigation && ( - <div className="absolute z-10 left-0 right-0 top-2/4 -translate-y-8 flex items-center justify-between px-2"> + <div className="absolute z-10 left-0 right-0 top-2/4 -translate-y-8 flex items-center justify-between px-2 pointer-events-none"> <Button variant="outline" size="icon" - className="rounded-full" + className="rounded-full pointer-events-auto" disabled={index === 0 && !loop} aria-label={labels.previous} onClick={() => { @@ -115,7 +115,7 @@ export const Carousel: React.FC<Readonly<CarouselProps>> = ({ <Button variant="outline" size="icon" - className="rounded-full" + className="rounded-full pointer-events-auto" disabled={isEnd && !loop} aria-label={labels.next} onClick={() => { diff --git a/packages/ui/src/components/Cart/CartItem/CartItem.stories.tsx b/packages/ui/src/components/Cart/CartItem/CartItem.stories.tsx new file mode 100644 index 000000000..110f54d9a --- /dev/null +++ b/packages/ui/src/components/Cart/CartItem/CartItem.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from 'storybook/test'; + +import { CartItem } from './CartItem'; + +const meta: Meta<typeof CartItem> = { + title: 'Components/Cart/CartItem', + component: CartItem, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<typeof CartItem>; + +const defaultArgs = { + id: 'cart-item-001', + productId: 'PRIM-001', + name: 'CLARIS S filter cartridge', + subtitle: 'Filters • JURA', + image: { + url: 'https://picsum.photos/200/200', + alt: 'CLARIS S filtering cartridge', + }, + quantity: 2, + price: { value: 89.99, currency: 'PLN' as const }, + total: { value: 179.98, currency: 'PLN' as const }, + labels: { + itemTotal: 'Subtotal', + remove: 'Remove product', + increaseQuantity: 'Increase quantity', + decreaseQuantity: 'Decrease quantity', + quantity: 'Quantity', + }, + onRemove: fn(), + onQuantityChange: fn(), +}; + +export const Default: Story = { + args: defaultArgs, +}; diff --git a/packages/ui/src/components/Cart/CartItem/CartItem.tsx b/packages/ui/src/components/Cart/CartItem/CartItem.tsx new file mode 100644 index 000000000..52dc5f316 --- /dev/null +++ b/packages/ui/src/components/Cart/CartItem/CartItem.tsx @@ -0,0 +1,98 @@ +'use client'; + +import React from 'react'; + +import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; +import { Image } from '@o2s/ui/components/Image'; +import { Price } from '@o2s/ui/components/Price'; +import { QuantityInput } from '@o2s/ui/components/QuantityInput'; + +import { Button } from '@o2s/ui/elements/button'; +import { Typography } from '@o2s/ui/elements/typography'; + +import { CartItemProps } from './CartItem.types'; + +export const CartItem: React.FC<Readonly<CartItemProps>> = ({ + id, + productId: _productId, + productUrl, + name, + subtitle, + image, + quantity, + price: _price, + total, + labels, + onRemove, + onQuantityChange, + LinkComponent, +}) => { + const imageContent = image && ( + <div className="relative w-32 h-32 shrink-0 rounded-md overflow-hidden bg-muted"> + <Image src={image.url} alt={image.alt || name} fill sizes="128px" className="object-cover object-center" /> + </div> + ); + + return ( + <div className="flex flex-row gap-4 p-4 bg-card rounded-lg border border-border"> + {/* Product Image */} + {image && + (productUrl && LinkComponent ? ( + <LinkComponent href={productUrl} className="shrink-0"> + {imageContent} + </LinkComponent> + ) : ( + imageContent + ))} + + {/* Product Info */} + <div className="min-w-0 flex-1 flex flex-col gap-2 justify-between"> + <div className="flex items-start justify-between gap-4"> + <div className="flex-1"> + {productUrl && LinkComponent ? ( + <LinkComponent href={productUrl}> + <Typography variant="h3" className="mb-1 hover:underline"> + {name} + </Typography> + </LinkComponent> + ) : ( + <Typography variant="h3" className="mb-1"> + {name} + </Typography> + )} + {subtitle && ( + <Typography variant="small" className="text-muted-foreground"> + {subtitle} + </Typography> + )} + </div> + <Button variant="ghost" size="icon" onClick={() => onRemove(id)} aria-label={labels.remove}> + <DynamicIcon name="X" size={20} /> + </Button> + </div> + + <div className="flex flex-wrap gap-x-4 gap-y-2"> + <QuantityInput + value={quantity} + onChange={(newQuantity) => onQuantityChange(id, newQuantity)} + labels={{ + increase: labels.increaseQuantity, + decrease: labels.decreaseQuantity, + quantity: labels.quantity, + }} + /> + + {/* Item Total */} + <div className="ml-auto flex min-w-0 flex-col items-end"> + <Typography variant="small" className="text-muted-foreground"> + {labels.itemTotal} + </Typography> + <Typography variant="h3" className="text-right text-primary"> + <Price price={total} /> + </Typography> + </div> + </div> + </div> + </div> + ); +}; diff --git a/packages/ui/src/components/Cart/CartItem/CartItem.types.ts b/packages/ui/src/components/Cart/CartItem/CartItem.types.ts new file mode 100644 index 000000000..7e947534f --- /dev/null +++ b/packages/ui/src/components/Cart/CartItem/CartItem.types.ts @@ -0,0 +1,24 @@ +import { Models } from '@o2s/framework/modules'; +import React from 'react'; + +export interface CartItemProps { + id: string; + productId: string; + productUrl?: string; + name: string; + subtitle?: string; + image?: { url: string; alt?: string }; + quantity: number; + price: Models.Price.Price; + total: Models.Price.Price; + labels: { + itemTotal: string; + remove: string; + increaseQuantity: string; + decreaseQuantity: string; + quantity: string; + }; + onRemove: (itemId: string) => void; + onQuantityChange: (itemId: string, quantity: number) => void; + LinkComponent?: React.ComponentType<{ href: string; children: React.ReactNode; className?: string }>; +} diff --git a/packages/ui/src/components/Cart/CartItem/index.ts b/packages/ui/src/components/Cart/CartItem/index.ts new file mode 100644 index 000000000..25bdcdca8 --- /dev/null +++ b/packages/ui/src/components/Cart/CartItem/index.ts @@ -0,0 +1,2 @@ +export { CartItem } from './CartItem'; +export type { CartItemProps } from './CartItem.types'; diff --git a/packages/ui/src/components/Cart/CartPromoCode/CartPromoCode.stories.tsx b/packages/ui/src/components/Cart/CartPromoCode/CartPromoCode.stories.tsx new file mode 100644 index 000000000..7692edafd --- /dev/null +++ b/packages/ui/src/components/Cart/CartPromoCode/CartPromoCode.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from 'storybook/test'; + +import { CartPromoCode } from './CartPromoCode'; + +const meta: Meta<typeof CartPromoCode> = { + title: 'Components/Cart/CartPromoCode', + component: CartPromoCode, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<typeof CartPromoCode>; + +export const Default: Story = { + args: { + promotions: [ + { code: 'SAVE10', name: '10% Off' }, + { code: 'FREESHIP', name: 'Free Shipping' }, + ], + labels: { + title: 'Promo Code', + inputPlaceholder: 'Enter promo code', + applyButton: 'Apply', + removeLabel: 'Remove promo code', + invalidCodeError: 'Invalid or expired promo code', + }, + isLoading: false, + onApply: fn(), + onRemove: fn(), + }, +}; diff --git a/packages/ui/src/components/Cart/CartPromoCode/CartPromoCode.tsx b/packages/ui/src/components/Cart/CartPromoCode/CartPromoCode.tsx new file mode 100644 index 000000000..6477e4dd9 --- /dev/null +++ b/packages/ui/src/components/Cart/CartPromoCode/CartPromoCode.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useState } from 'react'; + +import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; + +import { Button } from '@o2s/ui/elements/button'; +import { InputWithLabel } from '@o2s/ui/elements/input'; +import { Separator } from '@o2s/ui/elements/separator'; +import { Typography } from '@o2s/ui/elements/typography'; + +import { CartPromoCodeProps } from './CartPromoCode.types'; + +export const CartPromoCode: React.FC<Readonly<CartPromoCodeProps>> = ({ + promotions, + labels, + isLoading = false, + onApply, + onRemove, +}) => { + const [code, setCode] = useState(''); + const [error, setError] = useState<string | undefined>(); + + const handleApply = async () => { + const trimmed = code.trim(); + if (!trimmed) return; + + setError(undefined); + try { + await onApply(trimmed.toUpperCase()); + setCode(''); + } catch { + setError(labels.invalidCodeError); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === 'Enter') { + handleApply(); + } + }; + + const hasPromotions = promotions && promotions.length > 0; + + return ( + <div className="flex flex-col gap-4 p-6 bg-card rounded-lg border border-border"> + <Typography variant="h2">{labels.title}</Typography> + + <div className="flex gap-2"> + <div className="flex-1"> + <InputWithLabel + label={labels.inputPlaceholder} + isLabelHidden + type="text" + value={code} + onChange={(e) => setCode(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={labels.inputPlaceholder} + disabled={isLoading} + hasError={!!error} + className="uppercase placeholder:normal-case" + /> + </div> + <Button variant="outline" onClick={handleApply} disabled={isLoading || !code.trim()}> + {labels.applyButton} + </Button> + </div> + + {error && ( + <Typography variant="small" className="-mt-2 text-destructive"> + {error} + </Typography> + )} + + {hasPromotions && ( + <> + <Separator /> + <div className="flex flex-col gap-2"> + {promotions.map((promo) => ( + <div key={promo.code} className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <DynamicIcon name="Tag" size={14} className="shrink-0 text-green-600" /> + <Typography variant="small" className="text-green-600"> + {promo.code} + {promo.name && ` — ${promo.name}`} + </Typography> + </div> + <Button + variant="ghost" + size="icon" + className="h-6 w-6" + disabled={isLoading} + aria-label={labels.removeLabel} + onClick={() => onRemove(promo.code)} + > + <DynamicIcon name="X" size={14} /> + </Button> + </div> + ))} + </div> + </> + )} + </div> + ); +}; diff --git a/packages/ui/src/components/Cart/CartPromoCode/CartPromoCode.types.ts b/packages/ui/src/components/Cart/CartPromoCode/CartPromoCode.types.ts new file mode 100644 index 000000000..ac64847b3 --- /dev/null +++ b/packages/ui/src/components/Cart/CartPromoCode/CartPromoCode.types.ts @@ -0,0 +1,20 @@ +export interface CartPromoCodePromotion { + code: string; + name?: string; +} + +export interface CartPromoCodeLabels { + title: string; + inputPlaceholder: string; + applyButton: string; + removeLabel: string; + invalidCodeError: string; +} + +export interface CartPromoCodeProps { + promotions?: CartPromoCodePromotion[]; + labels: CartPromoCodeLabels; + isLoading?: boolean; + onApply: (code: string) => Promise<void>; + onRemove: (code: string) => Promise<void>; +} diff --git a/packages/ui/src/components/Cart/CartPromoCode/index.ts b/packages/ui/src/components/Cart/CartPromoCode/index.ts new file mode 100644 index 000000000..0be7ee2da --- /dev/null +++ b/packages/ui/src/components/Cart/CartPromoCode/index.ts @@ -0,0 +1,2 @@ +export * from './CartPromoCode'; +export * from './CartPromoCode.types'; diff --git a/packages/ui/src/components/Cart/CartSummary/CartSummary.stories.tsx b/packages/ui/src/components/Cart/CartSummary/CartSummary.stories.tsx new file mode 100644 index 000000000..30fc4f531 --- /dev/null +++ b/packages/ui/src/components/Cart/CartSummary/CartSummary.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { CartSummary } from './CartSummary'; + +const meta: Meta<typeof CartSummary> = { + title: 'Components/Cart/CartSummary', + component: CartSummary, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<typeof CartSummary>; + +export const Default: Story = { + args: { + subtotal: { value: 204.97, currency: 'EUR' as const }, + tax: { value: 47.14, currency: 'EUR' as const }, + total: { value: 231.61, currency: 'EUR' as const }, + discountTotal: { value: 20.5, currency: 'EUR' as const }, + shippingTotal: { value: 15.0, currency: 'EUR' as const }, + labels: { + title: 'Summary', + subtotalLabel: 'Subtotal', + taxLabel: 'VAT (23%)', + totalLabel: 'Total', + discountLabel: 'Discount', + shippingLabel: 'Shipping', + freeLabel: 'Free', + }, + primaryButton: { + label: 'Next step', + action: { type: 'submit', form: 'checkout-form' }, + }, + secondaryButton: { + label: 'Back', + action: { type: 'link', url: '#' }, + }, + }, +}; diff --git a/packages/ui/src/components/Cart/CartSummary/CartSummary.tsx b/packages/ui/src/components/Cart/CartSummary/CartSummary.tsx new file mode 100644 index 000000000..baa22556c --- /dev/null +++ b/packages/ui/src/components/Cart/CartSummary/CartSummary.tsx @@ -0,0 +1,189 @@ +'use client'; + +import React from 'react'; + +import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; +import { Price } from '@o2s/ui/components/Price'; + +import { Button } from '@o2s/ui/elements/button'; +import { Separator } from '@o2s/ui/elements/separator'; +import { Skeleton } from '@o2s/ui/elements/skeleton'; +import { Typography } from '@o2s/ui/elements/typography'; + +import { CartSummaryButton, CartSummaryProps } from './CartSummary.types'; + +const renderButton = ( + btn: CartSummaryButton, + variant: 'default' | 'outline', + LinkComponent?: CartSummaryProps['LinkComponent'], +): React.ReactNode => { + if (btn.action.type === 'submit') { + return ( + <Button + type="submit" + form={btn.action.form} + variant={variant} + size="lg" + className="w-full" + disabled={btn.disabled} + > + {btn.icon && <DynamicIcon name={btn.icon} size={20} className="mr-2" />} + {btn.label} + </Button> + ); + } + + if (btn.action.type === 'click') { + return ( + <Button variant={variant} size="lg" className="w-full" onClick={btn.action.onClick} disabled={btn.disabled}> + {btn.icon && <DynamicIcon name={btn.icon} size={20} className="mr-2" />} + {btn.label} + </Button> + ); + } + + if (!LinkComponent) return null; + + return ( + <Button variant={variant} size="lg" className="w-full" asChild disabled={btn.disabled}> + <LinkComponent href={btn.action.url} className="w-full"> + {btn.icon && <DynamicIcon name={btn.icon} size={20} className="mr-2" />} + {btn.label} + </LinkComponent> + </Button> + ); +}; + +export const CartSummary: React.FC<Readonly<CartSummaryProps>> = ({ + subtotal, + tax, + total, + discountTotal, + shippingTotal, + promotions, + notes, + labels, + LinkComponent, + primaryButton, + secondaryButton, +}) => { + const hasDiscount = discountTotal && discountTotal.value > 0; + const isFreeShipping = promotions?.some((p) => p.type === 'FREE_SHIPPING'); + + return ( + <div className="flex flex-col gap-4 p-6 bg-card rounded-lg border border-border"> + <Typography variant="h2">{labels.title}</Typography> + + <div className="flex flex-col gap-4"> + <div className="flex flex-col gap-1"> + <div className="flex justify-between gap-4"> + <Typography variant="small" className="text-muted-foreground"> + {labels.subtotalLabel} + </Typography> + <Typography variant="body" className="whitespace-nowrap shrink-0"> + <Price price={subtotal} /> + </Typography> + </div> + + <div className="flex justify-between gap-4"> + <Typography variant="small" className="text-muted-foreground"> + {labels.taxLabel} + </Typography> + <Typography variant="body" className="whitespace-nowrap shrink-0"> + <Price price={tax} /> + </Typography> + </div> + + {hasDiscount && ( + <div className="flex justify-between gap-4"> + <Typography variant="small" className="text-muted-foreground"> + {labels.discountLabel} + </Typography> + <Typography variant="body" className="text-green-600 whitespace-nowrap shrink-0"> + -<Price price={discountTotal} /> + </Typography> + </div> + )} + + {shippingTotal && ( + <div className="flex justify-between gap-4"> + <Typography variant="small" className="text-muted-foreground"> + {labels.shippingLabel} + </Typography> + <Typography variant="body" className="whitespace-nowrap shrink-0"> + {isFreeShipping ? ( + <span className="text-green-600">{labels.freeLabel}</span> + ) : ( + <Price price={shippingTotal} /> + )} + </Typography> + </div> + )} + </div> + + <Separator /> + + <div className="flex justify-between gap-4"> + <Typography variant="h3">{labels.totalLabel}</Typography> + <Typography variant="h2" className="text-primary whitespace-nowrap shrink-0"> + <Price price={total} /> + </Typography> + </div> + </div> + + {promotions && promotions.length > 0 && labels.activePromoCodesTitle && ( + <> + <Separator /> + <div className="flex flex-col gap-2"> + <Typography variant="h3">{labels.activePromoCodesTitle}</Typography> + <ul className="flex flex-col gap-2 list-none"> + {promotions.map((promo) => ( + <li + key={promo.code} + className="flex items-center gap-2 rounded-md bg-green-50 px-3 py-2 dark:bg-green-950/20" + > + <DynamicIcon name="Tag" size={14} className="shrink-0 text-green-600" /> + <Typography variant="small" className="text-green-600"> + {promo.code} + {promo.name && ` — ${promo.name}`} + </Typography> + </li> + ))} + </ul> + </div> + </> + )} + + {notes && ( + <> + <Separator /> + <div className="flex flex-col gap-2"> + <Typography variant="h3">{notes.title}</Typography> + <Typography variant="small" className="text-muted-foreground"> + {notes.content} + </Typography> + </div> + </> + )} + + {(primaryButton || secondaryButton) && ( + <> + <Separator /> + <div className="flex flex-col gap-3"> + {primaryButton && renderButton(primaryButton, 'default', LinkComponent)} + {secondaryButton && renderButton(secondaryButton, 'outline', LinkComponent)} + </div> + </> + )} + </div> + ); +}; + +export const CartSummarySkeleton: React.FC = () => ( + <div className="flex flex-col gap-4 p-6 bg-card rounded-lg border border-border"> + <Skeleton className="h-6 w-32" /> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-6 w-full" /> + </div> +); diff --git a/packages/ui/src/components/Cart/CartSummary/CartSummary.types.ts b/packages/ui/src/components/Cart/CartSummary/CartSummary.types.ts new file mode 100644 index 000000000..785dc65e4 --- /dev/null +++ b/packages/ui/src/components/Cart/CartSummary/CartSummary.types.ts @@ -0,0 +1,48 @@ +import { Models } from '@o2s/framework/modules'; + +import { Models as FrontendModels } from '@o2s/utils.frontend'; + +export interface CartSummaryPromotion { + code: string; + name?: string; + type?: 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FREE_SHIPPING'; + value?: string; +} + +export type CartSummaryButtonAction = + | { type: 'link'; url: string } + | { type: 'submit'; form?: string } + | { type: 'click'; onClick: () => void }; + +export interface CartSummaryButton { + label: string; + icon?: string; + disabled?: boolean; + action: CartSummaryButtonAction; +} + +export interface CartSummaryProps { + subtotal: Models.Price.Price; + tax: Models.Price.Price; + total: Models.Price.Price; + discountTotal?: Models.Price.Price; + shippingTotal?: Models.Price.Price; + promotions?: CartSummaryPromotion[]; + notes?: { + title: string; + content: string; + }; + labels: { + title: string; + subtotalLabel: string; + taxLabel: string; + totalLabel: string; + discountLabel?: string; + shippingLabel?: string; + freeLabel?: string; + activePromoCodesTitle?: string; + }; + LinkComponent?: FrontendModels.Link.LinkComponent; + primaryButton?: CartSummaryButton; + secondaryButton?: CartSummaryButton; +} diff --git a/packages/ui/src/components/Cart/CartSummary/index.ts b/packages/ui/src/components/Cart/CartSummary/index.ts new file mode 100644 index 000000000..52e9830a9 --- /dev/null +++ b/packages/ui/src/components/Cart/CartSummary/index.ts @@ -0,0 +1,2 @@ +export { CartSummary, CartSummarySkeleton } from './CartSummary'; +export type { CartSummaryProps } from './CartSummary.types'; diff --git a/packages/ui/src/components/Cart/index.ts b/packages/ui/src/components/Cart/index.ts new file mode 100644 index 000000000..2c3127d90 --- /dev/null +++ b/packages/ui/src/components/Cart/index.ts @@ -0,0 +1,3 @@ +export * from './CartItem'; +export * from './CartPromoCode'; +export * from './CartSummary'; diff --git a/packages/ui/src/components/Checkout/AddressFields/AddressFields.stories.tsx b/packages/ui/src/components/Checkout/AddressFields/AddressFields.stories.tsx new file mode 100644 index 000000000..04c610e83 --- /dev/null +++ b/packages/ui/src/components/Checkout/AddressFields/AddressFields.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Form, Formik } from 'formik'; +import React from 'react'; + +import { AddressFields } from './AddressFields'; +import type { AddressFieldsProps } from './AddressFields.types'; + +const mockFields: AddressFieldsProps['fields'] = { + streetName: { + label: 'Street name', + placeholder: 'e.g. Main Street', + required: true, + }, + streetNumber: { + label: 'Number', + placeholder: 'e.g. 123', + required: true, + }, + apartment: { + label: 'Apartment / suite', + placeholder: 'e.g. 4B', + required: false, + }, + city: { + label: 'City', + placeholder: 'City', + required: true, + }, + postalCode: { + label: 'Postal code', + placeholder: 'XX-XXX', + required: true, + }, + country: { + label: 'Country', + placeholder: 'Country', + required: true, + }, +}; + +const meta: Meta<typeof AddressFields> = { + title: 'Components/Checkout/AddressFields', + component: AddressFields, + tags: ['autodocs'], + decorators: [ + (Story) => ( + <Formik + initialValues={{ + streetName: '', + streetNumber: '', + apartment: '', + city: '', + postalCode: '', + country: '', + }} + onSubmit={() => {}} + > + <Form> + <Story /> + </Form> + </Formik> + ), + ], +}; + +export default meta; +type Story = StoryObj<typeof AddressFields>; + +export const Default: Story = { + args: { + fields: mockFields, + locale: 'en', + }, +}; diff --git a/packages/ui/src/components/Checkout/AddressFields/AddressFields.tsx b/packages/ui/src/components/Checkout/AddressFields/AddressFields.tsx new file mode 100644 index 000000000..9fea8dd2a --- /dev/null +++ b/packages/ui/src/components/Checkout/AddressFields/AddressFields.tsx @@ -0,0 +1,72 @@ +import { ErrorMessage, Field, FieldProps } from 'formik'; +import React from 'react'; + +import { FormField } from '@o2s/ui/components/Checkout/FormField'; + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@o2s/ui/elements/select'; +import { Typography } from '@o2s/ui/elements/typography'; + +import type { AddressFieldsProps } from './AddressFields.types'; + +const COUNTRY_CODES = ['PL', 'DE', 'GB'] as const; + +export const AddressFields: React.FC<Readonly<AddressFieldsProps>> = ({ fields, idPrefix = '', locale }) => { + const countryNames = new Intl.DisplayNames([locale], { type: 'region' }); + const name = (fieldName: string) => + idPrefix ? `${idPrefix}${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}` : fieldName; + + return ( + <div className="w-full grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="md:col-span-2"> + <FormField name={name('streetName')} field={fields.streetName} /> + </div> + + {(fields.streetNumber || fields.apartment) && ( + <> + {fields.streetNumber && <FormField name={name('streetNumber')} field={fields.streetNumber} />} + + {fields.apartment && <FormField name={name('apartment')} field={fields.apartment} />} + </> + )} + + <FormField name={name('city')} field={fields.city} /> + + <FormField name={name('postalCode')} field={fields.postalCode} /> + + <div className="md:col-span-2"> + <Field name={name('country')}> + {({ field, form: { touched, errors, setFieldValue } }: FieldProps<string>) => { + const fieldName = name('country'); + const fieldTouched = (touched as Record<string, boolean>)[fieldName]; + const fieldError = (errors as Record<string, string>)[fieldName]; + const hasError = !!(fieldTouched && fieldError); + + return ( + <div className="grid gap-2"> + <Select value={field.value} onValueChange={(value) => setFieldValue(fieldName, value)}> + <SelectTrigger id={fieldName} hasError={hasError}> + <SelectValue placeholder={fields.country.placeholder} /> + </SelectTrigger> + <SelectContent> + {COUNTRY_CODES.map((code) => ( + <SelectItem key={code} value={code}> + {countryNames.of(code)} + </SelectItem> + ))} + </SelectContent> + </Select> + <ErrorMessage name={fieldName}> + {(msg) => ( + <Typography variant="small" className="text-destructive"> + {msg} + </Typography> + )} + </ErrorMessage> + </div> + ); + }} + </Field> + </div> + </div> + ); +}; diff --git a/packages/ui/src/components/Checkout/AddressFields/AddressFields.types.ts b/packages/ui/src/components/Checkout/AddressFields/AddressFields.types.ts new file mode 100644 index 000000000..323049ac3 --- /dev/null +++ b/packages/ui/src/components/Checkout/AddressFields/AddressFields.types.ts @@ -0,0 +1,19 @@ +export interface AddressFieldConfig { + label: string; + placeholder?: string; + required: boolean; +} + +export interface AddressFieldsProps { + fields: { + streetName: AddressFieldConfig; + streetNumber?: AddressFieldConfig; + apartment?: AddressFieldConfig; + city: AddressFieldConfig; + postalCode: AddressFieldConfig; + country: AddressFieldConfig; + }; + /** Prefix for element ids (e.g. 'billing' -> billingStreetName, billingCity...) */ + idPrefix?: string; + locale: string; +} diff --git a/packages/ui/src/components/Checkout/AddressFields/index.ts b/packages/ui/src/components/Checkout/AddressFields/index.ts new file mode 100644 index 000000000..504c5c713 --- /dev/null +++ b/packages/ui/src/components/Checkout/AddressFields/index.ts @@ -0,0 +1,2 @@ +export { AddressFields } from './AddressFields'; +export type { AddressFieldsProps, AddressFieldConfig } from './AddressFields.types'; diff --git a/packages/ui/src/components/Checkout/FormField/FormField.tsx b/packages/ui/src/components/Checkout/FormField/FormField.tsx new file mode 100644 index 000000000..32fb703a0 --- /dev/null +++ b/packages/ui/src/components/Checkout/FormField/FormField.tsx @@ -0,0 +1,39 @@ +import { Field, FieldProps } from 'formik'; +import React from 'react'; + +import { InputWithDetails } from '@o2s/ui/elements/input'; + +import type { FormFieldProps } from './FormField.types'; + +export const FormField: React.FC<Readonly<FormFieldProps>> = ({ + name, + field: fieldConfig, + type = 'text', + disabled = false, + className, +}) => { + return ( + <Field name={name}> + {({ field, form: { touched, errors } }: FieldProps<string>) => { + const fieldTouched = (touched as Record<string, boolean>)[name]; + const fieldError = (errors as Record<string, string>)[name]; + const hasError = !!(fieldTouched && fieldError); + + return ( + <InputWithDetails + id={name} + type={type} + label={fieldConfig.label} + placeholder={fieldConfig.placeholder} + isRequired={fieldConfig.required} + disabled={disabled} + hasError={hasError} + errorMessage={hasError ? String(fieldError) : undefined} + className={className} + {...field} + /> + ); + }} + </Field> + ); +}; diff --git a/packages/ui/src/components/Checkout/FormField/FormField.types.ts b/packages/ui/src/components/Checkout/FormField/FormField.types.ts new file mode 100644 index 000000000..fb4e1fc90 --- /dev/null +++ b/packages/ui/src/components/Checkout/FormField/FormField.types.ts @@ -0,0 +1,13 @@ +export interface FieldConfig { + label: string; + placeholder?: string; + required: boolean; +} + +export interface FormFieldProps { + name: string; + field: FieldConfig; + type?: 'text' | 'email' | 'tel' | 'number'; + disabled?: boolean; + className?: string; +} diff --git a/packages/ui/src/components/Checkout/FormField/index.ts b/packages/ui/src/components/Checkout/FormField/index.ts new file mode 100644 index 000000000..d84cd43bf --- /dev/null +++ b/packages/ui/src/components/Checkout/FormField/index.ts @@ -0,0 +1,2 @@ +export { FormField } from './FormField'; +export type { FieldConfig, FormFieldProps } from './FormField.types'; diff --git a/packages/ui/src/components/Checkout/StepIndicator/StepIndicator.stories.tsx b/packages/ui/src/components/Checkout/StepIndicator/StepIndicator.stories.tsx new file mode 100644 index 000000000..0104c950d --- /dev/null +++ b/packages/ui/src/components/Checkout/StepIndicator/StepIndicator.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { StepIndicator } from './StepIndicator'; + +const meta: Meta<typeof StepIndicator> = { + title: 'Components/Checkout/StepIndicator', + component: StepIndicator, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<typeof StepIndicator>; + +export const Default: Story = { + args: { + steps: ['Company details', 'Delivery', 'Payment', 'Summary'], + currentStep: 2, + }, +}; diff --git a/packages/ui/src/components/Checkout/StepIndicator/StepIndicator.tsx b/packages/ui/src/components/Checkout/StepIndicator/StepIndicator.tsx new file mode 100644 index 000000000..4b06679ff --- /dev/null +++ b/packages/ui/src/components/Checkout/StepIndicator/StepIndicator.tsx @@ -0,0 +1,127 @@ +import React, { useMemo } from 'react'; + +import { cn } from '@o2s/ui/lib/utils'; + +import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; + +import { Typography } from '@o2s/ui/elements/typography'; + +import type { StepIndicatorProps } from './StepIndicator.types'; + +type StepState = { + stepNumber: number; + isActive: boolean; + isCompleted: boolean; + isLast: boolean; + isFilled: boolean; +}; + +function getStepState(index: number, currentStep: number, totalSteps: number): StepState { + const stepNumber = index + 1; + return { + stepNumber, + isActive: stepNumber === currentStep, + isCompleted: stepNumber < currentStep, + isLast: index === totalSteps - 1, + isFilled: stepNumber <= currentStep, + }; +} + +function StepCircle({ + stepNumber, + isCompleted, + isFilled, + className, +}: { + stepNumber: number; + isCompleted: boolean; + isFilled: boolean; + className?: string; +}) { + return ( + <div + className={cn( + 'h-10 w-10 rounded-full flex items-center justify-center shrink-0', + isFilled ? 'bg-success text-success-foreground' : 'border-2 border-border bg-background', + className, + )} + > + {isCompleted ? ( + <DynamicIcon name="Check" size={20} strokeWidth={2.5} /> + ) : ( + <span className="text-sm font-semibold">{stepNumber}</span> + )} + </div> + ); +} + +export const StepIndicator: React.FC<Readonly<StepIndicatorProps>> = ({ steps, currentStep }) => { + const stepsWithState = useMemo( + () => steps.map((label, index) => ({ label, ...getStepState(index, currentStep, steps.length) })), + [steps, currentStep], + ); + + return ( + <div className="w-full"> + {/* Desktop: flex with justify-between, connector lines via ::after */} + <ol aria-label="Checkout progress" className="hidden md:flex flex-col gap-4"> + <div className="flex justify-between"> + {stepsWithState.map(({ label, stepNumber, isActive, isCompleted, isLast, isFilled }) => ( + <li + key={stepNumber} + aria-current={isActive ? 'step' : undefined} + className={cn( + 'flex-1 flex flex-col items-center min-w-0 relative', + !isLast && + "after:content-[''] after:absolute after:top-5 after:left-[calc(50%+40px)] after:h-0.5 after:w-[calc(100%-80px)] after:-translate-y-1/2 after:z-0", + !isLast && (isCompleted ? 'after:bg-success' : 'after:bg-border'), + )} + > + <StepCircle + stepNumber={stepNumber} + isCompleted={isCompleted} + isFilled={isFilled} + className="relative z-10" + /> + <Typography + variant="small" + className={cn( + 'text-center mt-2 px-2 w-full', + isActive ? 'font-semibold' : 'text-muted-foreground', + )} + > + {label} + </Typography> + </li> + ))} + </div> + </ol> + + {/* Mobile: vertical layout */} + <ol aria-label="Checkout progress" className="flex flex-col md:hidden gap-0"> + {stepsWithState.map(({ label, stepNumber, isActive, isCompleted, isLast, isFilled }) => ( + <li + key={stepNumber} + aria-current={isActive ? 'step' : undefined} + className="flex items-start gap-3" + > + <div className="flex flex-col items-center shrink-0"> + <StepCircle stepNumber={stepNumber} isCompleted={isCompleted} isFilled={isFilled} /> + {!isLast && ( + <div className={cn('w-0.5 h-8 my-2', isCompleted ? 'bg-success' : 'bg-border')} /> + )} + </div> + <div className="pt-2 pb-4"> + <Typography + variant="small" + className={cn(isActive ? 'font-semibold' : 'text-muted-foreground')} + > + {label} + </Typography> + </div> + </li> + ))} + </ol> + </div> + ); +}; diff --git a/packages/ui/src/components/Checkout/StepIndicator/StepIndicator.types.ts b/packages/ui/src/components/Checkout/StepIndicator/StepIndicator.types.ts new file mode 100644 index 000000000..e8177a70d --- /dev/null +++ b/packages/ui/src/components/Checkout/StepIndicator/StepIndicator.types.ts @@ -0,0 +1,4 @@ +export interface StepIndicatorProps { + steps: string[]; + currentStep: number; +} diff --git a/packages/ui/src/components/Checkout/StepIndicator/index.ts b/packages/ui/src/components/Checkout/StepIndicator/index.ts new file mode 100644 index 000000000..241f3cbab --- /dev/null +++ b/packages/ui/src/components/Checkout/StepIndicator/index.ts @@ -0,0 +1,2 @@ +export { StepIndicator } from './StepIndicator'; +export type { StepIndicatorProps } from './StepIndicator.types'; diff --git a/packages/ui/src/components/DynamicIcon/DynamicIcon.tsx b/packages/ui/src/components/DynamicIcon/DynamicIcon.tsx index 39d4768a8..ebe2c1935 100644 --- a/packages/ui/src/components/DynamicIcon/DynamicIcon.tsx +++ b/packages/ui/src/components/DynamicIcon/DynamicIcon.tsx @@ -28,6 +28,8 @@ export const DynamicIcon: React.FC<DynamicIconProps> = ({ width: size, height: size, display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', }} > <LucideDynamicIcon diff --git a/packages/ui/src/components/ProductCarousel/ProductCarousel.stories.tsx b/packages/ui/src/components/ProductCarousel/ProductCarousel.stories.tsx index 61425a315..bfd8959e0 100644 --- a/packages/ui/src/components/ProductCarousel/ProductCarousel.stories.tsx +++ b/packages/ui/src/components/ProductCarousel/ProductCarousel.stories.tsx @@ -32,6 +32,7 @@ type Story = StoryObj<typeof meta>; const sampleProducts: ProductSummaryItem[] = [ { id: 'PRD-005', + sku: 'AG-125-A22', name: 'Cordless Angle Grinder', description: '<p>Cordless angle grinder with 22V battery platform</p>', image: { @@ -52,6 +53,7 @@ const sampleProducts: ProductSummaryItem[] = [ }, { id: 'PRD-006', + sku: 'PD-S', name: 'Laser Measurement Device', description: '<p>Laser measurement device for distance measurements</p>', image: { @@ -69,6 +71,7 @@ const sampleProducts: ProductSummaryItem[] = [ }, { id: 'PRD-007', + sku: 'SFC-22-A', name: 'Cordless Drill Driver', description: '<p>Cordless drill driver with 22V battery platform</p>', image: { @@ -86,6 +89,7 @@ const sampleProducts: ProductSummaryItem[] = [ }, { id: 'PRD-008', + sku: 'CAL-PRO', name: 'Professional Calibration Service', description: '<p>ISO-Certified Calibration for industrial equipment</p>', image: { @@ -103,6 +107,7 @@ const sampleProducts: ProductSummaryItem[] = [ }, { id: 'PRD-009', + sku: 'SAFE-PKG', name: 'Safety Equipment Package', description: '<p>Complete safety equipment for welding environments</p>', image: { @@ -123,6 +128,7 @@ const sampleProducts: ProductSummaryItem[] = [ }, { id: 'PRD-010', + sku: 'BAT-22V', name: 'Power Tool Battery Pack', description: '<p>High-capacity battery pack for cordless tools</p>', image: { @@ -145,7 +151,6 @@ export const Default: Story = { products: sampleProducts, title: 'Recommended Products', LinkComponent: MockLinkComponent, - linkDetailsLabel: 'View Details', }, }; @@ -155,7 +160,6 @@ export const WithDescription: Story = { title: 'You Might Also Like', description: '<p>Check out these carefully selected products that complement your choice.</p>', LinkComponent: MockLinkComponent, - linkDetailsLabel: 'View Details', }, }; @@ -170,6 +174,15 @@ export const WithAction: Story = { </Button> ), LinkComponent: MockLinkComponent, - linkDetailsLabel: 'View Details', + }, +}; + +export const WithAddToCart: Story = { + args: { + products: sampleProducts, + title: 'Recommended Products', + LinkComponent: MockLinkComponent, + addToCartLabel: 'Add to Cart', + onAddToCart: (sku, currency) => console.log('Add to cart:', sku, currency), }, }; diff --git a/packages/ui/src/components/ProductCarousel/ProductCarousel.tsx b/packages/ui/src/components/ProductCarousel/ProductCarousel.tsx index 5f6f575e0..f6d71ee77 100644 --- a/packages/ui/src/components/ProductCarousel/ProductCarousel.tsx +++ b/packages/ui/src/components/ProductCarousel/ProductCarousel.tsx @@ -1,5 +1,6 @@ 'use client'; +import { ShoppingCart } from 'lucide-react'; import React, { useId } from 'react'; import { cn } from '@o2s/ui/lib/utils'; @@ -8,6 +9,7 @@ import { ProductCard } from '@o2s/ui/components/Cards/ProductCard'; import { Carousel } from '@o2s/ui/components/Carousel'; import { RichText } from '@o2s/ui/components/RichText'; +import { Button } from '@o2s/ui/elements/button'; import { Typography } from '@o2s/ui/elements/typography'; import { ProductCarouselProps } from './ProductCarousel.types'; @@ -19,7 +21,9 @@ export const ProductCarousel: React.FC<ProductCarouselProps> = ({ action, LinkComponent, carouselConfig, - linkDetailsLabel, + addToCartLabel, + onAddToCart, + isAddingToCart, carouselClassName, keyboardControlMode, keyboardCarouselId, @@ -56,13 +60,21 @@ export const ProductCarousel: React.FC<ProductCarouselProps> = ({ price={product.price} image={product.image} tags={product.badges?.slice(0, 2)} - link={ - linkDetailsLabel - ? { - label: linkDetailsLabel, - url: product.link, - } - : undefined + link={product.link} + action={ + onAddToCart && addToCartLabel && product.price ? ( + <Button + variant="secondary" + size="sm" + disabled={isAddingToCart} + onClick={() => + onAddToCart(product.sku, product.price!.currency, product.variantId) + } + > + <ShoppingCart className="h-4 w-4 mr-2" /> + {addToCartLabel} + </Button> + ) : undefined } LinkComponent={LinkComponent} /> diff --git a/packages/ui/src/components/ProductCarousel/ProductCarousel.types.ts b/packages/ui/src/components/ProductCarousel/ProductCarousel.types.ts index 3704fb3fa..33045498d 100644 --- a/packages/ui/src/components/ProductCarousel/ProductCarousel.types.ts +++ b/packages/ui/src/components/ProductCarousel/ProductCarousel.types.ts @@ -12,7 +12,9 @@ export interface ProductCarouselProps { action?: React.ReactNode; LinkComponent: FrontendModels.Link.LinkComponent; carouselConfig?: Partial<CarouselProps>; - linkDetailsLabel?: string; + addToCartLabel?: string; + onAddToCart?: (sku: string, currency: Models.Price.Currency, variantId?: string) => void; + isAddingToCart?: boolean; carouselClassName?: string; keyboardControlMode?: CarouselProps['keyboardControlMode']; keyboardCarouselId?: string; @@ -20,6 +22,8 @@ export interface ProductCarouselProps { export interface ProductSummaryItem { id: string; + sku: string; + variantId?: string; name: string; description?: Models.RichText.RichText; image?: Models.Media.Media; diff --git a/packages/ui/src/components/QuantityInput/QuantityInput.stories.tsx b/packages/ui/src/components/QuantityInput/QuantityInput.stories.tsx new file mode 100644 index 000000000..7a3e3655b --- /dev/null +++ b/packages/ui/src/components/QuantityInput/QuantityInput.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React, { useState } from 'react'; + +import { QuantityInput } from './QuantityInput'; + +const meta: Meta<typeof QuantityInput> = { + title: 'Components/QuantityInput', + component: QuantityInput, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<typeof QuantityInput>; + +const DefaultWithState = (args: React.ComponentProps<typeof QuantityInput>) => { + const [value, setValue] = useState(args.value); + return <QuantityInput {...args} value={value} onChange={setValue} />; +}; + +export const Default: Story = { + args: { + value: 1, + min: 1, + labels: { + increase: 'Increase quantity', + decrease: 'Decrease quantity', + quantity: 'Quantity', + }, + }, + render: (args) => <DefaultWithState {...args} />, +}; diff --git a/packages/ui/src/components/QuantityInput/QuantityInput.tsx b/packages/ui/src/components/QuantityInput/QuantityInput.tsx new file mode 100644 index 000000000..d02f16077 --- /dev/null +++ b/packages/ui/src/components/QuantityInput/QuantityInput.tsx @@ -0,0 +1,42 @@ +'use client'; + +import React from 'react'; + +import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; + +import { Button } from '@o2s/ui/elements/button'; +import { Input } from '@o2s/ui/elements/input'; + +import { QuantityInputProps } from './QuantityInput.types'; + +export const QuantityInput: React.FC<Readonly<QuantityInputProps>> = ({ value, onChange, min = 1, labels }) => { + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const newQuantity = parseInt(e.target.value, 10) || min; + onChange(newQuantity); + }; + + return ( + <div className="flex items-end gap-2"> + <Button + variant="outline" + size="icon" + onClick={() => onChange(value - 1)} + disabled={value <= min} + aria-label={labels.decrease} + > + <DynamicIcon name="Minus" size={16} /> + </Button> + <Input + type="number" + min={min} + value={value} + onChange={handleInputChange} + className="w-16 text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + aria-label={labels.quantity} + /> + <Button variant="outline" size="icon" onClick={() => onChange(value + 1)} aria-label={labels.increase}> + <DynamicIcon name="Plus" size={16} /> + </Button> + </div> + ); +}; diff --git a/packages/ui/src/components/QuantityInput/QuantityInput.types.ts b/packages/ui/src/components/QuantityInput/QuantityInput.types.ts new file mode 100644 index 000000000..f372d81d3 --- /dev/null +++ b/packages/ui/src/components/QuantityInput/QuantityInput.types.ts @@ -0,0 +1,10 @@ +export interface QuantityInputProps { + value: number; + onChange: (newQuantity: number) => void; + min?: number; + labels: { + increase: string; + decrease: string; + quantity: string; + }; +} diff --git a/packages/ui/src/components/QuantityInput/index.ts b/packages/ui/src/components/QuantityInput/index.ts new file mode 100644 index 000000000..31164864e --- /dev/null +++ b/packages/ui/src/components/QuantityInput/index.ts @@ -0,0 +1,2 @@ +export { QuantityInput } from './QuantityInput'; +export type { QuantityInputProps } from './QuantityInput.types'; diff --git a/packages/ui/src/components/RadioTile/RadioTile.stories.tsx b/packages/ui/src/components/RadioTile/RadioTile.stories.tsx new file mode 100644 index 000000000..392c9bd57 --- /dev/null +++ b/packages/ui/src/components/RadioTile/RadioTile.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React, { useState } from 'react'; + +import { Typography } from '@o2s/ui/elements/typography'; + +import { RadioTileGroup } from './RadioTile'; +import type { RadioTileOption } from './RadioTile.types'; + +const meta: Meta<typeof RadioTileGroup> = { + title: 'Components/RadioTileGroup', + component: RadioTileGroup, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<typeof RadioTileGroup>; + +const shippingOptions: RadioTileOption[] = [ + { + id: 'standard', + label: 'Standard delivery', + description: '3–5 business days', + extra: <Typography variant="small">$5.99</Typography>, + }, + { + id: 'express', + label: 'Express delivery', + description: '1–2 business days', + extra: <Typography variant="small">$14.99</Typography>, + }, + { + id: 'free', + label: 'Free delivery', + description: '5–7 business days', + }, +]; + +const DefaultWithState = (args: React.ComponentProps<typeof RadioTileGroup>) => { + const [value, setValue] = useState('express'); + return <RadioTileGroup {...args} value={value} onValueChange={setValue} options={shippingOptions} />; +}; + +export const Default: Story = { + render: (args) => <DefaultWithState {...args} />, +}; diff --git a/packages/ui/src/components/RadioTile/RadioTile.tsx b/packages/ui/src/components/RadioTile/RadioTile.tsx new file mode 100644 index 000000000..e7a76c0c7 --- /dev/null +++ b/packages/ui/src/components/RadioTile/RadioTile.tsx @@ -0,0 +1,75 @@ +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; +import { Circle } from 'lucide-react'; +import React from 'react'; + +import { cn } from '@o2s/ui/lib/utils'; + +import { Label } from '@o2s/ui/elements/label'; +import { Typography } from '@o2s/ui/elements/typography'; + +import type { RadioTileGroupProps } from './RadioTile.types'; + +export const RadioTileGroup: React.FC<Readonly<RadioTileGroupProps>> = ({ + value, + onValueChange, + options, + hasError, + disabled, + className, +}) => { + return ( + <RadioGroupPrimitive.Root + value={value} + onValueChange={onValueChange} + disabled={disabled} + className={cn('flex flex-col gap-3', className)} + > + {options.map((option) => { + const isSelected = value === option.id; + const itemId = `radio-tile-${option.id}`; + return ( + <div + key={option.id} + className={cn( + 'rounded-lg border-2 transition-colors', + isSelected + ? 'border-primary bg-primary/5' + : 'border-border bg-background hover:border-primary/50', + hasError && !isSelected && 'border-destructive', + disabled && 'opacity-50 cursor-not-allowed', + )} + > + <Label htmlFor={itemId} className="flex items-start gap-3 cursor-pointer w-full p-4"> + <RadioGroupPrimitive.Item + id={itemId} + value={option.id} + className={cn( + 'mt-0.5 shrink-0 aspect-square h-4 w-4 rounded-full border border-primary text-primary', + 'ring-offset-background focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', + 'disabled:cursor-not-allowed cursor-pointer', + )} + > + <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> + <Circle className="h-2.5 w-2.5 fill-current text-current" /> + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + <div className="flex items-start justify-between gap-4 min-w-0 flex-1"> + <div className="flex flex-col gap-1 min-w-0"> + <Typography variant="p" className="font-medium leading-tight"> + {option.label} + </Typography> + {option.description && ( + <Typography variant="small" className="text-muted-foreground"> + {option.description} + </Typography> + )} + </div> + {option.extra && <div className="shrink-0">{option.extra}</div>} + </div> + </Label> + </div> + ); + })} + </RadioGroupPrimitive.Root> + ); +}; diff --git a/packages/ui/src/components/RadioTile/RadioTile.types.ts b/packages/ui/src/components/RadioTile/RadioTile.types.ts new file mode 100644 index 000000000..74eb91707 --- /dev/null +++ b/packages/ui/src/components/RadioTile/RadioTile.types.ts @@ -0,0 +1,17 @@ +import React from 'react'; + +export interface RadioTileOption { + id: string; + label: string; + description?: string; + extra?: React.ReactNode; +} + +export interface RadioTileGroupProps { + value: string; + onValueChange: (value: string) => void; + options: RadioTileOption[]; + hasError?: boolean; + disabled?: boolean; + className?: string; +} diff --git a/packages/ui/src/components/RadioTile/index.ts b/packages/ui/src/components/RadioTile/index.ts new file mode 100644 index 000000000..0888f60ff --- /dev/null +++ b/packages/ui/src/components/RadioTile/index.ts @@ -0,0 +1,2 @@ +export { RadioTileGroup } from './RadioTile'; +export type { RadioTileGroupProps, RadioTileOption } from './RadioTile.types'; diff --git a/packages/ui/src/theme.css b/packages/ui/src/theme.css index e34b303f0..baab8c61b 100644 --- a/packages/ui/src/theme.css +++ b/packages/ui/src/theme.css @@ -16,6 +16,10 @@ --accent-foreground: hsl(240 6% 10%); --destructive: hsl(346 100% 41%); --destructive-foreground: hsl(0 0% 100%); + --success: hsl(142 76% 36%); + --success-foreground: hsl(0 0% 100%); + --success-hover: hsl(142 76% 30%); + --success-foreground-hover: hsl(0 0% 100%); --border: hsl(232 12% 87%); --input: hsl(230 6% 81%); --ring: hsl(240 6% 10%); @@ -77,6 +81,10 @@ --accent-foreground: hsl(0 0% 100%); --destructive: hsl(0 63% 31%); --destructive-foreground: hsl(0 86% 97%); + --success: hsl(142 76% 42%); + --success-foreground: hsl(0 0% 100%); + --success-hover: hsl(142 76% 48%); + --success-foreground-hover: hsl(0 0% 100%); --border: hsl(226 71% 40%); --input: hsl(226 71% 40%); --ring: hsl(240 5% 84%); diff --git a/packages/utils/api-harmonization/CHANGELOG.md b/packages/utils/api-harmonization/CHANGELOG.md index cf518ea0c..24f0e7e36 100644 --- a/packages/utils/api-harmonization/CHANGELOG.md +++ b/packages/utils/api-harmonization/CHANGELOG.md @@ -1,5 +1,32 @@ # @o2s/utils.api-harmonization +## 0.3.3 + +### Patch Changes + +- 338cb01: Introduce typed header name constants (`HeaderName`) using `as const` and + replace selected magic header strings in API harmonization and frontend code. + + Update SDK header typing to use `AppHeaders` for stronger request typing. + +- 338cb01: fix(api-harmonization): align typed header usage across services and generated SDK/controller contracts +- Updated dependencies [fadbc63] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] +- Updated dependencies [338cb01] + - @o2s/framework@1.20.1 + +## 0.3.2 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + ## 0.3.1 ### Patch Changes diff --git a/packages/utils/api-harmonization/package.json b/packages/utils/api-harmonization/package.json index f7cc53e91..57e3a0e47 100644 --- a/packages/utils/api-harmonization/package.json +++ b/packages/utils/api-harmonization/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/utils.api-harmonization", - "version": "0.3.1", + "version": "0.3.3", "description": "Utility functions for O2S API Harmonization layer, including JWT and date helpers.", "private": false, "license": "MIT", @@ -39,7 +39,7 @@ "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3" diff --git a/packages/utils/api-harmonization/src/models/headers.ts b/packages/utils/api-harmonization/src/models/headers.ts index e394bea3f..89ae228fc 100644 --- a/packages/utils/api-harmonization/src/models/headers.ts +++ b/packages/utils/api-harmonization/src/models/headers.ts @@ -1,6 +1 @@ -export class AppHeaders { - 'x-locale'!: string; - 'x-client-timezone'?: string; - 'x-currency'?: string; - 'authorization'?: string; -} +export { AppHeaders, HeaderName } from '@o2s/framework/headers'; diff --git a/packages/utils/frontend/CHANGELOG.md b/packages/utils/frontend/CHANGELOG.md index ddb99f387..636e87454 100644 --- a/packages/utils/frontend/CHANGELOG.md +++ b/packages/utils/frontend/CHANGELOG.md @@ -1,5 +1,28 @@ # @o2s/utils.frontend +## 0.5.1 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies +- Updated dependencies [83a3d13] +- Updated dependencies [375cd90] +- Updated dependencies [98b2e68] + - @o2s/framework@1.20.0 + +## 0.5.0 + +### Minor Changes + +- 5d36519: Extended framework with e-commerce models: Address (companyName, taxId), Cart, Checkout and Order Confirmation CMS blocks. Added Mocked and Medusa integration support for cart, checkout flow, and guest order retrieval. + +### Patch Changes + +- Updated dependencies [5d36519] +- Updated dependencies [0e61431] + - @o2s/framework@1.19.0 + ## 0.4.1 ### Patch Changes diff --git a/packages/utils/frontend/package.json b/packages/utils/frontend/package.json index 103087696..21dea2ddc 100644 --- a/packages/utils/frontend/package.json +++ b/packages/utils/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/utils.frontend", - "version": "0.4.1", + "version": "0.5.1", "description": "Utility functions for O2S frontend applications.", "private": false, "license": "MIT", @@ -39,7 +39,7 @@ "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "react-string-replace": "^2.0.1", "tsc-alias": "^1.8.16", diff --git a/packages/utils/frontend/src/utils/format-address.ts b/packages/utils/frontend/src/utils/format-address.ts new file mode 100644 index 000000000..d63cc48ad --- /dev/null +++ b/packages/utils/frontend/src/utils/format-address.ts @@ -0,0 +1,10 @@ +/** + * Formats a street address from its component parts. + * Combines street name, optional street number, and optional apartment into a single string. + */ +export function formatStreetAddress(addr: { streetName: string; streetNumber?: string; apartment?: string }): string { + let street = addr.streetName; + if (addr.streetNumber) street += ` ${addr.streetNumber}`; + if (addr.apartment) street += `, ${addr.apartment}`; + return street; +} diff --git a/packages/utils/frontend/src/utils/format-country.ts b/packages/utils/frontend/src/utils/format-country.ts new file mode 100644 index 000000000..870e0a0b9 --- /dev/null +++ b/packages/utils/frontend/src/utils/format-country.ts @@ -0,0 +1,15 @@ +/** + * Converts a country code (e.g. PL, DE, GB) to a localized display name. + * Uses Intl.DisplayNames - same approach as AddressFields for consistency. + * If the code is not a valid ISO 3166-1 region code, returns the original value. + */ +export function formatCountryCode(code: string, locale: string): string { + if (!code) return code; + try { + const countryNames = new Intl.DisplayNames([locale], { type: 'region' }); + const displayName = countryNames.of(code.toUpperCase()); + return displayName ?? code; + } catch { + return code; + } +} diff --git a/packages/utils/frontend/src/utils/index.ts b/packages/utils/frontend/src/utils/index.ts index 3f33bb30e..ce1ba623a 100644 --- a/packages/utils/frontend/src/utils/index.ts +++ b/packages/utils/frontend/src/utils/index.ts @@ -1,3 +1,5 @@ -export * as Headers from './headers'; export * as DownloadFile from './download-file'; +export * as FormatAddress from './format-address'; +export * as FormatCountry from './format-country'; +export * as Headers from './headers'; export * as StringReplace from './string-replace'; diff --git a/packages/utils/logger/CHANGELOG.md b/packages/utils/logger/CHANGELOG.md index 523ed5559..4a7908bf5 100644 --- a/packages/utils/logger/CHANGELOG.md +++ b/packages/utils/logger/CHANGELOG.md @@ -1,5 +1,13 @@ # @o2s/utils.logger +## 1.2.3 + +### Patch Changes + +- 83a3d13: chore(deps): update dependencies +- daf592e: chore(deps): update dependencies +- 98b2e68: chore(deps): update dependencies + ## 1.2.2 ### Patch Changes diff --git a/packages/utils/logger/package.json b/packages/utils/logger/package.json index 0edccfe79..1db622828 100644 --- a/packages/utils/logger/package.json +++ b/packages/utils/logger/package.json @@ -1,6 +1,6 @@ { "name": "@o2s/utils.logger", - "version": "1.2.2", + "version": "1.2.3", "private": false, "license": "MIT", "description": "Winston-based logging utility for O2S applications with NestJS integration.", @@ -35,7 +35,7 @@ "dependencies": { "jwt-decode": "^4.0.0", "winston": "^3.19.0", - "axios": "^1.13.5", + "axios": "^1.13.6", "express": "5.2.1" }, "devDependencies": { @@ -45,7 +45,7 @@ "@o2s/typescript-config": "*", "@types/express": "^5.0.6", "concurrently": "^9.2.1", - "eslint": "^9.39.3", + "eslint": "^9.39.4", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", diff --git a/renovate.json b/renovate.json index faca38042..8b074595c 100644 --- a/renovate.json +++ b/renovate.json @@ -1,10 +1,16 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["config:recommended"], + "enabledManagers": ["npm"], + "ignorePaths": ["**/dist/**", "**/build/**", "**/.next/**", "**/generated/**"], + "lockFileMaintenance": { + "enabled": true, + "schedule": ["after 11pm on sunday"] + }, "vulnerabilityAlerts": { "enabled": true }, - "osvVulnerabilityAlerts": true, + "osvVulnerabilityAlerts": false, "packageRules": [ { "matchDepTypes": [ @@ -19,6 +25,5 @@ ], "rangeStrategy": "widen" } - ], - "postUpdateOptions": ["npmInstallTwice"] + ] } diff --git a/scripts/commands/eject-block.ts b/scripts/commands/eject-block.ts index 5b29d0f1f..a14519f74 100644 --- a/scripts/commands/eject-block.ts +++ b/scripts/commands/eject-block.ts @@ -10,11 +10,11 @@ import simpleGit from 'simple-git'; const PROJECT_PREFIX = 'o2s'; const PROJECT_NAME = `openselfservice`; const GITHUB_REPO_URL = `https://github.com/o2sdev/${PROJECT_NAME}.git`; -const BRANCH = 'main'; +const BRANCH = 'create-o2s-app/base'; const BLOCKS_PATH = 'packages/blocks'; // Path to blocks directory in the repo relative to the branch const PROJECT_ROOT = path.resolve(__dirname, '../..'); // Adjust to project root const OUTPUT_DIR = path.join(PROJECT_ROOT, 'packages/blocks'); // Local target folder -const FRONTEND_DIR = path.join(PROJECT_ROOT, 'apps/frontend'); // Frontend app directory +const _FRONTEND_DIR = path.join(PROJECT_ROOT, 'apps/frontend'); // Frontend app directory const TEMP_DIR = path.join(os.tmpdir(), `${PROJECT_NAME}-${Date.now()}`); // Temporary directory for cloning // Types @@ -205,7 +205,7 @@ const installBlockInFrontend = async (blockName: string): Promise<void> => { return new Promise<void>((resolve, reject) => { console.log(`Installing block "${blockName}"...`); - // Construct the package name (assuming it follows the @dxp/blocks.{blockName} pattern) + // Construct the package name (assuming it follows the @o2s/blocks.{blockName} pattern) const packageName = `@${PROJECT_PREFIX}/blocks.${blockName}`; try { diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index 5038145cf..820dcefec 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -155,6 +155,7 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { pattern: /(\/\/ MODULE_EXPORTS)/g, template: ' {{ camelCase module }}: {\n' + + " name: '{{kebabCase name}}',\n" + ' service: {{ pascalCase module }}Service,\n' + ' },\n// MODULE_EXPORTS', data: { module }, @@ -162,7 +163,7 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { ); } else { // Standard module structure - actions.push( + const moduleActions: PlopTypes.ActionType[] = [ { type: 'add', path: `packages/integrations/{{kebabCase name}}/src/modules/{{kebabCase module}}/index.ts`, @@ -208,11 +209,31 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { pattern: /(\/\/ MODULE_EXPORTS)/g, template: ' {{ camelCase module }}: {\n' + + " name: '{{kebabCase name}}',\n" + ' service: {{ pascalCase module }}Service,\n' + ' },\n// MODULE_EXPORTS', data: { module }, }, - ); + ]; + + if (module === 'cms') { + moduleActions.push( + { + type: 'add', + path: 'packages/integrations/{{kebabCase name}}/src/modules/cms/cms.model.ts', + templateFile: 'templates/integration/cms-model.hbs', + data: { module }, + }, + { + type: 'add', + path: 'packages/integrations/{{kebabCase name}}/src/modules/cms/extend-cms-model.ts', + templateFile: 'templates/integration/extend-cms-model.hbs', + data: { module }, + }, + ); + } + + actions.push(...moduleActions); } }); @@ -227,6 +248,7 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { type: 'input', name: 'name', message: 'What is the name of the block?', + validate: (value) => (value && value.trim().length > 0 ? true : 'Please enter a block name'), }, { type: 'checkbox', @@ -235,203 +257,227 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { message: 'Which project templates should include this block? (leave empty for custom-only)', }, ], - actions: [ - // API-HARMONIZATION - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/index.ts', - templateFile: 'templates/block/api-harmonization/index.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.client.ts', - templateFile: 'templates/block/api-harmonization/client.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.controller.ts', - templateFile: 'templates/block/api-harmonization/controller.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.service.ts', - templateFile: 'templates/block/api-harmonization/service.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.service.spec.ts', - templateFile: 'templates/block/api-harmonization/service.spec.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.module.ts', - templateFile: 'templates/block/api-harmonization/module.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.mapper.ts', - templateFile: 'templates/block/api-harmonization/mapper.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.model.ts', - templateFile: 'templates/block/api-harmonization/model.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.request.ts', - templateFile: 'templates/block/api-harmonization/request.hbs', - }, - { - type: 'modify', - path: 'apps/api-harmonization/src/app.module.ts', - pattern: /(\/\/ BLOCK IMPORT)/g, - template: `import * as {{pascalCase name}} from '@o2s/blocks.{{kebabCase name}}/api-harmonization';\n// BLOCK IMPORT`, - }, - { - type: 'modify', - path: 'apps/api-harmonization/src/app.module.ts', - pattern: /(\/\/ BLOCK REGISTER)/g, - template: `{{pascalCase name}}.Module.register(AppConfig),\n // BLOCK REGISTER`, - }, - { - type: 'modify', - path: 'apps/api-harmonization/src/modules/page/page.model.ts', - pattern: /(\/\/ BLOCK IMPORT)/g, - template: `import * as {{pascalCase name}} from '@o2s/blocks.{{kebabCase name}}/api-harmonization';\n// BLOCK IMPORT`, - }, - { - type: 'modify', - path: 'apps/api-harmonization/src/modules/page/page.model.ts', - pattern: /(\/\/ BLOCK REGISTER)/g, - template: `// BLOCK REGISTER\n | {{pascalCase name}}.Model.{{pascalCase name}}Block['__typename']`, - }, + actions: (data) => { + const actions: PlopTypes.ActionType[] = [ + // API-HARMONIZATION + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/index.ts', + templateFile: 'templates/block/api-harmonization/index.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.client.ts', + templateFile: 'templates/block/api-harmonization/client.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.controller.ts', + templateFile: 'templates/block/api-harmonization/controller.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.service.ts', + templateFile: 'templates/block/api-harmonization/service.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.service.spec.ts', + templateFile: 'templates/block/api-harmonization/service.spec.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.module.ts', + templateFile: 'templates/block/api-harmonization/module.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.mapper.ts', + templateFile: 'templates/block/api-harmonization/mapper.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.model.ts', + templateFile: 'templates/block/api-harmonization/model.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/api-harmonization/{{kebabCase name}}.request.ts', + templateFile: 'templates/block/api-harmonization/request.hbs', + }, + { + type: 'modify', + path: 'apps/api-harmonization/src/app.module.ts', + pattern: /(\/\/ BLOCK IMPORT)/g, + template: `import * as {{pascalCase name}} from '@o2s/blocks.{{kebabCase name}}/api-harmonization';\n// BLOCK IMPORT`, + }, + { + type: 'modify', + path: 'apps/api-harmonization/src/app.module.ts', + pattern: /(\/\/ BLOCK REGISTER)/g, + template: `{{pascalCase name}}.Module.register(AppConfig),\n // BLOCK REGISTER`, + }, + { + type: 'modify', + path: 'apps/api-harmonization/src/modules/page/page.model.ts', + pattern: /(\/\/ BLOCK IMPORT)/g, + template: `import * as {{pascalCase name}} from '@o2s/blocks.{{kebabCase name}}/api-harmonization';\n// BLOCK IMPORT`, + }, + { + type: 'modify', + path: 'apps/api-harmonization/src/modules/page/page.model.ts', + pattern: /(\/\/ BLOCK REGISTER)/g, + template: `// BLOCK REGISTER\n | {{pascalCase name}}.Model.{{pascalCase name}}Block['__typename']`, + }, - // FRONTEND - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/frontend/index.ts', - templateFile: 'templates/block/frontend/index.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/frontend/{{pascalCase name}}.renderer.tsx', - templateFile: 'templates/block/frontend/renderer.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/frontend/{{pascalCase name}}.server.tsx', - templateFile: 'templates/block/frontend/server.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/frontend/{{pascalCase name}}.client.tsx', - templateFile: 'templates/block/frontend/client.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/frontend/{{pascalCase name}}.client.stories.tsx', - templateFile: 'templates/block/frontend/stories.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/frontend/{{pascalCase name}}.types.ts', - templateFile: 'templates/block/frontend/types.hbs', - }, - { - type: 'modify', - path: 'apps/frontend/src/blocks/renderBlocks.tsx', - pattern: /(\/\/ BLOCK IMPORT)/g, - template: `import * as {{pascalCase name}} from '@o2s/blocks.{{kebabCase name}}/frontend';\n// BLOCK IMPORT`, - }, - { - type: 'modify', - path: 'apps/frontend/src/blocks/renderBlocks.tsx', - pattern: /(\/\/ BLOCK REGISTER)/g, - template: `case '{{pascalCase name}}Block':\n return <{{pascalCase name}}.Renderer {...blockProps} />;\n // BLOCK REGISTER`, - }, + // FRONTEND + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/frontend/index.ts', + templateFile: 'templates/block/frontend/index.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/frontend/{{pascalCase name}}.renderer.tsx', + templateFile: 'templates/block/frontend/renderer.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/frontend/{{pascalCase name}}.server.tsx', + templateFile: 'templates/block/frontend/server.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/frontend/{{pascalCase name}}.client.tsx', + templateFile: 'templates/block/frontend/client.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/frontend/{{pascalCase name}}.client.stories.tsx', + templateFile: 'templates/block/frontend/stories.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/frontend/{{pascalCase name}}.types.ts', + templateFile: 'templates/block/frontend/types.hbs', + }, + { + type: 'modify', + path: 'apps/frontend/src/blocks/renderBlocks.tsx', + pattern: /(\/\/ BLOCK IMPORT)/g, + template: `import * as {{pascalCase name}} from '@o2s/blocks.{{kebabCase name}}/frontend';\n// BLOCK IMPORT`, + }, + { + type: 'modify', + path: 'apps/frontend/src/blocks/renderBlocks.tsx', + pattern: /(\/\/ BLOCK REGISTER)/g, + template: `{{pascalCase name}}Block: (blockProps) => <{{pascalCase name}}.Renderer {...blockProps} />,\n // BLOCK REGISTER`, + }, - // SDK - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/sdk/index.ts', - templateFile: 'templates/block/sdk/index.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/src/sdk/{{kebabCase name}}.ts', - templateFile: 'templates/block/sdk/block.hbs', - }, + // SDK + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/sdk/index.ts', + templateFile: 'templates/block/sdk/index.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/src/sdk/{{kebabCase name}}.ts', + templateFile: 'templates/block/sdk/block.hbs', + }, - // CONFIG - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/.gitignore', - templateFile: 'templates/block/gitIgnore.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/.prettierrc.mjs', - templateFile: 'templates/block/prettierRc.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/eslint.config.mjs', - templateFile: 'templates/block/eslintConfig.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/lint-staged.config.mjs', - templateFile: 'templates/block/lintStagedConfig.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/package.json', - templateFile: 'templates/block/package.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/vitest.config.mjs', - templateFile: 'templates/block/vitestConfig.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/tsconfig.api.json', - templateFile: 'templates/block/tsconfigApi.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/tsconfig.frontend.json', - templateFile: 'templates/block/tsconfigFrontend.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/tsconfig.json', - templateFile: 'templates/block/tsconfig.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/tsconfig.sdk.json', - templateFile: 'templates/block/tsconfigSdk.hbs', - }, - { - type: 'add', - path: 'packages/blocks/{{kebabCase name}}/README.md', - templateFile: 'templates/block/README.hbs', - }, + // CONFIG + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/.gitignore', + templateFile: 'templates/block/gitIgnore.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/.prettierrc.mjs', + templateFile: 'templates/block/prettierRc.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/eslint.config.mjs', + templateFile: 'templates/block/eslintConfig.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/lint-staged.config.mjs', + templateFile: 'templates/block/lintStagedConfig.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/package.json', + templateFile: 'templates/block/package.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/vitest.config.mjs', + templateFile: 'templates/block/vitestConfig.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/tsconfig.api.json', + templateFile: 'templates/block/tsconfigApi.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/tsconfig.frontend.json', + templateFile: 'templates/block/tsconfigFrontend.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/tsconfig.json', + templateFile: 'templates/block/tsconfig.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/tsconfig.sdk.json', + templateFile: 'templates/block/tsconfigSdk.hbs', + }, + { + type: 'add', + path: 'packages/blocks/{{kebabCase name}}/README.md', + templateFile: 'templates/block/README.hbs', + }, - // FRAMEWORK - { - type: 'add', - path: 'packages/framework/src/modules/cms/models/blocks/{{kebabCase name}}.model.ts', - templateFile: 'templates/block/framework/model.hbs', - }, - { - type: 'modify', - path: 'packages/framework/src/modules/cms/cms.model.ts', - pattern: /(\/\/ BLOCK IMPORT)/g, - template: `export * as {{pascalCase name}}Block from './models/blocks/{{kebabCase name}}.model';\n// BLOCK IMPORT`, - }, - ], + // FRAMEWORK + { + type: 'add', + path: 'packages/framework/src/modules/cms/models/blocks/{{kebabCase name}}.model.ts', + templateFile: 'templates/block/framework/model.hbs', + }, + { + type: 'modify', + path: 'packages/framework/src/modules/cms/cms.model.ts', + pattern: /(\/\/ BLOCK IMPORT)/g, + template: `export * as {{pascalCase name}}Block from './models/blocks/{{kebabCase name}}.model';\n// BLOCK IMPORT`, + }, + ]; + + actions.push(() => { + const blockName = String(data?.name ?? '').trim(); + const kebabBlockName = plop.getHelper('kebabCase')(blockName) as string; + const packageName = `@o2s/blocks.${kebabBlockName}`; + const blockRootPath = `packages/blocks/${kebabBlockName}`; + + return [ + `Created ${packageName}`, + ` ${blockRootPath}/src/api-harmonization/`, + ` ${blockRootPath}/src/frontend/`, + ` ${blockRootPath}/src/sdk/`, + '', + 'Next steps:', + ` 1. Add "${packageName}": "*" to apps/api-harmonization/package.json`, + ` 2. Add "${packageName}": "*" to apps/frontend/package.json`, + ' 3. Run: npm install', + ' 4. Run: npm run lint (optional sanity check)', + ].join('\n'); + }); + + return actions; + }, }); } diff --git a/turbo/generators/templates/block/api-harmonization/controller.hbs b/turbo/generators/templates/block/api-harmonization/controller.hbs index d63c3898d..673d348b7 100644 --- a/turbo/generators/templates/block/api-harmonization/controller.hbs +++ b/turbo/generators/templates/block/api-harmonization/controller.hbs @@ -1,4 +1,3 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Controller, Get, Headers, Query, UseInterceptors } from '@nestjs/common'; import { LoggerService } from '@o2s/utils.logger'; @@ -8,6 +7,7 @@ import { Auth } from '@o2s/framework/modules'; import { URL } from './'; import { Get{{pascalCase name}}BlockQuery } from './{{kebabCase name }}.request'; import { {{pascalCase name}}Service } from './{{kebabCase name}}.service'; +import { AppHeaders } from '@o2s/framework/headers'; @Controller(URL) @UseInterceptors(LoggerService) @@ -18,7 +18,7 @@ export class {{pascalCase name}}Controller { @Auth.Decorators.Roles({ roles: [] }) // Optional: Add permission-based access control // @Auth.Decorators.Permissions({ resource: 'resource-name', actions: ['view'] }) - get{{pascalCase name}}Block(@Headers() headers: Models.Headers.AppHeaders, @Query() query: Get{{pascalCase name }}BlockQuery) { + get{{pascalCase name}}Block(@Headers() headers: AppHeaders, @Query() query: Get{{pascalCase name }}BlockQuery) { return this.service.get{{pascalCase name}}Block(query, headers); } } diff --git a/turbo/generators/templates/block/api-harmonization/model.hbs b/turbo/generators/templates/block/api-harmonization/model.hbs index 553959ea5..bc9287c94 100644 --- a/turbo/generators/templates/block/api-harmonization/model.hbs +++ b/turbo/generators/templates/block/api-harmonization/model.hbs @@ -1,5 +1,4 @@ import { CMS } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; export class {{ pascalCase name }}Block extends Models.Block.Block { __typename!: '{{ pascalCase name }}Block'; diff --git a/turbo/generators/templates/block/api-harmonization/service.hbs b/turbo/generators/templates/block/api-harmonization/service.hbs index 581e1fa19..7c5b339a5 100644 --- a/turbo/generators/templates/block/api-harmonization/service.hbs +++ b/turbo/generators/templates/block/api-harmonization/service.hbs @@ -1,5 +1,4 @@ import { CMS } from '@o2s/configs.integrations'; -import { Models } from '@o2s/utils.api-harmonization'; import { Injectable } from '@nestjs/common'; import { Observable, forkJoin, map } from 'rxjs'; @@ -8,6 +7,9 @@ import { Auth } from '@o2s/framework/modules'; import { map{{ pascalCase name }} } from './{{ kebabCase name }}.mapper'; import { {{ pascalCase name }}Block } from './{{ kebabCase name }}.model'; import { Get{{ pascalCase name }}BlockQuery } from './{{ kebabCase name }}.request'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; + +const H = HeaderName; @Injectable() export class {{ pascalCase name }}Service { @@ -19,17 +21,17 @@ export class {{ pascalCase name }}Service { get{{ pascalCase name }}Block( query: Get{{ pascalCase name }}BlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, ): Observable<{{ pascalCase name }}Block> { - const cms = this.cmsService.get{{ pascalCase name }}Block({ ...query, locale: headers['x-locale'] }); + const cms = this.cmsService.get{{ pascalCase name }}Block({ ...query, locale: headers[H.Locale] }); return forkJoin([cms]).pipe( map(([cms]) => { - const result = map{{ pascalCase name }}(cms, headers['x-locale']); + const result = map{{ pascalCase name }}(cms, headers[H.Locale]); // Optional: Add permission flags to the response - // if (headers.authorization) { - // const permissions = this.authService.canPerformActions(headers.authorization, 'resource-name', [ + // if (headers[H.Authorization]) { + // const permissions = this.authService.canPerformActions(headers[H.Authorization], 'resource-name', [ // 'view', // 'edit', // ]); diff --git a/turbo/generators/templates/block/frontend/server.hbs b/turbo/generators/templates/block/frontend/server.hbs index fa673e7de..24589a780 100644 --- a/turbo/generators/templates/block/frontend/server.hbs +++ b/turbo/generators/templates/block/frontend/server.hbs @@ -1,11 +1,14 @@ import dynamic from 'next/dynamic'; import React from 'react'; +import { HeaderName } from '@o2s/framework/headers'; import type { Model } from '../api-harmonization/{{ kebabCase name }}.client'; import { sdk } from '../sdk'; import { {{ pascalCase name }}Props } from './{{ pascalCase name }}.types'; +const H = HeaderName; + export const {{ pascalCase name }}Dynamic = dynamic(() => import('./{{ pascalCase name }}.client').then((module) => module.{{ pascalCase name }}Pure), ); @@ -17,7 +20,7 @@ export const {{ pascalCase name }}: React.FC<{{ pascalCase name }}Props> = async { id, }, - { 'x-locale': locale }, + { [H.Locale]: locale }, accessToken, ); } catch (error) { diff --git a/turbo/generators/templates/block/sdk/block.hbs b/turbo/generators/templates/block/sdk/block.hbs index d80ec1505..1a5ef3446 100644 --- a/turbo/generators/templates/block/sdk/block.hbs +++ b/turbo/generators/templates/block/sdk/block.hbs @@ -1,17 +1,18 @@ -import { Models } from '@o2s/utils.api-harmonization'; import { Utils } from '@o2s/utils.frontend'; import { Sdk } from '@o2s/framework/sdk'; import { Model, Request, URL } from '../api-harmonization/{{kebabCase name}}.client'; +import { AppHeaders, HeaderName } from '@o2s/framework/headers'; const API_URL = URL; +const H = HeaderName; export const {{ camelCase name }} = (sdk: Sdk) => ({ blocks: { get{{ pascalCase name }}: ( query: Request.Get{{ pascalCase name }}BlockQuery, - headers: Models.Headers.AppHeaders, + headers: AppHeaders, authorization?: string, ): Promise<Model.{{ pascalCase name }}Block> => sdk.makeRequest({ @@ -22,7 +23,7 @@ export const {{ camelCase name }} = (sdk: Sdk) => ({ ...headers, ...(authorization ? { - Authorization: `Bearer ${authorization}`, + [H.Authorization]: `Bearer ${authorization}`, } : {}), }, diff --git a/turbo/generators/templates/block/sdk/index.hbs b/turbo/generators/templates/block/sdk/index.hbs index 6b4c54b05..07827262d 100644 --- a/turbo/generators/templates/block/sdk/index.hbs +++ b/turbo/generators/templates/block/sdk/index.hbs @@ -1,6 +1,5 @@ // this unused import is necessary for TypeScript to properly resolve API methods // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Models } from '@o2s/utils.api-harmonization'; import { extendSdk, getSdk } from '@o2s/framework/sdk'; diff --git a/turbo/generators/templates/integration/auth-guard.hbs b/turbo/generators/templates/integration/auth-guard.hbs index 5be9b4cc0..d7d98d5bf 100644 --- a/turbo/generators/templates/integration/auth-guard.hbs +++ b/turbo/generators/templates/integration/auth-guard.hbs @@ -4,9 +4,12 @@ import { Reflector } from '@nestjs/core'; import { LoggerService } from '@o2s/utils.logger'; import { Auth } from '@o2s/framework/modules'; +import { HeaderName } from '@o2s/framework/headers'; import { Jwt } from './auth.model'; +const H = HeaderName; + @Injectable() export class RolesGuard implements Auth.Guards.RoleGuard { constructor( @@ -28,7 +31,7 @@ export class RolesGuard implements Auth.Guards.RoleGuard { const MatchingMode = roleMetadata.mode || Auth.Model.MatchingMode.ANY; const request = context.switchToHttp().getRequest(); - const authHeader = request.headers['authorization']; + const authHeader = request.headers[H.Authorization]; if (!authHeader) { throw new UnauthorizedException('Missing authorization token'); @@ -86,7 +89,7 @@ export class PermissionsGuard implements Auth.Guards.PermissionGuard { } const request = context.switchToHttp().getRequest(); - const authHeader = request.headers['authorization']; + const authHeader = request.headers[H.Authorization]; if (!authHeader) { throw new UnauthorizedException('Missing authorization token'); diff --git a/turbo/generators/templates/integration/cms-model.hbs b/turbo/generators/templates/integration/cms-model.hbs new file mode 100644 index 000000000..8c0f5598c --- /dev/null +++ b/turbo/generators/templates/integration/cms-model.hbs @@ -0,0 +1,5 @@ +import { extendCmsModel, type ExtendedCmsModel } from './extend-cms-model'; + +export const Model: ExtendedCmsModel = extendCmsModel({ + // Add custom CMS models here. +}); \ No newline at end of file diff --git a/turbo/generators/templates/integration/controller.hbs b/turbo/generators/templates/integration/controller.hbs index a2dbe4f84..7fd110c9b 100644 --- a/turbo/generators/templates/integration/controller.hbs +++ b/turbo/generators/templates/integration/controller.hbs @@ -1,7 +1,20 @@ -import { Get, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; -import { {{pascalCase module}} } from '@o2s/framework/modules'; +{{#if (eq module 'cms')}} + import { CMS } from '@o2s/framework/modules'; +{{else}} + import { + {{pascalCase module}} + } from '@o2s/framework/modules'; +{{/if}} @Injectable() -export class {{pascalCase module}}Controller extends {{pascalCase module}}.Controller { -} +{{#if (eq module 'cms')}} + export class + {{pascalCase module}}Controller extends CMS.Controller { +{{else}} + export class + {{pascalCase module}}Controller extends + {{pascalCase module}}.Controller { +{{/if}} +} \ No newline at end of file diff --git a/turbo/generators/templates/integration/extend-cms-model.hbs b/turbo/generators/templates/integration/extend-cms-model.hbs new file mode 100644 index 000000000..21ef482d9 --- /dev/null +++ b/turbo/generators/templates/integration/extend-cms-model.hbs @@ -0,0 +1,13 @@ +import { CMS } from '@o2s/framework/modules'; + +type CmsModel = typeof CMS.Model; +type CmsModelExtensions = { [key: string]: unknown }; + +export type ExtendedCmsModel = CmsModel & CmsModelExtensions; + +export const extendCmsModel = (extensions: CmsModelExtensions): ExtendedCmsModel => { + return { + ...CMS.Model, + ...extensions, + }; +}; diff --git a/turbo/generators/templates/integration/module-index.hbs b/turbo/generators/templates/integration/module-index.hbs index da1fc2686..5cd75b4c9 100644 --- a/turbo/generators/templates/integration/module-index.hbs +++ b/turbo/generators/templates/integration/module-index.hbs @@ -1,6 +1,19 @@ -import { {{pascalCase module}} } from '@o2s/framework/modules'; +{{#if (eq module 'cms')}} + import { CMS } from '@o2s/framework/modules'; +{{else}} + import { + {{pascalCase module}} + } from '@o2s/framework/modules'; +{{/if}} -export { {{pascalCase module}}Service as Service } from './{{kebabCase module}}.service'; +export { +{{pascalCase module}}Service as Service } from './{{kebabCase module}}.service'; -export import Request = {{pascalCase module}}.Request; -export import Model = {{pascalCase module}}.Model; \ No newline at end of file +{{#if (eq module 'cms')}} + export import Request = CMS.Request; + export { Model } from './cms.model'; +{{else}} + export import Request = + {{pascalCase module}}.Request; export import Model = + {{pascalCase module}}.Model; +{{/if}} \ No newline at end of file diff --git a/turbo/generators/templates/integration/modules-index.hbs b/turbo/generators/templates/integration/modules-index.hbs index 0d856ec41..e66eea940 100644 --- a/turbo/generators/templates/integration/modules-index.hbs +++ b/turbo/generators/templates/integration/modules-index.hbs @@ -1,2 +1,6 @@ +{{#if (eq module 'cms')}} +export * as CMS from './{{kebabCase module}}'; +{{else}} export * as {{pascalCase module}} from './{{kebabCase module}}'; +{{/if}} // MODULE_EXPORTS \ No newline at end of file diff --git a/turbo/generators/templates/integration/service.hbs b/turbo/generators/templates/integration/service.hbs index 9585e38b6..d3817af32 100644 --- a/turbo/generators/templates/integration/service.hbs +++ b/turbo/generators/templates/integration/service.hbs @@ -1,14 +1,23 @@ import { Injectable } from '@nestjs/common'; -import { Observable } from 'rxjs'; +{{#if (eq module 'cms')}} +import { CMS } from '@o2s/framework/modules'; +{{else}} import { {{pascalCase module}} } from '@o2s/framework/modules'; +{{/if}} // Optional: Import Auth when you need to inject Auth.Service for permission checking // import { Auth } from '@o2s/framework/modules'; @Injectable() -export class {{pascalCase module}}Service implements {{pascalCase module}}.Service { - // Optional: Inject Auth.Service when you need to check permissions or add permission flags - // constructor( - // private readonly authService: Auth.Service, - // ) {} -} +{{#if (eq module 'cms')}} +export class {{pascalCase module}}Service extends CMS.Service { +{{else}} +export class {{pascalCase module}}Service extends {{pascalCase module}}.Service { +{{/if}} + // Optional: Inject Auth.Service when you need to check permissions or add permission flags + // constructor( + // private readonly authService: Auth.Service, + // ) { + // super(); + // } +} \ No newline at end of file