Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 83 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
<hr />

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.

<hr />

### 🚀 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,
},
/* [...] */
});
```

<hr />

### 🤝 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.
4 changes: 3 additions & 1 deletion src/runtime/server/const/passthroughTokens.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>('@laioutr/app-shopware/currentProductIdsFragment');

Expand All @@ -19,3 +19,5 @@ export const parentIdToDefaultVariantIdToken = createPassthroughToken<Record<str
export const productsFragmentToken = createPassthroughToken<ShopwareProduct[]>('@laioutr/app-shopware/productsFragment');

export const productVariantsToken = createPassthroughToken<ShopwareProduct[]>('@laioutr/app-shopware/productVariants');

export const cartFragmentToken = createPassthroughToken<ShopwareCart>('@laioutr-app/shopify/cartFragment');
117 changes: 117 additions & 0 deletions src/runtime/server/orchestr/cart-item/base.resolver.ts
Original file line number Diff line number Diff line change
@@ -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],
};
},
});
3 changes: 2 additions & 1 deletion src/runtime/server/orchestr/cart/base.resolver.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 ?? [];
Expand Down
19 changes: 19 additions & 0 deletions src/runtime/server/orchestr/cart/cart-item.link.ts
Original file line number Diff line number Diff line change
@@ -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],
},
],
};
});
5 changes: 4 additions & 1 deletion src/runtime/server/orchestr/cart/get-current.query.ts
Original file line number Diff line number Diff line change
@@ -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 ?? '' };
});
2 changes: 2 additions & 0 deletions src/runtime/server/types/shopware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createAPIClient<operations>>;
export type ShopwareCart = components['schemas']['Cart'];
export type ShopwareCartLineItem = Required<components['schemas']['CartItems']>['items'][number];

export type ShopwareIncludesQuery = components['schemas']['Include'];
export type ShopwareAssociationsQuery = components['schemas']['Association'];
Expand Down
Loading