Add register + logout auth flows, and batch-5 resources
Auth (hand-built, uses the auth core): - /register + register() action: validates, hashes with argon2id (hashPassword), creates an emulator-compatible users row (accountCreated/ipRegister/ipCurrent/ look), redirect kept outside try so NEXT_REDIRECT propagates. Login/register cross-links; header shows Register (logged-out) and a Logout (signOut) button. Batch 5 (parallel agents): /admin/rooms (+[id]) search+detail, /admin/vouchers (CRUD), /admin/subscriptions (read), /admin/calendar (campaigns+rewards read), /marketplace (public). Header + admin nav extended. Verified: tsc exit 0, vitest 48/48, next build exit 0 (52 page routes).
This commit is contained in:
1 parent
e8be0461d8
commit
08a9882be8
13 files changed
+1001
-4
No files matched your search
@@ -0,0 +1,133 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// state == 1 → an active, unsold offer in the Arcturus marketplace.
|
||||
const STATE_ACTIVE = 1;
|
||||
|
||||
function listedAgo(ts: number): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = Math.max(0, now - ts);
|
||||
if (diff < 60) return "just now";
|
||||
const mins = Math.floor(diff / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function listedAt(ts: number): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function MarketplacePage() {
|
||||
let offers: {
|
||||
id: number;
|
||||
itemId: number;
|
||||
userId: number;
|
||||
price: number;
|
||||
timestamp: number;
|
||||
}[] = [];
|
||||
try {
|
||||
offers = await prisma.marketplaceItems.findMany({
|
||||
where: { state: STATE_ACTIVE },
|
||||
select: { id: true, itemId: true, userId: true, price: true, timestamp: true },
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
} catch {
|
||||
offers = [];
|
||||
}
|
||||
|
||||
// Resolve seller usernames in a single query (read-only display join).
|
||||
let sellers = new Map<number, string>();
|
||||
try {
|
||||
const ids = [...new Set(offers.map((o) => o.userId))];
|
||||
if (ids.length > 0) {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
sellers = new Map(users.map((u) => [u.id, u.username]));
|
||||
}
|
||||
} catch {
|
||||
sellers = new Map();
|
||||
}
|
||||
|
||||
const total = offers.reduce((sum, o) => sum + o.price, 0);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="hero">
|
||||
<h1 style={{ marginTop: 0 }}>Marketplace</h1>
|
||||
<p className="muted" style={{ marginBottom: 0 }}>
|
||||
Browse furniture and rares listed by players. Listings are placed in-game
|
||||
and update live — head into the hotel to make an offer.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="grid cols-3" style={{ marginBottom: "1.5rem" }}>
|
||||
<div className="card">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Active offers
|
||||
</p>
|
||||
<p style={{ fontSize: "1.6rem", fontWeight: 800, margin: "0.15rem 0 0" }}>
|
||||
{offers.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Sellers
|
||||
</p>
|
||||
<p style={{ fontSize: "1.6rem", fontWeight: 800, margin: "0.15rem 0 0" }}>
|
||||
{new Set(offers.map((o) => o.userId)).size}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Total value
|
||||
</p>
|
||||
<p style={{ fontSize: "1.6rem", fontWeight: 800, margin: "0.15rem 0 0" }}>
|
||||
{total.toLocaleString()} cr
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{offers.length === 0 ? (
|
||||
<div className="card">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
There are no active marketplace offers right now. Check back later.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card" style={{ padding: "0.5rem 1.25rem 0.75rem" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Offer</th>
|
||||
<th>Item</th>
|
||||
<th>Seller</th>
|
||||
<th>Price</th>
|
||||
<th>Listed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{offers.map((o) => (
|
||||
<tr key={o.id}>
|
||||
<td className="muted">#{o.id}</td>
|
||||
<td>Item #{o.itemId}</td>
|
||||
<td>{sellers.get(o.userId) ?? `User #${o.userId}`}</td>
|
||||
<td style={{ fontWeight: 700 }}>{o.price.toLocaleString()} cr</td>
|
||||
<td className="muted" title={listedAt(o.timestamp)}>
|
||||
{listedAgo(o.timestamp)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user