web_lib

Common web application libraries
git clone https://radroots.dev/git/web_lib.git
Log | Files | Refs | LICENSE

commit 3d7855921f37bd08af0d799ffcd691b7110afc0c
parent 5aef8fce8711e266b1819c3e9588bc3034b3c086
Author: triesap <triesap@radroots.dev>
Date:   Sat,  3 Jan 2026 22:19:00 +0000

listing: refactor farm product payload and nostr listing parsing

- Rename pricing fields to unit_price_* and add bin display metadata
- Update farm product schema validators for float-positive bin amounts
- Replace quantity/price tags with radroots:bin and radroots:price parsing
- Enforce farm ref, primary bin selection, and availability/delivery extraction

Diffstat:
Mapps-lib-pwa/src/lib/types/views/farms.ts | 11+++++------
Mapps-lib-pwa/src/lib/utils/farm/schema.ts | 13++++++-------
Mnostr/src/events/listing/parse.ts | 225++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mutils/src/validation/regex.ts | 24++++++++++++------------
4 files changed, 189 insertions(+), 84 deletions(-)

diff --git a/apps-lib-pwa/src/lib/types/views/farms.ts b/apps-lib-pwa/src/lib/types/views/farms.ts @@ -29,13 +29,12 @@ export type IViewFarmsProductsAddSubmitPayload = { product: string; process: string; description: string; - price_amount: number; - price_currency: string; - price_quantity_unit: string; + unit_price_amount: number; + unit_price_currency: string; + bin_display_amount: number; + bin_display_unit: string; + bin_label: string; photos: string[]; - quantity_amount: number; - quantity_unit: string; - quantity_label: string; geolocation_point: GeolocationPoint; geocode_result: GeocoderReverseResult; }; diff --git a/apps-lib-pwa/src/lib/utils/farm/schema.ts b/apps-lib-pwa/src/lib/utils/farm/schema.ts @@ -1,7 +1,7 @@ import { dev } from "$app/environment"; import type { IViewFarmsAddSubmission, IViewFarmsProductsAddSubmitPayload } from "$lib/types/views/farms"; import { schema_geocode_result, schema_geolocation_point } from "@radroots/geo"; -import { form_fields, util_rxp, zf_numi_pos, zf_price } from "@radroots/utils"; +import { form_fields, util_rxp, zf_numf_pos, zf_price } from "@radroots/utils"; import { z } from "zod"; @@ -18,13 +18,12 @@ export const schema_view_farms_products_add_submission: z.ZodSchema<IViewFarmsPr product: z.string().regex(form_fields.product_key.validate), process: z.string().regex(form_fields.product_process.validate), description: z.string().regex(form_fields.product_description.validate), - price_amount: zf_price, - price_currency: z.string().regex(form_fields.price_currency.validate), - price_quantity_unit: z.string().regex(form_fields.quantity_unit.validate), + unit_price_amount: zf_price, + unit_price_currency: z.string().regex(form_fields.price_currency.validate), + bin_display_amount: zf_numf_pos, + bin_display_unit: z.string().regex(form_fields.bin_display_unit.validate), + bin_label: z.string().regex(form_fields.bin_label.validate), photos: z.array(z.string().regex(dev ? util_rxp.url_image_upload_dev : util_rxp.url_image_upload)), - quantity_amount: zf_numi_pos, - quantity_unit: z.string().regex(form_fields.quantity_unit.validate), - quantity_label: z.string().regex(form_fields.quantity_label.validate), geolocation_point: schema_geolocation_point, geocode_result: schema_geocode_result, }); diff --git a/nostr/src/events/listing/parse.ts b/nostr/src/events/listing/parse.ts @@ -1,22 +1,24 @@ +import { RadrootsCoreUnit } from "@radroots/core-bindings"; import type { + RadrootsCoreDiscount, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, } from "@radroots/core-bindings"; import { KIND_LISTING, + radroots_listing_bin_schema, radroots_listing_discount_schema, radroots_listing_image_schema, radroots_listing_location_schema, - radroots_listing_price_schema, radroots_listing_product_schema, - radroots_listing_quantity_schema, radroots_listing_schema, type RadrootsListing, - type RadrootsListingDiscount, + type RadrootsListingAvailability, + type RadrootsListingDeliveryMethod, + type RadrootsListingFarmRef, type RadrootsListingImage, - type RadrootsListingLocation, - type RadrootsListingQuantity, + type RadrootsListingStatus, } from "@radroots/events-bindings"; import type { NostrEvent } from "../../types/nostr.js"; import { get_event_tag, get_event_tags, parse_nostr_event_basis } from "../lib.js"; @@ -27,6 +29,22 @@ export type RadrootsListingNostrEvent = NostrEventBasis<typeof KIND_LISTING> & { type CoreUnit = RadrootsCoreQuantity["unit"]; type CoreCurrency = RadrootsCoreMoney["currency"]; +type ListingBinDraft = { + bin_id: string; + quantity?: RadrootsCoreQuantity; + price_per_canonical_unit?: RadrootsCoreQuantityPrice; + display_amount?: number; + display_unit?: CoreUnit; + display_label?: string; + display_price?: RadrootsCoreMoney; + display_price_unit?: CoreUnit; +}; + +type ListingBinDraftComplete = ListingBinDraft & { + quantity: RadrootsCoreQuantity; + price_per_canonical_unit: RadrootsCoreQuantityPrice; +}; + type ListingLocationDraft = { primary?: string; city?: string; @@ -65,73 +83,87 @@ const parse_unit_code = (unit?: string): CoreUnit | undefined => { case "each": case "ea": case "count": - return "Each" as CoreUnit; + return RadrootsCoreUnit.Each; case "kg": case "kilogram": case "kilograms": - return "MassKg" as CoreUnit; + return RadrootsCoreUnit.MassKg; case "g": case "gram": case "grams": - return "MassG" as CoreUnit; + return RadrootsCoreUnit.MassG; case "oz": case "ounce": case "ounces": - return "MassOz" as CoreUnit; + return RadrootsCoreUnit.MassOz; case "lb": case "pound": case "pounds": - return "MassLb" as CoreUnit; + return RadrootsCoreUnit.MassLb; case "l": case "liter": case "litre": case "liters": case "litres": - return "VolumeL" as CoreUnit; + return RadrootsCoreUnit.VolumeL; case "ml": case "milliliter": case "millilitre": case "milliliters": case "millilitres": - return "VolumeMl" as CoreUnit; + return RadrootsCoreUnit.VolumeMl; default: return undefined; } }; -const parse_quantity_tag = (tag: string[]): RadrootsListingQuantity | undefined => { - if (tag.length < 3) return undefined; - const amount = to_number(tag[1]); - const unit = parse_unit_code(tag[2]); +const parse_bin_tag = (tag: string[]): ListingBinDraft | undefined => { + if (tag.length < 4) return undefined; + const bin_id = clean_string(tag[1]); + if (!bin_id) return undefined; + const amount = to_number(tag[2]); + const unit = parse_unit_code(tag[3]); if (amount === undefined || !unit) return undefined; - const label = clean_string(tag[3]); - const count = to_number(tag[4]); - const value: RadrootsCoreQuantity = label ? { amount, unit, label } : { amount, unit }; - return radroots_listing_quantity_schema.parse({ value, label, count }); -}; - -const parse_price_tag = (tag: string[]): RadrootsCoreQuantityPrice | undefined => { - if (tag.length < 5) return undefined; - const amount = to_number(tag[1]); - const currency = parse_currency_code(tag[2]); - const quantity_amount = to_number(tag[3]); - const quantity_unit = parse_unit_code(tag[4]); + const quantity: RadrootsCoreQuantity = { amount, unit }; + const draft: ListingBinDraft = { bin_id, quantity }; + const display_amount = to_number(tag[4]); + const display_unit = parse_unit_code(tag[5]); + if (display_amount !== undefined && display_unit) { + draft.display_amount = display_amount; + draft.display_unit = display_unit; + const display_label = clean_string(tag[6]); + if (display_label) draft.display_label = display_label; + } + return draft; +}; + +const parse_bin_price_tag = (tag: string[]): ListingBinDraft | undefined => { + if (tag.length < 6) return undefined; + const bin_id = clean_string(tag[1]); + if (!bin_id) return undefined; + const amount = to_number(tag[2]); + const currency = parse_currency_code(tag[3]); + const quantity_amount = to_number(tag[4]); + const quantity_unit = parse_unit_code(tag[5]); if (amount === undefined || !currency || quantity_amount === undefined || !quantity_unit) return undefined; - const label = clean_string(tag[5]); const money: RadrootsCoreMoney = { amount, currency }; - const quantity: RadrootsCoreQuantity = label - ? { amount: quantity_amount, unit: quantity_unit, label } - : { amount: quantity_amount, unit: quantity_unit }; - return radroots_listing_price_schema.parse({ amount: money, quantity }); + const quantity: RadrootsCoreQuantity = { amount: quantity_amount, unit: quantity_unit }; + const price_per_canonical_unit: RadrootsCoreQuantityPrice = { amount: money, quantity }; + const draft: ListingBinDraft = { bin_id, price_per_canonical_unit }; + const display_price_amount = to_number(tag[6]); + const display_price_unit = parse_unit_code(tag[7]); + if (display_price_amount !== undefined && display_price_unit) { + draft.display_price = { amount: display_price_amount, currency }; + draft.display_price_unit = display_price_unit; + } + return draft; }; -const parse_discount_tag = (tag: string[]): RadrootsListingDiscount | undefined => { - const prefix = "price-discount-"; - if (!tag[0]?.startsWith(prefix) || tag.length < 2) return undefined; - const kind = tag[0].slice(prefix.length) as RadrootsListingDiscount["kind"]; +const parse_discount_tag = (tag: string[]): RadrootsCoreDiscount | undefined => { + if (tag[0] !== "radroots:discount" || !tag[1]) return undefined; try { - const amount = JSON.parse(tag[1]); - return radroots_listing_discount_schema.parse({ kind, amount }); + const payload = JSON.parse(tag[1]); + return radroots_listing_discount_schema.parse(payload); } catch { return undefined; } @@ -147,19 +179,69 @@ const parse_image_tag = (tag: string[]): RadrootsListingImage | undefined => { return radroots_listing_image_schema.parse(image); }; -const is_listing_quantity = (value: RadrootsListingQuantity | undefined): value is RadrootsListingQuantity => - Boolean(value); - -const is_listing_price = (value: RadrootsCoreQuantityPrice | undefined): value is RadrootsCoreQuantityPrice => - Boolean(value); +const parse_farm_ref = (farm_pubkey?: string, farm_address?: string): RadrootsListingFarmRef | undefined => { + const pubkey = clean_string(farm_pubkey); + const address = clean_string(farm_address); + if (!pubkey || !address) return undefined; + const parts = address.split(":"); + if (parts.length !== 3) return undefined; + const [kind, addr_pubkey, d_tag] = parts; + if (kind !== "30340") return undefined; + if (addr_pubkey !== pubkey) return undefined; + if (!d_tag?.trim()) return undefined; + return { pubkey, d_tag }; +}; const is_listing_discount = ( - value: RadrootsListingDiscount | undefined, -): value is RadrootsListingDiscount => Boolean(value); + value: RadrootsCoreDiscount | undefined, +): value is RadrootsCoreDiscount => Boolean(value); const is_listing_image = (value: RadrootsListingImage | undefined): value is RadrootsListingImage => Boolean(value); +const is_listing_bin = (value: ListingBinDraft | undefined): value is ListingBinDraftComplete => + Boolean(value?.quantity && value.price_per_canonical_unit); + +const upsert_bin_draft = ( + drafts: Record<string, ListingBinDraft>, + order: string[], + draft: ListingBinDraft, +): void => { + const existing = drafts[draft.bin_id]; + if (!existing) order.push(draft.bin_id); + drafts[draft.bin_id] = { ...existing, ...draft, bin_id: draft.bin_id }; +}; + +const parse_listing_status = (value?: string): RadrootsListingStatus | undefined => { + const cleaned = clean_string(value); + if (!cleaned) return undefined; + if (cleaned === "active") return { kind: "active" }; + if (cleaned === "sold") return { kind: "sold" }; + return { kind: "other", amount: { value: cleaned } }; +}; + +const parse_listing_availability = (tags: string[][]): RadrootsListingAvailability | undefined => { + const status = parse_listing_status(get_event_tag(tags, "status")); + if (status) return { kind: "status", amount: { status } }; + const start = to_number(get_event_tag(tags, "published_at")); + const end = to_number(get_event_tag(tags, "expires_at")); + if (start === undefined && end === undefined) return undefined; + return { kind: "window", amount: { start, end } }; +}; + +const parse_delivery_method = (tags: string[][]): RadrootsListingDeliveryMethod | undefined => { + const delivery_tag = get_event_tags(tags, "delivery")[0]; + const kind = clean_string(delivery_tag?.[1]); + if (!kind) return undefined; + if (kind === "pickup") return { kind: "pickup" }; + if (kind === "local_delivery") return { kind: "local_delivery" }; + if (kind === "shipping") return { kind: "shipping" }; + if (kind !== "other") return undefined; + const method = clean_string(delivery_tag?.[2]); + if (!method) return undefined; + return { kind: "other", amount: { method } }; +}; + export const parse_nostr_listing_event = ( event: NostrEvent, ): RadrootsListingNostrEvent | undefined => { @@ -169,6 +251,11 @@ export const parse_nostr_listing_event = ( const tags = event.tags; const d_tag = get_event_tag(tags, "d"); + if (!clean_string(d_tag)) return undefined; + const farm_pubkey = get_event_tag(tags, "p"); + const farm_address = get_event_tag(tags, "a"); + const farm = parse_farm_ref(farm_pubkey, farm_address); + if (!farm) return undefined; const product_raw = { key: get_event_tag(tags, "key"), @@ -184,16 +271,28 @@ export const parse_nostr_listing_event = ( const product = radroots_listing_product_schema.parse(product_raw); - const quantities = get_event_tags(tags, "quantity") - .map(parse_quantity_tag) - .filter(is_listing_quantity); - - const prices = get_event_tags(tags, "price") - .map(parse_price_tag) - .filter(is_listing_price); + const bin_drafts: Record<string, ListingBinDraft> = {}; + const bin_order: string[] = []; + for (const tag of get_event_tags(tags, "radroots:bin")) { + const draft = parse_bin_tag(tag); + if (draft) upsert_bin_draft(bin_drafts, bin_order, draft); + } + for (const tag of get_event_tags(tags, "radroots:price")) { + const draft = parse_bin_price_tag(tag); + if (draft) upsert_bin_draft(bin_drafts, bin_order, draft); + } + const bins = bin_order + .map(bin_id => bin_drafts[bin_id]) + .filter(is_listing_bin) + .map(bin => radroots_listing_bin_schema.parse(bin)); + if (!bins.length) return undefined; + const primary_bin_tag = clean_string(get_event_tag(tags, "radroots:primary_bin")); + const primary_bin_id = bins.some(bin => bin.bin_id === primary_bin_tag) + ? primary_bin_tag + : bins[0]?.bin_id; + if (!primary_bin_id) return undefined; - const discounts = tags - .filter(t => t[0]?.startsWith("price-discount-")) + const discounts = get_event_tags(tags, "radroots:discount") .map(parse_discount_tag) .filter(is_listing_discount); @@ -223,18 +322,26 @@ export const parse_nostr_listing_event = ( } const location = location_raw.primary - ? radroots_listing_location_schema.parse(location_raw as RadrootsListingLocation) + ? radroots_listing_location_schema.parse(location_raw) : undefined; - const listing = radroots_listing_schema.parse({ + const inventory_available = to_number(get_event_tag(tags, "inventory")); + const availability = parse_listing_availability(tags); + const delivery_method = parse_delivery_method(tags); + + const listing_base = radroots_listing_schema.parse({ d_tag, product, - quantities, - prices, + primary_bin_id, + bins, discounts: discounts.length ? discounts : undefined, + inventory_available, + availability, + delivery_method, location, images: images.length ? images : undefined, }); + const listing: RadrootsListing = { ...listing_base, farm }; return { ...ev, listing }; } catch { return undefined; diff --git a/utils/src/validation/regex.ts b/utils/src/validation/regex.ts @@ -38,8 +38,8 @@ export const util_rxp = { currency_symbol: /(?:[A-Za-z]{3,5}\$|\p{Sc})/u, currency_marker: /(?:[A-Za-z]{2,4}[^\d\s]+|[^\d\s]{1,3}[A-Za-z]{2,4})/, ws_proto: /^(wss:\/\/|ws:\/\/)/, - quantity_unit: /^(kg|lb|g)$/, - quantity_unit_ch: /[A-Za-z]$/, + bin_display_unit: /^(kg|lb|g)$/, + bin_display_unit_ch: /[A-Za-z]$/, url_image_upload: /^file:\/\/.*\.(png|jpg|jpeg|gif|webp|bmp|svg)$/, url_image_upload_dev: /^file:\/\/.*\.(png|jpg|jpeg|gif|webp|bmp|svg)$/, country_code_a2: /^[A-Za-z]{2}$/, @@ -58,9 +58,9 @@ export type FormFieldsKey = | `product_description` | `price` | `price_currency` - | `quantity_unit` - | `quantity` - | `quantity_label` + | `bin_display_unit` + | `bin_display_amount` + | `bin_label` | `farm_name` | `farm_size` | `area` @@ -102,15 +102,15 @@ export const form_fields: Record<FormFieldsKey, FormField> = { charset: util_rxp.price_cur_ch, validate: util_rxp.price_cur, }, - quantity: { - charset: util_rxp.num, - validate: util_rxp.num, + bin_display_amount: { + charset: util_rxp.float_pos_ch, + validate: util_rxp.float_pos, }, - quantity_unit: { - charset: util_rxp.quantity_unit_ch, - validate: util_rxp.quantity_unit, + bin_display_unit: { + charset: util_rxp.bin_display_unit_ch, + validate: util_rxp.bin_display_unit, }, - quantity_label: { + bin_label: { charset: util_rxp.alphanum_ch, validate: util_rxp.alphanum, },