@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 +1 -0
- package/app/components/checkout/form/CheckoutSchemaForm.vue +5 -1
- package/app/components/checkout/onepage/CheckoutAddressPickerModal.vue +1 -1
- package/package.json +1 -1
- package/server/api/account/address/[id].delete.ts +2 -43
- package/server/api/account/address/[id].put.ts +20 -48
- package/server/api/account/address/index.post.ts +3 -41
- package/server/api/account/addresses.get.ts +3 -23
- package/server/api/checkout/profile.get.ts +1 -1
- package/server/api/payment/methods.post.ts +1 -1
- package/server/api/shipping/rates.post.ts +1 -1
- package/server/interfaces/addressBook.ts +19 -0
- package/server/interfaces/checkoutProfile.ts +8 -1
- package/server/services/ApiAddressBookService.ts +155 -0
- package/server/services/LocalFileCheckoutProfileService.ts +25 -54
- package/server/services/MockAddressBookService.ts +125 -0
- package/server/utils/addressBookService.ts +25 -0
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
|
-
|
|
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 (
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
38
|
+
availableDeliveryMethods: BASE_PROFILE.availableDeliveryMethods,
|
|
74
39
|
defaultDeliveryMethod: "standard",
|
|
75
40
|
};
|
|
76
41
|
|
|
77
42
|
/**
|
|
78
|
-
* Checkout profile backed by the
|
|
79
|
-
* /account/addresses read and write the same
|
|
80
|
-
*
|
|
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
|
|
89
|
-
|
|
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: "
|
|
101
|
-
|
|
102
|
-
??
|
|
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
|
-
...
|
|
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 —
|
|
114
|
-
return
|
|
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
|
+
}
|