kabo-contracts 0.0.0-stage → 1.0.3
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/README.md +101 -2
- package/package.json +34 -4
- package/src/cart.js +23 -0
- package/src/common.js +52 -0
- package/src/enums.js +37 -0
- package/src/index.js +6 -0
- package/src/message.js +32 -0
- package/src/order.js +124 -0
- package/src/product.js +33 -0
package/README.md
CHANGED
|
@@ -1,3 +1,102 @@
|
|
|
1
|
-
#
|
|
1
|
+
# kabo-contracts
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The data contracts shared by `kabobee-frontend` and `kabobee-backend`: [zod](https://zod.dev) schemas
|
|
4
|
+
and enums, in plain JavaScript. A rule written here is enforced in the browser (form errors) **and**
|
|
5
|
+
on the server (400 responses) with the same Bulgarian message.
|
|
6
|
+
|
|
7
|
+
## What is in it
|
|
8
|
+
|
|
9
|
+
| File | Exports | Used by |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `src/enums.js` | `DELIVERY_METHOD`, `PAYMENT_METHOD`, `INVOICE_TYPE`, `MESSAGE_TYPE`, `PRODUCT_CATEGORY`, `PRODUCT_AUDIENCE`, `CURRENCY`, `MAX_ITEM_QUANTITY` | both |
|
|
12
|
+
| `src/common.js` | `MESSAGES` (Bulgarian error texts), `EMAIL_REGEX`, `PHONE_NUMBER_REGEX`, building blocks (`requiredText`, `optionalText`, `econtId`, …) | both |
|
|
13
|
+
| `src/order.js` | `checkoutSchema` (checkout form), `createOrderSchema` (`POST /orders` = form + `products`) | form / API |
|
|
14
|
+
| `src/cart.js` | `cartItemSchema`, `cartItemsSchema`, `deliveryPriceRequestSchema` (`POST /econt/delivery/price`) | API |
|
|
15
|
+
| `src/message.js` | `contactMessageSchema`, `wholesaleMessageSchema` (forms), `createMessageSchema` (`POST /messages`) | form / API |
|
|
16
|
+
| `src/product.js` | `productInputSchema` (seed script), `productSchema` (`GET /products`) | both |
|
|
17
|
+
|
|
18
|
+
**Belongs here:** request bodies, response shapes and values that both sides must read the same way.
|
|
19
|
+
**Does not:** React or UI code (→ `@kabo/shared` in the frontend), business logic such as prices or
|
|
20
|
+
Econt calls (→ backend), secrets, environment config.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
Published on npmjs.com as [`kabo-contracts`](https://www.npmjs.com/package/kabo-contracts); no login needed.
|
|
25
|
+
Install it under the name the code imports, `@kabo/contracts`, with an npm alias:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install @kabo/contracts@npm:kabo-contracts@^1.0.3
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
"dependencies": { "@kabo/contracts": "npm:kabo-contracts@^1.0.3" }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`^1.0.3` takes new patch and minor versions on the next `npm update`; a major version (breaking
|
|
36
|
+
change) needs an explicit bump. Plain ES modules, no build step. The backend (CommonJS) can
|
|
37
|
+
`require()` it on Node ≥ 20.19.
|
|
38
|
+
|
|
39
|
+
## Use
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
// frontend – checkout form
|
|
43
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
44
|
+
import { checkoutSchema } from '@kabo/contracts';
|
|
45
|
+
|
|
46
|
+
const form = useForm({ defaultValues, resolver: zodResolver(checkoutSchema) });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
// backend – POST /orders
|
|
51
|
+
const { createOrderSchema } = require('@kabo/contracts');
|
|
52
|
+
|
|
53
|
+
const result = createOrderSchema.safeParse(req.body);
|
|
54
|
+
if (!result.success) {
|
|
55
|
+
return res.status(400).json({ message: result.error.issues[0].message, issues: result.error.issues });
|
|
56
|
+
}
|
|
57
|
+
const order = result.data; // trimmed strings, numeric Econt ids, unknown fields removed
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Editor types without TypeScript (VS Code reads JSDoc):
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
/** @param {import('@kabo/contracts').CreateOrder} order */
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Two rules worth knowing
|
|
67
|
+
|
|
68
|
+
- **All errors at once.** Zod skips an object's cross-field rules when any field fails, so the checkout
|
|
69
|
+
schema keeps its fields loose and checks every "required" rule in one `superRefine`. A customer who
|
|
70
|
+
submits an empty form sees every missing field, not just the first few.
|
|
71
|
+
- **An empty required field gets only "required"**, never "required" plus "invalid email".
|
|
72
|
+
|
|
73
|
+
## Change a rule
|
|
74
|
+
|
|
75
|
+
1. Edit the schema and its test in `test/`; `npm test`.
|
|
76
|
+
2. Bump `version` in `package.json`, add a line to `CHANGELOG.md`, commit, `git tag vX.Y.Z`, push with tags.
|
|
77
|
+
The **Publish to npm** GitHub Action tests and publishes it (the tag must equal the version).
|
|
78
|
+
3. `npm update @kabo/contracts` in the **backend first**, then the frontend (for a major version,
|
|
79
|
+
change the range in `package.json`, e.g. `npm:kabo-contracts@^2.0.0`).
|
|
80
|
+
|
|
81
|
+
| Change | Version | Example |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| Message text, bug fix | patch `1.0.1` | typo in an error message |
|
|
84
|
+
| New optional field, new enum value | minor `1.1.0` | `orders.store` |
|
|
85
|
+
| Field renamed, removed or newly required; enum value changed | major `2.0.0` | `phoneNumber` → `phone` – deploy backend before frontend |
|
|
86
|
+
|
|
87
|
+
## Publishing setup (once)
|
|
88
|
+
|
|
89
|
+
Releases are published by `.github/workflows/publish.yml` with npm **trusted publishing**: no token,
|
|
90
|
+
nothing expires. Configured on npmjs.com → kabo-contracts → Settings → Trusted Publisher →
|
|
91
|
+
GitHub Actions (`bobikoki99` / `kabo-contracts` / `publish.yml`), with publishing access set to
|
|
92
|
+
"Require two-factor authentication and disallow tokens". Nobody needs to log in to npm locally.
|
|
93
|
+
|
|
94
|
+
The very first version was published with a 7-day token in the `NPM_TOKEN` secret, because trusted
|
|
95
|
+
publishing can only be enabled for a package that already exists; that secret is deleted afterwards.
|
|
96
|
+
|
|
97
|
+
## Develop
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npm install
|
|
101
|
+
npm test
|
|
102
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kabo-contracts",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "Data contracts shared by the Kabo frontend and backend: zod schemas and enums",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"kabo",
|
|
7
|
+
"zod",
|
|
8
|
+
"contracts"
|
|
9
|
+
],
|
|
10
|
+
"license": "UNLICENSED",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/bobikoki99/kabo-contracts.git"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/bobikoki99/kabo-contracts#readme",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": "./src/index.js",
|
|
18
|
+
"files": [
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20.19"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "vitest run"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"zod": "^4.6.7"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"vitest": "^5.1.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/cart.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { MESSAGES, econtId, mongoId } from './common.js';
|
|
4
|
+
import { MAX_ITEM_QUANTITY } from './enums.js';
|
|
5
|
+
|
|
6
|
+
// What the client sends for each cart line; prices and weights always come from the database
|
|
7
|
+
export const cartItemSchema = z.object({
|
|
8
|
+
_id: mongoId,
|
|
9
|
+
quantity: z.number().int().min(1).max(MAX_ITEM_QUANTITY)
|
|
10
|
+
}, { error: MESSAGES.INVALID_CART_ITEM });
|
|
11
|
+
|
|
12
|
+
export const cartItemsSchema = z
|
|
13
|
+
.array(cartItemSchema, { error: MESSAGES.CART_EMPTY })
|
|
14
|
+
.min(1, MESSAGES.CART_EMPTY);
|
|
15
|
+
|
|
16
|
+
// POST /econt/delivery/price
|
|
17
|
+
export const deliveryPriceRequestSchema = z.object({
|
|
18
|
+
city: econtId.refine(city => city !== null, MESSAGES.REQUIRED),
|
|
19
|
+
items: cartItemsSchema
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** @typedef {import('zod').infer<typeof cartItemSchema>} CartItem */
|
|
23
|
+
/** @typedef {import('zod').infer<typeof deliveryPriceRequestSchema>} DeliveryPriceRequest */
|
package/src/common.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Error messages are shown to customers next to the form fields, so they are in Bulgarian
|
|
4
|
+
export const MESSAGES = Object.freeze({
|
|
5
|
+
REQUIRED: 'Това поле е задължително.',
|
|
6
|
+
TOO_LONG: 'Текстът е твърде дълъг.',
|
|
7
|
+
INVALID_EMAIL: 'Невалиден имейл адрес.',
|
|
8
|
+
INVALID_PHONE: 'Въведете валиден български телефонен номер, напр. 0888 123 456.',
|
|
9
|
+
TERMS_REQUIRED: 'Необходимо е да приемете Общите условия.',
|
|
10
|
+
INVALID_CHOICE: 'Изберете една от възможностите.',
|
|
11
|
+
CART_EMPTY: 'Количката е празна.',
|
|
12
|
+
INVALID_CART_ITEM: 'Невалиден продукт в количката.'
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const MAX_TEXT_LENGTH = 300;
|
|
16
|
+
|
|
17
|
+
// One address with a dot in the domain; deliberately simple, the same rule everywhere
|
|
18
|
+
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
19
|
+
|
|
20
|
+
// Bulgarian mobile or landline number once spaces, dashes and brackets are removed:
|
|
21
|
+
// 0888123456, +359888123456, 00359888123456, 029876543
|
|
22
|
+
export const PHONE_NUMBER_REGEX = /^(\+359|00359|0)\d{8,9}$/;
|
|
23
|
+
|
|
24
|
+
export const MONGO_ID_REGEX = /^[0-9a-f]{24}$/i;
|
|
25
|
+
|
|
26
|
+
export const isEmail = (value) => EMAIL_REGEX.test(value);
|
|
27
|
+
export const isPhoneNumber = (value) => PHONE_NUMBER_REGEX.test(value.replace(/[\s\-()]/g, ''));
|
|
28
|
+
|
|
29
|
+
// Trimmed text that may be empty; missing becomes ''
|
|
30
|
+
export const optionalText = (max = MAX_TEXT_LENGTH) => z.string().trim().max(max, MESSAGES.TOO_LONG).default('');
|
|
31
|
+
|
|
32
|
+
// Trimmed text that must not be empty
|
|
33
|
+
export const requiredText = (max = MAX_TEXT_LENGTH) => z
|
|
34
|
+
.string({ error: MESSAGES.REQUIRED })
|
|
35
|
+
.trim()
|
|
36
|
+
.min(1, { error: MESSAGES.REQUIRED, abort: true }) // an empty field gets only "required"
|
|
37
|
+
.max(max, MESSAGES.TOO_LONG);
|
|
38
|
+
|
|
39
|
+
export const requiredEmail = (max = MAX_TEXT_LENGTH) => requiredText(max).refine(isEmail, MESSAGES.INVALID_EMAIL);
|
|
40
|
+
|
|
41
|
+
export const optionalPhoneNumber = (max = MAX_TEXT_LENGTH) => optionalText(max)
|
|
42
|
+
.refine(value => !value || isPhoneNumber(value), MESSAGES.INVALID_PHONE);
|
|
43
|
+
|
|
44
|
+
// Econt city and office ids: numbers, but forms hold them as '' until chosen.
|
|
45
|
+
// Anything that is not a positive number becomes null, so "required" can be checked separately.
|
|
46
|
+
// null is accepted as input too, so the parsed output can be parsed again (form → API).
|
|
47
|
+
export const econtId = z
|
|
48
|
+
.union([z.number(), z.string(), z.null()])
|
|
49
|
+
.optional()
|
|
50
|
+
.transform(value => (Number(value) > 0 ? Number(value) : null));
|
|
51
|
+
|
|
52
|
+
export const mongoId = z.string().regex(MONGO_ID_REGEX);
|
package/src/enums.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Values the frontend and backend must agree on. Stored in the database as-is,
|
|
2
|
+
// so changing a value is a breaking change (major version + data migration).
|
|
3
|
+
|
|
4
|
+
export const DELIVERY_METHOD = Object.freeze({
|
|
5
|
+
ECONT_OFFICE: 'econt-office',
|
|
6
|
+
ECONT_ADDRESS: 'econt-address'
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const PAYMENT_METHOD = Object.freeze({
|
|
10
|
+
ON_DELIVERY: 'on-delivery'
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const INVOICE_TYPE = Object.freeze({
|
|
14
|
+
NATURAL_PERSON: 'NP',
|
|
15
|
+
LEGAL_ENTITY: 'LE'
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const MESSAGE_TYPE = Object.freeze({
|
|
19
|
+
CONTACT: 'contact', // contacts page
|
|
20
|
+
WHOLESALE: 'wholesale' // for-resellers page
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Which storefront sells a product (products.category)
|
|
24
|
+
export const PRODUCT_CATEGORY = Object.freeze({
|
|
25
|
+
SUBLIMATOR: 'sublimator',
|
|
26
|
+
HONEY: 'honey'
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Who a sublimator model is for (products.audience)
|
|
30
|
+
export const PRODUCT_AUDIENCE = Object.freeze({
|
|
31
|
+
HOBBY: 'hobby',
|
|
32
|
+
PRO: 'pro'
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const CURRENCY = 'EUR';
|
|
36
|
+
|
|
37
|
+
export const MAX_ITEM_QUANTITY = 99;
|
package/src/index.js
ADDED
package/src/message.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { MESSAGES, requiredText, requiredEmail, optionalText, optionalPhoneNumber } from './common.js';
|
|
4
|
+
import { MESSAGE_TYPE } from './enums.js';
|
|
5
|
+
|
|
6
|
+
const contactFields = {
|
|
7
|
+
name: requiredText(100),
|
|
8
|
+
email: requiredEmail(200),
|
|
9
|
+
phoneNumber: optionalPhoneNumber(30),
|
|
10
|
+
company: optionalText(150),
|
|
11
|
+
city: optionalText(100),
|
|
12
|
+
message: requiredText(5000)
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Contacts page form
|
|
16
|
+
export const contactMessageSchema = z.object(contactFields);
|
|
17
|
+
|
|
18
|
+
// For-resellers page form: the shop or company is required
|
|
19
|
+
export const wholesaleMessageSchema = z.object({ ...contactFields, company: requiredText(150) });
|
|
20
|
+
|
|
21
|
+
// POST /messages: either form, told apart by `type` (missing means a contact message)
|
|
22
|
+
export const createMessageSchema = z.preprocess(
|
|
23
|
+
body => (body && typeof body === 'object' && body.type === undefined ? { ...body, type: MESSAGE_TYPE.CONTACT } : body),
|
|
24
|
+
z.discriminatedUnion('type', [
|
|
25
|
+
contactMessageSchema.extend({ type: z.literal(MESSAGE_TYPE.CONTACT) }),
|
|
26
|
+
wholesaleMessageSchema.extend({ type: z.literal(MESSAGE_TYPE.WHOLESALE) })
|
|
27
|
+
], { error: MESSAGES.INVALID_CHOICE })
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
/** @typedef {import('zod').infer<typeof contactMessageSchema>} ContactMessage */
|
|
31
|
+
/** @typedef {import('zod').infer<typeof wholesaleMessageSchema>} WholesaleMessage */
|
|
32
|
+
/** @typedef {import('zod').infer<typeof createMessageSchema>} CreateMessage */
|
package/src/order.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { MESSAGES, optionalText, econtId, isEmail, isPhoneNumber } from './common.js';
|
|
4
|
+
import { cartItemsSchema } from './cart.js';
|
|
5
|
+
import { DELIVERY_METHOD, PAYMENT_METHOD, INVOICE_TYPE } from './enums.js';
|
|
6
|
+
|
|
7
|
+
// The fields are deliberately loose (missing → '') and every "required" rule lives in
|
|
8
|
+
// checkCheckout below. Zod skips an object's refinements when any field fails, so this
|
|
9
|
+
// is what lets a customer see all missing fields at once after the first submit.
|
|
10
|
+
|
|
11
|
+
const invoiceDetailsFields = z.object({
|
|
12
|
+
invoiceType: z.enum(Object.values(INVOICE_TYPE), { error: MESSAGES.INVALID_CHOICE }).default(INVOICE_TYPE.NATURAL_PERSON),
|
|
13
|
+
// Natural person (NP)
|
|
14
|
+
firstName: optionalText(),
|
|
15
|
+
lastName: optionalText(),
|
|
16
|
+
nationalIdentityNumber: optionalText(), // ЕГН, optional on purpose (less personal data)
|
|
17
|
+
// Legal entity (LE)
|
|
18
|
+
companyName: optionalText(),
|
|
19
|
+
materialResponsiblePerson: optionalText(), // МОЛ
|
|
20
|
+
companyIdentificationNumber: optionalText(), // ЕИК
|
|
21
|
+
isVATRegistered: z.boolean().default(false),
|
|
22
|
+
vatNumber: optionalText(),
|
|
23
|
+
// Invoice address
|
|
24
|
+
city: econtId,
|
|
25
|
+
cityName: optionalText(),
|
|
26
|
+
regionName: optionalText(),
|
|
27
|
+
postCode: optionalText(),
|
|
28
|
+
countryCode: optionalText(),
|
|
29
|
+
countryName: optionalText(),
|
|
30
|
+
address: optionalText()
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const checkoutFields = z.object({
|
|
34
|
+
// Contact
|
|
35
|
+
firstName: optionalText(),
|
|
36
|
+
lastName: optionalText(),
|
|
37
|
+
phoneNumber: optionalText(),
|
|
38
|
+
email: optionalText(),
|
|
39
|
+
// Delivery city (Econt city code + display data)
|
|
40
|
+
city: econtId,
|
|
41
|
+
cityName: optionalText(),
|
|
42
|
+
regionName: optionalText(),
|
|
43
|
+
postCode: optionalText(),
|
|
44
|
+
countryCode: optionalText(),
|
|
45
|
+
countryName: optionalText(),
|
|
46
|
+
// Delivery method: office fields or address
|
|
47
|
+
deliveryMethod: optionalText(),
|
|
48
|
+
office: econtId,
|
|
49
|
+
officeCode: optionalText(),
|
|
50
|
+
officeName: optionalText(),
|
|
51
|
+
officeAddress: optionalText(),
|
|
52
|
+
address: optionalText(),
|
|
53
|
+
// Payment and invoice
|
|
54
|
+
paymentMethod: z.enum(Object.values(PAYMENT_METHOD), { error: MESSAGES.INVALID_CHOICE }).default(PAYMENT_METHOD.ON_DELIVERY),
|
|
55
|
+
isInvoice: z.boolean().default(false),
|
|
56
|
+
invoiceDetails: invoiceDetailsFields.prefault({}),
|
|
57
|
+
termsAccepted: z.boolean().default(false)
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const requireValue = (ctx, value, path, message = MESSAGES.REQUIRED) => {
|
|
61
|
+
if (value === '' || value === null) {
|
|
62
|
+
ctx.addIssue({ code: 'custom', path, message });
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const checkCheckout = (order, ctx) => {
|
|
69
|
+
['firstName', 'lastName'].forEach(field => requireValue(ctx, order[field], [field]));
|
|
70
|
+
|
|
71
|
+
if (requireValue(ctx, order.phoneNumber, ['phoneNumber']) && !isPhoneNumber(order.phoneNumber)) {
|
|
72
|
+
ctx.addIssue({ code: 'custom', path: ['phoneNumber'], message: MESSAGES.INVALID_PHONE });
|
|
73
|
+
}
|
|
74
|
+
if (requireValue(ctx, order.email, ['email']) && !isEmail(order.email)) {
|
|
75
|
+
ctx.addIssue({ code: 'custom', path: ['email'], message: MESSAGES.INVALID_EMAIL });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
requireValue(ctx, order.city, ['city']);
|
|
79
|
+
|
|
80
|
+
if (requireValue(ctx, order.deliveryMethod, ['deliveryMethod']) && !Object.values(DELIVERY_METHOD).includes(order.deliveryMethod)) {
|
|
81
|
+
ctx.addIssue({ code: 'custom', path: ['deliveryMethod'], message: MESSAGES.INVALID_CHOICE });
|
|
82
|
+
}
|
|
83
|
+
if (order.deliveryMethod === DELIVERY_METHOD.ECONT_OFFICE) {
|
|
84
|
+
// The office is picked from a list that fills all three; any one missing means "no office chosen"
|
|
85
|
+
const hasOffice = order.office !== null && order.officeCode !== '' && order.officeName !== '';
|
|
86
|
+
requireValue(ctx, hasOffice ? order.office : null, ['office']);
|
|
87
|
+
}
|
|
88
|
+
if (order.deliveryMethod === DELIVERY_METHOD.ECONT_ADDRESS) {
|
|
89
|
+
requireValue(ctx, order.address, ['address']);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (order.isInvoice) {
|
|
93
|
+
const invoice = order.invoiceDetails;
|
|
94
|
+
const at = field => ['invoiceDetails', field];
|
|
95
|
+
|
|
96
|
+
requireValue(ctx, invoice.city, at('city'));
|
|
97
|
+
requireValue(ctx, invoice.address, at('address'));
|
|
98
|
+
|
|
99
|
+
if (invoice.invoiceType === INVOICE_TYPE.NATURAL_PERSON) {
|
|
100
|
+
['firstName', 'lastName'].forEach(field => requireValue(ctx, invoice[field], at(field)));
|
|
101
|
+
}
|
|
102
|
+
if (invoice.invoiceType === INVOICE_TYPE.LEGAL_ENTITY) {
|
|
103
|
+
['companyName', 'materialResponsiblePerson', 'companyIdentificationNumber'].forEach(field => requireValue(ctx, invoice[field], at(field)));
|
|
104
|
+
if (invoice.isVATRegistered) {
|
|
105
|
+
requireValue(ctx, invoice.vatNumber, at('vatNumber'));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (order.termsAccepted !== true) {
|
|
111
|
+
ctx.addIssue({ code: 'custom', path: ['termsAccepted'], message: MESSAGES.TERMS_REQUIRED });
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// The checkout form (frontend): everything the customer fills in
|
|
116
|
+
export const checkoutSchema = checkoutFields.superRefine(checkCheckout);
|
|
117
|
+
|
|
118
|
+
// POST /orders: the checkout form plus the cart lines
|
|
119
|
+
export const createOrderSchema = checkoutFields
|
|
120
|
+
.extend({ products: cartItemsSchema })
|
|
121
|
+
.superRefine(checkCheckout);
|
|
122
|
+
|
|
123
|
+
/** @typedef {import('zod').infer<typeof checkoutSchema>} Checkout */
|
|
124
|
+
/** @typedef {import('zod').infer<typeof createOrderSchema>} CreateOrder */
|
package/src/product.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { mongoId } from './common.js';
|
|
4
|
+
import { PRODUCT_CATEGORY, PRODUCT_AUDIENCE } from './enums.js';
|
|
5
|
+
|
|
6
|
+
// A product as stored by the seed script (kabobee-backend/scripts/seedPlaceholderModels.js).
|
|
7
|
+
// Sublimators use audience, warrantyMonths and inTheBox; honey products leave them out.
|
|
8
|
+
// `specs` keys differ per category (hives, powerSource… / honeyType, netWeight…), so they stay free-form.
|
|
9
|
+
export const productInputSchema = z.object({
|
|
10
|
+
modelKey: z.string().min(1),
|
|
11
|
+
category: z.enum(Object.values(PRODUCT_CATEGORY)),
|
|
12
|
+
name: z.string().min(1),
|
|
13
|
+
tagline: z.string().default(''),
|
|
14
|
+
price: z.number().positive(), // EUR incl. VAT
|
|
15
|
+
weight: z.number().positive(), // kg incl. packaging, used for Econt prices
|
|
16
|
+
sortOrder: z.number().int(),
|
|
17
|
+
specs: z.record(z.string(), z.string()).default({}),
|
|
18
|
+
highlights: z.array(z.string()).default([]),
|
|
19
|
+
briefSummary: z.string().default(''),
|
|
20
|
+
description: z.string().default(''),
|
|
21
|
+
mainImage: z.string().default(''),
|
|
22
|
+
imageUrls: z.array(z.string()).default([]),
|
|
23
|
+
videoUrl: z.string().default(''),
|
|
24
|
+
audience: z.enum(Object.values(PRODUCT_AUDIENCE)).optional(),
|
|
25
|
+
warrantyMonths: z.number().int().positive().optional(),
|
|
26
|
+
inTheBox: z.array(z.string()).optional(),
|
|
27
|
+
isPlaceholder: z.boolean().optional()
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// A product as returned by GET /products and GET /products/:productId
|
|
31
|
+
export const productSchema = productInputSchema.extend({ _id: mongoId });
|
|
32
|
+
|
|
33
|
+
/** @typedef {import('zod').infer<typeof productSchema>} Product */
|