diff --git a/.gitignore b/.gitignore index 2778e7ae1..b8afb86f4 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* .idea .vscode .cursor +agent-os # Local history .lh diff --git a/apps/api-harmonization/package.json b/apps/api-harmonization/package.json index f6f7c2b1f..3bdd646cd 100644 --- a/apps/api-harmonization/package.json +++ b/apps/api-harmonization/package.json @@ -46,8 +46,10 @@ "@o2s/blocks.orders-summary": "*", "@o2s/blocks.payments-history": "*", "@o2s/blocks.payments-summary": "*", + "@o2s/blocks.product-details": "*", "@o2s/blocks.product-list": "*", "@o2s/blocks.quick-links": "*", + "@o2s/blocks.recommended-products": "*", "@o2s/blocks.service-details": "*", "@o2s/blocks.service-list": "*", "@o2s/blocks.surveyjs-form": "*", diff --git a/apps/api-harmonization/src/app.module.ts b/apps/api-harmonization/src/app.module.ts index 21001e8b4..97cf196d6 100644 --- a/apps/api-harmonization/src/app.module.ts +++ b/apps/api-harmonization/src/app.module.ts @@ -40,8 +40,10 @@ import * as OrderList from '@o2s/blocks.order-list/api-harmonization'; import * as OrdersSummary from '@o2s/blocks.orders-summary/api-harmonization'; import * as PaymentsHistory from '@o2s/blocks.payments-history/api-harmonization'; import * as PaymentsSummary from '@o2s/blocks.payments-summary/api-harmonization'; +import * as ProductDetails from '@o2s/blocks.product-details/api-harmonization'; import * as ProductList from '@o2s/blocks.product-list/api-harmonization'; import * as QuickLinks from '@o2s/blocks.quick-links/api-harmonization'; +import * as RecommendedProducts from '@o2s/blocks.recommended-products/api-harmonization'; import * as ServiceDetails from '@o2s/blocks.service-details/api-harmonization'; import * as ServiceList from '@o2s/blocks.service-list/api-harmonization'; import * as SurveyJsForm from '@o2s/blocks.surveyjs-form/api-harmonization'; @@ -140,6 +142,8 @@ export const AuthModuleBaseModule = AuthModule.Module.register(AppConfig); ProductList.Module.register(AppConfig), NotificationSummary.Module.register(AppConfig), TicketSummary.Module.register(AppConfig), + ProductDetails.Module.register(AppConfig), + RecommendedProducts.Module.register(AppConfig), // BLOCK REGISTER ], providers: [ diff --git a/apps/api-harmonization/src/modules/page/page.model.ts b/apps/api-harmonization/src/modules/page/page.model.ts index cdee10b33..7a926c24a 100644 --- a/apps/api-harmonization/src/modules/page/page.model.ts +++ b/apps/api-harmonization/src/modules/page/page.model.ts @@ -16,8 +16,10 @@ import * as OrderList from '@o2s/blocks.order-list/api-harmonization'; import * as OrdersSummary from '@o2s/blocks.orders-summary/api-harmonization'; import * as PaymentsHistory from '@o2s/blocks.payments-history/api-harmonization'; import * as PaymentsSummary from '@o2s/blocks.payments-summary/api-harmonization'; +import * as ProductDetails from '@o2s/blocks.product-details/api-harmonization'; import * as ProductList from '@o2s/blocks.product-list/api-harmonization'; import * as QuickLinks from '@o2s/blocks.quick-links/api-harmonization'; +import * as RecommendedProducts from '@o2s/blocks.recommended-products/api-harmonization'; import * as ServiceDetails from '@o2s/blocks.service-details/api-harmonization'; import * as ServiceList from '@o2s/blocks.service-list/api-harmonization'; import * as Surveyjs from '@o2s/blocks.surveyjs-form/api-harmonization'; @@ -78,6 +80,8 @@ export class PageData { export type Blocks = // BLOCK REGISTER + | RecommendedProducts.Model.RecommendedProductsBlock['__typename'] + | ProductDetails.Model.ProductDetailsBlock['__typename'] | ProductList.Model.ProductListBlock['__typename'] | TicketSummary.Model.TicketSummaryBlock['__typename'] | NotificationSummary.Model.NotificationSummaryBlock['__typename'] diff --git a/apps/frontend/package.json b/apps/frontend/package.json index b005ee05f..7f8e0213d 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -31,8 +31,10 @@ "@o2s/blocks.orders-summary": "*", "@o2s/blocks.payments-history": "*", "@o2s/blocks.payments-summary": "*", + "@o2s/blocks.product-details": "*", "@o2s/blocks.product-list": "*", "@o2s/blocks.quick-links": "*", + "@o2s/blocks.recommended-products": "*", "@o2s/blocks.service-details": "*", "@o2s/blocks.service-list": "*", "@o2s/blocks.surveyjs-form": "*", @@ -92,4 +94,4 @@ "@types/react": "19.2.7", "@types/react-dom": "19.2.3" } -} \ No newline at end of file +} diff --git a/apps/frontend/src/blocks/renderBlocks.tsx b/apps/frontend/src/blocks/renderBlocks.tsx index 0a2f1ed82..45d89dde1 100644 --- a/apps/frontend/src/blocks/renderBlocks.tsx +++ b/apps/frontend/src/blocks/renderBlocks.tsx @@ -17,8 +17,10 @@ import * as OrderList from '@o2s/blocks.order-list/frontend'; import * as OrdersSummary from '@o2s/blocks.orders-summary/frontend'; import * as PaymentsHistory from '@o2s/blocks.payments-history/frontend'; import * as PaymentsSummary from '@o2s/blocks.payments-summary/frontend'; +import * as ProductDetails from '@o2s/blocks.product-details/frontend'; import * as ProductList from '@o2s/blocks.product-list/frontend'; import * as QuickLinks from '@o2s/blocks.quick-links/frontend'; +import * as RecommendedProducts from '@o2s/blocks.recommended-products/frontend'; import * as ServiceDetails from '@o2s/blocks.service-details/frontend'; import * as ServiceList from '@o2s/blocks.service-list/frontend'; import * as SurveyJsForm from '@o2s/blocks.surveyjs-form/frontend'; @@ -142,6 +144,10 @@ const renderBlock = (typename: string, blockProps: BlockProps) => { return ; case 'TicketSummaryBlock': return ; + case 'ProductDetailsBlock': + return ; + case 'RecommendedProductsBlock': + return ; // BLOCK REGISTER default: return null; diff --git a/package-lock.json b/package-lock.json index e017786f2..0b8d2353a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,8 +90,10 @@ "@o2s/blocks.orders-summary": "*", "@o2s/blocks.payments-history": "*", "@o2s/blocks.payments-summary": "*", + "@o2s/blocks.product-details": "*", "@o2s/blocks.product-list": "*", "@o2s/blocks.quick-links": "*", + "@o2s/blocks.recommended-products": "*", "@o2s/blocks.service-details": "*", "@o2s/blocks.service-list": "*", "@o2s/blocks.surveyjs-form": "*", @@ -594,8 +596,10 @@ "@o2s/blocks.orders-summary": "*", "@o2s/blocks.payments-history": "*", "@o2s/blocks.payments-summary": "*", + "@o2s/blocks.product-details": "*", "@o2s/blocks.product-list": "*", "@o2s/blocks.quick-links": "*", + "@o2s/blocks.recommended-products": "*", "@o2s/blocks.service-details": "*", "@o2s/blocks.service-list": "*", "@o2s/blocks.surveyjs-form": "*", @@ -13423,6 +13427,10 @@ "resolved": "packages/blocks/pricing-section", "link": true }, + "node_modules/@o2s/blocks.product-details": { + "resolved": "packages/blocks/product-details", + "link": true + }, "node_modules/@o2s/blocks.product-list": { "resolved": "packages/blocks/product-list", "link": true @@ -13431,6 +13439,10 @@ "resolved": "packages/blocks/quick-links", "link": true }, + "node_modules/@o2s/blocks.recommended-products": { + "resolved": "packages/blocks/recommended-products", + "link": true + }, "node_modules/@o2s/blocks.service-details": { "resolved": "packages/blocks/service-details", "link": true @@ -41283,6 +41295,23 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" }, + "node_modules/react-hook-form": { + "version": "7.69.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.69.0.tgz", + "integrity": "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-indiana-drag-scroll": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-indiana-drag-scroll/-/react-indiana-drag-scroll-2.2.1.tgz", @@ -50211,6 +50240,48 @@ "tailwindcss": "^4" } }, + "packages/blocks/product-details": { + "name": "@o2s/blocks.product-details", + "version": "0.0.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.1", + "prettier": "^3.6.2", + "react-hook-form": "^7.54.0", + "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", + "react-hook-form": "^7", + "rxjs": "^7", + "tailwindcss": "^4" + } + }, "packages/blocks/product-list": { "name": "@o2s/blocks.product-list", "version": "0.1.1", @@ -50290,6 +50361,46 @@ "tailwindcss": "^4" } }, + "packages/blocks/recommended-products": { + "name": "@o2s/blocks.recommended-products", + "version": "0.0.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.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/service-details": { "name": "@o2s/blocks.service-details", "version": "1.1.2", diff --git a/packages/blocks/product-details/.gitignore b/packages/blocks/product-details/.gitignore new file mode 100644 index 000000000..29986a380 --- /dev/null +++ b/packages/blocks/product-details/.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/product-details/.prettierrc.mjs b/packages/blocks/product-details/.prettierrc.mjs new file mode 100644 index 000000000..93b66d398 --- /dev/null +++ b/packages/blocks/product-details/.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/product-details/eslint.config.mjs b/packages/blocks/product-details/eslint.config.mjs new file mode 100644 index 000000000..223f2af08 --- /dev/null +++ b/packages/blocks/product-details/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/product-details/lint-staged.config.mjs b/packages/blocks/product-details/lint-staged.config.mjs new file mode 100644 index 000000000..b47bd93b9 --- /dev/null +++ b/packages/blocks/product-details/lint-staged.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/lint-staged-config/base'; + +export default config; diff --git a/packages/blocks/product-details/package.json b/packages/blocks/product-details/package.json new file mode 100644 index 000000000..62c29f779 --- /dev/null +++ b/packages/blocks/product-details/package.json @@ -0,0 +1,60 @@ +{ + "name": "@o2s/blocks.product-details", + "version": "0.0.1", + "private": false, + "license": "MIT", + "description": "A block for displaying comprehensive product information including title, images, price, description.", + "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/product-details.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.1", + "prettier": "^3.6.2", + "react-hook-form": "^7.54.0", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "react": "^19", + "react-dom": "^19", + "react-hook-form": "^7", + "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/product-details/src/api-harmonization/index.ts b/packages/blocks/product-details/src/api-harmonization/index.ts new file mode 100644 index 000000000..4552bbd0c --- /dev/null +++ b/packages/blocks/product-details/src/api-harmonization/index.ts @@ -0,0 +1,10 @@ +export const URL = '/blocks/product-details'; + +export { ProductDetailsBlockModule as Module } from './product-details.module'; +export { ProductDetailsService as Service } from './product-details.service'; +export { ProductDetailsController as Controller } from './product-details.controller'; + +export * as Model from './product-details.model'; +export * as Request from './product-details.request'; + +export * from './product-details.client'; diff --git a/packages/blocks/product-details/src/api-harmonization/product-details.client.ts b/packages/blocks/product-details/src/api-harmonization/product-details.client.ts new file mode 100644 index 000000000..51ef615ac --- /dev/null +++ b/packages/blocks/product-details/src/api-harmonization/product-details.client.ts @@ -0,0 +1,4 @@ +export { URL } from './product-details.url'; + +export * as Model from './product-details.model'; +export * as Request from './product-details.request'; 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 new file mode 100644 index 000000000..76e80cb8a --- /dev/null +++ b/packages/blocks/product-details/src/api-harmonization/product-details.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Headers, Param, 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 type { GetProductDetailsBlockQuery } from './product-details.request'; +import { ProductDetailsService } from './product-details.service'; + +@Controller(URL) +@UseInterceptors(LoggerService) +export class ProductDetailsController { + constructor(protected readonly service: ProductDetailsService) {} + + @Get(':id') + @Auth.Decorators.Roles({ roles: [] }) + getProductDetails( + @Param('id') id: string, + @Query() query: GetProductDetailsBlockQuery, + @Headers() headers: Models.Headers.AppHeaders, + ) { + return this.service.getProductDetails(id, 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 new file mode 100644 index 000000000..7866b1535 --- /dev/null +++ b/packages/blocks/product-details/src/api-harmonization/product-details.mapper.ts @@ -0,0 +1,44 @@ +import { CMS, Products } from '@o2s/configs.integrations'; + +import * as Model from './product-details.model'; + +export const mapProductDetails = ( + product: Products.Model.Product, + cms: CMS.Model.ProductDetailsBlock.ProductDetailsBlock, +): Model.ProductDetailsBlock => { + // Map Products.Model.Product to Model.Product + const mappedProduct: Model.Product = { + ...product, + images: product.images || (product.image ? [product.image] : []), + badges: + product.tags?.map((tag) => ({ + label: tag.label, + variant: tag.variant as Model.Badge['variant'], + })) || [], + keySpecs: product.keySpecs || [], + detailedSpecs: product.detailedSpecs || [], + }; + + const labels: Model.Labels = { + actionButtonLabel: cms.labels.actionButtonLabel, + specificationsTitle: cms.labels.specificationsTitle, + descriptionTitle: cms.labels.descriptionTitle, + downloadLabel: cms.labels.downloadLabel, + priceLabel: cms.labels.priceLabel, + offerLabel: cms.labels.offerLabel, + }; + + return { + __typename: 'ProductDetailsBlock', + id: product.id, + product: mappedProduct, + actionButton: labels.actionButtonLabel + ? { + label: labels.actionButtonLabel, + variant: 'default', + icon: 'MessageCircle', + } + : undefined, + labels, + }; +}; 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 new file mode 100644 index 000000000..06949c98c --- /dev/null +++ b/packages/blocks/product-details/src/api-harmonization/product-details.model.ts @@ -0,0 +1,39 @@ +import { Products } from '@o2s/configs.integrations'; + +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; + +export type Badge = { + label: string; + variant: 'default' | 'secondary' | 'destructive' | 'outline'; +}; + +export type Product = Products.Model.Product & { + badges?: Badge[]; + images: + | NonNullable + | (Products.Model.Product['image'] extends undefined ? never : [Products.Model.Product['image']]); +}; + +export type ActionButton = { + label: string; + onClick?: () => void; + href?: string; + variant?: 'default' | 'secondary' | 'destructive' | 'outline'; + icon?: string; +}; + +export type Labels = { + actionButtonLabel?: string; + downloadLabel?: string; + specificationsTitle: string; + descriptionTitle: string; + priceLabel: string; + offerLabel: string; +}; + +export type ProductDetailsBlock = ApiModels.Block.Block & { + __typename: 'ProductDetailsBlock'; + product: Product; + actionButton?: ActionButton; + labels: Labels; +}; diff --git a/packages/blocks/product-details/src/api-harmonization/product-details.module.ts b/packages/blocks/product-details/src/api-harmonization/product-details.module.ts new file mode 100644 index 000000000..6aca9b914 --- /dev/null +++ b/packages/blocks/product-details/src/api-harmonization/product-details.module.ts @@ -0,0 +1,29 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { CMS, Products } from '@o2s/configs.integrations'; + +import * as Framework from '@o2s/framework/modules'; + +import { ProductDetailsController } from './product-details.controller'; +import { ProductDetailsService } from './product-details.service'; + +@Module({}) +export class ProductDetailsBlockModule { + static register(_config: Framework.ApiConfig): DynamicModule { + return { + module: ProductDetailsBlockModule, + providers: [ + ProductDetailsService, + { + provide: CMS.Service, + useExisting: Framework.CMS.Service, + }, + { + provide: Products.Service, + useExisting: Framework.Products.Service, + }, + ], + controllers: [ProductDetailsController], + exports: [ProductDetailsService], + }; + } +} diff --git a/packages/blocks/product-details/src/api-harmonization/product-details.request.ts b/packages/blocks/product-details/src/api-harmonization/product-details.request.ts new file mode 100644 index 000000000..d71f81cad --- /dev/null +++ b/packages/blocks/product-details/src/api-harmonization/product-details.request.ts @@ -0,0 +1,8 @@ +export type GetProductDetailsBlockParams = { + id: string; +}; + +export type GetProductDetailsBlockQuery = { + id: string; + locale?: 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 new file mode 100644 index 000000000..760b93559 --- /dev/null +++ b/packages/blocks/product-details/src/api-harmonization/product-details.service.spec.ts @@ -0,0 +1,61 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CMS, Products } from '@o2s/configs.integrations'; +import { of } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProductDetailsService } from './product-details.service'; + +describe('ProductDetailsService', () => { + let service: ProductDetailsService; + let cmsService: CMS.Service; + let productsService: Products.Service; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProductDetailsService, + { + provide: CMS.Service, + useValue: { + getProductDetailsBlock: vi.fn().mockReturnValue( + of({ + id: 'product-details-1', + labels: { + actionButtonLabel: 'Request Quote', + specificationsTitle: 'Specifications', + descriptionTitle: 'Description', + downloadLabel: 'Download Brochure', + priceLabel: 'Price', + offerLabel: 'Offer', + }, + }), + ), + }, + }, + { + provide: Products.Service, + useValue: { + getProduct: vi.fn(), + getProductList: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ProductDetailsService); + cmsService = module.get(CMS.Service); + productsService = module.get(Products.Service); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should have cmsService injected', () => { + expect(cmsService).toBeDefined(); + }); + + it('should have productsService injected', () => { + expect(productsService).toBeDefined(); + }); +}); 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 new file mode 100644 index 000000000..af5d782d2 --- /dev/null +++ b/packages/blocks/product-details/src/api-harmonization/product-details.service.ts @@ -0,0 +1,44 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CMS, Products } from '@o2s/configs.integrations'; +import { Observable, forkJoin, map } from 'rxjs'; + +import { Models } from '@o2s/utils.api-harmonization'; + +import { mapProductDetails } from './product-details.mapper'; +import * as Model from './product-details.model'; +import * as Request from './product-details.request'; + +@Injectable() +export class ProductDetailsService { + constructor( + private readonly cmsService: CMS.Service, + private readonly productsService: Products.Service, + ) {} + + getProductDetails( + id: string, + query: Request.GetProductDetailsBlockQuery, + headers: Models.Headers.AppHeaders, + ): Observable { + const locale = query.locale || headers['x-locale'] || 'en'; + + const cms = this.cmsService.getProductDetailsBlock({ + id: query.id, + locale, + }); + const product = this.productsService.getProduct({ + id, + locale, + }); + + return forkJoin([cms, product]).pipe( + map(([cms, product]) => { + if (!product) { + throw new NotFoundException(); + } + + return mapProductDetails(product, cms); + }), + ); + } +} diff --git a/packages/blocks/product-details/src/api-harmonization/product-details.url.ts b/packages/blocks/product-details/src/api-harmonization/product-details.url.ts new file mode 100644 index 000000000..f1618c83b --- /dev/null +++ b/packages/blocks/product-details/src/api-harmonization/product-details.url.ts @@ -0,0 +1 @@ +export const URL = '/blocks/product-details'; diff --git a/packages/blocks/product-details/src/frontend/ProductDetails.client.stories.tsx b/packages/blocks/product-details/src/frontend/ProductDetails.client.stories.tsx new file mode 100644 index 000000000..d7769a00c --- /dev/null +++ b/packages/blocks/product-details/src/frontend/ProductDetails.client.stories.tsx @@ -0,0 +1,144 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; + +import { ProductDetailsPure } from './ProductDetails.client'; + +const meta = { + title: 'Blocks/ProductDetails', + component: ProductDetailsPure, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + __typename: 'ProductDetailsBlock', + id: 'product-details-1', + productId: 'PRD-015', + locale: 'en', + routing: { + locales: ['en', 'pl'], + defaultLocale: 'en', + pathnames: { + '/products/[id]': { + en: '/products/[id]', + pl: '/produkty/[id]', + }, + }, + }, + product: { + id: 'PRD-015', + sku: 'PREMIUM-XL-2000', + name: 'Premium Industrial Machine XL-2000', + description: + '

