diff --git a/apps/docs/.vitepress/sidebar.ts b/apps/docs/.vitepress/sidebar.ts index f937df8a4..069a27cb1 100644 --- a/apps/docs/.vitepress/sidebar.ts +++ b/apps/docs/.vitepress/sidebar.ts @@ -242,6 +242,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/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/index.md b/apps/docs/src/frontends-recipes/account/index.md index e42969c1c..7962a2670 100644 --- a/apps/docs/src/frontends-recipes/account/index.md +++ b/apps/docs/src/frontends-recipes/account/index.md @@ -10,6 +10,8 @@ Recipes for customer session and account flows. + + 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 new file mode 100644 index 000000000..f62214070 --- /dev/null +++ b/apps/docs/src/frontends-recipes/account/register.md @@ -0,0 +1,650 @@ +--- +nav: + position: 20 +recipe: + area: account + status: stable + frameworks: + - vue + composables: + - useUser + - useSalutations + - useCountries + - useSessionContext + - useCart + - useInternationalization + - useShopwareContext + helpers: + - getTranslatedProperty + operations: + - register post /account/register + - registerConfirm post /account/register-confirm + - 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 + - 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, and then awaits `refreshCart()`. +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, and awaits `refreshCart()` right after it. `login()` and `logout()` fire their cart refresh without awaiting it, so `register()` is the one that resolves with the cart already recalculated. + +## Request Flow + +| Step | Code | Store API | Type | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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` | | +| Refresh session context | `refreshSessionContext()` | `GET /context` | | +| 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. +- `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`. Like `useSalutations` it fetches on mount when the shared list is still empty, so the example never calls `fetchCountries` itself. It merges `associations.states` into your criteria, which is why `getStatesForCountry` can answer from the already loaded countries without a second request. +- `useCart`: exposes `refreshCart`, which `register()` awaits for you. Reach for it directly only when the registration page renders cart state of its own. +- `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 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` — 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 + +```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()` 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 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. +- 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 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 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 `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. + +## 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) diff --git a/apps/docs/src/frontends-recipes/account/wishlist.md b/apps/docs/src/frontends-recipes/account/wishlist.md index 8d036fb8a..afb88519c 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