From 7a7ef23ca711a6adc29e7079360c41b10c07e7dd Mon Sep 17 00:00:00 2001 From: Zeeshan Tariq Date: Wed, 22 Apr 2026 15:49:28 +0500 Subject: [PATCH] adds pearls a receipt recording app --- package.json | 1 + src/alex_frontend/core/config/apps.ts | 4 +- .../features/pearl/components/FieldError.tsx | 19 + .../pearl/components/HeaderFields.tsx | 129 ++++++ .../features/pearl/components/LineItemRow.tsx | 193 +++++++++ .../pearl/components/LineItemsEditor.tsx | 69 ++++ .../features/pearl/components/NotesField.tsx | 31 ++ .../pearl/components/PaymentSection.tsx | 59 +++ .../features/pearl/components/PearlForm.tsx | 88 ++++ .../features/pearl/components/TagsInput.tsx | 82 ++++ .../features/pearl/components/TapePreview.tsx | 138 +++++++ .../pearl/components/TaxLinesEditor.tsx | 95 +++++ .../core/features/pearl/components/Totals.tsx | 59 +++ .../core/features/pearl/constants.ts | 65 +++ .../features/pearl/hooks/useComputedTotals.ts | 46 +++ .../features/pearl/hooks/useMintReceipt.ts | 74 ++++ src/alex_frontend/core/features/pearl/plan.md | 7 + .../core/features/pearl/schema.ts | 58 +++ .../features/pearl/utils/buildArweaveTags.ts | 84 ++++ .../features/pearl/utils/renderTapeImage.ts | 57 +++ .../features/perpetua/actions/AddItem.tsx | 47 ++- .../perpetua/components/ShelfDetail.tsx | 57 ++- src/alex_frontend/lbry/public/logos/Pearl.svg | 33 ++ src/alex_frontend/lbry/routeTree.gen.ts | 23 ++ .../lbry/src/components/Tabs/AppsTab.tsx | 2 +- .../lbry/src/pages/pearl/PearlPage.tsx | 382 ++++++++++++++++++ .../lbry/src/routes/_auth/app/pearl.lazy.tsx | 6 + 27 files changed, 1900 insertions(+), 8 deletions(-) create mode 100644 src/alex_frontend/core/features/pearl/components/FieldError.tsx create mode 100644 src/alex_frontend/core/features/pearl/components/HeaderFields.tsx create mode 100644 src/alex_frontend/core/features/pearl/components/LineItemRow.tsx create mode 100644 src/alex_frontend/core/features/pearl/components/LineItemsEditor.tsx create mode 100644 src/alex_frontend/core/features/pearl/components/NotesField.tsx create mode 100644 src/alex_frontend/core/features/pearl/components/PaymentSection.tsx create mode 100644 src/alex_frontend/core/features/pearl/components/PearlForm.tsx create mode 100644 src/alex_frontend/core/features/pearl/components/TagsInput.tsx create mode 100644 src/alex_frontend/core/features/pearl/components/TapePreview.tsx create mode 100644 src/alex_frontend/core/features/pearl/components/TaxLinesEditor.tsx create mode 100644 src/alex_frontend/core/features/pearl/components/Totals.tsx create mode 100644 src/alex_frontend/core/features/pearl/constants.ts create mode 100644 src/alex_frontend/core/features/pearl/hooks/useComputedTotals.ts create mode 100644 src/alex_frontend/core/features/pearl/hooks/useMintReceipt.ts create mode 100644 src/alex_frontend/core/features/pearl/plan.md create mode 100644 src/alex_frontend/core/features/pearl/schema.ts create mode 100644 src/alex_frontend/core/features/pearl/utils/buildArweaveTags.ts create mode 100644 src/alex_frontend/core/features/pearl/utils/renderTapeImage.ts create mode 100644 src/alex_frontend/lbry/public/logos/Pearl.svg create mode 100644 src/alex_frontend/lbry/src/pages/pearl/PearlPage.tsx create mode 100644 src/alex_frontend/lbry/src/routes/_auth/app/pearl.lazy.tsx diff --git a/package.json b/package.json index 047b13852..ab2e67d96 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "flatted": "^3.3.1", "formik": "^2.4.6", "graphql": "^16.11.0", + "html-to-image": "^1.11.13", "html-to-text": "^9.0.5", "human-crypto-keys": "^0.1.4", "ic-mops": "^1.0.1", diff --git a/src/alex_frontend/core/config/apps.ts b/src/alex_frontend/core/config/apps.ts index 003625415..ebd685ba2 100644 --- a/src/alex_frontend/core/config/apps.ts +++ b/src/alex_frontend/core/config/apps.ts @@ -5,6 +5,7 @@ export interface App { path: string; logo?: string; comingSoon?: boolean; + hideFromHeader?: boolean; } export const appsData: App[] = [ @@ -12,8 +13,9 @@ export const appsData: App[] = [ { name: 'Alexandrian', description: 'Library', path: '/app/alexandrian', logo: '/logos/Alexandrian.svg' }, { name: 'Permasearch', description: 'Explore', path: '/app/permasearch', logo: '/logos/Permasearch.svg' }, { name: 'Emporium', description: 'Trade', path: '/app/emporium', logo: '/logos/Emporium.svg' }, - { name: 'Pinax', description: 'Upload', path: '/app/pinax', logo: '/logos/Pinax.svg', comingSoon: false }, { name: 'Sonora', description: 'Audio', path: '/app/sonora', logo: '/logos/Sonora.svg' }, + { name: 'Pinax', description: 'Upload', path: '/app/pinax', logo: '/logos/Pinax.svg', comingSoon: false, hideFromHeader: true }, + { name: 'Pearl', description: 'Receipt', path: '/app/pearl', logo: '/logos/Pearl.svg', comingSoon: false, hideFromHeader: true }, { name: 'Syllogos', description: 'Aggregate', path: '/app/syllogos', logo: '/logos/Syllogos.svg', comingSoon: true }, { name: 'Bibliotheca', description: 'Library', path: '/app/bibliotheca', comingSoon: true }, { name: 'Dialectica', description: 'Debate', path: '/app/dialectica', comingSoon: true }, diff --git a/src/alex_frontend/core/features/pearl/components/FieldError.tsx b/src/alex_frontend/core/features/pearl/components/FieldError.tsx new file mode 100644 index 000000000..241e15537 --- /dev/null +++ b/src/alex_frontend/core/features/pearl/components/FieldError.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { useFormikContext, getIn } from "formik"; + +/** + * Renders a Yup/Formik error for a (possibly nested) field path, gated on the + * field being touched. Safe for paths like `items[0].description` or + * `taxLines[2].ratePercent` via Formik's `getIn`. + */ +export function FieldError({ name }: { name: string }) { + const { errors, touched } = useFormikContext(); + const error = getIn(errors, name); + const isTouched = getIn(touched, name); + if (!isTouched || !error) return null; + return ( +

+ {String(error)} +

+ ); +} diff --git a/src/alex_frontend/core/features/pearl/components/HeaderFields.tsx b/src/alex_frontend/core/features/pearl/components/HeaderFields.tsx new file mode 100644 index 000000000..92f63e5c2 --- /dev/null +++ b/src/alex_frontend/core/features/pearl/components/HeaderFields.tsx @@ -0,0 +1,129 @@ +import React, { useState } from "react"; +import { useFormikContext } from "formik"; +import { ChevronDown, Check } from "lucide-react"; +import { Input } from "@/lib/components/input"; +import { Button } from "@/lib/components/button"; +import { Label } from "@/lib/components/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/lib/components/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/lib/components/command"; +import type { Receipt } from "../schema"; +import { CURRENCIES } from "../constants"; +import { FieldError } from "./FieldError"; + +const labelClass = + "text-xs font-medium text-gray-700 dark:text-gray-300 font-roboto-condensed"; + +export function HeaderFields() { + const { values, handleChange, setFieldValue } = useFormikContext(); + + return ( +
+
+ + + +
+ +
+ + + +
+ +
+ + setFieldValue("currency", code)} + /> +
+
+ ); +} + +/** Currency combobox, styled to match the surrounding inputs (rounded-md, + * same border + fill) so it reads as part of the header row. */ +function CurrencyPicker({ + value, + onChange, +}: { + value: string; + onChange: (code: string) => void; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + No currency found. + + {CURRENCIES.map(c => ( + { + onChange(c.code); + setOpen(false); + }} + > + {c.code} + {c.name} + {c.code === value && } + + ))} + + + + + + ); +} diff --git a/src/alex_frontend/core/features/pearl/components/LineItemRow.tsx b/src/alex_frontend/core/features/pearl/components/LineItemRow.tsx new file mode 100644 index 000000000..c63a8aec7 --- /dev/null +++ b/src/alex_frontend/core/features/pearl/components/LineItemRow.tsx @@ -0,0 +1,193 @@ +import React from "react"; +import { FieldArray, useFormikContext } from "formik"; +import { nanoid } from "nanoid"; +import { Plus, X } from "lucide-react"; +import { Input } from "@/lib/components/input"; +import { Button } from "@/lib/components/button"; +import type { Receipt, LineItem, LineItemChild } from "../schema"; +import { useComputedTotals } from "../hooks/useComputedTotals"; +import { FieldError } from "./FieldError"; + +interface LineItemRowProps { + item: LineItem; + index: number; + onRemove: () => void; +} + +const numericInputClass = "font-mono tabular-nums text-right"; + +export function LineItemRow({ item, index, onRemove }: LineItemRowProps) { + const { handleChange, setFieldValue } = useFormikContext(); + const totals = useComputedTotals(); + const hasChildren = item.children.length > 0; + const namePrefix = `items[${index}]`; + + return ( +
+
+
+ + +
+
+ + +
+
+ + {!hasChildren && } +
+
+ {totals.itemTotal(item).toFixed(2)} +
+
+ +
+
+ + + {({ push, remove }) => { + if (item.children.length === 0) { + return ( + + ); + } + return ( +
+ {/* Left rule spans the whole sub-items block; only the + description column is visually indented. Qty / Unit / Total / + actions stay in the same x-position as the parent row. */} +
+
+ {item.children.map((child: LineItemChild, ci: number) => { + const cPrefix = `${namePrefix}.children[${ci}]`; + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ {(child.quantity * child.unitPrice).toFixed(2)} +
+
+ +
+
+ ); + })} +
+
+ +
+
+ ); + }} + +
+ ); +} diff --git a/src/alex_frontend/core/features/pearl/components/LineItemsEditor.tsx b/src/alex_frontend/core/features/pearl/components/LineItemsEditor.tsx new file mode 100644 index 000000000..aa7d0b77f --- /dev/null +++ b/src/alex_frontend/core/features/pearl/components/LineItemsEditor.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { FieldArray, useFormikContext } from "formik"; +import { nanoid } from "nanoid"; +import { Plus } from "lucide-react"; +import { Button } from "@/lib/components/button"; +import type { Receipt } from "../schema"; +import { CURRENCIES } from "../constants"; +import { LineItemRow } from "./LineItemRow"; + +export function LineItemsEditor() { + // Use submitCount (not touched.items): Formik's touch-all on failed submit + // walks `values` and can't mark leaves of an empty array, so touched.items + // stays undefined even after a submit attempt on an empty list. + const { values, errors, submitCount } = useFormikContext(); + const symbol = + CURRENCIES.find(c => c.code === values.currency)?.symbol ?? values.currency; + + return ( + + {({ push, remove }) => ( +
+ {values.items.length > 0 && ( + <> +
+
Description
+
Qty
+
Unit ({symbol})
+
Total
+
+
+ +
+ {values.items.map((item, i) => ( + remove(i)} + /> + ))} +
+ + )} + + + + {typeof errors.items === "string" && submitCount > 0 && ( +

{errors.items}

+ )} +
+ )} + + ); +} diff --git a/src/alex_frontend/core/features/pearl/components/NotesField.tsx b/src/alex_frontend/core/features/pearl/components/NotesField.tsx new file mode 100644 index 000000000..7329e35ab --- /dev/null +++ b/src/alex_frontend/core/features/pearl/components/NotesField.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { useFormikContext } from "formik"; +import { Label } from "@/lib/components/label"; +import { Textarea } from "@/lib/components/textarea"; +import type { Receipt } from "../schema"; + +export function NotesField() { + const { values, handleChange } = useFormikContext(); + const count = values.notes.length; + return ( +
+
+ + + {count}/2000 + +
+