diff --git a/app/sitemap.ts b/app/sitemap.ts index d8cdfd2ea..d1b685ff1 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,4 +1,4 @@ -import { getCollections, getPages, getProducts } from 'lib/shopify'; +import { getProductSeoUrls } from 'lib/shopware'; import { MetadataRoute } from 'next'; const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL @@ -11,29 +11,16 @@ export default async function sitemap(): Promise - collections.map((collection) => ({ - url: `${baseUrl}${collection.path}`, - lastModified: collection.updatedAt - })) - ); - - const productsPromise = getProducts({}).then((products) => + // @ToDo: Get categories and get cms pages + const productsPromise = getProductSeoUrls().then((products) => products.map((product) => ({ - url: `${baseUrl}/product/${product.handle}`, + url: `${baseUrl}/product/${product.path}`, lastModified: product.updatedAt })) ); - const pagesPromise = getPages().then((pages) => - pages.map((page) => ({ - url: `${baseUrl}/${page.handle}`, - lastModified: page.updatedAt - })) - ); - const fetchedRoutes = ( - await Promise.all([collectionsPromise, productsPromise, pagesPromise]) + await Promise.all([productsPromise]) ).flat(); return [...routesMap, ...fetchedRoutes]; diff --git a/lib/shopware/api-extended.ts b/lib/shopware/api-extended.ts index b912ced0c..745325bd5 100644 --- a/lib/shopware/api-extended.ts +++ b/lib/shopware/api-extended.ts @@ -1,17 +1,73 @@ import { operations, operationPaths, components } from '@shopware/api-client/api-types'; +type schemas = components['schemas']; type operationsWithoutOriginal = Omit< operations, - 'readProduct' | 'searchPage' | 'readProductListing' + | 'readCategory' + | 'readCategoryList' + | 'readNavigation' + | 'readProduct' + | 'readProductCrossSellings' + | 'readProductListing' + | 'searchPage' >; -export type extendedPaths = 'readProduct post /product' | operationPaths; +export type extendedPaths = + | 'readCategory post /category/{navigationId}?slots' + | 'readCategoryList post /category' + | 'readNavigation post /navigation/{activeId}/{rootId} sw-include-seo-urls' + | 'readProduct post /product' + | 'readProductCrossSellings post /product/{productId}/cross-selling' + | 'readProductListing post /product-listing/{categoryId}' + | 'searchPage post /search' + | operationPaths; export type extendedOperations = operationsWithoutOriginal & { + readCategory: extendedReadCategory; + readCategoryList: extendedReadCategoryList; + readNavigation: extendedReadNavigation; readProduct: extendedReadProduct; - searchPage: extendedSearchPage; + readProductCrossSellings: extendedReadProductCrossSellings; readProductListing: extendedReadProductListing; + searchPage: extendedSearchPage; }; -export type ExtendedCriteria = Omit & { +export type ExtendedCmsBlock = Omit & { + slots?: schemas['CmsSlot'][]; +}; +export type ExtendedCmsSection = Omit & { + blocks?: ExtendedCmsBlock[]; +}; +export type ExtendedCmsPage = Omit & { + sections?: ExtendedCmsSection[]; +}; + +export type ExtendedProduct = Omit< + schemas['Product'], + 'children' | 'seoUrls' | 'options' | 'media' +> & { + children?: ExtendedProduct[]; + seoUrls?: schemas['SeoUrl'][]; + options?: schemas['PropertyGroupOption'][]; + media?: schemas['ProductMedia'][]; +}; + +export type ExtendedProductListingResult = Omit & { + elements?: ExtendedProduct[]; +}; + +export type ExtendedCrossSellingElementCollection = Omit< + schemas['CrossSellingElementCollection'], + 'products' +> & { + products?: ExtendedProduct[]; +}; + +export type ExtendedCategory = Omit & { + children?: ExtendedCategory[]; + seoUrls?: schemas['SeoUrl'][]; + cmsPage?: ExtendedCmsPage; +}; + +export type ExtendedCriteria = Omit & { filter?: { field: string; type: string; @@ -30,8 +86,25 @@ type extendedReadProduct = { 200: { content: { 'application/json': { - elements?: components['schemas']['Product'][]; - } & components['schemas']['EntitySearchResult']; + elements?: ExtendedProduct[]; + } & schemas['EntitySearchResult']; + }; + }; + }; +}; + +type extendedReadProductCrossSellings = { + parameters: { + path: { + /** Product ID */ + productId: string; + }; + }; + responses: { + /** Found cross sellings */ + 200: { + content: { + 'application/json': ExtendedCrossSellingElementCollection }; }; }; @@ -106,14 +179,93 @@ type extendedSearchPage = { /** Using the search parameter, the server performs a text search on all records based on their data model and weighting as defined in the entity definition using the SearchRanking flag. */ search: string; } & ExtendedProductCriteria & - components['schemas']['ProductListingFlags']; + schemas['ProductListingFlags']; }; }; responses: { /** Returns a product listing containing all products and additional fields to display a listing. */ 200: { content: { - 'application/json': components['schemas']['ProductListingResult']; + 'application/json': ExtendedProductListingResult; + }; + }; + }; +}; + +type extendedReadNavigation = { + parameters: { + header?: { + /** Instructs Shopware to try and resolve SEO URLs for the given navigation item */ + 'sw-include-seo-urls'?: boolean; + }; + path: { + /** Identifier of the active category in the navigation tree (if not used, just set to the same as rootId). */ + activeId: string; + /** Identifier of the root category for your desired navigation tree. You can use it to fetch sub-trees of your navigation tree. */ + rootId: string; + }; + }; + requestBody: { + content: { + 'application/json': ExtendedCriteria & { + /** Return the categories as a tree or as a flat list. */ + buildTree?: unknown; + /** Determines the depth of fetched navigation levels. */ + depth?: unknown; + }; + }; + }; + responses: { + /** All available navigations */ + 200: { + content: { + 'application/json': ExtendedCategory[]; + }; + }; + }; +}; + +type extendedReadCategory = { + parameters: { + query?: { + /** Resolves only the given slot identifiers. The identifiers have to be seperated by a '|' character */ + slots?: string; + }; + path: { + /** Identifier of the category to be fetched */ + navigationId: string; + }; + }; + requestBody?: { + content: { + 'application/json': ExtendedCriteria & + Omit & + ExtendedCriteria; + }; + }; + responses: { + /** The loaded category with cms page */ + 200: { + content: { + 'application/json': ExtendedCategory; + }; + }; + }; +}; + +type extendedReadCategoryList = { + requestBody?: { + content: { + 'application/json': ExtendedCriteria; + }; + }; + responses: { + /** Entity search result containing categories. */ + 200: { + content: { + 'application/json': { + elements?: ExtendedCategory[]; + } & components['schemas']['EntitySearchResult']; }; }; }; @@ -128,14 +280,14 @@ type extendedReadProductListing = { }; requestBody?: { content: { - 'application/json': ExtendedProductCriteria & components['schemas']['ProductListingFlags']; + 'application/json': ExtendedProductCriteria & schemas['ProductListingFlags']; }; }; responses: { /** Returns a product listing containing all products and additional fields to display a listing. */ 200: { content: { - 'application/json': components['schemas']['ProductListingResult']; + 'application/json': ExtendedProductListingResult; }; }; }; diff --git a/lib/shopware/api.ts b/lib/shopware/api.ts index f3bbb2ca6..6cbd1bbf8 100644 --- a/lib/shopware/api.ts +++ b/lib/shopware/api.ts @@ -1,10 +1,17 @@ import { createAPIClient, RequestReturnType } from '@shopware/api-client'; import { operations } from '@shopware/api-client/api-types'; -import { extendedPaths, extendedOperations } from './api-extended'; import { - ApiSchemas, + ExtendedCategory, + ExtendedCriteria, + ExtendedCrossSellingElementCollection, + ExtendedProductListingResult, + extendedOperations, + extendedPaths +} from './api-extended'; +import { CategoryListingResultSW, ProductListingCriteria, + RouteNames, SeoURLResultSW, StoreNavigationTypeSW } from './types'; @@ -27,7 +34,7 @@ export type ApiReturnType = RequestRetu export async function requestNavigation( type: StoreNavigationTypeSW, depth: number -): Promise { +): Promise { return await apiInstance.invoke( 'readNavigation post /navigation/{activeId}/{rootId} sw-include-seo-urls', { @@ -41,7 +48,7 @@ export async function requestNavigation( export async function requestCategory( categoryId: string, criteria?: Partial -): Promise { +): Promise { return await apiInstance.invoke('readCategory post /category/{navigationId}?slots', { navigationId: categoryId, criteria @@ -49,21 +56,21 @@ export async function requestCategory( } export async function requestCategoryList( - criteria: Partial + criteria: Partial ): Promise { return await apiInstance.invoke('readCategoryList post /category', criteria); } export async function requestProductsCollection( criteria: Partial -): Promise { +): Promise { return await apiInstance.invoke('readProduct post /product', criteria); } export async function requestCategoryProductsCollection( categoryId: string, criteria: Partial -): Promise { +): Promise { return await apiInstance.invoke('readProductListing post /product-listing/{categoryId}', { ...criteria, categoryId: categoryId @@ -72,17 +79,35 @@ export async function requestCategoryProductsCollection( export async function requestSearchCollectionProducts( criteria?: Partial -): Promise { +): Promise { return await apiInstance.invoke('searchPage post /search', { search: encodeURIComponent(criteria?.query || ''), ...criteria }); } -export async function requestSeoUrl(handle: string): Promise { +export async function requestSeoUrls(routeName: RouteNames, page: number = 1, limit: number = 100) { return await apiInstance.invoke('readSeoUrl post /seo-url', { - page: 1, - limit: 1, + page: page, + limit: limit, + filter: [ + { + type: 'equals', + field: 'routeName', + value: routeName + } + ] + }); +} + +export async function requestSeoUrl( + handle: string, + page: number = 1, + limit: number = 1 +): Promise { + return await apiInstance.invoke('readSeoUrl post /seo-url', { + page: page, + limit: limit, filter: [ { type: 'multi', @@ -108,7 +133,7 @@ export async function requestSeoUrl(handle: string): Promise { export async function requestCrossSell( productId: string, criteria?: Partial -): Promise { +): Promise { return await apiInstance.invoke( 'readProductCrossSellings post /product/{productId}/cross-selling', { diff --git a/lib/shopware/index.ts b/lib/shopware/index.ts index 4901c21cf..a6f6d5293 100644 --- a/lib/shopware/index.ts +++ b/lib/shopware/index.ts @@ -24,7 +24,8 @@ import { requestNavigation, requestProductsCollection, requestSearchCollectionProducts, - requestSeoUrl + requestSeoUrl, + requestSeoUrls } from './api'; import { transformCollection, @@ -35,6 +36,7 @@ import { transformProducts, transformStaticCollection } from './transform'; +import { ExtendedCategory, ExtendedProduct, ExtendedProductListingResult } from './api-extended'; export async function getMenu(params?: { type?: StoreNavigationTypeSW; @@ -66,11 +68,9 @@ export async function getFirstSeoUrlElement( } } -export async function getFirstProduct( - productId: string -): Promise { +export async function getFirstProduct(productId: string): Promise { const productCriteria = getDefaultProductCriteria(productId); - const res: ApiSchemas['ProductListingResult'] = await requestProductsCollection(productCriteria); + const res: ExtendedProductListingResult = await requestProductsCollection(productCriteria); if (res.elements && res.elements.length > 0 && res.elements[0]) { return res.elements[0]; } @@ -142,7 +142,7 @@ export async function getCollectionProducts(params?: { export async function getCategory( seoUrl: ApiSchemas['SeoUrl'], cms: boolean = false -): Promise { +): Promise { const criteria = cms ? getDefaultCategoryWithCmsCriteria() : getDefaultCategoryCriteria(); const resCategory = await requestCategory(seoUrl.foreignKey, criteria); @@ -165,8 +165,21 @@ export async function getCollection(handle: string | []) { } } +export async function getProductSeoUrls() { + const productSeoUrls: { path: string; updatedAt: string }[] = []; + const res = await requestSeoUrls('frontend.detail.page'); + + if (res.elements && res.elements.length > 0) { + res.elements.map((item) => + productSeoUrls.push({ path: item.seoPathInfo, updatedAt: item.updatedAt ?? item.createdAt }) + ); + } + + return productSeoUrls; +} + export async function getProduct(handle: string | []): Promise { - let productSW: ApiSchemas['Product'] | undefined; + let productSW: ExtendedProduct | undefined; let productId: string | undefined; const productHandle = transformHandle(handle); @@ -189,11 +202,12 @@ export async function getProduct(handle: string | []): Promise { - let products: ApiSchemas['ProductListingResult'] = {}; + let products: ExtendedProductListingResult = {}; const res = await requestCrossSell(productId, getDefaultCrossSellingCriteria()); // @ToDo: Make this more dynamic to merge multiple Cross-Sellings, at the moment we only get the first one if (res && res[0] && res[0].products) { + // @ts-ignore (@ToDo: fix this wrong type ...) products.elements = res[0].products; } diff --git a/lib/shopware/transform.ts b/lib/shopware/transform.ts index 9bff11aad..e67906678 100644 --- a/lib/shopware/transform.ts +++ b/lib/shopware/transform.ts @@ -8,9 +8,15 @@ import { ProductOption, ProductVariant } from './types'; +import { + ExtendedCategory, + ExtendedCmsPage, + ExtendedProduct, + ExtendedProductListingResult +} from './api-extended'; import { ListItem } from 'components/layout/search/filter'; -export function transformMenu(res: ApiSchemas['NavigationRouteResponse'], type: string) { +export function transformMenu(res: ExtendedCategory[], type: string) { let menu: Menu[] = []; res.map((item) => menu.push(transformMenuItem(item, type))); @@ -18,7 +24,7 @@ export function transformMenu(res: ApiSchemas['NavigationRouteResponse'], type: return menu; } -function transformMenuItem(item: ApiSchemas['Category'], type: string): Menu { +function transformMenuItem(item: ExtendedCategory, type: string): Menu { // @ToDo: currently only footer-navigation is used for cms pages, this need to be more dynamic (shoud depending on the item) return { id: item.id ?? '', @@ -36,11 +42,11 @@ function transformMenuItem(item: ApiSchemas['Category'], type: string): Menu { export function transformPage( seoUrlElement: ApiSchemas['SeoUrl'], - category: ApiSchemas['Category'] + category: ExtendedCategory ): Page { let plainHtmlContent; if (category.cmsPage) { - const cmsPage: ApiSchemas['CmsPage'] = category.cmsPage; + const cmsPage: ExtendedCmsPage = category.cmsPage; plainHtmlContent = transformToPlainHtmlContent(cmsPage); } @@ -62,7 +68,7 @@ export function transformPage( }; } -export function transformToPlainHtmlContent(cmsPage: ApiSchemas['CmsPage']): string { +export function transformToPlainHtmlContent(cmsPage: ExtendedCmsPage): string { let plainHtmlContent = ''; cmsPage.sections?.map((section) => { @@ -84,7 +90,7 @@ export function transformToPlainHtmlContent(cmsPage: ApiSchemas['CmsPage']): str export function transformCollection( seoUrlElement: ApiSchemas['SeoUrl'], - resCategory: ApiSchemas['Category'] + resCategory: ExtendedCategory ) { return { handle: seoUrlElement.seoPathInfo, @@ -137,7 +143,7 @@ export function transformStaticCollectionToList(collection: Collection[]): ListI return listItem; } -export function transformProducts(res: ApiSchemas['ProductListingResult']): Product[] { +export function transformProducts(res: ExtendedProductListingResult): Product[] { let products: Product[] = []; if (res.elements && res.elements.length > 0) { @@ -147,7 +153,7 @@ export function transformProducts(res: ApiSchemas['ProductListingResult']): Prod return products; } -export function transformProduct(item: ApiSchemas['Product']): Product { +export function transformProduct(item: ExtendedProduct): Product { const productOptions = transformOptions(item); const productVariants = transformVariants(item); @@ -202,7 +208,7 @@ export function transformProduct(item: ApiSchemas['Product']): Product { }; } -function transformOptions(parent: ApiSchemas['Product']): ProductOption[] { +function transformOptions(parent: ExtendedProduct): ProductOption[] { // we only transform options for parents with children, ignore child products with options let productOptions: ProductOption[] = []; if (parent.children && parent.parentId === null && parent.children.length > 0) { @@ -231,7 +237,7 @@ function transformOptions(parent: ApiSchemas['Product']): ProductOption[] { return productOptions; } -function transformVariants(parent: ApiSchemas['Product']): ProductVariant[] { +function transformVariants(parent: ExtendedProduct): ProductVariant[] { let productVariants: ProductVariant[] = []; if (parent.children && parent.parentId === null && parent.children.length > 0) { parent.children.map((child) => { diff --git a/lib/shopware/types.ts b/lib/shopware/types.ts index 4a6f9d875..405ed0b42 100644 --- a/lib/shopware/types.ts +++ b/lib/shopware/types.ts @@ -1,5 +1,5 @@ import { components } from '@shopware/api-client/api-types'; -import { ExtendedCriteria } from './api-extended'; +import { ExtendedCriteria, ExtendedCategory, ExtendedCmsPage } from './api-extended'; /** Shopware Types */ @@ -10,10 +10,11 @@ export type ProductListingCriteria = { query: string; } & Omit & ExtendedCriteria; +export type RouteNames = 'frontend.navigation.page' | 'frontend.detail.page' | 'frontend.account.customer-group-registration.page' | 'frontend.landing.page' /** Return Types */ export type CategoryListingResultSW = { - elements?: ApiSchemas['Category'][]; + elements?: ExtendedCategory[]; } & ApiSchemas['EntitySearchResult']; export type SeoURLResultSW = { elements?: ApiSchemas['SeoUrl'][]; @@ -33,7 +34,7 @@ export type Page = { updatedAt: string; routeName?: string; foreignKey?: string; - originalCmsPage?: ApiSchemas['CmsPage']; + originalCmsPage?: ExtendedCmsPage; }; export type ProductOption = {