Add membership renewals and door unlock
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="940" viewBox="0 0 1600 940" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Photo overlay wiring guide for ESP32 DevKit and PN532 NFC module</title>
|
||||
<title id="title">Photo overlay wiring guide for XIAO ESP32-S3 and PN532 NFC module</title>
|
||||
<desc id="desc">Annotated photo overlay showing common ESP32 DevKit pins wired to PN532 I2C pins for the L484 NFC door reader.</desc>
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
@@ -21,7 +21,7 @@
|
||||
</defs>
|
||||
|
||||
<rect class="bg" width="1600" height="940"/>
|
||||
<text class="title" x="60" y="70">ESP32 + PN532 PHOTO WIRING OVERLAY</text>
|
||||
<text class="title" x="60" y="70">XIAO ESP32-S3 + PN532 WIRING OVERLAY</text>
|
||||
<text class="subtitle" x="62" y="104">SUPPLIED BOARD PHOTOS, PN532 IN I2C MODE</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
@@ -34,7 +34,7 @@
|
||||
<image href="../PN532.jpg" x="1048" y="204" width="454" height="454" preserveAspectRatio="xMidYMid meet"/>
|
||||
</g>
|
||||
|
||||
<!-- ESP32 common DevKit V1 target dots. The supplied ESP32 photo does not expose readable pin labels. -->
|
||||
<!-- XIAO ESP32-S3 target dots. Confirm against the board silkscreen before powering. -->
|
||||
<circle class="pinDot" cx="455" cy="728" r="12" stroke="#e23a3a"/>
|
||||
<circle class="pinDot" cx="455" cy="690" r="12" stroke="#202020"/>
|
||||
<circle class="pinDot" cx="455" cy="320" r="12" stroke="#3f8cff"/>
|
||||
@@ -74,18 +74,18 @@
|
||||
<rect class="tag" x="525" y="662" width="168" height="38" rx="10" stroke="#202020"/>
|
||||
<text class="tagText" x="544" y="687">GND -> GND</text>
|
||||
<rect class="tag" x="524" y="302" width="188" height="38" rx="10" stroke="#3f8cff"/>
|
||||
<text class="tagText" x="543" y="327">GPIO21 -> SDA</text>
|
||||
<text class="tagText" x="543" y="327">D4 / GPIO5 -> SDA</text>
|
||||
<rect class="tag" x="524" y="248" width="188" height="38" rx="10" stroke="#ffd23c"/>
|
||||
<text class="tagText" x="543" y="273">GPIO22 -> SCL</text>
|
||||
<text class="tagText" x="543" y="273">D5 / GPIO6 -> SCL</text>
|
||||
<rect class="tag" x="524" y="586" width="168" height="38" rx="10" stroke="#b96cff"/>
|
||||
<text class="tagText" x="543" y="611">GPIO4 -> IRQ</text>
|
||||
<text class="tagText" x="543" y="611">D2 / GPIO3 -> IRQ</text>
|
||||
<rect class="tag" x="524" y="478" width="216" height="38" rx="10" stroke="#34d399"/>
|
||||
<text class="tagText" x="543" y="503">GPIO5 -> RSTO</text>
|
||||
<text class="tagText" x="543" y="503">D3 / GPIO4 -> RSTO</text>
|
||||
</g>
|
||||
|
||||
<g>
|
||||
<rect class="tag" x="62" y="815" width="1476" height="72" rx="18" stroke="#f2ad24"/>
|
||||
<text class="note" x="92" y="846">VERIFY THE SILKSCREEN/PINOUT ON YOUR EXACT ESP32 BOARD BEFORE POWERING.</text>
|
||||
<text class="body" x="92" y="873">The ESP32 photo does not show pin labels, so the ESP32 dots follow the common 30-pin DevKit V1 pinout. The PN532 labels are visible in the supplied photo.</text>
|
||||
<text class="note" x="92" y="846">VERIFY THE SILKSCREEN/PINOUT ON YOUR EXACT XIAO BOARD BEFORE POWERING.</text>
|
||||
<text class="body" x="92" y="873">Use XIAO D4/GPIO5 for SDA, D5/GPIO6 for SCL, D2/GPIO3 for IRQ, and D3/GPIO4 for reset. Set the PN532 to I2C mode.</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="820" viewBox="0 0 1400 820" role="img" aria-labelledby="title desc">
|
||||
<title id="title">ESP32 to PN532 I2C wiring for L484 door reader</title>
|
||||
<desc id="desc">Wiring diagram showing PN532 VCC, GND, SDA, SCL, IRQ, and reset pins connected to ESP32 3V3, GND, GPIO21, GPIO22, GPIO4, and GPIO5.</desc>
|
||||
<title id="title">XIAO ESP32-S3 to PN532 I2C wiring for L484 door reader</title>
|
||||
<desc id="desc">Wiring diagram showing PN532 VCC, GND, SDA, SCL, IRQ, and reset pins connected to XIAO ESP32-S3 3V3, GND, D4/GPIO5, D5/GPIO6, D2/GPIO3, and D3/GPIO4.</desc>
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#000000" flood-opacity="0.45"/>
|
||||
@@ -33,7 +33,7 @@
|
||||
<rect class="panel" x="48" y="42" width="1304" height="736" rx="28"/>
|
||||
|
||||
<text class="label" x="92" y="102">L484 NFC DOOR READER WIRING</text>
|
||||
<text class="small" x="94" y="137">ESP32 DEV BOARD + PN532 NFC V3 MODULE, I2C MODE</text>
|
||||
<text class="small" x="94" y="137">XIAO ESP32-S3 + PN532 NFC V3 MODULE, I2C MODE</text>
|
||||
|
||||
<!-- ESP32 board -->
|
||||
<g filter="url(#shadow)">
|
||||
@@ -41,16 +41,16 @@
|
||||
<rect x="226" y="222" width="164" height="58" rx="10" fill="#d6d6d6" stroke="#5d5d5d" stroke-width="2"/>
|
||||
<text class="small" x="263" y="257" fill="#111">USB-C</text>
|
||||
<rect class="chip" x="222" y="350" width="168" height="128" rx="14"/>
|
||||
<text class="label" x="227" y="420" font-size="24">ESP32</text>
|
||||
<text class="small" x="222" y="506">DEV BOARD</text>
|
||||
<text class="label" x="227" y="420" font-size="24">XIAO</text>
|
||||
<text class="small" x="222" y="506">ESP32-S3</text>
|
||||
|
||||
<g>
|
||||
<circle class="pin" cx="142" cy="302" r="15"/><text class="pinText" x="172" y="308">3V3</text>
|
||||
<circle class="pin" cx="142" cy="356" r="15"/><text class="pinText" x="172" y="362">GND</text>
|
||||
<circle class="pin" cx="142" cy="410" r="15"/><text class="pinText" x="172" y="416">GPIO21</text>
|
||||
<circle class="pin" cx="142" cy="464" r="15"/><text class="pinText" x="172" y="470">GPIO22</text>
|
||||
<circle class="pin" cx="142" cy="518" r="15"/><text class="pinText" x="172" y="524">GPIO4</text>
|
||||
<circle class="pin" cx="142" cy="572" r="15"/><text class="pinText" x="172" y="578">GPIO5</text>
|
||||
<circle class="pin" cx="142" cy="410" r="15"/><text class="pinText" x="172" y="416">D4 / GPIO5</text>
|
||||
<circle class="pin" cx="142" cy="464" r="15"/><text class="pinText" x="172" y="470">D5 / GPIO6</text>
|
||||
<circle class="pin" cx="142" cy="518" r="15"/><text class="pinText" x="172" y="524">D2 / GPIO3</text>
|
||||
<circle class="pin" cx="142" cy="572" r="15"/><text class="pinText" x="172" y="578">D3 / GPIO4</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
@@ -84,19 +84,19 @@
|
||||
|
||||
<path class="wireThin" stroke="#ffffff" d="M142 410 C364 420 1012 402 1258 410"/>
|
||||
<path class="wire" stroke="#3f8cff" d="M142 410 C364 420 1012 402 1258 410"/>
|
||||
<text class="callout" x="622" y="396">GPIO21 -> SDA</text>
|
||||
<text class="callout" x="622" y="396">D4 / GPIO5 -> SDA</text>
|
||||
|
||||
<path class="wireThin" stroke="#ffffff" d="M142 464 C362 502 1014 448 1258 464"/>
|
||||
<path class="wire" stroke="#ffd23c" d="M142 464 C362 502 1014 448 1258 464"/>
|
||||
<text class="callout" x="622" y="488">GPIO22 -> SCL</text>
|
||||
<text class="callout" x="622" y="488">D5 / GPIO6 -> SCL</text>
|
||||
|
||||
<path class="wireThin" stroke="#ffffff" d="M142 518 C376 600 1004 498 1258 518"/>
|
||||
<path class="wire" stroke="#b96cff" d="M142 518 C376 600 1004 498 1258 518"/>
|
||||
<text class="callout" x="646" y="566">GPIO4 -> IRQ</text>
|
||||
<text class="callout" x="646" y="566">D2 / GPIO3 -> IRQ</text>
|
||||
|
||||
<path class="wireThin" stroke="#ffffff" d="M142 572 C382 684 1002 576 1258 572"/>
|
||||
<path class="wire" stroke="#34d399" d="M142 572 C382 684 1002 576 1258 572"/>
|
||||
<text class="callout" x="616" y="660">GPIO5 -> RSTO/RST</text>
|
||||
<text class="callout" x="616" y="660">D3 / GPIO4 -> RSTO/RST</text>
|
||||
|
||||
<text class="warning" x="92" y="735">VERIFY THE SILKSCREEN ON YOUR EXACT BOARDS BEFORE POWERING.</text>
|
||||
<text class="note" x="92" y="762">Some PN532 V3 modules label reset as RSTO, RST, or RSTPDN. Set the PN532 switches/jumpers to I2C mode.</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -35,14 +35,14 @@ Clean schematic:
|
||||
|
||||

