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:
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,
},