@laioutr/app-shopware 0.14.5 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +87 -0
  2. package/dist/module.d.mts +15 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +1 -1
  5. package/dist/runtime/server/media-libraries/shopware.js +3 -3
  6. package/dist/runtime/server/middleware/defineShopware.js +13 -2
  7. package/dist/runtime/server/orchestr/category/base.resolver.js +9 -4
  8. package/dist/runtime/server/orchestr/category/bySlug.query.js +1 -1
  9. package/dist/runtime/server/orchestr/category/listing-page.page-index.js +17 -14
  10. package/dist/runtime/server/orchestr/menu/byAlias.query.js +6 -1
  11. package/dist/runtime/server/orchestr/product/base.resolver.js +5 -1
  12. package/dist/runtime/server/orchestr/product/byCategoryId.query.js +1 -1
  13. package/dist/runtime/server/orchestr/product/byCategorySlug.query.js +10 -7
  14. package/dist/runtime/server/orchestr/product/byCategorySlug.template.js +1 -1
  15. package/dist/runtime/server/orchestr/product/bySlug.query.js +1 -1
  16. package/dist/runtime/server/orchestr/product/detail-page.page-index.js +5 -3
  17. package/dist/runtime/server/orchestr/product/variants.link.js +5 -1
  18. package/dist/runtime/server/orchestr/product-variant/base.resolver.js +3 -1
  19. package/dist/runtime/server/orchestr/review/base.resolver.js +6 -3
  20. package/dist/runtime/server/orchestr-helper/requestedFields.d.ts +3 -16
  21. package/dist/runtime/server/orchestr-helper/requestedFields.js +46 -103
  22. package/dist/runtime/server/shopware-helper/criteria.d.ts +11 -0
  23. package/dist/runtime/server/shopware-helper/criteria.js +14 -0
  24. package/dist/runtime/server/shopware-helper/fetchAllProductVariants.d.ts +3 -1
  25. package/dist/runtime/server/shopware-helper/fetchAllProductVariants.js +4 -3
  26. package/dist/runtime/server/shopware-helper/shopwareSettings.d.ts +2 -0
  27. package/dist/runtime/server/shopware-helper/shopwareSettings.js +16 -0
  28. package/dist/runtime/server/shopware-helper/useSeoResolver.d.ts +2 -1
  29. package/dist/runtime/server/shopware-helper/useSeoResolver.js +2 -6
  30. package/dist/runtime/server/types/criteria.d.ts +7 -0
  31. package/dist/runtime/server/types/criteria.js +0 -0
  32. package/dist/runtime/server/types/settings.d.ts +21 -0
  33. package/dist/runtime/server/types/settings.js +0 -0
  34. package/package.json +1 -1
package/README.md CHANGED
@@ -71,6 +71,93 @@ export default defineNuxtConfig({
71
71
  in the private runtime config, and only `storefrontUrl`'s origin is exposed publicly. Read them
72
72
  from the environment rather than committing them to `nuxt.config.ts`.
73
73
 
74
+ In a Laioutr project the same options come from the app's `config` object in `laioutrrc.json`,
75
+ which the platform passes into this module — the `nuxt.config.ts` form above is for the playground
76
+ and for standalone Nuxt apps.
77
+
78
+ ```jsonc
79
+ // laioutrrc.json
80
+ {
81
+ "apps": [
82
+ {
83
+ "name": "@laioutr/app-shopware",
84
+ "version": "0.14.5",
85
+ "config": { "endpoint": "https://shop.example.com/store-api" }
86
+ }
87
+ ]
88
+ }
89
+ ```
90
+
91
+ ## Tuning the Store API reads
92
+
93
+ What the app asks Shopware for, and what those reads cost, is reachable through two Nitro hooks
94
+ rather than through config. A project's config is on its way to being editable from Studio, and
95
+ none of this is an editor's decision: a wrong `includes` value empties an entity component with no
96
+ error to trace it back from, and `maxLimit` describes the shop's deployment.
97
+
98
+ Both are **filter** hooks, seeded with what the app would otherwise have used — register no
99
+ handler and every read behaves exactly as it shipped.
100
+
101
+ ### `shopware:criteria:resolve`
102
+
103
+ Fires once per store-API read that hydrates a canonical entity, carrying the projection and
104
+ relations for that read.
105
+
106
+ ```ts
107
+ // server/plugins/shopware-criteria.ts
108
+ export default defineNitroPlugin((nitro) => {
109
+ nitro.hooks.hook('shopware:criteria:resolve', ({ target, result }) => {
110
+ if (target !== 'product') return;
111
+
112
+ result.criteria.includes.product.push('customFields');
113
+ result.criteria.associations.properties = { associations: { group: {} } };
114
+ });
115
+ });
116
+ ```
117
+
118
+ | `target` | Read |
119
+ | --- | --- |
120
+ | `product` | A product read, composed — the variant branch nested under it has already been through `product-variant`. |
121
+ | `product-variant` | A standalone variant read *and* the branch nested inside a product read, so widening variants widens them everywhere. |
122
+ | `category` | The category component resolver's read. |
123
+ | `menu` | The navigation read behind `MenuByAliasQuery`. |
124
+ | `product-review` | The review component resolver's read. |
125
+
126
+ Reads that hydrate nothing have no target and never fire: the id-only listing queries, both page
127
+ indexes, the breadcrumb link and the child-category link. Their payloads never reach a mapper.
128
+
129
+ `category` and `menu` arrive with an empty `includes` and stay that way unless a handler fills it —
130
+ Shopware returns whole rows when a read projects nothing. Putting a field there turns the read into
131
+ a whitelist, which is a way to make it *smaller*, not larger.
132
+
133
+ ### `shopware:settings:resolve`
134
+
135
+ Fires once per Orchestr request, carrying what the reads cost and how far they reach.
136
+
137
+ ```ts
138
+ // server/plugins/shopware-settings.ts
139
+ export default defineNitroPlugin((nitro) => {
140
+ nitro.hooks.hook('shopware:settings:resolve', ({ result }) => {
141
+ result.settings.maxLimit = 25;
142
+ result.settings.totalCountMode = 'next-pages';
143
+ result.settings.catalog.menuDepth = 3;
144
+ });
145
+ });
146
+ ```
147
+
148
+ | Setting | Default | Purpose |
149
+ | --- | --- | --- |
150
+ | `maxLimit` | `100` | The shop's `api.max_limit`. Page-index walks and the media library's folder read are clamped to it; a larger `limit` is rejected with a 400. |
151
+ | `totalCountMode` | `exact` | `total-count-mode` for product listing reads. `next-pages` is markedly cheaper on a large catalog but leaves the total an estimate. |
152
+ | `loadVariantsOnListing` | `true` | Pre-load every variant of every product on a category listing. Off, the variant resolver fetches on demand: smaller listing payloads, one extra read where the listing itself renders a variant picker. |
153
+ | `queryTemplateLimit` | `50` | How many categories the Studio query-template picker offers. |
154
+ | `mediaFolderLimit` | `500` | Page size for the media library's folder reads. |
155
+ | `catalog.menuDepth` | unset | Navigation depth for menu reads. Left unset, Shopware applies its own default of two levels. |
156
+ | `catalog.categoryPageIndex.types` | `['page']` | Shopware category `type` values the listing-page index covers. Add `'landing_page'` for shops that serve landing pages as listing pages. |
157
+ | `catalog.categoryPageIndex.minLevel` | `1` | Only categories *deeper* than this level are indexed — the default drops the navigation roots the storefront renders as the home page and the footer menu. |
158
+ | `catalog.categoryPageIndex.activeOnly` | `true` | Restrict the index to active categories. |
159
+ | `catalog.seoRouteNames` | `frontend.detail.page`; `frontend.navigation.page`, `frontend.landing.page` | `seo_url.routeName` values the slug resolver accepts, per entity type. Extend it when a plugin serves detail or listing pages under its own route name. |
160
+
74
161
  ## Checkout
75
162
 
76
163
  The app registers a `Checkout` page type. Tag one page with it in Studio and drop the
package/dist/module.d.mts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { HookResult, NuxtModule } from '@nuxt/schema';
2
+ import { ShopwareCriteriaTarget, ShopwareCriteria } from '../dist/runtime/server/types/criteria.js';
3
+ import { ShopwareSettings } from '../dist/runtime/server/types/settings.js';
2
4
  import { H3Event } from 'h3';
3
5
 
4
6
  declare module 'vue' {
@@ -36,6 +38,19 @@ declare module 'nitropack' {
36
38
  event: H3Event;
37
39
  token: string | null;
38
40
  }) => HookResult;
41
+ 'shopware:criteria:resolve': (args: {
42
+ event: H3Event;
43
+ target: ShopwareCriteriaTarget;
44
+ result: {
45
+ criteria: ShopwareCriteria;
46
+ };
47
+ }) => HookResult;
48
+ 'shopware:settings:resolve': (args: {
49
+ event: H3Event;
50
+ result: {
51
+ settings: ShopwareSettings;
52
+ };
53
+ }) => HookResult;
39
54
  }
40
55
  }
