diff --git a/docs/content/guides/7.multistore/3.patterns/5.subpath/1.subpath.md b/docs/content/guides/7.multistore/3.patterns/5.subpath/1.subpath.md index 90e4fedbf8..848b255833 100644 --- a/docs/content/guides/7.multistore/3.patterns/5.subpath/1.subpath.md +++ b/docs/content/guides/7.multistore/3.patterns/5.subpath/1.subpath.md @@ -76,7 +76,7 @@ export const configSwitcherExtension = createConfigSwitcherExtension) { - const pathname = - typeof window !== 'undefined' ? window.location.pathname : getPathnameFromRequestHeaders(baseHeaders); - - // Determine the config ID based on URL path - return pathname?.includes('/electronics') ? 'electronics' : 'apparel'; -} +// apps/storefront-unified-nextjs/sdk/modules/utils.ts +import { defineGetConfigSwitcherHeader } from '@vue-storefront/next'; -export function getSdkConfig() { - return defineSdkConfig(({ buildModule, config, getRequestHeaders, middlewareModule }) => ({ - unified: buildModule(middlewareModule, { - apiUrl: `${config.apiUrl}/commerce/unified`, - defaultRequestConfig: { - getConfigSwitcherHeader, - headers: getRequestHeaders, - }, - // ... other config - }), - // ... other modules - })); -} +export const defaultConfigId = 'apparel'; +export const configIds = ['apparel', 'electronics']; + +export const getConfigSwitcherHeader = defineGetConfigSwitcherHeader( + ({ pathname }) => configIds.find((configId) => pathname.startsWith(`/${configId}`)) ?? defaultConfigId, +); ``` #tab-2 In your Nuxt SDK configuration, define a reusable `getConfigSwitcherHeader` function: ```ts -// apps/storefront-unified-nuxt/sdk.config.ts -import type { CommerceEndpoints, UnifiedCmsEndpoints, UnifiedEndpoints } from 'storefront-middleware/types'; - -export default defineSdkConfig(({ buildModule, config, getRequestHeaders, middlewareModule }) => { - const route = useRoute(); - - // Create a reusable function for determining the config ID - function getConfigSwitcherHeader() { - return route.path.includes('/electronics') ? 'electronics' : 'apparel'; +// apps/storefront-unified-nuxt/sdk-modules/utils.ts +export const defaultConfigId = 'apparel'; +export const configIds = ['apparel', 'electronics']; + +export const getConfigSwitcherHeader = defineGetConfigSwitcherHeader( + ({ pathname }) => configIds.find((configId) => pathname.startsWith(`/${configId}`)) ?? defaultConfigId, +); +``` +:: + +## Updating the Routing Strategy + +To enable path-based routing, you need to restructure your application. Now URL path should include a dynamic `[configId]` parameter in the route. Usually you want to decide on one of two strategies: + +- having `[configId]` as first parameter - for instance `/electronics/de/category`, +- having `[configId]` as second parameter - for instance `/de/electronics/category`, + +where `electronics` is the `configId`. + +### `[configId]` as First Path Parameter + +This approach is not recommended, as it impacts the `i18n` packages and requires additional configuration changes to maintain proper internationalization. Consider using the second path parameter approach unless you have specific requirements that necessitate this structure. + +::tabs{:titles='["Next.js", "Nuxt"]'} + +#tab-1 +For Next.js apps using the App Router, update your file structure: + +```bash +apps/storefront-unified-nextjs/app/ +├── [configId]/ # Store identifier parameter +│ ├── [locale]/ # Language parameter +│ │ ├── (default)/ # Routes for all stores +│ │ │ ├── page.tsx # Home page +│ │ │ ├── cart/ # Cart pages +│ │ │ ├── checkout/ # Checkout pages +│ │ │ └── products/ # Product pages +│ │ ├── (electronics)/ # Electronics-specific routes +│ │ │ └── some-page/ # Page name +│ │ ├── (apparel)/ # Apparel-specific routes +│ │ │ └── another-page/ # Apparel-specific home +│ │ └── layout.tsx # Apply store-specific class +│ ├── favicon.ico +``` + +You can do it by running + +```bash +mkdir app/\[configId\]/ +mv app/\[locale\] app/\[configId\]/ +``` + +Then update import paths: in your IDE search for `@/app/[locale]` and replace it by `@/app/[configId]`. + +Next update `middleware.ts` to support `configId` as a first parameter. + +```ts +// ...remaining code + +// Helper function to extract configId and clean pathname from URL +function getPathnamePartsWithoutConfigIdAndLocale(pathname: string): { cleanPathname: string; configId: string } { + const segments = pathname.split('/').filter(Boolean); + let configId: string = defaultConfigId; + + if (segments.length > 0) { + const firstSegment = segments[0]; + const knownNonConfigSegments = [...locales, 'api', '_next', 'images', 'icons', 'favicon.ico']; + + if (!knownNonConfigSegments.includes(firstSegment) && configIds.includes(firstSegment)) { + configId = firstSegment; + segments.shift(); + } + } + + // Remove locale segment if present + if (segments.length > 0 && locales.includes(segments[0])) { + segments.shift(); } - + return { - // Unified commerce module - unified: buildModule(middlewareModule, { - apiUrl: `${config.apiUrl}/commerce/unified`, - defaultRequestConfig: { - getConfigSwitcherHeader, - headers: getRequestHeaders(), - }, - // ... other config - }), - // ... other modules + cleanPathname: `/${segments.join('/')}`, + configId, + }; +} + +export default createAlokaiMiddleware(async (request: NextRequest) => { + const { hash, origin, pathname, search } = request.nextUrl; + + // 1. Initial redirect - Ensures users always start with a valid store configuration + if (pathname === '/') { + return NextResponse.redirect(new URL(`/${defaultConfigId}${search}${hash}`, origin)); + } + const rootLocalePathRegex = new RegExp(`^\/(${locales.join('|')})\/?$`); + const rootLocaleMatch = pathname.match(rootLocalePathRegex); + if (rootLocaleMatch) { + const detectedLocaleSegment = rootLocaleMatch[1]; + return NextResponse.redirect( + new URL(`/${defaultConfigId}/${detectedLocaleSegment}${search}${hash}`.replace(/\/\/+/g, '/'), origin), + ); + } + + // 2. Extract configId and clean pathname - Separates store and locale information from the URL + const { cleanPathname, configId } = getPathnamePartsWithoutConfigIdAndLocale(pathname); + const pathForI18nProcessing = cleanPathname; + + // 3. i18n preparation - Prepares the request for internationalization handling + const i18nProcessedUrl = new URL(pathForI18nProcessing, origin); + i18nProcessedUrl.search = search; + i18nProcessedUrl.hash = hash; + const requestForI18n = new NextRequest(i18nProcessedUrl.toString(), request); + + // 4. i18n processing - Handles language-specific routing logic + let i18nResponse = i18nMiddleware(requestForI18n); + const detectedLocaleForResponse = + i18nResponse.headers.get('x-middleware-request-x-next-intl-locale') || defaultLocale; + + let finalPathname: string; + let responseToProcess: NextResponse; + + if (i18nResponse.headers.has('location')) { + // 5a. i18n redirect handling - Manages language-based redirects while preserving store context + const i18nRedirectLocation = new URL(i18nResponse.headers.get('location')!, origin); + finalPathname = i18nRedirectLocation.pathname; + // Prepend configId if it was extracted + if (configId) { + finalPathname = `/${configId}${finalPathname}`.replace(/\/\/+/g, '/'); + } + const finalRedirectUrl = new URL(finalPathname, origin); + finalRedirectUrl.search = i18nRedirectLocation.search; + finalRedirectUrl.hash = i18nRedirectLocation.hash; + responseToProcess = NextResponse.redirect(finalRedirectUrl.toString(), i18nResponse.status); + } else { + // 5b. Standard request handling - Processes normal page requests + finalPathname = pathForI18nProcessing; + + // Construct the full path for internal rewrite including configId and locale + let rewritePath = `/${detectedLocaleForResponse}${finalPathname.startsWith('/' + detectedLocaleForResponse) ? finalPathname.substring(detectedLocaleForResponse.length + 1) : finalPathname}`; + if (configId) { + rewritePath = `/${configId}${rewritePath}`.replace(/\/\/+/g, '/'); + } + rewritePath = rewritePath.replace(/\/\/+/g, '/'); + const internalRewriteUrl = new URL(rewritePath, origin); + internalRewriteUrl.search = search; + internalRewriteUrl.hash = hash; + responseToProcess = NextResponse.rewrite(internalRewriteUrl, { headers: i18nResponse.headers }); + } + + // 6. Authentication handling - Ensures auth redirects maintain store and language context + const authRedirectTarget = await getAuthRedirectPath(request, { locale: detectedLocaleForResponse }); + if (authRedirectTarget) { + let finalAuthRedirectPath = `/${detectedLocaleForResponse}${authRedirectTarget}`.replace(/\/\/+/g, '/'); + if (configId) { + // Ensure configId is part of auth redirects too + finalAuthRedirectPath = `/${configId}${finalAuthRedirectPath}`.replace(/\/\/+/g, '/'); + } + responseToProcess = NextResponse.redirect(new URL(finalAuthRedirectPath, request.nextUrl.origin)); + } + + // 7. Cache control - Applies appropriate caching headers to the response + const defaultCacheControl = env('NEXT_DEFAULT_HTML_CACHE_CONTROL'); + responseToProcess = cacheControlMiddleware(request, responseToProcess, defaultCacheControl); + + return responseToProcess; +}); +``` + +Then rename `apps/storefront-unified-nextjs/config/navigation.ts` -> `apps/storefront-unified-nextjs/config/navigation.tsx` + +And update its content. We need to update implementation of the components exported from `next-intl/navigation` as we no longer use the `locale` as a first path parameter: + +```tsx +'use client'; + +// eslint-disable-next-line no-restricted-imports +import NextLink from 'next/link'; +import { useLocale } from 'next-intl'; +import { createLocalizedPathnamesNavigation } from 'next-intl/navigation'; +import type { Pathnames } from 'next-intl/routing'; +import type { ComponentProps } from 'react'; + +import { defaultLocale, localePrefix, locales } from '@/i18n'; +import { configIds, defaultConfigId, getConfigSwitcherHeader } from '@/sdk/modules/utils'; + +export const pathnames = { + '/': '/', + '/cart': '/cart', + '/category': '/category', + '/category/[[...slugs]]': '/category/[[...slugs]]', + '/login': '/login', + '/my-account': '/my-account', + '/my-account/my-orders': '/my-account/my-orders', + '/my-account/my-orders/[id]': '/my-account/my-orders/[id]', + '/my-account/personal-data': '/my-account/personal-data', + '/my-account/returns': '/my-account/returns', + '/my-account/shipping-details': '/my-account/shipping-details', + '/order/failed': '/order/failed', + '/order/success': '/order/success', + '/product/[slug]/[id]': '/product/[slug]/[id]', + '/register': '/register', + '/search': '/search', +} satisfies Pathnames; + +export const { + Link: LocalizedLink, + redirect: localizedRedirect, + usePathname, + useRouter: useLocalizedRouter, +} = createLocalizedPathnamesNavigation({ + localePrefix, + locales, + pathnames: pathnames as Record<{} & string, string> & typeof pathnames, +}); + +export type LinkHref = ComponentProps['href']; + +// eslint-disable-next-line react/function-component-definition +export const Link: typeof LocalizedLink = (props) => { + const { href, ...rest } = props; + const locale = useLocale(); + const pathname = usePathname() as string; + const configId = getConfigSwitcherHeader({ headers: {}, pathname, searchParams: {} }); + + return ; +}; + +interface ResolveHrefContext { + configId: string; + locale: string; +} + +function resolveHref(href: LinkHref, context: ResolveHrefContext): string { + let resolvedPathname: string; + + if (typeof href === 'string') { + resolvedPathname = href; + } else { + const { params, pathname, query } = href as { + params: Record; + pathname: string; + query: Record; + }; + + // Replace dynamic segments with actual values + resolvedPathname = pathname + .replace(/\[\[?\.{3}(\w+)\]?\]/g, (_, key) => { + // Handle catch-all routes + return Array.isArray(params[key]) ? params[key].join('/') : params[key] || ''; + }) + .replace(/\[(\w+)\]/g, (_, key) => { + // Handle regular dynamic segments + return params[key] || ''; + }); + + // Add query parameters if they exist + const queryString = Object.entries(query || {}) + .map(([key, value]) => `${key}=${value}`) + .join('&'); + + if (queryString) { + resolvedPathname += `?${queryString}`; + } + } + + const configIdPart = context.configId === defaultConfigId ? '' : `/${context.configId}`; + const localePart = context.locale === defaultLocale ? '' : `/${context.locale}`; + + return [configIdPart, localePart, getPathnameWithoutConfigIdAndLocale(resolvedPathname)].join(''); +} + +function getPathnameWithoutConfigIdAndLocale(pathname: string) { + const resultSegments = pathname.split('/').filter(Boolean); + if (configIds.includes(resultSegments[0])) { + resultSegments.shift(); + } + if (locales.includes(resultSegments[0])) { + resultSegments.shift(); + } + return `/${resultSegments.join('/')}`; +} + +export const useRouter: typeof useLocalizedRouter = (...args) => { + const router = useLocalizedRouter(...args); + const pathname = usePathname() as string; + const configId = getConfigSwitcherHeader({ headers: {}, pathname, searchParams: {} }); + + return { + ...router, + push: (href, { locale, ...options } = {}) => { + const resolvedHref = resolveHref(href, { + configId: configId as string, + locale: locale ?? defaultLocale, + }); + router.push(resolvedHref, options); + }, + replace: (href, { locale, ...options } = {}) => { + const resolvedHref = resolveHref(href, { + configId: configId as string, + locale: locale ?? defaultLocale, + }); + router.replace(resolvedHref, options); + }, }; +}; +``` + +#tab-2 +First you need to create a `apps/storefront-unified-nuxt/app/router.options.ts` file to [modify generated routes](https://nuxt.com/docs/guide/recipes/custom-routing#router-options): + +```ts +import type { RouterConfig } from '@nuxt/schema'; +import type { RouteRecordRaw } from 'vue-router'; + +export default { + routes: (_routes) => { + const finalRoutes: RouteRecordRaw[] = []; + _routes.forEach((route) => { + finalRoutes.push(route); + finalRoutes.push({ + ...route, + name: `configId-${String(route.name)}`, + path: `/:configId${route.path}`, + }); + }); + + return finalRoutes; + }, +}; +``` +Then you need to update the link component to support the `configId` parameter. Create a `apps/storefront-unified-nuxt/components/BaseLink/BaseLink.vue` file: + +```vue + + + ``` -:: -## Updating the File Structure for Path Routing +To use a new link component. Search for all occurences of `NuxtLinkLocale` and replace it with `BaseLink`. + +:: -To enable path-based routing, you need to restructure your application to include a dynamic `[configId]` parameter in the route. This allows you to access the store identifier directly from the URL. +### `[configId]` as Second Path Parameter (Recommended) ::tabs{:titles='["Next.js", "Nuxt"]'} @@ -184,7 +521,7 @@ For Next.js apps using the App Router, update your file structure: ```bash apps/storefront-unified-nextjs/app/ -├── [locale]/ # Language parameter +├── [locale]/ # Locale parameter │ ├── [configId]/ # Store identifier parameter │ │ ├── (default)/ # Routes for all stores │ │ │ ├── page.tsx # Home page @@ -196,6 +533,7 @@ apps/storefront-unified-nextjs/app/ │ │ ├── (apparel)/ # Apparel-specific routes │ │ │ └── another-page/ # Apparel-specific home │ │ └── layout.tsx # Apply store-specific class +│ ├── favicon.ico ``` #tab-2 @@ -223,7 +561,7 @@ This structure enables routes like: - `/en/electronics/category` - PLP in the electronics store - `/en/apparel/category` - PLP in the apparel store -## Updating Internal Links +### Updating Internal Links ::warning Update Internal Links After setting up the path routing structure, you must update all internal links in your application to include the appropriate configId parameter. This is crucial to maintain proper navigation within each store and prevent users from accidentally switching between stores when clicking links.