diff --git a/DEMO_DATA.md b/DEMO_DATA.md index a6c776c..8b90ae1 100644 --- a/DEMO_DATA.md +++ b/DEMO_DATA.md @@ -56,3 +56,14 @@ Three additions, covered by `e2e-insurance-verification.cjs` (12/12): - All proof documents (policies, liabilities, assets) share one private Storage bucket, `nf-asset-documents`, with the same family-membership RLS 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. diff --git a/e2e-event-board.cjs b/e2e-event-board.cjs new file mode 100644 index 0000000..bf465a5 --- /dev/null +++ b/e2e-event-board.cjs @@ -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); }); diff --git a/src/lib/NeighbourhoodBoard.svelte b/src/lib/NeighbourhoodBoard.svelte index c195b0b..0998609 100644 --- a/src/lib/NeighbourhoodBoard.svelte +++ b/src/lib/NeighbourhoodBoard.svelte @@ -3,7 +3,8 @@ import { session } from './auth.js'; import { createNeighbourhood, joinNeighbourhoodByCode, listMyNeighbourhoods, leaveNeighbourhood, - listNeighbourhoodPosts, addNeighbourhoodPost, removeNeighbourhoodPost + listNeighbourhoodPosts, addNeighbourhoodPost, removeNeighbourhoodPost, + listEventRsvps, rsvpToEvent, cancelRsvp } from './db.js'; import Disclaimer from './Disclaimer.svelte'; import InfoPanel from './InfoPanel.svelte'; @@ -17,9 +18,11 @@ let posts = $state([]); let createName = $state(''); let joinCode = $state(''); - let postForm = $state({ title: '', body: '' }); + let postForm = $state({ title: '', body: '', isEvent: false, eventDate: '', eventLocation: '' }); let error = $state(''); let showAddForm = $state(false); + let boardFilter = $state('all'); // all | announcements | events + let rsvps = $state([]); // { post_id, user_id }[] async function refreshNeighbourhoods() { if (!userId) return; @@ -31,6 +34,16 @@ async function refreshPosts() { if (!activeId) return; 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); @@ -73,8 +86,9 @@ async function doPost() { if (!postForm.title.trim() || !activeId) return; - await addNeighbourhoodPost(activeId, userId, userEmail, postForm.title.trim(), postForm.body); - postForm = { title: '', body: '' }; + if (postForm.isEvent && !postForm.eventDate) return; + 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(); } async function doRemovePost(id) { @@ -83,6 +97,27 @@ } 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' }); + }
@@ -90,9 +125,12 @@

Neighbourhood

A community board, independent of your estate-planning family.

@@ -115,23 +153,45 @@
- + - + + {#if postForm.isEvent} + + + {/if} + +
+ +
+ + +
- {#each posts as p (p.id)} -
+ {#each visiblePosts as p (p.id)} +
- {p.title} + {p.is_event ? '📅 ' : ''}{p.title} {#if p.author_id === userId}{/if}
+ {#if p.is_event} +
+ 🕐 {formatEventDate(p.event_date)} + {#if p.event_location}📍 {p.event_location}{/if} +
+ {/if} {#if p.body}

{p.body}

{/if} + {#if p.is_event} + + {/if}
{:else} -

No announcements yet.

+

{boardFilter === 'events' ? 'No events posted yet.' : boardFilter === 'announcements' ? 'No announcements yet.' : 'Nothing posted yet.'}

{/each}
@@ -184,4 +244,14 @@ .post-meta { font-size: 10.5px; color: #8A8478; } .empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; } .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); } diff --git a/src/lib/db.js b/src/lib/db.js index ce54b14..8becec6 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -125,8 +125,11 @@ export async function listNeighbourhoodPosts(neighbourhoodId) { if (error) throw error; return data || []; } -export async function addNeighbourhoodPost(neighbourhoodId, authorId, authorName, title, body) { - const { error } = await supabase.from('nf_neighbourhood_posts').insert({ neighbourhood_id: neighbourhoodId, author_id: authorId, author_name: 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, + is_event: !!eventFields, event_date: eventFields?.eventDate || null, event_location: eventFields?.eventLocation || null + }); if (error) throw error; } export async function removeNeighbourhoodPost(id) { @@ -134,6 +137,22 @@ export async function removeNeighbourhoodPost(id) { 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 ── export async function listHibahGifts(familyId) { const { data, error } = await supabase.from('nf_hibah_gifts').select('*').eq('family_id', familyId).order('created_at');