diff --git a/README.md b/README.md index c9b88d7..9815e5e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,93 @@ -# @laioutr-app/shopware +# 🛍️ Laioutr Shopware Integration -> Shopware integration for laioutr +This repository contains the official Shopware 6 integration for the Laioutr frontend framework. This connector provides a comprehensive set of features to connect your Laioutr application with a Shopware backend, enabling full e-commerce functionality. -## Usage +This connector provides a robust bridge to Shopware as it handles sessions, customer data, cart management, product retrieval, reviews, and more. -Example package usage +
-In your nuxt.config.ts: +### ✨ Features: -```ts +- 👤 Customer & Session Management + + - Anonymous Sessions: Track users and persist carts before they log in. + - Login & Customer Sessions: Full support for registered customer authentication and session handling. + +- 🛒 Cart Management + + - Get Current Cart: Retrieve the active shopping cart for the current session. + - Add Item to Cart: Seamlessly add products and variants to the user's cart. + +- 🗂️ Category & Navigation + + - Category List: Fetch a flat list of all available categories. + - Category Tree: Retrieve a nested category structure by its alias (e.g., for building navigation menus). + +- 📦 Products & Variants + + - Products by Category ID: Get a list of all products within a specific category using its ID. + - Products by Category Slug: Get a list of all products within a specific category using its URL slug. + - Get Product by Slug: Retrieve detailed information for a single product. + - Product Variants: Fetch all available variants (e.g., size, color) for a product. + - Canonical Categories: Identify the primary (canonical) category for a product, essential for SEO. + - Product Search: Implement powerful, native Shopware search functionality across your product catalog. + +- 🔍 Search & Reviews + + - Get Reviews: Retrieve all customer reviews for a specific product. + - Create Reviews: Allow logged-in customers to submit new product reviews. + +- ✉️ Miscellaneous + - Newsletter Subscription: Enable users to subscribe to marketing newsletters. + +
+ +### 🚀 Installation + +```bash +# Using npm +npm install @laioutr/app-shopware + +# Using yarn +yarn add @laioutr/app-shopware +``` + +### ⚙️ Configuration & Usage + +To get started, you need to configure the connector with your Shopware API credentials inside `nuxt.config.ts`. We recommend using environment variables: + +```typescript defineNuxtConfig({ /* [...] */ - modules: ['@laioutr-app/shopware'], + modules: ['@laioutr/app-shopware'], + /* [...] */ + '@laioutr/app-shopware': { + endpoint: import.meta.env.SHOPWARE_DEMO_ENDPOINT, + accessToken: import.meta.env.SHOPWARE_DEMO_ACCESS_TOKEN, + adminEndpoint: import.meta.env.SHOPWARE_DEMO_ADMIN_ENDPOINT, + adminClientId: import.meta.env.SHOPWARE_DEMO_ADMIN_CLIENT_ID, + adminClientSecret: import.meta.env.SHOPWARE_DEMO_ADMIN_CLIENT_SECRET, + }, /* [...] */ }); ``` + +
+ +### 🤝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request or open an issue for bugs, feature requests, or improvements. + +Fork the repository. + +Create your feature branch (git checkout -b feature/AmazingFeature). + +Commit your changes (git commit -m 'feat: Add some AmazingFeature'). + +Push to the branch (git push origin feature/AmazingFeature). + +Open a Pull Request. + +### 📄 License + +This project is licensed under the MIT License. See the LICENSE file for details. diff --git a/src/runtime/server/const/passthroughTokens.ts b/src/runtime/server/const/passthroughTokens.ts index be61cb2..99f521d 100644 --- a/src/runtime/server/const/passthroughTokens.ts +++ b/src/runtime/server/const/passthroughTokens.ts @@ -1,5 +1,5 @@ import { createPassthroughToken } from '#imports'; -import { ShopwareCategory, ShopwareProduct } from '../types/shopware'; +import { ShopwareCart, ShopwareCategory, ShopwareProduct } from '../types/shopware'; export const currentProductIdsToken = createPassthroughToken('@laioutr/app-shopware/currentProductIdsFragment'); @@ -19,3 +19,5 @@ export const parentIdToDefaultVariantIdToken = createPassthroughToken('@laioutr/app-shopware/productsFragment'); export const productVariantsToken = createPassthroughToken('@laioutr/app-shopware/productVariants'); + +export const cartFragmentToken = createPassthroughToken('@laioutr-app/shopify/cartFragment'); diff --git a/src/runtime/server/orchestr/cart-item/base.resolver.ts b/src/runtime/server/orchestr/cart-item/base.resolver.ts new file mode 100644 index 0000000..cd01f25 --- /dev/null +++ b/src/runtime/server/orchestr/cart-item/base.resolver.ts @@ -0,0 +1,117 @@ +import { Money } from '@screeny05/ts-money'; +import { MeasurementUnit } from '@laioutr-core/canonical-types'; +import { + CartItemAvailability, + CartItemBase, + CartItemCost, + CartItemProductData, + CartItemQuantityRule, +} from '@laioutr-core/canonical-types/entity/cart-item'; +import { cartFragmentToken } from '../../const/passthroughTokens'; +import { defineShopwareComponentResolver } from '../../middleware/defineShopware'; +import { mapMedia } from '../../shopware-helper/mediaMapper'; + +export default defineShopwareComponentResolver({ + entityType: 'CartItem', + label: 'Shopify Cart Item Component Resolver', + provides: [CartItemBase, CartItemCost, CartItemProductData, CartItemAvailability, CartItemQuantityRule], + resolve: async ({ $entity, passthrough, clientEnv }) => { + const { currency } = clientEnv; + + const cart = passthrough.require(cartFragmentToken); + + const lineItems = (cart.lineItems ?? []).filter((item) => item.type === 'product'); + const discounts = (cart.lineItems ?? []).filter((item) => item.type === 'discount'); + + const mappedDiscounts = discounts.map((item) => + $entity({ + id: item.id, + base: () => ({ + type: 'discount-code' as const, + quantity: item.quantity, + title: item.label as string, + }), + cost: () => ({ + single: { amount: 0, currency: clientEnv.currency }, + subtotal: { amount: 0, currency: clientEnv.currency }, + total: { amount: 0, currency: clientEnv.currency }, + }), + productData: () => undefined, + availability: () => ({ + quantity: 1, + status: item.payload.available && item.payload.active ? 'inStock' : 'outOfStock', + }), + quantityRule: () => ({ + canChange: false, + increment: item.quantityInformation?.purchaseSteps ?? 1, + min: item.quantityInformation?.minPurchase ?? 1, + }), + }) + ); + + const mappedProducts = lineItems.map((item) => + $entity({ + id: item.id, + base: () => ({ + type: 'product' as const, + quantity: item.quantity, + title: item.label as string, + subtitle: item.payload.name, + brand: item.payload.manufacturerNumber, + code: item.uniqueIdentifier, + cover: item.cover?.media ? { ...mapMedia(item.cover.media), type: 'image' } : undefined, + link: { + type: 'reference', + reference: { + type: 'product', + slug: item.payload.productNumber, + id: item.id, + }, + }, + }), + cost: () => { + const listPrice = item.price?.listPrice?.price ?? 0; + const unitPrice = item.price?.unitPrice ?? 0; + const totalPrice = item.price?.totalPrice ?? 0; + const subtotal = Money.fromDecimal({ amount: listPrice * (item.quantity || 1), currency }); + const total = Money.fromDecimal({ amount: totalPrice, currency }); + const singleTotal = total.divide(item.quantity || 1); + const singleStrikethrough = Money.fromDecimal({ amount: listPrice, currency }); + const hasStrikethrogh = item.price?.listPrice?.discount && item.price.listPrice.discount < 0; + + return { + single: singleTotal, + singleStrikethrough: hasStrikethrogh && listPrice > unitPrice ? singleStrikethrough : undefined, + subtotal, + total, + }; + }, + productData: () => ({ + ...(item.payload.packUnit && item.payload.purchaseUnit && item.payload.referenceUnit ? + { + unitPrice: { + price: Money.fromDecimal({ amount: item.price?.unitPrice ?? 0, currency }), + quantity: { unit: item.payload.packUnit as MeasurementUnit, value: item.payload.purchaseUnit ?? 0 }, + reference: { unit: item.payload.packUnit as MeasurementUnit, value: item.payload.referenceUnit ?? 0 }, + }, + } + : {}), + }), + availability: () => ({ + quantity: item.payload.stock ?? 0, // Keep stock level + status: item.payload.available ? 'inStock' : 'outOfStock', // Use 'available' flag + }), + quantityRule: () => ({ + increment: item.quantityInformation?.purchaseSteps ?? 1, + min: item.quantityInformation?.minPurchase ?? 1, // Default to 1 + max: item.quantityInformation?.maxPurchase ?? Number.MAX_SAFE_INTEGER, + canChange: true, // Products should be changeable + }), + }) + ); + + return { + entities: [...mappedDiscounts, ...mappedProducts], + }; + }, +}); diff --git a/src/runtime/server/orchestr/cart/base.resolver.ts b/src/runtime/server/orchestr/cart/base.resolver.ts index dd53681..2edd240 100644 --- a/src/runtime/server/orchestr/cart/base.resolver.ts +++ b/src/runtime/server/orchestr/cart/base.resolver.ts @@ -1,3 +1,4 @@ +import { Money } from '@screeny05/ts-money'; import { CartBase, CartCost } from '@laioutr-core/canonical-types/entity/cart'; import { defineShopwareComponentResolver } from '../../middleware/defineShopware'; @@ -12,7 +13,7 @@ export default defineShopwareComponentResolver({ const cart = await storefrontClient.invoke('readCart get /checkout/cart'); // helper to build Money objects - const money = (amount: number, currency: string) => ({ amount, currency }); + const money = (amount: number, currency: string) => Money.fromDecimal({ amount, currency }); // safe defaults const lineItems = cart.data?.lineItems ?? []; diff --git a/src/runtime/server/orchestr/cart/cart-item.link.ts b/src/runtime/server/orchestr/cart/cart-item.link.ts new file mode 100644 index 0000000..ca2d565 --- /dev/null +++ b/src/runtime/server/orchestr/cart/cart-item.link.ts @@ -0,0 +1,19 @@ +import { CartItemsLink } from '@laioutr-core/canonical-types/ecommerce'; +import { cartFragmentToken } from '../../const/passthroughTokens'; +import { defineShopwareLink } from '../../middleware/defineShopware'; + +export default defineShopwareLink(CartItemsLink, async ({ passthrough }) => { + const cart = passthrough.require(cartFragmentToken); + + const lienItems = (cart.lineItems ?? []).filter((item) => item.type === 'product').map((item) => item.id); + const discounts = (cart.lineItems ?? []).filter((item) => item.type === 'discount').map((item) => item.id); + + return { + links: [ + { + sourceId: cart.token as string, + targetIds: [...lienItems, ...discounts], + }, + ], + }; +}); diff --git a/src/runtime/server/orchestr/cart/get-current.query.ts b/src/runtime/server/orchestr/cart/get-current.query.ts index a88a12e..edbe82f 100644 --- a/src/runtime/server/orchestr/cart/get-current.query.ts +++ b/src/runtime/server/orchestr/cart/get-current.query.ts @@ -1,11 +1,14 @@ import { GetCurrentCartQuery } from '@laioutr-core/canonical-types/ecommerce'; +import { cartFragmentToken } from '../../const/passthroughTokens'; import { defineShopwareQuery } from '../../middleware/defineShopware'; -export default defineShopwareQuery(GetCurrentCartQuery, async ({ context }) => { +export default defineShopwareQuery(GetCurrentCartQuery, async ({ context, passthrough }) => { const { storefrontClient } = context; const cart = await storefrontClient.invoke('readCart get /checkout/cart'); + passthrough.set(cartFragmentToken, cart.data); + /* Cart is identified per unique context session */ return { id: cart.data.token ?? '' }; }); diff --git a/src/runtime/server/types/shopware.ts b/src/runtime/server/types/shopware.ts index ab62057..3404ce4 100644 --- a/src/runtime/server/types/shopware.ts +++ b/src/runtime/server/types/shopware.ts @@ -8,6 +8,8 @@ export type ShopwareFilters = components['schemas']['Filters']; export type ShopwareManufacturer = components['schemas']['ProductManufacturer']; export type ShopwareSeoUrl = components['schemas']['SeoUrl']; export type StorefrontClient = ReturnType>; +export type ShopwareCart = components['schemas']['Cart']; +export type ShopwareCartLineItem = Required['items'][number]; export type ShopwareIncludesQuery = components['schemas']['Include']; export type ShopwareAssociationsQuery = components['schemas']['Association'];