The XL-2000 is a state-of-the-art industrial machine designed for maximum efficiency and durability. With advanced automation features and energy-saving technology, this machine is perfect for modern manufacturing facilities.

Advanced Automation Capabilities

The XL-2000 features a fully integrated automation system with intelligent control panel that provides real-time monitoring of all critical parameters.

Key features include:

  • Programmable operating sequences with customizable presets
  • Automatic fault detection and diagnostic reporting
  • Remote monitoring via integrated IoT connectivity
  • Predictive maintenance alerts

Energy Efficiency

With an exceptional energy efficiency rating of Class A++, the XL-2000 reduces energy consumption by up to 40% compared to traditional equipment. The low noise level of 65 dB ensures compliance with workplace safety regulations.

Durability and Maintenance

Designed for continuous operation in demanding environments, the XL-2000 features robust construction and modular design for easy maintenance access.

Maintenance highlights:

  • Quick-access panels for major components
  • Tool-free access to frequently serviced parts
  • Comprehensive diagnostic system
', + shortDescription: + 'State-of-the-art industrial machine with advanced automation and energy-saving technology', + subtitle: 'Industrial Equipment • Manufacturing Solutions', + image: { + url: 'https://picsum.photos/1200/800', + width: 1200, + height: 800, + alt: 'Premium Industrial Machine XL-2000', + }, + images: [ + { + url: 'https://picsum.photos/1200/800?random=1', + alt: 'Industrial Machine Front View', + width: 1200, + height: 800, + }, + { + url: 'https://picsum.photos/1200/800?random=2', + alt: 'Industrial Machine Side View', + width: 1200, + height: 800, + }, + { + url: 'https://picsum.photos/1200/800?random=3', + alt: 'Industrial Machine Control Panel', + width: 1200, + height: 800, + }, + ], + price: { + value: 125000, + currency: 'USD', + }, + link: 'https://example.com/products/xl-2000', + type: 'PHYSICAL', + category: 'TOOLS', + tags: [ + { + label: 'New', + variant: 'secondary', + }, + { + label: 'Bestseller', + variant: 'default', + }, + ], + badges: [ + { label: 'New', variant: 'secondary' }, + { label: 'Bestseller', variant: 'default' }, + ], + keySpecs: [ + { value: '2024', icon: 'Calendar' }, + { value: 'New', icon: 'CheckCircle' }, + { value: 'Electric', icon: 'Fuel' }, + { value: 'Automatic', icon: 'Settings' }, + ], + detailedSpecs: [ + { label: 'Engine Power', value: '150 kW' }, + { label: 'Max Speed', value: '2800 RPM' }, + { label: 'Operating Voltage', value: '380-480V' }, + { label: 'Dimensions', value: '2500 x 1800 x 2200 mm' }, + { label: 'Weight', value: '3500 kg' }, + { label: 'Energy Efficiency', value: 'Class A++' }, + { label: 'Noise Level', value: '65 dB' }, + { label: 'Operating Temperature', value: '-10°C to +40°C' }, + { label: 'Protection Rating', value: 'IP54' }, + { label: 'Certification', value: 'CE, ISO 9001' }, + ], + location: 'Chicago, IL', + }, + actionButton: { + label: 'Request Quote', + variant: 'default', + icon: 'MessageCircle', + onClick: () => { + console.log('Action button clicked'); + }, + }, + labels: { + specificationsTitle: 'Specifications', + descriptionTitle: 'Description', + downloadLabel: 'Download Brochure', + priceLabel: 'Price', + offerLabel: 'Offer', + }, + hasPriority: false, + }, +}; + +export const WithSecondaryButton: Story = { + args: { + ...Default.args, + actionButton: { + label: 'Add to Cart', + variant: 'secondary', + icon: 'ShoppingCart', + onClick: () => { + console.log('Add to cart clicked'); + }, + }, + }, +}; + +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 new file mode 100644 index 000000000..6899669fc --- /dev/null +++ b/packages/blocks/product-details/src/frontend/ProductDetails.client.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { createNavigation } from 'next-intl/navigation'; +import React from 'react'; + +import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; +import { Price } from '@o2s/ui/components/Price'; +import { ProductGallery } from '@o2s/ui/components/ProductGallery'; +import { RichText } from '@o2s/ui/components/RichText'; + +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 { ProductDetailsPureProps } from './ProductDetails.types'; + +export const ProductDetailsPure: React.FC = ({ + locale, + routing, + hasPriority, + ...component +}) => { + const { Link: LinkComponent } = createNavigation(routing); + const { product, labels, actionButton } = component; + + const keySpecs = product.keySpecs ?? []; + + return ( +
+
+
+ +
+ {product.name} + {product.subtitle && ( + + {product.subtitle} + + )} + + {product.badges && product.badges.length > 0 && ( +
    + {product.badges.map((badge, index) => ( +
  • + {badge.label} +
  • + ))} +
+ )} +
+ + + + {keySpecs.length > 0 && ( + <> +
    + {keySpecs.map((spec, index) => ( +
  • + {spec.icon && ( + + )} + {spec.value && ( + {spec.value} + )} +
  • + ))} +
+ + + )} + + {product.description && ( +
+ +

{labels.descriptionTitle}

+
+ +
+ )} + + + + {product.detailedSpecs && product.detailedSpecs.length > 0 && ( +
+ +

{labels.specificationsTitle}

+
+
    + {product.detailedSpecs.map((spec, index) => ( +
  • + {spec.label} + {spec.value} +
  • + ))} +
+
+ )} + + {(product.sku || product.location) && ( + <> + +
+ {product.sku && ( +
+ + + {labels.offerLabel}: {product.sku} + +
+ )} + {product.location && ( +
+ + {product.location} +
+ )} +
+ + )} +
+ +
+
+
+ {product.name} + {product.subtitle && ( + + {product.subtitle} + + )} +
+ + {product.badges && product.badges.length > 0 && ( +
    + {product.badges.map((badge, index) => ( +
  • + {badge.label} +
  • + ))} +
+ )} + +
+ {labels.priceLabel} + + + +
+ + {actionButton && ( + <> + +
+ {actionButton.href ? ( + + ) : ( + + )} +
+ + )} +
+
+
+ + {actionButton && ( + <> +
+
+
+ {labels.priceLabel} + + + +
+ {actionButton.href ? ( + + ) : ( + + )} +
+
+ +
+ + )} +
+ ); +}; diff --git a/packages/blocks/product-details/src/frontend/ProductDetails.renderer.tsx b/packages/blocks/product-details/src/frontend/ProductDetails.renderer.tsx new file mode 100644 index 000000000..4b1f8886e --- /dev/null +++ b/packages/blocks/product-details/src/frontend/ProductDetails.renderer.tsx @@ -0,0 +1,39 @@ +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 { ProductDetails } from './ProductDetails.server'; +import { ProductDetailsRendererProps } from './ProductDetails.types'; + +export const ProductDetailsRenderer: React.FC = ({ + id, + slug, + routing, + locale: propLocale, + hasPriority, +}) => { + const localeFromHook = useLocale(); + const locale = propLocale || localeFromHook; + + if (!slug[1]) { + return null; + } + + return ( + + + + + + + } + > + + + ); +}; diff --git a/packages/blocks/product-details/src/frontend/ProductDetails.server.tsx b/packages/blocks/product-details/src/frontend/ProductDetails.server.tsx new file mode 100644 index 000000000..dbe1331e5 --- /dev/null +++ b/packages/blocks/product-details/src/frontend/ProductDetails.server.tsx @@ -0,0 +1,36 @@ +import dynamic from 'next/dynamic'; +import React from 'react'; + +import { sdk } from '../sdk'; + +import { ProductDetailsProps } from './ProductDetails.types'; + +export const ProductDetailsDynamic = dynamic(() => + import('./ProductDetails.client').then((module) => module.ProductDetailsPure), +); + +export const ProductDetails: React.FC = async ({ + id, + productId, + locale, + routing, + hasPriority, +}) => { + try { + const data = await sdk.blocks.getProductDetails({ id: productId }, { id, locale }, { 'x-locale': locale }); + + return ( + + ); + } catch (error) { + console.error('Error fetching ProductDetails block', error); + return null; + } +}; diff --git a/packages/blocks/product-details/src/frontend/ProductDetails.types.ts b/packages/blocks/product-details/src/frontend/ProductDetails.types.ts new file mode 100644 index 000000000..f2bbc606d --- /dev/null +++ b/packages/blocks/product-details/src/frontend/ProductDetails.types.ts @@ -0,0 +1,17 @@ +import { defineRouting } from 'next-intl/routing'; + +import * as Client from '../api-harmonization/product-details.client'; + +export interface ProductDetailsProps { + id: string; + productId: string; + locale: string; + routing: ReturnType; + hasPriority?: boolean; +} + +export type ProductDetailsPureProps = ProductDetailsProps & Client.Model.ProductDetailsBlock; + +export type ProductDetailsRendererProps = Omit & { + slug: string[]; +}; diff --git a/packages/blocks/product-details/src/frontend/index.ts b/packages/blocks/product-details/src/frontend/index.ts new file mode 100644 index 000000000..32d764b60 --- /dev/null +++ b/packages/blocks/product-details/src/frontend/index.ts @@ -0,0 +1,3 @@ +export { ProductDetailsPure as Client } from './ProductDetails.client'; +export { ProductDetails as Server } from './ProductDetails.server'; +export { ProductDetailsRenderer as Renderer } from './ProductDetails.renderer'; diff --git a/packages/blocks/product-details/src/sdk/index.ts b/packages/blocks/product-details/src/sdk/index.ts new file mode 100644 index 000000000..34f37b191 --- /dev/null +++ b/packages/blocks/product-details/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 { productDetails } from './product-details'; + +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: { + getProductDetails: productDetails(internalSdk).blocks.getProductDetails, + }, +}); diff --git a/packages/blocks/product-details/src/sdk/product-details.ts b/packages/blocks/product-details/src/sdk/product-details.ts new file mode 100644 index 000000000..1eac959fa --- /dev/null +++ b/packages/blocks/product-details/src/sdk/product-details.ts @@ -0,0 +1,31 @@ +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/product-details.client'; + +export const productDetails = (sdk: Sdk) => ({ + blocks: { + getProductDetails: ( + params: Request.GetProductDetailsBlockParams, + query?: Request.GetProductDetailsBlockQuery, + headers?: Models.Headers.AppHeaders, + authorization?: string, + ): Promise => + sdk.makeRequest({ + method: 'get', + url: `${URL}/${params.id}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + params: query, + }), + }, +}); diff --git a/packages/blocks/product-details/tsconfig.api.json b/packages/blocks/product-details/tsconfig.api.json new file mode 100644 index 000000000..426543f70 --- /dev/null +++ b/packages/blocks/product-details/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/product-details/tsconfig.frontend.json b/packages/blocks/product-details/tsconfig.frontend.json new file mode 100644 index 000000000..ca3746c89 --- /dev/null +++ b/packages/blocks/product-details/tsconfig.frontend.json @@ -0,0 +1,23 @@ +{ + "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/product-details.client.ts", + "src/api-harmonization/product-details.model.ts", + "src/api-harmonization/product-details.request.ts", + "src/api-harmonization/product-details.url.ts", + "src/sdk" + ] +} diff --git a/packages/blocks/product-details/tsconfig.json b/packages/blocks/product-details/tsconfig.json new file mode 100644 index 000000000..c3031c1dd --- /dev/null +++ b/packages/blocks/product-details/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/product-details/tsconfig.sdk.json b/packages/blocks/product-details/tsconfig.sdk.json new file mode 100644 index 000000000..3efa3a00f --- /dev/null +++ b/packages/blocks/product-details/tsconfig.sdk.json @@ -0,0 +1,20 @@ +{ + "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/product-details.client.ts", + "src/api-harmonization/product-details.model.ts", + "src/api-harmonization/product-details.request.ts", + "src/api-harmonization/product-details.url.ts" + ] +} diff --git a/packages/blocks/product-details/vitest.config.mjs b/packages/blocks/product-details/vitest.config.mjs new file mode 100644 index 000000000..5ca3940be --- /dev/null +++ b/packages/blocks/product-details/vitest.config.mjs @@ -0,0 +1 @@ +import { config } from '@o2s/vitest-config/block'; export default config; diff --git a/packages/blocks/recommended-products/.gitignore b/packages/blocks/recommended-products/.gitignore new file mode 100644 index 000000000..29986a380 --- /dev/null +++ b/packages/blocks/recommended-products/.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/recommended-products/.prettierrc.mjs b/packages/blocks/recommended-products/.prettierrc.mjs new file mode 100644 index 000000000..93b66d398 --- /dev/null +++ b/packages/blocks/recommended-products/.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/recommended-products/eslint.config.mjs b/packages/blocks/recommended-products/eslint.config.mjs new file mode 100644 index 000000000..223f2af08 --- /dev/null +++ b/packages/blocks/recommended-products/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/recommended-products/lint-staged.config.mjs b/packages/blocks/recommended-products/lint-staged.config.mjs new file mode 100644 index 000000000..b47bd93b9 --- /dev/null +++ b/packages/blocks/recommended-products/lint-staged.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/lint-staged-config/base'; + +export default config; diff --git a/packages/blocks/recommended-products/package.json b/packages/blocks/recommended-products/package.json new file mode 100644 index 000000000..1e8be71a4 --- /dev/null +++ b/packages/blocks/recommended-products/package.json @@ -0,0 +1,58 @@ +{ + "name": "@o2s/blocks.recommended-products", + "version": "0.0.1", + "private": false, + "license": "MIT", + "description": "A simple block displaying static content in the form of an RecommendedProducts.", + "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/recommended-products.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.1", + "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/recommended-products/src/api-harmonization/index.ts b/packages/blocks/recommended-products/src/api-harmonization/index.ts new file mode 100644 index 000000000..962d86594 --- /dev/null +++ b/packages/blocks/recommended-products/src/api-harmonization/index.ts @@ -0,0 +1,8 @@ +export const URL = '/blocks/recommended-products'; + +export { RecommendedProductsBlockModule as Module } from './recommended-products.module'; +export { RecommendedProductsService as Service } from './recommended-products.service'; +export { RecommendedProductsController as Controller } from './recommended-products.controller'; + +export * as Model from './recommended-products.model'; +export * as Request from './recommended-products.request'; diff --git a/packages/blocks/recommended-products/src/api-harmonization/recommended-products.client.ts b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.client.ts new file mode 100644 index 000000000..eb12f0006 --- /dev/null +++ b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.client.ts @@ -0,0 +1,4 @@ +export const URL = '/blocks/recommended-products'; + +export * as Model from './recommended-products.model'; +export * as Request from './recommended-products.request'; 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 new file mode 100644 index 000000000..dd747e041 --- /dev/null +++ b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.controller.ts @@ -0,0 +1,25 @@ +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 { GetRecommendedProductsBlockQuery } from './recommended-products.request'; +import { RecommendedProductsService } from './recommended-products.service'; + +@Controller(URL) +@UseInterceptors(LoggerService) +export class RecommendedProductsController { + constructor(protected readonly service: RecommendedProductsService) {} + + @Get() + @Auth.Decorators.Roles({ roles: [] }) + getRecommendedProductsBlock( + @Headers() headers: Models.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 new file mode 100644 index 000000000..08986da49 --- /dev/null +++ b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.mapper.ts @@ -0,0 +1,21 @@ +import { CMS } from '@o2s/configs.integrations'; + +import * as Model from './recommended-products.model'; + +export const mapRecommendedProducts = ( + cms: CMS.Model.RecommendedProductsBlock.RecommendedProductsBlock, + products: Model.ProductSummary[], + _locale: string, +): Model.RecommendedProductsBlock => { + const labels: Model.Labels = { + title: cms.labels?.title, + detailsLabel: cms.labels?.detailsLabel, + }; + + return { + __typename: 'RecommendedProductsBlock', + id: cms.id, + products, + labels, + }; +}; 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 new file mode 100644 index 000000000..d326ebf47 --- /dev/null +++ b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.model.ts @@ -0,0 +1,29 @@ +import { Products } from '@o2s/configs.integrations'; + +import { Models as ApiModels } from '@o2s/utils.api-harmonization'; + +export type Badge = { + label: string; + variant: 'default' | 'secondary' | 'destructive' | 'outline'; +}; + +export type ProductSummary = { + id: string; + name: string; + description?: string; + image: Products.Model.Product['image']; + price: Products.Model.Product['price']; + link: string; + badges?: Badge[]; +}; + +export type Labels = { + title?: string; + detailsLabel?: string; +}; + +export type RecommendedProductsBlock = ApiModels.Block.Block & { + __typename: 'RecommendedProductsBlock'; + products: ProductSummary[]; + labels: Labels; +}; diff --git a/packages/blocks/recommended-products/src/api-harmonization/recommended-products.module.ts b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.module.ts new file mode 100644 index 000000000..a51d1fab6 --- /dev/null +++ b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.module.ts @@ -0,0 +1,29 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { CMS, Products } from '@o2s/configs.integrations'; + +import * as Framework from '@o2s/framework/modules'; + +import { RecommendedProductsController } from './recommended-products.controller'; +import { RecommendedProductsService } from './recommended-products.service'; + +@Module({}) +export class RecommendedProductsBlockModule { + static register(_config: Framework.ApiConfig): DynamicModule { + return { + module: RecommendedProductsBlockModule, + providers: [ + RecommendedProductsService, + { + provide: CMS.Service, + useExisting: Framework.CMS.Service, + }, + { + provide: Products.Service, + useExisting: Framework.Products.Service, + }, + ], + controllers: [RecommendedProductsController], + exports: [RecommendedProductsService], + }; + } +} diff --git a/packages/blocks/recommended-products/src/api-harmonization/recommended-products.request.ts b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.request.ts new file mode 100644 index 000000000..34560a25c --- /dev/null +++ b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.request.ts @@ -0,0 +1,6 @@ +import { CMS } from '@o2s/framework/modules'; + +export class GetRecommendedProductsBlockQuery implements Omit { + id!: string; + excludeProductId?: string; +} diff --git a/packages/blocks/recommended-products/src/api-harmonization/recommended-products.service.spec.ts b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.service.spec.ts new file mode 100644 index 000000000..05248bd93 --- /dev/null +++ b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.service.spec.ts @@ -0,0 +1,49 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CMS, Products } from '@o2s/configs.integrations'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RecommendedProductsService } from './recommended-products.service'; + +describe('RecommendedProductsService', () => { + let service: RecommendedProductsService; + let cmsService: CMS.Service; + let productsService: Products.Service; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RecommendedProductsService, + { + provide: CMS.Service, + useValue: { + getRecommendedProductsBlock: vi.fn().mockReturnValue({ + title: 'Test Block', + }), + }, + }, + { + provide: Products.Service, + useValue: { + getProductList: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(RecommendedProductsService); + cmsService = module.get(CMS.Service); + productsService = module.get(Products.Service); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should have cmsService injected', () => { + expect(cmsService).toBeDefined(); + }); + + it('should have productsService injected', () => { + expect(productsService).toBeDefined(); + }); +}); 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 new file mode 100644 index 000000000..1867b6ffa --- /dev/null +++ b/packages/blocks/recommended-products/src/api-harmonization/recommended-products.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { CMS, Products } from '@o2s/configs.integrations'; +import { Observable, forkJoin, map } from 'rxjs'; + +import { Models } from '@o2s/utils.api-harmonization'; + +import { mapRecommendedProducts } from './recommended-products.mapper'; +import * as Model from './recommended-products.model'; +import { GetRecommendedProductsBlockQuery } from './recommended-products.request'; + +@Injectable() +export class RecommendedProductsService { + constructor( + private readonly cmsService: CMS.Service, + private readonly productsService: Products.Service, + ) {} + + getRecommendedProductsBlock( + query: GetRecommendedProductsBlockQuery, + headers: Models.Headers.AppHeaders, + ): Observable { + const locale = headers['x-locale'] || 'en'; + + const cms = this.cmsService.getRecommendedProductsBlock({ + id: query.id, + locale, + }); + const products = this.productsService.getProductList({ + offset: 0, + locale, + }); + + return forkJoin([cms, products]).pipe( + map(([cms, products]) => { + // Filter out excluded product and products without images + const filteredProducts: Model.ProductSummary[] = products.data + .filter((product: Products.Model.Product) => { + if (query.excludeProductId && product.id === query.excludeProductId) { + return false; + } + return product.image; + }) + .map((product: Products.Model.Product) => ({ + id: product.id, + name: product.name, + description: product.shortDescription, + image: product.image!, + price: product.price, + link: product.link, + badges: product.tags?.map((tag: Products.Model.Product['tags'][number]) => ({ + label: tag.label, + variant: tag.variant as Model.Badge['variant'], + })), + })); + + return mapRecommendedProducts(cms, filteredProducts, locale); + }), + ); + } +} diff --git a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.stories.tsx b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.stories.tsx new file mode 100644 index 000000000..ae7347200 --- /dev/null +++ b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.stories.tsx @@ -0,0 +1,164 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; + +import { RecommendedProductsPure } from './RecommendedProducts.client'; + +const meta = { + title: 'Blocks/RecommendedProducts', + component: RecommendedProductsPure, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + __typename: 'RecommendedProductsBlock', + id: 'recommended-products-1', + locale: 'en', + routing: { + locales: ['en', 'pl'], + defaultLocale: 'en', + pathnames: { + '/products/[id]': { + en: '/products/[id]', + pl: '/produkty/[id]', + }, + }, + }, + products: [ + { + id: 'PRD-005', + name: 'Cordless Angle Grinder', + description: 'Cordless angle grinder with 22V battery platform', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/services-charger.jpg', + alt: 'Cordless Angle Grinder', + width: 640, + height: 656, + }, + price: { + value: 199.99, + currency: 'USD', + }, + link: 'https://example.com/products/ag-125-a22', + badges: [ + { label: 'New', variant: 'secondary' }, + { label: 'Promo', variant: 'destructive' }, + ], + }, + { + id: 'PRD-006', + name: 'Laser Measurement', + description: 'Laser measurement device for distance measurements', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/empty.jpg', + alt: 'Laser Measurement', + width: 640, + height: 656, + }, + price: { + value: 100, + currency: 'USD', + }, + link: 'https://example.com/products/pd-s', + badges: [{ label: 'New', variant: 'secondary' }], + }, + { + id: 'PRD-007', + name: 'Cordless Drill Driver', + description: 'Cordless drill driver with 22V battery platform', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/empty.jpg', + alt: 'Cordless Drill Driver', + width: 640, + height: 656, + }, + price: { + value: 100, + currency: 'USD', + }, + link: 'https://example.com/products/sfc-22-a', + badges: [{ label: 'New', variant: 'secondary' }], + }, + { + id: 'PRD-008', + name: 'Professional Calibration', + description: 'Professional calibration service for industrial equipment', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/services-charger.jpg', + alt: 'Professional Calibration Service', + width: 640, + height: 656, + }, + price: { + value: 199.99, + currency: 'USD', + }, + link: 'https://example.com/products/ag-125-a22', + badges: [ + { label: 'New', variant: 'secondary' }, + { label: 'Promo', variant: 'destructive' }, + ], + }, + ], + labels: { + title: 'Recommended Products', + detailsLabel: 'Details', + }, + }, +}; + +export const WithCustomTitle: Story = { + args: { + ...Default.args, + labels: { + title: 'You Might Also Like', + detailsLabel: 'View Details', + }, + }, +}; + +export const WithoutTitle: Story = { + args: { + ...Default.args, + labels: { + detailsLabel: 'Details', + }, + }, +}; + +export const SingleProduct: Story = { + args: { + ...Default.args, + products: [ + { + id: 'PRD-005', + name: 'Cordless Angle Grinder', + description: 'Cordless angle grinder with 22V battery platform', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/services-charger.jpg', + alt: 'Cordless Angle Grinder', + width: 640, + height: 656, + }, + price: { + value: 199.99, + currency: 'USD', + }, + link: 'https://example.com/products/ag-125-a22', + badges: [ + { label: 'New', variant: 'secondary' }, + { label: 'Promo', variant: 'destructive' }, + ], + }, + ], + }, +}; + +export const EmptyProducts: Story = { + args: { + ...Default.args, + products: [], + }, +}; diff --git a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.tsx b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.tsx new file mode 100644 index 000000000..4ce446f6c --- /dev/null +++ b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.client.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { createNavigation } from 'next-intl/navigation'; +import React from 'react'; + +import { ProductCarousel } from '@o2s/ui/components/ProductCarousel'; + +import { RecommendedProductsPureProps } from './RecommendedProducts.types'; + +export const RecommendedProductsPure: React.FC = ({ locale, routing, ...component }) => { + const { Link: LinkComponent } = createNavigation(routing); + const { products, labels } = component; + + if (!products || products.length === 0) { + return null; + } + + return ( + + ); +}; diff --git a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.renderer.tsx b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.renderer.tsx new file mode 100644 index 000000000..5cb83f4e1 --- /dev/null +++ b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.renderer.tsx @@ -0,0 +1,34 @@ +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 { RecommendedProducts } from './RecommendedProducts.server'; +import { RecommendedProductsRendererProps } from './RecommendedProducts.types'; + +export const RecommendedProductsRenderer: React.FC = ({ + id, + excludeProductId, + routing, + locale: propLocale, +}) => { + const localeFromHook = useLocale(); + const locale = propLocale || localeFromHook; + + return ( + + + + + + + } + > + + + ); +}; diff --git a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.server.tsx b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.server.tsx new file mode 100644 index 000000000..e5d154d14 --- /dev/null +++ b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.server.tsx @@ -0,0 +1,40 @@ +import dynamic from 'next/dynamic'; +import React from 'react'; + +import { sdk } from '../sdk'; + +import { RecommendedProductsProps } from './RecommendedProducts.types'; + +export const RecommendedProductsDynamic = dynamic(() => + import('./RecommendedProducts.client').then((module) => module.RecommendedProductsPure), +); + +export const RecommendedProducts: React.FC = async ({ + id, + excludeProductId, + locale, + routing, +}) => { + try { + const data = await sdk.blocks.getRecommendedProducts( + { id }, + { + excludeProductId, + }, + { 'x-locale': locale }, + ); + + return ( + + ); + } catch (error) { + console.error('Error fetching RecommendedProducts block', error); + return null; + } +}; diff --git a/packages/blocks/recommended-products/src/frontend/RecommendedProducts.types.ts b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.types.ts new file mode 100644 index 000000000..ef7e7fd6a --- /dev/null +++ b/packages/blocks/recommended-products/src/frontend/RecommendedProducts.types.ts @@ -0,0 +1,18 @@ +import { defineRouting } from 'next-intl/routing'; + +import { Model } from '../api-harmonization/recommended-products.client'; + +export interface RecommendedProductsProps { + id: string; + excludeProductId?: string; + limit?: number; + locale: string; + routing: ReturnType; +} + +export type RecommendedProductsPureProps = RecommendedProductsProps & Model.RecommendedProductsBlock; + +export type RecommendedProductsRendererProps = Omit & { + slug: string[]; + locale?: string; +}; diff --git a/packages/blocks/recommended-products/src/frontend/index.ts b/packages/blocks/recommended-products/src/frontend/index.ts new file mode 100644 index 000000000..2f4c8ee16 --- /dev/null +++ b/packages/blocks/recommended-products/src/frontend/index.ts @@ -0,0 +1,5 @@ +export { RecommendedProductsPure as Client } from './RecommendedProducts.client'; +export { RecommendedProducts as Server } from './RecommendedProducts.server'; +export { RecommendedProductsRenderer as Renderer } from './RecommendedProducts.renderer'; + +export * as Types from './RecommendedProducts.types'; diff --git a/packages/blocks/recommended-products/src/sdk/index.ts b/packages/blocks/recommended-products/src/sdk/index.ts new file mode 100644 index 000000000..8e1257231 --- /dev/null +++ b/packages/blocks/recommended-products/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 { recommendedProducts } from './recommended-products'; + +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: { + getRecommendedProducts: recommendedProducts(internalSdk).blocks.getRecommendedProducts, + }, +}); diff --git a/packages/blocks/recommended-products/src/sdk/recommended-products.ts b/packages/blocks/recommended-products/src/sdk/recommended-products.ts new file mode 100644 index 000000000..dbfb32233 --- /dev/null +++ b/packages/blocks/recommended-products/src/sdk/recommended-products.ts @@ -0,0 +1,34 @@ +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/recommended-products.client'; + +export const recommendedProducts = (sdk: Sdk) => ({ + blocks: { + getRecommendedProducts: ( + params: { id: string }, + query?: Omit, + headers?: Models.Headers.AppHeaders, + authorization?: string, + ): Promise => + sdk.makeRequest({ + method: 'get', + url: `${URL}`, + headers: { + ...Utils.Headers.getApiHeaders(), + ...headers, + ...(authorization + ? { + Authorization: `Bearer ${authorization}`, + } + : {}), + }, + params: { + ...params, + ...query, + }, + }), + }, +}); diff --git a/packages/blocks/recommended-products/tsconfig.api.json b/packages/blocks/recommended-products/tsconfig.api.json new file mode 100644 index 000000000..0f9f79f8e --- /dev/null +++ b/packages/blocks/recommended-products/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/recommended-products/tsconfig.frontend.json b/packages/blocks/recommended-products/tsconfig.frontend.json new file mode 100644 index 000000000..c919e0f3b --- /dev/null +++ b/packages/blocks/recommended-products/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/recommended-products.client.ts", + "src/api-harmonization/recommended-products.model.ts", + "src/api-harmonization/recommended-products.request.ts", + "src/sdk" + ] +} diff --git a/packages/blocks/recommended-products/tsconfig.json b/packages/blocks/recommended-products/tsconfig.json new file mode 100644 index 000000000..c3031c1dd --- /dev/null +++ b/packages/blocks/recommended-products/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/recommended-products/tsconfig.sdk.json b/packages/blocks/recommended-products/tsconfig.sdk.json new file mode 100644 index 000000000..d4bd8969a --- /dev/null +++ b/packages/blocks/recommended-products/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/recommended-products.client.ts", + "src/api-harmonization/recommended-products.model.ts", + "src/api-harmonization/recommended-products.request.ts" + ] +} diff --git a/packages/blocks/recommended-products/vitest.config.mjs b/packages/blocks/recommended-products/vitest.config.mjs new file mode 100644 index 000000000..9cf1cb08d --- /dev/null +++ b/packages/blocks/recommended-products/vitest.config.mjs @@ -0,0 +1 @@ +import { config } from '@o2s/vitest-config/block'; export default config; \ No newline at end of file diff --git a/packages/framework/src/modules/cms/cms.model.ts b/packages/framework/src/modules/cms/cms.model.ts index 0cdb702d0..44280657d 100644 --- a/packages/framework/src/modules/cms/cms.model.ts +++ b/packages/framework/src/modules/cms/cms.model.ts @@ -44,4 +44,6 @@ export * as FeatureSectionGridBlock from './models/blocks/feature-section-grid.m export * as HeroSectionBlock from './models/blocks/hero-section.model'; 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'; // BLOCK IMPORT diff --git a/packages/framework/src/modules/cms/cms.service.ts b/packages/framework/src/modules/cms/cms.service.ts index fb757cfbe..b1a3cca04 100644 --- a/packages/framework/src/modules/cms/cms.service.ts +++ b/packages/framework/src/modules/cms/cms.service.ts @@ -128,4 +128,16 @@ export abstract class CmsService { abstract getArticleSearchBlock( options: CMS.Request.GetCmsEntryParams, ): Observable; + + abstract getProductListBlock( + options: CMS.Request.GetCmsEntryParams, + ): Observable; + + abstract getProductDetailsBlock( + options: CMS.Request.GetCmsEntryParams, + ): Observable; + + abstract getRecommendedProductsBlock( + options: CMS.Request.GetCmsEntryParams, + ): Observable; } 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 new file mode 100644 index 000000000..93eb9d03a --- /dev/null +++ b/packages/framework/src/modules/cms/models/blocks/product-details.model.ts @@ -0,0 +1,15 @@ +import { Block } from '@/utils/models'; + +export type Labels = { + actionButtonLabel?: string; + downloadLabel?: string; + specificationsTitle: string; + descriptionTitle: string; + priceLabel: string; + offerLabel: string; +}; + +export class ProductDetailsBlock extends Block.Block { + title?: string; + labels!: Labels; +} 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 new file mode 100644 index 000000000..48cd0ae53 --- /dev/null +++ b/packages/framework/src/modules/cms/models/blocks/recommended-products.model.ts @@ -0,0 +1,10 @@ +import { Block } from '@/utils/models'; + +export type Labels = { + title?: string; + detailsLabel?: string; +}; + +export class RecommendedProductsBlock extends Block.Block { + labels!: Labels; +} diff --git a/packages/framework/src/modules/products/products.model.ts b/packages/framework/src/modules/products/products.model.ts index 2d9ca4eb7..769dd7577 100644 --- a/packages/framework/src/modules/products/products.model.ts +++ b/packages/framework/src/modules/products/products.model.ts @@ -5,14 +5,27 @@ export type ProductType = 'PHYSICAL' | 'VIRTUAL'; export type ProductReferenceType = 'SPARE_PART' | 'REPLACEMENT' | 'COMPATIBLE_SERVICE'; +export type KeySpecItem = { + value?: string; + icon?: string; +}; + +export type DetailedSpec = { + label: string; + value: string; + category?: string; +}; + export class Product { id!: string; sku!: string; name!: string; description!: string; shortDescription?: string; + subtitle?: string; variantId?: string; image?: Media.Media; + images?: Media.Media[]; price!: Price.Price; link!: string; type!: ProductType; @@ -21,6 +34,9 @@ export class Product { label: string; variant: string; }[]; + keySpecs?: KeySpecItem[]; + detailedSpecs?: DetailedSpec[]; + location?: string; } export type Products = Pagination.Paginated; 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 b2d78986a..f29415373 100644 --- a/packages/integrations/contentful-cms/src/modules/cms/cms.service.ts +++ b/packages/integrations/contentful-cms/src/modules/cms/cms.service.ts @@ -25,7 +25,10 @@ import { mapOrderListBlock } from './mappers/blocks/cms.order-list.mapper'; import { mapOrdersSummaryBlock } from './mappers/blocks/cms.orders-summary.mapper'; import { mapPaymentsHistoryBlock } from './mappers/blocks/cms.payments-history.mapper'; import { mapPaymentsSummaryBlock } from './mappers/blocks/cms.payments-summary.mapper'; +import { mapProductDetailsBlock } from './mappers/blocks/cms.product-details.mapper'; +import { mapProductListBlock } from './mappers/blocks/cms.product-list.mapper'; import { mapQuickLinksBlock } from './mappers/blocks/cms.quick-links.mapper'; +import { mapRecommendedProductsBlock } from './mappers/blocks/cms.recommended-products.mapper'; import { mapResourceDetailsBlock } from './mappers/blocks/cms.resource-details.mapper'; import { mapResourceListBlock } from './mappers/blocks/cms.resource-list.mapper'; import { mapServiceDetailsBlock } from './mappers/blocks/cms.service-details.mapper'; @@ -555,4 +558,16 @@ export class CmsService implements CMS.Service { getFeaturedServiceListBlock(options: CMS.Request.GetCmsEntryParams) { return of(mapFeaturedServiceListBlock(options.locale)); } + + getProductListBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapProductListBlock(options.locale)); + } + + getProductDetailsBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapProductDetailsBlock(options.locale)); + } + + getRecommendedProductsBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapRecommendedProductsBlock(options.locale)); + } } 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 new file mode 100644 index 000000000..eaf453956 --- /dev/null +++ b/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts @@ -0,0 +1,17 @@ +import { CMS } from '@o2s/framework/modules'; + +export const mapProductDetailsBlock = (_locale: string): CMS.Model.ProductDetailsBlock.ProductDetailsBlock => { + // TODO: Implement proper mapping from Contentful + // For now, return a basic structure with labels + return { + id: 'product-details-1', + labels: { + actionButtonLabel: 'Request Quote', + specificationsTitle: 'Specifications', + descriptionTitle: 'Description', + downloadLabel: 'Download Brochure', + priceLabel: 'Price', + offerLabel: 'Offer', + }, + }; +}; 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 new file mode 100644 index 000000000..6a108f7f3 --- /dev/null +++ b/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts @@ -0,0 +1,37 @@ +import { CMS } from '@o2s/framework/modules'; + +export const mapProductListBlock = (_locale: string): CMS.Model.ProductListBlock.ProductListBlock => { + // TODO: Implement proper mapping from Contentful + // For now, return a basic structure with all required fields + return { + id: 'product-list-1', + title: 'Products', + subtitle: 'Browse our product catalog', + detailsLabel: 'View Details', + fieldMapping: { + category: {}, + }, + table: { + columns: [ + { id: 'sku', title: 'SKU' }, + { id: 'name', title: 'Product Name' }, + { id: 'category', title: 'Category' }, + { id: 'type', title: 'Type' }, + { id: 'price', title: 'Price' }, + ], + actions: { + title: 'Actions', + label: 'View Details', + }, + }, + noResults: { + title: 'No Products Found', + description: 'There are no products matching your criteria', + }, + labels: { + clickToSelect: 'Click to select', + gridView: 'Grid view', + tableView: 'Table view', + }, + }; +}; 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 new file mode 100644 index 000000000..90a057f03 --- /dev/null +++ b/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts @@ -0,0 +1,15 @@ +import { CMS } from '@o2s/framework/modules'; + +export const mapRecommendedProductsBlock = ( + _locale: string, +): CMS.Model.RecommendedProductsBlock.RecommendedProductsBlock => { + // TODO: Implement proper mapping from Contentful + // For now, return a basic structure with labels + return { + id: 'recommended-products-1', + labels: { + title: 'Recommended Products', + detailsLabel: 'Details', + }, + }; +}; diff --git a/packages/integrations/mocked/src/modules/cms/cms.service.ts b/packages/integrations/mocked/src/modules/cms/cms.service.ts index bdccabbb7..3d1fa25fa 100644 --- a/packages/integrations/mocked/src/modules/cms/cms.service.ts +++ b/packages/integrations/mocked/src/modules/cms/cms.service.ts @@ -26,7 +26,9 @@ import { mapOrdersSummaryBlock } from './mappers/blocks/cms.orders-summary.mappe import { mapPaymentsHistoryBlock } from './mappers/blocks/cms.payments-history.mapper'; import { mapPaymentsSummaryBlock } from './mappers/blocks/cms.payments-summary.mapper'; import { mapPricingSectionBlock } from './mappers/blocks/cms.pricing-section.mapper'; +import { mapProductDetailsBlock } from './mappers/blocks/cms.product-details.mapper'; import { mapProductListBlock } from './mappers/blocks/cms.product-list.mapper'; +import { mapRecommendedProductsBlock } from './mappers/blocks/cms.recommended-products.mapper'; import { mapResourceDetailsBlock } from './mappers/blocks/cms.resource-details.mapper'; import { mapResourceListBlock } from './mappers/blocks/cms.resource-list.mapper'; import { mapServiceDetailsBlock } from './mappers/blocks/cms.service-details.mapper'; @@ -207,6 +209,14 @@ export class CmsService implements CMS.Service { return of(mapProductListBlock(options.locale)).pipe(responseDelay()); } + getProductDetailsBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapProductDetailsBlock(options.locale)).pipe(responseDelay()); + } + + getRecommendedProductsBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapRecommendedProductsBlock(options.locale)).pipe(responseDelay()); + } + getTicketSummaryBlock(options: CMS.Request.GetCmsEntryParams) { return of(mapTicketSummaryBlock(options.locale)).pipe(responseDelay()); } 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 new file mode 100644 index 000000000..0ff203f1a --- /dev/null +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts @@ -0,0 +1,50 @@ +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: 'Specifications', + descriptionTitle: 'Description', + downloadLabel: 'Download Brochure', + priceLabel: 'Price', + offerLabel: 'Offer', + }, +}; + +const MOCK_PRODUCT_DETAILS_BLOCK_DE: CMS.Model.ProductDetailsBlock.ProductDetailsBlock = { + id: 'product-details-1', + labels: { + actionButtonLabel: 'Angebot anfordern', + specificationsTitle: 'Spezifikationen', + descriptionTitle: 'Beschreibung', + downloadLabel: 'Broschüre herunterladen', + priceLabel: 'Preis', + offerLabel: 'Angebot', + }, +}; + +const MOCK_PRODUCT_DETAILS_BLOCK_PL: CMS.Model.ProductDetailsBlock.ProductDetailsBlock = { + id: 'product-details-1', + labels: { + actionButtonLabel: 'Zapytaj o ofertę', + specificationsTitle: 'Specyfikacja', + descriptionTitle: 'Opis', + downloadLabel: 'Pobierz broszurę', + priceLabel: 'Cena', + offerLabel: 'Oferta', + }, +}; + +export const mapProductDetailsBlock = (locale: string): CMS.Model.ProductDetailsBlock.ProductDetailsBlock => { + switch (locale) { + case 'en': + return MOCK_PRODUCT_DETAILS_BLOCK_EN; + case 'de': + return MOCK_PRODUCT_DETAILS_BLOCK_DE; + case 'pl': + return MOCK_PRODUCT_DETAILS_BLOCK_PL; + default: + return MOCK_PRODUCT_DETAILS_BLOCK_EN; + } +}; 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 new file mode 100644 index 000000000..45e9226e8 --- /dev/null +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts @@ -0,0 +1,40 @@ +import { CMS } from '@o2s/framework/modules'; + +const MOCK_RECOMMENDED_PRODUCTS_BLOCK_EN: CMS.Model.RecommendedProductsBlock.RecommendedProductsBlock = { + id: 'recommended-products-1', + labels: { + title: 'Recommended Products', + detailsLabel: 'Details', + }, +}; + +const MOCK_RECOMMENDED_PRODUCTS_BLOCK_DE: CMS.Model.RecommendedProductsBlock.RecommendedProductsBlock = { + id: 'recommended-products-1', + labels: { + title: 'Empfohlene Produkte', + detailsLabel: 'Details', + }, +}; + +const MOCK_RECOMMENDED_PRODUCTS_BLOCK_PL: CMS.Model.RecommendedProductsBlock.RecommendedProductsBlock = { + id: 'recommended-products-1', + labels: { + title: 'Rekomendowane produkty', + detailsLabel: 'Szczegóły', + }, +}; + +export const mapRecommendedProductsBlock = ( + locale: string, +): CMS.Model.RecommendedProductsBlock.RecommendedProductsBlock => { + switch (locale) { + case 'en': + return MOCK_RECOMMENDED_PRODUCTS_BLOCK_EN; + case 'de': + return MOCK_RECOMMENDED_PRODUCTS_BLOCK_DE; + case 'pl': + return MOCK_RECOMMENDED_PRODUCTS_BLOCK_PL; + default: + return MOCK_RECOMMENDED_PRODUCTS_BLOCK_EN; + } +}; 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 9b3f07336..cc90f912e 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 @@ -36,6 +36,11 @@ import { } from './mocks/pages/notification-list.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 { + PAGE_PRODUCT_DETAILS_DE, + PAGE_PRODUCT_DETAILS_EN, + PAGE_PRODUCT_DETAILS_PL, +} from './mocks/pages/product-details.page'; import { PAGE_PRODUCT_LIST_DE, PAGE_PRODUCT_LIST_EN, PAGE_PRODUCT_LIST_PL } from './mocks/pages/product-list.page'; import { PAGE_SERVICE_DETAILS_DE, @@ -150,6 +155,25 @@ export const mapPage = (slug: string, locale: string): CMS.Model.Page.Page | und case '/produkty': return PAGE_PRODUCT_LIST_PL; + case slug.match(/\/products\/.+/)?.[0]: + return { + ...PAGE_PRODUCT_DETAILS_EN, + slug: `/products/${slug.match(/(.+)\/(.+)/)?.[2]}`, + updatedAt: '2025-01-01', + }; + case slug.match(/\/produkte\/.+/)?.[0]: + return { + ...PAGE_PRODUCT_DETAILS_DE, + slug: `/produkte/${slug.match(/(.+)\/(.+)/)?.[2]}`, + updatedAt: '2025-01-01', + }; + case slug.match(/\/produkty\/.+/)?.[0]: + return { + ...PAGE_PRODUCT_DETAILS_PL, + slug: `/produkty/${slug.match(/(.+)\/(.+)/)?.[2]}`, + updatedAt: '2025-01-01', + }; + case slug.match(/\/services\/.+/)?.[0]: return { ...PAGE_SERVICE_DETAILS_EN, @@ -276,6 +300,7 @@ export const getAllPages = (locale: string): CMS.Model.Page.Page[] => { PAGE_SERVICE_LIST_PL, PAGE_SERVICE_DETAILS_PL, PAGE_PRODUCT_LIST_PL, + PAGE_PRODUCT_DETAILS_PL, PAGE_CONTACT_US_PL, PAGE_COMPLAINT_FORM_PL, PAGE_REQUEST_DEVICE_MAINTENANCE_PL, @@ -298,6 +323,7 @@ export const getAllPages = (locale: string): CMS.Model.Page.Page[] => { PAGE_SERVICE_LIST_DE, PAGE_SERVICE_DETAILS_DE, PAGE_PRODUCT_LIST_DE, + PAGE_PRODUCT_DETAILS_DE, PAGE_CONTACT_US_DE, PAGE_COMPLAINT_FORM_DE, PAGE_REQUEST_DEVICE_MAINTENANCE_DE, @@ -320,6 +346,7 @@ export const getAllPages = (locale: string): CMS.Model.Page.Page[] => { PAGE_SERVICE_LIST_EN, PAGE_SERVICE_DETAILS_EN, PAGE_PRODUCT_LIST_EN, + PAGE_PRODUCT_DETAILS_EN, PAGE_CONTACT_US_EN, PAGE_COMPLAINT_FORM_EN, PAGE_REQUEST_DEVICE_MAINTENANCE_EN, diff --git a/packages/integrations/mocked/src/modules/cms/mappers/mocks/pages/product-details.page.ts b/packages/integrations/mocked/src/modules/cms/mappers/mocks/pages/product-details.page.ts new file mode 100644 index 000000000..e42cae80f --- /dev/null +++ b/packages/integrations/mocked/src/modules/cms/mappers/mocks/pages/product-details.page.ts @@ -0,0 +1,133 @@ +import { Auth, CMS } from '@o2s/framework/modules'; + +export const PAGE_PRODUCT_DETAILS_EN: CMS.Model.Page.Page = { + id: '21', + slug: '/products/(.+)', + locale: 'en', + seo: { + noIndex: false, + noFollow: false, + title: 'Product Details', + description: 'Product Details', + keywords: [], + image: { + url: 'https://picsum.photos/150', + width: 150, + height: 150, + alt: 'Placeholder', + }, + }, + permissions: [Auth.Constants.Roles.ORG_USER, Auth.Constants.Roles.ORG_ADMIN], + hasOwnTitle: true, + parent: { + slug: '/products', + seo: { + title: 'Products', + }, + }, + template: { + __typename: 'OneColumnTemplate', + slots: { + main: [ + { + __typename: 'ProductDetailsBlock', + id: 'product-details-1', + }, + { + __typename: 'RecommendedProductsBlock', + id: 'recommended-products-1', + }, + ], + }, + }, + updatedAt: '2025-01-01', + createdAt: '2025-01-01', +}; + +export const PAGE_PRODUCT_DETAILS_DE: CMS.Model.Page.Page = { + id: '21', + slug: '/produkte/(.+)', + locale: 'de', + seo: { + noIndex: false, + noFollow: false, + title: 'Produktdetails', + description: 'Produktdetails', + keywords: [], + image: { + url: 'https://picsum.photos/150', + width: 150, + height: 150, + alt: 'Placeholder', + }, + }, + permissions: [Auth.Constants.Roles.ORG_USER, Auth.Constants.Roles.ORG_ADMIN], + hasOwnTitle: true, + parent: { + slug: '/produkte', + seo: { + title: 'Produkte', + }, + }, + template: { + __typename: 'OneColumnTemplate', + slots: { + main: [ + { + __typename: 'ProductDetailsBlock', + id: 'product-details-1', + }, + { + __typename: 'RecommendedProductsBlock', + id: 'recommended-products-1', + }, + ], + }, + }, + updatedAt: '2025-01-01', + createdAt: '2025-01-01', +}; + +export const PAGE_PRODUCT_DETAILS_PL: CMS.Model.Page.Page = { + id: '21', + slug: '/produkty/(.+)', + locale: 'pl', + seo: { + noIndex: false, + noFollow: false, + title: 'Szczegóły produktu', + description: 'Szczegóły produktu', + keywords: [], + image: { + url: 'https://picsum.photos/150', + width: 150, + height: 150, + alt: 'Placeholder', + }, + }, + permissions: [Auth.Constants.Roles.ORG_USER, Auth.Constants.Roles.ORG_ADMIN], + hasOwnTitle: true, + parent: { + slug: '/produkty', + seo: { + title: 'Produkty', + }, + }, + template: { + __typename: 'OneColumnTemplate', + slots: { + main: [ + { + __typename: 'ProductDetailsBlock', + id: 'product-details-1', + }, + { + __typename: 'RecommendedProductsBlock', + id: 'recommended-products-1', + }, + ], + }, + }, + updatedAt: '2025-01-01', + createdAt: '2025-01-01', +}; diff --git a/packages/integrations/mocked/src/modules/products/products.mapper.ts b/packages/integrations/mocked/src/modules/products/products.mapper.ts index 38af411b2..1f3a93dd7 100644 --- a/packages/integrations/mocked/src/modules/products/products.mapper.ts +++ b/packages/integrations/mocked/src/modules/products/products.mapper.ts @@ -16,7 +16,7 @@ const MOCK_PRODUCT_1: Products.Model.Product = { value: 100, currency: 'USD', }, - link: 'https://example.com/products/te-70-atc-avr', + link: '/products/PRD-004', type: 'PHYSICAL', category: 'TOOLS', tags: [ @@ -43,7 +43,7 @@ const MOCK_PRODUCT_2: Products.Model.Product = { value: 199.99, currency: 'USD', }, - link: 'https://example.com/products/ag-125-a22', + link: '/products/PRD-005', type: 'PHYSICAL', category: 'TOOLS', tags: [ @@ -74,7 +74,7 @@ const MOCK_PRODUCT_3: Products.Model.Product = { value: 100, currency: 'USD', }, - link: 'https://example.com/products/pd-s', + link: '/products/PRD-006', type: 'PHYSICAL', category: 'MEASUREMENT', tags: [ @@ -101,7 +101,7 @@ const MOCK_PRODUCT_4: Products.Model.Product = { value: 100, currency: 'USD', }, - link: 'https://example.com/products/sfc-22-a', + link: '/products/PRD-007', type: 'PHYSICAL', category: 'TOOLS', tags: [ @@ -128,7 +128,7 @@ const MOCK_PRODUCT_5: Products.Model.Product = { value: 39.99, currency: 'USD', }, - link: 'https://example.com/products/profis-engineering', + link: '/products/PRD-008', type: 'VIRTUAL', category: 'SOFTWARE', tags: [ @@ -157,7 +157,7 @@ const MOCK_PRODUCT_6: Products.Model.Product = { value: 79.83, currency: 'USD', }, - link: 'https://example.com/services/rentpro-industrial', + link: '/products/PRD-009', type: 'VIRTUAL', category: 'RENTAL', tags: [ @@ -186,7 +186,7 @@ const MOCK_PRODUCT_7: Products.Model.Product = { value: 19.99, currency: 'USD', }, - link: 'https://example.com/services/training', + link: '/products/PRD-010', type: 'VIRTUAL', category: 'TRAINING', tags: [ @@ -218,7 +218,7 @@ const MOCK_PRODUCT_8: Products.Model.Product = { value: 79.83, currency: 'EUR', }, - link: 'https://example.com/services/powercharge-solutions', + link: '/products/PRD-011', type: 'VIRTUAL', category: 'MAINTENANCE', tags: [ @@ -251,7 +251,7 @@ const MOCK_PRODUCT_9: Products.Model.Product = { value: 10, currency: 'USD', }, - link: 'https://example.com/services/weldguard-safety', + link: '/products/PRD-012', type: 'VIRTUAL', category: 'SAFETY', tags: [ @@ -280,7 +280,7 @@ const MOCK_PRODUCT_10: Products.Model.Product = { value: 19.99, currency: 'USD', }, - link: 'https://example.com/services/maxflow-air-systems', + link: '/products/PRD-013', type: 'VIRTUAL', category: 'MAINTENANCE', tags: [ @@ -308,7 +308,7 @@ const MOCK_PRODUCT_11: Products.Model.Product = { value: 19.99, currency: 'EUR', }, - link: 'https://example.com/services/rapidfix-repair', + link: '/products/PRD-014', type: 'VIRTUAL', category: 'MAINTENANCE', tags: [ @@ -319,6 +319,78 @@ const MOCK_PRODUCT_11: Products.Model.Product = { ], }; +const MOCK_PRODUCT_12: Products.Model.Product = { + id: 'PRD-015', + sku: 'PREMIUM-XL-2000', + name: 'Premium Industrial Machine XL-2000', + description: + '

