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