@revenexx/cover 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/app.config.ts CHANGED
@@ -12,6 +12,7 @@ import { DEFAULT_ICON_WEIGHT, nuxtUiIcons } from "./config/icons";
12
12
  export default defineAppConfig({
13
13
  schemaService: "local-file",
14
14
  accountService: "mock",
15
+ addressService: "mock",
15
16
  authService: "mock",
16
17
  productService: "mock",
17
18
  categoryService: "mock",
@@ -21,7 +21,11 @@ const { locale } = useI18n();
21
21
 
22
22
  const formId = useId();
23
23
 
24
- const { data: schema, pending } = await useAsyncData(
24
+ // No top-level await: async setup would turn this into an async component,
25
+ // and the parent's template ref then binds a wrapper WITHOUT the exposed
26
+ // submit() — the modal's save button silently did nothing. The template
27
+ // renders the pending state instead.
28
+ const { data: schema, pending } = useAsyncData(
25
29
  () => `checkout-schema-${props.schemaKey}-${locale.value}`,
26
30
  () => fetchSchemaByKey(props.schemaKey) as Promise<FormKitSchemaDefinition>,
27
31
  { watch: [() => props.schemaKey, locale] },
@@ -90,7 +90,7 @@ function splitStreet(street: string): { street: string; streetNumber: string } {
90
90
  }
91
91
 
92
92
  async function saveNewAddress() {
93
- if (!formRenderer.value) {
93
+ if (typeof formRenderer.value?.submit !== "function") {
94
94
  return;
95
95
  }
96
96
  const values = await formRenderer.value.submit();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/cover",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Cover \u2014 revenexx design system for Nuxt. Distributed as a Nuxt layer: generic UI components, theming tokens and stores shared by the demo shop, custom storefronts and the Blokkli theme.",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -1,9 +1,4 @@
1
- import type { AccountAddress, AccountAddressListResponse } from "../../../../app/interfaces/account/address-list";
2
-
3
- interface AddressListRaw {
4
- userId: string;
5
- addresses: AccountAddress[];
6
- }
1
+ import type { AccountAddressListResponse } from "../../../../app/interfaces/account/address-list";
7
2
 
8
3
  export default defineEventHandler(async (event): Promise<AccountAddressListResponse> => {
9
4
  const id = getRouterParam(event, "id");
@@ -11,41 +6,5 @@ export default defineEventHandler(async (event): Promise<AccountAddressListRespo
11
6
  throw createError({ statusCode: 400, statusMessage: "Missing address id" });
12
7
  }
13
8
 
14
- let data: AddressListRaw;
15
- try {
16
- data = await readCoverMutableJson<AddressListRaw>("account/address-list.json");
17
- }
18
- catch {
19
- throw createError({ statusCode: 500, statusMessage: "Address store unavailable" });
20
- }
21
-
22
- const address = data.addresses.find(a => a.id === id);
23
- if (!address) {
24
- throw createError({ statusCode: 404, statusMessage: "Address not found" });
25
- }
26
-
27
- const billingCount = data.addresses.filter(a => a.type === "billing").length;
28
- if (address.type === "billing" && billingCount <= 1) {
29
- throw createError({ statusCode: 400, statusMessage: "Cannot delete the last billing address" });
30
- }
31
-
32
- let remaining = data.addresses.filter(a => a.id !== id);
33
-
34
- // If deletion leaves no billing address, promote first shipping to billing + isDefault
35
- const hasBilling = remaining.some(a => a.type === "billing");
36
- if (!hasBilling && remaining.length > 0) {
37
- const firstShippingIdx = remaining.findIndex(a => a.type === "shipping");
38
- if (firstShippingIdx !== -1) {
39
- remaining = remaining.map((a, i) => {
40
- if (i === firstShippingIdx) {
41
- return { ...a, type: "billing" as const, isDefault: true };
42
- }
43
- return a;
44
- });
45
- }
46
- }
47
-
48
- await writeCoverMutableJson("account/address-list.json", { ...data, addresses: remaining });
49
-
50
- return { addresses: remaining };
9
+ return { addresses: await getAddressBookService(event).remove(event, id) };
51
10
  });
@@ -1,9 +1,8 @@
1
- import type { AccountAddress, AccountAddressType } from "../../../../app/interfaces/account/address-list";
2
-
3
- interface AddressListRaw {
4
- userId: string;
5
- addresses: AccountAddress[];
6
- }
1
+ import type {
2
+ AccountAddress,
3
+ AccountAddressFormState,
4
+ AccountAddressType,
5
+ } from "../../../../app/interfaces/account/address-list";
7
6
 
8
7
  interface AddressUpdateBody {
9
8
  readonly company?: unknown;
@@ -34,50 +33,23 @@ export default defineEventHandler(async (event): Promise<AccountAddress> => {
34
33
 
35
34
  const body = await readBody<AddressUpdateBody>(event);
36
35
 
37
- const company = typeof body.company === "string" ? body.company.trim() : "";
38
- const label = requireString(body.label, "label");
39
- const firstName = requireString(body.firstName, "firstName");
40
- const lastName = requireString(body.lastName, "lastName");
41
- const street = requireString(body.street, "street");
42
- const streetNumber = requireString(body.streetNumber, "streetNumber");
43
- const city = requireString(body.city, "city");
44
- const zip = requireString(body.zip, "zip");
45
- const country = requireString(body.country, "country");
46
-
47
36
  if (body.type !== "billing" && body.type !== "shipping") {
48
37
  throw createError({ statusCode: 422, statusMessage: "Invalid address type" });
49
38
  }
50
- const type = body.type as AccountAddressType;
51
- const isDefault = body.isDefault === true;
52
-
53
- let data: AddressListRaw;
54
- try {
55
- data = await readCoverMutableJson<AddressListRaw>("account/address-list.json");
56
- }
57
- catch {
58
- throw createError({ statusCode: 500, statusMessage: "Address store unavailable" });
59
- }
60
-
61
- const index = data.addresses.findIndex(a => a.id === id);
62
- if (index === -1) {
63
- throw createError({ statusCode: 404, statusMessage: "Address not found" });
64
- }
65
-
66
- // When setting isDefault, clear it from other addresses of the same type
67
- const updatedAddresses: AccountAddress[] = data.addresses.map((a, i) => {
68
- if (i === index) {
69
- return {
70
- ...a, company, label, firstName, lastName,
71
- street, streetNumber, city, zip, country, type, isDefault,
72
- };
73
- }
74
- if (isDefault && a.type === type) {
75
- return { ...a, isDefault: false };
76
- }
77
- return a;
78
- });
79
-
80
- await writeCoverMutableJson("account/address-list.json", { ...data, addresses: updatedAddresses });
81
39
 
82
- return updatedAddresses[index]!;
40
+ const input: AccountAddressFormState = {
41
+ company: typeof body.company === "string" ? body.company.trim() : "",
42
+ label: requireString(body.label, "label"),
43
+ firstName: requireString(body.firstName, "firstName"),
44
+ lastName: requireString(body.lastName, "lastName"),
45
+ street: requireString(body.street, "street"),
46
+ streetNumber: requireString(body.streetNumber, "streetNumber"),
47
+ city: requireString(body.city, "city"),
48
+ zip: requireString(body.zip, "zip"),
49
+ country: requireString(body.country, "country"),
50
+ type: body.type as AccountAddressType,
51
+ isDefault: body.isDefault === true,
52
+ };
53
+
54
+ return getAddressBookService(event).update(event, id, input);
83
55
  });
@@ -3,11 +3,6 @@ import type {
3
3
  AccountAddressFormState,
4
4
  } from "../../../../app/interfaces/account/address-list";
5
5
 
6
- interface AddressListRaw {
7
- userId: string;
8
- addresses: AccountAddress[];
9
- }
10
-
11
6
  export default defineEventHandler(async (event): Promise<AccountAddress> => {
12
7
  const body = await readBody<AccountAddressFormState>(event);
13
8
 
@@ -19,42 +14,9 @@ export default defineEventHandler(async (event): Promise<AccountAddress> => {
19
14
  throw createError({ statusCode: 400, statusMessage: `Missing required field: ${field}` });
20
15
  }
21
16
  }
22
-
23
- let data: AddressListRaw;
24
- try {
25
- data = await readCoverMutableJson<AddressListRaw>("account/address-list.json");
26
- }
27
- catch {
28
- throw createError({ statusCode: 500, statusMessage: "Address store unavailable" });
29
- }
30
-
31
- const sameType = data.addresses.filter(a => a.type === body.type);
32
- const isDefault = body.isDefault || sameType.length === 0;
33
-
34
- const newAddress: AccountAddress = {
35
- id: crypto.randomUUID(),
36
- company: body.company ?? "",
37
- label: body.label.trim(),
38
- firstName: body.firstName.trim(),
39
- lastName: body.lastName.trim(),
40
- street: body.street.trim(),
41
- streetNumber: body.streetNumber.trim(),
42
- city: body.city.trim(),
43
- zip: body.zip.trim(),
44
- country: body.country,
45
- type: body.type,
46
- isDefault,
47
- };
48
-
49
- let addresses = [...data.addresses];
50
- if (isDefault) {
51
- addresses = addresses.map(a =>
52
- a.type === newAddress.type ? { ...a, isDefault: false } : a,
53
- );
17
+ if (body.type !== "billing" && body.type !== "shipping") {
18
+ throw createError({ statusCode: 422, statusMessage: "Invalid address type" });
54
19
  }
55
- addresses.push(newAddress);
56
-
57
- await writeCoverMutableJson("account/address-list.json", { ...data, addresses });
58
20
 
59
- return newAddress;
21
+ return getAddressBookService(event).create(event, body);
60
22
  });
@@ -1,25 +1,5 @@
1
+ import type { AccountAddressListResponse } from "../../../app/interfaces/account/address-list";
1
2
 
2
- interface AccountAddress {
3
- readonly id: string;
4
- readonly company: string;
5
- readonly label: string;
6
- readonly firstName: string;
7
- readonly lastName: string;
8
- readonly street: string;
9
- readonly streetNumber: string;
10
- readonly city: string;
11
- readonly zip: string;
12
- readonly country: string;
13
- readonly isDefault: boolean;
14
- readonly type: "billing" | "shipping";
15
- }
16
-
17
- interface AccountAddressListResponse {
18
- readonly addresses: AccountAddress[];
19
- }
20
-
21
- export default defineEventHandler((): AccountAddressListResponse => {
22
-
23
-
24
- return readCoverMutableJson<AccountAddressListResponse>("account/address-list.json");
3
+ export default defineEventHandler(async (event): Promise<AccountAddressListResponse> => {
4
+ return { addresses: await getAddressBookService(event).list(event) };
25
5
  });
@@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
12
12
  }
13
13
 
14
14
  try {
15
- return await getCheckoutProfileService().getProfile(user?.$id ?? null);
15
+ return await getCheckoutProfileService().getProfile(event, user?.$id ?? null);
16
16
  }
17
17
  catch (err) {
18
18
  getLogService().error("Service error: checkout/profile", toErrorContext(err));
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
28
28
 
29
29
  if (resolvePaymentServiceKey(event) !== "api") {
30
30
  const user = await getAuthService(event).me(event);
31
- const profile = await getCheckoutProfileService().getProfile(user?.$id ?? null);
31
+ const profile = await getCheckoutProfileService().getProfile(event, user?.$id ?? null);
32
32
  return {
33
33
  managed: false,
34
34
  methods: profile.availablePaymentMethods,
@@ -35,7 +35,7 @@ export default defineEventHandler(async (event) => {
35
35
 
36
36
  if (resolveShippingServiceKey(event) !== "api") {
37
37
  const user = await getAuthService(event).me(event);
38
- const profile = await getCheckoutProfileService().getProfile(user?.$id ?? null);
38
+ const profile = await getCheckoutProfileService().getProfile(event, user?.$id ?? null);
39
39
  return {
40
40
  managed: false,
41
41
  methods: profile.availableDeliveryMethods,
@@ -0,0 +1,19 @@
1
+ import type { H3Event } from "h3";
2
+
3
+ import type { AccountAddress, AccountAddressFormState } from "../../app/interfaces/account/address-list";
4
+
5
+ /**
6
+ * Service contract for the customer's address book — consumed by the
7
+ * account address pages AND the checkout profile, so both surfaces always
8
+ * read and write the same store.
9
+ * - "mock" — mutable demo JSON (account/address-list.json)
10
+ * - "api" — contact addresses in the customers app via the public API
11
+ * Register the active implementation via app.config → addressService.
12
+ */
13
+ export interface IAddressBookService {
14
+ list(event: H3Event): Promise<AccountAddress[]>;
15
+ create(event: H3Event, input: AccountAddressFormState): Promise<AccountAddress>;
16
+ update(event: H3Event, id: string, input: AccountAddressFormState): Promise<AccountAddress>;
17
+ /** Returns the remaining addresses after deletion. */
18
+ remove(event: H3Event, id: string): Promise<AccountAddress[]>;
19
+ }
@@ -1,5 +1,12 @@
1
+ import type { H3Event } from "h3";
2
+
1
3
  import type { CheckoutProfile } from "../../app/interfaces/checkout";
2
4
 
3
5
  export interface ICheckoutProfileService {
4
- getProfile(userId: string | null): Promise<CheckoutProfile>;
6
+ /**
7
+ * The signed-in user's checkout profile. Addresses come from the
8
+ * address book service (mock or live per `addressService`); the event
9
+ * carries the session and the per-request service-mode resolution.
10
+ */
11
+ getProfile(event: H3Event, userId: string | null): Promise<CheckoutProfile>;
5
12
  }
@@ -0,0 +1,155 @@
1
+ import type { H3Event } from "h3";
2
+ import { AddressType } from "@revenexx/sdk";
3
+
4
+ import type { AccountAddress, AccountAddressFormState } from "../../app/interfaces/account/address-list";
5
+
6
+ import type { IAddressBookService } from "../interfaces/addressBook";
7
+
8
+ /** Raw address row from the customers app (snake_case API shape). */
9
+ interface ApiAddressRow {
10
+ id: string;
11
+ organization_id: string | null;
12
+ contact_id: string | null;
13
+ type: "billing" | "shipping";
14
+ company: string | null;
15
+ name: string | null;
16
+ street: string;
17
+ street2: string | null;
18
+ zip: string;
19
+ city: string;
20
+ region: string | null;
21
+ country: string;
22
+ phone: string | null;
23
+ is_default: boolean;
24
+ }
25
+
26
+ /** "Teststraße 1a" → { street: "Teststraße", streetNumber: "1a" }. */
27
+ function splitStreet(street: string): { street: string; streetNumber: string } {
28
+ const match = street.trim().match(/^(.*?)\s+(\S*\d\S*)$/);
29
+ return match
30
+ ? { street: match[1] ?? street, streetNumber: match[2] ?? "" }
31
+ : { street: street.trim(), streetNumber: "" };
32
+ }
33
+
34
+ function toAccountAddress(row: ApiAddressRow): AccountAddress {
35
+ const name = (row.name ?? "").trim();
36
+ const [firstName, ...rest] = name.split(/\s+/).filter(Boolean);
37
+ const streetParts = splitStreet(row.street);
38
+ return {
39
+ id: row.id,
40
+ company: row.company ?? "",
41
+ label: row.company || name || streetParts.street,
42
+ firstName: firstName ?? "",
43
+ lastName: rest.join(" "),
44
+ street: streetParts.street,
45
+ streetNumber: streetParts.streetNumber,
46
+ city: row.city,
47
+ zip: row.zip,
48
+ country: row.country,
49
+ isDefault: row.is_default,
50
+ type: row.type === "billing" ? "billing" : "shipping",
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Live address book: the signed-in contact's addresses in the customers
56
+ * app. The contact comes from the session cookie; guests have no address
57
+ * book. The customers schema requires ISO 3166-1 alpha-2 countries.
58
+ */
59
+ export class ApiAddressBookService implements IAddressBookService {
60
+ private contactRef(event: H3Event): { contact_id?: string; organization_id?: string } {
61
+ return sessionOrderRefs(event);
62
+ }
63
+
64
+ private requireContact(event: H3Event): string {
65
+ const { contact_id } = this.contactRef(event);
66
+ if (!contact_id) {
67
+ throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
68
+ }
69
+ return contact_id;
70
+ }
71
+
72
+ async list(event: H3Event): Promise<AccountAddress[]> {
73
+ const { contact_id } = this.contactRef(event);
74
+ if (!contact_id) {
75
+ return [];
76
+ }
77
+ try {
78
+ // Raw call: the list contract does not declare query params yet.
79
+ const { items } = await useRevenexxSdk().call<{ items: ApiAddressRow[] }>(
80
+ "GET",
81
+ "/v1/customers/addresses",
82
+ { query: { contact_id, limit: 200 } },
83
+ );
84
+ return items.map(toAccountAddress);
85
+ }
86
+ catch (err) {
87
+ getLogService().error("API request failed: addresses/list", apiErrorContext(err));
88
+ throw createError({ statusCode: 502, statusMessage: "Address service unavailable" });
89
+ }
90
+ }
91
+
92
+ async create(event: H3Event, input: AccountAddressFormState): Promise<AccountAddress> {
93
+ const contactId = this.requireContact(event);
94
+ const { organization_id } = this.contactRef(event);
95
+ try {
96
+ const row = await useRevenexxSdk().customers.customersAddressesCreate({
97
+ contactId,
98
+ ...(organization_id ? { organizationId: organization_id } : {}),
99
+ type: input.type === "billing" ? AddressType.Billing : AddressType.Shipping,
100
+ company: input.company || undefined,
101
+ name: `${input.firstName} ${input.lastName}`.trim(),
102
+ street: `${input.street} ${input.streetNumber}`.trim(),
103
+ zip: input.zip,
104
+ city: input.city,
105
+ country: input.country,
106
+ isDefault: input.isDefault === true,
107
+ }) as unknown as ApiAddressRow;
108
+ return toAccountAddress(row);
109
+ }
110
+ catch (err) {
111
+ if (err instanceof RevenexxException && (err.code === 400 || err.code === 422)) {
112
+ throw createError({ statusCode: 422, message: err.message });
113
+ }
114
+ getLogService().error("API request failed: addresses/create", apiErrorContext(err));
115
+ throw createError({ statusCode: 502, statusMessage: "Address service unavailable" });
116
+ }
117
+ }
118
+
119
+ async update(event: H3Event, id: string, input: AccountAddressFormState): Promise<AccountAddress> {
120
+ this.requireContact(event);
121
+ try {
122
+ const row = await useRevenexxSdk().customers.customersAddressesUpdate({
123
+ id,
124
+ type: input.type === "billing" ? AddressType.Billing : AddressType.Shipping,
125
+ company: input.company || undefined,
126
+ name: `${input.firstName} ${input.lastName}`.trim(),
127
+ street: `${input.street} ${input.streetNumber}`.trim(),
128
+ zip: input.zip,
129
+ city: input.city,
130
+ country: input.country,
131
+ isDefault: input.isDefault === true,
132
+ }) as unknown as ApiAddressRow;
133
+ return toAccountAddress(row);
134
+ }
135
+ catch (err) {
136
+ if (err instanceof RevenexxException && (err.code === 400 || err.code === 422)) {
137
+ throw createError({ statusCode: 422, message: err.message });
138
+ }
139
+ getLogService().error("API request failed: addresses/update", apiErrorContext(err));
140
+ throw createError({ statusCode: 502, statusMessage: "Address service unavailable" });
141
+ }
142
+ }
143
+
144
+ async remove(event: H3Event, id: string): Promise<AccountAddress[]> {
145
+ this.requireContact(event);
146
+ try {
147
+ await useRevenexxSdk().customers.customersAddressesDelete({ id });
148
+ }
149
+ catch (err) {
150
+ getLogService().error("API request failed: addresses/delete", apiErrorContext(err));
151
+ throw createError({ statusCode: 502, statusMessage: "Address service unavailable" });
152
+ }
153
+ return this.list(event);
154
+ }
155
+ }
@@ -1,47 +1,12 @@
1
+ import type { H3Event } from "h3";
2
+
3
+ import type { AccountAddress } from "../../app/interfaces/account/address-list";
1
4
  import type { Address } from "../../app/interfaces/address";
2
5
  import type { CheckoutProfile } from "../../app/interfaces/checkout";
3
6
 
4
7
  import type { ICheckoutProfileService } from "../interfaces/checkoutProfile";
5
8
 
6
- /** Shape of the account address book (account/address-list.json). */
7
- interface AccountAddressEntry {
8
- id: string;
9
- company: string;
10
- label: string;
11
- firstName: string;
12
- lastName: string;
13
- street: string;
14
- streetNumber: string;
15
- city: string;
16
- zip: string;
17
- country: string;
18
- isDefault: boolean;
19
- type: "billing" | "shipping";
20
- }
21
-
22
- const MOCK_PROFILE: CheckoutProfile = {
23
- addresses: [
24
- {
25
- id: "addr-1",
26
- companyName: "Demo GmbH",
27
- contactName: "Max Mustermann",
28
- street: "Musterstraße 1",
29
- city: "Berlin",
30
- postalCode: "10115",
31
- country: "DE",
32
- },
33
- {
34
- id: "addr-2",
35
- companyName: "Demo GmbH",
36
- contactName: "Erika Musterfrau",
37
- street: "Rechnungsweg 5",
38
- city: "Hamburg",
39
- postalCode: "20095",
40
- country: "DE",
41
- },
42
- ],
43
- defaultShippingAddressId: "addr-1",
44
- defaultBillingAddressId: "addr-1",
9
+ const BASE_PROFILE: Omit<CheckoutProfile, "addresses" | "defaultShippingAddressId" | "defaultBillingAddressId"> = {
45
10
  availablePaymentMethods: [
46
11
  { method: "invoice", label: "Rechnung", description: "30 Tage netto" },
47
12
  { method: "po_number", label: "Bestellschein", description: "Zahlung gemäß Bestellschein" },
@@ -70,25 +35,26 @@ const GUEST_PROFILE: CheckoutProfile = {
70
35
  ],
71
36
  defaultPaymentMethod: "invoice",
72
37
  costCenters: [],
73
- availableDeliveryMethods: MOCK_PROFILE.availableDeliveryMethods,
38
+ availableDeliveryMethods: BASE_PROFILE.availableDeliveryMethods,
74
39
  defaultDeliveryMethod: "standard",
75
40
  };
76
41
 
77
42
  /**
78
- * Checkout profile backed by the account address book: the checkout and
79
- * /account/addresses read and write the same mutable store, so addresses
80
- * created in either place show up in both.
43
+ * Checkout profile backed by the address book service: the checkout and
44
+ * /account/addresses read and write the same store the mutable demo
45
+ * JSON in mock mode, the customers app's contact addresses in live mode
46
+ * (`addressService` registry key). Payment and delivery options here are
47
+ * fallbacks; the live checkout resolves them via /api/payment/methods
48
+ * and /api/shipping/rates.
81
49
  */
82
50
  export class LocalFileCheckoutProfileService implements ICheckoutProfileService {
83
- async getProfile(userId: string | null): Promise<CheckoutProfile> {
51
+ async getProfile(event: H3Event, userId: string | null): Promise<CheckoutProfile> {
84
52
  if (!userId) {
85
53
  return GUEST_PROFILE;
86
54
  }
87
55
  try {
88
- const data = await readCoverMutableJson<{ addresses: AccountAddressEntry[] }>(
89
- "account/address-list.json",
90
- );
91
- const addresses: Address[] = data.addresses.map(entry => ({
56
+ const entries = await getAddressBookService(event).list(event);
57
+ const addresses: Address[] = entries.map(entry => ({
92
58
  id: entry.id,
93
59
  companyName: entry.company || `${entry.firstName} ${entry.lastName}`.trim(),
94
60
  contactName: `${entry.firstName} ${entry.lastName}`.trim(),
@@ -97,21 +63,26 @@ export class LocalFileCheckoutProfileService implements ICheckoutProfileService
97
63
  postalCode: entry.zip,
98
64
  country: entry.country,
99
65
  }));
100
- const defaultOf = (type: "shipping" | "billing"): string =>
101
- data.addresses.find(entry => entry.type === type && entry.isDefault)?.id
102
- ?? data.addresses.find(entry => entry.type === type)?.id
66
+ const defaultOf = (type: AccountAddress["type"]): string =>
67
+ entries.find(entry => entry.type === type && entry.isDefault)?.id
68
+ ?? entries.find(entry => entry.type === type)?.id
103
69
  ?? addresses[0]?.id
104
70
  ?? "";
105
71
  return {
106
- ...MOCK_PROFILE,
72
+ ...BASE_PROFILE,
107
73
  addresses,
108
74
  defaultShippingAddressId: defaultOf("shipping"),
109
75
  defaultBillingAddressId: defaultOf("billing"),
110
76
  };
111
77
  }
112
78
  catch {
113
- // Address book unavailable — fall back to the bundled profile.
114
- return MOCK_PROFILE;
79
+ // Address book unavailable — checkout still works via add-new.
80
+ return {
81
+ ...BASE_PROFILE,
82
+ addresses: [],
83
+ defaultShippingAddressId: "",
84
+ defaultBillingAddressId: "",
85
+ };
115
86
  }
116
87
  }
117
88
  }
@@ -0,0 +1,125 @@
1
+ import type { H3Event } from "h3";
2
+
3
+ import type { AccountAddress, AccountAddressFormState } from "../../app/interfaces/account/address-list";
4
+
5
+ import type { IAddressBookService } from "../interfaces/addressBook";
6
+
7
+ interface AddressListRaw {
8
+ userId: string;
9
+ addresses: AccountAddress[];
10
+ }
11
+
12
+ /**
13
+ * Demo address book backed by the mutable data store — the same JSON the
14
+ * account pages always used; checkout reads through this service too.
15
+ */
16
+ export class MockAddressBookService implements IAddressBookService {
17
+ private async read(): Promise<AddressListRaw> {
18
+ try {
19
+ return await readCoverMutableJson<AddressListRaw>("account/address-list.json");
20
+ }
21
+ catch {
22
+ throw createError({ statusCode: 500, statusMessage: "Address store unavailable" });
23
+ }
24
+ }
25
+
26
+ async list(_event: H3Event): Promise<AccountAddress[]> {
27
+ return (await this.read()).addresses;
28
+ }
29
+
30
+ async create(_event: H3Event, input: AccountAddressFormState): Promise<AccountAddress> {
31
+ const data = await this.read();
32
+ const sameType = data.addresses.filter(a => a.type === input.type);
33
+ const isDefault = input.isDefault || sameType.length === 0;
34
+
35
+ const newAddress: AccountAddress = {
36
+ id: crypto.randomUUID(),
37
+ company: input.company ?? "",
38
+ label: input.label.trim(),
39
+ firstName: input.firstName.trim(),
40
+ lastName: input.lastName.trim(),
41
+ street: input.street.trim(),
42
+ streetNumber: input.streetNumber.trim(),
43
+ city: input.city.trim(),
44
+ zip: input.zip.trim(),
45
+ country: input.country,
46
+ type: input.type,
47
+ isDefault,
48
+ };
49
+
50
+ let addresses = [...data.addresses];
51
+ if (isDefault) {
52
+ addresses = addresses.map(a =>
53
+ a.type === newAddress.type ? { ...a, isDefault: false } : a,
54
+ );
55
+ }
56
+ addresses.push(newAddress);
57
+
58
+ await writeCoverMutableJson("account/address-list.json", { ...data, addresses });
59
+ return newAddress;
60
+ }
61
+
62
+ async update(_event: H3Event, id: string, input: AccountAddressFormState): Promise<AccountAddress> {
63
+ const data = await this.read();
64
+ const index = data.addresses.findIndex(a => a.id === id);
65
+ if (index === -1) {
66
+ throw createError({ statusCode: 404, statusMessage: "Address not found" });
67
+ }
68
+
69
+ // When setting isDefault, clear it from other addresses of the same type
70
+ const updated: AccountAddress[] = data.addresses.map((a, i) => {
71
+ if (i === index) {
72
+ return {
73
+ ...a,
74
+ company: input.company,
75
+ label: input.label,
76
+ firstName: input.firstName,
77
+ lastName: input.lastName,
78
+ street: input.street,
79
+ streetNumber: input.streetNumber,
80
+ city: input.city,
81
+ zip: input.zip,
82
+ country: input.country,
83
+ type: input.type,
84
+ isDefault: input.isDefault,
85
+ };
86
+ }
87
+ if (input.isDefault && a.type === input.type) {
88
+ return { ...a, isDefault: false };
89
+ }
90
+ return a;
91
+ });
92
+
93
+ await writeCoverMutableJson("account/address-list.json", { ...data, addresses: updated });
94
+ return updated[index]!;
95
+ }
96
+
97
+ async remove(_event: H3Event, id: string): Promise<AccountAddress[]> {
98
+ const data = await this.read();
99
+ const address = data.addresses.find(a => a.id === id);
100
+ if (!address) {
101
+ throw createError({ statusCode: 404, statusMessage: "Address not found" });
102
+ }
103
+
104
+ const billingCount = data.addresses.filter(a => a.type === "billing").length;
105
+ if (address.type === "billing" && billingCount <= 1) {
106
+ throw createError({ statusCode: 400, statusMessage: "Cannot delete the last billing address" });
107
+ }
108
+
109
+ let remaining = data.addresses.filter(a => a.id !== id);
110
+
111
+ // If deletion leaves no billing address, promote first shipping to billing + isDefault
112
+ const hasBilling = remaining.some(a => a.type === "billing");
113
+ if (!hasBilling && remaining.length > 0) {
114
+ const firstShippingIdx = remaining.findIndex(a => a.type === "shipping");
115
+ if (firstShippingIdx !== -1) {
116
+ remaining = remaining.map((a, i) =>
117
+ i === firstShippingIdx ? { ...a, type: "billing" as const, isDefault: true } : a,
118
+ );
119
+ }
120
+ }
121
+
122
+ await writeCoverMutableJson("account/address-list.json", { ...data, addresses: remaining });
123
+ return remaining;
124
+ }
125
+ }
@@ -0,0 +1,25 @@
1
+ import type { H3Event } from "h3";
2
+
3
+ import type { IAddressBookService } from "../interfaces/addressBook";
4
+ import { ApiAddressBookService } from "../services/ApiAddressBookService";
5
+ import { MockAddressBookService } from "../services/MockAddressBookService";
6
+
7
+ /**
8
+ * Registry of available address book implementations.
9
+ * The active implementation is selected by the `addressService` key in app.config.ts:
10
+ * - "mock" — mutable demo address list (account/address-list.json)
11
+ * - "api" — the contact's addresses in the customers app (public revenexx API)
12
+ */
13
+ const serviceRegistry: Record<string, IAddressBookService> = {
14
+ mock: new MockAddressBookService(),
15
+ api: new ApiAddressBookService(),
16
+ };
17
+
18
+ export function getAddressBookService(event?: H3Event): IAddressBookService {
19
+ const key = resolveServiceKey(event, {
20
+ domain: "addressService",
21
+ mockKey: "mock",
22
+ liveKey: "api",
23
+ });
24
+ return serviceRegistry[key] ?? serviceRegistry["mock"]!;
25
+ }