From 7888dc771ede40323ae35acaaa19ef87f66b982d Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 7 Aug 2026 10:47:55 +0300 Subject: [PATCH 01/16] fix: (passenger): block payment initiation too close to the booking deadline --- .../common/utils/payment-deadline.utils.ts | 30 ++++++-- .../src/modules/payments/payments.dto.ts | 4 ++ .../modules/payments/payments.service.spec.ts | 68 +++++++++++++++++++ .../src/modules/payments/payments.service.ts | 48 +++++++++++-- .../src/providers/dmoney/dmoney.provider.ts | 2 +- 5 files changed, 141 insertions(+), 11 deletions(-) diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts index e125bb0bf..8c391aef1 100644 --- a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts @@ -13,11 +13,17 @@ export const MAX_PAYMENT_HOURS = 2; export const CUTOFF_MINUTES = 30; /** - * payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes) - * - * checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level - * checkinMinutesBefore so that each route's own window is respected. + * How long a passenger is given to finish one provider payment session, once opened. + * 5 minutes of actual paying (redirect → PIN/OTP → provider callback) + 1 minute of slack. */ +export const PAYMENT_SESSION_MINUTES = 6; + + +export const MIN_PAYMENT_WINDOW_MINUTES = 7; + +export const PAYMENT_SETTLE_MARGIN_SECONDS = 60; + + export function computePaymentDeadline( createdAt: Date, departureAt: Date, @@ -27,3 +33,19 @@ export function computePaymentDeadline( const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000); return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; } + + +export function canOpenPaymentSession( + paymentDeadline: Date, + now: Date = new Date(), +): boolean { + return paymentDeadline.getTime() - now.getTime() >= MIN_PAYMENT_WINDOW_MINUTES * 60 * 1000; +} + +export function computePaymentSessionExpiry( + paymentDeadline: Date, + now: Date = new Date(), +): Date { + const sessionEnd = new Date(now.getTime() + PAYMENT_SESSION_MINUTES * 60 * 1000); + return sessionEnd < paymentDeadline ? sessionEnd : paymentDeadline; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index dc389e5a1..1d033e5ff 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -154,6 +154,10 @@ export class InitiateResponseDto { @ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto; @ApiPropertyOptional() merchantOrderId?: string; + /** When this payment session stops being offered — PAYMENT_SESSION_MINUTES from initiation, capped at paymentDeadline. Drives the client-side countdown. */ + @ApiPropertyOptional() sessionExpiresAt?: string; + /** The booking's payment deadline: after it, the booking is auto-cancelled. */ + @ApiPropertyOptional() paymentDeadline?: string; } export class IntentStatusDto { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 8c4edf083..186383335 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -16,6 +16,11 @@ import { ProviderMethod, ProviderPaymentStatus, } from "@edr/types"; +import { + MAX_PAYMENT_HOURS, + MIN_PAYMENT_WINDOW_MINUTES, + PAYMENT_SESSION_MINUTES, +} from "../../common/utils/payment-deadline.utils"; describe("PaymentsService", () => { let service: PaymentsService; @@ -163,6 +168,69 @@ describe("PaymentsService", () => { ).rejects.toThrow(BadRequestException); }); + /** + * A booking whose payment deadline lands exactly `minutesLeft` from now: the deadline is + * MIN(createdAt + MAX_PAYMENT_HOURS, departure - checkin), so back-date createdAt and keep + * departure far away. Derived from MAX_PAYMENT_HOURS so the test survives changes to it. + */ + const bookingWithDeadlineIn = (minutesLeft: number) => ({ + ...mockBooking, + createdAt: new Date( + Date.now() - (MAX_PAYMENT_HOURS * 60 - minutesLeft) * 60 * 1000, + ), + originStationId: null, + schedule: { + departureAt: new Date(Date.now() + 10 * 60 * 60 * 1000), + stopTimes: [], + route: null, + }, + }); + + it("should refuse to open a provider session that cannot finish before auto-cancel", async () => { + // 2 minutes left — the real incident: the session was opened, the provider captured the + // money, and the auto-cancel cron had already cancelled the booking by then. + mockPrisma.booking.findUnique.mockResolvedValue(bookingWithDeadlineIn(2)); + + await expect( + service.initiatePayment({ + bookingId: "booking-1", + method: "TELEBIRR" as any, + }), + ).rejects.toThrow(BadRequestException); + + // Nothing may reach the provider — no session, no capture, no orphan payment. + expect(mockPaymentClient.initiate).not.toHaveBeenCalled(); + }); + + it("should open a session and report its expiry when the window is wide enough", async () => { + const minutesLeft = MIN_PAYMENT_WINDOW_MINUTES + 3; + mockPrisma.booking.findUnique.mockResolvedValue( + bookingWithDeadlineIn(minutesLeft), + ); + mockPaymentClient.initiate.mockResolvedValue( + requiresActionSnapshot(ProviderMethod.TELEBIRR), + ); + mockPrisma.paymentIntent.upsert.mockResolvedValue({ + id: "intent-1", + status: PaymentIntentStatus.REQUIRES_ACTION, + merchantOrderId: "PSG-MERCH-123", + }); + + const result = await service.initiatePayment({ + bookingId: "booking-1", + method: "TELEBIRR" as any, + }); + + expect(mockPaymentClient.initiate).toHaveBeenCalled(); + // Session ends PAYMENT_SESSION_MINUTES from now — before the deadline, not at it. + const sessionMs = + new Date(result.sessionExpiresAt!).getTime() - Date.now(); + expect(sessionMs).toBeLessThanOrEqual(PAYMENT_SESSION_MINUTES * 60 * 1000); + expect(new Date(result.sessionExpiresAt!).getTime()).toBeLessThan( + new Date(result.paymentDeadline!).getTime(), + ); + }); + it("should initiate a provider payment through the payment microservice", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPaymentClient.initiate.mockResolvedValue( diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 14e48dce5..2b1b14cf1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -29,7 +29,13 @@ import { MarkPaidResponseDto, BillQueryResponseDto, } from "./internal-payments.dto"; -import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils"; +import { + computePaymentDeadline, + computePaymentSessionExpiry, + canOpenPaymentSession, + MIN_PAYMENT_WINDOW_MINUTES, + PAYMENT_SETTLE_MARGIN_SECONDS, +} from "../../common/utils/payment-deadline.utils"; import { PaymentClientService, PaymentDiagnostic, @@ -260,6 +266,27 @@ export class PaymentsService { return this.initiateWalletPayment(booking); } + // Refuse to open a provider session that cannot finish before auto-cancel. Everything below + // this point hands the passenger off to an external provider (redirect/HPP/OTP), which takes + // minutes; TasksService cancels the booking the first cron tick after its payment deadline. + // Opening a session with less than MIN_PAYMENT_WINDOW_MINUTES left produces the worst possible + // outcome — the provider captures the money and the booking is already CANCELLED when the + // capture lands. WALLET is exempt (returned above): it is an instant internal balance debit. + const paymentDeadline = await this.computeBookingPaymentDeadline(booking.id); + const sessionExpiresAt = paymentDeadline + ? computePaymentSessionExpiry(paymentDeadline) + : undefined; + if (paymentDeadline && !canOpenPaymentSession(paymentDeadline)) { + const remainingMs = paymentDeadline.getTime() - Date.now(); + throw new BadRequestException( + remainingMs <= 0 + ? "The payment window for this booking has expired. Please make a new booking." + : `Too little time is left to start a payment (${Math.ceil(remainingMs / 60000)} minute(s) ` + + `until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` + + `Please make a new booking.`, + ); + } + // Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the // single passenger projection row (upserted by bookingId below) tracks the latest session. // Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here. @@ -317,9 +344,7 @@ export class PaymentsService { payerName = booking.seats.find((s) => s.leg === 1)?.passengerName ?? booking.seats[0]?.passengerName; - expiresAt = ( - await this.computeBookingPaymentDeadline(booking.id) - )?.toISOString(); + expiresAt = paymentDeadline?.toISOString(); } const snapshot = await this.paymentClient.initiate({ @@ -350,7 +375,11 @@ export class PaymentsService { where: { id: intent.id }, }); } - return this.formatIntentResponse(intent); + return { + ...this.formatIntentResponse(intent), + sessionExpiresAt: sessionExpiresAt?.toISOString(), + paymentDeadline: paymentDeadline?.toISOString(), + }; } /** @@ -436,8 +465,15 @@ export class PaymentsService { if (booking.status !== "PENDING_PAYMENT") { return { ...base, stillPayable: false, reason: "NOT_PAYABLE" }; } + // A CBE debit confirmed now lands in seconds, so this doesn't need the full + // MIN_PAYMENT_WINDOW_MINUTES that opening a session does — but it must not be confirmed so + // close to the deadline that the auto-cancel cron cancels the booking before the capture is + // registered. Refusing here is what keeps CBE from debiting a passenger for a dead booking. const deadline = await this.computeBookingPaymentDeadline(booking.id); - if (deadline && deadline.getTime() < Date.now()) { + if ( + deadline && + deadline.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < Date.now() + ) { return { ...base, stillPayable: false, reason: "EXPIRED" }; } return { ...base, stillPayable: true, reason: null }; diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 51e90381b..3af351e32 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -353,7 +353,7 @@ export class DMoneyProvider implements PaymentProvider { return this.config.get("dmoney.returnUrl") ?? ""; } private get timeoutExpress(): string { - return this.config.get("dmoney.timeoutExpress") ?? "120m"; + return this.config.get("dmoney.timeoutExpress") ?? "5m"; } private get language(): string { return this.config.get("dmoney.language") ?? "en"; From f51ee7cf598f838ca1373ca8b75d6c90179bba30 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 7 Aug 2026 13:48:58 +0300 Subject: [PATCH 02/16] feat: (payment) add telebirr mini-app in-app payment flow --- .../src/modules/payment/payments.dto.ts | 28 +++- .../modules/payments/payments.controller.ts | 11 +- .../src/modules/payments/payments.dto.ts | 38 ++++- .../payments/supplementary-charges.service.ts | 3 +- .../portal/src/app/booking/payment/page.tsx | 133 +++++++++++++++++- .../portal/src/lib/telebirr-bridge.ts | 106 ++++++++++++++ .../intents/dto/initiate-payment.dto.ts | 9 +- .../providers/telebirr/telebirr.provider.ts | 81 +++++++++-- packages/types/src/common/payments.ts | 13 +- 9 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 255da0ea2..ee7b703d2 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -60,14 +60,38 @@ export class RefundDto { } export class ClientActionDto { + // INVOKE_BRIDGE (SuperApp mini-app payload) is part of the shared ClientAction union and so + // must be assignable here, but freight never requests platform=inapp and therefore never + // receives one. Passenger owns that flow — see docs/telebirr-miniapp/. @ApiProperty({ - enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], + enum: [ + "REDIRECT", + "LAUNCH_APP", + "INVOKE_BRIDGE", + "COLLECT_OTP", + "SHOW_BILL_REFERENCE", + ], }) - type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; + type!: + | "REDIRECT" + | "LAUNCH_APP" + | "INVOKE_BRIDGE" + | "COLLECT_OTP" + | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; + @ApiPropertyOptional({ + description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight", + }) + bridge?: "TELEBIRR"; + + @ApiPropertyOptional({ + description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight", + }) + rawRequest?: string; + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) appId?: string; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 7da37df33..9c1bfa65d 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -56,7 +56,7 @@ class WaiveSupplementaryChargeDto { class PaySupplementaryChargeDto { @ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; - @ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile'; + @ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto; } @ApiTags("Payment") @@ -307,7 +307,14 @@ export class PaymentsController { }) @ApiQuery({ name: "bookingId", required: true }) @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) - @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiQuery({ + name: "platform", + enum: ["web", "mobile"], + required: false, + description: + "Browser-only endpoint — `inapp` is not offered here. A mini-app payer has no browser " + + "to redirect and must go through POST /payments/initiate for the bridge payload.", + }) @ApiProduces("text/html") async checkout( @Query("bookingId") bookingId: string, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 1d033e5ff..448aa4e7b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -28,7 +28,8 @@ export enum PaymentMethodTypeEnum { CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number) } -export type PaymentPlatformDto = "web" | "mobile"; +/** Mirrors `PaymentPlatform` in @edr/types — see there for what each surface means. */ +export type PaymentPlatformDto = "web" | "mobile" | "inapp"; export class InitiatePaymentDto { @ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string; @@ -45,12 +46,14 @@ export class InitiatePaymentDto { @IsString() paymentMethodId?: string; @ApiPropertyOptional({ - enum: ["web", "mobile"], + enum: ["web", "mobile", "inapp"], default: "web", - description: "Payment platform (web or mobile)", + description: + "Payer surface. `inapp` = the portal is running inside a SuperApp mini-app WebView " + + "(Telebirr), which cannot follow redirect flows and gets a bridge payload instead.", }) @IsOptional() - @IsIn(["web", "mobile"]) + @IsIn(["web", "mobile", "inapp"]) platform?: PaymentPlatformDto; @ApiPropertyOptional({ description: @@ -117,9 +120,20 @@ export class SupportedPaymentMethodDto { export class ClientActionDto { @ApiProperty({ - enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], + enum: [ + "REDIRECT", + "LAUNCH_APP", + "INVOKE_BRIDGE", + "COLLECT_OTP", + "SHOW_BILL_REFERENCE", + ], }) - type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; + type: + | "REDIRECT" + | "LAUNCH_APP" + | "INVOKE_BRIDGE" + | "COLLECT_OTP" + | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @ApiPropertyOptional({ @@ -134,6 +148,18 @@ export class ClientActionDto { description: "Set when type=LAUNCH_APP (mobile flow)", }) shortCode?: string; + @ApiPropertyOptional({ + description: + "Set when type=INVOKE_BRIDGE (telebirr mini app) — which SuperApp host bridge to call", + enum: ["TELEBIRR"], + }) + bridge?: "TELEBIRR"; + @ApiPropertyOptional({ + description: + "Set when type=INVOKE_BRIDGE (telebirr mini app). Signed query string handed verbatim " + + "to the host bridge (js_fun_start_pay). NOT a URL — never navigate to it.", + }) + rawRequest?: string; @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) providerOrderId?: string; @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts index f8c316d87..079792366 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -5,6 +5,7 @@ import { SmsClientService } from '../notifications/sms-client.service'; import { EmailClientService } from '../notifications/email-client.service'; import { PaymentClientService } from './payment-client.service'; import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types'; +import { PaymentPlatformDto } from './payments.dto'; const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours @@ -119,7 +120,7 @@ export class SupplementaryChargesService { async pay( token: string, method: string, - platform?: 'web' | 'mobile', + platform?: PaymentPlatformDto, requestOrigin?: string | null, ) { const charge = await this.getByToken(token); // validates status/expiry diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 685ba5ee2..166b1c799 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -6,7 +6,12 @@ import { usePaymentStore } from "@/lib/payment-store"; import { useMutation, useQuery } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; -import { useState, useEffect } from "react"; +import { + isTelebirrMiniApp, + onTelebirrPayResult, + startTelebirrPay, +} from "@/lib/telebirr-bridge"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { PaymentMethod } from "@/types"; import { format } from "date-fns"; import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format'; @@ -55,6 +60,17 @@ export default function PaymentPage() { } | null>(null); const [billCopied, setBillCopied] = useState(false); + // Telebirr mini app: the SuperApp payment sheet is open (or just closed) and we're + // polling our own status endpoint for the webhook-backed outcome. + const [verifyingPayment, setVerifyingPayment] = useState(false); + + // Resolved once on mount — SSR has no `window`, so this must not be read during render + // of the first (server) pass. + const [inMiniApp, setInMiniApp] = useState(false); + useEffect(() => { + setInMiniApp(isTelebirrMiniApp()); + }, []); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; // Use the same display currency as the review page — stored on the schedule at search time. @@ -72,8 +88,26 @@ export default function PaymentPage() { }, }); + // Inside the telebirr SuperApp only telebirr can complete: every other method is a + // redirect/HPP flow, and the mini-app WebView cannot follow the scheme handoffs those + // gateways use. Offering them would strand the payer on a dead page. + // Memoised: this feeds an effect's dep array, and a fresh array identity every render + // would re-run that effect on every render. + const availableMethods = useMemo( + () => paymentMethods.filter((m) => m.enabled && (!inMiniApp || m.type === 'TELEBIRR')), + [paymentMethods, inMiniApp], + ); + const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null; + // A method chosen before the container was known (or carried over in state) may no longer + // be offerable — drop it rather than letting Pay fire against a hidden method. + useEffect(() => { + if (selectedMethod && !availableMethods.some((m) => m.type === selectedMethod)) { + setSelectedMethod(null); + } + }, [selectedMethod, availableMethods]); + // Derive charge currency directly from the selected method — no separate state that can lag. const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase(); @@ -128,6 +162,70 @@ export default function PaymentPage() { } }, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]); + /** + * Poll our own status endpoint until the payment reaches a terminal state. + * + * Used by the telebirr mini-app flow, where nothing navigates and therefore no return page + * ever runs. The bridge callback only tells us the sheet closed; the authoritative outcome + * is the webhook-backed status the API reports here. + */ + const pollPaymentStatus = useCallback( + async (attemptsLeft: number): Promise => { + if (!bookingId) return; + try { + const res: any = await apiClient.get(`/payments/status/${bookingId}`); + if (res?.status === 'SUCCEEDED') { + setVerifyingPayment(false); + updateStatus("SUCCEEDED"); + router.push("/booking/confirmation"); + return; + } + if (res?.status === 'FAILED' || res?.status === 'CANCELLED') { + setVerifyingPayment(false); + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError(res?.failureMessage || "Payment was not completed. Please try again."); + return; + } + } catch { + // Transient read failure — keep polling; the attempt budget bounds it. + } + + if (attemptsLeft <= 0) { + // Don't call it failed: telebirr may have taken the money and the webhook is simply + // still in flight. Stop spinning, tell the truth, and let the payer re-check. + setVerifyingPayment(false); + setIsProcessing(false); + setPaymentError( + "We haven't received confirmation yet. If you completed the payment, your booking " + + "will be confirmed shortly — check My Bookings in a moment before paying again.", + ); + return; + } + setTimeout(() => void pollPaymentStatus(attemptsLeft - 1), 1500); + }, + [bookingId, router, updateStatus], + ); + + /** + * Telebirr mini app reports the sheet outcome on a global callback rather than a redirect. + * Registered on mount — the SuperApp can call back the moment the sheet closes, so it must + * already be installed before the bridge is invoked. + */ + useEffect(() => { + return onTelebirrPayResult((succeeded) => { + if (!succeeded) { + setVerifyingPayment(false); + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError("Payment was cancelled or declined. Please try again."); + return; + } + setVerifyingPayment(true); + void pollPaymentStatus(15); + }); + }, [pollPaymentStatus, updateStatus]); + const paymentMutation = useMutation({ mutationFn: async (data: any) => { return await apiClient.post("/payments/initiate", { @@ -135,7 +233,7 @@ export default function PaymentPage() { method: data.method, paymentMethodId: data.paymentMethodId, payerAccount: data.payerAccount, - platform: 'web', + platform: isTelebirrMiniApp() ? 'inapp' : 'web', }); }, onSuccess: async (data: any) => { @@ -163,6 +261,23 @@ export default function PaymentPage() { return; } + // Telebirr mini app: hand the signed rawRequest to the SuperApp bridge. Nothing + // navigates — telebirr draws its payment sheet over the WebView and reports back on + // the global callback registered above, which starts the status polling. + if (data?.clientAction?.type === 'INVOKE_BRIDGE') { + setPaymentIntent(data.intentId); + updateStatus("REQUIRES_ACTION"); + if (!startTelebirrPay(data.clientAction.rawRequest)) { + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError( + "Couldn't open the telebirr payment sheet. Please reopen this page from the " + + "telebirr app and try again.", + ); + } + return; + } + if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') { setPaymentIntent(data.intentId); updateStatus("REQUIRES_ACTION"); @@ -505,7 +620,15 @@ export default function PaymentPage() { {isProcessing && (
- {paymentMutation.isSuccess ? ( + {verifyingPayment ? ( + <> + +

Confirming payment

+

+ Checking with telebirr — this only takes a moment. +

+ + ) : paymentMutation.isSuccess ? ( <>

Loading...

@@ -688,13 +811,13 @@ export default function PaymentPage() {

Failed to load payment methods. Please refresh.

- ) : paymentMethods.length === 0 ? ( + ) : availableMethods.length === 0 ? (

No payment methods available at the moment.

) : (
- {paymentMethods.filter(m => m.enabled).map((method) => { + {availableMethods.map((method) => { const Icon = getIconForMethod(method.type); const isSelected = selectedMethod === method.type; return ( diff --git a/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts b/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts new file mode 100644 index 000000000..fd2a9ccea --- /dev/null +++ b/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts @@ -0,0 +1,106 @@ +/** + * Telebirr SuperApp mini-app bridge. + * + * When the portal runs inside the telebirr SuperApp, the ordinary web checkout is unusable: + * the H5 paygate page hands off to the native wallet with a custom scheme + * (`kcbconsumer://h5checkout?...`) that the SuperApp's WebView cannot resolve, so the payer + * only ever sees `net::ERR_UNKNOWN_URL_SCHEME`. + * + * The in-app flow never navigates. The API returns a signed `rawRequest` string + * (clientAction.type === "INVOKE_BRIDGE") which is handed to the host's JS bridge; telebirr + * renders its own payment sheet over the WebView and reports the outcome on a global callback. + * + * See docs/telebirr-miniapp/inapp-payment-plan.md. + */ + +/** Name of the global the SuperApp calls back into. Must be a property of `window`. */ +export const TELEBIRR_PAY_CALLBACK = "handleEdrPaymentCallback"; + +type ConsumerApp = { evaluate: (payload: string) => void }; + +declare global { + interface Window { + /** Injected by the telebirr SuperApp WebView. Absent everywhere else. */ + consumerapp?: ConsumerApp; + [TELEBIRR_PAY_CALLBACK]?: (response: unknown) => void; + } +} + +/** + * True only when the telebirr host bridge is actually present. + * + * Deliberately does NOT sniff the user agent. A UA match without `window.consumerapp` would + * make us request `platform: "inapp"` and get back a bare rawRequest we have no way to use — + * there is no navigating our way out of that, because by then the server has already committed + * to the bridge payload. Gating on the bridge object keeps the decision and the capability in + * sync: if we can't call it, we don't ask for it. + */ +export function isTelebirrMiniApp(): boolean { + return typeof window !== "undefined" && typeof window.consumerapp?.evaluate === "function"; +} + +/** + * Hand a signed rawRequest to the SuperApp to open its payment sheet. + * + * Register the callback (see `onTelebirrPayResult`) BEFORE calling this — the host may invoke + * it as soon as the sheet closes. Returns false when the bridge is missing or throws, so the + * caller can surface an error instead of leaving the payer on a dead spinner. + */ +export function startTelebirrPay(rawRequest: string): boolean { + if (!isTelebirrMiniApp()) return false; + try { + window.consumerapp!.evaluate( + JSON.stringify({ + functionName: "js_fun_start_pay", + params: { + rawRequest, + functionCallBackName: TELEBIRR_PAY_CALLBACK, + }, + }), + ); + return true; + } catch (err) { + console.error("[telebirr] bridge evaluate failed:", err); + return false; + } +} + +/** + * Install the global result callback; returns a disposer for effect cleanup. + * + * The result is a TRIGGER TO VERIFY, never proof of payment — the payer can close the sheet, + * the host can report success before settlement lands, and the payload shape is not a contract. + * Confirmation always comes from polling our own payment status (webhook-backed). + */ +export function onTelebirrPayResult(handler: (succeeded: boolean) => void): () => void { + if (typeof window === "undefined") return () => {}; + window[TELEBIRR_PAY_CALLBACK] = (response: unknown) => { + handler(isSuccessResponse(response)); + }; + return () => { + delete window[TELEBIRR_PAY_CALLBACK]; + }; +} + +/** + * Telebirr reports `code: 0` (number or string) for success. The payload arrives as either a + * JSON string or an object depending on host version, and an unparseable payload is treated as + * success on purpose: polling is what decides the outcome, and a false "failed" would strand a + * payer who actually paid. + */ +function isSuccessResponse(response: unknown): boolean { + let parsed: unknown = response; + if (typeof response === "string") { + try { + parsed = JSON.parse(response); + } catch { + return true; + } + } + if (parsed && typeof parsed === "object" && "code" in parsed) { + const code = (parsed as { code: unknown }).code; + if (code === undefined || code === null) return true; + return code === 0 || code === "0"; + } + return true; +} diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts index 6d602b28a..fa7587b2e 100644 --- a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -62,9 +62,14 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest { @IsEnum(ProviderMethod) provider!: ProviderMethod; - @ApiPropertyOptional({ enum: ["web", "mobile"] }) + @ApiPropertyOptional({ + enum: ["web", "mobile", "inapp"], + description: + "Payer surface. `inapp` = running inside a SuperApp mini-app WebView (Telebirr), " + + "which cannot follow redirect/HPP flows and gets a bridge payload instead.", + }) @IsOptional() - @IsIn(["web", "mobile"]) + @IsIn(["web", "mobile", "inapp"]) platform?: PaymentPlatform; @ApiPropertyOptional({ diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index d732a51e3..32bf859b1 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { HttpService } from "@nestjs/axios"; import { + ClientAction, + PaymentPlatform, PaymentProvider, ProviderInitiationInput, ProviderInitiationResult, @@ -67,15 +69,7 @@ export class TelebirrProvider implements PaymentProvider { requestBody.biz_content.timeout_express, ); const platform = input.platform ?? "web"; - const clientAction = - platform === "mobile" - ? { - type: "LAUNCH_APP" as const, - appId: this.merchantAppId, - receiveCode: response.biz_content?.receiveCode, - shortCode: this.merchantCode, - } - : { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) }; + const clientAction = this.buildClientAction(platform, prepayId, response); return { providerOrderId: prepayId, @@ -199,6 +193,11 @@ export class TelebirrProvider implements PaymentProvider { input: ProviderInitiationInput, ): CreateOrderRequest { const totalAmount = String(input.amountMinor); + // In-app pays inside the SuperApp overlay and never navigates, so there is no browser + // to send back — telebirr's own in-app integration omits redirect_url entirely. Keep it + // absent rather than undefined: a signed-but-unsent field is what produced the earlier + // "verify sign failed" (see docs/payment-service + telebirr.crypto skip-undefined). + const wantsRedirect = input.platform !== "inapp" && !!input.redirectUrl; const req = { timestamp: createTimestamp(), nonce_str: createNonceStr(), @@ -214,7 +213,7 @@ export class TelebirrProvider implements PaymentProvider { total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, - ...(input.redirectUrl ? { redirect_url: input.redirectUrl } : {}), + ...(wantsRedirect ? { redirect_url: input.redirectUrl! } : {}), }, }; const sign = signRequestObject( @@ -245,6 +244,68 @@ export class TelebirrProvider implements PaymentProvider { return { ...req, sign, sign_type: "SHA256WithRSA" }; } + /** + * Telebirr exposes the same pre-order three ways; only the launch payload differs. + * + * - `mobile` — native app hands off to the wallet app with a receiveCode. + * - `inapp` — the portal is running inside the telebirr SuperApp mini-app WebView. The + * H5 checkout page is unusable there: it deep-links to `kcbconsumer://…`, + * which the WebView cannot resolve (`net::ERR_UNKNOWN_URL_SCHEME`). The + * signed rawRequest goes to the host JS bridge instead — no navigation. + * - `web` — ordinary browser; redirect to the H5 checkout page. + */ + private buildClientAction( + platform: PaymentPlatform, + prepayId: string, + response: CreateOrderResponse, + ): ClientAction { + switch (platform) { + case "mobile": + return { + type: "LAUNCH_APP", + appId: this.merchantAppId, + receiveCode: response.biz_content?.receiveCode, + shortCode: this.merchantCode, + }; + case "inapp": + return { + type: "INVOKE_BRIDGE", + bridge: "TELEBIRR", + rawRequest: this.buildInAppRawRequest(prepayId), + }; + default: + return { type: "REDIRECT", url: this.buildCheckoutUrl(prepayId) }; + } + } + + /** + * Signed request handed verbatim to the SuperApp bridge (`js_fun_start_pay`). + * + * Emits `appid, merch_code, nonce_str, prepay_id, timestamp, sign_type, sign` in that + * order — no `webBaseUrl` prefix and no `version`/`trade_type` tail, because the bridge + * takes the bare query string rather than a URL. + * + * `sign_type` sits in the map purely so it lands in the output in the right position; + * `buildCanonicalString` excludes it (as does telebirr's own reference implementation), + * so the signature covers the same five fields as the web checkout URL. + * + * Kept separate from `buildCheckoutUrl` rather than sharing a builder: the two payloads + * are consumed by different validators, and the web flow is live. + */ + private buildInAppRawRequest(prepayId: string): string { + const map: Record = { + appid: this.merchantAppId, + merch_code: this.merchantCode, + nonce_str: createNonceStr(), + prepay_id: prepayId, + timestamp: createTimestamp(), + sign_type: "SHA256WithRSA", + }; + const sign = signRequestObject(map, this.privateKey); + const fields = Object.entries(map).map(([k, v]) => `${k}=${v}`); + return [...fields, `sign=${sign}`].join("&"); + } + private buildCheckoutUrl(prepayId: string): string { const map: Record = { appid: this.merchantAppId, diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 04849f54d..05be21682 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -32,7 +32,8 @@ export enum ProviderMethod { CBE_BILL = "CBE_BILL", } -export type PaymentPlatform = "web" | "mobile"; + +export type PaymentPlatform = "web" | "mobile" | "inapp"; export type ClientAction = | { type: "REDIRECT"; url: string } @@ -42,6 +43,16 @@ export type ClientAction = receiveCode?: string; shortCode: string; } + | { + type: "INVOKE_BRIDGE"; + /** Which SuperApp host bridge the payload targets. */ + bridge: "TELEBIRR"; + /** + * Signed query string handed verbatim to the host bridge (`js_fun_start_pay`). + * NOT a URL — it has no scheme or host and must never be navigated to. + */ + rawRequest: string; + } | { type: "COLLECT_OTP"; providerOrderId: string; From c77f5200a3051801f5c2f6ac426424e29918183f Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 11:43:18 +0000 Subject: [PATCH 03/16] feat(last-mile): default the approval advance to the live rate The chief's typed advance was mandatory, so the rule-based estimate shown in the approve dialog had to be retyped and could silently diverge from it. advanceAmount is now optional: the advance defaults to the live last-mile rate estimate (km x rate) and the typed value is only an override. When no rate covers the job the request is rejected with a message telling the chief to enter the amount manually, rather than approving a zero advance. The advance invoice now bills in the rate's currency from the snapshotted contract summary, falling back to the booking payment currency only when the amount came from a manual override. Co-Authored-By: Claude Opus 5 --- .../dto/approve-last-mile-request.dto.ts | 20 +++++++++----- .../last-mile-requests.controller.ts | 2 +- .../last-mile-requests.service.ts | 27 ++++++++++++++----- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts index 56be0c545..0fa7457a8 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts @@ -1,13 +1,19 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsNumber, Min } from 'class-validator'; +import { IsNumber, IsOptional, Min } from 'class-validator'; export class ApproveLastMileRequestDto { - // The approve dialog prefills this from GET :id/price-estimate (rule-based), - // but the chief can still override — the typed value is what's invoiced. - @ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 }) - @Transform(({ value }) => Number(value)) + // Omitted = the rule-based last-mile rate estimate is the advance. The chief + // can still override with an explicit amount (required when no rate covers + // the job). + @ApiPropertyOptional({ + description: + 'Advance override. Omitted = the amount comes from the live last-mile rates (km × rate).', + example: 3000, + }) + @IsOptional() + @Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value))) @IsNumber() @Min(0.01) - advanceAmount!: number; + advanceAmount?: number; } diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index 4c4ac88e5..6f96d12c6 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -110,7 +110,7 @@ export class LastMileRequestsController { @Post(':id/approve') @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) - @ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' }) + @ApiOperation({ summary: 'Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature' }) approve( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveLastMileRequestDto, diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index 901e7b9b5..b89f06479 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -299,7 +299,11 @@ export class LastMileRequestsService { return this.findById(id); } - async approve(id: string, staffId: string | null, advanceAmount: number): Promise { + async approve( + id: string, + staffId: string | null, + advanceOverride?: number | null, + ): Promise { const request = await this.findById(id); if (request.status !== LastMileRequestStatus.Submitted) { throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`); @@ -307,6 +311,18 @@ export class LastMileRequestsService { const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`); + // The live last-mile rates are the authority on the advance (km × rate); + // the chief's typed amount is only an override — and the only path when no + // rate covers the job. Snapshotted so the contract and invoice stay immune + // to later rate edits. + const estimate = await this.priceEstimate(id); + const advanceAmount = advanceOverride ?? estimate.total; + if (!advanceAmount || advanceAmount <= 0) { + throw new BadRequestException( + 'No live last-mile rate covers this job — enter the advance amount manually.', + ); + } + // Idempotent per booking — reuses the record if one already exists. const lastMile = await this.lastMileService.create({ bookingId: request.bookingId, @@ -316,10 +332,6 @@ export class LastMileRequestsService { // No invoice yet: the advance is invoiced by LastMileContractService.sign() // once the customer has signed the LM contract — doc first, then payment. - // Snapshot the rate estimate now so the contract shows the numbers the - // chief actually approved against, immune to later rate edits. - const estimate = await this.priceEstimate(id); - await this.requestsRepository.update(id, { status: LastMileRequestStatus.Approved, reviewedByStaffId: staffId, @@ -364,7 +376,10 @@ export class LastMileRequestsService { type: 'LAST_MILE_ADVANCE', companyId: booking.companyId, companyProfileId: booking.companyProfileId || '', - currency: booking.paymentCurrency || 'ETB', + // The advance is priced by the last-mile rate, so it bills in that + // rate's currency (birr for domestic trucking) — the booking's payment + // currency is only the fallback when the amount was a manual override. + currency: request.contractSummary?.currency || booking.paymentCurrency || 'ETB', lines: [ { chargeType: 'LAST_MILE_ADVANCE', From 6b1ffa831f9f17918de6f2e7b4a2f65960cef94b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 8 Aug 2026 04:05:45 +0000 Subject: [PATCH 04/16] feat(eims): surface filing state in backoffice and alert on failures Two gaps that only bite in production: nobody could see an invoice's filing state, and a blocked chain was visible only in the logs. A failed filing now notifies the staff who can act on it. An ambiguous result is HIGH priority because it blocks every further invoice for the system number until someone resolves it, and nothing else would surface that -- the sweep just goes quiet. A deterministic rejection affects one invoice, so it is normal priority. The alert never throws: it must not mask the filing outcome. The backoffice invoice detail page gains an EIMS card showing status, IRN, counter, submitted and acknowledged timestamps, and the gateway's own error message, with actions gated on invoices:eims_register. FAILED offers "File again" -- the reservation model already allows re-registering a rejected invoice, so retry needed no new endpoint. UNKNOWN offers no re-file button at all, since resubmitting risks a duplicate registration, and instead explains that a supervisor must record the IRN or discard the attempt. Also aligns the migration class name with its renamed file. The DDL is idempotent, so re-applying under the new name is a no-op against the columns; it leaves one superseded row in freight.migrations. Co-Authored-By: Claude Opus 5 --- ... 3330000000000-EimsInvoiceRegistration.ts} | 2 +- .../eims-invoice-registration.service.spec.ts | 67 +++++++- .../eims/eims-invoice-registration.service.ts | 39 +++++ .../src/modules/eims/eims.module.ts | 2 + .../components/invoices/EimsFilingCard.tsx | 158 ++++++++++++++++++ .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../backoffice/src/constants/URLS.ts | 8 + .../backoffice/src/lib/permissions.ts | 4 + .../src/pages/invoices/InvoiceDetailPage.tsx | 3 + .../backoffice/src/services/api.ts | 32 ++++ .../backoffice/src/services/eims.service.ts | 39 +++++ .../backoffice/src/types/eims.ts | 44 +++++ 12 files changed, 396 insertions(+), 3 deletions(-) rename apps/edr-freight-api/src/migrations/{3300000000000-EimsInvoiceRegistration.ts => 3330000000000-EimsInvoiceRegistration.ts} (98%) create mode 100644 apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/eims.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/eims.ts diff --git a/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts b/apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts similarity index 98% rename from apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts rename to apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts index c80dfcd1e..1ff9bab35 100644 --- a/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts +++ b/apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts @@ -24,7 +24,7 @@ import { MigrationInterface, QueryRunner } from "typeorm"; * ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored * verbatim so a compliance value is never mangled by a parse. */ -export class EimsInvoiceRegistration3300000000000 implements MigrationInterface { +export class EimsInvoiceRegistration3330000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE freight.invoices diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 1035c2b33..0cbe76cb5 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -6,6 +6,7 @@ import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper"; import { eimsInvoiceConfig } from "./eims-test-fixtures"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; @@ -147,13 +148,17 @@ const build = ( postSigned: jest.Mock, cfg: EimsConfig = config(), postBearer: jest.Mock = jest.fn(), - getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION), + getSessionContext: jest.Mock | undefined = undefined, + notify: jest.Mock = jest.fn().mockResolvedValue(undefined), ) => new EimsInvoiceRegistrationService( db.asDataSource(), { get: () => cfg } as unknown as ConfigService, { postSigned, postBearer } as unknown as EimsClientService, - { getSessionContext } as unknown as EimsAuthService, + { + getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION), + } as unknown as EimsAuthService, + { notify } as unknown as NotificationInboxService, ); /** Document number the fixtures register under; `/v1/verify` must echo it back. */ @@ -409,6 +414,64 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { }); }); +describe("EimsInvoiceRegistrationService staff alerting", () => { + it("raises a high-priority alert when a result is ambiguous, because all filing is blocked", async () => { + const db = new FakeDb([invoiceRow()]); + const notify = jest.fn().mockResolvedValue(undefined); + const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT")); + + await expect( + build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toBeInstanceOf(EimsApiException); + + expect(notify).toHaveBeenCalledTimes(1); + const sent = notify.mock.calls[0][0]; + expect(sent.priority).toBe("HIGH"); + expect(sent.title).toMatch(/blocked/i); + expect(sent.recipients.permissionKeys).toContain("edr_freight_app:invoices:eims_resolve"); + }); + + it("raises a normal-priority alert for a deterministic rejection", async () => { + const db = new FakeDb([invoiceRow()]); + const notify = jest.fn().mockResolvedValue(undefined); + const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406)); + + await expect( + build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toBeInstanceOf(EimsApiException); + + expect(notify.mock.calls[0][0].priority).toBe("NORMAL"); + }); + + it("does not alert on a successful filing", async () => { + const db = new FakeDb([invoiceRow()]); + const notify = jest.fn(); + + await build(db, jest.fn().mockResolvedValue(okResponse()), config(), jest.fn(), undefined, notify) + .registerInvoiceWithEims(INVOICE_ID); + + expect(notify).not.toHaveBeenCalled(); + }); + + it("lets the filing outcome stand even if the alert itself fails", async () => { + const db = new FakeDb([invoiceRow()]); + const notify = jest.fn().mockRejectedValue(new Error("inbox down")); + const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406)); + + await expect( + build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toThrow(/EIMS register failed \(406\)/); + + expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed); + }); +}); + describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { it("verifies the stored IRN over the unsigned bearer transport", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 4ff9ccfb8..bb38a64cf 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -17,6 +17,9 @@ import { EimsMapperLine, toEimsInvoice, } from "../billing/eims-invoice.mapper"; +import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; @@ -72,6 +75,7 @@ export class EimsInvoiceRegistrationService { private readonly config: ConfigService, private readonly client: EimsClientService, private readonly auth: EimsAuthService, + private readonly inbox: NotificationInboxService, ) {} private get cfg(): EimsConfig { @@ -397,6 +401,41 @@ export class EimsInvoiceRegistrationService { }); this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`); + await this.alertStaff(invoiceId, status, lastError, deterministic); + } + + /** + * Tell the people who can act about a failed filing. + * + * An ambiguous result is the urgent one: it blocks *every* further invoice for this system + * number until a human resolves it, and nothing else in the system would surface that — the + * sweep just goes quiet. A deterministic rejection affects one invoice, so it is normal + * priority. Never throws: an alert that fails must not mask the filing outcome. + */ + private async alertStaff( + invoiceId: string, + status: EimsInvoiceStatus, + error: EimsInvoiceError, + deterministic: boolean, + ): Promise { + try { + await this.inbox.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.GENERIC, + priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH, + title: deterministic + ? "EIMS rejected an invoice" + : "EIMS filing unresolved — all further filing is blocked", + body: deterministic + ? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` + : `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`, + link: `/dashboard/invoices/${invoiceId}`, + data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" }, + }); + } catch (err) { + this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(err as Error).message}`); + } } // ── internals ──────────────────────────────────────────────────────────────────────────────── diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 678b21b52..53d3d4090 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -3,6 +3,7 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; +import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { EimsAuthService } from "./eims-auth.service"; import { EimsAutoSubmitService } from "./eims-auto-submit.service"; import { EimsClientService } from "./eims-client.service"; @@ -22,6 +23,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; imports: [ HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }), TypeOrmModule.forFeature([EimsSystemState, Invoice]), + NotificationInboxModule, ], controllers: [EimsInvoiceController], providers: [ diff --git a/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx b/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx new file mode 100644 index 000000000..ba507513c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx @@ -0,0 +1,158 @@ +import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react"; + +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { api } from "@/services/api"; +import type { EimsInvoiceStatus } from "@/types/eims"; +import { useToast } from "@/hooks/use-toast"; + +const STATUS_COLOR: Record = { + NOT_SUBMITTED: "gray", + SUBMITTING: "yellow", + REGISTERED: "edr-green", + FAILED: "red", + UNKNOWN: "orange", +}; + +const STATUS_LABEL: Record = { + NOT_SUBMITTED: "Not filed", + SUBMITTING: "Filing…", + REGISTERED: "Filed", + FAILED: "Rejected", + UNKNOWN: "Unacknowledged", +}; + +function Field({ label, value }: { label: string; value?: string | number | null }) { + return ( + + + {label} + + + {value === null || value === undefined || value === "" ? "—" : value} + + + ); +} + +/** + * MoR EIMS filing state for one invoice, with the manual actions. + * + * Filing normally happens on the API's cron sweep, not here — these controls exist for controlled + * testing and for the exceptional cases the sweep deliberately refuses: a rejected invoice that + * needs re-filing, and an unacknowledged one that has blocked all further filing. + */ +export function EimsFilingCard({ invoiceId }: { invoiceId: string }) { + const { user } = useAuth(); + const { toast } = useToast(); + const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister); + + const { data: eims, isLoading } = useQuery( + api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }), + ); + + const register = useMutation( + api.invoices.eimsRegister.mutationOptions({ + onSuccess: (result) => + toast({ + title: result.eimsIrn ? "Filed with MoR" : "Filing finished", + description: result.eimsIrn ? `IRN ${result.eimsIrn}` : `Status ${result.eimsStatus}`, + }), + }), + ); + + const verify = useMutation( + api.invoices.eimsVerify.mutationOptions({ + onSuccess: (result) => + toast({ + title: "MoR confirmed the filing", + description: `Document ${result.body?.DocumentDetails?.DocumentNumber ?? "—"}`, + }), + }), + ); + + if (isLoading || !eims) return null; + + const status = eims.eimsStatus; + const busy = register.isPending || verify.isPending; + + return ( + + + + + MoR e-invoicing + + + {STATUS_LABEL[status] ?? status} + + + + + + + + + + + {status === "UNKNOWN" && ( + } title="All filing is blocked"> + This invoice was sent but never acknowledged, so its IRN is unknown and no further + invoice can be filed. Confirm its status with MoR, then have a supervisor record the IRN + or discard the attempt. + + )} + + {eims.eimsLastError && ( + } + title={`MoR reported: ${eims.eimsLastError.kind}`} + > + {eims.eimsLastError.message} + + )} + + {canFile && ( + + {/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */} + {status !== "REGISTERED" && status !== "UNKNOWN" && ( + + )} + + {eims.eimsIrn && ( + + )} + + )} + + + ); +} + +export default EimsFilingCard; diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index f34fa6470..6a1135e0d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -51,6 +51,7 @@ export const QUERY_KEYS = { list: (filter?: InvoiceListFilter) => ["invoices", "list", filter ?? {}] as const, byId: (id: string) => ["invoices", "detail", id] as const, + eimsStatus: (id: string) => ["invoices", "eims", id] as const, }, BOOKINGS: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index cb45a34ae..3f6d05257 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -107,6 +107,14 @@ export const URL_CONSTANTS = { INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`, }, + // MoR EIMS filing. Mounted on /invoices, not /billing/invoices — see EimsInvoiceController. + EIMS: { + STATUS: (id: string) => `/invoices/${id}/eims/status`, + REGISTER: (id: string) => `/invoices/${id}/eims/register`, + VERIFY: (id: string) => `/invoices/${id}/eims/verify`, + RESOLVE: (id: string) => `/invoices/${id}/eims/resolve`, + }, + CUSTOMERS_API: { BASE: "/api/customers", BY_ID: (id: string) => `/api/customers/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 8e58d642d..88dadb1f7 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -128,6 +128,10 @@ export const FREIGHT_PERMS = { invoices: { view: "edr_freight_app:invoices:view", export: "edr_freight_app:invoices:export", + // Filing with MoR EIMS. Held by named admins rather than a role preset: registration is + // irreversible at the tax authority, and resolving clears a system-wide filing block. + eimsRegister: "edr_freight_app:invoices:eims_register", + eimsResolve: "edr_freight_app:invoices:eims_resolve", }, firstMile: { view: "edr_freight_app:first_mile:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx index 629f8ec7c..3188509c8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx @@ -15,6 +15,7 @@ import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, Download } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { EimsFilingCard } from "@/components/invoices/EimsFilingCard"; import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; @@ -165,6 +166,8 @@ export default function InvoiceDetailPage() { + + diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index e3f8dbf15..a6613d368 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -148,6 +148,8 @@ import { import { containerTypesService } from "./container-types.service"; import { containerService, type Container } from "./containerService"; import { customersService } from "./customers.service"; +import { eimsService } from "./eims.service"; +import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims"; import { invoicesService } from "./invoices.service"; import { dropdownSettingsService } from "./dropdownSettings.service"; import { fileUploadSettingsService } from "./fileUploadSettings.service"; @@ -2914,6 +2916,36 @@ export const api = { ({ id }) => invoicesService.getById(id), ({ id }) => QUERY_KEYS.INVOICES.byId(id), ), + + eimsStatus: endpoint<{ id: string }, EimsInvoiceStatusView>( + "invoices", + "eimsStatus", + ({ id }) => eimsService.status(id), + ({ id }) => QUERY_KEYS.INVOICES.eimsStatus(id), + ), + + // Both mutations refresh the filing panel; register also moves the invoice's own row. + eimsRegister: endpoint<{ id: string }, EimsInvoiceStatusView>( + "invoices", + "eimsRegister", + ({ id }) => eimsService.register(id), + undefined, + ({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)], + ), + + eimsVerify: endpoint<{ id: string }, EimsVerifyResult>( + "invoices", + "eimsVerify", + ({ id }) => eimsService.verify(id), + ), + + eimsResolve: endpoint<{ id: string; irn?: string; discard?: boolean }, EimsInvoiceStatusView>( + "invoices", + "eimsResolve", + ({ id, irn, discard }) => eimsService.resolve(id, { irn, discard }), + undefined, + ({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)], + ), }, overview: { diff --git a/apps/edr-freight-web/backoffice/src/services/eims.service.ts b/apps/edr-freight-web/backoffice/src/services/eims.service.ts new file mode 100644 index 000000000..6f99aaa3f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/eims.service.ts @@ -0,0 +1,39 @@ +import { api as apiClient } from "@/auth/http"; +import { URL_CONSTANTS } from "@/constants/URLS"; +import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims"; + +/** + * MoR EIMS filing actions on an invoice. + * + * Registration is irreversible at the tax authority, so these are admin actions rather than part + * of the ordinary invoice screen: the normal production path is the API's cron sweep. + */ +export const eimsService = { + status(invoiceId: string): Promise { + return apiClient + .get(URL_CONSTANTS.EIMS.STATUS(invoiceId)) + .then((r) => r.data); + }, + + register(invoiceId: string): Promise { + return apiClient + .post(URL_CONSTANTS.EIMS.REGISTER(invoiceId)) + .then((r) => r.data); + }, + + verify(invoiceId: string): Promise { + return apiClient + .post(URL_CONSTANTS.EIMS.VERIFY(invoiceId)) + .then((r) => r.data); + }, + + /** Record an IRN confirmed with MoR, or discard the attempt. Clears the system-wide block. */ + resolve( + invoiceId: string, + input: { irn?: string; discard?: boolean }, + ): Promise { + return apiClient + .post(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input) + .then((r) => r.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/eims.ts b/apps/edr-freight-web/backoffice/src/types/eims.ts new file mode 100644 index 000000000..5b5a3e93d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/eims.ts @@ -0,0 +1,44 @@ +/** + * MoR EIMS filing state for one invoice. + * + * Mirrors `EimsInvoiceStatusView` in the freight API (`modules/eims/eims-registration.types.ts`). + * Kept local rather than in `@edr/types` because only the backoffice reads it. + */ +export type EimsInvoiceStatus = + | "NOT_SUBMITTED" + | "SUBMITTING" + | "REGISTERED" + | "FAILED" + | "UNKNOWN"; + +/** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */ +export interface EimsInvoiceError { + kind: string; + message: string; + httpStatus?: number; + details?: Record; + at: string; +} + +export interface EimsInvoiceStatusView { + invoiceId: string; + invoiceNumber: string; + eimsStatus: EimsInvoiceStatus; + eimsIrn: string | null; + eimsInvoiceCounter: number | null; + eimsSubmittedAt: string | null; + /** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */ + eimsAckDate: string | null; + eimsLastError: EimsInvoiceError | null; +} + +/** `POST /v1/verify` response, echoed back from the gateway. */ +export interface EimsVerifyResult { + statusCode?: number; + message?: string; + body?: { + Irn?: string; + DocumentDetails?: { Type?: string; DocumentNumber?: string; Date?: string }; + [section: string]: unknown; + }; +} From 2e26936bf1407c980cc4a97f210984828188ee80 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 8 Aug 2026 04:56:28 +0000 Subject: [PATCH 05/16] fix(eims): retimestamp the EIMS migration to 3330000000000 3300000000000 collided with BookingWagonCancellations after the rebase. 3320000000000 is also unavailable: BulkContractTemplates3320000000000 is already recorded in freight.migrations on the shared dev database from a branch not present in this checkout, so checking only src/migrations is not sufficient. 3330000000000 is unique across src/migrations and greater than the current maximum timestamp recorded in freight.migrations. Rename the migration file and class. The migration has no explicit name field and no other code references its previous identity. Verify migration discovery through the actual runtime path: scripts/migrate.js loads compiled dist/migrations/*.js migrations, while application boot does not run migrations automatically. Confirm the renamed migration is present in dist. For controlled dev verification, remove its migration-history row and run pnpm migration:run again. The migration is discovered and applied under 3330000000000; its idempotent DDL produces no schema changes where the EIMS schema already exists. --- apps/edr-freight-api/.env.example | 6 +- .../edr-freight-api/src/config/eims.config.ts | 3 + ...340000000000-EimsDocumentNumberSequence.ts | 34 ++++++++++ .../modules/billing/eims-invoice.mapper.ts | 15 ++++- .../billing/entities/invoice.entity.ts | 4 ++ .../src/modules/eims/eims-invoice-context.ts | 3 + .../eims-invoice-registration.service.spec.ts | 47 ++++++++++--- .../eims/eims-invoice-registration.service.ts | 67 ++++++++++++++++--- .../modules/eims/eims-registration.types.ts | 2 + .../src/modules/eims/eims-test-fixtures.ts | 1 + .../eims/entities/eims-system-state.entity.ts | 12 ++++ 11 files changed, 170 insertions(+), 24 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 086923df5..53d599de5 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -172,14 +172,16 @@ EIMS_SELLER_LOCALITY= # Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all # (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails # locally, naming the missing variables, until these are set. -EIMS_TAX_CODE=0 +# MoR enum: TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH +EIMS_TAX_CODE=VAT0 EIMS_TAX_RATE_PERCENT=0 EIMS_EXCISE_TAX_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0 EIMS_TRANSACTION_WITHHOLD_VALUE=0 # Document classification and payment presentation. EIMS_TRANSACTION_TYPE=B2B -EIMS_NATURE_OF_SUPPLIES=Service +# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'. +EIMS_NATURE_OF_SUPPLIES=service EIMS_PAYMENT_MODE=CASH EIMS_PAYMENT_TERM=IMMIDIATE EIMS_UNIT_DEFAULT=PCS diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 6eaaf8007..659377c77 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -81,6 +81,8 @@ export interface EimsInvoiceConfig { paymentTerm: string; unitDefault: string; buyerCountryCode: string | null; + /** MoR region code used when a buyer's stored region is a name rather than a code. */ + buyerRegionFallback: string | null; cashierName: string | null; salesPersonName: string | null; } @@ -168,6 +170,7 @@ export default registerAs("eims", (): EimsConfig => { paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, + buyerRegionFallback: process.env.EIMS_BUYER_REGION_FALLBACK || null, cashierName: process.env.EIMS_CASHIER_NAME || null, salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, }, diff --git a/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts b/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts new file mode 100644 index 000000000..481b7489c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * EIMS document numbering. + * + * MoR validates `DocumentDetails.DocumentNumber` against `^(0|[1-9][0-9]{0,8})$` — a plain integer + * of at most nine digits. Our own `INV-YYYYMMDD-NNNNN` can therefore never be sent, so EIMS needs + * its own sequence, allocated from the same locked state row as the invoice counter and recorded + * on the invoice so a filed document can be traced back to it. + */ +export class EimsDocumentNumberSequence3340000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ADD COLUMN IF NOT EXISTS next_document_number bigint NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS in_flight_document_number bigint + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_document_number varchar(16) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices DROP COLUMN IF EXISTS eims_document_number + `); + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + DROP COLUMN IF EXISTS next_document_number, + DROP COLUMN IF EXISTS in_flight_document_number + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index 864d9f829..0bd5d5a92 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -210,6 +210,14 @@ export interface EimsMapperContext { relatedDocument?: string | null; /** MoR numeric country code for the buyer; our DB stores the country name. */ buyerCountryCode?: string | null; + /** + * Region code to use when the buyer's stored region is not already one. + * + * MoR validates `BuyerDetails.Region` against `^[0-9]{1,3}$`, but `companies.region` is free + * text ("Addis Ababa"). Rather than ship a name→code table we cannot verify, a stored value that + * already looks like a code is passed through and anything else falls back to this. + */ + buyerRegionFallback?: string | null; buyerIdType?: string | null; buyerIdNumber?: string | null; buyerCity?: string | null; @@ -220,6 +228,9 @@ export interface EimsMapperContext { formatDate?: (issuedAt: Date) => string; } +/** MoR's own constraint on `Region`: one to three digits. */ +const REGION_CODE = /^[0-9]{1,3}$/; + const num = (v: number | string): number => { const n = Number(v); if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`); @@ -329,7 +340,9 @@ export function toEimsInvoice( Tin: company.tin, LegalName: company.name, Phone: company.phone ?? null, - Region: company.region ?? null, + Region: REGION_CODE.test(company.region ?? "") + ? (company.region as string) + : (context.buyerRegionFallback ?? null), Country: context.buyerCountryCode ?? null, Zone: company.zone ?? null, Kebele: company.kebele ?? null, diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index c5c000943..411d15ebe 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -115,6 +115,10 @@ export class Invoice extends BaseEntity { @Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true }) eimsIrn?: string | null; + /** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */ + @Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true }) + eimsDocumentNumber?: string | null; + /** The `SourceSystem.InvoiceCounter` this invoice consumed. */ @Column({ name: "eims_invoice_counter", type: "bigint", nullable: true }) eimsInvoiceCounter?: number | null; diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 20ccfdfba..8f81b5f78 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -112,6 +112,9 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E incomeWithholdValue: invoice.incomeWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, + // companies.region is free text ("Addis Ababa"); MoR wants ^[0-9]{1,3}$. A stored value that + // already looks like a code wins, otherwise the seller's own region stands in. + buyerRegionFallback: invoice.buyerRegionFallback || invoice.sellerRegion, exchangeRate: input.exchangeRate ?? null, }; } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 0cbe76cb5..f32b78000 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -92,9 +92,11 @@ class FakeDb { id: "state-1", systemNumber: SYSTEM_NUMBER, nextInvoiceCounter: 7, + nextDocumentNumber: 5, previousIrn: null, inFlightInvoiceId: null, inFlightCounter: null, + inFlightDocumentNumber: null, blockedReason: null, ...state, } as EimsSystemState; @@ -131,7 +133,10 @@ class FakeDb { return { manager: this.manager, getRepository: this.manager.getRepository, - query: async () => LINES, + query: async (sql: string) => + sql.includes("eims_system_state") + ? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }] + : LINES, transaction: async (body: (m: unknown) => Promise) => { this.onTransaction?.(); return body(this.manager); @@ -161,8 +166,13 @@ const build = ( { notify } as unknown as NotificationInboxService, ); -/** Document number the fixtures register under; `/v1/verify` must echo it back. */ -const DOCUMENT_NUMBER = "INV-20260807-00042"; +/** + * Document number the fixtures register under; `/v1/verify` must echo it back. + * + * A plain integer, not our `invoiceNumber`: MoR validates the field against + * `^(0|[1-9][0-9]{0,8})$`. It is allocated from `nextDocumentNumber` above. + */ +const DOCUMENT_NUMBER = "5"; /** * `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase @@ -225,7 +235,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; expect(request.SourceSystem.InvoiceCounter).toBe(42); expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN"); - expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042"); + expect(request.DocumentDetails.DocumentNumber).toBe(DOCUMENT_NUMBER); expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); }); @@ -350,7 +360,9 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { inFlightInvoiceId: null, blockedReason: null, previousIrn: null, - nextInvoiceCounter: 8, // consumed: the attempt reached the gateway + // Returned, not consumed: MoR tracks the sequence and rejects a gap + // ("Invoice counter is not correct. expected : 1"). + nextInvoiceCounter: 7, }); }); @@ -396,7 +408,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { expect(postSigned).toHaveBeenCalledTimes(1); }); - it("never reuses a counter once an attempt has begun", async () => { + it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => { const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]); const postSigned = jest .fn() @@ -409,8 +421,10 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { ); await service.registerInvoiceWithEims(OTHER_INVOICE_ID); + // A refused document returns its counter, so the next attempt reuses it — MoR expects a + // contiguous sequence of *accepted* documents, not of attempts. expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7); - expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8); + expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7); }); }); @@ -510,7 +524,15 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { const blocked = () => - new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], { + new FakeDb( + [ + invoiceRow({ + eimsStatus: EimsInvoiceStatus.Unknown, + eimsInvoiceCounter: 7, + eimsDocumentNumber: DOCUMENT_NUMBER, + }), + ], + { inFlightInvoiceId: INVOICE_ID, inFlightCounter: 7, nextInvoiceCounter: 8, @@ -561,13 +583,13 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { const db = blocked(); const postBearer = jest.fn().mockResolvedValue( verifyResponse({ - DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" }, + DocumentDetails: { Type: "INV", DocumentNumber: "99999" }, }), ); await expect( build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), - ).rejects.toThrow(/not INV-20260807-00042/); + ).rejects.toThrow(/not 5/); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Unknown, @@ -610,7 +632,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses to resolve an invoice that is not the in-flight one", async () => { const db = blocked(); - db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID })); + db.invoices.set( + OTHER_INVOICE_ID, + invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }), + ); const postBearer = jest.fn().mockResolvedValue(verifyResponse()); await expect( diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index bb38a64cf..66131ed01 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -47,6 +47,8 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU interface Reservation { stateId: string; invoiceCounter: number; + /** MoR requires a plain integer here, so it cannot be our own `invoiceNumber`. */ + documentNumber: string; previousIrn: string; } @@ -102,8 +104,9 @@ export class EimsInvoiceRegistrationService { invoice, buildEimsSeller(cfg), buildEimsContext(cfg, { - // Our own invoice number is the document number; EIMS only requires it to be unique. - documentNumber: invoice.invoiceNumber, + // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber + // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy. + documentNumber: reservation.documentNumber, invoiceCounter: reservation.invoiceCounter, previousIrn: reservation.previousIrn, session, @@ -178,8 +181,9 @@ export class EimsInvoiceRegistrationService { * Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this * IRN is the one it holds, and that it belongs to this invoice. * - * The document-number check is against `DocumentDetails.DocumentNumber`, which registration set - * from our own `invoiceNumber` — the only field tying an IRN back to a row in this database. + * The document-number check is against `DocumentDetails.DocumentNumber`, which registration + * allocated and stored on the invoice as `eimsDocumentNumber` — the only field tying an IRN back + * to a row in this database. * * Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and * chains every later document to a stranger's reference, so both checks are refusals rather @@ -235,11 +239,34 @@ export class EimsInvoiceRegistrationService { }); } + // Cheap ownership check before touching the gateway: resolving an invoice that does not hold + // the reservation is a caller mistake, not something to spend a MoR round trip on. The + // authoritative re-check happens under lock in the transaction below. + const [preState]: { in_flight_invoice_id: string | null }[] = await this.dataSource.query( + `SELECT in_flight_invoice_id FROM freight.eims_system_state + WHERE system_number = $1 AND deleted_at IS NULL LIMIT 1`, + [(await this.auth.getSessionContext()).systemNumber], + ); + if (preState?.in_flight_invoice_id && preState.in_flight_invoice_id !== invoiceId) { + throw new ConflictException({ + code: "EIMS_RESOLVE_WRONG_INVOICE", + message: `The in-flight EIMS submission is invoice ${preState.in_flight_invoice_id}, not ${invoiceId}`, + }); + } + // Outside the transaction: no lock is held across the wire, and a refused verification must // leave the block exactly as it was. if (irn) { const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId); - await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber); + if (!invoice.eimsDocumentNumber) { + throw new BadRequestException({ + code: "EIMS_NO_DOCUMENT_NUMBER", + message: + `Invoice ${invoice.invoiceNumber} was never allocated an EIMS document number, so a ` + + "returned IRN cannot be tied back to it.", + }); + } + await this.assertIrnBelongsToInvoice(irn, invoice.eimsDocumentNumber); } // Same source of truth as registration: the state row is keyed by the token's system number. @@ -270,6 +297,7 @@ export class EimsInvoiceRegistrationService { ...(irn ? { previousIrn: irn } : {}), inFlightInvoiceId: null, inFlightCounter: null, + inFlightDocumentNumber: null, blockedReason: null, }); }); @@ -315,23 +343,27 @@ export class EimsInvoiceRegistrationService { if (invoice.eimsIrn) return null; const invoiceCounter = Number(state.nextInvoiceCounter); + const documentNumber = String(Number(state.nextDocumentNumber)); const previousIrn = state.previousIrn ?? ""; // Counter consumed here, not on success: once an attempt begins it can never be reused, // whatever happens next. A gap is harmless at MoR; a collision is not. await manager.update(EimsSystemState, state.id, { nextInvoiceCounter: invoiceCounter + 1, + nextDocumentNumber: Number(documentNumber) + 1, inFlightInvoiceId: invoiceId, inFlightCounter: invoiceCounter, + inFlightDocumentNumber: Number(documentNumber), }); await manager.update(Invoice, invoiceId, { eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: invoiceCounter, + eimsDocumentNumber: documentNumber, eimsSubmittedAt: new Date(), eimsLastError: null, }); - return { stateId: state.id, invoiceCounter, previousIrn }; + return { stateId: state.id, invoiceCounter, documentNumber, previousIrn }; }); } @@ -354,15 +386,21 @@ export class EimsInvoiceRegistrationService { previousIrn: irn, inFlightInvoiceId: null, inFlightCounter: null, + inFlightDocumentNumber: null, blockedReason: null, }); }); } /** - * TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and - * blocks the system number, because `PreviousIrn` is now unknown for every later document. - * The counter stays consumed either way. + * TX2b. A deterministic rejection releases the reservation **and returns the counter**; an + * ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown + * for every later document. + * + * Returning the counter is not an optimisation — MoR tracks the sequence itself and rejects a + * gap: "Invoice counter is not correct. expected : 1". A document it definitively refused was + * never counted on its side, so ours must not advance either. An ambiguous result is the + * opposite case: MoR may have counted it, so the number stays spent until a human resolves it. */ private async settleFailure( invoiceId: string, @@ -390,7 +428,15 @@ export class EimsInvoiceRegistrationService { EimsSystemState, reservation.stateId, deterministic - ? { inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null } + ? { + // Hand both numbers back: MoR never counted a document it refused outright. + nextInvoiceCounter: reservation.invoiceCounter, + nextDocumentNumber: Number(reservation.documentNumber), + inFlightInvoiceId: null, + inFlightCounter: null, + inFlightDocumentNumber: null, + blockedReason: null, + } : { blockedReason: `Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` + @@ -527,6 +573,7 @@ export class EimsInvoiceRegistrationService { invoiceNumber: invoice.invoiceNumber, eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted, eimsIrn: invoice.eimsIrn ?? null, + eimsDocumentNumber: invoice.eimsDocumentNumber ?? null, eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter), eimsSubmittedAt: invoice.eimsSubmittedAt ?? null, eimsAckDate: invoice.eimsAckDate ?? null, diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts index ad6a3aa34..c4d9f3842 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -80,6 +80,8 @@ export interface EimsInvoiceStatusView { invoiceNumber: string; eimsStatus: EimsInvoiceStatus; eimsIrn: string | null; + /** The numeric DocumentNumber filed with MoR; not our own invoiceNumber. */ + eimsDocumentNumber: string | null; eimsInvoiceCounter: number | null; eimsSubmittedAt: Date | null; eimsAckDate: string | null; diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index 79fe30f96..d620cfa40 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -33,6 +33,7 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn paymentTerm: "IMMIDIATE", unitDefault: "PCS", buyerCountryCode: null, + buyerRegionFallback: "13", cashierName: null, salesPersonName: null, ...over, diff --git a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts index ac6489c93..a21057042 100644 --- a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts +++ b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts @@ -18,6 +18,18 @@ export class EimsSystemState extends BaseEntity { @Column({ name: "next_invoice_counter", type: "bigint", default: 1 }) nextInvoiceCounter!: number; + /** + * `DocumentDetails.DocumentNumber` for the next registration. + * + * Separate from our own `invoiceNumber`, which MoR cannot accept: it validates the field against + * `^(0|[1-9][0-9]{0,8})$`, a plain integer. + */ + @Column({ name: "next_document_number", type: "bigint", default: 1 }) + nextDocumentNumber!: number; + + @Column({ name: "in_flight_document_number", type: "bigint", nullable: true }) + inFlightDocumentNumber?: number | null; + /** IRN of the last successful registration; null until the first one succeeds. */ @Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true }) previousIrn?: string | null; From e12cb78e9b42c2bd74ee5f5e2aba0932a31099d4 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 8 Aug 2026 05:24:22 +0000 Subject: [PATCH 06/16] feat(portal): pay-later last-mile note, gated by service type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last-mile toggle explains deferred billing (confirm containers after Djibouti departure, sign supplementary LM contract on truck approval, pay advance) — shown only when the service defers mile billing, not for RAIL_CONTAINER_PAID_MILE where the mile is priced into the booking. Co-Authored-By: Claude Fable 5 --- .../new-booking-form/step2-service-type.tsx | 20 ++++++++++++++++++- .../new-contract-form/step2-service-type.tsx | 20 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index 64653abe3..47e80e7e2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -40,6 +40,11 @@ export function Step2ServiceType({ serviceType ?? {}; const firstMileEnabled = form.watch("firstMile.enabled"); const lastMileEnabled = form.watch("lastMile.enabled"); + // The paid-mile service prices the road legs into the booking itself; every + // other service defers last-mile billing to after the Djibouti departure + // (confirm containers → sign supplementary LM contract → pay advance). + const deferredLastMileBilling = + serviceType?.code !== "RAIL_CONTAINER_PAID_MILE"; const prevServiceType = useRef(serviceType); // Only clear a mile when the current service doesn't include it — this ran @@ -205,7 +210,11 @@ export function Step2ServiceType({ } title="Last Mile — Delivery" - description="Truck delivery from the destination rail yard to the final address (Port to Door)." + description={ + deferredLastMileBilling + ? "Truck delivery from the destination rail yard to the final address (Port to Door). Nothing is paid now — last-mile billing starts after your train departs Djibouti." + : "Truck delivery from the destination rail yard to the final address (Port to Door)." + } checked={field.value ?? false} onChange={(value) => { field.onChange(value); @@ -224,6 +233,15 @@ export function Step2ServiceType({ > {lastMileEnabled && ( + {deferredLastMileBilling && ( + + How it works: when your train departs Djibouti, you + will be asked to confirm which containers EDR should + deliver. Once truck availability is approved, you will + sign a short supplementary last-mile contract and pay + the delivery advance — nothing is charged at booking. + + )} { @@ -471,7 +476,11 @@ export function Step2ServiceType({ } title="Last Mile — Delivery" - description="Truck delivery from the destination rail yard to the final address (Port to Door)." + description={ + deferredLastMileBilling + ? "Truck delivery from the destination rail yard to the final address (Port to Door). Nothing is paid now — last-mile billing starts after your train departs Djibouti." + : "Truck delivery from the destination rail yard to the final address (Port to Door)." + } checked={field.value ?? false} onChange={(value) => { field.onChange(value); @@ -492,6 +501,15 @@ export function Step2ServiceType({ > {lastMileEnabled && ( + {deferredLastMileBilling && ( + + How it works: when your train departs Djibouti, you + will be asked to confirm which containers EDR should + deliver. Once truck availability is approved, you will + sign a short supplementary last-mile contract and pay + the delivery advance — nothing is charged at booking. + + )} Date: Sat, 8 Aug 2026 05:27:07 +0000 Subject: [PATCH 07/16] fix(eims): match MoR's payload rules found by live rejections Three live attempts turned six guesses into facts. Each fix below is the gateway's own words, not a reading of the collection. DocumentNumber and InvoiceCounter move differently, because MoR constrains them differently. The counter must not skip -- "Invoice counter is not correct. expected : 1" -- so a definitively refused document hands it back. The document number must not repeat, so the attempt burns it. Both stay spent after an ambiguous result, where MoR may have stored the document. NatureOfSupplies is normalised to MoR's exact lowercase constant and rejected outright if it is neither 'goods' nor 'service'; its schema branches on this as a oneOf, so "Service" invalidated the whole ItemList. Buyer region resolves through a name->code map and now FAILS locally when unmapped. MoR validates Region against ^[0-9]{1,3}$ on both the seller and buyer sides, so a name can never be sent and a guessed code on a tax document is worse than refusing to file. Seller phone, email, region and wereda are checked against MoR's own regexes before anything is sent, so a placeholder like "_" fails locally instead of costing a request and a counter. EIMS_TAX_CODE stays required and unset in .env.example: the choice between VAT0 (zero-rated) and VATEX (exempt) is a tax position awaiting finance, and MoR's enum is recorded there for whoever decides. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 9 ++- .../edr-freight-api/src/config/eims.config.ts | 20 ++++++- .../billing/eims-invoice.mapper.spec.ts | 50 +++++++++++++++- .../modules/billing/eims-invoice.mapper.ts | 59 +++++++++++++++---- .../src/modules/eims/eims-invoice-context.ts | 35 ++++++++++- .../eims-invoice-registration.service.spec.ts | 12 ++-- .../eims/eims-invoice-registration.service.ts | 17 ++++-- .../src/modules/eims/eims-test-fixtures.ts | 2 +- 8 files changed, 174 insertions(+), 30 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 53d599de5..54da939cd 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -172,8 +172,10 @@ EIMS_SELLER_LOCALITY= # Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all # (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails # locally, naming the missing variables, until these are set. -# MoR enum: TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH -EIMS_TAX_CODE=VAT0 +# Required, and deliberately unset: the choice is a tax position, not a default. +# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH +# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt). +EIMS_TAX_CODE= EIMS_TAX_RATE_PERCENT=0 EIMS_EXCISE_TAX_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0 @@ -187,6 +189,9 @@ EIMS_PAYMENT_TERM=IMMIDIATE EIMS_UNIT_DEFAULT=PCS # MoR numeric country code for the buyer; our companies store the country name. EIMS_BUYER_COUNTRY_CODE= +# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$. +# An unmapped region fails locally rather than being filed with a guess. +EIMS_BUYER_REGION_CODES=Addis Ababa=13 EIMS_CASHIER_NAME= EIMS_SALESPERSON_NAME= # Automatic filing of issued invoices (@Cron sweep, one invoice per tick). diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 659377c77..210667409 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -81,8 +81,12 @@ export interface EimsInvoiceConfig { paymentTerm: string; unitDefault: string; buyerCountryCode: string | null; - /** MoR region code used when a buyer's stored region is a name rather than a code. */ - buyerRegionFallback: string | null; + /** + * Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES` + * ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails + * locally rather than being filed with a guessed one. + */ + buyerRegionCodes: Record; cashierName: string | null; salesPersonName: string | null; } @@ -105,6 +109,16 @@ const positiveInt = (raw: string | undefined, fallback: number, name: string): n return value; }; +/** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */ +const parseRegionCodes = (raw: string | undefined): Record => { + const map: Record = {}; + for (const pair of (raw ?? "").split(",")) { + const [name, code] = pair.split("="); + if (name?.trim() && code?.trim()) map[name.trim()] = code.trim(); + } + return map; +}; + /** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */ const optionalNumber = (raw: string | undefined, name: string): number | null => { if (raw === undefined || raw === "") return null; @@ -170,7 +184,7 @@ export default registerAs("eims", (): EimsConfig => { paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, - buyerRegionFallback: process.env.EIMS_BUYER_REGION_FALLBACK || null, + buyerRegionCodes: parseRegionCodes(process.env.EIMS_BUYER_REGION_CODES), cashierName: process.env.EIMS_CASHIER_NAME || null, salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, }, diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index aef4ac163..45b4d33bd 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -60,6 +60,7 @@ const context = (over: Partial = {}): EimsMapperContext => ({ unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, + buyerRegionCodes: { "Addis Ababa": "13" }, ...over, }); @@ -130,7 +131,7 @@ describe("toEimsInvoice", () => { ExciseTaxValue: 0, TotalLineAmount: 11500, Unit: "PCS", - NatureOfSupplies: "Service", + NatureOfSupplies: "service", HarmonizationCode: null, }); expect(doc.ItemList[1]).toMatchObject({ @@ -207,6 +208,53 @@ describe("toEimsInvoice", () => { }); }); +describe("toEimsInvoice — MoR field constraints", () => { + it("passes a buyer region through when it is already a MoR code", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + expect(doc.BuyerDetails.Region).toBe("13"); + }); + + it("maps a region name to its code, ignoring case and spacing", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, region: " addis ababa " } }), + seller, + context({ buyerRegionCodes: { "Addis Ababa": "13" } }), + ); + expect(doc.BuyerDetails.Region).toBe("13"); + }); + + it("refuses to file a buyer whose region has no mapping", () => { + expect(() => + toEimsInvoice( + invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }), + seller, + context(), + ), + ).toThrow(/not a MoR region code and has no mapping/); + }); + + it("refuses a buyer with no region at all rather than guessing one", () => { + expect(() => + toEimsInvoice( + invoice({ company: { ...invoice().company!, region: null } }), + seller, + context(), + ), + ).toThrow(/buyer region \(unset\)/); + }); + + it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => { + const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" })); + expect(doc.ItemList[0].NatureOfSupplies).toBe("service"); + }); + + it("rejects a NatureOfSupplies MoR does not accept", () => { + expect(() => + toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Services" })), + ).toThrow(/must be one of goods, service/); + }); +}); + describe("formatEimsDate", () => { it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => { expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00"); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index 0bd5d5a92..1e6f8958d 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -211,13 +211,14 @@ export interface EimsMapperContext { /** MoR numeric country code for the buyer; our DB stores the country name. */ buyerCountryCode?: string | null; /** - * Region code to use when the buyer's stored region is not already one. + * Region name → MoR numeric code, for buyers whose stored region is free text. * - * MoR validates `BuyerDetails.Region` against `^[0-9]{1,3}$`, but `companies.region` is free - * text ("Addis Ababa"). Rather than ship a name→code table we cannot verify, a stored value that - * already looks like a code is passed through and anything else falls back to this. + * `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region` + * against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else + * must be in this map or the mapping **fails locally** — sending a guessed region code onto a + * tax document is worse than refusing to file. */ - buyerRegionFallback?: string | null; + buyerRegionCodes: Record; buyerIdType?: string | null; buyerIdNumber?: string | null; buyerCity?: string | null; @@ -228,9 +229,17 @@ export interface EimsMapperContext { formatDate?: (issuedAt: Date) => string; } -/** MoR's own constraint on `Region`: one to three digits. */ +/** MoR's own constraint on `Region`, on both the seller and buyer sides: one to three digits. */ const REGION_CODE = /^[0-9]{1,3}$/; +/** + * The only two values MoR accepts for `NatureOfSupplies`, lowercase. + * + * Its schema branches on this as a `oneOf` with a `const` per branch, so `"Service"` fails the + * whole `ItemList` — the error reads "must be the constant value 'service'". + */ +const NATURE_OF_SUPPLIES = ["goods", "service"] as const; + const num = (v: number | string): number => { const n = Number(v); if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`); @@ -251,6 +260,30 @@ export const formatEimsDate = (issuedAt: Date): string => * an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no * exchange rate. */ +/** + * A buyer's region as a MoR code: passed through when already numeric, otherwise looked up by name + * (case- and space-insensitive). Throws when neither applies. + */ +function resolveRegionCode( + region: string | null | undefined, + codes: Record, + invoiceNumber: string, +): string { + const raw = (region ?? "").trim(); + if (REGION_CODE.test(raw)) return raw; + + const key = raw.toLowerCase().replace(/\s+/g, " "); + const mapped = Object.entries(codes).find( + ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, + )?.[1]; + if (mapped && REGION_CODE.test(mapped)) return mapped; + + throw new Error( + `EIMS mapping: invoice ${invoiceNumber} has buyer region ${raw ? `"${raw}"` : "(unset)"}, ` + + "which is not a MoR region code and has no mapping. Add it to EIMS_BUYER_REGION_CODES.", + ); +} + export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, @@ -277,6 +310,14 @@ export function toEimsInvoice( throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`); } + const natureOfSupplies = context.natureOfSupplies.trim().toLowerCase(); + if (!NATURE_OF_SUPPLIES.includes(natureOfSupplies as (typeof NATURE_OF_SUPPLIES)[number])) { + throw new Error( + `EIMS mapping: NatureOfSupplies must be one of ${NATURE_OF_SUPPLIES.join(", ")}, ` + + `got "${context.natureOfSupplies}"`, + ); + } + const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => { const lineNumber = index + 1; const tax = context.taxForLine(line, lineNumber); @@ -296,7 +337,7 @@ export function toEimsInvoice( Discount: 0, ExciseTaxValue, HarmonizationCode: null, - NatureOfSupplies: context.natureOfSupplies, + NatureOfSupplies: natureOfSupplies, ItemCode: line.chargeType, ProductDescription: line.description?.trim() || line.chargeType, PreTaxValue, @@ -340,9 +381,7 @@ export function toEimsInvoice( Tin: company.tin, LegalName: company.name, Phone: company.phone ?? null, - Region: REGION_CODE.test(company.region ?? "") - ? (company.region as string) - : (context.buyerRegionFallback ?? null), + Region: resolveRegionCode(company.region, context.buyerRegionCodes, invoice.invoiceNumber), Country: context.buyerCountryCode ?? null, Zone: company.zone ?? null, Kebele: company.kebele ?? null, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 8f81b5f78..adb6826ec 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -57,6 +57,37 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void { `(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`, }); } + + assertSellerFormats(config.invoice); +} + +/** + * MoR's own patterns for the seller fields, checked here rather than at the gateway. + * + * A placeholder like `_` is "set" but unfilable, and finding that out costs a real request and a + * consumed counter — these are the exact regexes its 400 SCHEMA ERROR quoted back at us. + */ +const SELLER_FORMATS: { env: string; value: (i: EimsConfig["invoice"]) => string; pattern: RegExp }[] = [ + { env: "EIMS_SELLER_PHONE", value: (i) => i.sellerPhone, pattern: /^\+?[0-9]{6,}$/ }, + { + env: "EIMS_SELLER_EMAIL", + value: (i) => i.sellerEmail, + pattern: /^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$/, + }, + { env: "EIMS_SELLER_REGION", value: (i) => i.sellerRegion, pattern: /^[0-9]{1,3}$/ }, + { env: "EIMS_SELLER_WEREDA", value: (i) => i.sellerWereda, pattern: /^[0-9A-Za-z]{1,10}$/ }, +]; + +function assertSellerFormats(invoice: EimsConfig["invoice"]): void { + const bad = SELLER_FORMATS.filter(({ value, pattern }) => !pattern.test(value(invoice))).map( + ({ env, pattern }) => `${env} (must match ${pattern.source})`, + ); + if (bad.length > 0) { + throw new BadRequestException({ + code: "EIMS_INVOICE_CONFIG_INVALID", + message: `EIMS seller details would be rejected by MoR: ${bad.join("; ")}`, + }); + } } export function buildEimsSeller(config: EimsConfig): EimsSellerDetails { @@ -112,9 +143,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E incomeWithholdValue: invoice.incomeWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, - // companies.region is free text ("Addis Ababa"); MoR wants ^[0-9]{1,3}$. A stored value that - // already looks like a code wins, otherwise the seller's own region stands in. - buyerRegionFallback: invoice.buyerRegionFallback || invoice.sellerRegion, + buyerRegionCodes: invoice.buyerRegionCodes, exchangeRate: input.exchangeRate ?? null, }; } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index f32b78000..66e27da6b 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -421,10 +421,14 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { ); await service.registerInvoiceWithEims(OTHER_INVOICE_ID); - // A refused document returns its counter, so the next attempt reuses it — MoR expects a - // contiguous sequence of *accepted* documents, not of attempts. - expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7); - expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7); + // The two numbers move differently, because MoR constrains them differently: the counter must + // not skip (it returns), the document number must not repeat (it is burned). + const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest; + expect(first.SourceSystem.InvoiceCounter).toBe(7); + expect(second.SourceSystem.InvoiceCounter).toBe(7); + expect(first.DocumentDetails.DocumentNumber).toBe("5"); + expect(second.DocumentDetails.DocumentNumber).toBe("6"); }); }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 66131ed01..64854bf53 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -397,10 +397,15 @@ export class EimsInvoiceRegistrationService { * ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown * for every later document. * - * Returning the counter is not an optimisation — MoR tracks the sequence itself and rejects a - * gap: "Invoice counter is not correct. expected : 1". A document it definitively refused was - * never counted on its side, so ours must not advance either. An ambiguous result is the - * opposite case: MoR may have counted it, so the number stays spent until a human resolves it. + * The two numbers move differently, because MoR constrains them differently: + * + * - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A + * document MoR definitively refused was never counted there, so ours must not advance either. + * - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not + * unique". It is therefore spent by the attempt itself and never handed back, even for a + * refusal. + * + * An ambiguous result keeps both: MoR may have counted and stored the document. */ private async settleFailure( invoiceId: string, @@ -429,9 +434,9 @@ export class EimsInvoiceRegistrationService { reservation.stateId, deterministic ? { - // Hand both numbers back: MoR never counted a document it refused outright. + // Counter returns (MoR never counted a refused document); the document number does + // not (MoR requires it to be unique, so it is burned by the attempt). nextInvoiceCounter: reservation.invoiceCounter, - nextDocumentNumber: Number(reservation.documentNumber), inFlightInvoiceId: null, inFlightCounter: null, inFlightDocumentNumber: null, diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index d620cfa40..d30d28f5b 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -33,7 +33,7 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn paymentTerm: "IMMIDIATE", unitDefault: "PCS", buyerCountryCode: null, - buyerRegionFallback: "13", + buyerRegionCodes: { "Addis Ababa": "13" }, cashierName: null, salesPersonName: null, ...over, From 8d3dfa4113d905d3999dcf93176d7de7f2dadbaf Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 8 Aug 2026 07:47:03 +0000 Subject: [PATCH 08/16] fix(eims): map buyer Wereda to a MoR code too, fail locally if unmapped BuyerDetails.Wereda had the same problem Region did: companies.woreda holds names ("Yeka") MoR has no confirmed regex for, but every Wereda value MoR has actually shown us (seller "12"/"13", the collection's "574") is 1-3 digits like Region. Precautionary, not confirmed -- but the fix is identical either way: resolve through EIMS_BUYER_WEREDA_CODES and refuse to file rather than send a guessed code. Generalises the Region resolver (resolveRegionCode -> resolveLocationCode) to cover both fields instead of duplicating it. No code was invented for "Yeka" -- EIMS_BUYER_WEREDA_CODES ships empty, so this buyer now fails locally (new stop) instead of silently sending a name that was never verified against MoR's schema. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 3 ++ .../edr-freight-api/src/config/eims.config.ts | 7 ++- .../billing/eims-invoice.mapper.spec.ts | 29 +++++++++- .../modules/billing/eims-invoice.mapper.ts | 53 ++++++++++++++----- .../src/modules/eims/eims-invoice-context.ts | 1 + .../src/modules/eims/eims-test-fixtures.ts | 1 + 6 files changed, 77 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 54da939cd..2c636eee4 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -192,6 +192,9 @@ EIMS_BUYER_COUNTRY_CODE= # Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$. # An unmapped region fails locally rather than being filed with a guess. EIMS_BUYER_REGION_CODES=Addis Ababa=13 +# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is +# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess. +EIMS_BUYER_WEREDA_CODES= EIMS_CASHIER_NAME= EIMS_SALESPERSON_NAME= # Automatic filing of issued invoices (@Cron sweep, one invoice per tick). diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 210667409..a8a929ca5 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -87,6 +87,8 @@ export interface EimsInvoiceConfig { * locally rather than being filed with a guessed one. */ buyerRegionCodes: Record; + /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ + buyerWeredaCodes: Record; cashierName: string | null; salesPersonName: string | null; } @@ -110,7 +112,7 @@ const positiveInt = (raw: string | undefined, fallback: number, name: string): n }; /** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */ -const parseRegionCodes = (raw: string | undefined): Record => { +const parseCodeMap = (raw: string | undefined): Record => { const map: Record = {}; for (const pair of (raw ?? "").split(",")) { const [name, code] = pair.split("="); @@ -184,7 +186,8 @@ export default registerAs("eims", (): EimsConfig => { paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, - buyerRegionCodes: parseRegionCodes(process.env.EIMS_BUYER_REGION_CODES), + buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), + buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), cashierName: process.env.EIMS_CASHIER_NAME || null, salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, }, diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index 45b4d33bd..fa0876310 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -61,6 +61,7 @@ const context = (over: Partial = {}): EimsMapperContext => ({ incomeWithholdValue: 0, transactionWithholdValue: 0, buyerRegionCodes: { "Addis Ababa": "13" }, + buyerWeredaCodes: {}, ...over, }); @@ -230,7 +231,7 @@ describe("toEimsInvoice — MoR field constraints", () => { seller, context(), ), - ).toThrow(/not a MoR region code and has no mapping/); + ).toThrow(/not a MoR Region code and has no mapping/); }); it("refuses a buyer with no region at all rather than guessing one", () => { @@ -240,7 +241,31 @@ describe("toEimsInvoice — MoR field constraints", () => { seller, context(), ), - ).toThrow(/buyer region \(unset\)/); + ).toThrow(/buyer Region \(unset\)/); + }); + + it("passes a buyer wereda through when it is already a MoR code", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + expect(doc.BuyerDetails.Wereda).toBe("574"); + }); + + it("maps a wereda name to its code", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, woreda: "Yeka" } }), + seller, + context({ buyerWeredaCodes: { Yeka: "99" } }), + ); + expect(doc.BuyerDetails.Wereda).toBe("99"); + }); + + it("refuses to file a buyer whose wereda has no mapping", () => { + expect(() => + toEimsInvoice( + invoice({ company: { ...invoice().company!, woreda: "Yeka" } }), + seller, + context({ buyerWeredaCodes: {} }), + ), + ).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/); }); it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => { diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index 1e6f8958d..0c4b40829 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -219,6 +219,13 @@ export interface EimsMapperContext { * tax document is worse than refusing to file. */ buyerRegionCodes: Record; + /** + * Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names + * ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an + * error, so this is precautionary rather than confirmed — but the fix is identical either way: + * fail locally on an unmapped name rather than file a guess. + */ + buyerWeredaCodes: Record; buyerIdType?: string | null; buyerIdNumber?: string | null; buyerCity?: string | null; @@ -229,8 +236,13 @@ export interface EimsMapperContext { formatDate?: (issuedAt: Date) => string; } -/** MoR's own constraint on `Region`, on both the seller and buyer sides: one to three digits. */ -const REGION_CODE = /^[0-9]{1,3}$/; +/** + * MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused + * as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller + * "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex + * the way it named Region's. + */ +const LOCATION_CODE = /^[0-9]{1,3}$/; /** * The only two values MoR accepts for `NatureOfSupplies`, lowercase. @@ -261,26 +273,29 @@ export const formatEimsDate = (issuedAt: Date): string => * exchange rate. */ /** - * A buyer's region as a MoR code: passed through when already numeric, otherwise looked up by name - * (case- and space-insensitive). Throws when neither applies. + * A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric, + * otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending + * a guessed code onto a tax document is worse than refusing to file. */ -function resolveRegionCode( - region: string | null | undefined, +function resolveLocationCode( + field: "Region" | "Wereda", + value: string | null | undefined, codes: Record, + envVar: string, invoiceNumber: string, ): string { - const raw = (region ?? "").trim(); - if (REGION_CODE.test(raw)) return raw; + const raw = (value ?? "").trim(); + if (LOCATION_CODE.test(raw)) return raw; const key = raw.toLowerCase().replace(/\s+/g, " "); const mapped = Object.entries(codes).find( ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, )?.[1]; - if (mapped && REGION_CODE.test(mapped)) return mapped; + if (mapped && LOCATION_CODE.test(mapped)) return mapped; throw new Error( - `EIMS mapping: invoice ${invoiceNumber} has buyer region ${raw ? `"${raw}"` : "(unset)"}, ` + - "which is not a MoR region code and has no mapping. Add it to EIMS_BUYER_REGION_CODES.", + `EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` + + `which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`, ); } @@ -381,12 +396,24 @@ export function toEimsInvoice( Tin: company.tin, LegalName: company.name, Phone: company.phone ?? null, - Region: resolveRegionCode(company.region, context.buyerRegionCodes, invoice.invoiceNumber), + Region: resolveLocationCode( + "Region", + company.region, + context.buyerRegionCodes, + "EIMS_BUYER_REGION_CODES", + invoice.invoiceNumber, + ), Country: context.buyerCountryCode ?? null, Zone: company.zone ?? null, Kebele: company.kebele ?? null, VatNumber: company.vatNumber ?? null, - Wereda: company.woreda ?? null, + Wereda: resolveLocationCode( + "Wereda", + company.woreda, + context.buyerWeredaCodes, + "EIMS_BUYER_WEREDA_CODES", + invoice.invoiceNumber, + ), }, DocumentDetails: { DocumentNumber: context.documentNumber, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index adb6826ec..4e7a82069 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -144,6 +144,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, buyerRegionCodes: invoice.buyerRegionCodes, + buyerWeredaCodes: invoice.buyerWeredaCodes, exchangeRate: input.exchangeRate ?? null, }; } diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index d30d28f5b..edd079b51 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -34,6 +34,7 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn unitDefault: "PCS", buyerCountryCode: null, buyerRegionCodes: { "Addis Ababa": "13" }, + buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code cashierName: null, salesPersonName: null, ...over, From 375cf55e5f06fb336ffa989dc2aa51cda08d8ad0 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 8 Aug 2026 07:48:39 +0000 Subject: [PATCH 09/16] LM map missing map api fall back fix --- apps/edr-freight-web/backoffice/.env.example | 6 ++++++ .../backoffice/src/pages/fleet/TrackingPage.tsx | 2 ++ .../src/pages/bookings/new-booking-form/LocationPicker.tsx | 6 ++++++ 3 files changed, 14 insertions(+) diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index 454817139..36bdb80de 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -12,3 +12,9 @@ VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10 # observability stays off (the app works either way). Self-hosted instance. VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx VITE_POSTHOG_HOST=https://posthog.example.com + +# Maps JavaScript API key (fleet TrackingPage). Required — the hardcoded +# fallback in TrackingPage.tsx is expired (ExpiredKeyMapError), so without +# this set the tracking map renders blank. Get a key from the Google Cloud +# Console (Maps JavaScript API + Places API + Geocoding API enabled). +VITE_GOOGLE_MAPS_API_KEY= diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx index 958d99775..b773e9c7c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx @@ -34,6 +34,8 @@ import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.serv import { freightBrand } from "@/theme/freight-brand"; // Same default key + env override the portal's LocationPicker uses. +// NOTE: fallback key is EXPIRED (ExpiredKeyMapError) — set +// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key. const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index 8832279ad..45d133c5c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -47,6 +47,12 @@ interface PlacePrediction { // Maps JavaScript API keys are public client-side keys (lock them down by // HTTP-referrer in the Google Cloud console). The env var lets deployments // override the default key without a code change. +// +// NOTE: the fallback key below is EXPIRED (confirmed via live request — +// "Google Maps JavaScript API error: ExpiredKeyMapError"), which renders +// this picker's map blank while the search box spins forever. Set +// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key to fix it; don't +// rely on this default. const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI"; From 734351b3575d8a8782cd954110db97d03ef9c234 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 8 Aug 2026 11:13:08 +0300 Subject: [PATCH 10/16] Overall report and passenger list updates --- .../src/app/reports/overall/page.tsx | 19 +++++++++++++++--- .../src/app/reports/passengers/page.tsx | 20 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/overall/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/overall/page.tsx index e715fbd71..6ab811622 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/overall/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/overall/page.tsx @@ -30,13 +30,26 @@ export default function ReportsPage() { const getDateRange = () => { const end = new Date(); end.setHours(23, 59, 59, 999); - const start = new Date(); + + if (dateRange === 'custom') { + if (startDate && endDate) { + return startDate <= endDate + ? { startDate, endDate } + : { startDate: endDate, endDate: startDate }; + } + const fallbackStart = new Date(end); + fallbackStart.setDate(end.getDate() - 30); + return { + startDate: fallbackStart.toISOString().split('T')[0], + endDate: end.toISOString().split('T')[0], + }; + } + + const start = new Date(end); switch (dateRange) { case '7': start.setDate(end.getDate() - 7); break; case '30': start.setDate(end.getDate() - 30); break; case '90': start.setDate(end.getDate() - 90); break; - default: - if (startDate && endDate) return { startDate, endDate }; } return { startDate: start.toISOString().split('T')[0], diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 35b6d6cc7..ca6a2c2f8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -74,6 +74,7 @@ export default function PassengersReportPage() { const [tab, setTab] = useState("occupancy"); const [listSearch, setListSearch] = useState(""); const [filterOrigin, setFilterOrigin] = useState(""); + const [filterDestination, setFilterDestination] = useState(""); const [filterSeatClass, setFilterSeatClass] = useState(""); const [filterCoachNumber, setFilterCoachNumber] = useState(""); @@ -110,17 +111,23 @@ export default function PassengersReportPage() { const originOptions = [ ...new Set(passengerList.map((p) => p.origin).filter(Boolean)), ].sort() as string[]; + const destinationOptions = [ + ...new Set(passengerList.map((p) => p.destination).filter(Boolean)), + ].sort() as string[]; const filteredList = passengerList .filter((p) => { if (filterCoachNumber && p.coachNumber !== filterCoachNumber) return false; if (filterOrigin && p.origin !== filterOrigin) return false; + if (filterDestination && p.destination !== filterDestination) return false; if (filterSeatClass && p.seatClassName !== filterSeatClass) return false; if (listSearch.trim()) { const q = listSearch.toLowerCase(); return ( p.passengerName.toLowerCase().includes(q) || p.bookingRef.toLowerCase().includes(q) || + (p.origin ?? '').toLowerCase().includes(q) || + (p.destination ?? '').toLowerCase().includes(q) || (p.idDocumentNumber ?? "").toLowerCase().includes(q) || (p.passportNumber ?? "").toLowerCase().includes(q) ); @@ -207,6 +214,7 @@ export default function PassengersReportPage() { setListSearch(""); setFilterCoachNumber(""); setFilterOrigin(""); + setFilterDestination(""); setFilterSeatClass(""); }} disabled={loadingSchedules} @@ -499,6 +507,18 @@ export default function PassengersReportPage() { ))} + {passengerList.length > 0 && ( Date: Sat, 8 Aug 2026 09:24:59 +0000 Subject: [PATCH 11/16] fix: stamp export self-haul truck arrival on receive to warehouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import's customer_truck_assignments.arrived_at is set by a separate later gate action (release()'s arrival branch — the truck returning to collect already-warehoused goods). Export has no equivalent second step: the truck delivering cargo to the warehouse arrives and is received in the same act, so its arrival was never recorded anywhere. Add markCustomerTruckArrived, mirroring release()'s existing self-haul departure UPDATE (plate-matched, COALESCE(arrived_at, NOW())), and call it from receive()/bulkReceive() for EXPORT bookings --- .../mark-customer-truck-arrived.spec.ts | 56 +++++++++++++++++ .../warehouses/warehouse-inventory.service.ts | 63 ++++++++++++++++++- 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/warehouses/mark-customer-truck-arrived.spec.ts diff --git a/apps/edr-freight-api/src/modules/warehouses/mark-customer-truck-arrived.spec.ts b/apps/edr-freight-api/src/modules/warehouses/mark-customer-truck-arrived.spec.ts new file mode 100644 index 000000000..51a1f4c1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/mark-customer-truck-arrived.spec.ts @@ -0,0 +1,56 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * Export self-haul has no separate "truck arrived" gate action the way import + * does (release()'s arrival branch, fired later when a truck shows up to + * COLLECT already-warehoused goods) — the truck delivering cargo TO the + * warehouse arrives and is received in the same act, so receive()/ + * bulkReceive() must stamp customer_truck_assignments.arrived_at themselves. + */ +type Marker = ( + manager: { query: jest.Mock }, + bookingId: string, + plateNumber: string | null | undefined, +) => Promise; + +function makeMarker() { + const service = Object.create(WarehouseInventoryService.prototype) as Record; + const marker = ( + service as unknown as { markCustomerTruckArrived: Marker } + ).markCustomerTruckArrived.bind(service); + return marker; +} + +describe('markCustomerTruckArrived', () => { + it('stamps arrival matched by booking + plate', async () => { + const marker = makeMarker(); + const manager = { query: jest.fn().mockResolvedValue(undefined) }; + + await marker(manager, 'b-1', 'AAA-2323'); + + expect(manager.query).toHaveBeenCalledTimes(1); + const [sql, params] = manager.query.mock.calls[0]; + expect(sql).toMatch(/UPDATE freight\.customer_truck_assignments/); + expect(sql).toMatch(/UPPER\(a\.plate_number\) = UPPER\(\$2\)/); + expect(params).toEqual(['b-1', 'AAA-2323']); + }); + + it('trims the plate before matching', async () => { + const marker = makeMarker(); + const manager = { query: jest.fn().mockResolvedValue(undefined) }; + + await marker(manager, 'b-1', ' AAA-2323 '); + + expect(manager.query.mock.calls[0][1]).toEqual(['b-1', 'AAA-2323']); + }); + + it('no-ops on a missing/blank plate — no query, nothing to match on', async () => { + const marker = makeMarker(); + const manager = { query: jest.fn() }; + + await marker(manager, 'b-1', undefined); + await marker(manager, 'b-1', ' '); + + expect(manager.query).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 1159c1151..3e11380c1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1643,6 +1643,12 @@ export class WarehouseInventoryService { [bookingId], ); + // Export self-haul: this receive IS the truck's arrival — see + // markCustomerTruckArrived / receive()'s single-booking mirror. + if (dto.direction === 'EXPORT') { + await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber); + } + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -2886,6 +2892,13 @@ export class WarehouseInventoryService { ); } + // Export self-haul: this receive IS the truck's arrival — stamp it on + // its own customer_truck_assignments row (mirror of import's arrival, + // see markCustomerTruckArrived). + if (dto.bookingId && bookingDirection === 'EXPORT') { + await this.markCustomerTruckArrived(manager, dto.bookingId, truckEntrance.truckPlateNumber); + } + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -6008,6 +6021,33 @@ export class WarehouseInventoryService { }; } + /** + * EXPORT self-haul mirror of the customer truck lifecycle IMPORT already has: + * import stamps a truck's arrival on the SEPARATE gate action that comes + * later (release()'s arrival branch, when the customer's truck shows up to + * COLLECT already-warehoused goods). Export has no such separate step — the + * truck delivering cargo TO the warehouse arrives and is received in the + * same act, so receive()/bulkReceive() themselves are the arrival event. + * Matched by plate (not assignmentId — neither receive endpoint carries + * one), same as release()'s departure-branch self-haul UPDATE. + */ + private async markCustomerTruckArrived( + manager: EntityManager, + bookingId: string, + plateNumber: string | null | undefined, + ): Promise { + const plate = plateNumber?.trim(); + if (!plate) return; + await manager.query( + `UPDATE freight.customer_truck_assignments a + SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW() + WHERE a.booking_id = $1 + AND UPPER(a.plate_number) = UPPER($2) + AND a.deleted_at IS NULL`, + [bookingId, plate], + ); + } + private async getBookingTruckEntranceSource( manager: EntityManager, bookingId: string, @@ -6027,6 +6067,10 @@ export class WarehouseInventoryService { firstMileDriverPhone?: string | null; firstMileDriverLicenseNumber?: string | null; firstMileTruckType?: string | null; + customerTruckPlateNumber?: string | null; + customerTruckDriverName?: string | null; + customerTruckType?: string | null; + customerTruckContainerNumber?: string | null; }> { const [booking] = await manager.query( `SELECT b.reference AS "reference", @@ -6046,7 +6090,24 @@ export class WarehouseInventoryService { ) AS "firstMileDriverName", driver.phone_number AS "firstMileDriverPhone", driver.license_number AS "firstMileDriverLicenseNumber", - v.vehicle_type AS "firstMileTruckType" + v.vehicle_type AS "firstMileTruckType", + -- Self-haul truck assigned via the portal — export delivering to + -- the warehouse or import collecting from it. Same pattern as + -- eligibleBookings/importQueueByStatuses: multi-truck self-haul + -- writes plates/drivers to customer_truck_assignments and leaves + -- the booking columns null, so read the assignments first and + -- keep the legacy column as the fallback for single-truck + -- bookings written before that table existed. + COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ') + FROM freight.customer_truck_assignments cta + WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), + b.customer_truck_plate_number) AS "customerTruckPlateNumber", + COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ') + FROM freight.customer_truck_assignments cta + WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), + b.customer_truck_driver_name) AS "customerTruckDriverName", + b.customer_truck_type AS "customerTruckType", + b.customer_truck_container_number AS "customerTruckContainerNumber" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id ${primaryContactUserJoin('company')} From ee25de8817939cb96927745797a35fca232b7f55 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 8 Aug 2026 09:36:51 +0000 Subject: [PATCH 12/16] fix(freight:backoffice): wire dedicated permission keys instead of broad fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compliance & Alerts, Procurement, File settings, Dropdown settings, Trade access, and Exchange rate all fell back to a broad permission (fleet:view or admin) even though a dedicated key already existed in FREIGHT_PERMS — meaning granting one of these pages meant granting several unrelated ones too. Each now checks its own key first, with the broad permission kept as a fallback for existing role grants. Incidents left as-is: no dedicated edr_freight_app:incidents:* key exists yet on the backend. Co-Authored-By: Claude Sonnet 5 --- apps/edr-freight-web/backoffice/src/App.tsx | 24 ++++++++++++++----- .../components/layout/sidebar-sections.tsx | 14 ++++++----- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 07299462c..7ef07dd09 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -764,7 +764,9 @@ const App = () => { + } @@ -788,7 +790,9 @@ const App = () => { + } @@ -811,7 +815,9 @@ const App = () => { + } @@ -819,7 +825,9 @@ const App = () => { + } @@ -876,7 +884,9 @@ const App = () => { + } @@ -884,7 +894,9 @@ const App = () => { +
diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 5883da5b8..8b9e7bd3f 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -279,19 +279,21 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] label: "Compliance & Alerts", href: "/dashboard/compliance", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: [FREIGHT_PERMS.compliance.view, FREIGHT_PERMS.fleet.view], }, { label: "Incidents", href: "/dashboard/incidents", icon: , + // No dedicated backend key exists for incidents yet — stuck on the + // blanket fleet:view fallback until one is added. permission: FREIGHT_PERMS.fleet.view, }, { label: "Procurement", href: "/dashboard/procurement", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: [FREIGHT_PERMS.procurement.view, FREIGHT_PERMS.fleet.view], }, { label: "Financial Reports", @@ -476,13 +478,13 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] label: "File settings", href: "/dashboard/file-settings", icon: , - permission: FREIGHT_PERMS.admin, + permission: [FREIGHT_PERMS.settings.fileUpload.view, FREIGHT_PERMS.admin], }, { label: "Dropdown settings", href: "/dashboard/dropdown-settings", icon: , - permission: FREIGHT_PERMS.admin, + permission: [FREIGHT_PERMS.settings.dropdown.view, FREIGHT_PERMS.admin], }, { label: "Contract templates", @@ -514,12 +516,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] { label: "Trade access", href: "/dashboard/configuration/trade-access", - permission: FREIGHT_PERMS.admin, + permission: [FREIGHT_PERMS.tradeAccess.view, FREIGHT_PERMS.admin], }, { label: "Exchange rate", href: "/dashboard/configuration/exchange-rate", - permission: FREIGHT_PERMS.admin, + permission: [FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin], }, ], }, From 41e8c08ba38481d069329591c193b8d7336a49dc Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 8 Aug 2026 09:37:28 +0000 Subject: [PATCH 13/16] feat: WIP Contnet managemtn --- apps/edr-freight-api/src/app.module.ts | 9 + .../3350000000000-SupportContent.ts | 73 + .../3360000000000-SupportHelpSections.ts | 112 + .../dto/support-content.dto.ts | 311 +++ .../entities/support-document.entity.ts | 69 + .../public-support-content.controller.ts | 29 + .../support-content.controller.ts | 135 + .../support-content/support-content.module.ts | 23 + .../support-content.repository.ts | 71 + .../support-content.service.spec.ts | 237 ++ .../support-content.service.ts | 368 +++ .../src/seed/freight-permissions.registry.ts | 15 + .../src/seed/support-content.seeder.ts | 62 + apps/edr-freight-web/backoffice/package.json | 2 + apps/edr-freight-web/backoffice/src/App.tsx | 15 + .../components/layout/sidebar-sections.tsx | 11 + .../portal-content/usePortalContentAdmin.ts | 78 + .../backoffice/src/lib/permissions.ts | 5 + .../src/pages/portal_content/AccordionRow.tsx | 95 + .../src/pages/portal_content/FaqEditor.tsx | 242 ++ .../src/pages/portal_content/HelpEditor.tsx | 125 + .../pages/portal_content/LegalDocEditor.tsx | 110 + .../src/pages/portal_content/Markdown.tsx | 24 + .../pages/portal_content/MarkdownEditor.tsx | 142 + .../src/pages/portal_content/MediaManager.tsx | 122 + .../portal_content/PortalContentPage.tsx | 261 ++ .../portal_content/VersionHistoryModal.tsx | 198 ++ .../src/pages/portal_content/array-helpers.ts | 24 + .../pages/portal_content/markdown-editor.css | 109 + .../pages/portal_content/version-preview.ts | 79 + .../src/services/portal-content.service.ts | 98 + apps/edr-freight-web/portal/package.json | 1 + .../portal/src/constants/URLS.ts | 5 + .../portal/src/hooks/usePortalContent.ts | 39 + .../portal/src/pages/support/DocShell.tsx | 30 +- .../portal/src/pages/support/FaqPage.tsx | 53 +- .../portal/src/pages/support/HelpPage.tsx | 246 +- .../portal/src/pages/support/Markdown.tsx | 72 + .../src/pages/support/PrivacyPolicyPage.tsx | 15 +- .../portal/src/pages/support/TermsPage.tsx | 16 +- .../portal/src/pages/support/content.ts | 358 --- .../src/pages/support/portal-content.test.ts | 84 + .../src/pages/support/portal-content.ts | 103 + packages/types/src/freight/index.ts | 2 + .../src/freight/portal-content.defaults.ts | 375 +++ packages/types/src/freight/portal-content.ts | 206 ++ pnpm-lock.yaml | 2399 ++++++++++++++++- portal-content-contact.png | Bin 0 -> 74931 bytes 48 files changed, 6601 insertions(+), 657 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts create mode 100644 apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/entities/support-document.entity.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/public-support-content.controller.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/support-content.controller.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/support-content.module.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/support-content.repository.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/support-content.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/support-content.service.ts create mode 100644 apps/edr-freight-api/src/seed/support-content.seeder.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/portal-content/usePortalContentAdmin.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/AccordionRow.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/FaqEditor.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/HelpEditor.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/LegalDocEditor.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/Markdown.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/MarkdownEditor.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/MediaManager.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/PortalContentPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/VersionHistoryModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/array-helpers.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/markdown-editor.css create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/version-preview.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/portal-content.service.ts create mode 100644 apps/edr-freight-web/portal/src/hooks/usePortalContent.ts create mode 100644 apps/edr-freight-web/portal/src/pages/support/Markdown.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/support/content.ts create mode 100644 apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts create mode 100644 apps/edr-freight-web/portal/src/pages/support/portal-content.ts create mode 100644 packages/types/src/freight/portal-content.defaults.ts create mode 100644 packages/types/src/freight/portal-content.ts create mode 100644 portal-content-contact.png diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 50dde1034..1e0908da6 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -50,6 +50,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; +import { SupportContentModule } from "./modules/support-content/support-content.module"; import { OtpModule } from "./modules/otp/otp.module"; import { HealthModule } from "./modules/health/health.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; @@ -67,6 +68,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; // import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; +import { SupportContentSeeder } from "./seed/support-content.seeder"; // import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; // import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; // import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; @@ -220,6 +222,7 @@ if (!process.env.APPLICATION_NAME) { DropdownSettingsModule, ExchangeSettingsModule, ContractTemplatesModule, + SupportContentModule, OtpModule, HealthModule, RuleEngineModule, @@ -260,6 +263,7 @@ if (!process.env.APPLICATION_NAME) { EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, + SupportContentSeeder, // YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, FreightNotificationPermissionsSeeder, @@ -291,6 +295,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + private readonly supportContentSeeder: SupportContentSeeder, // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder, @@ -349,6 +354,10 @@ export class AppModule implements OnApplicationBootstrap { // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); + // Portal help/FAQ/legal copy — keep enabled. Idempotent by emptiness, so + // it fills an empty table once and never touches admin edits afterwards. + await this.supportContentSeeder.run(); + // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, // Dire Dawa). Idempotent; creates no yards. // await this.yardFacilitiesSeeder.run(); diff --git a/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts b/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts new file mode 100644 index 000000000..168ee2c6d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Editable customer-facing copy for the portal's public pages (/help, /faq, + * /terms, /privacy) plus the shared support-contact block, with an append-only + * version log behind it. + * + * `payload` is opaque jsonb: the five documents have genuinely different shapes + * and the help page's blocks change with the copy, so typed columns would mean + * a migration per wording tweak. The shape is enforced by per-slug DTOs on + * write instead. + * + * No rows are inserted here — `SupportContentSeeder` fills the table on first + * boot and skips whenever it is non-empty, so a redeploy never overwrites + * admin edits the way a migration-embedded INSERT eventually would. + */ +export class SupportContent3350000000000 implements MigrationInterface { + name = "SupportContent3350000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug varchar(32) NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + version integer NOT NULL DEFAULT 1, + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_support_documents_slug + ON freight.support_documents (slug); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_document_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + document_id uuid NOT NULL + REFERENCES freight.support_documents(id) ON DELETE CASCADE, + version integer NOT NULL, + payload jsonb NOT NULL, + actor_id uuid, + note varchar(255), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + // Closes the concurrent-save race: two editors saving at once cannot both + // claim the same version number. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_support_doc_version + ON freight.support_document_versions (document_id, version); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_support_doc_versions_document + ON freight.support_document_versions (document_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.support_document_versions;`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.support_documents;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts b/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts new file mode 100644 index 000000000..5220d3a6c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts @@ -0,0 +1,112 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Converts the HELP document from its original fixed-block shape + * (`video` / `chat` / `channels` / `topics` / `checklist`) to the free-form + * `sections[]` builder, where every block is a heading plus markdown plus + * attached media. + * + * Only rows still in the old shape are touched — detected by the presence of a + * `channels` key — so this is a no-op on any environment seeded after the + * change, and re-running it does nothing. + * + * The payload literal is inlined rather than imported from + * `SUPPORT_CONTENT_DEFAULTS`: a migration must keep doing the same thing + * forever, and that constant will keep moving. + * + * The rewrite also bumps `version` and writes a matching history row. The live + * row's version always having a matching entry in + * `support_document_versions` is the invariant the history list and rollback + * both depend on, and a silent payload swap would break it. + */ +const HELP_SECTIONS = [ + { + id: "help-walkthrough", + heading: "Portal walkthrough", + body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.", + media: [ + { + id: "help-walkthrough-video", + kind: "video", + src: "/assets/edr-portal-guide.webm", + caption: null, + }, + ], + }, + { + id: "help-chat", + heading: "Chat with our team", + body: "Signed-in customers can open a support conversation from the headset button at the bottom right of every portal page. You can send screenshots and documents in the chat, and replies appear there and as a notification.\n\n[Open the portal](/portal)", + media: [], + }, + { + id: "help-contact", + heading: "Contact us", + body: "- **Email** — [{{supportEmail}}](mailto:{{supportEmail}}). Best for document issues and anything needing an attachment.\n- **Phone** — [{{supportPhone}}](tel:{{supportPhoneTel}}). Best for urgent problems with cargo already in transit.\n- **Head office** — {{supportOffice}}. Walk-in support during working hours.\n- **Support hours** — {{supportHours}}. Outside these hours, email us and we reply the next working day.", + media: [], + }, + { + id: "help-topics", + heading: "Common topics", + body: "- **[Account & onboarding](/faq)** — registering your company, uploading your trade licence and TIN, and getting an operational profile approved.\n- **[Contracts](/faq)** — requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.\n- **[Bookings & tracking](/faq)** — raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.\n- **[Invoices & payments](/faq)** — finding invoices, paying through the bank channels and confirming a payment that has not yet settled.", + media: [], + }, + { + id: "help-checklist", + heading: "What to include when you contact us", + body: "- Your company name and the email you sign in with.\n- The reference of the contract, booking or invoice involved.\n- What you expected to happen and what happened instead.\n- A screenshot of any error message the portal showed.", + media: [], + }, +]; + +export class SupportHelpSections3360000000000 implements MigrationInterface { + name = "SupportHelpSections3360000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const rows: { id: string; version: number; payload: Record }[] = + await queryRunner.query(` + SELECT id, version, payload + FROM freight.support_documents + WHERE slug = 'HELP' AND payload ? 'channels' + `); + + for (const row of rows) { + const payload = { + title: row.payload.title ?? "Help & Support", + subtitle: + row.payload.subtitle ?? + "Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly.", + sections: HELP_SECTIONS, + }; + const version = row.version + 1; + + await queryRunner.query( + `UPDATE freight.support_documents + SET payload = $1::jsonb, version = $2, updated_at = now() + WHERE id = $3`, + [JSON.stringify(payload), version, row.id], + ); + + await queryRunner.query( + `INSERT INTO freight.support_document_versions + (document_id, version, payload, actor_id, note) + VALUES ($1, $2, $3::jsonb, NULL, $4)`, + [ + row.id, + version, + JSON.stringify(payload), + "Converted help page to free-form sections", + ], + ); + } + } + + /** + * Not reversible: the old fixed blocks cannot be recovered from markdown + * sections an editor may since have rewritten. The version history holds the + * pre-conversion payload if it is ever genuinely needed. + */ + public async down(): Promise { + // no-op + } +} diff --git a/apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts b/apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts new file mode 100644 index 000000000..fdfffe8be --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts @@ -0,0 +1,311 @@ +import { SUPPORT_MEDIA_PREFIX, SupportDocSlug } from "@edr/types"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsObject, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, + ValidateNested, +} from "class-validator"; + +/** + * Markdown bodies are safe on read — the portal renders them with + * `react-markdown` and no `rehype-raw`, so any HTML in them is inert. The + * fields worth validating are these: they land in `href`/`src` attributes and + * bypass markdown entirely, which is where a `javascript:` URL would actually + * execute. + * + * Placeholders survive the check because they sit after the scheme + * (`mailto:{{supportEmail}}`, `tel:{{supportPhoneTel}}`). + */ +const LINK_PATTERN = /^(https?:\/\/|mailto:|tel:|\/)/; +const LINK_MESSAGE = + "$property must start with http(s)://, mailto:, tel: or /"; + +/** + * A media source is either an uploaded MinIO object key, a same-origin path, or + * an https URL. Anything else — notably `javascript:` — is refused, since this + * value lands in an ``/`
} /> + + + + } + /> , + permission: [ + FREIGHT_PERMS.settings.supportContent.view, + FREIGHT_PERMS.settings.supportContent.manage, + FREIGHT_PERMS.admin, + ], + }, { label: "Audit logs", href: "/dashboard/audit-logs", diff --git a/apps/edr-freight-web/backoffice/src/hooks/portal-content/usePortalContentAdmin.ts b/apps/edr-freight-web/backoffice/src/hooks/portal-content/usePortalContentAdmin.ts new file mode 100644 index 000000000..49e76c80b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/portal-content/usePortalContentAdmin.ts @@ -0,0 +1,78 @@ +import type { SupportDocPayload, SupportDocSlug } from "@edr/types"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { portalContentService } from "@/services/portal-content.service"; + +/** + * Every key shares the `portal-content` prefix so one invalidate after a save + * or a restore sweeps the document and its version list together. + */ +const KEYS = { + ROOT: ["portal-content"] as const, + bySlug: (slug: string) => ["portal-content", "detail", slug] as const, + versions: (slug: string) => ["portal-content", "versions", slug] as const, +}; + +export function usePortalDoc(slug: SupportDocSlug) { + return useQuery({ + queryKey: KEYS.bySlug(slug), + queryFn: () => portalContentService.getBySlug(slug), + }); +} + +/** Version history. Stays idle until the history modal is opened. */ +export function usePortalDocVersions(slug: SupportDocSlug, enabled: boolean) { + return useQuery({ + queryKey: KEYS.versions(slug), + queryFn: () => portalContentService.listVersions(slug), + enabled, + }); +} + +/** One historical payload, fetched only when a version is previewed. */ +export function usePortalDocVersion( + slug: SupportDocSlug, + version: number | null, +) { + return useQuery({ + queryKey: [...KEYS.versions(slug), version], + queryFn: () => portalContentService.getVersion(slug, version as number), + enabled: version !== null, + }); +} + +function usePortalContentMutation( + mutationFn: (vars: TVariables) => Promise, + successMessage: string, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onSuccess: () => { + toast.success(successMessage); + void queryClient.invalidateQueries({ queryKey: KEYS.ROOT }); + }, + onError: (error: unknown) => { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Something went wrong"; + toast.error(Array.isArray(message) ? message.join(", ") : message); + }, + }); +} + +export function useUpdatePortalDoc(slug: SupportDocSlug) { + return usePortalContentMutation( + (vars: { payload: SupportDocPayload; note?: string }) => + portalContentService.update(slug, vars.payload, vars.note), + "Portal content saved", + ); +} + +export function useRestorePortalVersion(slug: SupportDocSlug) { + return usePortalContentMutation( + (version: number) => portalContentService.restore(slug, version), + "Version restored", + ); +} diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 88dadb1f7..511017805 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -326,6 +326,11 @@ export const FREIGHT_PERMS = { delete: "edr_freight_app:settings:contract_templates:delete", read: "edr_freight_app:settings:contract_templates:read", }, + // Portal-facing help/FAQ/legal copy, edited from Portal content. + supportContent: { + view: "edr_freight_app:settings:support_content:view", + manage: "edr_freight_app:settings:support_content:manage", + }, }, audit: { view: "edr_freight_app:audit:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/AccordionRow.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/AccordionRow.tsx new file mode 100644 index 000000000..309bdb34c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/AccordionRow.tsx @@ -0,0 +1,95 @@ +import { Accordion, ActionIcon, Center, Group, Text, Tooltip } from "@mantine/core"; +import { ChevronDown, ChevronUp, Trash2 } from "lucide-react"; +import type { ReactNode } from "react"; + +interface AccordionRowProps { + value: string; + /** Collapsed summary — the heading, question or card title. */ + title: string; + /** Small dimmed line under the title, e.g. a body excerpt. */ + subtitle?: string; + index: number; + length: number; + onMove: (delta: number) => void; + onRemove: () => void; + children: ReactNode; +} + +/** + * One collapsible item with reorder and delete controls in its header. + * + * Collapsing is the point: a legal document has fifteen sections and the FAQ + * seventeen answers, and rendering every textarea expanded turned each tab into + * an unnavigable mile of boxes. Collapsed, the tab reads as the list of + * headings the customer actually sees. + * + * The buttons sit outside `Accordion.Control` so clicking one does not also + * toggle the panel. + */ +export function AccordionRow({ + value, + title, + subtitle, + index, + length, + onMove, + onRemove, + children, +}: AccordionRowProps) { + return ( + +
+ +
+ + {title || (untitled)} + + {subtitle && ( + + {subtitle} + + )} +
+
+ + + + onMove(-1)} + > + + + + + onMove(1)} + > + + + + + + + + + +
+ + {children} +
+ ); +} + +/** First line of a markdown body, for an accordion subtitle. */ +export function excerpt(markdown: string, max = 90): string { + const line = markdown.replace(/[#*`>-]/g, "").trim().split("\n")[0] ?? ""; + return line.length > max ? `${line.slice(0, max)}…` : line; +} + +export default AccordionRow; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqEditor.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqEditor.tsx new file mode 100644 index 000000000..b5a9a7fca --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqEditor.tsx @@ -0,0 +1,242 @@ +import type { PortalFaqContent, PortalFaqGroup } from "@edr/types"; +import { + Accordion, + Badge, + Button, + Card, + Group, + Stack, + Switch, + TextInput, +} from "@mantine/core"; +import { Plus } from "lucide-react"; + +import { AccordionRow, excerpt } from "./AccordionRow"; +import { moveAt, newId, removeAt, replaceAt } from "./array-helpers"; +import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor"; + +interface FaqEditorProps { + value: PortalFaqContent; + onChange: (next: PortalFaqContent) => void; +} + +const EMPTY_FOOTER = { + heading: "Still need a hand?", + body: "", + ctaLabel: "Go to Help & Support", + ctaTo: "/help", +}; + +export function FaqEditor({ value, onChange }: FaqEditorProps) { + const setGroups = (groups: PortalFaqGroup[]) => onChange({ ...value, groups }); + + const setGroup = (index: number, next: PortalFaqGroup) => + setGroups(replaceAt(value.groups, index, next)); + + return ( + + + + + onChange({ ...value, title: e.currentTarget.value }) + } + /> + + onChange({ ...value, subtitle: e.currentTarget.value }) + } + /> + + + + + + + {value.groups.map((group, groupIndex) => ( + setGroups(moveAt(value.groups, groupIndex, delta))} + onRemove={() => setGroups(removeAt(value.groups, groupIndex))} + > + + + setGroup(groupIndex, { + ...group, + title: e.currentTarget.value, + }) + } + /> + + + {group.items.map((item, itemIndex) => ( + + setGroup(groupIndex, { + ...group, + items: moveAt(group.items, itemIndex, delta), + }) + } + onRemove={() => + setGroup(groupIndex, { + ...group, + items: removeAt(group.items, itemIndex), + }) + } + > + + + setGroup(groupIndex, { + ...group, + items: replaceAt(group.items, itemIndex, { + ...item, + question: e.currentTarget.value, + }), + }) + } + /> + + setGroup(groupIndex, { + ...group, + items: replaceAt(group.items, itemIndex, { + ...item, + answer, + }), + }) + } + /> + + + ))} + + + + + + ))} + + + + + + + + + onChange({ + ...value, + footer: e.currentTarget.checked ? EMPTY_FOOTER : null, + }) + } + /> + {!value.footer && Hidden} + + + {value.footer && ( + <> + + onChange({ + ...value, + footer: { ...value.footer!, heading: e.currentTarget.value }, + }) + } + /> + + onChange({ ...value, footer: { ...value.footer!, body } }) + } + /> + + + onChange({ + ...value, + footer: { + ...value.footer!, + ctaLabel: e.currentTarget.value, + }, + }) + } + /> + + onChange({ + ...value, + footer: { ...value.footer!, ctaTo: e.currentTarget.value }, + }) + } + /> + + + )} + + + + ); +} + +export default FaqEditor; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/HelpEditor.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/HelpEditor.tsx new file mode 100644 index 000000000..e7b648b43 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/HelpEditor.tsx @@ -0,0 +1,125 @@ +import type { PortalHelpContent, PortalHelpSection } from "@edr/types"; +import { Accordion, Button, Card, Divider, Stack, TextInput } from "@mantine/core"; +import { Plus } from "lucide-react"; + +import { AccordionRow, excerpt } from "./AccordionRow"; +import { moveAt, newId, removeAt, replaceAt } from "./array-helpers"; +import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor"; +import { MediaManager } from "./MediaManager"; + +interface HelpEditorProps { + value: PortalHelpContent; + onChange: (next: PortalHelpContent) => void; +} + +/** + * The help page is built, not filled in: an ordered list of sections, each a + * heading plus free markdown plus any images or videos. Nothing about the page + * is fixed except its title, so support can add, reorder or drop a section + * without a code change. + */ +export function HelpEditor({ value, onChange }: HelpEditorProps) { + // A row written before the free-form conversion has no `sections` at all. + // Tolerate it rather than crashing the tab: the migration rewrites it, but + // an environment can be mid-deploy. + const sections = value.sections ?? []; + + const setSections = (next: PortalHelpSection[]) => + onChange({ ...value, sections: next }); + + return ( + + + + + onChange({ ...value, title: e.currentTarget.value }) + } + /> + + onChange({ ...value, subtitle: e.currentTarget.value }) + } + /> + + + + + + + {sections.map((section, index) => ( + setSections(moveAt(sections, index, delta))} + onRemove={() => setSections(removeAt(sections, index))} + > + + + setSections( + replaceAt(sections, index, { + ...section, + heading: e.currentTarget.value, + }), + ) + } + /> + + + setSections( + replaceAt(sections, index, { ...section, body }), + ) + } + /> + + + + + setSections( + replaceAt(sections, index, { ...section, media }), + ) + } + /> + + + ))} + + + + + ); +} + +export default HelpEditor; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/LegalDocEditor.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/LegalDocEditor.tsx new file mode 100644 index 000000000..b24bf9be4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/LegalDocEditor.tsx @@ -0,0 +1,110 @@ +import type { PortalLegalContent } from "@edr/types"; +import { Accordion, Button, Card, Group, Stack, TextInput } from "@mantine/core"; +import { Plus } from "lucide-react"; + +import { AccordionRow, excerpt } from "./AccordionRow"; +import { moveAt, newId, removeAt, replaceAt } from "./array-helpers"; +import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor"; + +interface LegalDocEditorProps { + value: PortalLegalContent; + onChange: (next: PortalLegalContent) => void; +} + +/** Shared by the Privacy and Terms tabs — the two documents have one shape. */ +export function LegalDocEditor({ value, onChange }: LegalDocEditorProps) { + const setSections = (sections: PortalLegalContent["sections"]) => + onChange({ ...value, sections }); + + return ( + + + + + + onChange({ ...value, title: e.currentTarget.value }) + } + /> + + onChange({ ...value, lastUpdated: e.currentTarget.value }) + } + /> + + + + onChange({ ...value, subtitle: e.currentTarget.value }) + } + /> + + + + + + + {value.sections.map((section, index) => ( + setSections(moveAt(value.sections, index, delta))} + onRemove={() => setSections(removeAt(value.sections, index))} + > + + + setSections( + replaceAt(value.sections, index, { + ...section, + heading: e.currentTarget.value, + }), + ) + } + /> + + + setSections( + replaceAt(value.sections, index, { ...section, body }), + ) + } + /> + + + ))} + + + + + ); +} + +export default LegalDocEditor; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/Markdown.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/Markdown.tsx new file mode 100644 index 000000000..53a6937e8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/Markdown.tsx @@ -0,0 +1,24 @@ +import ReactMarkdown from "react-markdown"; + +// Same preflight fix the editor needs — Mantine's `Typography` defines its list +// and margin rules with `:where()`, which Tailwind's preflight outranks, so +// bullets rendered without markers here too. +import "./markdown-editor.css"; + +/** + * Read-only markdown rendering for the version-history preview. Editing goes + * through `MarkdownEditor` (MDXEditor); this is only for showing what an old + * version said. + * + * Same options as the portal's renderer — no `rehype-raw`, no custom + * `urlTransform` — so neither app grows an HTML-injection surface. + */ +export function Markdown({ children }: { children: string }) { + return ( +
+ {children} +
+ ); +} + +export default Markdown; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/MarkdownEditor.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/MarkdownEditor.tsx new file mode 100644 index 000000000..83ca7ef46 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/MarkdownEditor.tsx @@ -0,0 +1,142 @@ +import { PORTAL_MEDIA_URI_SCHEME } from "@edr/types"; +import { Box, Stack, Text } from "@mantine/core"; +import { + BlockTypeSelect, + BoldItalicUnderlineToggles, + CreateLink, + InsertImage, + InsertThematicBreak, + ListsToggle, + MDXEditor, + UndoRedo, + headingsPlugin, + imagePlugin, + linkDialogPlugin, + linkPlugin, + listsPlugin, + markdownShortcutPlugin, + quotePlugin, + thematicBreakPlugin, + toolbarPlugin, +} from "@mdxeditor/editor"; +import "@mdxeditor/editor/style.css"; + +import { portalContentService } from "@/services/portal-content.service"; + +// Undoes Tailwind's preflight inside the editor's content area — see the file. +import "./markdown-editor.css"; + +interface MarkdownEditorProps { + label: string; + value: string; + onChange: (next: string) => void; + description?: string; +} + +/** + * Signed URLs are per-request and short-lived, so previews are memoised for the + * life of the page rather than re-signed on every keystroke re-render. + */ +const previewCache = new Map>(); + +/** + * Inserted images are stored as `minio:`, never as the signed URL the + * upload returns: a presigned URL expires, so persisting one would leave every + * embedded image broken a few hours later. `imagePreviewHandler` resolves the + * ref back to a temporary URL purely for display, on both sides of the wire. + */ +function resolvePreview(url: string): Promise { + if (!url.startsWith(PORTAL_MEDIA_URI_SCHEME)) return Promise.resolve(url); + + const key = url.slice(PORTAL_MEDIA_URI_SCHEME.length); + let pending = previewCache.get(key); + if (!pending) { + pending = portalContentService + .mediaUrl(key) + .catch(() => url); // show a broken image rather than blowing up the editor + previewCache.set(key, pending); + } + return pending; +} + +export function MarkdownEditor({ + label, + value, + onChange, + description, +}: MarkdownEditorProps) { + return ( + + + {label} + + {description && ( + + {description} + + )} + + + { + if (!initialMarkdownNormalize) onChange(markdown); + }} + plugins={[ + headingsPlugin(), + listsPlugin(), + quotePlugin(), + linkPlugin(), + linkDialogPlugin(), + thematicBreakPlugin(), + imagePlugin({ + imageUploadHandler: async (file) => { + const { key } = await portalContentService.uploadMedia(file); + return `${PORTAL_MEDIA_URI_SCHEME}${key}`; + }, + imagePreviewHandler: resolvePreview, + }), + markdownShortcutPlugin(), + toolbarPlugin({ + toolbarContents: () => ( + <> + + + + + + + + + ), + }), + ]} + /> + + + ); +} + +/** Reminder of the substitution tokens, rendered once per tab. */ +export function MarkdownHint() { + return ( + + Placeholders resolve from the Contact tab, so one edit there updates every + page: {"{{supportEmail}}"} · {"{{supportPhone}}"}{" "} + · {"{{supportOffice}}"} · {"{{supportHours}}"} ·{" "} + {"{{supportPhoneTel}}"} (inside a tel: link). + + ); +} + +export default MarkdownEditor; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/MediaManager.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/MediaManager.tsx new file mode 100644 index 000000000..a80bd3b55 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/MediaManager.tsx @@ -0,0 +1,122 @@ +import type { PortalMedia } from "@edr/types"; +import { + ActionIcon, + Button, + Group, + Paper, + Stack, + Text, + TextInput, + Tooltip, +} from "@mantine/core"; +import { Film, Image as ImageIcon, Trash2, Upload } from "lucide-react"; +import { useRef, useState } from "react"; +import toast from "react-hot-toast"; + +import { portalContentService } from "@/services/portal-content.service"; + +import { newId, removeAt, replaceAt } from "./array-helpers"; + +interface MediaManagerProps { + value: PortalMedia[]; + onChange: (next: PortalMedia[]) => void; +} + +/** + * Attachments for one help section. Uploads store the MinIO object *key*; the + * signed URL the upload returns is short-lived and is never persisted, so the + * list shows the key rather than pretending to be a gallery. + */ +export function MediaManager({ value, onChange }: MediaManagerProps) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + + const upload = async (file: File) => { + setUploading(true); + try { + const { key, kind } = await portalContentService.uploadMedia(file); + onChange([...value, { id: newId(), kind, src: key, caption: null }]); + } catch (error) { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Upload failed"; + toast.error(Array.isArray(message) ? message.join(", ") : message); + } finally { + setUploading(false); + if (inputRef.current) inputRef.current.value = ""; + } + }; + + return ( + + + Attachments + + + {value.map((item, index) => ( + + + {item.kind === "video" ? ( + + ) : ( + + )} + + + + {item.src} + + + onChange( + replaceAt(value, index, { + ...item, + caption: e.currentTarget.value || null, + }), + ) + } + /> + + + + onChange(removeAt(value, index))} + > + + + + + + ))} + + { + const file = e.currentTarget.files?.[0]; + if (file) void upload(file); + }} + /> + + + + ); +} + +export default MediaManager; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/PortalContentPage.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/PortalContentPage.tsx new file mode 100644 index 000000000..17d5d190f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/PortalContentPage.tsx @@ -0,0 +1,261 @@ +import type { + PortalFaqContent, + PortalHelpContent, + PortalLegalContent, + PortalSupportContact, + SupportDocPayload, + SupportDocSlug, +} from "@edr/types"; +import { + Badge, + Button, + Card, + Group, + Loader, + Stack, + Tabs, + Text, + TextInput, +} from "@mantine/core"; +import { History, RotateCcw, Save } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { + usePortalDoc, + useUpdatePortalDoc, +} from "@/hooks/portal-content/usePortalContentAdmin"; + +import { FaqEditor } from "./FaqEditor"; +import { HelpEditor } from "./HelpEditor"; +import { LegalDocEditor } from "./LegalDocEditor"; +import { VersionHistoryModal } from "./VersionHistoryModal"; + +const TABS: { slug: SupportDocSlug; label: string }[] = [ + { slug: "CONTACT", label: "Contact" }, + { slug: "HELP", label: "Help" }, + { slug: "FAQ", label: "FAQ" }, + { slug: "PRIVACY", label: "Privacy" }, + { slug: "TERMS", label: "Terms" }, +]; + +/** + * Edits the copy on the freight portal's public pages — /help, /faq, /terms, + * /privacy — and the support contact block all four quote. + * + * Each tab is a local draft saved in one PATCH of the whole document, rather + * than a mutation per field. That is what makes one editorial change equal one + * version, which is the difference between a history you can read and a history + * of keystrokes. + */ +export default function PortalContentPage() { + const [active, setActive] = useState("CONTACT"); + + return ( + + + + setActive(value as SupportDocSlug)} + keepMounted={false} + > + + {TABS.map((tab) => ( + + {tab.label} + + ))} + + + {TABS.map((tab) => ( + + + + ))} + + + ); +} + +function DocumentTab({ slug }: { slug: SupportDocSlug }) { + const { data, isLoading } = usePortalDoc(slug); + const update = useUpdatePortalDoc(slug); + + const [draft, setDraft] = useState(null); + const [note, setNote] = useState(""); + const [historyOpen, setHistoryOpen] = useState(false); + + // Reseed only when the server's version number moves (load, save, restore). + // Keying off `data` itself would let a background refetch wipe edits that are + // still in progress. + const seededVersion = useRef(null); + useEffect(() => { + if (data && seededVersion.current !== data.version) { + seededVersion.current = data.version; + setDraft(data.payload); + setNote(""); + } + }, [data]); + + if (isLoading || !data || !draft) return ; + + const dirty = JSON.stringify(draft) !== JSON.stringify(data.payload); + + const reset = () => { + setDraft(data.payload); + setNote(""); + }; + + return ( + + {/* Sticky: these tabs are long lists, and a Save button that scrolls out + of reach is the fastest way to lose an edit. */} + + + + + v{data.version} + + + {dirty ? "Unsaved changes" : "Saved"} + + + + + {dirty && ( + setNote(e.currentTarget.value)} + w={240} + /> + )} + + + + + + + + + + setHistoryOpen(false)} + hasUnsavedChanges={dirty} + onRestored={reset} + /> + + ); +} + +function DocumentEditor({ + slug, + value, + onChange, +}: { + slug: SupportDocSlug; + value: SupportDocPayload; + onChange: (next: SupportDocPayload) => void; +}) { + switch (slug) { + case "CONTACT": + return ( + + ); + case "HELP": + return ( + + ); + case "FAQ": + return ; + case "PRIVACY": + case "TERMS": + return ( + + ); + } +} + +/** + * Four fields, so no separate file. These values feed the help page's contact + * cards and resolve the `{{supportEmail}}`-style placeholders used throughout + * the FAQ and legal copy — editing them here updates every page at once. + */ +function ContactEditor({ + value, + onChange, +}: { + value: PortalSupportContact; + onChange: (next: PortalSupportContact) => void; +}) { + return ( + + onChange({ ...value, email: e.currentTarget.value })} + /> + onChange({ ...value, phone: e.currentTarget.value })} + /> + onChange({ ...value, office: e.currentTarget.value })} + /> + onChange({ ...value, hours: e.currentTarget.value })} + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/VersionHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/VersionHistoryModal.tsx new file mode 100644 index 000000000..f9bcbb9d3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/VersionHistoryModal.tsx @@ -0,0 +1,198 @@ +import type { SupportDocSlug } from "@edr/types"; +import { + Alert, + Badge, + Button, + Card, + Group, + Loader, + Modal, + Stack, + Text, +} from "@mantine/core"; +import { AlertTriangle } from "lucide-react"; +import { useState } from "react"; + +import { + usePortalDocVersion, + usePortalDocVersions, + useRestorePortalVersion, +} from "@/hooks/portal-content/usePortalContentAdmin"; + +import { Markdown } from "./Markdown"; +import { summarizeVersion } from "./version-preview"; + +interface VersionHistoryModalProps { + slug: SupportDocSlug; + opened: boolean; + onClose: () => void; + /** True when the tab holds unsaved edits a restore would discard. */ + hasUnsavedChanges: boolean; + onRestored: () => void; +} + +function formatSavedAt(value: string): string { + return new Date(value).toLocaleString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** + * Version history for one document. Restoring re-saves the old payload as a new + * version server-side, so the list only ever grows and a restore is itself + * undoable — there is nothing here that can destroy history. + */ +export function VersionHistoryModal({ + slug, + opened, + onClose, + hasUnsavedChanges, + onRestored, +}: VersionHistoryModalProps) { + const { data: versions, isLoading } = usePortalDocVersions(slug, opened); + const [previewing, setPreviewing] = useState(null); + const [confirming, setConfirming] = useState(null); + const { data: preview } = usePortalDocVersion(slug, previewing); + const restore = useRestorePortalVersion(slug); + + const close = () => { + setPreviewing(null); + setConfirming(null); + onClose(); + }; + + const latest = versions?.[0]?.version; + + return ( + + + {hasUnsavedChanges && ( + } + title="Unsaved changes" + > + This tab has edits that have not been saved. Restoring a version + discards them. + + )} + + {isLoading && } + + {versions?.map((version) => ( + + + + + v{version.version} + {version.version === latest && ( + + Current + + )} + + {formatSavedAt(version.createdAt)} + + + {version.note && ( + + {version.note} + + )} + + + {confirming === version.version ? ( + + Restore v{version.version}? + + + + ) : ( + + + + + )} + + + {previewing === version.version && ( + + {preview ? ( + + {summarizeVersion(slug, preview.payload).map((entry, i) => ( + + + {entry.label} + + {entry.body} + + ))} + + ) : ( + + )} + + )} + + ))} + + {versions?.length === 0 && ( + + No history yet. + + )} + + + ); +} + +export default VersionHistoryModal; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/array-helpers.ts b/apps/edr-freight-web/backoffice/src/pages/portal_content/array-helpers.ts new file mode 100644 index 000000000..3a3ae40b0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/array-helpers.ts @@ -0,0 +1,24 @@ +/** Immutable list edits shared by the three payload editors. */ + +export function replaceAt(items: T[], index: number, next: T): T[] { + return items.map((item, i) => (i === index ? next : item)); +} + +export function removeAt(items: T[], index: number): T[] { + return items.filter((_, i) => i !== index); +} + +/** + * Swaps an item with its neighbour. Out-of-range moves return the list + * unchanged, so the ▲/▼ buttons need no disabled-state bookkeeping of their own. + */ +export function moveAt(items: T[], index: number, delta: number): T[] { + const target = index + delta; + if (target < 0 || target >= items.length) return items; + + const next = [...items]; + [next[index], next[target]] = [next[target], next[index]]; + return next; +} + +export const newId = () => crypto.randomUUID(); diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/markdown-editor.css b/apps/edr-freight-web/backoffice/src/pages/portal_content/markdown-editor.css new file mode 100644 index 000000000..0c5b0eb3f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/markdown-editor.css @@ -0,0 +1,109 @@ +/* + * Tailwind's preflight zeroes margins on `p`, strips `list-style` from `ul`/`ol` + * and flattens heading sizes. MDXEditor's own stylesheet assumes browser + * defaults, so inside this app its content area renders as one undifferentiated + * block — paragraphs run together and bullets lose their markers. + * + * This restores the handful of element styles the editor needs, scoped to its + * content area so nothing leaks back into the rest of the backoffice. It is a + * deliberate alternative to pulling in @tailwindcss/typography for one widget. + */ + +.edr-md-content p { + margin: 0 0 0.75rem; + line-height: 1.6; +} + +.edr-md-content p:last-child { + margin-bottom: 0; +} + +.edr-md-content ul, +.edr-md-content ol { + margin: 0 0 0.75rem; + padding-left: 1.5rem; +} + +.edr-md-content ul { + list-style: disc; +} + +.edr-md-content ol { + list-style: decimal; +} + +.edr-md-content li { + margin: 0.25rem 0; + line-height: 1.6; +} + +/* Nested lists — the editor's indent button produces these. */ +.edr-md-content li > ul, +.edr-md-content li > ol { + margin: 0.25rem 0 0; +} + +.edr-md-content h1, +.edr-md-content h2, +.edr-md-content h3, +.edr-md-content h4 { + font-weight: 700; + line-height: 1.3; + margin: 1rem 0 0.5rem; +} + +.edr-md-content h1 { + font-size: 1.5rem; +} + +.edr-md-content h2 { + font-size: 1.25rem; +} + +.edr-md-content h3 { + font-size: 1.1rem; +} + +.edr-md-content h4 { + font-size: 1rem; +} + +.edr-md-content strong { + font-weight: 600; +} + +.edr-md-content em { + font-style: italic; +} + +.edr-md-content a { + color: var(--mantine-color-blue-6); + text-decoration: underline; +} + +.edr-md-content blockquote { + margin: 0 0 0.75rem; + padding-left: 0.75rem; + border-left: 3px solid var(--mantine-color-gray-3); + color: var(--mantine-color-dimmed); +} + +.edr-md-content hr { + border: 0; + border-top: 1px solid var(--mantine-color-gray-3); + margin: 1rem 0; +} + +.edr-md-content code { + font-family: var(--mantine-font-family-monospace); + font-size: 0.875em; + background: var(--mantine-color-gray-1); + padding: 0.05rem 0.25rem; + border-radius: 3px; +} + +.edr-md-content img { + max-width: 100%; + height: auto; + border-radius: 8px; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/version-preview.ts b/apps/edr-freight-web/backoffice/src/pages/portal_content/version-preview.ts new file mode 100644 index 000000000..465c91aba --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/version-preview.ts @@ -0,0 +1,79 @@ +import type { + PortalFaqContent, + PortalHelpContent, + PortalLegalContent, + PortalSupportContact, + SupportDocPayload, + SupportDocSlug, +} from "@edr/types"; + +export interface PreviewEntry { + label: string; + /** Markdown, rendered read-only. */ + body: string; +} + +/** + * Flattens a stored payload into labelled markdown blocks for the history + * modal. An editor deciding whether to roll back needs to read the wording of + * that version — a raw JSON dump technically shows it, but not in a form + * anyone can compare legal prose in. + */ +export function summarizeVersion( + slug: SupportDocSlug, + payload: SupportDocPayload, +): PreviewEntry[] { + switch (slug) { + case "CONTACT": { + const contact = payload as PortalSupportContact; + return [ + { label: "Email", body: contact.email }, + { label: "Phone", body: contact.phone }, + { label: "Head office", body: contact.office }, + { label: "Support hours", body: contact.hours }, + ]; + } + + case "HELP": { + const help = payload as PortalHelpContent; + return [ + { label: "Title", body: help.title }, + { label: "Subtitle", body: help.subtitle }, + ...help.sections.map((section) => ({ + label: section.heading, + body: section.media.length + ? `${section.body}\n\n_${section.media.length} attachment${section.media.length === 1 ? "" : "s"}: ${section.media.map((m) => m.src).join(", ")}_` + : section.body, + })), + ]; + } + + case "FAQ": { + const faq = payload as PortalFaqContent; + return [ + { label: "Title", body: faq.title }, + ...faq.groups.flatMap((group) => + group.items.map((item) => ({ + label: `${group.title} — ${item.question}`, + body: item.answer, + })), + ), + ...(faq.footer + ? [{ label: faq.footer.heading, body: faq.footer.body }] + : []), + ]; + } + + case "PRIVACY": + case "TERMS": { + const legal = payload as PortalLegalContent; + return [ + { label: "Last updated", body: legal.lastUpdated }, + ...legal.sections.map((section) => ({ + label: section.heading, + body: section.body, + })), + ]; + } + } +} diff --git a/apps/edr-freight-web/backoffice/src/services/portal-content.service.ts b/apps/edr-freight-web/backoffice/src/services/portal-content.service.ts new file mode 100644 index 000000000..0f42b2a5a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/portal-content.service.ts @@ -0,0 +1,98 @@ +import type { + PortalMediaKind, + SupportDocPayload, + SupportDocSlug, + SupportDocumentDetail, + SupportDocVersionDetail, + SupportDocVersionSummary, +} from "@edr/types"; + +import { api as client } from "../auth/http"; + +const ROOT = "/support-content"; +const BASE = `${ROOT}/documents`; + +/** + * Customer-facing help/FAQ/legal copy for the freight portal. The client's + * response interceptor already unwraps the `{ success, data }` envelope, so + * every method is a one-liner. + */ +export const portalContentService = { + async getBySlug(slug: SupportDocSlug): Promise { + const { data } = await client.get(`${BASE}/${slug}`); + return data; + }, + + /** + * Replaces the document's whole payload. Whole-payload rather than per-field + * on purpose: one Save becomes exactly one version, which is what keeps the + * history list readable. + */ + async update( + slug: SupportDocSlug, + payload: SupportDocPayload, + note?: string, + ): Promise { + const { data } = await client.patch( + `${BASE}/${slug}`, + { payload, note }, + ); + return data; + }, + + async listVersions(slug: SupportDocSlug): Promise { + const { data } = await client.get( + `${BASE}/${slug}/versions`, + ); + return data; + }, + + async getVersion( + slug: SupportDocSlug, + version: number, + ): Promise { + const { data } = await client.get( + `${BASE}/${slug}/versions/${version}`, + ); + return data; + }, + + /** + * Uploads an image or video and returns its object *key*. The key is what + * gets saved in the document; `url` is only for showing the editor a preview + * right now, and expires. + */ + async uploadMedia( + file: File, + ): Promise<{ key: string; kind: PortalMediaKind; url: string }> { + const form = new FormData(); + form.append("file", file); + + const { data } = await client.post<{ + key: string; + kind: PortalMediaKind; + url: string; + }>(`${ROOT}/media`, form); + return data; + }, + + /** Resolves one stored key to a temporary URL, for editor previews. */ + async mediaUrl(key: string): Promise { + const { data } = await client.get<{ url: string }>(`${ROOT}/media-url`, { + params: { key }, + }); + return data.url; + }, + + /** Re-saves an old payload as a new version — never destructive. */ + async restore( + slug: SupportDocSlug, + version: number, + ): Promise { + const { data } = await client.post( + `${BASE}/${slug}/versions/${version}/restore`, + {}, + ); + return data; + }, +}; diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 4bb96ee4f..81800f447 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -33,6 +33,7 @@ "react-dom": "19.2.6", "react-hook-form": "^7.76.0", "react-hot-toast": "^2.6.0", + "react-markdown": "^9.1.0", "react-phone-number-input": "^3.4.17", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 88b310e84..153ed5e8b 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -218,6 +218,11 @@ export const URL_CONSTANTS = { PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`, }, + // Public — no session required; the sign-up screen links to these pages. + PORTAL_CONTENT: { + PUBLIC: "/api/support-content", + }, + LAST_MILE_REQUESTS: { BY_ID: (id: string) => `/last-mile-requests/${id}`, SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`, diff --git a/apps/edr-freight-web/portal/src/hooks/usePortalContent.ts b/apps/edr-freight-web/portal/src/hooks/usePortalContent.ts new file mode 100644 index 000000000..39d5d7a09 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/usePortalContent.ts @@ -0,0 +1,39 @@ +import type { PortalContentBundle } from "@edr/types"; +import { useQuery } from "@tanstack/react-query"; + +import { URL_CONSTANTS } from "@/constants/URLS"; +import { + FALLBACK_PORTAL_CONTENT, + withSupportVars, +} from "@/pages/support/portal-content"; +import type { ApiResponse } from "@/types/apiResponse"; +import { client } from "@/utils/api"; +import { unwrap } from "@/utils/endpoint"; + +/** + * The whole public help/FAQ/legal bundle in one request, shared by the four + * public pages (react-query dedupes it across the routes). + * + * The endpoint is unauthenticated and the shared axios client attaches a token + * only when the cookie exists, so this works for anonymous visitors as-is. + * + * `placeholderData` means `data` is never undefined: the pages render the + * shipped copy immediately and swap in the live copy when the fetch resolves. + * That is deliberate — it is what lets the four public pages skip loading and + * error states entirely. If the fallback is ever removed, those states are + * owed back. + */ +export function usePortalContent() { + return useQuery({ + queryKey: ["portal-content"], + queryFn: async (): Promise => { + const response = await client.get>( + URL_CONSTANTS.PORTAL_CONTENT.PUBLIC, + ); + return unwrap(response.data); + }, + placeholderData: FALLBACK_PORTAL_CONTENT, + select: withSupportVars, + staleTime: 5 * 60_000, + }); +} diff --git a/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx b/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx index 080189b3b..4808f5d52 100644 --- a/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx @@ -1,8 +1,9 @@ +import type { PortalDocSection } from "@edr/types"; import { ArrowLeft, Train } from "lucide-react"; import type { ReactNode } from "react"; import { Link } from "react-router-dom"; -import type { Section } from "./content"; +import { Markdown } from "./Markdown"; /** Public pages reachable from every doc page's header and footer. */ const DOC_LINKS = [ @@ -87,32 +88,21 @@ export function DocShell({ ); } -/** Renders a legal document's numbered sections. */ -export function DocSections({ sections }: { sections: Section[] }) { +/** + * Renders a legal document's numbered sections. Bodies are markdown, so the + * paragraph and bullet arrays this used to walk are one string now — keyed by + * `id` rather than by heading, which admin-authored text can duplicate. + */ +export function DocSections({ sections }: { sections: PortalDocSection[] }) { return (
{sections.map((section) => ( -
+

{section.heading}

- {section.body?.map((paragraph) => ( -

- {paragraph} -

- ))} - - {section.bullets && ( -
    - {section.bullets.map((bullet) => ( -
  • {bullet}
  • - ))} -
- )} + {section.body}
))}
diff --git a/apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx b/apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx index 9704c8cf8..7aa702a21 100644 --- a/apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx @@ -1,19 +1,21 @@ import { ChevronDown } from "lucide-react"; import { Link } from "react-router-dom"; +import { usePortalContent } from "@/hooks/usePortalContent"; + import { DocShell } from "./DocShell"; -import { FAQ_GROUPS, SUPPORT_CONTACT } from "./content"; +import { Markdown } from "./Markdown"; export default function FaqPage() { + // Never undefined — see TermsPage. + const { data } = usePortalContent(); + const faq = data!.faq; + return ( - +
- {FAQ_GROUPS.map((group) => ( -
+ {faq.groups.map((group) => ( +

{group.title}

@@ -21,7 +23,7 @@ export default function FaqPage() { // Native disclosure: keyboard- and screen-reader-accessible // without any state of our own.
@@ -29,9 +31,7 @@ export default function FaqPage() { -

- {item.answer} -

+ {item.answer}
))}
@@ -39,21 +39,20 @@ export default function FaqPage() { ))}
-
-

- Still need a hand? -

-

- Our team is on {SUPPORT_CONTACT.email} and {SUPPORT_CONTACT.phone}, or - you can start a chat from the support button inside the portal. -

- - Go to Help & Support - -
+ {faq.footer && ( +
+

+ {faq.footer.heading} +

+ {faq.footer.body} + + {faq.footer.ctaLabel} + +
+ )}
); } diff --git a/apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx b/apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx index b310dfa2c..37c748d79 100644 --- a/apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx @@ -1,208 +1,74 @@ -import { - Clock3, - FileText, - HelpCircle, - Mail, - MapPin, - MessageSquare, - Package, - Phone, - Receipt, - ShieldCheck, -} from "lucide-react"; -import { Link } from "react-router-dom"; +import type { PortalMedia } from "@edr/types"; + +import { usePortalContent } from "@/hooks/usePortalContent"; import { DocShell } from "./DocShell"; -import { SUPPORT_CONTACT } from "./content"; +import { Markdown } from "./Markdown"; +import { safeMediaSrc } from "./portal-content"; -const channels = [ - { - icon: Mail, - title: "Email", - value: SUPPORT_CONTACT.email, - href: `mailto:${SUPPORT_CONTACT.email}`, - note: "Best for document issues and anything needing an attachment.", - }, - { - icon: Phone, - title: "Phone", - value: SUPPORT_CONTACT.phone, - href: `tel:${SUPPORT_CONTACT.phone.replace(/\s/g, "")}`, - note: "Best for urgent problems with cargo already in transit.", - }, - { - icon: MapPin, - title: "Head office", - value: SUPPORT_CONTACT.office, - note: "Walk-in support during working hours.", - }, - { - icon: Clock3, - title: "Support hours", - value: SUPPORT_CONTACT.hours, - note: "Outside these hours, email us and we reply the next working day.", - }, -]; +/** + * An attached image or video. The API has already swapped stored MinIO keys for + * freshly signed URLs, so `src` is ready to render — `safeMediaSrc` is a last + * check that an admin-entered value is a path or an https URL. + */ +function Media({ item }: { item: PortalMedia }) { + const src = safeMediaSrc(item.src); + if (!src) return null; -const topics = [ - { - icon: ShieldCheck, - title: "Account & onboarding", - body: "Registering your company, uploading your trade licence and TIN, and getting an operational profile approved.", - }, - { - icon: FileText, - title: "Contracts", - body: "Requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.", - }, - { - icon: Package, - title: "Bookings & tracking", - body: "Raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.", - }, - { - icon: Receipt, - title: "Invoices & payments", - body: "Finding invoices, paying through the bank channels and confirming a payment that has not yet settled.", - }, -]; - -export default function HelpPage() { return ( - -
-

- Portal walkthrough -

-

- A guided tour of the portal — registering your company, raising a - booking against a contract, and settling an invoice. -

- - {/* preload="metadata" so the 28 MB file is not pulled on every visit; - the browser fetches it only once playback starts. */} +
+ {item.kind === "video" ? ( + // preload="metadata" so a large file is not pulled on every visit; the + // browser fetches it only once playback starts. -
+ ) : ( + {item.caption + )} - {/* Live chat is the fastest route, so lead with it. */} -
-
-
- -
+ {item.caption && ( +
+ {item.caption} +
+ )} + + ); +} -
+export default function HelpPage() { + // Never undefined — see TermsPage. + const { data } = usePortalContent(); + const help = data!.help; + + return ( + +
+ {help.sections.map((section) => ( +

- Chat with our team + {section.heading}

-

- Signed-in customers can open a support conversation from the - headset button at the bottom right of every portal page. You can - send screenshots and documents in the chat, and replies appear - there and as a notification. -

- - Open the portal - -
-
+ + {section.body} + + {section.media.map((item) => ( + + ))} + + ))}
- -
-

Contact us

- -
- {channels.map((channel) => ( -
-
- -
- -
-

{channel.title}

- {channel.href ? ( - - {channel.value} - - ) : ( -

{channel.value}

- )} -

- {channel.note} -

-
-
- ))} -
-
- -
-

Common topics

- -
- {topics.map((topic) => ( - -
- -
-

{topic.title}

-

- {topic.body} -

- - ))} -
-
- -
-
-
- -
- -
-

- What to include when you contact us -

-
    -
  • Your company name and the email you sign in with.
  • -
  • - The reference of the contract, booking or invoice involved. -
  • -
  • What you expected to happen and what happened instead.
  • -
  • A screenshot of any error message the portal showed.
  • -
-
-
-
); } diff --git a/apps/edr-freight-web/portal/src/pages/support/Markdown.tsx b/apps/edr-freight-web/portal/src/pages/support/Markdown.tsx new file mode 100644 index 000000000..11924cf23 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/support/Markdown.tsx @@ -0,0 +1,72 @@ +import ReactMarkdown from "react-markdown"; + +/** + * Renders admin-authored markdown from the support-content API. + * + * Deliberately plain `react-markdown`: it builds React elements directly, so + * unlike a markdown→HTML-string library it needs no `dangerouslySetInnerHTML` + * and no sanitizer, and the portal keeps its zero HTML-injection surface. + * + * Two things must stay absent for that to hold: + * - `rehype-raw`, which would start rendering raw HTML embedded in the copy; + * - a custom `urlTransform`, which would override the built-in stripping of + * `javascript:` and `data:` hrefs. + * + * `remark-gfm` is also left out: tables and strikethrough are not used in the + * legal or FAQ copy, and CommonMark already covers lists, emphasis and links. + * + * The component map reproduces the Tailwind classes the pages used when this + * copy was hardcoded, so switching to markdown changed nothing visually. + */ +export function Markdown({ children }: { children: string }) { + return ( + ( +

{content}

+ ), + ul: ({ children: content }) => ( +
    + {content} +
+ ), + ol: ({ children: content }) => ( +
    + {content} +
+ ), + li: ({ children: content }) =>
  • {content}
  • , + a: ({ href, children: content }) => ( + + {content} + + ), + strong: ({ children: content }) => ( + {content} + ), + em: ({ children: content }) => {content}, + h3: ({ children: content }) => ( +

    {content}

    + ), + // Images embedded by the editor. The API has already resolved these to + // signed URLs; react-markdown's default urlTransform still guards the + // scheme. + img: ({ src, alt }) => ( + {alt + ), + }} + > + {children} +
    + ); +} + +export default Markdown; diff --git a/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx b/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx index 76c2441ae..07e79d207 100644 --- a/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx @@ -1,15 +1,20 @@ +import { usePortalContent } from "@/hooks/usePortalContent"; + import { DocSections, DocShell } from "./DocShell"; -import { LEGAL_LAST_UPDATED, PRIVACY_SECTIONS } from "./content"; export default function PrivacyPolicyPage() { + // Never undefined — see TermsPage. + const { data } = usePortalContent(); + const privacy = data!.privacy; + return ( - + ); } diff --git a/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx b/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx index b095acae0..6302e838d 100644 --- a/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx @@ -1,15 +1,21 @@ +import { usePortalContent } from "@/hooks/usePortalContent"; + import { DocSections, DocShell } from "./DocShell"; -import { LEGAL_LAST_UPDATED, TERMS_SECTIONS } from "./content"; export default function TermsPage() { + // Never undefined — the hook seeds it with the shipped copy, so this public + // page renders instantly and survives the API being unreachable. + const { data } = usePortalContent(); + const terms = data!.terms; + return ( - + ); } diff --git a/apps/edr-freight-web/portal/src/pages/support/content.ts b/apps/edr-freight-web/portal/src/pages/support/content.ts deleted file mode 100644 index d7b8e2d2c..000000000 --- a/apps/edr-freight-web/portal/src/pages/support/content.ts +++ /dev/null @@ -1,358 +0,0 @@ -/** - * Copy for the public help/FAQ/legal pages. Kept as data so the pages stay - * thin — the shell in `DocShell.tsx` renders any `Section[]` the same way. - * - * The privacy and terms text is the platform's working draft; legal counsel - * signs off on the final wording, and `LEGAL_LAST_UPDATED` is bumped with it. - */ - -export const SUPPORT_CONTACT = { - email: "support@edrfreight.com", - phone: "+251 11 000 0000", - office: "Addis Ababa, Ethiopia", - hours: "Monday – Saturday, 8:30 AM – 5:30 PM (EAT)", -}; - -export const LEGAL_LAST_UPDATED = "6 August 2026"; - -export interface Section { - heading: string; - /** Paragraphs, rendered in order. */ - body?: string[]; - /** Optional bullet list, rendered after the paragraphs. */ - bullets?: string[]; -} - -export interface FaqItem { - question: string; - answer: string; -} - -export interface FaqGroup { - title: string; - items: FaqItem[]; -} - -export const FAQ_GROUPS: FaqGroup[] = [ - { - title: "Getting started", - items: [ - { - question: "How do I open an account on EDR Freight?", - answer: - "Sign up with your work email and verify the one-time code we send you. After you set a password, the onboarding wizard collects your company details, trade licence, TIN certificate and the operational services you need (importer, exporter, freight forwarder or transporter). Submit the wizard and our team reviews the application.", - }, - { - question: "How long does account approval take?", - answer: - "Most complete applications are reviewed within two working days. You will see the status on your dashboard, and we email you when a profile is approved or when a document needs to be re-uploaded.", - }, - { - question: "My profile was rejected. What now?", - answer: - "The rejection notice states the reason. Open Settings, correct the details or replace the document that was flagged, and re-apply — you do not need to create a new account.", - }, - { - question: "Can one company hold several operational services?", - answer: - "Yes. A company can hold importer, exporter, freight forwarder and transporter profiles at the same time. Each is approved separately, and the header lets you switch between the ones you hold.", - }, - ], - }, - { - title: "Contracts and bookings", - items: [ - { - question: "What is the difference between a contract and a booking?", - answer: - "A contract is the commercial agreement covering a cargo movement — route, commodity, volume and rates. A booking is a single shipment executed under that contract. You create the contract once, then raise bookings against it for each consignment.", - }, - { - question: "How do I create a booking?", - answer: - "Open the contract from the Contracts list and choose New Booking. Provide the consignment details, containers or tonnage, and the last-mile requirement if you need one. Bookings can also be started from the Bookings page, which routes you through contract selection first.", - }, - { - question: "Why do I have to sign a contract before shipping?", - answer: - "The contract document is the binding agreement for the movement. You must scroll to the end, accept the terms, and sign it with your saved signature and stamp before EDR schedules any wagon against it.", - }, - { - question: "Where do I set up my signature and stamp?", - answer: - "Under Signature & Stamp in the portal. It is saved once and reused for every contract you sign, so you do not have to upload it per document.", - }, - { - question: "Can I change a booking after submitting it?", - answer: - "You can edit a booking while it is still pending review. Once EDR has confirmed it and allocated capacity, changes go through our operations team — contact support with the booking reference.", - }, - { - question: "How do I track a consignment?", - answer: - "Open the booking and use the tracking panel, which shows the current milestone, the wagon or container assigned, and the timestamps recorded at each corridor point.", - }, - ], - }, - { - title: "Invoices and payments", - items: [ - { - question: "Where do I find my invoices?", - answer: - "The Invoices page lists every invoice raised against your company, with its status, due date and outstanding balance. Open any invoice to see its line items and download a PDF copy.", - }, - { - question: "Which payment methods are supported?", - answer: - "Payments are made through the integrated bank channels shown at checkout. After you complete the payment on the bank's page you are returned to the portal, and the invoice status updates once the bank confirms the transaction.", - }, - { - question: "My payment was deducted but the invoice still shows unpaid.", - answer: - "Bank confirmations can lag by a few minutes. Use the Check Payment Status page linked from your receipt; if it still has not settled after an hour, email support with the invoice number and the bank reference and we will reconcile it.", - }, - { - question: "Why is my invoice amount rounded?", - answer: - "Some bank channels only accept whole-birr amounts, so invoices routed through them are rounded up to the nearest birr. The rounding is shown on the invoice detail page.", - }, - ], - }, - { - title: "Account and security", - items: [ - { - question: "How do I reset my password?", - answer: - "Use Forgot Password on the sign-in page. We email you a reset link that is valid for a limited time. If a member of our staff issued the link, it works the same way even if you are already signed in.", - }, - { - question: "Can I add colleagues to my company account?", - answer: - "Yes. Company administrators can invite additional users from Settings. Each user signs in with their own credentials, and actions are recorded against the individual who performed them.", - }, - { - question: "How do I update company details after approval?", - answer: - "Edit them in Settings. Changes to regulated fields — trade licence, TIN, legal name — are re-verified by our team before they take effect.", - }, - ], - }, -]; - -export const PRIVACY_SECTIONS: Section[] = [ - { - heading: "1. Introduction", - body: [ - "The Ethio-Djibouti Standard Gauge Rail Share Company (\"EDR\", \"we\", \"us\") operates the EDR Freight platform, which lets customers register their business, agree freight contracts, raise bookings, track consignments and settle invoices online.", - "This policy explains what personal and business information we collect through the platform, why we collect it, how long we keep it and what rights you have over it. It applies to the EDR Freight customer portal and the services reached through it.", - ], - }, - { - heading: "2. Information we collect", - body: [ - "We collect information you give us, information generated by your use of the platform, and information we receive from the regulators and financial institutions we work with.", - ], - bullets: [ - "Account details — name, work email address, phone number and the credentials used to sign in.", - "Company and compliance records — legal name, trade licence, TIN certificate, VAT registration, ownership and manager details, and the operational services you apply for.", - "Identity verification data — where you verify through a national identity service, the verification result and the attributes that service returns to us.", - "Operational data — contracts, bookings, consignment and cargo details, container and wagon assignments, tracking events and delivery confirmations.", - "Financial data — invoices, payment references, transaction status and settlement confirmations received from banks. We do not store your card numbers or online banking credentials.", - "Support data — the messages and files you send us through the in-app support chat or by email.", - "Technical data — IP address, device and browser information, and event logs generated when you use the platform.", - ], - }, - { - heading: "3. How we use your information", - bullets: [ - "To create and administer your account and verify that your company is entitled to the services it applies for.", - "To perform the freight contracts and bookings you place, including allocating capacity and coordinating rail and last-mile movements.", - "To issue invoices, process payments and keep the accounting records the law requires us to keep.", - "To provide customer support and respond to the questions and complaints you raise.", - "To keep the platform secure, detect misuse and investigate incidents.", - "To meet our legal, tax, customs and regulatory obligations in Ethiopia and Djibouti.", - "To improve the platform — measuring which features are used and where users encounter errors, using aggregated and pseudonymised data wherever that is sufficient.", - ], - }, - { - heading: "4. Legal basis for processing", - body: [ - "We process your information because it is necessary to perform the contract between you and EDR, because we have a legal obligation to do so (customs, tax and transport regulation), or because we have a legitimate interest in operating and securing the platform. Where we rely on your consent — for example, optional marketing messages — you can withdraw it at any time.", - ], - }, - { - heading: "5. Sharing your information", - body: [ - "We do not sell your information. We share it only where it is necessary to deliver the service or where the law requires it.", - ], - bullets: [ - "Government and regulatory bodies — customs, revenue and transport authorities in Ethiopia and Djibouti, to the extent required for the movement of your cargo.", - "Ports, terminals and last-mile transporters involved in executing your bookings.", - "Banks and payment providers, to initiate and reconcile the payments you make.", - "Technology suppliers who host and maintain the platform on our behalf, under contracts that restrict them to processing data on our instructions.", - "Courts, law enforcement and other authorities where we are legally compelled to disclose.", - ], - }, - { - heading: "6. International transfers", - body: [ - "Cross-border freight inherently involves parties in more than one country, so consignment and clearance information is shared with counterparties and authorities in Djibouti as well as Ethiopia. Where we transfer information outside Ethiopia, we do so only as far as the movement requires or the law permits, and we require recipients to protect it to a comparable standard.", - ], - }, - { - heading: "7. Data retention", - body: [ - "We keep account and company records for as long as your account is active. Contract, booking, customs and financial records are kept for the period required by Ethiopian commercial, tax and customs law after the relevant transaction, because we are obliged to be able to produce them. Support conversations and technical logs are kept for a shorter period, sufficient to resolve disputes and investigate security incidents.", - ], - }, - { - heading: "8. Security", - body: [ - "Access to the platform requires authentication, and staff access to customer records is limited to what each role needs. Data is transmitted over encrypted connections and stored on systems protected by access controls and logging. No system is perfectly secure, so please keep your credentials confidential and tell us immediately if you believe your account has been compromised.", - ], - }, - { - heading: "9. Your rights", - body: [ - "Subject to Ethiopian law, you may ask us to give you a copy of the personal information we hold about you, correct it if it is inaccurate, restrict or object to certain processing, or delete it where we are not required to keep it. Requests are handled through the contact details below; we may need to verify your identity before acting.", - ], - }, - { - heading: "10. Cookies and similar technologies", - body: [ - "The platform uses cookies and browser storage to keep you signed in, remember your interface preferences and measure how the product is used so we can fix problems. Essential cookies cannot be turned off without breaking sign-in. You can clear or block the rest through your browser settings.", - ], - }, - { - heading: "11. Children", - body: [ - "The platform is a business service and is not directed at children. We do not knowingly collect information from anyone under 18.", - ], - }, - { - heading: "12. Changes to this policy", - body: [ - "We may update this policy as the platform and the law change. Material changes are announced in the portal before they take effect, and the date at the top of this page always reflects the current version.", - ], - }, - { - heading: "13. Contact us", - body: [ - `Questions about this policy or about how we handle your information can be sent to ${SUPPORT_CONTACT.email}, called in on ${SUPPORT_CONTACT.phone}, or addressed to our head office in ${SUPPORT_CONTACT.office}.`, - ], - }, -]; - -export const TERMS_SECTIONS: Section[] = [ - { - heading: "1. These terms", - body: [ - "These terms govern your use of the EDR Freight platform operated by the Ethio-Djibouti Standard Gauge Rail Share Company (\"EDR\"). By creating an account or using the platform, the company you represent agrees to them.", - "The platform is the channel through which you register, request and manage freight services. The commercial terms of each movement — routes, rates, volumes and payment terms — are set out in the freight contract you sign in the platform. Where a signed contract and these terms conflict, the signed contract governs that movement.", - ], - }, - { - heading: "2. Eligibility and accounts", - bullets: [ - "The platform is for registered businesses. You confirm that you are authorised to act for the company you register and to bind it to these terms.", - "The information and documents you submit — trade licence, TIN, VAT registration, ownership details — must be accurate, current and genuine.", - "Accounts and operational profiles are activated only after EDR has reviewed and approved them, and approval may be refused or withdrawn.", - "You are responsible for keeping credentials confidential and for everything done under your account. Tell us at once if you suspect unauthorised use.", - ], - }, - { - heading: "3. Contracts and bookings", - bullets: [ - "A freight contract takes effect when it is signed in the platform by you and countersigned by EDR.", - "A booking is a request for a specific movement under a contract. It becomes binding when EDR confirms it and allocates capacity — submission alone does not reserve a wagon or container.", - "You are responsible for the accuracy of consignment data: commodity description, weight, dimensions, container numbers, hazardous classification and consignee details.", - "Capacity is finite. EDR may decline, defer or reschedule a booking where capacity, safety, operating conditions or regulatory direction require it.", - ], - }, - { - heading: "4. Cargo, documents and compliance", - bullets: [ - "You must obtain and provide every permit, customs declaration and clearance document the movement requires, and you warrant that the cargo may lawfully be carried.", - "Prohibited and restricted goods may not be tendered without EDR's prior written agreement and any licence the law requires.", - "Cargo must be packed, secured and, where applicable, labelled to the standard the mode of carriage requires. EDR may inspect, refuse or offload cargo that is misdeclared or unsafe.", - "You are liable for fines, demurrage, storage charges and losses arising from misdeclared cargo, missing documents or delays attributable to you.", - ], - }, - { - heading: "5. Rates, invoicing and payment", - bullets: [ - "Charges are calculated from the rates in your contract, the tariffs published in the platform, and any accessorial services actually rendered.", - "Invoices are issued in the platform and are payable by the due date shown on them, through the payment channels the platform offers.", - "Payment is confirmed when the funds are confirmed by the bank, not when payment is initiated.", - "Overdue amounts may attract interest and may result in suspension of new bookings or of the account until the balance is cleared.", - "Taxes and statutory duties are your responsibility unless the contract expressly says otherwise.", - ], - }, - { - heading: "6. Delivery, delay and liability", - body: [ - "Transit times shown in the platform are estimates based on planned schedules. They are not guarantees, and EDR is not liable for indirect or consequential loss, loss of profit or loss of market arising from delay.", - "EDR's liability for loss of or damage to cargo is limited to the extent set out in the applicable freight contract and in the transport law governing the carriage. Claims must be notified in writing within the period the contract specifies; late claims may be rejected.", - "Neither party is liable for failure to perform caused by events beyond its reasonable control, including natural disasters, industrial action, civil unrest, infrastructure failure, or acts of government and regulatory authorities.", - ], - }, - { - heading: "7. Acceptable use of the platform", - bullets: [ - "Use the platform only for its intended purpose and in accordance with applicable law.", - "Do not attempt to gain unauthorised access, probe or disrupt the service, or interfere with other customers' data.", - "Do not scrape, resell or redistribute platform content, rates or data without written permission.", - "Do not upload malware or content that infringes the rights of others.", - ], - }, - { - heading: "8. Electronic signatures and records", - body: [ - "You agree that contracts signed in the platform using your stored signature and stamp are validly executed, that the records the platform keeps of those signatures are admissible evidence of them, and that they carry the same effect as signatures on paper.", - ], - }, - { - heading: "9. Availability and changes to the service", - body: [ - "We aim to keep the platform available, but it may be interrupted for maintenance, upgrades or reasons outside our control. We may add, change or withdraw features. Where a change materially affects how you use the platform, we will give reasonable notice in the portal.", - ], - }, - { - heading: "10. Suspension and termination", - body: [ - "We may suspend or terminate access where these terms are breached, where documents prove to be false, where amounts remain unpaid, or where the law or a regulator requires it. You may stop using the platform at any time. Termination does not affect obligations already incurred — cargo in transit, invoices outstanding, or records we are required to retain.", - ], - }, - { - heading: "11. Intellectual property", - body: [ - "The platform, its software, design and content belong to EDR or its licensors. You are granted a non-exclusive, non-transferable right to use it for your own freight operations. Your commercial and consignment data remains yours; you grant us the right to process it as needed to deliver the service and as described in the Privacy Policy.", - ], - }, - { - heading: "12. Confidentiality and data protection", - body: [ - "Each party will keep the other's non-public commercial information confidential and use it only for the purposes of the services. Our handling of personal information is described in the Privacy Policy, which forms part of these terms.", - ], - }, - { - heading: "13. Governing law and disputes", - body: [ - "These terms are governed by the laws of the Federal Democratic Republic of Ethiopia. The parties will first attempt to resolve any dispute amicably; failing that, the dispute is subject to the jurisdiction of the competent courts of Ethiopia, without prejudice to any arbitration clause agreed in a specific freight contract.", - ], - }, - { - heading: "14. Changes to these terms", - body: [ - "We may update these terms as the service and the law change. Updates are published here and announced in the portal. Continuing to use the platform after an update takes effect means you accept the revised terms.", - ], - }, - { - heading: "15. Contact", - body: [ - `For questions about these terms, write to ${SUPPORT_CONTACT.email} or call ${SUPPORT_CONTACT.phone}.`, - ], - }, -]; diff --git a/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts b/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts new file mode 100644 index 000000000..2523ca3ae --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { + applyPortalVars, + FALLBACK_PORTAL_CONTENT, + safeMediaSrc, + withSupportVars, +} from "./portal-content"; + +const contact = { + email: "support@edrfreight.com", + phone: "+251 11 000 0000", + office: "Addis Ababa, Ethiopia", + hours: "Monday – Saturday, 8:30 AM – 5:30 PM (EAT)", +}; + +describe("applyPortalVars", () => { + it("substitutes every known token", () => { + expect( + applyPortalVars( + "Mail {{supportEmail}}, call {{supportPhone}}, visit {{supportOffice}}, open {{supportHours}}.", + contact, + ), + ).toBe( + "Mail support@edrfreight.com, call +251 11 000 0000, visit Addis Ababa, Ethiopia, open Monday – Saturday, 8:30 AM – 5:30 PM (EAT).", + ); + }); + + it("strips spacing for the tel: variant", () => { + expect(applyPortalVars("tel:{{supportPhoneTel}}", contact)).toBe( + "tel:+251110000000", + ); + }); + + it("leaves an unknown token verbatim so the typo is visible", () => { + expect(applyPortalVars("Mail {{supportEmial}}.", contact)).toBe( + "Mail {{supportEmial}}.", + ); + }); + + it("does not let $& in a contact value corrupt the output", () => { + // Guards the replace-callback choice: with a replacement *string*, `$&` + // would expand to the matched token and the address would come out wrong. + expect( + applyPortalVars("Write to {{supportOffice}}.", { + ...contact, + office: "Bole $& Road", + }), + ).toBe("Write to Bole $& Road."); + }); +}); + +describe("withSupportVars", () => { + it("resolves placeholders buried in the shipped legal copy", () => { + const resolved = withSupportVars(FALLBACK_PORTAL_CONTENT); + const contactSection = resolved.privacy.sections.at(-1)!; + + expect(contactSection.body).toContain(contact.email); + expect(contactSection.body).not.toContain("{{"); + }); + + it("leaves the contact block itself alone — it is the substitution source", () => { + expect(withSupportVars(FALLBACK_PORTAL_CONTENT).contact).toEqual( + FALLBACK_PORTAL_CONTENT.contact, + ); + }); +}); + +describe("safeMediaSrc", () => { + it("keeps same-origin paths and https sources", () => { + expect(safeMediaSrc("/assets/guide.webm")).toBe("/assets/guide.webm"); + expect(safeMediaSrc("https://minio.internal/support-content/a.png?sig=x")).toBe( + "https://minio.internal/support-content/a.png?sig=x", + ); + }); + + it("drops javascript: and protocol-relative sources", () => { + expect(safeMediaSrc("javascript:alert(1)")).toBeNull(); + expect(safeMediaSrc("//evil.example.com/g.webm")).toBeNull(); + // A bare object key means the API failed to sign it — render nothing + // rather than a broken relative URL. + expect(safeMediaSrc("support-content/a.png")).toBeNull(); + }); +}); diff --git a/apps/edr-freight-web/portal/src/pages/support/portal-content.ts b/apps/edr-freight-web/portal/src/pages/support/portal-content.ts new file mode 100644 index 000000000..06bfdbce9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/support/portal-content.ts @@ -0,0 +1,103 @@ +import { + SUPPORT_CONTENT_DEFAULTS, + type PortalContentBundle, + type PortalContentVar, + type PortalSupportContact, +} from "@edr/types"; + +/** + * Copy for the public help/FAQ/legal pages now lives in the database and is + * edited from the backoffice. This module holds what is left in the app: the + * shipped copy as a fallback, and the two pure helpers the pages need. + * + * The fallback matters because these four routes are public and linked from + * the sign-up screen — they are often the first thing an anonymous visitor + * sees. Rendering the shipped text while the request is in flight (or if the + * API is down) beats showing them a spinner or an error card, and it is why + * none of the four pages carry loading or error branches. + */ +export const FALLBACK_PORTAL_CONTENT: PortalContentBundle = { + contact: SUPPORT_CONTENT_DEFAULTS.CONTACT, + help: SUPPORT_CONTENT_DEFAULTS.HELP, + faq: SUPPORT_CONTENT_DEFAULTS.FAQ, + privacy: SUPPORT_CONTENT_DEFAULTS.PRIVACY, + terms: SUPPORT_CONTENT_DEFAULTS.TERMS, +}; + +const VAR_PATTERN = + /\{\{(supportEmail|supportPhone|supportPhoneTel|supportOffice|supportHours)\}\}/g; + +function resolveVar(name: PortalContentVar, contact: PortalSupportContact) { + switch (name) { + case "supportEmail": + return contact.email; + case "supportPhone": + return contact.phone; + case "supportPhoneTel": + // tel: hrefs must not carry the display spacing. + return contact.phone.replace(/\s/g, ""); + case "supportOffice": + return contact.office; + case "supportHours": + return contact.hours; + } +} + +/** + * Substitutes `{{supportEmail}}`-style placeholders against the editable + * contact block. The support address used to be interpolated into the privacy + * and terms prose at build time, which meant editing it would have left those + * paragraphs quoting a stale one. + * + * Uses a replacement *callback* on purpose: with a replacement string, a `$&` + * or `$1` inside an admin-typed office address would be treated as a + * backreference and corrupt the output. + * + * Unknown tokens are left verbatim — the pattern only matches the four known + * names — so a typo shows up as `{{supportEmial}}` rather than a blank. + */ +export function applyPortalVars( + text: string, + contact: PortalSupportContact, +): string { + return text.replace(VAR_PATTERN, (_match, name: PortalContentVar) => + resolveVar(name, contact), + ); +} + +/** + * Applies {@link applyPortalVars} to every string in the bundle except the + * contact block itself, which is the substitution source. Walking the whole + * object means a placeholder works in any field, including ones added later. + */ +export function withSupportVars( + bundle: PortalContentBundle, +): PortalContentBundle { + const { contact, ...rest } = bundle; + + const walk = (value: unknown): unknown => { + if (typeof value === "string") return applyPortalVars(value, contact); + if (Array.isArray(value)) return value.map(walk); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, inner]) => [key, walk(inner)]), + ); + } + return value; + }; + + return { contact, ...(walk(rest) as Omit) }; +} + +/** + * Accepts only a same-origin path or an https URL for an attached image or + * video, returning null for anything else so the caller renders nothing. + * + * `(?!\/)` rejects protocol-relative `//host/...`, which would otherwise pass + * as a path. An ``/`` src is not a navigation, so a `javascript:` + * URL would not execute anyway — but the guard is cheaper than re-deriving + * that every time someone reads this file. + */ +export function safeMediaSrc(src: string): string | null { + return /^(https:\/\/|\/(?!\/))/.test(src) ? src : null; +} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 0eab2cfbb..3041f02d5 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -11,6 +11,8 @@ export * from "./ethiopian-regions.catalog"; export * from "./notifications"; export * from "./booking-window-ws"; export * from "./support-chat"; +export * from "./portal-content"; +export * from "./portal-content.defaults"; export enum TradeDirection { IMPORT = "IMPORT", diff --git a/packages/types/src/freight/portal-content.defaults.ts b/packages/types/src/freight/portal-content.defaults.ts new file mode 100644 index 000000000..30ae3cf79 --- /dev/null +++ b/packages/types/src/freight/portal-content.defaults.ts @@ -0,0 +1,375 @@ +import type { SupportDocPayloadMap } from "./portal-content"; + +/** + * The copy the portal shipped with, transcribed from what used to be + * `edr-freight-web/portal/src/pages/support/content.ts` and the inline blocks + * of `HelpPage.tsx`. + * + * Two mechanical changes from the original: + * + * 1. `Section { body: string[]; bullets: string[] }` collapses to one markdown + * string — paragraphs separated by a blank line, bullets as `- ` lines. + * 2. The support email/phone/office, previously string-interpolated into the + * privacy and terms prose at build time, are now `{{supportEmail}}`-style + * placeholders resolved against the CONTACT document at read time. That is + * what stops the legal text keeping a stale phone number after an edit. + * + * It lives in the shared package because three consumers need the same bytes + * and any drift between them would only surface during an outage: the API + * seeds the database from it and serves it for any row still missing, and the + * portal renders it while the request is in flight or if the API is down. + * Once seeded, the database is authoritative and this is only a floor. + */ +export const SUPPORT_CONTENT_DEFAULTS: SupportDocPayloadMap = { + CONTACT: { + email: "support@edrfreight.com", + phone: "+251 11 000 0000", + office: "Addis Ababa, Ethiopia", + hours: "Monday – Saturday, 8:30 AM – 5:30 PM (EAT)", + }, + + HELP: { + title: "Help & Support", + subtitle: + "Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly.", + sections: [ + { + id: "help-walkthrough", + heading: "Portal walkthrough", + body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.", + media: [ + { + id: "help-walkthrough-video", + kind: "video", + // Ships with the app rather than MinIO, so it is used verbatim. + src: "/assets/edr-portal-guide.webm", + caption: null, + }, + ], + }, + { + id: "help-chat", + heading: "Chat with our team", + body: "Signed-in customers can open a support conversation from the headset button at the bottom right of every portal page. You can send screenshots and documents in the chat, and replies appear there and as a notification.\n\n[Open the portal](/portal)", + media: [], + }, + { + id: "help-contact", + heading: "Contact us", + body: "- **Email** — [{{supportEmail}}](mailto:{{supportEmail}}). Best for document issues and anything needing an attachment.\n- **Phone** — [{{supportPhone}}](tel:{{supportPhoneTel}}). Best for urgent problems with cargo already in transit.\n- **Head office** — {{supportOffice}}. Walk-in support during working hours.\n- **Support hours** — {{supportHours}}. Outside these hours, email us and we reply the next working day.", + media: [], + }, + { + id: "help-topics", + heading: "Common topics", + body: "- **[Account & onboarding](/faq)** — registering your company, uploading your trade licence and TIN, and getting an operational profile approved.\n- **[Contracts](/faq)** — requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.\n- **[Bookings & tracking](/faq)** — raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.\n- **[Invoices & payments](/faq)** — finding invoices, paying through the bank channels and confirming a payment that has not yet settled.", + media: [], + }, + { + id: "help-checklist", + heading: "What to include when you contact us", + body: "- Your company name and the email you sign in with.\n- The reference of the contract, booking or invoice involved.\n- What you expected to happen and what happened instead.\n- A screenshot of any error message the portal showed.", + media: [], + }, + ], + }, + + FAQ: { + title: "Frequently Asked Questions", + subtitle: + "Answers to the questions customers ask most about registering, booking cargo and settling invoices on EDR Freight.", + groups: [ + { + id: "faq-getting-started", + title: "Getting started", + items: [ + { + id: "faq-open-account", + question: "How do I open an account on EDR Freight?", + answer: + "Sign up with your work email and verify the one-time code we send you. After you set a password, the onboarding wizard collects your company details, trade licence, TIN certificate and the operational services you need (importer, exporter, freight forwarder or transporter). Submit the wizard and our team reviews the application.", + }, + { + id: "faq-approval-time", + question: "How long does account approval take?", + answer: + "Most complete applications are reviewed within two working days. You will see the status on your dashboard, and we email you when a profile is approved or when a document needs to be re-uploaded.", + }, + { + id: "faq-rejected", + question: "My profile was rejected. What now?", + answer: + "The rejection notice states the reason. Open Settings, correct the details or replace the document that was flagged, and re-apply — you do not need to create a new account.", + }, + { + id: "faq-multiple-services", + question: "Can one company hold several operational services?", + answer: + "Yes. A company can hold importer, exporter, freight forwarder and transporter profiles at the same time. Each is approved separately, and the header lets you switch between the ones you hold.", + }, + ], + }, + { + id: "faq-contracts-bookings", + title: "Contracts and bookings", + items: [ + { + id: "faq-contract-vs-booking", + question: "What is the difference between a contract and a booking?", + answer: + "A contract is the commercial agreement covering a cargo movement — route, commodity, volume and rates. A booking is a single shipment executed under that contract. You create the contract once, then raise bookings against it for each consignment.", + }, + { + id: "faq-create-booking", + question: "How do I create a booking?", + answer: + "Open the contract from the Contracts list and choose New Booking. Provide the consignment details, containers or tonnage, and the last-mile requirement if you need one. Bookings can also be started from the Bookings page, which routes you through contract selection first.", + }, + { + id: "faq-sign-contract", + question: "Why do I have to sign a contract before shipping?", + answer: + "The contract document is the binding agreement for the movement. You must scroll to the end, accept the terms, and sign it with your saved signature and stamp before EDR schedules any wagon against it.", + }, + { + id: "faq-signature-setup", + question: "Where do I set up my signature and stamp?", + answer: + "Under Signature & Stamp in the portal. It is saved once and reused for every contract you sign, so you do not have to upload it per document.", + }, + { + id: "faq-change-booking", + question: "Can I change a booking after submitting it?", + answer: + "You can edit a booking while it is still pending review. Once EDR has confirmed it and allocated capacity, changes go through our operations team — contact support with the booking reference.", + }, + { + id: "faq-track-consignment", + question: "How do I track a consignment?", + answer: + "Open the booking and use the tracking panel, which shows the current milestone, the wagon or container assigned, and the timestamps recorded at each corridor point.", + }, + ], + }, + { + id: "faq-invoices-payments", + title: "Invoices and payments", + items: [ + { + id: "faq-find-invoices", + question: "Where do I find my invoices?", + answer: + "The Invoices page lists every invoice raised against your company, with its status, due date and outstanding balance. Open any invoice to see its line items and download a PDF copy.", + }, + { + id: "faq-payment-methods", + question: "Which payment methods are supported?", + answer: + "Payments are made through the integrated bank channels shown at checkout. After you complete the payment on the bank's page you are returned to the portal, and the invoice status updates once the bank confirms the transaction.", + }, + { + id: "faq-payment-not-settled", + question: + "My payment was deducted but the invoice still shows unpaid.", + answer: + "Bank confirmations can lag by a few minutes. Use the Check Payment Status page linked from your receipt; if it still has not settled after an hour, email support with the invoice number and the bank reference and we will reconcile it.", + }, + { + id: "faq-rounding", + question: "Why is my invoice amount rounded?", + answer: + "Some bank channels only accept whole-birr amounts, so invoices routed through them are rounded up to the nearest birr. The rounding is shown on the invoice detail page.", + }, + ], + }, + { + id: "faq-account-security", + title: "Account and security", + items: [ + { + id: "faq-reset-password", + question: "How do I reset my password?", + answer: + "Use Forgot Password on the sign-in page. We email you a reset link that is valid for a limited time. If a member of our staff issued the link, it works the same way even if you are already signed in.", + }, + { + id: "faq-add-colleagues", + question: "Can I add colleagues to my company account?", + answer: + "Yes. Company administrators can invite additional users from Settings. Each user signs in with their own credentials, and actions are recorded against the individual who performed them.", + }, + { + id: "faq-update-company", + question: "How do I update company details after approval?", + answer: + "Edit them in Settings. Changes to regulated fields — trade licence, TIN, legal name — are re-verified by our team before they take effect.", + }, + ], + }, + ], + footer: { + heading: "Still need a hand?", + body: "Our team is on {{supportEmail}} and {{supportPhone}}, or you can start a chat from the support button inside the portal.", + ctaLabel: "Go to Help & Support", + ctaTo: "/help", + }, + }, + + PRIVACY: { + title: "Privacy Policy", + subtitle: + "How EDR Freight collects, uses, shares and protects the information you provide when you use the platform.", + lastUpdated: "6 August 2026", + sections: [ + { + id: "privacy-1", + heading: "1. Introduction", + body: 'The Ethio-Djibouti Standard Gauge Rail Share Company ("EDR", "we", "us") operates the EDR Freight platform, which lets customers register their business, agree freight contracts, raise bookings, track consignments and settle invoices online.\n\nThis policy explains what personal and business information we collect through the platform, why we collect it, how long we keep it and what rights you have over it. It applies to the EDR Freight customer portal and the services reached through it.', + }, + { + id: "privacy-2", + heading: "2. Information we collect", + body: "We collect information you give us, information generated by your use of the platform, and information we receive from the regulators and financial institutions we work with.\n\n- Account details — name, work email address, phone number and the credentials used to sign in.\n- Company and compliance records — legal name, trade licence, TIN certificate, VAT registration, ownership and manager details, and the operational services you apply for.\n- Identity verification data — where you verify through a national identity service, the verification result and the attributes that service returns to us.\n- Operational data — contracts, bookings, consignment and cargo details, container and wagon assignments, tracking events and delivery confirmations.\n- Financial data — invoices, payment references, transaction status and settlement confirmations received from banks. We do not store your card numbers or online banking credentials.\n- Support data — the messages and files you send us through the in-app support chat or by email.\n- Technical data — IP address, device and browser information, and event logs generated when you use the platform.", + }, + { + id: "privacy-3", + heading: "3. How we use your information", + body: "- To create and administer your account and verify that your company is entitled to the services it applies for.\n- To perform the freight contracts and bookings you place, including allocating capacity and coordinating rail and last-mile movements.\n- To issue invoices, process payments and keep the accounting records the law requires us to keep.\n- To provide customer support and respond to the questions and complaints you raise.\n- To keep the platform secure, detect misuse and investigate incidents.\n- To meet our legal, tax, customs and regulatory obligations in Ethiopia and Djibouti.\n- To improve the platform — measuring which features are used and where users encounter errors, using aggregated and pseudonymised data wherever that is sufficient.", + }, + { + id: "privacy-4", + heading: "4. Legal basis for processing", + body: "We process your information because it is necessary to perform the contract between you and EDR, because we have a legal obligation to do so (customs, tax and transport regulation), or because we have a legitimate interest in operating and securing the platform. Where we rely on your consent — for example, optional marketing messages — you can withdraw it at any time.", + }, + { + id: "privacy-5", + heading: "5. Sharing your information", + body: "We do not sell your information. We share it only where it is necessary to deliver the service or where the law requires it.\n\n- Government and regulatory bodies — customs, revenue and transport authorities in Ethiopia and Djibouti, to the extent required for the movement of your cargo.\n- Ports, terminals and last-mile transporters involved in executing your bookings.\n- Banks and payment providers, to initiate and reconcile the payments you make.\n- Technology suppliers who host and maintain the platform on our behalf, under contracts that restrict them to processing data on our instructions.\n- Courts, law enforcement and other authorities where we are legally compelled to disclose.", + }, + { + id: "privacy-6", + heading: "6. International transfers", + body: "Cross-border freight inherently involves parties in more than one country, so consignment and clearance information is shared with counterparties and authorities in Djibouti as well as Ethiopia. Where we transfer information outside Ethiopia, we do so only as far as the movement requires or the law permits, and we require recipients to protect it to a comparable standard.", + }, + { + id: "privacy-7", + heading: "7. Data retention", + body: "We keep account and company records for as long as your account is active. Contract, booking, customs and financial records are kept for the period required by Ethiopian commercial, tax and customs law after the relevant transaction, because we are obliged to be able to produce them. Support conversations and technical logs are kept for a shorter period, sufficient to resolve disputes and investigate security incidents.", + }, + { + id: "privacy-8", + heading: "8. Security", + body: "Access to the platform requires authentication, and staff access to customer records is limited to what each role needs. Data is transmitted over encrypted connections and stored on systems protected by access controls and logging. No system is perfectly secure, so please keep your credentials confidential and tell us immediately if you believe your account has been compromised.", + }, + { + id: "privacy-9", + heading: "9. Your rights", + body: "Subject to Ethiopian law, you may ask us to give you a copy of the personal information we hold about you, correct it if it is inaccurate, restrict or object to certain processing, or delete it where we are not required to keep it. Requests are handled through the contact details below; we may need to verify your identity before acting.", + }, + { + id: "privacy-10", + heading: "10. Cookies and similar technologies", + body: "The platform uses cookies and browser storage to keep you signed in, remember your interface preferences and measure how the product is used so we can fix problems. Essential cookies cannot be turned off without breaking sign-in. You can clear or block the rest through your browser settings.", + }, + { + id: "privacy-11", + heading: "11. Children", + body: "The platform is a business service and is not directed at children. We do not knowingly collect information from anyone under 18.", + }, + { + id: "privacy-12", + heading: "12. Changes to this policy", + body: "We may update this policy as the platform and the law change. Material changes are announced in the portal before they take effect, and the date at the top of this page always reflects the current version.", + }, + { + id: "privacy-13", + heading: "13. Contact us", + body: "Questions about this policy or about how we handle your information can be sent to {{supportEmail}}, called in on {{supportPhone}}, or addressed to our head office in {{supportOffice}}.", + }, + ], + }, + + TERMS: { + title: "Terms of Service", + subtitle: + "The terms on which EDR provides the EDR Freight platform and the freight services you request through it.", + lastUpdated: "6 August 2026", + sections: [ + { + id: "terms-1", + heading: "1. These terms", + body: 'These terms govern your use of the EDR Freight platform operated by the Ethio-Djibouti Standard Gauge Rail Share Company ("EDR"). By creating an account or using the platform, the company you represent agrees to them.\n\nThe platform is the channel through which you register, request and manage freight services. The commercial terms of each movement — routes, rates, volumes and payment terms — are set out in the freight contract you sign in the platform. Where a signed contract and these terms conflict, the signed contract governs that movement.', + }, + { + id: "terms-2", + heading: "2. Eligibility and accounts", + body: "- The platform is for registered businesses. You confirm that you are authorised to act for the company you register and to bind it to these terms.\n- The information and documents you submit — trade licence, TIN, VAT registration, ownership details — must be accurate, current and genuine.\n- Accounts and operational profiles are activated only after EDR has reviewed and approved them, and approval may be refused or withdrawn.\n- You are responsible for keeping credentials confidential and for everything done under your account. Tell us at once if you suspect unauthorised use.", + }, + { + id: "terms-3", + heading: "3. Contracts and bookings", + body: "- A freight contract takes effect when it is signed in the platform by you and countersigned by EDR.\n- A booking is a request for a specific movement under a contract. It becomes binding when EDR confirms it and allocates capacity — submission alone does not reserve a wagon or container.\n- You are responsible for the accuracy of consignment data: commodity description, weight, dimensions, container numbers, hazardous classification and consignee details.\n- Capacity is finite. EDR may decline, defer or reschedule a booking where capacity, safety, operating conditions or regulatory direction require it.", + }, + { + id: "terms-4", + heading: "4. Cargo, documents and compliance", + body: "- You must obtain and provide every permit, customs declaration and clearance document the movement requires, and you warrant that the cargo may lawfully be carried.\n- Prohibited and restricted goods may not be tendered without EDR's prior written agreement and any licence the law requires.\n- Cargo must be packed, secured and, where applicable, labelled to the standard the mode of carriage requires. EDR may inspect, refuse or offload cargo that is misdeclared or unsafe.\n- You are liable for fines, demurrage, storage charges and losses arising from misdeclared cargo, missing documents or delays attributable to you.", + }, + { + id: "terms-5", + heading: "5. Rates, invoicing and payment", + body: "- Charges are calculated from the rates in your contract, the tariffs published in the platform, and any accessorial services actually rendered.\n- Invoices are issued in the platform and are payable by the due date shown on them, through the payment channels the platform offers.\n- Payment is confirmed when the funds are confirmed by the bank, not when payment is initiated.\n- Overdue amounts may attract interest and may result in suspension of new bookings or of the account until the balance is cleared.\n- Taxes and statutory duties are your responsibility unless the contract expressly says otherwise.", + }, + { + id: "terms-6", + heading: "6. Delivery, delay and liability", + body: "Transit times shown in the platform are estimates based on planned schedules. They are not guarantees, and EDR is not liable for indirect or consequential loss, loss of profit or loss of market arising from delay.\n\nEDR's liability for loss of or damage to cargo is limited to the extent set out in the applicable freight contract and in the transport law governing the carriage. Claims must be notified in writing within the period the contract specifies; late claims may be rejected.\n\nNeither party is liable for failure to perform caused by events beyond its reasonable control, including natural disasters, industrial action, civil unrest, infrastructure failure, or acts of government and regulatory authorities.", + }, + { + id: "terms-7", + heading: "7. Acceptable use of the platform", + body: "- Use the platform only for its intended purpose and in accordance with applicable law.\n- Do not attempt to gain unauthorised access, probe or disrupt the service, or interfere with other customers' data.\n- Do not scrape, resell or redistribute platform content, rates or data without written permission.\n- Do not upload malware or content that infringes the rights of others.", + }, + { + id: "terms-8", + heading: "8. Electronic signatures and records", + body: "You agree that contracts signed in the platform using your stored signature and stamp are validly executed, that the records the platform keeps of those signatures are admissible evidence of them, and that they carry the same effect as signatures on paper.", + }, + { + id: "terms-9", + heading: "9. Availability and changes to the service", + body: "We aim to keep the platform available, but it may be interrupted for maintenance, upgrades or reasons outside our control. We may add, change or withdraw features. Where a change materially affects how you use the platform, we will give reasonable notice in the portal.", + }, + { + id: "terms-10", + heading: "10. Suspension and termination", + body: "We may suspend or terminate access where these terms are breached, where documents prove to be false, where amounts remain unpaid, or where the law or a regulator requires it. You may stop using the platform at any time. Termination does not affect obligations already incurred — cargo in transit, invoices outstanding, or records we are required to retain.", + }, + { + id: "terms-11", + heading: "11. Intellectual property", + body: "The platform, its software, design and content belong to EDR or its licensors. You are granted a non-exclusive, non-transferable right to use it for your own freight operations. Your commercial and consignment data remains yours; you grant us the right to process it as needed to deliver the service and as described in the Privacy Policy.", + }, + { + id: "terms-12", + heading: "12. Confidentiality and data protection", + body: "Each party will keep the other's non-public commercial information confidential and use it only for the purposes of the services. Our handling of personal information is described in the Privacy Policy, which forms part of these terms.", + }, + { + id: "terms-13", + heading: "13. Governing law and disputes", + body: "These terms are governed by the laws of the Federal Democratic Republic of Ethiopia. The parties will first attempt to resolve any dispute amicably; failing that, the dispute is subject to the jurisdiction of the competent courts of Ethiopia, without prejudice to any arbitration clause agreed in a specific freight contract.", + }, + { + id: "terms-14", + heading: "14. Changes to these terms", + body: "We may update these terms as the service and the law change. Updates are published here and announced in the portal. Continuing to use the platform after an update takes effect means you accept the revised terms.", + }, + { + id: "terms-15", + heading: "15. Contact", + body: "For questions about these terms, write to {{supportEmail}} or call {{supportPhone}}.", + }, + ], + }, +}; diff --git a/packages/types/src/freight/portal-content.ts b/packages/types/src/freight/portal-content.ts new file mode 100644 index 000000000..38af9e41e --- /dev/null +++ b/packages/types/src/freight/portal-content.ts @@ -0,0 +1,206 @@ +/** + * Customer-facing copy for the freight portal's public pages — /help, /faq, + * /terms and /privacy. Edited in the backoffice, served to the portal by one + * public endpoint, and versioned so a bad edit can be rolled back. + * + * Body copy is **markdown**. It is rendered with `react-markdown` and no + * `rehype-raw`, so raw HTML inside it is inert — the fields that actually need + * validating are the structured ones that land in `href`/`src` attributes. + */ + +/** One editable document. Each is a row in `freight.support_documents`. */ +export const SUPPORT_DOC_SLUGS = [ + "CONTACT", + "HELP", + "FAQ", + "PRIVACY", + "TERMS", +] as const; + +export type SupportDocSlug = (typeof SUPPORT_DOC_SLUGS)[number]; + +/** MinIO key prefix every uploaded help attachment is stored under. */ +export const SUPPORT_MEDIA_PREFIX = "support-content/"; + +/** + * Marks a MinIO object reference inside markdown, e.g. + * `![Wagon](minio:support-content/abc.png)`. + * + * Stored copy always holds the *key*, never a signed URL: a presigned URL + * expires, so writing one into the saved markdown would silently rot every + * embedded image a few hours later. The API swaps these for freshly signed + * URLs on each read instead. + */ +export const PORTAL_MEDIA_URI_SCHEME = "minio:"; + +/** 50 MB — walkthrough videos are the large case. */ +export const SUPPORT_MEDIA_MAX_BYTES = 50 * 1024 * 1024; + +export type PortalMediaKind = "image" | "video"; + +/** An image or video attached to a help section. */ +export interface PortalMedia { + id: string; + kind: PortalMediaKind; + /** + * Stored: a MinIO object key, a same-origin `/path`, or an `https://` URL. + * Served: the same value with MinIO keys replaced by a fresh presigned URL — + * the API rewrites this field in place, so the portal just renders it. + */ + src: string; + caption?: string | null; +} + +/** + * Placeholders usable inside any markdown or link field. They are substituted + * against the CONTACT document when the portal reads the bundle, which is what + * keeps the support address in the privacy/terms prose from drifting out of + * sync with the contact cards. + * + * `supportPhoneTel` is the whitespace-stripped phone, for `tel:` hrefs. + */ +export const PORTAL_CONTENT_VARS = [ + "supportEmail", + "supportPhone", + "supportPhoneTel", + "supportOffice", + "supportHours", +] as const; + +export type PortalContentVar = (typeof PORTAL_CONTENT_VARS)[number]; + +/** Slug `CONTACT`. The one place support contact details are edited. */ +export interface PortalSupportContact { + email: string; + phone: string; + office: string; + hours: string; +} + +/** One numbered section of a legal document. `body` is markdown. */ +export interface PortalDocSection { + /** Stable per-section id — the React key, since headings can collide. */ + id: string; + heading: string; + body: string; +} + +/** Slugs `PRIVACY` and `TERMS` — same shape, separate documents. */ +export interface PortalLegalContent { + title: string; + subtitle: string; + /** Free text, e.g. "6 August 2026". Shown as "Last updated …". */ + lastUpdated: string; + sections: PortalDocSection[]; +} + +export interface PortalFaqItem { + id: string; + question: string; + /** Markdown. */ + answer: string; +} + +export interface PortalFaqGroup { + id: string; + title: string; + items: PortalFaqItem[]; +} + +/** A call-to-action card closing a page. `body` is markdown. */ +export interface PortalCtaCard { + heading: string; + body: string; + ctaLabel: string; + /** In-app route (`/help`) or absolute URL. */ + ctaTo: string; +} + +/** Slug `FAQ`. */ +export interface PortalFaqContent { + title: string; + subtitle: string; + groups: PortalFaqGroup[]; + /** The "Still need a hand?" card. Null hides it. */ + footer: PortalCtaCard | null; +} + +/** + * One free-form block of the help page: a heading, a full markdown body, and + * any number of attached images or videos. + * + * Deliberately not a fixed set of typed blocks (video / channels / topics / + * checklist, as this once was). The help page is the one document whose shape + * genuinely changes with what support needs to explain that quarter, so it is + * built rather than filled in — add, reorder and delete sections freely. + */ +export interface PortalHelpSection { + id: string; + heading: string; + /** + * Markdown. Embedded images reference uploads as + * `![alt](minio:support-content/)` — see {@link PORTAL_MEDIA_URI_SCHEME}. + */ + body: string; + /** Rendered under the body, in order. */ + media: PortalMedia[]; +} + +/** Slug `HELP`. */ +export interface PortalHelpContent { + title: string; + subtitle: string; + sections: PortalHelpSection[]; +} + +/** Payload shape per slug — the jsonb column's type, keyed by document. */ +export interface SupportDocPayloadMap { + CONTACT: PortalSupportContact; + HELP: PortalHelpContent; + FAQ: PortalFaqContent; + PRIVACY: PortalLegalContent; + TERMS: PortalLegalContent; +} + +export type SupportDocPayload = SupportDocPayloadMap[SupportDocSlug]; + +/** What `GET /api/support-content` returns — everything the portal needs. */ +export interface PortalContentBundle { + contact: PortalSupportContact; + help: PortalHelpContent; + faq: PortalFaqContent; + privacy: PortalLegalContent; + terms: PortalLegalContent; +} + +// ── Backoffice (staff) projections ───────────────────────────────────────── + +/** A document row without its payload — the admin list. */ +export interface SupportDocumentSummary { + id: string; + slug: SupportDocSlug; + version: number; + updatedAt: string; + updatedById: string | null; +} + +export interface SupportDocumentDetail< + S extends SupportDocSlug = SupportDocSlug, +> extends SupportDocumentSummary { + slug: S; + payload: SupportDocPayloadMap[S]; +} + +/** A history row without its payload — the version list. */ +export interface SupportDocVersionSummary { + id: string; + version: number; + actorId: string | null; + note: string | null; + createdAt: string; +} + +/** A history row with its payload — fetched when a version is previewed. */ +export interface SupportDocVersionDetail extends SupportDocVersionSummary { + payload: SupportDocPayload; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e1d47c20..68573264b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,6 +250,9 @@ importers: '@mantine/hooks': specifier: ^9.3.0 version: 9.3.0(react@19.2.6) + '@mdxeditor/editor': + specifier: ^4.2.0 + version: 4.2.0(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32) '@posthog/react': specifier: ^1.10.3 version: 1.10.3(@types/react@18.3.31)(posthog-js@1.400.1)(react@19.2.6) @@ -469,6 +472,9 @@ importers: react-intersection-observer: specifier: ^9.16.0 version: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-markdown: + specifier: ^9.1.0 + version: 9.1.0(@types/react@18.3.31)(react@19.2.6) react-pdf: specifier: ^10.4.1 version: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -598,7 +604,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -635,6 +641,9 @@ importers: react-hot-toast: specifier: ^2.6.0 version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-markdown: + specifier: ^9.1.0 + version: 9.1.0(@types/react@18.3.31)(react@19.2.6) react-phone-number-input: specifier: ^3.4.17 version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1744,6 +1753,99 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.10.4': + resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==} + + '@codemirror/lang-angular@0.1.4': + resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==} + + '@codemirror/lang-cpp@6.0.3': + resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-go@6.0.1': + resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==} + + '@codemirror/lang-html@6.4.12': + resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==} + + '@codemirror/lang-java@6.0.2': + resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-jinja@6.0.1': + resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-less@6.0.2': + resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} + + '@codemirror/lang-liquid@6.3.2': + resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==} + + '@codemirror/lang-markdown@6.5.2': + resolution: {integrity: sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==} + + '@codemirror/lang-php@6.0.2': + resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/lang-rust@6.0.2': + resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==} + + '@codemirror/lang-sass@6.0.2': + resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/lang-vue@0.1.3': + resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==} + + '@codemirror/lang-wast@6.0.2': + resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==} + + '@codemirror/lang-xml@6.1.0': + resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} + + '@codemirror/lang-yaml@6.1.3': + resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==} + + '@codemirror/language-data@6.5.2': + resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/legacy-modes@6.5.3': + resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/merge@6.12.2': + resolution: {integrity: sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==} + + '@codemirror/search@6.7.1': + resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==} + + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + + '@codemirror/view@6.43.8': + resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -2551,6 +2653,249 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lexical/a11y@0.48.0': + resolution: {integrity: sha512-18W4ehyipkUim4YVoDZitoH63Om3j6iCN4c84zdqE9RgkWf/PE4rvI/8BHTm6Ni7NkVE14nimXgkpaP5ok15zA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/clipboard@0.48.0': + resolution: {integrity: sha512-xO2trk6+yBl8XXa/VNe20kXczmPxFoWtUHjidbBLEtlGBj+mo63pJj6H5o/WlZsoMKmIOJxwxsn2ejrS8G0/7A==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/code-core@0.48.0': + resolution: {integrity: sha512-+O1Ge06AuSo6+r8R2Xk6SkWG07H5/e4K/Scw9aqCM/BRjITxgvFTHTNvgQbaobCsP0uBJiwW1+ZGeWAWcBCY4w==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/devtools-core@0.48.0': + resolution: {integrity: sha512-4kvKWW6ebgQnJNLXPLmw7dqgSChvzYIBNYtfuR6c48Sw+V/QXQTWqfIUbCIe5X4uG8EEXd5O/udXaJx7GBuP+w==} + peerDependencies: + react: '>=18.x' + react-dom: '>=18.x' + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/dragon@0.48.0': + resolution: {integrity: sha512-uPuu7fVca9vmL/Oz30CRZ7FIPIodwMrTgNsRmV8jE6Qd6a7RNiTW7r3+EhbhIdkLb/sjcWsYyOMyUR1TJAB0wQ==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/extension@0.48.0': + resolution: {integrity: sha512-4uBObgz84mVbQWiumndmIhkuJL0ojHiMwFSvSUM/FCo1YMVIZvpI56blI0y+2Vix/oLui9EgVQSJjjWV4NAszw==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/hashtag@0.48.0': + resolution: {integrity: sha512-hPQtdnbVoNAFsmfnCGfgY7mDbvk6mIznlCRmIR7tLeQKXqz/0Tb6eH3W24EESk9hAF8wFUYNKWE1/Kb3Hl2vEQ==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/history@0.48.0': + resolution: {integrity: sha512-NllvUfO+u3mfi5uC8k2CodwdzeeopFFVtMZ/NMifzFbZFysdWi9m9mqfO46NrEA1rSFOydyefv6oMzC8ULXInA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/html@0.48.0': + resolution: {integrity: sha512-uBxlgKl4YgSNEgHJSshdBqtGDzruWdx1ewop+u6faT67qHUdP3P0cUXIrG6NToDWvsL6fzCstAbN76PMER1Pnw==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/internal@0.48.0': + resolution: {integrity: sha512-sRwg53K7N0ZQ7KNAvcCY38LSwGizbXP1zlR1lIojZp0GoqHWNvR+vL49t1wYXu1nXx3Osf4ilHHm+aGcwq5hTw==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/link@0.48.0': + resolution: {integrity: sha512-E0UDmNLUXs/yMCnnE7hbFO0CvhWghmqa+qqPksFfzLkpMHdPpdS1yg59YEbYoNHLi/DXIu4cFRvpHIEuooxwNg==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/list@0.48.0': + resolution: {integrity: sha512-9Qe/Vur44v9F9enj55SUzf79FVsijcGOQug7SpiIU8ekLr7JNzcilKYBYcZ6etGEo7bqQHsYMHXeJcSBbCI2zA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/mark@0.48.0': + resolution: {integrity: sha512-DTtypWvnYSXyNUxEmsUnh4y0xXkmdk8Y72EZl8WDHcCwjqaLJlUakH7p/TuSJczs3uVSaoJzu0yh7oGkP1Vsvw==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/markdown@0.48.0': + resolution: {integrity: sha512-1WasBenW4bEsa5xtnycVo8G2hcxIqyYLn3/r98yD2Y+54ZyeFuIQfOeJYhIgCh4YkKpfqyrFwkMQwAOsQDqZjA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/overflow@0.48.0': + resolution: {integrity: sha512-1YEvMz2tW3EbwrON9mjrkjMVl/vdTcPYSn9P1j6mf5gj0LOoLDNI4TbvSD4SViy+TDghxNdG8YdCISyU2b4YKA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/plain-text@0.48.0': + resolution: {integrity: sha512-q4f/4VZKVgCrIW2FhDFR2RII1BU0ljedPgEmJ8XQn1zc+JOFPom8Lp0lV5nyEvZaAJYwM/TfoJ9g2V7ESFKznA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/react@0.48.0': + resolution: {integrity: sha512-uVh9/QSrbtjLjVbxfJ+sfiMyhUq/rv7H6uBEVDDIw1rkZJSDY1fvf/CX+dyKgwcDFjKQZ8/9i5f9UCVPeQ01hA==} + peerDependencies: + react: '>=18.x' + react-dom: '>=18.x' + typescript: '>=5.2' + yjs: '>=13.5.22' + peerDependenciesMeta: + typescript: + optional: true + yjs: + optional: true + + '@lexical/rich-text@0.48.0': + resolution: {integrity: sha512-QMXFnwCKAQ4yzxvx5FwmANcx3K+NaBkGTxAVD8s8pOKDD/U5rzDS1iIvhH6TLWaFp7VzvLmLB+Sl1Ie/RnkaDQ==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/selection@0.48.0': + resolution: {integrity: sha512-Uc0wTrEtHcYK6z/aHHjkgH3vX/R4Bf8mO+qH3VbxfSAKYzNYktM0j+ZGdq5kIEL4frnw/9SulNCXlH7xpjuDgA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/table@0.48.0': + resolution: {integrity: sha512-t9Mz7q6ODLUz0lG5Xn9EY/5YiVpTHCqlPQP4EtFXlnBQT3DuKeDS3cC0Cn8sGSZc11YY5OLDfWpB64Frs9BL3g==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/text@0.48.0': + resolution: {integrity: sha512-ktTMRbsX4wKxdG2OpZCkrqtt8k9Vg/ZpWdukOQ0r1xPRtCuL1T+q91l7cy2ywIuCfMGYS0aoZGB4LdpUMe/H1g==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/utils@0.48.0': + resolution: {integrity: sha512-W4k4P+y6jmRfna8+ad4X+iMd5h8es5PC3bUw5tbi7MRApxaaFG/0w+uJiZVSwbT2Q6JnA2xhBaqzPgt/Gn6djg==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/yjs@0.48.0': + resolution: {integrity: sha512-fFsE8EnPM/2KK9rMJ0z6T+Da5UW5V4P+XiAA03LoHTY5YQ/Oy8Q0i7Wcmocv/B/SpsY2o8g07euZEfudl9MVKA==} + peerDependencies: + typescript: '>=5.2' + yjs: '>=13.5.22' + peerDependenciesMeta: + typescript: + optional: true + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/cpp@1.1.6': + resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==} + + '@lezer/css@1.3.6': + resolution: {integrity: sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==} + + '@lezer/go@1.0.1': + resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/java@1.1.3': + resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@lezer/markdown@1.7.2': + resolution: {integrity: sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==} + + '@lezer/php@1.0.5': + resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} + + '@lezer/python@1.1.19': + resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==} + + '@lezer/rust@1.0.2': + resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} + + '@lezer/sass@1.1.0': + resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==} + + '@lezer/xml@1.0.6': + resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} + + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + '@lottiefiles/react-lottie-player@3.6.0': resolution: {integrity: sha512-WK5TriLJT93VF3w4IjSVyveiedraZCnDhKzCPhpbeLgQeMi6zufxa3dXNc4HmAFRXq+LULPAy+Idv1rAfkReMA==} peerDependencies: @@ -2636,6 +2981,23 @@ packages: peerDependencies: react: ^18.x || ^19.x + '@marijn/find-cluster-break@1.0.3': + resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + + '@mdxeditor/editor@4.2.0': + resolution: {integrity: sha512-S/IPY8AjWTV2v1TGbEy5lsbQ5w+J2M2j2QDeswxP4FpNh2B49OPkpSqHbbX5GbV0YBlhfXJxtzwBJv/sQACufw==} + engines: {node: '>=16'} + peerDependencies: + react: '>= 18 || >= 19' + react-dom: '>= 18 || >= 19' + + '@mdxeditor/gurx@1.2.4': + resolution: {integrity: sha512-9ZykIFYhKaXaaSPCs1cuI+FvYDegJjbKwmA4ASE/zY+hJY6EYqvoye4esiO85CjhOw9aoD/izD/CU78/egVqmg==} + engines: {node: '>=16'} + peerDependencies: + react: '>= 18 || >= 19' + react-dom: '>= 18 || >= 19' + '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} @@ -3361,6 +3723,9 @@ packages: '@posthog/types@1.394.0': resolution: {integrity: sha512-ifQ7p8o8hoHErlJmpzCFzHQcuRam0vXk8LBVhBu4BlPYP6S0tog4FSAFItnI/nwN6cHZI0WFQilr7sIqQa7Flg==} + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@prisma/client@6.19.3': resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} engines: {node: '>=18.18'} @@ -3396,6 +3761,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@radix-ui/colors@3.0.0': + resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} + '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -3646,6 +4014,11 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-icons@1.3.2': + resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==} + peerDependencies: + react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc + '@radix-ui/react-id@1.1.2': resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} peerDependencies: @@ -4804,6 +5177,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/dompurify@3.2.0': resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. @@ -4814,6 +5190,9 @@ packages: '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -4835,6 +5214,9 @@ packages: '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/hoist-non-react-statics@3.3.7': resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==} peerDependencies: @@ -4879,6 +5261,9 @@ packages: '@types/luxon@3.7.1': resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -4991,6 +5376,12 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -5814,6 +6205,9 @@ packages: babel-runtime@6.26.0: resolution: {integrity: sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -6075,6 +6469,9 @@ packages: caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cfb@1.2.2: resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} engines: {node: '>=0.8'} @@ -6105,6 +6502,18 @@ packages: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -6227,6 +6636,14 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cm6-theme-basic-light@0.2.0: + resolution: {integrity: sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA==} + peerDependencies: + '@codemirror/language': ^6.0.0 + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.0.0 + '@lezer/highlight': ^1.0.0 + cmdk@1.1.1: resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} peerDependencies: @@ -6244,6 +6661,9 @@ packages: resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==} engines: {node: '>=0.10.0'} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + codepage@1.15.0: resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} engines: {node: '>=0.8'} @@ -6281,6 +6701,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -6322,6 +6745,9 @@ packages: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} + compute-scroll-into-view@2.0.4: + resolution: {integrity: sha512-y/ZA3BGnxoM/QHHQ2Uy49CLtnWPbt4tTPpEEZiEmmiWBFKjej7nEyH8Ryz54jH0MLXflUYA3Er2zUxPSJu5R+g==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -6467,6 +6893,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + cron@4.4.0: resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} engines: {node: '>=18.x'} @@ -6658,6 +7087,9 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decode-uri-component@0.2.2: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} @@ -6765,6 +7197,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + devtools-protocol@0.0.1608973: resolution: {integrity: sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==} @@ -6785,6 +7220,10 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -6846,6 +7285,11 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + downshift@7.6.2: + resolution: {integrity: sha512-iOv+E1Hyt3JDdL9yYcOgW7nZ7GQ2Uz6YbggwXvKUSleetYhU2nXD482Rz6CzvM4lvI1At34BYruKAL4swRGxaA==} + peerDependencies: + react: '>=16.12.0' + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -7023,6 +7467,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + escodegen@2.1.0: resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} engines: {node: '>=6.0'} @@ -7160,6 +7608,12 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -7357,6 +7811,9 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -7494,6 +7951,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -7817,6 +8278,12 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -7871,6 +8338,9 @@ packages: resolution: {integrity: sha512-XxzooSo6oBoxBEUazgjdXj7VwTn/iSTSZzTYKzYY6I916tkaYzypHxy+pbVU1h+0UQ9JlVf5XkNQyxOAiiQO1g==} engines: {node: '>=0.10.0'} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html2canvas@1.4.1: resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} engines: {node: '>=8.0.0'} @@ -8012,6 +8482,9 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + input-format@0.3.14: resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} peerDependencies: @@ -8053,6 +8526,12 @@ packages: resolution: {integrity: sha512-AIbwAcazqP3R65dGvqk1V+a+vE5Fg1yu/ZKMOiBWSUIXXiwQkYmXQcVa2O0nh0tSDKDFKxG2mY7dB1Sr4hEP1g==} engines: {node: '>= 0.4'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arguments@1.2.0: resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} @@ -8106,6 +8585,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-descriptor@0.1.8: resolution: {integrity: sha512-SceYGWXvdqlWa/OnQ5FQuV+NxvNmMRhMw/w9AHkH71hTzveND4BTYgvp16g+oITK47qbOl/3D0bl0iygehWAWQ==} engines: {node: '>= 0.4'} @@ -8172,6 +8654,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -8369,6 +8854,9 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} @@ -8600,6 +9088,10 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + jsbn@0.1.1: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} @@ -8745,6 +9237,19 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lexical@0.48.0: + resolution: {integrity: sha512-KK4Tyr/cPsleoZ7XvhGRiRmcrZidSmoFUdIXK9nPubIifoC+80Dc5THyc4xtGKtsW24S1TsHzk5gmfBU+TxmEg==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + libphonenumber-js@1.13.6: resolution: {integrity: sha512-NdB6O6QvlGMCoG003m0YIKG2+Xw7DjmCZhmc1RH+K6HncADUbRf8TZeLegxBBN1VFyPHcNpPTKpIhYLXzJVy1Q==} @@ -9016,6 +9521,9 @@ packages: resolution: {integrity: sha512-qyIh2goLt1sOgQQrrIWuwkRjUx4NUcEqEGAcYqD8VOnOC6ItwkrVE8/tA4smGpjzyp4Svhc6RodDp9IO5ghpyA==} engines: {node: '>=0.10.0'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -9105,10 +9613,58 @@ packages: resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==} engines: {node: '>=0.10.0'} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-directive@3.1.0: + resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-highlight-mark@1.2.2: + resolution: {integrity: sha512-OYumVoytj+B9YgwzBhBcYUCLYHIPvJtAvwnMyKhUXbfUFuER5S+FDZyu9fadUxm2TCT5fRYK3jQXh2ioWAxrMw==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} @@ -9160,6 +9716,108 @@ packages: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-directive@3.0.2: + resolution: {integrity: sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-highlight-mark@1.2.0: + resolution: {integrity: sha512-huGtbd/9kQsMk8u7nrVMaS5qH/47yDG6ZADggo5Owz5JoY8wdfQjfuy118/QiYNCvdFuFDbzT0A7K7Hp2cBsXA==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@3.1.10: resolution: {integrity: sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==} engines: {node: '>=0.10.0'} @@ -9611,6 +10269,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -9969,6 +10630,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -10190,12 +10854,21 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} react-is@19.2.7: resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} + react-markdown@9.1.0: + resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-number-format@5.4.5: resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} peerDependencies: @@ -10453,6 +11126,12 @@ packages: resolution: {integrity: sha512-Q5W2qeYtY9GbiR8z1yHNZ1DGhyjb4AnLEjt8iE6XfcC1QIu+FAtj3HQaO0wH28H1mX6cqNLvAqWhP402dxJGyA==} engines: {node: '>= 0.8.0'} + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remarkable@1.7.4: resolution: {integrity: sha512-e6NKUXgX95whv7IgddywbeN/ItCkWbISmc2DiqHJb0wTrqZIexqdco5b8Z3XZoo/48IdNVKM9ZCvTPJ4F5uvhg==} engines: {node: '>= 0.10.0'} @@ -10615,6 +11294,10 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -10883,6 +11566,9 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split-on-first@1.1.0: resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} engines: {node: '>=6'} @@ -11018,6 +11704,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + stringify-object@5.0.0: resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} engines: {node: '>=14.16'} @@ -11072,9 +11761,18 @@ packages: resolution: {integrity: sha512-mKX8HA/cdBqMKUr0MMZAFssCkIGoZeSCMXgnt79yKxNFguMLVFgRe6wB+fsL0NmoHDbeyZXczy7vEPSoo3rkzg==} engines: {node: '>=16'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-object-to-css-string@1.1.3: resolution: {integrity: sha512-bISQoUsir/qGfo7vY8rw00ia9nnyE1jvYt3zZ2jhdkcXZ6dAEi74inMzQ6On57vFI+I4Fck6wOv5UI9BEwJDgw==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + styled-components@5.3.11: resolution: {integrity: sha512-uuzIIfnVkagcVHv9nE0VPlHPSCmXIUGKfJ42LNjxCCTDTL5sgnJ8Z7GZBq0EnLYGln77tPpEpExt2+qa+cZqSw==} engines: {node: '>=10'} @@ -11421,6 +12119,12 @@ packages: trim-canvas@0.1.2: resolution: {integrity: sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -11682,10 +12386,34 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + unidiff@1.0.4: + resolution: {integrity: sha512-ynU0vsAXw0ir8roa+xPCUHmnJ5goc5BTM2Kuc3IJd8UwgaeRs7VSD5+eeaQL+xp1JtB92hu/Zy/Lgy7RZcr1pQ==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + union-value@1.0.1: resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==} engines: {node: '>=0.10.0'} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universal-cookie@8.1.2: resolution: {integrity: sha512-kcKzTGNsxVytujrYOvQbvh//QyFrA53HrzCGyzh6i9ujCww5gfPrLK0tG+jJD40SIIldiEjBNPPSR8fBMS21GA==} @@ -11826,6 +12554,11 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uvu@0.5.6: + resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} + engines: {node: '>=8'} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -11855,6 +12588,12 @@ packages: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} @@ -11934,6 +12673,9 @@ packages: resolution: {integrity: sha512-lYEhd75l75P3D1LKpm4KqdOSpNyNdDJ9ixEZmC5ZAZUKGy6JNexfMdQ9SNaT5pCHuzuXXRJQedJ+CdqNg/D4Kw==} engines: {iojs: '>= 1.0.0', node: '>= 0.10.0'} + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -12192,6 +12934,10 @@ packages: resolution: {integrity: sha512-9GnJUZ0QM4OgXuOzsKNzTJ5EOkums1Xc+3YQXp+Q+UxFjf7zLucp9dQ8QMIft0Szs1E1hUiXFim1OYfEKFq97w==} engines: {node: '>=0.8'} + yjs@13.6.32: + resolution: {integrity: sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -12252,6 +12998,9 @@ packages: use-sync-external-store: optional: true + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -12314,11 +13063,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12353,7 +13102,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -12362,7 +13111,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12377,9 +13133,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -12394,13 +13150,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12553,6 +13309,18 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -12574,6 +13342,265 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.4': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + + '@codemirror/lang-angular@0.1.4': + dependencies: + '@codemirror/lang-html': 6.4.12 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-cpp@6.0.3': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/cpp': 1.1.6 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.6 + + '@codemirror/lang-go@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/go': 1.0.1 + + '@codemirror/lang-html@6.4.12': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.6 + '@lezer/html': 1.3.13 + + '@codemirror/lang-java@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/java': 1.1.3 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-jinja@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/json': 1.0.3 + + '@codemirror/lang-less@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-liquid@6.3.2': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-markdown@6.5.2': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/markdown': 1.7.2 + + '@codemirror/lang-php@6.0.2': + dependencies: + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/php': 1.0.5 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/python': 1.1.19 + + '@codemirror/lang-rust@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/rust': 1.0.2 + + '@codemirror/lang-sass@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/sass': 1.1.0 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-vue@0.1.3': + dependencies: + '@codemirror/lang-html': 6.4.12 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-wast@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-xml@6.1.0': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/xml': 1.0.6 + + '@codemirror/lang-yaml@6.1.3': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/yaml': 1.0.4 + + '@codemirror/language-data@6.5.2': + dependencies: + '@codemirror/lang-angular': 0.1.4 + '@codemirror/lang-cpp': 6.0.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-go': 6.0.1 + '@codemirror/lang-html': 6.4.12 + '@codemirror/lang-java': 6.0.2 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/lang-jinja': 6.0.1 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-less': 6.0.2 + '@codemirror/lang-liquid': 6.3.2 + '@codemirror/lang-markdown': 6.5.2 + '@codemirror/lang-php': 6.0.2 + '@codemirror/lang-python': 6.2.1 + '@codemirror/lang-rust': 6.0.2 + '@codemirror/lang-sass': 6.0.2 + '@codemirror/lang-sql': 6.10.0 + '@codemirror/lang-vue': 0.1.3 + '@codemirror/lang-wast': 6.0.2 + '@codemirror/lang-xml': 6.1.0 + '@codemirror/lang-yaml': 6.1.3 + '@codemirror/language': 6.12.4 + '@codemirror/legacy-modes': 6.5.3 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/legacy-modes@6.5.3': + dependencies: + '@codemirror/language': 6.12.4 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + crelt: 1.0.7 + + '@codemirror/merge@6.12.2': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/highlight': 1.2.3 + style-mod: 4.1.3 + + '@codemirror/search@6.7.1': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + crelt: 1.0.7 + + '@codemirror/state@6.7.1': + dependencies: + '@marijn/find-cluster-break': 1.0.3 + + '@codemirror/view@6.43.8': + dependencies: + '@codemirror/state': 6.7.1 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@colors/colors@1.5.0': optional: true @@ -12778,7 +13805,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -12944,7 +13971,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -13104,7 +14131,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -13516,6 +14543,329 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lexical/a11y@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/clipboard@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/list': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + '@types/trusted-types': 2.0.7 + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/code-core@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/devtools-core@0.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)': + dependencies: + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/link': 0.48.0(typescript@5.9.3) + '@lexical/mark': 0.48.0(typescript@5.9.3) + '@lexical/table': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/dragon@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/extension@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + '@preact/signals-core': 1.14.4 + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/hashtag@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/text': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/history@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/html@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/internal@0.48.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@lexical/link@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/list@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/mark@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/markdown@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/code-core': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/link': 0.48.0(typescript@5.9.3) + '@lexical/list': 0.48.0(typescript@5.9.3) + '@lexical/rich-text': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + '@lexical/text': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/overflow@0.48.0(typescript@5.9.3)': + dependencies: + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/plain-text@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/clipboard': 0.48.0(typescript@5.9.3) + '@lexical/dragon': 0.48.0(typescript@5.9.3) + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/react@0.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32)': + dependencies: + '@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@lexical/a11y': 0.48.0(typescript@5.9.3) + '@lexical/devtools-core': 0.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + '@lexical/dragon': 0.48.0(typescript@5.9.3) + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/hashtag': 0.48.0(typescript@5.9.3) + '@lexical/history': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/link': 0.48.0(typescript@5.9.3) + '@lexical/list': 0.48.0(typescript@5.9.3) + '@lexical/mark': 0.48.0(typescript@5.9.3) + '@lexical/markdown': 0.48.0(typescript@5.9.3) + '@lexical/overflow': 0.48.0(typescript@5.9.3) + '@lexical/plain-text': 0.48.0(typescript@5.9.3) + '@lexical/rich-text': 0.48.0(typescript@5.9.3) + '@lexical/table': 0.48.0(typescript@5.9.3) + '@lexical/text': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + '@lexical/yjs': 0.48.0(typescript@5.9.3)(yjs@13.6.32) + lexical: 0.48.0(typescript@5.9.3) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + typescript: 5.9.3 + yjs: 13.6.32 + + '@lexical/rich-text@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/clipboard': 0.48.0(typescript@5.9.3) + '@lexical/dragon': 0.48.0(typescript@5.9.3) + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/selection@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/internal': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/table@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/clipboard': 0.48.0(typescript@5.9.3) + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/text@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/internal': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/utils@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/yjs@0.48.0(typescript@5.9.3)(yjs@13.6.32)': + dependencies: + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + yjs: 13.6.32 + optionalDependencies: + typescript: 5.9.3 + + '@lezer/common@1.5.2': {} + + '@lezer/cpp@1.1.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/css@1.3.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/go@1.0.1': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/java@1.1.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/markdown@1.7.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + + '@lezer/php@1.0.5': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/python@1.1.19': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/rust@1.0.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/sass@1.1.0': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/xml@1.0.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lottiefiles/react-lottie-player@3.6.0(react@19.2.6)': dependencies: lottie-web: 5.13.0 @@ -13640,6 +14990,83 @@ snapshots: dependencies: react: 19.2.6 + '@marijn/find-cluster-break@1.0.3': {} + + '@mdxeditor/editor@4.2.0(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32)': + dependencies: + '@codemirror/commands': 6.10.4 + '@codemirror/lang-markdown': 6.5.2 + '@codemirror/language-data': 6.5.2 + '@codemirror/merge': 6.12.2 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lexical/clipboard': 0.48.0(typescript@5.9.3) + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/history': 0.48.0(typescript@5.9.3) + '@lexical/link': 0.48.0(typescript@5.9.3) + '@lexical/list': 0.48.0(typescript@5.9.3) + '@lexical/markdown': 0.48.0(typescript@5.9.3) + '@lexical/plain-text': 0.48.0(typescript@5.9.3) + '@lexical/react': 0.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32) + '@lexical/rich-text': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + '@mdxeditor/gurx': 1.2.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/colors': 3.0.0 + '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-icons': 1.3.2(react@19.2.6) + '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popper': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toggle-group': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toolbar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + cm6-theme-basic-light: 0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.8)(@lezer/highlight@1.2.3) + codemirror: 6.0.2 + downshift: 7.6.2(react@19.2.6) + js-yaml: 4.3.0 + lexical: 0.48.0(typescript@5.9.3) + mdast-util-directive: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-frontmatter: 2.0.1 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-highlight-mark: 1.2.2 + mdast-util-mdx: 3.0.0 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-to-markdown: 2.1.2 + micromark-extension-directive: 3.0.2 + micromark-extension-frontmatter: 2.0.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-extension-highlight-mark: 1.2.0 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs: 3.0.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-hook-form: 7.77.0(react@19.2.6) + unidiff: 1.0.4 + transitivePeerDependencies: + - '@codemirror/language' + - '@lezer/highlight' + - '@types/react' + - '@types/react-dom' + - supports-color + - typescript + - yjs + + '@mdxeditor/gurx@1.2.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@microsoft/tsdoc@0.15.1': {} '@microsoft/tsdoc@0.16.0': {} @@ -14264,6 +15691,8 @@ snapshots: '@posthog/types@1.394.0': {} + '@preact/signals-core@1.14.4': {} + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': optionalDependencies: prisma: 6.19.3(typescript@5.9.3) @@ -14301,7 +15730,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -14314,6 +15743,8 @@ snapshots: - react-native-b4a - supports-color + '@radix-ui/colors@3.0.0': {} + '@radix-ui/number@1.1.2': {} '@radix-ui/primitive@1.1.4': {} @@ -14806,6 +16237,10 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) + '@radix-ui/react-icons@1.3.2(react@19.2.6)': + dependencies: + react: 19.2.6 + '@radix-ui/react-id@1.1.2(@types/react@18.3.31)(react@18.3.1)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) @@ -16369,7 +17804,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -16668,6 +18103,130 @@ snapshots: - utf-8-validate - vite + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': + dependencies: + '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) + '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) + '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) + '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) + '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 7.17.8(react@19.2.6) + '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) + '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf/renderer': 4.5.1(react@19.2.6) + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) + '@tabler/icons-react': 3.44.0(react@19.2.6) + '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) + '@tanstack/react-query': 5.101.0(react@19.2.6) + '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) + '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) + '@types/dompurify': 3.2.0 + '@types/node': 24.13.1 + '@types/tinymce': 4.6.9 + axios: 1.17.0 + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + date-fns: 3.6.0 + dayjs: 1.11.21 + dompurify: 3.4.8 + ethiopian-calendar-date-converter: 2.1.6 + ethiopian-calendar-new: 1.1.0 + file-type: 18.7.0 + framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + html2canvas: 1.4.1 + i18next: 25.10.10(typescript@5.9.3) + i18next-browser-languagedetector: 8.2.1 + jquery: 3.7.1 + js-cookie: 3.0.8 + jspdf: 3.0.4 + lodash: 4.18.1 + lucide-react: 0.513.0(react@19.2.6) + mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) + next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + path: 0.12.7 + pdf-lib: 1.17.1 + qs: 6.15.2 + react: 19.2.6 + react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) + react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + react-dropzone: 14.4.1(react@19.2.6) + react-hook-form: 7.77.0(react@19.2.6) + react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-icons: 5.6.0(react@19.2.6) + react-image-crop: 11.0.10(react@19.2.6) + react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) + react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) + react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) + rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) + socket.io-client: 4.8.3 + sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + tailwind-merge: 3.6.0 + tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) + tailwindcss: 4.3.0 + tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) + tinymce: 7.9.3 + url: 0.11.4 + vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + xlsx: 0.18.5 + zod: 3.25.76 + transitivePeerDependencies: + - '@babel/core' + - '@emotion/is-prop-valid' + - '@mui/icons-material' + - '@mui/material' + - '@mui/x-date-pickers' + - '@types/prop-types' + - '@types/react' + - '@types/react-dom' + - bufferutil + - debug + - pdfjs-dist + - prop-types + - react-is + - react-native + - redux + - rolldown + - rollup + - supports-color + - typescript + - utf-8-validate + - vite + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -16779,6 +18338,10 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/dompurify@3.2.0': dependencies: dompurify: 3.4.8 @@ -16793,6 +18356,10 @@ snapshots: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.9': {} '@types/express-serve-static-core@4.19.8': @@ -16828,6 +18395,10 @@ snapshots: dependencies: '@types/node': 20.19.42 + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31)': dependencies: '@types/react': 18.3.31 @@ -16871,6 +18442,10 @@ snapshots: '@types/luxon@3.7.1': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/methods@1.1.4': {} '@types/mime@1.3.5': {} @@ -16994,8 +18569,11 @@ snapshots: '@types/tmp@0.2.6': {} - '@types/trusted-types@2.0.7': - optional: true + '@types/trusted-types@2.0.7': {} + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} '@types/use-sync-external-store@0.0.6': {} @@ -17046,7 +18624,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -17056,7 +18634,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -17075,7 +18653,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -17090,7 +18668,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -17379,7 +18957,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -17893,6 +19471,16 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): + dependencies: + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + picomatch: 4.0.4 + styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + transitivePeerDependencies: + - supports-color + babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -17929,6 +19517,8 @@ snapshots: core-js: 2.6.12 regenerator-runtime: 0.11.1 + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -18046,7 +19636,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -18222,6 +19812,8 @@ snapshots: caseless@0.12.0: {} + ccount@2.0.1: {} + cfb@1.2.2: dependencies: adler-32: 1.3.1 @@ -18258,6 +19850,14 @@ snapshots: char-regex@1.0.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + chardet@2.1.1: {} check-error@2.1.3: {} @@ -18385,6 +19985,13 @@ snapshots: clsx@2.1.1: {} + cm6-theme-basic-light@0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.8)(@lezer/highlight@1.2.3): + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/highlight': 1.2.3 + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@19.2.6) @@ -18403,6 +20010,16 @@ snapshots: code-point-at@1.1.0: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.10.4 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + codepage@1.15.0: {} collect-v8-coverage@1.0.3: {} @@ -18433,6 +20050,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + commander@11.1.0: {} commander@13.1.0: {} @@ -18466,6 +20085,8 @@ snapshots: normalize-path: 3.0.0 readable-stream: 3.6.2 + compute-scroll-into-view@2.0.4: {} + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -18603,6 +20224,8 @@ snapshots: create-require@1.1.1: {} + crelt@1.0.7: {} + cron@4.4.0: dependencies: '@types/luxon': 3.7.1 @@ -18820,6 +20443,10 @@ snapshots: decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + decode-uri-component@0.2.2: {} dedent@1.7.2(babel-plugin-macros@3.1.0): @@ -18902,6 +20529,10 @@ snapshots: detect-node-es@1.1.0: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + devtools-protocol@0.0.1608973: {} dezalgo@1.0.4: @@ -18917,6 +20548,8 @@ snapshots: diff@4.0.4: {} + diff@5.2.2: {} + diff@8.0.4: {} dijkstrajs@1.0.3: {} @@ -18979,6 +20612,15 @@ snapshots: dotenv@17.4.2: {} + downshift@7.6.2(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.7 + compute-scroll-into-view: 2.0.4 + prop-types: 15.8.1 + react: 19.2.6 + react-is: 17.0.2 + tslib: 2.8.1 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -19043,7 +20685,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -19063,7 +20705,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -19249,6 +20891,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + escodegen@2.1.0: dependencies: esprima: 4.0.1 @@ -19292,7 +20936,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -19420,7 +21064,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -19470,6 +21114,13 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -19650,7 +21301,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -19703,7 +21354,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -19774,6 +21425,10 @@ snapshots: dependencies: reusify: 1.1.0 + fault@2.0.1: + dependencies: + format: 0.2.2 + fb-watchman@2.0.2: dependencies: bser: 2.1.1 @@ -19854,7 +21509,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -19947,6 +21602,8 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + format@0.2.2: {} + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -20100,7 +21757,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -20306,6 +21963,30 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + he@1.2.0: {} headers-polyfill@5.0.1: @@ -20365,6 +22046,8 @@ snapshots: is-self-closing: 1.0.1 kind-of: 6.0.3 + html-url-attributes@3.0.1: {} + html2canvas@1.4.1: dependencies: css-line-break: 2.1.0 @@ -20381,7 +22064,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -20394,14 +22077,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -20486,6 +22169,8 @@ snapshots: ini@4.1.1: {} + inline-style-parser@0.2.7: {} + input-format@0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: prop-types: 15.8.1 @@ -20528,6 +22213,13 @@ snapshots: dependencies: hasown: 2.0.4 + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-arguments@1.2.0: dependencies: call-bound: 1.0.4 @@ -20589,6 +22281,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-descriptor@0.1.8: dependencies: is-accessor-descriptor: 1.0.2 @@ -20645,6 +22339,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -20794,6 +22490,8 @@ snapshots: isobject@3.0.1: {} + isomorphic.js@0.2.5: {} + isstream@0.1.2: {} istanbul-lib-coverage@3.2.2: {} @@ -20826,7 +22524,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -21221,6 +22919,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + jsbn@0.1.1: {} jsdom@25.0.1: @@ -21399,6 +23101,16 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lexical@0.48.0(typescript@5.9.3): + dependencies: + '@lexical/internal': 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + libphonenumber-js@1.13.6: {} libreoffice-convert@1.8.1: @@ -21472,7 +23184,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -21648,6 +23360,8 @@ snapshots: isobject: 3.0.1 log-utils: 0.2.1 + longest-streak@3.1.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -21730,8 +23444,165 @@ snapshots: dependencies: object-visit: 1.0.1 + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-directive@3.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-highlight-mark@1.2.2: + dependencies: + micromark-extension-highlight-mark: 1.2.0 + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdn-data@2.0.14: {} media-engine@1.0.3: {} @@ -21762,6 +23633,263 @@ snapshots: methods@1.1.2: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-directive@3.0.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + parse-entities: 4.0.2 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-highlight-mark@1.2.0: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + uvu: 0.5.6 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@8.1.1) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@3.1.10: dependencies: arr-diff: 4.0.0 @@ -22269,7 +24397,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -22297,6 +24425,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.7 @@ -22599,6 +24737,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-information@7.2.0: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -22607,7 +24747,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -22636,7 +24776,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -22889,6 +25029,15 @@ snapshots: - '@babel/core' - react-is + react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - react-is + react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -22980,10 +25129,30 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-is@19.2.7: {} + react-markdown@9.1.0(@types/react@18.3.31)(react@19.2.6): + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 18.3.31 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.6 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-number-format@5.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -23327,6 +25496,23 @@ snapshots: dependencies: isobject: 2.1.0 + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + remarkable@1.7.4: dependencies: argparse: 1.0.10 @@ -23462,7 +25648,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -23494,6 +25680,10 @@ snapshots: dependencies: tslib: 2.8.1 + sade@1.8.1: + dependencies: + mri: 1.2.0 + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -23580,7 +25770,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -23796,7 +25986,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -23806,7 +25996,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -23817,7 +26007,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -23826,7 +26016,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -23838,7 +26028,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -23883,6 +26073,8 @@ snapshots: source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} + split-on-first@1.1.0: {} split-string@3.1.0: @@ -24053,6 +26245,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + stringify-object@5.0.0: dependencies: get-own-enumerable-keys: 1.0.0 @@ -24096,8 +26293,18 @@ snapshots: '@tokenizer/token': 0.3.0 peek-readable: 5.4.2 + style-mod@4.1.3: {} + style-object-to-css-string@1.1.3: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + styled-components@5.3.11(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): dependencies: '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) @@ -24116,6 +26323,24 @@ snapshots: transitivePeerDependencies: - '@babel/core' + styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): + dependencies: + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@emotion/is-prop-valid': 1.4.0 + '@emotion/stylis': 0.8.5 + '@emotion/unitless': 0.7.5 + babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) + css-to-react-native: 3.2.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 19.2.7 + shallowequal: 1.1.0 + supports-color: 5.5.0 + transitivePeerDependencies: + - '@babel/core' + styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -24141,7 +26366,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -24441,6 +26666,10 @@ snapshots: trim-canvas@0.1.2: {} + trim-lines@3.0.1: {} + + trough@2.2.0: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -24650,7 +26879,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -24674,7 +26903,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -24727,6 +26956,20 @@ snapshots: unicorn-magic@0.3.0: {} + unidiff@1.0.4: + dependencies: + diff: 5.2.2 + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + union-value@1.0.1: dependencies: arr-union: 3.1.0 @@ -24734,6 +26977,33 @@ snapshots: is-extendable: 0.1.1 set-value: 2.0.1 + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universal-cookie@8.1.2: dependencies: cookie: 1.1.1 @@ -24905,6 +27175,13 @@ snapshots: uuid@8.3.2: {} + uvu@0.5.6: + dependencies: + dequal: 2.0.3 + diff: 5.2.2 + kleur: 4.1.5 + sade: 1.8.1 + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: @@ -24934,6 +27211,16 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + victory-vendor@36.9.2: dependencies: '@types/d3-array': 3.2.2 @@ -24977,7 +27264,7 @@ snapshots: vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -24995,7 +27282,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -25042,7 +27329,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -25078,7 +27365,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -25119,6 +27406,8 @@ snapshots: strip-ansi: 3.0.1 wrap-ansi: 2.1.0 + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -25411,6 +27700,10 @@ snapshots: year@0.2.1: {} + yjs@13.6.32: + dependencies: + lib0: 0.2.117 + yn@3.1.1: {} yocto-queue@0.1.0: {} @@ -25454,3 +27747,5 @@ snapshots: immer: 11.1.8 react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) + + zwitch@2.0.4: {} diff --git a/portal-content-contact.png b/portal-content-contact.png new file mode 100644 index 0000000000000000000000000000000000000000..dfbf73fc54ddd3694ebb8b876aff7783d33bbc01 GIT binary patch literal 74931 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

    %*LS;`_1bOW2X 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 b&1Yy^L0rhie(XNf6|_%MN|I$CjlTZ}c}nU` literal 0 HcmV?d00001 From 229d9c66513a953d2a7d13ba835e428a545bd746 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 8 Aug 2026 10:47:48 +0000 Subject: [PATCH 14/16] feat(freight-web): restrict booking currency to ETB and portal ETB payments to CBE bill only --- .../contracts/GlCreateBookingForm.tsx | 14 ++------- .../src/pages/bookings/NewBookingPage.tsx | 5 +-- .../portal/public/assets/cbe.png | Bin 0 -> 280872 bytes .../components/PaymentMethodModal.tsx | 29 ++++++++++-------- .../src/pages/bookings/EditBookingPage.tsx | 4 +-- .../pages/bookings/new-booking-form/schema.ts | 8 ++--- .../new-booking-form/step8-review.tsx | 2 +- .../src/pages/contracts/NewShipmentPage.tsx | 12 +++----- .../CurrencySelector/CurrencySelector.tsx | 9 ++---- 9 files changed, 31 insertions(+), 52 deletions(-) create mode 100644 apps/edr-freight-web/portal/public/assets/cbe.png diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 42ecbce61..5502886d3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -276,9 +276,8 @@ export default function GlCreateBookingForm() { const [trainScheduleId, setTrainScheduleId] = useState(""); const [contractRouteId, setContractRouteId] = useState(null); const [notes, setNotes] = useState(""); - // The customer states the billing currency on their shipment request — GL - // books in it. Intercity is always ETB (the API enforces this too). - const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("USD"); + // ponytail: ETB-only for now — widen back to "USD" | "ETB" when multi-currency billing returns. + const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("ETB"); // What the containers carry — captured per booking (moved off the contract). const [cargoDescription, setCargoDescription] = useState(""); const [containerLines, setContainerLines] = useState([]); @@ -449,9 +448,6 @@ export default function GlCreateBookingForm() { } if (bookingRequest.contractRouteId) setContractRouteId(bookingRequest.contractRouteId); - if (bookingRequest.paymentCurrency === "USD" || bookingRequest.paymentCurrency === "ETB") { - setPaymentCurrency(bookingRequest.paymentCurrency); - } if (bookingRequest.notes) setNotes(bookingRequest.notes); }, [bookingRequest, prefilled]); @@ -1761,11 +1757,7 @@ export default function GlCreateBookingForm() { Billing currency - {isIntercity - ? "Intercity shipments are invoiced in ETB." - : bookingRequest?.paymentCurrency - ? "Requested by the customer on their shipment request." - : "The contract is quoted in USD — pick the currency this shipment is invoiced in."} + Shipments are invoiced in ETB.