@revenexx/cover 0.1.19 → 0.1.21
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/components/cart/item/CartItem.vue +3 -2
- package/app/components/category/info/CategoryPrice.vue +3 -2
- package/app/components/layout/header/LocaleSwitcher.vue +34 -3
- package/app/composables/useCartSummaryFormatting.ts +3 -2
- package/app/composables/useDisplayCurrency.ts +13 -0
- package/app/composables/useLocalePreferences.ts +38 -6
- package/app/composables/useProduct.ts +3 -2
- package/app/interfaces/market.ts +16 -3
- package/package.json +1 -1
- package/server/api/cart/calculate.post.ts +23 -6
- package/server/api/orders/index.post.ts +23 -8
- package/server/data/markets.json +8 -0
- package/server/interfaces/cartCalculation.ts +8 -0
- package/server/services/ApiMarketService.ts +30 -12
- package/server/services/MockCartCalculationService.ts +5 -2
- package/server/utils/currency.ts +20 -0
- package/server/utils/livePrices.ts +4 -0
|
@@ -15,7 +15,8 @@ const props = defineProps<{
|
|
|
15
15
|
const { getImage } = useProductImage();
|
|
16
16
|
const cart = useCartStore();
|
|
17
17
|
const { t } = useI18n();
|
|
18
|
-
const { public: { locale
|
|
18
|
+
const { public: { locale } } = useRuntimeConfig();
|
|
19
|
+
const currency = useDisplayCurrency();
|
|
19
20
|
|
|
20
21
|
const effectivePrice = computed(() => cart.getEffectivePrice(props.item.id));
|
|
21
22
|
const taxRate = computed(() => {
|
|
@@ -33,7 +34,7 @@ const displayPrice = computed(() =>
|
|
|
33
34
|
),
|
|
34
35
|
);
|
|
35
36
|
const formattedPrice = computed(() =>
|
|
36
|
-
displayPrice.value.toLocaleString(locale as string, { style: "currency", currency: currency
|
|
37
|
+
displayPrice.value.toLocaleString(locale as string, { style: "currency", currency: currency.value }),
|
|
37
38
|
);
|
|
38
39
|
const maxOrderQuantity = computed(() => props.item.maxOrderQuantity);
|
|
39
40
|
const localePath = useLocalePath();
|
|
@@ -3,10 +3,11 @@ import { getDisplayUnitPrice, getPriceTiers, getPriceWithOptionalTax } from "../
|
|
|
3
3
|
import type { ProductList } from "../../../interfaces/product-list";
|
|
4
4
|
|
|
5
5
|
const { t } = useI18n();
|
|
6
|
-
const { public: { locale,
|
|
6
|
+
const { public: { locale, taxIncludedPrices } } = useRuntimeConfig();
|
|
7
|
+
const currency = useDisplayCurrency();
|
|
7
8
|
|
|
8
9
|
function formatPrice(price: number) {
|
|
9
|
-
return price.toLocaleString(locale, { style: "currency", currency });
|
|
10
|
+
return price.toLocaleString(locale, { style: "currency", currency: currency.value });
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
const props = defineProps<{
|
|
@@ -11,7 +11,7 @@ const { icon } = useIcons();
|
|
|
11
11
|
const { t, locale, locales } = useI18n();
|
|
12
12
|
const switchLocalePath = useSwitchLocalePath();
|
|
13
13
|
const { markets } = useMarkets();
|
|
14
|
-
const { market, setMarket, region, currencySymbol } = useLocalePreferences(markets);
|
|
14
|
+
const { market, setMarket, region, currencies, currency, currencySymbol, setCurrency } = useLocalePreferences(markets);
|
|
15
15
|
|
|
16
16
|
const open = ref(false);
|
|
17
17
|
|
|
@@ -44,6 +44,9 @@ async function onLanguageChange(code: string): Promise<void> {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const currencyItems = computed(() =>
|
|
48
|
+
currencies.value.map(code => ({ code, name: code, active: code === currency.value })));
|
|
49
|
+
|
|
47
50
|
async function onMarketChange(code: string): Promise<void> {
|
|
48
51
|
setMarket(code);
|
|
49
52
|
// The market may not offer the current language — fall back to its first.
|
|
@@ -51,6 +54,18 @@ async function onMarketChange(code: string): Promise<void> {
|
|
|
51
54
|
if (fallback && !languageItems.value.some(item => item.active)) {
|
|
52
55
|
await navigateTo(fallback.to);
|
|
53
56
|
}
|
|
57
|
+
// The market may not trade in the current currency — re-resolve prices.
|
|
58
|
+
reloadNuxtApp({ ttl: 0 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function onCurrencyChange(code: string): void {
|
|
62
|
+
if (code === currency.value) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setCurrency(code);
|
|
66
|
+
// Re-SSR so the catalog, cart and checkout all re-price in the new
|
|
67
|
+
// currency (prices come from the API, keyed by the currency cookie).
|
|
68
|
+
reloadNuxtApp({ ttl: 0 });
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
/** The trigger states the full current selection, e.g. "Deutschland (Deutsch) in €". */
|
|
@@ -108,8 +123,24 @@ const triggerLabel = computed(() =>
|
|
|
108
123
|
/>
|
|
109
124
|
</UFormField>
|
|
110
125
|
|
|
111
|
-
<!-- Currency:
|
|
112
|
-
<
|
|
126
|
+
<!-- Currency: chosen within the currencies the market trades in -->
|
|
127
|
+
<UFormField
|
|
128
|
+
v-if="currencyItems.length > 1"
|
|
129
|
+
:label="t('topbar.localePanel.currency')"
|
|
130
|
+
>
|
|
131
|
+
<USelect
|
|
132
|
+
:model-value="currency"
|
|
133
|
+
value-key="code"
|
|
134
|
+
label-key="name"
|
|
135
|
+
:items="currencyItems"
|
|
136
|
+
class="w-full"
|
|
137
|
+
@update:model-value="(code: string) => onCurrencyChange(code)"
|
|
138
|
+
/>
|
|
139
|
+
</UFormField>
|
|
140
|
+
<div
|
|
141
|
+
v-else
|
|
142
|
+
class="flex items-center justify-between text-sm"
|
|
143
|
+
>
|
|
113
144
|
<span class="text-muted">{{ t('topbar.localePanel.currency') }}</span>
|
|
114
145
|
<span class="font-medium">{{ currencySymbol }}</span>
|
|
115
146
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export const useCartSummaryFormatting = () => {
|
|
2
2
|
const { t } = useI18n();
|
|
3
|
-
const { public: { locale,
|
|
3
|
+
const { public: { locale, taxIncludedPrices } } = useRuntimeConfig();
|
|
4
|
+
const currency = useDisplayCurrency();
|
|
4
5
|
|
|
5
6
|
const taxLabelKey = computed(() => taxIncludedPrices ? "summary.tax.incl" : "summary.tax.excl");
|
|
6
7
|
|
|
@@ -16,7 +17,7 @@ export const useCartSummaryFormatting = () => {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
function formatCurrency(value: number): string {
|
|
19
|
-
return (value).toLocaleString(locale as string, { style: "currency", currency: currency
|
|
20
|
+
return (value).toLocaleString(locale as string, { style: "currency", currency: currency.value });
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
return {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ComputedRef } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The active display currency (ISO 4217) — the buyer's choice from the locale
|
|
5
|
+
* selector, falling back to the market's default. Price formatters use this
|
|
6
|
+
* instead of the static build-time currency so amounts render in the same
|
|
7
|
+
* currency the prices app resolved them in.
|
|
8
|
+
*/
|
|
9
|
+
export function useDisplayCurrency(): ComputedRef<string> {
|
|
10
|
+
const { markets } = useMarkets();
|
|
11
|
+
const { currency } = useLocalePreferences(markets);
|
|
12
|
+
return currency;
|
|
13
|
+
}
|
|
@@ -4,10 +4,11 @@ import type { ShopMarket } from "../interfaces/market";
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Market-driven locale preferences: the shop's markets (markets app) carry
|
|
7
|
-
* region,
|
|
8
|
-
*
|
|
9
|
-
* (Smashing Magazine, "Designing A Better
|
|
10
|
-
* as
|
|
7
|
+
* region, the offered locales and the currencies the market trades in — the
|
|
8
|
+
* selector picks a market, while language and currency stay independent
|
|
9
|
+
* choices within the market's options (Smashing Magazine, "Designing A Better
|
|
10
|
+
* Language Selector"). All three persist as cookies so SSR renders the same
|
|
11
|
+
* selection and the BFF resolves prices in the chosen currency.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
const CURRENCY_SYMBOLS: Record<string, string> = {
|
|
@@ -22,6 +23,12 @@ export function useLocalePreferences(markets: Ref<ShopMarket[]>) {
|
|
|
22
23
|
default: () => "",
|
|
23
24
|
watch: true,
|
|
24
25
|
});
|
|
26
|
+
// Read server-side by the BFF (resolveSelectedCurrency) to price the
|
|
27
|
+
// catalog, cart and order in the buyer's currency.
|
|
28
|
+
const currencyCode = useCookie<string>("cover-currency", {
|
|
29
|
+
default: () => "",
|
|
30
|
+
watch: true,
|
|
31
|
+
});
|
|
25
32
|
|
|
26
33
|
const market = computed<ShopMarket | null>(() => {
|
|
27
34
|
if (!markets.value.length) {
|
|
@@ -38,8 +45,33 @@ export function useLocalePreferences(markets: Ref<ShopMarket[]>) {
|
|
|
38
45
|
};
|
|
39
46
|
|
|
40
47
|
const region = computed(() => market.value?.name ?? "");
|
|
41
|
-
|
|
48
|
+
|
|
49
|
+
/** The currencies the active market offers (falls back to its base currency). */
|
|
50
|
+
const currencies = computed<string[]>(() => {
|
|
51
|
+
const offered = market.value?.currencies?.map(c => c.code) ?? [];
|
|
52
|
+
if (offered.length) {
|
|
53
|
+
return offered;
|
|
54
|
+
}
|
|
55
|
+
return market.value?.currency ? [market.value.currency] : ["EUR"];
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** The market's default currency — initial choice and fallback. */
|
|
59
|
+
const defaultCurrency = computed(() => {
|
|
60
|
+
const flagged = market.value?.currencies?.find(c => c.isDefault)?.code;
|
|
61
|
+
return flagged ?? market.value?.currency ?? currencies.value[0] ?? "EUR";
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** The active currency: the cookie choice if the market offers it, else the default. */
|
|
65
|
+
const currency = computed(() => {
|
|
66
|
+
const chosen = currencyCode.value;
|
|
67
|
+
return chosen && currencies.value.includes(chosen) ? chosen : defaultCurrency.value;
|
|
68
|
+
});
|
|
69
|
+
|
|
42
70
|
const currencySymbol = computed(() => CURRENCY_SYMBOLS[currency.value] ?? currency.value);
|
|
43
71
|
|
|
44
|
-
|
|
72
|
+
const setCurrency = (code: string): void => {
|
|
73
|
+
currencyCode.value = code;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return { market, setMarket, region, currencies, currency, defaultCurrency, currencySymbol, setCurrency };
|
|
45
77
|
}
|
|
@@ -11,7 +11,8 @@ import type {
|
|
|
11
11
|
} from "../interfaces/product-detail";
|
|
12
12
|
|
|
13
13
|
export const useProductDetail = (id: MaybeRefOrGetter<string>) => {
|
|
14
|
-
const { public: { locale,
|
|
14
|
+
const { public: { locale, taxIncludedPrices } } = useRuntimeConfig();
|
|
15
|
+
const currency = useDisplayCurrency();
|
|
15
16
|
|
|
16
17
|
const { data: detail, status, error, refresh } = useFetch<ProductDetail | null>(
|
|
17
18
|
() => productApi.detail(toValue(id)),
|
|
@@ -72,7 +73,7 @@ export const useProductDetail = (id: MaybeRefOrGetter<string>) => {
|
|
|
72
73
|
const basePrice = cents / 100;
|
|
73
74
|
const displayPrice = getPriceWithOptionalTax(basePrice, taxRate.value, taxIncludedPrices);
|
|
74
75
|
|
|
75
|
-
return displayPrice.toLocaleString(locale, { style: "currency", currency });
|
|
76
|
+
return displayPrice.toLocaleString(locale, { style: "currency", currency: currency.value });
|
|
76
77
|
};
|
|
77
78
|
|
|
78
79
|
const getDetailImageUrl = (filename: string): string => {
|
package/app/interfaces/market.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Storefront view of the markets app: a market bundles currency
|
|
3
|
-
* locales (language + country)
|
|
2
|
+
* Storefront view of the markets app: a market bundles its base currency,
|
|
3
|
+
* locales (language + country) and the currencies it trades in; the locale
|
|
4
|
+
* selector is driven by this.
|
|
4
5
|
*/
|
|
5
6
|
export interface ShopMarketLocale {
|
|
6
7
|
/** BCP-47 code, e.g. "de-DE". */
|
|
@@ -12,13 +13,25 @@ export interface ShopMarketLocale {
|
|
|
12
13
|
isDefault: boolean;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
export interface ShopMarketCurrency {
|
|
17
|
+
/** ISO 4217 code, e.g. "EUR". */
|
|
18
|
+
code: string;
|
|
19
|
+
isDefault: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
export interface ShopMarket {
|
|
16
23
|
id: string;
|
|
17
24
|
/** Stable market code, e.g. "de". */
|
|
18
25
|
code: string;
|
|
19
26
|
name: string;
|
|
20
|
-
/** ISO 4217, e.g. "EUR". */
|
|
27
|
+
/** ISO 4217 base currency, e.g. "EUR" — the fallback when none is chosen. */
|
|
21
28
|
currency: string;
|
|
22
29
|
isDefault: boolean;
|
|
23
30
|
locales: ShopMarketLocale[];
|
|
31
|
+
/**
|
|
32
|
+
* The currencies the market trades in (loosely configured in the markets
|
|
33
|
+
* app). Empty falls back to the single base `currency`; the selector lets
|
|
34
|
+
* the buyer pick one and prices resolve in it.
|
|
35
|
+
*/
|
|
36
|
+
currencies: ShopMarketCurrency[];
|
|
24
37
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revenexx/cover",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
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",
|
|
@@ -16,6 +16,10 @@ async function resolveLiveOverrides(
|
|
|
16
16
|
subtotal: number,
|
|
17
17
|
): Promise<CartCalculationOverrides> {
|
|
18
18
|
const overrides: CartCalculationOverrides = {};
|
|
19
|
+
const currency = resolveSelectedCurrency(event);
|
|
20
|
+
if (currency) {
|
|
21
|
+
overrides.currency = currency;
|
|
22
|
+
}
|
|
19
23
|
|
|
20
24
|
if (resolvePriceServiceKey(event) === "api" && body.items.length) {
|
|
21
25
|
try {
|
|
@@ -25,19 +29,31 @@ async function resolveLiveOverrides(
|
|
|
25
29
|
sku: it.sku ? String(it.sku) : undefined,
|
|
26
30
|
quantity: Number(it.quantity) || 1,
|
|
27
31
|
})) as Models.PriceResolveItem[],
|
|
28
|
-
|
|
32
|
+
...(currency ? { currency } : {}),
|
|
33
|
+
}) as unknown as { prices: Array<{ product_id: string | null; sku: string | null; tax_rate: number | null; on_request: boolean; unit_price: number | null }> };
|
|
29
34
|
const itemTaxRates: Record<string, number> = {};
|
|
35
|
+
const itemUnitPrices: Record<string, number> = {};
|
|
30
36
|
for (const p of prices) {
|
|
31
|
-
if (p.tax_rate
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
if (p.tax_rate != null) {
|
|
38
|
+
if (p.product_id) itemTaxRates[p.product_id] = p.tax_rate;
|
|
39
|
+
if (p.sku) itemTaxRates[p.sku] = p.tax_rate;
|
|
40
|
+
}
|
|
41
|
+
// Re-price in the resolved currency; on-request items keep the
|
|
42
|
+
// client price (the cart shows them as quote positions).
|
|
43
|
+
if (!p.on_request && p.unit_price != null) {
|
|
44
|
+
if (p.product_id) itemUnitPrices[p.product_id] = p.unit_price;
|
|
45
|
+
if (p.sku) itemUnitPrices[p.sku] = p.unit_price;
|
|
46
|
+
}
|
|
34
47
|
}
|
|
35
48
|
if (Object.keys(itemTaxRates).length) {
|
|
36
49
|
overrides.itemTaxRates = itemTaxRates;
|
|
37
50
|
}
|
|
51
|
+
if (Object.keys(itemUnitPrices).length) {
|
|
52
|
+
overrides.itemUnitPrices = itemUnitPrices;
|
|
53
|
+
}
|
|
38
54
|
}
|
|
39
55
|
catch (err) {
|
|
40
|
-
getLogService().error("Cart item tax
|
|
56
|
+
getLogService().error("Cart item price/tax resolution failed", apiErrorContext(err));
|
|
41
57
|
}
|
|
42
58
|
}
|
|
43
59
|
|
|
@@ -47,6 +63,7 @@ async function resolveLiveOverrides(
|
|
|
47
63
|
const { rates } = await useRevenexxSdk().shipping.shippingRates({
|
|
48
64
|
orderValue: subtotal,
|
|
49
65
|
country: "",
|
|
66
|
+
...(currency ? { currency } : {}),
|
|
50
67
|
}) as unknown as { rates: Array<{ code: string; price: number; tax_rate: number | null }> };
|
|
51
68
|
const rate = rates.find(r => r.code === method) ?? rates[0];
|
|
52
69
|
if (rate) {
|
|
@@ -91,7 +108,7 @@ export default defineEventHandler(async (event) => {
|
|
|
91
108
|
}
|
|
92
109
|
const subtotal = base.totals.find(row => row.key === "subtotal")?.amount ?? 0;
|
|
93
110
|
const overrides = await resolveLiveOverrides(event, body, subtotal);
|
|
94
|
-
if (!overrides.shipping && !overrides.itemTaxRates) {
|
|
111
|
+
if (!overrides.shipping && !overrides.itemTaxRates && !overrides.itemUnitPrices && !overrides.currency) {
|
|
95
112
|
return base;
|
|
96
113
|
}
|
|
97
114
|
return await service.calculate(body, context, locale, overrides);
|
|
@@ -128,6 +128,11 @@ export default defineEventHandler(async (event) => {
|
|
|
128
128
|
throw createError({ status: 422, message: addressError });
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// The buyer's chosen currency (locale selector cookie) — forwarded to
|
|
132
|
+
// every resolve so the order is priced, shipped and charged in one
|
|
133
|
+
// currency. Undefined → the apps fall back to their EUR price lists.
|
|
134
|
+
const selectedCurrency = resolveSelectedCurrency(event);
|
|
135
|
+
|
|
131
136
|
const liveShipping = resolveShippingServiceKey(event) === "api";
|
|
132
137
|
if (
|
|
133
138
|
!liveShipping
|
|
@@ -174,6 +179,7 @@ export default defineEventHandler(async (event) => {
|
|
|
174
179
|
orderValue: calculation.totals.find(row => row.key === "subtotal")?.amount
|
|
175
180
|
?? calculation.totals.find(row => row.key === "total")?.amount ?? 0,
|
|
176
181
|
country: String(address?.country ?? ""),
|
|
182
|
+
...(selectedCurrency ? { currency: selectedCurrency } : {}),
|
|
177
183
|
}) as unknown as { rates: Array<{ code: string; price: number; tax_rate: number | null }> };
|
|
178
184
|
const rate = rates.find(r => r.code === body.deliveryMethod);
|
|
179
185
|
if (!rate) {
|
|
@@ -224,26 +230,35 @@ export default defineEventHandler(async (event) => {
|
|
|
224
230
|
// app then taxes items + shipping and computes grand_total itself.
|
|
225
231
|
const orderItems = body.items as Array<Record<string, unknown>>;
|
|
226
232
|
const itemRate = new Map<string, number>();
|
|
233
|
+
const itemPrice = new Map<string, number>();
|
|
227
234
|
try {
|
|
228
235
|
const { prices: resolved } = await useRevenexxSdk().prices.pricesResolve({
|
|
229
236
|
items: orderItems.map(it => ({ product_id: String(it.id), sku: it.sku ? String(it.sku) : undefined, quantity: Number(it.quantity) || 1 })) as Models.PriceResolveItem[],
|
|
230
237
|
...(contactId ? { contactId } : {}),
|
|
231
238
|
...(organizationId ? { organizationId } : {}),
|
|
232
|
-
|
|
239
|
+
...(selectedCurrency ? { currency: selectedCurrency } : {}),
|
|
240
|
+
}) as unknown as { prices: Array<{ product_id: string | null; sku: string | null; tax_rate: number | null; on_request: boolean; unit_price: number | null }> };
|
|
233
241
|
for (const p of resolved) {
|
|
234
|
-
if (p.tax_rate
|
|
235
|
-
|
|
236
|
-
|
|
242
|
+
if (p.tax_rate != null) {
|
|
243
|
+
if (p.product_id) itemRate.set(p.product_id, p.tax_rate);
|
|
244
|
+
if (p.sku) itemRate.set(p.sku, p.tax_rate);
|
|
245
|
+
}
|
|
246
|
+
// Re-price in the resolved currency (the orders app is the SoR
|
|
247
|
+
// for totals, but it must be fed prices in the buyer's currency).
|
|
248
|
+
if (!p.on_request && p.unit_price != null) {
|
|
249
|
+
if (p.product_id) itemPrice.set(p.product_id, p.unit_price);
|
|
250
|
+
if (p.sku) itemPrice.set(p.sku, p.unit_price);
|
|
251
|
+
}
|
|
237
252
|
}
|
|
238
253
|
}
|
|
239
254
|
catch (rateErr) {
|
|
240
|
-
getLogService().error("Item tax
|
|
255
|
+
getLogService().error("Item price/tax resolution failed", apiErrorContext(rateErr));
|
|
241
256
|
}
|
|
242
257
|
|
|
243
258
|
const placed = await useRevenexxSdk().orders.ordersPlace({
|
|
244
259
|
...(contactId ? { contactId } : {}),
|
|
245
260
|
...(organizationId ? { organizationId } : {}),
|
|
246
|
-
currency: calculation.currency,
|
|
261
|
+
currency: selectedCurrency ?? calculation.currency,
|
|
247
262
|
...(body.orderNumber ? { customerOrderNumber: body.orderNumber } : {}),
|
|
248
263
|
buyer: user ? { name: user.name, email: user.email } : { email: body.contactEmail ?? null },
|
|
249
264
|
billingAddress: (billing ?? null) as object,
|
|
@@ -262,7 +277,7 @@ export default defineEventHandler(async (event) => {
|
|
|
262
277
|
sku: String(item.sku ?? "") || undefined,
|
|
263
278
|
name: String(item.name ?? item.sku ?? item.id),
|
|
264
279
|
quantity: calculation.lines[index]?.adjustedQuantity ?? Number(item.quantity),
|
|
265
|
-
unit_price: calculation.lines[index]?.unitPrice ?? Number(item.price ?? 0),
|
|
280
|
+
unit_price: itemPrice.get(String(item.id)) ?? itemPrice.get(String(item.sku ?? "")) ?? calculation.lines[index]?.unitPrice ?? Number(item.price ?? 0),
|
|
266
281
|
tax_rate: itemRate.get(String(item.id)) ?? itemRate.get(String(item.sku ?? "")) ?? 0,
|
|
267
282
|
product: {
|
|
268
283
|
...(item.image ? { image: String(item.image) } : {}),
|
|
@@ -307,7 +322,7 @@ export default defineEventHandler(async (event) => {
|
|
|
307
322
|
methodCode: payment.method,
|
|
308
323
|
// Charge exactly what the orders app computed (incl. shipping tax).
|
|
309
324
|
amount: Math.max(0, Math.round((liveOrderTotal ?? totalRow?.amount ?? 0) * 100) / 100),
|
|
310
|
-
currency: calculation.currency,
|
|
325
|
+
currency: selectedCurrency ?? calculation.currency,
|
|
311
326
|
country,
|
|
312
327
|
orderRef: orderId,
|
|
313
328
|
idempotencyKey: body.checkoutSessionToken,
|
package/server/data/markets.json
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"locales": [
|
|
9
9
|
{ "code": "de-DE", "language": "de", "country": "DE", "isDefault": true },
|
|
10
10
|
{ "code": "en-GB", "language": "en", "country": "GB", "isDefault": false }
|
|
11
|
+
],
|
|
12
|
+
"currencies": [
|
|
13
|
+
{ "code": "EUR", "isDefault": true },
|
|
14
|
+
{ "code": "USD", "isDefault": false }
|
|
11
15
|
]
|
|
12
16
|
},
|
|
13
17
|
{
|
|
@@ -19,6 +23,10 @@
|
|
|
19
23
|
"locales": [
|
|
20
24
|
{ "code": "de-CH", "language": "de", "country": "CH", "isDefault": true },
|
|
21
25
|
{ "code": "en-GB", "language": "en", "country": "GB", "isDefault": false }
|
|
26
|
+
],
|
|
27
|
+
"currencies": [
|
|
28
|
+
{ "code": "CHF", "isDefault": true },
|
|
29
|
+
{ "code": "EUR", "isDefault": false }
|
|
22
30
|
]
|
|
23
31
|
}
|
|
24
32
|
]
|
|
@@ -8,10 +8,18 @@ import type { CartCalculation, CartCalculationRequest } from "../../app/interfac
|
|
|
8
8
|
* endpoint; absent in mock mode (then the demo profile fills in).
|
|
9
9
|
*/
|
|
10
10
|
export interface CartCalculationOverrides {
|
|
11
|
+
/** The currency prices/shipping resolved in — becomes the cart currency. */
|
|
12
|
+
currency?: string;
|
|
11
13
|
/** Live shipping fee + its tax rate for the chosen method. */
|
|
12
14
|
shipping?: { price: number; taxRate: number };
|
|
13
15
|
/** Per-item tax rate (percent) keyed by product id or sku, from prices.resolve. */
|
|
14
16
|
itemTaxRates?: Record<string, number>;
|
|
17
|
+
/**
|
|
18
|
+
* Per-item unit price (in the resolved currency) keyed by product id or
|
|
19
|
+
* sku. Re-prices the cart in the chosen currency instead of trusting the
|
|
20
|
+
* client-supplied price — the dumb BFF reads prices from the prices app.
|
|
21
|
+
*/
|
|
22
|
+
itemUnitPrices?: Record<string, number>;
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ShopMarket, ShopMarketLocale } from "../../app/interfaces/market";
|
|
1
|
+
import type { ShopMarket, ShopMarketCurrency, ShopMarketLocale } from "../../app/interfaces/market";
|
|
2
2
|
import type { IMarketService } from "../interfaces/market";
|
|
3
3
|
|
|
4
4
|
interface ApiMarketRow {
|
|
@@ -19,6 +19,19 @@ interface ApiLocaleRow {
|
|
|
19
19
|
position: number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
interface ApiCurrencyRow {
|
|
23
|
+
code: string;
|
|
24
|
+
is_default: boolean;
|
|
25
|
+
position: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The markets app's /context response. Typed locally because the pinned SDK
|
|
29
|
+
// (0.0.5) predates the `currencies` field — at runtime the app returns it.
|
|
30
|
+
interface ApiMarketContext {
|
|
31
|
+
locales?: ApiLocaleRow[];
|
|
32
|
+
currencies?: ApiCurrencyRow[];
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
interface ApiListPage<T> {
|
|
23
36
|
items: T[];
|
|
24
37
|
}
|
|
@@ -31,8 +44,9 @@ const cache = new Map<string, { markets: ShopMarket[]; loadedAt: number }>();
|
|
|
31
44
|
|
|
32
45
|
/**
|
|
33
46
|
* Live markets via the public revenexx API (markets app):
|
|
34
|
-
* GET /v1/markets + each market's locales
|
|
35
|
-
* renders on every page and markets
|
|
47
|
+
* GET /v1/markets + each market's /context (locales + currencies in one
|
|
48
|
+
* call). Cached briefly — the selector renders on every page and markets
|
|
49
|
+
* change rarely.
|
|
36
50
|
*/
|
|
37
51
|
export class ApiMarketService implements IMarketService {
|
|
38
52
|
async listMarkets(): Promise<ShopMarket[]> {
|
|
@@ -49,21 +63,25 @@ export class ApiMarketService implements IMarketService {
|
|
|
49
63
|
.sort((a, b) => a.position - b.position || a.code.localeCompare(b.code));
|
|
50
64
|
|
|
51
65
|
const markets = await Promise.all(active.map(async (market): Promise<ShopMarket> => {
|
|
52
|
-
const
|
|
66
|
+
const context = await sdk.markets.marketsContext({ id: market.id }) as unknown as ApiMarketContext;
|
|
67
|
+
const locales = (context.locales ?? []).slice().sort((a, b) => a.position - b.position);
|
|
68
|
+
const currencies = (context.currencies ?? []).slice().sort((a, b) => a.position - b.position);
|
|
53
69
|
return {
|
|
54
70
|
id: market.id,
|
|
55
71
|
code: market.code,
|
|
56
72
|
name: market.name,
|
|
57
73
|
currency: market.currency,
|
|
58
74
|
isDefault: market.is_default,
|
|
59
|
-
locales: locales
|
|
60
|
-
.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
locales: locales.map((locale): ShopMarketLocale => ({
|
|
76
|
+
code: locale.code,
|
|
77
|
+
language: locale.language,
|
|
78
|
+
country: locale.country,
|
|
79
|
+
isDefault: locale.is_default,
|
|
80
|
+
})),
|
|
81
|
+
currencies: currencies.map((currency): ShopMarketCurrency => ({
|
|
82
|
+
code: currency.code,
|
|
83
|
+
isDefault: currency.is_default,
|
|
84
|
+
})),
|
|
67
85
|
};
|
|
68
86
|
}));
|
|
69
87
|
|
|
@@ -122,7 +122,10 @@ export class MockCartCalculationService implements ICartCalculationService {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
const labelCode = PRICE_LABELS[item.id];
|
|
125
|
-
|
|
125
|
+
// Live mode re-prices in the resolved currency (prices app); mock
|
|
126
|
+
// mode uses the cart item's tier price.
|
|
127
|
+
const liveUnitPrice = overrides?.itemUnitPrices?.[item.id] ?? overrides?.itemUnitPrices?.[item.sku ?? ""];
|
|
128
|
+
const unitPrice = labelCode ? 0 : (liveUnitPrice ?? tieredUnitPrice(item, quantity));
|
|
126
129
|
const lineTotal = r2(unitPrice * quantity);
|
|
127
130
|
|
|
128
131
|
const surcharges = !labelCode && SURCHARGE_SKU_PREFIXES.some(p => (item.sku ?? "").startsWith(p))
|
|
@@ -305,7 +308,7 @@ export class MockCartCalculationService implements ICartCalculationService {
|
|
|
305
308
|
];
|
|
306
309
|
|
|
307
310
|
return {
|
|
308
|
-
currency: "EUR",
|
|
311
|
+
currency: overrides?.currency ?? "EUR",
|
|
309
312
|
lines,
|
|
310
313
|
totals,
|
|
311
314
|
orderable,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { H3Event } from "h3";
|
|
2
|
+
|
|
3
|
+
/** Cookie the storefront's locale selector persists the chosen currency in. */
|
|
4
|
+
export const CURRENCY_COOKIE_NAME = "cover-currency";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The buyer's chosen currency for server-side price resolution. Read from the
|
|
8
|
+
* cookie the locale selector sets — the BFF forwards it to the prices and
|
|
9
|
+
* shipping apps so the catalog, cart and order all resolve in one currency.
|
|
10
|
+
* Undefined → the prices app falls back to the price list's currency (EUR),
|
|
11
|
+
* so the shop works before any choice is made.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveSelectedCurrency(event: H3Event): string | undefined {
|
|
14
|
+
const raw = getCookie(event, CURRENCY_COOKIE_NAME);
|
|
15
|
+
if (!raw) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const code = raw.trim().toUpperCase();
|
|
19
|
+
return /^[A-Z]{3}$/.test(code) ? code : undefined;
|
|
20
|
+
}
|
|
@@ -62,9 +62,11 @@ export async function enrichProductsWithLivePrices(event: H3Event, products: Pro
|
|
|
62
62
|
if (products.length === 0) {
|
|
63
63
|
return products;
|
|
64
64
|
}
|
|
65
|
+
const currency = resolveSelectedCurrency(event);
|
|
65
66
|
const { prices } = await useRevenexxSdk().prices.pricesResolve({
|
|
66
67
|
items: products.map(p => ({ product_id: p.id, sku: p.sku, quantity: 1 })) as Models.PriceResolveItem[],
|
|
67
68
|
...priceContext(event),
|
|
69
|
+
...(currency ? { currency } : {}),
|
|
68
70
|
}) as unknown as { prices: ResolvedPrice[] };
|
|
69
71
|
|
|
70
72
|
const byKey = new Map<string, ResolvedPrice>();
|
|
@@ -85,9 +87,11 @@ export async function enrichDetailWithLivePrices(event: H3Event, detail: Product
|
|
|
85
87
|
if (!product) {
|
|
86
88
|
return detail;
|
|
87
89
|
}
|
|
90
|
+
const currency = resolveSelectedCurrency(event);
|
|
88
91
|
const { prices } = await useRevenexxSdk().prices.pricesResolve({
|
|
89
92
|
items: [{ product_id: product.id, sku: product.sku, quantity: 1 }] as Models.PriceResolveItem[],
|
|
90
93
|
...priceContext(event),
|
|
94
|
+
...(currency ? { currency } : {}),
|
|
91
95
|
}) as unknown as { prices: ResolvedPrice[] };
|
|
92
96
|
const resolved = prices[0];
|
|
93
97
|
return { ...detail, prices: resolved ? toPriceMap(resolved) : [] };
|