41
56
 
package/dist/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laioutr/app-shopware",
3
- "version": "0.14.5",
3
+ "version": "0.15.0",
4
4
  "configKey": "@laioutr/app-shopware",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.1",
package/dist/module.mjs CHANGED
@@ -4,7 +4,7 @@ import { CHECKOUT_ENDPOINT_PATH, ADOPT_SESSION_ENDPOINT_PATH } from '../dist/run
4
4
  import { registerLaioutrApp } from '@laioutr-core/kit';
5
5
 
6
6
  const name = "@laioutr/app-shopware";
7
- const version = "0.14.5";
7
+ const version = "0.15.0";
8
8
 
9
9
  const module$1 = defineNuxtModule({
10
10
  meta: {
@@ -38,10 +38,10 @@ export const buildShopwareMediaFilters = (query) => {
38
38
  }
39
39
  return filters;
40
40
  };
41
- const fetchChildFolders = async (api, parentId) => {
41
+ const fetchChildFolders = async (api, parentId, limit) => {
42
42
  const response = await api.invoke("searchMediaFolder post /search/media-folder", {
43
43
  body: {
44
- limit: 500,
44
+ limit,
45
45
  filter: [{ type: "equals", field: "parentId", value: parentId ?? null }],
46
46
  sort: [{ field: "name", order: "ASC" }]
47
47
  }
@@ -69,7 +69,7 @@ export default defineShopware.mediaLibrary({
69
69
  ...query.term ? { query: generateSearchFilter(query.term) } : {}
70
70
  }
71
71
  });
72
- const folders = query.cursor || query.scope === "all" || query.term ? void 0 : await fetchChildFolders(api, query.folderId);
72
+ const folders = query.cursor || query.scope === "all" || query.term ? void 0 : await fetchChildFolders(api, query.folderId, ctx.settings.mediaFolderLimit);
73
73
  const items = response.data.data?.map((media) => ({
74
74
  media: mapMedia(media),
75
75
  previewUrl: media.thumbnails?.[0]?.url ?? media.url ?? "",
@@ -1,6 +1,7 @@
1
- import { defineOrchestr } from "#imports";
1
+ import { defineOrchestr, useNitroApp } from "#imports";
2
2
  import { shopwareAdminClientFactory } from "../client/shopwareAdminClientFactory.js";
3
3
  import { shopwareClientFactory } from "../client/shopwareClientFactory.js";
4
+ import { defaultShopwareSettings } from "../shopware-helper/shopwareSettings.js";
4
5
  import { getCurrentSystemEntities } from "../shopware-helper/system/getCurrentSystemEntities.js";
5
6
  import { getCachedSystemEntities } from "../shopware-helper/system/getSystemEntities.js";
6
7
  export const defineShopware = defineOrchestr.meta({
@@ -10,10 +11,18 @@ export const defineShopware = defineOrchestr.meta({
10
11
  }).extendRequest(async (args) => {
11
12
  const storefrontClient = await shopwareClientFactory(args.event);
12
13
  const adminClient = shopwareAdminClientFactory();
14
+ const nitro = useNitroApp();
13
15
  const systemEntities = await getCachedSystemEntities(storefrontClient);
14
16
  const currentSystemEntities = getCurrentSystemEntities(systemEntities, args.clientEnv);
15
17
  storefrontClient.defaultHeaders["sw-currency-id"] = currentSystemEntities.currency.id;
16
18
  storefrontClient.defaultHeaders["sw-language-id"] = currentSystemEntities.locale.languageId;
19
+ const settings = { settings: defaultShopwareSettings() };
20
+ await nitro.hooks.callHook("shopware:settings:resolve", { event: args.event, result: settings });
21
+ const resolveCriteria = async (target, criteria) => {
22
+ const result = { criteria };
23
+ await nitro.hooks.callHook("shopware:criteria:resolve", { event: args.event, target, result });
24
+ return result.criteria;
25
+ };
17
26
  return {
18
27
  context: {
19
28
  storefrontClient,
@@ -21,7 +30,9 @@ export const defineShopware = defineOrchestr.meta({
21
30
  systemEntities,
22
31
  currentSystemEntities,
23
32
  /** The systems current currency iso code */
24
- swCurrency: currentSystemEntities.currency.iso
33
+ swCurrency: currentSystemEntities.currency.iso,
34
+ settings: settings.settings,
35
+ resolveCriteria
25
36
  }
26
37
  };
27
38
  });
@@ -1,6 +1,7 @@
1
1
  import { CategoryBase, CategoryContent, CategoryMedia, CategorySeo } from "@laioutr-core/canonical-types/entity/category";
2
2
  import { categoriesToken } from "../../const/passthroughTokens.js";
3
3
  import { defineShopwareComponentResolver } from "../../middleware/defineShopware.js";
4
+ import { toRequestCriteria } from "../../shopware-helper/criteria.js";
4
5
  import { entitySlug } from "../../shopware-helper/mappers/slugMapper.js";
5
6
  import { mapMedia } from "../../shopware-helper/mediaMapper.js";
6
7
  import { swTranslated } from "../../shopware-helper/swTranslated.js";
@@ -10,10 +11,14 @@ export default defineShopwareComponentResolver({
10
11
  provides: [CategoryBase, CategoryContent, CategoryMedia, CategorySeo],
11
12
  resolve: async ({ entityIds, context, passthrough, $entity }) => {
12
13
  const { storefrontClient } = context;
13
- const categories = passthrough.has(categoriesToken) ? passthrough.get(categoriesToken) : (await storefrontClient.invoke("readCategoryList post /category", {
14
- // Neither association is returned by default; without `media` the media component is always empty.
15
- body: { ids: entityIds, associations: { seoUrls: {}, media: {} } }
16
- })).data.elements;
14
+ const readCategories = async () => {
15
+ const criteria = await context.resolveCriteria("category", { includes: {}, associations: { seoUrls: {}, media: {} } });
16
+ const response = await storefrontClient.invoke("readCategoryList post /category", {
17
+ body: { ids: entityIds, ...toRequestCriteria(criteria) }
18
+ });
19
+ return response.data.elements;
20
+ };
21
+ const categories = passthrough.has(categoriesToken) ? passthrough.get(categoriesToken) : await readCategories();
17
22
  if (!categories) {
18
23
  throw new Error(
19
24
  "Categories not found in passthrough. The component resolver does not request categories from shopware at the moment."
@@ -3,7 +3,7 @@ import { defineShopwareQuery } from "../../middleware/defineShopware.js";
3
3
  import { useSeoResolver } from "../../shopware-helper/useSeoResolver.js";
4
4
  export default defineShopwareQuery(CategoryBySlugQuery, async ({ context, input }) => {
5
5
  const { slug } = input;
6
- const seoResolver = useSeoResolver(context.storefrontClient);
6
+ const seoResolver = useSeoResolver(context.storefrontClient, context.settings.catalog.seoRouteNames);
7
7
  const seoEntry = await seoResolver.resolve("category", slug);
8
8
  if (!seoEntry) {
9
9
  throw new Error(`No seo url found for category slug: ${slug}`);
@@ -5,22 +5,22 @@ import { toCategoryPageEntry } from "../../shopware-helper/pageIndexEntries.js";
5
5
  import { readCategoryPageMeta } from "../../shopware-helper/readCategoryPageMeta.js";
6
6
  import { SHOPWARE_MAX_LIMIT, storeApiPageFetcher } from "../../shopware-helper/storeApiPageFetcher.js";
7
7
  import { useSeoResolver } from "../../shopware-helper/useSeoResolver.js";
8
- const membershipFilter = [
9
- { type: "equals", field: "type", value: "page" },
10
- { type: "equals", field: "active", value: true },
11
- { type: "range", field: "level", parameters: { gt: 1 } }
8
+ const buildMembershipFilter = ({ categoryPageIndex }) => [
9
+ { type: "equalsAny", field: "type", value: categoryPageIndex.types },
10
+ ...categoryPageIndex.activeOnly ? [{ type: "equals", field: "active", value: true }] : [],
11
+ { type: "range", field: "level", parameters: { gt: categoryPageIndex.minLevel } }
12
12
  ];
13
- const pageIndexCriteria = {
13
+ const buildPageIndexCriteria = (catalog) => ({
14
14
  associations: { seoUrls: {}, media: {} },
15
15
  includes: {
16
16
  category: ["id", "name", "translated", "updatedAt", "seoUrls", "media"],
17
17
  seo_url: ["seoPathInfo", "isCanonical", "routeName"],
18
18
  media: ["url"]
19
19
  },
20
- filter: membershipFilter,
20
+ filter: buildMembershipFilter(catalog),
21
21
  // Page-number cursors need a stable upstream order, or a resumed walk shifts entries between passes.
22
22
  sort: [{ field: "id", order: "ASC" }]
23
- };
23
+ });
24
24
  export default defineShopwarePageIndex({
25
25
  for: ProductListingPage,
26
26
  label: "Shopware Category",
@@ -37,7 +37,10 @@ export default defineShopwarePageIndex({
37
37
  * locale would claim the category has no page in any other.
38
38
  */
39
39
  locate: async ({ context, params }) => {
40
- const seoEntry = await useSeoResolver(context.storefrontClient).resolve("category", params.slug);
40
+ const seoEntry = await useSeoResolver(context.storefrontClient, context.settings.catalog.seoRouteNames).resolve(
41
+ "category",
42
+ params.slug
43
+ );
41
44
  if (!seoEntry) return void 0;
42
45
  return {
43
46
  subject: { type: "Category", id: seoEntry.id },
@@ -46,16 +49,16 @@ export default defineShopwarePageIndex({
46
49
  },
47
50
  count: async ({ context }) => {
48
51
  const response = await context.storefrontClient.invoke("readCategoryList post /category", {
49
- body: { ...pageIndexCriteria, limit: 1, "total-count-mode": "exact" }
52
+ body: { ...buildPageIndexCriteria(context.settings.catalog), limit: 1, "total-count-mode": "exact" }
50
53
  });
51
54
  return response.data.total ?? 0;
52
55
  },
53
56
  /** Substring match on name and metaTitle, the same form the query-template picker uses for categories. */
54
57
  search: ({ context, term, take }) => context.storefrontClient.invoke("readCategoryList post /category", {
55
58
  body: {
56
- ...pageIndexCriteria,
59
+ ...buildPageIndexCriteria(context.settings.catalog),
57
60
  filter: [
58
- ...membershipFilter,
61
+ ...buildMembershipFilter(context.settings.catalog),
59
62
  {
60
63
  type: "multi",
61
64
  operator: "or",
@@ -65,14 +68,14 @@ export default defineShopwarePageIndex({
65
68
  ]
66
69
  }
67
70
  ],
68
- limit: take
71
+ limit: Math.min(take, context.settings.maxLimit)
69
72
  }
70
73
  }).then((response) => (response.data.elements ?? []).map(toCategoryPageEntry)),
71
74
  list: ({ context, batchSize, startCursor }) => paginate(
72
75
  storeApiPageFetcher(
73
- ({ page, limit }) => context.storefrontClient.invoke("readCategoryList post /category", { body: { ...pageIndexCriteria, page, limit } }).then((response) => response.data),
76
+ ({ page, limit }) => context.storefrontClient.invoke("readCategoryList post /category", { body: { ...buildPageIndexCriteria(context.settings.catalog), page, limit } }).then((response) => response.data),
74
77
  toCategoryPageEntry,
75
- batchSize
78
+ Math.min(batchSize, context.settings.maxLimit)
76
79
  ),
77
80
  startCursor
78
81
  )
@@ -2,16 +2,21 @@ import { MenuByAliasQuery } from "@laioutr-core/canonical-types/ecommerce";
2
2
  import { categoriesToken } from "../../const/passthroughTokens.js";
3
3
  import { defineShopwareQuery } from "../../middleware/defineShopware.js";
4
4
  import { flattenCategories } from "../../shopware-helper/categoryFlattener.js";
5
+ import { toRequestCriteria } from "../../shopware-helper/criteria.js";
5
6
  export default defineShopwareQuery({
6
7
  implements: MenuByAliasQuery,
7
8
  run: async ({ input, context, passthrough }) => {
8
9
  const { alias } = input;
10
+ const criteria = await context.resolveCriteria("menu", { includes: {}, associations: { seoUrls: {} } });
9
11
  const response = await context.storefrontClient.invoke("readNavigation post /navigation/{activeId}/{rootId}", {
10
12
  pathParams: {
11
13
  activeId: alias,
12
14
  rootId: alias
13
15
  },
14
- body: { associations: { seoUrls: {} } }
16
+ body: {
17
+ ...toRequestCriteria(criteria),
18
+ ...context.settings.catalog.menuDepth === void 0 ? {} : { depth: context.settings.catalog.menuDepth }
19
+ }
15
20
  });
16
21
  const flattenedCategories = flattenCategories(response.data ?? []);
17
22
  passthrough.set(categoriesToken, flattenedCategories);
@@ -37,7 +37,11 @@ export default defineShopwareComponentResolver({
37
37
  const loadedVariants = passthrough.get(productVariantsToken) ?? [];
38
38
  const missingVariantIds = variantIds.filter((id) => !loadedVariants.some((variant) => variant.id === id));
39
39
  if (missingVariantIds.length > 0) {
40
- const response = await fetchAllProducts(context.storefrontClient, { productIds: missingVariantIds, loadVariants: false });
40
+ const response = await fetchAllProducts(context.storefrontClient, {
41
+ productIds: missingVariantIds,
42
+ loadVariants: false,
43
+ resolveCriteria: context.resolveCriteria
44
+ });
41
45
  loadedVariants.push(...response);
42
46
  }
43
47
  const shopwareProducts = variantIds.map((id) => {
@@ -22,7 +22,7 @@ export default defineShopwareQuery(
22
22
  includes: {
23
23
  product: ["id", "parentId"]
24
24
  },
25
- "total-count-mode": "exact",
25
+ "total-count-mode": context.settings.totalCountMode,
26
26
  "min-price": swBuiltInFilters?.["min-price"],
27
27
  "max-price": swBuiltInFilters?.["max-price"],
28
28
  manufacturer: swBuiltInFilters?.manufacturer,
@@ -13,7 +13,7 @@ export default defineShopwareQuery(
13
13
  ProductsByCategorySlugQuery,
14
14
  async ({ context, input, pagination, filter: selectedFilters, sorting, passthrough }) => {
15
15
  const { categorySlug } = input;
16
- const seoResolver = useSeoResolver(context.storefrontClient);
16
+ const seoResolver = useSeoResolver(context.storefrontClient, context.settings.catalog.seoRouteNames);
17
17
  const seoEntry = await seoResolver.resolve("category", categorySlug);
18
18
  if (!seoEntry) {
19
19
  throw new Error(`No seo url found for category slug: ${categorySlug}`);
@@ -29,7 +29,7 @@ export default defineShopwareQuery(
29
29
  includes: {
30
30
  product: ["id", "parentId"]
31
31
  },
32
- "total-count-mode": "exact",
32
+ "total-count-mode": context.settings.totalCountMode,
33
33
  "min-price": swBuiltInFilters?.["min-price"],
34
34
  "max-price": swBuiltInFilters?.["max-price"],
35
35
  manufacturer: swBuiltInFilters?.manufacturer,
@@ -43,11 +43,14 @@ export default defineShopwareQuery(
43
43
  );
44
44
  passthrough.set(parentIdToDefaultVariantIdToken, parentIdToDefaultVariantId);
45
45
  cacheProductParentIds(response.data.elements.map((product) => [product.id, product.parentId ?? product.id]));
46
- const allVariants = await fetchAllProducts(context.storefrontClient, {
47
- productIds: Object.keys(parentIdToDefaultVariantId),
48
- loadVariants: true
49
- });
50
- passthrough.set(productVariantsToken, allVariants);
46
+ if (context.settings.loadVariantsOnListing) {
47
+ const allVariants = await fetchAllProducts(context.storefrontClient, {
48
+ productIds: Object.keys(parentIdToDefaultVariantId),
49
+ loadVariants: true,
50
+ resolveCriteria: context.resolveCriteria
51
+ });
52
+ passthrough.set(productVariantsToken, allVariants);
53
+ }
51
54
  return {
52
55
  // Return the parent-id, in case the received product is a variant
53
56
  ids: response.data.elements.map((product) => product.parentId ?? product.id),
@@ -23,7 +23,7 @@ export default defineShopwareQueryTemplateProvider({
23
23
  category: ["id", "name", "translated", "seoUrls"],
24
24
  seo_url: ["seoPathInfo", "isCanonical", "routeName"]
25
25
  },
26
- limit: 50
26
+ limit: context.settings.queryTemplateLimit
27
27
  }
28
28
  });
29
29
  const templates = [];
@@ -4,7 +4,7 @@ import { parentIdToDefaultVariantIdToken } from "../../const/passthroughTokens.j
4
4
  import { defineShopwareQuery } from "../../middleware/defineShopware.js";
5
5
  import { useSeoResolver } from "../../shopware-helper/useSeoResolver.js";
6
6
  export default defineShopwareQuery(ProductBySlugQuery, async ({ context, input, passthrough }) => {
7
- const seoResolver = useSeoResolver(context.storefrontClient);
7
+ const seoResolver = useSeoResolver(context.storefrontClient, context.settings.catalog.seoRouteNames);
8
8
  const seoEntry = await seoResolver.resolve("product", input.slug);
9
9
  if (!seoEntry) {
10
10
  throw new Error(`No product found for slug: ${input.slug}`);
@@ -34,7 +34,7 @@ export default defineShopwarePageIndex({
34
34
  */
35
35
  locate: async ({ context, params }) => {
36
36
  const client = context.storefrontClient;
37
- const seoEntry = await useSeoResolver(client).resolve("product", params.slug);
37
+ const seoEntry = await useSeoResolver(client, context.settings.catalog.seoRouteNames).resolve("product", params.slug);
38
38
  if (!seoEntry) return void 0;
39
39
  const parentId = await useGetProductParentId(client)(seoEntry.id);
40
40
  const productId = parentId ?? seoEntry.id;
@@ -46,12 +46,14 @@ export default defineShopwarePageIndex({
46
46
  });
47
47
  return response.data.total ?? 0;
48
48
  },
49
- search: ({ context, term, take }) => context.storefrontClient.invoke("readProduct post /product", { body: { ...pageIndexCriteria, term, limit: take } }).then((response) => (response.data.elements ?? []).map(toProductPageEntry)),
49
+ search: ({ context, term, take }) => context.storefrontClient.invoke("readProduct post /product", {
50
+ body: { ...pageIndexCriteria, term, limit: Math.min(take, context.settings.maxLimit) }
51
+ }).then((response) => (response.data.elements ?? []).map(toProductPageEntry)),
50
52
  list: ({ context, batchSize, startCursor }) => paginate(
51
53
  storeApiPageFetcher(
52
54
  ({ page, limit }) => context.storefrontClient.invoke("readProduct post /product", { body: { ...pageIndexCriteria, page, limit } }).then((response) => response.data),
53
55
  toProductPageEntry,
54
- batchSize
56
+ Math.min(batchSize, context.settings.maxLimit)
55
57
  ),
56
58
  startCursor
57
59
  )
@@ -3,7 +3,11 @@ import { productVariantsToken } from "../../const/passthroughTokens.js";
3
3
  import { defineShopwareLink } from "../../middleware/defineShopware.js";
4
4
  import { fetchAllProducts } from "../../shopware-helper/fetchAllProductVariants.js";
5
5
  export default defineShopwareLink(ProductVariantsLink, async ({ entityIds, context, passthrough }) => {
6
- const allVariants = passthrough.get(productVariantsToken) ?? await fetchAllProducts(context.storefrontClient, { productIds: entityIds, loadVariants: true });
6
+ const allVariants = passthrough.get(productVariantsToken) ?? await fetchAllProducts(context.storefrontClient, {
7
+ productIds: entityIds,
8
+ loadVariants: true,
9
+ resolveCriteria: context.resolveCriteria
10
+ });
7
11
  passthrough.set(productVariantsToken, allVariants);
8
12
  return {
9
13
  links: entityIds.map((productId) => {
@@ -12,6 +12,7 @@ import {
12
12
  import { productVariantsToken } from "../../const/passthroughTokens.js";
13
13
  import { defineShopwareComponentResolver } from "../../middleware/defineShopware.js";
14
14
  import { resolveProductVariantFields } from "../../orchestr-helper/requestedFields.js";
15
+ import { toRequestCriteria } from "../../shopware-helper/criteria.js";
15
16
  import { mapMedia } from "../../shopware-helper/mediaMapper.js";
16
17
  import { swTranslated } from "../../shopware-helper/swTranslated.js";
17
18
  export default defineShopwareComponentResolver({
@@ -32,10 +33,11 @@ export default defineShopwareComponentResolver({
32
33
  const loadedVariants = passthrough.get(productVariantsToken) ?? [];
33
34
  const missingVariantIds = entityIds.filter((id) => !loadedVariants.some((variant) => variant.id === id));
34
35
  if (missingVariantIds.length > 0) {
36
+ const criteria = await resolveProductVariantFields(context.resolveCriteria);
35
37
  const response = await context.storefrontClient.invoke("readProduct post /product", {
36
38
  body: {
37
39
  ids: missingVariantIds,
38
- ...resolveProductVariantFields()
40
+ ...toRequestCriteria(criteria)
39
41
  }
40
42
  });
41
43
  loadedVariants.push(...response.data.elements ?? []);
@@ -1,6 +1,7 @@
1
1
  import { ReviewBase } from "@laioutr-core/canonical-types/entity/review";
2
2
  import { currentProductIdsToken } from "../../const/passthroughTokens.js";
3
3
  import { defineShopwareComponentResolver } from "../../middleware/defineShopware.js";
4
+ import { toRequestCriteria } from "../../shopware-helper/criteria.js";
4
5
  export default defineShopwareComponentResolver({
5
6
  label: "Shopware Product Review Connector",
6
7
  entityType: "Review",
@@ -9,14 +10,16 @@ export default defineShopwareComponentResolver({
9
10
  const currentProductIds = passthrough.get(currentProductIdsToken);
10
11
  if (!currentProductIds) throw new Error("Missing `currentProductIdsToken` in passthrough.");
11
12
  let entities = [];
13
+ const criteria = await context.resolveCriteria("product-review", {
14
+ includes: { product_review: ["id", "title", "content", "points", "externalUser"] },
15
+ associations: {}
16
+ });
12
17
  for (const productId of currentProductIds) {
13
18
  const res = await context.storefrontClient.invoke("readProductReviews post /product/{productId}/reviews", {
14
19
  pathParams: { productId },
15
20
  body: {
16
21
  ids: entityIds,
17
- includes: {
18
- product_review: ["id", "title", "content", "points", "externalUser"]
19
- }
22
+ ...toRequestCriteria(criteria)
20
23
  }
21
24
  });
22
25
  entities = entities.concat(...res.data.elements ?? []);
@@ -1,18 +1,5 @@
1
+ import type { ResolveCriteria, ShopwareCriteria } from '../types/criteria.js';
1
2
  export declare const resolveProductFields: ({ loadVariants }: {
2
3
  loadVariants: boolean;
3
- }) => {
4
- associations: {
5
- [key: string]: /*elided*/ any;
6
- };
7
- includes: {
8
- [key: string]: string[];
9
- };
10
- };
11
- export declare const resolveProductVariantFields: () => {
12
- associations: {
13
- [key: string]: /*elided*/ any;
14
- };
15
- includes: {
16
- [key: string]: string[];
17
- };
18
- };
4
+ }, resolveCriteria: ResolveCriteria) => Promise<ShopwareCriteria>;
5
+ export declare const resolveProductVariantFields: (resolveCriteria: ResolveCriteria) => Promise<ShopwareCriteria>;
@@ -1,102 +1,49 @@
1
- import { ProductVariantsLink } from "@laioutr-core/canonical-types/ecommerce";
2
1
  import { MediaIncludes } from "../const/includes.js";
3
- const unique = (arr) => Array.from(new Set(arr));
2
+ import { mergeIncludes } from "../shopware-helper/criteria.js";
4
3
  const addAssociation = (name, add, association = {}) => add ? { [name]: association } : {};
5
- const _resolveRequestedFields = ({
6
- requestedComponents,
7
- requestedLinks
8
- }) => {
9
- const requestedVariants = requestedLinks[ProductVariantsLink];
10
- const variantFields = requestedVariants ? resolveProductVariantFields() : void 0;
11
- const associations = {
12
- ...requestedComponents.includes("media") ? { cover: { associations: { media: {} } }, media: { associations: { media: {} } } } : {},
13
- // gallery images (via product_media -> media)
14
- ...requestedComponents.includes("options") ? { options: { associations: { group: {} } } } : {},
15
- // variant options like Color/Size + their group names
16
- ...requestedVariants ? {
17
- children: {
18
- associations: variantFields?.associations ?? {}
19
- }
20
- } : {}
21
- };
22
- const includes = {
23
- product: unique([
24
- "id",
25
- "parentId",
26
- ...requestedComponents.includes("base") ? ["name", "seoUrls"] : [],
27
- ...requestedComponents.includes("info") ? ["name", "productNumber", "ean", "translated", "manufacturer", "description", "cover"] : [],
28
- ...requestedComponents.includes("description") ? ["translated", "description"] : [],
29
- ...requestedComponents.includes("seo") ? ["metaTitle", "metaDescription", "seoUrls"] : [],
30
- ...requestedComponents.includes("media") ? ["cover", "media"] : [],
31
- ...requestedComponents.includes("prices") ? ["minPurchase", "purchaseSteps", "maxPurchase", "calculatedPrice", "calculatedPrices"] : [],
32
- ...requestedLinks["ecommerce/product/variants"] ? ["children"] : [],
33
- ...variantFields?.includes?.product ?? []
34
- ]),
35
- product_media: requestedComponents.includes("media") ? ["id", "mediaId", "media"] : [],
36
- media: requestedComponents.includes("media") ? MediaIncludes : [],
37
- property_group_option: requestedComponents.includes("availability") ? ["id", "name", "group"] : [],
38
- property_group: requestedComponents.includes("availability") ? ["id", "name"] : []
39
- };
40
- return { associations, includes };
41
- };
42
- const mergeIncludes = (includes1, includes2) => {
43
- const merged = {};
44
- for (const [key, value] of Object.entries(includes1)) {
45
- merged[key] = [...value];
46
- }
47
- for (const [key, value] of Object.entries(includes2)) {
48
- merged[key] = [...merged[key] ?? [], ...value];
49
- }
50
- for (const [key, value] of Object.entries(merged)) {
51
- merged[key] = unique([...value]);
52
- }
53
- return merged;
54
- };
55
- export const resolveProductFields = ({ loadVariants }) => {
56
- const variantFields = loadVariants ? resolveProductVariantFields() : void 0;
57
- const associations = {
58
- cover: { associations: { media: {} } },
59
- media: { associations: { media: {} } },
60
- ...addAssociation("children", loadVariants, variantFields?.associations ? { associations: variantFields?.associations } : {})
61
- };
62
- const includes = mergeIncludes(
63
- {
64
- product: [
65
- "id",
66
- "parentId",
67
- "name",
68
- "seoUrls",
69
- "productNumber",
70
- "ean",
71
- "translated",
72
- "manufacturer",
73
- "description",
74
- "cover",
75
- "metaTitle",
76
- "metaDescription",
77
- "cover",
78
- "media",
79
- "minPurchase",
80
- "purchaseSteps",
81
- "maxPurchase",
82
- "calculatedPrice",
83
- "calculatedPrices",
84
- "children",
85
- "ratingAverage",
86
- "productReviews"
87
- ],
88
- product_media: ["id", "mediaId", "media"],
89
- media: MediaIncludes
4
+ export const resolveProductFields = async ({ loadVariants }, resolveCriteria) => {
5
+ const variantFields = loadVariants ? await resolveProductVariantFields(resolveCriteria) : void 0;
6
+ return resolveCriteria("product", {
7
+ associations: {
8
+ cover: { associations: { media: {} } },
9
+ media: { associations: { media: {} } },
10
+ ...addAssociation("children", loadVariants, variantFields ? { associations: variantFields.associations } : {})
90
11
  },
91
- variantFields?.includes ?? {}
92
- );
93
- return {
94
- associations,
95
- includes
96
- };
12
+ includes: mergeIncludes(
13
+ {
14
+ product: [
15
+ "id",
16
+ "parentId",
17
+ "name",
18
+ "seoUrls",
19
+ "productNumber",
20
+ "ean",
21
+ "translated",
22
+ "manufacturer",
23
+ "description",
24
+ "cover",
25
+ "metaTitle",
26
+ "metaDescription",
27
+ "cover",
28
+ "media",
29
+ "minPurchase",
30
+ "purchaseSteps",
31
+ "maxPurchase",
32
+ "calculatedPrice",
33
+ "calculatedPrices",
34
+ "children",
35
+ "ratingAverage",
36
+ "productReviews"
37
+ ],
38
+ product_media: ["id", "mediaId", "media"],
39
+ media: MediaIncludes
40
+ },
41
+ variantFields?.includes ?? {}
42
+ )
43
+ });
97
44
  };
98
- export const resolveProductVariantFields = () => {
99
- const associations = {
45
+ export const resolveProductVariantFields = async (resolveCriteria) => resolveCriteria("product-variant", {
46
+ associations: {
100
47
  cover: { associations: { media: {} } },
101
48
  // main image
102
49
  media: { associations: { media: {} } },
@@ -106,8 +53,8 @@ export const resolveProductVariantFields = () => {
106
53
  manufacturer: {},
107
54
  deliveryTime: {},
108
55
  prices: { associations: { rule: {} } }
109
- };
110
- const includes = {
56
+ },
57
+ includes: {
111
58
  product: [
112
59
  "id",
113
60
  "parentId",
@@ -137,9 +84,5 @@ export const resolveProductVariantFields = () => {
137
84
  media: MediaIncludes,
138
85
  property_group_option: ["id", "name", "group", "translated"],
139
86
  property_group: ["id", "name", "translated"]
140
- };
141
- return {
142
- associations,
143
- includes
144
- };
145
- };
87
+ }
88
+ });
@@ -0,0 +1,11 @@
1
+ import type { ShopwareCriteria } from '../types/criteria.js';
2
+ import type { ShopwareIncludesQuery } from '../types/shopware.js';
3
+ export declare const mergeIncludes: (...projections: ShopwareIncludesQuery[]) => ShopwareIncludesQuery;
4
+ export declare const toRequestCriteria: ({ includes, associations }: ShopwareCriteria) => {
5
+ associations?: {
6
+ [key: string]: /*elided*/ any;
7
+ } | undefined;
8
+ includes?: {
9
+ [key: string]: string[];
10
+ } | undefined;
11
+ };
@@ -0,0 +1,14 @@
1
+ const unique = (arr) => Array.from(new Set(arr));
2
+ export const mergeIncludes = (...projections) => {
3
+ const merged = {};
4
+ for (const projection of projections) {
5
+ for (const [entity, fields] of Object.entries(projection)) {
6
+ merged[entity] = unique([...merged[entity] ?? [], ...fields]);
7
+ }
8
+ }
9
+ return merged;
10
+ };
11
+ export const toRequestCriteria = ({ includes, associations }) => ({
12
+ ...Object.keys(includes).length > 0 ? { includes } : {},
13
+ ...Object.keys(associations).length > 0 ? { associations } : {}
14
+ });
@@ -1,7 +1,9 @@
1
+ import type { ResolveCriteria } from '../types/criteria.js';
1
2
  import { StorefrontClient } from '../types/shopware.js';
2
- export declare const fetchAllProducts: (storefrontClient: StorefrontClient, { productIds, loadVariants }: {
3
+ export declare const fetchAllProducts: (storefrontClient: StorefrontClient, { productIds, loadVariants, resolveCriteria }: {
3
4
  productIds: string[];
4
5
  loadVariants: boolean;
6
+ resolveCriteria: ResolveCriteria;
5
7
  }) => Promise<{
6
8
  active?: boolean;
7
9
  apiAlias: "product";
@@ -1,13 +1,14 @@
1
+ import { toRequestCriteria } from "./criteria.js";
1
2
  import { resolveProductFields } from "../orchestr-helper/requestedFields.js";
2
- export const fetchAllProducts = async (storefrontClient, { productIds, loadVariants }) => {
3
- const fields = resolveProductFields({ loadVariants });
3
+ export const fetchAllProducts = async (storefrontClient, { productIds, loadVariants, resolveCriteria }) => {
4
4
  if (productIds.length === 0) {
5
5
  return [];
6
6
  }
7
+ const criteria = await resolveProductFields({ loadVariants }, resolveCriteria);
7
8
  const response = await storefrontClient.invoke("readProduct post /product", {
8
9
  body: {
9
10
  ids: productIds,
10
- ...fields
11
+ ...toRequestCriteria(criteria)
11
12
  }
12
13
  });
13
14
  const products = response.data.elements ?? [];
@@ -0,0 +1,2 @@
1
+ import type { ShopwareSettings } from '../types/settings.js';
2
+ export declare const defaultShopwareSettings: () => ShopwareSettings;
@@ -0,0 +1,16 @@
1
+ import { SHOPWARE_MAX_LIMIT } from "./storeApiPageFetcher.js";
2
+ export const defaultShopwareSettings = () => ({
3
+ maxLimit: SHOPWARE_MAX_LIMIT,
4
+ totalCountMode: "exact",
5
+ loadVariantsOnListing: true,
6
+ queryTemplateLimit: 50,
7
+ mediaFolderLimit: 500,
8
+ catalog: {
9
+ menuDepth: void 0,
10
+ categoryPageIndex: { types: ["page"], minLevel: 1, activeOnly: true },
11
+ seoRouteNames: {
12
+ product: ["frontend.detail.page"],
13
+ category: ["frontend.navigation.page", "frontend.landing.page"]
14
+ }
15
+ }
16
+ });
@@ -1,3 +1,4 @@
1
+ import type { ShopwareCatalogSettings } from '../types/settings.js';
1
2
  import { StorefrontClient } from '../types/shopware.js';
2
3
  type SeoUrlType = 'product' | 'category';
3
4
  interface SeoEntry {
@@ -5,7 +6,7 @@ interface SeoEntry {
5
6
  id: string;
6
7
  matchedPath: string;
7
8
  }
8
- export declare const useSeoResolver: (storefrontClient: StorefrontClient) => {
9
+ export declare const useSeoResolver: (storefrontClient: StorefrontClient, routeNames: ShopwareCatalogSettings["seoRouteNames"]) => {
9
10
  resolve: (type: SeoUrlType, slug: string) => Promise<SeoEntry | undefined>;
10
11
  };
11
12
  export {};
@@ -1,12 +1,8 @@
1
1
  import { extractShopwareId, isShopwareId } from "./isShopwareId.js";
2
2
  import { isSlugMatchingSeoPath } from "./mappers/slugMapper.js";
3
3
  import { useUserlandCache } from "#imports";
4
- const typeToRouteNames = {
5
- product: ["frontend.detail.page"],
6
- category: ["frontend.navigation.page", "frontend.landing.page"]
7
- };
8
4
  const SEO_ENTRY_TTL = 60 * 60 * 24;
9
- export const useSeoResolver = (storefrontClient) => {
5
+ export const useSeoResolver = (storefrontClient, routeNames) => {
10
6
  const cache = useUserlandCache("shopware/seo-urls");
11
7
  const resolve = async (type, slug) => {
12
8
  const languageId = storefrontClient.defaultHeaders["sw-language-id"] ?? "default";
@@ -38,7 +34,7 @@ export const useSeoResolver = (storefrontClient) => {
38
34
  { field: "seoPathInfo", type: "equals", value: slug }
39
35
  ]
40
36
  },
41
- { field: "routeName", type: "equalsAny", value: typeToRouteNames[type] }
37
+ { field: "routeName", type: "equalsAny", value: routeNames[type] }
42
38
  ]
43
39
  }
44
40
  ]
@@ -0,0 +1,7 @@
1
+ import type { ShopwareAssociationsQuery, ShopwareIncludesQuery } from './shopware.js';
2
+ export type ShopwareCriteriaTarget = 'product' | 'product-variant' | 'category' | 'menu' | 'product-review';
3
+ export interface ShopwareCriteria {
4
+ includes: ShopwareIncludesQuery;
5
+ associations: ShopwareAssociationsQuery;
6
+ }
7
+ export type ResolveCriteria = (target: ShopwareCriteriaTarget, criteria: ShopwareCriteria) => Promise<ShopwareCriteria>;
File without changes
@@ -0,0 +1,21 @@
1
+ import type { Schemas } from './storeApiTypes.js';
2
+ export interface ShopwareCatalogSettings {
3
+ menuDepth: number | undefined;
4
+ categoryPageIndex: {
5
+ types: string[];
6
+ minLevel: number;
7
+ activeOnly: boolean;
8
+ };
9
+ seoRouteNames: {
10
+ product: string[];
11
+ category: string[];
12
+ };
13
+ }
14
+ export interface ShopwareSettings {
15
+ maxLimit: number;
16
+ totalCountMode: Schemas['TotalCountMode'];
17
+ loadVariantsOnListing: boolean;
18
+ queryTemplateLimit: number;
19
+ mediaFolderLimit: number;
20
+ catalog: ShopwareCatalogSettings;
21
+ }
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laioutr/app-shopware",
3
- "version": "0.14.5",
3
+ "version": "0.15.0",
4
4
  "description": "Laioutr integration with Shopware 6",
5
5
  "repository": "github:laioutr/app-shopware",
6
6
  "license": "MIT",