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 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 reportShipping(form, msg)
261 return false
262 }
263
264 addToCart(js.Value{}, []js.Value{
265 js.ValueOf(sh.ID()),
266 js.ValueOf(sh.Cents),
267 js.ValueOf(1),
268 })
269 return false
270}
271
272// reportShipping says why a shipping line was refused, on the amount field
273// where the browser already shows its own min= message — so a refusal from
274// here looks like a refusal from the form itself.
275func reportShipping(form js.Value, msg string) {
276 if !form.Truthy() {
277 return
278 }
279 el := form.Call("querySelector", "[name='shipping-price']")
280 if !el.Truthy() {
281 return
282 }
283 el.Call("setCustomValidity", msg)
284 el.Call("reportValidity")
285 // Cleared on the next edit, or the message sticks to a field the reader
286 // has since corrected and the form can never be submitted again.
287 var clear js.Func
288 clear = js.FuncOf(func(js.Value, []js.Value) any {
289 el.Call("setCustomValidity", "")
290 clear.Release()
291 return nil
292 })
293 el.Call("addEventListener", "input", clear, map[string]any{"once": true})
294}
295
296var (
297 elements js.Value
298 stripeValue js.Value
299 stripe js.Value
300 checkoutStripe = doc.Call("getElementById", "stripecheckout")
301)
302
303func goToCheckout(this js.Value, args []js.Value) any {
304 if stripeValue.IsUndefined() {
305 log.Println(`js.Global().Get("Stripe")`)
306 stripeValue = js.Global().Get("Stripe")
307 if stripeValue.IsUndefined() {
308 log.Println(`Stripe is undefined, attempting to load Stripe.js`)
309
310 doc := js.Global().Get("document")
311 head := doc.Call("querySelector", "head")
312 script := doc.Call("createElement", "script")
313 script.Set("src", "https://js.stripe.com/v3/")
314 script.Set("defer", true)
315
316 done := make(chan bool)
317 script.Call("addEventListener", "load", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
318 log.Println(wasmName+":", "Stripe.js script has been loaded")
319 done <- true
320 return nil
321 }))
322
323 head.Call("appendChild", script)
324
325 <-done
326
327 stripeValue = js.Global().Get("Stripe")
328 if stripeValue.IsUndefined() {
329 log.Println(wasmName+":", "Failed to load Stripe.js")
330 return nil
331 }
332 }
333 }
334
335 log.Println(wasmName+":", "Stripe.js loaded successfully")
336
337 if stripe.IsUndefined() {
338 log.Println(wasmName+":", "Invoking Stripe")
339 stripe = stripeValue.Invoke(stripePK)
340 if stripe.IsUndefined() {
341 log.Println(wasmName+":", "Failed to invoke Stripe")
342 return nil
343 }
344 }
345
346 log.Println(wasmName+":", "Stripe initialized")
347 checkoutStripe = doc.Call("getElementById", "stripecheckout")
348 if checkoutStripe.IsUndefined() {
349 log.Println(wasmName+":", "element with ID stripecheckout not found")
350 }
351
352 checkoutStripe.Call("showModal")
353 log.Println(wasmName+":", "initializePayment()")
354 initializePayment()
355
356 return nil
357}
358
359func cancelCheckout(this js.Value, args []js.Value) any {
360 log.Println(wasmName+":", "Canceling checkout ; closing dialog")
361 checkoutStripe.Call("close")
362 updateCartDisplay()
363 return nil
364}
365
366func initializePayment() {
367 type cItem struct {
368 ID string `json:"id"`
369 Amount int `json:"amount"`
370 }
371 type checkout struct {
372 Items []cItem `json:"items"`
373 }
374 payload := checkout{
375 Items: func() []cItem {
376 var items []cItem
377 for _, it := range cart {
378 items = append(items, cItem{ID: it.ID + " X " + strconv.Itoa(it.Qty), Amount: it.Amount})
379 }
380 return items
381 }(),
382 }
383 payloadJSON, err := json.Marshal(payload)
384 if err != nil {
385 log.Println(wasmName+":", "Error marshaling JSON:", err)
386 return
387 }
388 fetchInit := map[string]interface{}{
389 "method": "POST",
390 "headers": map[string]interface{}{
391 "Content-Type": "application/json",
392 },
393 "body": string(payloadJSON),
394 }
395
396 log.Println(wasmName+":", "fetch /create-payment-intent")
397 js.Global().Call("fetch", "/create-payment-intent", js.ValueOf(fetchInit)).
398 Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
399 response := args[0]
400 log.Println(wasmName+":", "got response from fetch /create-payment-intent")
401 if !response.Get("ok").Bool() {
402 log.Println(wasmName+":", "Fetch request failed with status:", response.Get("status").Int())
403 showMessage("Failed to create payment intent: " + response.Get("status").String())
404 return nil
405 }
406 response.Call("json").Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
407 clientSecret := args[0].Get("clientSecret").String()
408 log.Println(wasmName+":", "Client secret received:", clientSecret)
409 setupStripeElements(clientSecret)
410 return nil
411 })).Call("catch", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
412 log.Println(wasmName+":", "Error parsing JSON response:", args[0])
413 showMessage("Failed to parse payment intent response.")
414 return nil
415 }))
416 return nil
417 })).
418 Call("catch", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
419 log.Println(wasmName+":", "Error in fetch request:", args[0])
420 showMessage("Failed to communicate with the server.")
421 return nil
422 }))
423}
424
425func setupStripeElements(clientSecret string) {
426 elements = stripe.Call("elements", map[string]interface{}{
427 "clientSecret": clientSecret,
428 })
429 if elements.IsUndefined() {
430 log.Println(wasmName+":", "Failed to initialize Stripe Elements")
431 showMessage("Failed to initialize payment elements.")
432 return
433 }
434 paymentElement := elements.Call("create", "payment", map[string]interface{}{
435 "layout": "tabs",
436 })
437 if paymentElement.IsUndefined() {
438 log.Println(wasmName+":", "Failed to create payment element")
439 showMessage("Failed to create payment element.")
440 return
441 }
442 paymentElement.Call("mount", "#payment-element")
443 submitButton := doc.Call("getElementById", "submit")
444 submitButton.Call("addEventListener", "click", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
445 args[0].Call("preventDefault")
446 showSpinner(true)
447 confirmPayment(clientSecret)
448 return nil
449 }))
450}
451
452func confirmPayment(clientSecret string) {
453
454 windowLocation := js.Global().Get("window").Get("location")
455 protocol := windowLocation.Get("protocol").String()
456 hostname := windowLocation.Get("hostname").String()
457 port := windowLocation.Get("port").String()
458
459 baseURL := protocol + "//" + hostname
460 if port != "" {
461 baseURL += ":" + port
462 }
463 // path := windowLocation.Get("pathname").String()
464 // baseURL += strings.Split(path, "?")[0]
465 // log.Println(wasmName+":","return url ", baseURL)
466
467 returnURL := baseURL + "/complete"
468 returnURL += "?payment_intent=" + clientSecret // + "#complete"
469 log.Println(wasmName+":", "Return URL for payment:", returnURL)
470
471 stripe.Call("confirmPayment", map[string]interface{}{
472 "elements": elements,
473 "confirmParams": map[string]interface{}{
474 "return_url": returnURL,
475 },
476 }).Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
477 result := args[0]
478 if result.Get("error").IsUndefined() {
479 log.Println(wasmName+":", "Payment successful:", result)
480 showMessage("Payment successful! Thank you for your order.")
481 } else {
482 log.Println(wasmName+":", "Payment error:", result.Get("error").Get("message").String())
483 showMessage("Payment failed: " + result.Get("error").Get("message").String())
484 }
485
486 showSpinner(false)
487 return nil
488 }))
489}
490
491func showMessage(message string) {
492 messageElement := doc.Call("getElementById", "payment-message")
493 messageElement.Set("innerText", message)
494 messageElement.Set("className", "")
495}
496
497func showSpinner(isLoading bool) {
498 spinner := doc.Call("getElementById", "spinner")
499 buttonText := doc.Call("getElementById", "button-text")
500
501 if isLoading {
502 spinner.Set("className", "")
503 buttonText.Set("className", "hidden")
504 } else {
505 spinner.Set("className", "hidden")
506 buttonText.Set("className", "")
507 }
508}
509
510
511// ===== complete.go =====
512//go:build js && wasm
513
514// Package main complete.go
515package main
516
517import (
518 "encoding/json"
519 "log"
520 "syscall/js"
521)
522
523func completeLogic() {
524 initializeStripe()
525}
526
527func initializeStripe() {
528 if stripeValue.IsUndefined() {
529 log.Println(`js.Global().Get("Stripe")`)
530 stripeValue = js.Global().Get("Stripe")
531 if stripeValue.IsUndefined() {
532 log.Println(`Stripe is undefined, attempting to load Stripe.js`)
533
534 doc := js.Global().Get("document")
535 head := doc.Call("querySelector", "head")
536 script := doc.Call("createElement", "script")
537 script.Set("src", "https://js.stripe.com/v3/")
538 script.Set("defer", true)
539
540 done := make(chan bool)
541 script.Call("addEventListener", "load", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
542 log.Println(wasmName+":", "Stripe.js script has been loaded")
543 done <- true
544 return nil
545 }))
546
547 head.Call("appendChild", script)
548
549 <-done
550
551 stripeValue = js.Global().Get("Stripe")
552 if stripeValue.IsUndefined() {
553 log.Println(wasmName+":", "Failed to load Stripe.js")
554 return
555 }
556 }
557 }
558
559 log.Println(wasmName+":", "Stripe.js loaded successfully")
560
561 if stripe.IsUndefined() {
562 log.Println(wasmName+":", "Invoking Stripe")
563 stripe = stripeValue.Invoke(stripePK)
564 if stripe.IsUndefined() {
565 log.Println(wasmName+":", "Failed to invoke Stripe")
566 return
567 }
568 }
569
570 log.Println(wasmName+":", "Stripe initialized")
571 checkStatus()
572}
573
574var (
575 successIcon = `<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
576 <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"/>
577 </svg>`
578
579 errorIcon = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
580 <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"/>
581 </svg>`
582
583 infoIcon = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
584 <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"/>
585 <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"/>
586 <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"/>
587 </svg>`
588)
589
590func setErrorState() {
591 js.Global().Get("document").Call("querySelector", "#status-icon").Set("style", map[string]interface{}{"backgroundColor": "#DF1B41"})
592 js.Global().Get("document").Call("querySelector", "#status-icon").Set("innerHTML", errorIcon)
593 js.Global().Get("document").Call("querySelector", "#status-text").Set("textContent", "Something went wrong, please try again.")
594 js.Global().Get("document").Call("querySelector", "#details-table").Call("classList").Call("add", "hidden")
595 js.Global().Get("document").Call("querySelector", "#view-details").Call("classList").Call("add", "hidden")
596}
597
598func checkStatus() {
599 clientSecret := js.Global().Get("URLSearchParams").New(js.Global().Get("window").Get("location").Get("search")).Call("get", "payment_intent_client_secret").String()
600
601 if clientSecret == "" {
602 setErrorState()
603 return
604 }
605
606 if stripe.IsUndefined() {
607 log.Println(wasmName+":", "Stripe is not initialized")
608 setErrorState()
609 return
610 }
611
612 stripe.Call("retrievePaymentIntent", clientSecret).Call("then", js.FuncOf(func(this js.Value, p []js.Value) interface{} {
613 paymentIntent := p[0].Get("paymentIntent")
614 setPaymentDetails(paymentIntent)
615 return nil
616 })).Call("catch", js.FuncOf(func(this js.Value, p []js.Value) interface{} {
617 setErrorState()
618 return nil
619 }))
620}
621
622func getAllLocalStorageData() map[string]interface{} {
623 localStorage := js.Global().Get("localStorage")
624 keys := js.Global().Get("Object").Call("keys", localStorage)
625 data := make(map[string]interface{})
626
627 for i := 0; i < keys.Length(); i++ {
628 key := keys.Index(i).String()
629 value := localStorage.Call("getItem", key).String()
630 var parsedValue interface{}
631 err := json.Unmarshal([]byte(value), &parsedValue)
632 if err != nil {
633 parsedValue = value // If not JSON, store raw value
634 }
635 data[key] = parsedValue
636 }
637 return data
638}
639
640func submitOrder(localStorageData map[string]interface{}, paymentIntentId string) {
641 orderData := map[string]interface{}{
642 "localStorageData": localStorageData,
643 "paymentIntentId": paymentIntentId,
644 }
645
646 body, err := json.Marshal(orderData)
647 if err != nil {
648 log.Println(wasmName+":", "Error marshaling order data:", err)
649 return
650 }
651
652 fetch := js.Global().Get("fetch")
653 if fetch.IsUndefined() {
654 log.Println(wasmName+":", "Fetch API is not available")
655 return
656 }
657
658 options := map[string]interface{}{
659 "method": "POST",
660 "headers": map[string]interface{}{
661 "Content-Type": "application/json",
662 },
663 "body": string(body),
664 }
665
666 fetch.Invoke("/submit-order", js.ValueOf(options)).Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
667 response := args[0]
668 if !response.Get("ok").Bool() {
669 response.Call("text").Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
670 errText := args[0].String()
671 log.Println(wasmName+":", "Order submit failed:", errText)
672 js.Global().Call("alert", "Order submission failed:\n"+errText)
673 return nil
674 }))
675 return nil
676 }
677 response.Call("json").Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
678 data := args[0]
679 log.Println(wasmName+":", "Order submitted successfully:", data)
680 return nil
681 }))
682 return nil
683 }))
684}
685
686func setPaymentDetails(intent js.Value) {
687 // Every path through the switch below sets this, including its default,
688 // so there is nothing to fall back to. iconColor and icon do fall back:
689 // the cases that only change the wording leave them red.
690 var statusText string
691 iconColor := "#DF1B41"
692 icon := errorIcon
693
694 if !intent.IsUndefined() {
695 intentStatus := intent.Get("status").String()
696 intentID := intent.Get("id").String()
697
698 allLocalStorageData := getAllLocalStorageData()
699
700 switch intentStatus {
701 case "succeeded":
702 statusText = "Payment succeeded"
703 iconColor = "#30B130"
704 icon = successIcon
705 if len(allLocalStorageData) > 0 {
706 submitOrder(allLocalStorageData, intentID)
707 } else {
708 log.Println(wasmName+":", "No data found in localStorage; order not submitted.")
709 }
710 case "processing":
711 statusText = "Your payment is processing."
712 iconColor = "#6D6E78"
713 icon = infoIcon
714 if len(allLocalStorageData) > 0 {
715 submitOrder(allLocalStorageData, intentID)
716 } else {
717 log.Println(wasmName+":", "No data found in localStorage; order not submitted.")
718 }
719 case "requires_payment_method":
720 statusText = "Your payment was not successful, please try again."
721 default:
722 statusText = "Unknown payment status."
723 }
724
725 // Update the status icon, text, and links
726 js.Global().Get("document").Call("querySelector", "#status-icon").Set("style", map[string]interface{}{"backgroundColor": iconColor})
727 js.Global().Get("document").Call("querySelector", "#status-icon").Set("innerHTML", icon)
728 js.Global().Get("document").Call("querySelector", "#status-text").Set("textContent", statusText)
729 js.Global().Get("document").Call("querySelector", "#intent-id").Set("textContent", intentID)
730 js.Global().Get("document").Call("querySelector", "#intent-status").Set("textContent", intentStatus)
731 js.Global().Get("document").Call("querySelector", "#view-details").Set("href", "https://dashboard.stripe.com/payments/"+intentID)
732
733 // Update the "Order Details" link with the paymentIntent ID
734 orderDetailsLink := js.Global().Get("document").Call("querySelector", "#order-details-link")
735 orderDetailsLink.Set("href", "/order/"+intentID)
736 orderDetailsLink.Set("onclick", nil) // Allow default behavior (navigation)
737
738 } else {
739 setErrorState()
740 }
741}
742
743
744// ===== crypto.go =====
745//go:build js && wasm
746
747package main
748
749// Paying in coin from the page.
750//
751// The same /create-invoice endpoint the terminal uses (pkg/tui/crypto.go);
752// this only renders what the server decided. The server validates every
753// line against its own catalog, prices the order, and allocates the
754// address — nothing here is trusted with any of that.
755
756import (
757 "encoding/json"
758 "log"
759 "strconv"
760 "syscall/js"
761)
762
763// invoiceView mirrors what pkg/web/invoice.go hands a client.
764type invoiceView struct {
765 ID string `json:"id"`
766 Coin string `json:"coin"`
767 Address string `json:"address"`
768 Amount string `json:"amount"`
769 URI string `json:"uri"`
770 Status string `json:"status"`
771 Paid string `json:"paid"`
772 Outstanding string `json:"outstanding"`
773 TxID string `json:"txid"`
774 Error string `json:"error"`
775}
776
777var coinInvoiceID string
778
779// payWithCoin asks the store for an invoice and shows it in the checkout
780// dialog, then polls until it settles.
781func payWithCoin(js.Value, []js.Value) any {
782 if len(cart) == 0 {
783 showMessage("Your cart is empty.")
784 return nil
785 }
786 type cItem struct {
787 ID string `json:"id"`
788 Amount int `json:"amount"`
789 }
790 var items []cItem
791 for _, it := range cart {
792 items = append(items, cItem{ID: it.ID + " X " + strconv.Itoa(it.Qty), Amount: it.Amount})
793 }
794 body, err := json.Marshal(map[string]any{"coin": "SKY", "items": items})
795 if err != nil {
796 showMessage("Could not build the request.")
797 return nil
798 }
799
800 checkoutStripe = doc.Call("getElementById", "stripecheckout")
801 checkoutStripe.Call("showModal")
802 setCoinBody("<p>Asking the store for an address…</p>")
803
804 fetchJSON("/create-invoice", string(body), func(inv *invoiceView, err error) {
805 if err != nil {
806 setCoinBody("<p>Could not create the invoice: " + err.Error() + "</p>")
807 return
808 }
809 coinInvoiceID = inv.ID
810 renderCoinInvoice(inv)
811 pollCoinInvoice()
812 })
813 return nil
814}
815
816// pollCoinInvoice re-reads the invoice every ten seconds while the dialog
817// is open. The server polls the chain when asked.
818func pollCoinInvoice() {
819 js.Global().Call("setTimeout", js.FuncOf(func(js.Value, []js.Value) any {
820 if coinInvoiceID == "" || !checkoutStripe.Get("open").Bool() {
821 return nil
822 }
823 fetchJSON("/invoice/"+coinInvoiceID, "", func(inv *invoiceView, err error) {
824 if err != nil {
825 pollCoinInvoice()
826 return
827 }
828 renderCoinInvoice(inv)
829 if inv.Status != "paid" && inv.Status != "expired" {
830 pollCoinInvoice()
831 }
832 })
833 return nil
834 }), 10000)
835}
836
837func renderCoinInvoice(inv *invoiceView) {
838 h := "<p>Send <b>" + inv.Amount + " " + inv.Coin + "</b> to:</p>" +
839 "<p style='word-break:break-all'><code>" + inv.Address + "</code></p>" +
840 "<p><a href='" + inv.URI + "'>open in a wallet</a></p>"
841 switch inv.Status {
842 case "paid":
843 h += "<p><b>Paid — thank you.</b> Your order is printing.</p>"
844 if inv.TxID != "" {
845 h += "<p style='word-break:break-all'><small>txid " + inv.TxID + "</small></p>"
846 }
847 emptyCart(js.Value{}, nil)
848 case "underpaid":
849 h += "<p>Part paid: " + inv.Paid + " received, <b>" + inv.Outstanding + "</b> still owed.</p>"
850 case "expired":
851 h += "<p>The quote expired. Close this and start again for a fresh price.</p>"
852 default:
853 h += "<p>Waiting for the payment to confirm…</p>"
854 }
855 setCoinBody(h)
856}
857
858func setCoinBody(html string) {
859 el := doc.Call("getElementById", "checkout-container")
860 if el.Truthy() {
861 el.Set("innerHTML", html)
862 }
863}
864
865// fetchJSON POSTs (or GETs, with an empty body) and decodes an invoice.
866func fetchJSON(path, body string, done func(*invoiceView, error)) {
867 init := map[string]any{}
868 if body != "" {
869 init["method"] = "POST"
870 init["headers"] = map[string]any{"Content-Type": "application/json"}
871 init["body"] = body
872 }
873 js.Global().Call("fetch", path, js.ValueOf(init)).
874 Call("then", js.FuncOf(func(_ js.Value, args []js.Value) any {
875 args[0].Call("text").Call("then", js.FuncOf(func(_ js.Value, a []js.Value) any {
876 var inv invoiceView
877 if err := json.Unmarshal([]byte(a[0].String()), &inv); err != nil {
878 done(nil, errString("the store did not answer with an invoice"))
879 return nil
880 }
881 if inv.Error != "" {
882 done(nil, errString(inv.Error))
883 return nil
884 }
885 done(&inv, nil)
886 return nil
887 }))
888 return nil
889 })).
890 Call("catch", js.FuncOf(func(_ js.Value, args []js.Value) any {
891 log.Println(wasmName+":", "invoice fetch failed:", args[0])
892 done(nil, errString("could not reach the store"))
893 return nil
894 }))
895}
896
897type errString string
898
899func (e errString) Error() string { return string(e) }
900
901
902// ===== main.go =====
903//go:build js && wasm
904
905package main
906
907import (
908 "log"
909 "syscall/js"
910)
911
912// set client pk on compile
913var stripePK string
914
915type item struct {
916 ID string `json:"id"`
917 Amount int `json:"amount"`
918 Qty int `json:"quantity"`
919}
920
921var (
922 wasmName string
923 doc = js.Global().Get("document")
924 cart []item
925)
926
927func main() {
928 ready := make(chan struct{})
929
930 document := js.Global().Get("document")
931 readyState := document.Get("readyState").String()
932 if readyState == "interactive" || readyState == "complete" {
933 log.Println(wasmName+":", "WASM: DOM already fully loaded")
934 close(ready)
935 } else {
936 cb := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
937 log.Println(wasmName+":", "WASM: DOM fully loaded and parsed")
938 close(ready)
939 return nil
940 })
941 defer cb.Release()
942
943 document.Call("addEventListener", "DOMContentLoaded", cb)
944 log.Println(wasmName+":", "WASM: waiting for DOM to load")
945 }
946
947 <-ready
948
949 c := make(chan struct{})
950 if stripePK == "" {
951 log.Fatal("Stripe PK not found!")
952 }
953 window := js.Global().Get("window")
954 location := window.Get("location")
955 pathname := location.Get("pathname").String()
956
957 switch pathname {
958 case "/complete":
959 completeLogic()
960 default:
961 defaultLogic()
962 }
963 <-c
964}
965
966func defaultLogic() {
967 js.Global().Set("addToCart", js.FuncOf(addUnToCart))
968 js.Global().Set("clearStorage", js.FuncOf(clearAll))
969 js.Global().Set("emptyCart", js.FuncOf(emptyCart))
970 js.Global().Set("updateItemQuantity", js.FuncOf(updateItemQuantity))
971 js.Global().Set("removeFromCart", js.FuncOf(removeFromCart))
972 js.Global().Set("addShippingInfo", js.FuncOf(addShippingInfo))
973 js.Global().Set("goToCheckout", js.FuncOf(goToCheckout))
974 js.Global().Set("payWithCoin", js.FuncOf(payWithCoin))
975 js.Global().Set("cancelCheckout", js.FuncOf(cancelCheckout))
976 js.Global().Set("callUpdateCartDisplay", js.FuncOf(updateCartDisplayWrapper))
977 loadCart()
978 updateCartDisplay()
979}
980
981