The XL-2000 is a state-of-the-art industrial machine designed for maximum efficiency and durability. With advanced automation features and energy-saving technology, this machine is perfect for modern manufacturing facilities.

Advanced Automation Capabilities

The XL-2000 features a fully integrated automation system with intelligent control panel that provides real-time monitoring of all critical parameters.

Key features include:

  • Programmable operating sequences with customizable presets
  • Automatic fault detection and diagnostic reporting
  • Remote monitoring via integrated IoT connectivity
  • Predictive maintenance alerts

Energy Efficiency

With an exceptional energy efficiency rating of Class A++, the XL-2000 reduces energy consumption by up to 40% compared to traditional equipment. The low noise level of 65 dB ensures compliance with workplace safety regulations.

Durability and Maintenance

Designed for continuous operation in demanding environments, the XL-2000 features robust construction and modular design for easy maintenance access.

Maintenance highlights:

  • Quick-access panels for major components
  • Tool-free access to frequently serviced parts
  • Comprehensive diagnostic system
', + shortDescription: 'State-of-the-art industrial machine with advanced automation and energy-saving technology', + subtitle: 'Industrial Equipment • Manufacturing Solutions', + image: { + url: 'https://picsum.photos/1200/800', + width: 1200, + height: 800, + alt: 'Premium Industrial Machine XL-2000', + }, + images: [ + { + url: 'https://picsum.photos/1200/800?random=1', + alt: 'Industrial Machine Front View', + width: 1200, + height: 800, + }, + { + url: 'https://picsum.photos/1200/800?random=2', + alt: 'Industrial Machine Side View', + width: 1200, + height: 800, + }, + { + url: 'https://picsum.photos/1200/800?random=3', + alt: 'Industrial Machine Control Panel', + width: 1200, + height: 800, + }, + ], + price: { + value: 125000, + currency: 'USD', + }, + link: '/products/PRD-015', + type: 'PHYSICAL', + category: 'TOOLS', + tags: [ + { + label: 'New', + variant: 'secondary', + }, + { + label: 'Bestseller', + variant: 'default', + }, + ], + keySpecs: [ + { value: '2024', icon: 'Calendar' }, + { value: 'New', icon: 'CheckCircle' }, + { value: 'Electric', icon: 'Fuel' }, + { value: 'Automatic', icon: 'Settings' }, + ], + detailedSpecs: [ + { label: 'Engine Power', value: '150 kW' }, + { label: 'Max Speed', value: '2800 RPM' }, + { label: 'Operating Voltage', value: '380-480V' }, + { label: 'Dimensions', value: '2500 x 1800 x 2200 mm' }, + { label: 'Weight', value: '3500 kg' }, + { label: 'Energy Efficiency', value: 'Class A++' }, + { label: 'Noise Level', value: '65 dB' }, + { label: 'Operating Temperature', value: '-10°C to +40°C' }, + { label: 'Protection Rating', value: 'IP54' }, + { label: 'Certification', value: 'CE, ISO 9001' }, + ], + location: 'Chicago, IL', +}; + const MOCK_PRODUCTS = [ MOCK_PRODUCT_1, MOCK_PRODUCT_2, @@ -331,6 +403,7 @@ const MOCK_PRODUCTS = [ MOCK_PRODUCT_9, MOCK_PRODUCT_10, MOCK_PRODUCT_11, + MOCK_PRODUCT_12, ]; export const mapProduct = (id: string): Products.Model.Product => { diff --git a/packages/integrations/mocked/src/modules/resources/mock/products.mock.ts b/packages/integrations/mocked/src/modules/resources/mock/products.mock.ts index 30771fd36..bdbaad22c 100644 --- a/packages/integrations/mocked/src/modules/resources/mock/products.mock.ts +++ b/packages/integrations/mocked/src/modules/resources/mock/products.mock.ts @@ -32,7 +32,7 @@ export const MOCK_PRODUCTS: Products.Model.Product[] = [ value: 100, currency: 'USD', }, - link: 'https://example.com/products/te-70-atc-avr', + link: '/products/PRD-004', type: 'PHYSICAL', category: 'TOOLS', tags: [ @@ -67,7 +67,7 @@ export const MOCK_PRODUCTS: Products.Model.Product[] = [ value: 199.99, currency: 'USD', }, - link: 'https://example.com/products/ag-125-a22', + link: '/products/PRD-005', type: 'PHYSICAL', category: 'TOOLS', tags: [ @@ -112,7 +112,7 @@ export const MOCK_PRODUCTS: Products.Model.Product[] = [ value: 100, currency: 'USD', }, - link: 'https://example.com/products/pd-s', + link: '/products/PRD-006', type: 'PHYSICAL', category: 'MEASUREMENT', tags: [ @@ -153,7 +153,7 @@ export const MOCK_PRODUCTS: Products.Model.Product[] = [ value: 100, currency: 'USD', }, - link: 'https://example.com/products/sfc-22-a', + link: '/products/PRD-007', type: 'PHYSICAL', category: 'TOOLS', tags: [ @@ -179,7 +179,7 @@ export const MOCK_PRODUCTS: Products.Model.Product[] = [ value: 39.99, currency: 'USD', }, - link: 'https://example.com/products/profis-engineering', + link: '/products/PRD-008', type: 'VIRTUAL', category: 'SOFTWARE', tags: [ @@ -207,7 +207,7 @@ export const MOCK_PRODUCTS: Products.Model.Product[] = [ value: 79.83, currency: 'USD', }, - link: 'https://example.com/services/rentpro-industrial', + link: '/products/PRD-009', type: 'VIRTUAL', category: 'RENTAL', tags: [ @@ -235,7 +235,7 @@ export const MOCK_PRODUCTS: Products.Model.Product[] = [ value: 19.99, currency: 'USD', }, - link: 'https://example.com/services/training', + link: '/products/PRD-010', type: 'VIRTUAL', category: 'TRAINING', tags: [ @@ -266,7 +266,7 @@ export const MOCK_PRODUCTS: Products.Model.Product[] = [ value: 79.83, currency: 'EUR', }, - link: 'https://example.com/services/powercharge-solutions', + link: '/products/PRD-011', type: 'VIRTUAL', category: 'MAINTENANCE', tags: [ @@ -298,7 +298,7 @@ export const MOCK_PRODUCTS: Products.Model.Product[] = [ value: 10, currency: 'USD', }, - link: 'https://example.com/services/weldguard-safety', + link: '/products/PRD-012', type: 'VIRTUAL', category: 'SAFETY', tags: [ @@ -326,7 +326,7 @@ export const MOCK_PRODUCTS: Products.Model.Product[] = [ value: 19.99, currency: 'USD', }, - link: 'https://example.com/services/maxflow-air-systems', + link: '/products/PRD-013', type: 'VIRTUAL', category: 'MAINTENANCE', tags: [ @@ -353,7 +353,7 @@ export const MOCK_PRODUCTS: Products.Model.Product[] = [ value: 19.99, currency: 'EUR', }, - link: 'https://example.com/services/rapidfix-repair', + link: '/products/PRD-014', type: 'VIRTUAL', category: 'MAINTENANCE', tags: [ 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 5d8733847..01e64ae57 100644 --- a/packages/integrations/strapi-cms/src/modules/cms/cms.service.ts +++ b/packages/integrations/strapi-cms/src/modules/cms/cms.service.ts @@ -27,7 +27,10 @@ import { mapOrderListBlock } from './mappers/blocks/cms.order-list.mapper'; import { mapOrdersSummaryBlock } from './mappers/blocks/cms.orders-summary.mapper'; import { mapPaymentsHistoryBlock } from './mappers/blocks/cms.payments-history.mapper'; import { mapPaymentsSummaryBlock } from './mappers/blocks/cms.payments-summary.mapper'; +import { mapProductDetailsBlock } from './mappers/blocks/cms.product-details.mapper'; +import { mapProductListBlock } from './mappers/blocks/cms.product-list.mapper'; import { mapQuickLinksBlock } from './mappers/blocks/cms.quick-links.mapper'; +import { mapRecommendedProductsBlock } from './mappers/blocks/cms.recommended-products.mapper'; import { mapResourceDetailsBlock } from './mappers/blocks/cms.resource-details.mapper'; import { mapResourceListBlock } from './mappers/blocks/cms.resource-list.mapper'; import { mapServiceDetailsBlock } from './mappers/blocks/cms.service-details.mapper'; @@ -444,4 +447,16 @@ export class CmsService implements CMS.Service { const key = `article-search-component-${options.id}-${options.locale}`; return this.getCachedBlock(key, () => this.getBlock(options).pipe(map(mapArticleSearchBlock))); } + + getProductListBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapProductListBlock(options.locale)); + } + + getProductDetailsBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapProductDetailsBlock(options.locale)); + } + + getRecommendedProductsBlock(options: CMS.Request.GetCmsEntryParams) { + return of(mapRecommendedProductsBlock(options.locale)); + } } 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 new file mode 100644 index 000000000..d6dfdc7da --- /dev/null +++ b/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.product-details.mapper.ts @@ -0,0 +1,17 @@ +import { CMS } from '@o2s/framework/modules'; + +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', + specificationsTitle: 'Specifications', + descriptionTitle: 'Description', + downloadLabel: 'Download Brochure', + priceLabel: 'Price', + offerLabel: 'Offer', + }, + }; +}; 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 new file mode 100644 index 000000000..d7440bfd9 --- /dev/null +++ b/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.product-list.mapper.ts @@ -0,0 +1,37 @@ +import { CMS } from '@o2s/framework/modules'; + +export const mapProductListBlock = (_locale: string): CMS.Model.ProductListBlock.ProductListBlock => { + // TODO: Implement proper mapping from Strapi + // For now, return a basic structure with all required fields + return { + id: 'product-list-1', + title: 'Products', + subtitle: 'Browse our product catalog', + detailsLabel: 'View Details', + fieldMapping: { + category: {}, + }, + table: { + columns: [ + { id: 'sku', title: 'SKU' }, + { id: 'name', title: 'Product Name' }, + { id: 'category', title: 'Category' }, + { id: 'type', title: 'Type' }, + { id: 'price', title: 'Price' }, + ], + actions: { + title: 'Actions', + label: 'View Details', + }, + }, + noResults: { + title: 'No Products Found', + description: 'There are no products matching your criteria', + }, + labels: { + clickToSelect: 'Click to select', + gridView: 'Grid view', + tableView: 'Table view', + }, + }; +}; 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 new file mode 100644 index 000000000..5723c3751 --- /dev/null +++ b/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.recommended-products.mapper.ts @@ -0,0 +1,15 @@ +import { CMS } from '@o2s/framework/modules'; + +export const mapRecommendedProductsBlock = ( + _locale: string, +): CMS.Model.RecommendedProductsBlock.RecommendedProductsBlock => { + // TODO: Implement proper mapping from Strapi + // For now, return a basic structure with labels + return { + id: 'recommended-products-1', + labels: { + title: 'Recommended Products', + detailsLabel: 'Details', + }, + }; +}; diff --git a/packages/ui/src/components/ProductCarousel/ProductCarousel.stories.tsx b/packages/ui/src/components/ProductCarousel/ProductCarousel.stories.tsx new file mode 100644 index 000000000..61425a315 --- /dev/null +++ b/packages/ui/src/components/ProductCarousel/ProductCarousel.stories.tsx @@ -0,0 +1,175 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import React from 'react'; + +import { Models as FrontendModels } from '@o2s/utils.frontend'; + +import { Button } from '@o2s/ui/elements/button'; + +import { ProductCarousel } from './ProductCarousel'; +import { ProductSummaryItem } from './ProductCarousel.types'; + +// Mock LinkComponent for stories +const MockLinkComponent: FrontendModels.Link.LinkComponent = ({ href, className, children }) => ( + + {children} + +); + +const meta = { + title: 'Components/ProductCarousel', + component: ProductCarousel, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// Sample data +const sampleProducts: ProductSummaryItem[] = [ + { + id: 'PRD-005', + name: 'Cordless Angle Grinder', + description: '

