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 = ?
|
WHERE a.meeting_slug = ?
|
||||||
`).all(meetingSlug) as { user_id: number; status: AttendanceStatus; name: string }[];
|
`).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 {
|
import {
|
||||||
getAllInvites, getAllUsersPublic, revokeInvite,
|
getAllInvites, getAllUsersPublic, revokeInvite,
|
||||||
createInvite, updateUserRole, deactivateUser,
|
createInvite, updateUserRole, deactivateUser,
|
||||||
|
getAllJoinRequests,
|
||||||
} from '../../lib/db';
|
} from '../../lib/db';
|
||||||
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
|
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
|
||||||
import { fmtDate } from '../../lib/markdown';
|
import { fmtDate } from '../../lib/markdown';
|
||||||
|
|
@ -66,6 +67,7 @@ if (Astro.request.method === 'POST') {
|
||||||
|
|
||||||
const invites = getAllInvites();
|
const invites = getAllInvites();
|
||||||
const users = getAllUsersPublic();
|
const users = getAllUsersPublic();
|
||||||
|
const joinRequests = getAllJoinRequests();
|
||||||
actionMsg = Astro.url.searchParams.get('msg');
|
actionMsg = Astro.url.searchParams.get('msg');
|
||||||
---
|
---
|
||||||
<AppLayout title="Admin" user={user}>
|
<AppLayout title="Admin" user={user}>
|
||||||
|
|
@ -86,6 +88,12 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
href="/admin?tab=participants"
|
href="/admin?tab=participants"
|
||||||
class:list={['tab label-sm', { active: tab === 'participants' }]}
|
class:list={['tab label-sm', { active: tab === 'participants' }]}
|
||||||
>Participants</a>
|
>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>
|
</div>
|
||||||
|
|
||||||
{actionMsg && (
|
{actionMsg && (
|
||||||
|
|
@ -255,6 +263,43 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</AppLayout>
|
</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: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.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 ────────────────────────────────────────────────────── */
|
/* ── Messages ────────────────────────────────────────────────────── */
|
||||||
.action-msg {
|
.action-msg {
|
||||||
padding: var(--space-3) var(--space-4);
|
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