1// ===== cart.go =====
2//go:build js && wasm
3
4// Package main complete.go
5package main
6
7import (
8 "encoding/json"
9 "fmt"
10 "log"
11 "strconv"
12 "strings"
13 "syscall/js"
14
15 "github.com/0magnet/m2/pkg/storepage"
16)
17
18func updateCartDisplayWrapper(this js.Value, args []js.Value) interface{} {
19 updateCartDisplay()
20 return nil
21}
22
23func saveCart() {
24 cartJSON, err := json.Marshal(cart)
25 if err != nil {
26 log.Println(wasmName+":", "Error saving cart:", err)
27 return
28 }
29 js.Global().Get("localStorage").Call("setItem", "cartItems", string(cartJSON))
30 updateCartDisplay()
31}
32
33// addToCart is called from Go rather than registered with js.FuncOf, so it
34// keeps the callback shape its callers build but returns nothing.
35//
36// The guard reads three arguments, not two: qty is args[2]. Checking for two
37// and then indexing the third is an out-of-range panic for any caller that
38// passes exactly two, which is what the check was there to prevent.
39func addToCart(_ js.Value, args []js.Value) {
40 if len(args) < 3 {
41 log.Println(wasmName+":", "addToCart: missing arguments")
42 return
43 }
44 var cartItem item
45 index := -1
46 id := args[0].String()
47 qty := args[2].Int()
48 if qty == 0 {
49 qty = 1
50 }
51 amount := int(args[1].Float()) * qty
52 for i := range cart {
53 if strings.Split(cart[i].ID, "|")[0] == strings.Split(id, "|")[0] {
54 index = i
55 }
56 }
57 if index > -1 {
58 // update shipping
59 if storepage.IsShippingID(cart[index].ID) {
60 cart[index].ID = id
61 cart[index].Qty = 1
62 cart[index].Amount = amount
63 } else {
64 cart[index].Qty = cart[index].Qty + qty
65 cart[index].Amount = cart[index].Amount + amount
66 }
67 } else {
68 cartItem = item{
69 ID: id,
70 Amount: amount,
71 Qty: qty,
72 }
73 cart = append(cart, cartItem)
74 }
75 saveCart()
76}
77
78func addUnToCart(this js.Value, args []js.Value) interface{} {
79 if len(args) < 2 {
80 return "Error: Missing arguments"
81 }
82 id := args[0].String()
83 price := args[1].Float()
84 quantityInput := doc.Call("getElementById", fmt.Sprintf("qty-%s", id))
85 if !quantityInput.Truthy() {
86 log.Println(wasmName+":", "Error: Quantity input not found for item", id)
87 return nil
88 }
89 quantity, err := strconv.Atoi(quantityInput.Get("value").String())
90 if err != nil || quantity < 1 {
91 quantity = 1
92 }
93
94 addToCart(js.Value{}, []js.Value{
95 js.ValueOf(id),
96 js.ValueOf(int(price * 100)),
97 js.ValueOf(quantity),
98 })
99 return nil
100}
101
102func removeFromCart(this js.Value, inputs []js.Value) interface{} {
103 id := inputs[0].String()
104 newCart := []item{}
105 for _, m := range cart {
106 if m.ID != id {
107 newCart = append(newCart, m)
108 }
109 }
110 cart = newCart
111 saveCart()
112 return nil
113}
114
115func loadCart() {
116 storedCart := js.Global().Get("localStorage").Call("getItem", "cartItems")
117 if !storedCart.IsUndefined() && !storedCart.IsNull() {
118 err := json.Unmarshal([]byte(storedCart.String()), &cart)
119 if err != nil {
120 log.Println(`can't unmarshal cart from local storage`)
121 cart = []item{}
122 }
123 }
124}
125
126func emptyCart(this js.Value, inputs []js.Value) interface{} {
127 js.Global().Get("localStorage").Call("removeItem", "cartItems")
128 cart = []item{}
129 updateCartDisplay()
130 return nil
131}
132
133func clearAll(this js.Value, inputs []js.Value) interface{} {
134 js.Global().Get("localStorage").Call("clear")
135 cart = []item{}
136 updateCartDisplay()
137 return nil
138}
139
140func updateCartDisplay() {
141 cartContainer := doc.Call("getElementById", "cart-items")
142 totalPriceElement := doc.Call("getElementById", "total-price")
143 table := cartContainer.Call("querySelector", "table")
144 if table.IsNull() {
145 table = doc.Call("createElement", "table")
146 thead := doc.Call("createElement", "thead")
147 thead.Set("innerHTML", `<tr><th>Item</th><th>Price</th><th>Quantity</th><th>Actions</th></tr>`)
148 table.Call("appendChild", thead)
149 tbody := doc.Call("createElement", "tbody")
150 tbody.Set("id", "cart-tbody")
151 table.Call("appendChild", tbody)
152 cartContainer.Call("appendChild", table)
153 }
154 tbody := doc.Call("getElementById", "cart-tbody")
155 tbody.Set("innerHTML", "")
156
157 total := 0
158 hasShipping := false
159 for _, m := range cart {
160 total += m.Amount
161 row := doc.Call("createElement", "tr")
162
163 row.Set("innerHTML", fmt.Sprintf(`<td>%s</td><td>$%.2f</td><td>%s</td><td><button onclick='removeFromCart("%s")'>Remove</button></td>`,
164 func() string {
165 parts := strings.Split(m.ID, "|")
166 if len(parts) < 8 {
167 return m.ID
168 }
169 hasShipping = true
170 return fmt.Sprintf("%s:<br>%s<br>%s<br>%s, %s %s<br>%s<br>%s", parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6], parts[7])
171 }(),
172 float64(m.Amount)/100,
173 func() string {
174 if len(strings.Split(m.ID, "|")) == 8 {
175 return ""
176 }
177 return fmt.Sprintf(`<input type='number' value='%d' min='1' onchange='updateItemQuantity("%s", this.value)'>`, m.Qty, m.ID)
178 }(),
179 m.ID,
180 ))
181 tbody.Call("appendChild", row)
182 }
183 totalPriceElement.Set("textContent", fmt.Sprintf("Total: $%.2f", float64(total)/100))
184
185 checkoutbutton := doc.Call("getElementById", "checkout-button")
186 if !checkoutbutton.Truthy() {
187 return
188 }
189
190 if len(cart) > 1 && hasShipping {
191 checkoutbutton.Call("removeAttribute", "disabled")
192 } else {
193 checkoutbutton.Call("setAttribute", "disabled", "true")
194 }
195}
196
197func updateItemQuantity(this js.Value, args []js.Value) interface{} {
198 id := args[0].String()
199 qty, err := strconv.Atoi(args[1].String())
200 if err != nil {
201 log.Println(err)
202 }
203 for i := range cart {
204 if cart[i].ID == id {
205 unitPrice := cart[i].Amount / cart[i].Qty
206 cart[i].Qty = qty
207 cart[i].Amount = unitPrice * qty
208 break
209 }
210 }
211 saveCart()
212 return nil
213}
214
215
216// ===== checkout.go =====
217//go:build js && wasm
218
219package main
220
221import (
222 "encoding/json"
223 "fmt"
224 "log"
225 "strconv"
226 "syscall/js"
227
228 "github.com/0magnet/m2/pkg/storepage"
229)
230
231func addShippingInfo(this js.Value, args []js.Value) interface{} {
232 event := args[0]
233 form := args[1]
234 event.Call("preventDefault")
235 getFormValue := func(name string) string {
236 return form.Call("querySelector", fmt.Sprintf("[name='%s']", name)).Get("value").String()
237 }
238 // Read by the shared field list, so the control names here and the ones
239 // the form was generated from are one list. The cart line's field order
240 // is storepage's too; this no longer spells it out.
241 var sh storepage.Shipping
242 for _, f := range storepage.ShippingFields {
243 if f.Kind != storepage.FieldMoney {
244 sh.Set(f.Name, getFormValue(f.Name))
245 }
246 }
247 priceStr := getFormValue("shipping-price")
248 price, err := strconv.ParseFloat(priceStr, 64)
249 if err != nil {
250 // This was price = 0.0, which quietly put free shipping in the cart
251 // whenever the amount could not be read. The terminal refused the
252 // same input; refusing is the answer that agrees.
253 log.Println(wasmName+":", "Error: Failed to parse shipping price")
254 reportShipping(form, "shipping-price", "shipping amount is not a number")
255 return false
256 }
257 sh.Cents = int(price * 100)
258 if msg := sh.Validate(); msg != "" {
259 log.Println(wasmName+":", msg)
260 field := "shipping-price"
261 if sh.Email == "" || !storepage.ValidEmail(sh.Email) {
262 field = "shipping-email"
263 }
264 reportShipping(form, field, msg)
265 return false
266 }
267
268 addToCart(js.Value{}, []js.Value{
269 js.ValueOf(sh.ID()),
270 js.ValueOf(sh.Cents),
271 js.ValueOf(1),
272 })
273 return false
274}
275
276// reportShipping says why a shipping line was refused, on the field it is
277// about, where the browser already shows its own min= and required messages
278// — so a refusal from here looks like a refusal from the form itself.
279func reportShipping(form js.Value, name, msg string) {
280 if !form.Truthy() {
281 return
282 }
283 el := form.Call("querySelector", "[name='"+name+"']")
284 if !el.Truthy() {
285 return
286 }
287 el.Call("setCustomValidity", msg)
288 el.Call("reportValidity")
289 // Cleared on the next edit, or the message sticks to a field the reader
290 // has since corrected and the form can never be submitted again.
291 var clear js.Func
292 clear = js.FuncOf(func(js.Value, []js.Value) any {
293 el.Call("setCustomValidity", "")
294 clear.Release()
295 return nil
296 })
297 el.Call("addEventListener", "input", clear, map[string]any{"once": true})
298}
299
300var (
301 elements js.Value
302 stripeValue js.Value
303 stripe js.Value
304 checkoutStripe = doc.Call("getElementById", "stripecheckout")
305)
306
307// shippingProblem says why the cart cannot be paid for yet, or "" when it
308// can: it needs a shipping line, and that line an email to confirm the order
309// to. The server refuses the same carts; this says so before a dialog opens.
310func shippingProblem() string {
311 for _, it := range cart {
312 if !storepage.IsShippingID(it.ID) {
313 continue
314 }
315 sh, ok := storepage.ParseShipping(it.ID)
316 if !ok || !storepage.ValidEmail(sh.Email) {
317 return "Please add an email address under Add Shipping Info — it is where your order confirmation goes."
318 }
319 return ""
320 }
321 return "Please fill in Add Shipping Info before checking out."
322}
323
324func goToCheckout(this js.Value, args []js.Value) any {
325 if msg := shippingProblem(); msg != "" {
326 js.Global().Call("alert", msg)
327 return nil
328 }
329 if stripeValue.IsUndefined() {
330 log.Println(`js.Global().Get("Stripe")`)
331 stripeValue = js.Global().Get("Stripe")
332 if stripeValue.IsUndefined() {
333 log.Println(`Stripe is undefined, attempting to load Stripe.js`)
334
335 doc := js.Global().Get("document")
336 head := doc.Call("querySelector", "head")
337 script := doc.Call("createElement", "script")
338 script.Set("src", "https://js.stripe.com/v3/")
339 script.Set("defer", true)
340
341 done := make(chan bool)
342 script.Call("addEventListener", "load", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
343 log.Println(wasmName+":", "Stripe.js script has been loaded")
344 done <- true
345 return nil
346 }))
347
348 head.Call("appendChild", script)
349
350 <-done
351
352 stripeValue = js.Global().Get("Stripe")
353 if stripeValue.IsUndefined() {
354 log.Println(wasmName+":", "Failed to load Stripe.js")
355 return nil
356 }
357 }
358 }
359
360 log.Println(wasmName+":", "Stripe.js loaded successfully")
361
362 if stripe.IsUndefined() {
363 log.Println(wasmName+":", "Invoking Stripe")
364 stripe = stripeValue.Invoke(stripePK)
365 if stripe.IsUndefined() {
366 log.Println(wasmName+":", "Failed to invoke Stripe")
367 return nil
368 }
369 }
370
371 log.Println(wasmName+":", "Stripe initialized")
372 checkoutStripe = doc.Call("getElementById", "stripecheckout")
373 if checkoutStripe.IsUndefined() {
374 log.Println(wasmName+":", "element with ID stripecheckout not found")
375 }
376
377 checkoutStripe.Call("showModal")
378 log.Println(wasmName+":", "initializePayment()")
379 initializePayment()
380
381 return nil
382}
383
384func cancelCheckout(this js.Value, args []js.Value) any {
385 log.Println(wasmName+":", "Canceling checkout ; closing dialog")
386 checkoutStripe.Call("close")
387 updateCartDisplay()
388 return nil
389}
390
391func initializePayment() {
392 type cItem struct {
393 ID string `json:"id"`
394 Amount int `json:"amount"`
395 }
396 type checkout struct {
397 Items []cItem `json:"items"`
398 }
399 payload := checkout{
400 Items: func() []cItem {
401 var items []cItem
402 for _, it := range cart {
403 items = append(items, cItem{ID: it.ID + " X " + strconv.Itoa(it.Qty), Amount: it.Amount})
404 }
405 return items
406 }(),
407 }
408 payloadJSON, err := json.Marshal(payload)
409 if err != nil {
410 log.Println(wasmName+":", "Error marshaling JSON:", err)
411 return
412 }
413 fetchInit := map[string]interface{}{
414 "method": "POST",
415 "headers": map[string]interface{}{
416 "Content-Type": "application/json",
417 },
418 "body": string(payloadJSON),
419 }
420
421 log.Println(wasmName+":", "fetch /create-payment-intent")
422 js.Global().Call("fetch", "/create-payment-intent", js.ValueOf(fetchInit)).
423 Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
424 response := args[0]
425 log.Println(wasmName+":", "got response from fetch /create-payment-intent")
426 if !response.Get("ok").Bool() {
427 log.Println(wasmName+":", "Fetch request failed with status:", response.Get("status").Int())
428 showMessage("Failed to create payment intent: " + response.Get("status").String())
429 return nil
430 }
431 response.Call("json").Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
432 clientSecret := args[0].Get("clientSecret").String()
433 log.Println(wasmName+":", "Client secret received:", clientSecret)
434 setupStripeElements(clientSecret)
435 return nil
436 })).Call("catch", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
437 log.Println(wasmName+":", "Error parsing JSON response:", args[0])
438 showMessage("Failed to parse payment intent response.")
439 return nil
440 }))
441 return nil
442 })).
443 Call("catch", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
444 log.Println(wasmName+":", "Error in fetch request:", args[0])
445 showMessage("Failed to communicate with the server.")
446 return nil
447 }))
448}
449
450func setupStripeElements(clientSecret string) {
451 elements = stripe.Call("elements", map[string]interface{}{
452 "clientSecret": clientSecret,
453 })
454 if elements.IsUndefined() {
455 log.Println(wasmName+":", "Failed to initialize Stripe Elements")
456 showMessage("Failed to initialize payment elements.")
457 return
458 }
459 paymentElement := elements.Call("create", "payment", map[string]interface{}{
460 "layout": "tabs",
461 })
462 if paymentElement.IsUndefined() {
463 log.Println(wasmName+":", "Failed to create payment element")
464 showMessage("Failed to create payment element.")
465 return
466 }
467 paymentElement.Call("mount", "#payment-element")
468 submitButton := doc.Call("getElementById", "submit")
469 submitButton.Call("addEventListener", "click", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
470 args[0].Call("preventDefault")
471 showSpinner(true)
472 confirmPayment(clientSecret)
473 return nil
474 }))
475}
476
477func confirmPayment(clientSecret string) {
478
479 windowLocation := js.Global().Get("window").Get("location")
480 protocol := windowLocation.Get("protocol").String()
481 hostname := windowLocation.Get("hostname").String()
482 port := windowLocation.Get("port").String()
483
484 baseURL := protocol + "//" + hostname
485 if port != "" {
486 baseURL += ":" + port
487 }
488 // path := windowLocation.Get("pathname").String()
489 // baseURL += strings.Split(path, "?")[0]
490 // log.Println(wasmName+":","return url ", baseURL)
491
492 returnURL := baseURL + "/complete"
493 returnURL += "?payment_intent=" + clientSecret // + "#complete"
494 log.Println(wasmName+":", "Return URL for payment:", returnURL)
495
496 stripe.Call("confirmPayment", map[string]interface{}{
497 "elements": elements,
498 "confirmParams": map[string]interface{}{
499 "return_url": returnURL,
500 },
501 }).Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
502 result := args[0]
503 if result.Get("error").IsUndefined() {
504 log.Println(wasmName+":", "Payment successful:", result)
505 showMessage("Payment successful! Thank you for your order.")
506 } else {
507 log.Println(wasmName+":", "Payment error:", result.Get("error").Get("message").String())
508 showMessage("Payment failed: " + result.Get("error").Get("message").String())
509 }
510
511 showSpinner(false)
512 return nil
513 }))
514}
515
516func showMessage(message string) {
517 messageElement := doc.Call("getElementById", "payment-message")
518 messageElement.Set("innerText", message)
519 messageElement.Set("className", "")
520}
521
522func showSpinner(isLoading bool) {
523 spinner := doc.Call("getElementById", "spinner")
524 buttonText := doc.Call("getElementById", "button-text")
525
526 if isLoading {
527 spinner.Set("className", "")
528 buttonText.Set("className", "hidden")
529 } else {
530 spinner.Set("className", "hidden")
531 buttonText.Set("className", "")
532 }
533}
534
535
536// ===== complete.go =====
537//go:build js && wasm
538
539// Package main complete.go
540package main
541
542import (
543 "encoding/json"
544 "log"
545 "syscall/js"
546)
547
548func completeLogic() {
549 initializeStripe()
550}
551
552func initializeStripe() {
553 if stripeValue.IsUndefined() {
554 log.Println(`js.Global().Get("Stripe")`)
555 stripeValue = js.Global().Get("Stripe")
556 if stripeValue.IsUndefined() {
557 log.Println(`Stripe is undefined, attempting to load Stripe.js`)
558
559 doc := js.Global().Get("document")
560 head := doc.Call("querySelector", "head")
561 script := doc.Call("createElement", "script")
562 script.Set("src", "https://js.stripe.com/v3/")
563 script.Set("defer", true)
564
565 done := make(chan bool)
566 script.Call("addEventListener", "load", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
567 log.Println(wasmName+":", "Stripe.js script has been loaded")
568 done <- true
569 return nil
570 }))
571
572 head.Call("appendChild", script)
573
574 <-done
575
576 stripeValue = js.Global().Get("Stripe")
577 if stripeValue.IsUndefined() {
578 log.Println(wasmName+":", "Failed to load Stripe.js")
579 return
580 }
581 }
582 }
583
584 log.Println(wasmName+":", "Stripe.js loaded successfully")
585
586 if stripe.IsUndefined() {
587 log.Println(wasmName+":", "Invoking Stripe")
588 stripe = stripeValue.Invoke(stripePK)
589 if stripe.IsUndefined() {
590 log.Println(wasmName+":", "Failed to invoke Stripe")
591 return
592 }
593 }
594
595 log.Println(wasmName+":", "Stripe initialized")
596 checkStatus()
597}
598
599var (
600 successIcon = `<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
601 <path fill-rule="evenodd" clip-rule="evenodd" d="M15.4695 0.232963C15.8241 0.561287 15.8454 1.1149 15.5171 1.46949L6.14206 11.5945C5.97228 11.7778 5.73221 11.8799 5.48237 11.8748C5.23253 11.8698 4.99677 11.7582 4.83452 11.5681L0.459523 6.44311C0.145767 6.07557 0.18937 5.52327 0.556912 5.20951C0.924454 4.89575 1.47676 4.93936 1.79051 5.3069L5.52658 9.68343L14.233 0.280522C14.5613 -0.0740672 15.1149 -0.0953599 15.4695 0.232963Z" fill="white"/>
602 </svg>`
603
604 errorIcon = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
605 <path fill-rule="evenodd" clip-rule="evenodd" d="M1.25628 1.25628C1.59799 0.914573 2.15201 0.914573 2.49372 1.25628L8 6.76256L13.5063 1.25628C13.848 0.914573 14.402 0.914573 14.7437 1.25628C15.0854 1.59799 15.0854 2.15201 14.7437 2.49372L9.23744 8L14.7437 13.5063C15.0854 13.848 15.0854 14.402 14.7437 14.7437C14.402 15.0854 13.848 15.0854 13.5063 14.7437L8 9.23744L2.49372 14.7437C2.15201 15.0854 1.59799 15.0854 1.25628 14.7437C0.914573 14.402 0.914573 13.848 1.25628 13.5063L6.76256 8L1.25628 2.49372C0.914573 2.15201 0.914573 1.59799 1.25628 1.25628Z" fill="white"/>
606 </svg>`
607
608 infoIcon = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
609 <path fill-rule="evenodd" clip-rule="evenodd" d="M10 1.5H4C2.61929 1.5 1.5 2.61929 1.5 4V10C1.5 11.3807 2.61929 12.5 4 12.5H10C11.3807 12.5 12.5 11.3807 12.5 10V4C12.5 2.61929 11.3807 1.5 10 1.5ZM4 0C1.79086 0 0 1.79086 0 4V10C0 12.2091 1.79086 14 4 14H10C12.2091 14 14 12.2091 14 10V4C14 1.79086 12.2091 0 10 0H4Z" fill="white"/>
610 <path fill-rule="evenodd" clip-rule="evenodd" d="M5.25 7C5.25 6.58579 5.58579 6.25 6 6.25H7.25C7.66421 6.25 8 6.58579 8 7V10.5C8 10.9142 7.66421 11.25 7.25 11.25C6.83579 11.25 6.5 10.9142 6.5 10.5V7.75H6C5.58579 7.75 5.25 7.41421 5.25 7Z" fill="white"/>
611 <path d="M5.75 4C5.75 3.31075 6.31075 2.75 7 2.75C7.68925 2.75 8.25 3.31075 8.25 4C8.25 4.68925 7.68925 5.25 7 5.25C6.31075 5.25 5.75 4.68925 5.75 4Z" fill="white"/>
612 </svg>`
613)
614
615func setErrorState() {
616 js.Global().Get("document").Call("querySelector", "#status-icon").Set("style", map[string]interface{}{"backgroundColor": "#DF1B41"})
617 js.Global().Get("document").Call("querySelector", "#status-icon").Set("innerHTML", errorIcon)
618 js.Global().Get("document").Call("querySelector", "#status-text").Set("textContent", "Something went wrong, please try again.")
619 js.Global().Get("document").Call("querySelector", "#details-table").Call("classList").Call("add", "hidden")
620 js.Global().Get("document").Call("querySelector", "#view-details").Call("classList").Call("add", "hidden")
621}
622
623func checkStatus() {
624 clientSecret := js.Global().Get("URLSearchParams").New(js.Global().Get("window").Get("location").Get("search")).Call("get", "payment_intent_client_secret").String()
625
626 if clientSecret == "" {
627 setErrorState()
628 return
629 }
630
631 if stripe.IsUndefined() {
632 log.Println(wasmName+":", "Stripe is not initialized")
633 setErrorState()
634 return
635 }
636
637 stripe.Call("retrievePaymentIntent", clientSecret).Call("then", js.FuncOf(func(this js.Value, p []js.Value) interface{} {
638 paymentIntent := p[0].Get("paymentIntent")
639 setPaymentDetails(paymentIntent)
640 return nil
641 })).Call("catch", js.FuncOf(func(this js.Value, p []js.Value) interface{} {
642 setErrorState()
643 return nil
644 }))
645}
646
647func getAllLocalStorageData() map[string]interface{} {
648 localStorage := js.Global().Get("localStorage")
649 keys := js.Global().Get("Object").Call("keys", localStorage)
650 data := make(map[string]interface{})
651
652 for i := 0; i < keys.Length(); i++ {
653 key := keys.Index(i).String()
654 value := localStorage.Call("getItem", key).String()
655 var parsedValue interface{}
656 err := json.Unmarshal([]byte(value), &parsedValue)
657 if err != nil {
658 parsedValue = value // If not JSON, store raw value
659 }
660 data[key] = parsedValue
661 }
662 return data
663}
664
665func submitOrder(localStorageData map[string]interface{}, paymentIntentId string) {
666 orderData := map[string]interface{}{
667 "localStorageData": localStorageData,
668 "paymentIntentId": paymentIntentId,
669 }
670
671 body, err := json.Marshal(orderData)
672 if err != nil {
673 log.Println(wasmName+":", "Error marshaling order data:", err)
674 return
675 }
676
677 fetch := js.Global().Get("fetch")
678 if fetch.IsUndefined() {
679 log.Println(wasmName+":", "Fetch API is not available")
680 return
681 }
682
683 options := map[string]interface{}{
684 "method": "POST",
685 "headers": map[string]interface{}{
686 "Content-Type": "application/json",
687 },
688 "body": string(body),
689 }
690
691 fetch.Invoke("/submit-order", js.ValueOf(options)).Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
692 response := args[0]
693 if !response.Get("ok").Bool() {
694 response.Call("text").Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
695 errText := args[0].String()
696 log.Println(wasmName+":", "Order submit failed:", errText)
697 js.Global().Call("alert", "Order submission failed:\n"+errText)
698 return nil
699 }))
700 return nil
701 }
702 response.Call("json").Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
703 data := args[0]
704 log.Println(wasmName+":", "Order submitted successfully:", data)
705 return nil
706 }))
707 return nil
708 }))
709}
710
711func setPaymentDetails(intent js.Value) {
712 // Every path through the switch below sets this, including its default,
713 // so there is nothing to fall back to. iconColor and icon do fall back:
714 // the cases that only change the wording leave them red.
715 var statusText string
716 iconColor := "#DF1B41"
717 icon := errorIcon
718
719 if !intent.IsUndefined() {
720 intentStatus := intent.Get("status").String()
721 intentID := intent.Get("id").String()
722
723 allLocalStorageData := getAllLocalStorageData()
724
725 switch intentStatus {
726 case "succeeded":
727 statusText = "Payment succeeded"
728 iconColor = "#30B130"
729 icon = successIcon
730 if len(allLocalStorageData) > 0 {
731 submitOrder(allLocalStorageData, intentID)
732 } else {
733 log.Println(wasmName+":", "No data found in localStorage; order not submitted.")
734 }
735 case "processing":
736 statusText = "Your payment is processing."
737 iconColor = "#6D6E78"
738 icon = infoIcon
739 if len(allLocalStorageData) > 0 {
740 submitOrder(allLocalStorageData, intentID)
741 } else {
742 log.Println(wasmName+":", "No data found in localStorage; order not submitted.")
743 }
744 case "requires_payment_method":
745 statusText = "Your payment was not successful, please try again."
746 default:
747 statusText = "Unknown payment status."
748 }
749
750 // Update the status icon, text, and links
751 js.Global().Get("document").Call("querySelector", "#status-icon").Set("style", map[string]interface{}{"backgroundColor": iconColor})
752 js.Global().Get("document").Call("querySelector", "#status-icon").Set("innerHTML", icon)
753 js.Global().Get("document").Call("querySelector", "#status-text").Set("textContent", statusText)
754 js.Global().Get("document").Call("querySelector", "#intent-id").Set("textContent", intentID)
755 js.Global().Get("document").Call("querySelector", "#intent-status").Set("textContent", intentStatus)
756 js.Global().Get("document").Call("querySelector", "#view-details").Set("href", "https://dashboard.stripe.com/payments/"+intentID)
757
758 // Update the "Order Details" link with the paymentIntent ID
759 orderDetailsLink := js.Global().Get("document").Call("querySelector", "#order-details-link")
760 orderDetailsLink.Set("href", "/order/"+intentID)
761 orderDetailsLink.Set("onclick", nil) // Allow default behavior (navigation)
762
763 } else {
764 setErrorState()
765 }
766}
767
768
769// ===== crypto.go =====
770//go:build js && wasm
771
772package main
773
774// Paying in coin from the page.
775//
776// The same /create-invoice endpoint the terminal uses (pkg/tui/crypto.go);
777// this only renders what the server decided. The server validates every
778// line against its own catalog, prices the order, and allocates the
779// address — nothing here is trusted with any of that.
780
781import (
782 "encoding/json"
783 "log"
784 "strconv"
785 "syscall/js"
786)
787
788// invoiceView mirrors what pkg/web/invoice.go hands a client.
789type invoiceView struct {
790 ID string `json:"id"`
791 Coin string `json:"coin"`
792 Address string `json:"address"`
793 Amount string `json:"amount"`
794 URI string `json:"uri"`
795 Status string `json:"status"`
796 Paid string `json:"paid"`
797 Outstanding string `json:"outstanding"`
798 TxID string `json:"txid"`
799 Error string `json:"error"`
800}
801
802var coinInvoiceID string
803
804// payWithCoin asks the store for an invoice and shows it in the checkout
805// dialog, then polls until it settles.
806func payWithCoin(js.Value, []js.Value) any {
807 if len(cart) == 0 {
808 showMessage("Your cart is empty.")
809 return nil
810 }
811 if msg := shippingProblem(); msg != "" {
812 js.Global().Call("alert", msg)
813 return nil
814 }
815 type cItem struct {
816 ID string `json:"id"`
817 Amount int `json:"amount"`
818 }
819 var items []cItem
820 for _, it := range cart {
821 items = append(items, cItem{ID: it.ID + " X " + strconv.Itoa(it.Qty), Amount: it.Amount})
822 }
823 body, err := json.Marshal(map[string]any{"coin": "SKY", "items": items})
824 if err != nil {
825 showMessage("Could not build the request.")
826 return nil
827 }
828
829 checkoutStripe = doc.Call("getElementById", "stripecheckout")
830 checkoutStripe.Call("showModal")
831 setCoinBody("<p>Asking the store for an address…</p>")
832
833 fetchJSON("/create-invoice", string(body), func(inv *invoiceView, err error) {
834 if err != nil {
835 setCoinBody("<p>Could not create the invoice: " + err.Error() + "</p>")
836 return
837 }
838 coinInvoiceID = inv.ID
839 renderCoinInvoice(inv)
840 pollCoinInvoice()
841 })
842 return nil
843}
844
845// pollCoinInvoice re-reads the invoice every ten seconds while the dialog
846// is open. The server polls the chain when asked.
847func pollCoinInvoice() {
848 js.Global().Call("setTimeout", js.FuncOf(func(js.Value, []js.Value) any {
849 if coinInvoiceID == "" || !checkoutStripe.Get("open").Bool() {
850 return nil
851 }
852 fetchJSON("/invoice/"+coinInvoiceID, "", func(inv *invoiceView, err error) {
853 if err != nil {
854 pollCoinInvoice()
855 return
856 }
857 renderCoinInvoice(inv)
858 if inv.Status != "paid" && inv.Status != "expired" {
859 pollCoinInvoice()
860 }
861 })
862 return nil
863 }), 10000)
864}
865
866func renderCoinInvoice(inv *invoiceView) {
867 h := "<p>Send <b>" + inv.Amount + " " + inv.Coin + "</b> to:</p>" +
868 "<p style='word-break:break-all'><code>" + inv.Address + "</code></p>" +
869 "<p><a href='" + inv.URI + "'>open in a wallet</a></p>"
870 switch inv.Status {
871 case "paid":
872 h += "<p><b>Paid — thank you.</b> Your order is printing.</p>"
873 if inv.TxID != "" {
874 h += "<p style='word-break:break-all'><small>txid " + inv.TxID + "</small></p>"
875 }
876 emptyCart(js.Value{}, nil)
877 case "underpaid":
878 h += "<p>Part paid: " + inv.Paid + " received, <b>" + inv.Outstanding + "</b> still owed.</p>"
879 case "expired":
880 h += "<p>The quote expired. Close this and start again for a fresh price.</p>"
881 default:
882 h += "<p>Waiting for the payment to confirm…</p>"
883 }
884 setCoinBody(h)
885}
886
887func setCoinBody(html string) {
888 el := doc.Call("getElementById", "checkout-container")
889 if el.Truthy() {
890 el.Set("innerHTML", html)
891 }
892}
893
894// fetchJSON POSTs (or GETs, with an empty body) and decodes an invoice.
895func fetchJSON(path, body string, done func(*invoiceView, error)) {
896 init := map[string]any{}
897 if body != "" {
898 init["method"] = "POST"
899 init["headers"] = map[string]any{"Content-Type": "application/json"}
900 init["body"] = body
901 }
902 js.Global().Call("fetch", path, js.ValueOf(init)).
903 Call("then", js.FuncOf(func(_ js.Value, args []js.Value) any {
904 args[0].Call("text").Call("then", js.FuncOf(func(_ js.Value, a []js.Value) any {
905 var inv invoiceView
906 if err := json.Unmarshal([]byte(a[0].String()), &inv); err != nil {
907 done(nil, errString("the store did not answer with an invoice"))
908 return nil
909 }
910 if inv.Error != "" {
911 done(nil, errString(inv.Error))
912 return nil
913 }
914 done(&inv, nil)
915 return nil
916 }))
917 return nil
918 })).
919 Call("catch", js.FuncOf(func(_ js.Value, args []js.Value) any {
920 log.Println(wasmName+":", "invoice fetch failed:", args[0])
921 done(nil, errString("could not reach the store"))
922 return nil
923 }))
924}
925
926type errString string
927
928func (e errString) Error() string { return string(e) }
929
930
931// ===== main.go =====
932//go:build js && wasm
933
934package main
935
936import (
937 "log"
938 "syscall/js"
939)
940
941// set client pk on compile
942var stripePK string
943
944type item struct {
945 ID string `json:"id"`
946 Amount int `json:"amount"`
947 Qty int `json:"quantity"`
948}
949
950var (
951 wasmName string
952 doc = js.Global().Get("document")
953 cart []item
954)
955
956func main() {
957 ready := make(chan struct{})
958
959 document := js.Global().Get("document")
960 readyState := document.Get("readyState").String()
961 if readyState == "interactive" || readyState == "complete" {
962 log.Println(wasmName+":", "WASM: DOM already fully loaded")
963 close(ready)
964 } else {
965 cb := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
966 log.Println(wasmName+":", "WASM: DOM fully loaded and parsed")
967 close(ready)
968 return nil
969 })
970 defer cb.Release()
971
972 document.Call("addEventListener", "DOMContentLoaded", cb)
973 log.Println(wasmName+":", "WASM: waiting for DOM to load")
974 }
975
976 <-ready
977
978 c := make(chan struct{})
979 if stripePK == "" {
980 log.Fatal("Stripe PK not found!")
981 }
982 window := js.Global().Get("window")
983 location := window.Get("location")
984 pathname := location.Get("pathname").String()
985
986 switch pathname {
987 case "/complete":
988 completeLogic()
989 default:
990 defaultLogic()
991 }
992 <-c
993}
994
995func defaultLogic() {
996 js.Global().Set("addToCart", js.FuncOf(addUnToCart))
997 js.Global().Set("clearStorage", js.FuncOf(clearAll))
998 js.Global().Set("emptyCart", js.FuncOf(emptyCart))
999 js.Global().Set("updateItemQuantity", js.FuncOf(updateItemQuantity))
1000 js.Global().Set("removeFromCart", js.FuncOf(removeFromCart))
1001 js.Global().Set("addShippingInfo", js.FuncOf(addShippingInfo))
1002 js.Global().Set("goToCheckout", js.FuncOf(goToCheckout))
1003 js.Global().Set("payWithCoin", js.FuncOf(payWithCoin))
1004 js.Global().Set("cancelCheckout", js.FuncOf(cancelCheckout))
1005 js.Global().Set("callUpdateCartDisplay", js.FuncOf(updateCartDisplayWrapper))
1006 loadCart()
1007 updateCartDisplay()
1008}
1009
1010