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
 176	var out resp
 177	if err := json.NewDecoder(rp.Body).Decode(&out); err != nil {
 178		return "", err
 179	}
 180	if out.Error != "" {
 181		log.Println("server returned error:", out.Error)
 182	}
 183	return out.ClientSecret, nil
 184}
 185
 186func buildCheckoutPayload(cart *Cart) []byte {
 187	type cItem struct {
 188		ID     string `json:"id"`
 189		Amount int    `json:"amount"`
 190	}
 191	type payload struct {
 192		Items []cItem `json:"items"`
 193	}
 194	var items []cItem
 195	cart.mu.Lock()
 196	for id, it := range cart.Items {
 197		if it.Qty <= 0 {
 198			continue
 199		}
 200		amt := priceToCents(it.Product.Price) * it.Qty
 201		label := id
 202		if !strings.HasPrefix(id, "shipping-to|") {
 203			label = id + " X " + strconv.Itoa(it.Qty)
 204		}
 205		items = append(items, cItem{ID: label, Amount: amt})
 206	}
 207	cart.mu.Unlock()
 208	b, _ := json.Marshal(payload{Items: items})
 209	return b
 210}
 211
 212// ---------- Dialog (verbatim to your old UI) ----------
 213
 214func ensureDialog() js.Value {
 215	doc := js.Global().Get("document")
 216	if dlg := doc.Call("getElementById", "stripecheckout"); dlg.Truthy() {
 217		return dlg
 218	}
 219	dlg := doc.Call("createElement", "dialog")
 220	dlg.Set("id", "stripecheckout")
 221	dlg.Set("innerHTML", `
 222<button id="close-dialog-button" onclick="cancelCheckout()" class="cancel-button">×</button>
 223<div class="checkout-container" id="checkout-container">
 224  <form id="payment-form" class="payment-form">
 225    <div id="payment-element"></div>
 226    <button id="submit" type="button">
 227      <div class="spinner hidden" id="spinner"></div>
 228      <span id="button-text">Pay now</span>
 229    </button>
 230    <div id="payment-message" class="hidden"></div>
 231  </form>
 232</div>`)
 233	doc.Get("body").Call("appendChild", dlg)
 234	return dlg
 235}
 236
 237var cancelBound bool
 238
 239func wireCancel() {
 240	if cancelBound {
 241		return
 242	}
 243	cancelBound = true
 244	js.Global().Set("cancelCheckout", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
 245		getEl("stripecheckout").Call("close")
 246		return nil
 247	}))
 248}
 249
 250// ---------- Tiny DOM helpers & UI bits ----------
 251
 252func getEl(id string) js.Value {
 253	return js.Global().Get("document").Call("getElementById", id)
 254}
 255func queryOne(sel string) js.Value {
 256	return js.Global().Get("document").Call("querySelector", sel)
 257}
 258func createEl(tag string) js.Value {
 259	return js.Global().Get("document").Call("createElement", tag)
 260}
 261
 262func showMessage(msg string) {
 263	m := getEl("payment-message")
 264	if !m.Truthy() {
 265		return
 266	}
 267	m.Set("textContent", msg)
 268	m.Get("classList").Call("remove", "hidden")
 269}
 270func hideMessage() {
 271	m := getEl("payment-message")
 272	if m.Truthy() {
 273		m.Get("classList").Call("add", "hidden")
 274		m.Set("textContent", "")
 275	}
 276}
 277func showSpinner(on bool) {
 278	sp := getEl("spinner")
 279	bt := getEl("button-text")
 280	if !sp.Truthy() || !bt.Truthy() {
 281		return
 282	}
 283	if on {
 284		sp.Get("classList").Call("remove", "hidden")
 285		bt.Get("classList").Call("add", "hidden")
 286	} else {
 287		sp.Get("classList").Call("add", "hidden")
 288		bt.Get("classList").Call("remove", "hidden")
 289	}
 290}
 291func hideSpinner() { showSpinner(false) }
 292
 293type strErr string
 294func (e strErr) Error() string { return string(e) }
 295func errStr(s string) error    { return strErr(s) }
 296
 297
 298// ===== checkout_notjs.go =====
 299//go:build !js
 300
 301package main
 302
 303import (
 304	"cogentcore.org/core/core"
 305	"cogentcore.org/core/events"
 306	"cogentcore.org/core/icons"
 307)
 308
 309// StartCheckout (native): just inform that checkout is web-only for now.
 310func StartCheckout(_ *Cart, anchor core.Widget) {
 311	d := core.NewBody("Checkout")
 312	core.NewText(d).
 313		SetType(core.TextSupporting).
 314		SetText("Checkout is currently supported on the web build. Please open this site in your browser to complete payment.")
 315
 316	d.AddBottomBar(func(bar *core.Frame) {
 317		d.AddCancel(bar).SetIcon(icons.Close).OnClick(func(e events.Event) {
 318			// no-op
 319		})
 320		d.AddOK(bar).SetIcon(icons.Home).SetText("OK")
 321	})
 322	d.RunDialog(anchor)
 323}
 324
 325
 326// ===== origin_js.go =====
 327//go:build js
 328package main
 329
 330import (
 331	"syscall/js"
 332)
 333
 334// currentLocation tries window/self and returns the JS location object.
 335func currentLocation() js.Value {
 336	g := js.Global()
 337	if l := g.Get("location"); l.Truthy() {
 338		return l
 339	}
 340	if w := g.Get("window"); w.Truthy() {
 341		if l := w.Get("location"); l.Truthy() {
 342			return l
 343		}
 344	}
 345	if s := g.Get("self"); s.Truthy() {
 346		if l := s.Get("location"); l.Truthy() {
 347			return l
 348		}
 349	}
 350	return js.Undefined()
 351}
 352
 353// Origin returns "scheme://host[:port]" (e.g., "https://example.net").
 354func Origin() string {
 355	loc := currentLocation()
 356	if !loc.Truthy() {
 357		return ""
 358	}
 359	if o := loc.Get("origin").String(); o != "" {
 360		return o
 361	}
 362	return loc.Get("protocol").String() + "//" + Host()
 363}
 364
 365// Host returns "domain[:port]" (e.g., "example.net" or "127.0.0.1:8080").
 366func Host() string {
 367	loc := currentLocation()
 368	if !loc.Truthy() {
 369		return ""
 370	}
 371	// location.host already includes port if present
 372	h := loc.Get("host").String()
 373	if h != "" {
 374		return h
 375	}
 376	// Fallback if host is empty (file: URLs etc.)
 377	hn := loc.Get("hostname").String()
 378	pt := loc.Get("port").String()
 379	if hn == "" {
 380		return ""
 381	}
 382	if pt != "" {
 383		return hn + ":" + pt
 384	}
 385	return hn
 386}
 387
 388
 389// ===== origin_notjs.go =====
 390//go:build !js
 391package main
 392
 393// Origin returns "scheme://host[:port]" (e.g., "https://example.net").
 394func Origin() string { return "" }
 395// Host returns "domain[:port]" (e.g., "example.net" or "127.0.0.1:8080").
 396func Host() string { return "" }
 397
 398
 399// ===== persist_js.go =====
 400//go:build js
 401
 402package main
 403
 404import (
 405	"encoding/json"
 406	"strconv"
 407	"strings"
 408	"syscall/js"
 409
 410	p "github.com/0magnet/m2/pkg/product"
 411)
 412
 413const cartStorageKey = "cartItems" // must match server-side expectation
 414
 415// server-compatible storage shape
 416type legacyItem struct {
 417	ID       string `json:"id"`       // product part number or special shipping id
 418	Amount   int    `json:"amount"`   // per-unit amount in cents
 419	Quantity int    `json:"quantity"` // quantity
 420}
 421
 422// LoadCartState restores the cart from window.localStorage using the server format:
 423// [
 424//   {"id": "rect-VS-43CTQ", "amount": 104, "quantity": 2},
 425//   {"id": "shipping-to|...","amount": 700, "quantity": 1}
 426// ]
 427func LoadCartState() (map[string]*CartItem, bool) {
 428	ls := js.Global().Get("localStorage")
 429	if ls.IsUndefined() || ls.IsNull() {
 430		return nil, false
 431	}
 432
 433	raw := ls.Call("getItem", cartStorageKey)
 434	if raw.IsUndefined() || raw.IsNull() {
 435		return nil, false
 436	}
 437
 438	var arr []legacyItem
 439	if err := json.Unmarshal([]byte(raw.String()), &arr); err != nil {
 440		return nil, false
 441	}
 442
 443	items := make(map[string]*CartItem, len(arr))
 444	for _, it := range arr {
 445		// Best-effort name: shipping entries get "Shipping", others default to empty (UI can look up)
 446		name := ""
 447		if strings.HasPrefix(it.ID, "shipping-") || strings.HasPrefix(it.ID, "shipping|") || strings.HasPrefix(it.ID, "shipping-to|") {
 448			name = "Shipping"
 449		}
 450
 451		items[it.ID] = &CartItem{
 452			Product: p.Product{
 453				Partno: it.ID,
 454				Name:   name,
 455				// Store a display string (e.g., "$7.00") computed from cents for UI convenience
 456				Price: centsToDollarString(it.Amount),
 457			},
 458			Qty: it.Quantity,
 459		}
 460	}
 461
 462	return items, true
 463}
 464
 465// SaveCartState persists the cart to window.localStorage in the server format noted above.
 466func SaveCartState(cart *Cart) {
 467	ls := js.Global().Get("localStorage")
 468	if ls.IsUndefined() || ls.IsNull() {
 469		return
 470	}
 471
 472	var out []legacyItem
 473
 474	cart.mu.Lock()
 475	for _, it := range cart.Items {
 476		out = append(out, legacyItem{
 477			ID:       it.Product.Partno,
 478			Amount:   dollarsStringToCents(it.Product.Price),
 479			Quantity: it.Qty,
 480		})
 481	}
 482	cart.mu.Unlock()
 483
 484	b, _ := json.Marshal(out)
 485	ls.Call("setItem", cartStorageKey, string(b))
 486}
 487
 488// dollarsStringToCents converts price strings to integer cents.
 489// Accepts formats like "$7.50", "7.50", "7", "0.1" (=> 10 cents), and trims whitespace.
 490// Invalid/empty input returns 0.
 491func dollarsStringToCents(s string) int {
 492	s = strings.TrimSpace(s)
 493	if s == "" {
 494		return 0
 495	}
 496	if strings.HasPrefix(s, "$") {
 497		s = strings.TrimSpace(s[1:])
 498	}
 499	// Normalize to exactly two decimal places without using float math
 500	// Split on ".", pad/truncate fraction to 2 digits.
 501	parts := strings.SplitN(s, ".", 3)
 502	intPart := parts[0]
 503	frac := "00"
 504	if len(parts) >= 2 {
 505		frac = parts[1]
 506	}
 507	if len(frac) == 0 {
 508		frac = "00"
 509	} else if len(frac) == 1 {
 510		frac = frac + "0"
 511	} else if len(frac) > 2 {
 512		frac = frac[:2] // truncate extra precision
 513	}
 514
 515	// Remove any stray non-digits from intPart (e.g., commas)
 516	intPart = strings.ReplaceAll(intPart, ",", "")
 517
 518	i, err1 := strconv.Atoi(intPart)
 519	f, err2 := strconv.Atoi(frac)
 520	if err1 != nil || err2 != nil || i < 0 || f < 0 {
 521		return 0
 522	}
 523	return i*100 + f
 524}
 525
 526// centsToDollarString renders cents as a display string like "$7.00"
 527func centsToDollarString(cents int) string {
 528	if cents < 0 {
 529		cents = 0
 530	}
 531	dollars := cents / 100
 532	remainder := cents % 100
 533	return "$" + strconv.Itoa(dollars) + "." + twoDigits(remainder)
 534}
 535
 536func twoDigits(n int) string {
 537	if n < 10 {
 538		return "0" + strconv.Itoa(n)
 539	}
 540	return strconv.Itoa(n)
 541}
 542
 543
 544// ===== persist_notjs.go =====
 545//go:build !js
 546
 547package main
 548
 549// No-op persistence for non-web builds.
 550func LoadCartState() (map[string]*CartItem, bool) { return nil, false }
 551func SaveCartState(_ *Cart)                        {}
 552
 553
 554// ===== ☠.go =====
 555// Package main ☠.go
 556package main
 557
 558import (
 559	"embed"
 560)
 561
 562//go:embed content/*
 563var econtent embed.FS
 564
 565//go:embed products.json
 566var productsJSON string
 567
 568//go:embed core.toml
 569var coreToml string
 570
 571
 572// ===== ☢.go =====
 573// Package main ☢.go
 574package main
 575
 576import (
 577	"encoding/binary"
 578
 579	"crypto/rand"
 580	"math"
 581
 582	"cogentcore.org/core/colors"
 583	"cogentcore.org/core/core"
 584	"cogentcore.org/core/paint"
 585)
 586
 587const (
 588	numLatLines = 12
 589	numLonLines = 24
 590	radius      = 0.4
 591	centerX     = 0.5
 592	centerY     = 0.5
 593)
 594
 595func randFloat64InRange(n, x float64) float64 {
 596	var b [8]byte
 597	_, err := rand.Read(b[:])
 598	if err != nil {
 599		panic("failed to read crypto/rand: " + err.Error())
 600	}
 601	u := float64(binary.LittleEndian.Uint64(b[:])) / (1 << 63)
 602	u = math.Min(u, 1)
 603	return n + u*(x-n)
 604}
 605
 606var (
 607	rotationX, rotationY, rotationZ = randFloat64InRange(0, 2*math.Pi), randFloat64InRange(0, 2*math.Pi), randFloat64InRange(0, 2*math.Pi)
 608	dX, dY, dZ                      = randFloat64InRange(-0.02, 0.02), randFloat64InRange(-0.02, 0.02), randFloat64InRange(-0.02, 0.02)
 609	pause                           = true
 610)
 611
 612func globeAnimation(w *core.Canvas) {
 613	w.SetDraw(func(pc *paint.Painter) {
 614		//pc.Fill.Color = colors.Uniform(colors.Transparent)
 615		//pc.Clear()
 616		sinX, cosX := math.Sin(rotationX), math.Cos(rotationX)
 617		sinY, cosY := math.Sin(rotationY), math.Cos(rotationY)
 618		sinZ, cosZ := math.Sin(rotationZ), math.Cos(rotationZ)
 619
 620		projectPoint := func(x, y, z float64) (float32, float32) {
 621			ry := y*cosX - z*sinX
 622			rz := y*sinX + z*cosX
 623
 624			rx := x*cosY + rz*sinY
 625			//			z = -x*sinY + rz*cosY
 626
 627			x = rx*cosZ - ry*sinZ
 628			y = rx*sinZ + ry*cosZ
 629
 630			screenX := centerX + float32(x)
 631			screenY := centerY + float32(y)
 632			return screenX, screenY
 633		}
 634
 635		pc.Stroke.Color = colors.Scheme.Primary.Base
 636		for lat := -math.Pi / 2; lat <= math.Pi/2; lat += math.Pi / float64(numLatLines) {
 637			first := true
 638			var startX, startY float32
 639			for lon := 0.0; lon <= 2*math.Pi; lon += math.Pi / 100 {
 640				x := radius * math.Cos(lat) * math.Cos(lon)
 641				y := radius * math.Cos(lat) * math.Sin(lon)
 642				z := radius * math.Sin(lat)
 643				screenX, screenY := projectPoint(x, y, z)
 644				if first {
 645					startX, startY = screenX, screenY
 646					first = false
 647				} else {
 648					pc.MoveTo(startX, startY)
 649					pc.LineTo(screenX, screenY)
 650					startX, startY = screenX, screenY
 651				}
 652			}
 653			pc.Draw()
 654		}
 655
 656		pc.Stroke.Color = colors.Scheme.Primary.Base
 657		for lon := 0.0; lon <= 2*math.Pi; lon += math.Pi / float64(numLonLines) {
 658			first := true
 659			var startX, startY float32
 660			for lat := -math.Pi / 2; lat <= math.Pi/2; lat += math.Pi / 100 {
 661				x := radius * math.Cos(lat) * math.Cos(lon)
 662				y := radius * math.Cos(lat) * math.Sin(lon)
 663				z := radius * math.Sin(lat)
 664				screenX, screenY := projectPoint(x, y, z)
 665				if first {
 666					startX, startY = screenX, screenY
 667					first = false
 668				} else {
 669					pc.MoveTo(startX, startY)
 670					pc.LineTo(screenX, screenY)
 671					startX, startY = screenX, screenY
 672				}
 673			}
 674			pc.Draw()
 675		}
 676	})
 677
 678	w.Animate(func(_ *core.Animation) {
 679		if pause {
 680			return
 681		}
 682		mySvg.Update()
 683		rotationX += dX
 684		rotationY += dY
 685		rotationZ += dZ
 686		w.NeedsRender()
 687	})
 688}
 689
 690
 691// ===== ☣.go =====
 692// payment.go
 693package main
 694
 695import (
 696	"bytes"
 697	"encoding/json"
 698	"log"
 699	"net/http"
 700	"net/url"
 701
 702	"cogentcore.org/core/core"
 703)
 704
 705// Injected at build time, e.g.:
 706//   go build -ldflags "-X 'main.stripePK=pk_live_...'"
 707var stripePK string
 708
 709// Base URL for your server API (adjust if different host/port/proto)
 710var serverBase = "https://" + siteName
 711func init() {
 712	serverBase = "https://" + siteName
 713}
 714// Toggle which flow to use:
 715//   true  -> Stripe Checkout Session
 716//   false -> Payment Intent + your hosted /pay page with Stripe Elements
 717const useCheckoutSession = false
 718
 719// Public entry point called from the Checkout button in cart_ui.go
 720func CheckoutWithServer(cart *Cart) {
 721	if stripePK == "" {
 722		log.Println("Stripe public key (stripePK) is not set; pass via ldflags: -X 'main.stripePK=pk_live_...'\nCheckout aborted.")
 723		return
 724	}
 725
 726	var err error
 727	if useCheckoutSession {
 728		err = checkoutViaSession(cart)
 729	} else {
 730		err = checkoutViaPaymentIntent(cart)
 731	}
 732	if err != nil {
 733		log.Println("Checkout error:", err)
 734	}
 735}
 736
 737// ---------- Strategy A: Stripe Checkout Session ----------
 738func checkoutViaSession(cart *Cart) error {
 739	type req struct {
 740		Items []APIItem `json:"items"`
 741	}
 742	type resp struct {
 743		URL   string `json:"url"`
 744		Error string `json:"error,omitempty"`
 745		// (optionally the server may also return "id" for the session)
 746	}
 747
 748	payload := req{Items: cart.ItemsForAPI()}
 749	body, _ := json.Marshal(payload)
 750
 751	endpoint := serverBase + "/create-checkout-session"
 752	rp, err := http.Post(endpoint, "application/json", bytes.NewReader(body))
 753	if err != nil {
 754		return err
 755	}
 756	defer rp.Body.Close()
 757
 758	var out resp
 759	if err := json.NewDecoder(rp.Body).Decode(&out); err != nil {
 760		return err
 761	}
 762	if out.Error != "" {
 763		log.Println("server returned error:", out.Error)
 764		return nil
 765	}
 766	if out.URL == "" {
 767		log.Println("create-checkout-session returned empty url")
 768		return nil
 769	}
 770
 771	openBrowser(out.URL)
 772	return nil
 773}
 774
 775// ---------- Strategy B: Payment Intent + hosted Elements page ----------
 776func checkoutViaPaymentIntent(cart *Cart) error {
 777	type req struct {
 778		Items []APIItem `json:"items"`
 779	}
 780	type resp struct {
 781		ClientSecret string `json:"clientSecret"`
 782		Error        string `json:"error,omitempty"`
 783	}
 784
 785	payload := req{Items: cart.ItemsForAPI()}
 786	body, _ := json.Marshal(payload)
 787
 788	endpoint := serverBase + "/create-payment-intent"
 789	rp, err := http.Post(endpoint, "application/json", bytes.NewReader(body))
 790	if err != nil {
 791		return err
 792	}
 793	defer rp.Body.Close()
 794
 795	var out resp
 796	if err := json.NewDecoder(rp.Body).Decode(&out); err != nil {
 797		return err
 798	}
 799	if out.Error != "" {
 800		log.Println("server returned error:", out.Error)
 801		return nil
 802	}
 803	if out.ClientSecret == "" {
 804		log.Println("create-payment-intent returned empty clientSecret")
 805		return nil
 806	}
 807
 808	// Open your hosted page that mounts Stripe Elements with the client secret.
 809	// If your page expects different param names, tweak here.
 810	payURL := serverBase + "/?" + url.Values{
 811		"client_secret": []string{out.ClientSecret},
 812		"pk":            []string{stripePK},
 813	}.Encode()
 814
 815	openBrowser(payURL)
 816	return nil
 817}
 818
 819// ---------- Open URL using Cogent Core ----------
 820func openBrowser(u string) {
 821	// Cogent Core handles native and WASM correctly:
 822	// - Native: opens system browser
 823	// - WASM: navigates the current window
 824	log.Println(u)
 825	core.TheApp.OpenURL(u)
 826}
 827
 828
 829// ===== ⚛.go =====
 830// Package main ui/⚛.go
 831package main
 832
 833import (
 834	"runtime"
 835	"strings"
 836	"unicode/utf8"
 837	"cogentcore.org/core/base/errors"
 838	"cogentcore.org/core/colors"
 839	"cogentcore.org/core/core"
 840	"cogentcore.org/core/events"
 841	"cogentcore.org/core/htmlcore"
 842	"cogentcore.org/core/styles"
 843	"cogentcore.org/core/styles/abilities"
 844	"cogentcore.org/core/styles/units"
 845	"cogentcore.org/core/text/rich"
 846	"cogentcore.org/core/text/text"
 847	"cogentcore.org/core/tree"
 848	"github.com/0magnet/calvin"
 849)
 850
 851var home *core.Frame
 852var mySvg *core.SVG
 853
 854var (
 855	title = strings.Replace(calvin.AsciiFont(siteName), " ", "\u00A0", -1)
 856	titleWidth int
 857	tagLine = "we have the technology"
 858)
 859
 860
 861func initTitle() {
 862	title = strings.Replace(calvin.AsciiFont(siteName), " ", "\u00A0", -1)
 863
 864	titles := strings.Split(title, "\n")
 865	for i := 0; i < len(titles) && i < 3; i++ {
 866		titles[i] = " " + titles[i]
 867	}
 868
 869	title = strings.Join(titles, "\n")
 870	titleWidth = utf8.RuneCountInString(strings.Split(title, "\n")[0])
 871}
 872
 873func homePage(ctx *htmlcore.Context) bool {
 874	initTitle()
 875	home = core.NewFrame(ctx.BlockParent)
 876	home.Styler(func(s *styles.Style) {
 877		s.Direction = styles.Column
 878		s.Grow.Set(1, 1)
 879		s.CenterAll()
 880	})
 881	home.OnShow(func(_ events.Event) {
 882		home.Update()
 883	})
 884
 885	tree.AddChildAt(home, "", func(w *core.Frame) {
 886		w.Styler(func(s *styles.Style) {
 887			s.Gap.Set(units.Em(0))
 888			s.Grow.Set(1, 0)
 889			if home.SizeClass() == core.SizeCompact {
 890				s.Direction = styles.Column
 891			}
 892		})
 893		w.Maker(func(p *tree.Plan) {
 894			tree.Add(p, func(w *core.Frame) {
 895				w.Styler(func(s *styles.Style) {
 896					s.Direction = styles.Column
 897					s.Text.Align = text.Start
 898					s.Grow.Set(1, 1)
 899				})
 900			})
 901		})
 902	})
 903
 904	tree.AddChild(home, func(w *core.Text) {
 905		w.SetType(core.TextHeadlineLarge).SetText(title)
 906		w.Styler(func(s *styles.Style) {
 907			s.Font.Family = rich.Custom
 908			if runtime.GOOS == "js" {
 909				s.Font.Family = rich.Monospace
 910			}
 911			s.Font.CustomFont = "mononoki"
 912			s.Text.WhiteSpace = text.WhiteSpacePre
 913			s.Min.X = units.Em(float32(titleWidth) * 0.62)
 914			s.Text.LineHeight = 1
 915			s.Gap.Set(units.Em(0))
 916			s.Color = colors.Scheme.Primary.Base
 917		})
 918	})
 919
 920	//svg + canvas animation overlay
 921	tree.AddChild(home, func(w *core.Frame) {
 922		w.Styler(func(s *styles.Style) {
 923			s.Display = styles.Custom
 924			s.SetAbilities(true, abilities.Clickable)
 925			s.Grow.Set(1, 1)
 926		})
 927
 928		w.OnClick(func(_ events.Event) {
 929			pause = !pause
 930			if pause {
 931				core.MessageSnackbar(b, "animation paused")
 932			} else {
 933				core.MessageSnackbar(b, "animation resumed")
 934			}
 935		})
 936		mySvg = core.NewSVG(w)
 937		errors.Log(mySvg.OpenFS(mySVG, "icon.svg")) //nolint
 938		mySvg.Styler(func(s *styles.Style) {
 939			s.Grow.Set(1, 1)
 940		})
 941		c := core.NewCanvas(w)
 942		c.Styler(func(s *styles.Style) {
 943			s.RenderBox = false
 944			s.Grow.Set(1, 1)
 945		})
 946		globeAnimation(c)
 947	})
 948
 949	tree.AddChild(home, func(w *core.Text) {
 950		w.SetType(core.TextTitleLarge).SetText(tagLine)
 951
 952	})
 953	return true
 954}
 955
 956
 957// ===== 🌐.go =====
 958// Package main 🌐.go
 959package main
 960
 961import (
 962	"embed"
 963	"encoding/json"
 964	"io/fs"
 965	"log"
 966	"reflect"
 967	"time"
 968
 969	p "github.com/0magnet/m2/pkg/product"
 970	"github.com/0magnet/calvin"
 971
 972	"github.com/bitfield/script"
 973	"cogentcore.org/core/content"
 974	"cogentcore.org/core/core"
 975	"cogentcore.org/core/htmlcore"
 976	"cogentcore.org/core/icons"
 977	"cogentcore.org/core/styles"
 978	"cogentcore.org/core/text/fonts"
 979	"cogentcore.org/core/text/rich"
 980	"cogentcore.org/core/text/text"
 981	"cogentcore.org/core/tree"
 982	"cogentcore.org/core/yaegicore/coresymbols"
 983)
 984
 985//go:embed mononoki/*.ttf
 986var mononoki embed.FS
 987
 988//go:embed icon.svg
 989var mySVG embed.FS
 990
 991var origin = Origin()
 992var host = Host()
 993var siteName = host
 994
 995var b = core.NewBody(calvin.BlackboardBold(siteName))
 996
 997func main() {
 998	var err error
 999	if siteName == "" {
1000		siteName, err = script.Echo(coreToml).Match("NamePrefix").Replace(`NamePrefix = "`, "").Replace(`"`, "").String()
1001		if err != nil {
1002			log.Fatal(err)
1003		}
1004	}
1005	b = core.NewBody(calvin.BlackboardBold(siteName))
1006	data, err := fs.ReadFile(mySVG, "icon.svg")
1007	if err != nil {
1008		panic("failed to read icon.svg: " + err.Error())
1009	}
1010	core.AppIcon = string(data)
1011	fonts.AddEmbedded(mononoki)
1012
1013	core.TheApp.SetSceneInit(func(sc *core.Scene) {
1014		sc.SetWidgetInit(func(w core.Widget) {
1015			w.AsWidget().Styler(func(s *styles.Style) {
1016				s.Font.Family = rich.Custom
1017				s.Font.CustomFont = "mononoki"
1018				s.Text.LineHeight = 1
1019				s.Text.WhiteSpace = text.WhiteSpacePreWrap
1020			})
1021		})
1022	})
1023	b.Styler(func(s *styles.Style) {
1024		s.Font.Family = rich.Custom
1025		s.Font.CustomFont = "mononoki"
1026		s.Text.LineHeight = 1
1027		s.Text.WhiteSpace = text.WhiteSpacePreWrap
1028	})
1029
1030	ct := content.NewContent(b).SetSource(econtent)
1031	ctx := ct.Context
1032
1033	b.AddTopBar(func(bar *core.Frame) {
1034		tb := core.NewToolbar(bar)
1035		tb.Maker(ct.MakeToolbar)
1036		tb.Maker(func(p *tree.Plan) {
1037			tree.Add(p, func(w *core.Text) {
1038				w.Updater(func() {
1039					w.SetText(time.Now().Format("Mon Jan 2 2006 15:04:05"))
1040				})
1041				go func() {
1042					for range time.Tick(time.Second) {
1043						if !w.IsVisible() {
1044							continue
1045						}
1046						w.AsyncLock()
1047						w.UpdateRender()
1048						w.AsyncUnlock()
1049					}
1050				}()
1051			})
1052			tree.Add(p, func(w *core.Button) {
1053				ctx.LinkButton(w, "about")
1054				w.SetText("About").SetIcon(icons.QuestionMarkFill)
1055			})
1056			tree.Add(p, func(w *core.Button) {
1057				ctx.LinkButton(w, "https://"+siteName+"/")
1058				w.SetText("Interface").SetIcon(icons.HtmlFill)
1059			})
1060		})
1061		tb.AddOverflowMenu(func(m *core.Scene) {
1062			core.NewFuncButton(m).SetFunc(core.SettingsWindow)
1063		})
1064		tb.Styler(func(s *styles.Style) {
1065			s.Font.Family = rich.Monospace
1066			s.Font.CustomFont = "mononoki"
1067			s.Text.LineHeight = 1
1068			s.Text.WhiteSpace = text.WhiteSpacePreWrap
1069		})
1070	})
1071
1072	if coresymbols.Symbols["."] == nil {
1073		coresymbols.Symbols["."] = make(map[string]reflect.Value)
1074	}
1075	coresymbols.Symbols["."]["econtent"] = reflect.ValueOf(econtent)
1076
1077	ctx.ElementHandlers["home-page"] = homePage
1078	ctx.ElementHandlers["about"] = func(ctx *htmlcore.Context) bool {
1079		f := core.NewFrame(ctx.BlockParent)
1080		tree.AddChild(f, func(w *core.Text) {
1081			w.SetType(core.TextTitleLarge).SetText("")
1082		})
1083		return true
1084	}
1085
1086	var products p.Products
1087	if err := json.Unmarshal([]byte(productsJSON), &products); err != nil {
1088		log.Fatalf("Error unmarshaling JSON: %v", err)
1089	}
1090	prodByID := make(map[string]p.Product, len(products))
1091	for _, pr := range products {
1092		prodByID[pr.Partno] = pr
1093	}
1094
1095	SetupCartUI(b, ctx, prodByID)
1096
1097	b.RunMainWindow()
1098	log.Println("exiting")
1099}
1100
1101func inlineParentNode(hctx *htmlcore.Context) tree.Node {
1102	switch v := any(hctx.InlineParent).(type) {
1103	case core.Widget:
1104		return v.AsTree()
1105	case func() core.Widget:
1106		return v().AsTree()
1107	default:
1108		return hctx.BlockParent.AsTree()
1109	}
1110}
1111
1112
1113// ===== 🮕.go =====
1114//Package main 🮕.go
1115package main
1116
1117import (
1118	"fmt"
1119	"strconv"
1120	"sync"
1121	"strings"
1122
1123	p "github.com/0magnet/m2/pkg/product"
1124)
1125
1126type CartItem struct {
1127	Product p.Product `edit:"-"`
1128	Qty     int
1129	// For shipping line: use Product.Partno == "shipping-to|<...>" and Price in Product.Price
1130}
1131
1132type Cart struct {
1133	mu       sync.Mutex
1134	Items    map[string]*CartItem // key: Partno
1135	onChange []func()
1136}
1137
1138func NewCart() *Cart { return &Cart{Items: make(map[string]*CartItem)} }
1139
1140func (c *Cart) OnChange(fn func()) { c.mu.Lock(); c.onChange = append(c.onChange, fn); c.mu.Unlock() }
1141func (c *Cart) notify()             { for _, fn := range c.onChange { fn() } }
1142
1143// ---- Mutators (unlock BEFORE notify) ----
1144func (c *Cart) Add(prod p.Product) {
1145	c.mu.Lock()
1146	if it, ok := c.Items[prod.Partno]; ok {
1147		it.Qty++
1148	} else {
1149		c.Items[prod.Partno] = &CartItem{Product: prod, Qty: 1}
1150	}
1151	c.mu.Unlock()
1152	c.notify()
1153}
1154func (c *Cart) Inc(id string)   { c.mu.Lock(); if it, ok := c.Items[id]; ok { it.Qty++ }; c.mu.Unlock(); c.notify() }
1155func (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() }
1156func (c *Cart) Remove(id string){ c.mu.Lock(); delete(c.Items, id); c.mu.Unlock(); c.notify() }
1157func (c *Cart) Clear()          { c.mu.Lock(); clear(c.Items); c.mu.Unlock(); c.notify() }
1158
1159// ---- Shipping helpers ----
1160const shippingPrefix = "shipping-to|"
1161
1162func (c *Cart) SetShipping(shippingID string, amountCents int) {
1163	// shippingID should start with shippingPrefix and contain the piped fields
1164	if shippingID == "" {
1165		return
1166	}
1167	price := fmt.Sprintf("$%.2f", float64(amountCents)/100.0)
1168	shipProd := p.Product{
1169		Partno: shippingID, // e.g., "shipping-to|Name|Addr|City|State|Zip|Country|Phone"
1170		Name:   "Shipping",
1171		Price:  price,
1172	}
1173	c.mu.Lock()
1174	// remove any existing shipping line
1175	for id := range c.Items {
1176		if len(id) >= len(shippingPrefix) && id[:len(shippingPrefix)] == shippingPrefix {
1177			delete(c.Items, id)
1178		}
1179	}
1180	// add/replace shipping as qty=1
1181	c.Items[shipProd.Partno] = &CartItem{Product: shipProd, Qty: 1}
1182	c.mu.Unlock()
1183	c.notify()
1184}
1185
1186func (c *Cart) HasShipping() bool {
1187	c.mu.Lock()
1188	defer c.mu.Unlock()
1189	for id := range c.Items {
1190		if len(id) >= len(shippingPrefix) && id[:len(shippingPrefix)] == shippingPrefix {
1191			return true
1192		}
1193	}
1194	return false
1195}
1196
1197func (c *Cart) CountNonShipping() int {
1198	c.mu.Lock()
1199	defer c.mu.Unlock()
1200	n := 0
1201	for id, it := range c.Items {
1202		if len(id) >= len(shippingPrefix) && id[:len(shippingPrefix)] == shippingPrefix {
1203			continue
1204		}
1205		n += it.Qty
1206	}
1207	return n
1208}
1209
1210func (c *Cart) RemoveShipping() {
1211	c.mu.Lock()
1212	for id := range c.Items {
1213		if strings.HasPrefix(id, shippingPrefix) {
1214			delete(c.Items, id)
1215			break
1216		}
1217	}
1218	c.mu.Unlock()
1219	c.notify()
1220}
1221
1222// ---- Queries ----
1223func (c *Cart) Count() int {
1224	c.mu.Lock(); defer c.mu.Unlock()
1225	n := 0
1226	for _, it := range c.Items {
1227		n += it.Qty
1228	}
1229	return n
1230}
1231func (c *Cart) TotalCents() int {
1232	c.mu.Lock(); defer c.mu.Unlock()
1233	total := 0
1234	for _, it := range c.Items {
1235		total += priceToCents(it.Product.Price) * it.Qty
1236	}
1237	return total
1238}
1239
1240// For server payload (mirrors your wasm code: "ID + ' X ' + qty")
1241type APIItem struct {
1242	ID     string `json:"id"`
1243	Amount int    `json:"amount"`
1244}
1245
1246func (c *Cart) ItemsForAPI() []APIItem {
1247	c.mu.Lock()
1248	defer c.mu.Unlock()
1249	res := make([]APIItem, 0, len(c.Items))
1250	for id, it := range c.Items {
1251		res = append(res, APIItem{
1252			ID:     fmt.Sprintf("%s X %d", id, it.Qty),
1253			Amount: priceToCents(it.Product.Price) * it.Qty,
1254		})
1255	}
1256	return res
1257}
1258
1259// ---- Money helpers ----
1260func priceToCents(s string) int {
1261	if s == "" { return 0 }
1262	if s[0] == '$' { s = s[1:] }
1263	f, err := strconv.ParseFloat(s, 64)
1264	if err != nil { return 0 }
1265	return int(f*100 + 0.5)
1266}
1267func centsToMoney(c int) string { return fmt.Sprintf("$%d.%02d", c/100, c%100) }
1268
1269
1270// ===== 🮖.go =====
1271//Package main cart_ui.go
1272package main
1273
1274import (
1275	"fmt"
1276	"net/url"
1277	"strings"
1278
1279	p "github.com/0magnet/m2/pkg/product"
1280	"cogentcore.org/core/core"
1281	"cogentcore.org/core/events"
1282	"cogentcore.org/core/htmlcore"
1283	"cogentcore.org/core/icons"
1284	"cogentcore.org/core/styles"
1285	"cogentcore.org/core/styles/units"
1286	"cogentcore.org/core/text/rich"
1287	"cogentcore.org/core/text/text"
1288)
1289
1290// cartRow backs the table rows
1291type cartRow struct {
1292	Item      string `edit:"-"`
1293	UnitPrice string `edit:"-"`
1294	Qty       int
1295	LineTotal string `edit:"-"`
1296	Remove    bool
1297}
1298
1299func SetupCartUI(root *core.Body, ctx *htmlcore.Context, prodByID map[string]p.Product) {
1300	cart := NewCart()
1301
1302	// hydrate from storage on web (no-op elsewhere)
1303	if items, ok := LoadCartState(); ok {
1304		cart.mu.Lock()
1305		cart.Items = items
1306		cart.mu.Unlock()
1307	}
1308
1309	// ===== Header bar (no collapser here anymore) =====
1310	headerBar := core.NewFrame(root)
1311	headerBar.Styler(func(s *styles.Style) {
1312		s.Font.Family = rich.Custom
1313		s.Font.CustomFont = "mononoki"
1314		s.Text.LineHeight = 1
1315		s.Text.WhiteSpace = text.WhiteSpacePreWrap
1316		s.Direction = styles.Row
1317		s.Gap.Set(units.Dp(12), units.Dp(8))
1318		s.Padding.Set(units.Dp(8), units.Dp(8))
1319		s.Border.Radius.Set(units.Dp(6))
1320	})
1321	// ===== Main section (full width with the ONLY collapser) =====
1322	mainSection := core.NewFrame(root)
1323	mainSection.Styler(func(s *styles.Style) {
1324		s.Direction = styles.Column
1325		s.Gap.Set(units.Dp(12), units.Dp(12))
1326		s.Grow.Set(1, 0)
1327		s.Padding.Set(units.Dp(4), units.Dp(4))
1328	})
1329
1330	// The single collapser
1331	col := core.NewCollapser(mainSection)
1332	// Live-updating summary on THIS collapser
1333	summary := core.NewText(col.Summary).SetText("Cart (0) — $0.00")
1334	summary.Updater(func() {
1335		summary.SetText(fmt.Sprintf("Cart (%d) — %s", cart.Count(), centsToMoney(cart.TotalCents())))
1336	})
1337
1338	// Details root we control (full width)
1339	detailsRoot := core.NewFrame(col.Details)
1340	detailsRoot.Styler(func(s *styles.Style) {
1341		s.Direction = styles.Column
1342		s.Gap.Set(units.Dp(12), units.Dp(12))
1343		s.Grow.Set(1, 0)
1344	})
1345
1346	// persistent backing for the table
1347	var tableRows []cartRow
1348	var rowIDs []string // parallel slice to map rows back to cart IDs
1349
1350	// Shipping form state
1351	type shipFormVals struct {
1352		Name, Address, City, State, Zip, Country, Phone string
1353		PriceStr                                        string
1354	}
1355	sf := &shipFormVals{Country: "US"}
1356
1357	// helper: find current shipping line
1358	getShipping := func() (id string, item *CartItem, ok bool) {
1359		for sid, it := range cart.Items {
1360			if strings.HasPrefix(sid, shippingPrefix) {
1361				return sid, it, true
1362			}
1363		}
1364		return "", nil, false
1365	}
1366
1367	// helper: open shipping dialog
1368	openShippingDialog := func(anchor core.Widget) {
1369		if sid, it, ok := getShipping(); ok {
1370			parts := strings.SplitN(sid, "|", 8)
1371			if len(parts) == 8 {
1372				sf.Name = parts[1]
1373				sf.Address = parts[2]
1374				sf.City = parts[3]
1375				sf.State = parts[4]
1376				sf.Zip = parts[5]
1377				sf.Country = parts[6]
1378				sf.Phone = parts[7]
1379			}
1380			if it != nil {
1381				c := priceToCents(it.Product.Price)
1382				sf.PriceStr = centsToMoney(c)[1:]
1383			}
1384		}
1385
1386		d := core.NewBody("Shipping")
1387		d.Styler(func(s *styles.Style) {
1388			s.Gap.Set(units.Dp(10), units.Dp(8))
1389			s.Padding.Set(units.Dp(8), units.Dp(8))
1390		})
1391		core.NewText(d).SetType(core.TextSupporting).SetText("Enter your shipping details:")
1392
1393		tfName := core.NewTextField(d).SetPlaceholder("Full name").SetText(sf.Name)
1394		tfAddr := core.NewTextField(d).SetPlaceholder("Address").SetText(sf.Address)
1395		tfCity := core.NewTextField(d).SetPlaceholder("City").SetText(sf.City)
1396		tfState := core.NewTextField(d).SetPlaceholder("State/Province").SetText(sf.State)
1397		tfZip := core.NewTextField(d).SetPlaceholder("ZIP/Postal").SetText(sf.Zip)
1398		tfCountry := core.NewTextField(d).SetPlaceholder("Country").SetText(sf.Country)
1399		tfPhone := core.NewTextField(d).SetPlaceholder("Phone").SetText(sf.Phone)
1400		tfPrice := core.NewTextField(d).SetPlaceholder("Shipping price (e.g. 7.50)").SetText(sf.PriceStr)
1401
1402		d.AddBottomBar(func(bar *core.Frame) {
1403			d.AddCancel(bar)
1404			d.AddOK(bar).OnClick(func(e events.Event) {
1405				sf.Name = tfName.Text()
1406				sf.Address = tfAddr.Text()
1407				sf.City = tfCity.Text()
1408				sf.State = tfState.Text()
1409				sf.Zip = tfZip.Text()
1410				sf.Country = tfCountry.Text()
1411				sf.Phone = tfPhone.Text()
1412				sf.PriceStr = tfPrice.Text()
1413
1414				shipID := fmt.Sprintf("shipping-to|%s|%s|%s|%s|%s|%s|%s",
1415					sf.Name, sf.Address, sf.City, sf.State, sf.Zip, sf.Country, sf.Phone,
1416				)
1417				v := sf.PriceStr
1418				if v == "" {
1419					v = "0"
1420				}
1421				_, _ = url.ParseQuery("x=" + v)
1422				cents := priceToCents("$" + v)
1423				cart.SetShipping(shipID, cents)
1424			})
1425		})
1426		d.RunDialog(anchor)
1427	}
1428
1429	// Build details every time
1430	detailsRoot.Updater(func() {
1431		if !col.Open {
1432			return
1433		}
1434		detailsRoot.DeleteChildren()
1435
1436		// Table rows from cart (excluding shipping)
1437		tableRows = tableRows[:0]
1438		rowIDs = rowIDs[:0]
1439		for id, it := range cart.Items {
1440			if strings.HasPrefix(id, shippingPrefix) {
1441				continue
1442			}
1443			unit := priceToCents(it.Product.Price)
1444			name := it.Product.Name
1445			if name == "" {
1446				name = id
1447			}
1448			tableRows = append(tableRows, cartRow{
1449				Item:      name,
1450				UnitPrice: centsToMoney(unit),
1451				Qty:       it.Qty,
1452				LineTotal: centsToMoney(unit * it.Qty),
1453				Remove:    false,
1454			})
1455			rowIDs = append(rowIDs, id)
1456		}
1457
1458		// Items TABLE (full width)
1459		tbl := core.NewTable(detailsRoot).SetSlice(&tableRows)
1460		tbl.SetReadOnly(false)
1461		tbl.Styler(func(s *styles.Style) {
1462			s.Grow.Set(1, 0)
1463		})
1464		tbl.OnChange(func(e events.Event) {
1465			for i := range tableRows {
1466				if i >= len(rowIDs) {
1467					continue
1468				}
1469				id := rowIDs[i]
1470				row := tableRows[i]
1471				it := cart.Items[id]
1472				if it == nil {
1473					continue
1474				}
1475				if row.Remove || row.Qty <= 0 {
1476					cart.Remove(id)
1477					continue
1478				}
1479				if row.Qty != it.Qty {
1480					diff := row.Qty - it.Qty
1481					if diff > 0 {
1482						for n := 0; n < diff; n++ {
1483							cart.Inc(id)
1484						}
1485					} else if diff < 0 {
1486						for n := 0; n < -diff; n++ {
1487							cart.Dec(id)
1488						}
1489					}
1490				}
1491			}
1492		})
1493
1494		// Shipping block (below the table)
1495		shipBox := core.NewFrame(detailsRoot)
1496		shipBox.Styler(func(s *styles.Style) {
1497			s.Direction = styles.Column
1498			s.Gap.Set(units.Dp(8), units.Dp(6))
1499			s.Padding.Set(units.Dp(8), units.Dp(8))
1500			s.Border.Radius.Set(units.Dp(6))
1501			s.Border.Width.Set(units.Dp(1))
1502		})
1503
1504		if sid, it, ok := getShipping(); ok {
1505			parts := strings.SplitN(sid, "|", 8)
1506			if len(parts) == 8 {
1507				core.NewText(shipBox).SetText("✔ Shipping to:")
1508				core.NewText(shipBox).SetText(parts[1])
1509				core.NewText(shipBox).SetText(parts[2])
1510				core.NewText(shipBox).SetText(fmt.Sprintf("%s, %s %s", parts[3], parts[4], parts[5]))
1511				core.NewText(shipBox).SetText(parts[6])
1512				core.NewText(shipBox).SetText("Phone: " + parts[7])
1513			} else {
1514				core.NewText(shipBox).SetText("✔ Shipping added")
1515			}
1516			amt := centsToMoney(priceToCents(it.Product.Price))
1517			core.NewText(shipBox).SetText("Shipping: " + amt)
1518		} else {
1519			core.NewText(shipBox).SetText("No shipping set")
1520		}
1521
1522		btns := core.NewFrame(shipBox)
1523		addUpd := core.NewButton(btns)
1524		if cart.HasShipping() {
1525			addUpd.SetText("Edit Shipping").SetIcon(icons.Edit)
1526		} else {
1527			addUpd.SetText("Add Shipping").SetIcon(icons.Box)
1528		}
1529		addUpd.OnClick(func(e events.Event) { openShippingDialog(addUpd) })
1530		if cart.HasShipping() {
1531			core.NewButton(btns).SetText("Remove Shipping").SetIcon(icons.Delete).
1532				OnClick(func(e events.Event) { cart.RemoveShipping() })
1533		}
1534
1535		// Footer: totals + actions
1536		footer := core.NewFrame(detailsRoot)
1537		footer.Styler(func(s *styles.Style) {
1538			s.Direction = styles.Row
1539			s.Justify.Content = styles.SpaceBetween
1540			s.Align.Items = styles.Center
1541			s.Padding.Set(units.Dp(8), units.Dp(8))
1542			s.Gap.Set(units.Dp(8), units.Dp(8))
1543		})
1544
1545		core.NewText(footer).SetText("Total: " + centsToMoney(cart.TotalCents()))
1546		acts := core.NewFrame(footer)
1547		acts.Styler(func(s *styles.Style) {
1548			s.Direction = styles.Row
1549			s.Gap.Set(units.Dp(8), units.Dp(8))
1550		})
1551		core.NewButton(acts).SetText("Clear Cart").SetIcon(icons.Delete).OnClick(func(e events.Event) {
1552			cart.Clear()
1553		})
1554		checkoutBtn := core.NewButton(acts).SetText("Checkout").SetIcon(icons.ShoppingCart)
1555		checkoutBtn.Updater(func() {
1556			enabled := cart.CountNonShipping() > 0 && cart.HasShipping()
1557			checkoutBtn.SetEnabled(enabled)
1558		})
1559		checkoutBtn.OnClick(func(e events.Event) {
1560			StartCheckout(cart, checkoutBtn)
1561		})
1562	})
1563
1564	// Initial paint and reactive updates
1565	detailsRoot.OnShow(func(_ events.Event) { detailsRoot.Update() })
1566	cart.OnChange(func() {
1567		core.TheApp.RunOnMain(func() {
1568			detailsRoot.Update()
1569			summary.Update()
1570		})
1571	})
1572	cart.OnChange(func() { SaveCartState(cart) })
1573
1574	// HTML custom element handler
1575	ctx.ElementHandlers["my-button"] = func(hctx *htmlcore.Context) bool {
1576		id := htmlcore.GetAttr(hctx.Node, "id")
1577		pr, ok := prodByID[id]
1578		if !ok {
1579		 return false
1580		}
1581		parent := inlineParentNode(hctx)
1582		core.NewButton(parent).SetText("add to cart").SetIcon(icons.Add).OnClick(func(e events.Event) { cart.Add(pr) })
1583		return true
1584	}
1585}
1586
1587