● Zion Boggan
repos/Stepping App/apps/web/src/app/admin/page.tsx
zionboggan.com ↗
563 lines · tsx
History for this file →
1
import Link from "next/link";
2
import { redirect } from "next/navigation";
3
import { revalidatePath } from "next/cache";
4
import { getCurrentUser, isAdmin, logout } from "@/lib/auth";
5
import { sessionCaller } from "@/lib/trpc/server";
6
import { saveUpload } from "@/lib/uploads";
7
 
8
export const dynamic = "force-dynamic";
9
 
10
export const metadata = { title: "Admin · Stepping", robots: { index: false } };
11
 
12
function stamp(d: Date | null) {
13
  if (!d) return "";
14
  return new Date(d).toLocaleString("en-US", {
15
    month: "short",
16
    day: "numeric",
17
    hour: "numeric",
18
    minute: "2-digit",
19
  }).toUpperCase();
20
}
21
 
22
export default async function AdminPage() {
23
  if (!isAdmin(await getCurrentUser())) redirect("/admin/login");
24
 
25
  const { caller } = await sessionCaller();
26
  const recent = await caller.moderation.recentEvents();
27
  const ordersLog = await caller.order.adminRecent();
28
  const sponsorsList = await caller.sponsor.adminList();
29
  const proList = await caller.proAccount.adminList();
30
  const launchedCities = await caller.city.listLaunched();
31
  const pendingPhotos = await caller.eventPhoto.adminPending();
32
  const vendorList = await caller.vendor.adminList();
33
  const openFlags = await caller.flag.adminOpen();
34
 
35
  const money = (c: number) => (c === 0 ? "Free" : `$${(c / 100).toFixed(2)}`);
36
 
37
  async function doLogout() {
38
    "use server";
39
    await logout();
40
    redirect("/");
41
  }
42
 
43
  async function hideEvent(formData: FormData) {
44
    "use server";
45
    const id = String(formData.get("id") ?? "");
46
    if (!id) return;
47
    const { caller } = await sessionCaller();
48
    await caller.moderation.hideEvent({ id });
49
    revalidatePath("/admin");
50
  }
51
 
52
  async function attachMedia(formData: FormData) {
53
    "use server";
54
    if (!isAdmin(await getCurrentUser())) return;
55
    const eventId = String(formData.get("eventId") ?? "");
56
    const kind = String(formData.get("kind") ?? "");
57
    const file = formData.get("file");
58
    if (!eventId || !(file instanceof File) || file.size === 0) return;
59
    let url: string;
60
    try {
61
      url = await saveUpload(file, kind === "program" ? "pdf" : "image");
62
    } catch {
63
      return;
64
    }
65
    const { caller } = await sessionCaller();
66
    if (kind === "program") {
67
      await caller.event.setProgram({ id: eventId, programUrl: url });
68
    } else {
69
      await caller.event.setFlyer({ id: eventId, flyerUrl: url });
70
    }
71
    revalidatePath("/admin");
72
  }
73
 
74
  async function reviewPhoto(formData: FormData) {
75
    "use server";
76
    const id = String(formData.get("id") ?? "");
77
    const approve = String(formData.get("decision") ?? "") === "approve";
78
    if (!id) return;
79
    const { caller } = await sessionCaller();
80
    await caller.eventPhoto.adminReview({ id, approve });
81
    revalidatePath("/admin");
82
  }
83
 
84
  async function resolveFlag(formData: FormData) {
85
    "use server";
86
    const id = String(formData.get("id") ?? "");
87
    const status = String(formData.get("status") ?? "");
88
    if (!id || !["reviewed", "actioned", "dismissed"].includes(status)) return;
89
    const { caller } = await sessionCaller();
90
    await caller.flag.adminResolve({
91
      id,
92
      status: status as "reviewed" | "actioned" | "dismissed",
93
    });
94
    revalidatePath("/admin");
95
  }
96
 
97
  async function createVendor(formData: FormData) {
98
    "use server";
99
    const cityId = String(formData.get("cityId") ?? "");
100
    const eventId = String(formData.get("eventId") ?? "");
101
    const name = String(formData.get("name") ?? "").trim();
102
    const category = String(formData.get("category") ?? "").trim();
103
    const description = String(formData.get("description") ?? "").trim();
104
    const linkUrl = String(formData.get("linkUrl") ?? "").trim();
105
    if (!cityId || !name) return;
106
    const { caller } = await sessionCaller();
107
    await caller.vendor.adminCreate({
108
      cityId,
109
      eventId: eventId || undefined,
110
      name,
111
      category: category || undefined,
112
      description: description || undefined,
113
      linkUrl: linkUrl || undefined,
114
    });
115
    revalidatePath("/admin");
116
  }
117
 
118
  async function setVendorStatus(formData: FormData) {
119
    "use server";
120
    const id = String(formData.get("id") ?? "");
121
    const status = String(formData.get("status") ?? "") as "active" | "hidden";
122
    if (!id) return;
123
    const { caller } = await sessionCaller();
124
    await caller.vendor.adminSetStatus({ id, status });
125
    revalidatePath("/admin");
126
  }
127
 
128
  async function createSponsor(formData: FormData) {
129
    "use server";
130
    const cityId = String(formData.get("cityId") ?? "");
131
    const name = String(formData.get("name") ?? "").trim();
132
    const blurb = String(formData.get("blurb") ?? "").trim();
133
    const linkUrl = String(formData.get("linkUrl") ?? "").trim();
134
    if (!cityId || !name) return;
135
    const { caller } = await sessionCaller();
136
    await caller.sponsor.adminCreate({
137
      cityId,
138
      name,
139
      blurb: blurb || undefined,
140
      linkUrl: linkUrl || undefined,
141
      status: "active",
142
    });
143
    revalidatePath("/admin");
144
  }
145
 
146
  async function setSponsorStatus(formData: FormData) {
147
    "use server";
148
    const id = String(formData.get("id") ?? "");
149
    const status = String(formData.get("status") ?? "") as "pending" | "active" | "ended";
150
    if (!id) return;
151
    const { caller } = await sessionCaller();
152
    await caller.sponsor.adminSetStatus({ id, status });
153
    revalidatePath("/admin");
154
  }
155
 
156
  async function setProStatus(formData: FormData) {
157
    "use server";
158
    const id = String(formData.get("id") ?? "");
159
    const status = String(formData.get("status") ?? "") as
160
      | "pending"
161
      | "active"
162
      | "expired"
163
      | "cancelled";
164
    if (!id) return;
165
    const { caller } = await sessionCaller();
166
    await caller.proAccount.adminSetStatus({ id, status });
167
    revalidatePath("/admin");
168
  }
169
 
170
  return (
171
    <main className="mx-auto w-full max-w-4xl px-5 py-12 sm:px-8">
172
      <div className="flex items-center justify-between">
173
        <div>
174
          <p className="kicker text-ash">Stepping · Admin</p>
175
          <h1 className="display mt-2 text-5xl sm:text-6xl">Control Room</h1>
176
        </div>
177
        <form action={doLogout}>
178
          <button type="submit" className="kicker text-ash hover:text-bone">
179
            Sign out →
180
          </button>
181
        </form>
182
      </div>
183
 
184
      {}
185
      <section className="mt-14 border-t border-[var(--line)] pt-8">
186
        <div className="flex items-end justify-between">
187
          <h2 className="display text-3xl">Moderation</h2>
188
          <span className="kicker text-ash">Recent events</span>
189
        </div>
190
        <ul className="mt-6">
191
          {recent.length === 0 ? (
192
            <li className="kicker text-ash">Nothing posted yet.</li>
193
          ) : (
194
            recent.map((e) => (
195
              <li
196
                key={e.id}
197
                className="flex items-center gap-4 border-b border-[var(--line)] py-4"
198
              >
199
                <span
200
                  className="kicker w-20"
201
                  style={{ color: e.status === "active" ? "var(--ash)" : "var(--gold)" }}
202
                >
203
                  {e.status.toUpperCase()}
204
                </span>
205
                <span className="display flex-1 text-xl text-bone">{e.title}</span>
206
                <span className="kicker hidden text-ash sm:block">{stamp(e.createdAt)}</span>
207
                {e.status === "active" ? (
208
                  <form action={hideEvent}>
209
                    <input type="hidden" name="id" value={e.id} />
210
                    <button type="submit" className="kicker text-gold hover:text-gold-bright">
211
                      Hide
212
                    </button>
213
                  </form>
214
                ) : null}
215
              </li>
216
            ))
217
          )}
218
        </ul>
219
      </section>
220
 
221
      {}
222
      <section className="mt-14 border-t border-[var(--line)] pt-8">
223
        <div className="flex items-end justify-between">
224
          <h2 className="display text-3xl">Photo Queue</h2>
225
          <span className="kicker text-ash">Pending event photos</span>
226
        </div>
227
        <ul className="mt-6">
228
          {pendingPhotos.length === 0 ? (
229
            <li className="kicker text-ash">Nothing waiting for review.</li>
230
          ) : (
231
            pendingPhotos.map((p) => (
232
              <li
233
                key={p.id}
234
                className="flex items-center gap-4 border-b border-[var(--line)] py-4"
235
              >
236
                <a href={p.url} target="_blank" rel="noopener noreferrer">
237
                  <img
238
                    src={p.url}
239
                    alt="Pending submission"
240
                    className="h-16 w-16 border border-[var(--line)] object-cover"
241
                  />
242
                </a>
243
                <div className="flex-1">
244
                  <p className="display text-lg text-bone">{p.eventTitle}</p>
245
                  {p.caption ? (
246
                    <p className="kicker mt-1 text-ash">{p.caption}</p>
247
                  ) : null}
248
                </div>
249
                <span className="kicker hidden text-ash sm:block">{stamp(p.createdAt)}</span>
250
                <form action={reviewPhoto} className="flex gap-3">
251
                  <input type="hidden" name="id" value={p.id} />
252
                  <button
253
                    name="decision"
254
                    value="approve"
255
                    className="kicker text-gold hover:text-gold-bright"
256
                  >
257
                    Approve
258
                  </button>
259
                  <button name="decision" value="reject" className="kicker text-ash hover:text-bone">
260
                    Reject
261
                  </button>
262
                </form>
263
              </li>
264
            ))
265
          )}
266
        </ul>
267
      </section>
268
 
269
      {}
270
      <section className="mt-14 border-t border-[var(--line)] pt-8">
271
        <div className="flex items-end justify-between">
272
          <h2 className="display text-3xl">Reports</h2>
273
          <span className="kicker text-ash">Open community reports</span>
274
        </div>
275
        <ul className="mt-6">
276
          {openFlags.length === 0 ? (
277
            <li className="kicker text-ash">Nothing waiting for review.</li>
278
          ) : (
279
            openFlags.map((f) => (
280
              <li
281
                key={f.id}
282
                className="flex items-center gap-4 border-b border-[var(--line)] py-4"
283
              >
284
                <div className="flex-1">
285
                  <p className="display text-lg text-bone">
286
                    {f.subjectType.replace(/_/g, " ")}
287
                  </p>
288
                  <p className="kicker mt-1 text-ash">
289
                    {f.reason || "No reason given"} · {f.subjectId.slice(0, 8)}
290
                  </p>
291
                </div>
292
                <span className="kicker hidden text-ash sm:block">{stamp(f.createdAt)}</span>
293
                <form action={resolveFlag} className="flex gap-3">
294
                  <input type="hidden" name="id" value={f.id} />
295
                  <button
296
                    name="status"
297
                    value="actioned"
298
                    className="kicker text-gold hover:text-gold-bright"
299
                  >
300
                    Actioned
301
                  </button>
302
                  <button
303
                    name="status"
304
                    value="reviewed"
305
                    className="kicker text-bone hover:text-gold"
306
                  >
307
                    Reviewed
308
                  </button>
309
                  <button name="status" value="dismissed" className="kicker text-ash hover:text-bone">
310
                    Dismiss
311
                  </button>
312
                </form>
313
              </li>
314
            ))
315
          )}
316
        </ul>
317
      </section>
318
 
319
      {}
320
      <section className="mt-14 border-t border-[var(--line)] pt-8">
321
        <div className="flex items-end justify-between">
322
          <h2 className="display text-3xl">Event Media</h2>
323
          <span className="kicker text-ash">Flyers + PDF programs</span>
324
        </div>
325
        <form action={attachMedia} className="mt-5 flex flex-wrap items-end gap-3">
326
          <select
327
            name="eventId"
328
            required
329
            defaultValue=""
330
            className="kicker max-w-60 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold"
331
          >
332
            <option value="" disabled>EVENT</option>
333
            {recent.map((e) => (
334
              <option key={e.id} value={e.id} className="bg-stage">
335
                {e.title.toUpperCase()}
336
              </option>
337
            ))}
338
          </select>
339
          <select
340
            name="kind"
341
            required
342
            defaultValue="flyer"
343
            className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold"
344
          >
345
            <option value="flyer" className="bg-stage">FLYER (IMAGE)</option>
346
            <option value="program" className="bg-stage">PROGRAM (PDF)</option>
347
          </select>
348
          <input
349
            type="file"
350
            name="file"
351
            required
352
            accept="image/jpeg,image/png,image/webp,application/pdf"
353
            className="kicker flex-1 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none file:mr-3 file:border-0 file:bg-gold file:px-3 file:py-1 file:text-stage"
354
          />
355
          <button type="submit" className="kicker bg-gold px-5 py-2 text-stage hover:bg-gold-bright">
356
            Attach
357
          </button>
358
        </form>
359
        <p className="kicker mt-3 text-ash">
360
          Flyers: JPEG/PNG/WebP up to 8MB. Programs: PDF up to 15MB. Attaching again replaces the current file.
361
        </p>
362
      </section>
363
 
364
      {}
365
      <section className="mt-14 border-t border-[var(--line)] pt-8">
366
        <div className="flex items-end justify-between">
367
          <h2 className="display text-3xl">Revenue</h2>
368
          <span className="kicker text-ash">
369
            Recent orders · payments off
370
          </span>
371
        </div>
372
        <ul className="mt-6">
373
          {ordersLog.length === 0 ? (
374
            <li className="kicker text-ash">No orders yet.</li>
375
          ) : (
376
            ordersLog.map((o) => (
377
              <li
378
                key={o.id}
379
                className="flex items-center gap-4 border-b border-[var(--line)] py-3"
380
              >
381
                <span className="kicker w-20 text-gold">{o.purpose.toUpperCase()}</span>
382
                <span className="kicker flex-1 text-ash">
383
                  {o.quantity} × · {o.status.toUpperCase()}
384
                </span>
385
                <span className="display text-lg">{money(o.amountCents)}</span>
386
                <span className="kicker hidden w-24 text-right text-ash sm:block">
387
                  {stamp(o.createdAt)}
388
                </span>
389
              </li>
390
            ))
391
          )}
392
        </ul>
393
      </section>
394
 
395
      {}
396
      <section className="mt-14 border-t border-[var(--line)] pt-8">
397
        <h2 className="display text-3xl">City Sponsors</h2>
398
        <form action={createSponsor} className="mt-5 flex flex-wrap items-end gap-3">
399
          <select
400
            name="cityId"
401
            required
402
            defaultValue=""
403
            className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold"
404
          >
405
            <option value="" disabled>CITY</option>
406
            {launchedCities.map((c) => (
407
              <option key={c.id} value={c.id} className="bg-stage">
408
                {c.name.toUpperCase()}
409
              </option>
410
            ))}
411
          </select>
412
          <input name="name" required placeholder="Sponsor name" className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold" />
413
          <input name="blurb" placeholder="Tagline (opt)" className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold" />
414
          <input name="linkUrl" placeholder="https:// (opt)" className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none placeholder:text-ash focus:border-gold" />
415
          <button type="submit" className="kicker bg-gold px-5 py-2 text-stage hover:bg-gold-bright">Add</button>
416
        </form>
417
        <ul className="mt-6">
418
          {sponsorsList.length === 0 ? (
419
            <li className="kicker text-ash">No sponsors yet.</li>
420
          ) : (
421
            sponsorsList.map((s) => (
422
              <li key={s.id} className="flex items-center gap-4 border-b border-[var(--line)] py-3">
423
                <span className="kicker w-20" style={{ color: s.status === "active" ? "var(--gold)" : "var(--ash)" }}>
424
                  {s.status.toUpperCase()}
425
                </span>
426
                <span className="display flex-1 text-lg">{s.name}</span>
427
                <form action={setSponsorStatus} className="flex gap-3">
428
                  <input type="hidden" name="id" value={s.id} />
429
                  <button name="status" value={s.status === "active" ? "ended" : "active"} className="kicker text-gold hover:text-gold-bright">
430
                    {s.status === "active" ? "End" : "Activate"}
431
                  </button>
432
                </form>
433
              </li>
434
            ))
435
          )}
436
        </ul>
437
      </section>
438
 
439
      {}
440
      <section className="mt-14 border-t border-[var(--line)] pt-8">
441
        <div className="flex items-end justify-between">
442
          <h2 className="display text-3xl">Vendors</h2>
443
          <span className="kicker text-ash">City-wide or tied to one event</span>
444
        </div>
445
        <form action={createVendor} className="mt-5 flex flex-wrap items-end gap-3">
446
          <select
447
            name="cityId"
448
            required
449
            defaultValue=""
450
            className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold"
451
          >
452
            <option value="" disabled>CITY</option>
453
            {launchedCities.map((c) => (
454
              <option key={c.id} value={c.id} className="bg-stage">
455
                {c.name.toUpperCase()}
456
              </option>
457
            ))}
458
          </select>
459
          <select
460
            name="eventId"
461
            defaultValue=""
462
            className="kicker max-w-52 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold"
463
          >
464
            <option value="" className="bg-stage">ALL CITY EVENTS</option>
465
            {recent.map((e) => (
466
              <option key={e.id} value={e.id} className="bg-stage">
467
                {e.title.toUpperCase()}
468
              </option>
469
            ))}
470
          </select>
471
          <input
472
            name="name"
473
            required
474
            placeholder="Vendor name"
475
            className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold"
476
          />
477
          <input
478
            name="category"
479
            placeholder="Category (opt)"
480
            className="kicker w-36 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none placeholder:text-ash focus:border-gold"
481
          />
482
          <input
483
            name="description"
484
            placeholder="What they sell (opt)"
485
            className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold"
486
          />
487
          <input
488
            name="linkUrl"
489
            placeholder="https:// (opt)"
490
            className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none placeholder:text-ash focus:border-gold"
491
          />
492
          <button type="submit" className="kicker bg-gold px-5 py-2 text-stage hover:bg-gold-bright">Add</button>
493
        </form>
494
        <ul className="mt-6">
495
          {vendorList.length === 0 ? (
496
            <li className="kicker text-ash">No vendors yet.</li>
497
          ) : (
498
            vendorList.map((v) => (
499
              <li key={v.id} className="flex items-center gap-4 border-b border-[var(--line)] py-3">
500
                <span
501
                  className="kicker w-20"
502
                  style={{ color: v.status === "active" ? "var(--gold)" : "var(--ash)" }}
503
                >
504
                  {v.status.toUpperCase()}
505
                </span>
506
                <span className="display flex-1 text-lg">{v.name}</span>
507
                <span className="kicker hidden text-ash sm:block">
508
                  {v.category ? v.category.toUpperCase() : ""}
509
                </span>
510
                <form action={setVendorStatus} className="flex gap-3">
511
                  <input type="hidden" name="id" value={v.id} />
512
                  <button
513
                    name="status"
514
                    value={v.status === "active" ? "hidden" : "active"}
515
                    className="kicker text-gold hover:text-gold-bright"
516
                  >
517
                    {v.status === "active" ? "Hide" : "Activate"}
518
                  </button>
519
                </form>
520
              </li>
521
            ))
522
          )}
523
        </ul>
524
      </section>
525
 
526
      {}
527
      <section className="mt-14 border-t border-[var(--line)] pt-8">
528
        <div className="flex items-end justify-between">
529
          <h2 className="display text-3xl">Pro Accounts</h2>
530
          <span className="kicker text-ash">Promoter / DJ / instructor plans</span>
531
        </div>
532
        <ul className="mt-6">
533
          {proList.length === 0 ? (
534
            <li className="kicker text-ash">
535
              No pro accounts yet. They appear here once members upgrade.
536
            </li>
537
          ) : (
538
            proList.map((p) => (
539
              <li key={p.id} className="flex items-center gap-4 border-b border-[var(--line)] py-3">
540
                <span className="kicker w-20" style={{ color: p.status === "active" ? "var(--gold)" : "var(--ash)" }}>
541
                  {p.status.toUpperCase()}
542
                </span>
543
                <span className="display flex-1 text-lg">{p.tier.toUpperCase()}</span>
544
                <form action={setProStatus} className="flex gap-3">
545
                  <input type="hidden" name="id" value={p.id} />
546
                  <button name="status" value={p.status === "active" ? "cancelled" : "active"} className="kicker text-gold hover:text-gold-bright">
547
                    {p.status === "active" ? "Cancel" : "Activate"}
548
                  </button>
549
                </form>
550
              </li>
551
            ))
552
          )}
553
        </ul>
554
      </section>
555
 
556
      <footer className="mt-16 border-t border-[var(--line)] pt-6">
557
        <Link href="/" className="kicker text-ash hover:text-bone">
558
          ← Back to site
559
        </Link>
560
      </footer>
561
    </main>
562
  );
563
}