Add market form + page
* market form with question, description and end date * markets cost 1k sats * a goroutine polls pending invoices from the db and checks LND for their status * markets are listed on front page (after paid) * market page contains buttons to bet yes or no * users have names now TODO: * show correct market percentage * show how percentage changed over time in chart * validate end date * implement betting / order form
This commit is contained in:
parent
698cc5c368
commit
845157c954
|
@ -1,5 +1,6 @@
|
|||
CREATE TABLE users(
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL DEFAULT LEFT(md5(random()::text), 8),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ln_pubkey TEXT UNIQUE,
|
||||
nostr_pubkey TEXT UNIQUE,
|
||||
|
@ -24,10 +25,9 @@ CREATE TABLE invoices(
|
|||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
msats BIGINT NOT NULL,
|
||||
msats_received BIGINT,
|
||||
preimage TEXT NOT NULL UNIQUE,
|
||||
hash TEXT NOT NULL UNIQUE,
|
||||
bolt11 TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
confirmed_at TIMESTAMP WITH TIME ZONE,
|
||||
held_since TIMESTAMP WITH TIME ZONE,
|
||||
|
@ -37,7 +37,8 @@ CREATE TABLE invoices(
|
|||
CREATE TABLE markets(
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
description TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
description TEXT,
|
||||
end_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
settled_at TIMESTAMP WITH TIME ZONE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
|
|
2
go.mod
2
go.mod
|
@ -50,7 +50,7 @@ require (
|
|||
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
|
||||
github.com/decred/dcrd/lru v1.0.0 // indirect
|
||||
github.com/dsnet/compress v0.0.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fergusstrange/embedded-postgres v1.10.0 // indirect
|
||||
github.com/go-errors/errors v1.0.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
|
|
2
go.sum
2
go.sum
|
@ -161,6 +161,8 @@ github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5Jflh
|
|||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
|
|
70
lnd/lnd.go
70
lnd/lnd.go
|
@ -1,9 +1,15 @@
|
|||
package lnd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.ekzyis.com/ekzyis/delphi.market/db"
|
||||
"github.com/lightninglabs/lndclient"
|
||||
"github.com/lightningnetwork/lnd/invoices"
|
||||
"github.com/lightningnetwork/lnd/lntypes"
|
||||
)
|
||||
|
||||
type LNDClient struct {
|
||||
|
@ -25,3 +31,67 @@ func New(config *LNDConfig) (*LNDClient, error) {
|
|||
log.Printf("Connected to %s running LND v%s", config.LndAddress, lnd.Version.Version)
|
||||
return lnd, nil
|
||||
}
|
||||
|
||||
func (lnd *LNDClient) CheckInvoices(db *db.DB) {
|
||||
var (
|
||||
pending bool
|
||||
rows *sql.Rows
|
||||
hash lntypes.Hash
|
||||
inv *lndclient.Invoice
|
||||
err error
|
||||
)
|
||||
for {
|
||||
// fetch all pending invoices
|
||||
if rows, err = db.Query("" +
|
||||
"SELECT hash, expires_at " +
|
||||
"FROM invoices " +
|
||||
"WHERE confirmed_at IS NULL AND expires_at > CURRENT_TIMESTAMP"); err != nil {
|
||||
log.Printf("error checking invoices: %v", err)
|
||||
}
|
||||
|
||||
pending = false
|
||||
|
||||
for rows.Next() {
|
||||
pending = true
|
||||
|
||||
var (
|
||||
h string
|
||||
expiresAt time.Time
|
||||
)
|
||||
rows.Scan(&h, &expiresAt)
|
||||
|
||||
if hash, err = lntypes.MakeHashFromStr(h); err != nil {
|
||||
log.Printf("error parsing hash: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if inv, err = lnd.Client.LookupInvoice(context.TODO(), hash); err != nil {
|
||||
log.Printf("error looking up invoice: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !inv.State.IsFinal() {
|
||||
log.Printf("invoice pending: %s %s", h, time.Until(expiresAt))
|
||||
continue
|
||||
}
|
||||
|
||||
if inv.State == invoices.ContractSettled {
|
||||
if _, err = db.Exec(
|
||||
"UPDATE invoices SET msats_received = $1, confirmed_at = $2 WHERE hash = $3",
|
||||
inv.AmountPaid, inv.SettleDate, h); err != nil {
|
||||
log.Printf("error updating invoice %s: %v", h, err)
|
||||
}
|
||||
log.Printf("invoice confirmed: %s", h)
|
||||
} else if inv.State == invoices.ContractCanceled {
|
||||
log.Printf("invoice expired: %s", h)
|
||||
}
|
||||
}
|
||||
|
||||
// poll faster if there are pending invoices
|
||||
if pending {
|
||||
time.Sleep(1 * time.Second)
|
||||
} else {
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
2
main.go
2
main.go
|
@ -68,7 +68,7 @@ func init() {
|
|||
log.Printf("[warn] error connecting to LND: %v\n", err)
|
||||
lnd_ = nil
|
||||
} else {
|
||||
// lnd_.CheckInvoices(db_)
|
||||
go lnd_.CheckInvoices(db_)
|
||||
}
|
||||
|
||||
ctx = server.Context{
|
||||
|
|
|
@ -10,6 +10,10 @@
|
|||
--nostr: #8d45dd;
|
||||
--black: #000;
|
||||
--white: #fff;
|
||||
--bg-success: #149e613d;
|
||||
--fg-success: #35df8d;
|
||||
--bg-error: #f5395e3d;
|
||||
--fg-error: #ff7386;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
@ -41,13 +45,17 @@
|
|||
|
||||
a:not(.no-link),
|
||||
button[hx-get],
|
||||
button[hx-post] {
|
||||
button[hx-post],
|
||||
button[type="submit"],
|
||||
.button {
|
||||
transition: background-color 150ms ease-in, color 150ms ease-in;
|
||||
}
|
||||
|
||||
a:not(.no-link):hover,
|
||||
button[hx-get]:hover,
|
||||
button[hx-post]:hover {
|
||||
button[hx-post]:hover,
|
||||
button[type="submit"]:hover,
|
||||
.button:hover {
|
||||
background-color: var(--color);
|
||||
color: var(--background);
|
||||
}
|
||||
|
@ -57,13 +65,16 @@
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button[hx-post] {
|
||||
button[hx-post],
|
||||
button[type="submit"],
|
||||
.button {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
nav a,
|
||||
button[hx-get],
|
||||
button[hx-post] {
|
||||
button[hx-post],
|
||||
button[type="submit"] {
|
||||
padding: 0 0.25em;
|
||||
}
|
||||
|
||||
|
@ -80,6 +91,41 @@
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
.text-reset {
|
||||
color: var(--color);
|
||||
}
|
||||
|
||||
.hitbox {
|
||||
padding: 15px;
|
||||
margin: -15px;
|
||||
}
|
||||
|
||||
.neon {
|
||||
border: none;
|
||||
padding: 0.5em 3em;
|
||||
transition: background-color 150ms ease-in, color 150ms ease-in;
|
||||
}
|
||||
|
||||
.neon.success {
|
||||
background-color: var(--bg-success);
|
||||
color: var(--fg-success);
|
||||
}
|
||||
|
||||
.neon.success:hover {
|
||||
background-color: var(--fg-success);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.neon.error {
|
||||
background-color: var(--bg-error);
|
||||
color: var(--fg-error);
|
||||
}
|
||||
|
||||
.neon.error:hover {
|
||||
background-color: var(--fg-error);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.figlet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -115,4 +161,8 @@
|
|||
.nostr:hover {
|
||||
filter: brightness(125%) drop-shadow(0 0 0.33rem var(--nostr));
|
||||
}
|
||||
|
||||
#modal {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
|
@ -1,13 +1,46 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
||||
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func HandleIndex(sc context.Context) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return pages.Index().Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||
var (
|
||||
db = sc.Db
|
||||
ctx = c.Request().Context()
|
||||
rows *sql.Rows
|
||||
err error
|
||||
markets []types.Market
|
||||
)
|
||||
|
||||
if rows, err = db.QueryContext(ctx, ""+
|
||||
"SELECT m.id, m.question, m.description, m.created_at, m.end_date, "+
|
||||
"u.id, u.name, u.created_at, u.ln_pubkey, u.nostr_pubkey, u.msats "+
|
||||
"FROM markets m "+
|
||||
"JOIN users u ON m.user_id = u.id "+
|
||||
"JOIN invoices i ON m.invoice_id = i.id "+
|
||||
"WHERE i.confirmed_at IS NOT NULL"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var m types.Market
|
||||
var u types.User
|
||||
if err = rows.Scan(
|
||||
&m.Id, &m.Question, &m.Description, &m.CreatedAt, &m.EndDate,
|
||||
&u.Id, &u.Name, &u.CreatedAt, &u.LnPubkey, &u.NostrPubkey, &u.Msats); err != nil {
|
||||
return err
|
||||
}
|
||||
m.User = u
|
||||
markets = append(markets, m)
|
||||
}
|
||||
|
||||
return pages.Index(markets).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||
"github.com/a-h/templ"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func HandleInvoice(sc context.Context) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var (
|
||||
db = sc.Db
|
||||
ctx = c.Request().Context()
|
||||
hash = c.Param("hash")
|
||||
u = c.Get("session").(types.User)
|
||||
inv = types.Invoice{}
|
||||
expiresIn int
|
||||
paid bool
|
||||
redirectUrl templ.SafeURL
|
||||
qr templ.Component
|
||||
err error
|
||||
)
|
||||
|
||||
if err = db.QueryRowContext(ctx, ""+
|
||||
"SELECT user_id, msats, COALESCE(msats_received, 0), expires_at, confirmed_at, bolt11, COALESCE(description, '') "+
|
||||
"FROM invoices "+
|
||||
"WHERE hash = $1", hash).
|
||||
Scan(&inv.UserId, &inv.Msats, &inv.MsatsReceived, &inv.ExpiresAt, &inv.ConfirmedAt, &inv.Bolt11, &inv.Description); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
c.Logger().Error(err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if u.Id != inv.UserId {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
expiresIn = int(time.Until(inv.ExpiresAt).Seconds())
|
||||
paid = inv.MsatsReceived >= inv.Msats
|
||||
redirectUrl = toRedirectUrl(inv.Description)
|
||||
|
||||
qr = components.Invoice(hash, inv.Bolt11, int(inv.Msats), expiresIn, paid, redirectUrl)
|
||||
|
||||
return components.Modal(qr).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
marketRegexp = regexp.MustCompile("^create market (?P<id>[0-9]+)$")
|
||||
)
|
||||
|
||||
func toRedirectUrl(description string) templ.SafeURL {
|
||||
var m []string
|
||||
if m = marketRegexp.FindStringSubmatch(description); m != nil {
|
||||
marketId := m[marketRegexp.SubexpIndex("id")]
|
||||
return templ.SafeURL(fmt.Sprintf("/market/%s", marketId))
|
||||
}
|
||||
return "/"
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||
"github.com/a-h/templ"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
|
||||
"github.com/lightningnetwork/lnd/lntypes"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
)
|
||||
|
||||
func HandleCreate(sc context.Context) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var (
|
||||
db = sc.Db
|
||||
lnd = sc.Lnd
|
||||
tx *sql.Tx
|
||||
ctx = c.Request().Context()
|
||||
u = c.Get("session").(types.User)
|
||||
question = c.FormValue("question")
|
||||
description = c.FormValue("description")
|
||||
endDate = c.FormValue("end_date")
|
||||
hash lntypes.Hash
|
||||
paymentRequest string
|
||||
cost = lnwire.MilliSatoshi(1000e3)
|
||||
expiry = int64(600)
|
||||
expiresAt = time.Now().Add(time.Second * time.Duration(expiry))
|
||||
invoiceId int
|
||||
marketId int
|
||||
invDescription string
|
||||
qr templ.Component
|
||||
err error
|
||||
)
|
||||
// TODO: validation
|
||||
|
||||
if tx, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hash, paymentRequest, err = lnd.Client.AddInvoice(ctx,
|
||||
&invoicesrpc.AddInvoiceData{
|
||||
Value: cost,
|
||||
Expiry: expiry,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.QueryRowContext(ctx, ""+
|
||||
"INSERT INTO invoices (user_id, msats, hash, bolt11, expires_at) "+
|
||||
"VALUES ($1, $2, $3, $4, $5) "+
|
||||
"RETURNING id",
|
||||
u.Id, cost, hash.String(), paymentRequest, expiresAt).Scan(&invoiceId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.QueryRowContext(ctx, ""+
|
||||
"INSERT INTO markets (question, description, end_date, user_id, invoice_id) "+
|
||||
"VALUES ($1, $2, $3, $4, $5) "+
|
||||
"RETURNING id",
|
||||
question, description, endDate, u.Id, invoiceId).Scan(&marketId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
invDescription = fmt.Sprintf("create market %d", marketId)
|
||||
if _, err = tx.ExecContext(ctx, ""+
|
||||
"UPDATE invoices SET description = $1 WHERE id = $2",
|
||||
invDescription, invoiceId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
qr = components.Invoice(hash.String(), paymentRequest, int(cost), int(expiry), false, toRedirectUrl(invDescription))
|
||||
|
||||
return components.Modal(qr).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleMarket(sc context.Context) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var (
|
||||
db = sc.Db
|
||||
ctx = c.Request().Context()
|
||||
id = c.Param("id")
|
||||
m = types.Market{}
|
||||
u = types.User{}
|
||||
err error
|
||||
)
|
||||
|
||||
if err = db.QueryRowContext(ctx, ""+
|
||||
"SELECT m.id, m.question, m.description, m.created_at, m.end_date, "+
|
||||
"u.id, u.name, u.created_at, u.ln_pubkey, u.nostr_pubkey, msats "+
|
||||
"FROM markets m JOIN users u ON m.user_id = u.id "+
|
||||
"WHERE m.id = $1", id).Scan(
|
||||
&m.Id, &m.Question, &m.Description, &m.CreatedAt, &m.EndDate,
|
||||
&u.Id, &u.Name, &u.CreatedAt, &u.LnPubkey, &u.NostrPubkey, &u.Msats); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
m.User = u
|
||||
|
||||
return pages.Market(m).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||
}
|
||||
}
|
|
@ -26,11 +26,11 @@ func Session(sc context.Context) echo.MiddlewareFunc {
|
|||
if err = db.QueryRowContext(
|
||||
ctx,
|
||||
""+
|
||||
"SELECT u.id, u.created_at, COALESCE(u.ln_pubkey, ''), COALESCE(u.nostr_pubkey, ''), u.msats "+
|
||||
"SELECT u.id, u.name, u.created_at, COALESCE(u.ln_pubkey, ''), COALESCE(u.nostr_pubkey, ''), u.msats "+
|
||||
"FROM sessions s LEFT JOIN users u ON u.id = s.user_id "+
|
||||
"WHERE s.id = $1",
|
||||
cookie.Value).
|
||||
Scan(&u.Id, &u.CreatedAt, &u.LnPubkey, &u.NostrPubkey, &u.Msats); err == nil {
|
||||
Scan(&u.Id, &u.Name, &u.CreatedAt, &u.LnPubkey, &u.NostrPubkey, &u.Msats); err == nil {
|
||||
// session found
|
||||
c.Set("session", u)
|
||||
} else if err != sql.ErrNoRows {
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
templ Invoice(hash string, bolt11 string, msats int, expiresIn int, paid bool, redirectUrl templ.SafeURL) {
|
||||
<div class="p-5 border border-muted bg-background text-center font-mono">
|
||||
<div id="close" class="flex justify-end"><button class="w-fit text-muted hitbox hover:text-reset">X</button></div>
|
||||
<div>Payment Required</div>
|
||||
<div class="my-1">@Qr(bolt11, "lightning:"+bolt11)</div>
|
||||
<div class="my-1">{ format(msats) }</div>
|
||||
@InvoiceStatus(hash, expiresIn, paid, redirectUrl)
|
||||
<div class="none" id="bolt11-data" bolt11-data={ templ.JSONString(bolt11) } hx-preserve></div>
|
||||
<script type="text/javascript" id="bolt11-js" hx-preserve>
|
||||
var $ = selector => document.querySelector(selector)
|
||||
$("#close").addEventListener("click", function () {
|
||||
// abort in-flight polls and prevent new polls
|
||||
htmx.trigger("#poll", "htmx:abort")
|
||||
$("#poll").addEventListener("htmx:beforeRequest", e => e.preventDefault())
|
||||
})
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ InvoiceStatus(hash string, expiresIn int, paid bool, redirectUrl templ.SafeURL) {
|
||||
if paid {
|
||||
<div class="font-mono neon success my-1">PAID</div>
|
||||
<div
|
||||
id="poll"
|
||||
hx-get={ string(redirectUrl) }
|
||||
hx-trigger="load delay:3s"
|
||||
hx-target="#content"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="#content"
|
||||
hx-push-url="true"
|
||||
hx-select-oob="#modal" />
|
||||
}
|
||||
else if expiresIn <= 0 {
|
||||
<div class="font-mono neon error my-1">EXPIRED</div>
|
||||
} else {
|
||||
<!-- invoice is pending -->
|
||||
<div class="font-mono my-1" id="countdown" countdown-data={ templ.JSONString(expiresIn) } hx-preserve></div>
|
||||
<script type="text/javascript" id="countdown-js" hx-preserve>
|
||||
var $ = selector => document.querySelector(selector)
|
||||
var expiresIn = JSON.parse($("#countdown").getAttribute("countdown-data"))
|
||||
|
||||
function pad(num, places) {
|
||||
return String(num).padStart(places, "0")
|
||||
}
|
||||
|
||||
function _countdown() {
|
||||
var minutes = Math.floor(expiresIn / 60)
|
||||
var seconds = expiresIn % 60
|
||||
var text = `${pad(minutes, 2)}:${pad(seconds, 2)}`
|
||||
try {
|
||||
$("#countdown").innerText = text
|
||||
expiresIn--
|
||||
} catch {
|
||||
// countdown element disappeared
|
||||
clearInterval(interval)
|
||||
}
|
||||
}
|
||||
|
||||
_countdown()
|
||||
var interval = setInterval(_countdown, 1000)
|
||||
</script>
|
||||
<div
|
||||
id="poll"
|
||||
hx-get={ string(templ.SafeURL("/invoice/" + hash)) }
|
||||
hx-trigger="load delay:1s"
|
||||
hx-target="#modal"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="#modal" />
|
||||
}
|
||||
}
|
||||
|
||||
func format(msats int) string {
|
||||
sats := msats / 1000
|
||||
if sats == 1 {
|
||||
return fmt.Sprintf("%d sat", sats)
|
||||
}
|
||||
return fmt.Sprintf("%d sats", sats)
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package components
|
||||
|
||||
templ Modal(component templ.Component) {
|
||||
if component != nil {
|
||||
<div
|
||||
id="modal"
|
||||
class="fixed left-0 top-0 w-screen h-screen"
|
||||
>
|
||||
<div
|
||||
class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
>
|
||||
<div class="flex justify-center p-3 w-screen">
|
||||
@component
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
var $ = selector => document.querySelector(selector)
|
||||
$("#close").addEventListener("click", function () {
|
||||
$("#modal").removeAttribute("class")
|
||||
$("#modal").setAttribute("class", "hidden")
|
||||
$("#modal").innerHTML = ""
|
||||
})
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div id="modal" class="hidden"></div>
|
||||
}
|
||||
}
|
|
@ -13,11 +13,29 @@ templ Qr(value string, href string) {
|
|||
>
|
||||
<img src={ "data:image/jpeg;base64," + qrEncode(value) }/>
|
||||
</a>
|
||||
<small class="flex my-1 mx-auto">
|
||||
<span class="block w-[188px] overflow-hidden">{ value }</span>
|
||||
<button id="copy" class="ms-1 button w-[64px]" hx-preserve>copy</button>
|
||||
</small>
|
||||
@CopyButton(value)
|
||||
} else {
|
||||
<img src={ "data:image/jpeg;base64," + qrEncode(value) }/>
|
||||
}
|
||||
}
|
||||
|
||||
templ CopyButton(value string) {
|
||||
<div class="none" id="copy-data" copy-data={ templ.JSONString(value) } hx-preserve></div>
|
||||
<script type="text/javascript" id="copy-js" hx-preserve>
|
||||
var $ = selector => document.querySelector(selector)
|
||||
var value = JSON.parse($("#copy-data").getAttribute("copy-data"))
|
||||
$("#copy").onclick = function () {
|
||||
window.navigator.clipboard.writeText(value)
|
||||
$("#copy").textContent = "copied"
|
||||
setTimeout(() => $("#copy").textContent = "copy", 1000)
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
func qrEncode(value string) string {
|
||||
png, err := qrcode.Encode(value, qrcode.Medium, 256)
|
||||
if err != nil {
|
||||
|
|
|
@ -1,17 +1,95 @@
|
|||
package pages
|
||||
|
||||
import "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||
import (
|
||||
c "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||
"fmt"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
templ Index() {
|
||||
templ Index(markets []types.Market) {
|
||||
<html>
|
||||
@components.Head()
|
||||
<body class="container">
|
||||
@components.Nav()
|
||||
<div id="content" class="flex flex-col text-center">
|
||||
@components.Figlet("random", "delphi")
|
||||
<div class="font-mono my-3">A prediction market using the lightning network</div>
|
||||
<div class="font-mono my-3"><small>A prediction market using the lightning network</small></div>
|
||||
<div
|
||||
id="grid-container"
|
||||
class="border border-muted text-start"
|
||||
hx-target="#grid-container"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="#grid-container"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div class="border border-muted">
|
||||
<button hx-get="/" class={ tabStyle(ctx.Value(c.ReqPathContextKey).(string), "/") }>markets</button>
|
||||
<button hx-get="/create" class={ tabStyle(ctx.Value(c.ReqPathContextKey).(string), "/create") }>create</button>
|
||||
</div>
|
||||
if ctx.Value(c.ReqPathContextKey).(string) == "/" {
|
||||
<div class="grid grid-cols-[auto_fit-content(10%)_fit-content(10%)]">
|
||||
for _, m := range markets {
|
||||
<span class="ps-3 border-b border-muted pb-3 mt-3">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/market/%d", m.Id)) }>{ m.Question }</a>
|
||||
<div class="text-small text-muted">{m.User.Name} / {humanize.Time(m.CreatedAt)} / {humanize.Time(m.EndDate)}</div>
|
||||
</span>
|
||||
<span class="px-3 border-b border-muted pb-3 mt-3 flex"><div class="self-center">51%</div></span>
|
||||
<span class="pe-3 border-b border-muted pb-3 mt-3 flex"><div class="self-center">0</div></span>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<form
|
||||
hx-post="/create"
|
||||
hx-target="#modal"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="#modal"
|
||||
class="flex flex-col mx-3"
|
||||
>
|
||||
<label class="my-1" for="question">question</label>
|
||||
<input
|
||||
id="question"
|
||||
name="question"
|
||||
type="text"
|
||||
class="my-1 p-1 text-black"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<div class="mt-3 mb-1">
|
||||
<label for="description">description</label>
|
||||
<span class="px-1 text-small text-muted">optional</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
type="text"
|
||||
class="my-1 p-1 text-black"
|
||||
></textarea>
|
||||
<label class="mt-3" for="end_date">end date</label>
|
||||
<input
|
||||
id="end_date"
|
||||
name="end_date"
|
||||
type="date"
|
||||
class="my-1 p-1 text-black"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<button type="submit" class="mt-3">submit</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@components.Modal(nil)
|
||||
@components.Footer()
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
func tabStyle(path string, tab string) string {
|
||||
class := "!no-underline"
|
||||
if path == tab {
|
||||
class += " font-bold border-b-none"
|
||||
}
|
||||
return class
|
||||
}
|
||||
|
|
|
@ -18,7 +18,6 @@ templ LnAuth(lnurl string, action string) {
|
|||
hx-push-url="true"
|
||||
>
|
||||
@components.Qr(lnurl, "lightning:"+lnurl)
|
||||
<small class="mx-auto w-[256px] my-1 break-words">{ lnurl }</small>
|
||||
</div>
|
||||
<div hx-get="/session" hx-trigger="every 1s" hx-swap="none"></div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
// TODO: Add countdown? Use or at least show somewhere precise timestamps?
|
||||
|
||||
templ Market(m types.Market) {
|
||||
<html>
|
||||
@components.Head()
|
||||
<body class="container">
|
||||
@components.Nav()
|
||||
<div id="content" class="flex flex-col">
|
||||
<small>
|
||||
@components.Figlet("random", "market")
|
||||
</small>
|
||||
<div class="text-center font-bold my-1">{ m.Question }</div>
|
||||
<div class="text-center text-muted my-1">{humanize.Time(m.EndDate)}</div>
|
||||
<div class="text-center text-muted my-1"></div>
|
||||
<blockquote cite="ekzyis" class="p-4 mb-4 border-s-4 border-muted">
|
||||
{ m.Description }
|
||||
<div class="text-muted text-right pt-4">― {m.User.Name}, { humanize.Time(m.CreatedAt) }</div>
|
||||
</blockquote>
|
||||
<div class="flex justify-center my-1">
|
||||
<button class="neon success mx-1">BET YES</button>
|
||||
<button class="neon error mx-1">BET NO</button>
|
||||
</div>
|
||||
</div>
|
||||
@components.Modal(nil)
|
||||
@components.Footer()
|
||||
</body>
|
||||
</html>
|
||||
}
|
|
@ -23,7 +23,9 @@ templ User(user *types.User) {
|
|||
hx-select-oob="#nav"
|
||||
>
|
||||
<div class="font-bold">id</div>
|
||||
<div>{ strconv.Itoa(user.Id) }</div>
|
||||
<div>{ strconv.Itoa(user.Id) }</div>
|
||||
<div class="font-bold">name</div>
|
||||
<div>{ user.Name }</div>
|
||||
<div class="font-bold">joined</div>
|
||||
<div>{ user.CreatedAt.Format(time.DateOnly) }</div>
|
||||
<div class="font-bold">sats</div>
|
||||
|
|
|
@ -14,6 +14,9 @@ func Init(e *echo.Echo, sc Context) {
|
|||
e.Use(middleware.Session(sc))
|
||||
|
||||
e.GET("/", handler.HandleIndex(sc))
|
||||
e.GET("/create", handler.HandleIndex(sc))
|
||||
e.POST("/create", handler.HandleCreate(sc), middleware.SessionGuard(sc))
|
||||
e.GET("/market/:id", handler.HandleMarket(sc))
|
||||
e.GET("/about", handler.HandleAbout(sc))
|
||||
|
||||
e.GET("/login", handler.HandleAuth(sc, "login"))
|
||||
|
@ -25,4 +28,6 @@ func Init(e *echo.Echo, sc Context) {
|
|||
|
||||
e.GET("/user", handler.HandleUser(sc), middleware.SessionGuard(sc))
|
||||
e.POST("/logout", handler.HandleLogout(sc), middleware.SessionGuard(sc))
|
||||
|
||||
e.GET("/invoice/:hash", handler.HandleInvoice(sc), middleware.SessionGuard(sc))
|
||||
}
|
||||
|
|
|
@ -6,7 +6,12 @@ module.exports = {
|
|||
center: true,
|
||||
padding: '1rem'
|
||||
},
|
||||
extend: {},
|
||||
extend: {
|
||||
colors: {
|
||||
'background': '191d21',
|
||||
'muted': '#6c757d',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
function ({ addComponents }) {
|
||||
|
|
|
@ -1,11 +1,39 @@
|
|||
package types
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gopkg.in/guregu/null.v4"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Id int
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
LnPubkey string
|
||||
NostrPubkey string
|
||||
LnPubkey null.String
|
||||
NostrPubkey null.String
|
||||
Msats int64
|
||||
}
|
||||
|
||||
type Invoice struct {
|
||||
Id int
|
||||
UserId int
|
||||
Msats int64
|
||||
MsatsReceived int64
|
||||
Hash string
|
||||
Bolt11 string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
ConfirmedAt null.Time
|
||||
HeldSince bool
|
||||
Description string
|
||||
}
|
||||
|
||||
type Market struct {
|
||||
Id int
|
||||
User User
|
||||
Question string
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
EndDate time.Time
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue