From aacc1b0ce7f1f50da04a0a52ba5e94d0695e3ddc Mon Sep 17 00:00:00 2001 From: Maciej <7597086+mdanilowicz@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:51:11 +0200 Subject: [PATCH 1/3] feat(docs): add registration page and update sidebar and account documentation --- apps/docs/.vitepress/sidebar.ts | 4 + .../src/frontends-recipes/account/index.md | 2 + .../src/frontends-recipes/account/register.md | 493 ++++++++++++++++++ .../src/frontends-recipes/account/wishlist.md | 2 +- 4 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/frontends-recipes/account/register.md diff --git a/apps/docs/.vitepress/sidebar.ts b/apps/docs/.vitepress/sidebar.ts index bba0dd935..aab7806bf 100644 --- a/apps/docs/.vitepress/sidebar.ts +++ b/apps/docs/.vitepress/sidebar.ts @@ -241,6 +241,10 @@ export const sidebar = [ text: "Login", link: "/frontends-recipes/account/login.html", }, + { + text: "Register", + link: "/frontends-recipes/account/register.html", + }, { text: "Wishlist", link: "/frontends-recipes/account/wishlist.html", diff --git a/apps/docs/src/frontends-recipes/account/index.md b/apps/docs/src/frontends-recipes/account/index.md index 1f925fd46..c8b1eeb02 100644 --- a/apps/docs/src/frontends-recipes/account/index.md +++ b/apps/docs/src/frontends-recipes/account/index.md @@ -10,4 +10,6 @@ Recipes for customer session and account flows. + + diff --git a/apps/docs/src/frontends-recipes/account/register.md b/apps/docs/src/frontends-recipes/account/register.md new file mode 100644 index 000000000..5dfa75b6f --- /dev/null +++ b/apps/docs/src/frontends-recipes/account/register.md @@ -0,0 +1,493 @@ +--- +nav: + position: 20 +recipe: + area: account + status: stable + frameworks: + - vue + composables: + - useUser + - useSalutations + - useCountries + - useSessionContext + - useInternationalization + - useShopwareContext + helpers: + - getTranslatedProperty + operations: + - register post /account/register + - registerConfirm post /account/register-confirm + - readContext get /context + - readSalutation post /salutation + - readCountry post /country + - getCustomerGroupRegistrationInfo get /customer-group-registration/config/{customerGroupId} + schemas: + - Customer + - CustomerAddress + - Salutation + - Country + - CountryState + - CustomerGroup +--- + + + +# Register + +## Goal + +Build a customer registration form and understand what the Store API does with the body you send. The important part is not the field list, but that one `register post /account/register` request creates the customer and its billing address at once, and that whether the customer ends up with a session depends on `active` and `doubleOptInRegistration` in the response. + +## Shopware Flow + +Registration is a single write. `register post /account/register` takes `email`, `password`, `firstName`, `lastName`, `acceptedDataProtection`, `storefrontUrl`, and a `billingAddress`, and returns the created `Customer`. An optional `shippingAddress` uses the same `CustomerAddress` shape, and the Store API reuses the customer name for the addresses when you do not send it explicitly. + +`useUser().register()` accepts the body without `storefrontUrl` and fills that field itself from `useInternationalization().getStorefrontUrl()`. Everything else the form needs comes from separate read routes: `readSalutation post /salutation` for `salutationId` and `readCountry post /country` for `billingAddress.countryId` and `billingAddress.countryStateId`. + +The response, not the HTTP status, tells you what happened. When double opt-in registration is enabled in the Shopware Admin, the returned customer carries `doubleOptInRegistration` and no session is created until the customer opens the confirmation email, which points at `registerConfirm post /account/register-confirm`. + +Hover a type chip to inspect fields generated from the current Store API schema. + + + +Read the diagram from left to right: + +1. The customer fills one object shaped like the register body, including the nested `billingAddress`, with salutation and country options loaded from their own routes. +2. `useUser().register()` adds `storefrontUrl` from `getStorefrontUrl()` and posts the body. +3. The Store API creates the customer in the session identified by `sw-context-token` and returns the `Customer`. +4. `register()` writes that customer into the shared customer context only when `active` is true and `doubleOptInRegistration` is false. +5. `register()` awaits `refreshSessionContext()`, so `readContext get /context` decides whether the session now carries a customer. +6. The UI reads `user`, `isLoggedIn`, and `isGuestSession` from composables instead of keeping its own copy. + +You do not call `readContext get /context` yourself after registering, because `register()` awaits `refreshSessionContext()` internally. Unlike `login()`, it does not call `refreshCart()`, so a cart already rendered on the page keeps the totals it had before the customer existed. + +## Request Flow + +| Step | Code | Store API | Type | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Load salutation options | `fetchSalutations()` | `POST /salutation` | | +| Load countries and their states | `fetchCountries()` | `POST /country` | | +| Read a customer group invitation | `apiClient.invoke("getCustomerGroupRegistrationInfo get /customer-group-registration/config/{customerGroupId}")` | `GET /customer-group-registration/config/{customerGroupId}` | | +| Submit the registration | `register(params)` | `POST /account/register` | | +| Read the created customer | `const customer = await register(params)` | `POST /account/register` | | +| Refresh session context | `refreshSessionContext()` | `GET /context` | | +| Confirm a double opt-in link | `apiClient.invoke("registerConfirm post /account/register-confirm")` | `POST /account/register-confirm` | | + +## Composables + +- `useUser`: exposes `register`, which returns the created `Schemas["Customer"]`, plus `user`, `isLoggedIn`, `isCustomerSession`, `isGuestSession`, and `refreshUser` for the state you render afterwards. +- `useSalutations`: exposes `getSalutations` and `fetchSalutations`. It fetches the list on mount when nothing has been provided yet and shares it through the `swSalutations` injection, so several forms on one page issue one request. +- `useCountries`: exposes `getCountries`, `getCountriesOptions`, `getStatesForCountry`, and `fetchCountries`. It merges `associations.states` into your criteria, which is why `getStatesForCountry` can answer from the already loaded countries without a second request. +- `useSessionContext`: exposes `refreshSessionContext` and `userFromContext`. `useUser` keeps its customer ref in sync with `userFromContext`, so the context response is the source of truth for the session. +- `useInternationalization`: exposes `getStorefrontUrl`, which `register()` uses to fill `storefrontUrl`. It returns the configured `devStorefrontUrl` or `window.location.origin`. +- `useShopwareContext`: exposes `apiClient` for `registerConfirm post /account/register-confirm`, which no composable wraps. + +## Types + +Use generated Store API types when you type the registration body, the created customer, or the option lists behind the form: + +
+ + + + + + + + +
+ +```ts +import type { Schemas, operations } from "#shopware"; + +type RegisterBody = operations["register post /account/register"]["body"]; +type RegisterPayload = Omit; +type RegisterConfirmBody = + operations["registerConfirm post /account/register-confirm"]["body"]; +type Customer = Schemas["Customer"]; +type CustomerAddress = Schemas["CustomerAddress"]; +``` + +`RegisterBody` is a union discriminated by `accountType`. The `private` branch keeps `company` and `vatIds` null, while the `business` branch requires `accountType: "business"`, a `company`, and a `vatIds` array with at least one entry. `RegisterPayload` is the shape `useUser().register()` accepts, because the composable fills `storefrontUrl`. + +## Minimal Vue Example + +```vue + + + +``` + +## State And Session + +The Store API identifies the sales channel session with the `sw-context-token` header, and registration runs inside the session the visitor already has. The customer is created against that token, so the guest context the visitor browsed with becomes the customer context without a new token being requested. + +`useUser().register()` assigns the returned customer to the shared customer context only when `active` is true and `doubleOptInRegistration` is false. It then awaits `refreshSessionContext()`, which invokes `readContext get /context` and replaces the reactive session context. `useUser` keeps its customer ref synced with `userFromContext`, so that context response is what `user`, `isLoggedIn`, `isCustomerSession`, and `isGuestSession` are computed from. + +`register()` does not call `refreshCart()`, and `login()` does. If the customer registers while a cart is on screen, call `refreshCart()` from `useCart` yourself, because line item prices, promotions, and rule matches are evaluated against the customer in the context. + +`storefrontUrl` is resolved inside `register()` by `getStorefrontUrl()`, which returns the configured `devStorefrontUrl` or `window.location.origin`. The Store API only accepts a value that matches a configured domain of the sales channel, and it is the base for the confirmation link in the double opt-in email. + +## Edge Cases + +- The generated `billingAddress` type is `Schemas["CustomerAddress"]`, which requires `id` and `customerId`. Send placeholder values as both starter templates do, because you cannot know the ids of an address that does not exist yet. +- A registration with double opt-in enabled resolves successfully while leaving `isLoggedIn` false. Branch on the returned `doubleOptInRegistration` flag instead of assuming a session exists after the promise resolves. +- `refreshSessionContext()` rethrows after logging, so a failing `readContext get /context` rejects the `register()` promise even though the customer was already created. A retry then hits `VIOLATION::CUSTOMER_EMAIL_NOT_UNIQUE`. +- `getStorefrontUrl()` falls back to `window.location.origin`, which is empty during server-side rendering. Submit the form from the client, and set `devStorefrontUrl` when the Shopware domain differs from the origin your app runs on. An origin that is not a configured sales channel domain comes back as a constraint violation pointing at `/storefrontUrl`, which no field in your form owns. +- `guest: true` creates a guest customer that can reuse an email address and needs no password. `isLoggedIn` stays false for that customer because it is computed as `!!id && active && !guest`, while `isGuestSession` becomes true. +- A `requestedGroupId` does not move the customer into that group. It stores the request, and the group has to be available for registration in the current sales channel, which `getCustomerGroupRegistrationInfo get /customer-group-registration/config/{customerGroupId}` reports through `registrationActive` and `registrationOnlyCompanyRegistration`. +- `registerConfirm post /account/register-confirm` needs both `hash` and `em` from the email link, and answers a second click with `CHECKOUT__CUSTOMER_IS_ALREADY_CONFIRMED`. Treat that code as an expected state, not a failure. +- No composable wraps the confirm call, so nothing refreshes the session for you. Call `refreshSessionContext()` after it, otherwise the customer is authenticated in the Store API while your UI still renders as signed out. +- Country states are only present because `useCountries` requests the `states` association. `getStatesForCountry` returns `null` for a country that has none, and a `countryStateId` left over from a previously selected country stays in the form until you clear it. + +## Common Mistakes + +- Do not send a separate address request after registering. The billing address is part of the register body, and `shippingAddress` is a second field in that same body. +- Do not treat a resolved `register()` call as a logged-in customer. Check `doubleOptInRegistration` and `isLoggedIn`. +- Do not set `storefrontUrl` yourself when calling `useUser().register()`. The composable fills it and the parameter type omits it. +- Do not keep a local copy of the registered customer. Read `user`, `isLoggedIn`, and `isGuestSession` from `useUser`. +- Do not leave the cart untouched when registration signs the customer in and prices are already on screen. +- Do not render `error.details.errors` as they arrive. Map `code` to your own copy and use `source.pointer` to place the message next to its field. + +## Testing Checklist + +- Submitting the form calls `register post /account/register` with a `billingAddress` and a `storefrontUrl` the form never set. +- A successful registration without double opt-in refreshes the session context and flips `isLoggedIn` to true. +- A registration with double opt-in renders the confirmation notice and leaves `isLoggedIn` false. +- Registering with an email that already exists shows a mapped message for `VIOLATION::CUSTOMER_EMAIL_NOT_UNIQUE` and keeps the entered values. +- A constraint violation on `billingAddress.zipcode` is rendered next to the postal code field, resolved from `source.pointer`. +- Switching `accountType` to `business` sends `company` and a non-empty `vatIds`. +- Selecting a country with states renders the state select, and selecting one without it does not. +- Opening the confirmation link calls `registerConfirm post /account/register-confirm` with `hash` and `em`, then refreshes the session context. +- A second visit to the confirmation link renders the already-confirmed state instead of an error toast. + +## Related Links + +- [Login recipe](login.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/account/wishlist.md b/apps/docs/src/frontends-recipes/account/wishlist.md index f26b5bd82..fc950bd6c 100644 --- a/apps/docs/src/frontends-recipes/account/wishlist.md +++ b/apps/docs/src/frontends-recipes/account/wishlist.md @@ -1,6 +1,6 @@ --- nav: - position: 20 + position: 30 recipe: area: account status: stable From d34a48b8a21e094a35ffcd99e970c901850f3560 Mon Sep 17 00:00:00 2001 From: Maciej <7597086+mdanilowicz@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:21:39 +0200 Subject: [PATCH 2/3] feat(docs): update account recipes with new positions and cart integration --- .../frontends-recipes/account/addresses.md | 2 +- .../frontends-recipes/account/newsletter.md | 2 +- .../src/frontends-recipes/account/register.md | 480 ++++++++++++------ 3 files changed, 317 insertions(+), 167 deletions(-) diff --git a/apps/docs/src/frontends-recipes/account/addresses.md b/apps/docs/src/frontends-recipes/account/addresses.md index a583898d3..8c3c669d2 100644 --- a/apps/docs/src/frontends-recipes/account/addresses.md +++ b/apps/docs/src/frontends-recipes/account/addresses.md @@ -1,6 +1,6 @@ --- nav: - position: 40 + position: 50 recipe: area: account status: stable diff --git a/apps/docs/src/frontends-recipes/account/newsletter.md b/apps/docs/src/frontends-recipes/account/newsletter.md index 2bb67c1ae..5d8354a73 100644 --- a/apps/docs/src/frontends-recipes/account/newsletter.md +++ b/apps/docs/src/frontends-recipes/account/newsletter.md @@ -1,6 +1,6 @@ --- nav: - position: 30 + position: 40 recipe: area: account status: stable diff --git a/apps/docs/src/frontends-recipes/account/register.md b/apps/docs/src/frontends-recipes/account/register.md index 5dfa75b6f..6839d8411 100644 --- a/apps/docs/src/frontends-recipes/account/register.md +++ b/apps/docs/src/frontends-recipes/account/register.md @@ -11,6 +11,7 @@ recipe: - useSalutations - useCountries - useSessionContext + - useCart - useInternationalization - useShopwareContext helpers: @@ -19,6 +20,7 @@ recipe: - register post /account/register - registerConfirm post /account/register-confirm - readContext get /context + - readCart get /checkout/cart - readSalutation post /salutation - readCountry post /country - getCustomerGroupRegistrationInfo get /customer-group-registration/config/{customerGroupId} @@ -34,6 +36,7 @@ recipe: ``` @@ -448,16 +591,20 @@ The Store API identifies the sales channel session with the `sw-context-token` h `useUser().register()` assigns the returned customer to the shared customer context only when `active` is true and `doubleOptInRegistration` is false. It then awaits `refreshSessionContext()`, which invokes `readContext get /context` and replaces the reactive session context. `useUser` keeps its customer ref synced with `userFromContext`, so that context response is what `user`, `isLoggedIn`, `isCustomerSession`, and `isGuestSession` are computed from. -`register()` does not call `refreshCart()`, and `login()` does. If the customer registers while a cart is on screen, call `refreshCart()` from `useCart` yourself, because line item prices, promotions, and rule matches are evaluated against the customer in the context. +`register()` awaits `refreshCart()` after the context refresh, so line item prices, promotions, and rule matches are re-evaluated against the customer in the context before the promise resolves. `login()` and `logout()` call `refreshCart()` without awaiting it, which is why only `register()` guarantees a settled cart by the time it returns. `storefrontUrl` is resolved inside `register()` by `getStorefrontUrl()`, which returns the configured `devStorefrontUrl` or `window.location.origin`. The Store API only accepts a value that matches a configured domain of the sales channel, and it is the base for the confirmation link in the double opt-in email. ## Edge Cases -- The generated `billingAddress` type is `Schemas["CustomerAddress"]`, which requires `id` and `customerId`. Send placeholder values as both starter templates do, because you cannot know the ids of an address that does not exist yet. +- The generated `billingAddress` type is `Schemas["CustomerAddress"]`, which requires `id` and `customerId`. Send placeholder values as `vue-starter-template` does, because you cannot know the ids of an address that does not exist yet. +- A business account is validated against `billingAddress.company`, not the top-level `company` the generated union declares. Bind your Company input to `form.billingAddress.company` as `vue-starter-template` does; a body that only sets the top-level field comes back with `VIOLATION::IS_BLANK_ERROR` pointing at `/billingAddress/company`. Send the top-level `company` and `vatIds` too, because the union requires them on the business branch. - A registration with double opt-in enabled resolves successfully while leaving `isLoggedIn` false. Branch on the returned `doubleOptInRegistration` flag instead of assuming a session exists after the promise resolves. -- `refreshSessionContext()` rethrows after logging, so a failing `readContext get /context` rejects the `register()` promise even though the customer was already created. A retry then hits `VIOLATION::CUSTOMER_EMAIL_NOT_UNIQUE`. -- `getStorefrontUrl()` falls back to `window.location.origin`, which is empty during server-side rendering. Submit the form from the client, and set `devStorefrontUrl` when the Shopware domain differs from the origin your app runs on. An origin that is not a configured sales channel domain comes back as a constraint violation pointing at `/storefrontUrl`, which no field in your form owns. +- `refreshSessionContext()` rethrows after logging, and the `refreshCart()` that `register()` awaits right after it has no error handling at all, so a failure on either read rejects the `register()` promise even though the customer was already created. A retry then hits `VIOLATION::CUSTOMER_EMAIL_NOT_UNIQUE`, so send the customer to sign-in or password reset instead of inviting another submit. +- `useUser().register()` writes the customer into the shared context before it awaits those two reads, so `isLoggedIn` can already be `true` when the promise rejects. Render your form-level error outside the branch that the form itself lives in, or the message lands in a subtree that has just unmounted. +- A timeout or a dropped connection is not an `ApiClientError`, so it falls through the `instanceof` branch. `apiClientConfig.timeout` has no default, which means an unanswered request leaves a submit button pending indefinitely unless you configure one and branch on `isTimeoutError`. +- The session branches of the form are decided by the customer in the session context, which is anonymous during server-side rendering unless `useUserContextInSSR` is enabled. Put a registration page on a route that opts out of SSR, the way `vue-starter-template` marks `/account/**` with `ssr: false`, or the signed-in branch will hydrate over a server-rendered form. +- `getStorefrontUrl()` falls back to `window.location.origin` whenever `devStorefrontUrl` is unset, and `window` does not exist during server-side rendering, so calling it on the server throws a `ReferenceError` rather than returning an empty string. Submit the form from the client, and set `devStorefrontUrl` when the Shopware domain differs from the origin your app runs on. An origin that is not a configured sales channel domain comes back as a constraint violation pointing at `/storefrontUrl`, which no field in your form owns. - `guest: true` creates a guest customer that can reuse an email address and needs no password. `isLoggedIn` stays false for that customer because it is computed as `!!id && active && !guest`, while `isGuestSession` becomes true. - A `requestedGroupId` does not move the customer into that group. It stores the request, and the group has to be available for registration in the current sales channel, which `getCustomerGroupRegistrationInfo get /customer-group-registration/config/{customerGroupId}` reports through `registrationActive` and `registrationOnlyCompanyRegistration`. - `registerConfirm post /account/register-confirm` needs both `hash` and `em` from the email link, and answers a second click with `CHECKOUT__CUSTOMER_IS_ALREADY_CONFIRMED`. Treat that code as an expected state, not a failure. @@ -470,17 +617,18 @@ The Store API identifies the sales channel session with the `sw-context-token` h - Do not treat a resolved `register()` call as a logged-in customer. Check `doubleOptInRegistration` and `isLoggedIn`. - Do not set `storefrontUrl` yourself when calling `useUser().register()`. The composable fills it and the parameter type omits it. - Do not keep a local copy of the registered customer. Read `user`, `isLoggedIn`, and `isGuestSession` from `useUser`. -- Do not leave the cart untouched when registration signs the customer in and prices are already on screen. +- Do not call `refreshCart()` yourself after `register()`. The composable already awaits one, so a second call is a redundant request. - Do not render `error.details.errors` as they arrive. Map `code` to your own copy and use `source.pointer` to place the message next to its field. ## Testing Checklist - Submitting the form calls `register post /account/register` with a `billingAddress` and a `storefrontUrl` the form never set. -- A successful registration without double opt-in refreshes the session context and flips `isLoggedIn` to true. +- A successful registration without double opt-in refreshes the session context and the cart, and flips `isLoggedIn` to true. - A registration with double opt-in renders the confirmation notice and leaves `isLoggedIn` false. - Registering with an email that already exists shows a mapped message for `VIOLATION::CUSTOMER_EMAIL_NOT_UNIQUE` and keeps the entered values. - A constraint violation on `billingAddress.zipcode` is rendered next to the postal code field, resolved from `source.pointer`. -- Switching `accountType` to `business` sends `company` and a non-empty `vatIds`. +- Switching `accountType` to `business` sends `billingAddress.company`, and the registration is accepted rather than rejected at `/billingAddress/company`. +- A constraint violation on a field the form does not render, such as `/storefrontUrl`, still reaches the customer as a form-level message instead of being dropped. - Selecting a country with states renders the state select, and selecting one without it does not. - Opening the confirmation link calls `registerConfirm post /account/register-confirm` with `hash` and `em`, then refreshes the session context. - A second visit to the confirmation link renders the already-confirmed state instead of an error toast. @@ -488,6 +636,8 @@ The Store API identifies the sales channel session with the `sw-context-token` h ## Related Links - [Login recipe](login.html) +- [Storefront URL](../../guides/storefront-url.html) - [Composables reference](../../packages/composables/) - [API client package](../../packages/api-client.html) - [Cart documentation](../../getting-started/e-commerce/cart.html) +- [devStorefrontUrl troubleshooting](../../resources/troubleshooting.html#what-is-devstorefronturl-and-when-to-use-it) From 8383e3987fccde9a40bd48e797d3d3daf3d839f5 Mon Sep 17 00:00:00 2001 From: Maciej <7597086+mdanilowicz@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:29:17 +0200 Subject: [PATCH 3/3] feat(docs): enhance account registration documentation with new GET routes for salutation and country --- .../src/frontends-recipes/account/register.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/docs/src/frontends-recipes/account/register.md b/apps/docs/src/frontends-recipes/account/register.md index 6839d8411..f62214070 100644 --- a/apps/docs/src/frontends-recipes/account/register.md +++ b/apps/docs/src/frontends-recipes/account/register.md @@ -22,7 +22,9 @@ recipe: - readContext get /context - readCart get /checkout/cart - readSalutation post /salutation + - readSalutationGet get /salutation - readCountry post /country + - readCountryGet get /country - getCustomerGroupRegistrationInfo get /customer-group-registration/config/{customerGroupId} schemas: - Customer @@ -138,8 +140,8 @@ You do not call `readContext get /context` yourself after registering, because ` | Step | Code | Store API | Type | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| Load salutation options | `fetchSalutations()` | `POST /salutation` | | -| Load countries and their states | `fetchCountries()` | `POST /country` | | +| Load salutation options | `fetchSalutations()` | `POST /salutation`, or `GET /salutation` with `cacheableReads` | | +| Load countries and their states | `fetchCountries()` | `POST /country`, or `GET /country` with `cacheableReads` | | | Read a customer group invitation | `apiClient.invoke("getCustomerGroupRegistrationInfo get /customer-group-registration/config/{customerGroupId}")` | `GET /customer-group-registration/config/{customerGroupId}` | | | Submit the registration | `register(params)` | `POST /account/register` | | | Read the created customer | `const customer = await register(params)` | `POST /account/register` | | @@ -147,6 +149,8 @@ You do not call `readContext get /context` yourself after registering, because ` | Refresh the cart | `refreshCart()` | `GET /checkout/cart` | | | Confirm a double opt-in link | `apiClient.invoke("registerConfirm post /account/register-confirm")` | `POST /account/register-confirm` | | +The two option lists are the only rows whose route depends on configuration. `useSalutations` and `useCountries` switch to the cacheable GET variant when `shopware.cacheableReads` is set, and `vue-starter-template` sets it, so the GET routes are what the supported template actually issues. The registration write itself is always a POST. + ## Composables - `useUser`: exposes `register`, which returns the created `Schemas["Customer"]`, plus `user`, `isLoggedIn`, `isCustomerSession`, `isGuestSession`, and `refreshUser` for the state you render afterwards. @@ -185,7 +189,9 @@ type CustomerAddress = Schemas["CustomerAddress"]; `RegisterBody` is a union discriminated by `accountType`. The `private` branch requires `company` and `vatIds` to be omitted or `null`, while the `business` branch requires `accountType: "business"`, a `company`, and a `vatIds` array with at least one entry. -`RegisterPayload` is the shape `useUser().register()` accepts, because the composable fills `storefrontUrl`. Note that `Omit` does not distribute over a union: it flattens `RegisterBody` into a single object whose `accountType`, `company`, and `vatIds` are all optional, so the composable's parameter type does **not** enforce the business branch. Build the branch yourself at submit time, as the example does, rather than trusting the type to catch a business body with no `company`. +`RegisterPayload` is the shape `useUser().register()` accepts, because the composable fills `storefrontUrl`. Note that `Omit` does not distribute over a union: it flattens `RegisterBody` into a single object whose `accountType`, `company`, and `vatIds` are all optional, so the composable's parameter type does **not** enforce the business branch. Build the branch yourself at submit time, as the example does, rather than trusting the type to catch a business body with no `company` — that combination is rejected by the Store API, not by the compiler. + +Two things follow from the flattening. `form.accountType = "business"` and `form.vatIds = [value]` compile directly, so `accountTypeModel` in the example is a `v-model` convenience over an optional field rather than something the type forces on you. And the `private` and `business` shapes the section above describes are only enforced on the wire, so nothing in your editor stops you sending a half-filled business body. ## Minimal Vue Example @@ -597,12 +603,13 @@ The Store API identifies the sales channel session with the `sw-context-token` h ## Edge Cases -- The generated `billingAddress` type is `Schemas["CustomerAddress"]`, which requires `id` and `customerId`. Send placeholder values as `vue-starter-template` does, because you cannot know the ids of an address that does not exist yet. +- The generated `billingAddress` type is `Schemas["CustomerAddress"]`, which marks `id` and `customerId` as required. The Store API does not need them — a register body that omits both is accepted — so the empty strings `vue-starter-template` sends are there to satisfy the generated type, not the route. Omit them if your form state is not typed against `CustomerAddress`. - A business account is validated against `billingAddress.company`, not the top-level `company` the generated union declares. Bind your Company input to `form.billingAddress.company` as `vue-starter-template` does; a body that only sets the top-level field comes back with `VIOLATION::IS_BLANK_ERROR` pointing at `/billingAddress/company`. Send the top-level `company` and `vatIds` too, because the union requires them on the business branch. - A registration with double opt-in enabled resolves successfully while leaving `isLoggedIn` false. Branch on the returned `doubleOptInRegistration` flag instead of assuming a session exists after the promise resolves. - `refreshSessionContext()` rethrows after logging, and the `refreshCart()` that `register()` awaits right after it has no error handling at all, so a failure on either read rejects the `register()` promise even though the customer was already created. A retry then hits `VIOLATION::CUSTOMER_EMAIL_NOT_UNIQUE`, so send the customer to sign-in or password reset instead of inviting another submit. - `useUser().register()` writes the customer into the shared context before it awaits those two reads, so `isLoggedIn` can already be `true` when the promise rejects. Render your form-level error outside the branch that the form itself lives in, or the message lands in a subtree that has just unmounted. - A timeout or a dropped connection is not an `ApiClientError`, so it falls through the `instanceof` branch. `apiClientConfig.timeout` has no default, which means an unanswered request leaves a submit button pending indefinitely unless you configure one and branch on `isTimeoutError`. +- `useSalutations` and `useCountries` load their lists in `onMounted`, and they keep them in a provided ref rather than in Nuxt state, so nothing is serialized into the payload. Both selects therefore render with only their placeholder option during server-side rendering and fill one tick after hydration. Call `fetchSalutations()` and `fetchCountries()` yourself if you need the options present in the server-rendered HTML. - The session branches of the form are decided by the customer in the session context, which is anonymous during server-side rendering unless `useUserContextInSSR` is enabled. Put a registration page on a route that opts out of SSR, the way `vue-starter-template` marks `/account/**` with `ssr: false`, or the signed-in branch will hydrate over a server-rendered form. - `getStorefrontUrl()` falls back to `window.location.origin` whenever `devStorefrontUrl` is unset, and `window` does not exist during server-side rendering, so calling it on the server throws a `ReferenceError` rather than returning an empty string. Submit the form from the client, and set `devStorefrontUrl` when the Shopware domain differs from the origin your app runs on. An origin that is not a configured sales channel domain comes back as a constraint violation pointing at `/storefrontUrl`, which no field in your form owns. - `guest: true` creates a guest customer that can reuse an email address and needs no password. `isLoggedIn` stays false for that customer because it is computed as `!!id && active && !guest`, while `isGuestSession` becomes true.