1// ===== pkg/config/config.go =====
   2// Package config pkg/config/config.go β€” the store's configuration.
   3//
   4// Values are sourced from a bash config file named by the MENV environment
   5// variable (see `m2 gen` for the template), overridable by CLI flags. The
   6// flag-registration helpers here bind a Values field to a flag whose default
   7// is read from the MENV file, so every command that registers flags gets the
   8// file-sourced defaults for free.
   9package config
  10
  11import (
  12	"embed"
  13	"fmt"
  14	"os"
  15	"reflect"
  16	"runtime"
  17	"strconv"
  18	"strings"
  19	"time"
  20
  21	"github.com/bitfield/script"
  22	"github.com/spf13/cobra"
  23	"github.com/stripe/stripe-go/v81"
  24)
  25
  26//go:embed *.go
  27var Source embed.FS
  28
  29// MENV names the bash-sourced config file, from the environment.
  30var MENV = os.Getenv("MENV")
  31
  32// Values holds every configurable value; field names map to the MENV file's
  33// uppercase variable names (SITENAME, PRODUCTSCSV, ...).
  34type Values struct {
  35	Teststripekey      bool
  36	ProductsCSV        string
  37	WebPort            int
  38	StripelivePK       string
  39	StripeliveSK       string
  40	StripetestPK       string
  41	StripetestSK       string
  42	StripeSK           string
  43	StripePK           string
  44	Siteimagesrc       string
  45	Siteordersurl      string
  46	Sitename           string
  47	Siteext            string
  48	Sitedomain         string
  49	Sitelongname       string
  50	Sitetagline        string
  51	Sitemeta           string
  52	Siteprettyname     string
  53	Siteprettynamecap  string
  54	Siteprettynamecaps string
  55	SiteASCIILogo      string
  56	Tgcontact          string
  57	Tgchannel          string
  58	UseTinygo          bool
  59	WasmSRC            []string
  60	WasmExecPath       string
  61	WasmExecPathGo     string
  62	WasmExecPathTinyGo string
  63	Gobuild            string
  64	Tinygobuild        string
  65	Buildwasmwith      string
  66	LDFlagsX           string
  67	PrinterName        string        // CUPS queue name (blank = default)
  68	CupsOptions        string        // comma-separated -o options
  69	LpTimeout          time.Duration // timeout for `lp`
  70	Storeurl           string        // tui client mode: browse this store over http instead of local files
  71}
  72
  73// F is the live configuration, shared by every package.
  74var F = Values{
  75	//	WasmSRC: []string{"wasm/stl2.go","wasm/checkout_wasm.go"},
  76	WasmExecPath:       runtime.GOROOT() + "/lib/wasm/wasm_exec.js",                                     //nolint
  77	WasmExecPathGo:     runtime.GOROOT() + "/lib/wasm/wasm_exec.js",                                     //nolint
  78	WasmExecPathTinyGo: strings.TrimSuffix(runtime.GOROOT(), "go") + "tinygo" + "/targets/wasm_exec.js", //nolint
  79	Gobuild:            "go build",
  80	Tinygobuild:        "tinygo build -target=wasm --no-debug",
  81	Buildwasmwith:      "go build",
  82	LDFlagsX:           "stripePK",
  83}
  84
  85// InitStripe selects the live or test key pair, hands the secret key to the
  86// stripe library, and bakes the publishable key into the wasm ldflags.
  87func InitStripe() {
  88	F.StripeSK = F.StripeliveSK
  89	F.StripePK = F.StripelivePK
  90	if F.Teststripekey {
  91		F.StripeSK = F.StripetestSK
  92		F.StripePK = F.StripetestPK
  93	}
  94	stripe.Key = F.StripeSK
  95	// awkward way to do this
  96	F.LDFlagsX += "=" + F.StripePK
  97}
  98
  99var (
 100	// Hardcoded array of valid shorthand characters, excluding "h"
 101	shorthandChars = []rune("abcdefgijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
 102	nextShortIndex = 0 // Index for the next shorthand flag
 103)
 104
 105func getNextShortFlag() string {
 106	if nextShortIndex >= len(shorthandChars) {
 107		return ""
 108	}
 109	short := shorthandChars[nextShortIndex]
 110	nextShortIndex++
 111	return string(short)
 112}
 113
 114var a = true
 115var b = false
 116
 117// AddStringFlag registers a string flag bound to a field of F on each command,
 118// defaulting to the MENV file's value for the field's uppercase name.
 119func AddStringFlag(cmds []*cobra.Command, fieldPtr *string, description string) {
 120	for i := range cmds {
 121		cmds[i].Flags().StringVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecString(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(s string) string {
 122			if s != "" {
 123				s = "-" + s
 124			}
 125			return s
 126		}(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
 127	}
 128}
 129
 130// AddStringSliceFlag is AddStringFlag for []string fields (bash arrays).
 131func AddStringSliceFlag(cmds []*cobra.Command, fieldPtr *[]string, description string) {
 132	for i := range cmds {
 133		cmds[i].Flags().StringSliceVarP(
 134			fieldPtr,
 135			ccc(fieldPtr, &F, b),
 136			getNextShortFlag(),
 137			ScriptExecStringSlice(fmt.Sprintf("${%s[@]}", ccc(fieldPtr, &F, a))),
 138			fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
 139		)
 140	}
 141}
 142
 143// AddBoolFlag is AddStringFlag for bool fields.
 144func AddBoolFlag(cmds []*cobra.Command, fieldPtr *bool, description string) {
 145	for i := range cmds {
 146		cmds[i].Flags().BoolVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecBool(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(b bool) string {
 147			return "-" + strconv.FormatBool(b)
 148		}(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
 149	}
 150}
 151
 152// AddIntFlag is AddStringFlag for int fields.
 153func AddIntFlag(cmds []*cobra.Command, fieldPtr *int, description string) {
 154	for i := range cmds {
 155		cmds[i].Flags().IntVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecInt(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(i int) string {
 156			return fmt.Sprintf("-%d", i)
 157		}(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
 158	}
 159}
 160
 161// AddDurationFlag is AddStringFlag for time.Duration fields.
 162func AddDurationFlag(cmds []*cobra.Command, fieldPtr *time.Duration, description string) {
 163	for i := range cmds {
 164		// Keep parity with the pattern of embedding a "-" when a non-zero default is present.
 165		def := ScriptExecDuration(fmt.Sprintf("${%s%s}",
 166			ccc(fieldPtr, &F, a),
 167			func(d time.Duration) string {
 168				if d != 0 {
 169					return "-" + d.String() // e.g. "-5s"
 170				}
 171				return ""
 172			}(*fieldPtr),
 173		))
 174		cmds[i].Flags().DurationVarP(
 175			fieldPtr,
 176			ccc(fieldPtr, &F, b),
 177			getNextShortFlag(),
 178			def,
 179			fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
 180		)
 181	}
 182}
 183
 184// ccc finds the name of the struct field val points into, upper or lower case.
 185func ccc(val interface{}, strct interface{}, upper bool) string {
 186	v := reflect.ValueOf(strct)
 187	if v.Kind() == reflect.Ptr {
 188		v = v.Elem()
 189	}
 190	if v.Kind() != reflect.Struct {
 191		panic("uc: second argument must be a pointer to a struct")
 192	}
 193	for i := 0; i < v.NumField(); i++ {
 194		field := v.Field(i)
 195		if field.CanAddr() && field.Addr().Interface() == val {
 196			if upper {
 197				return strings.ToUpper(v.Type().Field(i).Name)
 198			}
 199			return strings.ToLower(v.Type().Field(i).Name)
 200		}
 201	}
 202	return ""
 203}
 204
 205// ScriptExecString evaluates a bash expression with the MENV file sourced.
 206func ScriptExecString(s string) string {
 207	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
 208	if err == nil {
 209		return strings.TrimSpace(z)
 210	}
 211	return ""
 212}
 213
 214// ScriptExecStringSlice evaluates a bash array expression with the MENV file
 215// sourced. Empty entries are dropped: an empty or unset array still prints
 216// one empty line, which would otherwise come back as [""] and defeat every
 217// `len == 0` check downstream (e.g. WASMSRC=() disabling wasm).
 218func ScriptExecStringSlice(s string) []string {
 219	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s" "%s"'`, MENV, "%s\n", s)).Slice()
 220	if err != nil {
 221		return nil
 222	}
 223	out := z[:0]
 224	for _, e := range z {
 225		if strings.TrimSpace(e) != "" {
 226			out = append(out, e)
 227		}
 228	}
 229	return out
 230}
 231
 232// ScriptExecBool evaluates a bash expression as a bool with the MENV file sourced.
 233func ScriptExecBool(s string) bool {
 234	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
 235	if err == nil {
 236		b, err := strconv.ParseBool(z)
 237		if err == nil {
 238			return b
 239		}
 240	}
 241	return false
 242}
 243
 244// ScriptExecInt evaluates a bash expression as an int with the MENV file sourced.
 245func ScriptExecInt(s string) int {
 246	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
 247	if err == nil {
 248		if z == "" {
 249			return 0
 250		}
 251		i, err := strconv.Atoi(z)
 252		if err == nil {
 253			return i
 254		}
 255	}
 256	return 0
 257}
 258
 259// ScriptExecDuration evaluates a bash expression as a duration with the MENV
 260// file sourced. Accepts Go duration strings ("750ms", "2s", "5m", "1h") and
 261// bare integers (treated as seconds).
 262func ScriptExecDuration(s string) time.Duration {
 263	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
 264	if err != nil {
 265		return 0
 266	}
 267	z = strings.TrimSpace(z)
 268	if z == "" {
 269		return 0
 270	}
 271	z = strings.TrimPrefix(z, "-") // keep parity with how defaults are built
 272
 273	// Try full Go duration syntax first.
 274	if d, err := time.ParseDuration(z); err == nil {
 275		return d
 276	}
 277	// Fallback: plain integer means seconds.
 278	if n, err := strconv.ParseInt(z, 10, 64); err == nil {
 279		return time.Duration(n) * time.Second
 280	}
 281	return 0
 282}
 283
 284
 285// ===== pkg/product/csv.go =====
 286// Package product pkg/product/csv.go β€” catalog CSV loading.
 287package product
 288
 289import (
 290	"bufio"
 291	"bytes"
 292	"embed"
 293	"fmt"
 294	"log"
 295	"strings"
 296
 297	"github.com/bitfield/script"
 298)
 299
 300//go:embed *.go
 301var Source embed.FS
 302
 303func readproductscsv(csvFile string) (data []byte) {
 304	data, err := script.File(csvFile).Bytes() //nolint
 305	if err != nil {
 306		log.Printf(`Error reading %s file %v`, csvFile, err)
 307	}
 308	return data
 309}
 310
 311const csvMinFields = 51 // f[0] through f[50]
 312
 313// ReadCSV reads the catalog from a file. The parsing is in ParseCSV so that
 314// it can be tested without one.
 315func ReadCSV(csvFile string) Products {
 316	return ParseCSV(readproductscsv(csvFile))
 317}
 318
 319// ParseCSV turns the catalog bytes into products, skipping rows that are not
 320// enabled and rows too short to fill one.
 321func ParseCSV(data []byte) (prods Products) {
 322	scanner := bufio.NewScanner(bytes.NewReader(data))
 323	lineNum := 0
 324	for scanner.Scan() {
 325		lineNum++
 326		line := scanner.Text()
 327		f := strings.Split(line, ",")
 328		if len(f) < 4 {
 329			continue
 330		}
 331		if f[3] != "TRUE" {
 332			continue
 333		}
 334		if len(f) < csvMinFields {
 335			log.Printf("csv line %d: expected %d fields, got %d β€” skipping", lineNum, csvMinFields, len(f))
 336			continue
 337		}
 338		q := Product{
 339			Image1:            f[0],
 340			Partno:            f[1],
 341			Name:              f[2],
 342			Enable:            f[3],
 343			Price:             f[4],
 344			Quantity:          f[5],
 345			Shippable:         f[6],
 346			Minorder:          f[7],
 347			Maxorder:          f[8],
 348			Defaultquantity:   f[9],
 349			Stepquantity:      f[10],
 350			Mfgpartno:         f[11],
 351			Mfgname:           f[12],
 352			Category:          f[13],
 353			Subcategory:       f[14],
 354			Location:          f[15],
 355			Msrp:              f[16],
 356			Cost:              f[17],
 357			Typ:               f[18],
 358			Packagetype:       f[19],
 359			Technology:        f[20],
 360			Materials:         f[21],
 361			Value:             f[22],
 362			ValUnit:           f[23],
 363			Resistance:        f[24],
 364			ResUnit:           f[25],
 365			Tolerance:         f[26],
 366			VoltsRating:       f[27],
 367			AmpsRating:        f[28],
 368			WattsRating:       f[29],
 369			TempRating:        f[30],
 370			TempUnit:          f[31],
 371			Description1:      f[32],
 372			Description2:      f[33],
 373			Color1:            f[34],
 374			Color2:            f[35],
 375			Sourceinfo:        f[36],
 376			Datasheet:         f[37],
 377			Docs:              f[38],
 378			Reference:         f[39],
 379			Attributes:        f[40],
 380			Year:              f[41],
 381			Condition:         f[42],
 382			Note:              f[43],
 383			Warning:           f[44],
 384			CableLengthInches: f[45],
 385			LengthInches:      f[46],
 386			WidthInches:       f[47],
 387			HeightInches:      f[48],
 388			WeightLb:          f[49],
 389			WeightOz:          f[50],
 390		}
 391		prods = append(prods, q)
 392	}
 393	return prods
 394}
 395
 396// ValidateCSV scans products for patterns that could cause issues in HTML/JS rendering.
 397// Returns a list of warnings. Call after ReadCSV to check data integrity.
 398func ValidateCSV(prods Products) []string {
 399	var warnings []string
 400	for i, pr := range prods {
 401		check := func(field, value string) {
 402			if strings.ContainsAny(value, "<>\"'&") {
 403				warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains HTML-unsafe characters: %q", i, pr.Partno, field, value))
 404			}
 405			if strings.Contains(value, "|") {
 406				warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains pipe character: %q", i, pr.Partno, field, value))
 407			}
 408		}
 409		check("Partno", pr.Partno)
 410		check("Name", pr.Name)
 411		check("Description1", pr.Description1)
 412		check("Description2", pr.Description2)
 413		check("Note", pr.Note)
 414		check("Warning", pr.Warning)
 415		check("Category", pr.Category)
 416		check("Subcategory", pr.Subcategory)
 417		check("Mfgname", pr.Mfgname)
 418		check("Mfgpartno", pr.Mfgpartno)
 419	}
 420	return warnings
 421}
 422
 423
 424// ===== pkg/product/csv_test.go =====
 425package product
 426
 427import (
 428	"strings"
 429	"testing"
 430)
 431
 432// row builds a catalog line with the fields the parser needs, so a test can
 433// set the few it cares about without writing fifty-one commas.
 434func row(set map[int]string) string {
 435	f := make([]string, csvMinFields)
 436	f[0] = "img.jpg"
 437	f[1] = "PN-1"
 438	f[2] = "A Part"
 439	f[3] = "TRUE"
 440	f[4] = "1.50"
 441	f[5] = "10"
 442	for i, v := range set {
 443		f[i] = v
 444	}
 445	return strings.Join(f, ",")
 446}
 447
 448func TestParseCSVReadsAProduct(t *testing.T) {
 449	prods := ParseCSV([]byte(row(nil)))
 450	if len(prods) != 1 {
 451		t.Fatalf("got %d products, want 1", len(prods))
 452	}
 453	got := prods[0]
 454	if got.Partno != "PN-1" || got.Name != "A Part" || got.Price != "1.50" || got.Quantity != "10" {
 455		t.Errorf("fields did not land where they should: %+v", got)
 456	}
 457}
 458
 459// The last field is the fiftieth, and off-by-one at the end of a fifty-one
 460// column row is invisible in a spreadsheet.
 461func TestParseCSVReadsTheLastField(t *testing.T) {
 462	prods := ParseCSV([]byte(row(map[int]string{50: "3.25", 49: "1"})))
 463	if len(prods) != 1 {
 464		t.Fatalf("got %d products, want 1", len(prods))
 465	}
 466	if prods[0].WeightOz != "3.25" {
 467		t.Errorf("WeightOz = %q, want the last column", prods[0].WeightOz)
 468	}
 469	if prods[0].WeightLb != "1" {
 470		t.Errorf("WeightLb = %q, want the second to last column", prods[0].WeightLb)
 471	}
 472}
 473
 474// Only enabled rows are sold. A row that says anything but TRUE is a product
 475// deliberately withdrawn, and showing it anyway sells something not in stock.
 476func TestParseCSVSkipsRowsThatAreNotEnabled(t *testing.T) {
 477	for _, enable := range []string{"FALSE", "false", "true", "", "1", "yes"} {
 478		prods := ParseCSV([]byte(row(map[int]string{3: enable})))
 479		if len(prods) != 0 {
 480			t.Errorf("Enable=%q produced a product; only TRUE should", enable)
 481		}
 482	}
 483}
 484
 485// A short row cannot fill a product, and reading one anyway would panic on a
 486// live catalog rather than skip a line.
 487func TestParseCSVSkipsShortRows(t *testing.T) {
 488	short := strings.Join([]string{"img.jpg", "PN-1", "A Part", "TRUE", "1.50"}, ",")
 489	if prods := ParseCSV([]byte(short)); len(prods) != 0 {
 490		t.Errorf("a row with 5 fields produced %d products", len(prods))
 491	}
 492	// Exactly one short of the minimum is the boundary worth pinning.
 493	f := make([]string, csvMinFields-1)
 494	for i := range f {
 495		f[i] = "x"
 496	}
 497	f[3] = "TRUE"
 498	if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 0 {
 499		t.Errorf("a row one field short produced %d products", len(prods))
 500	}
 501}
 502
 503func TestParseCSVSkipsVeryShortAndBlankRows(t *testing.T) {
 504	for _, line := range []string{"", ",", "a,b,c", "\n\n"} {
 505		if prods := ParseCSV([]byte(line)); len(prods) != 0 {
 506			t.Errorf("%q produced %d products", line, len(prods))
 507		}
 508	}
 509}
 510
 511func TestParseCSVReadsSeveralRows(t *testing.T) {
 512	data := strings.Join([]string{
 513		row(map[int]string{1: "AAA"}),
 514		row(map[int]string{3: "FALSE", 1: "SKIPPED"}),
 515		row(map[int]string{1: "BBB"}),
 516	}, "\n")
 517	prods := ParseCSV([]byte(data))
 518	if len(prods) != 2 {
 519		t.Fatalf("got %d products, want 2", len(prods))
 520	}
 521	if prods[0].Partno != "AAA" || prods[1].Partno != "BBB" {
 522		t.Errorf("got %q and %q, want AAA and BBB", prods[0].Partno, prods[1].Partno)
 523	}
 524}
 525
 526// The catalog is split on commas rather than parsed as CSV, so a quoted
 527// field containing one shifts every column after it β€” including Enable, which
 528// is column 3. The row then does not say TRUE where the parser looks, and the
 529// product is dropped from the store without a word.
 530//
 531// A spreadsheet writes that quoting itself the moment a name contains a comma,
 532// so this is reachable from ordinary editing rather than from bad data.
 533//
 534// This test records what the parser does today. If it is ever changed to use
 535// encoding/csv, this test is the one that should fail.
 536func TestParseCSVSilentlyDropsRowsWithAQuotedComma(t *testing.T) {
 537	f := make([]string, csvMinFields)
 538	for i := range f {
 539		f[i] = "x"
 540	}
 541	f[1] = "PN-1"
 542	f[2] = `"Resistor, 10k"` // one field in a spreadsheet, two after a split
 543	f[3] = "TRUE"
 544	f[4] = "1.50"
 545	line := strings.Join(f, ",")
 546
 547	// The row is long enough β€” it is the shift that loses it, not the length.
 548	if n := len(strings.Split(line, ",")); n <= csvMinFields {
 549		t.Fatalf("the fixture has %d fields; it needs more than %d to isolate the shift", n, csvMinFields)
 550	}
 551
 552	prods := ParseCSV([]byte(line))
 553	if len(prods) != 0 {
 554		t.Fatalf("the parser now keeps rows with a quoted comma (%d products); "+
 555			"replace this test with one asserting the fields are correct", len(prods))
 556	}
 557
 558	// Without the comma the same row is read, which is what shows the comma is
 559	// the cause rather than anything else in the fixture.
 560	f[2] = "Resistor 10k"
 561	if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 1 {
 562		t.Errorf("the same row without the comma gave %d products, want 1", len(prods))
 563	}
 564}
 565
 566func TestValidateCSVAcceptsCleanData(t *testing.T) {
 567	prods := ParseCSV([]byte(row(nil)))
 568	if w := ValidateCSV(prods); len(w) != 0 {
 569		t.Errorf("clean data produced warnings: %v", w)
 570	}
 571}
 572
 573// These characters are what turn a product name into markup on the page.
 574func TestValidateCSVFlagsCharactersThatBreakThePage(t *testing.T) {
 575	for _, bad := range []string{`<script>`, `a"b`, "a'b", "a&b", "a>b"} {
 576		prods := ParseCSV([]byte(row(map[int]string{2: bad})))
 577		if len(prods) != 1 {
 578			t.Fatalf("%q: got %d products", bad, len(prods))
 579		}
 580		w := ValidateCSV(prods)
 581		if len(w) == 0 {
 582			t.Errorf("%q in a product name was not flagged", bad)
 583			continue
 584		}
 585		if !strings.Contains(w[0], "Name") {
 586			t.Errorf("%q: the warning does not name the field: %s", bad, w[0])
 587		}
 588	}
 589}
 590
 591// The pipe is the field separator in the cart's stored format, so a pipe in a
 592// product name corrupts the cart rather than the page.
 593func TestValidateCSVFlagsPipes(t *testing.T) {
 594	prods := ParseCSV([]byte(row(map[int]string{2: "A|B"})))
 595	w := ValidateCSV(prods)
 596	if len(w) == 0 {
 597		t.Fatal("a pipe in a product name was not flagged")
 598	}
 599	if !strings.Contains(w[0], "pipe") {
 600		t.Errorf("the warning does not mention the pipe: %s", w[0])
 601	}
 602}
 603
 604// Every field that reaches the page is checked, not just the name.
 605func TestValidateCSVChecksEveryRenderedField(t *testing.T) {
 606	fields := map[int]string{
 607		1:  "PN<",  // Partno
 608		2:  "N<",   // Name
 609		12: "MFG<", // Mfgname
 610		11: "MP<",  // Mfgpartno
 611		13: "CAT<", // Category
 612		14: "SUB<", // Subcategory
 613		32: "D1<",  // Description1
 614		33: "D2<",  // Description2
 615		43: "NT<",  // Note
 616		44: "WN<",  // Warning
 617	}
 618	for col, val := range fields {
 619		prods := ParseCSV([]byte(row(map[int]string{3: "TRUE", col: val})))
 620		if len(prods) != 1 {
 621			t.Fatalf("column %d: got %d products", col, len(prods))
 622		}
 623		if w := ValidateCSV(prods); len(w) == 0 {
 624			t.Errorf("an unsafe character in column %d was not flagged", col)
 625		}
 626	}
 627}
 628
 629func TestValidateCSVOnNoProducts(t *testing.T) {
 630	if w := ValidateCSV(nil); len(w) != 0 {
 631		t.Errorf("an empty catalog produced warnings: %v", w)
 632	}
 633}
 634
 635// The warning names the row so it can be found in a spreadsheet of thousands.
 636func TestValidateCSVNamesTheProduct(t *testing.T) {
 637	prods := ParseCSV([]byte(row(map[int]string{1: "PN-SPECIAL", 2: "bad<"})))
 638	w := ValidateCSV(prods)
 639	if len(w) == 0 {
 640		t.Fatal("no warning")
 641	}
 642	if !strings.Contains(w[0], "PN-SPECIAL") {
 643		t.Errorf("the warning does not name the part: %s", w[0])
 644	}
 645}
 646
 647
 648// ===== pkg/product/product.go =====
 649// Package product pkg/product/product.go
 650package product
 651
 652type Product struct {
 653	Enable            string
 654	Partno            string
 655	Name              string
 656	Image1            string
 657	Price             string
 658	Quantity          string
 659	Shippable         string
 660	Minorder          string
 661	Maxorder          string
 662	Defaultquantity   string
 663	Stepquantity      string
 664	Mfgpartno         string
 665	Mfgname           string
 666	Category          string
 667	Subcategory       string
 668	Location          string
 669	Msrp              string
 670	Cost              string
 671	Typ               string
 672	Packagetype       string
 673	Technology        string
 674	Materials         string
 675	Value             string
 676	ValUnit           string
 677	Resistance        string
 678	ResUnit           string
 679	Tolerance         string
 680	VoltsRating       string
 681	AmpsRating        string
 682	WattsRating       string
 683	TempRating        string
 684	TempUnit          string
 685	Description1      string
 686	Description2      string
 687	Color1            string
 688	Color2            string
 689	Sourceinfo        string
 690	Datasheet         string
 691	Docs              string
 692	Reference         string
 693	Attributes        string
 694	Year              string
 695	Condition         string
 696	Note              string
 697	Warning           string
 698	CableLengthInches string
 699	LengthInches      string
 700	WidthInches       string
 701	HeightInches      string
 702	WeightLb          string
 703	WeightOz          string
 704}
 705
 706// Products is an array of Product
 707type Products []Product
 708
 709
 710// ===== pkg/tui/catalog.go =====
 711// Package tui pkg/tui/catalog.go β€” the site's structures, derived the same
 712// way the templates derive them: category ordering from getcategories, the
 713// navigation tree from htmpl/catsubcats.html, the product page from
 714// htmpl/product.html. Where the website renders HTML, this renders the same
 715// data to terminal text.
 716package tui
 717
 718import (
 719	"fmt"
 720	"sort"
 721	"strconv"
 722	"strings"
 723
 724	"github.com/0magnet/m2/pkg/product"
 725)
 726
 727// catalogInfo mirrors what pageMeta hands the templates: categories sorted
 728// by product count, subcategories likewise, with counts.
 729type catalogInfo struct {
 730	prods        product.Products
 731	cats         []string
 732	catCounts    map[string]int
 733	subcatCounts map[string]map[string]int
 734	subcatsByCat map[string][]string
 735}
 736
 737func newCatalogInfo(prods product.Products) *catalogInfo {
 738	c := &catalogInfo{
 739		prods:        prods,
 740		catCounts:    make(map[string]int),
 741		subcatCounts: make(map[string]map[string]int),
 742		subcatsByCat: make(map[string][]string),
 743	}
 744	for _, prod := range prods {
 745		if prod.Category == "" {
 746			continue
 747		}
 748		c.catCounts[prod.Category]++
 749		if prod.Subcategory != "" {
 750			if c.subcatCounts[prod.Category] == nil {
 751				c.subcatCounts[prod.Category] = make(map[string]int)
 752			}
 753			c.subcatCounts[prod.Category][prod.Subcategory]++
 754		}
 755	}
 756	for cat := range c.catCounts {
 757		c.cats = append(c.cats, cat)
 758	}
 759	sort.Slice(c.cats, func(i, j int) bool {
 760		if c.catCounts[c.cats[i]] != c.catCounts[c.cats[j]] {
 761			return c.catCounts[c.cats[i]] > c.catCounts[c.cats[j]]
 762		}
 763		return c.cats[i] < c.cats[j]
 764	})
 765	for cat, subs := range c.subcatCounts {
 766		var names []string
 767		for sub := range subs {
 768			names = append(names, sub)
 769		}
 770		sort.Slice(names, func(i, j int) bool {
 771			if subs[names[i]] != subs[names[j]] {
 772				return subs[names[i]] > subs[names[j]]
 773			}
 774			return names[i] < names[j]
 775		})
 776		c.subcatsByCat[cat] = names
 777	}
 778	return c
 779}
 780
 781// products returns the rows the matching category/subcategory table lists.
 782// A category with subcategories lists only its uncategorized remainder on
 783// its own page ("Other Products in X"), exactly as front.html does.
 784func (c *catalogInfo) products(cat, subcat string) product.Products {
 785	var out product.Products
 786	for _, p := range c.prods {
 787		switch {
 788		case cat == "":
 789			out = append(out, p)
 790		case subcat == "":
 791			if p.Category == cat && (len(c.subcatsByCat[cat]) == 0 || p.Subcategory == "") {
 792				out = append(out, p)
 793			}
 794		default:
 795			if p.Category == cat && p.Subcategory == subcat {
 796				out = append(out, p)
 797			}
 798		}
 799	}
 800	return out
 801}
 802
 803func (c *catalogInfo) find(partno string) *product.Product {
 804	for i := range c.prods {
 805		if strings.EqualFold(c.prods[i].Partno, partno) {
 806			return &c.prods[i]
 807		}
 808	}
 809	return nil
 810}
 811
 812// navTarget is a place the categories tree can go: all products, a
 813// category, or a subcategory.
 814type navTarget struct{ cat, subcat string }
 815
 816type navItem struct {
 817	label   string
 818	target  navTarget
 819	hasSubs bool
 820}
 821
 822// buildTree reproduces htmpl/catsubcats.html: counts right-aligned by ─
 823// padding, β”œ/β”” branches, ┬ where a category opens into subcategories.
 824// Like the template's nested <details>, a category's subcategories are
 825// listed only when it is in the expanded set.
 826func (c *catalogInfo) buildTree(expanded map[string]bool) []navItem {
 827	lenall := len(c.prods)
 828	laLen := len(strconv.Itoa(lenall))
 829	items := []navItem{{fmt.Sprintf("β”œβ”€β”€%d All Products", lenall), navTarget{}, false}}
 830	for ci, cat := range c.cats {
 831		count := c.catCounts[cat]
 832		cs := strconv.Itoa(count)
 833		pad := strings.Repeat("─", laLen-len(cs))
 834		branch := "β”œ"
 835		if ci == len(c.cats)-1 {
 836			branch = "β””"
 837		}
 838		tee := "─"
 839		subs := c.subcatsByCat[cat]
 840		if len(subs) > 0 {
 841			tee = "┬"
 842		}
 843		items = append(items, navItem{fmt.Sprintf("%s─%s%s%d %s", branch, tee, pad, count, cat), navTarget{cat: cat}, len(subs) > 0})
 844		if expanded != nil && !expanded[cat] {
 845			continue
 846		}
 847		lead := "β”‚ "
 848		if ci == len(c.cats)-1 {
 849			lead = "  "
 850		}
 851		for si, sub := range subs {
 852			scount := c.subcatCounts[cat][sub]
 853			extrapad := strings.Repeat("─", len(cs)-len(strconv.Itoa(scount)))
 854			sb := "β”œ"
 855			if si == len(subs)-1 {
 856				sb = "β””"
 857			}
 858			items = append(items, navItem{fmt.Sprintf("%s%s%s%s%d %s", lead, sb, extrapad, pad, scount, sub), navTarget{cat: cat, subcat: sub}, false})
 859		}
 860	}
 861	return items
 862}
 863
 864// checkerboard styles alternate letters inverse-video, as the site's
 865// checkerBoard wraps every other letter of the domain in an .nv span.
 866func checkerboard(s string) string {
 867	var out strings.Builder
 868	for i, ch := range s {
 869		if i%2 == 0 {
 870			fmt.Fprintf(&out, "[black:white]%c[white:black]", ch)
 871		} else {
 872			out.WriteRune(ch)
 873		}
 874	}
 875	out.WriteString("[-:-]")
 876	return out.String()
 877}
 878
 879// set reports a field the way product.html's conditions do: present unless
 880// empty or a bare zero.
 881func set(v string) bool {
 882	return v != "" && v != "0" && v != "0.0"
 883}
 884
 885// productLines renders htmpl/product.html: the same fields, labels, and
 886// order, as terminal text.
 887func productLines(p *product.Product) string {
 888	var b strings.Builder
 889	line := func(format string, args ...interface{}) {
 890		fmt.Fprintf(&b, format+"\n", args...)
 891	}
 892	line("[::b]%s[-:-:-]", esc(p.Name))
 893	line("Price: [white]$%s[-]", esc(p.Price))
 894	line("In stock: %s", esc(p.Quantity))
 895	if p.Quantity != "0" {
 896		line("partno: %s", esc(p.Partno))
 897	}
 898	if !strings.EqualFold(strings.Join(strings.Fields(p.Description1), ""), strings.Join(strings.Fields(p.Name), "")) && p.Description1 != "" {
 899		line("\n%s\n", esc(p.Description1))
 900	}
 901	if p.Mfgname != "" {
 902		line("Brand: %s", esc(p.Mfgname))
 903	}
 904	if p.Mfgpartno != "" {
 905		line("MPN: %s", esc(p.Mfgpartno))
 906	}
 907	line("Category: [aqua]%s[-]", esc(p.Category))
 908	if p.Subcategory != "" {
 909		line("Subcategory: [aqua]%s[-]", esc(p.Subcategory))
 910	}
 911	if set(p.VoltsRating) {
 912		line("Voltage: %s", esc(p.VoltsRating))
 913	}
 914	if set(p.Value) {
 915		line("Value: %s%s", esc(p.Value), esc(p.ValUnit))
 916	}
 917	if set(p.AmpsRating) {
 918		line("Amperage: %s", esc(p.AmpsRating))
 919	}
 920	if p.Tolerance != "0" && p.Tolerance != "" {
 921		if f, err := strconv.ParseFloat(p.Tolerance, 64); err == nil {
 922			line("Tolerance: %.2f%%", 100*f)
 923		}
 924	}
 925	if p.Typ != "" {
 926		line("Typ: %s", esc(p.Typ))
 927	}
 928	if p.Packagetype != "" {
 929		line("Package Type: %s", esc(p.Packagetype))
 930	}
 931	if p.Technology != "" {
 932		line("Technology: %s", esc(p.Technology))
 933	}
 934	if p.Materials != "" {
 935		line("Materials: %s", esc(p.Materials))
 936	}
 937	if set(p.WattsRating) {
 938		line("Watts Rating: %s", esc(p.WattsRating))
 939	}
 940	if p.Year != "0" && p.Year != "" {
 941		line("Year: %s", esc(p.Year))
 942	}
 943	if set(p.CableLengthInches) {
 944		line("Cable Length: %s inches", esc(p.CableLengthInches))
 945	}
 946	if p.WeightOz != "0" && p.WeightOz != "0.0" && p.WeightOz != "" {
 947		line("Weight: %s oz", esc(p.WeightOz))
 948	}
 949	if p.TempRating != "0" && p.TempRating != "0.0" && p.TempRating != "" {
 950		line("Temp rating: %s%s", esc(p.TempRating), esc(p.TempUnit))
 951	}
 952	if p.Condition != "" {
 953		line("Condition: %s", esc(p.Condition))
 954	}
 955	if p.Datasheet != "" {
 956		line("Datasheet: [aqua]%s[-]", esc(p.Datasheet))
 957	}
 958	if p.Docs != "" {
 959		line("Documentation: %s", esc(p.Docs))
 960	}
 961	if p.Note != "" {
 962		line("Note: [yellow]%s[-]", esc(p.Note))
 963	}
 964	if p.Warning != "" {
 965		line("Warning: [red]%s[-]", esc(p.Warning))
 966	}
 967	if p.Description2 != "" {
 968		line("Additional Description: %s", esc(p.Description2))
 969	}
 970	return b.String()
 971}
 972
 973
 974// ===== pkg/tui/checkout.go =====
 975// Package tui pkg/tui/checkout.go β€” the cart, the shipping form, and
 976// checkout, mirroring the website's footer commerce (footer.html + the
 977// cart wasm). On the website the cart and the shipping form are
 978// <details> dropdowns rising from the fixed footer and checkout is a
 979// <dialog>; here they are overlays above the footer bar, outside the
 980// navigation history β€” v toggles the cart, Esc closes.
 981//
 982// The TUI is a client of the same store server the browser cart talks
 983// to: it builds the identical items payload ("partno X qty" lines plus
 984// a "shipping-to|..." line) and POSTs the same /create-payment-intent.
 985// The card-entry step lives behind payWithElements (pay_native.go / a
 986// future pay_js.go): when the TUI runs in the browser as wasm, that
 987// seam mounts the Stripe Payment Element over the terminal exactly as
 988// the cart wasm does today; a native terminal cannot take card details,
 989// and says so.
 990package tui
 991
 992import (
 993	"bytes"
 994	"encoding/json"
 995	"fmt"
 996	"io"
 997	"net/http"
 998	"regexp"
 999	"strings"
1000	"time"
1001
1002	"github.com/gdamore/tcell/v3"
1003)
1004
1005// shippingLine is the "Add Shipping Info" form's result: a cart line,
1006// exactly as the cart wasm stores it ("shipping-to|name|address|...").
1007type shippingLine struct {
1008	Cents                                           int
1009	Name, Address, City, State, Zip, Country, Phone string
1010}
1011
1012func (s *shippingLine) id() string {
1013	return strings.Join([]string{"shipping-to", s.Name, s.Address, s.City, s.State, s.Zip, s.Country, s.Phone}, "|")
1014}
1015
1016// usStates is the shipping form's State dropdown, from footer.html.
1017var usStates = []string{"", "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "DC",
1018	"FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", "MA",
1019	"MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND",
1020	"OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA",
1021	"WV", "WI", "WY"}
1022
1023var zipRe = regexp.MustCompile(`^[0-9]{5}$`)
1024
1025// commerceRect anchors an overlay just above the footer summary it
1026// expands from β€” the cart (and checkout) above View Cart, the form
1027// above Add Shipping Info.
1028func (u *ui) commerceRect() rect {
1029	w, h := u.commerce.size()
1030	sw, sh := u.screen.Size()
1031	if w > sw-2 {
1032		w = sw - 2
1033	}
1034	x := 1
1035	region := 0
1036	if _, ok := u.commerce.(*shippingOverlay); ok {
1037		region = 1
1038	}
1039	if region < len(u.footRegions) {
1040		x = u.footRegions[region].x1 - 1
1041	}
1042	if x+w > sw-1 {
1043		x = sw - 1 - w
1044	}
1045	if x < 0 {
1046		x = 0
1047	}
1048	y := sh - 2 - h
1049	if y < 1 {
1050		y = 1
1051		h = sh - 3
1052	}
1053	return rect{x, y, w, h}
1054}
1055
1056func (u *ui) closeCommerce() {
1057	u.commerce = nil
1058}
1059
1060func (u *ui) toggleCart() {
1061	if u.commerce != nil {
1062		u.closeCommerce()
1063		return
1064	}
1065	u.openCart()
1066}
1067
1068// ---- the cart overlay ----
1069
1070type cartOverlay struct {
1071	ta    *textArea
1072	lines int
1073}
1074
1075func (u *ui) openCart() {
1076	var b strings.Builder
1077	total := 0
1078	for _, partno := range u.cartOrder {
1079		qty := u.cartQty[partno]
1080		if qty == 0 {
1081			continue
1082		}
1083		p := u.cat.find(partno)
1084		if p == nil {
1085			continue
1086		}
1087		cents := priceCents(p.Price) * qty
1088		total += cents
1089		fmt.Fprintf(&b, "%3d Γ— [aqua]%s[-]  $%d.%02d\n", qty, esc(p.Name), cents/100, cents%100)
1090	}
1091	if u.shipping != nil {
1092		total += u.shipping.Cents
1093		to := strings.TrimSpace(u.shipping.Name + " " + u.shipping.City + " " + u.shipping.State)
1094		fmt.Fprintf(&b, "    shipping[gray] to %s[-]  $%d.%02d\n",
1095			esc(to), u.shipping.Cents/100, u.shipping.Cents%100)
1096	}
1097	if total == 0 {
1098		b.WriteString("[gray]your cart is empty[-]\n")
1099	}
1100	fmt.Fprintf(&b, "\nTotal: $%d.%02d\n", total/100, total%100)
1101	b.WriteString("\n[#5fd7ff]s[-] add shipping info Β· [#5fd7ff]p[-] checkout Β· [#5fd7ff]x[-] empty Β· [#5fd7ff]v[-] close\n")
1102	fmt.Fprintf(&b, "[gray]checkout talks to the store server at %s[-]", esc(ordersURL()))
1103	content := b.String()
1104	u.commerce = &cartOverlay{ta: newTextArea(content, false), lines: strings.Count(content, "\n") + 1}
1105}
1106
1107func (c *cartOverlay) title() string { return "View Cart" }
1108func (c *cartOverlay) size() (int, int) {
1109	return 70, c.lines + 2
1110}
1111
1112func (c *cartOverlay) draw(u *ui, sc tcell.Screen, r rect) {
1113	c.ta.draw(sc, r)
1114}
1115
1116func (c *cartOverlay) key(u *ui, ev *tcell.EventKey) bool {
1117	switch keyRune(ev) {
1118	case 'x':
1119		u.cartQty = map[string]int{}
1120		u.cartOrder = nil
1121		u.shipping = nil
1122		u.openCart()
1123		return true
1124	case 's':
1125		u.openShipping()
1126		return true
1127	case 'p':
1128		u.startCheckout()
1129		return true
1130	}
1131	return false
1132}
1133
1134// ---- the shipping form, from footer.html's Add Shipping Info ----
1135
1136type shippingOverlay struct {
1137	frm *form
1138	h   int
1139}
1140
1141func (s *shippingOverlay) title() string                       { return "Add Shipping Info" }
1142func (s *shippingOverlay) size() (int, int)                    { return 62, s.h }
1143func (s *shippingOverlay) draw(u *ui, sc tcell.Screen, r rect) { s.frm.draw(sc, r) }
1144func (s *shippingOverlay) key(u *ui, ev *tcell.EventKey) bool  { return s.frm.key(ev) }
1145
1146func (u *ui) openShipping() {
1147	prev := u.shipping
1148	if prev == nil {
1149		prev = &shippingLine{Cents: 700, Country: "United States"}
1150	}
1151	amount := &finput{lbl: "Amount ($, min 7):", text: []rune(fmt.Sprintf("%d.%02d", prev.Cents/100, prev.Cents%100)), width: 10}
1152	amount.cur = len(amount.text)
1153	mkInput := func(lbl, val string, w int) *finput {
1154		in := &finput{lbl: lbl, text: []rune(val), width: w}
1155		in.cur = len(in.text)
1156		return in
1157	}
1158	name := mkInput("Name:", prev.Name, 38)
1159	address := mkInput("Address:", prev.Address, 38)
1160	city := mkInput("City:", prev.City, 28)
1161	state := &fdropdown{lbl: "State (β—‚ β–Έ or type):", opts: usStates}
1162	for i, s := range usStates {
1163		if s == prev.State {
1164			state.sel = i
1165		}
1166	}
1167	zip := mkInput("ZIP Code:", prev.Zip, 8)
1168	country := &fdropdown{lbl: "Country:", opts: []string{"United States"}}
1169	phone := mkInput("Phone Number:", prev.Phone, 12)
1170
1171	items := []formItem{amount, name, address, city, state, zip, country, phone}
1172	// Declared separately on purpose: the buttons below close over frm, so it
1173	// has to exist before newForm is called.
1174	var frm *form //nolint:staticcheck
1175	frm = newForm(items, []fbutton{
1176		{"Add Shipping to Cart", func() {
1177			cents := priceCents(amount.value())
1178			if cents < 700 {
1179				u.notice = "shipping is at least $7.00"
1180				return
1181			}
1182			z := zip.value()
1183			if z != "" && !zipRe.MatchString(z) {
1184				u.notice = "ZIP code must be 5 digits"
1185				return
1186			}
1187			u.shipping = &shippingLine{
1188				Cents: cents, Name: name.value(), Address: address.value(), City: city.value(),
1189				State: state.value(), Zip: z, Country: country.value(), Phone: phone.value(),
1190			}
1191			u.openCart()
1192		}},
1193		{"Cancel", func() { u.openCart() }},
1194	}, func() { u.openCart() })
1195	u.commerce = &shippingOverlay{frm: frm, h: len(items)*2 + 3}
1196}
1197
1198// ---- checkout: the cart wasm's flow, against the same server ----
1199
1200type checkoutOverlay struct {
1201	ta *textArea
1202}
1203
1204func (c *checkoutOverlay) title() string                       { return "Checkout" }
1205func (c *checkoutOverlay) size() (int, int)                    { return 78, 14 }
1206func (c *checkoutOverlay) draw(u *ui, sc tcell.Screen, r rect) { c.ta.draw(sc, r) }
1207func (c *checkoutOverlay) key(u *ui, ev *tcell.EventKey) bool  { return c.ta.key(ev) }
1208
1209// ordersURL is where checkout is served from: the store being browsed
1210// in client mode, else SITEORDERSURL when the config names one (as the
1211// website's cart uses it), else the local store server.
1212func ordersURL() string {
1213	if s := storeURL(); s != "" {
1214		return s
1215	}
1216	if f.Siteordersurl != "" {
1217		return f.Siteordersurl
1218	}
1219	port := f.WebPort
1220	if port == 0 {
1221		port = 9883
1222	}
1223	return fmt.Sprintf("http://127.0.0.1:%d", port)
1224}
1225
1226// cartItem matches the JSON the cart wasm POSTs to /create-payment-intent.
1227type cartItem struct {
1228	ID     string `json:"ID"`
1229	Amount int64  `json:"Amount"`
1230}
1231
1232func (u *ui) startCheckout() {
1233	if len(u.cartOrder) == 0 {
1234		u.notice = "the cart is empty"
1235		return
1236	}
1237	if u.shipping == nil {
1238		u.notice = "add shipping info first (s in the cart)"
1239		return
1240	}
1241
1242	// The same payload the cart wasm sends: one line per product as
1243	// "partno X qty" with the line total, plus the shipping line.
1244	var items []cartItem
1245	for _, partno := range u.cartOrder {
1246		qty := u.cartQty[partno]
1247		p := u.cat.find(partno)
1248		if qty == 0 || p == nil {
1249			continue
1250		}
1251		items = append(items, cartItem{
1252			ID:     fmt.Sprintf("%s X %d", partno, qty),
1253			Amount: int64(priceCents(p.Price) * qty),
1254		})
1255	}
1256	items = append(items, cartItem{ID: u.shipping.id(), Amount: int64(u.shipping.Cents)})
1257
1258	co := &checkoutOverlay{ta: newTextArea("\n contacting "+esc(ordersURL())+" …", true)}
1259	u.commerce = co
1260
1261	go func() {
1262		clientSecret, err := createPaymentIntent(items)
1263		u.post(func() {
1264			if u.commerce != co {
1265				return // the overlay was closed meanwhile
1266			}
1267			if err != nil {
1268				co.ta.setContent(fmt.Sprintf(
1269					"\n [red]could not create the payment:[-] %s\n\n [gray]is the store server running? (%s)\n Esc closes β€” the cart is kept[-]\n",
1270					esc(err.Error()), esc(ordersURL())))
1271				return
1272			}
1273			u.payWithElements(clientSecret, co.ta.setContent)
1274		})
1275	}()
1276}
1277
1278// createPaymentIntent POSTs the cart to the store server, which
1279// validates every line against the catalog and answers with the payment
1280// intent's client secret β€” identical to the cart wasm's fetch.
1281func createPaymentIntent(items []cartItem) (string, error) {
1282	body, err := json.Marshal(map[string]interface{}{"items": items})
1283	if err != nil {
1284		return "", err
1285	}
1286	client := &http.Client{Timeout: 15 * time.Second}
1287	resp, err := client.Post(ordersURL()+"/create-payment-intent", "application/json", bytes.NewReader(body))
1288	if err != nil {
1289		return "", err
1290	}
1291	defer resp.Body.Close() //nolint:errcheck // read-side close
1292	data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
1293	if err != nil {
1294		return "", err
1295	}
1296	var out struct {
1297		ClientSecret string `json:"clientSecret"`
1298		Error        string `json:"error"`
1299	}
1300	if err := json.Unmarshal(data, &out); err != nil {
1301		return "", fmt.Errorf("bad response (%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
1302	}
1303	if out.Error != "" {
1304		return "", fmt.Errorf("%s", out.Error)
1305	}
1306	if out.ClientSecret == "" {
1307		return "", fmt.Errorf("no client secret in response (%d)", resp.StatusCode)
1308	}
1309	return out.ClientSecret, nil
1310}
1311
1312
1313// ===== pkg/tui/frame_dump_test.go =====
1314package tui
1315
1316import (
1317	"image/png"
1318	"os"
1319	"testing"
1320)
1321
1322func TestDumpFrame(t *testing.T) {
1323	out := os.Getenv("FRAMEOUT")
1324	if out == "" {
1325		t.Skip("set FRAMEOUT to dump a frame")
1326	}
1327	logo, err := loadImage(os.Getenv("FRAMELOGO"))
1328	if err != nil {
1329		t.Fatal(err)
1330	}
1331	bd := makeBackdrop(logo, 200, 100)
1332	fr := globeFrame(bd, 0.9, 2.3, -0.6)
1333	f, err := os.Create(out) //nolint:gosec
1334	if err != nil {
1335		t.Fatalf("create %s: %v", out, err)
1336	}
1337	defer f.Close() //nolint:errcheck,gosec
1338	if err := png.Encode(f, fr); err != nil {
1339		t.Fatal(err)
1340	}
1341}
1342
1343
1344// ===== pkg/tui/kit.go =====
1345// Package tui pkg/tui/kit.go β€” drawing primitives on tcell: a small
1346// color-tag markup ([fg:bg:flags], as tview popularized), line printing
1347// with alignment and wrapping, and bordered boxes. The TUI is written
1348// straight on tcell v3 so it runs wherever the 0magnet ecosystem's
1349// terminal stack does β€” natively today, in the browser via
1350// tuiwasm/xtcell tomorrow.
1351package tui
1352
1353import (
1354	"strings"
1355
1356	"github.com/gdamore/tcell/v3"
1357)
1358
1359type rect struct{ x, y, w, h int }
1360
1361// esc quotes user data so it never parses as a color tag: "[" doubles,
1362// and the parser reads "[[" back as one literal bracket.
1363func esc(s string) string {
1364	return strings.ReplaceAll(s, "[", "[[")
1365}
1366
1367// seg is a run of text in one style.
1368type seg struct {
1369	txt []rune
1370	st  tcell.Style
1371}
1372
1373// namedColors are the palette names the markup uses; anything else goes
1374// to tcell.GetColor (which handles #rrggbb and the W3C names).
1375var namedColors = map[string]tcell.Color{
1376	"aqua":   tcell.ColorAqua,
1377	"white":  tcell.ColorWhite,
1378	"black":  tcell.ColorBlack,
1379	"gray":   tcell.GetColor("#808080"),
1380	"grey":   tcell.GetColor("#808080"),
1381	"yellow": tcell.ColorYellow,
1382	"red":    tcell.ColorRed,
1383	"green":  tcell.ColorGreen,
1384	"orange": tcell.GetColor("#ffa500"),
1385}
1386
1387func markupColor(name string) (tcell.Color, bool) {
1388	if c, ok := namedColors[name]; ok {
1389		return c, true
1390	}
1391	c := tcell.GetColor(name)
1392	if c == tcell.ColorDefault && name != "default" {
1393		return c, false
1394	}
1395	return c, true
1396}
1397
1398// tagBody reports whether the text between brackets looks like a color
1399// tag: colors and flags only, at most two colons.
1400func tagBody(s string) bool {
1401	if s == "" {
1402		return false
1403	}
1404	colons := 0
1405	for _, r := range s {
1406		switch {
1407		case r == ':':
1408			colons++
1409		case r == '#' || r == '-':
1410		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
1411		default:
1412			return false
1413		}
1414	}
1415	return colons <= 2
1416}
1417
1418// applyTag folds one [fg:bg:flags] spec into a style. Empty parts keep,
1419// "-" resets to the base.
1420func applyTag(cur, base tcell.Style, body string) tcell.Style {
1421	parts := strings.SplitN(body, ":", 3)
1422	if len(parts) > 0 && parts[0] != "" {
1423		if parts[0] == "-" {
1424			cur = cur.Foreground(base.GetForeground())
1425		} else if c, ok := markupColor(parts[0]); ok {
1426			cur = cur.Foreground(c)
1427		}
1428	}
1429	if len(parts) > 1 && parts[1] != "" {
1430		if parts[1] == "-" {
1431			cur = cur.Background(base.GetBackground())
1432		} else if c, ok := markupColor(parts[1]); ok {
1433			cur = cur.Background(c)
1434		}
1435	}
1436	if len(parts) > 2 {
1437		if parts[2] == "-" || parts[2] == "" {
1438			cur = cur.Attributes(base.GetAttributes())
1439		} else {
1440			for _, f := range parts[2] {
1441				switch f {
1442				case 'b':
1443					cur = cur.Bold(true)
1444				case 'u':
1445					cur = cur.Underline(true)
1446				}
1447			}
1448		}
1449	}
1450	return cur
1451}
1452
1453// parseMarkup splits a tagged string into styled segments.
1454func parseMarkup(s string, base tcell.Style) []seg {
1455	var out []seg
1456	cur := base
1457	var run []rune
1458	flush := func() {
1459		if len(run) > 0 {
1460			out = append(out, seg{run, cur})
1461			run = nil
1462		}
1463	}
1464	r := []rune(s)
1465	for i := 0; i < len(r); i++ {
1466		if r[i] != '[' {
1467			run = append(run, r[i])
1468			continue
1469		}
1470		if i+1 < len(r) && r[i+1] == '[' { // escaped literal bracket
1471			run = append(run, '[')
1472			i++
1473			continue
1474		}
1475		end := -1
1476		for j := i + 1; j < len(r) && j < i+24; j++ {
1477			if r[j] == ']' {
1478				end = j
1479				break
1480			}
1481		}
1482		if end < 0 || !tagBody(string(r[i+1:end])) {
1483			run = append(run, r[i])
1484			continue
1485		}
1486		flush()
1487		cur = applyTag(cur, base, string(r[i+1:end]))
1488		i = end
1489	}
1490	flush()
1491	return out
1492}
1493
1494// markupWidth is the printed width of a tagged string.
1495func markupWidth(s string) int {
1496	n := 0
1497	for _, sg := range parseMarkup(s, tcell.StyleDefault) {
1498		n += len(sg.txt)
1499	}
1500	return n
1501}
1502
1503// printSegs draws segments on one row, clipped to maxW; returns cells drawn.
1504func printSegs(sc tcell.Screen, x, y, maxW int, segs []seg) int {
1505	n := 0
1506	for _, sg := range segs {
1507		for _, r := range sg.txt {
1508			if n >= maxW {
1509				return n
1510			}
1511			sc.SetContent(x+n, y, r, nil, sg.st)
1512			n++
1513		}
1514	}
1515	return n
1516}
1517
1518// printMarkup draws one tagged line, clipped to maxW.
1519// The width it returns mirrors printSegs, which callers of that do use.
1520func printMarkup(sc tcell.Screen, x, y, maxW int, s string, base tcell.Style) int { //nolint:unparam
1521	return printSegs(sc, x, y, maxW, parseMarkup(s, base))
1522}
1523
1524// printMarkupCenter draws one tagged line centered in w columns.
1525func printMarkupCenter(sc tcell.Screen, x, y, w int, s string, base tcell.Style) {
1526	pad := (w - markupWidth(s)) / 2
1527	if pad < 0 {
1528		pad = 0
1529	}
1530	printMarkup(sc, x+pad, y, w-pad, s, base)
1531}
1532
1533// wrapSegs word-wraps parsed lines to width w.
1534func wrapSegs(s string, w int, base tcell.Style, wrap bool) [][]seg {
1535	if w < 1 {
1536		w = 1
1537	}
1538	var out [][]seg
1539	for _, line := range strings.Split(s, "\n") {
1540		segs := parseMarkup(line, base)
1541		if !wrap {
1542			out = append(out, segs)
1543			continue
1544		}
1545		// Flatten to styled runes, then break into rows at spaces.
1546		var flat []styledRune
1547		for _, sg := range segs {
1548			for _, r := range sg.txt {
1549				flat = append(flat, styledRune{r, sg.st})
1550			}
1551		}
1552		for len(flat) > w {
1553			cut := -1
1554			for i := w; i > 0; i-- {
1555				if flat[i].r == ' ' {
1556					cut = i
1557					break
1558				}
1559			}
1560			if cut <= 0 {
1561				cut = w
1562			}
1563			out = append(out, packSegs(flat[:cut]))
1564			for cut < len(flat) && flat[cut].r == ' ' {
1565				cut++
1566			}
1567			flat = flat[cut:]
1568		}
1569		out = append(out, packSegs(flat))
1570	}
1571	return out
1572}
1573
1574type styledRune struct {
1575	r  rune
1576	st tcell.Style
1577}
1578
1579// keyRune reads the rune of a KeyRune event (v3 carries it as Str).
1580func keyRune(ev *tcell.EventKey) rune {
1581	if ev.Key() != tcell.KeyRune {
1582		return 0
1583	}
1584	rs := []rune(ev.Str())
1585	if len(rs) == 0 {
1586		return 0
1587	}
1588	return rs[0]
1589}
1590
1591func packSegs(flat []styledRune) []seg {
1592	var segs []seg
1593	for _, c := range flat {
1594		if n := len(segs); n > 0 && segs[n-1].st == c.st {
1595			segs[n-1].txt = append(segs[n-1].txt, c.r)
1596		} else {
1597			segs = append(segs, seg{[]rune{c.r}, c.st})
1598		}
1599	}
1600	return segs
1601}
1602
1603// fillRect paints a rectangle with spaces in the given style.
1604func fillRect(sc tcell.Screen, r rect, st tcell.Style) {
1605	for y := r.y; y < r.y+r.h; y++ {
1606		for x := r.x; x < r.x+r.w; x++ {
1607			sc.SetContent(x, y, ' ', nil, st)
1608		}
1609	}
1610}
1611
1612// drawRule draws a horizontal rule, the terminal stand-in for the 1px
1613// table borders that close the page's header and footer.
1614func drawRule(sc tcell.Screen, x, y, w int, st tcell.Style) {
1615	for i := 0; i < w; i++ {
1616		sc.SetContent(x+i, y, '─', nil, st)
1617	}
1618}
1619
1620// drawBox fills and frames a rectangle, with an optional title in the
1621// top border.
1622func drawBox(sc tcell.Screen, r rect, title string, border, fill tcell.Style) {
1623	if r.w < 2 || r.h < 2 {
1624		return
1625	}
1626	fillRect(sc, r, fill)
1627	for x := r.x + 1; x < r.x+r.w-1; x++ {
1628		sc.SetContent(x, r.y, '─', nil, border)
1629		sc.SetContent(x, r.y+r.h-1, '─', nil, border)
1630	}
1631	for y := r.y + 1; y < r.y+r.h-1; y++ {
1632		sc.SetContent(r.x, y, 'β”‚', nil, border)
1633		sc.SetContent(r.x+r.w-1, y, 'β”‚', nil, border)
1634	}
1635	sc.SetContent(r.x, r.y, 'β”Œ', nil, border)
1636	sc.SetContent(r.x+r.w-1, r.y, '┐', nil, border)
1637	sc.SetContent(r.x, r.y+r.h-1, 'β””', nil, border)
1638	sc.SetContent(r.x+r.w-1, r.y+r.h-1, 'β”˜', nil, border)
1639	if title != "" {
1640		printMarkup(sc, r.x+2, r.y, r.w-4, title, fill)
1641	}
1642}
1643
1644// inner is the area inside a box's border.
1645func (r rect) inner() rect {
1646	return rect{r.x + 1, r.y + 1, r.w - 2, r.h - 2}
1647}
1648
1649func (r rect) contains(x, y int) bool {
1650	return x >= r.x && y >= r.y && x < r.x+r.w && y < r.y+r.h
1651}
1652
1653
1654// ===== pkg/tui/pay_js.go =====
1655//go:build js && wasm
1656
1657// Package tui pkg/tui/pay_js.go β€” the browser half of the payment seam.
1658// This is where the Stripe Payment Element mounts over the terminal,
1659// exactly as the website's cart wasm mounts it over the page
1660// (wasm/cart/checkout.go holds the DOM/Stripe.js plumbing to relocate
1661// here). Until that lands, the browser build reports the gap instead of
1662// failing to compile.
1663package tui
1664
1665func (u *ui) payWithElements(clientSecret string, set func(string)) {
1666	_ = clientSecret
1667	set("\n [green]the store server accepted the cart[-] and created the\n payment intent.\n\n" +
1668		" [yellow]the Stripe Payment Element mount is not wired up in this build\n" +
1669		" yet[-] β€” complete this order at [aqua]https://" + esc(u.sitedomain) + "[-]\n")
1670}
1671
1672
1673// ===== pkg/tui/pay_native.go =====
1674//go:build !js || !wasm
1675
1676// Package tui pkg/tui/pay_native.go β€” the native half of the payment
1677// seam. Card details can never be typed into a terminal (they would
1678// pass through this program, which PCI forbids); the browser build of
1679// this TUI supplies the other half in pay_js.go, mounting the Stripe
1680// Payment Element over the terminal exactly as the website's cart wasm
1681// mounts it over the page. Until then the native flow proves the whole
1682// pipeline β€” cart, shipping, server validation, payment intent β€” and
1683// says where the last step lives.
1684package tui
1685
1686// payWithElements takes over the checkout dialog once the store server
1687// has created the payment intent for this cart.
1688func (u *ui) payWithElements(clientSecret string, set func(string)) {
1689	preview := clientSecret
1690	if len(preview) > 12 {
1691		preview = preview[:12] + "…"
1692	}
1693	set("\n [green]the store server accepted the cart[-] and created the payment\n intent (" +
1694		esc(preview) + ").\n\n" +
1695		" [yellow]card entry needs the Stripe Payment Element[-], which a terminal\n" +
1696		" cannot host. In the browser build of this TUI it opens right here,\n" +
1697		" over the terminal β€” in a native terminal, complete this order at\n" +
1698		" [aqua]https://" + esc(u.sitedomain) + "[-]\n\n" +
1699		" [gray]Esc closes β€” the cart is kept[-]\n")
1700}
1701
1702
1703// ===== pkg/tui/remote.go =====
1704// Package tui pkg/tui/remote.go β€” the TUI as a store client. With
1705// STOREURL set (m2 tui --storeurl https://magnetosphere.net) nothing is
1706// read from disk: the catalog comes from /api/products, images from /i,
1707// the logo from /logo.jpg, and checkout from the same origin β€” exactly
1708// what the browser build of this TUI will do from the page the store
1709// serves it on. Without it, the TUI reads the deployment's own files as
1710// before.
1711package tui
1712
1713import (
1714	"encoding/json"
1715	"fmt"
1716	"io"
1717	"net/http"
1718	"net/url"
1719	"os"
1720	"strings"
1721	"sync"
1722	"time"
1723
1724	"github.com/0magnet/m2/pkg/product"
1725)
1726
1727// storeURL is the remote store, normalized; empty means local files.
1728func storeURL() string {
1729	return strings.TrimRight(f.Storeurl, "/")
1730}
1731
1732// FetchCatalog loads the product catalog from a store's /api/products.
1733func FetchCatalog(store string) (product.Products, error) {
1734	client := &http.Client{Timeout: 30 * time.Second}
1735	resp, err := client.Get(strings.TrimRight(store, "/") + "/api/products")
1736	if err != nil {
1737		return nil, err
1738	}
1739	defer resp.Body.Close() //nolint:errcheck // read-side close
1740	if resp.StatusCode != http.StatusOK {
1741		return nil, fmt.Errorf("%s from %s/api/products", resp.Status, store)
1742	}
1743	var prods product.Products
1744	if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&prods); err != nil {
1745		return nil, err
1746	}
1747	return prods, nil
1748}
1749
1750// FetchSite fills the site identity (name, tagline, telegram links) from
1751// a store's /api/site β€” the browser build has no MENV file to source.
1752func FetchSite(store string) error {
1753	client := &http.Client{Timeout: 15 * time.Second}
1754	resp, err := client.Get(strings.TrimRight(store, "/") + "/api/site")
1755	if err != nil {
1756		return err
1757	}
1758	defer resp.Body.Close() //nolint:errcheck // read-side close
1759	if resp.StatusCode != http.StatusOK {
1760		return fmt.Errorf("%s from %s/api/site", resp.Status, store)
1761	}
1762	var site struct {
1763		Sitename, Siteext, Sitelongname, Sitetagline string
1764		Tgcontact, Tgchannel                         string
1765		Teststripekey                                bool
1766		Sitemeta, Sitedomain                         string
1767		Siteprettyname, Siteprettynamecap            string
1768		Siteprettynamecaps, SiteASCIILogo            string
1769		Stripepk                                     string
1770	}
1771	if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&site); err != nil {
1772		return err
1773	}
1774	f.Sitename, f.Siteext = site.Sitename, site.Siteext
1775	f.Sitelongname, f.Sitetagline = site.Sitelongname, site.Sitetagline
1776	f.Tgcontact, f.Tgchannel = site.Tgcontact, site.Tgchannel
1777	f.Teststripekey = site.Teststripekey
1778	f.Sitemeta, f.Sitedomain = site.Sitemeta, site.Sitedomain
1779	f.Siteprettyname, f.Siteprettynamecap = site.Siteprettyname, site.Siteprettynamecap
1780	f.Siteprettynamecaps, f.SiteASCIILogo = site.Siteprettynamecaps, site.SiteASCIILogo
1781	if site.Stripepk != "" {
1782		f.StripePK = site.Stripepk
1783	}
1784	return nil
1785}
1786
1787// fetchContent reads a stock page (about/policy/links): from the store's
1788// /api/content in client mode, from the deployment's content/ otherwise.
1789func fetchContent(name string) string {
1790	s := storeURL()
1791	if s == "" {
1792		return contentFile("content/" + name + ".html")
1793	}
1794	client := &http.Client{Timeout: 15 * time.Second}
1795	resp, err := client.Get(s + "/api/content/" + url.PathEscape(name))
1796	if err != nil {
1797		return ""
1798	}
1799	defer resp.Body.Close() //nolint:errcheck // read-side close
1800	if resp.StatusCode != http.StatusOK {
1801		return ""
1802	}
1803	data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
1804	if err != nil {
1805		return ""
1806	}
1807	return string(data)
1808}
1809
1810// imageCache holds fetched remote images; the render worker re-reads an
1811// image at every pane size, and the network should pay only once.
1812var (
1813	imageCacheMu sync.Mutex
1814	imageCache   = map[string][]byte{}
1815)
1816
1817// getImageData reads an image by path or URL.
1818func getImageData(path string) ([]byte, error) {
1819	if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
1820		return os.ReadFile(path) //nolint:gosec // paths come from the catalog, not the user
1821	}
1822	imageCacheMu.Lock()
1823	data, ok := imageCache[path]
1824	imageCacheMu.Unlock()
1825	if ok {
1826		return data, nil
1827	}
1828	client := &http.Client{Timeout: 20 * time.Second}
1829	// Fetching the URL it was asked for is the point of this function.
1830	resp, err := client.Get(path) //nolint:gosec
1831	if err != nil {
1832		return nil, err
1833	}
1834	defer resp.Body.Close() //nolint:errcheck // read-side close
1835	if resp.StatusCode != http.StatusOK {
1836		return nil, fmt.Errorf("%s", resp.Status)
1837	}
1838	data, err = io.ReadAll(io.LimitReader(resp.Body, 16<<20))
1839	if err != nil {
1840		return nil, err
1841	}
1842	imageCacheMu.Lock()
1843	if len(imageCache) > 64 {
1844		imageCache = map[string][]byte{}
1845	}
1846	imageCache[path] = data
1847	imageCacheMu.Unlock()
1848	return data, nil
1849}
1850
1851
1852// ===== pkg/tui/render.go =====
1853// Package tui pkg/tui/render.go β€” images, the globe, and text conversion.
1854// Everything renders to cells: product photos become pixel buffers
1855// painted as β–€ half-blocks (or libcaca canvases blitted glyph by glyph),
1856// and tcell diffs frames so only changed cells reach the terminal.
1857package tui
1858
1859import (
1860	"bytes"
1861	"fmt"
1862	"image"
1863	"image/color"
1864	_ "image/gif"
1865	_ "image/jpeg"
1866	_ "image/png"
1867	"net/url"
1868	"os"
1869	"path/filepath"
1870	"regexp"
1871	"strings"
1872
1873	xhtml "html"
1874
1875	"github.com/gdamore/tcell/v3"
1876	"golang.org/x/image/draw"
1877
1878	"github.com/0magnet/chaosrack/pkg/geom"
1879	"github.com/0magnet/chaosrack/pkg/rasterview"
1880	"github.com/0magnet/img2txt-go/caca"
1881
1882	"github.com/0magnet/m2/pkg/product"
1883)
1884
1885// imgMode selects how product photos are painted.
1886type imgMode int
1887
1888const (
1889	modeHalfBlock imgMode = iota // truecolor, two pixels per cell via β–€
1890	modeCaca                     // libcaca ANSI art, as /logo renders it
1891)
1892
1893// artwork is a rendered product photo: a pixel buffer (half-block mode),
1894// a cell canvas (caca mode), or a message when there is nothing to show.
1895type artwork struct {
1896	rgba  *image.RGBA
1897	cells *cellArt
1898	msg   string
1899}
1900
1901// cellArt is a libcaca canvas as terminal cells.
1902type cellArt struct {
1903	w, h int
1904	ch   []rune
1905	st   []tcell.Style
1906}
1907
1908// imagePath resolves a product photo the way the templates do:
1909// img/<category>/<image1> (from the store's /i in client mode), falling
1910// back locally to img/<image1>. A nil product (nothing selected yet β€”
1911// an empty table's preview) has no image.
1912func imagePath(p *product.Product) string {
1913	if p == nil || p.Image1 == "" {
1914		return ""
1915	}
1916	if s := storeURL(); s != "" {
1917		return s + "/i/" + url.PathEscape(p.Category) + "/" + url.PathEscape(p.Image1)
1918	}
1919	for _, path := range []string{
1920		filepath.Join("img", p.Category, p.Image1),
1921		filepath.Join("img", p.Image1),
1922	} {
1923		if _, err := os.Stat(path); err == nil {
1924			return path
1925		}
1926	}
1927	return ""
1928}
1929
1930func loadImage(path string) (image.Image, error) {
1931	data, err := getImageData(path)
1932	if err != nil {
1933		return nil, err
1934	}
1935	img, _, err := image.Decode(bytes.NewReader(data))
1936	return img, err
1937}
1938
1939// scaleToFit scales img into a pixel buffer no larger than maxW x maxH,
1940// preserving aspect. Half-block pixels are roughly square.
1941func scaleToFit(img image.Image, maxW, maxH int) *image.RGBA {
1942	b := img.Bounds()
1943	pw, ph := b.Dx(), b.Dy()
1944	if pw*maxH > ph*maxW {
1945		ph = ph * maxW / pw
1946		pw = maxW
1947	} else {
1948		pw = pw * maxH / ph
1949		ph = maxH
1950	}
1951	if pw < 1 {
1952		pw = 1
1953	}
1954	if ph < 1 {
1955		ph = 1
1956	}
1957	dst := image.NewRGBA(image.Rect(0, 0, pw, ph))
1958	draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
1959	return dst
1960}
1961
1962// renderArt produces a product photo sized to fit cols x rows cells.
1963func renderArt(path string, cols, rows int, mode imgMode) artwork {
1964	if path == "" {
1965		return artwork{msg: "no image"}
1966	}
1967	if cols < 2 || rows < 2 {
1968		return artwork{msg: "pane too small"}
1969	}
1970	if mode == modeCaca {
1971		cells, err := renderCaca(path, cols, rows)
1972		if err != nil {
1973			return artwork{msg: err.Error()}
1974		}
1975		return artwork{cells: cells}
1976	}
1977	img, err := loadImage(path)
1978	if err != nil {
1979		return artwork{msg: err.Error()}
1980	}
1981	if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
1982		return artwork{msg: "empty image"}
1983	}
1984	return artwork{rgba: scaleToFit(img, cols, rows*2)}
1985}
1986
1987// renderCaca draws an image as libcaca ANSI art β€” the same renderer
1988// behind the /logo endpoint, via the pure-Go img2txt port β€” and reads
1989// the canvas cells straight into terminal cells.
1990func renderCaca(path string, cols, rows int) (*cellArt, error) {
1991	data, err := getImageData(path)
1992	if err != nil {
1993		return nil, err
1994	}
1995	im, err := caca.DecodeImage(bytes.NewReader(data))
1996	if err != nil {
1997		return nil, err
1998	}
1999	if im.W == 0 || im.H == 0 {
2000		return nil, fmt.Errorf("bad dimensions")
2001	}
2002	// img2txt's 6x10 font-cell aspect, constrained by both axes.
2003	lines := cols * im.H * 6 / im.W / 10
2004	if lines > rows {
2005		lines = rows
2006		cols = lines * im.W * 10 / im.H / 6
2007	}
2008	if lines < 1 || cols < 1 {
2009		return nil, fmt.Errorf("pane too small")
2010	}
2011	cv := caca.NewCanvas(cols, lines)
2012	cv.SetColorANSI(caca.Default, caca.Transparent)
2013	cv.Clear()
2014	im.Dither.SetAlgorithm("fstein")
2015	cv.DitherBitmap(0, 0, cols, lines, im.Dither, im.Pixels)
2016
2017	art := &cellArt{w: cv.Width, h: cv.Height,
2018		ch: make([]rune, cv.Width*cv.Height), st: make([]tcell.Style, cv.Width*cv.Height)}
2019	for i, ch := range cv.Chars {
2020		art.ch[i] = ch
2021		art.st[i] = tcell.StyleDefault.
2022			Foreground(rgb12(caca.AttrToRGB12Fg(cv.Attrs[i]))).
2023			Background(rgb12(caca.AttrToRGB12Bg(cv.Attrs[i])))
2024	}
2025	return art, nil
2026}
2027
2028// rgb12 expands libcaca's 12-bit 0xRGB into a tcell color.
2029func rgb12(v uint16) tcell.Color {
2030	return tcell.NewRGBColor(
2031		int32((v>>8&0xF)*17), int32((v>>4&0xF)*17), int32((v&0xF)*17))
2032}
2033
2034// drawArtwork paints a rendered photo centered in r.
2035func drawArtwork(sc tcell.Screen, r rect, art *artwork) {
2036	switch {
2037	case art == nil:
2038	case art.rgba != nil:
2039		rows := (art.rgba.Bounds().Dy() + 1) / 2
2040		yoff := (r.h - rows) / 2
2041		if yoff < 0 {
2042			yoff = 0
2043		}
2044		drawRGBA(sc, r.x, r.y+yoff, r.w, r.h-yoff, art.rgba)
2045	case art.cells != nil:
2046		xoff := (r.w - art.cells.w) / 2
2047		yoff := (r.h - art.cells.h) / 2
2048		if xoff < 0 {
2049			xoff = 0
2050		}
2051		if yoff < 0 {
2052			yoff = 0
2053		}
2054		for y := 0; y < art.cells.h && y < r.h; y++ {
2055			for x := 0; x < art.cells.w && x < r.w; x++ {
2056				i := y*art.cells.w + x
2057				sc.SetContent(r.x+xoff+x, r.y+yoff+y, art.cells.ch[i], nil, art.cells.st[i])
2058			}
2059		}
2060	case art.msg != "":
2061		printMarkupCenter(sc, r.x, r.y+r.h/2, r.w, "[gray]"+esc(art.msg)+"[-]", styleText)
2062	}
2063}
2064
2065// The home panel: a wireframe globe turning over the site logo, as the
2066// website's landing view renders its wasm globe over the svg logo.
2067
2068// makeBackdrop scales the logo to one panel size.
2069func makeBackdrop(logo image.Image, cols, rows int) *image.RGBA {
2070	if logo == nil || cols <= 0 || rows <= 0 {
2071		if cols < 1 {
2072			cols = 1
2073		}
2074		if rows < 1 {
2075			rows = 1
2076		}
2077		return image.NewRGBA(image.Rect(0, 0, cols, rows*2))
2078	}
2079	// Full brightness, as the website shows it: the svg behind the canvas
2080	// is not dimmed there, so its white stays white here too. The globe
2081	// reads on top by its own saturation, the way it does on the page.
2082	return scaleToFit(logo, cols, rows*2)
2083}
2084
2085// globeLines is the globe the website's attractor engine uploads to
2086// WebGL β€” same geometry, from the same package.
2087var globeLines = geom.Globe(18, 36, 60)
2088
2089// globeFrame composites one animation frame: the dimmed logo with the
2090// chaosrack globe drawn over it by chaosrack's own software renderer, in
2091// its default look — random pose, red→blue gradient along model z (see
2092// rasterview.DefaultGradient). BackDim is the one departure from the
2093// WebGL look: an unantialiased raster needs the depth cue.
2094func globeFrame(backdrop *image.RGBA, ax, ay, az float64) *image.RGBA {
2095	frame := image.NewRGBA(backdrop.Bounds())
2096	copy(frame.Pix, backdrop.Pix)
2097	view := rasterview.View{AngleX: ax, AngleY: ay, AngleZ: az, Dist: 3.2, BackDim: 0.5}
2098	view.Render(frame, globeLines.Vertices, globeLines.Indices, rasterview.DefaultGradient())
2099	return frame
2100}
2101
2102// drawRGBA paints a pixel buffer into terminal cells at x0,y0, two pixels
2103// per cell via β–€, centered in cols columns. Cells unchanged since the
2104// last frame cost nothing: tcell diffs them away.
2105func drawRGBA(sc tcell.Screen, x0, y0, cols, maxRows int, img *image.RGBA) {
2106	pw, ph := img.Bounds().Dx(), img.Bounds().Dy()
2107	xoff := 0
2108	if cols > pw {
2109		xoff = (cols - pw) / 2
2110	}
2111	for y := 0; y < ph; y += 2 {
2112		if y/2 >= maxRows {
2113			break
2114		}
2115		for x := 0; x < pw; x++ {
2116			if xoff+x >= cols {
2117				break
2118			}
2119			top := img.RGBAAt(x, y)
2120			var bot color.RGBA
2121			if y+1 < ph {
2122				bot = img.RGBAAt(x, y+1)
2123			}
2124			st := tcell.StyleDefault.
2125				Foreground(tcell.NewRGBColor(step16(top.R), step16(top.G), step16(top.B))).
2126				Background(tcell.NewRGBColor(step16(bot.R), step16(bot.G), step16(bot.B)))
2127			sc.SetContent(x0+xoff+x, y0+y/2, 'β–€', nil, st)
2128		}
2129	}
2130}
2131
2132var (
2133	tmplAction = regexp.MustCompile(`\{\{[^}]*\}\}`)
2134	tagAnchor  = regexp.MustCompile(`(?is)<a\b[^>]*>(.*?)</a>`)
2135	tagBreak   = regexp.MustCompile(`(?i)<br\s*/?>`)
2136	tagBlock   = regexp.MustCompile(`(?i)</(p|h1|h2|h3|div|tr|li|ul|table|pre)>`)
2137	tagHeading = regexp.MustCompile(`(?is)<h[1-3][^>]*>(.*?)</h[1-3]>`)
2138	tagLi      = regexp.MustCompile(`(?i)<li[^>]*>`)
2139	tagCell    = regexp.MustCompile(`(?i)</t[dh]>`)
2140	tagAny     = regexp.MustCompile(`(?s)<[^>]*>`)
2141	manyBlank  = regexp.MustCompile(`\n{3,}`)
2142)
2143
2144// htmlToText renders a stock-page fragment (about/policy/links) as
2145// terminal text: links cyan like the site, headings bold, tags dropped.
2146func htmlToText(s, year string) string {
2147	s = strings.ReplaceAll(s, "{{.Page.Year}}", year)
2148	s = tmplAction.ReplaceAllString(s, "")
2149	s = strings.ReplaceAll(s, "[", "[[") // user HTML must not become tags
2150	s = tagHeading.ReplaceAllString(s, "\n[::b]$1[-:-:-]\n")
2151	s = tagAnchor.ReplaceAllString(s, "[aqua]$1[-]")
2152	s = tagBreak.ReplaceAllString(s, "\n")
2153	s = tagLi.ReplaceAllString(s, "  * ")
2154	s = tagCell.ReplaceAllString(s, "  ")
2155	s = tagBlock.ReplaceAllString(s, "\n")
2156	s = tagAny.ReplaceAllString(s, "")
2157	s = xhtml.UnescapeString(s)
2158	s = manyBlank.ReplaceAllString(s, "\n\n")
2159	return strings.TrimSpace(s) + "\n"
2160}
2161
2162// step16 snaps a channel to 16 levels, spanning the full range: 0x00 stays
2163// 0x00 and 0xFF stays 0xFF, so the logo's black and white are untouched and
2164// only the interpolated greys move, by at most 1/32 of the range.
2165//
2166// This is a rendering cost, not an aesthetic choice. The WebGL renderer
2167// caches rasterised glyphs under a key that includes both colors, so a
2168// truecolor half-block is a distinct glyph per color pair. A full-screen
2169// gradient at ~11k cells hands it ~11k keys a frame against an atlas of
2170// ~18k slots; it fills, clears, and re-rasterises everything, every frame.
2171// Quantising collapses the key space enough for the cache to hold, which
2172// measured out at roughly half the CPU and less than half the worst-case
2173// frame time.
2174func step16(v uint8) int32 { return int32(v&0xF0 | v>>4) }
2175
2176
2177// ===== pkg/tui/tui.go =====
2178// Package tui pkg/tui/tui.go β€” a terminal view of the web store, derived
2179// from the website itself: the same masthead, the catsubcats navigation
2180// tree, one panel at a time like the front page's :target panels, the
2181// category tables, product pages in template order, the stock content
2182// pages, and the footer bar. The landing view is the site's landing
2183// view: a wireframe globe turning over the logo.
2184//
2185// Written directly on tcell v3 with the small widget kit in widgets.go β€”
2186// the same terminal stack as the rest of the 0magnet ecosystem, so the
2187// browser build can ride tuiwasm/xtcell.
2188package tui
2189
2190import (
2191	"embed"
2192	"fmt"
2193	"image"
2194	"math"
2195	"math/rand"
2196	"os"
2197	"strconv"
2198	"strings"
2199	"sync"
2200	"time"
2201
2202	"github.com/gdamore/tcell/v3"
2203
2204	"github.com/0magnet/calvin"
2205
2206	"github.com/0magnet/m2/pkg/config"
2207	"github.com/0magnet/m2/pkg/product"
2208)
2209
2210//go:embed *.go
2211var Source embed.FS
2212
2213// f aliases the shared configuration, as in pkg/web.
2214var f = &config.F
2215
2216// site styles, from content/style.css
2217var (
2218	styleText   = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack)
2219	styleLink   = styleText.Foreground(tcell.ColorAqua)                                                 // a{color:cyan}
2220	styleSel    = tcell.StyleDefault.Foreground(tcell.GetColor("#ffa500")).Background(tcell.ColorWhite) // a.cur
2221	styleThead  = styleText.Foreground(tcell.GetColor("#a020f0")).Bold(true).Underline(true)            // thead border: purple
2222	styleBorder = styleText.Foreground(tcell.ColorBlue)
2223	styleField  = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.GetColor("#000087"))
2224	styleButton = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.GetColor("#005f87"))
2225
2226	// The header and footer tables are closed by a 1px blue border on the
2227	// page; their cell edges are white. Terminal rules stand in for both.
2228	ruleBlue  = styleText.Foreground(tcell.GetColor("#0000ff"))
2229	ruleWhite = styleText.Foreground(tcell.ColorWhite)
2230)
2231
2232// panel is one of the site's :target panels.
2233type panel interface {
2234	draw(u *ui, sc tcell.Screen, r rect)
2235	key(u *ui, ev *tcell.EventKey) bool
2236	mouse(u *ui, r rect, ev *tcell.EventMouse) bool
2237}
2238
2239// overlayW is a commerce overlay (cart / shipping / checkout).
2240type overlayW interface {
2241	draw(u *ui, sc tcell.Screen, r rect)
2242	key(u *ui, ev *tcell.EventKey) bool
2243	size() (w, h int)
2244	title() string
2245}
2246
2247// dropState is an open header dropdown.
2248type dropState struct {
2249	kind    string // "cats" | "etc"
2250	lst     *list
2251	targets []navTarget // cats only
2252	width   int
2253}
2254
2255// artHolder carries one asynchronously rendered product photo.
2256type artHolder struct {
2257	key  string // what art holds
2258	want string // what was last requested
2259	art  *artwork
2260}
2261
2262type artReq struct {
2263	key    string
2264	path   string
2265	w, h   int
2266	mode   imgMode
2267	holder *artHolder
2268}
2269
2270type ui struct {
2271	screen tcell.Screen
2272	cat    *catalogInfo
2273	quit   bool
2274
2275	panels  map[string]panel
2276	history []string
2277	fwd     []string
2278
2279	drop     *dropState
2280	commerce overlayW
2281	notice   string
2282
2283	// postMu gates post against RunOn's shutdown closing the queue.
2284	postMu   sync.Mutex
2285	finished bool
2286
2287	// header nav and footer summary click regions, rebuilt each draw
2288	navRegions []struct {
2289		x1, x2 int
2290		act    string
2291	}
2292	footRegions []struct {
2293		x1, x2 int
2294		act    string
2295	}
2296
2297	// image rendering
2298	mode  imgMode
2299	reqCh chan artReq
2300
2301	// expanded holds which categories' branches are open in the
2302	// Categories dropdown, like the template's <details open>.
2303	expanded map[string]bool
2304
2305	// home animation. The ticker goroutine computes frame (a pixel
2306	// buffer) and the home panel paints it; both sides go through mu.
2307	// current names the visible panel; the goroutine reads it too.
2308	mu       sync.Mutex
2309	current  string
2310	logo     image.Image
2311	homeW    int
2312	homeH    int
2313	frame    *image.RGBA
2314	backdrop *image.RGBA
2315	bdW, bdH int
2316	// pose randomized at startup like chaosrack's randomizeOrientation,
2317	// then tumbled by a random rate on every axis.
2318	poseX, poseY, poseZ float64
2319	rateX, rateY, rateZ float64
2320	done                chan struct{}
2321
2322	asciiLines []string
2323	caps       string
2324
2325	// cart, as the footer's View Cart
2326	cartOrder []string
2327	cartQty   map[string]int
2328	shipping  *shippingLine
2329
2330	sitedomain string
2331	masthead   [3]string
2332}
2333
2334// Run browses the store in a full-screen terminal UI until the user
2335// quits. It owns the screen; the browser build makes an xtcell screen
2336// itself and calls RunOn.
2337func Run(prods product.Products) error {
2338	sc, err := tcell.NewScreen()
2339	if err != nil {
2340		return err
2341	}
2342	if err := sc.Init(); err != nil {
2343		return err
2344	}
2345	return RunOn(sc, prods)
2346}
2347
2348// RunOn drives the store UI on an initialized screen until the user
2349// quits, then finalizes it.
2350func RunOn(sc tcell.Screen, prods product.Products) error {
2351	u := &ui{
2352		cat:      newCatalogInfo(prods),
2353		panels:   map[string]panel{},
2354		mode:     modeHalfBlock,
2355		reqCh:    make(chan artReq, 1),
2356		expanded: map[string]bool{},
2357		done:     make(chan struct{}),
2358		cartQty:  map[string]int{},
2359	}
2360	u.sitedomain = f.Sitename + f.Siteext
2361	if u.sitedomain == "" {
2362		u.sitedomain = "m2"
2363	}
2364	u.asciiLines = strings.Split(strings.TrimRight(calvin.AsciiFont(u.sitedomain), "\n"), "\n")
2365	// The page sets this line in blackboard bold, and the terminal cannot:
2366	// the double-struck capitals live outside the BMP and no monospace font
2367	// here carries them, so every one of them falls back to a face whose
2368	// glyph is nearly twice the cell it is given and collides with its
2369	// neighbor. Plain capitals are the same words, drawn on the grid.
2370	u.caps = strings.ToUpper(u.sitedomain)
2371	long := f.Sitelongname
2372	if long == "" {
2373		long = u.sitedomain
2374	}
2375	tag := ""
2376	if f.Sitetagline != "" {
2377		tag = "- " + esc(f.Sitetagline) + " -"
2378	}
2379	u.masthead = [3]string{"[::b]" + esc(long) + "[-:-:-]", "[gray]" + tag + "[-]", checkerboard(u.sitedomain)}
2380
2381	// A fresh random starting pose each run, as the website gives each
2382	// page load β€” and a random spin rate per axis, each with a floor so
2383	// all three visibly contribute, the vector normalized so the overall
2384	// speed matches auto-rotate's.
2385	u.poseX = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
2386	u.poseY = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
2387	u.poseZ = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
2388	randRate := func() float64 {
2389		r := 0.35 + 0.65*rand.Float64() //nolint:gosec
2390		if rand.Intn(2) == 0 {          //nolint:gosec
2391			return -r
2392		}
2393		return r
2394	}
2395	u.rateX, u.rateY, u.rateZ = randRate(), randRate(), randRate()
2396	norm := math.Sqrt(u.rateX*u.rateX + u.rateY*u.rateY + u.rateZ*u.rateZ)
2397	const pace = 0.042 // auto-rotate's 0.3 rad/s at the 140ms ticker
2398	u.rateX, u.rateY, u.rateZ = u.rateX/norm*pace, u.rateY/norm*pace, u.rateZ/norm*pace
2399
2400	logos := []string{"logo.jpg", "logo.png"}
2401	if s := storeURL(); s != "" {
2402		logos = []string{s + "/logo.jpg", s + "/logo.png"}
2403	}
2404	for _, p := range logos {
2405		if img, err := loadImage(p); err == nil {
2406			u.logo = img
2407			break
2408		}
2409	}
2410
2411	u.screen = sc
2412	sc.SetStyle(styleText)
2413	sc.EnableMouse()
2414
2415	u.panels["home"] = &homePanel{}
2416	u.current = "home"
2417
2418	go u.renderWorker()
2419	go u.animateHome()
2420
2421	defer func() {
2422		// Stop accepting posts before the screen closes the event queue
2423		// under them β€” a ticker mid-post must not race the close.
2424		u.postMu.Lock()
2425		u.finished = true
2426		u.postMu.Unlock()
2427		sc.Fini()
2428		close(u.done)
2429	}()
2430	events := sc.EventQ()
2431	u.draw()
2432	for !u.quit {
2433		ev, ok := <-events
2434		if !ok {
2435			break
2436		}
2437		switch e := ev.(type) {
2438		case *tcell.EventInterrupt:
2439			if fn, ok := e.Data().(func()); ok && fn != nil {
2440				fn()
2441			}
2442		case *tcell.EventResize:
2443			sc.Sync()
2444		case *tcell.EventKey:
2445			// v3 reports key releases too where the terminal can; a
2446			// shortcut must fire once, on the press.
2447			if e.Pressed() {
2448				u.handleKey(e)
2449			}
2450		case *tcell.EventMouse:
2451			u.handleMouse(e)
2452		}
2453		u.draw()
2454	}
2455	return nil
2456}
2457
2458// post runs fn on the event loop and redraws β€” the worker and the
2459// animation ticker use it. Non-blocking and refused after shutdown, so
2460// a producer can never race the queue's close nor wedge against a loop
2461// that has already exited.
2462func (u *ui) post(fn func()) {
2463	u.postMu.Lock()
2464	defer u.postMu.Unlock()
2465	if u.finished {
2466		return
2467	}
2468	select {
2469	case u.screen.EventQ() <- tcell.NewEventInterrupt(fn):
2470	default:
2471	}
2472}
2473
2474// ---- drawing ----
2475
2476func (u *ui) draw() {
2477	sc := u.screen
2478	w, h := sc.Size()
2479	if w < 4 || h < 8 {
2480		sc.Show()
2481		return
2482	}
2483	sc.HideCursor()
2484	fillRect(sc, rect{0, 0, w, h}, styleText)
2485	u.drawHeader(sc, w)
2486	// Row 1 is the header table's bottom border, so the masthead starts
2487	// below it, as the page's title block sits under the nav rule.
2488	for i, line := range u.masthead {
2489		printMarkupCenter(sc, 0, 2+i, w, line, styleText)
2490	}
2491	content := rect{0, 5, w, h - 8}
2492	u.mu.Lock()
2493	cur := u.current
2494	u.mu.Unlock()
2495	if p := u.panels[cur]; p != nil {
2496		p.draw(u, sc, content)
2497	}
2498	u.drawFooter(sc, w, h)
2499	if u.commerce != nil {
2500		r := u.commerceRect()
2501		drawBox(sc, r, "[white] "+u.commerce.title()+" [-]", styleText.Foreground(tcell.ColorWhite), styleText)
2502		u.commerce.draw(u, sc, r.inner())
2503	}
2504	if u.drop != nil {
2505		r := u.dropRect()
2506		drawBox(sc, r, "[white] "+map[string]string{"cats": "Categories", "etc": "Etc..."}[u.drop.kind]+" [-]", styleBorder, styleText)
2507		u.drop.lst.draw(sc, r.inner())
2508	}
2509	if u.notice != "" {
2510		nw := markupWidth(u.notice) + 6
2511		if nw > w-4 {
2512			nw = w - 4
2513		}
2514		r := rect{(w - nw) / 2, h/2 - 2, nw, 5}
2515		drawBox(sc, r, "", styleText.Foreground(tcell.ColorWhite), styleText)
2516		printMarkupCenter(sc, r.x+1, r.y+2, r.w-2, u.notice, styleText)
2517	}
2518	sc.Show()
2519}
2520
2521// drawHeader lays the nav out as the website's header table does: three
2522// equal columns β€” Categories, Home, Etc... β€” each label centered in its
2523// third, with its dropdown opening beneath it. The current section is
2524// cyan and underlined and the rest are white, as the page renders its
2525// links, and the row is closed by the header table's blue bottom border.
2526func (u *ui) drawHeader(sc tcell.Screen, w int) {
2527	entries := []struct{ label, act string }{
2528		{"Categories", "cats"}, {"Home", "home"}, {"Etc...", "etc"},
2529	}
2530	active := u.navActive()
2531	u.navRegions = u.navRegions[:0]
2532	third := w / 3
2533	for i, e := range entries {
2534		x := i*third + (third-len(e.label))/2
2535		if x < 0 {
2536			x = 0
2537		}
2538		tag := "[white]"
2539		if e.act == active {
2540			tag = "[aqua::u]"
2541		}
2542		printMarkup(sc, x, 0, w-x, tag+e.label+"[-:-:-]", styleText)
2543		u.navRegions = append(u.navRegions, struct {
2544			x1, x2 int
2545			act    string
2546		}{x, x + len(e.label), e.act})
2547	}
2548	drawRule(sc, 0, 1, w, ruleBlue)
2549}
2550
2551// navActive names the header entry for the page on screen, the way the
2552// site marks the section you are in: the catalog pages belong to
2553// Categories, the stock pages to Etc..., everything else to Home.
2554func (u *ui) navActive() string {
2555	u.mu.Lock()
2556	cur := u.current
2557	u.mu.Unlock()
2558	switch {
2559	case strings.HasPrefix(cur, "list:"), strings.HasPrefix(cur, "prod:"):
2560		return "cats"
2561	case strings.HasPrefix(cur, "content:"):
2562		return "etc"
2563	}
2564	return "home"
2565}
2566
2567// drawFooter is the website's fixed footer: the status flash, address
2568// and key hints on one row, then the footer table itself β€” View Cart on
2569// the left half and Add Shipping Info on the right, each centered in its
2570// cell, ruled above and divided down the middle as the page's is.
2571func (u *ui) drawFooter(sc tcell.Screen, w, h int) {
2572	status := "[#FF10F0:#009EFF] Open For Business [-:-]"
2573	if f.Teststripekey {
2574		status = "[yellow:red] Test Mode Active - No Orders Processed [-:-]"
2575	}
2576	arrows := ""
2577	if len(u.history) > 0 {
2578		arrows += "←"
2579	}
2580	if len(u.fwd) > 0 {
2581		arrows += "β†’"
2582	}
2583	if arrows != "" {
2584		arrows = "[gray]" + arrows + "[-] "
2585	}
2586	line := fmt.Sprintf(" %s  %s[aqua]%s[-]  [gray]⌫ back · f fwd · c cats · h home · e etc · b buy · v cart · i img · q quit[-]",
2587		status, arrows, u.pagePath())
2588	printMarkup(sc, 0, h-3, w, line, styleText)
2589
2590	// The footer table: a white rule for its top edge, then two equal
2591	// cells split by the divider the page draws between them.
2592	drawRule(sc, 0, h-2, w, ruleWhite)
2593	total, count := u.cartTotal()
2594	cart := fmt.Sprintf("β–΄ View Cart Total: $%d.%02d", total/100, total%100)
2595	if count > 0 {
2596		cart = fmt.Sprintf("β–΄ View Cart (%d) Total: $%d.%02d", count, total/100, total%100)
2597	}
2598	ship := "β–΄ Add Shipping Info"
2599	u.footRegions = u.footRegions[:0]
2600	half := w / 2
2601	for i, e := range []struct{ label, act string }{{cart, "cart"}, {ship, "shipping"}} {
2602		x := i*half + (half-len([]rune(e.label)))/2
2603		if x < 0 {
2604			x = 0
2605		}
2606		printMarkup(sc, x, h-1, w-x, "[aqua]"+esc(e.label)+"[-]", styleText)
2607		u.footRegions = append(u.footRegions, struct {
2608			x1, x2 int
2609			act    string
2610		}{x, x + len([]rune(e.label)), e.act})
2611	}
2612	sc.SetContent(half, h-1, 'β”‚', nil, ruleWhite)
2613}
2614
2615func (u *ui) cartTotal() (cents, count int) {
2616	for partno, qty := range u.cartQty {
2617		if p := u.cat.find(partno); p != nil {
2618			cents += priceCents(p.Price) * qty
2619			count += qty
2620		}
2621	}
2622	if u.shipping != nil {
2623		cents += u.shipping.Cents
2624	}
2625	return cents, count
2626}
2627
2628func priceCents(s string) int {
2629	s = strings.TrimPrefix(s, "$")
2630	fl, err := strconv.ParseFloat(s, 64)
2631	if err != nil {
2632		return 0
2633	}
2634	return int(fl*100 + 0.5)
2635}
2636
2637// ---- input routing ----
2638
2639func (u *ui) handleKey(ev *tcell.EventKey) {
2640	if ev.Key() == tcell.KeyCtrlC {
2641		u.quit = true
2642		return
2643	}
2644	if u.notice != "" {
2645		u.notice = ""
2646		return
2647	}
2648	if u.drop != nil {
2649		if u.drop.lst.key(ev) {
2650			return
2651		}
2652		r := keyRune(ev)
2653		if ev.Key() == tcell.KeyEscape ||
2654			(r == 'c' && u.drop.kind == "cats") || (r == 'e' && u.drop.kind == "etc") {
2655			u.drop = nil
2656			return
2657		}
2658	}
2659	if u.commerce != nil {
2660		if u.commerce.key(u, ev) {
2661			return
2662		}
2663		if ev.Key() == tcell.KeyEscape {
2664			u.closeCommerce()
2665			return
2666		}
2667	}
2668	switch ev.Key() {
2669	case tcell.KeyEscape:
2670		u.back()
2671		return
2672	case tcell.KeyBackspace, tcell.KeyBackspace2:
2673		u.back()
2674		return
2675	}
2676	if ev.Key() == tcell.KeyRune {
2677		switch keyRune(ev) {
2678		case 'q':
2679			u.quit = true
2680			return
2681		case 'c':
2682			u.openCategories()
2683			return
2684		case 'h':
2685			u.show("home")
2686			return
2687		case 'e':
2688			u.openEtc()
2689			return
2690		case 'f':
2691			u.forward()
2692			return
2693		case 'v':
2694			u.toggleCart()
2695			return
2696		case 'i':
2697			if u.mode == modeHalfBlock {
2698				u.mode = modeCaca
2699			} else {
2700				u.mode = modeHalfBlock
2701			}
2702			return // the changed mode re-keys art requests at next draw
2703		}
2704	}
2705	if u.drop != nil || u.commerce != nil {
2706		return
2707	}
2708	u.mu.Lock()
2709	cur := u.current
2710	u.mu.Unlock()
2711	if p := u.panels[cur]; p != nil {
2712		p.key(u, ev)
2713	}
2714}
2715
2716func (u *ui) handleMouse(ev *tcell.EventMouse) {
2717	x, y := ev.Position()
2718	w, h := u.screen.Size()
2719	click := ev.Buttons()&tcell.Button1 != 0
2720	if u.notice != "" {
2721		if click {
2722			u.notice = ""
2723		}
2724		return
2725	}
2726	wasOpen := ""
2727	if u.drop != nil {
2728		r := u.dropRect().inner()
2729		if r.contains(x, y) {
2730			u.drop.lst.mouse(y-r.y, ev)
2731			return
2732		}
2733		if !click {
2734			return
2735		}
2736		// Close, and let the click land: clicking the other header label
2737		// switches menus in one click, as details dropdowns do β€” but a
2738		// click on this menu's own label must collapse it, not reopen it.
2739		wasOpen = u.drop.kind
2740		u.drop = nil
2741	}
2742	if click && y == 0 {
2743		for _, reg := range u.navRegions {
2744			if x >= reg.x1 && x < reg.x2 {
2745				switch reg.act {
2746				case "cats":
2747					if wasOpen != "cats" {
2748						u.openCategories()
2749					}
2750				case "home":
2751					u.show("home")
2752				case "etc":
2753					if wasOpen != "etc" {
2754						u.openEtc()
2755					}
2756				}
2757				return
2758			}
2759		}
2760		return
2761	}
2762	if click && y == h-1 {
2763		for _, reg := range u.footRegions {
2764			if x >= reg.x1 && x < reg.x2 {
2765				switch reg.act {
2766				case "cart":
2767					u.toggleCart()
2768				case "shipping":
2769					if _, open := u.commerce.(*shippingOverlay); open {
2770						u.closeCommerce()
2771					} else {
2772						u.openShipping()
2773					}
2774				}
2775				return
2776			}
2777		}
2778		return
2779	}
2780	if u.commerce != nil {
2781		r := u.commerceRect()
2782		if !r.contains(x, y) && click {
2783			u.closeCommerce()
2784		}
2785		return
2786	}
2787	content := rect{0, 5, w, h - 8}
2788	if content.contains(x, y) {
2789		u.mu.Lock()
2790		cur := u.current
2791		u.mu.Unlock()
2792		if p := u.panels[cur]; p != nil {
2793			p.mouse(u, content, ev)
2794		}
2795	}
2796}
2797
2798// ---- navigation ----
2799
2800func (u *ui) show(page string) {
2801	u.drop = nil
2802	u.closeCommerce()
2803	u.mu.Lock()
2804	if page == u.current {
2805		u.mu.Unlock()
2806		return
2807	}
2808	u.history = append(u.history, u.current)
2809	u.current = page
2810	u.mu.Unlock()
2811	u.fwd = nil
2812	u.ensurePanel(page)
2813}
2814
2815func (u *ui) back() {
2816	u.mu.Lock()
2817	if len(u.history) == 0 {
2818		u.mu.Unlock()
2819		return
2820	}
2821	page := u.history[len(u.history)-1]
2822	u.history = u.history[:len(u.history)-1]
2823	u.fwd = append(u.fwd, u.current)
2824	u.current = page
2825	u.mu.Unlock()
2826}
2827
2828func (u *ui) forward() {
2829	u.mu.Lock()
2830	if len(u.fwd) == 0 {
2831		u.mu.Unlock()
2832		return
2833	}
2834	page := u.fwd[len(u.fwd)-1]
2835	u.fwd = u.fwd[:len(u.fwd)-1]
2836	u.history = append(u.history, u.current)
2837	u.current = page
2838	u.mu.Unlock()
2839	u.ensurePanel(page)
2840}
2841
2842func (u *ui) ensurePanel(page string) {
2843	if _, ok := u.panels[page]; ok {
2844		return
2845	}
2846	switch {
2847	case strings.HasPrefix(page, "list:"):
2848		spec := strings.TrimPrefix(page, "list:")
2849		parts := strings.SplitN(spec, "|", 2)
2850		cat, sub := parts[0], ""
2851		if len(parts) == 2 {
2852			sub = parts[1]
2853		}
2854		u.panels[page] = newListPanel(u, page, cat, sub)
2855	case strings.HasPrefix(page, "prod:"):
2856		u.panels[page] = newProductPanel(u, strings.TrimPrefix(page, "prod:"))
2857	case strings.HasPrefix(page, "content:"):
2858		u.panels[page] = newContentPanel(strings.TrimPrefix(page, "content:"))
2859	}
2860}
2861
2862func listPageID(t navTarget) string {
2863	if t.cat == "" {
2864		return "list:"
2865	}
2866	if t.subcat == "" {
2867		return "list:" + t.cat
2868	}
2869	return "list:" + t.cat + "|" + t.subcat
2870}
2871
2872// pagePath renders the current panel as the URL path the website would
2873// give it β€” the footer shows it like an address bar.
2874func (u *ui) pagePath() string {
2875	u.mu.Lock()
2876	cur := u.current
2877	u.mu.Unlock()
2878	switch {
2879	case cur == "home":
2880		return "/"
2881	case strings.HasPrefix(cur, "content:"):
2882		return "/#" + strings.TrimPrefix(cur, "content:")
2883	case strings.HasPrefix(cur, "prod:"):
2884		return "/p/" + strings.TrimPrefix(cur, "prod:")
2885	case strings.HasPrefix(cur, "list:"):
2886		spec := strings.TrimPrefix(cur, "list:")
2887		if spec == "" {
2888			return "/cat"
2889		}
2890		return "/cat/" + strings.ReplaceAll(spec, "|", "/")
2891	}
2892	return "/"
2893}
2894
2895// ---- header dropdowns ----
2896
2897// dropRect anchors an open menu beneath its header label, like the
2898// website's details dropdowns.
2899func (u *ui) dropRect() rect {
2900	sw, sh := u.screen.Size()
2901	x := 1
2902	idx := 0
2903	if u.drop.kind == "etc" {
2904		idx = 2
2905	}
2906	if idx < len(u.navRegions) {
2907		x = u.navRegions[idx].x1 - 1
2908	}
2909	if x+u.drop.width > sw-1 {
2910		x = sw - 1 - u.drop.width
2911	}
2912	if x < 0 {
2913		x = 0
2914	}
2915	h := len(u.drop.lst.items) + 2
2916	if max := sh - 4; h > max {
2917		h = max
2918	}
2919	return rect{x, 2, u.drop.width, h}
2920}
2921
2922// openCategories drops down the catsubcats tree. Like the template's
2923// nested <details>, categories open and close: the current category
2924// starts open, Enter on a closed category opens it (Enter again
2925// navigates), β†’/space toggle, ← closes.
2926func (u *ui) openCategories() {
2927	if u.drop != nil && u.drop.kind == "cats" {
2928		u.drop = nil
2929		return
2930	}
2931	if cat, _ := u.currentTarget(); cat != "" {
2932		u.expanded[cat] = true
2933	}
2934	// Width from the fully expanded tree, so the box doesn't resize as
2935	// branches open and close.
2936	width := 20
2937	for _, it := range u.cat.buildTree(nil) {
2938		if w := len([]rune(it.label)) + 4; w > width {
2939			width = w
2940		}
2941	}
2942	d := &dropState{kind: "cats", lst: &list{}, width: width}
2943	u.drop = d
2944	rebuild := func(selCat string) {
2945		items := u.cat.buildTree(u.expanded)
2946		d.lst.items = d.lst.items[:0]
2947		d.targets = d.targets[:0]
2948		sel := 0
2949		for i, it := range items {
2950			d.lst.items = append(d.lst.items, esc(it.label))
2951			d.targets = append(d.targets, it.target)
2952			if it.target.cat == selCat && it.target.subcat == "" {
2953				sel = i
2954			}
2955		}
2956		d.lst.sel = sel
2957	}
2958	rebuild("")
2959	itemAt := func(i int) (navTarget, bool) {
2960		if i < 0 || i >= len(d.targets) {
2961			return navTarget{}, false
2962		}
2963		return d.targets[i], true
2964	}
2965	hasSubs := func(t navTarget) bool {
2966		return t.cat != "" && t.subcat == "" && len(u.cat.subcatsByCat[t.cat]) > 0
2967	}
2968	d.lst.onPick = func(i int) {
2969		t, ok := itemAt(i)
2970		if !ok {
2971			return
2972		}
2973		if hasSubs(t) && !u.expanded[t.cat] {
2974			u.expanded[t.cat] = true
2975			rebuild(t.cat)
2976			return
2977		}
2978		u.show(listPageID(t))
2979	}
2980	d.lst.onKey = func(ev *tcell.EventKey, sel int) bool {
2981		t, ok := itemAt(sel)
2982		if !ok {
2983			return false
2984		}
2985		switch {
2986		case ev.Key() == tcell.KeyRight:
2987			if hasSubs(t) && !u.expanded[t.cat] {
2988				u.expanded[t.cat] = true
2989				rebuild(t.cat)
2990			}
2991			return true
2992		case ev.Key() == tcell.KeyLeft:
2993			if t.cat != "" && (u.expanded[t.cat] || t.subcat != "") {
2994				u.expanded[t.cat] = false
2995				rebuild(t.cat)
2996			}
2997			return true
2998		case keyRune(ev) == ' ':
2999			if t.cat == "" {
3000				return true
3001			}
3002			if t.subcat != "" {
3003				u.expanded[t.cat] = false
3004			} else if hasSubs(t) {
3005				u.expanded[t.cat] = !u.expanded[t.cat]
3006			}
3007			rebuild(t.cat)
3008			return true
3009		}
3010		return false
3011	}
3012}
3013
3014// currentTarget reports which category/subcategory panel is showing.
3015func (u *ui) currentTarget() (string, string) {
3016	u.mu.Lock()
3017	cur := u.current
3018	u.mu.Unlock()
3019	if !strings.HasPrefix(cur, "list:") {
3020		return "", ""
3021	}
3022	parts := strings.SplitN(strings.TrimPrefix(cur, "list:"), "|", 2)
3023	if len(parts) == 2 {
3024		return parts[0], parts[1]
3025	}
3026	return parts[0], ""
3027}
3028
3029// openEtc drops down the Etc... menu: About, Policy, Telegram, Contact, Links.
3030func (u *ui) openEtc() {
3031	if u.drop != nil && u.drop.kind == "etc" {
3032		u.drop = nil
3033		return
3034	}
3035	type entry struct {
3036		label string
3037		fn    func()
3038	}
3039	var entries []entry
3040	add := func(label string, fn func()) { entries = append(entries, entry{label, fn}) }
3041	add("About", func() { u.show("content:about") })
3042	add("Policy", func() { u.show("content:policy") })
3043	if f.Tgchannel != "" {
3044		ch := f.Tgchannel
3045		add("Telegram", func() { u.drop = nil; u.notice = "[aqua]https://t.me/" + esc(ch) + "[-]" })
3046	}
3047	if f.Tgcontact != "" {
3048		ct := f.Tgcontact
3049		add("Contact", func() { u.drop = nil; u.notice = "[aqua]https://t.me/" + esc(ct) + "[-]" })
3050	}
3051	add("Links", func() { u.show("content:links") })
3052	lst := &list{}
3053	for _, e := range entries {
3054		lst.items = append(lst.items, esc(e.label))
3055	}
3056	lst.onPick = func(i int) { entries[i].fn() }
3057	u.drop = &dropState{kind: "etc", lst: lst, width: 30}
3058}
3059
3060// ---- the render worker ----
3061
3062func (u *ui) requestArt(h *artHolder, path string, w, ht int) {
3063	key := fmt.Sprintf("%s|%d|%d|%d", path, w, ht, u.mode)
3064	if h.key == key || h.want == key {
3065		return
3066	}
3067	h.want = key
3068	req := artReq{key: key, path: path, w: w, h: ht, mode: u.mode, holder: h}
3069	select {
3070	case u.reqCh <- req:
3071	default:
3072		select { // replace the stale pending request
3073		case <-u.reqCh:
3074		default:
3075		}
3076		u.reqCh <- req
3077	}
3078}
3079
3080func (u *ui) renderWorker() {
3081	cache := map[string]artwork{}
3082	for req := range u.reqCh {
3083		art, ok := cache[req.key]
3084		if !ok {
3085			art = renderArt(req.path, req.w, req.h, req.mode)
3086			if len(cache) > 128 {
3087				cache = map[string]artwork{}
3088			}
3089			cache[req.key] = art
3090		}
3091		r := req
3092		a := art
3093		u.post(func() {
3094			r.holder.key = r.key
3095			r.holder.art = &a
3096		})
3097	}
3098}
3099
3100// ---- the home panel ----
3101
3102type homePanel struct{}
3103
3104func (hp *homePanel) draw(u *ui, sc tcell.Screen, r rect) {
3105	u.mu.Lock()
3106	u.homeW, u.homeH = r.w, r.h
3107	frame := u.frame
3108	u.mu.Unlock()
3109	if frame == nil {
3110		return
3111	}
3112	rows := (frame.Bounds().Dy() + 1) / 2
3113	drawRGBA(sc, r.x, r.y, r.w, r.h, frame)
3114	if rows < r.h {
3115		ty := r.y + rows
3116		for i, l := range u.asciiLines {
3117			if ty+i >= r.y+r.h {
3118				break
3119			}
3120			printMarkupCenter(sc, r.x, ty+i, r.w, esc(l), styleText)
3121		}
3122		if ty+len(u.asciiLines)+1 < r.y+r.h {
3123			printMarkupCenter(sc, r.x, ty+len(u.asciiLines)+1, r.w, esc(u.caps), styleText)
3124		}
3125	}
3126}
3127
3128func (hp *homePanel) key(*ui, *tcell.EventKey) bool           { return false }
3129func (hp *homePanel) mouse(*ui, rect, *tcell.EventMouse) bool { return false }
3130
3131// animateHome turns the globe over the logo while the home panel is
3132// shown, like the website's landing animation over the svg.
3133func (u *ui) animateHome() {
3134	ticker := time.NewTicker(140 * time.Millisecond)
3135	defer ticker.Stop()
3136	for {
3137		select {
3138		case <-u.done:
3139			return
3140		case <-ticker.C:
3141		}
3142		u.mu.Lock()
3143		cur, w, h := u.current, u.homeW, u.homeH
3144		u.mu.Unlock()
3145		if cur != "home" || w <= 0 || h <= 0 {
3146			continue
3147		}
3148		globeRows := h - len(u.asciiLines) - 2
3149		if globeRows < 4 {
3150			globeRows = h
3151		}
3152		if u.backdrop == nil || u.bdW != w || u.bdH != globeRows {
3153			u.backdrop = makeBackdrop(u.logo, w, globeRows)
3154			u.bdW, u.bdH = w, globeRows
3155		}
3156		u.poseX += u.rateX
3157		u.poseY += u.rateY
3158		u.poseZ += u.rateZ
3159		frame := globeFrame(u.backdrop, u.poseX, u.poseY, u.poseZ)
3160		u.mu.Lock()
3161		u.frame = frame
3162		u.mu.Unlock()
3163		u.post(nil) // just redraw
3164	}
3165}
3166
3167// ---- category / subcategory listings ----
3168
3169// listPanel renders a front.html category panel: the h2 heading, the
3170// subcategory line, and the product table, with the selected row's image
3171// beside it (the terminal's stand-in for the table's image column).
3172type listPanel struct {
3173	page, cat, sub string
3174	heading        []string
3175	prods          []*product.Product // panel's full set
3176	rows           []*product.Product // filtered
3177	tbl            *table
3178	art            artHolder
3179	filterOn       bool
3180	filter         finput
3181}
3182
3183func newListPanel(u *ui, page, cat, sub string) *listPanel {
3184	lp := &listPanel{page: page, cat: cat, sub: sub}
3185	all := u.cat.products(cat, sub)
3186	for i := range all {
3187		lp.prods = append(lp.prods, &all[i])
3188	}
3189	title := "All Products"
3190	switch {
3191	case sub != "":
3192		title = cat + " β†’ " + sub
3193	case cat != "":
3194		title = "Category: " + cat
3195	}
3196	lp.heading = []string{"[::b]" + esc(title) + "[-:-:-]"}
3197	if cat != "" && sub == "" {
3198		if subs := u.cat.subcatsByCat[cat]; len(subs) > 0 {
3199			var parts []string
3200			for _, s := range subs {
3201				parts = append(parts, fmt.Sprintf("[aqua]%s[-] (%d)", esc(s), u.cat.subcatCounts[cat][s]))
3202			}
3203			lp.heading = append(lp.heading, "Subcategories: "+strings.Join(parts, ", "))
3204			// front.html shows the remainder table only when there is one
3205			if len(lp.prods) > 0 {
3206				lp.heading = append(lp.heading, "[::b]Other Products in "+esc(cat)+":[-:-:-]")
3207			} else {
3208				lp.heading = append(lp.heading, "[gray]all "+esc(cat)+" products are in subcategories β€” open one from the Categories tree[-]")
3209			}
3210		}
3211	}
3212	lp.tbl = &table{cols: []tcol{
3213		{title: "Name"},
3214		{title: "Price", width: 9, alignRight: true},
3215		{title: "Stock", width: 6, alignRight: true},
3216	}}
3217	lp.tbl.onActivate = func(i int) {
3218		if i >= 0 && i < len(lp.rows) {
3219			u.show("prod:" + lp.rows[i].Partno)
3220		}
3221	}
3222	lp.fill("")
3223	return lp
3224}
3225
3226func (lp *listPanel) fill(query string) {
3227	lp.rows = lp.rows[:0]
3228	var rows []trow
3229	query = strings.ToLower(query)
3230	for _, p := range lp.prods {
3231		if query != "" && !strings.Contains(strings.ToLower(
3232			p.Partno+" "+p.Name+" "+p.Subcategory+" "+p.Description1), query) {
3233			continue
3234		}
3235		nameStyle := styleLink
3236		if p.Quantity == "0" {
3237			nameStyle = styleText.Foreground(tcell.GetColor("#808080"))
3238		}
3239		rows = append(rows, trow{cells: []tcell_{
3240			{p.Name, nameStyle},
3241			{"$" + p.Price, styleText},
3242			{p.Quantity, styleText},
3243		}})
3244		lp.rows = append(lp.rows, p)
3245	}
3246	lp.tbl.setRows(rows)
3247}
3248
3249func (lp *listPanel) selected() *product.Product {
3250	if lp.tbl.sel >= 0 && lp.tbl.sel < len(lp.rows) {
3251		return lp.rows[lp.tbl.sel]
3252	}
3253	return nil
3254}
3255
3256func (lp *listPanel) layout(r rect) (head, tblR, artR, filtR rect) {
3257	hh := len(lp.heading)
3258	head = rect{r.x + 1, r.y, r.w - 2, hh}
3259	body := rect{r.x, r.y + hh + 1, r.w, r.h - hh - 1}
3260	fh := 0
3261	if lp.filterOn {
3262		fh = 1
3263	}
3264	tw := body.w * 3 / 5
3265	tblR = rect{body.x + 1, body.y, tw - 2, body.h - fh}
3266	artR = rect{body.x + tw, body.y, body.w - tw, body.h - fh}
3267	filtR = rect{body.x + 1, body.y + body.h - 1, body.w - 2, 1}
3268	return
3269}
3270
3271func (lp *listPanel) draw(u *ui, sc tcell.Screen, r rect) {
3272	head, tblR, artR, filtR := lp.layout(r)
3273	for i, line := range lp.heading {
3274		printMarkup(sc, head.x, head.y+i, head.w, line, styleText)
3275	}
3276	lp.tbl.draw(sc, tblR)
3277	if p := lp.selected(); p != nil {
3278		u.requestArt(&lp.art, imagePath(p), artR.w, artR.h)
3279	}
3280	drawArtwork(sc, artR, lp.art.art)
3281	if lp.filterOn {
3282		printMarkup(sc, filtR.x, filtR.y, 3, "[aqua]/ [-]", styleText)
3283		curX, _ := lp.filter.drawValue(sc, filtR.x+2, filtR.y, filtR.w-2, true)
3284		sc.ShowCursor(curX, filtR.y)
3285	}
3286}
3287
3288func (lp *listPanel) key(u *ui, ev *tcell.EventKey) bool {
3289	if lp.filterOn {
3290		switch ev.Key() {
3291		case tcell.KeyEscape:
3292			lp.filterOn = false
3293			lp.filter.text = nil
3294			lp.filter.cur = 0
3295			lp.fill("")
3296			return true
3297		case tcell.KeyEnter:
3298			lp.filterOn = false
3299			return true
3300		}
3301		if lp.filter.key(ev) {
3302			lp.fill(string(lp.filter.text))
3303			return true
3304		}
3305		return ev.Key() == tcell.KeyRune
3306	}
3307	if ev.Key() == tcell.KeyRune {
3308		switch keyRune(ev) {
3309		case '/':
3310			lp.filterOn = true
3311			lp.filter = finput{width: 40}
3312			return true
3313		case 'b':
3314			if p := lp.selected(); p != nil {
3315				u.addToCart(p)
3316			}
3317			return true
3318		}
3319	}
3320	return lp.tbl.key(ev)
3321}
3322
3323func (lp *listPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
3324	_, tblR, _, _ := lp.layout(r)
3325	x, y := ev.Position()
3326	if x >= tblR.x+tblR.w && ev.Buttons()&tcell.Button1 == 0 {
3327		// wheel over the picture side still drives the table
3328		return lp.tbl.mouse(1, ev)
3329	}
3330	if tblR.contains(x, y) || ev.Buttons()&(tcell.WheelUp|tcell.WheelDown) != 0 {
3331		return lp.tbl.mouse(y-tblR.y, ev)
3332	}
3333	return false
3334}
3335
3336// ---- the product panel ----
3337
3338type productPanel struct {
3339	prod *product.Product
3340	ta   *textArea
3341	art  artHolder
3342}
3343
3344func newProductPanel(u *ui, partno string) panel {
3345	p := u.cat.find(partno)
3346	if p == nil {
3347		return newContentPanel("") // empty
3348	}
3349	return &productPanel{prod: p, ta: newTextArea(productLines(p), true)}
3350}
3351
3352func (pp *productPanel) draw(u *ui, sc tcell.Screen, r rect) {
3353	ih := r.h * 3 / 5
3354	imgR := rect{r.x, r.y, r.w, ih}
3355	txtR := rect{r.x + 1, r.y + ih + 1, r.w - 2, r.h - ih - 1}
3356	u.requestArt(&pp.art, imagePath(pp.prod), imgR.w, imgR.h)
3357	drawArtwork(sc, imgR, pp.art.art)
3358	pp.ta.draw(sc, txtR)
3359}
3360
3361func (pp *productPanel) key(u *ui, ev *tcell.EventKey) bool {
3362	if ev.Key() == tcell.KeyEnter || keyRune(ev) == 'b' {
3363		u.addToCart(pp.prod)
3364		return true
3365	}
3366	return pp.ta.key(ev)
3367}
3368
3369func (pp *productPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
3370	switch {
3371	case ev.Buttons()&tcell.WheelUp != 0:
3372		pp.ta.wheel(true)
3373	case ev.Buttons()&tcell.WheelDown != 0:
3374		pp.ta.wheel(false)
3375	default:
3376		return false
3377	}
3378	return true
3379}
3380
3381func (u *ui) addToCart(p *product.Product) {
3382	if p.Quantity == "0" {
3383		u.notice = esc(p.Name) + " is out of stock"
3384		return
3385	}
3386	if _, ok := u.cartQty[p.Partno]; !ok {
3387		u.cartOrder = append(u.cartOrder, p.Partno)
3388	}
3389	u.cartQty[p.Partno]++
3390}
3391
3392// ---- content pages: about, policy, links ----
3393
3394type contentPanel struct{ ta *textArea }
3395
3396// contentFile mirrors pkg/web: the deployment-local file when present,
3397// falling back to the committed .example.
3398func contentFile(path string) string {
3399	if data, err := os.ReadFile(path); err == nil { //nolint
3400		return string(data)
3401	}
3402	data, err := os.ReadFile(path + ".example") //nolint
3403	if err != nil {
3404		return ""
3405	}
3406	return string(data)
3407}
3408
3409func newContentPanel(name string) *contentPanel {
3410	raw := fetchContent(name)
3411	year := fmt.Sprintf("%d", time.Now().Year())
3412	text := "[gray]no content/" + esc(name) + ".html[-]"
3413	if raw != "" {
3414		text = htmlToText(raw, year)
3415	}
3416	ta := newTextArea(text, true)
3417	return &contentPanel{ta: ta}
3418}
3419
3420func (cp *contentPanel) draw(u *ui, sc tcell.Screen, r rect) {
3421	cp.ta.draw(sc, rect{r.x + 1, r.y, r.w - 2, r.h})
3422}
3423
3424func (cp *contentPanel) key(u *ui, ev *tcell.EventKey) bool {
3425	return cp.ta.key(ev)
3426}
3427
3428func (cp *contentPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
3429	switch {
3430	case ev.Buttons()&tcell.WheelUp != 0:
3431		cp.ta.wheel(true)
3432	case ev.Buttons()&tcell.WheelDown != 0:
3433		cp.ta.wheel(false)
3434	default:
3435		return false
3436	}
3437	return true
3438}
3439
3440
3441// ===== pkg/tui/widgets.go =====
3442// Package tui pkg/tui/widgets.go β€” the widget kit: a table, a list, a
3443// scrollable text area, and a form, written directly on tcell. Small on
3444// purpose: these are the site's shapes (the category table, the
3445// catsubcats dropdown, product pages, Add Shipping Info), not a general
3446// toolkit.
3447package tui
3448
3449import (
3450	"strings"
3451
3452	"github.com/gdamore/tcell/v3"
3453)
3454
3455// ---- textArea: scrollable, optionally word-wrapped markup text ----
3456
3457type textArea struct {
3458	content string
3459	base    tcell.Style
3460	wrap    bool
3461	scroll  int
3462	lines   [][]seg
3463	lastW   int
3464}
3465
3466func newTextArea(content string, wrap bool) *textArea {
3467	return &textArea{content: content, base: styleText, wrap: wrap}
3468}
3469
3470func (t *textArea) setContent(s string) {
3471	t.content = s
3472	t.lastW = 0
3473	t.scroll = 0
3474}
3475
3476func (t *textArea) draw(sc tcell.Screen, r rect) {
3477	if r.w != t.lastW || t.lines == nil {
3478		t.lines = wrapSegs(t.content, r.w, t.base, t.wrap)
3479		t.lastW = r.w
3480	}
3481	if max := len(t.lines) - r.h; t.scroll > max {
3482		t.scroll = max
3483	}
3484	if t.scroll < 0 {
3485		t.scroll = 0
3486	}
3487	for i := 0; i < r.h; i++ {
3488		li := t.scroll + i
3489		if li >= len(t.lines) {
3490			break
3491		}
3492		printSegs(sc, r.x, r.y+i, r.w, t.lines[li])
3493	}
3494}
3495
3496func (t *textArea) key(ev *tcell.EventKey) bool {
3497	switch ev.Key() {
3498	case tcell.KeyUp:
3499		t.scroll--
3500	case tcell.KeyDown:
3501		t.scroll++
3502	case tcell.KeyPgUp:
3503		t.scroll -= 10
3504	case tcell.KeyPgDn:
3505		t.scroll += 10
3506	case tcell.KeyHome:
3507		t.scroll = 0
3508	case tcell.KeyEnd:
3509		t.scroll = len(t.lines)
3510	default:
3511		return false
3512	}
3513	return true
3514}
3515
3516func (t *textArea) wheel(up bool) {
3517	if up {
3518		t.scroll -= 3
3519	} else {
3520		t.scroll += 3
3521	}
3522}
3523
3524// ---- table: the site's product table ----
3525
3526type tcol struct {
3527	title      string
3528	width      int // 0 = expands
3529	alignRight bool
3530}
3531
3532type tcell_ struct {
3533	text  string
3534	style tcell.Style
3535}
3536
3537type trow struct {
3538	cells []tcell_
3539}
3540
3541type table struct {
3542	cols       []tcol
3543	rows       []trow
3544	sel        int
3545	offset     int
3546	onSelect   func(int)
3547	onActivate func(int)
3548}
3549
3550func (t *table) setRows(rows []trow) {
3551	t.rows = rows
3552	t.offset = 0
3553	if len(rows) == 0 {
3554		t.sel = -1
3555		return
3556	}
3557	t.sel = 0
3558	if t.onSelect != nil {
3559		t.onSelect(0)
3560	}
3561}
3562
3563// colWidths distributes r.w across the columns; width-0 columns share
3564// the remainder.
3565func (t *table) colWidths(w int) []int {
3566	ws := make([]int, len(t.cols))
3567	fixed, flex := 0, 0
3568	for i, c := range t.cols {
3569		ws[i] = c.width
3570		if c.width == 0 {
3571			flex++
3572		} else {
3573			fixed += c.width + 1
3574		}
3575	}
3576	if flex > 0 {
3577		share := (w - fixed - flex) / flex
3578		if share < 4 {
3579			share = 4
3580		}
3581		for i, c := range t.cols {
3582			if c.width == 0 {
3583				ws[i] = share
3584			}
3585		}
3586	}
3587	return ws
3588}
3589
3590func (t *table) draw(sc tcell.Screen, r rect) {
3591	if r.h < 2 {
3592		return
3593	}
3594	ws := t.colWidths(r.w)
3595	x := r.x
3596	for i, c := range t.cols {
3597		st := styleThead
3598		txt := c.title
3599		if len(txt) > ws[i] {
3600			txt = txt[:ws[i]]
3601		}
3602		pad := ws[i] - len(txt)
3603		if c.alignRight {
3604			printMarkup(sc, x+pad, r.y, ws[i], esc(txt), st)
3605		} else {
3606			printMarkup(sc, x, r.y, ws[i], esc(txt), st)
3607		}
3608		x += ws[i] + 1
3609	}
3610	visible := r.h - 1
3611	if t.sel >= 0 {
3612		if t.sel < t.offset {
3613			t.offset = t.sel
3614		}
3615		if t.sel >= t.offset+visible {
3616			t.offset = t.sel - visible + 1
3617		}
3618	}
3619	for row := 0; row < visible; row++ {
3620		ri := t.offset + row
3621		if ri >= len(t.rows) {
3622			break
3623		}
3624		y := r.y + 1 + row
3625		selected := ri == t.sel
3626		if selected {
3627			fillRect(sc, rect{r.x, y, r.w, 1}, styleSel)
3628		}
3629		x = r.x
3630		for i, c := range t.rows[ri].cells {
3631			if i >= len(ws) {
3632				break
3633			}
3634			st := c.style
3635			if selected {
3636				st = styleSel
3637			}
3638			txt := c.text
3639			if len(txt) > ws[i] {
3640				txt = txt[:ws[i]]
3641			}
3642			pad := 0
3643			if t.cols[i].alignRight {
3644				pad = ws[i] - len(txt)
3645			}
3646			printMarkup(sc, x+pad, y, ws[i]-pad, esc(txt), st)
3647			x += ws[i] + 1
3648		}
3649	}
3650}
3651
3652func (t *table) move(d int) {
3653	if len(t.rows) == 0 {
3654		return
3655	}
3656	t.sel += d
3657	if t.sel < 0 {
3658		t.sel = 0
3659	}
3660	if t.sel >= len(t.rows) {
3661		t.sel = len(t.rows) - 1
3662	}
3663	if t.onSelect != nil {
3664		t.onSelect(t.sel)
3665	}
3666}
3667
3668func (t *table) key(ev *tcell.EventKey) bool {
3669	switch ev.Key() {
3670	case tcell.KeyUp:
3671		t.move(-1)
3672	case tcell.KeyDown:
3673		t.move(1)
3674	case tcell.KeyPgUp:
3675		t.move(-10)
3676	case tcell.KeyPgDn:
3677		t.move(10)
3678	case tcell.KeyHome:
3679		t.move(-len(t.rows))
3680	case tcell.KeyEnd:
3681		t.move(len(t.rows))
3682	case tcell.KeyEnter:
3683		if t.sel >= 0 && t.onActivate != nil {
3684			t.onActivate(t.sel)
3685		}
3686	default:
3687		return false
3688	}
3689	return true
3690}
3691
3692// mouse handles a click or wheel at a position local to the table rect;
3693// clicking the selected row activates it, as clicking a link does.
3694func (t *table) mouse(localY int, ev *tcell.EventMouse) bool {
3695	btn := ev.Buttons()
3696	switch {
3697	case btn&tcell.WheelUp != 0:
3698		t.move(-3)
3699	case btn&tcell.WheelDown != 0:
3700		t.move(3)
3701	case btn&tcell.Button1 != 0:
3702		ri := t.offset + localY - 1 // row 0 is the header
3703		if localY < 1 || ri < 0 || ri >= len(t.rows) {
3704			return false
3705		}
3706		if ri == t.sel {
3707			if t.onActivate != nil {
3708				t.onActivate(ri)
3709			}
3710		} else {
3711			t.sel = ri
3712			if t.onSelect != nil {
3713				t.onSelect(ri)
3714			}
3715		}
3716	default:
3717		return false
3718	}
3719	return true
3720}
3721
3722// ---- list: the dropdown menus ----
3723
3724type list struct {
3725	items  []string // markup labels
3726	sel    int
3727	offset int
3728	onPick func(int)
3729	// onKey sees keys first β€” the categories tree uses it for its
3730	// expand/collapse keys.
3731	onKey func(ev *tcell.EventKey, sel int) bool
3732}
3733
3734func (l *list) draw(sc tcell.Screen, r rect) {
3735	if l.sel >= 0 {
3736		if l.sel < l.offset {
3737			l.offset = l.sel
3738		}
3739		if l.sel >= l.offset+r.h {
3740			l.offset = l.sel - r.h + 1
3741		}
3742	}
3743	for i := 0; i < r.h; i++ {
3744		li := l.offset + i
3745		if li >= len(l.items) {
3746			break
3747		}
3748		st := styleLink
3749		if li == l.sel {
3750			st = styleSel
3751			fillRect(sc, rect{r.x, r.y + i, r.w, 1}, styleSel)
3752		}
3753		printMarkup(sc, r.x, r.y+i, r.w, l.items[li], st)
3754	}
3755}
3756
3757func (l *list) key(ev *tcell.EventKey) bool {
3758	if l.onKey != nil && l.onKey(ev, l.sel) {
3759		return true
3760	}
3761	switch ev.Key() {
3762	case tcell.KeyUp:
3763		if l.sel > 0 {
3764			l.sel--
3765		}
3766	case tcell.KeyDown:
3767		if l.sel < len(l.items)-1 {
3768			l.sel++
3769		}
3770	case tcell.KeyHome:
3771		l.sel = 0
3772	case tcell.KeyEnd:
3773		l.sel = len(l.items) - 1
3774	case tcell.KeyEnter:
3775		if l.onPick != nil && l.sel >= 0 {
3776			l.onPick(l.sel)
3777		}
3778	default:
3779		return false
3780	}
3781	return true
3782}
3783
3784func (l *list) mouse(localY int, ev *tcell.EventMouse) bool {
3785	btn := ev.Buttons()
3786	switch {
3787	case btn&tcell.WheelUp != 0:
3788		if l.sel > 0 {
3789			l.sel--
3790		}
3791	case btn&tcell.WheelDown != 0:
3792		if l.sel < len(l.items)-1 {
3793			l.sel++
3794		}
3795	case btn&tcell.Button1 != 0:
3796		li := l.offset + localY
3797		if li < 0 || li >= len(l.items) {
3798			return false
3799		}
3800		l.sel = li
3801		if l.onPick != nil {
3802			l.onPick(li)
3803		}
3804	default:
3805		return false
3806	}
3807	return true
3808}
3809
3810// ---- form: Add Shipping Info ----
3811
3812type formItem interface {
3813	label() string
3814	// drawValue paints the value cell; the form places the cursor.
3815	drawValue(sc tcell.Screen, x, y, w int, focused bool) (curX int, showCur bool)
3816	key(ev *tcell.EventKey) bool
3817	value() string
3818}
3819
3820type finput struct {
3821	lbl   string
3822	text  []rune
3823	cur   int
3824	width int
3825}
3826
3827func (f *finput) label() string { return f.lbl }
3828func (f *finput) value() string { return strings.TrimSpace(string(f.text)) }
3829
3830func (f *finput) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
3831	if f.width < w {
3832		w = f.width
3833	}
3834	st := styleField
3835	fillRect(sc, rect{x, y, w, 1}, st)
3836	start := 0
3837	if f.cur >= w {
3838		start = f.cur - w + 1
3839	}
3840	for i := 0; i < w && start+i < len(f.text); i++ {
3841		sc.SetContent(x+i, y, f.text[start+i], nil, st)
3842	}
3843	return x + f.cur - start, focused
3844}
3845
3846func (f *finput) key(ev *tcell.EventKey) bool {
3847	switch ev.Key() {
3848	case tcell.KeyRune:
3849		ins := []rune(ev.Str())
3850		f.text = append(f.text[:f.cur], append(ins, f.text[f.cur:]...)...)
3851		f.cur += len(ins)
3852	case tcell.KeyBackspace, tcell.KeyBackspace2:
3853		if f.cur > 0 {
3854			f.text = append(f.text[:f.cur-1], f.text[f.cur:]...)
3855			f.cur--
3856		}
3857	case tcell.KeyDelete:
3858		if f.cur < len(f.text) {
3859			f.text = append(f.text[:f.cur], f.text[f.cur+1:]...)
3860		}
3861	case tcell.KeyLeft:
3862		if f.cur > 0 {
3863			f.cur--
3864		}
3865	case tcell.KeyRight:
3866		if f.cur < len(f.text) {
3867			f.cur++
3868		}
3869	case tcell.KeyHome:
3870		f.cur = 0
3871	case tcell.KeyEnd:
3872		f.cur = len(f.text)
3873	default:
3874		return false
3875	}
3876	return true
3877}
3878
3879// fdropdown selects among options with ←/β†’, or by typing a prefix (so
3880// "t","x" lands on TX).
3881type fdropdown struct {
3882	lbl  string
3883	opts []string
3884	sel  int
3885}
3886
3887func (f *fdropdown) label() string { return f.lbl }
3888func (f *fdropdown) value() string { return f.opts[f.sel] }
3889
3890func (f *fdropdown) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
3891	st := styleField
3892	label := f.opts[f.sel]
3893	if label == "" {
3894		label = "β€”"
3895	}
3896	txt := "β—‚ " + label + " β–Έ"
3897	if len(txt) > w {
3898		txt = txt[:w]
3899	}
3900	fillRect(sc, rect{x, y, w, 1}, st)
3901	printMarkup(sc, x, y, w, esc(txt), st)
3902	return x, false
3903}
3904
3905func (f *fdropdown) key(ev *tcell.EventKey) bool {
3906	switch {
3907	case ev.Key() == tcell.KeyLeft:
3908		if f.sel > 0 {
3909			f.sel--
3910		}
3911	case ev.Key() == tcell.KeyRight:
3912		if f.sel < len(f.opts)-1 {
3913			f.sel++
3914		}
3915	case ev.Key() == tcell.KeyRune:
3916		want := strings.ToUpper(ev.Str())
3917		for i := 1; i <= len(f.opts); i++ {
3918			o := f.opts[(f.sel+i)%len(f.opts)]
3919			if strings.HasPrefix(strings.ToUpper(o), want) {
3920				f.sel = (f.sel + i) % len(f.opts)
3921				break
3922			}
3923		}
3924	default:
3925		return false
3926	}
3927	return true
3928}
3929
3930type fbutton struct {
3931	lbl string
3932	fn  func()
3933}
3934
3935// form lays items out one per row with a button row below, inside a
3936// bordered box drawn by its owner.
3937type form struct {
3938	items    []formItem
3939	buttons  []fbutton
3940	focus    int // 0..len(items)-1, then buttons
3941	cancel   func()
3942	labelCol int
3943}
3944
3945func newForm(items []formItem, buttons []fbutton, cancel func()) *form {
3946	f := &form{items: items, buttons: buttons, cancel: cancel}
3947	for _, it := range items {
3948		if n := len(it.label()); n > f.labelCol {
3949			f.labelCol = n
3950		}
3951	}
3952	return f
3953}
3954
3955func (f *form) draw(sc tcell.Screen, r rect) {
3956	sc.HideCursor()
3957	for i, it := range f.items {
3958		y := r.y + i*2
3959		if y >= r.y+r.h-1 {
3960			break
3961		}
3962		printMarkup(sc, r.x, y, r.w, esc(it.label()), styleLink)
3963		curX, show := it.drawValue(sc, r.x+f.labelCol+1, y, r.w-f.labelCol-1, f.focus == i)
3964		if show && f.focus == i {
3965			sc.ShowCursor(curX, y)
3966		}
3967	}
3968	by := r.y + len(f.items)*2
3969	x := r.x + 2
3970	for i, b := range f.buttons {
3971		st := styleButton
3972		if f.focus == len(f.items)+i {
3973			st = styleSel
3974		}
3975		lbl := "  " + b.lbl + "  "
3976		printMarkup(sc, x, by, r.w, esc(lbl), st)
3977		x += len(lbl) + 2
3978	}
3979}
3980
3981func (f *form) key(ev *tcell.EventKey) bool {
3982	switch ev.Key() {
3983	case tcell.KeyEscape:
3984		if f.cancel != nil {
3985			f.cancel()
3986		}
3987		return true
3988	case tcell.KeyTab, tcell.KeyDown:
3989		f.focus = (f.focus + 1) % (len(f.items) + len(f.buttons))
3990		return true
3991	case tcell.KeyBacktab, tcell.KeyUp:
3992		f.focus--
3993		if f.focus < 0 {
3994			f.focus = len(f.items) + len(f.buttons) - 1
3995		}
3996		return true
3997	case tcell.KeyEnter:
3998		if f.focus >= len(f.items) {
3999			f.buttons[f.focus-len(f.items)].fn()
4000		} else {
4001			f.focus++
4002		}
4003		return true
4004	}
4005	if f.focus < len(f.items) {
4006		if f.items[f.focus].key(ev) {
4007			return true
4008		}
4009	}
4010	// Swallow stray runes so shortcuts never fire while a form is up.
4011	return ev.Key() == tcell.KeyRune
4012}
4013
4014
4015// ===== pkg/web/app.go =====
4016// Package web pkg/web/app.go β€” the fiber application, buildable for two hosts.
4017//
4018// Serve (native) and the in-tab site server (js/wasm β€” pkg/storepane's
4019// `serve` command, listening on the bottle vnet loopback) construct the same
4020// application from the same templates and handlers. AppOpts carries the only
4021// differences: where the request log goes, and the origin to lean on for
4022// everything a browser tab must not or cannot hold β€” the Stripe secret key,
4023// files on disk, and the toolchain that compiles wasm drop-ins.
4024package web
4025
4026import (
4027	"errors"
4028	"fmt"
4029	"io"
4030	"net/http"
4031	"os"
4032	"strings"
4033	"time"
4034
4035	"github.com/gofiber/fiber/v3"
4036	"github.com/gofiber/fiber/v3/middleware/static"
4037
4038	"github.com/0magnet/bottle"
4039)
4040
4041// AppOpts selects the host the app is being built for.
4042type AppOpts struct {
4043	// LogOutput receives the request log; nil keeps the native default
4044	// (stdout). The in-tab server points this at its shell's terminal, so
4045	// browsing the vnet site scrolls an access log like any server.
4046	LogOutput io.Writer
4047
4048	// ProxyOrigin, when non-empty, marks the in-tab role. Routes that need
4049	// the host machine (wasm compilation, source tarballs, images and fonts
4050	// on disk, the logo pipeline, CUPS printing) are not registered β€” a GET
4051	// that matches nothing is fetched from this origin instead β€” and the
4052	// payment endpoints forward there, so the tab renders the store but
4053	// never holds a secret key.
4054	ProxyOrigin string
4055}
4056
4057// NewApp builds the store's fiber application. It does not listen; Serve
4058// (native) and the in-tab server each own their listener.
4059func NewApp(o AppOpts) *fiber.App {
4060	initTMPL()
4061	logOut := o.LogOutput
4062	if logOut == nil {
4063		logOut = os.Stdout
4064	}
4065	inTab := o.ProxyOrigin != ""
4066
4067	r := fiber.New(fiber.Config{
4068		ErrorHandler: func(c fiber.Ctx, err error) error {
4069			code := fiber.StatusInternalServerError
4070			var e *fiber.Error
4071			if errors.As(err, &e) {
4072				code = e.Code
4073			}
4074			c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
4075			return c.Status(code).SendString(err.Error())
4076		},
4077	})
4078
4079	r.Use(func(c fiber.Ctx) error {
4080		start := time.Now()
4081		err := c.Next()
4082		status := c.Response().StatusCode()
4083		// Unmatched routes and handler errors are turned into their real
4084		// status by the app ErrorHandler after this middleware returns, so
4085		// c.Response() still reads 200 here. Recover the true code from the
4086		// error so 404s (and 5xx) are logged accurately rather than as 200.
4087		if err != nil {
4088			var fe *fiber.Error
4089			if errors.As(err, &fe) {
4090				status = fe.Code
4091			} else {
4092				status = fiber.StatusInternalServerError
4093			}
4094		}
4095		lat := time.Since(start)
4096		colors := c.App().Config().ColorScheme
4097		ip := fmt.Sprintf("%*s", 15, c.IP())
4098		ipsStr := strings.Join(c.IPs(), ", ")
4099		ips := fmt.Sprintf("%*s", 15, ipsStr)
4100		method := fmt.Sprintf("%-*s", 6, c.Method())
4101		statCol := statusColor(status, colors) + fmt.Sprintf("%3d", status) + colors.Reset
4102		methCol := methodColor(c.Method(), colors) + method + colors.Reset
4103		fmt.Fprintf(logOut, "%s | %s | %12s | %s | %s | %s | %s\n", time.Now().Format("2006-01-02 15:04:05"), statCol, lat, ip, ips, methCol, c.Path()) //nolint:errcheck,gosec
4104		return err
4105	})
4106
4107	if !inTab {
4108		// The desk page's OS layer and browser engine, served ahead of any
4109		// wasm module: jsfs/vnet must exist before Go captures globalThis.fs.
4110		jsAsset := func(body []byte) fiber.Handler {
4111			return func(c fiber.Ctx) error {
4112				c.Set(fiber.HeaderContentType, "text/javascript; charset=utf-8")
4113				c.Set(fiber.HeaderCacheControl, "public, max-age=3600")
4114				return c.Send(body)
4115			}
4116		}
4117		r.Get("/bottle/jsfs.js", jsAsset(bottle.JSFS()))
4118		r.Get("/bottle/vnet.js", jsAsset(bottle.VNetJS()))
4119		serveSourceCode(r)
4120		serveWASM(r)
4121		r.Get("/logo", logo)
4122		r.Get("/logo/:width", logo)
4123		r.Get("/logo/:width/:height", logo)
4124		r.Get("/logo.png", sendFile)
4125		r.Get("/logo.html", sendFile)
4126		r.Get("/mobilelogo.html", sendFile)
4127		r.Get("/logolarge.html", sendFile)
4128		r.Get("/favicon.ico", sendImage)
4129		if f.Siteimagesrc == "" {
4130			r.Use("/i", static.New("./img"))
4131			r.Use("/img", static.New("./img"))
4132		}
4133		r.Use("/font", static.New("./font"))
4134		r.Get("/stl/:filename", func(c fiber.Ctx) error {
4135			name := c.Params("filename")
4136			if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
4137				return c.SendStatus(fiber.StatusBadRequest)
4138			}
4139			return c.SendFile("./img/stl/" + name)
4140		})
4141		r.Get("/stl/base64/:filename", stlbase64)
4142		r.Get("/tui", tuipage)
4143		r.Get("/desk", deskpage)
4144	}
4145
4146	r.Get("/robots.txt", robots)
4147	r.Get("/site.webmanifest", func(c fiber.Ctx) error {
4148		return c.JSON(fiber.Map{
4149			"name":       f.Sitelongname,
4150			"short_name": f.Sitename,
4151			"icons": []fiber.Map{
4152				{"src": f.Siteimagesrc + "/i/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"},
4153				{"src": f.Siteimagesrc + "/i/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"},
4154			},
4155			"theme_color":      "#ffffff",
4156			"background_color": "#ffffff",
4157			"display":          "standalone",
4158		})
4159	})
4160	r.Get("/api/products", apiproducts)
4161	r.Get("/api/site", apisite)
4162	r.Get("/api/content/:name", apicontent)
4163	r.Get("/sitemap", sitemap)
4164	r.Get("/sitemap.xml", sitemap)
4165	r.Get("/", homepage)
4166	r.Get("/p/:partno", productpage)
4167	r.Get("/post/:partno", handlecat)
4168	r.Get("/p", handlecat)
4169	r.Get("/cat", handlecat)
4170	r.Get("/cat/:cat", handlecat)
4171	r.Get("/cat/:cat/:subcat", handlecat)
4172	r.Get("/style.css", style)
4173	r.Get("/font.css", fontcss)
4174
4175	if !inTab {
4176		for _, register := range extraRoutes {
4177			register(r)
4178		}
4179		handleOrder(r)
4180	} else {
4181		// Money crosses back to the real server: the tab renders the cart,
4182		// the origin holds the Stripe key and the order book.
4183		r.Post("/create-payment-intent", proxyToOrigin(o.ProxyOrigin))
4184		r.Post("/submit-order", proxyToOrigin(o.ProxyOrigin))
4185		// Everything the tab does not carry β€” images, fonts, STL models,
4186		// the logo pipeline, /complete's Stripe lookup β€” reads through to
4187		// the origin. Registered last, so it is the 404 path.
4188		r.Use(proxyToOrigin(o.ProxyOrigin))
4189	}
4190	return r
4191}
4192
4193// proxyToOrigin relays the request to the real origin and copies the answer
4194// back. Under js/wasm net/http rides the browser's fetch, so a same-origin
4195// relay needs no credentials or CORS ceremony.
4196func proxyToOrigin(origin string) fiber.Handler {
4197	return func(c fiber.Ctx) error {
4198		method := c.Method()
4199		if method != fiber.MethodGet && method != fiber.MethodPost {
4200			return fiber.ErrNotFound
4201		}
4202		var body io.Reader
4203		if method == fiber.MethodPost {
4204			body = strings.NewReader(string(c.Body()))
4205		}
4206		req, err := http.NewRequest(method, origin+c.OriginalURL(), body)
4207		if err != nil {
4208			return fiber.ErrBadGateway
4209		}
4210		if ct := c.Get(fiber.HeaderContentType); ct != "" {
4211			req.Header.Set(fiber.HeaderContentType, ct)
4212		}
4213		resp, err := http.DefaultClient.Do(req)
4214		if err != nil {
4215			return fiber.ErrBadGateway
4216		}
4217		defer resp.Body.Close() //nolint:errcheck,gosec
4218		payload, err := io.ReadAll(resp.Body)
4219		if err != nil {
4220			return fiber.ErrBadGateway
4221		}
4222		if ct := resp.Header.Get("Content-Type"); ct != "" {
4223			c.Set(fiber.HeaderContentType, ct)
4224		}
4225		return c.Status(resp.StatusCode).Send(payload)
4226	}
4227}
4228
4229
4230// ===== pkg/web/catalog.go =====
4231// Package web pkg/web/catalog.go β€” the in-memory catalog the server sells from.
4232package web
4233
4234import (
4235	"log"
4236	"os"
4237	"sync"
4238	"time"
4239
4240	p "github.com/0magnet/m2/pkg/product"
4241)
4242
4243var (
4244	allproducts   p.Products
4245	allproductsMu sync.RWMutex
4246)
4247
4248var lastModTime time.Time
4249
4250// LoadCatalog reads the products CSV named in the config into memory,
4251// logging any data-integrity warnings.
4252func LoadCatalog() error {
4253	fileInfo, err := os.Stat(f.ProductsCSV)
4254	if err != nil {
4255		return err
4256	}
4257	lastModTime = fileInfo.ModTime()
4258	prods := p.ReadCSV(f.ProductsCSV)
4259	if warnings := p.ValidateCSV(prods); len(warnings) > 0 {
4260		for _, w := range warnings {
4261			log.Println("CSV warning:", w)
4262		}
4263	}
4264	allproductsMu.Lock()
4265	allproducts = prods
4266	allproductsMu.Unlock()
4267	return nil
4268}
4269
4270// WatchCatalog polls the products CSV and reloads it when it changes.
4271// Run it in a goroutine; it never returns.
4272func WatchCatalog() {
4273	for {
4274		fileInfo, err := os.Stat(f.ProductsCSV)
4275		if err != nil {
4276			log.Println("Error getting file info:", err)
4277			time.Sleep(10 * time.Second)
4278			continue
4279		}
4280
4281		currentModTime := fileInfo.ModTime()
4282		if currentModTime != lastModTime {
4283			log.Println("CSV file has been modified!")
4284			if err := LoadCatalog(); err != nil {
4285				log.Println("Error reloading catalog:", err)
4286			}
4287		}
4288
4289		time.Sleep(10 * time.Second)
4290	}
4291}
4292
4293// SetCatalog replaces the in-memory catalog directly, for hosts that have no
4294// CSV on disk to load β€” the in-tab site server seeds this from /api/products,
4295// which is already the public projection (no cost, location or source info).
4296func SetCatalog(prods p.Products) {
4297	allproductsMu.Lock()
4298	allproducts = prods
4299	allproductsMu.Unlock()
4300}
4301
4302
4303// ===== pkg/web/order.go =====
4304// Package web pkg/web/order.go β€” checkout, order persistence, receipt printing.
4305package web
4306
4307import (
4308	"bytes"
4309	"encoding/json"
4310	"fmt"
4311	htmpl "html/template"
4312	"log"
4313	"os"
4314	"path/filepath"
4315	"regexp"
4316	"strconv"
4317	"strings"
4318	"time"
4319
4320	"github.com/bitfield/script"
4321	"github.com/gofiber/fiber/v3"
4322	"github.com/stripe/stripe-go/v81"
4323	"github.com/stripe/stripe-go/v81/paymentintent"
4324)
4325
4326// validPIID matches Stripe PaymentIntent IDs: "pi_" followed by alphanumeric chars.
4327// Also allows plain alphanumeric+underscore+hyphen for test order IDs.
4328var validPIID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
4329
4330func handleOrder(r *fiber.App) {
4331	r.Get("/checkout.css", func(c fiber.Ctx) error {
4332		c.Set("Content-Type", "text/css;charset=utf-8")
4333		_, err := c.Status(fiber.StatusOK).Write([]byte(h.CheckoutCSS()))
4334		return err
4335	})
4336
4337	r.Get("/complete", func(c fiber.Ctx) error {
4338		// Complete template
4339		completetmpl := htmpl.New("index")
4340		if _, err := completetmpl.Parse(h.CompletePage()); err != nil {
4341			msg := fmt.Sprintf("Error parsing complete page template: %v", err)
4342			log.Println(msg)
4343			return c.Status(fiber.StatusInternalServerError).SendString(msg)
4344		}
4345		if _, err := completetmpl.New("wasm").Parse(h.Wasm()); err != nil {
4346			log.Println("Error parsing wasm template:", err)
4347			msg := fmt.Sprintf("Error parsing wasm template: %v", err)
4348			log.Println(msg)
4349			return c.Status(fiber.StatusInternalServerError).SendString(msg)
4350		}
4351		h1 := htmlPageTemplateData
4352		/*
4353			proto := "http"
4354			if c.Secure() {
4355				proto += "s"
4356			}
4357		*/
4358		proto := "https"
4359		h1.Canonical = proto + `://` + c.Hostname() + c.OriginalURL()
4360		h1.BaseURL = proto + `://` + c.Hostname()
4361		h1.RequestHost = c.Hostname()
4362		h1.Protocol = proto
4363		h1.Time = time.Now().Format(time.RFC3339Nano)
4364		h1.Year = fmt.Sprintf("%v", time.Now().Year())
4365		tmplData := map[string]interface{}{
4366			"Page": h1,
4367		}
4368		var result bytes.Buffer
4369		err := completetmpl.Execute(&result, tmplData)
4370		if err != nil {
4371			msg := fmt.Sprintf("Could not execute html template %v", err)
4372			log.Println(msg)
4373			return c.Status(fiber.StatusInternalServerError).SendString(msg)
4374		}
4375		c.Set("Content-Type", "text/html;charset=utf-8")
4376		return c.Status(fiber.StatusOK).Send(result.Bytes())
4377	})
4378
4379	r.Get("/order/:piid", func(c fiber.Ctx) error {
4380		piid := c.Params("piid")
4381		if !validPIID.MatchString(piid) {
4382			return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
4383		}
4384		order, err := script.File("orders/" + piid + ".json").Bytes()
4385		if err != nil {
4386			return c.Status(fiber.StatusNotFound).SendString("Order not found")
4387		}
4388		return c.Status(fiber.StatusOK).Send(order)
4389	})
4390
4391	r.Get("/order/:piid/html", func(c fiber.Ctx) error {
4392		piid := c.Params("piid")
4393		if !validPIID.MatchString(piid) {
4394			return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
4395		}
4396		order, err := script.File("orders/" + piid + ".json").Bytes()
4397		if err != nil {
4398			return c.Status(fiber.StatusNotFound).SendString("Order not found")
4399		}
4400		var m map[string]interface{}
4401		if err := json.Unmarshal(order, &m); err != nil {
4402			return c.Status(500).SendString("failed to unmarshal order json: " + err.Error())
4403		}
4404		receipt, err := buildReceipt(m, piid)
4405		if err != nil {
4406			return c.Status(500).SendString("failed to build receipt: " + err.Error())
4407		}
4408		return c.Status(200).SendString(string(receipt))
4409	})
4410
4411	r.Post("/create-payment-intent", func(c fiber.Ctx) error {
4412		rawBody := c.Body()
4413		if rawBody == nil {
4414			log.Printf("Failed to read raw request body")
4415			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to read request body"})
4416		}
4417
4418		var req struct {
4419			Items []item `json:"items"`
4420		}
4421		if err := json.Unmarshal(rawBody, &req); err != nil {
4422			log.Printf("Failed to parse JSON: %v", err)
4423			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
4424		}
4425
4426		if len(req.Items) == 0 {
4427			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No items in request"})
4428		}
4429
4430		// Validate each item's amount against the server-side product catalog.
4431		// Client sends ID as "partno X qty" for products, or "shipping-to|..." for shipping.
4432		total := int64(0)
4433		for _, it := range req.Items {
4434			if it.Amount <= 0 {
4435				log.Printf("Rejected item with non-positive amount: %s = %d", it.ID, it.Amount)
4436				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid item amount"})
4437			}
4438			if strings.HasPrefix(it.ID, "shipping-to|") {
4439				// Shipping line β€” accept the client-supplied amount
4440				total += it.Amount
4441				continue
4442			}
4443			// Extract partno and qty from "partno X qty"
4444			expectedAmt, err := validateItemAmount(it.ID, it.Amount)
4445			if err != nil {
4446				log.Printf("Item validation failed for %q: %v", it.ID, err)
4447				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Item validation failed"})
4448			}
4449			total += expectedAmt
4450		}
4451
4452		if total < 50 {
4453			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Order total must be at least $0.50"})
4454		}
4455
4456		params := &stripe.PaymentIntentParams{
4457			Amount:   stripe.Int64(total),
4458			Currency: stripe.String(string(stripe.CurrencyUSD)),
4459		}
4460		pi, err := paymentintent.New(params)
4461		if err != nil {
4462			log.Printf("Failed to create PaymentIntent: %v", err)
4463			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
4464		}
4465
4466		log.Printf("Created PaymentIntent %s for %d cents", pi.ID, total)
4467		return c.Status(fiber.StatusOK).JSON(fiber.Map{
4468			"clientSecret":   pi.ClientSecret,
4469			"dpmCheckerLink": fmt.Sprintf("https://dashboard.stripe.com/settings/payment_methods/review?transaction_id=%s", pi.ID),
4470		})
4471	})
4472
4473	r.Post("/submit-order", func(c fiber.Ctx) error {
4474		var requestData struct {
4475			LocalStorageData map[string]interface{} `json:"localStorageData"`
4476			PaymentIntentID  string                 `json:"paymentIntentId"`
4477		}
4478
4479		if err := c.Bind().Body(&requestData); err != nil {
4480			log.Println(err)
4481			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request data"})
4482		}
4483
4484		if !validPIID.MatchString(requestData.PaymentIntentID) {
4485			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payment intent ID"})
4486		}
4487
4488		log.Printf("Received payment intent ID: %s\n", requestData.PaymentIntentID)
4489
4490		paymentIntent, err := paymentintent.Get(requestData.PaymentIntentID, nil)
4491		if err != nil {
4492			log.Printf("Error retrieving payment intent: %v", err)
4493			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to verify payment"})
4494		}
4495		if paymentIntent.Status != stripe.PaymentIntentStatusSucceeded {
4496			log.Printf("Payment was not successful, status: %s", paymentIntent.Status)
4497			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Payment not successful"})
4498		}
4499
4500		ordersDir := "./orders"
4501		if err := os.MkdirAll(ordersDir, 0o750); err != nil {
4502			log.Printf("Error creating orders directory: %v", err)
4503			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
4504		}
4505
4506		filePath := filepath.Join(ordersDir, fmt.Sprintf("%s.json", requestData.PaymentIntentID))
4507
4508		// Idempotency: if the order file already exists, don't overwrite or reprint
4509		if _, err := os.Stat(filePath); err == nil {
4510			log.Printf("Order %s already exists, skipping duplicate submission", requestData.PaymentIntentID)
4511			return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order already submitted"})
4512		}
4513
4514		// Include the verified Stripe amount alongside the client-supplied data
4515		orderData := map[string]interface{}{
4516			"clientData":    requestData.LocalStorageData,
4517			"verifiedCents": paymentIntent.Amount,
4518			"currency":      string(paymentIntent.Currency),
4519			"stripeStatus":  string(paymentIntent.Status),
4520			"submittedAt":   time.Now().Format(time.RFC3339),
4521		}
4522
4523		data, err := json.MarshalIndent(orderData, "", "  ")
4524		if err != nil {
4525			log.Printf("Error marshaling data to json: %v", err)
4526			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
4527		}
4528		if err := os.WriteFile(filePath, data, 0o600); err != nil {
4529			log.Printf("Error writing data to file: %v", err)
4530			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
4531		}
4532
4533		// ---- Print receipt via CUPS (non-blocking so your response is snappy)
4534		go func(pid string, local map[string]interface{}) {
4535			receipt, err := buildReceipt(local, pid)
4536			if err != nil {
4537				log.Printf("build receipt failed: %v", err)
4538				return
4539			}
4540			if err := sendToCUPS(receipt, "Order "+pid); err != nil {
4541				log.Printf("print failed: %v", err)
4542				_ = os.WriteFile(filepath.Join(ordersDir, pid+".print_failed"), []byte(err.Error()), 0o600) //nolint:errcheck // a best-effort marker that the receipt did not print; the failure is already logged
4543			}
4544		}(requestData.PaymentIntentID, requestData.LocalStorageData)
4545
4546		return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order submitted successfully"})
4547	})
4548
4549	/*
4550			r.Post("/reprint/:pid", func(c fiber.Ctx) error {
4551		    pid := c.Params("pid")
4552		    b, err := os.ReadFile(filepath.Join("./orders", pid+".json"))
4553		    if err != nil { return c.Status(404).SendString("not found") }
4554		    var m map[string]interface{}
4555		    if err := json.Unmarshal(b, &m); err != nil { return c.Status(500).SendString(err.Error()) }
4556		    receipt, err := buildReceipt(m, pid)
4557		    if err != nil { return c.Status(500).SendString(err.Error()) }
4558		    if err := sendToCUPS(receipt, "Order "+pid); err != nil {
4559		        return c.Status(500).SendString(err.Error())
4560		    }
4561		    return c.SendStatus(204)
4562		})
4563	*/
4564}
4565
4566func buildReceipt(local map[string]interface{}, paymentIntentID string) ([]byte, error) {
4567	// Pretty JSON body from what you already persisted
4568	body, err := json.MarshalIndent(local, "", "  ")
4569	if err != nil {
4570		return nil, err
4571	}
4572	// Simple text receipt header
4573	ts := time.Now().Format("2006-01-02 15:04:05")
4574	hdr := fmt.Sprintf(
4575		"==================== ORDER ====================\n"+
4576			"PaymentIntent: %s\nTime: %s\n===============================================\n\n",
4577		paymentIntentID, ts,
4578	)
4579	// Footer (optional)
4580	ftr := "\n\n---------------------- END ---------------------\n"
4581	receipt := append([]byte(hdr), body...)
4582	receipt = append(receipt, []byte(ftr)...)
4583	return receipt, nil
4584}
4585
4586// serverPriceCents looks up a product's price from the server-side catalog by part number.
4587func serverPriceCents(partno string) (int64, error) {
4588	allproductsMu.RLock()
4589	prods := allproducts
4590	allproductsMu.RUnlock()
4591	for _, prod := range prods {
4592		if prod.Partno == partno {
4593			return parsePriceCents(prod.Price), nil
4594		}
4595	}
4596	return 0, fmt.Errorf("product %q not found in catalog", partno)
4597}
4598
4599// parsePriceCents converts a price string like "$1.23" or "1.23" to cents.
4600func parsePriceCents(s string) int64 {
4601	if s == "" {
4602		return 0
4603	}
4604	s = strings.TrimPrefix(s, "$")
4605	f, err := strconv.ParseFloat(s, 64)
4606	if err != nil {
4607		return 0
4608	}
4609	if f < 0 {
4610		return -int64(-f*100 + 0.5)
4611	}
4612	return int64(f*100 + 0.5)
4613}
4614
4615// validateItemAmount parses a client item ID ("partno X qty"), looks up the
4616// server-side price, computes the expected total, and returns it. If the
4617// client-supplied amount doesn't match, an error is returned.
4618func validateItemAmount(itemID string, clientAmount int64) (int64, error) {
4619	// Parse "partno X qty"
4620	parts := strings.SplitN(itemID, " X ", 2)
4621	if len(parts) != 2 {
4622		return 0, fmt.Errorf("unexpected item ID format: %q", itemID)
4623	}
4624	partno := parts[0]
4625	qty, err := strconv.Atoi(parts[1])
4626	if err != nil || qty <= 0 {
4627		return 0, fmt.Errorf("invalid quantity in item ID %q", itemID)
4628	}
4629
4630	unitCents, err := serverPriceCents(partno)
4631	if err != nil {
4632		return 0, err
4633	}
4634	expected := unitCents * int64(qty)
4635	if expected != clientAmount {
4636		return 0, fmt.Errorf("amount mismatch for %q: client sent %d cents, server expects %d cents", partno, clientAmount, expected)
4637	}
4638	return expected, nil
4639}
4640
4641// escape for inclusion inside *double quotes* in a bash command string
4642func bashEscapeDoubleQuoted(s string) string {
4643	s = strings.ReplaceAll(s, `\`, `\\`)
4644	s = strings.ReplaceAll(s, `"`, `\"`)
4645	s = strings.ReplaceAll(s, "$", `\$`)
4646	s = strings.ReplaceAll(s, "`", "\\`")
4647	return s
4648}
4649
4650func sendToCUPS(receipt []byte, title string) error {
4651	if title == "" {
4652		title = "Order"
4653	}
4654	var cmd strings.Builder
4655	cmd.WriteString("lp")
4656
4657	if f.PrinterName != "" {
4658		cmd.WriteString(` -d "`)
4659		cmd.WriteString(bashEscapeDoubleQuoted(f.PrinterName))
4660		cmd.WriteString(`"`)
4661	}
4662
4663	cmd.WriteString(` -t "`)
4664	cmd.WriteString(bashEscapeDoubleQuoted(title))
4665	cmd.WriteString(`"`)
4666
4667	if f.CupsOptions != "" {
4668		for _, opt := range strings.Split(f.CupsOptions, ",") {
4669			opt = strings.TrimSpace(opt)
4670			if opt == "" {
4671				continue
4672			}
4673			cmd.WriteString(` -o "`)
4674			cmd.WriteString(bashEscapeDoubleQuoted(opt))
4675			cmd.WriteString(`"`)
4676		}
4677	}
4678
4679	full := fmt.Sprintf(`bash -lc %q`, cmd.String())
4680
4681	_, err := script.Echo(string(receipt)).Exec(full).Stdout()
4682	if err != nil {
4683		return fmt.Errorf("lp failed: %v", err)
4684	}
4685	return nil
4686}
4687
4688
4689// ===== pkg/web/other.go =====
4690// Package web pkg/web/other.go β€” site-specific drop-in routes (not
4691// committed). Registers via the extraRoutes registry in server.go;
4692// deleting this file removes these routes with no other code changes.
4693// See other.go.example.
4694package web
4695
4696import (
4697	"bytes"
4698	"fmt"
4699	"log"
4700
4701	"github.com/gofiber/fiber/v3"
4702)
4703
4704func init() {
4705	extraRoutes = append(extraRoutes, handleOthers)
4706}
4707
4708func handleOthers(r *fiber.App) {
4709	r.Get("/coffee", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusTeapot) })
4710	r.Get("/clock", clock)
4711	r.Get("/attractors", attractorspage)
4712	r.Get("/COVID", covidpage)
4713}
4714
4715func clock(c fiber.Ctx) error {
4716	c.Set("Content-Type", "text/html;charset=utf-8")
4717	_, err := c.Status(fiber.StatusOK).Write([]byte(mustReadFileToString("content/clock.html")))
4718	return err
4719}
4720
4721func covidpage(c fiber.Ctx) error {
4722	tmpl, err := mainTmpl()
4723	if err != nil {
4724		msg := fmt.Sprintf("Error parse html template: %v", err)
4725		log.Println(msg)
4726		return c.Status(fiber.StatusInternalServerError).SendString(msg)
4727	}
4728	tmpl0, err := tmpl.Clone()
4729	if err != nil {
4730		msg := fmt.Sprintf("Error cloning template: %v", err)
4731		log.Println(msg)
4732		return c.Status(fiber.StatusInternalServerError).SendString(msg)
4733	}
4734	_, err = tmpl0.New("main").Parse(mustReadFileToString("content/mementomori.html"))
4735	if err != nil {
4736		msg := fmt.Sprintf("Error parsing main template: %v", err)
4737		log.Println(msg)
4738		return c.Status(fiber.StatusInternalServerError).SendString(msg)
4739	}
4740	tmpl = tmpl0
4741	log.Println(c.Get("User-Agent"))
4742	c.Set("Content-Type", "text/html;charset=utf-8")
4743	h1 := pageMeta(c, htmlPageTemplateData)
4744	h1.Page = "hidden"
4745	h1.MetaDesc = "The COVID  ΜΆvΜΆaΜΆcΜΆcΜΆiΜΆnΜΆeΜΆ bioweapon injection genocide and the new dark age of humanity"
4746	//		h1.Mobile = strings.Contains(strings.ToLower(c.Get("User-Agent")), "mobile")
4747	tmplData := map[string]interface{}{
4748		"Page":  h1,
4749		"Prods": allproducts,
4750	}
4751	var result bytes.Buffer
4752	err = tmpl.Execute(&result, tmplData)
4753	if err != nil {
4754		msg := fmt.Sprintf("Error executing template: %v", err)
4755		log.Println(msg)
4756		return c.Status(fiber.StatusInternalServerError).SendString(msg)
4757	}
4758	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
4759	return err
4760}
4761
4762// attractorspage renders a chromeless fullscreen page for the
4763// strange-attractor visualizer (no header, footer, cart, or store
4764// nav). Reuses the existing stl2 wasm β€” its URL-path dispatcher
4765// sees "/attractors" and falls into the default branch which
4766// invokes attractor.Run(). Loads the TinyGo or stdlib wasm based
4767// on f.UseTinygo.
4768func attractorspage(c fiber.Ctx) error {
4769	suffix := ".wasm"
4770	if f.UseTinygo {
4771		suffix = "-tiny.wasm"
4772	}
4773	wasmFile := "stl2" + suffix
4774	html := fmt.Sprintf(`<!DOCTYPE html>
4775<html lang="en">
4776<head>
4777<meta charset="utf-8">
4778<meta name="viewport" content="width=device-width, initial-scale=1">
4779<title>Strange Attractors β€” %s</title>
4780<meta name="description" content="Interactive 3D strange-attractor visualizer with mouse-drag rotation. Lorenz, Rossler, Chua, Aizawa, Sprott, Lissajous, Thomas, Halvorsen, Chen, Dadras, Rabinovich-Fabrikant, Burke-Shaw, Platonic solids, globe, sphere, torus, magnetosphere.">
4781<meta name="robots" content="index, follow">
4782<style>html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;color:#fff;overflow:hidden;}#gocanvas{position:fixed;top:0;left:0;width:100%%;height:100%%;display:block;}</style>
4783<script src="%s"></script>
4784<script>
4785if (!WebAssembly.instantiateStreaming) {
4786  WebAssembly.instantiateStreaming = async (resp, importObject) => {
4787    const source = await (await resp).arrayBuffer();
4788    return await WebAssembly.instantiate(source, importObject);
4789  };
4790}
4791const go = new Go();
4792WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject).then((result) => {
4793  go.run(result.instance);
4794}).catch((err) => { console.error("Failed to run WASM:", err); });
4795</script>
4796</head>
4797<body>
4798<canvas id="gocanvas"></canvas>
4799</body>
4800</html>`, f.Sitelongname, f.WasmExecPath, wasmFile)
4801	c.Set("Content-Type", "text/html; charset=utf-8")
4802	return c.Status(fiber.StatusOK).SendString(html)
4803}
4804
4805
4806// ===== pkg/web/server.go =====
4807// Package web pkg/web/server.go β€” the store server: routes and page handlers.
4808package web
4809
4810import (
4811	"bytes"
4812	"encoding/base64"
4813	"fmt"
4814	"log"
4815	"os"
4816	"path/filepath"
4817	"regexp"
4818	"sort"
4819	"strconv"
4820	"strings"
4821	"sync"
4822
4823	"github.com/gofiber/fiber/v3"
4824
4825	"github.com/bitfield/script"
4826
4827	"github.com/0magnet/m2/pkg/config"
4828	p "github.com/0magnet/m2/pkg/product"
4829)
4830
4831// f aliases the shared configuration; the name keeps the handler code
4832// identical to its pre-refactor form at the repo root, and drop-in
4833// other.go files keep working unchanged.
4834var f = &config.F
4835
4836var collapseNewlines = regexp.MustCompile(`\n{2,}`)
4837
4838func methodColor(method string, colors fiber.Colors) string {
4839	switch method {
4840	case fiber.MethodGet:
4841		return colors.Cyan
4842	case fiber.MethodPost:
4843		return colors.Green
4844	case fiber.MethodPut:
4845		return colors.Yellow
4846	case fiber.MethodDelete:
4847		return colors.Red
4848	case fiber.MethodPatch:
4849		return colors.White
4850	case fiber.MethodHead:
4851		return colors.Magenta
4852	case fiber.MethodOptions:
4853		return colors.Blue
4854	default:
4855		return colors.Reset
4856	}
4857}
4858
4859func statusColor(code int, colors fiber.Colors) string {
4860	switch {
4861	case code >= fiber.StatusOK && code < fiber.StatusMultipleChoices:
4862		return colors.Green
4863	case code >= fiber.StatusMultipleChoices && code < fiber.StatusBadRequest:
4864		return colors.Blue
4865	case code >= fiber.StatusBadRequest && code < fiber.StatusInternalServerError:
4866		return colors.Yellow
4867	default:
4868		return colors.Red
4869	}
4870}
4871
4872// Serve runs the web store: templates, routes, wasm compilation, and the
4873// http listener. It blocks for the life of the server.
4874func Serve() {
4875	wg := new(sync.WaitGroup)
4876	wg.Add(1)
4877	r := NewApp(AppOpts{})
4878	go func() {
4879		err := r.Listen(fmt.Sprintf(":%d", f.WebPort))
4880		if err != nil {
4881			log.Println("Error serving http: ", err)
4882		}
4883		wg.Done()
4884	}()
4885	CompileWASM()
4886	wg.Wait()
4887}
4888
4889func sitemap(c fiber.Ctx) error {
4890	c.Type("xml", "utf-8")
4891	return c.SendString(generateSitemapXML())
4892}
4893
4894// apisite serves the site identity for store clients β€” the browser tui
4895// has no MENV file to source its masthead from.
4896func apisite(c fiber.Ctx) error {
4897	return c.JSON(fiber.Map{
4898		"Sitename":      f.Sitename,
4899		"Siteext":       f.Siteext,
4900		"Sitelongname":  f.Sitelongname,
4901		"Sitetagline":   f.Sitetagline,
4902		"Tgcontact":     f.Tgcontact,
4903		"Tgchannel":     f.Tgchannel,
4904		"Teststripekey": f.Teststripekey,
4905		// The identity fields below appear verbatim in every rendered page,
4906		// so serving them here reveals nothing new; they let the in-tab site
4907		// server render the same masthead and metadata the origin does.
4908		"Sitemeta":           f.Sitemeta,
4909		"Sitedomain":         f.Sitedomain,
4910		"Siteprettyname":     f.Siteprettyname,
4911		"Siteprettynamecap":  f.Siteprettynamecap,
4912		"Siteprettynamecaps": f.Siteprettynamecaps,
4913		"SiteASCIILogo":      f.SiteASCIILogo,
4914		"Stripepk":           f.StripePK,
4915	})
4916}
4917
4918// apicontent serves the stock-page fragments (about/policy/links) raw,
4919// for the tui to render as text.
4920func apicontent(c fiber.Ctx) error {
4921	name := c.Params("name")
4922	switch name {
4923	case "about", "policy", "links":
4924	default:
4925		return c.SendStatus(fiber.StatusNotFound)
4926	}
4927	c.Set("Content-Type", "text/html;charset=utf-8")
4928	return c.SendString(contentFile("content/" + name + ".html"))
4929}
4930
4931// tuipage serves the terminal storefront: the same store TUI `m2 tui`
4932// runs natively, compiled to wasm (the wasm/tui drop-in, listed in
4933// WASMSRC) and drawn into an xterm-go terminal filling the page.
4934func tuipage(c fiber.Ctx) error {
4935	// Prefer the tinygo build when it exists; the tui's Go build is the
4936	// usual case (tinygo's net/http shim does not compile it yet). The
4937	// wasm_exec.js runtime must match the binary's toolchain β€” under
4938	// USETINYGO the site default is tinygo's, which cannot start a
4939	// stdlib Go binary (LinkError on the gojs imports).
4940	suffix := ".wasm"
4941	execPath := f.WasmExecPathGo
4942	if f.UseTinygo {
4943		if _, err := os.Stat("tui-tiny.wasm"); err == nil {
4944			suffix = "-tiny.wasm"
4945			execPath = f.WasmExecPathTinyGo
4946		}
4947	}
4948	html := fmt.Sprintf(`<!DOCTYPE html>
4949<html lang="en">
4950<head>
4951<meta charset="utf-8">
4952<meta name="viewport" content="width=device-width, initial-scale=1">
4953<title>%s β€” terminal</title>
4954<meta name="description" content="%s as a terminal: browse the catalog, view products, and fill a cart in a TUI running in your browser.">
4955<meta name="robots" content="index, follow">
4956<link rel="stylesheet" href="/font.css">
4957<style>html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;}#terminal{position:fixed;inset:0;}</style>
4958<script src="%s"></script>
4959<script>
4960if (!WebAssembly.instantiateStreaming) {
4961  WebAssembly.instantiateStreaming = async (resp, importObject) => {
4962    const source = await (await resp).arrayBuffer();
4963    return await WebAssembly.instantiate(source, importObject);
4964  };
4965}
4966const go = new Go();
4967WebAssembly.instantiateStreaming(fetch("/tui%s"), go.importObject).then((result) => {
4968  go.run(result.instance);
4969}).catch((err) => { console.error("Failed to run WASM:", err); });
4970</script>
4971</head>
4972<body>
4973<div id="terminal"></div>
4974</body>
4975</html>`, f.Sitedomain, f.Sitelongname, execPath, suffix)
4976	c.Set("Content-Type", "text/html; charset=utf-8")
4977	return c.Status(fiber.StatusOK).SendString(html)
4978}
4979
4980// deskpage serves the site as a desktop: the website in a maximized window
4981// and the storefront terminal in another, both launchable from the panel.
4982// Same drop-in mechanism as tuipage, against the 'wasm/desk' source.
4983func deskpage(c fiber.Ctx) error {
4984	suffix := ".wasm"
4985	execPath := f.WasmExecPathGo
4986	if f.UseTinygo {
4987		if _, err := os.Stat("desk-tiny.wasm"); err == nil {
4988			suffix = "-tiny.wasm"
4989			execPath = f.WasmExecPathTinyGo
4990		}
4991	}
4992	// ?bare / ?fsonly / ?nonet split the page's script layer for debugging:
4993	// bare = none of jsfs/vnet, fsonly = jsfs alone, nonet = no browser.
4994	//
4995	// netscrape is no longer among these. It used to be a JS engine served at
4996	// /netscrape.js; it is now a Go browser compiled INTO the desk wasm, so
4997	// there is no script to leave out β€” ?nonet is honored by the desk itself,
4998	// which reads it from location.search and does not open the window.
4999	scripts := `<script src="/bottle/jsfs.js"></script>
5000<script src="/bottle/vnet.js"></script>`
5001	switch {
5002	case c.Query("bare") != "":
5003		scripts = ""
5004	case c.Query("fsonly") != "":
5005		scripts = `<script src="/bottle/jsfs.js"></script>`
5006	}
5007	html := fmt.Sprintf(`<!DOCTYPE html>
5008<html lang="en">
5009<head>
5010<meta charset="utf-8">
5011<meta name="viewport" content="width=device-width, initial-scale=1">
5012<title>%s β€” desktop</title>
5013<meta name="description" content="%s as a desktop: the website and the storefront terminal, each in a window.">
5014<meta name="robots" content="index, follow">
5015<link rel="stylesheet" href="/font.css">
5016<style>
5017html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;
5018  font-family:mononokiregular,ui-monospace,monospace;color:#fff;}
5019/* Windows are positioned against this, so it is the element that fills the
5020   page rather than the body. */
5021#desktop{position:fixed;inset:0;}
5022#boot{padding:14px;color:#777;font-size:13px;}
5023</style>
5024%s
5025<script>
5026// Channel providers for the netscrape browser window the desk opens. The
5027// clearnet channel is the page's own fetch β€” enough for this origin and for
5028// anything CORS permits; there is no skysocks here. dmsg has no transport in
5029// this page at all, so it answers 502 rather than hanging.
5030globalThis.__m2Glue = {
5031  fetchClearnet: async function (exit, method, url, body, winId, headers) {
5032    try {
5033      const r = await fetch(url, { method: method || "GET", body: body || undefined, headers: headers || undefined });
5034      const buf = new Uint8Array(await r.arrayBuffer());
5035      const hs = {};
5036      r.headers.forEach(function (v, k) { hs[k] = v; });
5037      return { status: r.status, body: buf, headers: hs };
5038    } catch (e) {
5039      return { status: 502, body: new Uint8Array(), headers: {} };
5040    }
5041  },
5042  fetchDmsg: async function () { return { status: 502, body: new Uint8Array(), headers: {} }; },
5043};
5044
5045// netscrape asks globalThis.__netscrapeFetch(url) for every page and
5046// subresource, and expects a Response. Without one it falls back to a
5047// same-origin /fetch proxy this server does not have, so the transport is
5048// wired here rather than left to that default.
5049//
5050// Note the argument order: netscrape's own loader.js calls
5051// fetchClearnet(url, method, body), but __m2Glue predates it and takes
5052// (exit, method, url, ...). The glue is what the rest of this page uses, so
5053// the shim adapts to the glue rather than the other way round.
5054globalThis.__netscrapeFetch = function (url) {
5055  var u;
5056  try { u = new URL(url, location.href); } catch (e) { return fetch(url); }
5057  var path = (u.pathname || "/") + (u.search || "");
5058  var mesh = /\.(dmsg|skysocks|skynet)$/i.test(u.hostname) || /^[0-9a-f]{66}$/i.test(u.hostname);
5059  // vnet:<port> is the site running INSIDE this tab, on the page's virtual
5060  // loopback. It needs its own name: the desk's own origin is a real
5061  // 127.0.0.1:<port>, so addressing the in-tab server as 127.0.0.1 too sent
5062  // it to the page's ordinary fetch and out to the host, which answered
5063  // ERR_CONNECTION_REFUSED. Nothing outside this page can be reached at
5064  // "vnet", so the routing is unambiguous.
5065  var call;
5066  if (u.hostname === "vnet") {
5067    call = globalThis.vnet.httpFetch(parseInt(u.port || "80", 10), "GET", path, null);
5068  } else if (mesh) {
5069    call = globalThis.__m2Glue.fetchDmsg(u.hostname, "GET", path, null);
5070  } else {
5071    call = globalThis.__m2Glue.fetchClearnet(null, "GET", u.href, null, null, null);
5072  }
5073  return Promise.resolve(call).then(function (r) {
5074    var h = new Headers();
5075    if (r && r.headers) { for (var k in r.headers) { try { h.set(k, r.headers[k]); } catch (e) {} } }
5076    return new Response((r && r.body) || new Uint8Array(0), { status: (r && r.status) || 200, headers: h });
5077  });
5078};
5079</script>
5080<script src="%s"></script>
5081<script>
5082if (!WebAssembly.instantiateStreaming) {
5083  WebAssembly.instantiateStreaming = async (resp, importObject) => {
5084    const source = await (await resp).arrayBuffer();
5085    return await WebAssembly.instantiate(source, importObject);
5086  };
5087}
5088const go = new Go();
5089WebAssembly.instantiateStreaming(fetch("/desk%s"), go.importObject).then((result) => {
5090  const b = document.getElementById("boot"); if (b) b.remove();
5091  go.run(result.instance);
5092}).catch((err) => { console.error("Failed to run WASM:", err); });
5093</script>
5094</head>
5095<body>
5096<div id="desktop"><div id="boot">loading…</div></div>
5097</body>
5098</html>`, f.Sitedomain, f.Sitelongname, scripts, execPath, suffix)
5099	c.Set("Content-Type", "text/html; charset=utf-8")
5100	return c.Status(fiber.StatusOK).SendString(html)
5101}
5102
5103// apiproducts serves the catalog as JSON for store clients (the tui's
5104// --store mode, and the browser tui to come). The business-sensitive
5105// columns the HTML never renders stay private.
5106func apiproducts(c fiber.Ctx) error {
5107	allproductsMu.RLock()
5108	prods := make(p.Products, len(allproducts))
5109	copy(prods, allproducts)
5110	allproductsMu.RUnlock()
5111	for i := range prods {
5112		prods[i].Cost = ""
5113		prods[i].Location = ""
5114		prods[i].Sourceinfo = ""
5115	}
5116	return c.JSON(prods)
5117}
5118
5119// extraRoutes collects route registrars from optional drop-in files
5120// (see other.go.example). A drop-in appends its registrar from init();
5121// deleting the file removes its routes with no other code changes.
5122var extraRoutes []func(*fiber.App)
5123
5124func logo(c fiber.Ctx) error {
5125	tmpl, err := auxTmpl()
5126	if err != nil {
5127		msg := fmt.Sprintf("Error parsing html template: %v", err)
5128		log.Println(msg)
5129		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5130	}
5131	tmpl0, err := tmpl.Clone()
5132	if err != nil {
5133		msg := fmt.Sprintf("Error cloning template: %v", err)
5134		log.Println(msg)
5135		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5136	}
5137	_, err = tmpl0.New("main").Parse(h.Logo())
5138	if err != nil {
5139		msg := fmt.Sprintf("Error parsing product page template: %v", err)
5140		log.Println(msg)
5141		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5142	}
5143	tmpl = tmpl0
5144	c.Set("Content-Type", "text/html;charset=utf-8")
5145
5146	img2txtFlags := ""
5147	if w, err := strconv.Atoi(c.Params("width")); err == nil {
5148		img2txtFlags = fmt.Sprintf("--width=%d ", w)
5149	}
5150	if h, err := strconv.Atoi(c.Params("height")); err == nil {
5151		img2txtFlags = fmt.Sprintf("--height=%d ", h)
5152	}
5153
5154	logoHTMLslice, err := script.Exec(fmt.Sprintf("bash -c 'img2txt %s logo.jpg | ansifilter -H'", img2txtFlags)).Slice()
5155	if err != nil {
5156		log.Println("error: ", err)
5157		_, err = c.Status(fiber.StatusInternalServerError).Write([]byte(err.Error() + "/n" + strings.Join(logoHTMLslice, "\n")))
5158		return err
5159	}
5160	if len(logoHTMLslice) > 2 {
5161		logoHTMLslice = logoHTMLslice[:len(logoHTMLslice)-3]
5162	}
5163	if len(logoHTMLslice) > 18 {
5164		logoHTMLslice = logoHTMLslice[19:]
5165	}
5166
5167	var result bytes.Buffer
5168	h1 := pageMeta(c, htmlTemplateData{})
5169	h1.Page = "logo"
5170	h1.Title = "logo"
5171	tmplData := map[string]interface{}{
5172		"Content": strings.Join(logoHTMLslice, "\n"),
5173	}
5174	err = tmpl.Execute(&result, tmplData)
5175	if err != nil {
5176		log.Println("error: ", err)
5177		_, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
5178		return err
5179	}
5180	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
5181	return err
5182}
5183
5184func robots(c fiber.Ctx) error {
5185	c.Set("Content-Type", "text/plain;charset=utf-8")
5186	_, err := c.Status(fiber.StatusOK).Write([]byte(fmt.Sprintf("User-agent: *\n\nSitemap: https://%s/sitemap.xml", c.Hostname())))
5187	return err
5188}
5189
5190func style(c fiber.Ctx) error {
5191	c.Set("Content-Type", "text/css;charset=utf-8")
5192	_, err := c.Status(fiber.StatusOK).Write([]byte(h.StyleCSS()))
5193	return err
5194}
5195
5196// fontcss serves just the @font-face block, so the terminal page can have
5197// the site's face without pulling in the whole stylesheet. The TUI measures
5198// its cell from this font when it opens, which is why it is a separate,
5199// cacheable route rather than something inlined per page load.
5200func fontcss(c fiber.Ctx) error {
5201	c.Set("Content-Type", "text/css;charset=utf-8")
5202	c.Set("Cache-Control", "public, max-age=86400")
5203	_, err := c.Status(fiber.StatusOK).Write([]byte(h.FontCSS()))
5204	return err
5205}
5206
5207func serveWASM(r *fiber.App) {
5208	if f.WasmExecPath != "" {
5209		_, err := script.File(f.WasmExecPath).Bytes()
5210		if err != nil {
5211			log.Printf("Error reading %s: %v\n", f.WasmExecPath, err)
5212		} else { //the wasm exec must be present or none of the webassembly stuff will work ; provided by the golang installaton
5213			r.Get(f.WasmExecPathTinyGo, func(c fiber.Ctx) error {
5214				wasmExecData, err := script.File(f.WasmExecPathTinyGo).Bytes()
5215				if err != nil {
5216					log.Printf("Error reading %s: %v\n", f.WasmExecPathTinyGo, err)
5217					return c.SendStatus(fiber.StatusNotFound)
5218				}
5219				c.Set("Content-Type", "application/js")
5220				_, err = c.Status(fiber.StatusOK).Write(wasmExecData)
5221				return err
5222			})
5223
5224			r.Get(f.WasmExecPathGo, func(c fiber.Ctx) error {
5225				wasmExecData, err := script.File(f.WasmExecPathGo).Bytes()
5226				if err != nil {
5227					log.Printf("Error reading %s: %v\n", f.WasmExecPathGo, err)
5228					return c.SendStatus(fiber.StatusNotFound)
5229				}
5230				c.Set("Content-Type", "application/js")
5231				_, err = c.Status(fiber.StatusOK).Write(wasmExecData)
5232				return err
5233			})
5234
5235			// Register both variants per source: a drop-in tinygo cannot
5236			// compile still serves its Go build, and pages pick whichever
5237			// binary exists.
5238			for _, wasmSRC := range f.WasmSRC {
5239				base := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC))
5240				for _, suffix := range []string{".wasm", "-tiny.wasm"} {
5241					outputFile := base + suffix
5242					r.Get("/"+outputFile, func(c fiber.Ctx) error {
5243						// A binary this size must revalidate, not cache
5244						// blindly: a browser holding yesterday's wasm
5245						// makes a deploy look like nothing changed.
5246						fi, err := os.Stat(outputFile)
5247						if err != nil {
5248							return c.SendStatus(fiber.StatusInternalServerError)
5249						}
5250						etag := fmt.Sprintf(`"%x-%x"`, fi.ModTime().Unix(), fi.Size())
5251						c.Set("Cache-Control", "no-cache")
5252						c.Set("ETag", etag)
5253						if c.Get("If-None-Match") == etag {
5254							return c.SendStatus(fiber.StatusNotModified)
5255						}
5256						data, err := script.File(outputFile).Bytes()
5257						if err != nil {
5258							script.File(outputFile).Stdout() //nolint
5259							return c.SendStatus(fiber.StatusInternalServerError)
5260						}
5261						c.Set("Content-Type", "application/wasm")
5262						return c.Status(fiber.StatusOK).Send(data)
5263					})
5264				}
5265			}
5266		}
5267	}
5268}
5269
5270func sendFile(c fiber.Ctx) error {
5271	return c.SendFile("." + c.Path())
5272}
5273func sendImage(c fiber.Ctx) error {
5274	c.Set("Content-Type", "image/jpeg")
5275	return c.SendFile("./img" + c.Path())
5276}
5277
5278func stlbase64(c fiber.Ctx) error {
5279	name := c.Params("filename")
5280	if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
5281		return c.SendStatus(fiber.StatusBadRequest)
5282	}
5283	stlfile, err := script.File("img/stl/" + name).Bytes()
5284	if err != nil {
5285		return c.SendStatus(fiber.StatusNotFound)
5286	}
5287	_, err = c.Status(fiber.StatusOK).Write([]byte("data:model/stl;base64," + base64.StdEncoding.EncodeToString(stlfile)))
5288	return err
5289}
5290
5291type item struct {
5292	ID     string
5293	Amount int64
5294}
5295
5296func cathtmlfunc(c fiber.Ctx) error {
5297	tmpl, err := mainTmpl()
5298	if err != nil {
5299		msg := fmt.Sprintf("Error parsing html template: %v", err)
5300		log.Println(msg)
5301		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5302	}
5303	tmpl0, err := tmpl.Clone()
5304	if err != nil {
5305		msg := fmt.Sprintf("Error cloning html template: %v", err)
5306		log.Println(msg)
5307		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5308	}
5309	_, err = tmpl0.New("main").Parse(h.CategoryPage())
5310	if err != nil {
5311		msg := fmt.Sprintf("Error parsing Category page template: %v", err)
5312		log.Println(msg)
5313		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5314	}
5315	tmpl = tmpl0
5316	var tmplData map[string]interface{}
5317	var result bytes.Buffer
5318	var categoryproducts p.Products
5319	c.Set("Content-Type", "text/html;charset=utf-8")
5320	h1 := pageMeta(c, htmlPageTemplateData)
5321	h1.Title = fmt.Sprintf("%s | %s", func() string {
5322		var str string
5323		if c.Params("partno") != "" {
5324			return "No product matching partno.: " + c.Params("partno") + " | Showing All Products"
5325		}
5326		if c.Params("cat") == "" {
5327			return "All Products"
5328		}
5329		str = fmt.Sprintf("Category: %s", c.Params("cat"))
5330		if c.Params("subcat") != "" {
5331			str += fmt.Sprintf("; Subcategory: %s", c.Params("subcat"))
5332		}
5333		return str
5334	}(), h1.Title)
5335	h1.Page = "category"
5336	if c.Params("cat") == "" && c.Params("subcat") == "" {
5337		tmplData = map[string]interface{}{
5338			"Products":    allproducts,
5339			"Page":        h1,
5340			"Category":    c.Params("cat"),
5341			"Subcategory": c.Params("subcat"),
5342			"Prods":       allproducts,
5343			"Product":     c.Params("partno"),
5344		}
5345	} else {
5346
5347		for _, prod := range allproducts {
5348			if strings.EqualFold(prod.Category, c.Params("cat")) && (c.Params("subcat") == "" || strings.EqualFold(escapesubcat(prod.Subcategory), c.Params("subcat"))) {
5349				categoryproducts = append(categoryproducts, prod)
5350			}
5351		}
5352		tmplData = map[string]interface{}{
5353			"Products":    categoryproducts,
5354			"Page":        h1,
5355			"Category":    c.Params("cat"),
5356			"Subcategory": c.Params("subcat"),
5357			"Prods":       allproducts,
5358		}
5359	}
5360	err = tmpl.Execute(&result, tmplData)
5361	if err != nil {
5362		msg := fmt.Sprintf("Error execute html template: %v", err)
5363		log.Println(msg)
5364		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5365	}
5366	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
5367	return err
5368}
5369
5370func getcats() (cats []string) {
5371	var catsMap = make(map[string]int)
5372	for _, prod := range allproducts {
5373		catsMap[prod.Category]++
5374	}
5375	for cat := range catsMap {
5376		cats = append(cats, cat)
5377	}
5378	return cats
5379}
5380func contains(slice []string, str string) bool {
5381	for _, s := range slice {
5382		if s == str {
5383			return true
5384		}
5385	}
5386	return false
5387}
5388func getcategories(allproducts p.Products) (map[string]int, []string, map[string]map[string]int, map[string][]string) {
5389	categoryCounts := make(map[string]int)
5390	subcategoryCounts := make(map[string]map[string]int)
5391	subcategoriesByCategory := make(map[string][]string)
5392
5393	for _, prod := range allproducts {
5394		if prod.Category != "" {
5395			categoryCounts[prod.Category]++
5396			if prod.Subcategory != "" {
5397				if subcategoryCounts[prod.Category] == nil {
5398					subcategoryCounts[prod.Category] = make(map[string]int)
5399				}
5400				subcategoryCounts[prod.Category][prod.Subcategory]++
5401				if !contains(subcategoriesByCategory[prod.Category], prod.Subcategory) {
5402					subcategoriesByCategory[prod.Category] = append(subcategoriesByCategory[prod.Category], prod.Subcategory)
5403				}
5404			}
5405		}
5406	}
5407
5408	var sortableCategories []struct {
5409		Name  string
5410		Count int
5411	}
5412	for cat, count := range categoryCounts {
5413		sortableCategories = append(sortableCategories, struct {
5414			Name  string
5415			Count int
5416		}{Name: cat, Count: count})
5417	}
5418	sort.Slice(sortableCategories, func(i, j int) bool {
5419		return sortableCategories[i].Count > sortableCategories[j].Count
5420	})
5421	var sortedCategories []string
5422	for _, cat := range sortableCategories {
5423		sortedCategories = append(sortedCategories, cat.Name)
5424		var sortableSubcategories []struct {
5425			Name  string
5426			Count int
5427		}
5428		for subcat, count := range subcategoryCounts[cat.Name] {
5429			sortableSubcategories = append(sortableSubcategories, struct {
5430				Name  string
5431				Count int
5432			}{Name: subcat, Count: count})
5433		}
5434		sort.Slice(sortableSubcategories, func(i, j int) bool {
5435			return sortableSubcategories[i].Count > sortableSubcategories[j].Count
5436		})
5437		var sortedSubcategories []string
5438		for _, subcat := range sortableSubcategories {
5439			sortedSubcategories = append(sortedSubcategories, subcat.Name)
5440		}
5441		subcategoriesByCategory[cat.Name] = sortedSubcategories
5442	}
5443	return categoryCounts, sortedCategories, subcategoryCounts, subcategoriesByCategory
5444}
5445
5446func getsubcats(cat string) (subcats []string) {
5447	var subcatsMap = make(map[string]int)
5448	for _, prod := range allproducts {
5449		if cat == "" || strings.EqualFold(cat, prod.Category) {
5450			if prod.Subcategory != "" {
5451				subcatsMap[escapesubcat(prod.Subcategory)]++
5452			}
5453		}
5454	}
5455	for subcat := range subcatsMap {
5456		subcats = append(subcats, subcat)
5457	}
5458	return subcats
5459}
5460func escapesubcat(sc string) (esc string) {
5461	esc = strings.Replace(sc, "ΒΌ", "quarter-", -1)
5462	esc = strings.Replace(esc, "Β½", "half-", -1)
5463	esc = strings.Replace(esc, "1/16", "sixteenth-", -1)
5464	esc = strings.Replace(esc, "%", "-pct", -1)
5465	esc = strings.Replace(esc, "  ", " ", -1)
5466	esc = strings.Replace(esc, " ", "-", -1)
5467	esc = strings.Replace(esc, "--", "-", -1)
5468	esc = strings.Replace(esc, "watt1", "watt-1", -1)
5469	esc = strings.Replace(esc, "watt5", "watt-5", -1)
5470	return esc
5471}
5472
5473func handlecat(c fiber.Ctx) error {
5474	if c.Params("cat") == "" && c.Params("subcat") == "" {
5475		return cathtmlfunc(c)
5476	}
5477	var catexists bool
5478	var subcatexists bool
5479	catexists = false
5480	for _, cat := range getcats() {
5481		if strings.EqualFold(cat, c.Params("cat")) {
5482			catexists = true
5483			break
5484		}
5485	}
5486	subcatexists = false
5487	if c.Params("subcat") != "" {
5488		for _, subcat := range getsubcats("") {
5489			if strings.EqualFold(escapesubcat(subcat), c.Params("subcat")) {
5490				subcatexists = true
5491				break
5492			}
5493		}
5494	}
5495	if c.Params("subcat") != "" && !subcatexists {
5496		log.Printf("subcategory %s does not match any existing subcategory\n", c.Params("subcat"))
5497		return c.Redirect().To("/cat/" + c.Params("cat"))
5498	}
5499	if !catexists {
5500		log.Printf("category %s does not match any existing category\n", c.Params("cat"))
5501		return c.Redirect().To("/cat")
5502	}
5503	if catexists || (catexists && subcatexists) {
5504		return cathtmlfunc(c)
5505	}
5506	return c.SendStatus(fiber.StatusNotFound)
5507}
5508
5509func homepage(c fiber.Ctx) error {
5510	tmpl, err := mainTmpl()
5511	if err != nil {
5512		msg := fmt.Sprintf("Could not parsing html template: %v", err)
5513		log.Println(msg)
5514		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5515	}
5516	tmpl0, err := tmpl.Clone()
5517	if err != nil {
5518		msg := fmt.Sprintf("Error cloning template: %v", err)
5519		log.Println(msg)
5520		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5521	}
5522	_, err = tmpl0.New("main").Parse(h.FrontPage())
5523	if err != nil {
5524		msg := fmt.Sprintf("Error parsing Front Page template: %v", err)
5525		log.Println(msg)
5526		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5527	}
5528	_, err = tmpl0.New("about").Parse(h.AboutPage())
5529	if err != nil {
5530		msg := fmt.Sprintf("Error parsing About Page template: %v", err)
5531		log.Println(msg)
5532		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5533	}
5534	_, err = tmpl0.New("policy").Parse(h.PolicyPage())
5535	if err != nil {
5536		msg := fmt.Sprintf("Error parsing Policy Page template: %v", err)
5537		log.Println(msg)
5538		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5539	}
5540	_, err = tmpl0.New("links").Parse(h.LinksPage())
5541	if err != nil {
5542		msg := fmt.Sprintf("Error parsing Links Page template: %v", err)
5543		log.Println(msg)
5544		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5545	}
5546	tmpl = tmpl0
5547	log.Println(c.Get("User-Agent"))
5548	c.Set("Content-Type", "text/html;charset=utf-8")
5549	h1 := pageMeta(c, htmlPageTemplateData)
5550	tmplData := map[string]interface{}{
5551		"Page":  h1,
5552		"Prods": allproducts,
5553	}
5554	var result bytes.Buffer
5555	err = tmpl.Execute(&result, tmplData)
5556	if err != nil {
5557		msg := fmt.Sprintf("Error executing template: %v", err)
5558		log.Println(msg)
5559		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5560	}
5561	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
5562	return err
5563}
5564
5565func productpage(c fiber.Ctx) error {
5566	tmpl, err := mainTmpl()
5567	if err != nil {
5568		msg := fmt.Sprintf("Error parsing html template: %v", err)
5569		log.Println(msg)
5570		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5571	}
5572	tmpl0, err := tmpl.Clone()
5573	if err != nil {
5574		msg := fmt.Sprintf("Error cloning template: %v", err)
5575		log.Println(msg)
5576		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5577	}
5578	_, err = tmpl0.New("main").Parse(h.ProductPage())
5579	if err != nil {
5580		msg := fmt.Sprintf("Error parsing product page template: %v", err)
5581		log.Println(msg)
5582		return c.Status(fiber.StatusInternalServerError).SendString(msg)
5583	}
5584	tmpl = tmpl0
5585	c.Set("Content-Type", "text/html;charset=utf-8")
5586	for _, prod := range allproducts {
5587		if strings.EqualFold(prod.Partno, c.Params("partno")) {
5588			var result bytes.Buffer
5589			h1 := pageMeta(c, htmlPageTemplateData)
5590			h1.Page = "product"
5591			h1.Title = fmt.Sprintf("%s | %s", prod.Name, h1.Title)
5592			tmplData := map[string]interface{}{
5593				"Prod":  prod,
5594				"Page":  h1,
5595				"Prods": allproducts,
5596			}
5597			err := tmpl.Execute(&result, tmplData)
5598			if err != nil {
5599				log.Println("error: ", err)
5600				_, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
5601				return err
5602			}
5603			_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
5604			return err
5605		}
5606	}
5607	log.Printf("product %s does not match any existing product\n", c.Params("partno"))
5608	return c.Status(fiber.StatusNotFound).Redirect().To("/cat")
5609}
5610
5611
5612// ===== pkg/web/sourcecode.go =====
5613// Package web pkg/web/sourcecode.go β€” the store serves its own source.
5614package web
5615
5616import (
5617	"bytes"
5618	"embed"
5619	"fmt"
5620	"io/fs"
5621	"os"
5622	"strings"
5623
5624	"github.com/alecthomas/chroma/v2"
5625	"github.com/alecthomas/chroma/v2/formatters/html"
5626	"github.com/alecthomas/chroma/v2/lexers"
5627	"github.com/alecthomas/chroma/v2/styles"
5628	"github.com/gofiber/fiber/v3"
5629
5630	"github.com/0magnet/m2/pkg/config"
5631	"github.com/0magnet/m2/pkg/product"
5632	"github.com/0magnet/m2/pkg/tui"
5633)
5634
5635//go:embed *.go
5636var srcWeb embed.FS
5637
5638type srcEntry struct {
5639	Prefix string
5640	FS     fs.FS
5641}
5642
5643// goSources holds each package's embedded Go files for /sourcecode/go. A Go
5644// package cannot embed files outside its own directory, so since the code
5645// moved out of the repo root every package embeds its own *.go and is listed
5646// here. The web package's own embed picks up the other.go drop-in when a
5647// deployment builds with one.
5648var goSources = []srcEntry{
5649	{"pkg/config", config.Source},
5650	{"pkg/product", product.Source},
5651	{"pkg/tui", tui.Source},
5652	{"pkg/web", srcWeb},
5653}
5654
5655// RegisterSource adds a package's embedded sources to /sourcecode/go β€” used
5656// by the commands package, which web cannot import without a cycle.
5657func RegisterSource(prefix string, fsys fs.FS) {
5658	goSources = append(goSources, srcEntry{prefix, fsys})
5659}
5660
5661var sourcesWasm []fs.FS
5662var sourceCore = os.DirFS("ui")
5663var sourceHtml = os.DirFS("htmpl")
5664var sourceContent = os.DirFS("content")
5665
5666func serveSourceCode(r *fiber.App) {
5667	for _, wasmSRC := range f.WasmSRC {
5668		sourcesWasm = append(sourcesWasm, os.DirFS(wasmSRC))
5669	}
5670	r.Get("/sourcecode", func(c fiber.Ctx) error {
5671		ret := `<!doctype html>
5672<html lang='en'>
5673<head>
5674<link rel="stylesheet" href="/style.css" type="text/css">
5675</head>
5676<body class='grid-container' style='background-color:black;color:white;'>
5677<a href='/sourcecode/go'>GO</a><br><br>
5678
5679<a href='/sourcecode/html'>HTML</a><br><br>
5680
5681<a href='/sourcecode/content'>Content</a><br><br>
5682
5683<a href='/sourcecode/core'>C.O.R.E.</a><br><br>
5684
5685<a href='/sourcecode/wasm'>WASM</a><br><br>
5686
5687</body>
5688</html>
5689`
5690		c.Set("Content-Type", "text/html;charset=utf-8")
5691		_, err := c.Status(fiber.StatusOK).Write([]byte(ret))
5692		return err
5693	})
5694
5695	r.Get("/sourcecode/html", sourcecodehtml)
5696	r.Get("/sourcecode/content", sourcecodecontent)
5697	r.Get("/sourcecode/go", sourcecodego)
5698	r.Get("/sourcecode/core", sourcecodecore)
5699	r.Get("/sourcecode/wasm", func(c fiber.Ctx) error {
5700		ret := `<!doctype html>
5701<html lang='en'>
5702<head>
5703<link rel="stylesheet" href="/style.css" type="text/css">
5704</head>
5705<body class='grid-container' style='background-color:black;color:white;'>
5706`
5707		for _, wasmSRC := range f.WasmSRC {
5708			pathNameSlc := strings.Split(wasmSRC, "/")
5709			pathName := pathNameSlc[len(pathNameSlc)-1]
5710			ret += `<a href='/sourcecode/wasm/` + pathName + `'>` + pathName + `</a><br>
5711			`
5712		}
5713		ret += `</body></html>
5714		`
5715		c.Set("Content-Type", "text/html;charset=utf-8")
5716		_, err := c.Status(fiber.StatusOK).Write([]byte(ret))
5717		return err
5718	})
5719
5720	for i, wasmSRC := range f.WasmSRC {
5721		pathNameSlc := strings.Split(wasmSRC, "/")
5722		pathName := pathNameSlc[len(pathNameSlc)-1]
5723		r.Get("/sourcecode/wasm/"+pathName, func(c fiber.Ctx) error {
5724			return sourcecode(c, sourcesWasm[i], "dracula", "go")
5725		})
5726	}
5727}
5728
5729func sourcecodehtml(c fiber.Ctx) error {
5730	return sourcecode(c, sourceHtml, "monokai", "html")
5731}
5732func sourcecodecontent(c fiber.Ctx) error {
5733	return sourcecode(c, sourceContent, "monokai", "html")
5734}
5735
5736func sourcecodego(c fiber.Ctx) error {
5737	var builder strings.Builder
5738	for _, src := range goSources {
5739		if err := collectSource(&builder, src.FS, src.Prefix, "go"); err != nil {
5740			return err
5741		}
5742	}
5743	return renderSource(c, builder.String(), "monokai", "go")
5744}
5745
5746func sourcecodecore(c fiber.Ctx) error {
5747	return sourcecode(c, sourceCore, "solarized-dark256", "go")
5748}
5749
5750func sourcecode(c fiber.Ctx, fsys fs.FS, styleName string, lang string) error {
5751	var builder strings.Builder
5752	if err := collectSource(&builder, fsys, "", lang); err != nil {
5753		return err
5754	}
5755	return renderSource(c, builder.String(), styleName, lang)
5756}
5757
5758// collectSource concatenates every .<lang> file in fsys into builder, each
5759// under a banner naming it (with prefix, so files from different packages
5760// keep their repo paths).
5761func collectSource(builder *strings.Builder, fsys fs.FS, prefix string, lang string) error {
5762	return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
5763		if err != nil {
5764			return err
5765		}
5766		if !d.IsDir() && strings.HasSuffix(path, "."+lang) {
5767			content, err := fs.ReadFile(fsys, path)
5768			if err != nil {
5769				return err
5770			}
5771			name := path
5772			if prefix != "" {
5773				name = prefix + "/" + path
5774			}
5775			builder.WriteString(fmt.Sprintf("// ===== %s =====\n", name))
5776			builder.Write(content)
5777			builder.WriteString("\n\n")
5778		}
5779		return nil
5780	})
5781}
5782
5783func renderSource(c fiber.Ctx, source string, styleName string, lang string) error {
5784	c.Set("Content-Type", "text/html;charset=utf-8")
5785	var buf bytes.Buffer
5786
5787	// Pick lexer & style
5788	lexer := lexers.Get(lang)
5789	if lexer == nil {
5790		lexer = lexers.Fallback
5791	}
5792	lexer = chroma.Coalesce(lexer)
5793
5794	style := styles.Get(styleName)
5795	if style == nil {
5796		style = styles.Fallback
5797	}
5798
5799	// Formatter with line numbers & CSS classes
5800	formatter := html.New(
5801		html.WithLineNumbers(true),
5802		html.WithClasses(true),
5803	)
5804
5805	iterator, err := lexer.Tokenise(nil, source)
5806	if err != nil {
5807		return err
5808	}
5809
5810	// Optional: include CSS in output
5811	var css bytes.Buffer
5812	_ = formatter.WriteCSS(&css, style) //nolint:errcheck // the stylesheet is decoration; the highlighted source is still readable without it
5813	buf.WriteString("<style>")
5814	buf.Write(css.Bytes())
5815	buf.WriteString("</style>")
5816
5817	if err := formatter.Format(&buf, style, iterator); err != nil {
5818		return err
5819	}
5820
5821	_, err = c.Status(fiber.StatusOK).Write(buf.Bytes())
5822	return err
5823}
5824
5825
5826// ===== pkg/web/tmpl.go =====
5827// Package web pkg/web/tmpl.go β€” template plumbing.
5828package web
5829
5830import (
5831	"bytes"
5832	"fmt"
5833	htmpl "html/template"
5834	"log"
5835	"os"
5836	"path/filepath"
5837	"sort"
5838	"strconv"
5839	"strings"
5840	ttmpl "text/template"
5841	"time"
5842
5843	"github.com/gofiber/fiber/v3"
5844
5845	p "github.com/0magnet/m2/pkg/product"
5846)
5847
5848/*
5849//go:embed htmpl/*
5850var templatesFS embed.FS
5851
5852//go:embed content/*
5853var contentFS embed.FS
5854*/
5855/*
5856var (
5857	templatesFS = os.DirFS("htmpl")
5858	contentFS   = os.DirFS("content")
5859)
5860*/
5861/*
5862func mustReadEmbeddedFileToString(path string, fs embed.FS) string {
5863	return string(mustReadEmbeddedFileToBytes(path, fs))
5864}
5865
5866func mustReadEmbeddedFileToBytes(path string, fs embed.FS) []byte {
5867	data, err := fs.ReadFile(path)
5868	if err != nil {
5869		panic(err)
5870	}
5871	return data
5872}
5873*/
5874
5875func mustReadFileToString(path string) string {
5876	return string(mustReadFileToBytes(path))
5877}
5878
5879// optionalReadFileToString returns "" when the file does not exist.
5880// Used for deployment-local assets (e.g. content/font.css) that are
5881// not part of the source repo.
5882func optionalReadFileToString(path string) string {
5883	data, err := os.ReadFile(path) //nolint
5884	if err != nil {
5885		return ""
5886	}
5887	return string(data)
5888}
5889
5890// contentFile returns the deployment-local file when present, falling
5891// back to the committed <path>.example. Lets site operators override
5892// stock pages (about, policy, links) without touching tracked files.
5893func contentFile(path string) string {
5894	if s := optionalReadFileToString(path); s != "" {
5895		return s
5896	}
5897	return mustReadFileToString(path + ".example")
5898}
5899
5900func mustReadFileToBytes(path string) []byte {
5901	data, err := os.ReadFile(path) //nolint
5902	if err != nil {
5903		panic(err)
5904	}
5905	return data
5906}
5907
5908type htmlTemplate struct {
5909	Empty          func() string
5910	Head           func() string
5911	Logo           func() string
5912	Header         func() string
5913	Categories     func() string
5914	CatSubcats     func() string
5915	Footer         func() string
5916	MainPage       func() string
5917	AuxPage        func() string
5918	FrontPage      func() string
5919	CategoryPage   func() string
5920	CategoryPageMD func() string
5921	ProductPage    func() string
5922	ProductPageMD  func() string
5923	Schema         func() string
5924	Cart           func() string
5925	XMLSitemap     func() string
5926	Wasm           func() string
5927	AboutPage      func() string
5928	PolicyPage     func() string
5929	LinksPage      func() string
5930	CheckoutPage   func() string
5931	CompletePage   func() string
5932	CheckoutCSS    func() string
5933	StyleCSS       func() string
5934	FontCSS        func() string
5935}
5936
5937var h = htmlTemplate{
5938	Empty:          func() string { return mustReadFileToString("htmpl/empty.html") },
5939	Head:           func() string { return mustReadFileToString("htmpl/head.html") },
5940	Logo:           func() string { return mustReadFileToString("htmpl/logo.html") },
5941	Header:         func() string { return mustReadFileToString("htmpl/header.html") },
5942	Categories:     func() string { return mustReadFileToString("htmpl/categories.html") },
5943	CatSubcats:     func() string { return mustReadFileToString("htmpl/catsubcats.html") },
5944	Footer:         func() string { return mustReadFileToString("htmpl/footer.html") },
5945	MainPage:       func() string { return mustReadFileToString("htmpl/main.html") },
5946	AuxPage:        func() string { return mustReadFileToString("htmpl/auxpage.html") },
5947	FrontPage:      func() string { return mustReadFileToString("htmpl/front.html") },
5948	CategoryPage:   func() string { return mustReadFileToString("htmpl/category.html") },
5949	CategoryPageMD: func() string { return mustReadFileToString("htmpl/category.md") },
5950	ProductPage:    func() string { return mustReadFileToString("htmpl/product.html") },
5951	ProductPageMD:  func() string { return mustReadFileToString("htmpl/product.md") },
5952	Schema:         func() string { return mustReadFileToString("htmpl/schema.html") },
5953	Cart:           func() string { return mustReadFileToString("htmpl/cart.html") },
5954	XMLSitemap:     func() string { return mustReadFileToString("htmpl/sitemap.xml") },
5955	Wasm:           func() string { return mustReadFileToString("htmpl/wasm.html") },
5956	CompletePage:   func() string { return mustReadFileToString("htmpl/complete.html") },
5957	AboutPage:      func() string { return contentFile("content/about.html") },
5958	PolicyPage:     func() string { return contentFile("content/policy.html") },
5959	LinksPage:      func() string { return contentFile("content/links.html") },
5960	CheckoutPage:   func() string { return mustReadFileToString("content/checkout.html") },
5961	CheckoutCSS:    func() string { return mustReadFileToString("content/checkout.css") },
5962	StyleCSS: func() string {
5963		return fontCSS() + mustReadFileToString("content/style.css")
5964	},
5965	// The terminal page wants the face on its own, without the stylesheet.
5966	FontCSS: func() string { return fontCSS() },
5967}
5968
5969var htmlPageTemplateData htmlTemplateData
5970
5971var funcs = htmpl.FuncMap{
5972	"replace": replace, "mul": mul, "div": div, "safeHTML": safeHTML,
5973	"safeJS": safeJS, "stripProtocol": stripProtocol, "add": add, "sub": sub,
5974	"toFloat": toFloat, "equalsIgnoreCase": equalsIgnoreCase,
5975	"getsubcats": getsubcats, "escapesubcat": escapesubcat,
5976	"sortsubcats": sortsubcats, "repeat": repeat, "subcatlink": subcatlink,
5977}
5978
5979func mainTmpl() (tmpl *htmpl.Template, err error) {
5980	tmpl = htmpl.New("index").Funcs(funcs)
5981	if _, err := tmpl.Parse(h.MainPage()); err != nil {
5982		log.Println("Error parsing index template:", err)
5983		return tmpl, err
5984	}
5985
5986	partials := []struct {
5987		Name    string
5988		Content string
5989	}{
5990		{"head", h.Head()},
5991		{"schema", h.Schema()},
5992		{"header", h.Header()},
5993		{"catsubcats", h.CatSubcats()},
5994		{"categories", h.Categories()},
5995		{"footer", h.Footer()},
5996		{"cart", h.Cart()},
5997		{"wasm", h.Wasm()},
5998	}
5999
6000	for _, p := range partials {
6001		if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
6002			log.Printf("Error parsing %s template: %v", p.Name, err)
6003			return tmpl, err
6004		}
6005	}
6006	return tmpl, err
6007}
6008
6009func auxTmpl() (tmpl *htmpl.Template, err error) {
6010	tmpl = htmpl.New("index").Funcs(funcs)
6011	if _, err := tmpl.Parse(h.AuxPage()); err != nil {
6012		log.Println("Error parsing index template:", err)
6013		return tmpl, err
6014	}
6015
6016	partials := []struct {
6017		Name    string
6018		Content string
6019	}{
6020		{"head", h.Head()},
6021		{"schema", h.Empty()},
6022		{"wasm", h.Empty()},
6023	}
6024
6025	for _, p := range partials {
6026		if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
6027			log.Printf("Error parsing %s template: %v", p.Name, err)
6028			return tmpl, err
6029		}
6030	}
6031	return tmpl, err
6032}
6033
6034func pageMeta(c fiber.Ctx, base htmlTemplateData) htmlTemplateData {
6035	h := base
6036	host := string(c.Request().Host())
6037	/*
6038		proto := "http"
6039		if c.Secure() {
6040			proto += "s"
6041		}
6042	*/
6043	proto := "https"
6044	h.Canonical = proto + "://" + host + c.OriginalURL()
6045	h.BaseURL = proto + "://" + host
6046	h.RequestHost = host
6047	h.Protocol = proto
6048	h.CatsCounts, h.Cats, h.SubCatsCounts, h.SubCatsByCat = getcategories(allproducts)
6049	h.LenAllProducts = len(allproducts)
6050	h.Time = time.Now().Format(time.RFC3339Nano)
6051	h.Year = fmt.Sprintf("%v", time.Now().Year())
6052	h.MetaDesc = f.Sitemeta
6053	h.KeyWords = strings.Replace(f.Sitelongname, " ", ", ", -1)
6054	return h
6055}
6056
6057func initTMPL() {
6058	htmlPageTemplateData = htmlTemplateData{
6059		TestMode:           f.Teststripekey,
6060		Title:              f.Sitelongname,
6061		StripePK:           f.StripePK,
6062		SiteName:           f.Sitedomain,
6063		SiteTagLine:        f.Sitetagline,
6064		SiteName1:          htmpl.HTML(checkerBoard(f.Sitedomain)), //nolint
6065		SiteLongName:       f.Sitelongname,
6066		SiteASCIILogo:      htmpl.HTML(f.SiteASCIILogo), //nolint
6067		SitePrettyName:     f.Siteprettyname,
6068		SitePrettyNameCap:  f.Siteprettynamecap,
6069		SitePrettyNameCaps: f.Siteprettynamecaps,
6070		TelegramContact:    f.Tgcontact,
6071		TelegramChannel:    f.Tgchannel,
6072		WasmExecPath:       f.WasmExecPath,
6073		WasmExecRel:        f.WasmExecPath,
6074		Cats:               getcats(),
6075		LenAllProducts:     len(allproducts),
6076		ImgSRC: func() (ret string) {
6077			ret = f.Siteimagesrc
6078			if ret == "" {
6079				ret = "/i"
6080			}
6081			return ret
6082		}(),
6083		Page: "front",
6084		Time: time.Now().Format(time.RFC3339Nano),
6085		Year: fmt.Sprintf("%v", time.Now().Year()),
6086	}
6087	htmlPageTemplateData.CatsCounts, htmlPageTemplateData.Cats, htmlPageTemplateData.SubCatsCounts, htmlPageTemplateData.SubCatsByCat = getcategories(allproducts)
6088	htmlPageTemplateData.WasmBinary = wasmBinary()
6089
6090}
6091
6092func wasmBinary() (ret []string) {
6093	if len(f.WasmSRC) == 0 {
6094		return ret
6095	}
6096	if f.UseTinygo {
6097		for _, wasmSRC := range f.WasmSRC {
6098			outputFile := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC)) + "-tiny.wasm"
6099			ret = append(ret, outputFile)
6100		}
6101		return ret
6102	}
6103	for _, wasmSRC := range f.WasmSRC {
6104		outputFile := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC)) + ".wasm"
6105		ret = append(ret, outputFile)
6106	}
6107	return ret
6108}
6109
6110type xmlTemplateData struct {
6111	BaseURL      string
6112	Cats         []string
6113	SubCatsByCat map[string][]string
6114	Products     p.Products
6115	Update       string
6116}
6117
6118func generateSitemapXML() string {
6119	xmlSitemapTemplateData := xmlTemplateData{
6120		BaseURL:  "https://" + f.Sitedomain,
6121		Products: allproducts,
6122		Update:   time.Now().Format("2006-01-02"),
6123	}
6124	_, xmlSitemapTemplateData.Cats, _, xmlSitemapTemplateData.SubCatsByCat = getcategories(allproducts)
6125	var err1 error
6126	xtmpl, err1 := ttmpl.New("index").Funcs(ttmpl.FuncMap{"getsubcats": getsubcats}).Parse(h.XMLSitemap())
6127	if err1 != nil {
6128		log.Println("Error parsing index template:", err1)
6129	}
6130	var result bytes.Buffer
6131	err1 = xtmpl.Execute(&result, xmlSitemapTemplateData)
6132	if err1 != nil {
6133		log.Println("error: ", err1)
6134	}
6135	return result.String()
6136}
6137
6138func toFloat(s string) float64 {
6139	if s == "" {
6140		return 0.0
6141	}
6142	f, err := strconv.ParseFloat(s, 64)
6143	if err != nil {
6144		return 0.0
6145	}
6146	return f
6147}
6148
6149func checkerBoard(input string) string {
6150	var result strings.Builder
6151	for i, char := range input {
6152		// Wrap every other letter with the specified HTML
6153		if i%2 == 0 {
6154			result.WriteString(fmt.Sprintf("<span class='nv'>%c</span>", char))
6155		} else {
6156			result.WriteRune(char)
6157		}
6158	}
6159	return result.String()
6160}
6161
6162type htmlTemplateData struct {
6163	Title              string
6164	MetaDesc           string
6165	Canonical          string
6166	BaseURL            string
6167	ImgSRC             string // url where images are hosted
6168	OrdersURL          string // url where checkout is served from
6169	SiteName           string
6170	SiteTagLine        string
6171	SiteName1          htmpl.HTML //checkerboard - alternate swap text & bg color
6172	SiteLongName       string
6173	SitePrettyName     string //π•„π•’π•˜π•Ÿπ•–π•₯𝕠𝕀𝕑𝕙𝕖𝕣𝕖.π•Ÿπ•–π•₯
6174	SitePrettyNameCap  string //π•„π•’π•˜π•Ÿπ•–π•₯𝕠𝕀𝕑𝕙𝕖𝕣𝕖.π•Ÿπ•–π•₯
6175	SitePrettyNameCaps string //π•„π”Έπ”Ύβ„•π”Όπ•‹π•†π•Šβ„™β„π”Όβ„π”Ό.ℕ𝔼𝕋
6176	SiteASCIILogo      htmpl.HTML
6177	TelegramContact    string
6178	TelegramChannel    string
6179	Protocol           string
6180	RequestHost        string
6181	KeyWords           string
6182	Style              htmpl.HTML
6183	Heading            htmpl.HTML
6184	StripePK           string
6185	Cats               []string
6186	CatsCounts         map[string]int
6187	SubCatsCounts      map[string]map[string]int
6188	SubCatsByCat       map[string][]string
6189	LenAllProducts     int
6190	Mobile             bool
6191	Gocanvas           htmpl.HTML
6192	WasmBinary         []string
6193	WasmExecPath       string
6194	WasmExecRel        string
6195	StyleFontFace      htmpl.CSS
6196	Message            htmpl.HTML
6197	Page               string
6198	Year               string
6199	Time               string
6200	AboutHTML          htmpl.HTML
6201	LinksHTML          htmpl.HTML
6202	PolicyHTML         htmpl.HTML
6203	TestMode           bool
6204}
6205
6206func equalsIgnoreCase(a, b string) bool {
6207	return strings.EqualFold(strings.Join(strings.Fields(a), ""), strings.Join(strings.Fields(b), ""))
6208}
6209
6210func replace(s, o, n string) string {
6211	return strings.ReplaceAll(s, o, n)
6212}
6213func mul(a, b float64) float64 {
6214	return a * b
6215}
6216func div(a, b float64) float64 {
6217	return a / b
6218}
6219func add(a, b int) int {
6220	return a + b
6221}
6222func sub(a, b int) int {
6223	return a - b
6224}
6225func safeHTML(s string) htmpl.HTML {
6226	return htmpl.HTML(s) //nolint
6227}
6228func safeJS(s string) htmpl.JS {
6229	return htmpl.JS(s) //nolint
6230}
6231func stripProtocol(s string) string {
6232	return strings.Replace(strings.Replace(s, "https://", "", -1), "http://", "", -1)
6233}
6234func repeat(s string, count int) string {
6235	var result string
6236	for i := 0; i < count; i++ {
6237		result += s
6238	}
6239	return result
6240}
6241func sortsubcats(subcats []string, counts map[string]map[string]int) []string {
6242	sort.Slice(subcats, func(i, j int) bool {
6243		catI, catJ := subcats[i], subcats[j]
6244		countI, countJ := counts[catI]["count"], counts[catJ]["count"]
6245		return countI > countJ
6246	})
6247	return subcats
6248}
6249
6250func subcatlink(subcategory string) string {
6251	s := subcategory
6252	s = strings.ReplaceAll(s, "ΒΌ", "quarter-")
6253	s = strings.ReplaceAll(s, "Β½", "half-")
6254	s = strings.ReplaceAll(s, "1/16", "sixteenth-")
6255	s = strings.ReplaceAll(s, "%", "-pct")
6256	s = strings.ReplaceAll(s, "  ", " ")
6257	s = strings.ReplaceAll(s, "watt1", "watt-1")
6258	s = strings.ReplaceAll(s, "watt5", "watt-5")
6259	s = strings.ReplaceAll(s, " ", "-")
6260	s = strings.ReplaceAll(s, "--", "-")
6261	return s
6262}
6263
6264// fontCSS is the deployment's @font-face block, read from disk. It is a
6265// plain function rather than a method on h because h's own initializer
6266// needs it, and a var cannot refer to itself.
6267func fontCSS() string { return optionalReadFileToString("content/font.css") }
6268
6269
6270// ===== pkg/web/wasmbuild.go =====
6271// Package web pkg/web/wasmbuild.go β€” compiling the drop-in wasm apps.
6272package web
6273
6274import (
6275	"encoding/json"
6276	"fmt"
6277	"io"
6278	"log"
6279	"net/http"
6280	"os/exec"
6281	"path/filepath"
6282	"regexp"
6283	"strconv"
6284	"strings"
6285	"time"
6286
6287	"github.com/bitfield/script"
6288	"github.com/briandowns/spinner"
6289
6290	"github.com/0magnet/m2/pkg/config"
6291)
6292
6293func CompileWASM() {
6294	s := spinner.New(spinner.CharSets[14], 25*time.Millisecond)
6295	s.Suffix = " Compiling wasm..."
6296	// Each build runs on the system's default Go first; only if that fails does
6297	// it retry on a pinned Go toolchain. The pinned version is normally resolved
6298	// dynamically β€” tinygo trails the latest Go release, so on failure we read
6299	// the Go range tinygo supports from its own error and pin the newest matching
6300	// Go patch (see resolveCompatibleToolchain). That keeps the site on the newest
6301	// Go whenever tinygo supports it and self-heals across Go bumps with no
6302	// hardcoded version. GOTOOLCHAINFALLBACK in the MENV config is an OPTIONAL
6303	// offline backstop, used only if the dynamic lookup can't reach the release
6304	// list; leave it empty for fully dynamic behavior.
6305	offlineFallback := config.ScriptExecString("${GOTOOLCHAINFALLBACK}")
6306	for _, wasmSRC := range f.WasmSRC {
6307		ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
6308		outputFile := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC)) + ".wasm"
6309		mk := func(tc string) string {
6310			return fmt.Sprintf("cd %s || exit 1 ; time GOOS=js GOARCH=wasm %s%s -o %s %s -ldflags=\"-s -w\" %s && cd %s && du %s", wasmSRC, tc, f.Gobuild, ascend+outputFile, ldflags(wasmSRC), ".", ascend, outputFile)
6311		}
6312		buildWasmWithFallback(mk, offlineFallback, s, true)
6313	}
6314	if !f.UseTinygo {
6315		return
6316	}
6317	for _, wasmSRC := range f.WasmSRC {
6318		ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
6319		outputFile := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC)) + "-tiny.wasm"
6320		mk := func(tc string) string {
6321			return fmt.Sprintf("cd %s || exit 1 ; time GOOS=js GOARCH=wasm %s%s -o %s %s %s && cd %s && du %s", wasmSRC, tc, f.Tinygobuild, ascend+outputFile, ldflags(wasmSRC), ".", ascend, outputFile)
6322		}
6323		// The Go build above succeeded and serves; a drop-in that tinygo
6324		// cannot compile (e.g. one using net/http, which tinygo's shim
6325		// does not build) must not take the whole store down.
6326		buildWasmWithFallback(mk, offlineFallback, s, false)
6327	}
6328}
6329
6330// runBash runs an inner bash script and returns its combined stdout+stderr, so
6331// the caller can both log it and parse compiler errors out of it.
6332func runBash(inner string) (string, error) {
6333	// The command is built by this package, not taken from a request.
6334	out, err := exec.Command("bash", "-c", inner).CombinedOutput() //nolint:gosec
6335	return string(out), err
6336}
6337
6338// buildWasmWithFallback builds wasm on the system Go first (mkCmd("")). On
6339// failure it resolves a Go toolchain tinygo can use β€” dynamically from tinygo's
6340// own error output, or the offlineFallback if the release list is unreachable β€”
6341// and retries with GOTOOLCHAIN pinned to it. When required, a build that still
6342// fails is fatal; otherwise it is logged and skipped (the pages fall back to
6343// whichever binary exists). mkCmd receives the string to place before the
6344// build command: "" for the default, or "GOTOOLCHAIN=… ".
6345func buildWasmWithFallback(mkCmd func(toolchainPrefix string) string, offlineFallback string, s *spinner.Spinner, required bool) {
6346	log.Println("Compiling wasm with:")
6347	log.Println(mkCmd(""))
6348	s.Start()
6349	out, err := runBash(mkCmd(""))
6350	s.Stop()
6351	log.Print(out)
6352	if err == nil {
6353		log.Println("Compiled wasm!")
6354		return
6355	}
6356	tc := resolveCompatibleToolchain(out)
6357	if tc == "" {
6358		tc = offlineFallback
6359	}
6360	if tc == "" {
6361		if required {
6362			log.Fatalf("wasm build failed and no compatible Go toolchain could be resolved: %v", err)
6363		}
6364		log.Printf("optional wasm build failed (no compatible toolchain): %v", err)
6365		return
6366	}
6367	log.Printf("wasm build failed on the default Go; retrying with GOTOOLCHAIN=%s", tc)
6368	s.Start()
6369	out, err = runBash(mkCmd("GOTOOLCHAIN=" + tc + " "))
6370	s.Stop()
6371	log.Print(out)
6372	if err != nil {
6373		if required {
6374			log.Fatal(err)
6375		}
6376		log.Printf("optional wasm build failed: %v", err)
6377		return
6378	}
6379	log.Printf("Compiled wasm (Go toolchain %s)!", tc)
6380}
6381
6382// resolveCompatibleToolchain reads tinygo's supported Go range from a failed
6383// build's output (e.g. "requires go version 1.19 through 1.26, got go1.27") and
6384// returns the newest released Go patch of that ceiling minor (e.g. "go1.26.7"),
6385// or "" if the range can't be parsed or the release list can't be fetched.
6386func resolveCompatibleToolchain(buildOutput string) string {
6387	m := regexp.MustCompile(`through (\d+)\.(\d+)`).FindStringSubmatch(buildOutput)
6388	if m == nil {
6389		return ""
6390	}
6391	minor := m[1] + "." + m[2]
6392
6393	client := &http.Client{Timeout: 20 * time.Second}
6394	resp, err := client.Get("https://go.dev/dl/?mode=json&include=all")
6395	if err != nil {
6396		return ""
6397	}
6398	defer func() { _ = resp.Body.Close() }() //nolint:errcheck,gosec
6399	body, err := io.ReadAll(resp.Body)
6400	if err != nil {
6401		return ""
6402	}
6403	var rels []struct {
6404		Version string `json:"version"`
6405	}
6406	if err := json.Unmarshal(body, &rels); err != nil {
6407		return ""
6408	}
6409
6410	// Newest patch of go<minor> (require a patch number: GOTOOLCHAIN rejects a
6411	// bare "go1.26" β€” it must be a full toolchain version like go1.26.7).
6412	re := regexp.MustCompile(`^go` + regexp.QuoteMeta(minor) + `\.(\d+)$`)
6413	best, bestPatch := "", -1
6414	for _, r := range rels {
6415		mm := re.FindStringSubmatch(r.Version)
6416		if mm == nil {
6417			continue
6418		}
6419		// A version that does not parse yields 0, which loses the comparison
6420		// below, so an unparsable tag is skipped rather than erroring.
6421		if p, _ := strconv.Atoi(mm[1]); p > bestPatch { //nolint:errcheck
6422			bestPatch, best = p, r.Version
6423		}
6424	}
6425	return best
6426}
6427
6428func ldflags(s string) (ss string) {
6429	checkFiles, err := script.FindFiles(s).Slice()
6430	if err != nil {
6431		log.Fatal(err)
6432	}
6433	if f.LDFlagsX != "" {
6434		for _, s := range checkFiles {
6435			res, err := script.File(s).Match(strings.Split(f.LDFlagsX, "=")[0]).String()
6436			if err != nil {
6437				log.Fatal(err)
6438			}
6439			if res != "" {
6440				ss += fmt.Sprintf(` -X 'main.%s' `, f.LDFlagsX)
6441				break
6442			}
6443		}
6444	}
6445	for _, s := range checkFiles {
6446		res, err := script.File(s).Match("wasmName").String()
6447		if err != nil {
6448			log.Fatal(err)
6449		}
6450		if res != "" {
6451			ss += fmt.Sprintf(` -X 'main.wasmName=%s' `, strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))+".wasm")
6452			break
6453		}
6454	}
6455	if ss != "" {
6456		ss = `-ldflags="` + ss + `"`
6457	}
6458	return ss
6459}
6460
6461
6462// ===== cmd/m2/commands/gen.go =====
6463// Package commands cmd/m2/commands/gen.go β€” the `m2 gen` config template.
6464package commands
6465
6466import (
6467	"fmt"
6468
6469	"github.com/spf13/cobra"
6470)
6471
6472var genCmd = &cobra.Command{
6473	Use:   "gen",
6474	Short: "generate conf template",
6475	Long:  "generate conf template",
6476	Run: func(_ *cobra.Command, _ []string) {
6477		fmt.Print(envfiletemplate)
6478	},
6479}
6480
6481const envfiletemplate = `#########################################################################
6482#	M2 CONFIG
6483#
6484# Copy to <yoursite>.conf and run with:  MENV=<yoursite>.conf m2 run
6485# This file is sourced by bash; use shell syntax.
6486# Comment a value with # to use the built-in default.
6487#########################################################################
6488
6489### Stripe Configuration ################################################
6490
6491#-- Live and test API keys - REQUIRED for checkout
6492# https://dashboard.stripe.com/apikeys
6493STRIPELIVEPK='pk_live_...'
6494STRIPELIVESK='sk_live_...'
6495STRIPETESTPK='pk_test_...'
6496STRIPETESTSK='sk_test_...'
6497
6498#-- Use the test keys instead of the live keys
6499TESTSTRIPEKEY=true
6500
6501### Product Data ########################################################
6502
6503#-- Products CSV path (see products.example.csv for the schema)
6504PRODUCTSCSV='products.csv'
6505
6506### Site Identity #######################################################
6507
6508#-- Image subdomain, no trailing slash (ex. 'https://img.example.com')
6509# empty = serve images from ./img
6510SITEIMAGESRC=''
6511
6512#-- Orders subdomain, no trailing slash (ex. 'https://pay.example.com')
6513SITEORDERSURL=''
6514
6515#-- Website (Host) Name - domain minus extension (ex. 'example')
6516SITENAME='example'
6517
6518#-- Website Domain Extension (ex. '.com' '.net')
6519SITEEXT='.com'
6520
6521#-- Site Long Name (ex. 'example electronic surplus')
6522SITELONGNAME='example web store'
6523
6524#-- Site Tag Line
6525SITETAGLINE='an example web store'
6526
6527#-- Site Meta Description (SEO)
6528SITEMETA='an example web store selling example things'
6529
6530#-- Telegram contact + channel; username only, no 'https://t.me/'
6531TGCONTACT=''
6532TGCHANNEL=''
6533
6534### Web Server ##########################################################
6535
6536#-- Port to serve http on
6537WEBPORT='9883'
6538
6539
6540### WebAssembly #########################################################
6541
6542#-- Compile wasm with tinygo (smaller output) in addition to go
6543USETINYGO=true
6544
6545#-- wasm source directories, relative paths
6546# 'wasm/cart' powers checkout β€” keep it. 'wasm/tui' is the terminal
6547# storefront served at /tui. Additional entries are drop-in
6548# wasm apps: any dir under wasm/ with a main package (a self-contained
6549# module with its own vendor/ also works). Empty () disables all wasm.
6550WASMSRC=('wasm/cart' 'wasm/tui' 'wasm/desk')
6551
6552### Receipt Printing (CUPS) #############################################
6553
6554#-- CUPS printer name (default: system default)
6555PRINTERNAME=''
6556
6557#-- CUPS options, comma separated (ex. 'media=Custom.80x200mm,fit-to-page')
6558CUPSOPTIONS=''
6559
6560#-- timeout for lp command
6561LPTIMEOUT='10s'
6562
6563### Terminal UI ##########################################################
6564
6565#-- m2 tui client mode: browse this store over http instead of the
6566# local products csv / img / content files (ex. 'https://example.com')
6567STOREURL=''
6568`
6569
6570
6571// ===== cmd/m2/commands/root.go =====
6572// Package commands cmd/m2/commands/root.go β€” the m2 CLI.
6573package commands
6574
6575import (
6576	"embed"
6577	"log"
6578
6579	"github.com/spf13/cobra"
6580	"github.com/stripe/stripe-go/v81"
6581
6582	"github.com/0magnet/calvin"
6583	cc "github.com/0magnet/coloredcobra"
6584
6585	"github.com/0magnet/m2/pkg/config"
6586	"github.com/0magnet/m2/pkg/web"
6587)
6588
6589//go:embed *.go
6590var source embed.FS
6591
6592// f aliases the shared configuration, as in pkg/web.
6593var f = &config.F
6594
6595func init() {
6596	stripe.EnableTelemetry = false
6597	RootCmd.CompletionOptions.DisableDefaultCmd = true
6598	RootCmd.AddCommand(
6599		runCmd,
6600		genCmd,
6601		wasmCmd,
6602		tuiCmd,
6603	)
6604	var helpflag bool
6605	RootCmd.SetUsageTemplate(help)
6606	RootCmd.PersistentFlags().BoolVarP(&helpflag, "help", "h", false, "help for "+RootCmd.Use)
6607	RootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
6608	RootCmd.PersistentFlags().MarkHidden("help") //nolint
6609
6610	web.RegisterSource("cmd/m2/commands", source)
6611}
6612
6613func init() {
6614	runCmd.Flags().SortFlags = false
6615	config.AddStringFlag([]*cobra.Command{runCmd}, &f.ProductsCSV, "products csv file")
6616	config.AddBoolFlag([]*cobra.Command{runCmd, wasmCmd}, &f.Teststripekey, "use stripe test api keys instead of live key")
6617	config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripeliveSK, "stripe live api sk")
6618	config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripelivePK, "stripe live api pk")
6619	config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestSK, "stripe test api sk")
6620	config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestPK, "stripe test api pk")
6621	config.AddIntFlag([]*cobra.Command{runCmd}, &f.WebPort, "port to serve on")
6622	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteimagesrc, "domain for images - leave blank to serve images")
6623	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteordersurl, "domain for orders - leave blank for same domain")
6624	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitename, "site name")
6625	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteext, "site domain extension")
6626	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitelongname, "site long name")
6627	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitetagline, "site tag line")
6628	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitemeta, "site meta")
6629	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgcontact, "telegram contact")
6630	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgchannel, "telegram channel")
6631	config.AddBoolFlag([]*cobra.Command{runCmd}, &f.UseTinygo, "use tinygo instead of go to compile wasm")
6632	config.AddStringSliceFlag([]*cobra.Command{runCmd, wasmCmd}, &f.WasmSRC, "wasm source code files RELATIVE PATHS without '..'")
6633	config.AddStringFlag([]*cobra.Command{runCmd}, &f.PrinterName, "CUPS printer name (default: system default)")
6634	config.AddStringFlag([]*cobra.Command{runCmd}, &f.CupsOptions, "e.g. 'media=Custom.80x200mm,fit-to-page'")
6635	config.AddDurationFlag([]*cobra.Command{runCmd}, &f.LpTimeout, "timeout for lp command")
6636	config.AddStringFlag([]*cobra.Command{tuiCmd}, &f.Storeurl, "browse this store over http (ex. 'https://magnetosphere.net') instead of local files")
6637}
6638
6639// RootCmd is the top-level m2 command.
6640var RootCmd = &cobra.Command{
6641	Use:   "m2",
6642	Short: "web store server",
6643	Long:  calvin.AsciiFont("magnetosphere.net") + "\n" + "web store server",
6644}
6645
6646// Execute executes the root cli command
6647func Execute() {
6648	cc.Init(&cc.Config{
6649		RootCmd:         RootCmd,
6650		Headings:        cc.HiBlue + cc.Bold,
6651		Commands:        cc.HiBlue + cc.Bold,
6652		CmdShortDescr:   cc.HiBlue,
6653		Example:         cc.HiBlue + cc.Italic,
6654		ExecName:        cc.HiBlue + cc.Bold,
6655		Flags:           cc.HiBlue + cc.Bold,
6656		FlagsDescr:      cc.HiBlue,
6657		NoExtraNewlines: true,
6658		NoBottomNewline: true,
6659	})
6660	if err := RootCmd.Execute(); err != nil {
6661		log.Fatal("Failed to execute command: ", err)
6662	}
6663}
6664
6665const help = "\r\n" +
6666	"  {{if .HasAvailableSubCommands}}{{end}} {{if gt (len .Aliases) 0}}\r\n\r\n" +
6667	"{{.NameAndAliases}}{{end}}{{if .HasAvailableSubCommands}}\r\n\r\n" +
6668	"Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand)}}\r\n  " +
6669	"{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}\r\n\r\n" +
6670	"Flags:\r\n" +
6671	"{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}\r\n\r\n" +
6672	"Global Flags:\r\n" +
6673	"{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}\r\n\r\n"
6674
6675
6676// ===== cmd/m2/commands/run.go =====
6677// Package commands cmd/m2/commands/run.go β€” the `m2 run` web server command.
6678package commands
6679
6680import (
6681	"fmt"
6682	"log"
6683	"os"
6684	"strings"
6685
6686	"github.com/spf13/cobra"
6687	"golang.org/x/text/cases"
6688	"golang.org/x/text/language"
6689
6690	"github.com/0magnet/calvin"
6691
6692	"github.com/0magnet/m2/pkg/config"
6693	"github.com/0magnet/m2/pkg/web"
6694)
6695
6696var runCmd = &cobra.Command{
6697	Use:   "run",
6698	Short: "run the web application",
6699	Long: calvin.AsciiFont("magnetosphere.net") + "\n" + func() string {
6700		helptext := `Run the web application
6701Generate a config file first
6702
6703Config defaults file may also be specified with:
6704MENV=m2.conf m2 run
6705OR
6706MENV=/path/to/m2.conf m2 run
6707print the MENV file template with:
6708m2 gen`
6709		if config.MENV == "" {
6710			return helptext
6711		}
6712		if _, err := os.Stat(config.MENV); err == nil {
6713			return `Run the web application
6714
6715menv file detected: ` + config.MENV
6716		}
6717		return helptext
6718	}(),
6719	Run: func(_ *cobra.Command, _ []string) {
6720		f.Sitedomain = f.Sitename + f.Siteext
6721		log.Println(" Initializing " + f.Sitedomain)
6722		fmt.Println(calvin.BlackboardBold(f.Sitedomain))
6723		fmt.Println(calvin.AsciiFont(f.Sitedomain))
6724		config.InitStripe()
6725		f.Siteprettyname = calvin.BlackboardBold(f.Sitedomain) //"π•„π•’π•˜π•Ÿπ•–π•₯𝕠𝕀𝕑𝕙𝕖𝕣𝕖.π•Ÿπ•–π•₯"
6726		c := cases.Title(language.English)
6727		f.Siteprettynamecap = calvin.BlackboardBold(c.String(f.Sitedomain))         //"π•„π•’π•˜π•Ÿπ•–π•₯𝕠𝕀𝕑𝕙𝕖𝕣𝕖.π•Ÿπ•–π•₯"
6728		f.Siteprettynamecaps = calvin.BlackboardBold(strings.ToUpper(f.Sitedomain)) //"π•„π”Έπ”Ύβ„•π”Όπ•‹π•†π•Šβ„™β„π”Όβ„π”Ό.ℕ𝔼𝕋"
6729		f.SiteASCIILogo = strings.Replace(strings.Replace(calvin.AsciiFont(f.Sitedomain), " ", "&nbsp;", -1), "\n", "<br>\n", -1)
6730
6731		if f.UseTinygo {
6732			f.WasmExecPath = f.WasmExecPathTinyGo
6733			f.Buildwasmwith = f.Tinygobuild
6734		}
6735		if len(f.WasmSRC) == 0 {
6736			f.WasmExecPath = ""
6737			f.Buildwasmwith = ""
6738		}
6739		log.Println("Checking for products CSV")
6740		log.Println("Reading products CSV")
6741		if err := web.LoadCatalog(); err != nil {
6742			log.Fatal("Error getting file info:", err)
6743		}
6744		go web.WatchCatalog()
6745		web.Serve()
6746	},
6747}
6748
6749
6750// ===== cmd/m2/commands/tui.go =====
6751// Package commands cmd/m2/commands/tui.go β€” the `m2 tui` terminal storefront.
6752package commands
6753
6754import (
6755	"log"
6756
6757	"github.com/spf13/cobra"
6758
6759	"github.com/0magnet/m2/pkg/product"
6760	"github.com/0magnet/m2/pkg/tui"
6761)
6762
6763var tuiCmd = &cobra.Command{
6764	Use:   "tui",
6765	Short: "browse the store in the terminal",
6766	Long: `Browse the store in a terminal UI.
6767
6768Reads the same config as 'run':
6769MENV=m2.conf m2 tui
6770
6771As a client of a running store (catalog, images, and checkout over
6772http; no local files needed):
6773m2 tui --storeurl https://magnetosphere.net`,
6774	Run: func(_ *cobra.Command, _ []string) {
6775		var prods product.Products
6776		if f.Storeurl != "" {
6777			var err error
6778			prods, err = tui.FetchCatalog(f.Storeurl)
6779			if err != nil {
6780				log.Fatalf("could not fetch the catalog from %s: %v", f.Storeurl, err)
6781			}
6782		} else {
6783			if f.ProductsCSV == "" {
6784				f.ProductsCSV = "products.csv"
6785			}
6786			prods = product.ReadCSV(f.ProductsCSV)
6787		}
6788		if len(prods) == 0 {
6789			log.Fatal("no products in the catalog")
6790		}
6791		if err := tui.Run(prods); err != nil {
6792			log.Fatal(err)
6793		}
6794	},
6795}
6796
6797
6798// ===== cmd/m2/commands/wasm.go =====
6799// Package commands cmd/m2/commands/wasm.go β€” the `m2 wasm` compile command.
6800package commands
6801
6802import (
6803	"log"
6804
6805	"github.com/spf13/cobra"
6806
6807	"github.com/0magnet/m2/pkg/config"
6808	"github.com/0magnet/m2/pkg/web"
6809)
6810
6811var wasmCmd = &cobra.Command{
6812	Use:   "wasm",
6813	Short: "compile wasm",
6814	Long:  "compile wasm",
6815	Run: func(_ *cobra.Command, _ []string) {
6816		if len(f.WasmSRC) == 0 {
6817			log.Fatal("No wasm source code specified")
6818		}
6819		config.InitStripe()
6820		web.CompileWASM()
6821	},
6822}
6823
6824