Add security middleware, audit log, alerts, PayPal, cron, radio + apps
Security (launch blockers): - src/middleware.ts (edge): forwards x-pathname + real client IP. - access-guard.ts (Node, from root layout): routes non-staff to /maintenance when maintenance mode is on, banned users to /banned. New /banned + /maintenance pages (the consumers the admin toggle was missing). Admin layout enforces force_staff_2fa before /admin. - staff-activity.ts audit log wired into ban/lift/give-currency/set-rank actions. Infra (parallel agents): alert service (alert_logs + Discord embed + email), PayPal top-up (create/capture API routes + /shop/topup), cron worker (scripts/jobs-worker.ts via croner: emulator-ping->alert, maintenance-check, bans-cleanup), social connections page, admin radio settings/banners/ranks. Public radio subsystem: /radio (+schedule, shouts+post, contests, giveaways, apply, leaderboard) and /apply/staff + /apply/team submission forms. Radio nav link added. .env.example documents the new optional vars. (radio song-requests dropped: its table is a stub in AtomCMS — columns added by un-modeled alter-migrations.) Verified: tsc exit 0, vitest 48/48, next build exit 0 (82 page routes).
This commit is contained in:
1 parent
e19debb795
commit
22d53d0e9c
40 files changed
+3781
-6
No files matched your search
@@ -1,12 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const staff = await requireStaff();
|
||||
|
||||
// Force staff 2FA before the admin panel (AtomCMS ForceStaffTwoFactorMiddleware).
|
||||
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
||||
const u = await prisma.user
|
||||
.findUnique({ where: { id: staff.id }, select: { twoFactorConfirmedAt: true } })
|
||||
.catch(() => null);
|
||||
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "190px 1fr", gap: "1.5rem" }}>
|
||||
<aside className="card" style={{ height: "fit-content" }}>
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
createRadioBanner,
|
||||
deleteRadioBanner,
|
||||
updateRadioBanner,
|
||||
} from '@/actions/admin-radio-extra';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Banner = {
|
||||
id: bigint;
|
||||
userId: bigint;
|
||||
imagePath: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date | null;
|
||||
};
|
||||
|
||||
function formatDate(d: Date | null): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
|
||||
}
|
||||
|
||||
export default async function AdminRadioBannersPage() {
|
||||
let banners: Banner[] = [];
|
||||
let dbError = false;
|
||||
|
||||
try {
|
||||
banners = await prisma.radioBanners.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
imagePath: true,
|
||||
title: true,
|
||||
description: true,
|
||||
sortOrder: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
} catch {
|
||||
dbError = true;
|
||||
banners = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
||||
<Link href="/admin/radio">Radio</Link> ·{' '}
|
||||
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
||||
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
||||
<Link href="/admin/radio/ranks">Ranks</Link>
|
||||
</nav>
|
||||
|
||||
<h1>Radio Banners</h1>
|
||||
<p className="muted">
|
||||
Promotional banners shown on the radio page, ordered by sort order.
|
||||
</p>
|
||||
|
||||
{dbError ? (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Could not load banners (database unavailable).
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={createRadioBanner} className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add banner</h3>
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label htmlFor="new_imagePath" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Image path / URL
|
||||
</label>
|
||||
<input
|
||||
id="new_imagePath"
|
||||
name="imagePath"
|
||||
placeholder="/storage/radio/banner.png"
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new_title" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Title
|
||||
</label>
|
||||
<input id="new_title" name="title" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label htmlFor="new_description" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="new_description"
|
||||
name="description"
|
||||
rows={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new_sortOrder" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Sort order
|
||||
</label>
|
||||
<input
|
||||
id="new_sortOrder"
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
defaultValue={0}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
|
||||
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create banner
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{banners.length === 0 ? (
|
||||
<p className="muted">No banners yet.</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{banners.map((b) => (
|
||||
<div key={String(b.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<strong>#{String(b.id)}</strong>
|
||||
<span className="muted">
|
||||
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
|
||||
{b.isActive ? 'active' : 'hidden'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{b.imagePath ? (
|
||||
<img
|
||||
src={b.imagePath}
|
||||
alt={b.title ?? 'Radio banner'}
|
||||
className="article-img"
|
||||
style={{ margin: '0.5rem 0' }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<form action={updateRadioBanner}>
|
||||
<input type="hidden" name="id" value={String(b.id)} />
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`img_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Image path / URL
|
||||
</label>
|
||||
<input
|
||||
id={`img_${b.id}`}
|
||||
name="imagePath"
|
||||
defaultValue={b.imagePath}
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`title_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id={`title_${b.id}`}
|
||||
name="title"
|
||||
defaultValue={b.title ?? ''}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label
|
||||
htmlFor={`desc_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id={`desc_${b.id}`}
|
||||
name="description"
|
||||
rows={2}
|
||||
defaultValue={b.description ?? ''}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`sort_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Sort order
|
||||
</label>
|
||||
<input
|
||||
id={`sort_${b.id}`}
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
defaultValue={b.sortOrder}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label
|
||||
style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isActive"
|
||||
value="1"
|
||||
defaultChecked={b.isActive}
|
||||
/>{' '}
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.6rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={deleteRadioBanner} style={{ marginTop: '0.5rem' }}>
|
||||
<input type="hidden" name="id" value={String(b.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
createRadioRank,
|
||||
deleteRadioRank,
|
||||
updateRadioRank,
|
||||
} from '@/actions/admin-radio-extra';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Rank = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
description: string | null;
|
||||
badgeCode: string | null;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export default async function AdminRadioRanksPage() {
|
||||
let ranks: Rank[] = [];
|
||||
let dbError = false;
|
||||
|
||||
try {
|
||||
ranks = await prisma.radioRanks.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
badgeCode: true,
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
} catch {
|
||||
dbError = true;
|
||||
ranks = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
||||
<Link href="/admin/radio">Radio</Link> ·{' '}
|
||||
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
||||
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
||||
<Link href="/admin/radio/ranks">Ranks</Link>
|
||||
</nav>
|
||||
|
||||
<h1>Radio Ranks</h1>
|
||||
<p className="muted">DJ / staff ranks assignable on radio applications.</p>
|
||||
|
||||
{dbError ? (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Could not load ranks (database unavailable).
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={createRadioRank} className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add rank</h3>
|
||||
<div className="grid cols-3">
|
||||
<div>
|
||||
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Name
|
||||
</label>
|
||||
<input id="new_name" name="name" required style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new_badge" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Badge code
|
||||
</label>
|
||||
<input id="new_badge" name="badgeCode" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
|
||||
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label htmlFor="new_desc" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Description
|
||||
</label>
|
||||
<input id="new_desc" name="description" style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create rank
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{ranks.length === 0 ? (
|
||||
<p className="muted">No ranks yet.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Badge</th>
|
||||
<th>Description</th>
|
||||
<th>Active</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ranks.map((r) => (
|
||||
<tr key={String(r.id)}>
|
||||
<td>{String(r.id)}</td>
|
||||
<td colSpan={5}>
|
||||
<form
|
||||
action={updateRadioRank}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(r.id)} />
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={r.name}
|
||||
required
|
||||
placeholder="Name"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<input
|
||||
name="badgeCode"
|
||||
defaultValue={r.badgeCode ?? ''}
|
||||
placeholder="Badge"
|
||||
style={{ minWidth: 100 }}
|
||||
/>
|
||||
<input
|
||||
name="description"
|
||||
defaultValue={r.description ?? ''}
|
||||
placeholder="Description"
|
||||
style={{ flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
<label
|
||||
style={{ display: 'inline-flex', gap: '0.3rem', alignItems: 'center' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isActive"
|
||||
value="1"
|
||||
defaultChecked={r.isActive}
|
||||
/>{' '}
|
||||
Active
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{ranks.map((r) => (
|
||||
<tr key={`del_${String(r.id)}`}>
|
||||
<td colSpan={6} style={{ borderTop: 'none', paddingTop: 0 }}>
|
||||
<form action={deleteRadioRank} style={{ display: 'inline' }}>
|
||||
<input type="hidden" name="id" value={String(r.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete rank #{String(r.id)}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import Link from 'next/link';
|
||||
import { saveRadioSetting, saveRadioSettings } from '@/actions/admin-radio-extra';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Curated radio_* keys (faithful to AtomCMS RadioSettingsSeeder), grouped for
|
||||
// the editor. Any extra radio_* rows found in the DB are appended under "Other".
|
||||
type Field = { key: string; comment: string };
|
||||
type Group = { title: string; fields: Field[] };
|
||||
|
||||
const GROUPS: Group[] = [
|
||||
{
|
||||
title: 'AzureCast & Stream',
|
||||
fields: [
|
||||
{ key: 'radio_enabled', comment: 'Radio enabled (0=no, 1=yes)' },
|
||||
{ key: 'radio_stream_url', comment: 'Main stream URL' },
|
||||
{ key: 'radio_stream_backup_url', comment: 'Backup stream URL' },
|
||||
{ key: 'radio_azurecast_base_url', comment: 'AzureCast base URL' },
|
||||
{ key: 'radio_azurecast_station_id', comment: 'AzureCast station ID' },
|
||||
{ key: 'radio_azurecast_api_key', comment: 'AzureCast API key' },
|
||||
{ key: 'radio_azurecast_port', comment: 'AzureCast stream port' },
|
||||
{ key: 'radio_azurecast_protocol', comment: 'AzureCast protocol (http/https)' },
|
||||
{ key: 'radio_azurecast_use_proxy', comment: 'Use AzureCast proxy (0=no, 1=yes)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'External sources',
|
||||
fields: [
|
||||
{ key: 'radio_sambroadcaster_api_url', comment: 'Sambroadcaster API URL' },
|
||||
{ key: 'radio_sambroadcaster_password', comment: 'Sambroadcaster password' },
|
||||
{ key: 'radio_virtual_dj_url', comment: 'Virtual DJ URL' },
|
||||
{ key: 'radio_virtual_dj_password', comment: 'Virtual DJ password' },
|
||||
{ key: 'radio_djs_api_url', comment: 'DJs API URL' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Player',
|
||||
fields: [
|
||||
{ key: 'radio_style', comment: 'Player style (dark/light/blue/purple/pink/gold)' },
|
||||
{ key: 'radio_player_type', comment: 'Player type (default/minimal/embedded)' },
|
||||
{ key: 'radio_auto_play', comment: 'Auto-play on page load (0=no, 1=yes)' },
|
||||
{ key: 'radio_auto_play_delay', comment: 'Auto-play delay in seconds' },
|
||||
{ key: 'radio_mute_on_start', comment: 'Muted on start (0=no, 1=yes)' },
|
||||
{ key: 'radio_volume', comment: 'Default volume (0-100)' },
|
||||
{ key: 'radio_language', comment: 'Default language (nl/en/de/fr/es/pt/it/br)' },
|
||||
{ key: 'radio_multi_language_enabled', comment: 'Multi-language support (0=no, 1=yes)' },
|
||||
{ key: 'radio_player_color_primary', comment: 'Player primary color' },
|
||||
{ key: 'radio_player_color_secondary', comment: 'Player secondary color' },
|
||||
{ key: 'radio_player_color_text', comment: 'Player text color' },
|
||||
{ key: 'radio_player_color_accent', comment: 'Player accent color' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Now playing & history',
|
||||
fields: [
|
||||
{ key: 'radio_now_playing_enabled', comment: 'Show now playing info (0=no, 1=yes)' },
|
||||
{ key: 'radio_now_playing_api_url', comment: 'Now playing API URL' },
|
||||
{ key: 'radio_now_playing_cache_duration', comment: 'Now playing cache duration in seconds' },
|
||||
{ key: 'radio_show_song_history', comment: 'Show song history (0=no, 1=yes)' },
|
||||
{ key: 'radio_song_history_count', comment: 'Number of songs in history' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Listeners',
|
||||
fields: [
|
||||
{ key: 'radio_listeners_enabled', comment: 'Show listeners count (0=no, 1=yes)' },
|
||||
{ key: 'radio_listeners_api_url', comment: 'Listeners API URL' },
|
||||
{ key: 'radio_listeners_update_interval', comment: 'Listeners update interval in seconds' },
|
||||
{ key: 'radio_show_listener_peak', comment: 'Show listener peak (0=no, 1=yes)' },
|
||||
{ key: 'radio_peak_reset_time', comment: 'Peak reset time (HH:MM)' },
|
||||
{ key: 'radio_store_listener_stats', comment: 'Store listener statistics (0=no, 1=yes)' },
|
||||
{ key: 'radio_stats_retention_days', comment: 'Statistics retention in days' },
|
||||
{ key: 'radio_show_listener_graph', comment: 'Show listener graph (0=no, 1=yes)' },
|
||||
{ key: 'radio_listener_graph_period', comment: 'Listener graph period (1h/6h/12h/24h/7d)' },
|
||||
{ key: 'radio_show_average_listeners', comment: 'Show average listeners (0=no, 1=yes)' },
|
||||
{ key: 'radio_show_unique_listeners', comment: 'Show unique listeners (0=no, 1=yes)' },
|
||||
{ key: 'radio_listener_alerts_enabled', comment: 'Enable listener alerts (0=no, 1=yes)' },
|
||||
{ key: 'radio_min_listeners_threshold', comment: 'Minimum listeners threshold' },
|
||||
{ key: 'radio_listener_alert_threshold_high', comment: 'High listener alert threshold' },
|
||||
{ key: 'radio_listener_alert_threshold_low', comment: 'Low listener alert threshold' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'DJ',
|
||||
fields: [
|
||||
{ key: 'radio_show_current_dj', comment: 'Show current DJ (0=no, 1=yes)' },
|
||||
{ key: 'radio_current_dj_id', comment: 'Manual DJ user ID' },
|
||||
{ key: 'radio_dj_avatar_size', comment: 'DJ avatar size in pixels' },
|
||||
{ key: 'radio_show_dj_schedule', comment: 'Show DJ schedule (0=no, 1=yes)' },
|
||||
{ key: 'radio_auto_dj_detection', comment: 'Enable auto DJ detection (0=no, 1=yes)' },
|
||||
{ key: 'radio_dj_detection_interval', comment: 'DJ detection interval in seconds' },
|
||||
{ key: 'auto_dj_detected_action', comment: 'Action on DJ detection (set_dj/notify/both)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Shouts & chat',
|
||||
fields: [
|
||||
{ key: 'radio_shouts_enabled', comment: 'Enable shouts (0=no, 1=yes)' },
|
||||
{ key: 'radio_shouts_max_length', comment: 'Maximum shout length in characters' },
|
||||
{ key: 'radio_shouts_cooldown', comment: 'Shout cooldown in seconds' },
|
||||
{ key: 'radio_shouts_require_moderation', comment: 'Require shout moderation (0=no, 1=yes)' },
|
||||
{ key: 'radio_chat_enabled', comment: 'Enable live chat (0=no, 1=yes)' },
|
||||
{ key: 'radio_chat_position', comment: 'Chat position (left/right/bottom)' },
|
||||
{ key: 'radio_chat_width', comment: 'Chat width in pixels' },
|
||||
{ key: 'radio_chat_height', comment: 'Chat height in pixels' },
|
||||
{ key: 'radio_chat_messages_count', comment: 'Number of chat messages to show' },
|
||||
{ key: 'radio_chat_show_timestamps', comment: 'Show chat timestamps (0=no, 1=yes)' },
|
||||
{ key: 'radio_chat_allow_links', comment: 'Allow links in chat (0=no, 1=yes)' },
|
||||
{ key: 'radio_chat_allow_emojis', comment: 'Allow emojis in chat (0=no, 1=yes)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Requests & widget',
|
||||
fields: [
|
||||
{ key: 'radio_request_form_enabled', comment: 'Enable song requests (0=no, 1=yes)' },
|
||||
{ key: 'radio_request_max_per_user', comment: 'Maximum requests per user' },
|
||||
{ key: 'radio_request_cooldown', comment: 'Request cooldown in seconds' },
|
||||
{ key: 'radio_voting_enabled', comment: 'Enable request voting (0=no, 1=yes)' },
|
||||
{ key: 'radio_voting_show_song_requests', comment: 'Show requests with votes (0=no, 1=yes)' },
|
||||
{ key: 'radio_widget_enabled', comment: 'Enable radio widget (0=no, 1=yes)' },
|
||||
{ key: 'radio_widget_show_globally', comment: 'Show widget globally (0=no, 1=yes)' },
|
||||
{ key: 'radio_widget_position', comment: 'Widget position' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Offline & branding',
|
||||
fields: [
|
||||
{ key: 'radio_show_offline_message', comment: 'Show offline message (0=no, 1=yes)' },
|
||||
{ key: 'radio_offline_message', comment: 'Offline message text' },
|
||||
{ key: 'radio_offline_image', comment: 'Offline page image URL' },
|
||||
{ key: 'radio_background_image', comment: 'Background image URL' },
|
||||
{ key: 'radio_logo_url', comment: 'Radio logo URL' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Applications',
|
||||
fields: [
|
||||
{ key: 'radio_applications_enabled', comment: 'Enable DJ applications (0=no, 1=yes)' },
|
||||
{ key: 'radio_applications_max_per_day', comment: 'Maximum applications per day' },
|
||||
{ key: 'radio_applications_require_approval', comment: 'Require application approval (0=no, 1=yes)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Stats display',
|
||||
fields: [
|
||||
{ key: 'radio_stats_show_daily', comment: 'Show daily statistics (0=no, 1=yes)' },
|
||||
{ key: 'radio_stats_show_weekly', comment: 'Show weekly statistics (0=no, 1=yes)' },
|
||||
{ key: 'radio_stats_show_monthly', comment: 'Show monthly statistics (0=no, 1=yes)' },
|
||||
{ key: 'radio_stats_show_top_djs', comment: 'Show top DJs (0=no, 1=yes)' },
|
||||
{ key: 'radio_stats_show_top_songs', comment: 'Show top songs (0=no, 1=yes)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Social & contact',
|
||||
fields: [
|
||||
{ key: 'radio_social_facebook', comment: 'Facebook URL' },
|
||||
{ key: 'radio_social_twitter', comment: 'Twitter URL' },
|
||||
{ key: 'radio_social_instagram', comment: 'Instagram URL' },
|
||||
{ key: 'radio_social_discord', comment: 'Discord URL' },
|
||||
{ key: 'radio_social_youtube', comment: 'YouTube URL' },
|
||||
{ key: 'radio_social_twitch', comment: 'Twitch URL' },
|
||||
{ key: 'radio_contact_email', comment: 'Contact email' },
|
||||
{ key: 'radio_contact_phone', comment: 'Contact phone' },
|
||||
{ key: 'radio_about_text', comment: 'About radio text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Discord webhook & custom code',
|
||||
fields: [
|
||||
{ key: 'radio_discord_webhook_url', comment: 'Discord webhook URL' },
|
||||
{ key: 'radio_discord_enabled', comment: 'Enable Discord notifications (0=no, 1=yes)' },
|
||||
{ key: 'radio_discord_dj_live', comment: 'Discord DJ live notification (0=no, 1=yes)' },
|
||||
{ key: 'radio_discord_song_changes', comment: 'Discord song change notification (0=no, 1=yes)' },
|
||||
{ key: 'radio_discord_custom_message', comment: 'Custom Discord message template' },
|
||||
{ key: 'radio_custom_css', comment: 'Custom CSS code' },
|
||||
{ key: 'radio_custom_js', comment: 'Custom JavaScript code' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const CURATED_KEYS = new Set(GROUPS.flatMap((g) => g.fields.map((f) => f.key)));
|
||||
|
||||
export default async function AdminRadioSettingsPage() {
|
||||
let values: Map<string, string> = new Map();
|
||||
let dbError = false;
|
||||
let extra: Field[] = [];
|
||||
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
select: { key: true, value: true, comment: true },
|
||||
});
|
||||
values = new Map(rows.map((r) => [r.key, r.value]));
|
||||
extra = rows
|
||||
.filter(
|
||||
(r) =>
|
||||
(r.key.startsWith('radio_') || r.key.startsWith('auto_dj_')) &&
|
||||
!CURATED_KEYS.has(r.key),
|
||||
)
|
||||
.map((r) => ({ key: r.key, comment: r.comment ?? '' }));
|
||||
} catch {
|
||||
dbError = true;
|
||||
}
|
||||
|
||||
const groups: Group[] = extra.length
|
||||
? [...GROUPS, { title: 'Other radio settings', fields: extra }]
|
||||
: GROUPS;
|
||||
|
||||
const allKeys = [...CURATED_KEYS, ...extra.map((e) => e.key)].join(',');
|
||||
|
||||
return (
|
||||
<main>
|
||||
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
||||
<Link href="/admin/radio">Radio</Link> ·{' '}
|
||||
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
||||
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
||||
<Link href="/admin/radio/ranks">Ranks</Link>
|
||||
</nav>
|
||||
|
||||
<h1>Radio Settings</h1>
|
||||
<p className="muted">
|
||||
Edits write to <code>website_settings</code> (radio_* keys) and bust the
|
||||
runtime settings cache. Booleans use <code>0</code> / <code>1</code>.
|
||||
</p>
|
||||
|
||||
{dbError ? (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Could not load settings (database unavailable). Defaults will be
|
||||
created on save.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={saveRadioSettings}>
|
||||
<input type="hidden" name="__keys" value={allKeys} />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{groups.map((group) => (
|
||||
<section key={group.title} className="card" style={{ marginBottom: '1.25rem' }}>
|
||||
<h2 style={{ marginTop: 0 }}>{group.title}</h2>
|
||||
<div className="grid cols-2">
|
||||
{group.fields.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label
|
||||
htmlFor={`f_${field.key}`}
|
||||
style={{ display: 'block', fontWeight: 700, fontSize: '0.85rem' }}
|
||||
>
|
||||
{field.key}
|
||||
</label>
|
||||
<input
|
||||
id={`f_${field.key}`}
|
||||
name={field.key}
|
||||
defaultValue={values.get(field.key) ?? ''}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{field.comment ? (
|
||||
<p className="muted" style={{ margin: '0.2rem 0 0' }}>
|
||||
{field.comment}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save all
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="card" style={{ marginTop: '1.5rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add / overwrite a single key</h3>
|
||||
<form
|
||||
action={saveRadioSetting}
|
||||
style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}
|
||||
>
|
||||
<input name="key" placeholder="radio_custom_key" required />
|
||||
<input name="value" placeholder="value" style={{ flex: 1, minWidth: 160 }} />
|
||||
<input name="comment" placeholder="comment (optional)" />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
captureOrder,
|
||||
creditsPerUnit,
|
||||
isPayPalConfigured,
|
||||
} from "@/lib/services/paypal";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendCurrency } from "@/lib/services/send-currency";
|
||||
import { env } from "@/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* POST /api/paypal/capture — capture an approved order and credit the buyer.
|
||||
* Body: { orderId: string } (PayPal order id from /api/paypal/create).
|
||||
*
|
||||
* On a COMPLETED capture we:
|
||||
* 1. record the payment in website_paypal_transactions (idempotent on
|
||||
* transaction_id so a double-submit can't double-credit), and
|
||||
* 2. credit the buyer's `credits` wallet via sendCurrency (RCON-first, DB
|
||||
* fallback). NOTE: the schema has no dedicated website-balance column —
|
||||
* User.credits is the website/in-game wallet, so the top-up lands there,
|
||||
* consistent with the voucher redeem flow.
|
||||
*
|
||||
* Auth-gated via auth(); the crediting user id is the session user, not a body
|
||||
* field, so a captured order always credits the person who is signed in.
|
||||
*/
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "You must be signed in." }, { status: 401 });
|
||||
}
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) {
|
||||
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isPayPalConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const orderId = String((body as { orderId?: unknown })?.orderId ?? "").trim();
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ error: "Missing orderId." }, { status: 422 });
|
||||
}
|
||||
|
||||
// Idempotency: if this order was already recorded, don't capture/credit again.
|
||||
try {
|
||||
const existing = await prisma.websitePaypalTransactions.findFirst({
|
||||
where: { transactionId: orderId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json({
|
||||
ok: existing.status === "COMPLETED",
|
||||
alreadyProcessed: true,
|
||||
status: existing.status,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// If the lookup fails we fall through; the capture call itself is the source
|
||||
// of truth and PayPal rejects a second capture of the same order.
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await captureOrder(orderId);
|
||||
} catch (e) {
|
||||
console.error("[paypal/capture]", (e as Error).message);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not capture the PayPal payment. If you were charged, contact staff." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status !== "COMPLETED") {
|
||||
// Record the non-completed attempt so support can trace it.
|
||||
try {
|
||||
await prisma.websitePaypalTransactions.create({
|
||||
data: {
|
||||
userId,
|
||||
transactionId: result.id || orderId,
|
||||
status: result.status,
|
||||
description: `${env.HOTEL_NAME} top-up (not completed)`,
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* best-effort logging */
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ ok: false, status: result.status, error: "Payment was not completed." },
|
||||
{ status: 402 },
|
||||
);
|
||||
}
|
||||
|
||||
const credits = Math.floor(result.amount * creditsPerUnit());
|
||||
|
||||
// Record the transaction BEFORE crediting so a crash mid-grant can't be
|
||||
// reprocessed into a double credit (the idempotency check above keys on this).
|
||||
try {
|
||||
await prisma.websitePaypalTransactions.create({
|
||||
data: {
|
||||
userId,
|
||||
transactionId: result.captureId ?? result.id,
|
||||
status: "COMPLETED",
|
||||
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[paypal/capture] record failed", (e as Error).message);
|
||||
return NextResponse.json(
|
||||
{ error: "Payment captured but could not be recorded. Contact staff with your order id." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Credit the buyer's website credits wallet (RCON-first, DB fallback).
|
||||
try {
|
||||
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
|
||||
} catch (e) {
|
||||
console.error("[paypal/capture] credit failed", (e as Error).message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Payment recorded but credits could not be delivered. Contact staff.",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: "COMPLETED",
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
credits,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import {
|
||||
createOrder,
|
||||
creditsPerUnit,
|
||||
isPayPalConfigured,
|
||||
PAYPAL_CURRENCY,
|
||||
} from "@/lib/services/paypal";
|
||||
import { env } from "@/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MIN_AMOUNT = 1;
|
||||
const MAX_AMOUNT = 500;
|
||||
|
||||
/**
|
||||
* POST /api/paypal/create — create a PayPal CAPTURE order for the signed-in user.
|
||||
* Body: { amount: number } (in the configured currency, default USD).
|
||||
* Returns { id, approveUrl } on success; a clear JSON error otherwise.
|
||||
*
|
||||
* Auth-gated via auth(): the order is tied to the session, never to a body field.
|
||||
*/
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "You must be signed in to top up." }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fail fast (and clearly) when the sandbox/live keys aren't set.
|
||||
if (!isPayPalConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const raw = (body as { amount?: unknown })?.amount;
|
||||
const amount = Math.round(Number(raw) * 100) / 100;
|
||||
if (!Number.isFinite(amount) || amount < MIN_AMOUNT || amount > MAX_AMOUNT) {
|
||||
return NextResponse.json(
|
||||
{ error: `Enter an amount between ${MIN_AMOUNT} and ${MAX_AMOUNT} ${PAYPAL_CURRENCY}.` },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const credits = Math.floor(amount * creditsPerUnit());
|
||||
const base = env.APP_URL.replace(/\/+$/, "");
|
||||
|
||||
try {
|
||||
const order = await createOrder(amount, {
|
||||
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
|
||||
returnUrl: `${base}/shop/topup?status=success`,
|
||||
cancelUrl: `${base}/shop/topup?status=cancel`,
|
||||
});
|
||||
|
||||
if (!order.approveUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal did not return an approval link. Try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: order.id,
|
||||
approveUrl: order.approveUrl,
|
||||
amount,
|
||||
currency: PAYPAL_CURRENCY,
|
||||
credits,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[paypal/create]", (e as Error).message);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not start the PayPal checkout. Please try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { applyStaff } from "@/actions/applications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
function formatDeadline(d: Date | null | undefined): string {
|
||||
if (!d) return "No deadline set";
|
||||
return d.toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function ApplyStaffPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
|
||||
// ── Open positions ──────────────────────────────────────────
|
||||
// Each query is isolated so a DB hiccup degrades that section to empty
|
||||
// rather than 500-ing the whole page.
|
||||
const positions = await prisma.websiteOpenPositions
|
||||
.findMany({ orderBy: { createdAt: "desc" } })
|
||||
.catch(() => []);
|
||||
|
||||
// Resolve display info for each position's rank. The rich rank fields
|
||||
// (rankName/badge/jobDescription/staffColor) live in website_teams, keyed by
|
||||
// the same rank id the position's permission_id points at.
|
||||
const rankIds = Array.from(new Set(positions.map((p) => p.permissionId)));
|
||||
const teams = rankIds.length
|
||||
? await prisma.websiteTeams
|
||||
.findMany({
|
||||
where: { id: { in: rankIds.map((r) => BigInt(r)) } },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const teamByRankId = new Map(teams.map((t) => [Number(t.id), t]));
|
||||
|
||||
// Ranks this user has already applied for (block re-applying).
|
||||
const myApps = await prisma.websiteStaffApplications
|
||||
.findMany({ where: { userId }, select: { rankId: true } })
|
||||
.catch(() => []);
|
||||
const appliedRankIds = new Set(myApps.map((a) => a.rankId));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.5rem" }}>Apply for {hotelName} staff</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
We open staff applications every now and then. If you come across a position you feel you
|
||||
would fit perfectly into, do not hesitate to apply for it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{positions.length === 0 ? (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>No positions open</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
There are currently no positions open. Please come back at a later time to check if we
|
||||
have any openings by then. Thank you for your interest.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{positions.map((position) => {
|
||||
const team = teamByRankId.get(position.permissionId);
|
||||
const rankName = team?.rankName ?? `Rank #${position.permissionId}`;
|
||||
const alreadyApplied = appliedRankIds.has(position.permissionId);
|
||||
return (
|
||||
<article key={String(position.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
alignItems: "center",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{team?.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}/${team.badge}.gif`}
|
||||
alt={rankName}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: team?.staffColor || undefined }}>{rankName}</h3>
|
||||
{team?.jobDescription ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{team.jobDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||
Application deadline: {formatDeadline(position.applyTo)}
|
||||
</p>
|
||||
|
||||
{alreadyApplied ? (
|
||||
<button type="button" className="btn btn-danger" disabled style={{ width: "100%" }}>
|
||||
You have already applied for {rankName}
|
||||
</button>
|
||||
) : (
|
||||
<form action={applyStaff}>
|
||||
{/* rank_id is the position's permission id; the applicant is
|
||||
re-read from the session inside the action. */}
|
||||
<input type="hidden" name="rankId" value={String(position.permissionId)} />
|
||||
<label htmlFor={`content-${position.id}`} className="muted">
|
||||
About you
|
||||
</label>
|
||||
<textarea
|
||||
id={`content-${position.id}`}
|
||||
name="content"
|
||||
required
|
||||
minLength={10}
|
||||
rows={5}
|
||||
placeholder={`Tell us why you'd be a great ${rankName}…`}
|
||||
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-secondary" style={{ width: "100%" }}>
|
||||
Apply for {rankName}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { applyTeam } from "@/actions/applications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
export default async function ApplyTeamPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
|
||||
// ── Teams open for application ──────────────────────────────
|
||||
// The Prisma slice exposes teams directly (no separate team-position table
|
||||
// with position_kind), so the team application concept lists website_teams.
|
||||
// Hidden ranks are excluded from the public apply page.
|
||||
const teams = await prisma.websiteTeams
|
||||
.findMany({
|
||||
where: { hiddenRank: false },
|
||||
orderBy: { rankName: "asc" },
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
// Teams this user has already applied to. Team applications reuse the staff
|
||||
// applications table with rank_id carrying the team id (the team flag).
|
||||
const myApps = await prisma.websiteStaffApplications
|
||||
.findMany({ where: { userId }, select: { rankId: true } })
|
||||
.catch(() => []);
|
||||
const appliedTeamIds = new Set(myApps.map((a) => a.rankId));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.5rem" }}>Apply for the {hotelName} team</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
We open team applications periodically. If you see a team you fit, do not hesitate to
|
||||
apply!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{teams.length === 0 ? (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>No team positions open</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
There are currently no open team positions. Please come back later to check for new
|
||||
openings. Thank you!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{teams.map((team) => {
|
||||
const teamId = Number(team.id);
|
||||
const alreadyApplied = appliedTeamIds.has(teamId);
|
||||
return (
|
||||
<article key={String(team.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
alignItems: "center",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{team.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}/${team.badge}.gif`}
|
||||
alt={team.rankName}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: team.staffColor || undefined }}>
|
||||
{team.rankName}
|
||||
</h3>
|
||||
{team.jobDescription ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{team.jobDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{alreadyApplied ? (
|
||||
<button type="button" className="btn btn-danger" disabled style={{ width: "100%" }}>
|
||||
Your application is pending
|
||||
</button>
|
||||
) : (
|
||||
<form action={applyTeam}>
|
||||
{/* The team id is the application's rank flag; the applicant
|
||||
is re-read from the session inside the action. */}
|
||||
<input type="hidden" name="teamId" value={String(team.id)} />
|
||||
<label htmlFor={`content-${team.id}`} className="muted">
|
||||
About you
|
||||
</label>
|
||||
<textarea
|
||||
id={`content-${team.id}`}
|
||||
name="content"
|
||||
required
|
||||
minLength={10}
|
||||
rows={5}
|
||||
placeholder={`Tell us why you'd be a great fit for ${team.rankName}…`}
|
||||
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: "100%" }}>
|
||||
Apply for {team.rankName}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const FAR_FUTURE = Math.floor(Date.now() / 1000) + 50 * 365 * 24 * 3600;
|
||||
|
||||
export default async function BannedPage() {
|
||||
const session = await auth();
|
||||
let reason = "";
|
||||
let expire = 0;
|
||||
if (session?.user?.id) {
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const ban = await prisma.ban.findFirst({
|
||||
where: { userId: Number(session.user.id), banExpire: { gt: now } },
|
||||
orderBy: { banExpire: "desc" },
|
||||
select: { banReason: true, banExpire: true },
|
||||
});
|
||||
if (ban) {
|
||||
reason = ban.banReason;
|
||||
expire = ban.banExpire;
|
||||
}
|
||||
} catch {
|
||||
// show generic message
|
||||
}
|
||||
}
|
||||
|
||||
const expiryText =
|
||||
expire === 0
|
||||
? ""
|
||||
: expire > FAR_FUTURE
|
||||
? "This ban is permanent."
|
||||
: `Expires ${new Date(expire * 1000).toISOString().slice(0, 16).replace("T", " ")}.`;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 540, margin: "2rem auto" }}>
|
||||
<div className="card" style={{ padding: "1.75rem", textAlign: "center" }}>
|
||||
<h1 style={{ marginTop: 0, color: "var(--color-danger)" }}>You are banned</h1>
|
||||
<p>Your account has been suspended from the hotel.</p>
|
||||
{reason ? (
|
||||
<p>
|
||||
<strong>Reason:</strong> {reason}
|
||||
</p>
|
||||
) : null}
|
||||
{expiryText ? <p className="muted">{expiryText}</p> : null}
|
||||
<p className="muted">If you believe this is a mistake, contact the staff team.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -6,6 +6,7 @@ import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { ThemeVars } from "@/components/theme-vars";
|
||||
import { TopHeader } from "@/components/top-header";
|
||||
import { enforceSiteAccess } from "@/lib/access-guard";
|
||||
import "./globals.css";
|
||||
|
||||
const nunito = Nunito({
|
||||
@@ -20,7 +21,8 @@ export const metadata: Metadata = {
|
||||
description: "AtomCMS — retro hotel CMS (Next.js conversion)",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
await enforceSiteAccess();
|
||||
return (
|
||||
<html lang="en" className={`app ${nunito.variable}`}>
|
||||
<body
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MaintenancePage() {
|
||||
const [hotel, message] = await Promise.all([
|
||||
siteSettings.get("hotel_name", "Atom"),
|
||||
siteSettings.get(
|
||||
"maintenance_message",
|
||||
"We are currently performing maintenance. Please check back soon.",
|
||||
),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 540, margin: "2rem auto" }}>
|
||||
<div className="card" style={{ padding: "1.75rem", textAlign: "center" }}>
|
||||
<h1 style={{ marginTop: 0 }}>{hotel} is under maintenance 🛠️</h1>
|
||||
<p>{message}</p>
|
||||
<p className="muted">Staff can still log in.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { applyDj } from "@/actions/radio-apply";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RadioApplyPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
// Surface the applicant's most recent application (if any) so they aren't
|
||||
// left guessing. Isolated query: a DB hiccup degrades this to null, not 500.
|
||||
const existing = await prisma.radioApplications
|
||||
.findFirst({
|
||||
where: { userId: BigInt(userId) },
|
||||
orderBy: { id: "desc" },
|
||||
select: { id: true, status: true, createdAt: true },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
const submittedDate = existing?.createdAt
|
||||
? existing.createdAt.toISOString().slice(0, 10)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.25rem" }}>Apply to be a Radio DJ</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Tell us about yourself and why you'd be a great fit for the airwaves.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{existing ? (
|
||||
<div className="card" style={{ marginBottom: "1.25rem" }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
<strong>Your latest application:</strong> {existing.status}
|
||||
{submittedDate ? (
|
||||
<span className="muted"> · submitted {submittedDate}</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
||||
You can submit again below if your details have changed.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={applyDj} className="card">
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label htmlFor="realName" className="muted">
|
||||
Real name
|
||||
</label>
|
||||
<input
|
||||
id="realName"
|
||||
name="realName"
|
||||
type="text"
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Your full name"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="age" className="muted">
|
||||
Age
|
||||
</label>
|
||||
<input
|
||||
id="age"
|
||||
name="age"
|
||||
type="number"
|
||||
required
|
||||
min={13}
|
||||
max={120}
|
||||
placeholder="18"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="availability" className="muted">
|
||||
Availability
|
||||
</label>
|
||||
<textarea
|
||||
id="availability"
|
||||
name="availability"
|
||||
required
|
||||
rows={3}
|
||||
placeholder="Which days and times can you broadcast?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="musicStyle" className="muted">
|
||||
Music style
|
||||
</label>
|
||||
<input
|
||||
id="musicStyle"
|
||||
name="musicStyle"
|
||||
type="text"
|
||||
maxLength={255}
|
||||
placeholder="e.g. House, Pop, Hip-Hop"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="experience" className="muted">
|
||||
Experience <span className="muted">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="experience"
|
||||
name="experience"
|
||||
rows={3}
|
||||
placeholder="Any prior DJ or hosting experience?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="motivation" className="muted">
|
||||
Motivation
|
||||
</label>
|
||||
<textarea
|
||||
id="motivation"
|
||||
name="motivation"
|
||||
required
|
||||
rows={4}
|
||||
placeholder="Why do you want to join the radio team?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" style={{ marginTop: "1.25rem" }}>
|
||||
Submit application
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
|
||||
}
|
||||
|
||||
export default async function RadioContestDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
// Route param is a string; RadioContests.id is a BigInt. Guard against a
|
||||
// non-numeric id before constructing the BigInt (would otherwise throw).
|
||||
let contestId: bigint;
|
||||
try {
|
||||
contestId = BigInt(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const contest = await prisma.radioContests
|
||||
.findUnique({ where: { id: contestId! } })
|
||||
.catch(() => null);
|
||||
|
||||
if (!contest) notFound();
|
||||
|
||||
const contestIdStr = contest.id.toString();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted" style={{ margin: "0 0 0.5rem" }}>
|
||||
<Link href="/radio/contests">← Back to contests</Link>
|
||||
</p>
|
||||
|
||||
<div className="card">
|
||||
<h1 style={{ margin: "0 0 0.5rem" }}>Contest #{contestIdStr}</h1>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<td>{formatDate(contest.createdAt) || <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Updated</th>
|
||||
<td>{formatDate(contest.updatedAt) || <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "";
|
||||
}
|
||||
|
||||
export default async function RadioContestsPage() {
|
||||
// RadioContests.id is a BigInt — stringify before use in keys/routes.
|
||||
const contests = await prisma.radioContests
|
||||
.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: 0 }}>Radio Contests</h1>
|
||||
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
|
||||
Tune in and take part in our latest on-air contests.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{contests.length === 0 ? (
|
||||
<p className="muted">No contests yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{contests.map((c) => {
|
||||
const id = c.id.toString();
|
||||
return (
|
||||
<article key={id} className="card hover">
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>
|
||||
<Link href={`/radio/contests/${id}`}>Contest #{id}</Link>
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(c.createdAt) || "Date unknown"}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
|
||||
}
|
||||
|
||||
export default async function RadioGiveawayDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
// Route param is a string; RadioGiveaways.id is a BigInt. Guard against a
|
||||
// non-numeric id before constructing the BigInt (would otherwise throw).
|
||||
let giveawayId: bigint;
|
||||
try {
|
||||
giveawayId = BigInt(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const giveaway = await prisma.radioGiveaways
|
||||
.findUnique({ where: { id: giveawayId! } })
|
||||
.catch(() => null);
|
||||
|
||||
if (!giveaway) notFound();
|
||||
|
||||
const giveawayIdStr = giveaway.id.toString();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted" style={{ margin: "0 0 0.5rem" }}>
|
||||
<Link href="/radio/giveaways">← Back to giveaways</Link>
|
||||
</p>
|
||||
|
||||
<div className="card">
|
||||
<h1 style={{ margin: "0 0 0.5rem" }}>Giveaway #{giveawayIdStr}</h1>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<td>{formatDate(giveaway.createdAt) || <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Updated</th>
|
||||
<td>{formatDate(giveaway.updatedAt) || <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "";
|
||||
}
|
||||
|
||||
export default async function RadioGiveawaysPage() {
|
||||
// RadioGiveaways.id is a BigInt — stringify before use in keys/routes.
|
||||
const giveaways = await prisma.radioGiveaways
|
||||
.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: 0 }}>Radio Giveaways</h1>
|
||||
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
|
||||
Listen in for your chance to win in our radio giveaways.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{giveaways.length === 0 ? (
|
||||
<p className="muted">No giveaways yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{giveaways.map((g) => {
|
||||
const id = g.id.toString();
|
||||
return (
|
||||
<article key={id} className="card hover">
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>
|
||||
<Link href={`/radio/giveaways/${id}`}>Giveaway #{id}</Link>
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(g.createdAt) || "Date unknown"}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Link from "next/link";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// The radio_listener_points table in the live AtomCMS DB carries user_id +
|
||||
// points columns, but the generated Prisma model is a stub (id/timestamps only),
|
||||
// so it has no `points`/`userId` delegate fields. We therefore read it via
|
||||
// $queryRaw, joining users for the username/look — the same approach used for
|
||||
// the keyless/@@ignore'd emulator tables (see src/app/admin/logs). MySQL may
|
||||
// return BigInt columns as bigint, so values are coerced defensively.
|
||||
type LeaderRow = {
|
||||
username: string;
|
||||
look: string;
|
||||
points: number | bigint;
|
||||
};
|
||||
|
||||
async function loadRows(): Promise<{ username: string; look: string; points: number }[]> {
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<LeaderRow[]>`
|
||||
SELECT u.username AS username, u.look AS look, p.points AS points
|
||||
FROM radio_listener_points p
|
||||
INNER JOIN users u ON u.id = p.user_id
|
||||
ORDER BY p.points DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
return rows.map((r) => ({
|
||||
username: r.username,
|
||||
look: r.look,
|
||||
points: Number(r.points),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RadioLeaderboardPage() {
|
||||
const [rows, imagerBase] = await Promise.all([
|
||||
loadRows(),
|
||||
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Listener Leaderboard</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Top 50 listeners by points earned tuning in to the radio.
|
||||
</p>
|
||||
|
||||
<div className="card">
|
||||
{rows.length === 0 ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
No listener points to show yet.
|
||||
</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "3rem" }}>#</th>
|
||||
<th style={{ width: "3.5rem" }}>Avatar</th>
|
||||
<th>Listener</th>
|
||||
<th style={{ textAlign: "right" }}>Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const avatar = avatarImageUrl(imagerBase ?? "", row.look, {
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
});
|
||||
return (
|
||||
<tr key={`${row.username}-${i}`}>
|
||||
<td style={{ fontWeight: 800 }}>{i + 1}</td>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="avatar"
|
||||
src={avatar}
|
||||
alt={`${row.username} avatar`}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/u/${encodeURIComponent(row.username)}`}
|
||||
style={{ fontWeight: 700 }}
|
||||
>
|
||||
{row.username}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ textAlign: "right", fontWeight: 800 }}>
|
||||
{row.points.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Radio config + today's schedule are read live from the DB / settings table.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Sub-pages linked from the radio home hub.
|
||||
const RADIO_LINKS: { label: string; href: string }[] = [
|
||||
{ label: "Schedule", href: "/radio/schedule" },
|
||||
{ label: "Shouts", href: "/radio/shouts" },
|
||||
{ label: "Requests", href: "/radio/requests" },
|
||||
{ label: "Apply as DJ", href: "/radio/apply" },
|
||||
{ label: "Leaderboard", href: "/radio/leaderboard" },
|
||||
];
|
||||
|
||||
// Canonical weekday order so today's slots come out chronologically and we can
|
||||
// match the current day against whatever casing the `day` column stores.
|
||||
const WEEKDAYS = [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
];
|
||||
|
||||
// radio_schedules.start_time / end_time are stored as TIME and surface through
|
||||
// Prisma as a Date with the time component on a 1970 epoch date — render just
|
||||
// the HH:MM portion in UTC to avoid timezone drift.
|
||||
function formatTime(d: Date | null | undefined): string {
|
||||
if (!d) return "";
|
||||
return d.toISOString().slice(11, 16);
|
||||
}
|
||||
|
||||
export default async function RadioPage() {
|
||||
// ── Radio config from website_settings (graceful fallbacks) ──
|
||||
const [streamUrl, currentDj, currentSong] = await Promise.all([
|
||||
siteSettings.get("radio_stream_url", ""),
|
||||
siteSettings.get("radio_current_dj", ""),
|
||||
siteSettings.get("radio_current_song", ""),
|
||||
]);
|
||||
|
||||
// ── Today's schedule (isolated; a DB hiccup degrades to empty) ──
|
||||
const today = WEEKDAYS[new Date().getUTCDay()] ?? "";
|
||||
const schedules = await prisma.radioSchedules
|
||||
.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { startTime: "asc" },
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
const todaysSlots = schedules.filter(
|
||||
(s) => s.day.toLowerCase() === today.toLowerCase(),
|
||||
);
|
||||
|
||||
// Resolve DJ usernames for today's slots in one query. radio_schedules.user_id
|
||||
// is an UnsignedBigInt while users.id is an Int, so narrow to Number for the
|
||||
// lookup (DJ ids comfortably fit in a JS-safe integer).
|
||||
const djIds = Array.from(new Set(todaysSlots.map((s) => Number(s.userId))));
|
||||
const djs = djIds.length
|
||||
? await prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: djIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const djById = new Map(djs.map((d) => [d.id, d.username]));
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* ── Hero: live stream + now playing ─────────────────────── */}
|
||||
<div className="hero">
|
||||
<h1 style={{ marginTop: 0 }}>Radio</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Tune in to the hotel radio — live DJ sets, requests and shoutouts.
|
||||
</p>
|
||||
|
||||
<div className="grid cols-2" style={{ marginTop: "1rem" }}>
|
||||
<div>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>Now playing</strong>
|
||||
</p>
|
||||
<p style={{ margin: "0 0 0.5rem", fontSize: "1.1rem" }}>
|
||||
{currentSong ? currentSong : <span className="muted">Nothing playing right now.</span>}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{currentDj ? `On air: ${currentDj}` : "No DJ on air."}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{streamUrl ? (
|
||||
<audio
|
||||
controls
|
||||
src={streamUrl}
|
||||
style={{ width: "100%" }}
|
||||
preload="none"
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
) : (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
The stream is currently offline.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Sub-page links ──────────────────────────────────────── */}
|
||||
<section style={{ marginBottom: "1.5rem" }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem" }}>
|
||||
{RADIO_LINKS.map((l) => (
|
||||
<Link key={l.href} href={l.href} className="btn btn-outline">
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Today's schedule ────────────────────────────────────── */}
|
||||
<section>
|
||||
<h2>Today’s schedule{today ? ` · ${today}` : ""}</h2>
|
||||
{todaysSlots.length === 0 ? (
|
||||
<p className="muted">No shows scheduled for today.</p>
|
||||
) : (
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Show</th>
|
||||
<th>DJ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{todaysSlots.map((s) => {
|
||||
const djName = djById.get(Number(s.userId));
|
||||
return (
|
||||
<tr key={String(s.id)}>
|
||||
<td>
|
||||
{formatTime(s.startTime)} – {formatTime(s.endTime)}
|
||||
</td>
|
||||
<td>
|
||||
{s.showName || <span className="muted">Untitled show</span>}
|
||||
</td>
|
||||
<td>
|
||||
{djName ? (
|
||||
<Link href={`/u/${djName}`}>{djName}</Link>
|
||||
) : (
|
||||
<span className="muted">{`User #${s.userId}`}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Weekly order so the table reads Monday → Sunday regardless of insert order.
|
||||
// The `day` column stores a free-form string; we key case-insensitively.
|
||||
const DAYS = [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
] as const;
|
||||
|
||||
// `start_time` / `end_time` are MySQL TIME columns; Prisma hydrates them as a
|
||||
// Date on the 1970-01-01 epoch. Pull just HH:MM in UTC (Time has no zone).
|
||||
function formatTime(d: Date | null | undefined): string {
|
||||
if (!d) return "";
|
||||
return d.toISOString().slice(11, 16);
|
||||
}
|
||||
|
||||
type Slot = {
|
||||
id: string;
|
||||
dj: string;
|
||||
showName: string | null;
|
||||
description: string | null;
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
|
||||
export default async function RadioSchedulePage() {
|
||||
const schedules = await prisma.radioSchedules
|
||||
.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { startTime: "asc" },
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
// Resolve DJ usernames. schedule.userId is BigInt; User.id is Int — convert.
|
||||
const djIds = Array.from(new Set(schedules.map((s) => Number(s.userId))));
|
||||
const djs = djIds.length
|
||||
? await prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: djIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const djById = new Map(djs.map((u) => [u.id, u.username]));
|
||||
|
||||
// Bucket slots by normalised day name.
|
||||
const byDay = new Map<string, Slot[]>();
|
||||
for (const day of DAYS) byDay.set(day, []);
|
||||
for (const s of schedules) {
|
||||
const key =
|
||||
DAYS.find((d) => d.toLowerCase() === s.day.trim().toLowerCase()) ?? s.day;
|
||||
const slot: Slot = {
|
||||
id: String(s.id),
|
||||
dj: djById.get(Number(s.userId)) ?? `User #${s.userId}`,
|
||||
showName: s.showName,
|
||||
description: s.description,
|
||||
start: formatTime(s.startTime),
|
||||
end: formatTime(s.endTime),
|
||||
};
|
||||
const bucket = byDay.get(key);
|
||||
if (bucket) bucket.push(slot);
|
||||
else byDay.set(key, [slot]);
|
||||
}
|
||||
|
||||
const hasAny = schedules.length > 0;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Schedule</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Our weekly line-up of DJs and shows.
|
||||
</p>
|
||||
|
||||
{!hasAny ? (
|
||||
<p className="muted">No shows scheduled yet.</p>
|
||||
) : (
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Day</th>
|
||||
<th>Time</th>
|
||||
<th>DJ</th>
|
||||
<th>Show</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DAYS.map((day) => {
|
||||
const slots = byDay.get(day) ?? [];
|
||||
if (slots.length === 0) {
|
||||
return (
|
||||
<tr key={day}>
|
||||
<td>
|
||||
<strong>{day}</strong>
|
||||
</td>
|
||||
<td colSpan={3} className="muted">
|
||||
No shows
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return slots.map((slot, i) => (
|
||||
<tr key={slot.id}>
|
||||
<td>{i === 0 ? <strong>{day}</strong> : ""}</td>
|
||||
<td>
|
||||
{slot.start}
|
||||
{slot.end ? ` – ${slot.end}` : ""}
|
||||
</td>
|
||||
<td>{slot.dj}</td>
|
||||
<td>
|
||||
{slot.showName || <span className="muted">—</span>}
|
||||
{slot.description ? (
|
||||
<div className="muted">{slot.description}</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { postShout } from "@/actions/radio-shouts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
|
||||
}
|
||||
|
||||
export default async function RadioShoutsPage() {
|
||||
const [shouts, imagerBase, session] = await Promise.all([
|
||||
prisma.radioShouts
|
||||
.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
})
|
||||
.catch(() => []),
|
||||
siteSettings.get(
|
||||
"habbo_imaging_url",
|
||||
"https://www.habbo.com/habbo-imaging/avatarimage",
|
||||
),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
const isLoggedIn = Boolean(session?.user?.id);
|
||||
|
||||
// Resolve author usernames/looks. shout.userId is BigInt; User.id is Int.
|
||||
const authorIds = Array.from(new Set(shouts.map((s) => Number(s.userId))));
|
||||
const authors = authorIds.length
|
||||
? await prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: authorIds } },
|
||||
select: { id: true, username: true, look: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Shouts</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Shout-outs and requests from the community.
|
||||
</p>
|
||||
|
||||
{isLoggedIn ? (
|
||||
<form action={postShout} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
{/* The author is taken from the session inside the action, never from
|
||||
this form — no user id is submitted. */}
|
||||
<label htmlFor="shout-message" className="muted">
|
||||
Post a shout
|
||||
</label>
|
||||
<textarea
|
||||
id="shout-message"
|
||||
name="message"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder="Send a shout to the DJ…"
|
||||
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Shout
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted">Log in to post a shout.</p>
|
||||
)}
|
||||
|
||||
{shouts.length === 0 ? (
|
||||
<p className="muted">No shouts yet. Be the first!</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{shouts.map((s) => {
|
||||
const author = authorById.get(Number(s.userId));
|
||||
const authorAvatar = author
|
||||
? avatarImageUrl(imagerBase ?? "", author.look, {
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={String(s.id)}
|
||||
className="card"
|
||||
style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}
|
||||
>
|
||||
{authorAvatar ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
className="avatar"
|
||||
src={authorAvatar}
|
||||
alt={author?.username ?? "author"}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "}
|
||||
<span className="muted">{formatDate(s.createdAt)}</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{s.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { env } from "@/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Social providers we can link. `available` reflects whether the OAuth app
|
||||
// credentials are configured (mirrors src/lib/auth.ts, which only registers a
|
||||
// provider when both its id + secret env vars are set). When unavailable the
|
||||
// connect button is disabled so we don't bounce the user to a 404 sign-in URL.
|
||||
const PROVIDERS = [
|
||||
{
|
||||
key: "discord",
|
||||
label: "Discord",
|
||||
available: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
|
||||
},
|
||||
{
|
||||
key: "google",
|
||||
label: "Google",
|
||||
available: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default async function ConnectionsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const id = Number(session.user.id);
|
||||
|
||||
// social_accounts is a CMS-only table that may not exist in every install
|
||||
// (it has no bundled migration). Fall back to "nothing linked" on any error so
|
||||
// the page still renders the generic connect buttons.
|
||||
let linked: Set<string> = new Set();
|
||||
let lookupFailed = false;
|
||||
try {
|
||||
const rows = await prisma.socialAccounts.findMany({
|
||||
where: { userId: BigInt(id) },
|
||||
select: { provider: true },
|
||||
});
|
||||
linked = new Set(rows.map((r) => r.provider.toLowerCase()));
|
||||
} catch {
|
||||
lookupFailed = true;
|
||||
}
|
||||
|
||||
// After connecting/erroring, NextAuth bounces back here so we land on a known page.
|
||||
const callbackUrl = "/settings/connections";
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 560 }}>
|
||||
<p className="muted">
|
||||
<Link href="/settings">← Settings</Link>
|
||||
</p>
|
||||
<h1>Connected accounts</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Link a social account so you can sign in with one click. We match it to
|
||||
your hotel account by e-mail, so use the address on file:{" "}
|
||||
<strong>{session.user.name}</strong>.
|
||||
</p>
|
||||
|
||||
{lookupFailed ? (
|
||||
<div className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
We couldn't read your linked accounts right now. You can still connect
|
||||
below — your status will show once it's available.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid" style={{ gap: "1rem" }}>
|
||||
{PROVIDERS.map((p) => {
|
||||
const isLinked = linked.has(p.key);
|
||||
return (
|
||||
<div
|
||||
key={p.key}
|
||||
className="card"
|
||||
style={{ display: "flex", alignItems: "center", gap: "1rem" }}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>{p.label}</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{!p.available
|
||||
? "Not available on this hotel."
|
||||
: isLinked
|
||||
? "Connected — you can sign in with this account."
|
||||
: "Not connected."}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{isLinked ? (
|
||||
<span
|
||||
className="btn btn-outline"
|
||||
style={{ cursor: "default", pointerEvents: "none" }}
|
||||
aria-disabled="true"
|
||||
>
|
||||
Connected
|
||||
</span>
|
||||
) : p.available ? (
|
||||
// Plain link into NextAuth's built-in sign-in handler. Re-using
|
||||
// the same OAuth flow as /login binds the provider to the
|
||||
// matching hotel account (see auth.ts signIn callback).
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={`/api/auth/signin/${p.key}?callbackUrl=${encodeURIComponent(
|
||||
callbackUrl,
|
||||
)}`}
|
||||
>
|
||||
Connect {p.label}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
className="btn"
|
||||
style={{ cursor: "not-allowed", opacity: 0.55, pointerEvents: "none" }}
|
||||
aria-disabled="true"
|
||||
>
|
||||
Unavailable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="muted" style={{ marginTop: "1.5rem", marginBottom: 0 }}>
|
||||
Connecting takes you to the provider to authorise, then back here. If your
|
||||
social account's e-mail doesn't match a hotel account, you'll be returned
|
||||
to the login screen with a note.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -92,6 +92,16 @@ export default async function SettingsPage() {
|
||||
Two-factor authentication
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: 520, marginTop: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Connected accounts</h3>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Link Discord or Google to sign in with one click.
|
||||
</p>
|
||||
<Link href="/settings/connections" className="btn btn-outline">
|
||||
Manage connections
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Client form for /shop/topup. Posts the chosen amount to /api/paypal/create,
|
||||
* then redirects the browser to PayPal's approval URL. After PayPal returns to
|
||||
* /shop/topup?token=<orderId>&status=success, it captures via /api/paypal/capture.
|
||||
*/
|
||||
export default function TopUpForm({
|
||||
currency,
|
||||
creditsPerUnit,
|
||||
}: {
|
||||
currency: string;
|
||||
creditsPerUnit: number;
|
||||
}) {
|
||||
const [amount, setAmount] = useState("5.00");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const numeric = Number(amount);
|
||||
const credits = Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric * creditsPerUnit) : 0;
|
||||
|
||||
async function startCheckout(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
const res = await fetch("/api/paypal/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ amount: numeric }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
approveUrl?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok || !data.approveUrl) {
|
||||
setError(data.error ?? "Could not start the PayPal checkout.");
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
// Hand off to PayPal for approval.
|
||||
window.location.href = data.approveUrl;
|
||||
} catch {
|
||||
setError("Network error — please try again.");
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function capture(orderId: string) {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/paypal/capture", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderId }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
ok?: boolean;
|
||||
credits?: number;
|
||||
error?: string;
|
||||
};
|
||||
if (res.ok && data.ok) {
|
||||
setSuccess(
|
||||
`Payment complete! ${(data.credits ?? 0).toLocaleString()} credits were added.`,
|
||||
);
|
||||
} else {
|
||||
setError(data.error ?? "We couldn't confirm your payment. Contact staff if charged.");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error confirming payment — contact staff if you were charged.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
// Clean the token/status query so a refresh doesn't re-capture.
|
||||
window.history.replaceState(null, "", "/shop/topup");
|
||||
}
|
||||
}
|
||||
|
||||
// On return from PayPal (?token=<orderId>&status=success), auto-capture once.
|
||||
if (typeof window !== "undefined" && !pending && !success) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get("token");
|
||||
if (token && params.get("status") === "success") {
|
||||
// Defer to avoid setState during render.
|
||||
queueMicrotask(() => capture(token));
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{ margin: 0, fontWeight: 700, color: "var(--color-accent)" }}
|
||||
>
|
||||
{success}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={startCheckout}
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}
|
||||
>
|
||||
<label htmlFor="amount" style={{ fontWeight: 700 }}>
|
||||
Amount ({currency})
|
||||
</label>
|
||||
<input
|
||||
id="amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
step={0.01}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-label={`Amount in ${currency}`}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
{["2.00", "5.00", "10.00", "25.00"].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={`btn ${amount === v ? "btn-primary" : "btn-outline"}`}
|
||||
onClick={() => setAmount(v)}
|
||||
>
|
||||
{currency} {v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
You'll receive{" "}
|
||||
<strong>{credits.toLocaleString()}</strong> credits.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<button type="submit" className="btn btn-primary" disabled={pending || credits <= 0}>
|
||||
{pending ? "Redirecting…" : "Pay with PayPal"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
style={{ margin: 0, fontWeight: 700, color: "var(--color-danger)" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import TopUpForm from "./TopUpForm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { creditsPerUnit, isPayPalConfigured, PAYPAL_CURRENCY } from "@/lib/services/paypal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TopUpPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ status?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const sp = await searchParams;
|
||||
const id = Number(session.user.id);
|
||||
|
||||
// Current credits balance for context (fail soft).
|
||||
let user: { username: string; credits: number } | null = null;
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { username: true, credits: true },
|
||||
});
|
||||
} catch {
|
||||
user = null;
|
||||
}
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const configured = isPayPalConfigured();
|
||||
const rate = creditsPerUnit();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ marginBottom: "0.25rem" }}>Top up with PayPal</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Pay securely with PayPal and your credits are added the moment the payment clears.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sp.status === "cancel" ? (
|
||||
<div className="card" style={{ marginBottom: "1.5rem", borderColor: "var(--color-danger)" }}>
|
||||
<p style={{ margin: 0 }}>Checkout was cancelled — no payment was taken.</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Choose an amount</h3>
|
||||
{configured ? (
|
||||
<TopUpForm currency={PAYPAL_CURRENCY} creditsPerUnit={rate} />
|
||||
) : (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
PayPal isn't configured yet. An administrator needs to set{" "}
|
||||
<code>PAYPAL_CLIENT_ID</code> and <code>PAYPAL_SECRET</code> (and optionally{" "}
|
||||
<code>PAYPAL_API</code> for sandbox vs. live).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Your balance</h3>
|
||||
<p style={{ margin: "0 0 0.6rem" }}>
|
||||
Signed in as <strong>{user.username}</strong>.
|
||||
</p>
|
||||
<span className="currency">
|
||||
<span className="coin credits">cr</span>
|
||||
{user.credits.toLocaleString()}
|
||||
</span>
|
||||
<p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}>
|
||||
You receive <strong>{rate.toLocaleString()}</strong> credits per 1.00 {PAYPAL_CURRENCY}.
|
||||
Credits appear in-game the next time you log in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user