Skip to content

Commit f336479

Browse files
committed
Ticket scanner
1 parent 0d41cb3 commit f336479

13 files changed

Lines changed: 10457 additions & 8 deletions

File tree

public/js/jsQR.js

Lines changed: 10100 additions & 0 deletions
Large diffs are not rendered by default.

public/style.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7321,6 +7321,9 @@ video {
73217321
flex-grow: 1;
73227322
flex-shrink: 1;
73237323
}
7324+
.pre-line {
7325+
white-space: pre-line;
7326+
}
73247327
@media screen and (min-width: 35em) {
73257328
.flex-fair-ns {
73267329
flex-basis: 1px;

src/hmnurl/urls.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@ func BuildTicketsAdminReserve(urlSlug string) string {
158158
return Url(fmt.Sprintf("/tickets/admin/%s/reserve", urlSlug), nil)
159159
}
160160

161+
var RegexTicketsAdminScan = regexp.MustCompile("^/tickets/admin/(?P<urlslug>[^/]+)/scan$")
162+
163+
func BuildTicketsAdminScan(urlSlug string) string {
164+
defer CatchPanic()
165+
return Url(fmt.Sprintf("/tickets/admin/%s/scan", urlSlug), nil)
166+
}
167+
161168
var RegexTicketPurchase = regexp.MustCompile("^/tickets/(?P<urlslug>[^/]+)/purchase$")
162169

163170
func BuildTicketPurchase(urlSlug string) string {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package migrations
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"git.handmade.network/hmn/hmn/src/migration/types"
8+
"github.com/jackc/pgx/v5"
9+
)
10+
11+
func init() {
12+
registerMigration(AddCheckedInToTicket{})
13+
}
14+
15+
type AddCheckedInToTicket struct{}
16+
17+
func (m AddCheckedInToTicket) Version() types.MigrationVersion {
18+
return types.MigrationVersion(time.Date(2026, 5, 12, 15, 13, 43, 0, time.UTC))
19+
}
20+
21+
func (m AddCheckedInToTicket) Name() string {
22+
return "AddCheckedInToTicket"
23+
}
24+
25+
func (m AddCheckedInToTicket) Description() string {
26+
return "Add checked-in to ticket"
27+
}
28+
29+
func (m AddCheckedInToTicket) Up(ctx context.Context, tx pgx.Tx) error {
30+
_, err := tx.Exec(ctx,
31+
`
32+
ALTER TABLE ticket
33+
ADD COLUMN checked_in BOOLEAN NOT NULL DEFAULT FALSE;
34+
`,
35+
)
36+
return err
37+
}
38+
39+
func (m AddCheckedInToTicket) Down(ctx context.Context, tx pgx.Tx) error {
40+
_, err := tx.Exec(ctx,
41+
`
42+
ALTER TABLE ticket
43+
DROP COLUMN checked_in;
44+
`,
45+
)
46+
return err
47+
}

src/models/ticket.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ type Ticket struct {
1414
OwnerName string `db:"name"`
1515
OwnerEmail string `db:"email"`
1616

17-
CheckedIn bool
17+
CheckedIn bool `db:"checked_in"`
1818

1919
// Whether the ticket has been reserved ahead of time and therefore has no corresponding payment.
2020
Reserved bool `db:"reserved"`

src/rawdata/css/core.css

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ video {
102102
flex-shrink: 1;
103103
}
104104

105+
.pre-line {
106+
white-space: pre-line;
107+
}
108+
105109
@media screen and (min-width: 35em) {
106110
.flex-fair-ns {
107111
flex-basis: 1px;
@@ -905,4 +909,4 @@ on our SVGs to ensure that they naturally render at the right size.)
905909
& th {
906910
border: 1px solid var(--border-color);
907911
}
908-
}
912+
}

src/templates/mapping.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,7 @@ func TicketToTemplate(t *models.Ticket) Ticket {
550550
Reserved: t.Reserved,
551551
Pending: t.Pending,
552552
Notes: t.Notes,
553+
CheckedIn: t.CheckedIn,
553554

554555
Url: hmnurl.BuildTicketSingle(t.ID.String()),
555556
DeleteUrl: hmnurl.BuildTicketDelete(t.ID.String()),

src/templates/src/tickets_admin_event.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
<div class="pa3 flex flex-column g3">
120120
<div>
121121
<div class="f3">Tickets</div>
122+
<a href="{{ .ScannerUrl }}">Scan tickets</a>
122123
<div>
123124
<div><b>Total:</b> {{ add .TicketsEvent.Metadata.SoldTickets .TicketsEvent.Metadata.ReservedTickets }}</div>
124125
<div><b>Pending:</b> {{ .TicketsEvent.Metadata.PendingTickets }}</div>
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
{{ template "base-2024.html" . }}
2+
3+
{{ define "content" }}
4+
<div class="flex justify-center">
5+
<div class="pv3 ph3 ph0-ns w6 mw-site flex flex-column g3">
6+
<div class="f4 ma3 tc">{{ .Event.Name }}</div>
7+
8+
<div>
9+
<canvas id="scanner_canvas" class="db w-100"></canvas>
10+
<div class="bg3 pa2 pre-line" id="scan_status">Initializing...</div>
11+
</div>
12+
13+
<div id="ticket_details" class="dn flex-column g3">
14+
<div class="center f3" id="ticket_name"></div>
15+
<div id="ticket_notes"></div>
16+
<div class="flex flex-row g2">
17+
<button class="flex-grow-1" id="rescan">Rescan</button>
18+
<button class="flex-grow-1" id="checkin">Check-in</button>
19+
</div>
20+
</div>
21+
</div>
22+
</div>
23+
24+
<script type="application/javascript" src="{{ static "js/jsQR.js" }}"></script>
25+
<script>
26+
const ticketScanUrl = "{{ .TicketScanUrl }}";
27+
const csrf = JSON.parse({{ csrftokenjs .Session }});
28+
let video = document.createElement("VIDEO");
29+
let canvas = document.querySelector("#scanner_canvas");
30+
let ctx = canvas.getContext("2d");
31+
let status = document.querySelector("#scan_status");
32+
let name = document.querySelector("#ticket_name");
33+
let notes = document.querySelector("#ticket_notes");
34+
let detailsContainer = document.querySelector("#ticket_details");
35+
let rescanButton = document.querySelector("#rescan");
36+
let checkinButton = document.querySelector("#checkin");
37+
38+
let scanning = false;
39+
40+
let mediaParams = {
41+
video: {
42+
facingMode: "environment",
43+
aspectRatio: { exact: 1.0 },
44+
width: { max: 720 },
45+
frameRate: 30,
46+
},
47+
audio: false,
48+
};
49+
50+
navigator.mediaDevices.getUserMedia(mediaParams).then((stream) => {
51+
video.srcObject = stream;
52+
video.setAttribute("playsinline", true);
53+
video.play();
54+
runScanner();
55+
});
56+
57+
async function runScanner() {
58+
while (true) {
59+
let code = null;
60+
detailsContainer.style.display = "none";
61+
status.textContent = "Scanning...";
62+
let lastScanTime = performance.now();
63+
while (!code) {
64+
await nextScanTime();
65+
code = scanQR();
66+
}
67+
68+
status.textContent = "Fetching ticket info...";
69+
let ticketInfo = await sendTicketCommand(code, "info");
70+
detailsContainer.style.display = "flex";
71+
status.textContent = ticketInfo.message;
72+
let action = await userActionForTicket(ticketInfo);
73+
if (action == "checkin") {
74+
status.textContent = "Checking in...";
75+
ticketInfo = await sendTicketCommand(code, "checkin");
76+
status.textContent = ticketInfo.message;
77+
await userActionForTicket(ticketInfo);
78+
}
79+
}
80+
}
81+
82+
function nextScanTime() {
83+
return new Promise((resolve, reject) => {
84+
requestAnimationFrame(resolve);
85+
})
86+
}
87+
88+
function scanQR() {
89+
if (video.readyState == video.HAVE_ENOUGH_DATA) {
90+
canvas.width = video.videoWidth;
91+
canvas.height = video.videoHeight;
92+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
93+
let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
94+
95+
let code = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: "dontInvert" });
96+
if (code && code.data != "") {
97+
console.log("Found code", code);
98+
99+
ctx.save();
100+
ctx.lineWidth = 3;
101+
ctx.strokeStyle = "red";
102+
ctx.beginPath();
103+
ctx.moveTo(code.location.bottomLeftCorner.x, code.location.bottomLeftCorner.y);
104+
ctx.lineTo(code.location.bottomRightCorner.x, code.location.bottomRightCorner.y);
105+
ctx.lineTo(code.location.topRightCorner.x, code.location.topRightCorner.y);
106+
ctx.lineTo(code.location.topLeftCorner.x, code.location.topLeftCorner.y);
107+
ctx.lineTo(code.location.bottomLeftCorner.x, code.location.bottomLeftCorner.y);
108+
ctx.stroke();
109+
ctx.restore();
110+
111+
return code.data;
112+
}
113+
}
114+
115+
return null;
116+
}
117+
118+
async function sendTicketCommand(code, command) {
119+
return new Promise(async (resolve, reject) => {
120+
let ticketInfo = {
121+
success: false,
122+
name: "",
123+
notes: "",
124+
checkedIn: false,
125+
message: "",
126+
};
127+
128+
try {
129+
let payload = new FormData();
130+
payload.append(csrf.field, csrf.token);
131+
payload.append("ticketData", code);
132+
payload.append("command", command);
133+
let response = await fetch(ticketScanUrl, { method: "POST", body: payload });
134+
let responseJson = await response.json()
135+
if (response.ok) {
136+
ticketInfo.success = true;
137+
ticketInfo.name = responseJson.name;
138+
ticketInfo.notes = responseJson.notes;
139+
ticketInfo.checkedIn = responseJson.checkedIn;
140+
ticketInfo.message = responseJson.message;
141+
} else {
142+
let errorMessage = "Scanned:\n" + code + "\n\n";
143+
errorMessage += responseJson.rejectReason || responseJson.errors.map((e) => e.Message).join(", ");
144+
ticketInfo.message = errorMessage;
145+
}
146+
} catch (err) {
147+
ticketInfo.message = err.message || err;
148+
}
149+
150+
resolve(ticketInfo);
151+
});
152+
}
153+
154+
function userActionForTicket(ticketInfo) {
155+
let abortController = new AbortController();
156+
157+
return new Promise((resolve, reject) => {
158+
name.style.display = (ticketInfo.name ? "block" : "none");
159+
name.textContent = ticketInfo.name;
160+
161+
notes.style.display = (ticketInfo.notes ? "block" : "none");
162+
notes.textContent = ticketInfo.notes;
163+
164+
checkinButton.textContent = (ticketInfo.checkedIn ? "Already checked in" : "Check-in");
165+
checkinButton.disabled = (!ticketInfo.success || ticketInfo.checkedIn);
166+
167+
checkinButton.addEventListener("click", () => {
168+
resolve("checkin");
169+
}, { signal: abortController.signal });
170+
rescanButton.addEventListener("click", () => {
171+
resolve("rescan");
172+
}, { signal: abortController.signal });
173+
}).then((action) => {
174+
abortController.abort();
175+
return action;
176+
});
177+
}
178+
</script>
179+
{{ end }}

src/templates/src/tickets_single_edit.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
<div class="pa3 input-group">
3939
<div>
4040
<input type="checkbox" id="checkedin" name="checkedin" value="checkedin" {{ if .Ticket.CheckedIn }}checked{{ end }} />
41-
<label for="reserved">Checked in</label>
41+
<label for="checkedin">Checked in</label>
4242
</div>
4343
</div>
4444
<div class="pa3 input-group">

0 commit comments

Comments
 (0)