spread

https://git.tonybtw.com/spread.git git://git.tonybtw.com/spread.git
17,580 bytes raw
1
package app
2
3
import "html/template"
4
5
const pageCSS = `
6
@font-face{font-family:'Iosevka';font-style:normal;font-weight:400;font-display:swap;src:url('/assets/fonts/iosevka-latin-400-normal.woff2') format('woff2')}
7
@font-face{font-family:'Iosevka';font-style:normal;font-weight:500;font-display:swap;src:url('/assets/fonts/iosevka-latin-500-normal.woff2') format('woff2')}
8
@font-face{font-family:'Iosevka';font-style:normal;font-weight:600;font-display:swap;src:url('/assets/fonts/iosevka-latin-600-normal.woff2') format('woff2')}
9
@font-face{font-family:'Iosevka';font-style:italic;font-weight:400;font-display:swap;src:url('/assets/fonts/iosevka-latin-400-italic.woff2') format('woff2')}
10
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url('/assets/fonts/ibm-plex-sans-latin-400-normal.woff2') format('woff2')}
11
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url('/assets/fonts/ibm-plex-sans-latin-500-normal.woff2') format('woff2')}
12
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
13
:root{--bg:#0a0a0c;--surface:#18181c;--border:#2a2a2e;--border-strong:#3a3a40;
14
--text:#f5f4f0;--muted:#a8a6a0;--dim:#6a6864;--green:#5dd39e;--green-bg:#11201a;--green-dim:#1f3a2c;
15
--red:#ff8a7a;--red-bg:#2a1410;--serif:'Iosevka',ui-monospace,monospace;--sans:'IBM Plex Sans',system-ui,sans-serif;--mono:'Iosevka',ui-monospace,monospace}
16
html,body{background:var(--bg);color:var(--text);font-family:var(--sans);-webkit-font-smoothing:antialiased}
17
a{color:inherit;text-decoration:none}
18
.wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem}
19
.card{width:100%;max-width:400px;background:var(--surface);border:0.5px solid var(--border);border-radius:16px;padding:2.5rem}
20
.brand{font-family:var(--serif);font-size:22px;font-weight:500;letter-spacing:-0.5px}
21
.brand span{color:var(--green);font-size:13px}
22
.brand em{font-style:normal;color:var(--dim);font-size:16px}
23
h1{font-family:var(--serif);font-size:30px;font-weight:500;letter-spacing:-1px;margin-bottom:1.25rem}
24
label{display:block;font-size:12px;color:var(--muted);margin-bottom:1rem;letter-spacing:0.2px}
25
input,select{display:block;width:100%;margin-top:6px;padding:11px 13px;background:var(--bg);color:var(--text);
26
border:0.5px solid var(--border-strong);border-radius:9px;font-family:var(--sans);font-size:14px}
27
input:focus,select:focus{outline:none;border-color:var(--green)}
28
input[readonly]{color:var(--muted);border-style:dashed}
29
button{font-family:var(--sans);font-size:14px;font-weight:500;padding:12px 22px;border:none;border-radius:100px;
30
background:var(--green);color:#061a12;cursor:pointer;margin-top:6px}
31
button.ghost,a.btn-secondary{background:transparent;color:var(--text);border:0.5px solid var(--border-strong);display:inline-block}
32
.btn-sm{padding:6px 14px;font-size:12px;margin-top:0}
33
button.link{background:none;border:none;color:var(--muted);font-size:13px;cursor:pointer;padding:0;margin:0}
34
button.link:hover{color:var(--text)}
35
.btn-danger{background:transparent;color:var(--red);border:0.5px solid #4a2018;padding:6px 12px;font-size:12px;border-radius:100px;cursor:pointer;margin:0}
36
.muted{color:var(--muted);font-size:14px;line-height:1.55;margin-bottom:0.75rem}
37
.muted b{color:var(--text);font-weight:500}.muted a{color:var(--green)}
38
.err{background:var(--red-bg);color:var(--red);border:0.5px solid #4a2018;border-radius:9px;padding:10px 13px;font-size:13px;margin-bottom:1.25rem}
39
.notice{background:var(--green-bg);color:var(--green);border:0.5px solid var(--green-dim);border-radius:9px;padding:10px 13px;font-size:13px;margin-bottom:1.25rem}
40
41
/* admin */
42
.topbar{display:flex;align-items:center;justify-content:space-between;padding:1rem 2rem;border-bottom:0.5px solid var(--border);position:sticky;top:0;background:rgba(10,10,12,.85);backdrop-filter:blur(10px);z-index:10}
43
.topnav{display:flex;align-items:center;gap:1.5rem}
44
.topnav a{font-size:13px;color:var(--muted)}.topnav a:hover{color:var(--text)}
45
.topnav form{margin:0}
46
.admin{max-width:900px;margin:0 auto;padding:2.5rem 2rem}
47
.admin h2{font-family:var(--serif);font-size:20px;font-weight:500;margin:2rem 0 1rem;letter-spacing:-.3px}
48
table.admin-t{width:100%;border-collapse:collapse;font-size:13px;margin-bottom:1rem}
49
table.admin-t th{text-align:left;padding:9px 10px;font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--dim);border-bottom:0.5px solid var(--border)}
50
table.admin-t td{padding:10px;border-bottom:0.5px solid var(--border);color:var(--muted);vertical-align:middle}
51
table.admin-t td.mono{font-family:var(--mono);color:var(--text)}
52
.stat-row{display:flex;gap:12px;margin:1.5rem 0;flex-wrap:wrap}
53
.stat{flex:1;min-width:120px;background:var(--surface);border:0.5px solid var(--border);border-radius:12px;padding:1.1rem 1.25rem}
54
.stat .n{font-family:var(--serif);font-size:30px;font-weight:500;letter-spacing:-1px}
55
.stat .l{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:var(--dim);margin-top:4px}
56
.form-inline{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;background:var(--surface);border:0.5px solid var(--border);border-radius:12px;padding:1.25rem;margin-bottom:1rem}
57
.form-inline label{margin:0;flex:1;min-width:140px}
58
.form-inline .btnwrap{display:flex;gap:8px;align-items:center}
59
.form-inline button{margin-top:0}
60
.pill{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:1px;padding:2px 9px;border-radius:100px;border:0.5px solid var(--border-strong);color:var(--dim);vertical-align:middle}
61
.pill.int{color:var(--green);border-color:var(--green-dim)}
62
a.editlink{color:var(--green);font-family:var(--mono);font-size:11px}
63
`
64
65
var tmpl = template.Must(template.New("").Parse(`
66
{{define "head"}}<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
67
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · Spread</title><style>` + pageCSS + `</style></head><body><div class="wrap">{{end}}
68
{{define "foot"}}</div></body></html>{{end}}
69
70
{{define "ahead"}}<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
71
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · Spread admin</title><style>` + pageCSS + `</style></head><body>
72
<div class="topbar">
73
  <div class="brand">Spread<span>●</span> <em>· admin</em></div>
74
  <nav class="topnav">
75
    <a href="/admin">Overview</a>
76
    <a href="/admin/catalog">Catalog</a>
77
    <a href="/admin/subscribers">Subscribers</a>
78
    <a href="/admin/boms">BOMs</a>
79
    <form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="link">Log out</button></form>
80
  </nav>
81
</div>
82
<main class="admin">{{end}}
83
{{define "afoot"}}</main></body></html>{{end}}
84
85
{{define "login"}}{{template "head" .}}
86
<div class="card">
87
  <div class="brand">Spread<span>●</span></div>
88
  <h1>Sign in</h1>
89
  {{if .Error}}<div class="err">{{.Error}}</div>{{end}}
90
  <form method="post" action="/login">
91
    <input type="hidden" name="csrf_token" value="{{.CSRF}}">
92
    <label>Email<input type="email" name="email" value="{{.Email}}" required autofocus></label>
93
    <label>Password<input type="password" name="password" required></label>
94
    <button type="submit">Sign in →</button>
95
  </form>
96
  <p class="muted" style="margin-top:1rem"><a href="/forgot">Forgot your password?</a></p>
97
</div>{{template "foot" .}}{{end}}
98
99
{{define "chead"}}<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
100
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · Spread</title>
101
<style>` + pageCSS + `</style></head><body>
102
<div class="topbar">
103
  <div class="brand">Spread<span>●</span></div>
104
  <nav class="topnav">
105
    <a href="/app">Your BOMs</a>
106
    <a href="/">Public demo</a>
107
    <form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="link">Log out</button></form>
108
  </nav>
109
</div>
110
<main class="admin">{{end}}
111
{{define "cfoot"}}</main></body></html>{{end}}
112
113
{{define "app"}}{{template "chead" .}}
114
<h1>Your BOMs</h1>
115
{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
116
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
117
<form class="form-inline" method="post" action="/app/analyze" enctype="multipart/form-data">
118
  <input type="hidden" name="csrf_token" value="{{.CSRF}}">
119
  <label>Upload a BOM (.csv or .xlsx)<input type="file" name="bom" accept=".csv,.tsv,.txt,.xlsx" required></label>
120
  <div class="btnwrap"><button type="submit">Re-quote →</button></div>
121
</form>
122
<p class="muted">Signed in as <b>{{.User.Email}}</b> — <b>{{.Sub.Name}}</b>. Uploads are re-quoted against the live catalog and saved here.</p>
123
<table class="admin-t">
124
  <thead><tr><th>BOM</th><th>Uploaded</th><th>Parts</th><th>Total savings</th><th>Net</th><th></th></tr></thead>
125
  <tbody>
126
  {{range .BOMs}}
127
    <tr>
128
      <td class="mono">{{.Ref}}</td>
129
      <td>{{.CreatedAt}}</td>
130
      <td>{{.Lines}}</td>
131
      <td class="mono">{{printf "$%.2f" .TotalSavings}}</td>
132
      <td class="mono" style="color:var(--green)">{{printf "$%.2f" .NetSavings}}</td>
133
      <td style="text-align:right"><a class="editlink" href="/app/bom/{{.ID}}">view</a></td>
134
    </tr>
135
  {{else}}
136
    <tr><td colspan="6" style="color:var(--dim)">No BOMs yet — upload one above to see it re-quoted.</td></tr>
137
  {{end}}
138
  </tbody>
139
</table>
140
{{template "cfoot" .}}{{end}}
141
142
{{define "bom"}}{{template "chead" .}}
143
{{with .BOM}}
144
<p class="muted"><a href="{{$.BackURL}}">← back</a></p>
145
<h1>{{.Ref}}</h1>
146
<p class="muted">{{.SubscriberName}} · {{.CreatedAt}} · {{.Lines}} parts{{if .Filename}} · {{.Filename}}{{end}}</p>
147
<div class="stat-row">
148
  <div class="stat"><div class="n">{{printf "$%.2f" .TotalSavings}}</div><div class="l">Total savings</div></div>
149
  <div class="stat"><div class="n">{{printf "$%.2f" .OurCut}}</div><div class="l">Our cut (20%)</div></div>
150
  <div class="stat"><div class="n" style="color:var(--green)">{{printf "$%.2f" .NetSavings}}</div><div class="l">Net savings</div></div>
151
</div>
152
<table class="admin-t">
153
  <thead><tr><th>Part</th><th>Qty</th><th>Market</th><th>Spread price</th><th>You save</th></tr></thead>
154
  <tbody>
155
  {{range .Items}}
156
    <tr>
157
      <td>{{.Description}}<br><span class="mono" style="font-size:11px;color:var(--dim)">{{.PartNumber}}{{if not .Matched}} · est.{{end}}</span></td>
158
      <td class="mono">{{printf "%.0f" .Quantity}}</td>
159
      <td class="mono">{{printf "$%.4f" .UnitPrice}}</td>
160
      <td class="mono">{{printf "$%.4f" .SpreadPrice}}</td>
161
      <td class="mono" style="color:var(--green)">{{printf "$%.2f" .LineSaving}}</td>
162
    </tr>
163
  {{end}}
164
  </tbody>
165
</table>
166
{{end}}
167
{{template "cfoot" .}}{{end}}
168
169
{{define "boms"}}{{template "ahead" .}}
170
<h1>Uploaded BOMs</h1>
171
<table class="admin-t">
172
  <thead><tr><th>BOM</th><th>Subscriber</th><th>Uploaded by</th><th>When</th><th>Parts</th><th>Savings</th><th></th></tr></thead>
173
  <tbody>
174
  {{range .BOMs}}
175
    <tr>
176
      <td class="mono">{{.Ref}}</td>
177
      <td>{{.SubscriberName}}</td>
178
      <td class="mono">{{.UploadedByEmail}}</td>
179
      <td>{{.CreatedAt}}</td>
180
      <td>{{.Lines}}</td>
181
      <td class="mono">{{printf "$%.2f" .TotalSavings}}</td>
182
      <td style="text-align:right"><a class="editlink" href="/app/bom/{{.ID}}">view</a></td>
183
    </tr>
184
  {{else}}
185
    <tr><td colspan="7" style="color:var(--dim)">No BOMs uploaded yet.</td></tr>
186
  {{end}}
187
  </tbody>
188
</table>
189
{{template "afoot" .}}{{end}}
190
191
{{define "admin"}}{{template "ahead" .}}
192
<h1>Overview</h1>
193
<p class="muted">Signed in as <b>{{.User.Email}}</b> — <b>{{.Sub.Name}}</b>.</p>
194
<div class="stat-row">
195
  <div class="stat"><div class="n">{{.Counts.Catalog}}</div><div class="l">Catalog parts</div></div>
196
  <div class="stat"><div class="n">{{.Counts.Subs}}</div><div class="l">Subscribers</div></div>
197
  <div class="stat"><div class="n">{{.Counts.Users}}</div><div class="l">Users</div></div>
198
  <div class="stat"><div class="n">{{.Counts.Boms}}</div><div class="l">BOMs uploaded</div></div>
199
</div>
200
<p class="muted"><a href="/admin/catalog">Manage catalog →</a></p>
201
<p class="muted"><a href="/admin/subscribers">Manage subscribers &amp; users →</a></p>
202
<p class="muted"><a href="/admin/boms">Review uploaded BOMs →</a></p>
203
{{template "afoot" .}}{{end}}
204
205
{{define "catalog"}}{{template "ahead" .}}
206
<h1>Catalog</h1>
207
{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
208
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
209
<form class="form-inline" method="post" action="/admin/catalog">
210
  <input type="hidden" name="csrf_token" value="{{.CSRF}}">
211
  <label>Part number<input name="part_number" value="{{with .Edit}}{{.PartNumber}}{{end}}"{{if .Edit}} readonly{{end}} required></label>
212
  <label>Description<input name="description" value="{{with .Edit}}{{.Description}}{{end}}"></label>
213
  <label>Spread price<input name="spread_price" type="number" step="0.0001" min="0" value="{{with .Edit}}{{printf "%.4f" .SpreadPrice}}{{end}}" required></label>
214
  <div class="btnwrap">
215
    <button type="submit">{{if .Edit}}Update{{else}}Add part{{end}}</button>
216
    {{if .Edit}}<a class="btn-secondary btn-sm" href="/admin/catalog">Cancel</a>{{end}}
217
  </div>
218
</form>
219
<table class="admin-t">
220
  <thead><tr><th>Part number</th><th>Description</th><th>Spread price</th><th></th></tr></thead>
221
  <tbody>
222
  {{range .Catalog}}
223
    <tr>
224
      <td class="mono">{{.PartNumber}}</td>
225
      <td>{{.Description}}</td>
226
      <td class="mono">{{printf "$%.4f" .SpreadPrice}}</td>
227
      <td style="text-align:right;white-space:nowrap">
228
        <a class="editlink" href="/admin/catalog?edit={{.PartNumber}}">edit</a>
229
        <form method="post" action="/admin/catalog/delete" style="display:inline;margin-left:12px">
230
          <input type="hidden" name="csrf_token" value="{{$.CSRF}}">
231
          <input type="hidden" name="part_number" value="{{.PartNumber}}">
232
          <button class="btn-danger" type="submit">delete</button>
233
        </form>
234
      </td>
235
    </tr>
236
  {{else}}
237
    <tr><td colspan="4" style="color:var(--dim)">No catalog entries yet.</td></tr>
238
  {{end}}
239
  </tbody>
240
</table>
241
<form method="post" action="/admin/catalog/seed"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="btn-secondary btn-sm">{{if .Catalog}}Re-seed demo parts{{else}}Seed demo catalog{{end}}</button></form>
242
{{template "afoot" .}}{{end}}
243
244
{{define "subscribers"}}{{template "ahead" .}}
245
<h1>Subscribers &amp; users</h1>
246
{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
247
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
248
249
<h2>Add a subscriber</h2>
250
<form class="form-inline" method="post" action="/admin/subscribers">
251
  <input type="hidden" name="csrf_token" value="{{.CSRF}}">
252
  <label>Company / subscriber name<input name="name" required></label>
253
  <div class="btnwrap"><button type="submit">Create subscriber</button></div>
254
</form>
255
256
<h2>Add a user</h2>
257
<form class="form-inline" method="post" action="/admin/users">
258
  <input type="hidden" name="csrf_token" value="{{.CSRF}}">
259
  <label>Subscriber<select name="subscriber_id" required>{{range .Subs}}<option value="{{.ID}}">{{.Name}}{{if .IsInternal}} (internal){{end}}</option>{{end}}</select></label>
260
  <label>Email<input type="email" name="email" required></label>
261
  <label>Name<input name="name"></label>
262
  <label>Password <span style="color:var(--dim)">(blank = email an invite)</span><input type="password" name="password" minlength="8"></label>
263
  <div class="btnwrap"><button type="submit">Create user</button></div>
264
</form>
265
266
{{range .Subs}}
267
<h2>{{.Name}} {{if .IsInternal}}<span class="pill int">internal</span>{{else}}<span class="pill">customer</span>{{end}}</h2>
268
<table class="admin-t">
269
  <thead><tr><th>Email</th><th>Name</th><th>User ID</th></tr></thead>
270
  <tbody>
271
  {{range .Users}}<tr><td class="mono">{{.Email}}</td><td>{{.Name}}</td><td class="mono">{{.ID}}</td></tr>
272
  {{else}}<tr><td colspan="3" style="color:var(--dim)">No users yet.</td></tr>{{end}}
273
  </tbody>
274
</table>
275
{{end}}
276
{{template "afoot" .}}{{end}}
277
278
{{define "forgot"}}{{template "head" .}}
279
<div class="card">
280
  <div class="brand">Spread<span>●</span></div>
281
  <h1>Reset password</h1>
282
  {{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
283
  <form method="post" action="/forgot">
284
    <input type="hidden" name="csrf_token" value="{{.CSRF}}">
285
    <label>Email<input type="email" name="email" required autofocus></label>
286
    <button type="submit">Send reset link →</button>
287
  </form>
288
  <p class="muted" style="margin-top:1rem"><a href="/login">Back to sign in</a></p>
289
</div>{{template "foot" .}}{{end}}
290
291
{{define "setpassword"}}{{template "head" .}}
292
<div class="card">
293
  <div class="brand">Spread<span>●</span></div>
294
  <h1>Set your password</h1>
295
  {{if .Error}}<div class="err">{{.Error}}</div>{{end}}
296
  <form method="post" action="/set-password">
297
    <input type="hidden" name="csrf_token" value="{{.CSRF}}">
298
    <input type="hidden" name="token" value="{{.Token}}">
299
    <label>New password<input type="password" name="password" required minlength="8" autofocus></label>
300
    <label>Confirm password<input type="password" name="confirm" required minlength="8"></label>
301
    <button type="submit">Set password →</button>
302
  </form>
303
</div>{{template "foot" .}}{{end}}
304
305
{{define "tokenerror"}}{{template "head" .}}
306
<div class="card">
307
  <div class="brand">Spread<span>●</span></div>
308
  <h1>Link expired</h1>
309
  <p class="muted">This link is invalid or has already been used. Ask an admin to re-send your invite, or <a href="/forgot">request a new reset link</a>.</p>
310
</div>{{template "foot" .}}{{end}}
311
`))