feat: join_requests table and join CTA flow
This commit is contained in:
parent
fa5e6d8414
commit
4bed3a5fe0
4 changed files with 112 additions and 2 deletions
5
migrations/0002_join_requests.sql
Normal file
5
migrations/0002_join_requests.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
CREATE TABLE IF NOT EXISTS join_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
|
@ -329,3 +329,33 @@ export function getAllAttendance(meetingSlug: string): { user_id: number; status
|
|||
WHERE a.meeting_slug = ?
|
||||
`).all(meetingSlug) as { user_id: number; status: AttendanceStatus; name: string }[];
|
||||
}
|
||||
|
||||
// ── Join requests ────────────────────────────────────────────────
|
||||
|
||||
export function createJoinRequest(userId: number): void {
|
||||
db.prepare('INSERT OR IGNORE INTO join_requests (user_id) VALUES (?)').run(userId);
|
||||
}
|
||||
|
||||
export function hasJoinRequest(userId: number): boolean {
|
||||
const r = db.prepare('SELECT id FROM join_requests WHERE user_id = ?').get(userId);
|
||||
return r !== undefined;
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
id: number;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
user_name: string;
|
||||
user_email: string;
|
||||
user_organisation: string;
|
||||
}
|
||||
|
||||
export function getAllJoinRequests(): JoinRequest[] {
|
||||
return db.prepare(`
|
||||
SELECT jr.id, jr.user_id, jr.created_at,
|
||||
u.name AS user_name, u.email AS user_email, u.organisation AS user_organisation
|
||||
FROM join_requests jr
|
||||
JOIN users u ON u.id = jr.user_id
|
||||
ORDER BY jr.created_at DESC
|
||||
`).all() as JoinRequest[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import AppLayout from '../../layouts/AppLayout.astro';
|
|||
import {
|
||||
getAllInvites, getAllUsersPublic, revokeInvite,
|
||||
createInvite, updateUserRole, deactivateUser,
|
||||
getAllJoinRequests,
|
||||
} from '../../lib/db';
|
||||
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
|
||||
import { fmtDate } from '../../lib/markdown';
|
||||
|
|
@ -64,8 +65,9 @@ if (Astro.request.method === 'POST') {
|
|||
}
|
||||
}
|
||||
|
||||
const invites = getAllInvites();
|
||||
const users = getAllUsersPublic();
|
||||
const invites = getAllInvites();
|
||||
const users = getAllUsersPublic();
|
||||
const joinRequests = getAllJoinRequests();
|
||||
actionMsg = Astro.url.searchParams.get('msg');
|
||||
---
|
||||
<AppLayout title="Admin" user={user}>
|
||||
|
|
@ -86,6 +88,12 @@ actionMsg = Astro.url.searchParams.get('msg');
|
|||
href="/admin?tab=participants"
|
||||
class:list={['tab label-sm', { active: tab === 'participants' }]}
|
||||
>Participants</a>
|
||||
<a
|
||||
href="/admin?tab=join"
|
||||
class:list={['tab label-sm', { active: tab === 'join' }]}
|
||||
>
|
||||
Join requests {joinRequests.length > 0 && <span class="tab-count">{joinRequests.length}</span>}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{actionMsg && (
|
||||
|
|
@ -255,6 +263,43 @@ actionMsg = Astro.url.searchParams.get('msg');
|
|||
</div>
|
||||
)}
|
||||
|
||||
<!-- Join requests tab -->
|
||||
{tab === 'join' && (
|
||||
<div class="tab-content">
|
||||
<section class="section">
|
||||
<h2 class="label-sm section-heading">Join requests</h2>
|
||||
<p class="body-sm section-note">
|
||||
Users who clicked "I want to join" on the home page. Use this to prioritise
|
||||
follow-up and generate invite links.
|
||||
</p>
|
||||
{joinRequests.length === 0 ? (
|
||||
<p class="body-sm empty-msg">No join requests yet.</p>
|
||||
) : (
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="label-sm">Name</th>
|
||||
<th class="label-sm">Email</th>
|
||||
<th class="label-sm">Organisation</th>
|
||||
<th class="label-sm">Requested</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{joinRequests.map((jr) => (
|
||||
<tr>
|
||||
<td class="body-sm">{jr.user_name}</td>
|
||||
<td class="body-sm">{jr.user_email}</td>
|
||||
<td class="body-sm">{jr.user_organisation}</td>
|
||||
<td class="body-sm muted">{fmtDate(jr.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
|
|
@ -303,6 +348,27 @@ actionMsg = Astro.url.searchParams.get('msg');
|
|||
.tab:hover { color: var(--on-surface-variant); background: var(--surface-container-low); border-bottom: none; }
|
||||
.tab.active { color: var(--on-surface); background: var(--surface-container); }
|
||||
|
||||
.tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--secondary);
|
||||
color: var(--on-secondary);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-label-sm);
|
||||
font-weight: 700;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 var(--space-1);
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
.section-note {
|
||||
color: var(--on-surface-muted);
|
||||
margin: 0;
|
||||
max-width: var(--reading-max);
|
||||
}
|
||||
|
||||
/* ── Messages ────────────────────────────────────────────────────── */
|
||||
.action-msg {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
|
|
|
|||
9
src/pages/api/join.ts
Normal file
9
src/pages/api/join.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { createJoinRequest } from '../../lib/db';
|
||||
|
||||
export const POST: APIRoute = ({ locals }) => {
|
||||
createJoinRequest(locals.user.id);
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue