local-review 0.2.0 → 0.4.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/CHANGELOG.md +23 -0
- package/dist/demo/lib/gitworld.js +224 -0
- package/dist/demo/world/api.js +2436 -0
- package/dist/demo/world/infra.js +189 -0
- package/dist/demo/world/people.js +16 -0
- package/dist/demo/world/review.js +578 -0
- package/dist/demo/world/sdk.js +376 -0
- package/dist/demo/world/web.js +657 -0
- package/dist/server/cli.js +28 -0
- package/dist/server/config.js +3 -0
- package/dist/server/examples.js +204 -0
- package/dist/server/index.js +17 -2
- package/dist/server/projects.js +2 -0
- package/dist/web/assets/{angular-html-DJ4eInGE.js → angular-html-F7zRgWCZ.js} +1 -1
- package/dist/web/assets/{angular-ts-DBhgg2a7.js → angular-ts-BpEpytbw.js} +1 -1
- package/dist/web/assets/{apl-TCQAu201.js → apl-CXKG97CP.js} +1 -1
- package/dist/web/assets/{astro-DVtFuwC2.js → astro-Bbu7K9fw.js} +1 -1
- package/dist/web/assets/{blade-CMp-sZYg.js → blade-BIHnATAa.js} +1 -1
- package/dist/web/assets/{c-DgqspIM2.js → c-C-acQYm6.js} +1 -1
- package/dist/web/assets/{chapel-NTLt7i8E.js → chapel-CO4oLJ27.js} +1 -1
- package/dist/web/assets/{cobol-CJiWQrT4.js → cobol-DwOqj9Jg.js} +1 -1
- package/dist/web/assets/{coffee-Bv_hE6wf.js → coffee-D4-tkAkt.js} +1 -1
- package/dist/web/assets/{cpp-CuUsyAvc.js → cpp-B5AO2QHP.js} +1 -1
- package/dist/web/assets/{crystal-BeLopiD_.js → crystal-Da5vXEdt.js} +1 -1
- package/dist/web/assets/{css-OngwBV3K.js → css-BPZthOhS.js} +1 -1
- package/dist/web/assets/{edge-BFLildoE.js → edge-Dax-tr1_.js} +1 -1
- package/dist/web/assets/{elixir-1Fs6FjVs.js → elixir-Bn9yrJM6.js} +1 -1
- package/dist/web/assets/{elm-piMM67vF.js → elm-BOsm44l2.js} +1 -1
- package/dist/web/assets/{erb-JJ0sF8GL.js → erb-Gt3RYukx.js} +1 -1
- package/dist/web/assets/{git-rebase-CtqXywWe.js → git-rebase-PkordWam.js} +1 -1
- package/dist/web/assets/{glimmer-js-BPewWxGC.js → glimmer-js-B5msfXVo.js} +1 -1
- package/dist/web/assets/{glimmer-ts-DyUQWB47.js → glimmer-ts-CR148_Kt.js} +1 -1
- package/dist/web/assets/{glsl-D6tJoMaT.js → glsl-qur0cRcN.js} +1 -1
- package/dist/web/assets/{graphql-E6u2hkWE.js → graphql-C42K488Q.js} +1 -1
- package/dist/web/assets/{hack-Djq191-P.js → hack-NLFouBH4.js} +1 -1
- package/dist/web/assets/{haml-C0LqmZG3.js → haml-DKn_-I-I.js} +1 -1
- package/dist/web/assets/{handlebars-DwdxF6uN.js → handlebars-CQReOdSJ.js} +1 -1
- package/dist/web/assets/{html-derivative-DTVrLAw3.js → html-derivative-DoqJMb8S.js} +1 -1
- package/dist/web/assets/{html-B5Myt0fy.js → html-iUF_eP_4.js} +1 -1
- package/dist/web/assets/{http-CFoh3cQf.js → http-BJrCx5B5.js} +1 -1
- package/dist/web/assets/{hurl-B_38naAo.js → hurl-DlDfbRcL.js} +1 -1
- package/dist/web/assets/{index-CfxIojO0.js → index-CH6ceWie.js} +52 -52
- package/dist/web/assets/index-DU33o_Lu.css +1 -0
- package/dist/web/assets/{java-odnexk6r.js → java-DdxicsS9.js} +1 -1
- package/dist/web/assets/{javascript-DvFCJKws.js → javascript-Dw8rInq-.js} +1 -1
- package/dist/web/assets/{jinja-UeLt2DYu.js → jinja-ztBbs9jc.js} +1 -1
- package/dist/web/assets/{jison-HNaDYeqE.js → jison-DyOjkTQj.js} +1 -1
- package/dist/web/assets/{json-D57CnrjR.js → json-C6t0u9rP.js} +1 -1
- package/dist/web/assets/{jsx-o_5ZAydi.js → jsx-BpIc6zqG.js} +1 -1
- package/dist/web/assets/{julia-20UebbrD.js → julia-CvA9RPfz.js} +1 -1
- package/dist/web/assets/{just-DqnPCg-Q.js → just-CiH5trEM.js} +1 -1
- package/dist/web/assets/{latex-CZhIB-LV.js → latex-BbpuYBUm.js} +1 -1
- package/dist/web/assets/{liquid-BzouPsN0.js → liquid-Dy7K9saE.js} +1 -1
- package/dist/web/assets/{lua-BXclv8Fm.js → lua-CLN5DayT.js} +1 -1
- package/dist/web/assets/{marko-M78_INlG.js → marko-Be70_WI8.js} +1 -1
- package/dist/web/assets/{mdc-CBluOiY7.js → mdc-RgJIMLnK.js} +1 -1
- package/dist/web/assets/{nginx-CZFFQFbT.js → nginx-GhsGoiRj.js} +1 -1
- package/dist/web/assets/{nim-17kKsGqk.js → nim-BaIl7YzC.js} +1 -1
- package/dist/web/assets/{org-BZABHRQH.js → org-ksusUjYr.js} +1 -1
- package/dist/web/assets/{perl-CMrAQ9Je.js → perl-KX9noYTv.js} +1 -1
- package/dist/web/assets/{php-DwA3kETk.js → php-C-szMz57.js} +1 -1
- package/dist/web/assets/{pug-D4fM85kA.js → pug-nbUZSc0V.js} +1 -1
- package/dist/web/assets/{qml-C3893RHi.js → qml-BD2H6imu.js} +1 -1
- package/dist/web/assets/{r-DwJhLW77.js → r-CbR0TzPr.js} +1 -1
- package/dist/web/assets/{razor-_KkCNZ8V.js → razor-D_SYu_ob.js} +1 -1
- package/dist/web/assets/{regexp-BZ9NHg3b.js → regexp-BIEFfqXG.js} +1 -1
- package/dist/web/assets/{rst-gdK-1c4t.js → rst-D21NyilJ.js} +1 -1
- package/dist/web/assets/{ruby-DsmqYtlH.js → ruby-CCk7F-X_.js} +1 -1
- package/dist/web/assets/{sas-BNGv1ScF.js → sas-BasHIiX3.js} +1 -1
- package/dist/web/assets/{scss-DjYUXT73.js → scss-DL8S6L92.js} +1 -1
- package/dist/web/assets/{shellscript-DBtV-eli.js → shellscript-D-B4NAUX.js} +1 -1
- package/dist/web/assets/{shellsession-BKEV8Fnx.js → shellsession-LwE3hLIg.js} +1 -1
- package/dist/web/assets/{soy-DUapMxos.js → soy-DaUNAouD.js} +1 -1
- package/dist/web/assets/{sql-B-UyqKkd.js → sql-Drw6fxyA.js} +1 -1
- package/dist/web/assets/{stata-Xq3iZiMK.js → stata-Bk-VgNHo.js} +1 -1
- package/dist/web/assets/{surrealql-CzXacK8i.js → surrealql-BfOZjDkH.js} +1 -1
- package/dist/web/assets/{svelte-CZaBEmPP.js → svelte-BP8BXHT0.js} +1 -1
- package/dist/web/assets/{templ-CxkGuUpr.js → templ-BgcZlshd.js} +1 -1
- package/dist/web/assets/{tex-DP74JH6g.js → tex-BQczXiA6.js} +1 -1
- package/dist/web/assets/{ts-tags-BAcrCE2Y.js → ts-tags-CVFTjDsr.js} +1 -1
- package/dist/web/assets/{tsx-Dw11YP6z.js → tsx-j3JIiZaT.js} +1 -1
- package/dist/web/assets/{twig-BMVjBxD6.js → twig-D7saNHJy.js} +1 -1
- package/dist/web/assets/{typescript-DoBLlVdC.js → typescript-D62o9E27.js} +1 -1
- package/dist/web/assets/{typst-w2u_vl4_.js → typst-DQOeX7Fq.js} +1 -1
- package/dist/web/assets/{vue-BmuHTv5p.js → vue-BpdrgvY0.js} +1 -1
- package/dist/web/assets/{vue-html-Dt0zSF4I.js → vue-html-Cwi6ng_G.js} +1 -1
- package/dist/web/assets/{vue-vine-wvJ1BTex.js → vue-vine-BO8zdtYP.js} +1 -1
- package/dist/web/assets/{xml-CXftIBCQ.js → xml-DgdKyO3D.js} +1 -1
- package/dist/web/assets/{xsl-BoHSOMz1.js → xsl-CLXFRDPl.js} +1 -1
- package/dist/web/assets/{yaml-dGreldtD.js → yaml-BYa9ePnt.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/dist/web/assets/index-QywYYqTw.css +0 -1
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
// orchard-web: the members' web app (React + Vite), on @orchard/sdk. Files that change over many commits are
|
|
2
|
+
// templates of a "stage" number, so each commit writes the whole file as of that stage.
|
|
3
|
+
import { RepoBuilder, replace } from '../lib/gitworld.js';
|
|
4
|
+
import { at, june, maya, remote, sam, theo } from './people.js';
|
|
5
|
+
const append = (s) => (old) => old + s;
|
|
6
|
+
/** ClassCard.tsx: 2 schedule, 3 book, 6 spots left, 8 studio timezone, 10 join waitlist */
|
|
7
|
+
function classCard(stage) {
|
|
8
|
+
const time = stage >= 8
|
|
9
|
+
? "{formatClassTime(cls.starts_at, timeZone)} · {cls.minutes} min"
|
|
10
|
+
: "{new Date(cls.starts_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · {cls.minutes} min";
|
|
11
|
+
const imports = [
|
|
12
|
+
stage >= 3 ? "import { useState } from 'react';\n" : '',
|
|
13
|
+
"import type { Class } from '@orchard/sdk';\n",
|
|
14
|
+
stage >= 8 ? "import { formatClassTime } from '../format';\n" : '',
|
|
15
|
+
].join('');
|
|
16
|
+
if (stage < 3)
|
|
17
|
+
return `${imports}
|
|
18
|
+
export function ClassCard({ cls }: { cls: Class }) {
|
|
19
|
+
return (
|
|
20
|
+
<article className="class-card">
|
|
21
|
+
<header>
|
|
22
|
+
<h3>{cls.title}</h3>
|
|
23
|
+
<span className="coach">with {cls.coach}</span>
|
|
24
|
+
</header>
|
|
25
|
+
<p className="when">
|
|
26
|
+
${time}
|
|
27
|
+
</p>
|
|
28
|
+
</article>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
`;
|
|
32
|
+
const props = stage >= 10
|
|
33
|
+
? `interface Props {
|
|
34
|
+
cls: Class;
|
|
35
|
+
booked: boolean;
|
|
36
|
+
/** Your place in this class's waitlist, if you're on it. */
|
|
37
|
+
waitlistPosition?: number;
|
|
38
|
+
onBook: () => Promise<void>;
|
|
39
|
+
onJoinWaitlist: () => Promise<void>;${stage >= 8 ? '\n timeZone: string;' : ''}
|
|
40
|
+
}`
|
|
41
|
+
: `interface Props {
|
|
42
|
+
cls: Class;
|
|
43
|
+
booked: boolean;
|
|
44
|
+
onBook: () => Promise<void>;${stage >= 8 ? '\n timeZone: string;' : ''}
|
|
45
|
+
}`;
|
|
46
|
+
const args = stage >= 10 ? `{ cls, booked, waitlistPosition, onBook, onJoinWaitlist${stage >= 8 ? ', timeZone' : ''} }` : `{ cls, booked, onBook${stage >= 8 ? ', timeZone' : ''} }`;
|
|
47
|
+
const full = stage >= 6 ? '\n const full = cls.spots_left === 0;' : '';
|
|
48
|
+
const spots = stage >= 6
|
|
49
|
+
? `
|
|
50
|
+
<span className="spots" id={\`spots-\${cls.id}\`}>
|
|
51
|
+
{full ? 'Full' : \`\${cls.spots_left} of \${cls.capacity} left\`}
|
|
52
|
+
</span>`
|
|
53
|
+
: '';
|
|
54
|
+
let action;
|
|
55
|
+
if (stage >= 10)
|
|
56
|
+
action = ` {booked ? (
|
|
57
|
+
<span className="pill pill-booked">Booked</span>
|
|
58
|
+
) : waitlistPosition ? (
|
|
59
|
+
<span className="pill pill-waiting">#{waitlistPosition} in line</span>
|
|
60
|
+
) : full ? (
|
|
61
|
+
<button className="secondary" disabled={busy} onClick={() => run(onJoinWaitlist)}>
|
|
62
|
+
Join waitlist
|
|
63
|
+
</button>
|
|
64
|
+
) : (
|
|
65
|
+
<button className="primary" disabled={busy} onClick={() => run(onBook)}>
|
|
66
|
+
Book
|
|
67
|
+
</button>
|
|
68
|
+
)}`;
|
|
69
|
+
else
|
|
70
|
+
action = ` {booked ? (
|
|
71
|
+
<span className="pill pill-booked">Booked</span>
|
|
72
|
+
) : (
|
|
73
|
+
<button className="primary" disabled={busy${stage >= 6 ? ' || full' : ''}} onClick={() => run(onBook)}>
|
|
74
|
+
Book
|
|
75
|
+
</button>
|
|
76
|
+
)}`;
|
|
77
|
+
return `${imports}
|
|
78
|
+
${props}
|
|
79
|
+
|
|
80
|
+
export function ClassCard(${args}: Props) {
|
|
81
|
+
const [busy, setBusy] = useState(false);${full}
|
|
82
|
+
const run = async (action: () => Promise<void>) => {
|
|
83
|
+
setBusy(true);
|
|
84
|
+
try {
|
|
85
|
+
await action();
|
|
86
|
+
} finally {
|
|
87
|
+
setBusy(false);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
return (
|
|
91
|
+
<article className="class-card">
|
|
92
|
+
<header>
|
|
93
|
+
<h3>{cls.title}</h3>
|
|
94
|
+
<span className="coach">with {cls.coach}</span>
|
|
95
|
+
</header>
|
|
96
|
+
<p className="when">
|
|
97
|
+
${time}
|
|
98
|
+
</p>
|
|
99
|
+
<footer>${spots}
|
|
100
|
+
${action}
|
|
101
|
+
</footer>
|
|
102
|
+
</article>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
`;
|
|
106
|
+
}
|
|
107
|
+
/** MyBookings.tsx: 4 list, 5 cancel, 8 timezone, 11 waitlist places, 12 offer banner, 13 hold-window copy */
|
|
108
|
+
function myBookings(stage) {
|
|
109
|
+
const tz = stage >= 8;
|
|
110
|
+
const when = (v) => (tz ? `formatClassTime(${v}.starts_at, timeZone)` : `new Date(${v}.starts_at).toLocaleString()`);
|
|
111
|
+
const imports = [
|
|
112
|
+
"import { useEffect, useState } from 'react';\n",
|
|
113
|
+
`import type { Booking${stage >= 11 ? ', WaitlistPlace' : ''} } from '@orchard/sdk';\n`,
|
|
114
|
+
stage >= 12 ? "import { Banner } from '../components/Banner';\n" : '',
|
|
115
|
+
tz ? "import { formatClassTime } from '../format';\n" : '',
|
|
116
|
+
"import { orchard } from '../orchard';\n",
|
|
117
|
+
].join('');
|
|
118
|
+
const state = stage >= 11
|
|
119
|
+
? ` const [bookings, setBookings] = useState<Booking[] | null>(null);
|
|
120
|
+
const [waitlist, setWaitlist] = useState<WaitlistPlace[]>([]);
|
|
121
|
+
|
|
122
|
+
const load = () =>
|
|
123
|
+
orchard.bookings.listMine().then((r) => {
|
|
124
|
+
setBookings(r.bookings);
|
|
125
|
+
setWaitlist(r.waitlist);
|
|
126
|
+
});`
|
|
127
|
+
: ` const [bookings, setBookings] = useState<Booking[] | null>(null);
|
|
128
|
+
|
|
129
|
+
const load = () => orchard.bookings.listMine().then((r) => setBookings(r.bookings));`;
|
|
130
|
+
const cancel = stage >= 5
|
|
131
|
+
? `
|
|
132
|
+
|
|
133
|
+
const cancel = async (b: Booking) => {
|
|
134
|
+
if (!confirm(\`Cancel \${b.class?.title}? Your spot goes to the next person waiting.\`)) return;
|
|
135
|
+
await orchard.bookings.cancel(b.id, { ignoreMissing: true });
|
|
136
|
+
await load();
|
|
137
|
+
};`
|
|
138
|
+
: '';
|
|
139
|
+
const accept = stage >= 12
|
|
140
|
+
? `
|
|
141
|
+
|
|
142
|
+
const accept = async (p: WaitlistPlace) => {
|
|
143
|
+
await orchard.bookings.acceptOffer(p.id);
|
|
144
|
+
await load();
|
|
145
|
+
};
|
|
146
|
+
const offers = waitlist.filter((p) => p.status === 'offered');`
|
|
147
|
+
: '';
|
|
148
|
+
const holdCopy = stage >= 13
|
|
149
|
+
? `
|
|
150
|
+
<p>
|
|
151
|
+
We're holding it for you until <strong>{formatHoldTime(p.hold_until!, timeZone)}</strong>. After that it
|
|
152
|
+
goes to the next person in line, and you'll stay on the waitlist.
|
|
153
|
+
</p>`
|
|
154
|
+
: `
|
|
155
|
+
<p>A spot opened up and it's yours if you want it.</p>`;
|
|
156
|
+
const banner = stage >= 12
|
|
157
|
+
? `
|
|
158
|
+
{offers.map((p) => (
|
|
159
|
+
<Banner key={p.id} tone="success" title={\`A spot opened up in \${p.class?.title}\`}>${holdCopy}
|
|
160
|
+
<button className="primary" onClick={() => accept(p)}>
|
|
161
|
+
Take the spot
|
|
162
|
+
</button>
|
|
163
|
+
</Banner>
|
|
164
|
+
))}`
|
|
165
|
+
: '';
|
|
166
|
+
const cancelButton = stage >= 5
|
|
167
|
+
? `
|
|
168
|
+
<button className="link danger" onClick={() => cancel(b)}>
|
|
169
|
+
Cancel
|
|
170
|
+
</button>`
|
|
171
|
+
: '';
|
|
172
|
+
const waitlistSection = stage >= 11
|
|
173
|
+
? `
|
|
174
|
+
{waitlist.length > 0 && (
|
|
175
|
+
<>
|
|
176
|
+
<h2>Waitlists</h2>
|
|
177
|
+
<ul className="booking-list">
|
|
178
|
+
{waitlist.map((p) => (
|
|
179
|
+
<li key={p.id}>
|
|
180
|
+
<strong>{p.class?.title}</strong>
|
|
181
|
+
<span className="muted">{p.class && ${when('p.class')}}</span>
|
|
182
|
+
{p.status === 'offered' ? (
|
|
183
|
+
<span className="pill pill-offer">Spot held for you</span>
|
|
184
|
+
) : (
|
|
185
|
+
<span className="pill pill-waiting" title="Your place in the line">
|
|
186
|
+
#{p.position} in line
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
</li>
|
|
190
|
+
))}
|
|
191
|
+
</ul>
|
|
192
|
+
</>
|
|
193
|
+
)}`
|
|
194
|
+
: '';
|
|
195
|
+
const sig = tz ? 'export function MyBookings({ timeZone }: { timeZone: string }) {' : 'export function MyBookings() {';
|
|
196
|
+
const holdFn = stage >= 13
|
|
197
|
+
? `
|
|
198
|
+
/** "15:30", or "tomorrow 07:10" when the hold runs past midnight. */
|
|
199
|
+
function formatHoldTime(iso: string, timeZone: string): string {
|
|
200
|
+
const d = new Date(iso);
|
|
201
|
+
const time = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone });
|
|
202
|
+
const day = (x: Date) => x.toLocaleDateString('en-CA', { timeZone });
|
|
203
|
+
return day(d) === day(new Date()) ? time : \`tomorrow \${time}\`;
|
|
204
|
+
}
|
|
205
|
+
`
|
|
206
|
+
: '';
|
|
207
|
+
return `${imports}
|
|
208
|
+
${sig}
|
|
209
|
+
${state}${cancel}${accept}
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
load();
|
|
213
|
+
}, []);
|
|
214
|
+
|
|
215
|
+
if (!bookings) return <p className="muted">Loading…</p>;
|
|
216
|
+
return (
|
|
217
|
+
<section>
|
|
218
|
+
<h1>My bookings</h1>${banner}
|
|
219
|
+
{bookings.length === 0 ? (
|
|
220
|
+
<p className="empty">Nothing booked yet. Find a class on the schedule.</p>
|
|
221
|
+
) : (
|
|
222
|
+
<ul className="booking-list">
|
|
223
|
+
{bookings.map((b) => (
|
|
224
|
+
<li key={b.id}>
|
|
225
|
+
<strong>{b.class?.title}</strong>
|
|
226
|
+
<span className="muted">{b.class && ${when('b.class')}}</span>${cancelButton}
|
|
227
|
+
</li>
|
|
228
|
+
))}
|
|
229
|
+
</ul>
|
|
230
|
+
)}${waitlistSection}
|
|
231
|
+
</section>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
${holdFn}`;
|
|
235
|
+
}
|
|
236
|
+
/** Schedule.tsx: 2 list, 3 book, 8 timezone, 10 join waitlist */
|
|
237
|
+
function schedule(stage) {
|
|
238
|
+
const tz = stage >= 8;
|
|
239
|
+
const cardProps = stage >= 10
|
|
240
|
+
? `
|
|
241
|
+
booked={booked.has(c.id)}
|
|
242
|
+
waitlistPosition={waiting.get(c.id)}
|
|
243
|
+
onBook={() => book(c)}
|
|
244
|
+
onJoinWaitlist={() => joinWaitlist(c)}${tz ? '\n timeZone={timeZone}' : ''}`
|
|
245
|
+
: stage >= 3
|
|
246
|
+
? `
|
|
247
|
+
booked={booked.has(c.id)}
|
|
248
|
+
onBook={() => book(c)}${tz ? '\n timeZone={timeZone}' : ''}`
|
|
249
|
+
: '';
|
|
250
|
+
const state = stage >= 3
|
|
251
|
+
? `
|
|
252
|
+
const [booked, setBooked] = useState(new Set<number>());${stage >= 10 ? '\n const [waiting, setWaiting] = useState(new Map<number, number>());' : ''}
|
|
253
|
+
const [error, setError] = useState<string | null>(null);
|
|
254
|
+
|
|
255
|
+
const book = async (c: Class) => {
|
|
256
|
+
setError(null);
|
|
257
|
+
try {
|
|
258
|
+
await orchard.bookings.book(c.id);
|
|
259
|
+
setBooked((s) => new Set(s).add(c.id));
|
|
260
|
+
} catch (e) {
|
|
261
|
+
setError(e instanceof ApiError ? e.message : 'Booking failed. Try again.');
|
|
262
|
+
}
|
|
263
|
+
};${stage >= 10
|
|
264
|
+
? `
|
|
265
|
+
|
|
266
|
+
const joinWaitlist = async (c: Class) => {
|
|
267
|
+
setError(null);
|
|
268
|
+
try {
|
|
269
|
+
const place = await orchard.bookings.joinWaitlist(c.id);
|
|
270
|
+
setWaiting((m) => new Map(m).set(c.id, place.position));
|
|
271
|
+
} catch (e) {
|
|
272
|
+
setError(e instanceof ApiError ? e.message : "Couldn't join the waitlist. Try again.");
|
|
273
|
+
}
|
|
274
|
+
};`
|
|
275
|
+
: ''}`
|
|
276
|
+
: '';
|
|
277
|
+
return `import { useEffect, useState } from 'react';
|
|
278
|
+
import { ${stage >= 3 ? 'ApiError, ' : ''}type Class } from '@orchard/sdk';
|
|
279
|
+
import { ClassCard } from '../components/ClassCard';
|
|
280
|
+
import { orchard } from '../orchard';
|
|
281
|
+
|
|
282
|
+
${tz ? 'export function Schedule({ studioId, timeZone }: { studioId: number; timeZone: string }) {' : 'export function Schedule({ studioId }: { studioId: number }) {'}
|
|
283
|
+
const [classes, setClasses] = useState<Class[] | null>(null);${state}
|
|
284
|
+
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
orchard.classes.schedule(studioId).then((r) => setClasses(r.classes));
|
|
287
|
+
}, [studioId]);
|
|
288
|
+
|
|
289
|
+
if (!classes) return <p className="muted">Loading the schedule…</p>;
|
|
290
|
+
return (
|
|
291
|
+
<section>
|
|
292
|
+
<h1>This week</h1>${stage >= 3 ? '\n {error && <p className="error">{error}</p>}' : ''}
|
|
293
|
+
<div className="class-grid">
|
|
294
|
+
{classes.map((c) => (
|
|
295
|
+
<ClassCard key={c.id} cls={c}${cardProps}${stage >= 3 ? '\n ' : ' '}/>
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
</section>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
`.replace('<ClassCard key={c.id} cls={c}\n booked', '<ClassCard\n key={c.id}\n cls={c}\n booked');
|
|
302
|
+
}
|
|
303
|
+
export function buildWeb() {
|
|
304
|
+
const r = new RepoBuilder('orchard-web', remote('orchard-web'));
|
|
305
|
+
const c1 = r.commit(null, 'Scaffold the web app with Vite and React', {
|
|
306
|
+
'package.json': `{
|
|
307
|
+
"name": "orchard-web",
|
|
308
|
+
"private": true,
|
|
309
|
+
"type": "module",
|
|
310
|
+
"scripts": {
|
|
311
|
+
"dev": "vite",
|
|
312
|
+
"build": "tsc --noEmit && vite build",
|
|
313
|
+
"test": "vitest run"
|
|
314
|
+
},
|
|
315
|
+
"dependencies": {
|
|
316
|
+
"@orchard/sdk": "^0.6.0",
|
|
317
|
+
"react": "^18.3.1",
|
|
318
|
+
"react-dom": "^18.3.1"
|
|
319
|
+
},
|
|
320
|
+
"devDependencies": {
|
|
321
|
+
"@types/react": "^18.3.3",
|
|
322
|
+
"@types/react-dom": "^18.3.0",
|
|
323
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
324
|
+
"typescript": "^5.5.4",
|
|
325
|
+
"vite": "^5.4.0",
|
|
326
|
+
"vitest": "^2.0.5"
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
`,
|
|
330
|
+
'vite.config.ts': `import { defineConfig } from 'vite';
|
|
331
|
+
import react from '@vitejs/plugin-react';
|
|
332
|
+
|
|
333
|
+
export default defineConfig({
|
|
334
|
+
plugins: [react()],
|
|
335
|
+
server: { proxy: { '/api': 'http://localhost:8080' } },
|
|
336
|
+
});
|
|
337
|
+
`,
|
|
338
|
+
'index.html': `<!doctype html>
|
|
339
|
+
<html lang="en">
|
|
340
|
+
<head>
|
|
341
|
+
<meta charset="UTF-8" />
|
|
342
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
343
|
+
<title>Orchard</title>
|
|
344
|
+
</head>
|
|
345
|
+
<body>
|
|
346
|
+
<div id="root"></div>
|
|
347
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
348
|
+
</body>
|
|
349
|
+
</html>
|
|
350
|
+
`,
|
|
351
|
+
'src/main.tsx': `import { StrictMode } from 'react';
|
|
352
|
+
import { createRoot } from 'react-dom/client';
|
|
353
|
+
import { App } from './App';
|
|
354
|
+
import './styles.css';
|
|
355
|
+
|
|
356
|
+
createRoot(document.getElementById('root')!).render(
|
|
357
|
+
<StrictMode>
|
|
358
|
+
<App />
|
|
359
|
+
</StrictMode>,
|
|
360
|
+
);
|
|
361
|
+
`,
|
|
362
|
+
'src/App.tsx': `export function App() {
|
|
363
|
+
return (
|
|
364
|
+
<main className="app">
|
|
365
|
+
<h1>Orchard</h1>
|
|
366
|
+
</main>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
`,
|
|
370
|
+
'src/styles.css': `:root {
|
|
371
|
+
--green: #2f6b3a;
|
|
372
|
+
--ink: #1d2420;
|
|
373
|
+
--muted: #66706a;
|
|
374
|
+
--line: #dfe5df;
|
|
375
|
+
font-family: system-ui, sans-serif;
|
|
376
|
+
color: var(--ink);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.app {
|
|
380
|
+
max-width: 960px;
|
|
381
|
+
margin: 0 auto;
|
|
382
|
+
padding: 24px;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.muted {
|
|
386
|
+
color: var(--muted);
|
|
387
|
+
}
|
|
388
|
+
`,
|
|
389
|
+
'README.md': `# orchard-web
|
|
390
|
+
|
|
391
|
+
The members' web app: the class schedule, booking, and My bookings.
|
|
392
|
+
|
|
393
|
+
npm install
|
|
394
|
+
npm run dev # proxies /api to orchard-api on :8080
|
|
395
|
+
`,
|
|
396
|
+
}, { author: june, at: at('2026-05-22 15:30') });
|
|
397
|
+
const c2 = r.commit(c1, 'Add the schedule page', {
|
|
398
|
+
'src/orchard.ts': `import { OrchardClient } from '@orchard/sdk';
|
|
399
|
+
|
|
400
|
+
/** The app's API client. The session token comes from the sign-in page. */
|
|
401
|
+
export const orchard = new OrchardClient({
|
|
402
|
+
baseUrl: \`\${location.origin}/api/\`,
|
|
403
|
+
token: localStorage.getItem('orchard.token') ?? '',
|
|
404
|
+
});
|
|
405
|
+
`,
|
|
406
|
+
'src/components/ClassCard.tsx': classCard(2),
|
|
407
|
+
'src/pages/Schedule.tsx': schedule(2),
|
|
408
|
+
'src/App.tsx': `import { Schedule } from './pages/Schedule';
|
|
409
|
+
|
|
410
|
+
export function App() {
|
|
411
|
+
return (
|
|
412
|
+
<main className="app">
|
|
413
|
+
<Schedule studioId={1} />
|
|
414
|
+
</main>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
`,
|
|
418
|
+
'src/styles.css': append(`
|
|
419
|
+
.class-grid {
|
|
420
|
+
display: grid;
|
|
421
|
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
422
|
+
gap: 12px;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.class-card {
|
|
426
|
+
border: 1px solid var(--line);
|
|
427
|
+
border-radius: 10px;
|
|
428
|
+
padding: 12px 14px;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.class-card h3 {
|
|
432
|
+
margin: 0;
|
|
433
|
+
font-size: 16px;
|
|
434
|
+
}
|
|
435
|
+
`),
|
|
436
|
+
}, { author: june, at: at('2026-05-30 12:10') });
|
|
437
|
+
const c3 = r.commit(c2, 'Book a class from the schedule', {
|
|
438
|
+
'src/components/ClassCard.tsx': classCard(3),
|
|
439
|
+
'src/pages/Schedule.tsx': schedule(3),
|
|
440
|
+
'src/styles.css': append(`
|
|
441
|
+
.class-card footer {
|
|
442
|
+
display: flex;
|
|
443
|
+
align-items: center;
|
|
444
|
+
justify-content: space-between;
|
|
445
|
+
margin-top: 10px;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
button.primary {
|
|
449
|
+
background: var(--green);
|
|
450
|
+
color: white;
|
|
451
|
+
border: 0;
|
|
452
|
+
border-radius: 6px;
|
|
453
|
+
padding: 6px 14px;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
button:disabled {
|
|
457
|
+
opacity: 0.5;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.pill {
|
|
461
|
+
border-radius: 999px;
|
|
462
|
+
padding: 2px 10px;
|
|
463
|
+
font-size: 12px;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.pill-booked {
|
|
467
|
+
background: #e3f1e5;
|
|
468
|
+
color: var(--green);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.error {
|
|
472
|
+
color: #b42318;
|
|
473
|
+
}
|
|
474
|
+
`),
|
|
475
|
+
}, { author: theo, at: at('2026-06-06 10:45') });
|
|
476
|
+
const c4 = r.commit(c3, 'Add My bookings', {
|
|
477
|
+
'src/pages/MyBookings.tsx': myBookings(4),
|
|
478
|
+
'src/App.tsx': `import { useState } from 'react';
|
|
479
|
+
import { MyBookings } from './pages/MyBookings';
|
|
480
|
+
import { Schedule } from './pages/Schedule';
|
|
481
|
+
|
|
482
|
+
type Tab = 'schedule' | 'bookings';
|
|
483
|
+
|
|
484
|
+
export function App() {
|
|
485
|
+
const [tab, setTab] = useState<Tab>('schedule');
|
|
486
|
+
return (
|
|
487
|
+
<main className="app">
|
|
488
|
+
<nav className="tabs">
|
|
489
|
+
<button aria-pressed={tab === 'schedule'} onClick={() => setTab('schedule')}>
|
|
490
|
+
Schedule
|
|
491
|
+
</button>
|
|
492
|
+
<button aria-pressed={tab === 'bookings'} onClick={() => setTab('bookings')}>
|
|
493
|
+
My bookings
|
|
494
|
+
</button>
|
|
495
|
+
</nav>
|
|
496
|
+
{tab === 'schedule' ? <Schedule studioId={1} /> : <MyBookings />}
|
|
497
|
+
</main>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
`,
|
|
501
|
+
'src/styles.css': append(`
|
|
502
|
+
.booking-list {
|
|
503
|
+
list-style: none;
|
|
504
|
+
padding: 0;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.booking-list li {
|
|
508
|
+
display: flex;
|
|
509
|
+
gap: 12px;
|
|
510
|
+
align-items: baseline;
|
|
511
|
+
padding: 10px 0;
|
|
512
|
+
border-bottom: 1px solid var(--line);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.empty {
|
|
516
|
+
color: var(--muted);
|
|
517
|
+
font-style: italic;
|
|
518
|
+
}
|
|
519
|
+
`),
|
|
520
|
+
}, { author: june, at: at('2026-06-16 14:20') });
|
|
521
|
+
const c5 = r.commit(c4, 'Let members cancel from My bookings', {
|
|
522
|
+
'src/pages/MyBookings.tsx': myBookings(5),
|
|
523
|
+
'src/styles.css': append(`
|
|
524
|
+
button.link {
|
|
525
|
+
background: none;
|
|
526
|
+
border: 0;
|
|
527
|
+
padding: 0;
|
|
528
|
+
text-decoration: underline;
|
|
529
|
+
cursor: pointer;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
button.danger {
|
|
533
|
+
color: #b42318;
|
|
534
|
+
}
|
|
535
|
+
`),
|
|
536
|
+
}, { author: maya, at: at('2026-06-25 11:30') });
|
|
537
|
+
const c6 = r.commit(c5, 'Show spots left on each class card', {
|
|
538
|
+
'src/components/ClassCard.tsx': classCard(6),
|
|
539
|
+
'src/styles.css': append(`
|
|
540
|
+
.spots {
|
|
541
|
+
font-size: 13px;
|
|
542
|
+
color: var(--muted);
|
|
543
|
+
}
|
|
544
|
+
`),
|
|
545
|
+
}, { author: june, at: at('2026-07-08 16:05') });
|
|
546
|
+
const c7 = r.commit(c6, 'Add a banner component for page-level messages', {
|
|
547
|
+
'src/components/Banner.tsx': `import type { ReactNode } from 'react';
|
|
548
|
+
|
|
549
|
+
interface Props {
|
|
550
|
+
tone: 'info' | 'success' | 'warning';
|
|
551
|
+
title: string;
|
|
552
|
+
children?: ReactNode;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** A message across the top of a page. role="status" so screen readers announce it when it appears. */
|
|
556
|
+
export function Banner({ tone, title, children }: Props) {
|
|
557
|
+
return (
|
|
558
|
+
<div className={\`banner banner-\${tone}\`} role="status">
|
|
559
|
+
<strong>{title}</strong>
|
|
560
|
+
{children}
|
|
561
|
+
</div>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
`,
|
|
565
|
+
'src/styles.css': append(`
|
|
566
|
+
.banner {
|
|
567
|
+
border-radius: 10px;
|
|
568
|
+
padding: 12px 16px;
|
|
569
|
+
margin-bottom: 16px;
|
|
570
|
+
display: grid;
|
|
571
|
+
gap: 6px;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
.banner-info {
|
|
575
|
+
background: #eef4fb;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.banner-success {
|
|
579
|
+
background: #e3f1e5;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
.banner-warning {
|
|
583
|
+
background: #fdf3e1;
|
|
584
|
+
}
|
|
585
|
+
`),
|
|
586
|
+
}, { author: sam, at: at('2026-07-15 09:50') });
|
|
587
|
+
const c8 = r.commit(c7, "Format class times in the studio's timezone\n\nMembers travelling saw classes at their own local time. A class is at the studio, so show the\nstudio's wall-clock time.", {
|
|
588
|
+
'src/format.ts': `/** "Mon 21 Sep, 07:00", in the studio's timezone (not the browser's). */
|
|
589
|
+
export function formatClassTime(iso: string, timeZone: string): string {
|
|
590
|
+
return new Date(iso).toLocaleString('en-GB', {
|
|
591
|
+
weekday: 'short',
|
|
592
|
+
day: 'numeric',
|
|
593
|
+
month: 'short',
|
|
594
|
+
hour: '2-digit',
|
|
595
|
+
minute: '2-digit',
|
|
596
|
+
timeZone,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
`,
|
|
600
|
+
'src/format.test.ts': `import { expect, it } from 'vitest';
|
|
601
|
+
import { formatClassTime } from './format';
|
|
602
|
+
|
|
603
|
+
it("shows the studio's wall-clock time", () => {
|
|
604
|
+
expect(formatClassTime('2026-09-21T06:00:00Z', 'Europe/London')).toBe('Mon 21 Sept, 07:00');
|
|
605
|
+
});
|
|
606
|
+
`,
|
|
607
|
+
'src/components/ClassCard.tsx': classCard(8),
|
|
608
|
+
'src/pages/Schedule.tsx': schedule(8),
|
|
609
|
+
'src/pages/MyBookings.tsx': myBookings(8),
|
|
610
|
+
'src/App.tsx': (s) => replace("const [tab, setTab] = useState<Tab>('schedule');\n", "const [tab, setTab] = useState<Tab>('schedule');\n const timeZone = 'Europe/London'; // TODO: from the studio, once the API returns it\n")(s).replace("{tab === 'schedule' ? <Schedule studioId={1} /> : <MyBookings />}", "{tab === 'schedule' ? <Schedule studioId={1} timeZone={timeZone} /> : <MyBookings timeZone={timeZone} />}"),
|
|
611
|
+
}, { author: june, at: at('2026-08-12 13:25') });
|
|
612
|
+
// ---------------------------------------------------------------- Maya's waitlists branch
|
|
613
|
+
const w0 = r.commit(c8, 'Bump @orchard/sdk to 0.8.0-beta.1', {
|
|
614
|
+
'package.json': replace('"@orchard/sdk": "^0.6.0"', '"@orchard/sdk": "0.8.0-beta.1"'),
|
|
615
|
+
}, { author: maya, at: at('2026-09-11 14:00'), key: 'w0' });
|
|
616
|
+
const w1 = r.commit(w0, 'Add a Join waitlist button to full classes', {
|
|
617
|
+
'src/components/ClassCard.tsx': classCard(10),
|
|
618
|
+
'src/pages/Schedule.tsx': schedule(10),
|
|
619
|
+
'src/styles.css': append(`
|
|
620
|
+
button.secondary {
|
|
621
|
+
background: white;
|
|
622
|
+
color: var(--green);
|
|
623
|
+
border: 1px solid var(--green);
|
|
624
|
+
border-radius: 6px;
|
|
625
|
+
padding: 5px 13px;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
.pill-waiting {
|
|
629
|
+
background: #fdf3e1;
|
|
630
|
+
color: #8a5a00;
|
|
631
|
+
}
|
|
632
|
+
`),
|
|
633
|
+
}, { author: maya, at: at('2026-09-11 16:40'), key: 'w1' });
|
|
634
|
+
const w2 = r.commit(w1, 'Show waitlist position on My bookings', {
|
|
635
|
+
'src/pages/MyBookings.tsx': myBookings(11),
|
|
636
|
+
}, { author: maya, at: at('2026-09-12 10:15'), key: 'w2' });
|
|
637
|
+
const w3 = r.commit(w2, 'Add the spot-offered banner', {
|
|
638
|
+
'src/pages/MyBookings.tsx': myBookings(12),
|
|
639
|
+
'src/styles.css': append(`
|
|
640
|
+
.pill-offer {
|
|
641
|
+
background: #e3f1e5;
|
|
642
|
+
color: var(--green);
|
|
643
|
+
font-weight: 600;
|
|
644
|
+
}
|
|
645
|
+
`),
|
|
646
|
+
}, { author: maya, at: at('2026-09-12 14:50'), key: 'w3' });
|
|
647
|
+
const w4 = r.commit(w3, 'Explain the hold window in the offer banner', {
|
|
648
|
+
'src/pages/MyBookings.tsx': myBookings(13),
|
|
649
|
+
}, { author: maya, at: at('2026-09-15 11:20'), key: 'w4' });
|
|
650
|
+
r.ref('main', c8);
|
|
651
|
+
r.ref('refs/remotes/origin/main', c8);
|
|
652
|
+
r.ref('refs/remotes/origin/HEAD', c8);
|
|
653
|
+
r.ref('maya/waitlists', w4);
|
|
654
|
+
r.ref('maya/web-join-button', w1);
|
|
655
|
+
r.ref('maya/web-waitlist-position', w2);
|
|
656
|
+
return r;
|
|
657
|
+
}
|