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' }); + }
A community board, independent of your estate-planning family.
@@ -115,23 +153,45 @@{p.body}
{/if} + {#if p.is_event} + + {/if}No announcements yet.
+{boardFilter === 'events' ? 'No events posted yet.' : boardFilter === 'announcements' ? 'No announcements yet.' : 'Nothing posted yet.'}
{/each}