studio-os 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +7 -0
- package/.env.example +34 -0
- package/CHANGELOG.md +86 -0
- package/Dockerfile +25 -0
- package/LICENSE +21 -0
- package/PROGRESS.md +60 -0
- package/README.md +253 -0
- package/SPEC.md +130 -0
- package/docker-compose.yml +26 -0
- package/docs/screenshots/admin-client.png +0 -0
- package/docs/screenshots/admin-dashboard.png +0 -0
- package/docs/screenshots/admin-reports.png +0 -0
- package/docs/screenshots/admin-roster.png +0 -0
- package/docs/screenshots/admin-schedule.png +0 -0
- package/docs/screenshots/public-booking.png +0 -0
- package/docs/screenshots/public-buy.png +0 -0
- package/docs/screenshots/public-schedule.png +0 -0
- package/package.json +54 -0
- package/public/css/custom.css +40 -0
- package/public/js/icon.svg +4 -0
- package/public/js/sw.js +23 -0
- package/public/manifest.json +17 -0
- package/public/vendor/htmx.min.js +1 -0
- package/public/vendor/pico.min.css +4 -0
- package/scripts/import-mindbody.mjs +109 -0
- package/scripts/seed.mjs +147 -0
- package/src/app.js +141 -0
- package/src/db/002-instructor-role.sql +26 -0
- package/src/db/index.js +82 -0
- package/src/db/schema.sql +164 -0
- package/src/lib/ratelimit.js +50 -0
- package/src/lib/time.js +87 -0
- package/src/routes/admin.js +692 -0
- package/src/routes/auth.js +34 -0
- package/src/routes/instructor.js +82 -0
- package/src/routes/me.js +101 -0
- package/src/routes/public.js +215 -0
- package/src/routes/setup.js +55 -0
- package/src/routes/webhooks.js +28 -0
- package/src/server.js +29 -0
- package/src/services/auth.js +81 -0
- package/src/services/booking.js +261 -0
- package/src/services/importer.js +201 -0
- package/src/services/mailer.js +92 -0
- package/src/services/schedule.js +71 -0
- package/src/services/stripe.js +169 -0
- package/test/booking.test.js +346 -0
- package/test/fixtures/mindbody-clients.csv +9 -0
- package/test/fixtures/mindbody-passes.csv +8 -0
- package/test/helpers.js +59 -0
- package/test/importer.test.js +173 -0
- package/test/instructor.test.js +217 -0
- package/test/ratelimit.test.js +123 -0
- package/test/schedule.test.js +102 -0
- package/test/stripe.test.js +249 -0
- package/test/views.test.js +38 -0
- package/test/web.test.js +392 -0
- package/views/admin/class_types.ejs +45 -0
- package/views/admin/client.ejs +149 -0
- package/views/admin/clients.ejs +33 -0
- package/views/admin/dashboard.ejs +55 -0
- package/views/admin/import.ejs +39 -0
- package/views/admin/instructor_classes.ejs +21 -0
- package/views/admin/instructor_new.ejs +15 -0
- package/views/admin/instructors.ejs +49 -0
- package/views/admin/products.ejs +83 -0
- package/views/admin/reports.ejs +34 -0
- package/views/admin/roster.ejs +58 -0
- package/views/admin/rules.ejs +71 -0
- package/views/admin/schedule.ejs +50 -0
- package/views/admin/settings.ejs +53 -0
- package/views/error.ejs +7 -0
- package/views/instructor/roster.ejs +27 -0
- package/views/instructor/schedule.ejs +21 -0
- package/views/login.ejs +11 -0
- package/views/partials/foot.ejs +11 -0
- package/views/partials/head.ejs +54 -0
- package/views/public/book_result.ejs +26 -0
- package/views/public/buy.ejs +37 -0
- package/views/public/buy_manual.ejs +17 -0
- package/views/public/buy_thanks.ejs +7 -0
- package/views/public/class.ejs +29 -0
- package/views/public/magic_request.ejs +21 -0
- package/views/public/me.ejs +47 -0
- package/views/public/schedule.ejs +49 -0
- package/views/setup.ejs +53 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<h3>Weekly schedule rules</h3>
|
|
3
|
+
<p><small>Rules materialize into bookable class instances on a rolling 8-week horizon (at boot, daily, and whenever a rule changes).</small></p>
|
|
4
|
+
<% const days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; %>
|
|
5
|
+
<details open>
|
|
6
|
+
<summary role="button" class="outline">Add weekly rule</summary>
|
|
7
|
+
<form method="post" action="/admin/rules"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
8
|
+
<div class="grid">
|
|
9
|
+
<label>Class
|
|
10
|
+
<select name="class_type_id" required>
|
|
11
|
+
<% for (const t of types) { %><option value="<%= t.id %>"><%= t.name %></option><% } %>
|
|
12
|
+
</select>
|
|
13
|
+
</label>
|
|
14
|
+
<label>Instructor
|
|
15
|
+
<select name="instructor_id">
|
|
16
|
+
<option value="">—</option>
|
|
17
|
+
<% for (const i of instructors) { %><option value="<%= i.id %>"><%= i.name %></option><% } %>
|
|
18
|
+
</select>
|
|
19
|
+
</label>
|
|
20
|
+
<label>Day
|
|
21
|
+
<select name="weekday"><% days.forEach((d, idx) => { %><option value="<%= idx %>"><%= d %></option><% }) %></select>
|
|
22
|
+
</label>
|
|
23
|
+
<label>Start (HH:MM) <input name="start_time" required pattern="[0-2][0-9]:[0-5][0-9]" placeholder="09:00"></label>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="grid">
|
|
26
|
+
<label>Capacity override <input type="number" name="capacity_override" min="1" placeholder="(class default)"></label>
|
|
27
|
+
<label>Active from <input type="date" name="active_from"></label>
|
|
28
|
+
<label>Active until <input type="date" name="active_until"></label>
|
|
29
|
+
</div>
|
|
30
|
+
<button>Add rule</button>
|
|
31
|
+
</form>
|
|
32
|
+
</details>
|
|
33
|
+
<% if (!rules.length) { %><p>No rules yet.</p><% } %>
|
|
34
|
+
<% for (const rule of rules) { %>
|
|
35
|
+
<details>
|
|
36
|
+
<summary>
|
|
37
|
+
<strong><%= days[rule.weekday] %> <%= rule.start_time %></strong> — <%= rule.class_name %>
|
|
38
|
+
<% if (rule.instructor_name) { %><small>(<%= rule.instructor_name %>)</small><% } %>
|
|
39
|
+
<%= rule.active ? '' : ' · INACTIVE' %>
|
|
40
|
+
</summary>
|
|
41
|
+
<form method="post" action="/admin/rules/<%= rule.id %>"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
42
|
+
<div class="grid">
|
|
43
|
+
<label>Class
|
|
44
|
+
<select name="class_type_id">
|
|
45
|
+
<% for (const t of types) { %><option value="<%= t.id %>" <%= t.id === rule.class_type_id ? 'selected' : '' %>><%= t.name %></option><% } %>
|
|
46
|
+
</select>
|
|
47
|
+
</label>
|
|
48
|
+
<label>Instructor
|
|
49
|
+
<select name="instructor_id">
|
|
50
|
+
<option value="">—</option>
|
|
51
|
+
<% for (const i of instructors) { %><option value="<%= i.id %>" <%= i.id === rule.instructor_id ? 'selected' : '' %>><%= i.name %></option><% } %>
|
|
52
|
+
</select>
|
|
53
|
+
</label>
|
|
54
|
+
<label>Day
|
|
55
|
+
<select name="weekday"><% days.forEach((d, idx) => { %><option value="<%= idx %>" <%= idx === rule.weekday ? 'selected' : '' %>><%= d %></option><% }) %></select>
|
|
56
|
+
</label>
|
|
57
|
+
<label>Start <input name="start_time" value="<%= rule.start_time %>"></label>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="grid">
|
|
60
|
+
<label>Capacity override <input type="number" name="capacity_override" value="<%= rule.capacity_override || '' %>"></label>
|
|
61
|
+
<label>Active from <input type="date" name="active_from" value="<%= rule.active_from || '' %>"></label>
|
|
62
|
+
<label>Active until <input type="date" name="active_until" value="<%= rule.active_until || '' %>"></label>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="grid">
|
|
65
|
+
<button>Save & regenerate</button>
|
|
66
|
+
<button name="action" value="delete" class="outline secondary" onclick="return confirm('Deactivate this rule? Future empty classes will be removed.')">Deactivate</button>
|
|
67
|
+
</div>
|
|
68
|
+
</form>
|
|
69
|
+
</details>
|
|
70
|
+
<% } %>
|
|
71
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<h3>Schedule — next 14 days</h3>
|
|
3
|
+
<details>
|
|
4
|
+
<summary role="button" class="outline">Add one-off class</summary>
|
|
5
|
+
<form method="post" action="/admin/schedule"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
6
|
+
<div class="grid">
|
|
7
|
+
<label>Class
|
|
8
|
+
<select name="class_type_id" required>
|
|
9
|
+
<% for (const t of types) { %><option value="<%= t.id %>"><%= t.name %></option><% } %>
|
|
10
|
+
</select>
|
|
11
|
+
</label>
|
|
12
|
+
<label>Instructor
|
|
13
|
+
<select name="instructor_id"><option value="">—</option>
|
|
14
|
+
<% for (const i of instructors) { %><option value="<%= i.id %>"><%= i.name %></option><% } %>
|
|
15
|
+
</select>
|
|
16
|
+
</label>
|
|
17
|
+
<label>Date <input type="date" name="date" required></label>
|
|
18
|
+
<label>Time <input name="time" required pattern="[0-2][0-9]:[0-5][0-9]" placeholder="18:30"></label>
|
|
19
|
+
<label>Capacity <input type="number" name="capacity" min="1" placeholder="(class default)"></label>
|
|
20
|
+
</div>
|
|
21
|
+
<label>Notes <input name="notes"></label>
|
|
22
|
+
<button>Create class</button>
|
|
23
|
+
</form>
|
|
24
|
+
</details>
|
|
25
|
+
<%
|
|
26
|
+
let lastDay = '';
|
|
27
|
+
for (const inst of instances) {
|
|
28
|
+
const day = fmtDt(inst.starts_at, { weekday: 'long', day: 'numeric', month: 'long', hour: undefined, minute: undefined });
|
|
29
|
+
%>
|
|
30
|
+
<% if (day !== lastDay) { lastDay = day; %><h5 class="day-header"><%= day %></h5><% } %>
|
|
31
|
+
<article class="class-row <%= inst.status === 'cancelled' ? 'cancelled' : '' %>" style="border-left: 6px solid <%= inst.color %>">
|
|
32
|
+
<div class="grid">
|
|
33
|
+
<div>
|
|
34
|
+
<strong><%= fmtDt(inst.starts_at, { weekday: undefined, day: undefined, month: undefined }) %></strong>
|
|
35
|
+
· <a href="/admin/instances/<%= inst.id %>"><%= inst.class_name %></a>
|
|
36
|
+
<% if (inst.instructor_name) { %><small>(<%= inst.instructor_name %>)</small><% } %>
|
|
37
|
+
<% if (inst.status === 'cancelled') { %><mark>cancelled</mark><% } %>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="right">
|
|
40
|
+
<%= inst.booked_count %>/<%= inst.capacity %><% if (inst.waitlist_count) { %> +<%= inst.waitlist_count %> WL<% } %>
|
|
41
|
+
<% if (inst.status === 'scheduled') { %>
|
|
42
|
+
<form method="post" action="/admin/instances/<%= inst.id %>/cancel" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
43
|
+
<button class="slim outline secondary" onclick="return confirm('Cancel this class? All bookings are refunded and notified.')">Cancel</button>
|
|
44
|
+
</form>
|
|
45
|
+
<% } %>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</article>
|
|
49
|
+
<% } %>
|
|
50
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<h3>Settings</h3>
|
|
3
|
+
<form method="post" action="/admin/settings"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
4
|
+
<div class="grid">
|
|
5
|
+
<label>Studio name <input name="studio_name" value="<%= settings.studio_name %>"></label>
|
|
6
|
+
<label>Timezone <input name="timezone" value="<%= settings.timezone %>" placeholder="Asia/Hong_Kong"></label>
|
|
7
|
+
<label>Currency <input name="currency" maxlength="3" value="<%= settings.currency %>"></label>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="grid">
|
|
10
|
+
<label>Cancellation window (hours)
|
|
11
|
+
<input type="number" name="cancellation_window_hours" min="0" value="<%= settings.cancellation_window_hours %>">
|
|
12
|
+
</label>
|
|
13
|
+
<label>Late-cancel policy
|
|
14
|
+
<select name="late_cancel_policy">
|
|
15
|
+
<option value="forfeit" <%= settings.late_cancel_policy === 'forfeit' ? 'selected' : '' %>>Forfeit credit (default)</option>
|
|
16
|
+
<option value="refund" <%= settings.late_cancel_policy === 'refund' ? 'selected' : '' %>>Refund credit anyway</option>
|
|
17
|
+
</select>
|
|
18
|
+
</label>
|
|
19
|
+
</div>
|
|
20
|
+
<label>Waiver text (Markdown)
|
|
21
|
+
<textarea name="waiver_markdown" rows="8"><%= settings.waiver_markdown %></textarea>
|
|
22
|
+
</label>
|
|
23
|
+
<button>Save settings</button>
|
|
24
|
+
</form>
|
|
25
|
+
<div class="grid">
|
|
26
|
+
<article>
|
|
27
|
+
<h5>Integrations (set via environment / .env)</h5>
|
|
28
|
+
<p><small>
|
|
29
|
+
Stripe secret key: <strong><%= stripeConfigured ? 'set' : 'not set' %></strong><br>
|
|
30
|
+
Stripe webhook secret: <strong><%= locals.services ? '' : '' %><%= stripeConfigured ? (settings.stripe_webhook_hint || 'see .env') : '—' %></strong><br>
|
|
31
|
+
SMTP: <strong><%= smtpConfigured ? 'configured' : 'not set (emails go to data/outbox/, links shown on screen)' %></strong>
|
|
32
|
+
</small></p>
|
|
33
|
+
</article>
|
|
34
|
+
<article>
|
|
35
|
+
<h5>Backup</h5>
|
|
36
|
+
<p><small>Downloads a consistent snapshot of the SQLite database (VACUUM INTO).</small></p>
|
|
37
|
+
<a href="/admin/backup" role="button" class="outline">Download backup</a>
|
|
38
|
+
</article>
|
|
39
|
+
</div>
|
|
40
|
+
<% if (user && user.role === 'owner') { %>
|
|
41
|
+
<article>
|
|
42
|
+
<h5>Add staff account</h5>
|
|
43
|
+
<form method="post" action="/admin/staff"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
44
|
+
<div class="grid">
|
|
45
|
+
<label>Name <input name="name"></label>
|
|
46
|
+
<label>Email <input type="email" name="email" required></label>
|
|
47
|
+
<label>Password <input type="password" name="password" minlength="8" required></label>
|
|
48
|
+
</div>
|
|
49
|
+
<button class="slim">Create staff login</button>
|
|
50
|
+
</form>
|
|
51
|
+
</article>
|
|
52
|
+
<% } %>
|
|
53
|
+
<%- include('../partials/foot') %>
|
package/views/error.ejs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<hgroup>
|
|
3
|
+
<h3><%= inst.class_name %> roster</h3>
|
|
4
|
+
<p><%= fmtDt(inst.starts_at) %> · <%= inst.duration_min %> min · capacity <%= inst.capacity %>
|
|
5
|
+
<% if (inst.status === 'cancelled') { %> · <mark>CANCELLED</mark><% } %></p>
|
|
6
|
+
</hgroup>
|
|
7
|
+
<p><a href="/instructor/schedule">← Back to my schedule</a></p>
|
|
8
|
+
<table>
|
|
9
|
+
<thead><tr><th>Attendee</th><th>Status</th><th></th></tr></thead>
|
|
10
|
+
<tbody>
|
|
11
|
+
<% for (const b of roster) { %>
|
|
12
|
+
<tr>
|
|
13
|
+
<td><%= b.client_name %></td>
|
|
14
|
+
<td><%= b.status === 'attended' ? 'checked-in' : b.status.replace(/_/g, ' ') %></td>
|
|
15
|
+
<td>
|
|
16
|
+
<% if (b.status === 'booked') { %>
|
|
17
|
+
<form method="post" action="/instructor/classes/<%= inst.id %>/checkin/<%= b.id %>" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
18
|
+
<button class="slim">Check in</button>
|
|
19
|
+
</form>
|
|
20
|
+
<% } %>
|
|
21
|
+
</td>
|
|
22
|
+
</tr>
|
|
23
|
+
<% } %>
|
|
24
|
+
<% if (!roster.length) { %><tr><td colspan="3">No bookings yet.</td></tr><% } %>
|
|
25
|
+
</tbody>
|
|
26
|
+
</table>
|
|
27
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<hgroup>
|
|
3
|
+
<h3>My schedule</h3>
|
|
4
|
+
<p>Classes you are assigned to teach, from today onward.</p>
|
|
5
|
+
</hgroup>
|
|
6
|
+
<table>
|
|
7
|
+
<thead><tr><th>When</th><th>Class</th><th>Booked</th><th></th></tr></thead>
|
|
8
|
+
<tbody>
|
|
9
|
+
<% for (const c of classes) { %>
|
|
10
|
+
<tr>
|
|
11
|
+
<td><%= fmtDt(c.starts_at) %></td>
|
|
12
|
+
<td><span class="dot" style="background:<%= c.color %>"></span> <%= c.class_name %>
|
|
13
|
+
<% if (c.status === 'cancelled') { %> <mark>CANCELLED</mark><% } %></td>
|
|
14
|
+
<td><%= c.booked_count %>/<%= c.capacity %></td>
|
|
15
|
+
<td><a href="/instructor/classes/<%= c.id %>/roster" role="button" class="slim outline">Roster</a></td>
|
|
16
|
+
</tr>
|
|
17
|
+
<% } %>
|
|
18
|
+
<% if (!classes.length) { %><tr><td colspan="4">No upcoming classes assigned to you yet — ask the studio admin.</td></tr><% } %>
|
|
19
|
+
</tbody>
|
|
20
|
+
</table>
|
|
21
|
+
<%- include('../partials/foot') %>
|
package/views/login.ejs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%- include('partials/head', { adminNav: false }) %>
|
|
2
|
+
<article class="narrow">
|
|
3
|
+
<h2>Staff login</h2>
|
|
4
|
+
<% if (error) { %><p class="flash error" role="alert"><%= error %></p><% } %>
|
|
5
|
+
<form method="post" action="/admin/login"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
6
|
+
<label>Email <input type="email" name="email" required autofocus></label>
|
|
7
|
+
<label>Password <input type="password" name="password" required></label>
|
|
8
|
+
<button type="submit">Log in</button>
|
|
9
|
+
</form>
|
|
10
|
+
</article>
|
|
11
|
+
<%- include('partials/foot') %>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
</main>
|
|
2
|
+
<footer class="container">
|
|
3
|
+
<small><%= settings.studio_name || 'Studio OS' %> · powered by <a href="https://github.com/studio-os">Studio OS</a> (self-hosted)</small>
|
|
4
|
+
</footer>
|
|
5
|
+
<script>
|
|
6
|
+
if ('serviceWorker' in navigator) {
|
|
7
|
+
navigator.serviceWorker.register('/js/sw.js').catch(function () {});
|
|
8
|
+
}
|
|
9
|
+
</script>
|
|
10
|
+
</body>
|
|
11
|
+
</html>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%= title %> — <%= settings.studio_name || 'Studio OS' %></title>
|
|
7
|
+
<link rel="stylesheet" href="/vendor/pico.min.css">
|
|
8
|
+
<link rel="stylesheet" href="/css/custom.css">
|
|
9
|
+
<link rel="manifest" href="/manifest.json">
|
|
10
|
+
<meta name="theme-color" content="#4f7cac">
|
|
11
|
+
<script src="/vendor/htmx.min.js" defer></script>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<nav class="container">
|
|
15
|
+
<ul>
|
|
16
|
+
<li><strong><a href="/" class="brand"><%= settings.studio_name || 'Studio OS' %></a></strong></li>
|
|
17
|
+
</ul>
|
|
18
|
+
<ul>
|
|
19
|
+
<li><a href="/">Schedule</a></li>
|
|
20
|
+
<li><a href="/buy">Passes</a></li>
|
|
21
|
+
<li><a href="/magic-link">My bookings</a></li>
|
|
22
|
+
<% if (user && user.role === 'instructor') { %>
|
|
23
|
+
<li><a href="/instructor/schedule" role="button" class="outline">My classes</a></li>
|
|
24
|
+
<% } else if (user) { %>
|
|
25
|
+
<li><a href="/admin" role="button" class="outline">Admin</a></li>
|
|
26
|
+
<% } %>
|
|
27
|
+
</ul>
|
|
28
|
+
</nav>
|
|
29
|
+
<% if (user && user.role === 'instructor' && (typeof adminNav !== 'undefined' ? adminNav : true)) { %>
|
|
30
|
+
<nav class="container admin-nav">
|
|
31
|
+
<ul>
|
|
32
|
+
<li><a href="/instructor/schedule">My schedule</a></li>
|
|
33
|
+
<li><form method="post" action="/admin/logout" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>"><button class="outline secondary slim">Log out</button></form></li>
|
|
34
|
+
</ul>
|
|
35
|
+
</nav>
|
|
36
|
+
<% } else if (user && (typeof adminNav !== 'undefined' ? adminNav : true)) { %>
|
|
37
|
+
<nav class="container admin-nav">
|
|
38
|
+
<ul>
|
|
39
|
+
<li><a href="/admin">Dashboard</a></li>
|
|
40
|
+
<li><a href="/admin/schedule">Schedule</a></li>
|
|
41
|
+
<li><a href="/admin/rules">Rules</a></li>
|
|
42
|
+
<li><a href="/admin/class-types">Classes</a></li>
|
|
43
|
+
<li><a href="/admin/instructors">Instructors</a></li>
|
|
44
|
+
<li><a href="/admin/clients">Clients</a></li>
|
|
45
|
+
<li><a href="/admin/products">Products</a></li>
|
|
46
|
+
<li><a href="/admin/reports">Reports</a></li>
|
|
47
|
+
<li><a href="/admin/import">Import</a></li>
|
|
48
|
+
<li><a href="/admin/settings">Settings</a></li>
|
|
49
|
+
<li><form method="post" action="/admin/logout" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>"><button class="outline secondary slim">Log out</button></form></li>
|
|
50
|
+
</ul>
|
|
51
|
+
</nav>
|
|
52
|
+
<% } %>
|
|
53
|
+
<main class="container">
|
|
54
|
+
<% if (flash) { %><article class="flash" role="alert"><%= flash %></article><% } %>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%- include('../partials/head', { adminNav: false }) %>
|
|
2
|
+
<article>
|
|
3
|
+
<% if (result.waitlisted) { %>
|
|
4
|
+
<h2>You're on the waitlist</h2>
|
|
5
|
+
<p><strong><%= inst.class_name %></strong> on <%= fmtDt(inst.starts_at) %> is currently full.
|
|
6
|
+
You're #<%= result.booking.id ? '' : '' %> in line — if a spot opens we'll book you in automatically<%= smtpConfigured ? ' and email you' : '' %>.</p>
|
|
7
|
+
<% } else { %>
|
|
8
|
+
<h2>You're booked!</h2>
|
|
9
|
+
<p><strong><%= inst.class_name %></strong> · <%= fmtDt(inst.starts_at) %></p>
|
|
10
|
+
<p>Payment:
|
|
11
|
+
<% if (result.booking.paid_with === 'pack') { %>1 credit deducted from your class pack.
|
|
12
|
+
<% } else if (result.booking.paid_with === 'membership') { %>covered by your membership.
|
|
13
|
+
<% } else if (result.paymentId) { %>please pay <%= fmtMoney(inst.drop_in_price_cents) %> at the studio (cash/FPS) before class.
|
|
14
|
+
<% } else { %>drop-in.
|
|
15
|
+
<% } %>
|
|
16
|
+
</p>
|
|
17
|
+
<% } %>
|
|
18
|
+
<p>Manage or cancel your bookings any time:</p>
|
|
19
|
+
<p><a href="<%= magicUrl %>" role="button" class="outline">My bookings</a></p>
|
|
20
|
+
<% if (!smtpConfigured) { %>
|
|
21
|
+
<p><small>Save that link — it's your personal booking manager (valid 7 days; you can request a new one from “My bookings” in the menu).</small></p>
|
|
22
|
+
<% } else { %>
|
|
23
|
+
<p><small>A confirmation email with your personal link is on its way.</small></p>
|
|
24
|
+
<% } %>
|
|
25
|
+
</article>
|
|
26
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<%- include('../partials/head', { adminNav: false }) %>
|
|
2
|
+
<hgroup>
|
|
3
|
+
<h2>Passes & memberships</h2>
|
|
4
|
+
<p><%= stripeConfigured ? 'Pay securely online, or at the studio.' : 'Purchases are completed at the studio (cash / FPS / bank transfer).' %></p>
|
|
5
|
+
</hgroup>
|
|
6
|
+
<% if (error) { %><p class="flash error" role="alert"><%= error %></p><% } %>
|
|
7
|
+
<div class="grid">
|
|
8
|
+
<section>
|
|
9
|
+
<h4>Class packs</h4>
|
|
10
|
+
<% if (!packs.length) { %><p>No packs available right now.</p><% } %>
|
|
11
|
+
<% for (const p of packs) { %>
|
|
12
|
+
<article>
|
|
13
|
+
<strong><%= p.name %></strong> — <%= fmtMoney(p.price_cents) %>
|
|
14
|
+
<br><small><%= p.credits %> classes<% if (p.validity_days) { %> · valid <%= p.validity_days %> days<% } %></small>
|
|
15
|
+
<form method="post" action="/buy/pack/<%= p.id %>"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
16
|
+
<input type="email" name="email" required placeholder="you@example.com">
|
|
17
|
+
<button type="submit" class="slim"><%= stripeConfigured ? 'Buy online' : 'Reserve — pay at studio' %></button>
|
|
18
|
+
</form>
|
|
19
|
+
</article>
|
|
20
|
+
<% } %>
|
|
21
|
+
</section>
|
|
22
|
+
<section>
|
|
23
|
+
<h4>Memberships</h4>
|
|
24
|
+
<% if (!plans.length) { %><p>No membership plans right now.</p><% } %>
|
|
25
|
+
<% for (const p of plans) { %>
|
|
26
|
+
<article>
|
|
27
|
+
<strong><%= p.name %></strong> — <%= fmtMoney(p.price_cents) %>/month
|
|
28
|
+
<br><small><%= p.unlimited ? 'Unlimited classes' : `${p.credits_per_month} classes per month` %></small>
|
|
29
|
+
<form method="post" action="/buy/membership/<%= p.id %>"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
30
|
+
<input type="email" name="email" required placeholder="you@example.com">
|
|
31
|
+
<button type="submit" class="slim"><%= (stripeConfigured && p.stripe_price_id) ? 'Subscribe online' : 'Reserve — arrange at studio' %></button>
|
|
32
|
+
</form>
|
|
33
|
+
</article>
|
|
34
|
+
<% } %>
|
|
35
|
+
</section>
|
|
36
|
+
</div>
|
|
37
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%- include('../partials/head', { adminNav: false }) %>
|
|
2
|
+
<article>
|
|
3
|
+
<% if (error) { %><p class="flash error" role="alert"><%= error %></p><% } %>
|
|
4
|
+
<h2>Almost there</h2>
|
|
5
|
+
<% const item = product || plan; %>
|
|
6
|
+
<% if (item) { %>
|
|
7
|
+
<p>You're reserving: <strong><%= item.name %></strong>
|
|
8
|
+
— <%= fmtMoney(item.price_cents) %><%= plan ? '/month' : '' %></p>
|
|
9
|
+
<% } %>
|
|
10
|
+
<p>Online payment isn't enabled<%= plan && stripeConfigured ? ' for this plan' : '' %>, so to complete your purchase:</p>
|
|
11
|
+
<ol>
|
|
12
|
+
<li>Pay at the studio (cash / FPS / bank transfer) or contact us to arrange payment.</li>
|
|
13
|
+
<li>Mention the email you entered<%= email ? ` (${email})` : '' %> — we'll activate it on your account right away.</li>
|
|
14
|
+
</ol>
|
|
15
|
+
<p><a href="/buy">Back to passes</a> · <a href="/">Schedule</a></p>
|
|
16
|
+
</article>
|
|
17
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<%- include('../partials/head', { adminNav: false }) %>
|
|
2
|
+
<article>
|
|
3
|
+
<h2>Thank you!</h2>
|
|
4
|
+
<p>Your payment was received. Your pass or membership is now active on your account — just book with the same email.</p>
|
|
5
|
+
<p><a href="/" role="button">Browse the schedule</a></p>
|
|
6
|
+
</article>
|
|
7
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<%- include('../partials/head', { adminNav: false }) %>
|
|
2
|
+
<article style="border-top: 6px solid <%= inst.color %>">
|
|
3
|
+
<hgroup>
|
|
4
|
+
<h2><%= inst.class_name %></h2>
|
|
5
|
+
<p><%= fmtDt(inst.starts_at) %> · <%= inst.duration_min %> min<% if (inst.instructor_name) { %> · <%= inst.instructor_name %><% } %></p>
|
|
6
|
+
</hgroup>
|
|
7
|
+
<% if (inst.class_description) { %><p><%= inst.class_description %></p><% } %>
|
|
8
|
+
<p>
|
|
9
|
+
<% const left = inst.capacity - inst.booked_count; %>
|
|
10
|
+
<% if (full) { %><mark>This class is full — you can join the waitlist.</mark>
|
|
11
|
+
<% } else { %><small><%= left %> of <%= inst.capacity %> spots available.</small><% } %>
|
|
12
|
+
<% if (inst.drop_in_price_cents > 0) { %><br><small>Drop-in price: <%= fmtMoney(inst.drop_in_price_cents) %> · uses <%= inst.credits_required %> credit<%= inst.credits_required === 1 ? '' : 's' %> from a pass</small><% } %>
|
|
13
|
+
</p>
|
|
14
|
+
<% if (error) { %><p class="flash error" role="alert"><%= error %></p><% } %>
|
|
15
|
+
<form method="post" action="/class/<%= inst.id %>/book"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
16
|
+
<div class="grid">
|
|
17
|
+
<label>Your name <input name="name" value="<%= form.name || '' %>" placeholder="Jane Chan"></label>
|
|
18
|
+
<label>Email <input type="email" name="email" required value="<%= form.email || '' %>" placeholder="you@example.com"></label>
|
|
19
|
+
</div>
|
|
20
|
+
<details>
|
|
21
|
+
<summary>Liability waiver (required for first booking)</summary>
|
|
22
|
+
<div class="waiver-box"><%- waiverHtml %></div>
|
|
23
|
+
<label><input type="checkbox" name="waiver_agree" value="1"> I have read and agree to the waiver</label>
|
|
24
|
+
</details>
|
|
25
|
+
<button type="submit"><%= full ? 'Join waitlist' : 'Book this class' %></button>
|
|
26
|
+
</form>
|
|
27
|
+
<p><small>Already booked with us before? Just use the same email — membership or class-pack credits are applied automatically.</small></p>
|
|
28
|
+
</article>
|
|
29
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%- include('../partials/head', { adminNav: false }) %>
|
|
2
|
+
<article class="narrow">
|
|
3
|
+
<h2>My bookings</h2>
|
|
4
|
+
<% if (error) { %><p class="flash error" role="alert"><%= error %></p><% } %>
|
|
5
|
+
<% if (sent) { %>
|
|
6
|
+
<% if (magicUrl) { %>
|
|
7
|
+
<p>Here's your personal booking link:</p>
|
|
8
|
+
<p><a href="<%= magicUrl %>" role="button">Open my bookings</a></p>
|
|
9
|
+
<p><small>(Email sending is not configured on this studio, so the link is shown here. It's valid for 7 days.)</small></p>
|
|
10
|
+
<% } else { %>
|
|
11
|
+
<p>If that email has an account with us, a booking-management link is on its way to your inbox.</p>
|
|
12
|
+
<% } %>
|
|
13
|
+
<% } else { %>
|
|
14
|
+
<p>Enter your email and we'll give you a secure link to view and manage your bookings. No password needed.</p>
|
|
15
|
+
<form method="post" action="/magic-link"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
16
|
+
<label>Email <input type="email" name="email" required placeholder="you@example.com"></label>
|
|
17
|
+
<button type="submit">Send me my link</button>
|
|
18
|
+
</form>
|
|
19
|
+
<% } %>
|
|
20
|
+
</article>
|
|
21
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<%- include('../partials/head', { adminNav: false }) %>
|
|
2
|
+
<hgroup>
|
|
3
|
+
<h2>Hi <%= clientInfo.name %></h2>
|
|
4
|
+
<p><%= clientInfo.email %></p>
|
|
5
|
+
</hgroup>
|
|
6
|
+
<section>
|
|
7
|
+
<h4>Upcoming bookings</h4>
|
|
8
|
+
<% if (!bookings.length) { %><p>No upcoming bookings. <a href="/">Browse the schedule</a></p><% } %>
|
|
9
|
+
<% for (const b of bookings) { %>
|
|
10
|
+
<article class="class-row">
|
|
11
|
+
<div class="grid">
|
|
12
|
+
<div>
|
|
13
|
+
<strong><%= b.class_name %></strong> · <%= fmtDt(b.starts_at) %>
|
|
14
|
+
<% if (b.instructor_name) { %><small>with <%= b.instructor_name %></small><% } %>
|
|
15
|
+
<% if (b.status === 'waitlist') { %><mark>waitlist</mark><% } %>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="right">
|
|
18
|
+
<form method="post" action="/me/cancel/<%= b.id %>"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
19
|
+
<input type="hidden" name="token" value="<%= token %>">
|
|
20
|
+
<button class="slim outline secondary"
|
|
21
|
+
onclick="return confirm('Cancel this booking?')">Cancel</button>
|
|
22
|
+
</form>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</article>
|
|
26
|
+
<% } %>
|
|
27
|
+
<p><small>Cancel at least <%= windowHours %> hours before class starts to get your credit back.</small></p>
|
|
28
|
+
</section>
|
|
29
|
+
<div class="grid">
|
|
30
|
+
<section>
|
|
31
|
+
<h4>Class packs</h4>
|
|
32
|
+
<% if (!passes.length) { %><p><small>No active packs. <a href="/buy">Buy one</a></small></p><% } %>
|
|
33
|
+
<% for (const p of passes) { %>
|
|
34
|
+
<p><strong><%= p.name %></strong>: <%= p.credits_remaining %>/<%= p.credits_total %> credits left
|
|
35
|
+
<% if (p.expires_on) { %><small>· expires <%= p.expires_on %></small><% } %></p>
|
|
36
|
+
<% } %>
|
|
37
|
+
</section>
|
|
38
|
+
<section>
|
|
39
|
+
<h4>Memberships</h4>
|
|
40
|
+
<% if (!memberships.length) { %><p><small>No active membership.</small></p><% } %>
|
|
41
|
+
<% for (const m of memberships) { %>
|
|
42
|
+
<p><strong><%= m.plan_name %></strong> —
|
|
43
|
+
<%= m.unlimited ? 'unlimited' : `${Math.max(0, (m.credits_per_month || 0) - m.credits_used_this_cycle)} of ${m.credits_per_month} classes left this cycle` %></p>
|
|
44
|
+
<% } %>
|
|
45
|
+
</section>
|
|
46
|
+
</div>
|
|
47
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<%- include('../partials/head', { adminNav: false }) %>
|
|
2
|
+
<hgroup>
|
|
3
|
+
<h2>Class schedule</h2>
|
|
4
|
+
<p>Next 14 days</p>
|
|
5
|
+
</hgroup>
|
|
6
|
+
<form method="get" action="/" class="grid filters">
|
|
7
|
+
<select name="type" onchange="this.form.submit()">
|
|
8
|
+
<option value="">All classes</option>
|
|
9
|
+
<% for (const t of types) { %>
|
|
10
|
+
<option value="<%= t.id %>" <%= filterType === t.id ? 'selected' : '' %>><%= t.name %></option>
|
|
11
|
+
<% } %>
|
|
12
|
+
</select>
|
|
13
|
+
<select name="instructor" onchange="this.form.submit()">
|
|
14
|
+
<option value="">All instructors</option>
|
|
15
|
+
<% for (const i of instructors) { %>
|
|
16
|
+
<option value="<%= i.id %>" <%= filterInstructor === i.id ? 'selected' : '' %>><%= i.name %></option>
|
|
17
|
+
<% } %>
|
|
18
|
+
</select>
|
|
19
|
+
</form>
|
|
20
|
+
<% if (!instances.length) { %>
|
|
21
|
+
<article>No upcoming classes match. Check back soon!</article>
|
|
22
|
+
<% } %>
|
|
23
|
+
<%
|
|
24
|
+
let lastDay = '';
|
|
25
|
+
for (const inst of instances) {
|
|
26
|
+
const day = fmtDt(inst.starts_at, { weekday: 'long', day: 'numeric', month: 'long', hour: undefined, minute: undefined });
|
|
27
|
+
%>
|
|
28
|
+
<% if (day !== lastDay) { lastDay = day; %><h4 class="day-header"><%= day %></h4><% } %>
|
|
29
|
+
<article class="class-row" style="border-left: 6px solid <%= inst.color %>">
|
|
30
|
+
<div class="grid">
|
|
31
|
+
<div>
|
|
32
|
+
<strong><%= fmtDt(inst.starts_at, { weekday: undefined, day: undefined, month: undefined }) %></strong>
|
|
33
|
+
· <a href="/class/<%= inst.id %>"><%= inst.class_name %></a>
|
|
34
|
+
<% if (inst.instructor_name) { %><small>with <%= inst.instructor_name %></small><% } %>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="right">
|
|
37
|
+
<% const left = inst.capacity - inst.booked_count; %>
|
|
38
|
+
<% if (left > 0) { %>
|
|
39
|
+
<small><%= left %> spot<%= left === 1 ? '' : 's' %> left</small>
|
|
40
|
+
<a href="/class/<%= inst.id %>" role="button" class="slim">Book</a>
|
|
41
|
+
<% } else { %>
|
|
42
|
+
<small>Full<% if (inst.waitlist_count) { %> · <%= inst.waitlist_count %> waitlisted<% } %></small>
|
|
43
|
+
<a href="/class/<%= inst.id %>" role="button" class="slim outline">Join waitlist</a>
|
|
44
|
+
<% } %>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</article>
|
|
48
|
+
<% } %>
|
|
49
|
+
<%- include('../partials/foot') %>
|
package/views/setup.ejs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Welcome — Studio OS setup</title>
|
|
7
|
+
<link rel="stylesheet" href="/vendor/pico.min.css">
|
|
8
|
+
<link rel="stylesheet" href="/css/custom.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<main class="container narrow">
|
|
12
|
+
<hgroup>
|
|
13
|
+
<h1>Welcome to Studio OS</h1>
|
|
14
|
+
<p>Two minutes of setup and your studio is online.</p>
|
|
15
|
+
</hgroup>
|
|
16
|
+
<% if (error) { %><article class="flash error" role="alert"><%= error %></article><% } %>
|
|
17
|
+
<form method="post" action="/setup"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
18
|
+
<label>Studio name
|
|
19
|
+
<input name="studio_name" required placeholder="Harbour Yoga" value="<%= form.studio_name || '' %>">
|
|
20
|
+
</label>
|
|
21
|
+
<div class="grid">
|
|
22
|
+
<label>Timezone
|
|
23
|
+
<select name="timezone">
|
|
24
|
+
<% for (const tzName of timezones) { %>
|
|
25
|
+
<option value="<%= tzName %>" <%= (form.timezone || 'Asia/Hong_Kong') === tzName ? 'selected' : '' %>><%= tzName %></option>
|
|
26
|
+
<% } %>
|
|
27
|
+
</select>
|
|
28
|
+
</label>
|
|
29
|
+
<label>Currency (ISO code)
|
|
30
|
+
<input name="currency" maxlength="3" value="<%= form.currency || 'HKD' %>">
|
|
31
|
+
</label>
|
|
32
|
+
</div>
|
|
33
|
+
<h4>Owner account</h4>
|
|
34
|
+
<label>Email
|
|
35
|
+
<input type="email" name="email" required value="<%= form.email || '' %>">
|
|
36
|
+
</label>
|
|
37
|
+
<div class="grid">
|
|
38
|
+
<label>Password (min 8 chars)
|
|
39
|
+
<input type="password" name="password" minlength="8" required>
|
|
40
|
+
</label>
|
|
41
|
+
<label>Repeat password
|
|
42
|
+
<input type="password" name="password2" minlength="8" required>
|
|
43
|
+
</label>
|
|
44
|
+
</div>
|
|
45
|
+
<label>
|
|
46
|
+
<input type="checkbox" name="seed_examples" value="1" checked>
|
|
47
|
+
Create two example class types to explore with
|
|
48
|
+
</label>
|
|
49
|
+
<button type="submit">Create my studio</button>
|
|
50
|
+
</form>
|
|
51
|
+
</main>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|