@laconius/cart 0.1.0

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.
Files changed (55) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +24 -0
  3. package/lib/module/adapter.js +167 -0
  4. package/lib/module/adapter.js.map +1 -0
  5. package/lib/module/components.js +212 -0
  6. package/lib/module/components.js.map +1 -0
  7. package/lib/module/config.js +53 -0
  8. package/lib/module/config.js.map +1 -0
  9. package/lib/module/defaults.js +57 -0
  10. package/lib/module/defaults.js.map +1 -0
  11. package/lib/module/index.js +11 -0
  12. package/lib/module/index.js.map +1 -0
  13. package/lib/module/models.js +4 -0
  14. package/lib/module/models.js.map +1 -0
  15. package/lib/module/normalizer.js +36 -0
  16. package/lib/module/normalizer.js.map +1 -0
  17. package/lib/module/package.json +1 -0
  18. package/lib/module/queries.js +287 -0
  19. package/lib/module/queries.js.map +1 -0
  20. package/lib/module/store.js +42 -0
  21. package/lib/module/store.js.map +1 -0
  22. package/lib/module/translations.js +31 -0
  23. package/lib/module/translations.js.map +1 -0
  24. package/lib/typescript/package.json +1 -0
  25. package/lib/typescript/src/adapter.d.ts +25 -0
  26. package/lib/typescript/src/adapter.d.ts.map +1 -0
  27. package/lib/typescript/src/components.d.ts +49 -0
  28. package/lib/typescript/src/components.d.ts.map +1 -0
  29. package/lib/typescript/src/config.d.ts +57 -0
  30. package/lib/typescript/src/config.d.ts.map +1 -0
  31. package/lib/typescript/src/defaults.d.ts +16 -0
  32. package/lib/typescript/src/defaults.d.ts.map +1 -0
  33. package/lib/typescript/src/index.d.ts +10 -0
  34. package/lib/typescript/src/index.d.ts.map +1 -0
  35. package/lib/typescript/src/models.d.ts +122 -0
  36. package/lib/typescript/src/models.d.ts.map +1 -0
  37. package/lib/typescript/src/normalizer.d.ts +24 -0
  38. package/lib/typescript/src/normalizer.d.ts.map +1 -0
  39. package/lib/typescript/src/queries.d.ts +47 -0
  40. package/lib/typescript/src/queries.d.ts.map +1 -0
  41. package/lib/typescript/src/store.d.ts +25 -0
  42. package/lib/typescript/src/store.d.ts.map +1 -0
  43. package/lib/typescript/src/translations.d.ts +29 -0
  44. package/lib/typescript/src/translations.d.ts.map +1 -0
  45. package/package.json +78 -0
  46. package/src/adapter.ts +187 -0
  47. package/src/components.tsx +237 -0
  48. package/src/config.ts +81 -0
  49. package/src/defaults.tsx +53 -0
  50. package/src/index.ts +61 -0
  51. package/src/models.ts +142 -0
  52. package/src/normalizer.ts +39 -0
  53. package/src/queries.ts +288 -0
  54. package/src/store.ts +46 -0
  55. package/src/translations.ts +28 -0
