From cf8f328e852449c5d94766a7e1d69d5bc8fe2ed0 Mon Sep 17 00:00:00 2001 From: Maciej <7597086+mdanilowicz@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:36:00 +0200 Subject: [PATCH 1/3] feat: init Frontends recipes --- apps/docs/.vitepress/sidebar.ts | 17 ++ apps/docs/src/components/LoginFlowDiagram.vue | 268 ++++++++++++++++++ .../src/frontends-recipes/account/index.md | 11 + .../src/frontends-recipes/account/login.md | 175 ++++++++++++ apps/docs/src/frontends-recipes/index.md | 15 + 5 files changed, 486 insertions(+) create mode 100644 apps/docs/src/components/LoginFlowDiagram.vue create mode 100644 apps/docs/src/frontends-recipes/account/index.md create mode 100644 apps/docs/src/frontends-recipes/account/login.md create mode 100644 apps/docs/src/frontends-recipes/index.md diff --git a/apps/docs/.vitepress/sidebar.ts b/apps/docs/.vitepress/sidebar.ts index c11bb148e..48d0b4798 100644 --- a/apps/docs/.vitepress/sidebar.ts +++ b/apps/docs/.vitepress/sidebar.ts @@ -240,6 +240,23 @@ export const sidebar = [ }, ], }, + { + text: "FRONTENDS RECIPES", + link: "/frontends-recipes/", + items: [ + { + text: "Account", + link: "/frontends-recipes/account/", + collapsed: true, + items: [ + { + text: "Login", + link: "/frontends-recipes/account/login.html", + }, + ], + }, + ], + }, { text: "BEST PRACTICES", link: "/best-practices/", diff --git a/apps/docs/src/components/LoginFlowDiagram.vue b/apps/docs/src/components/LoginFlowDiagram.vue new file mode 100644 index 000000000..9c3a2fa13 --- /dev/null +++ b/apps/docs/src/components/LoginFlowDiagram.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/apps/docs/src/frontends-recipes/account/index.md b/apps/docs/src/frontends-recipes/account/index.md new file mode 100644 index 000000000..dfa745deb --- /dev/null +++ b/apps/docs/src/frontends-recipes/account/index.md @@ -0,0 +1,11 @@ +--- +nav: + title: Account + position: 10 +--- + +# Account + +Recipes for customer session and account flows. + + diff --git a/apps/docs/src/frontends-recipes/account/login.md b/apps/docs/src/frontends-recipes/account/login.md new file mode 100644 index 000000000..98bd8e0a8 --- /dev/null +++ b/apps/docs/src/frontends-recipes/account/login.md @@ -0,0 +1,175 @@ +--- +nav: + position: 10 +recipe: + area: account + status: stable + frameworks: + - vue + composables: + - useUser + - useSessionContext + - useCart + helpers: [] + operations: + - loginCustomer post /account/login + - readContext get /context + - logoutCustomer post /account/logout + schemas: + - Customer + - SalesChannelContext +--- + + + +# Login + +## Goal + +Build a customer login flow and understand what happens after credentials are submitted. The important part is not the form itself, but how Shopware Frontends updates the customer session, context, and cart after the Store API accepts the login. + +## Shopware Flow + +Login is a small form action, but it changes the whole sales channel session. The important part is that `POST /account/login` only authenticates the customer. The UI becomes reliable after the session context and cart are refreshed. + + + +Read the diagram from left to right: + +1. Customer submits the login form. +2. `useUser().login()` sends credentials to `POST /account/login`. +3. The API client keeps using the current `sw-context-token` and reacts to context-token changes from the API response. +4. `useUser` refreshes the sales channel context with `GET /context`. +5. `useUser` refreshes the cart so it matches the customer session. +6. The UI reads `user`, `isLoggedIn`, and cart state from composables instead of keeping its own copy. + +You usually do not need to call `readContext get /context` manually after login, because `useUser` calls `refreshSessionContext()` internally. + +## Request Flow + +| Step | Code | Store API | Type | +|---|---|---|---| +| Submit credentials | `login(credentials)` | `POST /account/login` | `operations["loginCustomer post /account/login"]["body"]` | +| Refresh session context | `refreshSessionContext()` | `GET /context` | `operations["readContext get /context"]["response"]` | +| Logout customer | `logout()` | `POST /account/logout` | `operations["logoutCustomer post /account/logout"]["response"]` | + +## Composables + +- `useUser`: exposes `login`, `logout`, `user`, `isLoggedIn`, and related customer state. +- `useSessionContext`: refreshes the sales channel context after the customer session changes. +- `useCart`: refreshes the cart after login or logout so line items and prices match the current customer context. + +## Types + +Use generated Store API types when you need to type credentials, responses, or lower-level API client calls: + +```ts +import type { Schemas, operations } from "#shopware"; + +type LoginBody = operations["loginCustomer post /account/login"]["body"]; +type LoginResponse = + operations["loginCustomer post /account/login"]["response"]; +type SessionContext = operations["readContext get /context"]["response"]; +type Customer = Schemas["Customer"]; +``` + +## Minimal Vue Example + +```vue + + + +``` + +## State And Session + +The Store API identifies the current sales channel session with the `sw-context-token` header. A successful login can affect the current customer context and the cart associated with that context. + +After `POST /account/login`, `useUser().login()` calls `refreshSessionContext()`. That request uses `GET /context` and updates the reactive session context. The `user` and `isLoggedIn` values then reflect the customer from the refreshed context. + +`useUser().login()` also calls `refreshCart()`. This matters because cart prices, promotions, customer-specific rules, and line items can depend on the logged-in customer context. + +## Edge Cases + +- Invalid credentials cause the API client call to throw. Map this error to the login form instead of assuming the composable stores form errors. +- If the session context is missing or stale, login can fail before customer state is refreshed. +- Customer-specific prices, promotions, or rules may change after login because the cart is refreshed in the new context. +- A successful API response does not mean old local UI state is still valid. Read `user`, `isLoggedIn`, and cart data from the composables after the login promise resolves. + +## Common Mistakes + +- Do not keep a separate local `isLoggedIn` flag. Use `useUser().isLoggedIn`. +- Do not skip the cart refresh after login when implementing a custom flow with `apiClient.invoke` directly. +- Do not assume the old cart totals are still correct after the customer session changes. +- Do not expose raw API error details directly in the UI. + +## Testing Checklist + +- Successful login calls `loginCustomer post /account/login`. +- Successful login refreshes the session context and updates `isLoggedIn`. +- Successful login refreshes the cart. +- Invalid credentials show a form-level error and keep the user logged out. +- Logout calls `logoutCustomer post /account/logout`, refreshes context, and refreshes cart. + +## Related Links + +- [Login form page element](../../getting-started/page-elements/login-form.html) +- [Composables reference](../../packages/composables/) +- [API client package](../../packages/api-client.html) +- [Cart documentation](../../getting-started/e-commerce/cart.html) diff --git a/apps/docs/src/frontends-recipes/index.md b/apps/docs/src/frontends-recipes/index.md new file mode 100644 index 000000000..769f8ad3b --- /dev/null +++ b/apps/docs/src/frontends-recipes/index.md @@ -0,0 +1,15 @@ +--- +nav: + title: Frontends Recipes + position: 115 +--- + +# Frontends Recipes + +Frontends Recipes explain how common Shopware Frontends features work end-to-end. Each recipe connects a UI action with composables, API client calls, Store API endpoints, generated types, session state, and production concerns. + +These pages are written for frontend developers who want to understand the Shopware flow behind real Frontends features. They are not generic Vue tutorials. + +## Account + + From 2cd1672127beb6f85c5b19491648d18101aaf0b3 Mon Sep 17 00:00:00 2001 From: Maciej <7597086+mdanilowicz@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:51:04 +0200 Subject: [PATCH 2/3] feat: add vue flow --- apps/docs/package.json | 1 + .../src/components/LoginVueFlowDiagram.vue | 313 ++++++++++++++++++ .../src/frontends-recipes/account/login.md | 5 + pnpm-lock.yaml | 93 ++++++ 4 files changed, 412 insertions(+) create mode 100644 apps/docs/src/components/LoginVueFlowDiagram.vue diff --git a/apps/docs/package.json b/apps/docs/package.json index 75b70394c..510daa101 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -14,6 +14,7 @@ "@shopware/composables": "workspace:*", "@shopware/helpers": "workspace:*", "@shopware/api-client": "workspace:*", + "@vue-flow/core": "^1.48.2", "flexsearch": "0.8.212", "markdown-it": "14.1.1", "vitepress": "1.6.4", diff --git a/apps/docs/src/components/LoginVueFlowDiagram.vue b/apps/docs/src/components/LoginVueFlowDiagram.vue new file mode 100644 index 000000000..2c99ef591 --- /dev/null +++ b/apps/docs/src/components/LoginVueFlowDiagram.vue @@ -0,0 +1,313 @@ + + + + + + + diff --git a/apps/docs/src/frontends-recipes/account/login.md b/apps/docs/src/frontends-recipes/account/login.md index 98bd8e0a8..7b7f870ac 100644 --- a/apps/docs/src/frontends-recipes/account/login.md +++ b/apps/docs/src/frontends-recipes/account/login.md @@ -22,6 +22,7 @@ recipe: # Login @@ -34,6 +35,10 @@ Build a customer login flow and understand what happens after credentials are su Login is a small form action, but it changes the whole sales channel session. The important part is that `POST /account/login` only authenticates the customer. The UI becomes reliable after the session context and cart are refreshed. + + + + Read the diagram from left to right: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9375cee9..e12560cdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@shopware/helpers': specifier: workspace:* version: link:../../packages/helpers + '@vue-flow/core': + specifier: ^1.48.2 + version: 1.48.2(vue@3.5.34(typescript@5.9.3)) flexsearch: specifier: 0.8.212 version: 0.8.212 @@ -6181,6 +6184,11 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + '@vue-flow/core@1.48.2': + resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==} + peerDependencies: + vue: ^3.3.0 + '@vue-macros/common@3.1.1': resolution: {integrity: sha512-afW2DMjgCBVs33mWRlz7YsGHzoEEupnl0DK5ZTKsgziAlLh5syc5m+GM7eqeYrgiQpwMaVxa1fk73caCvPxyAw==} engines: {node: '>=20.19.0'} @@ -7284,6 +7292,44 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -19839,6 +19885,17 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 + '@vue-flow/core@1.48.2(vue@3.5.34(typescript@5.9.3))': + dependencies: + '@vueuse/core': 10.11.1(vue@3.5.34(typescript@5.9.3)) + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.5.34(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + '@vue-macros/common@3.1.1(vue@3.5.34(typescript@5.9.3))': dependencies: '@vue/compiler-sfc': 3.5.34 @@ -21219,6 +21276,42 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + data-uri-to-buffer@4.0.1: optional: true From e0aa12c83085ad824666476ff7c6134f5c28e7ed Mon Sep 17 00:00:00 2001 From: Maciej <7597086+mdanilowicz@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:59:16 +0200 Subject: [PATCH 3/3] feat: type tooltip --- .../.vitepress/data/login-flow-schema.data.ts | 296 ++++++++++++++++++ apps/docs/src/components/LoginFlowDiagram.vue | 25 +- .../src/components/LoginVueFlowDiagram.vue | 81 ++++- .../docs/src/components/SchemaTypeTooltip.vue | 173 ++++++++++ .../src/frontends-recipes/account/login.md | 17 +- 5 files changed, 576 insertions(+), 16 deletions(-) create mode 100644 apps/docs/.vitepress/data/login-flow-schema.data.ts create mode 100644 apps/docs/src/components/SchemaTypeTooltip.vue diff --git a/apps/docs/.vitepress/data/login-flow-schema.data.ts b/apps/docs/.vitepress/data/login-flow-schema.data.ts new file mode 100644 index 000000000..da2b81b41 --- /dev/null +++ b/apps/docs/.vitepress/data/login-flow-schema.data.ts @@ -0,0 +1,296 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { defineLoader } from "vitepress"; + +type OpenApiSchema = { + paths: Record>; + components: { + schemas: Record; + responses: Record; + }; +}; + +type Operation = { + requestBody?: { + content?: Record; + }; + responses?: Record; +}; + +type ResponseNode = { + description?: string; + headers?: Record; + content?: Record; +}; + +type SchemaNode = { + $ref?: string; + type?: string; + format?: string; + description?: string; + enum?: string[]; + required?: string[]; + properties?: Record; + items?: SchemaNode; + oneOf?: SchemaNode[]; + allOf?: SchemaNode[]; + anyOf?: SchemaNode[]; +}; + +export type SchemaFieldSummary = { + name: string; + type: string; + required: boolean; + description?: string; +}; + +export type SchemaSummary = { + label: string; + source: string; + description?: string; + fields: SchemaFieldSummary[]; + hiddenFields: number; +}; + +export interface Data { + summaries: Record; +} + +declare const data: Data; +export { data }; + +export default defineLoader({ + load(): Data { + const projectRootDir = getProjectRootDir(); + const schemaPath = join( + projectRootDir, + "packages/api-client/api-types/storeApiSchema.json", + ); + const schema = JSON.parse( + readFileSync(schemaPath, "utf8"), + ) as OpenApiSchema; + + const loginOperation = schema.paths["/account/login"].post; + const contextOperation = schema.paths["/context"].get; + const logoutOperation = schema.paths["/account/logout"].post; + + return { + summaries: { + LoginBody: summarizeSchema({ + label: "LoginBody", + source: 'operations["loginCustomer post /account/login"]["body"]', + schema, + node: getJsonSchema(schema, loginOperation.requestBody), + }), + ContextTokenResponse: summarizeResponse({ + label: "ContextTokenResponse", + source: 'operations["loginCustomer post /account/login"]["response"]', + schema, + response: loginOperation.responses?.["200"], + }), + LogoutResponse: summarizeResponse({ + label: "LogoutResponse", + source: + 'operations["logoutCustomer post /account/logout"]["response"]', + schema, + response: logoutOperation.responses?.["200"], + }), + SalesChannelContext: summarizeSchema({ + label: "SalesChannelContext", + source: 'operations["readContext get /context"]["response"]', + schema, + node: getJsonSchema(schema, contextOperation.responses?.["200"]), + }), + Customer: summarizeSchema({ + label: "Customer", + source: 'Schemas["Customer"]', + schema, + node: schema.components.schemas.Customer, + }), + Cart: summarizeSchema({ + label: "Cart", + source: 'Schemas["Cart"]', + schema, + node: schema.components.schemas.Cart, + }), + ApiError: summarizeSchema({ + label: "ApiError", + source: 'components["schemas"]["failure"]', + schema, + node: schema.components.schemas.failure, + }), + }, + }; + }, +}); + +function getProjectRootDir() { + const cwd = process.cwd(); + if (cwd.endsWith("/apps/docs")) { + return join(cwd, "../.."); + } + + return join(cwd, "src/frontends/_source"); +} + +function summarizeResponse({ + label, + source, + schema, + response, +}: { + label: string; + source: string; + schema: OpenApiSchema; + response?: ResponseNode | { $ref: string }; +}): SchemaSummary { + const resolvedResponse = resolveResponse(schema, response); + const bodySummary = summarizeSchema({ + label, + source, + schema, + node: getJsonSchema(schema, resolvedResponse), + }); + + const headerFields = Object.entries(resolvedResponse?.headers ?? {}).map( + ([name, header]) => ({ + name, + type: formatType(schema, header.schema), + required: false, + description: header.description, + }), + ); + + return { + ...bodySummary, + description: resolvedResponse?.description || bodySummary.description, + fields: [...headerFields, ...bodySummary.fields], + }; +} + +function summarizeSchema({ + label, + source, + schema, + node, +}: { + label: string; + source: string; + schema: OpenApiSchema; + node?: SchemaNode; +}): SchemaSummary { + const resolvedNode = resolveSchema(schema, node); + const properties = Object.entries(resolvedNode?.properties ?? {}); + const maxFields = 8; + const required = new Set(resolvedNode?.required ?? []); + + return { + label, + source, + description: resolvedNode?.description, + fields: properties.slice(0, maxFields).map(([name, property]) => ({ + name, + type: formatType(schema, property), + required: required.has(name), + description: property.description, + })), + hiddenFields: Math.max(properties.length - maxFields, 0), + }; +} + +function getJsonSchema( + schema: OpenApiSchema, + node: ResponseNode | { $ref: string } | Operation["requestBody"] | undefined, +) { + if (!node) { + return undefined; + } + + if ("$ref" in node) { + return getJsonSchema(schema, resolveResponse(schema, node)); + } + + if ("content" in node) { + return node.content?.["application/json"]?.schema; + } + + return undefined; +} + +function resolveResponse( + schema: OpenApiSchema, + response: ResponseNode | { $ref: string } | undefined, +): ResponseNode | undefined { + if (!response) { + return undefined; + } + + if ("$ref" in response) { + return resolveRef(schema, response.$ref) as ResponseNode | undefined; + } + + return response; +} + +function resolveSchema(schema: OpenApiSchema, node?: SchemaNode): SchemaNode { + if (!node) { + return {}; + } + + if (node.$ref) { + return resolveSchema(schema, resolveRef(schema, node.$ref) as SchemaNode); + } + + if (node.allOf?.length) { + return resolveSchema(schema, node.allOf[0]); + } + + return node; +} + +function resolveRef(schema: OpenApiSchema, ref: string) { + return ref + .replace("#/", "") + .split("/") + .reduce((current, segment) => { + if (current && typeof current === "object" && segment in current) { + return (current as Record)[segment]; + } + + return undefined; + }, schema); +} + +function formatType(schema: OpenApiSchema, node?: SchemaNode): string { + const resolvedNode = resolveSchema(schema, node); + + if (node?.$ref) { + return node.$ref.split("/").at(-1) ?? "object"; + } + + if (resolvedNode.oneOf?.length) { + return resolvedNode.oneOf + .map((item) => formatType(schema, item)) + .join(" | "); + } + + if (resolvedNode.anyOf?.length) { + return resolvedNode.anyOf + .map((item) => formatType(schema, item)) + .join(" | "); + } + + if (resolvedNode.type === "array") { + return `${formatType(schema, resolvedNode.items)}[]`; + } + + if (resolvedNode.enum?.length) { + return resolvedNode.enum.map((value) => `"${value}"`).join(" | "); + } + + if (resolvedNode.format) { + return `${resolvedNode.type ?? "unknown"}:${resolvedNode.format}`; + } + + return resolvedNode.type ?? "object"; +} diff --git a/apps/docs/src/components/LoginFlowDiagram.vue b/apps/docs/src/components/LoginFlowDiagram.vue index 9c3a2fa13..5de4e1c6c 100644 --- a/apps/docs/src/components/LoginFlowDiagram.vue +++ b/apps/docs/src/components/LoginFlowDiagram.vue @@ -1,5 +1,6 @@