1// ===== checkout_js.go =====
   2//go:build js
   3
   4package main
   5
   6import (
   7	"bytes"
   8	"encoding/json"
   9	"log"
  10	"net/http"
  11	"strconv"
  12	"strings"
  13	"syscall/js"
  14
  15	"cogentcore.org/core/core"
  16)
  17
  18// StartCheckout (web): creates a plain <dialog> (verbatim to your old UI),
  19// loads Stripe.js, creates a Payment Intent, mounts Elements, confirms payment.
  20func StartCheckout(cart *Cart, _ core.Widget) {
  21	if stripePK == "" {
  22		log.Println("stripePK is empty; provide with -ldflags \"-X 'main.stripePK=pk_...'\"")
  23		return
  24	}
  25	go startStripeFlow(cart)
  26}
  27
  28func startStripeFlow(cart *Cart) {
  29	if err := ensureStripe(); err != nil {
  30		showMessage("Stripe init failed.")
  31		log.Println("ensureStripe:", err)
  32		return
  33	}
  34	dlg := ensureDialog()
  35	hideSpinner()
  36	hideMessage()
  37	dlg.Call("showModal")
  38
  39	clientSecret, err := createPaymentIntent(buildCheckoutPayload(cart))
  40	if err != nil || clientSecret == "" {
  41		if err != nil {
  42			log.Println("createPaymentIntent:", err)
  43		}
  44		showMessage("Failed to create payment intent.")
  45		return
  46	}
  47	if err := setupStripeElements(clientSecret); err != nil {
  48		showMessage("Failed to initialize payment form.")
  49		log.Println("setupStripeElements:", err)
  50		return
  51	}
  52	wireSubmit(clientSecret)
  53	wireCancel()
  54}
  55
  56// ---------- Stripe + Elements ----------
  57
  58var (
  59	stripeValue js.Value
  60	stripe      js.Value
  61	elements    js.Value
  62)
  63
  64func ensureStripe() error {
  65	// If Stripe is already present, reuse it.
  66	stripeValue = js.Global().Get("Stripe")
  67	if !stripeValue.Truthy() {
  68		// Load script
  69		head := queryOne("head")
  70		if !head.Truthy() {
  71			return errStr("no <head/> for script injection")
  72		}
  73		script := createEl("script")
  74		script.Set("src", "https://js.stripe.com/v3/")
  75		script.Set("defer", true)
  76
  77		done := make(chan struct{})
  78		onload := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
  79			done <- struct{}{}
  80			return nil
  81		})
  82		script.Call("addEventListener", "load", onload)
  83		head.Call("appendChild", script)
  84		<-done
  85		onload.Release()
  86
  87		stripeValue = js.Global().Get("Stripe")
  88		if !stripeValue.Truthy() {
  89			return errStr("Stripe global missing after load")
  90		}
  91	}
  92	if !stripe.Truthy() {
  93		stripe = stripeValue.Invoke(stripePK)
  94		if !stripe.Truthy() {
  95			return errStr("Stripe(pk) init failed")
  96		}
  97	}
  98	return nil
  99}
 100
 101func setupStripeElements(clientSecret string) error {
 102	elements = stripe.Call("elements", map[string]any{
 103		"clientSecret": clientSecret,
 104	})
 105	if !elements.Truthy() {
 106		return errStr("elements() failed")
 107	}
 108	payment := elements.Call("create", "payment", map[string]any{"layout": "tabs"})
 109	if !payment.Truthy() {
 110		return errStr("create('payment') failed")
 111	}
 112	host := getEl("payment-element")
 113	if !host.Truthy() {
 114		return errStr("#payment-element not found")
 115	}
 116	host.Set("innerHTML", "")
 117	payment.Call("mount", "#payment-element")
 118	return nil
 119}
 120
 121func wireSubmit(clientSecret string) {
 122	submit := getEl("submit")
 123	if !submit.Truthy() {
 124		return
 125	}
 126	cl := submit.Call("cloneNode", true)
 127	submit.Get("parentNode").Call("replaceChild", cl, submit)
 128	cl.Call("addEventListener", "click", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
 129		args[0].Call("preventDefault")
 130		showSpinner(true)
 131		confirmPayment(clientSecret)
 132		return nil
 133	}))
 134}
 135
 136func confirmPayment(clientSecret string) {
 137	loc := js.Global().Get("window").Get("location")
 138	origin := loc.Get("origin").String()
 139	returnURL := origin + "/complete"
 140
 141	stripe.Call("confirmPayment", map[string]any{
 142		"elements": elements,
 143		"confirmParams": map[string]any{
 144			"return_url": returnURL,
 145		},
 146	}).Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
 147		hideSpinner()
 148		res := args[0]
 149		err := res.Get("error")
 150		if err.Truthy() {
 151			msg := err.Get("message").String()
 152			if msg == "" {
 153				msg = "Payment failed."
 154			}
 155			showMessage(msg)
 156		} else {
 157			showMessage("Payment submitted. Redirecting…")
 158		}
 159		return nil
 160	}))
 161}
 162
 163// ---------- Server call ----------
 164
 165func createPaymentIntent(payload []byte) (string, error) {
 166	type resp struct {
 167		ClientSecret string `json:"clientSecret"`
 168		Error        string `json:"error,omitempty"`
 169	}
 170	rp, err := http.Post(serverBase+"/create-payment-intent", "application/json", bytes.NewReader(payload))
 171	if err != nil {
 172		return "", err
 173	}
 174	defer rp.Body.Close()
 175	if rp.StatusCode != http.StatusOK {
 176		return "", errStr("server returned " + rp.Status)
 177	}
 178
 179	var out resp
 180	if err := json.NewDecoder(rp.Body).Decode(&out); err != nil {
 181		return "", err
 182	}
 183	if out.Error != "" {
 184		log.Println("server returned error:", out.Error)
 185	}
 186	return out.ClientSecret, nil
 187}
 188
 189func buildCheckoutPayload(cart *Cart) []byte {
 190	type cItem struct {
 191		ID     string `json:"id"`
 192		Amount int    `json:"amount"`
 193	}
 194	type payload struct {
 195		Items []cItem `json:"items"`
 196	}
 197	var items []cItem
 198	cart.mu.Lock()
 199	for id, it := range cart.Items {
 200		if it.Qty <= 0 {
 201			continue
 202		}
 203		amt := priceToCents(it.Product.Price) * it.Qty
 204		label := id
 205		if !strings.HasPrefix(id, "shipping-to|") {
 206			label = id + " X " + strconv.Itoa(it.Qty)
 207		}
 208		items = append(items, cItem{ID: label, Amount: amt})
 209	}
 210	cart.mu.Unlock()
 211	b, err := json.Marshal(payload{Items: items})
 212	if err != nil {
 213		log.Printf("buildCheckoutPayload: marshal error: %v", err)
 214		return nil
 215	}
 216	return b
 217}
 218
 219// ---------- Dialog (verbatim to your old UI) ----------
 220
 221func ensureDialog() js.Value {
 222	doc := js.Global().Get("document")
 223	if dlg := doc.Call("getElementById", "stripecheckout"); dlg.Truthy() {
 224		return dlg
 225	}
 226	dlg := doc.Call("createElement", "dialog")
 227	dlg.Set("id", "stripecheckout")
 228	dlg.Set("innerHTML", `
 229<button id="close-dialog-button" onclick="cancelCheckout()" class="cancel-button">×</button>
 230<div class="checkout-container" id="checkout-container">
 231  <form id="payment-form" class="payment-form">
 232    <div id="payment-element"></div>
 233    <button id="submit" type="button">
 234      <div class="spinner hidden" id="spinner"></div>
 235      <span id="button-text">Pay now</span>
 236    </button>
 237    <div id="payment-message" class="hidden"></div>
 238  </form>
 239</div>`)
 240	doc.Get("body").Call("appendChild", dlg)
 241	return dlg
 242}
 243
 244var cancelBound bool
 245
 246func wireCancel() {
 247	if cancelBound {
 248		return
 249	}
 250	cancelBound = true
 251	js.Global().Set("cancelCheckout", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
 252		getEl("stripecheckout").Call("close")
 253		return nil
 254	}))
 255}
 256
 257// ---------- Tiny DOM helpers & UI bits ----------
 258
 259func getEl(id string) js.Value {
 260	return js.Global().Get("document").Call("getElementById", id)
 261}
 262func queryOne(sel string) js.Value {
 263	return js.Global().Get("document").Call("querySelector", sel)
 264}
 265func createEl(tag string) js.Value {
 266	return js.Global().Get("document").Call("createElement", tag)
 267}
 268
 269func showMessage(msg string) {
 270	m := getEl("payment-message")
 271	if !m.Truthy() {
 272		return
 273	}
 274	m.Set("textContent", msg)
 275	m.Get("classList").Call("remove", "hidden")
 276}
 277func hideMessage() {
 278	m := getEl("payment-message")
 279	if m.Truthy() {
 280		m.Get("classList").Call("add", "hidden")
 281		m.Set("textContent", "")
 282	}
 283}
 284func showSpinner(on bool) {
 285	sp := getEl("spinner")
 286	bt := getEl("button-text")
 287	if !sp.Truthy() || !bt.Truthy() {
 288		return
 289	}
 290	if on {
 291		sp.Get("classList").Call("remove", "hidden")
 292		bt.Get("classList").Call("add", "hidden")
 293	} else {
 294		sp.Get("classList").Call("add", "hidden")
 295		bt.Get("classList").Call("remove", "hidden")
 296	}
 297}
 298func hideSpinner() { showSpinner(false) }
 299
 300type strErr string
 301func (e strErr) Error() string { return string(e) }
 302func errStr(s string) error    { return strErr(s) }
 303
 304
 305// ===== checkout_notjs.go =====
 306//go:build !js
 307
 308package main
 309
 310import (
 311	"cogentcore.org/core/core"
 312	"cogentcore.org/core/events"
 313	"cogentcore.org/core/icons"
 314)
 315
 316// StartCheckout (native): just inform that checkout is web-only for now.
 317func StartCheckout(_ *Cart, anchor core.Widget) {
 318	d := core.NewBody("Checkout")
 319	core.NewText(d).
 320		SetType(core.TextSupporting).
 321		SetText("Checkout is currently supported on the web build. Please open this site in your browser to complete payment.")
 322
 323	d.AddBottomBar(func(bar *core.Frame) {
 324		d.AddCancel(bar).SetIcon(icons.Close).OnClick(func(e events.Event) {
 325			// no-op
 326		})
 327		d.AddOK(bar).SetIcon(icons.Home).SetText("OK")
 328	})
 329	d.RunDialog(anchor)
 330}
 331
 332
 333// ===== origin_js.go =====
 334//go:build js
 335package main
 336
 337import (
 338	"syscall/js"
 339)
 340
 341// currentLocation tries window/self and returns the JS location object.
 342func currentLocation() js.Value {
 343	g := js.Global()
 344	if l := g.Get("location"); l.Truthy() {
 345		return l
 346	}
 347	if w := g.Get("window"); w.Truthy() {
 348		if l := w.Get("location"); l.Truthy() {
 349			return l
 350		}
 351	}
 352	if s := g.Get("self"); s.Truthy() {
 353		if l := s.Get("location"); l.Truthy() {
 354			return l
 355		}
 356	}
 357	return js.Undefined()
 358}
 359
 360// Origin returns "scheme://host[:port]" (e.g., "https://example.net").
 361func Origin() string {
 362	loc := currentLocation()
 363	if !loc.Truthy() {
 364		return ""
 365	}
 366	if o := loc.Get("origin").String(); o != "" {
 367		return o
 368	}
 369	return loc.Get("protocol").String() + "//" + Host()
 370}
 371
 372// Host returns "domain[:port]" (e.g., "example.net" or "127.0.0.1:8080").
 373func Host() string {
 374	loc := currentLocation()
 375	if !loc.Truthy() {
 376		return ""
 377	}
 378	// location.host already includes port if present
 379	h := loc.Get("host").String()
 380	if h != "" {
 381		return h
 382	}
 383	// Fallback if host is empty (file: URLs etc.)
 384	hn := loc.Get("hostname").String()
 385	pt := loc.Get("port").String()
 386	if hn == "" {
 387		return ""
 388	}
 389	if pt != "" {
 390		return hn + ":" + pt
 391	}
 392	return hn
 393}
 394
 395
 396// ===== origin_notjs.go =====
 397//go:build !js
 398package main
 399
 400// Origin returns "scheme://host[:port]" (e.g., "https://example.net").
 401func Origin() string { return "" }
 402// Host returns "domain[:port]" (e.g., "example.net" or "127.0.0.1:8080").
 403func Host() string { return "" }
 404
 405
 406// ===== pages.go =====
 407// Package main pages.go — programmatic UI mode (no content system)
 408package main
 409
 410import (
 411	"fmt"
 412	"net/http"
 413	"sort"
 414	"strconv"
 415	"strings"
 416	"time"
 417
 418	p "github.com/0magnet/m2/pkg/product"
 419	"cogentcore.org/core/colors"
 420	"cogentcore.org/core/core"
 421	"cogentcore.org/core/cursors"
 422	"cogentcore.org/core/events"
 423	"cogentcore.org/core/icons"
 424	"cogentcore.org/core/styles"
 425	"cogentcore.org/core/styles/abilities"
 426	"cogentcore.org/core/styles/units"
 427	"cogentcore.org/core/text/rich"
 428	"cogentcore.org/core/text/text"
 429	"cogentcore.org/core/tree"
 430	"golang.org/x/image/draw"
 431	goimage "image"
 432	_ "image/jpeg"
 433	_ "image/png"
 434)
 435
 436var pageContent *core.Frame
 437
 438type catInfo struct {
 439	Name     string
 440	Products []p.Product
 441}
 442
 443func setupPagesUI(root *core.Body, products p.Products, prodByID map[string]p.Product) *Cart {
 444	cart := NewCart()
 445	if items, ok := LoadCartState(); ok {
 446		cart.mu.Lock()
 447		cart.Items = items
 448		cart.mu.Unlock()
 449	}
 450
 451	// Organize products by category
 452	catMap := make(map[string]*catInfo)
 453	for _, pr := range products {
 454		ci, ok := catMap[pr.Category]
 455		if !ok {
 456			ci = &catInfo{Name: pr.Category}
 457			catMap[pr.Category] = ci
 458		}
 459		ci.Products = append(ci.Products, pr)
 460	}
 461	var categories []*catInfo
 462	for _, ci := range catMap {
 463		categories = append(categories, ci)
 464	}
 465	sort.Slice(categories, func(i, j int) bool {
 466		return strings.ToLower(categories[i].Name) < strings.ToLower(categories[j].Name)
 467	})
 468
 469	// Toolbar with Home, About, clock, Interface
 470	root.AddTopBar(func(bar *core.Frame) {
 471		tb := core.NewToolbar(bar)
 472		tb.Maker(func(p *tree.Plan) {
 473			tree.Add(p, func(w *core.Button) {
 474				w.SetText("Home").SetIcon(icons.Home)
 475				w.OnClick(func(e events.Event) { showPagesHome() })
 476			})
 477			tree.Add(p, func(w *core.Button) {
 478				w.SetText("About").SetIcon(icons.Info)
 479				w.OnClick(func(e events.Event) { showPagesAbout() })
 480			})
 481			tree.Add(p, func(w *core.Text) {
 482				w.Updater(func() {
 483					w.SetText(time.Now().Format("Mon Jan 2 2006 15:04:05"))
 484				})
 485				go func() {
 486					ticker := time.NewTicker(time.Second)
 487					defer ticker.Stop()
 488					for range ticker.C {
 489						if !w.IsVisible() {
 490							continue
 491						}
 492						w.AsyncLock()
 493						w.UpdateRender()
 494						w.AsyncUnlock()
 495					}
 496				}()
 497			})
 498			tree.Add(p, func(w *core.Button) {
 499				w.SetText("Interface").SetIcon(icons.HtmlFill)
 500				w.OnClick(func(e events.Event) {
 501					core.TheApp.OpenURL("https://" + siteName + "/")
 502				})
 503			})
 504		})
 505		tb.Styler(func(s *styles.Style) {
 506			s.Font.Family = rich.Monospace
 507			s.Font.CustomFont = "mononoki"
 508			s.Text.LineHeight = 1
 509			s.Text.WhiteSpace = text.WhiteSpacePreWrap
 510		})
 511	})
 512
 513	// Main layout: nav sidebar + content area using Splits (same as content system)
 514	sp := core.NewSplits(root)
 515	sp.SetSplits(0.2, 0.8)
 516
 517	// Nav sidebar
 518	nav := core.NewFrame(sp)
 519	nav.Styler(func(s *styles.Style) {
 520		s.Direction = styles.Column
 521		s.Overflow.Y = styles.OverflowAuto
 522		s.Grow.Set(1, 1)
 523		s.Padding.Set(units.Dp(4))
 524		s.Gap.Set(units.Dp(0))
 525	})
 526
 527	// Content area
 528	pageContent = core.NewFrame(sp)
 529	pageContent.Styler(func(s *styles.Style) {
 530		s.Direction = styles.Column
 531		s.Overflow.Y = styles.OverflowAuto
 532		s.Grow.Set(1, 1)
 533		s.Padding.Set(units.Dp(8))
 534	})
 535
 536	// Nav: Categories with collapsible product lists
 537	for _, ci := range categories {
 538		cat := ci // capture for closure
 539		col := core.NewCollapser(nav)
 540
 541		// Category name in summary — clicking it shows the category page
 542		catName := cat.Name
 543		catProds := cat.Products
 544		catText := core.NewText(col.Summary).SetText(fmt.Sprintf("%s (%d)", cat.Name, len(cat.Products)))
 545		catText.OnClick(func(e events.Event) {
 546			showPagesCategory(catName, catProds, cart)
 547		})
 548		catText.Styler(func(s *styles.Style) {
 549			s.SetAbilities(true, abilities.Hoverable)
 550			s.Cursor = cursors.Pointer
 551		})
 552
 553		details := core.NewFrame(col.Details)
 554		details.Styler(func(s *styles.Style) {
 555			s.Direction = styles.Column
 556			s.Gap.Set(units.Dp(0))
 557			s.Padding.Left = units.Em(0.5)
 558		})
 559
 560		// Individual products as compact text links
 561		for _, pr := range cat.Products {
 562			prod := pr // capture
 563			link := core.NewText(details).SetText(prod.Name)
 564			link.Styler(func(s *styles.Style) {
 565				s.SetAbilities(true, abilities.Hoverable, abilities.Clickable)
 566				s.Cursor = cursors.Pointer
 567				s.Font.Size = units.Dp(13)
 568				s.Color = colors.Scheme.Primary.Base
 569				s.Padding.Set(units.Dp(2), units.Dp(4))
 570				s.Text.WhiteSpace = text.WrapNever
 571				s.Max.X = units.Pw(100)
 572				s.Overflow.X = styles.OverflowHidden
 573			})
 574			link.OnClick(func(e events.Event) { showPagesProduct(prod, cart) })
 575		}
 576	}
 577
 578	// Show home initially
 579	showPagesHome()
 580
 581	return cart
 582}
 583
 584func showPagesHome() {
 585	pageContent.DeleteChildren()
 586	initTitle()
 587
 588	core.NewText(pageContent).SetType(core.TextHeadlineLarge).SetText(title).Styler(func(s *styles.Style) {
 589		s.Font.Family = rich.Custom
 590		s.Font.CustomFont = "mononoki"
 591		s.Text.WhiteSpace = text.WhiteSpacePre
 592		s.Text.LineHeight = 1
 593		s.Color = colors.Scheme.Primary.Base
 594	})
 595
 596	// SVG + globe animation
 597	frame := core.NewFrame(pageContent)
 598	frame.Styler(func(s *styles.Style) {
 599		s.Display = styles.Custom
 600		s.Grow.Set(1, 1)
 601	})
 602	frame.OnClick(func(e events.Event) {
 603		pause = !pause
 604		if pause {
 605			core.MessageSnackbar(b, "animation paused")
 606		} else {
 607			core.MessageSnackbar(b, "animation resumed")
 608		}
 609	})
 610	mySvg = core.NewSVG(frame)
 611	mySvg.OpenFS(mySVG, "icon.svg") //nolint
 612	mySvg.Styler(func(s *styles.Style) { s.Grow.Set(1, 1) })
 613	c := core.NewCanvas(frame)
 614	c.Styler(func(s *styles.Style) {
 615		s.RenderBox = false
 616		s.Grow.Set(1, 1)
 617	})
 618	globeAnimation(c)
 619
 620	core.NewText(pageContent).SetType(core.TextTitleLarge).SetText(tagLine)
 621	pageContent.Update()
 622}
 623
 624func showPagesCategory(catName string, prods []p.Product, cart *Cart) {
 625	pageContent.DeleteChildren()
 626
 627	core.NewText(pageContent).SetType(core.TextTitleLarge).SetText(catName)
 628
 629	for _, pr := range prods {
 630		prod := pr // capture
 631		row := core.NewFrame(pageContent)
 632		row.Styler(func(s *styles.Style) {
 633			s.Direction = styles.Row
 634			s.Gap.Set(units.Dp(12))
 635			s.Align.Items = styles.Center
 636			s.Padding.Set(units.Dp(6))
 637			s.Border.Width.Bottom = units.Dp(1)
 638			s.Border.Color.Bottom = colors.Scheme.OutlineVariant
 639		})
 640
 641		// Thumbnail
 642		if prod.Image1 != "" {
 643			thumb := core.NewImage(row)
 644			thumb.Styler(func(s *styles.Style) {
 645				s.Min.Set(units.Dp(96), units.Dp(96))
 646				s.Max.Set(units.Dp(96), units.Dp(96))
 647				s.ObjectFit = styles.FitContain
 648			})
 649			loadProductImage(thumb, productImageURL(prod))
 650		}
 651
 652		// Info column: name, price, stock
 653		info := core.NewFrame(row)
 654		info.Styler(func(s *styles.Style) {
 655			s.Direction = styles.Column
 656			s.Grow.X = 1
 657			s.Gap.Set(units.Dp(2))
 658		})
 659
 660		nameLink := core.NewText(info).SetText(prod.Name)
 661		nameLink.Styler(func(s *styles.Style) {
 662			s.SetAbilities(true, abilities.Hoverable, abilities.Clickable)
 663			s.Cursor = cursors.Pointer
 664			s.Color = colors.Scheme.Primary.Base
 665			s.Text.WhiteSpace = text.WrapNever
 666		})
 667		nameLink.OnClick(func(e events.Event) { showPagesProduct(prod, cart) })
 668
 669		detailRow := core.NewFrame(info)
 670		detailRow.Styler(func(s *styles.Style) {
 671			s.Direction = styles.Row
 672			s.Gap.Set(units.Dp(16))
 673		})
 674		core.NewText(detailRow).SetText("$" + prod.Price).Styler(func(s *styles.Style) {
 675			s.Font.Weight = rich.Bold
 676		})
 677		core.NewText(detailRow).SetText("In stock: " + prod.Quantity)
 678
 679		// Add button — fixed width column on the right
 680		btnFrame := core.NewFrame(row)
 681		btnFrame.Styler(func(s *styles.Style) {
 682			s.Min.X = units.Dp(100)
 683			s.Align.Items = styles.Center
 684			s.Justify.Content = styles.Center
 685		})
 686		if prod.Quantity != "0" {
 687			core.NewButton(btnFrame).SetText("Add").SetIcon(icons.Add).SetType(core.ButtonTonal).
 688				OnClick(func(e events.Event) {
 689					cart.Add(prod)
 690					core.MessageSnackbar(pageContent, fmt.Sprintf("Added %s to cart", prod.Name))
 691				})
 692		} else {
 693			core.NewText(btnFrame).SetText("out of stock").Styler(func(s *styles.Style) {
 694				s.Color = colors.Scheme.Error.Base
 695			})
 696		}
 697	}
 698
 699	pageContent.Update()
 700}
 701
 702// productImageURL builds the image URL for a product.
 703func productImageURL(prod p.Product) string {
 704	return "https://" + siteName + "/i/" + prod.Category + "/" + prod.Image1
 705}
 706
 707// loadProductImage fetches an image from a URL and sets it on the widget asynchronously.
 708func loadProductImage(img *core.Image, url string) {
 709	go func() {
 710		resp, err := http.Get(url) //nolint:gosec
 711		if err != nil {
 712			return
 713		}
 714		defer resp.Body.Close()
 715		if resp.StatusCode != http.StatusOK {
 716			return
 717		}
 718		src, _, err := goimage.Decode(resp.Body)
 719		if err != nil {
 720			return
 721		}
 722		// Scale down to reasonable size for display
 723		bounds := src.Bounds()
 724		maxW := 400
 725		if bounds.Dx() > maxW {
 726			ratio := float64(maxW) / float64(bounds.Dx())
 727			newH := int(float64(bounds.Dy()) * ratio)
 728			dst := goimage.NewRGBA(goimage.Rect(0, 0, maxW, newH))
 729			draw.CatmullRom.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil)
 730			src = dst
 731		}
 732		img.AsyncLock()
 733		img.SetImage(src)
 734		img.Update()
 735		img.AsyncUnlock()
 736	}()
 737}
 738
 739func showPagesProduct(prod p.Product, cart *Cart) {
 740	pageContent.DeleteChildren()
 741
 742	core.NewText(pageContent).SetType(core.TextTitleLarge).SetText(prod.Name)
 743
 744	// Product image
 745	if prod.Image1 != "" {
 746		img := core.NewImage(pageContent)
 747		img.Styler(func(s *styles.Style) {
 748			s.Max.X = units.Dp(400)
 749			s.Max.Y = units.Dp(400)
 750		})
 751		loadProductImage(img, productImageURL(prod))
 752	}
 753
 754	// Build spec rows
 755	type spec struct{ Label, Value string }
 756	var specs []spec
 757
 758	addSpec := func(label, value string) {
 759		if value != "" && value != "0" && value != "0.0" {
 760			specs = append(specs, spec{label, value})
 761		}
 762	}
 763
 764	specs = append(specs, spec{"Price", "$" + prod.Price})
 765	specs = append(specs, spec{"In Stock", prod.Quantity})
 766	specs = append(specs, spec{"Category", prod.Category})
 767	if prod.Subcategory != "" {
 768		specs = append(specs, spec{"Subcategory", prod.Subcategory})
 769	}
 770	if prod.Description1 != "" && !strings.EqualFold(prod.Description1, prod.Name) {
 771		specs = append(specs, spec{"Description", prod.Description1})
 772	}
 773	addSpec("Brand", prod.Mfgname)
 774	addSpec("MPN", prod.Mfgpartno)
 775	addSpec("Voltage", prod.VoltsRating)
 776	if prod.Value != "" && prod.Value != "0" && prod.Value != "0.0" {
 777		specs = append(specs, spec{"Value", prod.Value + prod.ValUnit})
 778	}
 779	addSpec("Amperage", prod.AmpsRating)
 780	if prod.Tolerance != "" && prod.Tolerance != "0" {
 781		if f, err := strconv.ParseFloat(prod.Tolerance, 64); err == nil {
 782			specs = append(specs, spec{"Tolerance", fmt.Sprintf("%.2f%%", f*100)})
 783		}
 784	}
 785	addSpec("Type", prod.Typ)
 786	addSpec("Package", prod.Packagetype)
 787	addSpec("Technology", prod.Technology)
 788	addSpec("Materials", prod.Materials)
 789	addSpec("Watts", prod.WattsRating)
 790	addSpec("Year", prod.Year)
 791	if prod.CableLengthInches != "" && prod.CableLengthInches != "0" && prod.CableLengthInches != "0.0" {
 792		specs = append(specs, spec{"Cable Length", prod.CableLengthInches + " inches"})
 793	}
 794	if prod.WeightOz != "" && prod.WeightOz != "0" && prod.WeightOz != "0.0" {
 795		specs = append(specs, spec{"Weight", prod.WeightOz + " oz"})
 796	}
 797	if prod.TempRating != "" && prod.TempRating != "0" && prod.TempRating != "0.0" {
 798		specs = append(specs, spec{"Temp Rating", prod.TempRating + prod.TempUnit})
 799	}
 800	addSpec("Condition", prod.Condition)
 801	addSpec("Datasheet", prod.Datasheet)
 802	addSpec("Docs", prod.Docs)
 803	addSpec("Note", prod.Note)
 804	addSpec("Warning", prod.Warning)
 805	if prod.Description2 != "" {
 806		specs = append(specs, spec{"Additional Info", prod.Description2})
 807	}
 808
 809	for _, sp := range specs {
 810		row := core.NewFrame(pageContent)
 811		row.Styler(func(s *styles.Style) {
 812			s.Direction = styles.Row
 813			s.Gap.Set(units.Dp(12))
 814			s.Text.WhiteSpace = text.WrapNever
 815			s.Overflow.X = styles.OverflowHidden
 816		})
 817		lbl := core.NewText(row).SetText(sp.Label + ":")
 818		lbl.Styler(func(s *styles.Style) {
 819			s.Min.X = units.Em(10)
 820			s.Max.X = units.Em(10)
 821			s.Font.Weight = rich.Bold
 822			s.Text.WhiteSpace = text.WrapNever
 823		})
 824		core.NewText(row).SetText(sp.Value).Styler(func(s *styles.Style) {
 825			s.Text.WhiteSpace = text.WrapNever
 826		})
 827	}
 828
 829	if prod.Quantity != "0" {
 830		core.NewButton(pageContent).SetText("Add to Cart").SetIcon(icons.Add).
 831			OnClick(func(e events.Event) {
 832				cart.Add(prod)
 833				core.MessageSnackbar(pageContent, fmt.Sprintf("Added %s to cart", prod.Name))
 834			})
 835	}
 836
 837	pageContent.Update()
 838}
 839
 840func showPagesAbout() {
 841	pageContent.DeleteChildren()
 842	core.NewText(pageContent).SetType(core.TextTitleLarge).SetText("About")
 843	core.NewText(pageContent).SetText(`Magnetosphere is an electronic surplus webstore
 844
 845Our sincerest thanks to:
 846  BG Micro - bgmicro.com
 847  Tanner Electronics - tannerelectronics.com
 848  the Bunker of Doom - bunkerofdoom.com
 849
 850In memory of Lewis Cearly - Nortex Electronics
 851In memory of Billy Gage - BG Micro
 852
 853This website is made with golang & golang webassembly - Now with CogentCore`).Styler(func(s *styles.Style) {
 854		s.Text.WhiteSpace = text.WhiteSpacePre
 855	})
 856	pageContent.Update()
 857}
 858
 859
 860// ===== persist_js.go =====
 861//go:build js
 862
 863package main
 864
 865import (
 866	"encoding/json"
 867	"strconv"
 868	"strings"
 869	"syscall/js"
 870
 871	p "github.com/0magnet/m2/pkg/product"
 872)
 873
 874const cartStorageKey = "cartItems" // must match server-side expectation
 875
 876// server-compatible storage shape
 877type legacyItem struct {
 878	ID       string `json:"id"`       // product part number or special shipping id
 879	Amount   int    `json:"amount"`   // per-unit amount in cents
 880	Quantity int    `json:"quantity"` // quantity
 881}
 882
 883// LoadCartState restores the cart from window.localStorage using the server format:
 884// [
 885//   {"id": "rect-VS-43CTQ", "amount": 104, "quantity": 2},
 886//   {"id": "shipping-to|...","amount": 700, "quantity": 1}
 887// ]
 888func LoadCartState() (map[string]*CartItem, bool) {
 889	ls := js.Global().Get("localStorage")
 890	if ls.IsUndefined() || ls.IsNull() {
 891		return nil, false
 892	}
 893
 894	raw := ls.Call("getItem", cartStorageKey)
 895	if raw.IsUndefined() || raw.IsNull() {
 896		return nil, false
 897	}
 898
 899	var arr []legacyItem
 900	if err := json.Unmarshal([]byte(raw.String()), &arr); err != nil {
 901		return nil, false
 902	}
 903
 904	items := make(map[string]*CartItem, len(arr))
 905	for _, it := range arr {
 906		// Best-effort name: shipping entries get "Shipping", others default to empty (UI can look up)
 907		name := ""
 908		if strings.HasPrefix(it.ID, "shipping-") || strings.HasPrefix(it.ID, "shipping|") || strings.HasPrefix(it.ID, "shipping-to|") {
 909			name = "Shipping"
 910		}
 911
 912		items[it.ID] = &CartItem{
 913			Product: p.Product{
 914				Partno: it.ID,
 915				Name:   name,
 916				// Store a display string (e.g., "$7.00") computed from cents for UI convenience
 917				Price: centsToDollarString(it.Amount),
 918			},
 919			Qty: it.Quantity,
 920		}
 921	}
 922
 923	return items, true
 924}
 925
 926// SaveCartState persists the cart to window.localStorage in the server format noted above.
 927func SaveCartState(cart *Cart) {
 928	ls := js.Global().Get("localStorage")
 929	if ls.IsUndefined() || ls.IsNull() {
 930		return
 931	}
 932
 933	var out []legacyItem
 934
 935	cart.mu.Lock()
 936	for _, it := range cart.Items {
 937		out = append(out, legacyItem{
 938			ID:       it.Product.Partno,
 939			Amount:   dollarsStringToCents(it.Product.Price),
 940			Quantity: it.Qty,
 941		})
 942	}
 943	cart.mu.Unlock()
 944
 945	b, _ := json.Marshal(out)
 946	ls.Call("setItem", cartStorageKey, string(b))
 947}
 948
 949// dollarsStringToCents converts price strings to integer cents.
 950// Accepts formats like "$7.50", "7.50", "7", "0.1" (=> 10 cents), and trims whitespace.
 951// Invalid/empty input returns 0.
 952func dollarsStringToCents(s string) int {
 953	s = strings.TrimSpace(s)
 954	if s == "" {
 955		return 0
 956	}
 957	if strings.HasPrefix(s, "$") {
 958		s = strings.TrimSpace(s[1:])
 959	}
 960	// Normalize to exactly two decimal places without using float math
 961	// Split on ".", pad/truncate fraction to 2 digits.
 962	parts := strings.SplitN(s, ".", 3)
 963	intPart := parts[0]
 964	frac := "00"
 965	if len(parts) >= 2 {
 966		frac = parts[1]
 967	}
 968	if len(frac) == 0 {
 969		frac = "00"
 970	} else if len(frac) == 1 {
 971		frac = frac + "0"
 972	} else if len(frac) > 2 {
 973		frac = frac[:2] // truncate extra precision
 974	}
 975
 976	// Remove any stray non-digits from intPart (e.g., commas)
 977	intPart = strings.ReplaceAll(intPart, ",", "")
 978
 979	i, err1 := strconv.Atoi(intPart)
 980	f, err2 := strconv.Atoi(frac)
 981	if err1 != nil || err2 != nil || i < 0 || f < 0 {
 982		return 0
 983	}
 984	return i*100 + f
 985}
 986
 987// centsToDollarString renders cents as a display string like "$7.00"
 988func centsToDollarString(cents int) string {
 989	if cents < 0 {
 990		cents = 0
 991	}
 992	dollars := cents / 100
 993	remainder := cents % 100
 994	return "$" + strconv.Itoa(dollars) + "." + twoDigits(remainder)
 995}
 996
 997func twoDigits(n int) string {
 998	if n < 10 {
 999		return "0" + strconv.Itoa(n)
1000	}
1001	return strconv.Itoa(n)
1002}
1003
1004
1005// ===== persist_notjs.go =====
1006//go:build !js
1007
1008package main
1009
1010// No-op persistence for non-web builds.
1011func LoadCartState() (map[string]*CartItem, bool) { return nil, false }
1012func SaveCartState(_ *Cart)                        {}
1013
1014
1015// ===== ☠.go =====
1016// Package main ☠.go
1017package main
1018
1019import (
1020	"embed"
1021)
1022
1023//go:embed content/*
1024var econtent embed.FS
1025
1026//go:embed products.json
1027var productsJSON string
1028
1029//go:embed core.toml
1030var coreToml string
1031
1032
1033// ===== ☢.go =====
1034// Package main ☢.go
1035package main
1036
1037import (
1038	"encoding/binary"
1039
1040	"crypto/rand"
1041	"math"
1042
1043	"cogentcore.org/core/colors"
1044	"cogentcore.org/core/core"
1045	"cogentcore.org/core/paint"
1046)
1047
1048const (
1049	numLatLines = 12
1050	numLonLines = 24
1051	radius      = 0.4
1052	centerX     = 0.5
1053	centerY     = 0.5
1054)
1055
1056func randFloat64InRange(n, x float64) float64 {
1057	var b [8]byte
1058	_, err := rand.Read(b[:])
1059	if err != nil {
1060		panic("failed to read crypto/rand: " + err.Error())
1061	}
1062	u := float64(binary.LittleEndian.Uint64(b[:])) / (1 << 63)
1063	u = math.Min(u, 1)
1064	return n + u*(x-n)
1065}
1066
1067var (
1068	rotationX, rotationY, rotationZ = randFloat64InRange(0, 2*math.Pi), randFloat64InRange(0, 2*math.Pi), randFloat64InRange(0, 2*math.Pi)
1069	dX, dY, dZ                      = randFloat64InRange(-0.02, 0.02), randFloat64InRange(-0.02, 0.02), randFloat64InRange(-0.02, 0.02)
1070	pause                           = true
1071)
1072
1073func globeAnimation(w *core.Canvas) {
1074	w.SetDraw(func(pc *paint.Painter) {
1075		//pc.Fill.Color = colors.Uniform(colors.Transparent)
1076		//pc.Clear()
1077		sinX, cosX := math.Sin(rotationX), math.Cos(rotationX)
1078		sinY, cosY := math.Sin(rotationY), math.Cos(rotationY)
1079		sinZ, cosZ := math.Sin(rotationZ), math.Cos(rotationZ)
1080
1081		projectPoint := func(x, y, z float64) (float32, float32) {
1082			ry := y*cosX - z*sinX
1083			rz := y*sinX + z*cosX
1084
1085			rx := x*cosY + rz*sinY
1086			//			z = -x*sinY + rz*cosY
1087
1088			x = rx*cosZ - ry*sinZ
1089			y = rx*sinZ + ry*cosZ
1090
1091			screenX := centerX + float32(x)
1092			screenY := centerY + float32(y)
1093			return screenX, screenY
1094		}
1095
1096		pc.Stroke.Color = colors.Scheme.Primary.Base
1097		for lat := -math.Pi / 2; lat <= math.Pi/2; lat += math.Pi / float64(numLatLines) {
1098			first := true
1099			var startX, startY float32
1100			for lon := 0.0; lon <= 2*math.Pi; lon += math.Pi / 100 {
1101				x := radius * math.Cos(lat) * math.Cos(lon)
1102				y := radius * math.Cos(lat) * math.Sin(lon)
1103				z := radius * math.Sin(lat)
1104				screenX, screenY := projectPoint(x, y, z)
1105				if first {
1106					startX, startY = screenX, screenY
1107					first = false
1108				} else {
1109					pc.MoveTo(startX, startY)
1110					pc.LineTo(screenX, screenY)
1111					startX, startY = screenX, screenY
1112				}
1113			}
1114			pc.Draw()
1115		}
1116
1117		pc.Stroke.Color = colors.Scheme.Primary.Base
1118		for lon := 0.0; lon <= 2*math.Pi; lon += math.Pi / float64(numLonLines) {
1119			first := true
1120			var startX, startY float32
1121			for lat := -math.Pi / 2; lat <= math.Pi/2; lat += math.Pi / 100 {
1122				x := radius * math.Cos(lat) * math.Cos(lon)
1123				y := radius * math.Cos(lat) * math.Sin(lon)
1124				z := radius * math.Sin(lat)
1125				screenX, screenY := projectPoint(x, y, z)
1126				if first {
1127					startX, startY = screenX, screenY
1128					first = false
1129				} else {
1130					pc.MoveTo(startX, startY)
1131					pc.LineTo(screenX, screenY)
1132					startX, startY = screenX, screenY
1133				}
1134			}
1135			pc.Draw()
1136		}
1137	})
1138
1139	w.Animate(func(_ *core.Animation) {
1140		if pause {
1141			return
1142		}
1143		mySvg.Update()
1144		rotationX += dX
1145		rotationY += dY
1146		rotationZ += dZ
1147		w.NeedsRender()
1148	})
1149}
1150
1151
1152// ===== ☣.go =====
1153// payment.go
1154package main
1155
1156import (
1157	"bytes"
1158	"encoding/json"
1159	"log"
1160	"net/http"
1161	"net/url"
1162
1163	"cogentcore.org/core/core"
1164)
1165
1166// Injected at build time, e.g.:
1167//   go build -ldflags "-X 'main.stripePK=pk_live_...'"
1168var stripePK string
1169
1170// Base URL for your server API (adjust if different host/port/proto)
1171var serverBase = "https://" + siteName
1172func init() {
1173	serverBase = "https://" + siteName
1174}
1175// Toggle which flow to use:
1176//   true  -> Stripe Checkout Session
1177//   false -> Payment Intent + your hosted /pay page with Stripe Elements
1178const useCheckoutSession = false
1179
1180// Public entry point called from the Checkout button in cart_ui.go
1181func CheckoutWithServer(cart *Cart) {
1182	if stripePK == "" {
1183		log.Println("Stripe public key (stripePK) is not set; pass via ldflags: -X 'main.stripePK=pk_live_...'\nCheckout aborted.")
1184		return
1185	}
1186
1187	var err error
1188	if useCheckoutSession {
1189		err = checkoutViaSession(cart)
1190	} else {
1191		err = checkoutViaPaymentIntent(cart)
1192	}
1193	if err != nil {
1194		log.Println("Checkout error:", err)
1195	}
1196}
1197
1198// ---------- Strategy A: Stripe Checkout Session ----------
1199func checkoutViaSession(cart *Cart) error {
1200	type req struct {
1201		Items []APIItem `json:"items"`
1202	}
1203	type resp struct {
1204		URL   string `json:"url"`
1205		Error string `json:"error,omitempty"`
1206		// (optionally the server may also return "id" for the session)
1207	}
1208
1209	payload := req{Items: cart.ItemsForAPI()}
1210	body, _ := json.Marshal(payload)
1211
1212	endpoint := serverBase + "/create-checkout-session"
1213	rp, err := http.Post(endpoint, "application/json", bytes.NewReader(body))
1214	if err != nil {
1215		return err
1216	}
1217	defer rp.Body.Close()
1218
1219	var out resp
1220	if err := json.NewDecoder(rp.Body).Decode(&out); err != nil {
1221		return err
1222	}
1223	if out.Error != "" {
1224		log.Println("server returned error:", out.Error)
1225		return nil
1226	}
1227	if out.URL == "" {
1228		log.Println("create-checkout-session returned empty url")
1229		return nil
1230	}
1231
1232	openBrowser(out.URL)
1233	return nil
1234}
1235
1236// ---------- Strategy B: Payment Intent + hosted Elements page ----------
1237func checkoutViaPaymentIntent(cart *Cart) error {
1238	type req struct {
1239		Items []APIItem `json:"items"`
1240	}
1241	type resp struct {
1242		ClientSecret string `json:"clientSecret"`
1243		Error        string `json:"error,omitempty"`
1244	}
1245
1246	payload := req{Items: cart.ItemsForAPI()}
1247	body, _ := json.Marshal(payload)
1248
1249	endpoint := serverBase + "/create-payment-intent"
1250	rp, err := http.Post(endpoint, "application/json", bytes.NewReader(body))
1251	if err != nil {
1252		return err
1253	}
1254	defer rp.Body.Close()
1255
1256	var out resp
1257	if err := json.NewDecoder(rp.Body).Decode(&out); err != nil {
1258		return err
1259	}
1260	if out.Error != "" {
1261		log.Println("server returned error:", out.Error)
1262		return nil
1263	}
1264	if out.ClientSecret == "" {
1265		log.Println("create-payment-intent returned empty clientSecret")
1266		return nil
1267	}
1268
1269	// Open your hosted page that mounts Stripe Elements with the client secret.
1270	// If your page expects different param names, tweak here.
1271	payURL := serverBase + "/?" + url.Values{
1272		"client_secret": []string{out.ClientSecret},
1273		"pk":            []string{stripePK},
1274	}.Encode()
1275
1276	openBrowser(payURL)
1277	return nil
1278}
1279
1280// ---------- Open URL using Cogent Core ----------
1281func openBrowser(u string) {
1282	// Cogent Core handles native and WASM correctly:
1283	// - Native: opens system browser
1284	// - WASM: navigates the current window
1285	log.Println(u)
1286	core.TheApp.OpenURL(u)
1287}
1288
1289
1290// ===== ⚛.go =====
1291// Package main ui/⚛.go
1292package main
1293
1294import (
1295	"runtime"
1296	"strings"
1297	"unicode/utf8"
1298	"cogentcore.org/core/base/errors"
1299	"cogentcore.org/core/colors"
1300	"cogentcore.org/core/core"
1301	"cogentcore.org/core/events"
1302	"cogentcore.org/core/htmlcore"
1303	"cogentcore.org/core/styles"
1304	"cogentcore.org/core/styles/abilities"
1305	"cogentcore.org/core/styles/units"
1306	"cogentcore.org/core/text/rich"
1307	"cogentcore.org/core/text/text"
1308	"cogentcore.org/core/tree"
1309	"github.com/0magnet/calvin"
1310)
1311
1312var home *core.Frame
1313var mySvg *core.SVG
1314
1315var (
1316	title = strings.Replace(calvin.AsciiFont(siteName), " ", "\u00A0", -1)
1317	titleWidth int
1318	tagLine = "we have the technology"
1319)
1320
1321
1322func initTitle() {
1323	title = strings.Replace(calvin.AsciiFont(siteName), " ", "\u00A0", -1)
1324
1325	titles := strings.Split(title, "\n")
1326	for i := 0; i < len(titles) && i < 3; i++ {
1327		titles[i] = " " + titles[i]
1328	}
1329
1330	title = strings.Join(titles, "\n")
1331	titleWidth = utf8.RuneCountInString(strings.Split(title, "\n")[0])
1332}
1333
1334func homePage(ctx *htmlcore.Context) bool {
1335	initTitle()
1336	home = core.NewFrame(ctx.BlockParent)
1337	home.Styler(func(s *styles.Style) {
1338		s.Direction = styles.Column
1339		s.Grow.Set(1, 1)
1340		s.CenterAll()
1341	})
1342	home.OnShow(func(_ events.Event) {
1343		home.Update()
1344	})
1345
1346	tree.AddChildAt(home, "", func(w *core.Frame) {
1347		w.Styler(func(s *styles.Style) {
1348			s.Gap.Set(units.Em(0))
1349			s.Grow.Set(1, 0)
1350			if home.SizeClass() == core.SizeCompact {
1351				s.Direction = styles.Column
1352			}
1353		})
1354		w.Maker(func(p *tree.Plan) {
1355			tree.Add(p, func(w *core.Frame) {
1356				w.Styler(func(s *styles.Style) {
1357					s.Direction = styles.Column
1358					s.Text.Align = text.Start
1359					s.Grow.Set(1, 1)
1360				})
1361			})
1362		})
1363	})
1364
1365	tree.AddChild(home, func(w *core.Text) {
1366		w.SetType(core.TextHeadlineLarge).SetText(title)
1367		w.Styler(func(s *styles.Style) {
1368			s.Font.Family = rich.Custom
1369			if runtime.GOOS == "js" {
1370				s.Font.Family = rich.Monospace
1371			}
1372			s.Font.CustomFont = "mononoki"
1373			s.Text.WhiteSpace = text.WhiteSpacePre
1374			s.Min.X = units.Em(float32(titleWidth) * 0.62)
1375			s.Text.LineHeight = 1
1376			s.Gap.Set(units.Em(0))
1377			s.Color = colors.Scheme.Primary.Base
1378		})
1379	})
1380
1381	//svg + canvas animation overlay
1382	tree.AddChild(home, func(w *core.Frame) {
1383		w.Styler(func(s *styles.Style) {
1384			s.Display = styles.Custom
1385			s.SetAbilities(true, abilities.Clickable)
1386			s.Grow.Set(1, 1)
1387		})
1388
1389		w.OnClick(func(_ events.Event) {
1390			pause = !pause
1391			if pause {
1392				core.MessageSnackbar(b, "animation paused")
1393			} else {
1394				core.MessageSnackbar(b, "animation resumed")
1395			}
1396		})
1397		mySvg = core.NewSVG(w)
1398		errors.Log(mySvg.OpenFS(mySVG, "icon.svg")) //nolint
1399		mySvg.Styler(func(s *styles.Style) {
1400			s.Grow.Set(1, 1)
1401		})
1402		c := core.NewCanvas(w)
1403		c.Styler(func(s *styles.Style) {
1404			s.RenderBox = false
1405			s.Grow.Set(1, 1)
1406		})
1407		globeAnimation(c)
1408	})
1409
1410	tree.AddChild(home, func(w *core.Text) {
1411		w.SetType(core.TextTitleLarge).SetText(tagLine)
1412
1413	})
1414	return true
1415}
1416
1417
1418// ===== 🌐.go =====
1419// Package main 🌐.go
1420package main
1421
1422import (
1423	"embed"
1424	"encoding/json"
1425	"io/fs"
1426	"log"
1427	"reflect"
1428	"time"
1429
1430	p "github.com/0magnet/m2/pkg/product"
1431	"github.com/0magnet/calvin"
1432
1433	"github.com/bitfield/script"
1434	"cogentcore.org/core/content"
1435	"cogentcore.org/core/core"
1436	"cogentcore.org/core/htmlcore"
1437	"cogentcore.org/core/icons"
1438	"cogentcore.org/core/styles"
1439	"cogentcore.org/core/text/fonts"
1440	"cogentcore.org/core/text/rich"
1441	"cogentcore.org/core/text/text"
1442	"cogentcore.org/core/tree"
1443	"cogentcore.org/core/yaegicore/coresymbols"
1444)
1445
1446//go:embed mononoki/*.ttf
1447var mononoki embed.FS
1448
1449//go:embed icon.svg
1450var mySVG embed.FS
1451
1452var origin = Origin()
1453var host = Host()
1454var siteName = host
1455
1456// uiMode is set via -ldflags "-X 'main.uiMode=pages'" to use the
1457// programmatic Pages UI instead of the content system.
1458var uiMode string
1459
1460var b = core.NewBody(calvin.BlackboardBold(siteName))
1461
1462func main() {
1463	var err error
1464	if siteName == "" {
1465		siteName, err = script.Echo(coreToml).Match("NamePrefix").Replace(`NamePrefix = "`, "").Replace(`"`, "").String()
1466		if err != nil {
1467			log.Fatal(err)
1468		}
1469	}
1470	b = core.NewBody(calvin.BlackboardBold(siteName))
1471	data, err := fs.ReadFile(mySVG, "icon.svg")
1472	if err != nil {
1473		panic("failed to read icon.svg: " + err.Error())
1474	}
1475	core.AppIcon = string(data)
1476	fonts.AddEmbedded(mononoki)
1477
1478	core.TheApp.SetSceneInit(func(sc *core.Scene) {
1479		sc.SetWidgetInit(func(w core.Widget) {
1480			w.AsWidget().Styler(func(s *styles.Style) {
1481				s.Font.Family = rich.Custom
1482				s.Font.CustomFont = "mononoki"
1483				s.Text.LineHeight = 1
1484				s.Text.WhiteSpace = text.WhiteSpacePreWrap
1485			})
1486		})
1487	})
1488	b.Styler(func(s *styles.Style) {
1489		s.Font.Family = rich.Custom
1490		s.Font.CustomFont = "mononoki"
1491		s.Text.LineHeight = 1
1492		s.Text.WhiteSpace = text.WhiteSpacePreWrap
1493	})
1494
1495	var products p.Products
1496	if err := json.Unmarshal([]byte(productsJSON), &products); err != nil {
1497		log.Fatalf("Error unmarshaling JSON: %v", err)
1498	}
1499	prodByID := make(map[string]p.Product, len(products))
1500	for _, pr := range products {
1501		prodByID[pr.Partno] = pr
1502	}
1503
1504	if uiMode == "pages" {
1505		cart := setupPagesUI(b, products, prodByID)
1506		SetupCartUI(b, nil, prodByID, cart)
1507	} else {
1508		ct := content.NewContent(b).SetSource(econtent)
1509		ctx := ct.Context
1510
1511		b.AddTopBar(func(bar *core.Frame) {
1512			tb := core.NewToolbar(bar)
1513			tb.Maker(ct.MakeToolbar)
1514			tb.Maker(func(p *tree.Plan) {
1515				tree.Add(p, func(w *core.Text) {
1516					w.Updater(func() {
1517						w.SetText(time.Now().Format("Mon Jan 2 2006 15:04:05"))
1518					})
1519					go func() {
1520						ticker := time.NewTicker(time.Second)
1521						defer ticker.Stop()
1522						for range ticker.C {
1523							if !w.IsVisible() {
1524								continue
1525							}
1526							w.AsyncLock()
1527							w.UpdateRender()
1528							w.AsyncUnlock()
1529						}
1530					}()
1531				})
1532				tree.Add(p, func(w *core.Button) {
1533					ctx.LinkButton(w, "about")
1534					w.SetText("About").SetIcon(icons.QuestionMarkFill)
1535				})
1536				tree.Add(p, func(w *core.Button) {
1537					ctx.LinkButton(w, "https://"+siteName+"/")
1538					w.SetText("Interface").SetIcon(icons.HtmlFill)
1539				})
1540			})
1541			tb.AddOverflowMenu(func(m *core.Scene) {
1542				core.NewFuncButton(m).SetFunc(core.SettingsWindow)
1543			})
1544			tb.Styler(func(s *styles.Style) {
1545				s.Font.Family = rich.Monospace
1546				s.Font.CustomFont = "mononoki"
1547				s.Text.LineHeight = 1
1548				s.Text.WhiteSpace = text.WhiteSpacePreWrap
1549			})
1550		})
1551
1552		if coresymbols.Symbols["."] == nil {
1553			coresymbols.Symbols["."] = make(map[string]reflect.Value)
1554		}
1555		coresymbols.Symbols["."]["econtent"] = reflect.ValueOf(econtent)
1556
1557		ctx.ElementHandlers["home-page"] = homePage
1558		ctx.ElementHandlers["about"] = func(ctx *htmlcore.Context) bool {
1559			f := core.NewFrame(ctx.BlockParent)
1560			tree.AddChild(f, func(w *core.Text) {
1561				w.SetType(core.TextTitleLarge).SetText("")
1562			})
1563			return true
1564		}
1565
1566		SetupCartUI(b, ctx, prodByID, nil)
1567	}
1568
1569	b.RunMainWindow()
1570	log.Println("exiting")
1571}
1572
1573func inlineParentNode(hctx *htmlcore.Context) tree.Node {
1574	switch v := any(hctx.InlineParent).(type) {
1575	case core.Widget:
1576		return v.AsTree()
1577	case func() core.Widget:
1578		return v().AsTree()
1579	default:
1580		return hctx.BlockParent.AsTree()
1581	}
1582}
1583
1584
1585// ===== 🮕.go =====
1586//Package main 🮕.go
1587package main
1588
1589import (
1590	"fmt"
1591	"strconv"
1592	"sync"
1593	"strings"
1594
1595	p "github.com/0magnet/m2/pkg/product"
1596)
1597
1598type CartItem struct {
1599	Product p.Product `edit:"-"`
1600	Qty     int
1601	// For shipping line: use Product.Partno == "shipping-to|<...>" and Price in Product.Price
1602}
1603
1604type Cart struct {
1605	mu       sync.Mutex
1606	Items    map[string]*CartItem // key: Partno
1607	onChange []func()
1608}
1609
1610func NewCart() *Cart { return &Cart{Items: make(map[string]*CartItem)} }
1611
1612func (c *Cart) OnChange(fn func()) { c.mu.Lock(); c.onChange = append(c.onChange, fn); c.mu.Unlock() }
1613func (c *Cart) notify()             { for _, fn := range c.onChange { fn() } }
1614
1615// ---- Mutators (unlock BEFORE notify) ----
1616func (c *Cart) Add(prod p.Product) {
1617	c.mu.Lock()
1618	if it, ok := c.Items[prod.Partno]; ok {
1619		it.Qty++
1620	} else {
1621		c.Items[prod.Partno] = &CartItem{Product: prod, Qty: 1}
1622	}
1623	c.mu.Unlock()
1624	c.notify()
1625}
1626func (c *Cart) Inc(id string)   { c.mu.Lock(); if it, ok := c.Items[id]; ok { it.Qty++ }; c.mu.Unlock(); c.notify() }
1627func (c *Cart) Dec(id string)   { c.mu.Lock(); if it, ok := c.Items[id]; ok { it.Qty--; if it.Qty <= 0 { delete(c.Items, id) } }; c.mu.Unlock(); c.notify() }
1628func (c *Cart) Remove(id string){ c.mu.Lock(); delete(c.Items, id); c.mu.Unlock(); c.notify() }
1629func (c *Cart) Clear()          { c.mu.Lock(); clear(c.Items); c.mu.Unlock(); c.notify() }
1630
1631// ---- Shipping helpers ----
1632const shippingPrefix = "shipping-to|"
1633
1634func (c *Cart) SetShipping(shippingID string, amountCents int) {
1635	// shippingID should start with shippingPrefix and contain the piped fields
1636	if shippingID == "" {
1637		return
1638	}
1639	price := fmt.Sprintf("$%d.%02d", amountCents/100, amountCents%100)
1640	shipProd := p.Product{
1641		Partno: shippingID, // e.g., "shipping-to|Name|Addr|City|State|Zip|Country|Phone"
1642		Name:   "Shipping",
1643		Price:  price,
1644	}
1645	c.mu.Lock()
1646	// remove any existing shipping line
1647	for id := range c.Items {
1648		if len(id) >= len(shippingPrefix) && id[:len(shippingPrefix)] == shippingPrefix {
1649			delete(c.Items, id)
1650		}
1651	}
1652	// add/replace shipping as qty=1
1653	c.Items[shipProd.Partno] = &CartItem{Product: shipProd, Qty: 1}
1654	c.mu.Unlock()
1655	c.notify()
1656}
1657
1658func (c *Cart) HasShipping() bool {
1659	c.mu.Lock()
1660	defer c.mu.Unlock()
1661	for id := range c.Items {
1662		if len(id) >= len(shippingPrefix) && id[:len(shippingPrefix)] == shippingPrefix {
1663			return true
1664		}
1665	}
1666	return false
1667}
1668
1669func (c *Cart) CountNonShipping() int {
1670	c.mu.Lock()
1671	defer c.mu.Unlock()
1672	n := 0
1673	for id, it := range c.Items {
1674		if len(id) >= len(shippingPrefix) && id[:len(shippingPrefix)] == shippingPrefix {
1675			continue
1676		}
1677		n += it.Qty
1678	}
1679	return n
1680}
1681
1682func (c *Cart) RemoveShipping() {
1683	c.mu.Lock()
1684	for id := range c.Items {
1685		if strings.HasPrefix(id, shippingPrefix) {
1686			delete(c.Items, id)
1687			break
1688		}
1689	}
1690	c.mu.Unlock()
1691	c.notify()
1692}
1693
1694// ---- Queries ----
1695func (c *Cart) Count() int {
1696	c.mu.Lock(); defer c.mu.Unlock()
1697	n := 0
1698	for _, it := range c.Items {
1699		n += it.Qty
1700	}
1701	return n
1702}
1703func (c *Cart) TotalCents() int {
1704	c.mu.Lock(); defer c.mu.Unlock()
1705	total := 0
1706	for _, it := range c.Items {
1707		total += priceToCents(it.Product.Price) * it.Qty
1708	}
1709	return total
1710}
1711
1712// For server payload (mirrors your wasm code: "ID + ' X ' + qty")
1713type APIItem struct {
1714	ID     string `json:"id"`
1715	Amount int    `json:"amount"`
1716}
1717
1718func (c *Cart) ItemsForAPI() []APIItem {
1719	c.mu.Lock()
1720	defer c.mu.Unlock()
1721	res := make([]APIItem, 0, len(c.Items))
1722	for id, it := range c.Items {
1723		res = append(res, APIItem{
1724			ID:     fmt.Sprintf("%s X %d", id, it.Qty),
1725			Amount: priceToCents(it.Product.Price) * it.Qty,
1726		})
1727	}
1728	return res
1729}
1730
1731// ---- Money helpers ----
1732func priceToCents(s string) int {
1733	if s == "" {
1734		return 0
1735	}
1736	s = strings.TrimPrefix(s, "$")
1737	f, err := strconv.ParseFloat(s, 64)
1738	if err != nil {
1739		return 0
1740	}
1741	if f < 0 {
1742		return -int(-f*100 + 0.5)
1743	}
1744	return int(f*100 + 0.5)
1745}
1746
1747func centsToMoney(c int) string {
1748	if c < 0 {
1749		return fmt.Sprintf("-$%d.%02d", -c/100, -c%100)
1750	}
1751	return fmt.Sprintf("$%d.%02d", c/100, c%100)
1752}
1753
1754
1755// ===== 🮖.go =====
1756//Package main cart_ui.go
1757package main
1758
1759import (
1760	"fmt"
1761	"strings"
1762
1763	p "github.com/0magnet/m2/pkg/product"
1764	"cogentcore.org/core/colors"
1765	"cogentcore.org/core/core"
1766	"cogentcore.org/core/events"
1767	"cogentcore.org/core/htmlcore"
1768	"cogentcore.org/core/icons"
1769	"cogentcore.org/core/styles"
1770	"cogentcore.org/core/styles/units"
1771	"cogentcore.org/core/text/rich"
1772	"cogentcore.org/core/text/text"
1773)
1774
1775// cartRow backs the table rows
1776type cartRow struct {
1777	Item      string `edit:"-"`
1778	UnitPrice string `edit:"-"`
1779	Qty       int
1780	LineTotal string `edit:"-"`
1781	Remove    bool
1782}
1783
1784func SetupCartUI(root *core.Body, ctx *htmlcore.Context, prodByID map[string]p.Product, existingCart *Cart) {
1785	cart := existingCart
1786	if cart == nil {
1787		cart = NewCart()
1788		// hydrate from storage on web (no-op elsewhere)
1789		if items, ok := LoadCartState(); ok {
1790			cart.mu.Lock()
1791			cart.Items = items
1792			cart.mu.Unlock()
1793		}
1794	}
1795
1796	// ===== Main section (full width with the ONLY collapser) =====
1797	mainSection := core.NewFrame(root)
1798	mainSection.Styler(func(s *styles.Style) {
1799		s.Direction = styles.Column
1800		s.Gap.Set(units.Dp(12), units.Dp(12))
1801		s.Grow.Set(1, 0)
1802		s.Padding.Set(units.Dp(4), units.Dp(4))
1803	})
1804
1805	// The single collapser
1806	col := core.NewCollapser(mainSection)
1807	// Live-updating summary on THIS collapser
1808	summary := core.NewText(col.Summary).SetText("Cart (0) — $0.00")
1809	summary.Updater(func() {
1810		summary.SetText(fmt.Sprintf("Cart (%d) — %s", cart.Count(), centsToMoney(cart.TotalCents())))
1811	})
1812
1813	// Details root we control (full width)
1814	detailsRoot := core.NewFrame(col.Details)
1815	detailsRoot.Styler(func(s *styles.Style) {
1816		s.Direction = styles.Column
1817		s.Gap.Set(units.Dp(12), units.Dp(12))
1818		s.Grow.Set(1, 0)
1819	})
1820
1821	// persistent backing for the table
1822	var tableRows []cartRow
1823	var rowIDs []string // parallel slice to map rows back to cart IDs
1824
1825	// Shipping form state
1826	type shipFormVals struct {
1827		Name, Address, City, State, Zip, Country, Phone string
1828		PriceStr                                        string
1829	}
1830	sf := &shipFormVals{Country: "US"}
1831
1832	// helper: find current shipping line
1833	getShipping := func() (id string, item *CartItem, ok bool) {
1834		for sid, it := range cart.Items {
1835			if strings.HasPrefix(sid, shippingPrefix) {
1836				return sid, it, true
1837			}
1838		}
1839		return "", nil, false
1840	}
1841
1842	// helper: open shipping dialog
1843	openShippingDialog := func(anchor core.Widget) {
1844		if sid, it, ok := getShipping(); ok {
1845			parts := strings.SplitN(sid, "|", 8)
1846			if len(parts) == 8 {
1847				sf.Name = parts[1]
1848				sf.Address = parts[2]
1849				sf.City = parts[3]
1850				sf.State = parts[4]
1851				sf.Zip = parts[5]
1852				sf.Country = parts[6]
1853				sf.Phone = parts[7]
1854			}
1855			if it != nil {
1856				c := priceToCents(it.Product.Price)
1857				sf.PriceStr = centsToMoney(c)[1:]
1858			}
1859		}
1860
1861		d := core.NewBody("Shipping")
1862		d.Styler(func(s *styles.Style) {
1863			s.Gap.Set(units.Dp(10), units.Dp(8))
1864			s.Padding.Set(units.Dp(8), units.Dp(8))
1865		})
1866		core.NewText(d).SetType(core.TextSupporting).SetText("Enter your shipping details:")
1867
1868		tfName := core.NewTextField(d).SetPlaceholder("Full name").SetText(sf.Name)
1869		tfAddr := core.NewTextField(d).SetPlaceholder("Address").SetText(sf.Address)
1870		tfCity := core.NewTextField(d).SetPlaceholder("City").SetText(sf.City)
1871		tfState := core.NewTextField(d).SetPlaceholder("State/Province").SetText(sf.State)
1872		tfZip := core.NewTextField(d).SetPlaceholder("ZIP/Postal").SetText(sf.Zip)
1873		tfCountry := core.NewTextField(d).SetPlaceholder("Country").SetText(sf.Country)
1874		tfPhone := core.NewTextField(d).SetPlaceholder("Phone").SetText(sf.Phone)
1875		tfPrice := core.NewTextField(d).SetPlaceholder("Shipping price (e.g. 7.50)").SetText(sf.PriceStr)
1876
1877		d.AddBottomBar(func(bar *core.Frame) {
1878			d.AddCancel(bar)
1879			d.AddOK(bar).OnClick(func(e events.Event) {
1880				sf.Name = tfName.Text()
1881				sf.Address = tfAddr.Text()
1882				sf.City = tfCity.Text()
1883				sf.State = tfState.Text()
1884				sf.Zip = tfZip.Text()
1885				sf.Country = tfCountry.Text()
1886				sf.Phone = tfPhone.Text()
1887				sf.PriceStr = tfPrice.Text()
1888
1889				// Strip pipe characters to prevent delimiter injection
1890				strip := func(s string) string { return strings.ReplaceAll(s, "|", "") }
1891				shipID := fmt.Sprintf("shipping-to|%s|%s|%s|%s|%s|%s|%s",
1892					strip(sf.Name), strip(sf.Address), strip(sf.City), strip(sf.State), strip(sf.Zip), strip(sf.Country), strip(sf.Phone),
1893				)
1894				v := sf.PriceStr
1895				if v == "" {
1896					v = "0"
1897				}
1898				cents := priceToCents("$" + v)
1899				cart.SetShipping(shipID, cents)
1900			})
1901		})
1902		d.RunDialog(anchor)
1903	}
1904
1905	// Build details every time
1906	detailsRoot.Updater(func() {
1907		if !col.Open {
1908			return
1909		}
1910		detailsRoot.DeleteChildren()
1911
1912		// Table rows from cart (excluding shipping)
1913		tableRows = tableRows[:0]
1914		rowIDs = rowIDs[:0]
1915		for id, it := range cart.Items {
1916			if strings.HasPrefix(id, shippingPrefix) {
1917				continue
1918			}
1919			unit := priceToCents(it.Product.Price)
1920			name := it.Product.Name
1921			if name == "" {
1922				name = id
1923			}
1924			tableRows = append(tableRows, cartRow{
1925				Item:      name,
1926				UnitPrice: centsToMoney(unit),
1927				Qty:       it.Qty,
1928				LineTotal: centsToMoney(unit * it.Qty),
1929				Remove:    false,
1930			})
1931			rowIDs = append(rowIDs, id)
1932		}
1933
1934		// Items TABLE (full width)
1935		tbl := core.NewTable(detailsRoot).SetSlice(&tableRows)
1936		tbl.SetReadOnly(false)
1937		tbl.Styler(func(s *styles.Style) {
1938			s.Grow.Set(1, 0)
1939		})
1940		tbl.OnChange(func(e events.Event) {
1941			for i := range tableRows {
1942				if i >= len(rowIDs) {
1943					continue
1944				}
1945				id := rowIDs[i]
1946				row := tableRows[i]
1947				cart.mu.Lock()
1948				it := cart.Items[id]
1949				cart.mu.Unlock()
1950				if it == nil {
1951					continue
1952				}
1953				if row.Remove || row.Qty <= 0 {
1954					cart.Remove(id)
1955					continue
1956				}
1957				if row.Qty != it.Qty {
1958					diff := row.Qty - it.Qty
1959					if diff > 0 {
1960						for n := 0; n < diff; n++ {
1961							cart.Inc(id)
1962						}
1963					} else if diff < 0 {
1964						for n := 0; n < -diff; n++ {
1965							cart.Dec(id)
1966						}
1967					}
1968				}
1969			}
1970		})
1971
1972		// Shipping block (below the table)
1973		shipBox := core.NewFrame(detailsRoot)
1974		shipBox.Styler(func(s *styles.Style) {
1975			s.Direction = styles.Column
1976			s.Gap.Set(units.Dp(6))
1977			s.Padding.Set(units.Dp(10))
1978			s.Border.Radius.Set(units.Dp(6))
1979			s.Border.Width.Set(units.Dp(1))
1980		})
1981
1982		core.NewText(shipBox).SetType(core.TextTitleSmall).SetText("Shipping")
1983
1984		if sid, it, ok := getShipping(); ok {
1985			parts := strings.SplitN(sid, "|", 8)
1986			if len(parts) == 8 {
1987				addr := fmt.Sprintf("%s\n%s\n%s, %s %s\n%s\nPhone: %s",
1988					parts[1], parts[2], parts[3], parts[4], parts[5], parts[6], parts[7])
1989				core.NewText(shipBox).SetText(addr).Styler(func(s *styles.Style) {
1990					s.Text.WhiteSpace = text.WhiteSpacePre
1991				})
1992			}
1993			amt := centsToMoney(priceToCents(it.Product.Price))
1994			core.NewText(shipBox).SetText("Cost: " + amt).Styler(func(s *styles.Style) {
1995				s.Font.Weight = rich.Bold
1996			})
1997		} else {
1998			core.NewText(shipBox).SetText("No shipping address set").Styler(func(s *styles.Style) {
1999				s.Color = colors.Scheme.OnSurfaceVariant
2000			})
2001		}
2002
2003		shipBtns := core.NewFrame(shipBox)
2004		shipBtns.Styler(func(s *styles.Style) {
2005			s.Direction = styles.Row
2006			s.Gap.Set(units.Dp(8))
2007		})
2008		addUpd := core.NewButton(shipBtns)
2009		if cart.HasShipping() {
2010			addUpd.SetText("Edit Shipping").SetIcon(icons.Edit)
2011		} else {
2012			addUpd.SetText("Add Shipping").SetIcon(icons.Box)
2013		}
2014		addUpd.OnClick(func(e events.Event) { openShippingDialog(addUpd) })
2015		if cart.HasShipping() {
2016			core.NewButton(shipBtns).SetText("Remove").SetIcon(icons.Delete).
2017				OnClick(func(e events.Event) { cart.RemoveShipping() })
2018		}
2019
2020		// Footer: totals breakdown + actions
2021		footer := core.NewFrame(detailsRoot)
2022		footer.Styler(func(s *styles.Style) {
2023			s.Direction = styles.Column
2024			s.Gap.Set(units.Dp(6))
2025			s.Padding.Set(units.Dp(10))
2026			s.Border.Width.Top = units.Dp(1)
2027			s.Border.Color.Top = colors.Scheme.OutlineVariant
2028		})
2029
2030		// Subtotal and shipping breakdown
2031		subtotal := cart.TotalCents()
2032		var shipCost int
2033		if _, it, ok := getShipping(); ok {
2034			shipCost = priceToCents(it.Product.Price)
2035			subtotal -= shipCost
2036		}
2037		totalsRow := core.NewFrame(footer)
2038		totalsRow.Styler(func(s *styles.Style) {
2039			s.Direction = styles.Column
2040			s.Gap.Set(units.Dp(2))
2041		})
2042		core.NewText(totalsRow).SetText("Subtotal: " + centsToMoney(subtotal))
2043		if shipCost > 0 {
2044			core.NewText(totalsRow).SetText("Shipping: " + centsToMoney(shipCost))
2045		}
2046		core.NewText(totalsRow).SetText("Total: " + centsToMoney(cart.TotalCents())).Styler(func(s *styles.Style) {
2047			s.Font.Weight = rich.Bold
2048			s.Font.Size = units.Dp(18)
2049		})
2050
2051		acts := core.NewFrame(footer)
2052		acts.Styler(func(s *styles.Style) {
2053			s.Direction = styles.Row
2054			s.Gap.Set(units.Dp(8))
2055		})
2056		core.NewButton(acts).SetText("Clear Cart").SetIcon(icons.Delete).SetType(core.ButtonOutlined).OnClick(func(e events.Event) {
2057			cart.Clear()
2058		})
2059		checkoutBtn := core.NewButton(acts).SetText("Checkout").SetIcon(icons.ShoppingCart).SetType(core.ButtonFilled)
2060		checkoutBtn.Updater(func() {
2061			enabled := cart.CountNonShipping() > 0 && cart.HasShipping()
2062			checkoutBtn.SetEnabled(enabled)
2063		})
2064		checkoutBtn.OnClick(func(e events.Event) {
2065			StartCheckout(cart, checkoutBtn)
2066		})
2067	})
2068
2069	// Initial paint and reactive updates
2070	detailsRoot.OnShow(func(_ events.Event) { detailsRoot.Update() })
2071	cart.OnChange(func() {
2072		core.TheApp.RunOnMain(func() {
2073			detailsRoot.Update()
2074			summary.Update()
2075		})
2076	})
2077	cart.OnChange(func() { SaveCartState(cart) })
2078
2079	// HTML custom element handler (only when using the content system)
2080	if ctx != nil {
2081		ctx.ElementHandlers["my-button"] = func(hctx *htmlcore.Context) bool {
2082			id := htmlcore.GetAttr(hctx.Node, "id")
2083			pr, ok := prodByID[id]
2084			if !ok {
2085				return false
2086			}
2087			parent := inlineParentNode(hctx)
2088			core.NewButton(parent).SetText("add to cart").SetIcon(icons.Add).OnClick(func(e events.Event) { cart.Add(pr) })
2089			return true
2090		}
2091	}
2092}
2093
2094