Add order form
* add POST /api/order -> 402 Payment Required * redirect to /invoice/:id
This commit is contained in:
		
							parent
							
								
									55afc3b097
								
							
						
					
					
						commit
						ebdf0d1e90
					
				@ -49,12 +49,12 @@ type (
 | 
			
		||||
	Order struct {
 | 
			
		||||
		Id        UUID
 | 
			
		||||
		CreatedAt time.Time
 | 
			
		||||
		ShareId   string `form:"share_id"`
 | 
			
		||||
		ShareId   string `json:"sid"`
 | 
			
		||||
		Share
 | 
			
		||||
		Pubkey    string
 | 
			
		||||
		Side      string `form:"side"`
 | 
			
		||||
		Quantity  int64  `form:"quantity"`
 | 
			
		||||
		Price     int64  `form:"price"`
 | 
			
		||||
		Side      string `json:"side"`
 | 
			
		||||
		Quantity  int64  `json:"quantity"`
 | 
			
		||||
		Price     int64  `json:"price"`
 | 
			
		||||
		InvoiceId UUID
 | 
			
		||||
		Invoice
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -2,12 +2,10 @@ package handler
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"database/sql"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strconv"
 | 
			
		||||
 | 
			
		||||
	"git.ekzyis.com/ekzyis/delphi.market/db"
 | 
			
		||||
	"git.ekzyis.com/ekzyis/delphi.market/env"
 | 
			
		||||
	"git.ekzyis.com/ekzyis/delphi.market/lib"
 | 
			
		||||
	"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
 | 
			
		||||
	"github.com/labstack/echo/v4"
 | 
			
		||||
@ -39,33 +37,26 @@ func HandleMarket(sc context.ServerContext) echo.HandlerFunc {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
		data = map[string]any{
 | 
			
		||||
			"session":     c.Get("session"),
 | 
			
		||||
			"Id":          market.Id,
 | 
			
		||||
			"Description": market.Description,
 | 
			
		||||
			// shares are sorted by description in descending order
 | 
			
		||||
			// that's how we know that YES must be the first share
 | 
			
		||||
			"YesShare": shares[0],
 | 
			
		||||
			"NoShare":  shares[1],
 | 
			
		||||
			"Orders":   orders,
 | 
			
		||||
			"Shares":      shares,
 | 
			
		||||
		}
 | 
			
		||||
		return c.JSON(http.StatusOK, data)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func HandlePostOrder(sc context.ServerContext) echo.HandlerFunc {
 | 
			
		||||
func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
 | 
			
		||||
	return func(c echo.Context) error {
 | 
			
		||||
		var (
 | 
			
		||||
			marketId string
 | 
			
		||||
			u        db.User
 | 
			
		||||
			o        db.Order
 | 
			
		||||
			invoice  *db.Invoice
 | 
			
		||||
			msats    int64
 | 
			
		||||
			data     map[string]any
 | 
			
		||||
			qr       string
 | 
			
		||||
			hash     lntypes.Hash
 | 
			
		||||
			err      error
 | 
			
		||||
			u       db.User
 | 
			
		||||
			o       db.Order
 | 
			
		||||
			invoice *db.Invoice
 | 
			
		||||
			msats   int64
 | 
			
		||||
			data    map[string]any
 | 
			
		||||
			qr      string
 | 
			
		||||
			hash    lntypes.Hash
 | 
			
		||||
			err     error
 | 
			
		||||
		)
 | 
			
		||||
		marketId = c.Param("id")
 | 
			
		||||
		// TODO:
 | 
			
		||||
		//   [ ] Step 0: If SELL order, check share balance of user
 | 
			
		||||
		//   [x] Create HODL invoice
 | 
			
		||||
@ -108,12 +99,11 @@ func HandlePostOrder(sc context.ServerContext) echo.HandlerFunc {
 | 
			
		||||
		// TODO: find matching orders
 | 
			
		||||
 | 
			
		||||
		data = map[string]any{
 | 
			
		||||
			"session":     c.Get("session"),
 | 
			
		||||
			"lnurl":       invoice.PaymentRequest,
 | 
			
		||||
			"qr":          qr,
 | 
			
		||||
			"invoice":     *invoice,
 | 
			
		||||
			"redirectURL": fmt.Sprintf("https://%s/market/%s", env.PublicURL, marketId),
 | 
			
		||||
			"id":     invoice.Id,
 | 
			
		||||
			"bolt11": invoice.PaymentRequest,
 | 
			
		||||
			"amount": msats,
 | 
			
		||||
			"qr":     qr,
 | 
			
		||||
		}
 | 
			
		||||
		return sc.Render(c, http.StatusPaymentRequired, "invoice.html", data)
 | 
			
		||||
		return c.JSON(http.StatusPaymentRequired, data)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -31,7 +31,7 @@ func addFrontendRoutes(e *echo.Echo, sc ServerContext) {
 | 
			
		||||
		handler.HandleMarket,
 | 
			
		||||
		middleware.SessionGuard)
 | 
			
		||||
	POST(e, sc, "/market/:id/order",
 | 
			
		||||
		handler.HandlePostOrder,
 | 
			
		||||
		handler.HandleOrder,
 | 
			
		||||
		middleware.SessionGuard,
 | 
			
		||||
		middleware.LNDGuard)
 | 
			
		||||
	GET(e, sc, "/invoice/:id",
 | 
			
		||||
@ -42,6 +42,10 @@ func addFrontendRoutes(e *echo.Echo, sc ServerContext) {
 | 
			
		||||
func addBackendRoutes(e *echo.Echo, sc ServerContext) {
 | 
			
		||||
	GET(e, sc, "/api/markets", handler.HandleMarkets)
 | 
			
		||||
	GET(e, sc, "/api/market/:id", handler.HandleMarket)
 | 
			
		||||
	POST(e, sc, "/api/order",
 | 
			
		||||
		handler.HandleOrder,
 | 
			
		||||
		middleware.SessionGuard,
 | 
			
		||||
		middleware.LNDGuard)
 | 
			
		||||
	GET(e, sc, "/api/login", handler.HandleLogin)
 | 
			
		||||
	GET(e, sc, "/api/login/callback", handler.HandleLoginCallback)
 | 
			
		||||
	POST(e, sc, "/api/logout", handler.HandleLogout)
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <NavBar />
 | 
			
		||||
  <div id="container">
 | 
			
		||||
    <NavBar />
 | 
			
		||||
    <router-view />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
@ -38,6 +38,7 @@ body {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#container {
 | 
			
		||||
  margin: 1em;
 | 
			
		||||
  margin: 1em auto;
 | 
			
		||||
  width: fit-content;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
@ -107,31 +107,6 @@ figcaption {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.label {
 | 
			
		||||
  width: fit-content;
 | 
			
		||||
  margin: 1em auto;
 | 
			
		||||
  padding: 0.5em 3em;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.label:hover {
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.success {
 | 
			
		||||
  background-color: rgba(20, 158, 97, .24);
 | 
			
		||||
  color: #35df8d;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.success:hover {
 | 
			
		||||
  background-color: #35df8d;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.error {
 | 
			
		||||
  background-color: rgba(245, 57, 94, .24);
 | 
			
		||||
  color: #ff7386;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.error:hover {
 | 
			
		||||
  background-color: #ff7386;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
@ -10,17 +10,63 @@
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="font-mono">{{ market.Description }}</div>
 | 
			
		||||
  <!-- eslint-enable -->
 | 
			
		||||
  <button type="button" :class="yesClass" class="label success font-mono mx-1 my-3"
 | 
			
		||||
    @click.prevent="toggleYes">YES</button>
 | 
			
		||||
  <button type="button" :class="noClass" class="label error font-mono mx-1 my-3" @click.prevent="toggleNo">NO</button>
 | 
			
		||||
  <form v-show="showForm" @submit.prevent="submitForm">
 | 
			
		||||
    <label for="stake">how much?</label>
 | 
			
		||||
    <input name="stake" v-model="stake" type="number" min="0" placeholder="🗲 sats" />
 | 
			
		||||
    <label for="certainty">how sure?</label>
 | 
			
		||||
    <input name="certainty" v-model="certainty" type="number" min="0" max="1" step="0.001"
 | 
			
		||||
      placeholder="fraction like 0.5" />
 | 
			
		||||
    <label>you receive:</label>
 | 
			
		||||
    <label>{{ format(shares) }} shares @ 🗲{{ format(price) }}</label>
 | 
			
		||||
    <label>you pay:</label>
 | 
			
		||||
    <label>🗲{{ format(cost) }}</label>
 | 
			
		||||
    <label>if you win:</label>
 | 
			
		||||
    <label>+🗲{{ format(profit) }}</label>
 | 
			
		||||
    <button class="col-span-2" type="submit">submit order</button>
 | 
			
		||||
  </form>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref } from 'vue'
 | 
			
		||||
import { useRoute } from 'vue-router'
 | 
			
		||||
import { ref, computed } from 'vue'
 | 
			
		||||
import { useRoute, useRouter } from 'vue-router'
 | 
			
		||||
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
const route = useRoute()
 | 
			
		||||
const marketId = route.params.id
 | 
			
		||||
const selected = ref(null)
 | 
			
		||||
const showForm = computed(() => selected.value !== null)
 | 
			
		||||
const yesClass = computed(() => selected.value === 'yes' ? ['active'] : [])
 | 
			
		||||
const noClass = computed(() => selected.value === 'no' ? ['active'] : [])
 | 
			
		||||
 | 
			
		||||
// how much wants the user bet?
 | 
			
		||||
const stake = ref(null)
 | 
			
		||||
// how sure is the user he will win?
 | 
			
		||||
const certainty = ref(null)
 | 
			
		||||
// price per share: more risk, lower price, higher reward
 | 
			
		||||
const price = computed(() => certainty.value * 100)
 | 
			
		||||
// how many (full) shares can be bought?
 | 
			
		||||
const shares = computed(() => {
 | 
			
		||||
  const val = price.value > 0 ? stake.value / price.value : null
 | 
			
		||||
  // only full shares can be bought
 | 
			
		||||
  return Math.round(val)
 | 
			
		||||
})
 | 
			
		||||
// how much does this order cost?
 | 
			
		||||
const cost = computed(() => {
 | 
			
		||||
  return shares.value * price.value
 | 
			
		||||
})
 | 
			
		||||
// how high is the potential reward?
 | 
			
		||||
const profit = computed(() => {
 | 
			
		||||
  // shares expire at 10 or 0 sats
 | 
			
		||||
  const val = (100 * shares.value) - cost.value
 | 
			
		||||
  return isNaN(val) ? 0 : val
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const format = (x, i = 3) => x === null ? null : x >= 1 ? Math.round(x) : x.toFixed(i)
 | 
			
		||||
 | 
			
		||||
const market = ref(null)
 | 
			
		||||
 | 
			
		||||
const url = '/api/market/' + marketId
 | 
			
		||||
await fetch(url)
 | 
			
		||||
  .then(r => r.json())
 | 
			
		||||
@ -28,5 +74,62 @@ await fetch(url)
 | 
			
		||||
    market.value = body
 | 
			
		||||
  })
 | 
			
		||||
  .catch(console.error)
 | 
			
		||||
// Currently, we only support binary markets.
 | 
			
		||||
// (only events which can be answered with YES and NO)
 | 
			
		||||
const yesShareId = computed(() => {
 | 
			
		||||
  return market?.value.Shares.find(s => s.Description === 'YES').Id
 | 
			
		||||
})
 | 
			
		||||
const noShareId = computed(() => {
 | 
			
		||||
  return market?.value.Shares.find(s => s.Description === 'NO').Id
 | 
			
		||||
})
 | 
			
		||||
const shareId = computed(() => {
 | 
			
		||||
  return selected.value === 'yes' ? yesShareId.value : noShareId.value
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const toggleYes = () => {
 | 
			
		||||
  selected.value = selected.value === 'yes' ? null : 'yes'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const toggleNo = () => {
 | 
			
		||||
  selected.value = selected.value === 'no' ? null : 'no'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const submitForm = async () => {
 | 
			
		||||
  // TODO validate form
 | 
			
		||||
  const url = window.origin + '/api/order'
 | 
			
		||||
  const body = JSON.stringify({
 | 
			
		||||
    sid: shareId.value,
 | 
			
		||||
    quantity: shares.value,
 | 
			
		||||
    price: price.value,
 | 
			
		||||
    // TODO support selling
 | 
			
		||||
    side: 'BUY'
 | 
			
		||||
  })
 | 
			
		||||
  const res = await fetch(url, { method: 'POST', headers: { 'Content-type': 'application/json' }, body })
 | 
			
		||||
  const resBody = await res.json()
 | 
			
		||||
  const invoiceId = resBody.id
 | 
			
		||||
  router.push('/invoice/' + invoiceId)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.success.active {
 | 
			
		||||
  background-color: #35df8d;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.error.active {
 | 
			
		||||
  background-color: #ff7386;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form {
 | 
			
		||||
  margin: 0 auto;
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: auto auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form>* {
 | 
			
		||||
  margin: 0.5em 1em;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
@ -13,6 +13,10 @@ button:hover {
 | 
			
		||||
  background: #8787A4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
input {
 | 
			
		||||
  color: #000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
a {
 | 
			
		||||
  color: #8787a4;
 | 
			
		||||
  text-decoration: underline;
 | 
			
		||||
@ -27,3 +31,32 @@ a.selected {
 | 
			
		||||
  color: #ffffff;
 | 
			
		||||
  background: #8787A4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.label {
 | 
			
		||||
  border: none;
 | 
			
		||||
  width: fit-content;
 | 
			
		||||
  padding: 0.5em 3em;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.label:hover {
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.success {
 | 
			
		||||
  background-color: rgba(20, 158, 97, .24);
 | 
			
		||||
  color: #35df8d;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.success:hover {
 | 
			
		||||
  background-color: #35df8d;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.error {
 | 
			
		||||
  background-color: rgba(245, 57, 94, .24);
 | 
			
		||||
  color: #ff7386;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.error:hover {
 | 
			
		||||
  background-color: #ff7386;
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user