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