includio-cms 0.36.0 → 0.36.2
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/API.md +5 -2
- package/CHANGELOG.md +22 -0
- package/DOCS.md +1 -1
- package/dist/admin/client/shop/shop-order-detail-page.svelte +8 -1
- package/dist/admin/remote/shop.remote.d.ts +1 -0
- package/dist/admin/remote/shop.remote.js +7 -2
- package/dist/shop/adapters/fakturownia/payload.d.ts +2 -0
- package/dist/shop/adapters/fakturownia/payload.js +5 -0
- package/dist/shop/index.d.ts +1 -1
- package/dist/shop/server/email.d.ts +9 -1
- package/dist/shop/server/email.js +35 -1
- package/dist/shop/server/emailSubject.d.ts +31 -0
- package/dist/shop/server/emailSubject.js +53 -0
- package/dist/shop/server/emailTemplateRegistry.d.ts +3 -1
- package/dist/shop/server/emailTemplateRegistry.js +55 -2
- package/dist/shop/server/orders.d.ts +27 -1
- package/dist/shop/server/orders.js +30 -0
- package/dist/shop/types.d.ts +87 -0
- package/dist/updates/0.36.1/index.d.ts +2 -0
- package/dist/updates/0.36.1/index.js +10 -0
- package/dist/updates/0.36.2/index.d.ts +2 -0
- package/dist/updates/0.36.2/index.js +17 -0
- package/dist/updates/index.js +5 -1
- package/package.json +1 -1
package/API.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# includio-cms — Public API v0.36.
|
|
1
|
+
# includio-cms — Public API v0.36.2
|
|
2
2
|
|
|
3
3
|
> Auto-generated by `scripts/generate-api-md.ts`. Do not edit by hand.
|
|
4
4
|
|
|
5
|
-
Entry points: **19** · Stable: **
|
|
5
|
+
Entry points: **19** · Stable: **495** · Experimental: **4**
|
|
6
6
|
|
|
7
7
|
Tags:
|
|
8
8
|
- `@public` — frozen for v1.0; semver-protected.
|
|
@@ -446,6 +446,7 @@ Tags:
|
|
|
446
446
|
- `type Currency = 'PLN'`
|
|
447
447
|
- `defineShop(config: ShopConfig): ResolvedShopConfig`
|
|
448
448
|
- `type DepositAmount = { type: 'percent'; value: number } | { type: 'amount'; value: number }` — Deposit amount specifier. `percent` charges `floor(base * value / 100)` of
|
|
449
|
+
- `interface EmailContext` — Context passed to a {@link SubjectPlaceholderResolver}. Currently carries
|
|
449
450
|
- `fakturowniaAdapter(opts: FakturowniaAdapterOptions): InvoicingAdapter` — Invoicing adapter backed by Fakturownia (fakturownia.pl). Issues a paid VAT
|
|
450
451
|
- `interface FakturowniaAdapterOptions`
|
|
451
452
|
- `filterUpcoming(variants: T[], config: VariantExpiryConfig | null, now?: Date): T[]` — Return the subset of `variants` that have not yet expired. Order is
|
|
@@ -471,6 +472,7 @@ Tags:
|
|
|
471
472
|
- `type Order = typeof shopOrdersTable.$inferSelect` — Public row type for a single shop order — mirrors the Drizzle `shop_orders`
|
|
472
473
|
- `interface OrderRef`
|
|
473
474
|
- `type OrderStatus = | 'new' | 'awaitingPayment' | 'paid' | 'preparing' | 'sent' | 'done' | 'cancelled' | 'paymentReje...`
|
|
475
|
+
- `interface OrderStatusConfig` — Per-status configuration. Lets consumer projects hide a built-in status from
|
|
474
476
|
- `interface PartialPayment` — Persisted partial-payment summary on `order.partialPayment` when the order
|
|
475
477
|
- `interface PaymentAdapter`
|
|
476
478
|
- `interface PaymentCreateContext`
|
|
@@ -490,6 +492,7 @@ Tags:
|
|
|
490
492
|
- `interface ShopFeatures`
|
|
491
493
|
- `stripeAdapter(opts: StripeAdapterOptions): PaymentAdapter` — Stripe payment adapter (Checkout Session flow).
|
|
492
494
|
- `interface StripeAdapterOptions`
|
|
495
|
+
- `type SubjectPlaceholderResolver = (order: Order, ctx: EmailContext) => string` — Resolves one `{placeholder}` token in an email subject template to a string.
|
|
493
496
|
- `type VariantAttribute = | VariantAttributeText | VariantAttributeNumber | VariantAttributeDatetime | VariantAttributeSele...` — Schema descriptor for one product-variant attribute (city, startsAt, …).
|
|
494
497
|
- `interface VariantAttributeBoolean`
|
|
495
498
|
- `interface VariantAttributeDatetime`
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,28 @@
|
|
|
3
3
|
All notable changes to includio-cms are documented here.
|
|
4
4
|
Generated from `src/lib/updates/` — do not edit manually.
|
|
5
5
|
|
|
6
|
+
## 0.36.2 — 2026-06-08
|
|
7
|
+
|
|
8
|
+
Per-status overrides (`ShopConfig.orderStatuses`) + globalny rejestr subject placeholders (`formatSubject`/`ShopConfig.emailSubjectPlaceholders`) + nowy hook `ShopConfig.resolveStatusSubject` do czytania subject z CMS + Handlebars helper `{{{structured}}}` renderujący `StructuredContentDoc` (TipTap) jako email-safe HTML z automatycznym token replacement. Email context rozszerzony o billing fields (firma/NIP/adres/telefon). Dwa fixy bugów w resolverze CMS singletons w mailach. Zaprojektowane pod sklepy ze szkoleniami/wydarzeniami (stationary), gdzie statusy `preparing`/`sent` nie mają sensu. Wszystkie API generyczne, do reusu w innych consumer projects.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`ShopConfig.orderStatuses?: OrderStatusConfig[]`** — opcjonalna lista per-status overrides. `hidden: true` chowa status z admin dropdownu (`shop-order-detail-page.svelte`) i blokuje manual transitions z admina rzucając nowy `HiddenStatusTransitionError` (kod `HIDDEN_STATUS_TRANSITION`). `sendEmail: false` powoduje, że `sendOrderStatusEmail` early-returnuje przy wejściu w ten status. Webhook / carrier-driven transitions BYPASSują guard — provider-driven update musi przejść bez względu na config (rola engine fundamentalna). Nowy parametr `updateOrderStatus(orderId, status, { source: "admin" })` rozróżnia kontekst; `source: "webhook" | "system"` (default) zawsze pomija check. Pure helpery: `assertManualStatusAllowed(status, orderStatuses)` (rzuca) i `shouldSendStatusEmail(status, orderStatuses)` (zwraca bool).
|
|
12
|
+
- **`formatSubject` + `builtInSubjectPlaceholders`** (`$lib/shop/server/emailSubject.js`) — generyczna interpolacja `{key}` w subject mailach. Built-in: `{order_number}`, `{customer_name}`, `{customer_email}`, `{total_gross}` (formatted PLN currency), `{currency}`. Nowy `ShopConfig.emailSubjectPlaceholders?: Record<string, SubjectPlaceholderResolver>` — projektowe rozszerzenia (np. `{event_date}` dla szkoleń), mergowane na built-in (custom wins). `sendOrderStatusEmail` przepuszcza fallback subject (`STATUS_SUBJECTS[status][lang] · {order_number}`) przez `formatSubject` zanim wyśle. Nieznane placeholdery są lenient — zostają w stringu.
|
|
13
|
+
- **`ShopConfig.resolveStatusSubject?(status, ctx) => string | null | undefined`** — async hook pozwalający consumerowi pobrać template subject z dowolnego źródła (typically CMS singleton). Wywoływany w `sendOrderStatusEmail` przed `formatSubject`, więc tokeny działają tak samo jak w defaultach. Pusta wartość / błąd → fallback do `STATUS_SUBJECTS`. Paczka pozostaje agnostyczna o CMS schemie consumera.
|
|
14
|
+
- **Handlebars helper `{{{structured doc}}}`** (`emailTemplateRegistry.ts`) — renderuje `StructuredContentDoc` (output TipTap fielda `content`) jako inline HTML dla maili. Używa `structuredToHtml()` z core; zwraca `SafeString`. Po render — automatyczne **token replacement** przez `formatSubject` (built-in + `emailSubjectPlaceholders`) jeśli `order` jest w Handlebars context. Pozwala wpisać `{order_number}` w polu TipTap w CMSie i mieć je podmienione w wysłanym mailu. Pusty/undefined doc → "".
|
|
15
|
+
- **Email context rozszerzony o billing fields** (`email.ts`): `order.customerCompanyName`, `customerNip`, `customerPhone`, `billingAddress` (`{street, postalCode, city, country?}`) + flagi `hasCompany` / `hasBillingAddress` dla wygodnych `{{#if}}` w templates. Pozwala renderować WooCommerce-style billing block bez custom JOIN.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **`extractCmsSlugs` skanuje partials** — wcześniej AST tylko głównego templatu był parsowany, więc `cms.<slug>.*` referencje w partials (np. `cms.globalSettings.legalEntity` w `_partials/footer.pl.html`) były ignorowane → singleton nie był prefetchowany → context był pusty → fallbacki renderowane mimo wypełnionego CMSa. Teraz dodatkowo iteruje przez wszystkie zarejestrowane `Handlebars.partials` i merguje slugi.
|
|
19
|
+
- **`resolveCmsContext` poprawnie spłaszcza entry** — wcześniej używał `entry?.data ?? {}`, ale `resolveEntry` zwraca obiekt spreadowany (`{ _id, _slug, _type, _publishedAt, _url, ...fields }`) bez pola `.data`. Efekt: każdy `cms.<slug>` w mailu był pustym obiektem. Teraz strip-uje meta keys (`_*`) i zwraca user fields. Mock w testach był niezgodny z prawdziwym API, dlatego bug przetrwał — naprawiony, dodany regression test dla nested path.
|
|
20
|
+
|
|
21
|
+
## 0.36.1 — 2026-06-08
|
|
22
|
+
|
|
23
|
+
Fakturownia adapter — fix B2C: gdy brak `buyer.nip` i `buyer.companyName`, payload zawiera `buyer_first_name` + `buyer_last_name` (rozdzielone przez `splitFullName` — spójnie z PayU/InPost). Bez tej zmiany Fakturownia API odrzuca fakturę dla osoby fizycznej z 422 `buyer_tax_no — nie może być puste`.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- `buildFakturowniaInvoice` — dla B2C (brak NIP i companyName) payload zawiera teraz `buyer_first_name` i `buyer_last_name` (rozdzielone z `buyer.name` przez `splitFullName` z adaptera PayU). B2B (z NIP lub companyName) zachowuje obecne zachowanie — bez first/last name. Dla `splitFullName("Madonna")` (1 słowo) → tylko `buyer_first_name`. Naprawia 422 z Fakturownia dla osób fizycznych.
|
|
27
|
+
|
|
6
28
|
## 0.36.0 — 2026-06-05
|
|
7
29
|
|
|
8
30
|
`defineShop({ invoiceName, cartName })` — opcjonalne multilingual template overriding produktowej nazwy w koszyku, podsumowaniu zamówienia i na fakturze. Reusuje engine od `variantLabel.template` (`{slug}`, `{slug|filter}`), z nowymi możliwościami: dot-path (`{hero.title}`) i auto-unwrap pól i18n. Reserved `{variant}` = wyrenderowany variantLabel. Order item `nameSnapshot` zyskuje opcjonalne pole `invoice` (zamrażane przy checkout). Additive — brak zmian default behavior.
|
package/DOCS.md
CHANGED
|
@@ -148,6 +148,13 @@
|
|
|
148
148
|
{ value: 'refunded', label: 'Zwrócone' }
|
|
149
149
|
];
|
|
150
150
|
|
|
151
|
+
// Statuses the consumer marked `hidden: true` in defineShop({ orderStatuses })
|
|
152
|
+
// — never shown in the dropdown (manual transitions blocked server-side too).
|
|
153
|
+
const hiddenStatuses = $derived(
|
|
154
|
+
new Set<OrderStatus>(((query.current?.hiddenStatuses as OrderStatus[] | undefined) ?? []))
|
|
155
|
+
);
|
|
156
|
+
const visibleStatuses = $derived(STATUSES.filter((s) => !hiddenStatuses.has(s.value)));
|
|
157
|
+
|
|
151
158
|
let newStatus = $state<OrderStatus | ''>('');
|
|
152
159
|
let note = $state('');
|
|
153
160
|
let saving = $state(false);
|
|
@@ -482,7 +489,7 @@
|
|
|
482
489
|
class="border-border w-full rounded-lg border px-3 py-2 text-sm"
|
|
483
490
|
>
|
|
484
491
|
<option value="">— wybierz —</option>
|
|
485
|
-
{#each
|
|
492
|
+
{#each visibleStatuses as s (s.value)}
|
|
486
493
|
<option value={s.value} disabled={s.value === order.status}>{s.label}</option>
|
|
487
494
|
{/each}
|
|
488
495
|
</select>
|
|
@@ -211,6 +211,7 @@ export declare const getOrderForAdmin: import("@sveltejs/kit").RemoteQueryFuncti
|
|
|
211
211
|
code: string;
|
|
212
212
|
discountAmount: number;
|
|
213
213
|
} | null;
|
|
214
|
+
hiddenStatuses: import("../../shop/types.js").OrderStatus[];
|
|
214
215
|
} | null>;
|
|
215
216
|
export declare const updateOrderStatusCmd: import("@sveltejs/kit").RemoteCommand<{
|
|
216
217
|
orderId: string;
|
|
@@ -171,7 +171,8 @@ export const getOrderForAdmin = query(z.string(), async (id) => {
|
|
|
171
171
|
getOrderStatusHistory(id),
|
|
172
172
|
getOrderCoupon(id).catch(() => null)
|
|
173
173
|
]);
|
|
174
|
-
|
|
174
|
+
const hiddenStatuses = getCMS().shopConfig?.orderStatuses?.filter((s) => s.hidden).map((s) => s.key) ?? [];
|
|
175
|
+
return { order, items, history, coupon, hiddenStatuses };
|
|
175
176
|
});
|
|
176
177
|
export const updateOrderStatusCmd = command(z.object({
|
|
177
178
|
orderId: z.string(),
|
|
@@ -179,7 +180,11 @@ export const updateOrderStatusCmd = command(z.object({
|
|
|
179
180
|
note: z.string().optional()
|
|
180
181
|
}), async ({ orderId, status, note }) => {
|
|
181
182
|
requireAuth();
|
|
182
|
-
const updated = await updateOrderStatus(orderId, status, {
|
|
183
|
+
const updated = await updateOrderStatus(orderId, status, {
|
|
184
|
+
note,
|
|
185
|
+
changedBy: 'admin',
|
|
186
|
+
source: 'admin'
|
|
187
|
+
});
|
|
183
188
|
return updated;
|
|
184
189
|
});
|
|
185
190
|
export const deleteOrderCmd = command(z.object({ orderId: z.string() }), async ({ orderId }) => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { splitFullName } from '../payu/payload.js';
|
|
1
2
|
/** Minor units (grosze) → major units (PLN) with 2-decimal precision. */
|
|
2
3
|
function toMajor(minor) {
|
|
3
4
|
return Math.round(minor) / 100;
|
|
@@ -18,6 +19,8 @@ function pick(addr, ...keys) {
|
|
|
18
19
|
export function buildFakturowniaInvoice(payload, opts = {}) {
|
|
19
20
|
const date = payload.paidAt.slice(0, 10);
|
|
20
21
|
const { buyer } = payload;
|
|
22
|
+
const isB2C = !buyer.nip && !buyer.companyName;
|
|
23
|
+
const names = isB2C ? splitFullName(buyer.name) : {};
|
|
21
24
|
return {
|
|
22
25
|
kind: opts.kind ?? 'vat',
|
|
23
26
|
status: 'paid',
|
|
@@ -28,6 +31,8 @@ export function buildFakturowniaInvoice(payload, opts = {}) {
|
|
|
28
31
|
buyer_name: buyer.companyName || buyer.name,
|
|
29
32
|
buyer_email: buyer.email,
|
|
30
33
|
...(buyer.nip ? { buyer_tax_no: buyer.nip } : {}),
|
|
34
|
+
...(names.firstName ? { buyer_first_name: names.firstName } : {}),
|
|
35
|
+
...(names.lastName ? { buyer_last_name: names.lastName } : {}),
|
|
31
36
|
...(pick(buyer.address, 'street') ? { buyer_street: pick(buyer.address, 'street') } : {}),
|
|
32
37
|
...(pick(buyer.address, 'city') ? { buyer_city: pick(buyer.address, 'city') } : {}),
|
|
33
38
|
...(pick(buyer.address, 'postCode', 'zip', 'postalCode')
|
package/dist/shop/index.d.ts
CHANGED
|
@@ -12,6 +12,6 @@ export type { InpostAdapterOptions, InpostSenderAddress, GeowidgetConfigPreset,
|
|
|
12
12
|
export { fakturowniaAdapter } from './adapters/fakturownia/index.js';
|
|
13
13
|
export type { FakturowniaAdapterOptions } from './adapters/fakturownia/index.js';
|
|
14
14
|
export { isValidNip } from './nip.js';
|
|
15
|
-
export type { ShopConfig, ResolvedShopConfig, Currency, Order, OrderStatus, PaymentAdapter, PaymentCreateContext, PaymentRefundInput, PaymentRefundResult, CarrierAdapter, CarrierEvent, ShipmentCreateInput, ShipmentCreateResult, ShipmentLabel, ConsentConfig, ShopFeatures, PaymentCreateResult, PaymentEvent, OrderRef, CouponRef, I18nText, VariantAttribute, VariantAttributeText, VariantAttributeNumber, VariantAttributeDatetime, VariantAttributeSelect, VariantAttributeBoolean, VariantAttributeImage, VariantAttributeEntry, VariantAttributeSlug, VariantLabelConfig, VariantExpiryConfig, PaymentPolicy, DepositAmount, PartialPayment, InvoicingAdapter, InvoiceIssuePolicy, InvoiceBuyer, InvoiceLineItem, InvoicePayload, InvoiceCreateResult, InvoiceContext } from './types.js';
|
|
15
|
+
export type { ShopConfig, ResolvedShopConfig, Currency, Order, OrderStatus, OrderStatusConfig, SubjectPlaceholderResolver, EmailContext, PaymentAdapter, PaymentCreateContext, PaymentRefundInput, PaymentRefundResult, CarrierAdapter, CarrierEvent, ShipmentCreateInput, ShipmentCreateResult, ShipmentLabel, ConsentConfig, ShopFeatures, PaymentCreateResult, PaymentEvent, OrderRef, CouponRef, I18nText, VariantAttribute, VariantAttributeText, VariantAttributeNumber, VariantAttributeDatetime, VariantAttributeSelect, VariantAttributeBoolean, VariantAttributeImage, VariantAttributeEntry, VariantAttributeSlug, VariantLabelConfig, VariantExpiryConfig, PaymentPolicy, DepositAmount, PartialPayment, InvoicingAdapter, InvoiceIssuePolicy, InvoiceBuyer, InvoiceLineItem, InvoicePayload, InvoiceCreateResult, InvoiceContext } from './types.js';
|
|
16
16
|
export { interpolateTemplate, renderShopName } from './template.js';
|
|
17
17
|
export type { I18nTemplate } from './template.js';
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import type { OrderStatus } from '../types.js';
|
|
1
|
+
import type { OrderStatus, OrderStatusConfig } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Decide whether a status-change email should be sent for a given status,
|
|
4
|
+
* given the consumer-supplied per-status config (`ShopConfig.orderStatuses`).
|
|
5
|
+
* Defaults to `true` — only an entry with `sendEmail === false` suppresses
|
|
6
|
+
* the email. Pure helper, exported for testing + reuse.
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
export declare function shouldSendStatusEmail(status: OrderStatus, orderStatuses: OrderStatusConfig[] | undefined): boolean;
|
|
2
10
|
/**
|
|
3
11
|
* List of template names a shop install must ship in `dist/shop/templates/`.
|
|
4
12
|
* Consumed by `validateBuiltinTemplates` at CMS bootstrap.
|
|
@@ -4,6 +4,18 @@ import { getOrderCoupon } from './coupons.js';
|
|
|
4
4
|
import { requireShopConfig } from './db.js';
|
|
5
5
|
import { buildOrderViewUrl } from './order-access-url.js';
|
|
6
6
|
import { renderEmailTemplate } from './emailTemplateRegistry.js';
|
|
7
|
+
import { formatSubject } from './emailSubject.js';
|
|
8
|
+
/**
|
|
9
|
+
* Decide whether a status-change email should be sent for a given status,
|
|
10
|
+
* given the consumer-supplied per-status config (`ShopConfig.orderStatuses`).
|
|
11
|
+
* Defaults to `true` — only an entry with `sendEmail === false` suppresses
|
|
12
|
+
* the email. Pure helper, exported for testing + reuse.
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
export function shouldSendStatusEmail(status, orderStatuses) {
|
|
16
|
+
const cfg = orderStatuses?.find((s) => s.key === status);
|
|
17
|
+
return cfg?.sendEmail !== false;
|
|
18
|
+
}
|
|
7
19
|
const STATUS_SUBJECTS = {
|
|
8
20
|
new: { pl: 'Zamówienie przyjęte', en: 'Order received' },
|
|
9
21
|
awaitingPayment: { pl: 'Oczekiwanie na płatność', en: 'Awaiting payment' },
|
|
@@ -53,6 +65,8 @@ export const REQUIRED_TEMPLATE_NAMES = [
|
|
|
53
65
|
export async function sendOrderStatusEmail(orderId, status) {
|
|
54
66
|
const cms = getCMS();
|
|
55
67
|
const shop = requireShopConfig();
|
|
68
|
+
if (!shouldSendStatusEmail(status, shop.orderStatuses))
|
|
69
|
+
return;
|
|
56
70
|
const emailAdapter = cms.emailAdapter;
|
|
57
71
|
if (!emailAdapter) {
|
|
58
72
|
console.warn('[shop] No email adapter configured — skipping status email.');
|
|
@@ -134,6 +148,12 @@ export async function sendOrderStatusEmail(orderId, status) {
|
|
|
134
148
|
status: order.status,
|
|
135
149
|
customerName: order.customerName ?? '',
|
|
136
150
|
customerEmail: order.customerEmail,
|
|
151
|
+
customerPhone: order.customerPhone ?? null,
|
|
152
|
+
customerCompanyName: order.customerCompanyName ?? null,
|
|
153
|
+
customerNip: order.customerNip ?? null,
|
|
154
|
+
billingAddress: (order.billingAddress ?? null),
|
|
155
|
+
hasCompany: Boolean(order.customerCompanyName || order.customerNip),
|
|
156
|
+
hasBillingAddress: Boolean(order.billingAddress && order.billingAddress.street),
|
|
137
157
|
language: order.language,
|
|
138
158
|
totalGross: formatPrice(order.totalGross, order.currency),
|
|
139
159
|
totalNet: formatPrice(order.totalNet, order.currency),
|
|
@@ -167,7 +187,21 @@ export async function sendOrderStatusEmail(orderId, status) {
|
|
|
167
187
|
adminEmail: shop.adminEmail ?? null
|
|
168
188
|
}
|
|
169
189
|
};
|
|
170
|
-
|
|
190
|
+
// Subject template lookup: project hook (typically reads CMS) → package default.
|
|
191
|
+
// Both feed through `formatSubject` so `{order_number}` etc. are formatted the same way.
|
|
192
|
+
let subjectTemplate;
|
|
193
|
+
if (shop.resolveStatusSubject) {
|
|
194
|
+
try {
|
|
195
|
+
subjectTemplate = await shop.resolveStatusSubject(status, { order, language: lang });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
console.error('[shop] resolveStatusSubject threw — falling back to default:', err);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!subjectTemplate || typeof subjectTemplate !== 'string' || !subjectTemplate.trim()) {
|
|
202
|
+
subjectTemplate = `${STATUS_SUBJECTS[status][subjectKey]} · {order_number}`;
|
|
203
|
+
}
|
|
204
|
+
const subject = formatSubject(subjectTemplate, order, { language: lang }, shop.emailSubjectPlaceholders);
|
|
171
205
|
const templateName = STATUS_TO_TEMPLATE[status];
|
|
172
206
|
let html;
|
|
173
207
|
try {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subject placeholder registry — generic `{key}`-style interpolation for the
|
|
3
|
+
* email subject string fed to {@link sendOrderStatusEmail}. Built-in keys
|
|
4
|
+
* cover the data common to every shop order; consumer projects merge custom
|
|
5
|
+
* resolvers via `ShopConfig.emailSubjectPlaceholders` to add domain-specific
|
|
6
|
+
* tokens (e.g. `{event_date}`).
|
|
7
|
+
*
|
|
8
|
+
* Unknown placeholders are left untouched (lenient), so a typo in CMS-managed
|
|
9
|
+
* copy never breaks delivery.
|
|
10
|
+
*/
|
|
11
|
+
import type { EmailContext, Order, SubjectPlaceholderResolver } from '../types.js';
|
|
12
|
+
/**
|
|
13
|
+
* Default subject-placeholder resolvers, available in every shop install.
|
|
14
|
+
* Merged with `ShopConfig.emailSubjectPlaceholders` in {@link formatSubject};
|
|
15
|
+
* custom resolvers take precedence on key collision.
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
export declare const builtInSubjectPlaceholders: Record<string, SubjectPlaceholderResolver>;
|
|
19
|
+
/**
|
|
20
|
+
* Replace every `{key}` token in `template` with the result of the matching
|
|
21
|
+
* resolver. Custom resolvers (e.g. from `ShopConfig.emailSubjectPlaceholders`)
|
|
22
|
+
* are merged on top of {@link builtInSubjectPlaceholders} — colliding keys
|
|
23
|
+
* are overridden. Unknown keys are left untouched.
|
|
24
|
+
*
|
|
25
|
+
* @param template Subject template (CMS string or hardcoded fallback).
|
|
26
|
+
* @param order Resolved order row passed to each resolver.
|
|
27
|
+
* @param ctx Minimal email context (currently just `language`).
|
|
28
|
+
* @param custom Optional per-project resolvers merged into the registry.
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
export declare function formatSubject(template: string, order: Order, ctx: EmailContext, custom?: Record<string, SubjectPlaceholderResolver>): string;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subject placeholder registry — generic `{key}`-style interpolation for the
|
|
3
|
+
* email subject string fed to {@link sendOrderStatusEmail}. Built-in keys
|
|
4
|
+
* cover the data common to every shop order; consumer projects merge custom
|
|
5
|
+
* resolvers via `ShopConfig.emailSubjectPlaceholders` to add domain-specific
|
|
6
|
+
* tokens (e.g. `{event_date}`).
|
|
7
|
+
*
|
|
8
|
+
* Unknown placeholders are left untouched (lenient), so a typo in CMS-managed
|
|
9
|
+
* copy never breaks delivery.
|
|
10
|
+
*/
|
|
11
|
+
function formatMoney(minorUnits, currency) {
|
|
12
|
+
const cur = typeof currency === 'string' && currency.length > 0 ? currency : 'PLN';
|
|
13
|
+
return new Intl.NumberFormat('pl-PL', {
|
|
14
|
+
style: 'currency',
|
|
15
|
+
currency: cur,
|
|
16
|
+
minimumFractionDigits: 2
|
|
17
|
+
}).format(minorUnits / 100);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Default subject-placeholder resolvers, available in every shop install.
|
|
21
|
+
* Merged with `ShopConfig.emailSubjectPlaceholders` in {@link formatSubject};
|
|
22
|
+
* custom resolvers take precedence on key collision.
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
export const builtInSubjectPlaceholders = {
|
|
26
|
+
order_number: (o) => o.number,
|
|
27
|
+
customer_name: (o) => o.customerName ?? '',
|
|
28
|
+
customer_email: (o) => o.customerEmail,
|
|
29
|
+
total_gross: (o) => formatMoney(o.totalGross, o.currency),
|
|
30
|
+
currency: (o) => o.currency
|
|
31
|
+
};
|
|
32
|
+
const PLACEHOLDER_RE = /\{(\w+)\}/g;
|
|
33
|
+
/**
|
|
34
|
+
* Replace every `{key}` token in `template` with the result of the matching
|
|
35
|
+
* resolver. Custom resolvers (e.g. from `ShopConfig.emailSubjectPlaceholders`)
|
|
36
|
+
* are merged on top of {@link builtInSubjectPlaceholders} — colliding keys
|
|
37
|
+
* are overridden. Unknown keys are left untouched.
|
|
38
|
+
*
|
|
39
|
+
* @param template Subject template (CMS string or hardcoded fallback).
|
|
40
|
+
* @param order Resolved order row passed to each resolver.
|
|
41
|
+
* @param ctx Minimal email context (currently just `language`).
|
|
42
|
+
* @param custom Optional per-project resolvers merged into the registry.
|
|
43
|
+
* @public
|
|
44
|
+
*/
|
|
45
|
+
export function formatSubject(template, order, ctx, custom) {
|
|
46
|
+
const resolvers = custom
|
|
47
|
+
? { ...builtInSubjectPlaceholders, ...custom }
|
|
48
|
+
: builtInSubjectPlaceholders;
|
|
49
|
+
return template.replace(PLACEHOLDER_RE, (match, key) => {
|
|
50
|
+
const fn = resolvers[key];
|
|
51
|
+
return fn ? fn(order, ctx) : match;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -20,7 +20,9 @@ interface ResolveResult {
|
|
|
20
20
|
declare function resolveTemplatePath(projectDir: string, name: string, lang: Lang, fallbackLang: Lang): ResolveResult | null;
|
|
21
21
|
/**
|
|
22
22
|
* Traverse Handlebars AST and collect all `cms.<slug>.*` accessor slugs.
|
|
23
|
-
*
|
|
23
|
+
* Also scans all currently-registered partials so `cms.*` refs inside
|
|
24
|
+
* `{{> footer}}` etc. are also prefetched. Returns the unique set so the
|
|
25
|
+
* renderer can prefetch them.
|
|
24
26
|
*/
|
|
25
27
|
declare function extractCmsSlugs(ast: hbs.AST.Program): Set<string>;
|
|
26
28
|
/**
|
|
@@ -12,6 +12,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
12
12
|
import Handlebars from 'handlebars';
|
|
13
13
|
import { getCMS } from '../../core/cms.js';
|
|
14
14
|
import { resolveEntry } from '../../sveltekit/server/index.js';
|
|
15
|
+
import { structuredToHtml } from '../../core/fields/structuredToHtml.js';
|
|
16
|
+
import { formatSubject } from './emailSubject.js';
|
|
17
|
+
import { requireShopConfig } from './db.js';
|
|
15
18
|
const PACKAGE_TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
|
|
16
19
|
const cache = new Map();
|
|
17
20
|
let helpersRegistered = false;
|
|
@@ -50,6 +53,32 @@ function registerHelpers() {
|
|
|
50
53
|
return new Intl.DateTimeFormat('pl-PL').format(d);
|
|
51
54
|
});
|
|
52
55
|
Handlebars.registerHelper('eq', (a, b) => a === b);
|
|
56
|
+
// `{{{structured doc}}}` — render a TipTap `StructuredContentDoc` (output of
|
|
57
|
+
// AriaCMS `content` fields) as inline HTML for email. Returns a SafeString
|
|
58
|
+
// so the caller's triple-stache passes the markup through; pre-rendered text
|
|
59
|
+
// inside `doc` is still HTML-escaped by `structuredToHtml`.
|
|
60
|
+
//
|
|
61
|
+
// Token replacement: any `{key}` token found inside the rendered HTML is
|
|
62
|
+
// substituted via `formatSubject` (built-in placeholders + project
|
|
63
|
+
// `emailSubjectPlaceholders`). Requires `order` in the rendering context.
|
|
64
|
+
// Uses `function` (not arrow) so `this` is the Handlebars root context.
|
|
65
|
+
Handlebars.registerHelper('structured', function (doc) {
|
|
66
|
+
if (!doc || typeof doc !== 'object')
|
|
67
|
+
return '';
|
|
68
|
+
let html = structuredToHtml(doc);
|
|
69
|
+
// Best-effort token replacement — silently skip when no order in scope.
|
|
70
|
+
const order = this?.order;
|
|
71
|
+
if (order) {
|
|
72
|
+
try {
|
|
73
|
+
const shop = requireShopConfig();
|
|
74
|
+
html = formatSubject(html, order, { language: order.language ?? 'pl' }, shop.emailSubjectPlaceholders);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// shop config not initialized — leave HTML as-is
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return new Handlebars.SafeString(html);
|
|
81
|
+
});
|
|
53
82
|
helpersRegistered = true;
|
|
54
83
|
}
|
|
55
84
|
/**
|
|
@@ -77,7 +106,9 @@ function resolveTemplatePath(projectDir, name, lang, fallbackLang) {
|
|
|
77
106
|
}
|
|
78
107
|
/**
|
|
79
108
|
* Traverse Handlebars AST and collect all `cms.<slug>.*` accessor slugs.
|
|
80
|
-
*
|
|
109
|
+
* Also scans all currently-registered partials so `cms.*` refs inside
|
|
110
|
+
* `{{> footer}}` etc. are also prefetched. Returns the unique set so the
|
|
111
|
+
* renderer can prefetch them.
|
|
81
112
|
*/
|
|
82
113
|
function extractCmsSlugs(ast) {
|
|
83
114
|
const slugs = new Set();
|
|
@@ -114,6 +145,19 @@ function extractCmsSlugs(ast) {
|
|
|
114
145
|
}
|
|
115
146
|
};
|
|
116
147
|
visit(ast);
|
|
148
|
+
// Also visit ASTs of every registered partial — `cms.*` paths in shared
|
|
149
|
+
// partials like footer/legal_notice would otherwise be missed and the
|
|
150
|
+
// underlying singleton would never be prefetched.
|
|
151
|
+
for (const partial of Object.values(Handlebars.partials)) {
|
|
152
|
+
if (typeof partial !== 'string')
|
|
153
|
+
continue;
|
|
154
|
+
try {
|
|
155
|
+
visit(Handlebars.parse(partial));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// malformed partial — already logged elsewhere when it tries to render
|
|
159
|
+
}
|
|
160
|
+
}
|
|
117
161
|
return slugs;
|
|
118
162
|
}
|
|
119
163
|
/**
|
|
@@ -185,7 +229,16 @@ async function resolveCmsContext(slugs) {
|
|
|
185
229
|
const entries = await Promise.all(slugList.map(async (slug) => {
|
|
186
230
|
try {
|
|
187
231
|
const entry = await resolveEntry({ collection: slug });
|
|
188
|
-
|
|
232
|
+
if (!entry)
|
|
233
|
+
return [slug, {}];
|
|
234
|
+
// `resolveEntry` returns a flat object: `{ _id, _slug, _type, _publishedAt, _url, ...fields }`.
|
|
235
|
+
// Strip leading-underscore meta keys so `cms.<slug>.<field>` matches the user-defined schema.
|
|
236
|
+
const data = {};
|
|
237
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
238
|
+
if (!k.startsWith('_'))
|
|
239
|
+
data[k] = v;
|
|
240
|
+
}
|
|
241
|
+
return [slug, data];
|
|
189
242
|
}
|
|
190
243
|
catch {
|
|
191
244
|
return [slug, {}];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { shopOrderItemsTable, shopOrderStatusHistoryTable, shopOrdersTable } from '../../db-postgres/schema/shop/index.js';
|
|
2
2
|
import type { CartItemRef } from '../cart/types.js';
|
|
3
|
-
import type { OrderStatus } from '../types.js';
|
|
3
|
+
import type { OrderStatus, OrderStatusConfig } from '../types.js';
|
|
4
4
|
/**
|
|
5
5
|
* @public
|
|
6
6
|
* Thrown by `createOrderFromCart` when the cart contains items from multiple
|
|
@@ -40,6 +40,25 @@ export type OrderDeletionDecision = {
|
|
|
40
40
|
export declare function decideOrderDeletion(status: OrderStatus, invoice: {
|
|
41
41
|
status: 'pending' | 'issued' | 'sent' | 'failed';
|
|
42
42
|
} | null): OrderDeletionDecision;
|
|
43
|
+
/**
|
|
44
|
+
* @public
|
|
45
|
+
* Thrown by {@link assertManualStatusAllowed} (and {@link updateOrderStatus}
|
|
46
|
+
* when called from the admin UI) for a manual transition into a status the
|
|
47
|
+
* consumer marked as hidden in `ShopConfig.orderStatuses`. Webhook /
|
|
48
|
+
* carrier-driven transitions bypass this check — `source: 'webhook'`.
|
|
49
|
+
*/
|
|
50
|
+
export declare class HiddenStatusTransitionError extends Error {
|
|
51
|
+
readonly status: OrderStatus;
|
|
52
|
+
readonly code = "HIDDEN_STATUS_TRANSITION";
|
|
53
|
+
constructor(status: OrderStatus);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* @public
|
|
57
|
+
* Throw a {@link HiddenStatusTransitionError} when the target status is marked
|
|
58
|
+
* `hidden: true` in the consumer config. No-op when no config / no matching
|
|
59
|
+
* entry. Pure check, exported for testing + reuse by admin pre-flight UI.
|
|
60
|
+
*/
|
|
61
|
+
export declare function assertManualStatusAllowed(status: OrderStatus, orderStatuses: OrderStatusConfig[] | undefined): void;
|
|
43
62
|
/**
|
|
44
63
|
* @public
|
|
45
64
|
* Thrown by `softDeleteOrder` when the order can't be hidden: either its status
|
|
@@ -98,6 +117,13 @@ export declare function updateOrderStatus(orderId: string, status: OrderStatus,
|
|
|
98
117
|
note?: string;
|
|
99
118
|
changedBy?: string;
|
|
100
119
|
paymentKind?: 'full' | 'deposit' | 'balance';
|
|
120
|
+
/**
|
|
121
|
+
* Caller context. `'admin'` enforces the `orderStatuses[].hidden` guard
|
|
122
|
+
* (throws {@link HiddenStatusTransitionError}). `'webhook'` and `'system'`
|
|
123
|
+
* (default) bypass it — provider-driven and engine-internal transitions
|
|
124
|
+
* are always allowed so a hidden status never silently drops a webhook.
|
|
125
|
+
*/
|
|
126
|
+
source?: 'admin' | 'webhook' | 'system';
|
|
101
127
|
}): Promise<OrderRow>;
|
|
102
128
|
/**
|
|
103
129
|
* @internal
|
|
@@ -60,6 +60,33 @@ export function decideOrderDeletion(status, invoice) {
|
|
|
60
60
|
}
|
|
61
61
|
return { ok: true };
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* @public
|
|
65
|
+
* Thrown by {@link assertManualStatusAllowed} (and {@link updateOrderStatus}
|
|
66
|
+
* when called from the admin UI) for a manual transition into a status the
|
|
67
|
+
* consumer marked as hidden in `ShopConfig.orderStatuses`. Webhook /
|
|
68
|
+
* carrier-driven transitions bypass this check — `source: 'webhook'`.
|
|
69
|
+
*/
|
|
70
|
+
export class HiddenStatusTransitionError extends Error {
|
|
71
|
+
status;
|
|
72
|
+
code = 'HIDDEN_STATUS_TRANSITION';
|
|
73
|
+
constructor(status) {
|
|
74
|
+
super(`Manual transition into hidden status "${status}" is not allowed.`);
|
|
75
|
+
this.status = status;
|
|
76
|
+
this.name = 'HiddenStatusTransitionError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* @public
|
|
81
|
+
* Throw a {@link HiddenStatusTransitionError} when the target status is marked
|
|
82
|
+
* `hidden: true` in the consumer config. No-op when no config / no matching
|
|
83
|
+
* entry. Pure check, exported for testing + reuse by admin pre-flight UI.
|
|
84
|
+
*/
|
|
85
|
+
export function assertManualStatusAllowed(status, orderStatuses) {
|
|
86
|
+
const cfg = orderStatuses?.find((s) => s.key === status);
|
|
87
|
+
if (cfg?.hidden === true)
|
|
88
|
+
throw new HiddenStatusTransitionError(status);
|
|
89
|
+
}
|
|
63
90
|
/**
|
|
64
91
|
* @public
|
|
65
92
|
* Thrown by `softDeleteOrder` when the order can't be hidden: either its status
|
|
@@ -385,6 +412,9 @@ export async function updateOrderStatus(orderId, status, opts = {}) {
|
|
|
385
412
|
if (order.status === status)
|
|
386
413
|
return order;
|
|
387
414
|
const shop = requireShopConfig();
|
|
415
|
+
if (opts.source === 'admin') {
|
|
416
|
+
assertManualStatusAllowed(status, shop.orderStatuses);
|
|
417
|
+
}
|
|
388
418
|
// Deposit-kind transition to `paid`: bump partialPayment.paidAmount to the
|
|
389
419
|
// deposit amount, stamp paidAt, and flag balanceOwed = true. Stock is
|
|
390
420
|
// permanently confirmed by the existing `paid` branch below — no TTL
|
package/dist/shop/types.d.ts
CHANGED
|
@@ -8,6 +8,43 @@ export type Currency = 'PLN';
|
|
|
8
8
|
*/
|
|
9
9
|
export type Order = typeof shopOrdersTable.$inferSelect;
|
|
10
10
|
export type OrderStatus = 'new' | 'awaitingPayment' | 'paid' | 'preparing' | 'sent' | 'done' | 'cancelled' | 'paymentRejected' | 'refunded';
|
|
11
|
+
/**
|
|
12
|
+
* Per-status configuration. Lets consumer projects hide a built-in status from
|
|
13
|
+
* the admin status dropdown (`hidden`) and / or skip the customer status email
|
|
14
|
+
* when the order enters that status (`sendEmail: false`). Useful when the
|
|
15
|
+
* product is stationary (events, courses) and the shipping-oriented states
|
|
16
|
+
* (`preparing`, `sent`) don't apply.
|
|
17
|
+
*
|
|
18
|
+
* `hidden: true` blocks manual transitions from the admin UI, but webhook /
|
|
19
|
+
* carrier driven transitions still go through — those updates are
|
|
20
|
+
* engine-fundamental and the consumer can't always foresee what a provider
|
|
21
|
+
* will send.
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
export interface OrderStatusConfig {
|
|
25
|
+
key: OrderStatus;
|
|
26
|
+
/** Hide from admin dropdown + block manual admin transition. Default `false`. */
|
|
27
|
+
hidden?: boolean;
|
|
28
|
+
/** Send status email when the order enters this status. Default `true`. */
|
|
29
|
+
sendEmail?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Context passed to a {@link SubjectPlaceholderResolver}. Currently carries
|
|
33
|
+
* just the resolved order language; expand as new built-in placeholders need
|
|
34
|
+
* extra data.
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
export interface EmailContext {
|
|
38
|
+
language: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolves one `{placeholder}` token in an email subject template to a string.
|
|
42
|
+
* Registered globally via {@link ShopConfig.emailSubjectPlaceholders} —
|
|
43
|
+
* consumer projects add their own (e.g. `{event_date}`) without touching the
|
|
44
|
+
* shop engine. Receives the full order row + a minimal context object.
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
export type SubjectPlaceholderResolver = (order: Order, ctx: EmailContext) => string;
|
|
11
48
|
export type I18nText = {
|
|
12
49
|
[lang: string]: string;
|
|
13
50
|
};
|
|
@@ -437,6 +474,56 @@ export interface ShopConfig {
|
|
|
437
474
|
/** Throw on missing tokens / unknown CMS slugs. Default `false` (lenient → ""). */
|
|
438
475
|
strict?: boolean;
|
|
439
476
|
};
|
|
477
|
+
/**
|
|
478
|
+
* Per-status overrides — hide a status from the admin dropdown and / or
|
|
479
|
+
* skip the customer status email when the order enters that status. Useful
|
|
480
|
+
* for product types that don't ship physically (stationary events, digital
|
|
481
|
+
* services). Each entry targets one of the built-in {@link OrderStatus}
|
|
482
|
+
* keys; built-in status semantics are preserved, only UI visibility +
|
|
483
|
+
* email-send are toggled.
|
|
484
|
+
*
|
|
485
|
+
* `hidden: true` blocks admin manual transitions but webhook-driven
|
|
486
|
+
* transitions are unaffected.
|
|
487
|
+
* @public
|
|
488
|
+
*/
|
|
489
|
+
orderStatuses?: OrderStatusConfig[];
|
|
490
|
+
/**
|
|
491
|
+
* Project-specific subject placeholder resolvers, merged on top of the
|
|
492
|
+
* built-in registry (`order_number`, `customer_name`, `customer_email`,
|
|
493
|
+
* `total_gross`, `currency`). Use to add tokens specific to the consumer's
|
|
494
|
+
* domain (e.g. `{event_date}` for course shops).
|
|
495
|
+
*
|
|
496
|
+
* Templates use `{key}` syntax in any subject string (CMS or hardcoded
|
|
497
|
+
* fallback); unknown keys are left untouched (lenient).
|
|
498
|
+
* @public
|
|
499
|
+
*/
|
|
500
|
+
emailSubjectPlaceholders?: Record<string, SubjectPlaceholderResolver>;
|
|
501
|
+
/**
|
|
502
|
+
* Optional resolver for the email subject template per order status.
|
|
503
|
+
* Receives the resolved status + order language and returns a subject
|
|
504
|
+
* template string (with `{placeholder}` tokens), or `null`/`undefined`
|
|
505
|
+
* to fall back to the package default.
|
|
506
|
+
*
|
|
507
|
+
* The returned string is fed through {@link formatSubject} just like the
|
|
508
|
+
* default, so built-in and {@link emailSubjectPlaceholders} tokens both work.
|
|
509
|
+
*
|
|
510
|
+
* Use this to source subjects from a CMS singleton (the package itself stays
|
|
511
|
+
* agnostic about your CMS schema). Async — you can hit the DB here.
|
|
512
|
+
*
|
|
513
|
+
* @example
|
|
514
|
+
* ```ts
|
|
515
|
+
* resolveStatusSubject: async (status, { language }) => {
|
|
516
|
+
* const entry = await resolveEntry({ collection: 'shopEmails' });
|
|
517
|
+
* const key = STATUS_TO_CMS_KEY[status];
|
|
518
|
+
* return entry?.statuses?.[key]?.subject;
|
|
519
|
+
* }
|
|
520
|
+
* ```
|
|
521
|
+
* @public
|
|
522
|
+
*/
|
|
523
|
+
resolveStatusSubject?: (status: OrderStatus, ctx: {
|
|
524
|
+
order: Order;
|
|
525
|
+
language: string;
|
|
526
|
+
}) => Promise<string | null | undefined> | string | null | undefined;
|
|
440
527
|
/**
|
|
441
528
|
* Optional callback fired after an order transitions to `paid`.
|
|
442
529
|
*
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.36.1',
|
|
3
|
+
date: '2026-06-08',
|
|
4
|
+
description: 'Fakturownia adapter — fix B2C: gdy brak `buyer.nip` i `buyer.companyName`, payload zawiera `buyer_first_name` + `buyer_last_name` (rozdzielone przez `splitFullName` — spójnie z PayU/InPost). Bez tej zmiany Fakturownia API odrzuca fakturę dla osoby fizycznej z 422 `buyer_tax_no — nie może być puste`.',
|
|
5
|
+
features: [],
|
|
6
|
+
fixes: [
|
|
7
|
+
'`buildFakturowniaInvoice` — dla B2C (brak NIP i companyName) payload zawiera teraz `buyer_first_name` i `buyer_last_name` (rozdzielone z `buyer.name` przez `splitFullName` z adaptera PayU). B2B (z NIP lub companyName) zachowuje obecne zachowanie — bez first/last name. Dla `splitFullName("Madonna")` (1 słowo) → tylko `buyer_first_name`. Naprawia 422 z Fakturownia dla osób fizycznych.'
|
|
8
|
+
],
|
|
9
|
+
breakingChanges: []
|
|
10
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.36.2',
|
|
3
|
+
date: '2026-06-08',
|
|
4
|
+
description: 'Per-status overrides (`ShopConfig.orderStatuses`) + globalny rejestr subject placeholders (`formatSubject`/`ShopConfig.emailSubjectPlaceholders`) + nowy hook `ShopConfig.resolveStatusSubject` do czytania subject z CMS + Handlebars helper `{{{structured}}}` renderujący `StructuredContentDoc` (TipTap) jako email-safe HTML z automatycznym token replacement. Email context rozszerzony o billing fields (firma/NIP/adres/telefon). Dwa fixy bugów w resolverze CMS singletons w mailach. Zaprojektowane pod sklepy ze szkoleniami/wydarzeniami (stationary), gdzie statusy `preparing`/`sent` nie mają sensu. Wszystkie API generyczne, do reusu w innych consumer projects.',
|
|
5
|
+
features: [
|
|
6
|
+
'**`ShopConfig.orderStatuses?: OrderStatusConfig[]`** — opcjonalna lista per-status overrides. `hidden: true` chowa status z admin dropdownu (`shop-order-detail-page.svelte`) i blokuje manual transitions z admina rzucając nowy `HiddenStatusTransitionError` (kod `HIDDEN_STATUS_TRANSITION`). `sendEmail: false` powoduje, że `sendOrderStatusEmail` early-returnuje przy wejściu w ten status. Webhook / carrier-driven transitions BYPASSują guard — provider-driven update musi przejść bez względu na config (rola engine fundamentalna). Nowy parametr `updateOrderStatus(orderId, status, { source: "admin" })` rozróżnia kontekst; `source: "webhook" | "system"` (default) zawsze pomija check. Pure helpery: `assertManualStatusAllowed(status, orderStatuses)` (rzuca) i `shouldSendStatusEmail(status, orderStatuses)` (zwraca bool).',
|
|
7
|
+
'**`formatSubject` + `builtInSubjectPlaceholders`** (`$lib/shop/server/emailSubject.js`) — generyczna interpolacja `{key}` w subject mailach. Built-in: `{order_number}`, `{customer_name}`, `{customer_email}`, `{total_gross}` (formatted PLN currency), `{currency}`. Nowy `ShopConfig.emailSubjectPlaceholders?: Record<string, SubjectPlaceholderResolver>` — projektowe rozszerzenia (np. `{event_date}` dla szkoleń), mergowane na built-in (custom wins). `sendOrderStatusEmail` przepuszcza fallback subject (`STATUS_SUBJECTS[status][lang] · {order_number}`) przez `formatSubject` zanim wyśle. Nieznane placeholdery są lenient — zostają w stringu.',
|
|
8
|
+
'**`ShopConfig.resolveStatusSubject?(status, ctx) => string | null | undefined`** — async hook pozwalający consumerowi pobrać template subject z dowolnego źródła (typically CMS singleton). Wywoływany w `sendOrderStatusEmail` przed `formatSubject`, więc tokeny działają tak samo jak w defaultach. Pusta wartość / błąd → fallback do `STATUS_SUBJECTS`. Paczka pozostaje agnostyczna o CMS schemie consumera.',
|
|
9
|
+
'**Handlebars helper `{{{structured doc}}}`** (`emailTemplateRegistry.ts`) — renderuje `StructuredContentDoc` (output TipTap fielda `content`) jako inline HTML dla maili. Używa `structuredToHtml()` z core; zwraca `SafeString`. Po render — automatyczne **token replacement** przez `formatSubject` (built-in + `emailSubjectPlaceholders`) jeśli `order` jest w Handlebars context. Pozwala wpisać `{order_number}` w polu TipTap w CMSie i mieć je podmienione w wysłanym mailu. Pusty/undefined doc → "".',
|
|
10
|
+
'**Email context rozszerzony o billing fields** (`email.ts`): `order.customerCompanyName`, `customerNip`, `customerPhone`, `billingAddress` (`{street, postalCode, city, country?}`) + flagi `hasCompany` / `hasBillingAddress` dla wygodnych `{{#if}}` w templates. Pozwala renderować WooCommerce-style billing block bez custom JOIN.'
|
|
11
|
+
],
|
|
12
|
+
fixes: [
|
|
13
|
+
'**`extractCmsSlugs` skanuje partials** — wcześniej AST tylko głównego templatu był parsowany, więc `cms.<slug>.*` referencje w partials (np. `cms.globalSettings.legalEntity` w `_partials/footer.pl.html`) były ignorowane → singleton nie był prefetchowany → context był pusty → fallbacki renderowane mimo wypełnionego CMSa. Teraz dodatkowo iteruje przez wszystkie zarejestrowane `Handlebars.partials` i merguje slugi.',
|
|
14
|
+
'**`resolveCmsContext` poprawnie spłaszcza entry** — wcześniej używał `entry?.data ?? {}`, ale `resolveEntry` zwraca obiekt spreadowany (`{ _id, _slug, _type, _publishedAt, _url, ...fields }`) bez pola `.data`. Efekt: każdy `cms.<slug>` w mailu był pustym obiektem. Teraz strip-uje meta keys (`_*`) i zwraca user fields. Mock w testach był niezgodny z prawdziwym API, dlatego bug przetrwał — naprawiony, dodany regression test dla nested path.'
|
|
15
|
+
],
|
|
16
|
+
breakingChanges: []
|
|
17
|
+
};
|
package/dist/updates/index.js
CHANGED
|
@@ -67,6 +67,8 @@ import { update as update0340 } from './0.34.0/index.js';
|
|
|
67
67
|
import { update as update0341 } from './0.34.1/index.js';
|
|
68
68
|
import { update as update0350 } from './0.35.0/index.js';
|
|
69
69
|
import { update as update0360 } from './0.36.0/index.js';
|
|
70
|
+
import { update as update0361 } from './0.36.1/index.js';
|
|
71
|
+
import { update as update0362 } from './0.36.2/index.js';
|
|
70
72
|
export const updates = [
|
|
71
73
|
update0065,
|
|
72
74
|
update0066,
|
|
@@ -136,7 +138,9 @@ export const updates = [
|
|
|
136
138
|
update0340,
|
|
137
139
|
update0341,
|
|
138
140
|
update0350,
|
|
139
|
-
update0360
|
|
141
|
+
update0360,
|
|
142
|
+
update0361,
|
|
143
|
+
update0362
|
|
140
144
|
];
|
|
141
145
|
export const getUpdatesFrom = (fromVersion) => {
|
|
142
146
|
const fromParts = fromVersion.split('.').map(Number);
|