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,45 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<h3>Class types</h3>
|
|
3
|
+
<details open>
|
|
4
|
+
<summary role="button" class="outline">Add class type</summary>
|
|
5
|
+
<form method="post" action="/admin/class-types"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
6
|
+
<div class="grid">
|
|
7
|
+
<label>Name <input name="name" required></label>
|
|
8
|
+
<label>Color <input type="color" name="color" value="#4f7cac"></label>
|
|
9
|
+
</div>
|
|
10
|
+
<label>Description <input name="description"></label>
|
|
11
|
+
<div class="grid">
|
|
12
|
+
<label>Duration (min) <input type="number" name="duration_min" value="60" min="5"></label>
|
|
13
|
+
<label>Capacity <input type="number" name="capacity" value="10" min="1"></label>
|
|
14
|
+
<label>Drop-in price <input type="number" name="drop_in_price" value="0" min="0" step="0.01"></label>
|
|
15
|
+
<label>Credits required <input type="number" name="credits_required" value="1" min="1"></label>
|
|
16
|
+
</div>
|
|
17
|
+
<button>Add</button>
|
|
18
|
+
</form>
|
|
19
|
+
</details>
|
|
20
|
+
<% for (const t of types) { %>
|
|
21
|
+
<details>
|
|
22
|
+
<summary style="border-left: 6px solid <%= t.color %>; padding-left: .5rem">
|
|
23
|
+
<strong><%= t.name %></strong> · <%= t.duration_min %>min · cap <%= t.capacity %> · <%= fmtMoney(t.drop_in_price_cents) %> drop-in
|
|
24
|
+
<%= t.active ? '' : ' · INACTIVE' %>
|
|
25
|
+
</summary>
|
|
26
|
+
<form method="post" action="/admin/class-types/<%= t.id %>"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
27
|
+
<div class="grid">
|
|
28
|
+
<label>Name <input name="name" value="<%= t.name %>" required></label>
|
|
29
|
+
<label>Color <input type="color" name="color" value="<%= t.color %>"></label>
|
|
30
|
+
</div>
|
|
31
|
+
<label>Description <input name="description" value="<%= t.description %>"></label>
|
|
32
|
+
<div class="grid">
|
|
33
|
+
<label>Duration <input type="number" name="duration_min" value="<%= t.duration_min %>"></label>
|
|
34
|
+
<label>Capacity <input type="number" name="capacity" value="<%= t.capacity %>"></label>
|
|
35
|
+
<label>Drop-in price <input type="number" name="drop_in_price" value="<%= (t.drop_in_price_cents / 100).toFixed(2) %>" step="0.01"></label>
|
|
36
|
+
<label>Credits <input type="number" name="credits_required" value="<%= t.credits_required %>"></label>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="grid">
|
|
39
|
+
<button>Save</button>
|
|
40
|
+
<button name="action" value="toggle" class="outline secondary"><%= t.active ? 'Deactivate' : 'Reactivate' %></button>
|
|
41
|
+
</div>
|
|
42
|
+
</form>
|
|
43
|
+
</details>
|
|
44
|
+
<% } %>
|
|
45
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<hgroup>
|
|
3
|
+
<h3><%= clientInfo.name %></h3>
|
|
4
|
+
<p><%= clientInfo.email %><% if (clientInfo.phone) { %> · <%= clientInfo.phone %><% } %> · source: <%= clientInfo.source %></p>
|
|
5
|
+
</hgroup>
|
|
6
|
+
<div class="grid">
|
|
7
|
+
<article>
|
|
8
|
+
<h5>Profile</h5>
|
|
9
|
+
<form method="post" action="/admin/clients/<%= clientInfo.id %>"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
10
|
+
<label>Name <input name="name" value="<%= clientInfo.name %>"></label>
|
|
11
|
+
<label>Phone <input name="phone" value="<%= clientInfo.phone %>"></label>
|
|
12
|
+
<label>Notes <textarea name="notes" rows="3"><%= clientInfo.notes %></textarea></label>
|
|
13
|
+
<button class="slim">Save</button>
|
|
14
|
+
</form>
|
|
15
|
+
<form method="post" action="/admin/clients/<%= clientInfo.id %>"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
16
|
+
<input type="hidden" name="action" value="waiver_toggle">
|
|
17
|
+
<p>Waiver: <strong><%= clientInfo.waiver_signed_at ? `signed ${fmtDt(clientInfo.waiver_signed_at)}` : 'not signed' %></strong>
|
|
18
|
+
<button class="slim outline"><%= clientInfo.waiver_signed_at ? 'Clear' : 'Mark signed' %></button></p>
|
|
19
|
+
</form>
|
|
20
|
+
<p><small>Magic link (share with client): <a href="<%= magicUrl %>">open</a></small></p>
|
|
21
|
+
</article>
|
|
22
|
+
<article>
|
|
23
|
+
<h5>Add pack / membership / payment</h5>
|
|
24
|
+
<details>
|
|
25
|
+
<summary>Add class pack</summary>
|
|
26
|
+
<form method="post" action="/admin/clients/<%= clientInfo.id %>/passes"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
27
|
+
<% if (packProducts.length) { %>
|
|
28
|
+
<label>From product
|
|
29
|
+
<select onchange="const o=this.selectedOptions[0]; if(o.dataset.credits){this.form.name.value=o.text;this.form.credits.value=o.dataset.credits;this.form.price.value=o.dataset.price;this.form.expires_on.value=o.dataset.expires||''}">
|
|
30
|
+
<option value="">custom…</option>
|
|
31
|
+
<% for (const pp of packProducts) { %>
|
|
32
|
+
<option data-credits="<%= pp.credits %>" data-price="<%= (pp.price_cents/100).toFixed(2) %>"><%= pp.name %></option>
|
|
33
|
+
<% } %>
|
|
34
|
+
</select>
|
|
35
|
+
</label>
|
|
36
|
+
<% } %>
|
|
37
|
+
<div class="grid">
|
|
38
|
+
<label>Name <input name="name" placeholder="10-class pack"></label>
|
|
39
|
+
<label>Credits <input type="number" name="credits" value="10" min="1"></label>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="grid">
|
|
42
|
+
<label>Expires on <input type="date" name="expires_on"></label>
|
|
43
|
+
<label>Price paid <input type="number" name="price" step="0.01" min="0" value="0"></label>
|
|
44
|
+
<label>Method
|
|
45
|
+
<select name="method"><option value="cash">cash</option><option value="fps">FPS</option><option value="other">other</option></select>
|
|
46
|
+
</label>
|
|
47
|
+
</div>
|
|
48
|
+
<label><input type="checkbox" name="record_payment" value="1" checked> Record payment too</label>
|
|
49
|
+
<button class="slim">Add pack</button>
|
|
50
|
+
</form>
|
|
51
|
+
</details>
|
|
52
|
+
<details>
|
|
53
|
+
<summary>Add membership</summary>
|
|
54
|
+
<form method="post" action="/admin/clients/<%= clientInfo.id %>/memberships"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
55
|
+
<% if (plans.length) { %>
|
|
56
|
+
<label>Plan
|
|
57
|
+
<select name="plan_id">
|
|
58
|
+
<option value="">custom…</option>
|
|
59
|
+
<% for (const p of plans) { %><option value="<%= p.id %>"><%= p.name %> (<%= fmtMoney(p.price_cents) %>/mo)</option><% } %>
|
|
60
|
+
</select>
|
|
61
|
+
</label>
|
|
62
|
+
<% } %>
|
|
63
|
+
<div class="grid">
|
|
64
|
+
<label>Custom name <input name="plan_name" placeholder="Monthly unlimited"></label>
|
|
65
|
+
<label>Unlimited?
|
|
66
|
+
<select name="unlimited"><option value="1">Unlimited</option><option value="0">Credit-based</option></select>
|
|
67
|
+
</label>
|
|
68
|
+
<label>Credits/month <input type="number" name="credits_per_month" min="1" placeholder="8"></label>
|
|
69
|
+
</div>
|
|
70
|
+
<label><input type="checkbox" name="record_payment" value="1"> Record first payment (plan price)</label>
|
|
71
|
+
<label>Method <select name="method"><option value="cash">cash</option><option value="fps">FPS</option><option value="other">other</option></select></label>
|
|
72
|
+
<button class="slim">Add membership</button>
|
|
73
|
+
</form>
|
|
74
|
+
</details>
|
|
75
|
+
<details>
|
|
76
|
+
<summary>Record payment</summary>
|
|
77
|
+
<form method="post" action="/admin/clients/<%= clientInfo.id %>/payments"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
78
|
+
<div class="grid">
|
|
79
|
+
<label>Amount <input type="number" name="amount" step="0.01" min="0" required></label>
|
|
80
|
+
<label>For
|
|
81
|
+
<select name="what"><option value="drop_in">drop-in</option><option value="pack">pack</option><option value="membership">membership</option></select>
|
|
82
|
+
</label>
|
|
83
|
+
<label>Method
|
|
84
|
+
<select name="method"><option value="cash">cash</option><option value="fps">FPS</option><option value="stripe">stripe</option><option value="other">other</option></select>
|
|
85
|
+
</label>
|
|
86
|
+
</div>
|
|
87
|
+
<label>Reference <input name="reference"></label>
|
|
88
|
+
<button class="slim">Record</button>
|
|
89
|
+
</form>
|
|
90
|
+
</details>
|
|
91
|
+
</article>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="grid">
|
|
94
|
+
<section>
|
|
95
|
+
<h5>Packs</h5>
|
|
96
|
+
<% if (!passes.length) { %><p><small>None.</small></p><% } %>
|
|
97
|
+
<% for (const p of passes) { %>
|
|
98
|
+
<p class="slim-row"><%= p.name %>: <strong><%= p.credits_remaining %>/<%= p.credits_total %></strong>
|
|
99
|
+
<% if (p.expires_on) { %><small>exp <%= p.expires_on %></small><% } %> <small>(<%= p.source %>)</small></p>
|
|
100
|
+
<% } %>
|
|
101
|
+
<h5>Memberships</h5>
|
|
102
|
+
<% if (!memberships.length) { %><p><small>None.</small></p><% } %>
|
|
103
|
+
<% for (const m of memberships) { %>
|
|
104
|
+
<p class="slim-row"><%= m.plan_name %> — <%= m.status %> ·
|
|
105
|
+
<%= m.unlimited ? 'unlimited' : `${m.credits_used_this_cycle}/${m.credits_per_month} used` %>
|
|
106
|
+
<% if (m.status === 'active') { %>
|
|
107
|
+
<form method="post" action="/admin/clients/<%= clientInfo.id %>/memberships" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
108
|
+
<input type="hidden" name="action" value="status"><input type="hidden" name="membership_id" value="<%= m.id %>">
|
|
109
|
+
<button name="status" value="paused" class="slim outline">Pause</button>
|
|
110
|
+
<button name="status" value="cancelled" class="slim outline secondary">Cancel</button>
|
|
111
|
+
</form>
|
|
112
|
+
<% } else if (m.status === 'paused') { %>
|
|
113
|
+
<form method="post" action="/admin/clients/<%= clientInfo.id %>/memberships" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
114
|
+
<input type="hidden" name="action" value="status"><input type="hidden" name="membership_id" value="<%= m.id %>">
|
|
115
|
+
<button name="status" value="active" class="slim outline">Resume</button>
|
|
116
|
+
</form>
|
|
117
|
+
<% } %>
|
|
118
|
+
</p>
|
|
119
|
+
<% } %>
|
|
120
|
+
</section>
|
|
121
|
+
<section>
|
|
122
|
+
<h5>Payments</h5>
|
|
123
|
+
<% if (!payments.length) { %><p><small>None.</small></p><% } %>
|
|
124
|
+
<% for (const p of payments) { %>
|
|
125
|
+
<p class="slim-row"><%= fmtDt(p.created_at) %> · <%= fmtMoney(p.amount_cents) %> · <%= p.what %> (<%= p.method %>)
|
|
126
|
+
<% if (p.status === 'pending') { %><mark>pending</mark>
|
|
127
|
+
<form method="post" action="/admin/payments/<%= p.id %>/paid" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>"><button class="slim">Mark paid</button></form>
|
|
128
|
+
<% } %></p>
|
|
129
|
+
<% } %>
|
|
130
|
+
</section>
|
|
131
|
+
</div>
|
|
132
|
+
<section>
|
|
133
|
+
<h5>Bookings</h5>
|
|
134
|
+
<table>
|
|
135
|
+
<thead><tr><th>When</th><th>Class</th><th>Status</th><th>Paid with</th></tr></thead>
|
|
136
|
+
<tbody>
|
|
137
|
+
<% for (const b of bookings) { %>
|
|
138
|
+
<tr>
|
|
139
|
+
<td><%= fmtDt(b.starts_at) %></td>
|
|
140
|
+
<td><a href="/admin/instances/<%= b.class_instance_id %>"><%= b.class_name %></a></td>
|
|
141
|
+
<td><%= b.status %></td>
|
|
142
|
+
<td><%= b.paid_with.replace(/_/g, ' ') %></td>
|
|
143
|
+
</tr>
|
|
144
|
+
<% } %>
|
|
145
|
+
<% if (!bookings.length) { %><tr><td colspan="4">No bookings.</td></tr><% } %>
|
|
146
|
+
</tbody>
|
|
147
|
+
</table>
|
|
148
|
+
</section>
|
|
149
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<h3>Clients</h3>
|
|
3
|
+
<form method="get" action="/admin/clients" class="grid">
|
|
4
|
+
<input type="search" name="q" value="<%= q %>" placeholder="Search name, email or phone">
|
|
5
|
+
<button class="slim">Search</button>
|
|
6
|
+
</form>
|
|
7
|
+
<details <%= clients.length ? '' : 'open' %>>
|
|
8
|
+
<summary role="button" class="outline">Add client</summary>
|
|
9
|
+
<form method="post" action="/admin/clients"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
10
|
+
<div class="grid">
|
|
11
|
+
<label>Name <input name="name"></label>
|
|
12
|
+
<label>Email <input type="email" name="email" required></label>
|
|
13
|
+
<label>Phone <input name="phone"></label>
|
|
14
|
+
</div>
|
|
15
|
+
<button>Add</button>
|
|
16
|
+
</form>
|
|
17
|
+
</details>
|
|
18
|
+
<table>
|
|
19
|
+
<thead><tr><th>Name</th><th>Email</th><th>Phone</th><th>Waiver</th><th>Source</th></tr></thead>
|
|
20
|
+
<tbody>
|
|
21
|
+
<% for (const c of clients) { %>
|
|
22
|
+
<tr>
|
|
23
|
+
<td><a href="/admin/clients/<%= c.id %>"><%= c.name %></a></td>
|
|
24
|
+
<td><%= c.email %></td>
|
|
25
|
+
<td><%= c.phone %></td>
|
|
26
|
+
<td><%= c.waiver_signed_at ? 'signed' : '—' %></td>
|
|
27
|
+
<td><%= c.source %></td>
|
|
28
|
+
</tr>
|
|
29
|
+
<% } %>
|
|
30
|
+
<% if (!clients.length) { %><tr><td colspan="5">No clients found.</td></tr><% } %>
|
|
31
|
+
</tbody>
|
|
32
|
+
</table>
|
|
33
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<div class="grid">
|
|
3
|
+
<article>
|
|
4
|
+
<h4>This week's revenue</h4>
|
|
5
|
+
<h2><%= fmtMoney(weekRevenue) %></h2>
|
|
6
|
+
<small>paid in the last 7 days · <a href="/admin/reports">reports</a></small>
|
|
7
|
+
</article>
|
|
8
|
+
<article>
|
|
9
|
+
<h4>Status</h4>
|
|
10
|
+
<p><small>Stripe: <%= stripeConfigured ? 'connected' : 'not connected — purchases fall back to pay-at-studio' %><br>
|
|
11
|
+
Email: <%= smtpConfigured ? 'SMTP configured' : 'off — messages saved to data/outbox, links shown on screen' %></small></p>
|
|
12
|
+
</article>
|
|
13
|
+
</div>
|
|
14
|
+
<section>
|
|
15
|
+
<h4>Today's classes</h4>
|
|
16
|
+
<% if (!todayClasses.length) { %><p>No classes today.</p><% } %>
|
|
17
|
+
<% for (const c of todayClasses) { %>
|
|
18
|
+
<article class="class-row" style="border-left: 6px solid <%= c.color %>">
|
|
19
|
+
<div class="grid">
|
|
20
|
+
<div>
|
|
21
|
+
<strong><%= fmtDt(c.starts_at, { weekday: undefined, day: undefined, month: undefined }) %></strong>
|
|
22
|
+
· <a href="/admin/instances/<%= c.id %>"><%= c.class_name %></a>
|
|
23
|
+
<% if (c.instructor_name) { %><small>(<%= c.instructor_name %>)</small><% } %>
|
|
24
|
+
<% if (c.status === 'cancelled') { %><mark>cancelled</mark><% } %>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="right">
|
|
27
|
+
<a href="/admin/instances/<%= c.id %>"><%= c.booked_count %>/<%= c.capacity %> booked<% if (c.waitlist_count) { %> · +<%= c.waitlist_count %> WL<% } %></a>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</article>
|
|
31
|
+
<% } %>
|
|
32
|
+
</section>
|
|
33
|
+
<div class="grid">
|
|
34
|
+
<section>
|
|
35
|
+
<h4>Pending payments</h4>
|
|
36
|
+
<% if (!pendingPayments.length) { %><p><small>None — all settled.</small></p><% } %>
|
|
37
|
+
<% for (const p of pendingPayments) { %>
|
|
38
|
+
<article class="slim-row">
|
|
39
|
+
<%= p.client_name || 'Unknown' %> · <%= fmtMoney(p.amount_cents) %> <small>(<%= p.what %>)</small>
|
|
40
|
+
<form method="post" action="/admin/payments/<%= p.id %>/paid" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
41
|
+
<select name="method" class="slim-select"><option value="cash">cash</option><option value="fps">FPS</option><option value="other">other</option></select>
|
|
42
|
+
<button class="slim">Mark paid</button>
|
|
43
|
+
</form>
|
|
44
|
+
</article>
|
|
45
|
+
<% } %>
|
|
46
|
+
</section>
|
|
47
|
+
<section>
|
|
48
|
+
<h4>Passes expiring in 30 days</h4>
|
|
49
|
+
<% if (!expiringPasses.length) { %><p><small>None.</small></p><% } %>
|
|
50
|
+
<% for (const p of expiringPasses) { %>
|
|
51
|
+
<p class="slim-row"><a href="/admin/clients/<%= p.client_id %>"><%= p.client_name %></a> — <%= p.name %>, <%= p.credits_remaining %> credits, expires <%= p.expires_on %></p>
|
|
52
|
+
<% } %>
|
|
53
|
+
</section>
|
|
54
|
+
</div>
|
|
55
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<h3>Mindbody import</h3>
|
|
3
|
+
<p><small>Export from Mindbody (Reports → Clients / Pricing Options), open the CSV in a text editor and paste it below.
|
|
4
|
+
Re-running is safe: existing clients are matched by email and updated, never duplicated.
|
|
5
|
+
You can also use the CLI: <code>npm run import-mindbody -- --clients file.csv --dry-run</code></small></p>
|
|
6
|
+
<form method="post" action="/admin/import"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
7
|
+
<div class="grid">
|
|
8
|
+
<label>What are you importing?
|
|
9
|
+
<select name="kind">
|
|
10
|
+
<option value="clients" <%= kind === 'clients' ? 'selected' : '' %>>Clients export</option>
|
|
11
|
+
<option value="passes" <%= kind === 'passes' ? 'selected' : '' %>>Pricing options / passes export</option>
|
|
12
|
+
</select>
|
|
13
|
+
</label>
|
|
14
|
+
<label>
|
|
15
|
+
<input type="checkbox" name="dry_run" value="1" <%= dryRun ? 'checked' : '' %>>
|
|
16
|
+
Dry run (preview only, no changes)
|
|
17
|
+
</label>
|
|
18
|
+
</div>
|
|
19
|
+
<label>CSV content
|
|
20
|
+
<textarea name="csv" rows="10" placeholder="First Name,Last Name,Email,Phone Jane,Chan,jane@example.com,+852 9123 4567" required></textarea>
|
|
21
|
+
</label>
|
|
22
|
+
<button>Import</button>
|
|
23
|
+
</form>
|
|
24
|
+
<% if (result) { %>
|
|
25
|
+
<article>
|
|
26
|
+
<h5>Result<%= dryRun ? ' (dry run — nothing written)' : '' %></h5>
|
|
27
|
+
<p><strong><%= result.created %></strong> created · <strong><%= result.updated %></strong> updated · <strong><%= result.skipped %></strong> skipped
|
|
28
|
+
<% if (result.clientsCreated) { %> · <%= result.clientsCreated %> clients auto-created<% } %></p>
|
|
29
|
+
<details>
|
|
30
|
+
<summary>Row-by-row detail</summary>
|
|
31
|
+
<ul>
|
|
32
|
+
<% for (const row of result.rows.slice(0, 200)) { %>
|
|
33
|
+
<li><code><%= row.action %></code> <%= row.email || '' %> <%= row.name || row.passName || '' %> <%= row.reason ? `— ${row.reason}` : '' %></li>
|
|
34
|
+
<% } %>
|
|
35
|
+
</ul>
|
|
36
|
+
</details>
|
|
37
|
+
</article>
|
|
38
|
+
<% } %>
|
|
39
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<hgroup>
|
|
3
|
+
<h3>Assign classes — <%= account.name || account.email %></h3>
|
|
4
|
+
<p>Tick the upcoming classes this instructor teaches. They will see these on
|
|
5
|
+
their schedule and can check attendees in. Past assignments are kept as-is.</p>
|
|
6
|
+
</hgroup>
|
|
7
|
+
<form method="post" action="/admin/instructors/<%= account.id %>/classes"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
8
|
+
<% if (!instances.length) { %>
|
|
9
|
+
<p>No upcoming scheduled classes — create rules or one-off classes first.</p>
|
|
10
|
+
<% } %>
|
|
11
|
+
<% for (const ci of instances) { %>
|
|
12
|
+
<label>
|
|
13
|
+
<input type="checkbox" name="class_ids" value="<%= ci.id %>" <%= assigned.has(ci.id) ? 'checked' : '' %>>
|
|
14
|
+
<%= fmtDt(ci.starts_at) %> — <%= ci.class_name %>
|
|
15
|
+
<% if (ci.instructor_name) { %><small>(profile: <%= ci.instructor_name %>)</small><% } %>
|
|
16
|
+
</label>
|
|
17
|
+
<% } %>
|
|
18
|
+
<% if (instances.length) { %><button>Save assignments</button><% } %>
|
|
19
|
+
</form>
|
|
20
|
+
<p><a href="/admin/instructors">← Back to instructors</a></p>
|
|
21
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<article class="narrow">
|
|
3
|
+
<h3>New instructor login</h3>
|
|
4
|
+
<p>Creates an account that can sign in on the staff login page, see its assigned
|
|
5
|
+
classes and check attendees in — nothing else.</p>
|
|
6
|
+
<% if (error) { %><p class="flash error" role="alert"><%= error %></p><% } %>
|
|
7
|
+
<form method="post" action="/admin/instructors/new"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
8
|
+
<label>Name <input name="name" value="<%= values.name || '' %>" required></label>
|
|
9
|
+
<label>Email <input type="email" name="email" value="<%= values.email || '' %>" required></label>
|
|
10
|
+
<label>Password <input type="password" name="password" minlength="8" required></label>
|
|
11
|
+
<button>Create instructor login</button>
|
|
12
|
+
</form>
|
|
13
|
+
<p><a href="/admin/instructors">← Back to instructors</a></p>
|
|
14
|
+
</article>
|
|
15
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<h3>Instructors</h3>
|
|
3
|
+
<details open>
|
|
4
|
+
<summary role="button" class="outline">Add instructor</summary>
|
|
5
|
+
<form method="post" action="/admin/instructors"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
6
|
+
<div class="grid">
|
|
7
|
+
<label>Name <input name="name" required></label>
|
|
8
|
+
<label>Bio <input name="bio"></label>
|
|
9
|
+
</div>
|
|
10
|
+
<button>Add</button>
|
|
11
|
+
</form>
|
|
12
|
+
</details>
|
|
13
|
+
<% for (const i of instructors) { %>
|
|
14
|
+
<details>
|
|
15
|
+
<summary><strong><%= i.name %></strong><%= i.active ? '' : ' · INACTIVE' %></summary>
|
|
16
|
+
<form method="post" action="/admin/instructors/<%= i.id %>"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
17
|
+
<div class="grid">
|
|
18
|
+
<label>Name <input name="name" value="<%= i.name %>" required></label>
|
|
19
|
+
<label>Bio <input name="bio" value="<%= i.bio %>"></label>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="grid">
|
|
22
|
+
<button>Save</button>
|
|
23
|
+
<button name="action" value="toggle" class="outline secondary"><%= i.active ? 'Deactivate' : 'Reactivate' %></button>
|
|
24
|
+
</div>
|
|
25
|
+
</form>
|
|
26
|
+
</details>
|
|
27
|
+
<% } %>
|
|
28
|
+
|
|
29
|
+
<h3>Instructor logins</h3>
|
|
30
|
+
<p>Accounts that can sign in, see the classes assigned to them and check
|
|
31
|
+
attendees in — no admin access. <a href="/admin/instructors/new" role="button" class="slim outline">Add instructor login</a></p>
|
|
32
|
+
<% if (accounts.length) { %>
|
|
33
|
+
<table>
|
|
34
|
+
<thead><tr><th>Name</th><th>Email</th><th>Upcoming classes</th><th></th></tr></thead>
|
|
35
|
+
<tbody>
|
|
36
|
+
<% for (const a of accounts) { %>
|
|
37
|
+
<tr>
|
|
38
|
+
<td><%= a.name || '—' %></td>
|
|
39
|
+
<td><%= a.email %></td>
|
|
40
|
+
<td><%= a.upcoming_count %></td>
|
|
41
|
+
<td><a href="/admin/instructors/<%= a.id %>/classes" role="button" class="slim outline">Assign classes</a></td>
|
|
42
|
+
</tr>
|
|
43
|
+
<% } %>
|
|
44
|
+
</tbody>
|
|
45
|
+
</table>
|
|
46
|
+
<% } else { %>
|
|
47
|
+
<p><em>No instructor logins yet.</em></p>
|
|
48
|
+
<% } %>
|
|
49
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<h3>Products</h3>
|
|
3
|
+
<div class="grid">
|
|
4
|
+
<section>
|
|
5
|
+
<h4>Class packs (buy page)</h4>
|
|
6
|
+
<details open>
|
|
7
|
+
<summary role="button" class="outline">Add pack product</summary>
|
|
8
|
+
<form method="post" action="/admin/products/pack"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
9
|
+
<div class="grid">
|
|
10
|
+
<label>Name <input name="name" required placeholder="10-class pack"></label>
|
|
11
|
+
<label>Credits <input type="number" name="credits" value="10" min="1"></label>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="grid">
|
|
14
|
+
<label>Price <input type="number" name="price" step="0.01" min="0" required></label>
|
|
15
|
+
<label>Validity (days) <input type="number" name="validity_days" min="1" placeholder="never expires"></label>
|
|
16
|
+
</div>
|
|
17
|
+
<label>Stripe Price ID <input name="stripe_price_id" placeholder="price_… (optional)"></label>
|
|
18
|
+
<button>Add</button>
|
|
19
|
+
</form>
|
|
20
|
+
</details>
|
|
21
|
+
<% for (const p of packs) { %>
|
|
22
|
+
<details>
|
|
23
|
+
<summary><strong><%= p.name %></strong> · <%= p.credits %> credits · <%= fmtMoney(p.price_cents) %><%= p.active ? '' : ' · INACTIVE' %></summary>
|
|
24
|
+
<form method="post" action="/admin/products/pack/<%= p.id %>"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
25
|
+
<div class="grid">
|
|
26
|
+
<label>Name <input name="name" value="<%= p.name %>"></label>
|
|
27
|
+
<label>Credits <input type="number" name="credits" value="<%= p.credits %>"></label>
|
|
28
|
+
<label>Price <input type="number" name="price" step="0.01" value="<%= (p.price_cents/100).toFixed(2) %>"></label>
|
|
29
|
+
<label>Validity days <input type="number" name="validity_days" value="<%= p.validity_days || '' %>"></label>
|
|
30
|
+
</div>
|
|
31
|
+
<label>Stripe Price ID <input name="stripe_price_id" value="<%= p.stripe_price_id || '' %>"></label>
|
|
32
|
+
<div class="grid">
|
|
33
|
+
<button>Save</button>
|
|
34
|
+
<button name="action" value="toggle" class="outline secondary"><%= p.active ? 'Deactivate' : 'Reactivate' %></button>
|
|
35
|
+
</div>
|
|
36
|
+
</form>
|
|
37
|
+
</details>
|
|
38
|
+
<% } %>
|
|
39
|
+
</section>
|
|
40
|
+
<section>
|
|
41
|
+
<h4>Membership plans</h4>
|
|
42
|
+
<details open>
|
|
43
|
+
<summary role="button" class="outline">Add plan</summary>
|
|
44
|
+
<form method="post" action="/admin/products/plan"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
45
|
+
<div class="grid">
|
|
46
|
+
<label>Name <input name="name" required placeholder="Monthly unlimited"></label>
|
|
47
|
+
<label>Price/month <input type="number" name="price" step="0.01" min="0" required></label>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="grid">
|
|
50
|
+
<label>Type
|
|
51
|
+
<select name="unlimited"><option value="1">Unlimited</option><option value="0">Credit-based</option></select>
|
|
52
|
+
</label>
|
|
53
|
+
<label>Credits/month <input type="number" name="credits_per_month" min="1" placeholder="8"></label>
|
|
54
|
+
</div>
|
|
55
|
+
<label>Stripe Price ID <input name="stripe_price_id" placeholder="price_… (required for online subscribe)"></label>
|
|
56
|
+
<button>Add</button>
|
|
57
|
+
</form>
|
|
58
|
+
</details>
|
|
59
|
+
<% for (const p of plans) { %>
|
|
60
|
+
<details>
|
|
61
|
+
<summary><strong><%= p.name %></strong> · <%= fmtMoney(p.price_cents) %>/mo · <%= p.unlimited ? 'unlimited' : `${p.credits_per_month}/mo` %><%= p.active ? '' : ' · INACTIVE' %></summary>
|
|
62
|
+
<form method="post" action="/admin/products/plan/<%= p.id %>"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
63
|
+
<div class="grid">
|
|
64
|
+
<label>Name <input name="name" value="<%= p.name %>"></label>
|
|
65
|
+
<label>Price <input type="number" name="price" step="0.01" value="<%= (p.price_cents/100).toFixed(2) %>"></label>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="grid">
|
|
68
|
+
<label>Type
|
|
69
|
+
<select name="unlimited"><option value="1" <%= p.unlimited ? 'selected' : '' %>>Unlimited</option><option value="0" <%= p.unlimited ? '' : 'selected' %>>Credit-based</option></select>
|
|
70
|
+
</label>
|
|
71
|
+
<label>Credits/month <input type="number" name="credits_per_month" value="<%= p.credits_per_month || '' %>"></label>
|
|
72
|
+
</div>
|
|
73
|
+
<label>Stripe Price ID <input name="stripe_price_id" value="<%= p.stripe_price_id || '' %>"></label>
|
|
74
|
+
<div class="grid">
|
|
75
|
+
<button>Save</button>
|
|
76
|
+
<button name="action" value="toggle" class="outline secondary"><%= p.active ? 'Deactivate' : 'Reactivate' %></button>
|
|
77
|
+
</div>
|
|
78
|
+
</form>
|
|
79
|
+
</details>
|
|
80
|
+
<% } %>
|
|
81
|
+
</section>
|
|
82
|
+
</div>
|
|
83
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<h3>Reports</h3>
|
|
3
|
+
<section>
|
|
4
|
+
<h4>Revenue by month <a href="/admin/reports.csv?report=revenue" role="button" class="slim outline">CSV</a></h4>
|
|
5
|
+
<table>
|
|
6
|
+
<thead><tr><th>Month</th><th>Total</th><th>Drop-ins</th><th>Packs</th><th>Memberships</th><th>Payments</th></tr></thead>
|
|
7
|
+
<tbody>
|
|
8
|
+
<% for (const r of revenue) { %>
|
|
9
|
+
<tr>
|
|
10
|
+
<td><%= r.month %></td>
|
|
11
|
+
<td><strong><%= fmtMoney(r.total) %></strong></td>
|
|
12
|
+
<td><%= fmtMoney(r.drop_in) %></td>
|
|
13
|
+
<td><%= fmtMoney(r.pack) %></td>
|
|
14
|
+
<td><%= fmtMoney(r.membership) %></td>
|
|
15
|
+
<td><%= r.count %></td>
|
|
16
|
+
</tr>
|
|
17
|
+
<% } %>
|
|
18
|
+
<% if (!revenue.length) { %><tr><td colspan="6">No paid payments yet.</td></tr><% } %>
|
|
19
|
+
</tbody>
|
|
20
|
+
</table>
|
|
21
|
+
</section>
|
|
22
|
+
<section>
|
|
23
|
+
<h4>Attendance by class type <a href="/admin/reports.csv?report=attendance" role="button" class="slim outline">CSV</a></h4>
|
|
24
|
+
<table>
|
|
25
|
+
<thead><tr><th>Class type</th><th>Attended</th><th>No-shows</th><th>Booked (upcoming)</th><th>Total</th></tr></thead>
|
|
26
|
+
<tbody>
|
|
27
|
+
<% for (const a of attendance) { %>
|
|
28
|
+
<tr><td><%= a.class_type %></td><td><%= a.attended %></td><td><%= a.no_shows %></td><td><%= a.booked %></td><td><%= a.total %></td></tr>
|
|
29
|
+
<% } %>
|
|
30
|
+
<% if (!attendance.length) { %><tr><td colspan="5">No bookings yet.</td></tr><% } %>
|
|
31
|
+
</tbody>
|
|
32
|
+
</table>
|
|
33
|
+
</section>
|
|
34
|
+
<%- include('../partials/foot') %>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<%- include('../partials/head') %>
|
|
2
|
+
<hgroup>
|
|
3
|
+
<h3><%= inst.class_name %> roster</h3>
|
|
4
|
+
<p><%= fmtDt(inst.starts_at) %> · <%= roster.length %>/<%= inst.capacity %> booked
|
|
5
|
+
<% if (inst.instructor_name) { %> · <%= inst.instructor_name %><% } %>
|
|
6
|
+
<% if (inst.status === 'cancelled') { %> · <mark>CANCELLED</mark><% } %></p>
|
|
7
|
+
</hgroup>
|
|
8
|
+
<table>
|
|
9
|
+
<thead><tr><th>Client</th><th>Paid with</th><th>Status</th><th>Actions</th></tr></thead>
|
|
10
|
+
<tbody>
|
|
11
|
+
<% for (const b of roster) { %>
|
|
12
|
+
<tr>
|
|
13
|
+
<td><a href="/admin/clients/<%= b.client_id %>"><%= b.client_name %></a><br><small><%= b.email %></small></td>
|
|
14
|
+
<td><%= b.paid_with.replace(/_/g, ' ') %></td>
|
|
15
|
+
<td><%= b.status %></td>
|
|
16
|
+
<td>
|
|
17
|
+
<form method="post" action="/admin/bookings/<%= b.id %>/status" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
18
|
+
<% if (b.status !== 'attended') { %><button name="status" value="attended" class="slim">Check in</button><% } %>
|
|
19
|
+
<% if (b.status !== 'no_show') { %><button name="status" value="no_show" class="slim outline">No-show</button><% } %>
|
|
20
|
+
<% if (b.status !== 'booked') { %><button name="status" value="booked" class="slim outline secondary">Undo</button><% } %>
|
|
21
|
+
<% if (b.status === 'booked') { %><button name="status" value="cancelled" class="slim outline secondary" onclick="return confirm('Cancel this booking?')">Cancel</button><% } %>
|
|
22
|
+
</form>
|
|
23
|
+
</td>
|
|
24
|
+
</tr>
|
|
25
|
+
<% } %>
|
|
26
|
+
<% if (!roster.length) { %><tr><td colspan="4">No bookings yet.</td></tr><% } %>
|
|
27
|
+
</tbody>
|
|
28
|
+
</table>
|
|
29
|
+
<% if (waitlist.length) { %>
|
|
30
|
+
<h5>Waitlist (<%= waitlist.length %>)</h5>
|
|
31
|
+
<ol>
|
|
32
|
+
<% for (const b of waitlist) { %>
|
|
33
|
+
<li><a href="/admin/clients/<%= b.client_id %>"><%= b.client_name %></a> <small><%= b.email %></small>
|
|
34
|
+
<form method="post" action="/admin/bookings/<%= b.id %>/status" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
|
35
|
+
<button name="status" value="cancelled" class="slim outline secondary">Remove</button>
|
|
36
|
+
</form>
|
|
37
|
+
</li>
|
|
38
|
+
<% } %>
|
|
39
|
+
</ol>
|
|
40
|
+
<% } %>
|
|
41
|
+
<details>
|
|
42
|
+
<summary role="button" class="outline">Add walk-in</summary>
|
|
43
|
+
<form method="post" action="/admin/instances/<%= inst.id %>/walkin"><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>Payment
|
|
48
|
+
<select name="paid_with">
|
|
49
|
+
<option value="auto">Auto (membership/pack first)</option>
|
|
50
|
+
<option value="drop_in_manual">Drop-in (pay at studio)</option>
|
|
51
|
+
<option value="comp">Comp (free)</option>
|
|
52
|
+
</select>
|
|
53
|
+
</label>
|
|
54
|
+
</div>
|
|
55
|
+
<button>Add to class</button>
|
|
56
|
+
</form>
|
|
57
|
+
</details>
|
|
58
|
+
<%- include('../partials/foot') %>
|