@@ -0,0 +1,237 @@
1
+ import { getErrorMessage, getPrimaryImage, useTranslation } from '@laconius/core';
2
+ import {
3
+ Button,
4
+ Divider,
5
+ Media,
6
+ Price,
7
+ QuantityStepper,
8
+ Text,
9
+ useStyles,
10
+ useUiComponent,
11
+ type Theme,
12
+ } from '@laconius/ui';
13
+ import {
14
+ AccessibilityInfo,
15
+ StyleSheet,
16
+ View,
17
+ type ImageStyle,
18
+ type StyleProp,
19
+ type TextStyle,
20
+ type ViewStyle,
21
+ } from 'react-native';
22
+
23
+ import type { Cart, OrderEntry } from './models';
24
+ import { useAddToCart, useRemoveCartEntry, useUpdateCartEntry } from './queries';
25
+
26
+ export type AddToCartButtonProps = {
27
+ productCode: string;
28
+ quantity?: number;
29
+ disabled?: boolean;
30
+ /** Root style. */
31
+ style?: StyleProp<ViewStyle>;
32
+ /** Named inner parts — public, versioned API. */
33
+ styles?: {
34
+ container?: StyleProp<ViewStyle>;
35
+ error?: StyleProp<TextStyle>;
36
+ };
37
+ };
38
+
39
+ /**
40
+ * The one component with no UI hook: its logic *is* the mutation
41
+ * ([chapter 07](../../../docs/spec/07-ui-kit.md)). An OCC business error — out of stock, most
42
+ * likely — reaches the user as a translated sentence, per hook, with no message bus.
43
+ */
44
+ export function AddToCartButton({
45
+ productCode,
46
+ quantity = 1,
47
+ disabled = false,
48
+ style,
49
+ styles: parts,
50
+ }: AddToCartButtonProps) {
51
+ const t = useTranslation();
52
+ const sheet = useStyles(createStyles);
53
+ const Action = useUiComponent('Button', Button);
54
+ const addToCart = useAddToCart();
55
+
56
+ return (
57
+ <View style={[sheet.addToCart, parts?.container, style]}>
58
+ <Action
59
+ title={t('cart.addToCart')}
60
+ accessibilityLabel={t('cart.addToCart')}
61
+ loading={addToCart.isPending}
62
+ disabled={disabled || addToCart.isPending || !productCode}
63
+ onPress={() =>
64
+ addToCart.mutate(
65
+ { productCode, quantity },
66
+ {
67
+ onSuccess: () =>
68
+ AccessibilityInfo.announceForAccessibility(t('cart.itemAdded')),
69
+ onError: (error) =>
70
+ AccessibilityInfo.announceForAccessibility(getErrorMessage(error, t)),
71
+ },
72
+ )
73
+ }
74
+ />
75
+ {addToCart.error ? (
76
+ <Text variant="caption" style={[sheet.error, parts?.error]}>
77
+ {getErrorMessage(addToCart.error, t)}
78
+ </Text>
79
+ ) : null}
80
+ </View>
81
+ );
82
+ }
83
+
84
+ export type CartEntryRowProps = {
85
+ entry: OrderEntry;
86
+ /** Root style. */
87
+ style?: StyleProp<ViewStyle>;
88
+ styles?: {
89
+ container?: StyleProp<ViewStyle>;
90
+ media?: StyleProp<ImageStyle>;
91
+ info?: StyleProp<ViewStyle>;
92
+ title?: StyleProp<TextStyle>;
93
+ price?: StyleProp<TextStyle>;
94
+ error?: StyleProp<TextStyle>;
95
+ };
96
+ };
97
+
98
+ /** Quantity and remove, one mutation hook each so each keeps its own `isPending` and `error`. */
99
+ export function CartEntryRow({ entry, style, styles: parts }: CartEntryRowProps) {
100
+ const t = useTranslation();
101
+ const sheet = useStyles(createStyles);
102
+ const Remove = useUiComponent('Button', Button);
103
+ const update = useUpdateCartEntry();
104
+ const remove = useRemoveCartEntry();
105
+
106
+ const entryNumber = entry.entryNumber;
107
+ const busy = update.isPending || remove.isPending;
108
+ const error = update.error ?? remove.error;
109
+
110
+ return (
111
+ <View style={[sheet.row, parts?.container, style]}>
112
+ <Media
113
+ media={getPrimaryImage(entry.product?.images)}
114
+ mediaRole="cartIcon"
115
+ decorative
116
+ style={[sheet.rowMedia, parts?.media]}
117
+ />
118
+ <View style={[sheet.rowInfo, parts?.info]}>
119
+ <Text variant="label" numberOfLines={2} style={parts?.title}>
120
+ {entry.product?.name}
121
+ </Text>
122
+ <Price price={entry.totalPrice} variant="label" style={parts?.price} />
123
+ <View style={sheet.rowActions}>
124
+ <QuantityStepper
125
+ value={entry.quantity ?? 1}
126
+ max={entry.product?.stock?.stockLevel}
127
+ disabled={entryNumber === undefined || busy}
128
+ onChange={(value) => {
129
+ if (entryNumber === undefined) return;
130
+ update.mutate(
131
+ { entryNumber, quantity: value },
132
+ {
133
+ onSuccess: () =>
134
+ AccessibilityInfo.announceForAccessibility(t('cart.quantityUpdated')),
135
+ },
136
+ );
137
+ }}
138
+ />
139
+ <Remove
140
+ variant="ghost"
141
+ title={t('cart.remove')}
142
+ accessibilityLabel={t('cart.removeItem', { name: entry.product?.name ?? '' })}
143
+ disabled={entryNumber === undefined || busy}
144
+ onPress={() => {
145
+ if (entryNumber === undefined) return;
146
+ remove.mutate(
147
+ { entryNumber },
148
+ {
149
+ onSuccess: () =>
150
+ AccessibilityInfo.announceForAccessibility(t('cart.itemRemoved')),
151
+ },
152
+ );
153
+ }}
154
+ />
155
+ </View>
156
+ {error ? (
157
+ <Text variant="caption" style={[sheet.error, parts?.error]}>
158
+ {getErrorMessage(error, t)}
159
+ </Text>
160
+ ) : null}
161
+ </View>
162
+ </View>
163
+ );
164
+ }
165
+
166
+ export type CartTotalsProps = {
167
+ cart?: Cart;
168
+ /** Root style. */
169
+ style?: StyleProp<ViewStyle>;
170
+ styles?: {
171
+ container?: StyleProp<ViewStyle>;
172
+ row?: StyleProp<ViewStyle>;
173
+ label?: StyleProp<TextStyle>;
174
+ value?: StyleProp<TextStyle>;
175
+ };
176
+ };
177
+
178
+ /** Pure component: the cart arrives as a prop, exactly like `Price` ([chapter 07](../../../docs/spec/07-ui-kit.md)). */
179
+ export function CartTotals({ cart, style, styles: parts }: CartTotalsProps) {
180
+ const t = useTranslation();
181
+ const sheet = useStyles(createStyles);
182
+ if (!cart) return null;
183
+
184
+ const rows: { key: string; label: string; price: Cart['subTotal'] }[] = [];
185
+ if (cart.subTotal) rows.push({ key: 'subtotal', label: t('cart.subtotal'), price: cart.subTotal });
186
+ if (cart.totalDiscounts?.value) {
187
+ rows.push({ key: 'discounts', label: t('cart.discounts'), price: cart.totalDiscounts });
188
+ }
189
+ if (cart.deliveryCost) {
190
+ rows.push({ key: 'delivery', label: t('cart.delivery'), price: cart.deliveryCost });
191
+ }
192
+ if (cart.totalTax?.value) rows.push({ key: 'tax', label: t('cart.tax'), price: cart.totalTax });
193
+
194
+ return (
195
+ <View style={[sheet.totals, parts?.container, style]}>
196
+ {rows.map((row) => (
197
+ <View key={row.key} style={[sheet.totalsRow, parts?.row]}>
198
+ <Text variant="caption" muted style={parts?.label}>
199
+ {row.label}
200
+ </Text>
201
+ <Price price={row.price} variant="caption" style={parts?.value} />
202
+ </View>
203
+ ))}
204
+ <Divider />
205
+ <View style={[sheet.totalsRow, parts?.row]}>
206
+ <Text variant="label" style={parts?.label}>
207
+ {t('cart.total')}
208
+ </Text>
209
+ <Price
210
+ price={cart.totalPriceWithTax ?? cart.totalPrice}
211
+ variant="subheading"
212
+ style={parts?.value}
213
+ />
214
+ </View>
215
+ </View>
216
+ );
217
+ }
218
+
219
+ const createStyles = (theme: Theme) =>
220
+ StyleSheet.create({
221
+ addToCart: { gap: theme.spacing.xs },
222
+ error: { color: theme.colors.danger },
223
+ row: { flexDirection: 'row', gap: theme.spacing.md },
224
+ rowMedia: { width: 64, height: 64, borderRadius: theme.radii.sm },
225
+ rowInfo: { flex: 1, gap: theme.spacing.xs },
226
+ rowActions: {
227
+ flexDirection: 'row',
228
+ alignItems: 'center',
229
+ justifyContent: 'space-between',
230
+ },
231
+ totals: { gap: theme.spacing.xs },
232
+ totalsRow: {
233
+ flexDirection: 'row',
234
+ alignItems: 'center',
235
+ justifyContent: 'space-between',
236
+ },
237
+ });
package/src/config.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { LaconiusConfigChunk } from '@laconius/core';
2
+ import type { CmsComponentMapping } from '@laconius/cms';
3
+
4
+ import { defaultCartConverters, type CartAdapter } from './adapter';
5
+ import { CmsMiniCart } from './defaults';
6
+ import { defaultCartTranslations } from './translations';
7
+
8
+ declare module '@laconius/ui' {
9
+ /** The cart components, replaceable through the typed UI registry. */
10
+ interface LaconiusUiComponents {
11
+ AddToCartButton: unknown;
12
+ CartEntryRow: unknown;
13
+ CartTotals: unknown;
14
+ }
15
+ }
16
+
17
+ declare module '@laconius/core' {
18
+ interface LaconiusEndpoints {
19
+ carts: string | Record<string, string>;
20
+ cart: string | Record<string, string>;
21
+ createCart: string | Record<string, string>;
22
+ deleteCart: string | Record<string, string>;
23
+ addEntries: string | Record<string, string>;
24
+ updateEntries: string | Record<string, string>;
25
+ removeEntries: string | Record<string, string>;
26
+ cartApplyVoucher: string | Record<string, string>;
27
+ cartRemoveVoucher: string | Record<string, string>;
28
+ cartVoucher: string | Record<string, string>;
29
+ validate: string | Record<string, string>;
30
+ }
31
+
32
+ interface LaconiusConverters {
33
+ cart: unknown;
34
+ cartModification: unknown;
35
+ }
36
+
37
+ interface LaconiusAdapters {
38
+ cart: CartAdapter;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Ported from `feature-libs/cart/base/occ/config/default-occ-cart-config-factory.ts`, minus the
44
+ * saved-cart fields (`saveTime`, `name`, `description` stay off the wire — v1 ships one active
45
+ * cart). `stock(FULL)` on entry products is what lets the stepper cap at the stock level.
46
+ */
47
+ const CART_FIELDS =
48
+ 'DEFAULT,potentialProductPromotions,appliedProductPromotions,potentialOrderPromotions,appliedOrderPromotions,entries(totalPrice(formattedValue),product(images(FULL),stock(FULL)),basePrice(formattedValue,value),updateable),totalPrice(formattedValue),totalItems,totalPriceWithTax(formattedValue),totalDiscounts(value,formattedValue),subTotal(formattedValue),totalUnitCount,deliveryItemsQuantity,deliveryCost(formattedValue),totalTax(formattedValue,value),pickupItemsQuantity,net,appliedVouchers,productDiscounts(formattedValue),user';
49
+
50
+ export const defaultCartEndpoints = {
51
+ carts: 'users/${userId}/carts?fields=carts(' + CART_FIELDS + ')',
52
+ cart: 'users/${userId}/carts/${cartId}?fields=' + CART_FIELDS,
53
+ createCart: 'users/${userId}/carts?fields=' + CART_FIELDS,
54
+ deleteCart: 'users/${userId}/carts/${cartId}',
55
+ addEntries: 'users/${userId}/carts/${cartId}/entries',
56
+ updateEntries: 'users/${userId}/carts/${cartId}/entries/${entryNumber}',
57
+ removeEntries: 'users/${userId}/carts/${cartId}/entries/${entryNumber}',
58
+ cartApplyVoucher: 'users/${userId}/carts/${cartId}/applyVoucher',
59
+ cartRemoveVoucher: 'users/${userId}/carts/${cartId}/removeVoucher',
60
+ /** The legacy pair, used when `capabilities.jsonVouchers` is off. */
61
+ cartVoucher: {
62
+ default: 'users/${userId}/carts/${cartId}/vouchers',
63
+ delete: 'users/${userId}/carts/${cartId}/vouchers/${voucherId}',
64
+ },
65
+ validate: 'users/${userId}/carts/${cartId}/validate?fields=DEFAULT',
66
+ };
67
+
68
+ /**
69
+ * The content types that read cart data ship here rather than in `@laconius/cms`, which keeps
70
+ * the dependency arrow `cart -> cms` ([chapter 01](../../../docs/spec/01-packages.md)).
71
+ */
72
+ export const defaultCartCmsComponents: Record<string, CmsComponentMapping> = {
73
+ MiniCartComponent: { component: CmsMiniCart },
74
+ };
75
+
76
+ export const defaultCartConfig: LaconiusConfigChunk = {
77
+ backend: { occ: { endpoints: defaultCartEndpoints } },
78
+ cms: { components: defaultCartCmsComponents },
79
+ converters: defaultCartConverters,
80
+ i18n: { translations: defaultCartTranslations },
81
+ };
@@ -0,0 +1,53 @@
1
+ import { useTranslation } from '@laconius/core';
2
+ import { useCmsComponentData, useCmsNavigate, type CmsComponent } from '@laconius/cms';
3
+ import { Price, Text, useStyles, type Theme } from '@laconius/ui';
4
+ import { Pressable, StyleSheet } from 'react-native';
5
+
6
+ import { useActiveCart } from './queries';
7
+
8
+ /** Spartacus' `CmsMiniCartComponent` fields; the lightbox banner is web furniture and dropped. */
9
+ export interface CmsMiniCartData extends CmsComponent {
10
+ title?: string;
11
+ shownProductCount?: string;
12
+ /** `TOTAL` or `SUBTOTAL`. */
13
+ totalDisplay?: string;
14
+ }
15
+
16
+ /**
17
+ * Item count and total. It ships here rather than in `@laconius/cms` because it reads cart data,
18
+ * which is what keeps the dependency arrow `cart -> cms`
19
+ * ([chapter 01](../../../docs/spec/01-packages.md)) — the same reason Spartacus ships
20
+ * `MiniCartComponent` from `feature-libs/cart/base/components/mini-cart/`.
21
+ */
22
+ export function CmsMiniCart() {
23
+ const data = useCmsComponentData<CmsMiniCartData>();
24
+ const t = useTranslation();
25
+ const sheet = useStyles(createStyles);
26
+ const navigate = useCmsNavigate();
27
+ const { data: cart } = useActiveCart();
28
+
29
+ const count = cart?.totalUnitCount ?? cart?.totalItems ?? 0;
30
+ const total = data.totalDisplay === 'SUBTOTAL' ? cart?.subTotal : cart?.totalPrice;
31
+
32
+ return (
33
+ <Pressable
34
+ accessibilityRole="button"
35
+ accessibilityLabel={`${data.title ?? t('cart.title')}: ${t('cart.entryCount', { count })}`}
36
+ onPress={() => navigate({ kind: 'url', url: '/cart', external: false })}
37
+ style={sheet.root}
38
+ >
39
+ <Text variant="label">{t('cart.entryCount', { count })}</Text>
40
+ {count > 0 && total ? <Price price={total} variant="label" /> : null}
41
+ </Pressable>
42
+ );
43
+ }
44
+
45
+ const createStyles = (theme: Theme) =>
46
+ StyleSheet.create({
47
+ root: {
48
+ flexDirection: 'row',
49
+ alignItems: 'center',
50
+ gap: theme.spacing.sm,
51
+ minHeight: 44,
52
+ },
53
+ });
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ export type {
2
+ AddToCartInput,
3
+ Cart,
4
+ CartMergeFailure,
5
+ CartMergeResult,
6
+ CartModification,
7
+ CartModificationList,
8
+ CartMutationResult,
9
+ CartValidationStatusCode,
10
+ DeliveryMode,
11
+ OrderEntry,
12
+ PromotionOrderEntryConsumed,
13
+ PromotionResult,
14
+ Voucher,
15
+ } from './models';
16
+
17
+ export {
18
+ getCartId,
19
+ normalizeCartImages,
20
+ type OccCart,
21
+ type OccOrderEntry,
22
+ } from './normalizer';
23
+
24
+ export {
25
+ cartAdapter,
26
+ defaultCartAdapter,
27
+ defaultCartConverters,
28
+ type CartAdapter,
29
+ } from './adapter';
30
+
31
+ export {
32
+ cartQueries,
33
+ useActiveCart,
34
+ useAddToCart,
35
+ useApplyVoucher,
36
+ useMergeAnonymousCart,
37
+ useRemoveCartEntry,
38
+ useRemoveVoucher,
39
+ useUpdateCartEntry,
40
+ } from './queries';
41
+
42
+ export { useActiveCartIdStore, type ActiveCartState } from './store';
43
+
44
+ export {
45
+ AddToCartButton,
46
+ CartEntryRow,
47
+ CartTotals,
48
+ type AddToCartButtonProps,
49
+ type CartEntryRowProps,
50
+ type CartTotalsProps,
51
+ } from './components';
52
+
53
+ export { CmsMiniCart, type CmsMiniCartData } from './defaults';
54
+
55
+ export {
56
+ defaultCartCmsComponents,
57
+ defaultCartConfig,
58
+ defaultCartEndpoints,
59
+ } from './config';
60
+
61
+ export { defaultCartTranslations } from './translations';
package/src/models.ts ADDED
@@ -0,0 +1,142 @@
1
+ import type { Currency, Price, Principal, Product, Promotion } from '@laconius/core';
2
+
3
+ /**
4
+ * Cart models, ported from Spartacus `feature-libs/cart/base/root/models/cart.model.ts`
5
+ * (Apache-2.0, see NOTICE), minus the B2B, saved-cart and pickup-in-store noise
6
+ * ([chapter 03](../../../docs/spec/03-occ-layer.md)).
7
+ *
8
+ * `CartModification` is copied **exactly**: it encodes protocol rather than data — a cart
9
+ * mutation answers with an adjusted quantity and a status, and an API returning `void` there
10
+ * would be wrong.
11
+ */
12
+
13
+ export interface Voucher {
14
+ appliedValue?: Price;
15
+ code?: string;
16
+ currency?: Currency;
17
+ description?: string;
18
+ freeShipping?: boolean;
19
+ name?: string;
20
+ value?: number;
21
+ valueFormatted?: string;
22
+ valueString?: string;
23
+ voucherCode?: string;
24
+ }
25
+
26
+ export interface PromotionOrderEntryConsumed {
27
+ adjustedUnitPrice?: number;
28
+ code?: string;
29
+ orderEntryNumber?: number;
30
+ quantity?: number;
31
+ }
32
+
33
+ export interface PromotionResult {
34
+ consumedEntries?: PromotionOrderEntryConsumed[];
35
+ description?: string;
36
+ promotion?: Promotion;
37
+ }
38
+
39
+ export interface DeliveryMode {
40
+ code?: string;
41
+ deliveryCost?: Price;
42
+ description?: string;
43
+ name?: string;
44
+ }
45
+
46
+ export interface OrderEntry {
47
+ basePrice?: Price;
48
+ deliveryMode?: DeliveryMode;
49
+ entryNumber?: number;
50
+ product?: Product;
51
+ quantity?: number;
52
+ totalPrice?: Price;
53
+ updateable?: boolean;
54
+ promotions?: PromotionResult[];
55
+ }
56
+
57
+ export interface Cart {
58
+ appliedOrderPromotions?: PromotionResult[];
59
+ appliedProductPromotions?: PromotionResult[];
60
+ appliedVouchers?: Voucher[];
61
+ calculated?: boolean;
62
+ code?: string;
63
+ deliveryCost?: Price;
64
+ deliveryItemsQuantity?: number;
65
+ deliveryMode?: DeliveryMode;
66
+ description?: string;
67
+ entries?: OrderEntry[];
68
+ /** ISO string: OCC returns strings and never `Date`. */
69
+ expirationTime?: string;
70
+ guid?: string;
71
+ name?: string;
72
+ net?: boolean;
73
+ orderDiscounts?: Price;
74
+ pickupItemsQuantity?: number;
75
+ potentialOrderPromotions?: PromotionResult[];
76
+ potentialProductPromotions?: PromotionResult[];
77
+ productDiscounts?: Price;
78
+ site?: string;
79
+ store?: string;
80
+ subTotal?: Price;
81
+ totalDiscounts?: Price;
82
+ totalItems?: number;
83
+ totalPrice?: Price;
84
+ totalPriceWithTax?: Price;
85
+ totalTax?: Price;
86
+ totalUnitCount?: number;
87
+ user?: Principal;
88
+ }
89
+
90
+ export interface CartModification {
91
+ deliveryModeChanged?: boolean;
92
+ entry?: OrderEntry;
93
+ quantity?: number;
94
+ quantityAdded?: number;
95
+ statusCode?: string;
96
+ statusMessage?: string;
97
+ }
98
+
99
+ export interface CartModificationList {
100
+ cartModifications?: CartModification[];
101
+ }
102
+
103
+ export type CartValidationStatusCode =
104
+ | 'noStock'
105
+ | 'lowStock'
106
+ | 'reviewConfiguration'
107
+ | 'pricingError'
108
+ | 'unresolvableIssues'
109
+ | 'below_min_quantity'
110
+ | 'exceed_max_quantity';
111
+
112
+ export type AddToCartInput = {
113
+ productCode: string;
114
+ quantity?: number;
115
+ };
116
+
117
+ /**
118
+ * What every cart mutation resolves to. It carries the cart id it acted on: with lazy creation,
119
+ * an `onSuccess` closing over `activeCartId` writes the wrong cache entry
120
+ * ([chapter 04](../../../docs/spec/04-state.md)).
121
+ */
122
+ export type CartMutationResult = {
123
+ cart: Cart;
124
+ cartId: string;
125
+ modification?: CartModification;
126
+ };
127
+
128
+ export type CartMergeFailure = {
129
+ entry: OrderEntry;
130
+ /** The `LaconiusHttpError` that rejected the replay. */
131
+ error: unknown;
132
+ };
133
+
134
+ /**
135
+ * The one place Laconius returns errors as data rather than throwing them: a partial replay is
136
+ * not a failure, and silently dropping the rejected entries is exactly what users notice
137
+ * ([chapter 09](../../../docs/spec/09-i18n-and-errors.md)).
138
+ */
139
+ export type CartMergeResult = {
140
+ replayed: OrderEntry[];
141
+ failed: CartMergeFailure[];
142
+ };
@@ -0,0 +1,39 @@
1
+ import { normalizeImages, type Image, type Product } from '@laconius/core';
2
+
3
+ import type { Cart, OrderEntry } from './models';
4
+
5
+ /** An entry as OCC answers it: the product's images are still a flat list. */
6
+ export type OccOrderEntry = Omit<OrderEntry, 'product'> & {
7
+ product?: Omit<Product, 'images'> & { images?: Image[] };
8
+ };
9
+
10
+ export type OccCart = Omit<Cart, 'entries'> & { entries?: OccOrderEntry[] };
11
+
12
+ /**
13
+ * Reshapes every entry product's flat image list through core's `normalizeImages` — the same
14
+ * mechanism the product adapter uses ([chapter 08](../../../docs/spec/08-media.md)). URLs are
15
+ * not absolutised here: converters are pure, so the adapter absolutises the whole payload
16
+ * afterwards.
17
+ */
18
+ export function normalizeCartImages(source: OccCart, target?: Cart): Cart {
19
+ const cart = target ?? ({ ...source } as unknown as Cart);
20
+ if (source.entries) {
21
+ cart.entries = source.entries.map((entry) =>
22
+ entry.product?.images
23
+ ? ({
24
+ ...entry,
25
+ product: { ...entry.product, images: normalizeImages(entry.product.images) },
26
+ } as OrderEntry)
27
+ : (entry as OrderEntry),
28
+ );
29
+ }
30
+ return cart;
31
+ }
32
+
33
+ /**
34
+ * Ported from `feature-libs/cart/base/core/utils/utils.ts`: an anonymous cart is addressed by
35
+ * its `guid`, a logged-in user's cart by its `code`.
36
+ */
37
+ export function getCartId(cart: Cart | undefined, userId: string): string {
38
+ return (userId === 'anonymous' ? cart?.guid : cart?.code) ?? '';
39
+ }