feat: add Event Board to the Neighbourhood community board + dummy content
Researched mosque-app and community-board best practices (MadinaAPPS,
Nextdoor) before building: the pattern that works is one-tap RSVP with
what/who/where/when up front, plus a clean separation between plain
announcements and dated events. Deliberately did NOT auto-populate a
global Islamic calendar (Eid/Ramadan dates) — those are moon-sighting-
dependent and hardcoding one as fact would be exactly the kind of
fabrication this app avoids everywhere else (real Overpass data, real
Quran API, no invented listings). The Event Board is community-submitted
only: a mosque posting its own Eid prayer time is real information: this
app guessing the date isn't.
Implementation extends the existing Neighbourhood board rather than
building a parallel system: nf_neighbourhood_posts gains is_event/
event_date/event_location, plus a new nf_event_rsvps table (RLS reuses
the existing nf_is_neighbourhood_member helper via a join, no duplicated
logic). UI adds an event toggle on the post form (reveals date/time/
location), a 📅-badged event card with one-tap 'I'm going' RSVP + live
count, and an All/Events/Announcements filter with events sorted
soonest-first.
Seeded a real demo neighbourhood, 'Masjid Al-Falah Kariah' (join code
ALFALAH), with all 5 demo family owners plus a mutawalli as members: 3
announcements and 4 dated, RSVP'd events (Jumu'ah khutbah, weekly Quran
circle, an Islamic finance/estate-planning talk tying back to the app's
own core purpose, and a Ramadan iftar potluck). Caught and fixed two
seeding bugs during verification: timestamps stored without timezone
context displayed several hours off from the intended Malaysia-local
time, and a 'Saturday' event that actually landed on a Sunday — both
fixed by computing against real weekdays/timezone rather than guessing
offsets.
Covered by e2e-event-board.cjs (14/14), including live verification that
the seeded demo neighbourhood renders its real content. Full regression:
all suites pass.
This commit is contained in:
@@ -56,3 +56,14 @@ Three additions, covered by `e2e-insurance-verification.cjs` (12/12):
|
|||||||
- All proof documents (policies, liabilities, assets) share one private
|
- All proof documents (policies, liabilities, assets) share one private
|
||||||
Storage bucket, `nf-asset-documents`, with the same family-membership RLS
|
Storage bucket, `nf-asset-documents`, with the same family-membership RLS
|
||||||
pattern used for person photos.
|
pattern used for person photos.
|
||||||
|
|
||||||
|
## Demo Neighbourhood — "Masjid Al-Falah Kariah"
|
||||||
|
|
||||||
|
Join code `ALFALAH`. All 5 demo family owners plus `nf.demo.mutawalli@gmail.com`
|
||||||
|
(as "Ustaz Hafiz") are members — reachable from any demo account via
|
||||||
|
Family → Neighbourhood. Seeded with 3 plain announcements and 4 dated,
|
||||||
|
RSVP'd events (Jumu'ah khutbah, weekly Quran circle, an Islamic
|
||||||
|
finance/estate-planning talk, and a Ramadan iftar potluck), so the board
|
||||||
|
and Event filter are never empty for a demo. Event dates are computed
|
||||||
|
relative to "today" (real Fridays/Saturdays), not hardcoded — reseed with
|
||||||
|
fresh relative dates if this demo goes stale.
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Verifies the Event Board extension to the Neighbourhood board: posting an
|
||||||
|
// event (date/time/location), RSVP toggle + count, the Announcements/Events
|
||||||
|
// filter, and that the seeded demo neighbourhood ("Masjid Al-Falah Kariah")
|
||||||
|
// renders real content for a demo account.
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
|
||||||
|
const BASE = 'https://moslem04.falahos.my/';
|
||||||
|
const results = [];
|
||||||
|
const consoleErrors = [];
|
||||||
|
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||||
|
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
||||||
|
page.on('pageerror', e => consoleErrors.push(e.message));
|
||||||
|
|
||||||
|
await signInFreshFamily(page, BASE, 'e2e-eventboard');
|
||||||
|
await gotoTab(page, 'Neighbourhood');
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
const addToggle = await page.locator('.add-toggle').isVisible().catch(() => false);
|
||||||
|
if (addToggle) await page.locator('.add-toggle').click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.locator('.form-card .field:has-text("Name") input').fill(`Event Board Test ${Date.now()}`);
|
||||||
|
await page.locator('button.btn-primary', { hasText: 'Create & get a join code' }).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Plain announcement — no event fields shown until the toggle is checked
|
||||||
|
const eventFieldsHiddenByDefault = !(await page.locator('.field:has-text("Date & time")').isVisible().catch(() => false));
|
||||||
|
record('Neighbourhood: event date/location fields hidden until toggled', eventFieldsHiddenByDefault);
|
||||||
|
|
||||||
|
await page.locator('.field:has-text("Title") input').fill('General announcement');
|
||||||
|
await page.locator('button.btn-primary', { hasText: 'Post announcement' }).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
record('Neighbourhood: plain announcement posts without an event badge', await page.locator('.post-row:not(.event-row)', { hasText: 'General announcement' }).isVisible().catch(() => false));
|
||||||
|
|
||||||
|
// Event post
|
||||||
|
await page.locator('.field:has-text("Title") input').fill('Community Quran Class');
|
||||||
|
await page.locator('.event-toggle input').check();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const eventFieldsShown = await page.locator('.field:has-text("Date & time")').isVisible().catch(() => false);
|
||||||
|
record('Neighbourhood: event fields appear once toggled', eventFieldsShown);
|
||||||
|
await page.locator('.field:has-text("Date & time") input').fill('2026-12-01T18:30');
|
||||||
|
await page.locator('.field:has-text("Location") input').fill('Masjid Test Hall');
|
||||||
|
await page.locator('button.btn-primary', { hasText: 'Post event' }).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const eventCardVisible = await page.locator('.event-row', { hasText: 'Community Quran Class' }).isVisible().catch(() => false);
|
||||||
|
record('Event Board: event posts with the 📅 badge and event styling', eventCardVisible);
|
||||||
|
const eventDetailsVisible = await page.locator('.event-row', { hasText: 'Community Quran Class' }).locator('.event-when, .event-where').count();
|
||||||
|
record('Event Board: shows date/time and location on the event card', eventDetailsVisible === 2, `${eventDetailsVisible} detail lines`);
|
||||||
|
|
||||||
|
// RSVP
|
||||||
|
const rsvpBtn = page.locator('.event-row', { hasText: 'Community Quran Class' }).locator('.rsvp-btn');
|
||||||
|
record('Event Board: starts as not going', !(await rsvpBtn.evaluate(el => el.classList.contains('going'))));
|
||||||
|
await rsvpBtn.click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
record('Event Board: RSVP toggles to "Going" with a count of 1', await rsvpBtn.evaluate(el => el.classList.contains('going')) && (await rsvpBtn.textContent()).includes('1'));
|
||||||
|
await rsvpBtn.click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
record('Event Board: RSVP toggles back off (cancel)', !(await rsvpBtn.evaluate(el => el.classList.contains('going'))));
|
||||||
|
|
||||||
|
// Filter
|
||||||
|
await page.locator('.board-filter button', { hasText: 'Events' }).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const onlyEventsShown = await page.locator('.post-row').count() === 1 && await page.locator('.event-row').isVisible();
|
||||||
|
record('Event Board: Events filter hides plain announcements', onlyEventsShown);
|
||||||
|
await page.locator('.board-filter button', { hasText: 'Announcements' }).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const onlyAnnouncementsShown = await page.locator('.post-row').count() === 1 && !(await page.locator('.event-row').isVisible().catch(() => false));
|
||||||
|
record('Event Board: Announcements filter hides events', onlyAnnouncementsShown);
|
||||||
|
|
||||||
|
// ── Seeded demo neighbourhood ──
|
||||||
|
const demoPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||||
|
demoPage.on('console', m => { if (m.type() === 'error') consoleErrors.push('[demo] ' + m.text()); });
|
||||||
|
await demoPage.goto(BASE, { waitUntil: 'networkidle' });
|
||||||
|
await demoPage.locator('.field:has-text("Email") input').fill('nf.demo.ismail@gmail.com');
|
||||||
|
await demoPage.locator('.field:has-text("Password") input').fill('DemoPassword123!');
|
||||||
|
await demoPage.locator('button.btn-primary', { hasText: 'Sign in' }).click();
|
||||||
|
await demoPage.waitForTimeout(1500);
|
||||||
|
if (await demoPage.locator('.switcher-screen').isVisible().catch(() => false)) {
|
||||||
|
await demoPage.locator('.family-row').first().click();
|
||||||
|
await demoPage.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
await gotoTab(demoPage, 'Neighbourhood');
|
||||||
|
await demoPage.waitForTimeout(800);
|
||||||
|
record('Demo neighbourhood: Masjid Al-Falah Kariah is visible to the demo owner', await demoPage.locator('.active-card', { hasText: 'Masjid Al-Falah Kariah' }).isVisible().catch(() => false));
|
||||||
|
const demoPostCount = await demoPage.locator('.post-row').count();
|
||||||
|
record('Demo neighbourhood: has real seeded content (7 posts)', demoPostCount === 7, `${demoPostCount} posts`);
|
||||||
|
const demoEventCount = await demoPage.locator('.event-row').count();
|
||||||
|
record('Demo neighbourhood: has real seeded events (4 events)', demoEventCount === 4, `${demoEventCount} events`);
|
||||||
|
await demoPage.close();
|
||||||
|
|
||||||
|
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.slice(0, 5).join(' || '));
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
const passCount = results.filter(r => r.pass).length;
|
||||||
|
const failCount = results.length - passCount;
|
||||||
|
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
|
||||||
|
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
|
||||||
|
process.exit(failCount > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import { session } from './auth.js';
|
import { session } from './auth.js';
|
||||||
import {
|
import {
|
||||||
createNeighbourhood, joinNeighbourhoodByCode, listMyNeighbourhoods, leaveNeighbourhood,
|
createNeighbourhood, joinNeighbourhoodByCode, listMyNeighbourhoods, leaveNeighbourhood,
|
||||||
listNeighbourhoodPosts, addNeighbourhoodPost, removeNeighbourhoodPost
|
listNeighbourhoodPosts, addNeighbourhoodPost, removeNeighbourhoodPost,
|
||||||
|
listEventRsvps, rsvpToEvent, cancelRsvp
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import Disclaimer from './Disclaimer.svelte';
|
import Disclaimer from './Disclaimer.svelte';
|
||||||
import InfoPanel from './InfoPanel.svelte';
|
import InfoPanel from './InfoPanel.svelte';
|
||||||
@@ -17,9 +18,11 @@
|
|||||||
let posts = $state([]);
|
let posts = $state([]);
|
||||||
let createName = $state('');
|
let createName = $state('');
|
||||||
let joinCode = $state('');
|
let joinCode = $state('');
|
||||||
let postForm = $state({ title: '', body: '' });
|
let postForm = $state({ title: '', body: '', isEvent: false, eventDate: '', eventLocation: '' });
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let showAddForm = $state(false);
|
let showAddForm = $state(false);
|
||||||
|
let boardFilter = $state('all'); // all | announcements | events
|
||||||
|
let rsvps = $state([]); // { post_id, user_id }[]
|
||||||
|
|
||||||
async function refreshNeighbourhoods() {
|
async function refreshNeighbourhoods() {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
@@ -31,6 +34,16 @@
|
|||||||
async function refreshPosts() {
|
async function refreshPosts() {
|
||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
posts = await listNeighbourhoodPosts(activeId);
|
posts = await listNeighbourhoodPosts(activeId);
|
||||||
|
const eventIds = posts.filter(p => p.is_event).map(p => p.id);
|
||||||
|
rsvps = await listEventRsvps(eventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsvpCount(postId) { return rsvps.filter(r => r.post_id === postId).length; }
|
||||||
|
function isGoing(postId) { return rsvps.some(r => r.post_id === postId && r.user_id === userId); }
|
||||||
|
async function toggleRsvp(postId) {
|
||||||
|
if (isGoing(postId)) await cancelRsvp(postId, userId);
|
||||||
|
else await rsvpToEvent(postId, userId);
|
||||||
|
await refreshPosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(refreshNeighbourhoods);
|
onMount(refreshNeighbourhoods);
|
||||||
@@ -73,8 +86,9 @@
|
|||||||
|
|
||||||
async function doPost() {
|
async function doPost() {
|
||||||
if (!postForm.title.trim() || !activeId) return;
|
if (!postForm.title.trim() || !activeId) return;
|
||||||
await addNeighbourhoodPost(activeId, userId, userEmail, postForm.title.trim(), postForm.body);
|
if (postForm.isEvent && !postForm.eventDate) return;
|
||||||
postForm = { title: '', body: '' };
|
await addNeighbourhoodPost(activeId, userId, userEmail, postForm.title.trim(), postForm.body, postForm.isEvent ? { eventDate: postForm.eventDate, eventLocation: postForm.eventLocation } : null);
|
||||||
|
postForm = { title: '', body: '', isEvent: false, eventDate: '', eventLocation: '' };
|
||||||
await refreshPosts();
|
await refreshPosts();
|
||||||
}
|
}
|
||||||
async function doRemovePost(id) {
|
async function doRemovePost(id) {
|
||||||
@@ -83,6 +97,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeNeighbourhood = $derived(neighbourhoods.find(n => n.id === activeId));
|
const activeNeighbourhood = $derived(neighbourhoods.find(n => n.id === activeId));
|
||||||
|
|
||||||
|
const visiblePosts = $derived.by(() => {
|
||||||
|
let list = posts;
|
||||||
|
if (boardFilter === 'announcements') list = list.filter(p => !p.is_event);
|
||||||
|
if (boardFilter === 'events') list = list.filter(p => p.is_event);
|
||||||
|
// Upcoming events sort by date ascending (soonest first); everything
|
||||||
|
// else stays newest-first, matching the query order from the server.
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
const aUpcoming = a.is_event && a.event_date && new Date(a.event_date) >= new Date();
|
||||||
|
const bUpcoming = b.is_event && b.event_date && new Date(b.event_date) >= new Date();
|
||||||
|
if (aUpcoming && bUpcoming) return new Date(a.event_date) - new Date(b.event_date);
|
||||||
|
if (aUpcoming !== bUpcoming) return aUpcoming ? -1 : 1;
|
||||||
|
return new Date(b.created_at) - new Date(a.created_at);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatEventDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="module">
|
<div class="module">
|
||||||
@@ -90,9 +125,12 @@
|
|||||||
<h2>Neighbourhood</h2>
|
<h2>Neighbourhood</h2>
|
||||||
<InfoPanel
|
<InfoPanel
|
||||||
title="Neighbourhood"
|
title="Neighbourhood"
|
||||||
what="A simple announcement board for your mosque or local community — separate from your estate-planning family. Join with a code from your congregation, or start one and share the code."
|
what="A community board and event listing for your mosque or local community — separate from your estate-planning family. Join with a code from your congregation, or start one and share the code."
|
||||||
how="Create a neighbourhood to get a join code to share, or enter a code someone gave you. Once joined, post and read announcements with everyone else in that neighbourhood."
|
how="Create a neighbourhood to get a join code to share, or enter a code someone gave you. Post a plain announcement, or check 'This is an event' to add a date, time, and location — members can tap 'I'm going' to RSVP."
|
||||||
fields={[]}
|
fields={[
|
||||||
|
{ label: 'Event toggle', hint: 'Turns a post into a dated, located event others can RSVP to — otherwise it\'s a plain announcement.' },
|
||||||
|
{ label: 'RSVP', hint: 'A one-tap headcount, visible to everyone in the neighbourhood — not private.' }
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="sub">A community board, independent of your estate-planning family.</p>
|
<p class="sub">A community board, independent of your estate-planning family.</p>
|
||||||
@@ -115,23 +153,45 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<label class="field"><span>Title</span><input type="text" bind:value={postForm.title} placeholder="e.g. Friday khutbah reminder" /></label>
|
<label class="field"><span>Title</span><input type="text" bind:value={postForm.title} placeholder={postForm.isEvent ? 'e.g. Ramadan Iftar Potluck' : 'e.g. Friday khutbah reminder'} /></label>
|
||||||
<label class="field"><span>Details</span><input type="text" bind:value={postForm.body} /></label>
|
<label class="field"><span>Details</span><input type="text" bind:value={postForm.body} /></label>
|
||||||
<button class="btn-primary" onclick={doPost}>Post announcement</button>
|
<label class="event-toggle"><input type="checkbox" bind:checked={postForm.isEvent} /><span>This is an event (with date, time & location)</span></label>
|
||||||
|
{#if postForm.isEvent}
|
||||||
|
<label class="field"><span>Date & time</span><input type="datetime-local" bind:value={postForm.eventDate} /></label>
|
||||||
|
<label class="field"><span>Location</span><input type="text" bind:value={postForm.eventLocation} placeholder="e.g. Masjid Al-Falah, main hall" /></label>
|
||||||
|
{/if}
|
||||||
|
<button class="btn-primary" onclick={doPost}>{postForm.isEvent ? 'Post event' : 'Post announcement'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="board-filter">
|
||||||
|
<button class:active={boardFilter === 'all'} onclick={() => boardFilter = 'all'}>All</button>
|
||||||
|
<button class:active={boardFilter === 'events'} onclick={() => boardFilter = 'events'}>📅 Events</button>
|
||||||
|
<button class:active={boardFilter === 'announcements'} onclick={() => boardFilter = 'announcements'}>📣 Announcements</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="post-list">
|
<div class="post-list">
|
||||||
{#each posts as p (p.id)}
|
{#each visiblePosts as p (p.id)}
|
||||||
<div class="post-row">
|
<div class="post-row" class:event-row={p.is_event}>
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<strong>{p.title}</strong>
|
<strong>{p.is_event ? '📅 ' : ''}{p.title}</strong>
|
||||||
{#if p.author_id === userId}<button class="post-remove" onclick={() => doRemovePost(p.id)}>✕</button>{/if}
|
{#if p.author_id === userId}<button class="post-remove" onclick={() => doRemovePost(p.id)}>✕</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if p.is_event}
|
||||||
|
<div class="event-details">
|
||||||
|
<span class="event-when">🕐 {formatEventDate(p.event_date)}</span>
|
||||||
|
{#if p.event_location}<span class="event-where">📍 {p.event_location}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if p.body}<p class="post-body">{p.body}</p>{/if}
|
{#if p.body}<p class="post-body">{p.body}</p>{/if}
|
||||||
<span class="post-meta">{p.author_name || 'Member'} · {p.created_at?.slice(0, 10)}</span>
|
<span class="post-meta">{p.author_name || 'Member'} · {p.created_at?.slice(0, 10)}</span>
|
||||||
|
{#if p.is_event}
|
||||||
|
<button class="rsvp-btn" class:going={isGoing(p.id)} onclick={() => toggleRsvp(p.id)}>
|
||||||
|
{isGoing(p.id) ? `✓ Going` : 'I\'m going'} {rsvpCount(p.id) > 0 ? `(${rsvpCount(p.id)})` : ''}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty">No announcements yet.</p>
|
<p class="empty">{boardFilter === 'events' ? 'No events posted yet.' : boardFilter === 'announcements' ? 'No announcements yet.' : 'Nothing posted yet.'}</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -184,4 +244,14 @@
|
|||||||
.post-meta { font-size: 10.5px; color: #8A8478; }
|
.post-meta { font-size: 10.5px; color: #8A8478; }
|
||||||
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
|
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
|
||||||
.add-toggle { margin-top: 16px; }
|
.add-toggle { margin-top: 16px; }
|
||||||
|
|
||||||
|
.event-toggle { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: #B8B2A6; margin-bottom: 12px; cursor: pointer; }
|
||||||
|
.board-filter { display: flex; gap: 6px; margin-bottom: 14px; }
|
||||||
|
.board-filter button { flex: 1; padding: 8px 6px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.2); background: rgba(255,255,255,0.03); color: #B8B2A6; font-size: 11.5px; cursor: pointer; }
|
||||||
|
.board-filter button.active { background: rgba(201,168,76,0.15); color: #C9A84C; border-color: rgba(201,168,76,0.4); }
|
||||||
|
.event-row { border: 1px solid rgba(46,204,113,0.2); }
|
||||||
|
.event-details { display: flex; flex-direction: column; gap: 3px; margin: 6px 0; }
|
||||||
|
.event-when, .event-where { font-size: 11.5px; color: #2ECC71; }
|
||||||
|
.rsvp-btn { margin-top: 8px; padding: 7px 14px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.3); background: rgba(255,255,255,0.04); color: #C9A84C; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||||
|
.rsvp-btn.going { background: rgba(46,204,113,0.15); color: #2ECC71; border-color: rgba(46,204,113,0.4); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+21
-2
@@ -125,8 +125,11 @@ export async function listNeighbourhoodPosts(neighbourhoodId) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data || [];
|
return data || [];
|
||||||
}
|
}
|
||||||
export async function addNeighbourhoodPost(neighbourhoodId, authorId, authorName, title, body) {
|
export async function addNeighbourhoodPost(neighbourhoodId, authorId, authorName, title, body, eventFields) {
|
||||||
const { error } = await supabase.from('nf_neighbourhood_posts').insert({ neighbourhood_id: neighbourhoodId, author_id: authorId, author_name: authorName, title, body });
|
const { error } = await supabase.from('nf_neighbourhood_posts').insert({
|
||||||
|
neighbourhood_id: neighbourhoodId, author_id: authorId, author_name: authorName, title, body,
|
||||||
|
is_event: !!eventFields, event_date: eventFields?.eventDate || null, event_location: eventFields?.eventLocation || null
|
||||||
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
export async function removeNeighbourhoodPost(id) {
|
export async function removeNeighbourhoodPost(id) {
|
||||||
@@ -134,6 +137,22 @@ export async function removeNeighbourhoodPost(id) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Event Board — RSVPs on event-flagged neighbourhood posts. ──
|
||||||
|
export async function listEventRsvps(postIds) {
|
||||||
|
if (!postIds.length) return [];
|
||||||
|
const { data, error } = await supabase.from('nf_event_rsvps').select('post_id, user_id').in('post_id', postIds);
|
||||||
|
if (error) throw error;
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
export async function rsvpToEvent(postId, userId) {
|
||||||
|
const { error } = await supabase.from('nf_event_rsvps').insert({ post_id: postId, user_id: userId });
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
export async function cancelRsvp(postId, userId) {
|
||||||
|
const { error } = await supabase.from('nf_event_rsvps').delete().eq('post_id', postId).eq('user_id', userId);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Hibah ──
|
// ── Hibah ──
|
||||||
export async function listHibahGifts(familyId) {
|
export async function listHibahGifts(familyId) {
|
||||||
const { data, error } = await supabase.from('nf_hibah_gifts').select('*').eq('family_id', familyId).order('created_at');
|
const { data, error } = await supabase.from('nf_hibah_gifts').select('*').eq('family_id', familyId).order('created_at');
|
||||||
|
|||||||
Reference in New Issue
Block a user