Cordless angle grinder with 22V battery platform

', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/services-charger.jpg', + alt: 'Cordless Angle Grinder', + width: 640, + height: 656, + }, + price: { + value: 199.99, + currency: 'USD', + }, + link: '/products/ag-125-a22', + badges: [ + { label: 'New', variant: 'secondary' }, + { label: 'Promo', variant: 'destructive' }, + ], + }, + { + id: 'PRD-006', + name: 'Laser Measurement Device', + description: '

Laser measurement device for distance measurements

', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/empty.jpg', + alt: 'Laser Measurement', + width: 640, + height: 656, + }, + price: { + value: 100, + currency: 'USD', + }, + link: '/products/pd-s', + badges: [{ label: 'New', variant: 'secondary' }], + }, + { + id: 'PRD-007', + name: 'Cordless Drill Driver', + description: '

Cordless drill driver with 22V battery platform

', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/empty.jpg', + alt: 'Cordless Drill Driver', + width: 640, + height: 656, + }, + price: { + value: 100, + currency: 'USD', + }, + link: '/products/sfc-22-a', + badges: [{ label: 'New', variant: 'secondary' }], + }, + { + id: 'PRD-008', + name: 'Professional Calibration Service', + description: '

ISO-Certified Calibration for industrial equipment

', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/services-calibration.jpg', + alt: 'Professional Calibration Service', + width: 640, + height: 656, + }, + price: { + value: 149.99, + currency: 'USD', + }, + link: '/services/calibration', + badges: [{ label: 'Popular', variant: 'default' }], + }, + { + id: 'PRD-009', + name: 'Safety Equipment Package', + description: '

Complete safety equipment for welding environments

', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/services-welding.jpg', + alt: 'Safety Equipment Package', + width: 640, + height: 656, + }, + price: { + value: 299.99, + currency: 'USD', + }, + link: '/products/safety-package', + badges: [ + { label: 'Bestseller', variant: 'default' }, + { label: 'Safety', variant: 'outline' }, + ], + }, + { + id: 'PRD-010', + name: 'Power Tool Battery Pack', + description: '

High-capacity battery pack for cordless tools

', + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/services-charger.jpg', + alt: 'Power Tool Battery Pack', + width: 640, + height: 656, + }, + price: { + value: 79.99, + currency: 'USD', + }, + link: '/products/battery-pack', + badges: [{ label: 'New', variant: 'secondary' }], + }, +]; + +export const Default: Story = { + args: { + products: sampleProducts, + title: 'Recommended Products', + LinkComponent: MockLinkComponent, + linkDetailsLabel: 'View Details', + }, +}; + +export const WithDescription: Story = { + args: { + products: sampleProducts, + title: 'You Might Also Like', + description: '

Check out these carefully selected products that complement your choice.

', + LinkComponent: MockLinkComponent, + linkDetailsLabel: 'View Details', + }, +}; + +export const WithAction: Story = { + args: { + products: sampleProducts, + title: 'Popular Products', + description: '

Discover our most popular items chosen by customers like you.

', + action: ( + + ), + LinkComponent: MockLinkComponent, + linkDetailsLabel: 'View Details', + }, +}; diff --git a/packages/ui/src/components/ProductCarousel/ProductCarousel.tsx b/packages/ui/src/components/ProductCarousel/ProductCarousel.tsx new file mode 100644 index 000000000..e5d9e385e --- /dev/null +++ b/packages/ui/src/components/ProductCarousel/ProductCarousel.tsx @@ -0,0 +1,88 @@ +'use client'; + +import React from 'react'; + +import { cn } from '@o2s/ui/lib/utils'; + +import { ProductCard } from '@o2s/ui/components/Cards/ProductCard'; +import { Carousel } from '@o2s/ui/components/Carousel'; +import { RichText } from '@o2s/ui/components/RichText'; + +import { Typography } from '@o2s/ui/elements/typography'; + +import { ProductCarouselProps } from './ProductCarousel.types'; + +export const ProductCarousel: React.FC = ({ + products, + title, + description, + action, + LinkComponent, + carouselConfig, + linkDetailsLabel, + carouselClassName, +}) => { + if (!products || products.length === 0) { + return null; + } + + return ( +
+ {/* Header section */} + {(title || description || action) && ( +
+ {(title || description) && ( +
+ {title && {title}} + {description && } +
+ )} + {action} +
+ )} + + {/* Carousel */} + ( +
+ +
+ ))} + slidesPerView={1} + spaceBetween={16} + showNavigation={true} + showPagination={true} + className={cn( + '[&_.swiper-slide]:h-auto [&_.swiper-pagination]:bottom-0 [&_.swiper-pagination-bullet]:bg-primary [&_.swiper-pagination-bullet-active]:bg-primary [&_.swiper-pagination-bullet]:opacity-100 [&_.swiper-pagination-bullet]:w-2.5 [&_.swiper-pagination-bullet]:h-2.5', + carouselClassName, + )} + breakpoints={{ + 0: { + slidesPerView: 1, + spaceBetween: 16, + }, + 640: { + slidesPerView: 2, + spaceBetween: 20, + }, + 1024: { + slidesPerView: 3, + spaceBetween: 24, + }, + }} + {...carouselConfig} + /> +
+ ); +}; diff --git a/packages/ui/src/components/ProductCarousel/ProductCarousel.types.ts b/packages/ui/src/components/ProductCarousel/ProductCarousel.types.ts new file mode 100644 index 000000000..b6b73fc34 --- /dev/null +++ b/packages/ui/src/components/ProductCarousel/ProductCarousel.types.ts @@ -0,0 +1,32 @@ +import { Models } from '@o2s/framework/modules'; +import React from 'react'; + +import { Models as FrontendModels } from '@o2s/utils.frontend'; + +import { CarouselProps } from '@o2s/ui/components/Carousel'; + +export interface ProductCarouselProps { + products: ProductSummaryItem[]; + title?: string; + description?: Models.RichText.RichText; + action?: React.ReactNode; + LinkComponent: FrontendModels.Link.LinkComponent; + carouselConfig?: Partial; + linkDetailsLabel?: string; + carouselClassName?: string; +} + +export interface ProductSummaryItem { + id: string; + name: string; + description?: Models.RichText.RichText; + image?: Models.Media.Media; + price?: Models.Price.Price; + link: string; + badges?: ProductBadge[]; +} + +export interface ProductBadge { + label: string; + variant: 'default' | 'secondary' | 'destructive' | 'outline'; +} diff --git a/packages/ui/src/components/ProductCarousel/index.ts b/packages/ui/src/components/ProductCarousel/index.ts new file mode 100644 index 000000000..a63d35bd9 --- /dev/null +++ b/packages/ui/src/components/ProductCarousel/index.ts @@ -0,0 +1,2 @@ +export { ProductCarousel } from './ProductCarousel'; +export type { ProductCarouselProps, ProductSummaryItem, ProductBadge } from './ProductCarousel.types'; diff --git a/packages/ui/src/components/ProductGallery/ProductGallery.stories.tsx b/packages/ui/src/components/ProductGallery/ProductGallery.stories.tsx new file mode 100644 index 000000000..2958b6547 --- /dev/null +++ b/packages/ui/src/components/ProductGallery/ProductGallery.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; + +import { ProductGallery } from './ProductGallery'; +import { ImageItem } from './ProductGallery.types'; + +const meta = { + title: 'Components/ProductGallery', + component: ProductGallery, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// Sample image content (similar to Carousel's SampleSlide) +const colors = ['4F46E5', '7C3AED', 'EC4899', 'F59E0B', '10B981', '3B82F6', 'EF4444', '8B5CF6']; + +const createSampleImage = (number: number): ImageItem => { + const color = colors[(number - 1) % colors.length]; + return { + url: `https://placehold.co/800x600/${color}/white?text=Image+${number}`, + alt: `Image ${number}`, + }; +}; + +// Sample product images +const fourImages: ImageItem[] = [ + createSampleImage(1), + createSampleImage(2), + createSampleImage(3), + createSampleImage(4), +]; + +const singleImage: ImageItem[] = [createSampleImage(1)]; + +const manyImages: ImageItem[] = [ + createSampleImage(1), + createSampleImage(2), + createSampleImage(3), + createSampleImage(4), + createSampleImage(5), + createSampleImage(6), + createSampleImage(7), + createSampleImage(8), +]; + +/** + * Default gallery with thumbnails and navigation. + * Click main image to open fullscreen lightbox. + * Hover thumbnails for preview. + */ +export const Default: Story = { + args: { + images: fourImages, + showNavigation: true, + }, +}; + +/** + * Gallery with many images (8+ items). + * Tests thumbnail scrolling and lightbox with multiple images. + */ +export const ManyImages: Story = { + args: { + images: manyImages, + showNavigation: true, + }, +}; + +/** + * Gallery without thumbnails. + * Shows pagination dots and always-visible navigation arrows. + */ +export const WithoutThumbnails: Story = { + args: { + images: fourImages, + showNavigation: true, + showThumbnails: false, + showPagination: true, + }, +}; + +/** + * Single image gallery. + * No thumbnails or pagination needed. + */ +export const SingleImage: Story = { + args: { + images: singleImage, + showNavigation: false, + }, +}; diff --git a/packages/ui/src/components/ProductGallery/ProductGallery.tsx b/packages/ui/src/components/ProductGallery/ProductGallery.tsx new file mode 100644 index 000000000..b31c5ed49 --- /dev/null +++ b/packages/ui/src/components/ProductGallery/ProductGallery.tsx @@ -0,0 +1,252 @@ +'use client'; + +import { X } from 'lucide-react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { Swiper as SwiperType } from 'swiper'; +import 'swiper/css'; +import 'swiper/css/navigation'; +import 'swiper/css/pagination'; +import 'swiper/css/thumbs'; +import { A11y, Keyboard, Navigation, Pagination, Thumbs } from 'swiper/modules'; +import { Swiper, SwiperSlide } from 'swiper/react'; + +import { cn } from '@o2s/ui/lib/utils'; + +import { Image } from '@o2s/ui/components/Image'; + +import { ProductGalleryProps } from './ProductGallery.types'; + +export const ProductGallery: React.FC> = ({ + images, + className, + showNavigation = true, + showThumbnails = true, + showPagination = false, + speed = 300, + shouldPreloadGallery = false, + ...swiperProps +}) => { + const [thumbsSwiper, setThumbsSwiper] = useState(null); + const [mainSwiper, setMainSwiper] = useState(null); + const [isLightboxOpen, setIsLightboxOpen] = useState(false); + const [lightboxInitialSlide, setLightboxInitialSlide] = useState(0); + const [lightboxThumbsSwiper, setLightboxThumbsSwiper] = useState(null); + const [lightboxMainSwiper, setLightboxMainSwiper] = useState(null); + + const mainModules = useMemo( + () => [ + A11y, + Keyboard, + ...(showNavigation ? [Navigation] : []), + ...(showPagination ? [Pagination] : []), + Thumbs, + ], + [showNavigation, showPagination], + ); + + const handleThumbnailHover = (index: number, swiperInstance: SwiperType | null) => { + swiperInstance?.slideToLoop(index, 0); + }; + + const handleOpenLightbox = useCallback(() => { + if (mainSwiper) { + setLightboxInitialSlide(mainSwiper.realIndex); + } + setIsLightboxOpen(true); + }, [mainSwiper]); + + const handleCloseLightbox = useCallback(() => { + setIsLightboxOpen(false); + }, []); + + // Handle ESC key to close lightbox + useEffect(() => { + if (!isLightboxOpen) return; + + const handleEscKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleCloseLightbox(); + } + }; + + document.addEventListener('keydown', handleEscKey); + // Prevent body scroll when lightbox is open + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', handleEscKey); + document.body.style.overflow = ''; + }; + }, [isLightboxOpen, handleCloseLightbox]); + + // Return null if no images + if (!images || images.length === 0) { + return null; + } + + return ( +
+ {/* Main Gallery */} +
+ + {images.map((image, index) => ( + +
+ {image.alt} +
+
+ ))} +
+
+ + {/* Thumbnail Gallery */} + {showThumbnails && images.length > 1 && ( +
+ + {images.map((image, index) => ( + +
handleThumbnailHover(index, mainSwiper)} + onClick={handleOpenLightbox} + > + {image.alt} +
+
+ ))} +
+
+ )} + + {/* Fullscreen Lightbox */} + {isLightboxOpen && ( +
+ {/* Close Button */} + + + {/* Lightbox Content */} +
e.stopPropagation()}> + {/* Lightbox Main Gallery */} + + {images.map((image, index) => ( + +
+ {image.alt} +
+
+ ))} +
+ + {/* Lightbox Thumbnails */} + {images.length > 1 && ( +
+ + {images.map((image, index) => ( + +
handleThumbnailHover(index, lightboxMainSwiper)} + > + {image.alt} +
+
+ ))} +
+
+ )} +
+
+ )} +
+ ); +}; diff --git a/packages/ui/src/components/ProductGallery/ProductGallery.types.ts b/packages/ui/src/components/ProductGallery/ProductGallery.types.ts new file mode 100644 index 000000000..a90931b69 --- /dev/null +++ b/packages/ui/src/components/ProductGallery/ProductGallery.types.ts @@ -0,0 +1,16 @@ +import { SwiperProps } from 'swiper/react'; + +export interface ImageItem { + url: string; + alt: string; +} + +export interface ProductGalleryProps extends Omit { + images: ImageItem[]; + className?: string; + showNavigation?: boolean; + showThumbnails?: boolean; + showPagination?: boolean; + speed?: number; + shouldPreloadGallery?: boolean; +} diff --git a/packages/ui/src/components/ProductGallery/index.ts b/packages/ui/src/components/ProductGallery/index.ts new file mode 100644 index 000000000..7eb91e16c --- /dev/null +++ b/packages/ui/src/components/ProductGallery/index.ts @@ -0,0 +1,2 @@ +export { ProductGallery } from './ProductGallery'; +export type { ProductGalleryProps, ImageItem } from './ProductGallery.types';