|
||||
|
||||
Default ESP32 dev board wiring:
|
||||
Default Seeed Studio XIAO ESP32-S3 wiring:
|
||||
|
||||
- PN532 `VCC` -> ESP32 `3V3`
|
||||
- PN532 `GND` -> ESP32 `GND`
|
||||
- PN532 `SDA` -> ESP32 `GPIO21`
|
||||
- PN532 `SCL` -> ESP32 `GPIO22`
|
||||
- PN532 `IRQ` -> ESP32 `GPIO4`
|
||||
- PN532 `RSTO/RST` -> ESP32 `GPIO5`
|
||||
- PN532 `VCC` -> XIAO `3V3`
|
||||
- PN532 `GND` -> XIAO `GND`
|
||||
- PN532 `SDA` -> XIAO `D4 / GPIO5 / SDA`
|
||||
- PN532 `SCL` -> XIAO `D5 / GPIO6 / SCL`
|
||||
- PN532 `IRQ` -> XIAO `D2 / GPIO3`
|
||||
- PN532 `RSTO/RST` -> XIAO `D3 / GPIO4`
|
||||
|
||||
Set the PN532 board switches to I2C mode.
|
||||
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
|
||||
// Copy this file to include/l484_door_config.h and fill in local values.
|
||||
|
||||
#define WIFI_SSID "your-wifi"
|
||||
#define WIFI_PASSWORD "your-wifi-password"
|
||||
#define WIFI_SSID "Sapien"
|
||||
#define WIFI_PASSWORD "nose2tail"
|
||||
|
||||
// Use the on-prem L484 backend reachable from the ESP32 LAN.
|
||||
#define L484_ACCESS_URL "http://192.168.1.10:2354/api/access/check"
|
||||
#define L484_CONTROLLER_TOKEN "replace-with-ACCESS_CONTROLLER_TOKEN"
|
||||
#define L484_DOOR_ID "front-door"
|
||||
|
||||
// PN532 I2C pins on a typical ESP32 dev board.
|
||||
#define PN532_SDA_PIN 21
|
||||
#define PN532_SCL_PIN 22
|
||||
#define PN532_IRQ_PIN 4
|
||||
#define PN532_RESET_PIN 5
|
||||
// PN532 I2C pins for Seeed Studio XIAO ESP32-S3.
|
||||
// XIAO D4 = GPIO5/SDA, D5 = GPIO6/SCL.
|
||||
#define PN532_SDA_PIN 5
|
||||
#define PN532_SCL_PIN 6
|
||||
#define PN532_IRQ_PIN 3
|
||||
#define PN532_RESET_PIN 4
|
||||
|
||||
// Card credential modes:
|
||||
// 1 = submit UID hex. Good for bench testing only.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[env:esp32dev]
|
||||
[env:seeed_xiao_esp32s3]
|
||||
platform = espressif32
|
||||
board = esp32dev
|
||||
board = seeed_xiao_esp32s3
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
lib_deps =
|
||||
|
||||
242
server/server.js
242
server/server.js
@@ -61,6 +61,7 @@ const files = {
|
||||
siteContent: path.join(dataDir, 'site-content.json'),
|
||||
adminRequests: path.join(dataDir, 'admin-requests.json'),
|
||||
notificationSubscriptions: path.join(dataDir, 'notification-subscriptions.json'),
|
||||
membershipNotifications: path.join(dataDir, 'membership-notifications.json'),
|
||||
}
|
||||
|
||||
const state = {
|
||||
@@ -71,6 +72,7 @@ const state = {
|
||||
siteContent: null,
|
||||
adminRequests: [],
|
||||
notificationSubscriptions: [],
|
||||
membershipNotifications: [],
|
||||
}
|
||||
let bitcoinPriceCache = null
|
||||
const adminEventClients = new Set()
|
||||
@@ -140,6 +142,7 @@ const loadStore = async () => {
|
||||
state.siteContent = normalizeSiteContent(await loadJson(files.siteContent).catch(() => defaultSiteContent))
|
||||
state.adminRequests = await loadJson(files.adminRequests)
|
||||
state.notificationSubscriptions = await loadJson(files.notificationSubscriptions)
|
||||
state.membershipNotifications = await loadJson(files.membershipNotifications)
|
||||
}
|
||||
|
||||
const saveMemberships = () => saveJson(files.memberships, state.memberships)
|
||||
@@ -149,6 +152,7 @@ const saveAccessLogs = () => saveJson(files.accessLogs, state.accessLogs)
|
||||
const saveSiteContent = () => saveJson(files.siteContent, state.siteContent)
|
||||
const saveAdminRequests = () => saveJson(files.adminRequests, state.adminRequests)
|
||||
const saveNotificationSubscriptions = () => saveJson(files.notificationSubscriptions, state.notificationSubscriptions)
|
||||
const saveMembershipNotifications = () => saveJson(files.membershipNotifications, state.membershipNotifications)
|
||||
|
||||
const readBody = async (req) => {
|
||||
const raw = await readRawBody(req)
|
||||
@@ -289,8 +293,34 @@ const decryptMember = (member) => decryptMembership(member)
|
||||
const encryptedMembers = () => state.memberships
|
||||
const members = () => state.memberships.map(decryptMember)
|
||||
const findMember = (predicate) => members().find(predicate)
|
||||
const activePaymentFor = (membershipId) =>
|
||||
state.payments.find((payment) => payment.membershipId === membershipId && payment.status === 'paid')
|
||||
const membershipPeriodOptions = [1, 2, 3, 6, 12]
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
|
||||
const normalizePaymentMonths = (value) => {
|
||||
const months = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(months)) return 1
|
||||
return Math.max(1, Math.min(24, months))
|
||||
}
|
||||
|
||||
const addMembershipMonths = (date, months) => {
|
||||
const start = new Date(date)
|
||||
const day = start.getDate()
|
||||
const result = new Date(start)
|
||||
result.setDate(1)
|
||||
result.setMonth(result.getMonth() + normalizePaymentMonths(months))
|
||||
const lastDay = new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate()
|
||||
result.setDate(Math.min(day, lastDay))
|
||||
return result
|
||||
}
|
||||
|
||||
const activePaymentFor = (membershipId) => {
|
||||
const now = new Date()
|
||||
return state.payments.find((payment) =>
|
||||
payment.membershipId === membershipId &&
|
||||
payment.status === 'paid' &&
|
||||
(!payment.paidThrough || new Date(payment.paidThrough) > now)
|
||||
)
|
||||
}
|
||||
const activeCardFor = (membershipId) =>
|
||||
state.cards.find((card) => card.membershipId === membershipId && card.status === 'active')
|
||||
|
||||
@@ -303,6 +333,16 @@ const memberAccessStatus = (member) => {
|
||||
return member.status || 'requested'
|
||||
}
|
||||
|
||||
const updateMemberRecord = async (membershipId, updater) => {
|
||||
const index = members().findIndex((member) => member.membershipId === membershipId)
|
||||
if (index < 0) return null
|
||||
const current = decryptMember(state.memberships[index])
|
||||
const updated = updater({ ...current }) || current
|
||||
state.memberships[index] = encryptMembership(updated)
|
||||
await saveMemberships()
|
||||
return updated
|
||||
}
|
||||
|
||||
const validateMember = (data) => {
|
||||
const signerNpubs = Array.isArray(data.signerNpubs)
|
||||
? data.signerNpubs.map((item) => cleanText(item, 80)).filter(Boolean)
|
||||
@@ -339,7 +379,7 @@ const validateMember = (data) => {
|
||||
|
||||
if (!member.expiresAt) {
|
||||
const expiresAt = new Date(member.createdAt)
|
||||
expiresAt.setFullYear(expiresAt.getFullYear() + 1)
|
||||
expiresAt.setMonth(expiresAt.getMonth() + 1)
|
||||
member.expiresAt = expiresAt.toISOString()
|
||||
}
|
||||
|
||||
@@ -438,6 +478,111 @@ const sendPushNotification = async ({ title, message, url = '/edit', icon = '/im
|
||||
return { success: true, sent, failed }
|
||||
}
|
||||
|
||||
const sendPushNotificationToMember = async (member, payload) => {
|
||||
const targets = state.notificationSubscriptions.filter((subscription) =>
|
||||
subscription.membershipId === member.membershipId ||
|
||||
subscription.npub === member.npub ||
|
||||
member.signerNpubs?.includes(subscription.npub)
|
||||
)
|
||||
if (!targets.length) return { success: false, reason: 'no_member_subscriptions', sent: 0, failed: 0 }
|
||||
if (!isValidVapidPublicKey(vapidPublicKey) || !vapidPrivateKey) {
|
||||
return { success: false, reason: 'vapid_not_configured', sent: 0, failed: 0 }
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
title: cleanText(payload.title, 80) || 'L484',
|
||||
message: cleanText(payload.message, 220) || 'New L484 update.',
|
||||
icon: cleanText(payload.icon, 240) || '/images/small-logo.svg',
|
||||
tag: cleanText(payload.tag, 80) || 'l484-update',
|
||||
data: { url: cleanText(payload.url, 240) || '/' },
|
||||
})
|
||||
const targetEndpoints = new Set(targets.map((subscription) => subscription.endpoint))
|
||||
const results = await Promise.allSettled(targets.map((subscription) => webpush.sendNotification(subscription, body)))
|
||||
const invalidEndpoints = new Set()
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
sent += 1
|
||||
return
|
||||
}
|
||||
failed += 1
|
||||
if ([404, 410].includes(result.reason?.statusCode)) invalidEndpoints.add(targets[index].endpoint)
|
||||
})
|
||||
if (invalidEndpoints.size) {
|
||||
state.notificationSubscriptions = state.notificationSubscriptions.filter((subscription) =>
|
||||
!targetEndpoints.has(subscription.endpoint) || !invalidEndpoints.has(subscription.endpoint)
|
||||
)
|
||||
await saveNotificationSubscriptions()
|
||||
}
|
||||
return { success: true, sent, failed }
|
||||
}
|
||||
|
||||
const applyPaidMembershipPeriod = async (payment) => {
|
||||
const member = findMember((item) => item.membershipId === payment.membershipId)
|
||||
if (!member) return null
|
||||
const now = new Date()
|
||||
const existingExpiry = new Date(member.expiresAt)
|
||||
const coversFrom = Number.isNaN(existingExpiry.getTime()) || existingExpiry < now ? now : existingExpiry
|
||||
const months = normalizePaymentMonths(payment.months)
|
||||
const paidThrough = addMembershipMonths(coversFrom, months)
|
||||
payment.months = months
|
||||
payment.periodLabel = months === 1 ? '1 month' : `${months} months`
|
||||
payment.amountUsd = membershipMonthlyUsd * months
|
||||
payment.coversFrom = coversFrom.toISOString()
|
||||
payment.paidThrough = paidThrough.toISOString()
|
||||
const updated = await updateMemberRecord(payment.membershipId, (current) => ({
|
||||
...current,
|
||||
expiresAt: paidThrough.toISOString(),
|
||||
status: activeCardFor(payment.membershipId) ? 'active' : 'pending_payment',
|
||||
}))
|
||||
await savePayments()
|
||||
return updated
|
||||
}
|
||||
|
||||
const reconcileMembershipCycles = async ({ notify = false } = {}) => {
|
||||
const now = new Date()
|
||||
const allMembers = members()
|
||||
let changed = false
|
||||
for (const member of allMembers) {
|
||||
const expiry = new Date(member.expiresAt)
|
||||
if (Number.isNaN(expiry.getTime())) continue
|
||||
if (['active', 'pending_card', 'pending_payment'].includes(member.status) && expiry <= now) {
|
||||
await updateMemberRecord(member.membershipId, (current) => ({ ...current, status: 'suspended' }))
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (!notify || !['active', 'pending_card', 'pending_payment'].includes(member.status)) continue
|
||||
const daysUntilExpiry = Math.ceil((expiry.getTime() - now.getTime()) / dayMs)
|
||||
if (daysUntilExpiry < 1 || daysUntilExpiry > 3) continue
|
||||
const dayKey = now.toISOString().slice(0, 10)
|
||||
const alreadySent = state.membershipNotifications.some((entry) =>
|
||||
entry.membershipId === member.membershipId &&
|
||||
entry.type === 'expiry_reminder' &&
|
||||
entry.dayKey === dayKey
|
||||
)
|
||||
if (alreadySent) continue
|
||||
const result = await sendPushNotificationToMember(member, {
|
||||
title: 'Membership renewal due',
|
||||
message: `Your L484 membership expires in ${daysUntilExpiry} day${daysUntilExpiry === 1 ? '' : 's'}.`,
|
||||
url: '/',
|
||||
tag: `l484-renewal-${member.membershipId}-${dayKey}`,
|
||||
})
|
||||
state.membershipNotifications.unshift({
|
||||
id: createId('membership-notice'),
|
||||
membershipId: member.membershipId,
|
||||
type: 'expiry_reminder',
|
||||
dayKey,
|
||||
expiresAt: expiry.toISOString(),
|
||||
sent: result.sent || 0,
|
||||
failed: result.failed || 0,
|
||||
createdAt: now.toISOString(),
|
||||
})
|
||||
state.membershipNotifications = state.membershipNotifications.slice(0, 2000)
|
||||
await saveMembershipNotifications()
|
||||
}
|
||||
return { changed }
|
||||
}
|
||||
|
||||
const broadcastAdminEvent = (event, payload = {}) => {
|
||||
const message = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`
|
||||
for (const client of adminEventClients) {
|
||||
@@ -543,12 +688,7 @@ const upsertMember = async (member) => {
|
||||
}
|
||||
|
||||
const updateMemberStatus = async (membershipId, status) => {
|
||||
const index = members().findIndex((member) => member.membershipId === membershipId)
|
||||
if (index < 0) return null
|
||||
const member = { ...decryptMember(state.memberships[index]), status }
|
||||
state.memberships[index] = encryptMembership(member)
|
||||
await saveMemberships()
|
||||
return member
|
||||
return updateMemberRecord(membershipId, (member) => ({ ...member, status }))
|
||||
}
|
||||
|
||||
const serializeAdmin = (pubkey = '') => {
|
||||
@@ -560,6 +700,8 @@ const serializeAdmin = (pubkey = '') => {
|
||||
},
|
||||
memberships: allMembers.map((member) => ({ ...member, accessStatus: memberAccessStatus(member) })),
|
||||
payments: state.payments,
|
||||
membershipMonthlyUsd,
|
||||
membershipPeriodOptions,
|
||||
cards: state.cards.map(({ cardSecretHash, uidHash, ...card }) => card),
|
||||
accessLogs: state.accessLogs.slice(0, 200),
|
||||
}
|
||||
@@ -688,6 +830,8 @@ const seedDevelopmentStore = async () => {
|
||||
id: `payment-dev-paid-${member.membershipId.slice(-6).toLowerCase()}`,
|
||||
membershipId: member.membershipId,
|
||||
provider: index % 2 ? 'btcpay' : 'cash',
|
||||
months: 1,
|
||||
periodLabel: '1 month',
|
||||
amountUsd: membershipMonthlyUsd,
|
||||
amountSats: index % 2 ? 420000 : 0,
|
||||
status: 'paid',
|
||||
@@ -695,11 +839,15 @@ const seedDevelopmentStore = async () => {
|
||||
checkoutLink: index % 2 ? `${btcpayServerUrl || 'https://shop.tx1138.com'}/i/dev-paid-${index + 1}` : '',
|
||||
createdAt: member.createdAt,
|
||||
paidAt: member.createdAt,
|
||||
coversFrom: member.createdAt,
|
||||
paidThrough: member.expiresAt,
|
||||
})),
|
||||
...pendingMembers.map((member, index) => ({
|
||||
id: `payment-dev-pending-${member.membershipId.slice(-6).toLowerCase()}`,
|
||||
membershipId: member.membershipId,
|
||||
provider: 'btcpay',
|
||||
months: 1,
|
||||
periodLabel: '1 month',
|
||||
amountUsd: membershipMonthlyUsd,
|
||||
amountSats: 420000,
|
||||
status: 'pending',
|
||||
@@ -812,8 +960,10 @@ const fetchBtcpayPaymentMethods = async (invoiceId) => {
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
const createBtcpayInvoice = async (member) => {
|
||||
const createBtcpayInvoice = async (member, months = 1) => {
|
||||
if (!btcpayConfigured()) throw new Error('BTCPay credentials are not configured.')
|
||||
const normalizedMonths = normalizePaymentMonths(months)
|
||||
const amountUsd = membershipMonthlyUsd * normalizedMonths
|
||||
const response = await fetch(`${btcpayServerUrl}/api/v1/stores/${btcpayStoreId}/invoices`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -821,11 +971,12 @@ const createBtcpayInvoice = async (member) => {
|
||||
Authorization: `token ${btcpayApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: membershipMonthlyUsd,
|
||||
amount: amountUsd,
|
||||
currency: 'USD',
|
||||
metadata: {
|
||||
orderId: member.membershipId,
|
||||
itemDesc: 'L484 monthly membership',
|
||||
membershipMonths: normalizedMonths,
|
||||
itemDesc: normalizedMonths === 1 ? 'L484 monthly membership' : `L484 ${normalizedMonths} month membership`,
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -859,15 +1010,16 @@ const verifyBtcpayWebhook = (req, rawBody) => {
|
||||
const markInvoicePaid = async (invoiceId) => {
|
||||
const payment = state.payments.find((item) => item.btcpayInvoiceId === invoiceId)
|
||||
if (!payment) return null
|
||||
if (payment.status === 'paid') return payment
|
||||
payment.status = 'paid'
|
||||
payment.paidAt = new Date().toISOString()
|
||||
await savePayments()
|
||||
await updateMemberStatus(payment.membershipId, activeCardFor(payment.membershipId) ? 'active' : 'pending_payment')
|
||||
await applyPaidMembershipPeriod(payment)
|
||||
broadcastAdminEvent('payment-paid', {
|
||||
invoiceId,
|
||||
membershipId: payment.membershipId,
|
||||
paymentId: payment.id,
|
||||
paidAt: payment.paidAt,
|
||||
paidThrough: payment.paidThrough,
|
||||
})
|
||||
return payment
|
||||
}
|
||||
@@ -895,6 +1047,8 @@ const paymentStatusPayload = async (payment) => {
|
||||
invoice: {
|
||||
id: payment.btcpayInvoiceId || payment.id,
|
||||
amount: payment.amountUsd,
|
||||
months: normalizePaymentMonths(payment.months),
|
||||
periodLabel: payment.periodLabel || (normalizePaymentMonths(payment.months) === 1 ? '1 month' : `${normalizePaymentMonths(payment.months)} months`),
|
||||
currency: 'USD',
|
||||
status: invoice?.status || (payment.status === 'paid' ? 'Settled' : 'New'),
|
||||
paymentUrl: checkoutLink,
|
||||
@@ -905,6 +1059,7 @@ const paymentStatusPayload = async (payment) => {
|
||||
lightningPaymentLink: lightning?.paymentLink || '',
|
||||
bitcoinPaymentLink: bitcoin?.paymentLink || '',
|
||||
createdAt: payment.createdAt,
|
||||
paidThrough: payment.paidThrough || '',
|
||||
expiresAt: invoice?.expirationTime ? new Date(Number(invoice.expirationTime) * 1000).toISOString() : '',
|
||||
},
|
||||
}
|
||||
@@ -958,6 +1113,8 @@ const handleApi = async (req, res) => {
|
||||
if (!endpoint || !subscription?.keys?.p256dh || !subscription?.keys?.auth) {
|
||||
return json(res, 400, { error: 'Invalid push subscription.' })
|
||||
}
|
||||
const existingIndex = state.notificationSubscriptions.findIndex((item) => item.endpoint === endpoint)
|
||||
const existingSubscription = existingIndex >= 0 ? state.notificationSubscriptions[existingIndex] : null
|
||||
const normalized = {
|
||||
endpoint,
|
||||
expirationTime: subscription.expirationTime || null,
|
||||
@@ -965,10 +1122,12 @@ const handleApi = async (req, res) => {
|
||||
p256dh: cleanText(subscription.keys.p256dh, 500),
|
||||
auth: cleanText(subscription.keys.auth, 200),
|
||||
},
|
||||
membershipId: cleanText(body.membershipId || existingSubscription?.membershipId, 32).toUpperCase(),
|
||||
npub: cleanText(body.npub || existingSubscription?.npub, 90),
|
||||
userAgent: cleanText(req.headers['user-agent'], 240),
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: existingSubscription?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
const existingIndex = state.notificationSubscriptions.findIndex((item) => item.endpoint === endpoint)
|
||||
if (existingIndex >= 0) state.notificationSubscriptions[existingIndex] = normalized
|
||||
else state.notificationSubscriptions.unshift(normalized)
|
||||
await saveNotificationSubscriptions()
|
||||
@@ -1040,6 +1199,36 @@ const handleApi = async (req, res) => {
|
||||
return json(res, found ? 200 : 404, found ? { success: true, membership: found } : { error: 'No membership found for that nsec.' })
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/member/door/unlock') {
|
||||
if (!publicApiEnabled()) return json(res, 404, { error: 'Public membership access is disabled on this deployment.' })
|
||||
const body = await readBody(req)
|
||||
const membershipId = cleanText(body.membershipId, 32).toUpperCase()
|
||||
const nsecHash = cleanText(body.nsecHash, 64).toLowerCase()
|
||||
const doorId = cleanText(body.doorId, 80) || 'front-door'
|
||||
const member = findMember((item) => item.membershipId === membershipId && item.nsecHash === nsecHash)
|
||||
const card = activeCardFor(membershipId)
|
||||
const status = memberAccessStatus(member)
|
||||
const allow = Boolean(member && card && status === 'active')
|
||||
const unlock = allow
|
||||
? await triggerDoorUnlock({ doorId, member, card })
|
||||
: { attempted: false, ok: false, reason: member ? `member_${status}` : 'member_not_found' }
|
||||
await recordAccess({
|
||||
membershipId: member?.membershipId || membershipId,
|
||||
cardId: card?.id || '',
|
||||
cardPublicId: card?.cardPublicId || '',
|
||||
doorId,
|
||||
decision: allow && unlock.ok ? 'allow' : 'deny',
|
||||
reason: allow ? unlock.reason : unlock.reason,
|
||||
})
|
||||
return json(res, allow && unlock.ok ? 200 : 403, {
|
||||
success: allow && unlock.ok,
|
||||
allow,
|
||||
reason: allow ? unlock.reason : unlock.reason,
|
||||
unlock,
|
||||
member: member ? { membershipId: member.membershipId, fullName: member.fullName, status } : null,
|
||||
})
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/admin/state') {
|
||||
if (!requireAdmin(req, res)) return
|
||||
return json(res, 200, serializeAdmin(getAuthPubkey(req)))
|
||||
@@ -1143,11 +1332,14 @@ const handleApi = async (req, res) => {
|
||||
const membershipId = cleanText(body.membershipId, 32).toUpperCase()
|
||||
const member = findMember((item) => item.membershipId === membershipId)
|
||||
if (!member) return json(res, 404, { error: 'Member not found.' })
|
||||
const months = normalizePaymentMonths(body.months)
|
||||
const payment = {
|
||||
id: createId('payment'),
|
||||
membershipId,
|
||||
provider: cleanText(body.provider, 24) || 'manual',
|
||||
amountUsd: Number(body.amountUsd || membershipMonthlyUsd),
|
||||
months,
|
||||
periodLabel: months === 1 ? '1 month' : `${months} months`,
|
||||
amountUsd: membershipMonthlyUsd * months,
|
||||
amountSats: Number(body.amountSats || 0),
|
||||
status: 'paid',
|
||||
btcpayInvoiceId: cleanText(body.btcpayInvoiceId, 120),
|
||||
@@ -1155,8 +1347,7 @@ const handleApi = async (req, res) => {
|
||||
paidAt: new Date().toISOString(),
|
||||
}
|
||||
state.payments.unshift(payment)
|
||||
await savePayments()
|
||||
await updateMemberStatus(membershipId, activeCardFor(membershipId) ? 'active' : 'pending_payment')
|
||||
await applyPaidMembershipPeriod(payment)
|
||||
return json(res, 201, { success: true, payment })
|
||||
}
|
||||
|
||||
@@ -1167,12 +1358,15 @@ const handleApi = async (req, res) => {
|
||||
const membershipId = cleanText(body.membershipId, 32).toUpperCase()
|
||||
const member = findMember((item) => item.membershipId === membershipId)
|
||||
if (!member) return json(res, 404, { error: 'Member not found.' })
|
||||
const invoice = await createBtcpayInvoice(member)
|
||||
const months = normalizePaymentMonths(body.months)
|
||||
const invoice = await createBtcpayInvoice(member, months)
|
||||
const payment = {
|
||||
id: createId('payment'),
|
||||
membershipId,
|
||||
provider: 'btcpay',
|
||||
amountUsd: membershipMonthlyUsd,
|
||||
months,
|
||||
periodLabel: months === 1 ? '1 month' : `${months} months`,
|
||||
amountUsd: membershipMonthlyUsd * months,
|
||||
amountSats: 0,
|
||||
status: 'pending',
|
||||
btcpayInvoiceId: cleanText(invoice.id, 120),
|
||||
@@ -1182,7 +1376,7 @@ const handleApi = async (req, res) => {
|
||||
}
|
||||
state.payments.unshift(payment)
|
||||
await savePayments()
|
||||
await updateMemberStatus(membershipId, 'pending_payment')
|
||||
if (!['active', 'pending_card'].includes(memberAccessStatus(member))) await updateMemberStatus(membershipId, 'pending_payment')
|
||||
return json(res, 201, await paymentStatusPayload(payment))
|
||||
}
|
||||
|
||||
@@ -1275,6 +1469,10 @@ const handleApi = async (req, res) => {
|
||||
|
||||
await loadStore()
|
||||
await seedDevelopmentStore()
|
||||
await reconcileMembershipCycles({ notify: true })
|
||||
setInterval(() => {
|
||||
reconcileMembershipCycles({ notify: true }).catch((error) => console.warn('Membership cycle reconciliation failed:', error.message))
|
||||
}, 60 * 60 * 1000)
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
try {
|
||||
|
||||
97
src/App.vue
97
src/App.vue
@@ -155,6 +155,9 @@ const isCardRevealing = ref(false)
|
||||
const generatedCredentials = ref(null)
|
||||
const copiedKey = ref('')
|
||||
const signupNotificationsEnabled = ref(false)
|
||||
const isDoorUnlocking = ref(false)
|
||||
const doorUnlockMessage = ref('')
|
||||
const doorUnlockError = ref('')
|
||||
const memberSigninError = ref('')
|
||||
const isMemberSigninLoading = ref(false)
|
||||
const isRemoteSignerLoading = ref(false)
|
||||
@@ -184,6 +187,9 @@ const isPaymentModalLoading = ref(false)
|
||||
const paymentModalLoadingMethod = ref('')
|
||||
const paymentInvoiceMethod = ref('lightning')
|
||||
const paymentInvoiceQrUrl = ref('')
|
||||
const paymentMonths = ref(1)
|
||||
const membershipMonthlyUsd = ref(MEMBERSHIP_MONTHLY_USD)
|
||||
const membershipPeriodOptions = ref([1, 2, 3, 6, 12])
|
||||
const currentPath = ref(window.location.pathname)
|
||||
const appConfig = ref({ mode: 'all', adminEnabled: true, publicMembershipEnabled: true, accessEnabled: true })
|
||||
const siteContent = ref(defaultSiteContent)
|
||||
@@ -352,6 +358,8 @@ const paymentInvoiceCopyText = computed(() => {
|
||||
const paymentInvoiceUrl = computed(() => paymentModalInvoice.value?.paymentUrl || paymentModalInvoice.value?.checkoutLink || '#')
|
||||
const paymentInvoiceStatus = computed(() => paymentModalInvoice.value?.status || 'New')
|
||||
const paymentInvoicePaid = computed(() => ['settled', 'complete'].some((status) => paymentInvoiceStatus.value.toLowerCase().includes(status)))
|
||||
const paymentTotalUsd = computed(() => membershipMonthlyUsd.value * paymentMonths.value)
|
||||
const paymentPeriodLabel = computed(() => paymentMonths.value === 1 ? '1 month' : `${paymentMonths.value} months`)
|
||||
const paymentInvoiceStatusClass = computed(() => {
|
||||
const status = paymentInvoiceStatus.value.toLowerCase()
|
||||
if (status.includes('settled') || status.includes('complete')) return 'settled'
|
||||
@@ -685,6 +693,8 @@ const loadMembers = async () => {
|
||||
payments.value = Array.isArray(data.payments) ? data.payments : []
|
||||
cards.value = Array.isArray(data.cards) ? data.cards : []
|
||||
accessLogs.value = Array.isArray(data.accessLogs) ? data.accessLogs : []
|
||||
membershipMonthlyUsd.value = Number(data.membershipMonthlyUsd || MEMBERSHIP_MONTHLY_USD)
|
||||
membershipPeriodOptions.value = Array.isArray(data.membershipPeriodOptions) && data.membershipPeriodOptions.length ? data.membershipPeriodOptions : [1, 2, 3, 6, 12]
|
||||
adminIsMaster.value = Boolean(data.admin?.isMaster)
|
||||
await loadAdminAccessRequests()
|
||||
await loadNotificationStats()
|
||||
@@ -1077,14 +1087,14 @@ const createMembership = async () => {
|
||||
signupNotificationsEnabled.value = true
|
||||
subscribeToNotificationsInBackground()
|
||||
} catch (error) {
|
||||
formError.value = error instanceof Error ? error.message : 'Could not enable notifications.'
|
||||
isCreatingMembership.value = false
|
||||
return
|
||||
signupNotificationsEnabled.value = false
|
||||
formError.value = ''
|
||||
console.warn('Continuing signup without push notifications:', error instanceof Error ? error.message : error)
|
||||
}
|
||||
|
||||
const createdAt = new Date()
|
||||
const expiresAt = new Date(createdAt)
|
||||
expiresAt.setFullYear(expiresAt.getFullYear() + 1)
|
||||
expiresAt.setMonth(expiresAt.getMonth() + 1)
|
||||
const signerPubkey = await getActiveSignerPublicKey().catch(() => '')
|
||||
const privateKey = generateSecretKey()
|
||||
const pubkey = getPublicKey(privateKey)
|
||||
@@ -1118,6 +1128,11 @@ const createMembership = async () => {
|
||||
currentMemberId.value = savedMember.membershipId
|
||||
createdMember.value = savedMember
|
||||
generatedCredentials.value = { nsec, npub }
|
||||
if (signupNotificationsEnabled.value) {
|
||||
subscribeToNotifications({ requestPermission: false, member: savedMember }).catch((error) => {
|
||||
console.warn('Could not attach member to push subscription:', error instanceof Error ? error.message : error)
|
||||
})
|
||||
}
|
||||
localStorage.setItem(CURRENT_MEMBER_KEY, savedMember.membershipId)
|
||||
localStorage.setItem(USER_ID_KEY, savedMember.userId)
|
||||
saveMembers()
|
||||
@@ -1488,14 +1503,14 @@ const submitEventEnquiry = () => {
|
||||
eventEnquiry.message = ''
|
||||
}
|
||||
|
||||
const markManualPayment = async (membershipId) => {
|
||||
const markManualPayment = async (membershipId, months = 1) => {
|
||||
adminActionError.value = ''
|
||||
adminActionMessage.value = ''
|
||||
try {
|
||||
await fetchJson('/api/payment/manual', {
|
||||
method: 'POST',
|
||||
headers: adminHeaders(),
|
||||
body: JSON.stringify({ membershipId, provider: 'manual', amountUsd: MEMBERSHIP_MONTHLY_USD }),
|
||||
body: JSON.stringify({ membershipId, provider: 'manual', months }),
|
||||
})
|
||||
adminActionMessage.value = 'Cash payment recorded.'
|
||||
await refreshAdminState()
|
||||
@@ -1504,14 +1519,14 @@ const markManualPayment = async (membershipId) => {
|
||||
}
|
||||
}
|
||||
|
||||
const createBtcpayInvoice = async (membershipId) => {
|
||||
const createBtcpayInvoice = async (membershipId, months = 1) => {
|
||||
adminActionError.value = ''
|
||||
adminActionMessage.value = ''
|
||||
try {
|
||||
const result = await fetchJson('/api/payment/btcpay', {
|
||||
method: 'POST',
|
||||
headers: adminHeaders(),
|
||||
body: JSON.stringify({ membershipId }),
|
||||
body: JSON.stringify({ membershipId, months }),
|
||||
})
|
||||
adminActionMessage.value = 'Bitcoin invoice created.'
|
||||
if (result.payment?.checkoutLink) window.open(result.payment.checkoutLink, '_blank', 'noopener,noreferrer')
|
||||
@@ -1587,6 +1602,7 @@ const openPayment = (member) => {
|
||||
paymentModalInvoice.value = null
|
||||
paymentModalError.value = ''
|
||||
paymentInvoiceMethod.value = 'lightning'
|
||||
paymentMonths.value = 1
|
||||
isPaymentOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1598,6 +1614,7 @@ const closePayment = () => {
|
||||
paymentInvoiceMethod.value = 'lightning'
|
||||
isPaymentModalLoading.value = false
|
||||
paymentModalLoadingMethod.value = ''
|
||||
paymentMonths.value = 1
|
||||
}
|
||||
|
||||
const takeCashPayment = async () => {
|
||||
@@ -1605,7 +1622,7 @@ const takeCashPayment = async () => {
|
||||
isPaymentModalLoading.value = true
|
||||
paymentModalLoadingMethod.value = 'cash'
|
||||
paymentModalError.value = ''
|
||||
await markManualPayment(selectedPaymentMember.value.membershipId)
|
||||
await markManualPayment(selectedPaymentMember.value.membershipId, paymentMonths.value)
|
||||
isPaymentModalLoading.value = false
|
||||
paymentModalLoadingMethod.value = ''
|
||||
if (!adminActionError.value) closePayment()
|
||||
@@ -1622,7 +1639,7 @@ const createBitcoinPayment = async () => {
|
||||
const result = await fetchJson('/api/payment/btcpay', {
|
||||
method: 'POST',
|
||||
headers: adminHeaders(),
|
||||
body: JSON.stringify({ membershipId: selectedPaymentMember.value.membershipId }),
|
||||
body: JSON.stringify({ membershipId: selectedPaymentMember.value.membershipId, months: paymentMonths.value }),
|
||||
})
|
||||
paymentModalInvoice.value = result.invoice
|
||||
adminActionMessage.value = 'Bitcoin invoice created.'
|
||||
@@ -1653,6 +1670,29 @@ const refreshPaymentStatus = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const unlockDoor = async () => {
|
||||
const member = createdMember.value || currentMember.value
|
||||
if (!member) return
|
||||
isDoorUnlocking.value = true
|
||||
doorUnlockMessage.value = ''
|
||||
doorUnlockError.value = ''
|
||||
try {
|
||||
const result = await fetchJson('/api/member/door/unlock', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
membershipId: member.membershipId,
|
||||
nsecHash: member.nsecHash,
|
||||
doorId: 'front-door',
|
||||
}),
|
||||
})
|
||||
doorUnlockMessage.value = result.success ? 'Door unlock sent.' : 'Door access denied.'
|
||||
} catch (error) {
|
||||
doorUnlockError.value = error instanceof Error ? error.message : 'Could not unlock door.'
|
||||
} finally {
|
||||
isDoorUnlocking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goHome = () => {
|
||||
navigateTo('/')
|
||||
}
|
||||
@@ -3222,7 +3262,7 @@ watch(mobileMenuOpen, (open) => {
|
||||
<span>{{ payment.provider === 'btcpay' ? 'Bitcoin' : 'Cash' }}</span>
|
||||
<p>{{ payment.membershipId }}</p>
|
||||
<strong>{{ formatUsd(payment.amountUsd || MEMBERSHIP_MONTHLY_USD) }}</strong>
|
||||
<small>{{ payment.status }}</small>
|
||||
<small>{{ payment.status }} · {{ payment.periodLabel || `${payment.months || 1} month` }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="empty-admin">No payments yet.</div>
|
||||
@@ -3277,6 +3317,9 @@ watch(mobileMenuOpen, (open) => {
|
||||
placeholder="Scan card"
|
||||
/>
|
||||
</label>
|
||||
<button class="primary-action compact-action" type="button" @click="openPayment(member)">
|
||||
Pay
|
||||
</button>
|
||||
<button v-if="!cardForMember(member.membershipId)" class="primary-action compact-action" type="button" @click="issueCard(member.membershipId)">
|
||||
Activate card
|
||||
</button>
|
||||
@@ -3438,7 +3481,7 @@ watch(mobileMenuOpen, (open) => {
|
||||
<div>
|
||||
<p class="section-kicker">Payment</p>
|
||||
<h2 class="text-2xl font-black uppercase leading-none">{{ selectedPaymentMember.fullName }}</h2>
|
||||
<p class="mt-2 text-sm text-white/50">{{ selectedPaymentMember.membershipId }} · {{ formatUsd(MEMBERSHIP_MONTHLY_USD) }}</p>
|
||||
<p class="mt-2 text-sm text-white/50">{{ selectedPaymentMember.membershipId }} · {{ paymentPeriodLabel }} · {{ formatUsd(paymentTotalUsd) }}</p>
|
||||
</div>
|
||||
<button class="modal-close" type="button" aria-label="Close" @click="closePayment"></button>
|
||||
</div>
|
||||
@@ -3489,15 +3532,34 @@ watch(mobileMenuOpen, (open) => {
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="payment-period-panel">
|
||||
<div>
|
||||
<p class="section-kicker">Renewal period</p>
|
||||
<strong>{{ formatUsd(paymentTotalUsd) }}</strong>
|
||||
<small>{{ formatUsd(membershipMonthlyUsd) }} × {{ paymentMonths }} {{ paymentMonths === 1 ? 'month' : 'months' }}</small>
|
||||
</div>
|
||||
<div class="payment-period-options" role="group" aria-label="Membership renewal period">
|
||||
<button
|
||||
v-for="months in membershipPeriodOptions"
|
||||
:key="months"
|
||||
type="button"
|
||||
:class="{ active: paymentMonths === months }"
|
||||
:disabled="isPaymentModalLoading"
|
||||
@click="paymentMonths = months"
|
||||
>
|
||||
{{ months }}m
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="payment-choice-card" type="button" :disabled="isPaymentModalLoading" @click="takeCashPayment">
|
||||
<span>$ Cash</span>
|
||||
<strong>Paid in cash</strong>
|
||||
<small>Use when the member pays in person.</small>
|
||||
<small>Record {{ paymentPeriodLabel }} paid in person.</small>
|
||||
</button>
|
||||
<button class="payment-choice-card bitcoin" type="button" :disabled="isPaymentModalLoading || !appConfig.btcpayEnabled" @click="createBitcoinPayment">
|
||||
<span>₿ Bitcoin</span>
|
||||
<strong>BTCPay invoice</strong>
|
||||
<small>{{ appConfig.btcpayEnabled ? 'Create a Bitcoin checkout invoice.' : 'BTCPay is not configured.' }}</small>
|
||||
<small>{{ appConfig.btcpayEnabled ? `Create a ${formatUsd(paymentTotalUsd)} Bitcoin checkout invoice.` : 'BTCPay is not configured.' }}</small>
|
||||
<span v-if="paymentModalLoadingMethod === 'bitcoin'" class="payment-choice-spinner" aria-label="Creating BTCPay invoice">
|
||||
<img src="/images/small-logo.svg" alt="" />
|
||||
</span>
|
||||
@@ -3699,6 +3761,13 @@ watch(mobileMenuOpen, (open) => {
|
||||
<p v-if="signupNotificationsEnabled" class="validation-message rounded border border-emerald-400/30 bg-emerald-400/10 p-3 text-sm text-emerald-100">
|
||||
🎉 Notifications enabled
|
||||
</p>
|
||||
<div v-if="(createdMember.accessStatus || createdMember.status) === 'active'" class="member-door-panel">
|
||||
<button class="primary-action w-full" type="button" :disabled="isDoorUnlocking" @click="unlockDoor">
|
||||
{{ isDoorUnlocking ? 'Opening...' : 'Open door' }}
|
||||
</button>
|
||||
<p v-if="doorUnlockMessage" class="validation-message rounded border border-emerald-400/30 bg-emerald-400/10 p-3 text-sm text-emerald-100">{{ doorUnlockMessage }}</p>
|
||||
<p v-if="doorUnlockError" class="validation-message rounded border border-red-400/40 bg-red-500/10 p-3 text-sm text-red-200">{{ doorUnlockError }}</p>
|
||||
</div>
|
||||
<div v-if="generatedCredentials" class="member-keys">
|
||||
<p class="field-label">Member keys</p>
|
||||
<p class="text-sm leading-6 text-white/62">
|
||||
|
||||
@@ -30,11 +30,15 @@ export const requestNotificationPermission = async () => {
|
||||
return true
|
||||
}
|
||||
|
||||
const saveSubscription = async (subscription) => {
|
||||
const saveSubscription = async (subscription, member = null) => {
|
||||
const response = await fetch('/api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subscription: subscription.toJSON() }),
|
||||
body: JSON.stringify({
|
||||
subscription: subscription.toJSON(),
|
||||
membershipId: member?.membershipId || '',
|
||||
npub: member?.npub || '',
|
||||
}),
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(data.error || 'Could not save notification subscription.')
|
||||
@@ -49,7 +53,7 @@ const sameApplicationServerKey = (subscription, applicationServerKey) => {
|
||||
return existingBytes.every((byte, index) => byte === applicationServerKey[index])
|
||||
}
|
||||
|
||||
export const subscribeToNotifications = async ({ requestPermission = true } = {}) => {
|
||||
export const subscribeToNotifications = async ({ requestPermission = true, member = null } = {}) => {
|
||||
const support = notificationSupport()
|
||||
if (!support.supported) throw new Error('Push notifications are not supported in this browser.')
|
||||
if (!support.secure) throw new Error('Push notifications require HTTPS.')
|
||||
@@ -67,7 +71,7 @@ export const subscribeToNotifications = async ({ requestPermission = true } = {}
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const existing = await registration.pushManager.getSubscription()
|
||||
if (existing) {
|
||||
if (sameApplicationServerKey(existing, applicationServerKey)) return saveSubscription(existing)
|
||||
if (sameApplicationServerKey(existing, applicationServerKey)) return saveSubscription(existing, member)
|
||||
await existing.unsubscribe().catch(() => false)
|
||||
}
|
||||
|
||||
@@ -75,7 +79,7 @@ export const subscribeToNotifications = async ({ requestPermission = true } = {}
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey,
|
||||
})
|
||||
return saveSubscription(subscription)
|
||||
return saveSubscription(subscription, member)
|
||||
}
|
||||
|
||||
export const subscribeToNotificationsInBackground = ({ attempts = 8 } = {}) => {
|
||||
|
||||
@@ -1261,6 +1261,11 @@ body.menu-open {
|
||||
padding-inline: 0.8rem;
|
||||
}
|
||||
|
||||
.member-door-panel {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
|
||||
@@ -2043,6 +2048,60 @@ body.menu-open {
|
||||
width: min(100%, 30rem);
|
||||
}
|
||||
|
||||
.payment-period-panel {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
border: 1px solid rgba(242, 169, 0, 0.34);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(circle at 0% 0%, rgba(242, 169, 0, 0.12), transparent 14rem),
|
||||
rgba(255, 255, 255, 0.04);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.payment-period-panel strong {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
color: #fafafa;
|
||||
font-size: 1.9rem;
|
||||
font-weight: 950;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.payment-period-panel small {
|
||||
display: block;
|
||||
margin-top: 0.3rem;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.payment-period-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.payment-period-options button {
|
||||
min-height: 2.6rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 950;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.payment-period-options button.active {
|
||||
border-color: rgba(242, 169, 0, 0.82);
|
||||
background: #f2a900;
|
||||
color: #080808;
|
||||
}
|
||||
|
||||
.payment-choice-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
|
||||
Reference in New Issue
Block a user