Files
sapien/src/App.vue
2026-05-20 15:07:13 -05:00

4122 lines
171 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import QRCode from 'qrcode'
import { sha256 } from '@noble/hashes/sha256'
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
import {
cancelPendingRemoteAppLogin,
clearSigner,
getActiveSignerPublicKey,
hasPendingRemoteAppLogin,
loginWithExtension,
loginWithRemoteApp,
resumeRemoteAppLogin,
} from './services/signer'
import {
notificationPermission,
requestNotificationPermission,
subscribeToNotifications,
subscribeToNotificationsInBackground,
} from './services/notifications'
const heroBackgrounds = Object.entries(
import.meta.glob('../public/images/bg-*.{avif,webp,jpg,jpeg,png}', {
eager: true,
query: '?url',
import: 'default',
}),
)
.sort(([first], [second]) => first.localeCompare(second, undefined, { numeric: true }))
.map(([, src]) => src)
const facilityBackgroundImages = Object.fromEntries(Object.entries(
import.meta.glob('../public/images/{sauna,plunge,gym,firepit}.avif', {
eager: true,
query: '?url',
import: 'default',
}),
))
const facilityBackgrounds = ['sauna', 'plunge', 'gym', 'firepit'].map((name) => facilityBackgroundImages[`../public/images/${name}.avif`])
const memberBenefitMeta = [
{ number: '01', icon: 'icon-milk' },
{ number: '02', icon: 'icon-beef' },
{ number: '03', icon: 'icon-upgrade' },
{ number: '04', icon: 'icon-meals' },
{ number: '05', icon: 'icon-drinks' },
]
const facilityItemMeta = [
{ number: '01', icon: 'icon-sauna', backgroundIndex: 0 },
{ number: '02', icon: 'icon-plunge', backgroundIndex: 1 },
{ number: '03', icon: 'icon-gym', backgroundIndex: 2 },
{ number: '04', icon: 'icon-event', backgroundIndex: 3 },
{ number: '05', icon: 'icon-fire', backgroundIndex: 3 },
]
const defaultSiteContent = {
homepage: {
hero: {
line1: 'Decentralization',
line2: 'In Motion',
benefitsCue: 'Benefits',
},
members: {
title: 'Members Get',
benefits: [
{ title: 'Raw milk', description: 'Placeholder member benefit.' },
{ title: 'Grass fed beef', description: 'Placeholder member benefit.' },
{ title: 'Upgrade Labs', description: 'Placeholder member benefit.' },
{ title: '5 meals a month', description: 'Placeholder member benefit.' },
{ title: 'Free drinks', description: 'Placeholder member benefit.' },
],
},
facilities: {
title: 'Facilities',
items: [
{ title: 'Sauna', description: '6-8 person sauna available for member use.' },
{ title: 'Cold plunge', description: 'Two XL plunges filtered and cooled to your chosen temperature.' },
{ title: 'Gym', description: 'Outdoor turf space set up for fitness and group workouts.' },
{ title: 'Event space', description: 'Customizable indoor and outdoor space for private gatherings.' },
{ title: 'Grill area / firepit', description: 'Multiple grills and a custom sandbox firepit outside.' },
],
},
events: {
navLabel: 'Events',
title: 'Events',
description: 'Private member gatherings, workshops, and hosted sessions at L484.',
enquiryTitle: 'Event Enquiry',
items: [
{ title: 'Member nights', description: 'Small-format gatherings for active members and invited guests.', date: 'Fridays', price: 'Members', image: '/images/firepit.avif' },
{ title: 'Workshops', description: 'Hands-on sessions around food, wellness, Bitcoin, and local resilience.', date: 'Monthly', price: '$40+', image: '/images/gym.avif' },
{ title: 'Private bookings', description: 'Indoor and outdoor areas available for approved member events.', date: 'By enquiry', price: 'Custom', image: '/images/bg-3.avif' },
],
},
},
}
const defaultContentLimits = {
heroLine: 24,
cue: 16,
sectionTitle: 32,
navLabel: 16,
itemTitle: 36,
itemDescription: 90,
eventTitle: 48,
eventDescription: 140,
eventImage: 240,
eventDate: 32,
eventPrice: 32,
maxBenefits: 5,
maxFacilities: 5,
maxEvents: 6,
}
const MEMBERS_KEY = 'l484-members'
const CURRENT_MEMBER_KEY = 'l484-current-member'
const ADMIN_AUTH_KEY = 'l484-admin-user'
const USER_ID_KEY = 'l484-user-id'
const SIGNER_LOGIN_COMPLETE_KEY = 'l484-signer-login-complete'
const SIGNER_LOGIN_CONTEXT_KEY = 'l484-signer-login-context'
const SIGNUP_DRAFT_KEY = 'l484-signup-draft'
const MAX_NAME_LENGTH = 80
const MAX_EMAIL_LENGTH = 160
const MAX_PHONE_LENGTH = 32
const MEMBERSHIP_MONTHLY_USD = 350
const BITCOIN_PRICE_FALLBACK_USD = 79592.095
const EMAIL_PATTERN = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/
const PHONE_PATTERN = /^[0-9()+.\-\s]{7,32}$/
const MEMBERSHIP_ID_PATTERN = /^L484-\d{4}-[A-Z0-9]{6}$/
const DATA_IMAGE_PATTERN = /^data:image\/png;base64,[A-Za-z0-9+/=]+$/
const NPUB_PATTERN = /^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/
const covenantItems = [
'I am joining a private, members-only association, not a public business.',
"Participation in L484's activities, meals, and services is limited to accepted members only.",
'All exchanges of goods or services within L484 are private & internal.',
'Suggested contribution amounts communicated by L484 are internal coordination references only, not public prices.',
'Participation is voluntary and undertaken at my own risk within a private context.',
'Membership is granted for a 24-hour period beginning upon acceptance of this application, unless ended earlier by the member or by L484.',
"Any disputes will be resolved privately and consistent with L484's ecclesiastical and contractual principles contained in this covenant.",
'I acknowledge that my membership, participation, and personal information are confidential, and that I will respect the privacy of L484 and other members.',
'I acknowledge that L484 operates under internal Articles, Bylaws, and policies, which are not publicly distributed, and that I do not acquire governance, voting, or inspection rights by virtue of my membership.',
'I acknowledge and accept that I may have interacted with L484 or its activities prior to completing this application, and I agree that such interactions are part of my voluntary participation as a member.',
"I agree to abide by L484's rules, governance, and confidentiality requirements, as outlined in this Covenant.",
'I agree not to represent L484 or its activities as open to the public or as a commercial entity.',
]
const activeBackground = ref(0)
const loadedHeroBackgroundIndexes = ref(new Set([0]))
const activeFacilityBackground = ref(0)
const hasRotatingBackgrounds = computed(() => heroBackgrounds.length > 1)
const isSignupOpen = ref(false)
const isMemberSigninOpen = ref(false)
const signupStep = ref(0)
const deferredInstallPrompt = ref(null)
const pwaInstallMessage = ref('')
const isPwaStandalone = ref(false)
const installPlatform = ref('desktop')
const members = ref([])
const currentMemberId = ref('')
const createdMember = ref(null)
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)
const isCreatingMembership = ref(false)
const formError = ref('')
const signatureCanvas = ref(null)
const signatureHasInk = ref(false)
const signatureStrokes = ref([])
const backupFileInput = ref(null)
const backupPassword = ref('')
const restorePassword = ref('')
const backupMessage = ref('')
const backupError = ref('')
const bitcoinUsdPrice = ref(BITCOIN_PRICE_FALLBACK_USD)
const bitcoinPriceIsLive = ref(false)
const bitcoinPriceFetchedAt = ref('')
const bitcoinPriceError = ref('')
const isBackupOpen = ref(false)
const isRestoreOpen = ref(false)
const selectedAgreementMember = ref(null)
const isAgreementOpen = ref(false)
const selectedPaymentMember = ref(null)
const isPaymentOpen = ref(false)
const paymentModalInvoice = ref(null)
const paymentModalError = ref('')
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)
const siteContentLimits = ref(defaultContentLimits)
const siteContentDraft = ref(JSON.parse(JSON.stringify(defaultSiteContent)))
const isSiteContentLoading = ref(false)
const siteContentDirty = ref(false)
const eventEnquiryMessage = ref('')
const adminAccessRequests = ref([])
const adminIsMaster = ref(false)
const adminRequestName = ref('')
const adminRequestCredentials = ref(null)
const adminRequestMessage = ref('')
const adminRequestError = ref('')
const isEventEditorOpen = ref(false)
const notificationStats = ref({ configured: false, subscriberCount: 0 })
const notificationMessage = ref('')
const notificationError = ref('')
const payments = ref([])
const cards = ref([])
const accessLogs = ref([])
const adminTab = ref('requested')
const editTab = ref('hero')
const adminActionMessage = ref('')
const adminActionError = ref('')
const cardCredentialInputs = reactive({})
const isCardReaderOpen = ref(false)
const cardReaderBaselineId = ref('')
const cardReaderLastSeenId = ref('')
const cardReaderPulse = ref(false)
const cardReaderError = ref('')
const adminUser = ref('')
const adminError = ref('')
const adminLoginMethod = ref('')
const isAdminLoading = ref(false)
const isAdminSessionChecking = ref(false)
const adminSessionChecked = ref(false)
const isAdminMenuOpen = ref(false)
const mobileMenuOpen = ref(false)
let isSigning = false
let activeSignatureStroke = null
const form = reactive({
fullName: '',
email: '',
phone: '',
signature: '',
accepted: false,
})
const eventEnquiry = reactive({
name: '',
email: '',
message: '',
})
const eventEditorForm = reactive({
title: '',
date: '',
price: '',
image: '',
description: '',
})
let backgroundTimer
let adminToastTimer
let parallaxFrame = 0
let adminEvents = null
let adminEventsReconnectTimer = 0
let cardReaderPollTimer = 0
let isHeroRotationPreloading = false
const heroBackgroundPreloads = new Map()
const cloneContent = (content) => JSON.parse(JSON.stringify(content))
const homepageContent = computed(() => siteContent.value.homepage || defaultSiteContent.homepage)
const contentLimits = computed(() => siteContentLimits.value || defaultContentLimits)
const memberBenefitItems = computed(() =>
memberBenefitMeta.map((meta, index) => ({
...meta,
...(homepageContent.value.members?.benefits?.[index] || defaultSiteContent.homepage.members.benefits[index]),
})),
)
const facilityItems = computed(() =>
facilityItemMeta.map((meta, index) => ({
...meta,
...(homepageContent.value.facilities?.items?.[index] || defaultSiteContent.homepage.facilities.items[index]),
})),
)
const eventItems = computed(() => homepageContent.value.events?.items || defaultSiteContent.homepage.events.items)
const publicNavItems = computed(() => [
{ label: homepageContent.value.events?.navLabel || 'Events', path: '/events' },
])
const currentMember = computed(() =>
members.value.find((member) => member.membershipId === currentMemberId.value) || null,
)
const isHomeRoute = computed(() => currentPath.value === '/')
const isEventsRoute = computed(() => currentPath.value === '/events')
const isAdminRoute = computed(() => currentPath.value === '/admin')
const isEditRoute = computed(() => currentPath.value === '/edit')
const isAdminLikeRoute = computed(() => isAdminRoute.value || isEditRoute.value)
const isAdminAuthenticated = computed(() => adminSessionChecked.value && Boolean(adminUser.value))
const pendingAdminRequests = computed(() => adminAccessRequests.value.filter((request) => request.status === 'requested'))
const adminTabs = computed(() => [
{ id: 'requested', label: 'Requested', count: members.value.filter((member) => !paymentForMember(member.membershipId) && !['suspended', 'revoked', 'expired'].includes(member.accessStatus || member.status)).length, icon: 'icon-admin-requested' },
{ id: 'paid', label: 'Paid', count: members.value.filter((member) => paymentForMember(member.membershipId) && !['suspended', 'revoked', 'expired'].includes(member.accessStatus || member.status)).length, icon: 'icon-admin-paid' },
{ id: 'suspended', label: 'Suspended', count: members.value.filter((member) => ['suspended', 'revoked', 'expired'].includes(member.accessStatus || member.status)).length, icon: 'icon-admin-suspended' },
{ id: 'payments', label: 'Payments', count: payments.value.length, icon: 'icon-admin-payments' },
{ id: 'logs', label: 'Logs', count: accessLogs.value.length, icon: 'icon-admin-logs' },
])
const editTabs = computed(() => [
{ id: 'hero', label: 'Hero', count: 0, icon: 'icon-edit-hero' },
{ id: 'members', label: 'Members', count: siteContentDraft.value.homepage.members.benefits.length, icon: 'icon-edit-members' },
{ id: 'facilities', label: 'Facilities', count: siteContentDraft.value.homepage.facilities.items.length, icon: 'icon-edit-facilities' },
{ id: 'events', label: 'Events', count: siteContentDraft.value.homepage.events.items.length, icon: 'icon-edit-events' },
{ id: 'notifications', label: 'Alerts', count: notificationStats.value.subscriberCount || 0, icon: 'icon-edit-alerts' },
{ id: 'admins', label: 'Admins', count: pendingAdminRequests.value.length, icon: 'icon-edit-admins' },
])
const filteredAdminMembers = computed(() => {
if (adminTab.value === 'logs' || adminTab.value === 'payments') return []
return members.value.filter((member) => {
const status = member.accessStatus || member.status
if (adminTab.value === 'requested') return !paymentForMember(member.membershipId) && !['suspended', 'revoked', 'expired'].includes(status)
if (adminTab.value === 'paid') return Boolean(paymentForMember(member.membershipId)) && !['suspended', 'revoked', 'expired'].includes(status)
if (adminTab.value === 'suspended') return ['suspended', 'revoked', 'expired'].includes(status)
return true
})
})
const newestMember = computed(() =>
[...members.value].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))[0] || null,
)
const latestAccessLog = computed(() => accessLogs.value[0] || null)
const cardReaderHasAttempt = computed(() => Boolean(latestAccessLog.value && latestAccessLog.value.id !== cardReaderBaselineId.value))
const cardReaderStatus = computed(() => {
const log = latestAccessLog.value
if (!cardReaderHasAttempt.value || !log) return 'waiting'
return log.decision === 'allow' ? 'allowed' : 'denied'
})
const cardReaderStatusLabel = computed(() => {
if (cardReaderStatus.value === 'allowed') return 'Access allowed'
if (cardReaderStatus.value === 'denied') return 'Access denied'
return 'Waiting for card'
})
const cardReaderMemberLabel = computed(() => latestAccessLog.value?.member?.fullName || 'Unknown card')
const cardReaderCardLabel = computed(() => latestAccessLog.value?.cardPublicId || latestAccessLog.value?.cardId || 'Unregistered')
const cardReaderDoorLabel = computed(() => latestAccessLog.value?.doorId || 'front-door')
const cardReaderUnlockLabel = computed(() => {
if (!cardReaderHasAttempt.value) return 'Awaiting next scan'
const unlock = latestAccessLog.value?.unlock
if (unlock?.attempted && unlock.ok) return 'Unlock accepted'
if (unlock?.attempted && !unlock.ok) return unlock.reason || 'Unlock failed'
if (unlock && !unlock.attempted) return unlock.reason || 'Door stayed locked'
const reason = latestAccessLog.value?.reason || ''
if (reason.includes('unlock_failed')) return 'Unlock failed'
if (reason.includes('unlock_webhook_not_configured')) return 'Unlock not configured'
if (latestAccessLog.value?.decision === 'allow') return 'Unlock requested'
return 'Door stayed locked'
})
const paidPayments = computed(() => payments.value.filter((payment) => payment.status === 'paid'))
const revenuePaidPayments = computed(() => paidPayments.value.filter((payment) => payment.provider !== 'comp'))
const compPayments = computed(() => paidPayments.value.filter((payment) => payment.provider === 'comp'))
const pendingPayments = computed(() => payments.value.filter((payment) => payment.status !== 'paid' && payment.provider !== 'comp'))
const compedPaymentValue = (payment) => {
const amount = Number(payment.amountUsd)
if (Number.isFinite(amount) && amount > 0) return amount
return Number(payment.months || 1) * membershipMonthlyUsd.value
}
const paymentTotals = computed(() => {
const paid = revenuePaidPayments.value.reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
const pending = pendingPayments.value.reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
const cash = revenuePaidPayments.value.filter((payment) => ['manual', 'cash'].includes(payment.provider)).reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
const bitcoin = revenuePaidPayments.value.filter((payment) => payment.provider === 'btcpay').reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
const comp = compPayments.value.reduce((total, payment) => total + compedPaymentValue(payment), 0)
return { paid, pending, cash, bitcoin, comp }
})
const paymentMethodRows = computed(() => {
const rows = [
{ label: 'Cash', value: paymentTotals.value.cash, count: revenuePaidPayments.value.filter((payment) => ['manual', 'cash'].includes(payment.provider)).length },
{ label: 'Bitcoin', value: paymentTotals.value.bitcoin, count: revenuePaidPayments.value.filter((payment) => payment.provider === 'btcpay').length },
{ label: 'Comped', value: paymentTotals.value.comp, count: compPayments.value.length },
]
const max = Math.max(...rows.map((row) => row.value), 1)
return rows.map((row) => ({ ...row, percentage: Math.max(4, Math.round((row.value / max) * 100)) }))
})
const paymentTimelineRows = computed(() => {
const days = Array.from({ length: 7 }, (_, index) => {
const date = new Date()
date.setDate(date.getDate() - (6 - index))
const key = date.toISOString().slice(0, 10)
return { key, label: date.toLocaleDateString('en-US', { weekday: 'short' }), value: 0 }
})
revenuePaidPayments.value.forEach((payment) => {
const key = String(payment.paidAt || payment.createdAt || '').slice(0, 10)
const day = days.find((item) => item.key === key)
if (day) day.value += Number(payment.amountUsd || 0)
})
const max = Math.max(...days.map((day) => day.value), 1)
return days.map((day) => ({ ...day, height: Math.max(6, Math.round((day.value / max) * 100)) }))
})
const paymentMethodData = (invoice, method) => {
if (!invoice) return ''
if (method === 'lightning') {
return invoice.lightningInvoice || invoice.lightningPaymentLink || invoice.paymentUrl || invoice.checkoutLink || ''
}
return invoice.paymentAddress || invoice.bitcoinPaymentLink || invoice.paymentUrl || invoice.checkoutLink || ''
}
const paymentInvoiceQrData = computed(() => {
return paymentMethodData(paymentModalInvoice.value, paymentInvoiceMethod.value)
})
const paymentInvoiceCopyText = computed(() => {
return paymentMethodData(paymentModalInvoice.value, paymentInvoiceMethod.value)
})
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'
if (status.includes('processing')) return 'processing'
if (status.includes('expired') || status.includes('invalid')) return 'expired'
return 'new'
})
const adminDisplayName = computed(() => (adminUser.value ? 'L484 Admin' : 'Admin'))
const adminShortKey = computed(() =>
adminUser.value ? `${adminUser.value.slice(0, 8)}...${adminUser.value.slice(-6)}` : '',
)
const adminAvatarStyle = computed(() => {
const hue = adminUser.value
? parseInt(adminUser.value.slice(0, 6), 16) % 360
: 42
return {
background: `linear-gradient(135deg, hsl(${hue} 52% 34%), hsl(${(hue + 32) % 360} 62% 18%))`,
}
})
const canContinue = computed(() => {
if (signupStep.value === 2) return validateApplicant(false)
if (signupStep.value === 3) return form.accepted
if (signupStep.value === 4) return DATA_IMAGE_PATTERN.test(form.signature)
return true
})
const pwaInstallTitle = computed(() => {
if (isPwaStandalone.value) return 'PWA installed'
return 'Install for all benefits'
})
const pwaInstallCopy = computed(() => {
if (installPlatform.value === 'ios') return 'Use Safari, tap Share, then Add to Home Screen. Reopen L484 from the new app icon to continue signup.'
return 'Optional, but recommended for a better member card and notification experience.'
})
const pwaInstallPrimaryLabel = computed(() => {
if (isPwaStandalone.value) return 'Continue signup'
return 'Install app'
})
const membershipBtcAmount = computed(() =>
bitcoinUsdPrice.value ? MEMBERSHIP_MONTHLY_USD / bitcoinUsdPrice.value : 0,
)
const membershipSatsAmount = computed(() =>
Math.round(membershipBtcAmount.value * 100_000_000),
)
const membershipBtcText = computed(() =>
`${formatSats(membershipSatsAmount.value)} sats`,
)
const bitcoinUsdText = computed(() =>
`${formatUsd(bitcoinUsdPrice.value)} ${bitcoinPriceIsLive.value ? 'live' : 'est.'}`,
)
const heroBackgroundEntries = computed(() =>
heroBackgrounds
.map((background, index) => ({ background, index }))
.filter(({ index }) => loadedHeroBackgroundIndexes.value.has(index)),
)
const currentHeroBackground = computed(() => heroBackgrounds[activeBackground.value] || heroBackgrounds[0] || '')
const sanitizeText = (value, maxLength) =>
String(value ?? '')
.normalize('NFKC')
.replace(/[\u0000-\u001f\u007f<>`{}[\]\\]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength)
const normalizeContentList = (items, fallback, limit, titleLimit, descriptionLimit) => {
const source = Array.isArray(items) && items.length ? items : fallback
return source.slice(0, limit).map((item, index) => ({
title: sanitizeText(item?.title, titleLimit) || fallback[index]?.title || `Item ${index + 1}`,
description: sanitizeText(item?.description, descriptionLimit) || fallback[index]?.description || '',
}))
}
const normalizeEventList = (items, fallback, limits) => {
const source = Array.isArray(items) && items.length ? items : fallback
return source.slice(0, limits.maxEvents).map((item, index) => ({
title: sanitizeText(item?.title, limits.eventTitle) || fallback[index]?.title || `Event ${index + 1}`,
description: sanitizeText(item?.description, limits.eventDescription) || fallback[index]?.description || '',
date: sanitizeText(item?.date, limits.eventDate) || fallback[index]?.date || '',
price: sanitizeText(item?.price, limits.eventPrice) || fallback[index]?.price || '',
image: sanitizeText(item?.image, limits.eventImage) || fallback[index]?.image || '/images/bg-1.avif',
}))
}
const normalizeSiteContent = (content = {}) => {
const limits = contentLimits.value
const homepage = content.homepage || {}
const defaults = defaultSiteContent.homepage
return {
homepage: {
hero: {
line1: sanitizeText(homepage.hero?.line1, limits.heroLine) || defaults.hero.line1,
line2: sanitizeText(homepage.hero?.line2, limits.heroLine) || defaults.hero.line2,
benefitsCue: sanitizeText(homepage.hero?.benefitsCue, limits.cue) || defaults.hero.benefitsCue,
},
members: {
title: sanitizeText(homepage.members?.title, limits.sectionTitle) || defaults.members.title,
benefits: normalizeContentList(homepage.members?.benefits, defaults.members.benefits, limits.maxBenefits, limits.itemTitle, limits.itemDescription),
},
facilities: {
title: sanitizeText(homepage.facilities?.title, limits.sectionTitle) || defaults.facilities.title,
items: normalizeContentList(homepage.facilities?.items, defaults.facilities.items, limits.maxFacilities, limits.itemTitle, limits.itemDescription),
},
events: {
navLabel: sanitizeText(homepage.events?.navLabel, limits.navLabel) || defaults.events.navLabel,
title: sanitizeText(homepage.events?.title, limits.sectionTitle) || defaults.events.title,
description: sanitizeText(homepage.events?.description, limits.eventDescription) || defaults.events.description,
enquiryTitle: sanitizeText(homepage.events?.enquiryTitle, limits.sectionTitle) || defaults.events.enquiryTitle,
items: normalizeEventList(homepage.events?.items, defaults.events.items, limits),
},
},
}
}
const getUserId = () => {
let userId = localStorage.getItem(USER_ID_KEY)
if (!userId) {
userId = `l484-user-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
localStorage.setItem(USER_ID_KEY, userId)
}
return userId
}
const sha256Hex = async (value) => {
return bytesToHex(sha256(new TextEncoder().encode(value)))
}
const adminHeaders = (pubkey = adminUser.value) => (pubkey ? { Authorization: `Bearer ${pubkey}` } : {})
const fetchJson = async (url, options = {}) => {
const response = await fetch(url, {
...options,
headers: {
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {}),
},
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.error || data.message || `Request failed: ${response.status}`)
}
return data
}
const loadBitcoinPrice = async () => {
bitcoinPriceError.value = ''
if (appConfig.value.mode === 'legacy') return
try {
const data = await fetchJson('/api/bitcoin-price')
const price = Number(data.usd)
if (!Number.isFinite(price) || price <= 0) throw new Error('Bitcoin price unavailable.')
bitcoinUsdPrice.value = price
bitcoinPriceIsLive.value = !data.fallback
bitcoinPriceFetchedAt.value = data.fetchedAt ? new Date(data.fetchedAt).toISOString() : new Date().toISOString()
} catch (error) {
bitcoinPriceError.value = error instanceof Error ? error.message : 'Bitcoin price unavailable.'
bitcoinPriceIsLive.value = false
}
}
const copyToClipboard = async (value, label) => {
let copied = false
if (navigator.clipboard?.writeText && window.isSecureContext) {
try {
await navigator.clipboard.writeText(value)
copied = true
} catch {
copied = false
}
}
if (!copied) {
const textarea = document.createElement('textarea')
textarea.value = value
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '0'
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
document.execCommand('copy')
textarea.remove()
}
copiedKey.value = label
window.setTimeout(() => {
if (copiedKey.value === label) copiedKey.value = ''
}, 1600)
}
const sanitizeForm = () => {
form.fullName = sanitizeText(form.fullName, MAX_NAME_LENGTH)
form.email = sanitizeText(form.email, MAX_EMAIL_LENGTH).toLowerCase()
form.phone = sanitizeText(form.phone, MAX_PHONE_LENGTH)
}
const isValidDateString = (value) => {
const date = new Date(value)
return typeof value === 'string' && !Number.isNaN(date.getTime())
}
const validateApplicant = (showError = true) => {
const fullName = sanitizeText(form.fullName, MAX_NAME_LENGTH)
const email = sanitizeText(form.email, MAX_EMAIL_LENGTH).toLowerCase()
const phone = sanitizeText(form.phone, MAX_PHONE_LENGTH)
if (fullName.length < 2) {
if (showError) formError.value = 'Enter a valid full name.'
return false
}
if (email && !EMAIL_PATTERN.test(email)) {
if (showError) formError.value = 'Enter a valid email address or leave it blank.'
return false
}
if (phone && !PHONE_PATTERN.test(phone)) {
if (showError) formError.value = 'Enter a valid phone number or leave it blank.'
return false
}
return true
}
const normalizeMember = (value) => {
if (!value || typeof value !== 'object') return null
const membershipId = sanitizeText(value.membershipId, 24).toUpperCase()
const fullName = sanitizeText(value.fullName, MAX_NAME_LENGTH)
const email = sanitizeText(value.email, MAX_EMAIL_LENGTH).toLowerCase()
const phone = sanitizeText(value.phone, MAX_PHONE_LENGTH)
const signature = String(value.signature || '')
const signedDate = String(value.signedDate || '')
const createdAt = String(value.createdAt || '')
const expiresAt = String(value.expiresAt || '')
const userId = sanitizeText(value.userId, 80)
const npub = sanitizeText(value.npub, 80)
const nsecHash = sanitizeText(value.nsecHash, 64).toLowerCase()
const signerNpubs = Array.isArray(value.signerNpubs)
? value.signerNpubs.map((item) => sanitizeText(item, 80)).filter((item) => NPUB_PATTERN.test(item))
: []
if (!MEMBERSHIP_ID_PATTERN.test(membershipId)) return null
if (fullName.length < 2) return null
if (email && !EMAIL_PATTERN.test(email)) return null
if (phone && !PHONE_PATTERN.test(phone)) return null
if (signature && !DATA_IMAGE_PATTERN.test(signature)) return null
if (!isValidDateString(signedDate) || !isValidDateString(createdAt) || !isValidDateString(expiresAt)) return null
return {
membershipId,
fullName,
email,
phone,
userId,
npub,
signerNpubs,
nsecHash,
signature,
signedDate,
createdAt,
expiresAt,
status: sanitizeText(value.status || 'requested', 32),
accessStatus: sanitizeText(value.accessStatus || value.status || 'requested', 32),
}
}
const loadAppConfig = async () => {
try {
appConfig.value = await fetchJson('/api/config')
} catch {
appConfig.value = { mode: 'legacy', adminEnabled: true, publicMembershipEnabled: true, accessEnabled: true }
}
}
const loadSiteContent = async () => {
try {
const data = await fetchJson('/api/site-content')
siteContentLimits.value = { ...defaultContentLimits, ...(data.limits || {}) }
siteContent.value = normalizeSiteContent(data.content || defaultSiteContent)
if (!siteContentDirty.value) siteContentDraft.value = cloneContent(siteContent.value)
} catch (error) {
console.warn('Could not load site content:', error)
siteContent.value = normalizeSiteContent(defaultSiteContent)
if (!siteContentDirty.value) siteContentDraft.value = cloneContent(siteContent.value)
}
}
const loadAdminAccessRequests = async () => {
if (!adminUser.value) return
try {
const data = await fetchJson('/api/admin/access-requests', { headers: adminHeaders() })
adminAccessRequests.value = Array.isArray(data.requests) ? data.requests : []
adminIsMaster.value = Boolean(data.isMasterAdmin)
} catch (error) {
console.warn('Could not load admin access requests:', error)
adminAccessRequests.value = []
adminIsMaster.value = false
}
}
const loadNotificationStats = async () => {
if (!adminUser.value) return
try {
const data = await fetchJson('/api/admin/notifications/stats', { headers: adminHeaders() })
notificationStats.value = data
} catch (error) {
notificationStats.value = { configured: false, subscriberCount: 0 }
}
}
const loadMembers = async () => {
try {
const parsed = JSON.parse(localStorage.getItem(MEMBERS_KEY) || '[]')
members.value = Array.isArray(parsed) ? parsed.map(normalizeMember).filter(Boolean) : []
currentMemberId.value = sanitizeText(localStorage.getItem(CURRENT_MEMBER_KEY), 24).toUpperCase()
saveMembers()
} catch {
members.value = []
currentMemberId.value = ''
}
try {
if (isAdminAuthenticated.value) {
const data = appConfig.value.adminEnabled
? await fetchJson('/api/admin/state', { headers: adminHeaders() }).catch(async (error) => {
const fallback = await fetchJson('/api/memberships', { headers: adminHeaders() })
return { ...fallback, payments: [], cards: [], accessLogs: [] }
})
: { memberships: [], payments: [], cards: [], accessLogs: [] }
members.value = Array.isArray(data.memberships) ? data.memberships.map(normalizeMember).filter(Boolean) : []
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()
saveMembers()
return
}
const userId = localStorage.getItem(USER_ID_KEY)
if (userId) {
const data = await fetchJson(`/api/membership/check?userId=${encodeURIComponent(userId)}`)
if (data.hasMembership && data.membership) {
const member = normalizeMember(data.membership)
if (member) {
members.value = [member, ...members.value.filter((item) => item.membershipId !== member.membershipId)]
currentMemberId.value = member.membershipId
localStorage.setItem(CURRENT_MEMBER_KEY, member.membershipId)
saveMembers()
}
}
}
} catch (error) {
console.warn('Could not sync memberships with server:', error)
}
}
const adminNotAuthorizedMessage = 'This npub is not an admin. Please request access and we will authorise it if you are permissioned.'
const loadAdminSession = async () => {
adminSessionChecked.value = false
isAdminSessionChecking.value = isAdminLikeRoute.value
try {
const stored = JSON.parse(localStorage.getItem(ADMIN_AUTH_KEY) || 'null')
const storedPubkey = stored?.pubkey?.toLowerCase() || ''
if (!storedPubkey) return
await verifyAdminSession(storedPubkey)
adminUser.value = storedPubkey
} catch {
adminUser.value = ''
localStorage.removeItem(ADMIN_AUTH_KEY)
} finally {
adminSessionChecked.value = true
isAdminSessionChecking.value = false
}
}
const saveMembers = () => {
localStorage.setItem(MEMBERS_KEY, JSON.stringify(members.value))
}
const clampSignupStep = (step) => {
const numericStep = Number(step)
if (!Number.isFinite(numericStep)) return 0
return Math.min(5, Math.max(0, Math.round(numericStep)))
}
const saveSignupDraft = (step = signupStep.value) => {
if (currentMember.value || signupStep.value === 5) return
localStorage.setItem(SIGNUP_DRAFT_KEY, JSON.stringify({
step: clampSignupStep(step),
form: {
fullName: form.fullName,
email: form.email,
phone: form.phone,
accepted: form.accepted,
signature: form.signature,
},
savedAt: Date.now(),
}))
}
const clearSignupDraft = () => {
localStorage.removeItem(SIGNUP_DRAFT_KEY)
}
const restoreSignupDraft = () => {
try {
const draft = JSON.parse(localStorage.getItem(SIGNUP_DRAFT_KEY) || 'null')
if (!draft || typeof draft !== 'object') return 0
form.fullName = sanitizeText(draft.form?.fullName, MAX_NAME_LENGTH)
form.email = sanitizeText(draft.form?.email, MAX_EMAIL_LENGTH)
form.phone = sanitizeText(draft.form?.phone, MAX_PHONE_LENGTH)
form.accepted = Boolean(draft.form?.accepted)
form.signature = DATA_IMAGE_PATTERN.test(draft.form?.signature || '') ? draft.form.signature : ''
signatureStrokes.value = []
activeSignatureStroke = null
signatureHasInk.value = Boolean(form.signature)
const restoredStep = clampSignupStep(draft.step)
if (restoredStep === 1 && isPwaStandalone.value) return 2
return restoredStep
} catch {
clearSignupDraft()
return 0
}
}
const detectInstallPlatform = () => {
const ua = navigator.userAgent || ''
const isIos = /iphone|ipad|ipod/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
if (isIos) return 'ios'
if (/android/i.test(ua)) return 'android'
return 'desktop'
}
const refreshPwaStandalone = () => {
isPwaStandalone.value = Boolean(
window.matchMedia?.('(display-mode: standalone)').matches ||
window.navigator.standalone,
)
}
const handleBeforeInstallPrompt = (event) => {
event.preventDefault()
deferredInstallPrompt.value = event
pwaInstallMessage.value = ''
}
const handlePwaInstalled = () => {
deferredInstallPrompt.value = null
saveSignupDraft(2)
refreshPwaStandalone()
signupStep.value = 2
}
const handlePwaInstallPrimary = async () => {
formError.value = ''
pwaInstallMessage.value = ''
refreshPwaStandalone()
if (isPwaStandalone.value) {
signupStep.value = 2
return
}
if (deferredInstallPrompt.value) {
const promptEvent = deferredInstallPrompt.value
deferredInstallPrompt.value = null
await promptEvent.prompt()
const choice = await promptEvent.userChoice.catch(() => null)
if (choice?.outcome === 'accepted') {
saveSignupDraft(2)
signupStep.value = 2
return
}
signupStep.value = 2
return
}
if (installPlatform.value === 'desktop' || installPlatform.value === 'android') {
pwaInstallMessage.value = 'Use your browser menu or address-bar install icon to install L484. Choose Skip app to continue in this browser.'
return
}
pwaInstallMessage.value = 'Install L484 from Safari using Share, Add to Home Screen, then reopen it from the app icon.'
}
const continueWithoutInstall = () => {
formError.value = ''
pwaInstallMessage.value = ''
signupStep.value = 2
}
const startSignup = async () => {
formError.value = ''
refreshPwaStandalone()
installPlatform.value = detectInstallPlatform()
if (isPwaStandalone.value) {
signupStep.value = 2
return
}
signupStep.value = 1
}
const openSignup = () => {
isSignupOpen.value = true
loadBitcoinPrice()
refreshPwaStandalone()
installPlatform.value = detectInstallPlatform()
createdMember.value = currentMember.value
signupStep.value = currentMember.value ? 5 : restoreSignupDraft()
isCardRevealing.value = false
generatedCredentials.value = null
signupNotificationsEnabled.value = false
formError.value = ''
}
const openMemberSignin = () => {
isMemberSigninOpen.value = true
memberSigninError.value = ''
}
const navigateTo = (path) => {
window.history.pushState({}, '', path)
currentPath.value = window.location.pathname
}
const navigateAndTop = (path) => {
navigateTo(path)
mobileMenuOpen.value = false
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const toggleMobileMenu = () => {
mobileMenuOpen.value = !mobileMenuOpen.value
}
const closeSignup = () => {
saveSignupDraft()
isSignupOpen.value = false
isCardRevealing.value = false
generatedCredentials.value = null
signupNotificationsEnabled.value = false
}
const closeMemberSignin = () => {
isMemberSigninOpen.value = false
memberSigninError.value = ''
isRemoteSignerLoading.value = false
cancelPendingRemoteAppLogin()
}
const signOutMember = () => {
clearSigner()
cancelPendingRemoteAppLogin()
localStorage.removeItem('l484-member-keys')
clearSignupDraft()
currentMemberId.value = ''
createdMember.value = null
generatedCredentials.value = null
signupNotificationsEnabled.value = false
isSignupOpen.value = false
isMemberSigninOpen.value = false
isRemoteSignerLoading.value = false
isCreatingMembership.value = false
isMemberSigninLoading.value = false
memberSigninError.value = ''
localStorage.removeItem(CURRENT_MEMBER_KEY)
}
const resetForm = () => {
form.fullName = ''
form.email = ''
form.phone = ''
form.signature = ''
form.accepted = false
formError.value = ''
signatureStrokes.value = []
activeSignatureStroke = null
signatureHasInk.value = false
}
const nextStep = () => {
formError.value = ''
sanitizeForm()
if (signupStep.value === 2 && !validateApplicant()) {
return
}
if (signupStep.value !== 2 && !canContinue.value) {
formError.value = 'Please complete the required fields before continuing.'
return
}
signupStep.value += 1
}
const previousStep = () => {
formError.value = ''
signupStep.value = Math.max(0, signupStep.value - 1)
}
const completeMemberSignerLogin = async (pubkey) => {
const npub = nip19.npubEncode(pubkey)
const cachedMember = members.value.find((item) => item.npub === npub || item.signerNpubs?.includes(npub))
if (cachedMember) {
members.value = [cachedMember, ...members.value.filter((item) => item.membershipId !== cachedMember.membershipId)]
currentMemberId.value = cachedMember.membershipId
createdMember.value = cachedMember
localStorage.setItem(CURRENT_MEMBER_KEY, cachedMember.membershipId)
if (cachedMember.userId) localStorage.setItem(USER_ID_KEY, cachedMember.userId)
saveMembers()
localStorage.setItem(SIGNER_LOGIN_COMPLETE_KEY, JSON.stringify({
membershipId: cachedMember.membershipId,
completedAt: Date.now(),
}))
closeMemberSignin()
openSignup()
return
}
const data = await fetchJson(`/api/membership/check?npub=${encodeURIComponent(npub)}`)
const member = normalizeMember(data.membership)
if (!data.hasMembership || !member) {
throw new Error(`Signer connected as ${npub.slice(0, 12)}...${npub.slice(-8)}, but no L484 membership is attached to that npub. Import the nsec issued at signup into this signer, or import your encrypted member file.`)
}
members.value = [member, ...members.value.filter((item) => item.membershipId !== member.membershipId)]
currentMemberId.value = member.membershipId
createdMember.value = member
localStorage.setItem(CURRENT_MEMBER_KEY, member.membershipId)
if (member.userId) localStorage.setItem(USER_ID_KEY, member.userId)
saveMembers()
localStorage.setItem(SIGNER_LOGIN_COMPLETE_KEY, JSON.stringify({
membershipId: member.membershipId,
completedAt: Date.now(),
}))
closeMemberSignin()
openSignup()
}
const loginMemberWithExtension = async () => {
memberSigninError.value = ''
isMemberSigninLoading.value = true
try {
await completeMemberSignerLogin(await loginWithExtension())
} catch (error) {
memberSigninError.value = error instanceof Error ? error.message : 'Sign in failed.'
} finally {
isMemberSigninLoading.value = false
}
}
const loginMemberWithRemoteApp = async () => {
memberSigninError.value = ''
isRemoteSignerLoading.value = true
sessionStorage.setItem(SIGNER_LOGIN_CONTEXT_KEY, 'member')
try {
await completeMemberSignerLogin(await loginWithRemoteApp())
} catch (error) {
memberSigninError.value = error instanceof Error ? error.message : 'Remote signer login failed.'
} finally {
isRemoteSignerLoading.value = false
}
}
const resumePendingRemoteSignin = async () => {
if (!hasPendingRemoteAppLogin() || isRemoteSignerLoading.value) return
if (sessionStorage.getItem(SIGNER_LOGIN_CONTEXT_KEY) === 'admin') {
await resumePendingAdminRemoteSignin()
return
}
isMemberSigninOpen.value = true
memberSigninError.value = ''
isRemoteSignerLoading.value = true
try {
await completeMemberSignerLogin(await resumeRemoteAppLogin())
} catch (error) {
memberSigninError.value = error instanceof Error ? error.message : 'Remote signer login failed.'
} finally {
isRemoteSignerLoading.value = false
if (window.location.pathname === '/auth/nostr-callback') {
window.history.replaceState({}, '', '/')
currentPath.value = '/'
}
}
}
const handleSignerCompletion = async () => {
if (sessionStorage.getItem(SIGNER_LOGIN_CONTEXT_KEY) === 'admin') {
sessionStorage.removeItem(SIGNER_LOGIN_CONTEXT_KEY)
return
}
await loadMembers()
if (!currentMember.value) return
isRemoteSignerLoading.value = false
isMemberSigninLoading.value = false
isMemberSigninOpen.value = false
memberSigninError.value = ''
}
const createMembership = async () => {
formError.value = ''
sanitizeForm()
isCreatingMembership.value = true
if (!validateApplicant()) {
isCreatingMembership.value = false
return
}
if (!canContinue.value) {
formError.value = 'Please sign the agreement before creating your membership.'
isCreatingMembership.value = false
return
}
try {
await requestNotificationPermission()
signupNotificationsEnabled.value = true
subscribeToNotificationsInBackground()
} catch (error) {
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.setMonth(expiresAt.getMonth() + 1)
const signerPubkey = await getActiveSignerPublicKey().catch(() => '')
const privateKey = generateSecretKey()
const pubkey = getPublicKey(privateKey)
const nsec = nip19.nsecEncode(privateKey)
const npub = nip19.npubEncode(pubkey)
const signerNpub = signerPubkey && signerPubkey !== pubkey ? nip19.npubEncode(signerPubkey) : ''
const member = {
membershipId: `L484-${createdAt.getFullYear()}-${Math.random().toString(36).slice(2, 8).toUpperCase()}`,
userId: getUserId(),
fullName: form.fullName.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
npub,
signerNpubs: signerNpub ? [signerNpub] : [],
nsecHash: await sha256Hex(nsec),
signature: form.signature.trim(),
signedDate: createdAt.toISOString(),
createdAt: createdAt.toISOString(),
expiresAt: expiresAt.toISOString(),
status: 'requested',
}
try {
const result = await fetchJson('/api/membership/create', {
method: 'POST',
body: JSON.stringify(member),
})
const savedMember = normalizeMember(result.membership) || member
members.value = [savedMember, ...members.value.filter((item) => item.membershipId !== savedMember.membershipId)]
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()
clearSignupDraft()
resetForm()
isCardRevealing.value = true
signupStep.value = 5
window.setTimeout(() => {
isCardRevealing.value = false
}, 2400)
} catch (error) {
formError.value = error instanceof Error ? error.message : 'Could not save membership.'
} finally {
isCreatingMembership.value = false
}
}
const syncSignatureCanvas = () => {
const canvas = signatureCanvas.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
if (rect.width < 1 || rect.height < 1) {
window.requestAnimationFrame(syncSignatureCanvas)
return
}
const ratio = Math.max(window.devicePixelRatio || 1, 1)
canvas.width = Math.round(rect.width * ratio)
canvas.height = Math.round(rect.height * ratio)
const context = canvas.getContext('2d')
context.setTransform(ratio, 0, 0, ratio, 0, 0)
context.fillStyle = '#080808'
context.fillRect(0, 0, rect.width, rect.height)
context.lineCap = 'round'
context.lineJoin = 'round'
context.lineWidth = 2.4
context.strokeStyle = '#ffffff'
if (signatureStrokes.value.length) {
signatureStrokes.value.forEach((stroke) => {
if (!stroke.length) return
context.beginPath()
context.moveTo(stroke[0].x * rect.width, stroke[0].y * rect.height)
stroke.slice(1).forEach((point) => context.lineTo(point.x * rect.width, point.y * rect.height))
context.stroke()
})
form.signature = canvas.toDataURL('image/png')
signatureHasInk.value = true
return
}
if (DATA_IMAGE_PATTERN.test(form.signature)) {
const expectedSignature = form.signature
const savedSignature = new Image()
savedSignature.onload = () => {
if (form.signature !== expectedSignature || signatureStrokes.value.length) return
context.drawImage(savedSignature, 0, 0, rect.width, rect.height)
context.lineCap = 'round'
context.lineJoin = 'round'
context.lineWidth = 2.4
context.strokeStyle = '#ffffff'
}
savedSignature.src = form.signature
}
}
const getSignaturePoint = (event) => {
const rect = signatureCanvas.value.getBoundingClientRect()
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
width: rect.width,
height: rect.height,
}
}
const startSignature = (event) => {
if (!signatureCanvas.value) return
isSigning = true
signatureCanvas.value.setPointerCapture?.(event.pointerId)
const context = signatureCanvas.value.getContext('2d')
const point = getSignaturePoint(event)
activeSignatureStroke = [{ x: point.x / point.width, y: point.y / point.height }]
signatureStrokes.value.push(activeSignatureStroke)
context.beginPath()
context.moveTo(point.x, point.y)
}
const drawSignature = (event) => {
if (!isSigning || !signatureCanvas.value) return
const context = signatureCanvas.value.getContext('2d')
const point = getSignaturePoint(event)
activeSignatureStroke?.push({ x: point.x / point.width, y: point.y / point.height })
context.lineTo(point.x, point.y)
context.stroke()
signatureHasInk.value = true
form.signature = signatureCanvas.value.toDataURL('image/png')
}
const endSignature = () => {
isSigning = false
activeSignatureStroke = null
}
const clearSignature = () => {
form.signature = ''
signatureStrokes.value = []
activeSignatureStroke = null
signatureHasInk.value = false
syncSignatureCanvas()
}
const deleteMember = async (membershipId) => {
const member = members.value.find((item) => item.membershipId === membershipId)
const label = member?.fullName || membershipId
if (!window.confirm(`Delete ${label}? This removes the member, payments, and issued cards from the admin store.`)) return
if (isAdminAuthenticated.value) {
try {
await fetchJson('/api/membership', {
method: 'DELETE',
headers: adminHeaders(),
body: JSON.stringify({ membershipId }),
})
} catch (error) {
console.warn('Could not delete membership from server:', error)
}
}
members.value = members.value.filter((member) => member.membershipId !== membershipId)
if (currentMemberId.value === membershipId) {
currentMemberId.value = ''
localStorage.removeItem(CURRENT_MEMBER_KEY)
}
saveMembers()
}
const refreshAdminState = async () => {
if (!isAdminAuthenticated.value) return
await loadMembers()
}
const stopCardReaderPolling = () => {
window.clearInterval(cardReaderPollTimer)
cardReaderPollTimer = 0
}
const pollCardReader = async () => {
if (!isCardReaderOpen.value || !isAdminAuthenticated.value) return
try {
await refreshAdminState()
cardReaderError.value = ''
} catch (error) {
cardReaderError.value = error instanceof Error ? error.message : 'Could not refresh card reader events.'
}
}
const startCardReaderPolling = () => {
stopCardReaderPolling()
cardReaderPollTimer = window.setInterval(pollCardReader, 1800)
}
const openCardReader = async () => {
isAdminMenuOpen.value = false
isCardReaderOpen.value = true
cardReaderError.value = ''
await refreshAdminState().catch((error) => {
cardReaderError.value = error instanceof Error ? error.message : 'Could not load card reader events.'
})
cardReaderBaselineId.value = latestAccessLog.value?.id || ''
cardReaderLastSeenId.value = latestAccessLog.value?.id || ''
startCardReaderPolling()
}
const closeCardReader = () => {
isCardReaderOpen.value = false
stopCardReaderPolling()
}
const disconnectAdminEvents = () => {
window.clearTimeout(adminEventsReconnectTimer)
adminEventsReconnectTimer = 0
if (!adminEvents) return
adminEvents.close()
adminEvents = null
}
const connectAdminEvents = () => {
window.clearTimeout(adminEventsReconnectTimer)
adminEventsReconnectTimer = 0
if (!isAdminAuthenticated.value || adminEvents || typeof EventSource === 'undefined') return
adminEvents = new EventSource(`/api/admin/events?pubkey=${encodeURIComponent(adminUser.value)}`)
adminEvents.addEventListener('payment-paid', async (event) => {
const payload = JSON.parse(event.data || '{}')
if (payload.invoiceId && payload.invoiceId === paymentModalInvoice.value?.id) {
adminActionMessage.value = 'Bitcoin invoice paid.'
await refreshPaymentStatus()
}
await refreshAdminState()
})
adminEvents.addEventListener('membership-created', async () => {
adminActionMessage.value = 'New member request received.'
await refreshAdminState()
})
adminEvents.onerror = () => {
if (adminEvents) {
adminEvents.close()
adminEvents = null
}
if (isAdminAuthenticated.value && !adminEventsReconnectTimer) {
adminEventsReconnectTimer = window.setTimeout(connectAdminEvents, 3000)
}
}
}
const updateMemberStatus = async (membershipId, status) => {
adminActionError.value = ''
adminActionMessage.value = ''
try {
await fetchJson('/api/membership/status', {
method: 'PATCH',
headers: adminHeaders(),
body: JSON.stringify({ membershipId, status }),
})
adminActionMessage.value = `Member marked ${status.replace('_', ' ')}.`
await refreshAdminState()
} catch (error) {
adminActionError.value = error instanceof Error ? error.message : 'Could not update member status.'
}
}
const markSiteContentDirty = () => {
siteContentDirty.value = true
}
const resetSiteContentDraft = () => {
siteContentDraft.value = cloneContent(siteContent.value)
siteContentDirty.value = false
adminActionError.value = ''
adminActionMessage.value = ''
}
const saveSiteContent = async () => {
adminActionError.value = ''
adminActionMessage.value = ''
isSiteContentLoading.value = true
try {
const content = normalizeSiteContent(siteContentDraft.value)
const data = await fetchJson('/api/admin/site-content', {
method: 'PUT',
headers: adminHeaders(),
body: JSON.stringify({ content }),
})
siteContentLimits.value = { ...defaultContentLimits, ...(data.limits || {}) }
siteContent.value = normalizeSiteContent(data.content || content)
siteContentDraft.value = cloneContent(siteContent.value)
siteContentDirty.value = false
adminActionMessage.value = 'Homepage content saved.'
} catch (error) {
adminActionError.value = error instanceof Error ? error.message : 'Could not save homepage content.'
} finally {
isSiteContentLoading.value = false
}
}
const requestAdminAccess = async () => {
adminRequestError.value = ''
adminRequestMessage.value = ''
const secretKey = generateSecretKey()
const pubkey = getPublicKey(secretKey)
const nsec = nip19.nsecEncode(secretKey)
const npub = nip19.npubEncode(pubkey)
const displayName = sanitizeText(adminRequestName.value, MAX_NAME_LENGTH) || 'Admin request'
adminRequestCredentials.value = { nsec, npub, pubkey, status: 'generated' }
try {
const result = await fetchJson('/api/admin/request-access', {
method: 'POST',
body: JSON.stringify({ displayName, pubkey, npub }),
})
adminRequestCredentials.value = { nsec, npub, pubkey, status: result.request?.status || 'requested' }
adminRequestName.value = ''
adminRequestMessage.value = result.request?.status === 'approved'
? 'Admin key is already approved.'
: 'Admin access requested. Store this nsec now; it will not be shown again.'
} catch (error) {
const message = error instanceof Error ? error.message : 'Could not request admin access.'
adminRequestMessage.value = 'Admin key generated. Store this nsec now; it will not be shown again.'
adminRequestError.value = message === 'Not found.'
? 'The running backend does not have the admin request endpoint yet. Restart or redeploy the server, then generate a fresh request.'
: message
}
}
const updateAdminAccessRequest = async (id, status) => {
adminActionError.value = ''
adminActionMessage.value = ''
if (!adminIsMaster.value) {
adminActionError.value = 'Master admin access required.'
return
}
try {
await fetchJson('/api/admin/access-requests/status', {
method: 'POST',
headers: adminHeaders(),
body: JSON.stringify({ id, status }),
})
adminActionMessage.value = `Admin request ${status}.`
await loadAdminAccessRequests()
} catch (error) {
adminActionError.value = error instanceof Error ? error.message : 'Could not update admin request.'
}
}
const addEventItem = () => {
const events = siteContentDraft.value.homepage.events.items
if (events.length >= contentLimits.value.maxEvents) {
adminActionError.value = `Maximum ${contentLimits.value.maxEvents} events.`
return
}
eventEditorForm.title = ''
eventEditorForm.date = ''
eventEditorForm.price = ''
eventEditorForm.image = '/images/bg-1.avif'
eventEditorForm.description = ''
isEventEditorOpen.value = true
}
const closeEventEditor = () => {
isEventEditorOpen.value = false
}
const createEventFromModal = () => {
const limits = contentLimits.value
const title = sanitizeText(eventEditorForm.title, limits.eventTitle)
if (!title) {
adminActionError.value = 'Event title is required.'
return
}
siteContentDraft.value.homepage.events.items.push({
title,
date: sanitizeText(eventEditorForm.date, limits.eventDate) || 'By enquiry',
price: sanitizeText(eventEditorForm.price, limits.eventPrice) || 'Custom',
image: sanitizeText(eventEditorForm.image, limits.eventImage) || '/images/bg-1.avif',
description: sanitizeText(eventEditorForm.description, limits.eventDescription),
})
isEventEditorOpen.value = false
markSiteContentDirty()
}
const deleteEventItem = (index) => {
siteContentDraft.value.homepage.events.items.splice(index, 1)
markSiteContentDirty()
}
const isEventDirty = (index) => {
const draft = siteContentDraft.value.homepage.events.items[index]
const saved = siteContent.value.homepage?.events?.items?.[index]
return JSON.stringify(draft || null) !== JSON.stringify(saved || null)
}
const updateEventItem = async (index) => {
if (!isEventDirty(index)) return
await saveSiteContent()
}
const enableNotifications = async () => {
notificationError.value = ''
notificationMessage.value = ''
try {
const result = await subscribeToNotifications()
notificationMessage.value = `Notifications enabled. ${result.subscriberCount || 1} device(s) subscribed.`
await loadNotificationStats()
} catch (error) {
notificationError.value = error instanceof Error ? error.message : 'Could not enable notifications.'
}
}
const sendTestNotification = async () => {
notificationError.value = ''
notificationMessage.value = ''
try {
const result = await fetchJson('/api/admin/notifications/send', {
method: 'POST',
headers: adminHeaders(),
body: JSON.stringify({
title: 'L484 notification test',
message: 'Push notifications are configured.',
url: '/edit',
}),
})
notificationMessage.value = `Sent to ${result.sent || 0} device(s).`
await loadNotificationStats()
} catch (error) {
notificationError.value = error instanceof Error ? error.message : 'Could not send notification.'
}
}
const submitEventEnquiry = () => {
eventEnquiry.name = sanitizeText(eventEnquiry.name, MAX_NAME_LENGTH)
eventEnquiry.email = sanitizeText(eventEnquiry.email, MAX_EMAIL_LENGTH).toLowerCase()
eventEnquiry.message = sanitizeText(eventEnquiry.message, 500)
eventEnquiryMessage.value = 'Thanks. Your event enquiry is ready for follow-up.'
eventEnquiry.name = ''
eventEnquiry.email = ''
eventEnquiry.message = ''
}
const paymentProviderLabel = (provider) => {
if (provider === 'btcpay') return 'Bitcoin'
if (provider === 'comp') return 'Comp'
return 'Cash'
}
const markManualPayment = async (membershipId, months = 1, provider = 'manual') => {
adminActionError.value = ''
adminActionMessage.value = ''
const isComp = provider === 'comp'
try {
const result = await fetchJson('/api/payment/manual', {
method: 'POST',
headers: adminHeaders(),
body: JSON.stringify({ membershipId, provider, months }),
})
if (result.payment) {
payments.value = [result.payment, ...payments.value.filter((payment) => payment.id !== result.payment.id)]
}
if (result.membership) {
members.value = members.value.map((member) =>
member.membershipId === result.membership.membershipId ? normalizeMember(result.membership) : member,
)
saveMembers()
}
adminActionMessage.value = isComp ? 'Comp membership recorded.' : 'Cash payment recorded.'
await refreshAdminState().catch((error) => {
console.warn(`Could not refresh admin state after ${isComp ? 'comp' : 'cash'} payment:`, error)
})
} catch (error) {
adminActionError.value = error instanceof Error ? error.message : `Could not record ${isComp ? 'comp membership' : 'cash payment'}.`
}
}
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, months }),
})
adminActionMessage.value = 'Bitcoin invoice created.'
if (result.payment?.checkoutLink) window.open(result.payment.checkoutLink, '_blank', 'noopener,noreferrer')
await refreshAdminState()
} catch (error) {
adminActionError.value = error instanceof Error ? error.message : 'Could not create Bitcoin invoice.'
}
}
const issueCard = async (membershipId) => {
const cardCredential = sanitizeText(cardCredentialInputs[membershipId], 160)
adminActionError.value = ''
adminActionMessage.value = ''
try {
await fetchJson('/api/card/issue', {
method: 'POST',
headers: adminHeaders(),
body: JSON.stringify({ membershipId, cardCredential }),
})
cardCredentialInputs[membershipId] = ''
adminActionMessage.value = 'NFC card activated.'
await refreshAdminState()
} catch (error) {
adminActionError.value = error instanceof Error ? error.message : 'Could not activate card.'
}
}
const simulateAccessCheck = async (membershipId) => {
const cardCredential = sanitizeText(cardCredentialInputs[membershipId], 160)
adminActionError.value = ''
adminActionMessage.value = ''
try {
const result = await fetchJson('/api/access/check', {
method: 'POST',
headers: adminHeaders(),
body: JSON.stringify({ doorId: 'mock-front-door', cardCredential }),
})
adminActionMessage.value = result.allow ? 'Mock access allowed.' : `Mock access denied: ${result.reason}`
await refreshAdminState()
} catch (error) {
adminActionError.value = error instanceof Error ? error.message : 'Could not run access check.'
}
}
const paymentForMember = (membershipId) =>
payments.value.find((payment) => payment.membershipId === membershipId && payment.status === 'paid')
const latestPaymentForMember = (membershipId) =>
payments.value.find((payment) => payment.membershipId === membershipId)
const cardForMember = (membershipId) =>
cards.value.find((card) => card.membershipId === membershipId && card.status === 'active')
const openAgreement = (member) => {
selectedAgreementMember.value = member
isAgreementOpen.value = true
}
const closeAgreement = () => {
isAgreementOpen.value = false
selectedAgreementMember.value = null
}
const deleteSelectedAgreementMember = async () => {
if (!selectedAgreementMember.value) return
const membershipId = selectedAgreementMember.value.membershipId
closeAgreement()
await deleteMember(membershipId)
}
const openPayment = (member) => {
selectedPaymentMember.value = member
paymentModalInvoice.value = null
paymentModalError.value = ''
paymentInvoiceMethod.value = 'lightning'
paymentMonths.value = 1
isPaymentOpen.value = true
}
const closePayment = () => {
isPaymentOpen.value = false
selectedPaymentMember.value = null
paymentModalInvoice.value = null
paymentModalError.value = ''
paymentInvoiceMethod.value = 'lightning'
isPaymentModalLoading.value = false
paymentModalLoadingMethod.value = ''
paymentMonths.value = 1
}
const takeCashPayment = async () => {
if (!selectedPaymentMember.value) return
isPaymentModalLoading.value = true
paymentModalLoadingMethod.value = 'cash'
paymentModalError.value = ''
await markManualPayment(selectedPaymentMember.value.membershipId, paymentMonths.value)
isPaymentModalLoading.value = false
paymentModalLoadingMethod.value = ''
if (!adminActionError.value) closePayment()
}
const compMembership = async () => {
if (!selectedPaymentMember.value) return
isPaymentModalLoading.value = true
paymentModalLoadingMethod.value = 'comp'
paymentModalError.value = ''
await markManualPayment(selectedPaymentMember.value.membershipId, paymentMonths.value, 'comp')
isPaymentModalLoading.value = false
paymentModalLoadingMethod.value = ''
if (!adminActionError.value) closePayment()
}
const createBitcoinPayment = async () => {
if (!selectedPaymentMember.value) return
isPaymentModalLoading.value = true
paymentModalLoadingMethod.value = 'bitcoin'
paymentModalError.value = ''
adminActionError.value = ''
adminActionMessage.value = ''
try {
const result = await fetchJson('/api/payment/btcpay', {
method: 'POST',
headers: adminHeaders(),
body: JSON.stringify({ membershipId: selectedPaymentMember.value.membershipId, months: paymentMonths.value }),
})
paymentModalInvoice.value = result.invoice
adminActionMessage.value = 'Bitcoin invoice created.'
await refreshAdminState()
connectAdminEvents()
} catch (error) {
paymentModalError.value = error instanceof Error ? error.message : 'Could not create Bitcoin invoice.'
} finally {
isPaymentModalLoading.value = false
paymentModalLoadingMethod.value = ''
}
}
const refreshPaymentStatus = async () => {
const invoiceId = paymentModalInvoice.value?.id
if (!invoiceId) return
try {
const result = await fetchJson(`/api/payment/status/${encodeURIComponent(invoiceId)}`, {
headers: adminHeaders(),
})
paymentModalInvoice.value = result.invoice
if (result.paid) {
adminActionMessage.value = 'Bitcoin invoice paid.'
await refreshAdminState()
}
} catch (error) {
paymentModalError.value = error instanceof Error ? error.message : 'Could not refresh invoice status.'
}
}
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('/')
}
const goHomeOrTop = () => {
if (window.location.pathname !== '/') {
navigateTo('/')
}
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const bytesToHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
const normalizePubkey = (value) => value.toLowerCase()
const setAdminSession = (pubkey) => {
const normalized = normalizePubkey(pubkey)
adminUser.value = normalized
localStorage.setItem(ADMIN_AUTH_KEY, JSON.stringify({ pubkey: normalized, lastLogin: new Date().toISOString() }))
}
const verifyAdminSession = async (pubkey = adminUser.value) => {
try {
const data = await fetchJson('/api/admin/access-requests', { headers: adminHeaders(pubkey) })
adminAccessRequests.value = Array.isArray(data.requests) ? data.requests : []
adminIsMaster.value = Boolean(data.isMasterAdmin)
return data
} catch (error) {
if (error instanceof Error && /admin access required|403/i.test(error.message)) {
throw new Error(adminNotAuthorizedMessage)
}
throw error
}
}
const loginAdminWithExtension = async () => {
adminError.value = ''
adminLoginMethod.value = 'extension'
isAdminLoading.value = true
try {
const pubkey = await loginWithExtension()
await verifyAdminSession(pubkey)
setAdminSession(pubkey)
await loadMembers()
connectAdminEvents()
} catch (error) {
logoutAdmin()
adminError.value = error instanceof Error ? error.message : 'Nostr extension login failed.'
} finally {
isAdminLoading.value = false
adminLoginMethod.value = ''
}
}
const loginAdminWithRemoteApp = async () => {
adminError.value = ''
adminLoginMethod.value = 'remote'
isAdminLoading.value = true
sessionStorage.setItem(SIGNER_LOGIN_CONTEXT_KEY, 'admin')
sessionStorage.setItem('l484-admin-return-path', isEditRoute.value ? '/edit' : '/admin')
try {
const pubkey = await loginWithRemoteApp()
await verifyAdminSession(pubkey)
setAdminSession(pubkey)
await loadMembers()
connectAdminEvents()
sessionStorage.removeItem(SIGNER_LOGIN_CONTEXT_KEY)
} catch (error) {
logoutAdmin()
adminError.value = error instanceof Error ? error.message : 'Remote signer login failed.'
} finally {
isAdminLoading.value = false
adminLoginMethod.value = ''
}
}
const resumePendingAdminRemoteSignin = async () => {
if (!hasPendingRemoteAppLogin() || isAdminLoading.value) return
adminError.value = ''
adminLoginMethod.value = 'remote'
isAdminLoading.value = true
try {
const pubkey = await resumeRemoteAppLogin()
await verifyAdminSession(pubkey)
setAdminSession(pubkey)
await loadMembers()
connectAdminEvents()
sessionStorage.removeItem(SIGNER_LOGIN_CONTEXT_KEY)
} catch (error) {
logoutAdmin()
adminError.value = error instanceof Error ? error.message : 'Remote signer login failed.'
} finally {
isAdminLoading.value = false
adminLoginMethod.value = ''
if (window.location.pathname === '/auth/nostr-callback') {
const targetPath = sessionStorage.getItem('l484-admin-return-path') || '/admin'
window.history.replaceState({}, '', targetPath)
currentPath.value = targetPath
}
}
}
const logoutAdmin = () => {
disconnectAdminEvents()
adminUser.value = ''
adminIsMaster.value = false
adminSessionChecked.value = true
isAdminSessionChecking.value = false
isAdminMenuOpen.value = false
localStorage.removeItem(ADMIN_AUTH_KEY)
}
const encodeBase64 = (bytes) => btoa(String.fromCharCode(...bytes))
const decodeBase64 = (value) => Uint8Array.from(atob(value), (char) => char.charCodeAt(0))
const deriveBackupKey = async (password, salt) => {
const baseKey = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey'],
)
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: 150000,
hash: 'SHA-256',
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
)
}
const openBackup = () => {
backupPassword.value = ''
backupMessage.value = ''
backupError.value = ''
isBackupOpen.value = true
}
const openRestore = () => {
restorePassword.value = ''
backupMessage.value = ''
backupError.value = ''
isRestoreOpen.value = true
}
const downloadEncryptedBackup = async () => {
backupError.value = ''
backupMessage.value = ''
if (!currentMember.value) {
backupError.value = 'Create a membership card before exporting.'
return
}
if (backupPassword.value.length < 8) {
backupError.value = 'Use at least 8 characters for the export password.'
return
}
try {
const salt = crypto.getRandomValues(new Uint8Array(16))
const iv = crypto.getRandomValues(new Uint8Array(12))
const key = await deriveBackupKey(backupPassword.value, salt)
const payload = {
type: 'l484-membership-card-backup',
version: 2,
exportedAt: new Date().toISOString(),
member: currentMember.value,
keys: generatedCredentials.value,
}
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(JSON.stringify(payload)),
)
const backup = {
type: payload.type,
version: payload.version,
kdf: 'PBKDF2-SHA256',
iterations: 150000,
salt: encodeBase64(salt),
iv: encodeBase64(iv),
data: encodeBase64(new Uint8Array(encrypted)),
}
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${currentMember.value.membershipId}-encrypted-member-export.json`
anchor.click()
URL.revokeObjectURL(url)
backupMessage.value = 'Encrypted member export created.'
isBackupOpen.value = false
} catch {
backupError.value = 'Could not create the encrypted export.'
}
}
const restoreEncryptedBackup = async (event) => {
backupError.value = ''
backupMessage.value = ''
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
if (restorePassword.value.length < 1) {
backupError.value = 'Enter the export password before choosing a file.'
return
}
try {
const backup = JSON.parse(await file.text())
if (backup.type !== 'l484-membership-card-backup' || !backup.data) {
throw new Error('Invalid encrypted member file')
}
const salt = decodeBase64(backup.salt)
const iv = decodeBase64(backup.iv)
const key = await deriveBackupKey(restorePassword.value, salt)
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, decodeBase64(backup.data))
const payload = JSON.parse(new TextDecoder().decode(decrypted))
const member = normalizeMember(payload.member)
if (!member) {
throw new Error('Invalid membership payload')
}
members.value = [member, ...members.value.filter((item) => item.membershipId !== member.membershipId)]
currentMemberId.value = member.membershipId
createdMember.value = member
if (payload.keys?.nsec?.startsWith('nsec1') && payload.keys?.npub?.startsWith('npub1')) {
generatedCredentials.value = payload.keys
}
localStorage.setItem(CURRENT_MEMBER_KEY, member.membershipId)
if (member.userId) localStorage.setItem(USER_ID_KEY, member.userId)
saveMembers()
backupMessage.value = 'Encrypted member file imported.'
isRestoreOpen.value = false
} catch {
backupError.value = 'Could not import this file. Check the password and file.'
}
}
const createPrintableSignature = (signature) =>
new Promise((resolve) => {
if (!DATA_IMAGE_PATTERN.test(signature)) {
resolve('')
return
}
const image = new Image()
image.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext('2d')
context.drawImage(image, 0, 0)
const pixels = context.getImageData(0, 0, canvas.width, canvas.height)
for (let index = 0; index < pixels.data.length; index += 4) {
const red = pixels.data[index]
const green = pixels.data[index + 1]
const blue = pixels.data[index + 2]
const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722
const inkAlpha = Math.max(0, Math.min(255, (luminance - 32) * 1.5))
pixels.data[index] = 255 - inkAlpha
pixels.data[index + 1] = 255 - inkAlpha
pixels.data[index + 2] = 255 - inkAlpha
pixels.data[index + 3] = 255
}
context.putImageData(pixels, 0, 0)
resolve(canvas.toDataURL('image/png'))
}
image.onerror = () => resolve('')
image.src = signature
})
const agreementHtml = (member, printableSignature = '') => {
const safe = (value) =>
String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
const signatureImage = DATA_IMAGE_PATTERN.test(printableSignature)
? `<img src="${printableSignature}" alt="Member signature" class="signature" />`
: '<div class="signature-text">No signature image stored</div>'
const covenantList = covenantItems.map((item) => `<li>${safe(item)}</li>`).join('')
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>L484 Membership Agreement - ${safe(member.fullName)}</title>
<style>
@media print { @page { margin: 0.5in; } }
body { font-family: Arial, sans-serif; color: #333; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }
h1 { font-size: 24px; margin-bottom: 10px; text-transform: uppercase; }
h2 { font-size: 20px; margin-top: 30px; margin-bottom: 10px; }
h3 { font-size: 18px; margin-top: 20px; margin-bottom: 10px; }
h4 { font-size: 16px; margin-top: 15px; margin-bottom: 8px; }
p { margin: 10px 0; }
ol { margin: 10px 0; padding-left: 30px; }
li { margin: 8px 0; }
.header { text-align: center; margin-bottom: 30px; }
.section { margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; }
.info-item { margin: 8px 0; }
.covenant { background: #f5f5f5; padding: 20px; border-radius: 5px; margin: 20px 0; }
.signature-section { margin-top: 20px; }
.signature-box { border: 1px solid #ccc; padding: 15px; margin: 10px 0; }
.signature { max-width: 300px; max-height: 100px; background: #fff; padding: 12px; }
.signature-text { font-style: italic; color: #666; }
</style>
</head>
<body>
<div class="header">
<h1>L484</h1>
<h2>A Private Membership Association</h2>
<p><em>Note: This is a private, members-only association. Participation is absolutely voluntary.</em></p>
</div>
<div class="section">
<h3>Step 1 — Applicant Information</h3>
<div class="info-item"><strong>Full Name:</strong> ${safe(member.fullName || 'N/A')}</div>
<div class="info-item"><strong>Email:</strong> ${safe(member.email || 'N/A')}</div>
<div class="info-item"><strong>Phone:</strong> ${safe(member.phone || 'N/A')}</div>
<div class="info-item"><strong>Membership ID:</strong> ${safe(member.membershipId)}</div>
<div class="info-item"><strong>Expires:</strong> ${safe(formatDate(member.expiresAt))}</div>
</div>
<div class="section">
<h3>Step 2 — Membership Covenant</h3>
<p><em>Please read carefully before continuing.</em></p>
<div class="covenant">
<h4>L484 Membership Covenant</h4>
<p>By submitting this application and becoming a member of L484, I acknowledge and agree that:</p>
<ol>${covenantList}</ol>
</div>
</div>
<div class="section">
<h3>Step 3 — Agreement Confirmation</h3>
<p><strong>Acceptance:</strong></p>
<p>"I have read and agree to the L484 Membership Covenant, and I voluntarily apply for membership."</p>
<div class="signature-section">
<p><strong>Digital Signature:</strong></p>
<div class="signature-box">${signatureImage}</div>
</div>
<p><strong>Date:</strong> ${safe(formatDate(member.signedDate))}</p>
</div>
</body>
</html>`
}
const downloadAgreement = async (member) => {
if (!member) return
const printableSignature = await createPrintableSignature(member.signature)
const printWindow = window.open('', '_blank')
if (!printWindow) {
alert('Please allow popups to generate the PDF.')
return
}
printWindow.document.write(agreementHtml(member, printableSignature))
printWindow.document.close()
printWindow.onload = () => {
setTimeout(() => {
printWindow.print()
}, 500)
}
}
const formatCardNumber = (membershipId) => {
const cleaned = membershipId.replace(/[^A-Z0-9]/gi, '').toUpperCase().padEnd(16, '0')
return cleaned.slice(0, 16).match(/.{1,4}/g).join(' ')
}
const formatCardDate = (dateString) => {
const date = new Date(dateString)
return `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getFullYear()).slice(-2)}`
}
const cardStatusKey = (member) => {
const status = member?.accessStatus || member?.status || 'requested'
if (['suspended', 'revoked', 'expired'].includes(status)) return 'suspended'
if (paymentForMember(member?.membershipId) || ['active', 'pending_card'].includes(status)) return 'active'
return 'pending'
}
const cardStatusLabel = (member) => cardStatusKey(member)
const formatDate = (dateString) =>
new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
const formatDateTime = (dateString) =>
new Date(dateString).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
})
const formatUsd = (value) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(value)
const formatSats = (value) =>
new Intl.NumberFormat('en-US', {
maximumFractionDigits: 0,
}).format(value)
const syncHeroParallax = () => {
parallaxFrame = 0
const scrollY = Math.max(0, window.scrollY || window.pageYOffset || 0)
const viewportHeight = Math.max(1, window.innerHeight || 1)
const progress = Math.min(scrollY / viewportHeight, 1)
document.documentElement.style.setProperty('--hero-bg-y', `${scrollY * 0.18}px`)
document.documentElement.style.setProperty('--hero-copy-y', `${scrollY * -0.12}px`)
document.documentElement.style.setProperty('--hero-copy-fade', String(Math.max(0.18, 1 - progress * 0.82)))
document.documentElement.style.setProperty('--members-pattern-y', `${scrollY * -0.08}px`)
const patternSpinRate = window.innerWidth < 700 ? 0.48 : 0.58
document.documentElement.style.setProperty('--members-pattern-rotate', `${scrollY * patternSpinRate}deg`)
const facilities = document.getElementById('facilities')
if (facilities) {
const rect = facilities.getBoundingClientRect()
const localProgress = Math.max(-1, Math.min(1, (viewportHeight - rect.top) / (viewportHeight + rect.height)))
document.documentElement.style.setProperty('--facilities-bg-y', `${localProgress * -74}px`)
document.documentElement.style.setProperty('--facilities-copy-y', `${localProgress * 46}px`)
document.documentElement.style.setProperty('--facilities-list-y', `${localProgress * -34}px`)
}
}
const requestHeroParallax = () => {
if (parallaxFrame) return
parallaxFrame = window.requestAnimationFrame(syncHeroParallax)
}
const markHeroBackgroundLoaded = (index) => {
if (loadedHeroBackgroundIndexes.value.has(index)) return
const next = new Set(loadedHeroBackgroundIndexes.value)
next.add(index)
loadedHeroBackgroundIndexes.value = next
}
const preloadHeroBackground = (index) => {
const background = heroBackgrounds[index]
if (!background || loadedHeroBackgroundIndexes.value.has(index)) return Promise.resolve()
if (heroBackgroundPreloads.has(index)) return heroBackgroundPreloads.get(index)
const preload = new Promise((resolve) => {
const img = new Image()
const done = () => {
markHeroBackgroundLoaded(index)
heroBackgroundPreloads.delete(index)
resolve()
}
img.decoding = 'async'
img.onload = () => {
if (img.decode) img.decode().catch(() => {}).finally(done)
else done()
}
img.onerror = () => {
heroBackgroundPreloads.delete(index)
resolve()
}
img.src = background
})
heroBackgroundPreloads.set(index, preload)
return preload
}
const scheduleHeroBackgroundPreloads = () => {
if (heroBackgrounds.length < 2) return
preloadHeroBackground(1)
const preloadRemaining = () => {
heroBackgrounds.forEach((_, index) => {
if (index > 1) preloadHeroBackground(index)
})
}
if ('requestIdleCallback' in window) {
window.requestIdleCallback(preloadRemaining, { timeout: 4500 })
} else {
window.setTimeout(preloadRemaining, 1800)
}
}
const selectFacility = (backgroundIndex) => {
if (!facilityBackgrounds[backgroundIndex]) return
activeFacilityBackground.value = backgroundIndex
}
onMounted(async () => {
localStorage.removeItem('l484-member-keys')
installPlatform.value = detectInstallPlatform()
refreshPwaStandalone()
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handlePwaInstalled)
await loadAppConfig()
await loadSiteContent()
await loadAdminSession()
await loadMembers()
connectAdminEvents()
if (appConfig.value.mode !== 'legacy') loadBitcoinPrice()
const navigationEntry = performance.getEntriesByType?.('navigation')?.[0]
const isPageReload = navigationEntry?.type === 'reload'
if (isPageReload) {
if (window.location.pathname !== '/auth/nostr-callback') {
cancelPendingRemoteAppLogin()
sessionStorage.removeItem(SIGNER_LOGIN_CONTEXT_KEY)
isRemoteSignerLoading.value = false
}
if (window.location.pathname === '/auth/nostr-callback') {
resumePendingRemoteSignin()
}
} else if (window.location.pathname === '/auth/nostr-callback') {
resumePendingRemoteSignin()
}
if (new URLSearchParams(window.location.search).get('signup') === 'continue') {
window.history.replaceState({}, '', '/')
currentPath.value = '/'
signupStep.value = currentMember.value ? 5 : 2
saveSignupDraft()
openSignup()
}
subscribeToNotificationsInBackground()
window.addEventListener('popstate', () => {
currentPath.value = window.location.pathname
})
window.addEventListener('storage', (event) => {
if (event.key === SIGNER_LOGIN_COMPLETE_KEY && !isAdminLikeRoute.value) {
handleSignerCompletion()
}
})
window.addEventListener('resize', syncSignatureCanvas)
window.addEventListener('scroll', requestHeroParallax, { passive: true })
window.addEventListener('resize', requestHeroParallax)
syncHeroParallax()
scheduleHeroBackgroundPreloads()
if (hasRotatingBackgrounds.value) {
backgroundTimer = window.setInterval(() => {
if (isHeroRotationPreloading) return
const nextBackground = (activeBackground.value + 1) % heroBackgrounds.length
isHeroRotationPreloading = true
preloadHeroBackground(nextBackground).then(() => {
activeBackground.value = nextBackground
preloadHeroBackground((nextBackground + 1) % heroBackgrounds.length)
}).finally(() => {
isHeroRotationPreloading = false
})
}, 6500)
}
})
watch(currentHeroBackground, (background) => {
document.documentElement.style.setProperty('--safe-area-bg-image', background ? `url(${background})` : '#000')
}, { immediate: true })
onBeforeUnmount(() => {
window.clearInterval(backgroundTimer)
window.clearTimeout(adminToastTimer)
stopCardReaderPolling()
document.body.classList.remove('menu-open')
disconnectAdminEvents()
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handlePwaInstalled)
window.removeEventListener('resize', syncSignatureCanvas)
window.removeEventListener('scroll', requestHeroParallax)
window.removeEventListener('resize', requestHeroParallax)
if (parallaxFrame) window.cancelAnimationFrame(parallaxFrame)
})
watch(signupStep, async (step) => {
saveSignupDraft()
if (step !== 4) return
await nextTick()
window.requestAnimationFrame(() => {
window.requestAnimationFrame(syncSignatureCanvas)
})
})
watch(form, () => {
if (isSignupOpen.value) saveSignupDraft()
}, { deep: true })
watch([adminActionMessage, adminActionError], ([message, error]) => {
window.clearTimeout(adminToastTimer)
if (!message && !error) return
adminToastTimer = window.setTimeout(() => {
adminActionMessage.value = ''
adminActionError.value = ''
}, error ? 5200 : 3200)
})
watch(latestAccessLog, (log) => {
if (!isCardReaderOpen.value || !log || log.id === cardReaderLastSeenId.value) return
cardReaderLastSeenId.value = log.id
cardReaderPulse.value = false
window.requestAnimationFrame(() => {
cardReaderPulse.value = true
window.setTimeout(() => {
cardReaderPulse.value = false
}, 900)
})
})
watch(paymentInvoiceQrData, async (value) => {
if (!value) {
paymentInvoiceQrUrl.value = ''
return
}
paymentInvoiceQrUrl.value = await QRCode.toDataURL(value, {
errorCorrectionLevel: 'H',
margin: 1,
width: 256,
color: {
dark: '#000000',
light: '#ffffff',
},
})
}, { immediate: true })
watch(mobileMenuOpen, (open) => {
document.body.classList.toggle('menu-open', open)
})
</script>
<template>
<main class="min-h-screen bg-black text-white">
<svg class="hidden" aria-hidden="true">
<symbol id="icon-milk" viewBox="0 0 24 24">
<path d="M9 3h6M10 3v4l-2 3v10h8V10l-2-3V3M8 10h8" />
</symbol>
<symbol id="icon-beef" viewBox="0 0 24 24">
<path d="M5 13c0-4 3.4-7 7.7-7H15c2.8 0 5 2.1 5 4.8 0 4.2-4.5 7.2-9.4 7.2H8.7C6.6 18 5 15.9 5 13Z" />
<path d="M10 12.5a2.5 2.5 0 1 0 5 0 2.5 2.5 0 0 0-5 0Z" />
</symbol>
<symbol id="icon-upgrade" viewBox="0 0 24 24">
<path d="M12 3v18M6 9l6-6 6 6M5 17h14" />
</symbol>
<symbol id="icon-meals" viewBox="0 0 24 24">
<path d="M4 12a8 8 0 0 0 16 0H4Z" />
<path d="M7 8c0-1 1-1 1-2s-1-1-1-2M12 8c0-1 1-1 1-2s-1-1-1-2M17 8c0-1 1-1 1-2s-1-1-1-2" />
</symbol>
<symbol id="icon-drinks" viewBox="0 0 24 24">
<path d="M7 4h10l-1 16H8L7 4Z" />
<path d="M8 9h8M10 20v2h4v-2" />
</symbol>
<symbol id="icon-sauna" viewBox="0 0 24 24">
<path d="M4 18h16M6 18v-6h12v6M8 12V8M12 12V6M16 12V8" />
<path d="M8 8c-1-1-.6-2 .3-2.8M12 6c-1-1-.6-2 .3-2.8M16 8c-1-1-.6-2 .3-2.8" />
</symbol>
<symbol id="icon-plunge" viewBox="0 0 24 24">
<path d="M12 3s6 6.2 6 11a6 6 0 0 1-12 0c0-4.8 6-11 6-11Z" />
</symbol>
<symbol id="icon-gym" viewBox="0 0 24 24">
<path d="M3 10v4M6 8v8M18 8v8M21 10v4M6 12h12" />
</symbol>
<symbol id="icon-event" viewBox="0 0 24 24">
<path d="M5 5h14v14H5V5ZM8 3v4M16 3v4M5 10h14" />
</symbol>
<symbol id="icon-fire" viewBox="0 0 24 24">
<path d="M12 21c4 0 7-2.7 7-6.5 0-3.2-2.4-5.6-4.3-7.5-.6 2-1.7 3.1-3.2 4.1.3-3.1-1-5.4-3.1-7.1C8.4 8.1 5 10.8 5 15c0 3.8 3 6 7 6Z" />
</symbol>
<symbol id="icon-admin-requested" viewBox="0 0 24 24">
<path d="M8 2v4M16 2v4M4 9h16M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" />
<path d="M9 14h6M12 11v6" />
</symbol>
<symbol id="icon-admin-paid" viewBox="0 0 24 24">
<path d="M12 2v20M17 6.5H9.5a3 3 0 0 0 0 6H14a3 3 0 0 1 0 6H6" />
</symbol>
<symbol id="icon-admin-suspended" viewBox="0 0 24 24">
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
<path d="M8 8l8 8" />
</symbol>
<symbol id="icon-admin-payments" viewBox="0 0 24 24">
<path d="M3 7h18v10H3V7Z" />
<path d="M7 11h3M15 13h2M5 17v2h14v-2" />
</symbol>
<symbol id="icon-admin-logs" viewBox="0 0 24 24">
<path d="M8 6h13M8 12h13M8 18h13" />
<path d="M3.5 6h.01M3.5 12h.01M3.5 18h.01" />
</symbol>
<symbol id="icon-admin-site" viewBox="0 0 24 24">
<path d="M4 5h16v14H4V5Z" />
<path d="M4 9h16M8 13h4M8 16h8" />
</symbol>
<symbol id="icon-edit-hero" viewBox="0 0 24 24">
<path d="M4 6h16v12H4V6Z" />
<path d="M7 15l3-4 2 2 2-3 3 5" />
</symbol>
<symbol id="icon-edit-members" viewBox="0 0 24 24">
<path d="M8 12a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM16 11a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
<path d="M3.5 19c.8-3 2.4-4.5 4.5-4.5S11.7 16 12.5 19M13 18.5c.7-2.2 1.8-3.3 3.4-3.3 1.7 0 3 1.2 3.8 3.8" />
</symbol>
<symbol id="icon-edit-facilities" viewBox="0 0 24 24">
<path d="M4 20V9l8-5 8 5v11" />
<path d="M8 20v-7h8v7M9 9h.01M15 9h.01" />
</symbol>
<symbol id="icon-edit-events" viewBox="0 0 24 24">
<path d="M5 5h14v15H5V5ZM8 3v4M16 3v4M5 10h14" />
<path d="M8 14h3M8 17h5" />
</symbol>
<symbol id="icon-edit-admins" viewBox="0 0 24 24">
<path d="M12 3l7 3v5c0 4.2-2.6 7.5-7 10-4.4-2.5-7-5.8-7-10V6l7-3Z" />
<path d="M9 12l2 2 4-5" />
</symbol>
<symbol id="icon-edit-alerts" viewBox="0 0 24 24">
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9Z" />
<path d="M10 21h4" />
</symbol>
</svg>
<header v-if="!isAdminLikeRoute" class="intro-header fixed left-0 right-0 top-0 z-[100]">
<div class="mx-auto flex w-full max-w-7xl items-center justify-between gap-4 px-4 py-5 sm:px-10 sm:py-7 lg:px-12">
<div class="header-left">
<button class="header-logo-button" type="button" aria-label="Go to homepage" @click="goHomeOrTop">
<img class="h-4 w-auto sm:h-5" src="/images/header-logo.svg" alt="L484" />
</button>
<nav class="public-nav" aria-label="Homepage sections">
<button v-for="item in publicNavItems" :key="item.path" type="button" @click="navigateAndTop(item.path)">{{ item.label }}</button>
</nav>
</div>
<div class="header-actions">
<button class="member-button ghost-member-button" type="button" @click="currentMember ? signOutMember() : openMemberSignin()">
{{ currentMember ? 'Sign out' : 'Sign in' }}
</button>
<button class="member-button" type="button" @click="openSignup">
{{ currentMember ? 'View card' : 'Become a member' }}
</button>
</div>
</div>
</header>
<button
v-if="!isAdminLikeRoute"
class="hamburger mobile-menu-trigger"
:class="{ open: mobileMenuOpen }"
type="button"
aria-label="Menu"
@click="toggleMobileMenu"
>
<span></span>
<span></span>
<span></span>
</button>
<div v-if="!isAdminLikeRoute" class="mobile-menu" :class="{ open: mobileMenuOpen }">
<div class="mobile-menu-links">
<button
v-for="item in publicNavItems"
:key="item.path"
class="mobile-menu-link"
type="button"
@click="navigateAndTop(item.path)"
>
{{ item.label }}
</button>
<div class="mobile-menu-actions">
<button class="member-button ghost-member-button" type="button" @click="currentMember ? signOutMember() : openMemberSignin(); mobileMenuOpen = false">
{{ currentMember ? 'Sign out' : 'Sign in' }}
</button>
<button v-if="currentMember" class="member-button" type="button" @click="openSignup(); mobileMenuOpen = false">
View card
</button>
</div>
</div>
</div>
<section
v-if="isHomeRoute"
class="hero-fold relative isolate overflow-hidden"
:style="{ '--safe-area-bg-image': `url(${currentHeroBackground})` }"
>
<div class="hero-bg-layer">
<img
v-for="{ background, index } in heroBackgroundEntries"
:key="background"
class="hero-bg absolute inset-0 h-full w-full object-cover"
:class="{ 'is-active': index === activeBackground }"
:src="background"
:loading="index === 0 ? 'eager' : 'lazy'"
:fetchpriority="index === 0 ? 'high' : 'low'"
decoding="async"
alt=""
aria-hidden="true"
/>
</div>
<div class="absolute inset-0 bg-[radial-gradient(circle_at_50%_38%,rgba(242,169,0,0.18),transparent_30%),linear-gradient(90deg,rgba(0,0,0,0.9),rgba(0,0,0,0.34)_48%,rgba(0,0,0,0.82))]"></div>
<div class="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-black/80 to-transparent"></div>
<div class="hero-bottom-fade absolute inset-x-0 bottom-0"></div>
<div class="film-grain absolute inset-0 opacity-[0.14] mix-blend-screen"></div>
<div class="scanline absolute inset-x-0 top-0 h-px bg-amber-300/80"></div>
<div class="hero-shell relative z-10 mx-auto flex min-h-svh w-full max-w-7xl flex-col px-4 pb-5 pt-24 sm:px-10 sm:pb-7 sm:pt-28 lg:px-12">
<div class="hero-content grid flex-1 items-center gap-5 py-6 sm:gap-10 sm:py-14 lg:py-16">
<div class="intro-copy">
<h1 class="hero-title font-black uppercase leading-[0.86] tracking-normal">
<span class="hero-title-line hero-title-line-primary">{{ homepageContent.hero.line1 }}</span>
<span class="hero-title-line hero-title-line-secondary">{{ homepageContent.hero.line2 }}</span>
</h1>
<button class="member-button hero-mobile-cta" type="button" @click="openSignup">
{{ currentMember ? 'View card' : 'Become a member' }}
</button>
</div>
</div>
<a class="benefits-cue" href="#members-get" aria-label="Scroll to member benefits">
<span>{{ homepageContent.hero.benefitsCue }}</span>
<span class="mobile-swipe-cue" aria-hidden="true">
<svg class="hand-icon" viewBox="0 0 106.17 122.88" xmlns="http://www.w3.org/2000/svg">
<path d="M29.96,67.49c-0.16-0.09-0.32-0.19-0.47-0.31c-1.95-1.56-4.08-3.29-5.94-4.81c-2.69-2.2-5.8-4.76-7.97-6.55 c-1.49-1.23-3.17-2.07-4.75-2.39c-1.02-0.2-1.95-0.18-2.67,0.12c-0.59,0.24-1.1,0.72-1.45,1.48c-0.45,0.99-0.66,2.41-0.54,4.32 c0.11,1.69,0.7,3.55,1.48,5.33c1.16,2.63,2.73,5.04,3.89,6.59c0.07,0.09,0.13,0.19,0.19,0.29l23.32,33.31 c0.3,0.43,0.47,0.91,0.53,1.4l0.01,0c0.46,3.85,1.28,6.73,2.49,8.54c0.88,1.31,2.01,1.98,3.42,1.94l0.07,0v-0.01h36.38 c0.09,0,0.17,0,0.26,0.01c2.28-0.03,4.36-0.71,6.25-2.02c2.09-1.44,3.99-3.68,5.72-6.7c0.03-0.05,0.06-0.11,0.1-0.16 c0.67-1.15,1.55-2.6,2.41-4.02c3.72-6.13,6.96-11.45,7.35-19.04L99.8,74.34c-0.02-0.15-0.03-0.3-0.03-0.45 c0-0.14,0.02-1.13,0.03-2.46c0.09-6.92,0.19-15.48-6.14-16.56h-4.05l-0.04,0c-0.02,1.95-0.15,3.93-0.27,5.86 c-0.11,1.71-0.21,3.37-0.21,4.95c0,1.7-1.38,3.08-3.08,3.08c-1.7,0-3.08-1.38-3.08-3.08c0-1.58,0.12-3.42,0.24-5.33 c0.41-6.51,0.89-13.99-4.33-14.93H74.8c-0.23,0-0.45-0.02-0.66-0.07c0.04,2.36-0.12,4.81-0.27,7.16c-0.11,1.71-0.21,3.37-0.21,4.95 c0,1.7-1.38,3.08-3.08,3.08c-1.7,0-3.08-1.38-3.08-3.08c0-1.58,0.12-3.42,0.24-5.33c0.41-6.51,0.89-13.99-4.33-14.93h-4.05 c-0.28,0-0.55-0.04-0.8-0.11V49c0,1.7-1.38,3.08-3.08,3.08c-1.7,0-3.08-1.38-3.08-3.08V17.05c0-5.35-2.18-8.73-4.97-10.14 c-1.02-0.52-2.12-0.78-3.21-0.78c-1.08,0-2.18,0.26-3.19,0.77c-2.76,1.4-4.92,4.79-4.92,10.28v56c0,1.7-1.38,3.08-3.08,3.08 c-1.7,0-3.08-1.38-3.08-3.08V67.49L29.96,67.49z M58.57,31.15c0.26-0.07,0.53-0.11,0.8-0.11h4.24c0.24,0,0.47,0.03,0.69,0.08 c5.65,0.88,8.17,4.18,9.2,8.43c0.39-0.18,0.83-0.29,1.3-0.29h4.24c0.24,0,0.47,0.03,0.69,0.08c6.08,0.94,8.53,4.69,9.41,9.41 c0.15-0.02,0.31-0.04,0.47-0.04h4.24c0.24,0,0.47,0.03,0.69,0.08c11.64,1.8,11.5,13.37,11.38,22.71c0,0.33-0.01,0.68-0.01,2.35 l0,0.07l0.24,10.77c0.01,0.11,0.01,0.23,0,0.34c-0.45,9.16-4.07,15.12-8.24,21.98c-0.7,1.14-1.41,2.32-2.34,3.93 c-0.02,0.04-0.04,0.08-0.07,0.13c-2.18,3.8-4.7,6.71-7.57,8.69c-2.92,2.02-6.16,3.06-9.71,3.1c-0.09,0.01-0.19,0.01-0.28,0.01 H41.58v-0.01c-3.66,0.07-6.5-1.53-8.59-4.66c-1.68-2.51-2.79-6.03-3.4-10.47L6.73,75.07c-0.03-0.04-0.07-0.08-0.1-0.12 c-1.36-1.82-3.21-4.65-4.59-7.79C1,64.8,0.21,62.24,0.05,59.74c-0.2-2.97,0.22-5.36,1.06-7.23c1.05-2.32,2.72-3.83,4.74-4.66 c1.89-0.77,4.01-0.88,6.16-0.45c2.57,0.51,5.22,1.81,7.49,3.68c1.86,1.54,4.95,4.07,7.95,6.52l2.52,2.06V17.18 c0-8.14,3.63-13.39,8.28-15.76C40.12,0.47,42.17,0,44.23,0c2.05,0,4.1,0.48,5.98,1.43c4.69,2.37,8.36,7.62,8.36,15.62V31.15 L58.57,31.15z" />
</svg>
</span>
<span class="desktop-scroll-cue" aria-hidden="true">
<span></span>
</span>
</a>
</div>
</section>
<section v-if="isHomeRoute" id="members-get" class="members-get-section">
<div class="members-pattern-layer" aria-hidden="true">
<span v-for="index in 96" :key="index" class="members-pattern-mark">
<img src="/images/small-logo.svg" alt="" />
</span>
</div>
<div class="members-get-inner">
<div class="members-get-copy">
<h2 class="members-get-title">{{ homepageContent.members.title }}</h2>
<div class="members-get-pricing">
<p class="members-get-price">$350 PM</p>
<p class="btc-price-pill">{{ membershipBtcText }}</p>
</div>
</div>
<div class="members-get-list" aria-label="Member benefits">
<article v-for="benefit in memberBenefitItems" :key="benefit.number" class="members-get-item">
<span>{{ benefit.number }}</span>
<span class="list-icon" aria-hidden="true"><svg><use :href="`#${benefit.icon}`" /></svg></span>
<div>
<h3>{{ benefit.title }}</h3>
<p>{{ benefit.description }}</p>
</div>
</article>
</div>
</div>
</section>
<section v-if="isHomeRoute" id="facilities" class="facilities-section">
<div class="facilities-bg-layer" aria-hidden="true">
<img
v-for="(background, index) in facilityBackgrounds"
:key="background"
class="facilities-bg"
:class="{ 'is-active': index === activeFacilityBackground }"
:src="background"
loading="lazy"
decoding="async"
alt=""
/>
</div>
<div class="facilities-inner">
<div class="facilities-list" aria-label="Facilities">
<button
v-for="facility in facilityItems"
:key="facility.title"
class="facilities-item"
type="button"
@click="selectFacility(facility.backgroundIndex)"
>
<span>{{ facility.number }}</span>
<span class="list-icon" aria-hidden="true"><svg><use :href="`#${facility.icon}`" /></svg></span>
<div>
<h3>{{ facility.title }}</h3>
<p>{{ facility.description }}</p>
</div>
</button>
</div>
<div class="facilities-copy">
<h2 class="facilities-title">{{ homepageContent.facilities.title }}</h2>
</div>
</div>
</section>
<section v-if="isEventsRoute" class="events-page">
<div class="events-inner">
<div class="events-copy">
<p class="section-kicker">Event Calendar</p>
<h2>{{ homepageContent.events.title }}</h2>
<p>{{ homepageContent.events.description }}</p>
</div>
</div>
<div class="event-grid" aria-label="Event calendar">
<article v-for="event in eventItems" :key="event.title" class="event-card">
<img :src="event.image" :alt="event.title" />
<div class="event-card-body">
<div class="event-card-meta">
<span>{{ event.date }}</span>
<span>{{ event.price }}</span>
</div>
<h3>{{ event.title }}</h3>
<p>{{ event.description }}</p>
</div>
</article>
</div>
<div class="event-enquiry-shell">
<form class="event-enquiry-form" @submit.prevent="submitEventEnquiry">
<h3>{{ homepageContent.events.enquiryTitle }}</h3>
<label>
<span>Name</span>
<input v-model="eventEnquiry.name" maxlength="80" required />
</label>
<label>
<span>Email</span>
<input v-model="eventEnquiry.email" maxlength="160" type="email" required />
</label>
<label>
<span>Message</span>
<textarea v-model="eventEnquiry.message" maxlength="500" rows="4" required></textarea>
</label>
<button class="primary-action" type="submit">Send enquiry</button>
<p v-if="eventEnquiryMessage" class="event-enquiry-message">{{ eventEnquiryMessage }}</p>
</form>
</div>
</section>
<section v-if="isAdminLikeRoute && !adminSessionChecked" class="admin-login-area">
<div class="mx-auto flex min-h-svh w-full max-w-lg flex-col justify-center px-5 py-16">
<div class="admin-login-card text-center">
<img class="mx-auto h-5 w-auto" src="/images/header-logo.svg" alt="L484" />
<p class="section-kicker mt-8">{{ isEditRoute ? 'Site Editor' : 'Admin Panel' }}</p>
<h1 class="mt-3 text-3xl font-black uppercase leading-none">Checking access</h1>
</div>
</div>
</section>
<section v-else-if="isAdminLikeRoute && !isAdminAuthenticated" class="admin-login-area">
<div class="mx-auto flex min-h-svh w-full max-w-lg flex-col justify-center px-5 py-16">
<div class="admin-login-card">
<img class="mx-auto h-5 w-auto" src="/images/header-logo.svg" alt="L484" />
<div class="mt-8 text-center">
<p class="section-kicker">{{ isEditRoute ? 'Site Editor' : 'Admin Panel' }}</p>
<h1 class="text-4xl font-black uppercase leading-none">Nostr sign in</h1>
<p class="mt-4 text-sm leading-6 text-white/58">
Sign in with a browser extension or mobile signer.
</p>
</div>
<div class="mt-8 space-y-4">
<button class="primary-action signin-option" type="button" :disabled="isAdminLoading" @click="loginAdminWithExtension">
{{ isAdminLoading && adminLoginMethod === 'extension' ? 'Connecting...' : 'Browser extension' }}
<small>NIP-07 · Alby, nos2x, Primal</small>
</button>
<button class="primary-action signin-option" type="button" :disabled="isAdminLoading" @click="loginAdminWithRemoteApp">
<span v-if="isAdminLoading && adminLoginMethod === 'remote'" class="signin-loading-label">
<img src="/images/small-logo.svg" alt="" aria-hidden="true" />
Waiting for signer...
</span>
<template v-else>Open signer app</template>
<small>Amber, Primal, or Nostr Connect</small>
</button>
<p v-if="adminError" class="rounded border border-red-400/40 bg-red-500/10 p-3 text-sm text-red-200">
{{ adminError }}
</p>
</div>
<div v-if="isEditRoute" class="admin-request-card">
<p class="section-kicker">Request Admin</p>
<label class="field-label">
Name
<input v-model="adminRequestName" class="field-input" maxlength="80" placeholder="Admin name" />
</label>
<button class="secondary-action w-full" type="button" @click="requestAdminAccess">
Generate nsec / npub request
</button>
<div v-if="adminRequestCredentials" class="member-keys">
<p class="field-label">Admin keys</p>
<p class="text-sm leading-6 text-white/62">
{{ adminRequestMessage }}
</p>
<div class="member-key-row">
<span>npub</span>
<code>{{ adminRequestCredentials.npub }}</code>
<button class="secondary-action compact-action" type="button" @click="copyToClipboard(adminRequestCredentials.npub, 'admin-npub')">
{{ copiedKey === 'admin-npub' ? '' : 'Copy' }}
</button>
</div>
<div class="member-key-row">
<span>nsec</span>
<code>{{ adminRequestCredentials.nsec }}</code>
<button class="secondary-action compact-action" type="button" @click="copyToClipboard(adminRequestCredentials.nsec, 'admin-nsec')">
{{ copiedKey === 'admin-nsec' ? '' : 'Copy' }}
</button>
</div>
</div>
<p v-if="adminRequestError" class="rounded border border-red-400/40 bg-red-500/10 p-3 text-sm text-red-200">
{{ adminRequestError }}
</p>
</div>
</div>
</div>
</section>
<section v-if="isEditRoute && isAdminAuthenticated" class="admin-area edit-area">
<div class="admin-shell">
<header class="admin-header">
<img class="h-4 w-auto sm:h-5" src="/images/header-logo.svg" alt="L484" />
<nav class="admin-tabbar desktop-admin-tabbar" aria-label="Edit sections">
<button
v-for="tab in editTabs"
:key="tab.id"
class="admin-tab"
:class="{ active: editTab === tab.id }"
type="button"
@click="editTab = tab.id"
>
<svg aria-hidden="true"><use :href="`#${tab.icon}`" /></svg>
<span>{{ tab.label }}</span>
<strong>{{ tab.count }}</strong>
</button>
</nav>
<div class="admin-profile">
<button class="admin-profile-button" type="button" @click="isAdminMenuOpen = !isAdminMenuOpen">
<span class="admin-avatar" :style="adminAvatarStyle">{{ adminDisplayName.slice(0, 1) }}</span>
<span class="admin-profile-text">
<strong>Site Editor</strong>
<small>{{ adminShortKey }}</small>
</span>
</button>
<div v-if="isAdminMenuOpen" class="admin-profile-menu">
<p>{{ adminShortKey }}</p>
<button class="admin-profile-menu-action" type="button" @click="openCardReader">Card Reader</button>
<button type="button" @click="logoutAdmin">Logout</button>
</div>
</div>
</header>
<nav class="mobile-admin-tabbar" aria-label="Edit sections">
<button
v-for="tab in editTabs"
:key="tab.id"
class="admin-tab mobile-admin-tab"
:class="{ active: editTab === tab.id }"
type="button"
@click="editTab = tab.id"
>
<svg aria-hidden="true"><use :href="`#${tab.icon}`" /></svg>
<span>{{ tab.label }}</span>
<strong>{{ tab.count }}</strong>
</button>
</nav>
<div
v-if="adminActionMessage || adminActionError"
class="admin-toast"
:class="{ error: adminActionError }"
role="status"
aria-live="polite"
>
{{ adminActionError || adminActionMessage }}
</div>
<div class="edit-page-grid">
<div v-if="!['admins', 'notifications'].includes(editTab)" class="admin-panel-surface site-editor-panel">
<div class="admin-section-heading">
<div>
<p class="section-kicker">Site</p>
<h3>{{ editTabs.find((tab) => tab.id === editTab)?.label }}</h3>
</div>
<div class="site-editor-actions">
<button
v-if="editTab === 'events'"
class="secondary-action"
type="button"
:disabled="siteContentDraft.homepage.events.items.length >= contentLimits.maxEvents"
@click="addEventItem"
>
Add event
</button>
<button class="secondary-action" type="button" :disabled="isSiteContentLoading || !siteContentDirty" @click="resetSiteContentDraft">
Reset
</button>
<button class="primary-action" type="button" :disabled="isSiteContentLoading || !siteContentDirty" @click="saveSiteContent">
{{ isSiteContentLoading ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
<div class="site-editor-grid">
<section v-if="editTab === 'hero'" class="site-editor-section">
<p class="section-kicker">Hero</p>
<label>
<span>Line 1</span>
<input v-model="siteContentDraft.homepage.hero.line1" :maxlength="contentLimits.heroLine" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.hero.line1.length }}/{{ contentLimits.heroLine }}</small>
</label>
<label>
<span>Line 2</span>
<input v-model="siteContentDraft.homepage.hero.line2" :maxlength="contentLimits.heroLine" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.hero.line2.length }}/{{ contentLimits.heroLine }}</small>
</label>
<label>
<span>Scroll cue</span>
<input v-model="siteContentDraft.homepage.hero.benefitsCue" :maxlength="contentLimits.cue" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.hero.benefitsCue.length }}/{{ contentLimits.cue }}</small>
</label>
</section>
<section v-if="editTab === 'members'" class="site-editor-section">
<p class="section-kicker">Members</p>
<label>
<span>Section title</span>
<input v-model="siteContentDraft.homepage.members.title" :maxlength="contentLimits.sectionTitle" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.members.title.length }}/{{ contentLimits.sectionTitle }}</small>
</label>
<div v-for="(benefit, index) in siteContentDraft.homepage.members.benefits" :key="`edit-benefit-${index}`" class="site-editor-item">
<label>
<span>Benefit {{ index + 1 }}</span>
<input v-model="benefit.title" :maxlength="contentLimits.itemTitle" @input="markSiteContentDirty" />
<small>{{ benefit.title.length }}/{{ contentLimits.itemTitle }}</small>
</label>
<label>
<span>Description</span>
<input v-model="benefit.description" :maxlength="contentLimits.itemDescription" @input="markSiteContentDirty" />
<small>{{ benefit.description.length }}/{{ contentLimits.itemDescription }}</small>
</label>
</div>
</section>
<section v-if="editTab === 'facilities'" class="site-editor-section">
<p class="section-kicker">Facilities</p>
<label>
<span>Section title</span>
<input v-model="siteContentDraft.homepage.facilities.title" :maxlength="contentLimits.sectionTitle" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.facilities.title.length }}/{{ contentLimits.sectionTitle }}</small>
</label>
<div v-for="(facility, index) in siteContentDraft.homepage.facilities.items" :key="`edit-facility-${index}`" class="site-editor-item">
<label>
<span>Facility {{ index + 1 }}</span>
<input v-model="facility.title" :maxlength="contentLimits.itemTitle" @input="markSiteContentDirty" />
<small>{{ facility.title.length }}/{{ contentLimits.itemTitle }}</small>
</label>
<label>
<span>Description</span>
<input v-model="facility.description" :maxlength="contentLimits.itemDescription" @input="markSiteContentDirty" />
<small>{{ facility.description.length }}/{{ contentLimits.itemDescription }}</small>
</label>
</div>
</section>
<div v-if="editTab === 'events'" class="events-editor-layout">
<div class="events-page-editor">
<div class="site-editor-item-heading">
<strong>Events page text</strong>
</div>
<label>
<span>Nav label</span>
<input v-model="siteContentDraft.homepage.events.navLabel" :maxlength="contentLimits.navLabel" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.events.navLabel.length }}/{{ contentLimits.navLabel }}</small>
</label>
<label>
<span>Page title</span>
<input v-model="siteContentDraft.homepage.events.title" :maxlength="contentLimits.sectionTitle" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.events.title.length }}/{{ contentLimits.sectionTitle }}</small>
</label>
<label>
<span>Page text</span>
<textarea v-model="siteContentDraft.homepage.events.description" :maxlength="contentLimits.eventDescription" rows="3" @input="markSiteContentDirty"></textarea>
<small>{{ siteContentDraft.homepage.events.description.length }}/{{ contentLimits.eventDescription }}</small>
</label>
<label>
<span>Enquiry title</span>
<input v-model="siteContentDraft.homepage.events.enquiryTitle" :maxlength="contentLimits.sectionTitle" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.events.enquiryTitle.length }}/{{ contentLimits.sectionTitle }}</small>
</label>
</div>
<div class="events-list-heading">
<p class="section-kicker">Calendar</p>
<h4>Events</h4>
</div>
<div class="event-editor-grid">
<article v-for="(event, index) in siteContentDraft.homepage.events.items" :key="`edit-event-${index}`" class="event-editor-card">
<div class="site-editor-item-heading">
<strong>Event {{ index + 1 }}</strong>
</div>
<div class="event-editor-fields">
<label>
<span>Title</span>
<input v-model="event.title" :maxlength="contentLimits.eventTitle" @input="markSiteContentDirty" />
<small>{{ event.title.length }}/{{ contentLimits.eventTitle }}</small>
</label>
<label>
<span>Date</span>
<input v-model="event.date" :maxlength="contentLimits.eventDate" @input="markSiteContentDirty" />
<small>{{ event.date.length }}/{{ contentLimits.eventDate }}</small>
</label>
<label>
<span>Price</span>
<input v-model="event.price" :maxlength="contentLimits.eventPrice" @input="markSiteContentDirty" />
<small>{{ event.price.length }}/{{ contentLimits.eventPrice }}</small>
</label>
<label>
<span>Image path</span>
<input v-model="event.image" :maxlength="contentLimits.eventImage" @input="markSiteContentDirty" />
<small>{{ event.image.length }}/{{ contentLimits.eventImage }}</small>
</label>
<label>
<span>Description</span>
<input v-model="event.description" :maxlength="contentLimits.eventDescription" @input="markSiteContentDirty" />
<small>{{ event.description.length }}/{{ contentLimits.eventDescription }}</small>
</label>
</div>
<div class="event-editor-card-actions">
<button class="primary-action" type="button" :disabled="isSiteContentLoading || !isEventDirty(index)" @click="updateEventItem(index)">
{{ isSiteContentLoading && isEventDirty(index) ? 'Updating...' : 'Update' }}
</button>
<button class="icon-danger-action" type="button" aria-label="Delete event" @click="deleteEventItem(index)">
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M4 7h16M10 11v6M14 11v6M9 7V4h6v3M6 7l1 14h10l1-14" />
</svg>
<span>Delete</span>
</button>
</div>
</article>
</div>
</div>
</div>
</div>
<aside v-if="editTab === 'admins'" class="admin-panel-surface admin-access-panel">
<div class="admin-section-heading">
<div>
<p class="section-kicker">Access</p>
<h3>Admin requests</h3>
</div>
<strong>{{ pendingAdminRequests.length }}</strong>
</div>
<div class="admin-request-list">
<div v-if="!adminIsMaster" class="empty-admin">Only the configured master admin can approve or reject admin requests.</div>
<article v-for="request in adminAccessRequests" :key="request.id" class="admin-request-row">
<div>
<strong>{{ request.displayName }}</strong>
<small>{{ request.npub || request.pubkey }}</small>
<span>{{ request.status }}</span>
</div>
<div v-if="adminIsMaster && request.status === 'requested'" class="admin-request-actions">
<button class="primary-action" type="button" @click="updateAdminAccessRequest(request.id, 'approved')">Approve</button>
<button class="secondary-action" type="button" @click="updateAdminAccessRequest(request.id, 'rejected')">Reject</button>
</div>
</article>
<div v-if="!adminAccessRequests.length" class="empty-admin">No admin requests yet.</div>
</div>
</aside>
<aside v-if="editTab === 'notifications'" class="admin-panel-surface admin-access-panel">
<div class="admin-section-heading">
<div>
<p class="section-kicker">Notifications</p>
<h3>Push alerts</h3>
</div>
<strong>{{ notificationStats.subscriberCount || 0 }}</strong>
</div>
<div class="admin-request-list">
<article class="admin-request-row">
<div>
<strong>Status</strong>
<small>
VAPID {{ notificationStats.configured ? 'configured' : 'not configured' }} · Permission {{ notificationPermission }}
</small>
<span>{{ notificationStats.subscriberCount || 0 }} subscribed</span>
</div>
<div class="admin-request-actions">
<button class="primary-action" type="button" @click="enableNotifications">Enable</button>
<button class="secondary-action" type="button" :disabled="!notificationStats.configured" @click="sendTestNotification">Send test</button>
</div>
</article>
<p v-if="notificationMessage" class="rounded border border-amber-400/30 bg-amber-400/10 p-3 text-sm text-amber-100">
{{ notificationMessage }}
</p>
<p v-if="notificationError" class="rounded border border-red-400/40 bg-red-500/10 p-3 text-sm text-red-200">
{{ notificationError }}
</p>
</div>
</aside>
</div>
</div>
</section>
<section v-if="isAdminRoute && isAdminAuthenticated" id="admin-panel" class="admin-area">
<div class="admin-shell">
<header class="admin-header">
<img class="h-4 w-auto sm:h-5" src="/images/header-logo.svg" alt="L484" />
<nav class="admin-tabbar desktop-admin-tabbar" aria-label="Admin sections">
<button
v-for="tab in adminTabs"
:key="tab.id"
class="admin-tab"
:class="{ active: adminTab === tab.id }"
type="button"
@click="adminTab = tab.id"
>
<svg aria-hidden="true"><use :href="`#${tab.icon}`" /></svg>
<span>{{ tab.label }}</span>
<strong>{{ tab.count }}</strong>
</button>
</nav>
<div class="admin-profile">
<button class="admin-profile-button" type="button" @click="isAdminMenuOpen = !isAdminMenuOpen">
<span class="admin-avatar" :style="adminAvatarStyle">
{{ adminDisplayName.slice(0, 1) }}
</span>
<span class="admin-profile-text">
<strong>{{ adminDisplayName }}</strong>
<small>{{ adminShortKey }}</small>
</span>
</button>
<div v-if="isAdminMenuOpen" class="admin-profile-menu">
<p>{{ adminShortKey }}</p>
<button class="admin-profile-menu-action" type="button" @click="openCardReader">Card Reader</button>
<button type="button" @click="logoutAdmin">Logout</button>
</div>
</div>
</header>
<nav class="mobile-admin-tabbar" aria-label="Admin sections">
<button
v-for="tab in adminTabs"
:key="tab.id"
class="admin-tab mobile-admin-tab"
:class="{ active: adminTab === tab.id }"
type="button"
@click="adminTab = tab.id"
>
<svg aria-hidden="true"><use :href="`#${tab.icon}`" /></svg>
<span>{{ tab.label }}</span>
<strong>{{ tab.count }}</strong>
</button>
</nav>
<div
v-if="adminActionMessage || adminActionError"
class="admin-toast"
:class="{ error: adminActionError }"
role="status"
aria-live="polite"
>
{{ adminActionError || adminActionMessage }}
</div>
<div v-if="adminTab === 'site'" class="admin-panel-surface site-editor-panel">
<div class="admin-section-heading">
<div>
<p class="section-kicker">Site</p>
<h3>Homepage content</h3>
</div>
<div class="site-editor-actions">
<button class="secondary-action" type="button" :disabled="isSiteContentLoading || !siteContentDirty" @click="resetSiteContentDraft">
Reset
</button>
<button class="primary-action" type="button" :disabled="isSiteContentLoading || !siteContentDirty" @click="saveSiteContent">
{{ isSiteContentLoading ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
<div class="site-editor-grid">
<section class="site-editor-section">
<p class="section-kicker">Hero</p>
<label>
<span>Line 1</span>
<input v-model="siteContentDraft.homepage.hero.line1" :maxlength="contentLimits.heroLine" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.hero.line1.length }}/{{ contentLimits.heroLine }}</small>
</label>
<label>
<span>Line 2</span>
<input v-model="siteContentDraft.homepage.hero.line2" :maxlength="contentLimits.heroLine" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.hero.line2.length }}/{{ contentLimits.heroLine }}</small>
</label>
<label>
<span>Scroll cue</span>
<input v-model="siteContentDraft.homepage.hero.benefitsCue" :maxlength="contentLimits.cue" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.hero.benefitsCue.length }}/{{ contentLimits.cue }}</small>
</label>
</section>
<section class="site-editor-section">
<p class="section-kicker">Members</p>
<label>
<span>Section title</span>
<input v-model="siteContentDraft.homepage.members.title" :maxlength="contentLimits.sectionTitle" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.members.title.length }}/{{ contentLimits.sectionTitle }}</small>
</label>
<div v-for="(benefit, index) in siteContentDraft.homepage.members.benefits" :key="`benefit-${index}`" class="site-editor-item">
<label>
<span>Benefit {{ index + 1 }}</span>
<input v-model="benefit.title" :maxlength="contentLimits.itemTitle" @input="markSiteContentDirty" />
<small>{{ benefit.title.length }}/{{ contentLimits.itemTitle }}</small>
</label>
<label>
<span>Description</span>
<input v-model="benefit.description" :maxlength="contentLimits.itemDescription" @input="markSiteContentDirty" />
<small>{{ benefit.description.length }}/{{ contentLimits.itemDescription }}</small>
</label>
</div>
</section>
<section class="site-editor-section">
<p class="section-kicker">Facilities</p>
<label>
<span>Section title</span>
<input v-model="siteContentDraft.homepage.facilities.title" :maxlength="contentLimits.sectionTitle" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.facilities.title.length }}/{{ contentLimits.sectionTitle }}</small>
</label>
<div v-for="(facility, index) in siteContentDraft.homepage.facilities.items" :key="`facility-${index}`" class="site-editor-item">
<label>
<span>Facility {{ index + 1 }}</span>
<input v-model="facility.title" :maxlength="contentLimits.itemTitle" @input="markSiteContentDirty" />
<small>{{ facility.title.length }}/{{ contentLimits.itemTitle }}</small>
</label>
<label>
<span>Description</span>
<input v-model="facility.description" :maxlength="contentLimits.itemDescription" @input="markSiteContentDirty" />
<small>{{ facility.description.length }}/{{ contentLimits.itemDescription }}</small>
</label>
</div>
</section>
<section class="site-editor-section">
<p class="section-kicker">Events</p>
<label class="site-editor-toggle">
<input v-model="siteContentDraft.homepage.events.enabled" type="checkbox" @change="markSiteContentDirty" />
<span>Show events section</span>
</label>
<label>
<span>Nav label</span>
<input v-model="siteContentDraft.homepage.events.navLabel" :maxlength="contentLimits.navLabel" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.events.navLabel.length }}/{{ contentLimits.navLabel }}</small>
</label>
<label>
<span>Section title</span>
<input v-model="siteContentDraft.homepage.events.title" :maxlength="contentLimits.sectionTitle" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.events.title.length }}/{{ contentLimits.sectionTitle }}</small>
</label>
<label>
<span>Description</span>
<textarea v-model="siteContentDraft.homepage.events.description" :maxlength="contentLimits.eventDescription" rows="3" @input="markSiteContentDirty"></textarea>
<small>{{ siteContentDraft.homepage.events.description.length }}/{{ contentLimits.eventDescription }}</small>
</label>
<label>
<span>Enquiry title</span>
<input v-model="siteContentDraft.homepage.events.enquiryTitle" :maxlength="contentLimits.sectionTitle" @input="markSiteContentDirty" />
<small>{{ siteContentDraft.homepage.events.enquiryTitle.length }}/{{ contentLimits.sectionTitle }}</small>
</label>
<div v-for="(event, index) in siteContentDraft.homepage.events.items" :key="`event-${index}`" class="site-editor-item">
<label>
<span>Event {{ index + 1 }}</span>
<input v-model="event.title" :maxlength="contentLimits.eventTitle" @input="markSiteContentDirty" />
<small>{{ event.title.length }}/{{ contentLimits.eventTitle }}</small>
</label>
<label>
<span>Date</span>
<input v-model="event.date" :maxlength="contentLimits.eventDate" @input="markSiteContentDirty" />
<small>{{ event.date.length }}/{{ contentLimits.eventDate }}</small>
</label>
<label>
<span>Price</span>
<input v-model="event.price" :maxlength="contentLimits.eventPrice" @input="markSiteContentDirty" />
<small>{{ event.price.length }}/{{ contentLimits.eventPrice }}</small>
</label>
<label>
<span>Image path</span>
<input v-model="event.image" :maxlength="contentLimits.eventImage" @input="markSiteContentDirty" />
<small>{{ event.image.length }}/{{ contentLimits.eventImage }}</small>
</label>
<label>
<span>Description</span>
<input v-model="event.description" :maxlength="contentLimits.eventDescription" @input="markSiteContentDirty" />
<small>{{ event.description.length }}/{{ contentLimits.eventDescription }}</small>
</label>
</div>
</section>
</div>
</div>
<div v-else-if="adminTab === 'logs'" class="admin-panel-surface">
<div class="admin-section-heading">
<p class="section-kicker">Access Logs</p>
<h3>Door events</h3>
</div>
<div v-if="accessLogs.length" class="access-log-list">
<article v-for="log in accessLogs.slice(0, 30)" :key="log.id" class="access-log-row">
<span :class="log.decision === 'allow' ? 'text-emerald-300' : 'text-red-200'">{{ log.decision }}</span>
<p>{{ log.reason }} · {{ log.doorId || 'door' }}</p>
<small>{{ formatDateTime(log.seenAt) }}</small>
</article>
</div>
<div v-else class="empty-admin">No access logs yet.</div>
</div>
<div v-else-if="adminTab === 'payments'" class="admin-payments-dashboard">
<div class="payment-metric-grid">
<article class="payment-metric">
<span>Collected</span>
<strong>{{ formatUsd(paymentTotals.paid) }}</strong>
<p>{{ revenuePaidPayments.length }} paid</p>
</article>
<article class="payment-metric">
<span>Pending</span>
<strong>{{ formatUsd(paymentTotals.pending) }}</strong>
<p>{{ pendingPayments.length }} open</p>
</article>
<article class="payment-metric">
<span>Cash</span>
<strong>{{ formatUsd(paymentTotals.cash) }}</strong>
<p>Manual payments</p>
</article>
<article class="payment-metric">
<span>Bitcoin</span>
<strong>{{ formatUsd(paymentTotals.bitcoin) }}</strong>
<p>BTCPay invoices</p>
</article>
<article class="payment-metric">
<span>Comped</span>
<strong>{{ formatUsd(paymentTotals.comp) }}</strong>
<p>{{ compPayments.length }} complimentary</p>
</article>
</div>
<div class="payment-dashboard-grid">
<section class="payment-chart-panel">
<div class="payment-panel-heading">
<p class="section-kicker">Payments</p>
<h3>Last 7 days</h3>
</div>
<div class="payment-bar-chart" aria-label="Payment revenue for the last seven days">
<div v-for="day in paymentTimelineRows" :key="day.key" class="payment-day-bar">
<span :style="{ height: `${day.height}%` }"></span>
<small>{{ day.label }}</small>
</div>
</div>
</section>
<section class="payment-chart-panel">
<div class="payment-panel-heading">
<p class="section-kicker">Method Split</p>
<h3>Payment methods</h3>
</div>
<div class="payment-method-list">
<article v-for="method in paymentMethodRows" :key="method.label">
<div>
<span>{{ method.label }}</span>
<strong>{{ formatUsd(method.value) }}</strong>
</div>
<div class="payment-method-track">
<i :style="{ width: `${method.percentage}%` }"></i>
</div>
<small>{{ method.count }} payments</small>
</article>
</div>
</section>
</div>
<section class="payment-chart-panel payment-table-panel">
<div class="payment-panel-heading">
<p class="section-kicker">Recent</p>
<h3>Payment activity</h3>
</div>
<div v-if="payments.length" class="payment-table">
<article v-for="payment in payments.slice(0, 12)" :key="payment.id">
<span>{{ paymentProviderLabel(payment.provider) }}</span>
<p>{{ payment.membershipId }}</p>
<strong>{{ formatUsd(payment.amountUsd ?? MEMBERSHIP_MONTHLY_USD) }}</strong>
<small>{{ payment.status }} · {{ payment.periodLabel || `${payment.months || 1} month` }}</small>
</article>
</div>
<div v-else class="empty-admin">No payments yet.</div>
</section>
</div>
<div v-else-if="filteredAdminMembers.length" class="admin-member-grid">
<article v-for="member in filteredAdminMembers" :key="member.membershipId" class="admin-member-tile">
<button class="admin-card-button" type="button" @click="openAgreement(member)">
<div class="l484-card admin-wood-card">
<div class="card-shine"></div>
<div class="relative z-10 flex h-full flex-col justify-between">
<div class="flex items-start justify-between">
<img class="burned-card-logo" src="/images/header-logo.svg" alt="L484" />
<span class="card-status" :class="`is-${cardStatusKey(member)}`">{{ cardStatusLabel(member) }}</span>
</div>
<div class="card-midline">
<p class="card-number">{{ formatCardNumber(member.membershipId) }}</p>
</div>
<div>
<div class="mt-8 grid grid-cols-[1fr_auto_auto] gap-5">
<div>
<p class="card-label">Cardholder</p>
<p class="card-value">{{ member.fullName }}</p>
</div>
<div>
<p class="card-label">Valid</p>
<p class="card-value">{{ formatCardDate(member.createdAt) }}</p>
</div>
<div>
<p class="card-label">Expires</p>
<p class="card-value">{{ formatCardDate(member.expiresAt) }}</p>
</div>
</div>
</div>
</div>
</div>
</button>
<div class="admin-tile-actions">
<template v-if="['suspended', 'revoked', 'expired'].includes(member.accessStatus || member.status)">
<button class="primary-action compact-action" type="button" @click="openPayment(member)">
Pay dues
</button>
</template>
<template v-else-if="paymentForMember(member.membershipId)">
<label class="admin-scan-field" aria-label="NFC card credential">
<input
v-model="cardCredentialInputs[member.membershipId]"
type="password"
autocomplete="off"
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>
<button class="secondary-action compact-action" type="button" @click="simulateAccessCheck(member.membershipId)">
Test access
</button>
<button class="secondary-action compact-action" type="button" @click="updateMemberStatus(member.membershipId, 'suspended')">
Suspend
</button>
</template>
<template v-else>
<button class="primary-action compact-action" type="button" @click="openPayment(member)">
Pay
</button>
<button class="secondary-action compact-action" type="button" @click="updateMemberStatus(member.membershipId, 'suspended')">
Suspend
</button>
</template>
</div>
</article>
</div>
<div v-else class="empty-admin">
No {{ adminTabs.find((tab) => tab.id === adminTab)?.label.toLowerCase() }} members yet.
</div>
</div>
</section>
<div v-if="isCardReaderOpen" class="modal-backdrop" @click.self="closeCardReader">
<div class="backup-modal card-reader-modal" role="dialog" aria-modal="true" aria-labelledby="card-reader-title">
<div class="modal-header">
<div>
<p class="section-kicker">Live Door Feed</p>
<h2 id="card-reader-title">Card Reader</h2>
</div>
<button class="modal-close" type="button" aria-label="Close" @click="closeCardReader"></button>
</div>
<div class="card-reader-body">
<div class="card-reader-status" :class="[cardReaderStatus, { pulse: cardReaderPulse }]">
<span class="card-reader-live-dot"></span>
<strong>{{ cardReaderStatusLabel }}</strong>
<p>{{ cardReaderHasAttempt ? formatDateTime(latestAccessLog.seenAt) : 'Listening for the next PN532 scan.' }}</p>
</div>
<div class="card-reader-grid">
<article>
<span>Person</span>
<strong>{{ cardReaderHasAttempt ? cardReaderMemberLabel : 'Waiting' }}</strong>
<p>{{ cardReaderHasAttempt ? (latestAccessLog?.member?.membershipId || 'No member matched') : 'No scan yet' }}</p>
</article>
<article>
<span>Card</span>
<strong>{{ cardReaderHasAttempt ? cardReaderCardLabel : 'Waiting' }}</strong>
<p>{{ cardReaderHasAttempt ? (latestAccessLog?.cardId || 'No card record') : 'No scan yet' }}</p>
</article>
<article>
<span>Door</span>
<strong>{{ cardReaderDoorLabel }}</strong>
<p>{{ cardReaderUnlockLabel }}</p>
</article>
<article>
<span>Response</span>
<strong>{{ cardReaderHasAttempt ? (latestAccessLog?.reason || 'No response') : 'No response yet' }}</strong>
<p>{{ cardReaderHasAttempt ? (latestAccessLog?.decision || 'listening') : 'listening' }}</p>
</article>
</div>
<div class="card-reader-history">
<div class="admin-section-heading">
<p class="section-kicker">Recent Attempts</p>
<h3>Last card reads</h3>
</div>
<div v-if="accessLogs.length" class="card-reader-log-list">
<article v-for="log in accessLogs.slice(0, 8)" :key="log.id" class="card-reader-log-row" :class="{ latest: log.id === latestAccessLog?.id }">
<span :class="log.decision === 'allow' ? 'text-emerald-300' : 'text-red-200'">{{ log.decision }}</span>
<div>
<strong>{{ log.member?.fullName || 'Unknown card' }}</strong>
<p>{{ log.cardPublicId || log.cardId || 'Unregistered' }} · {{ log.reason }} · {{ log.doorId || 'door' }}</p>
</div>
<small>{{ formatDateTime(log.seenAt) }}</small>
</article>
</div>
<div v-else class="empty-admin">No card reads yet.</div>
</div>
<p v-if="cardReaderError" class="validation-message text-sm text-red-200">{{ cardReaderError }}</p>
</div>
<div class="card-reader-footer">
<button class="secondary-action compact-action" type="button" @click="pollCardReader">Refresh</button>
<button class="primary-action compact-action" type="button" @click="closeCardReader">Close</button>
</div>
</div>
</div>
<div v-if="isEventEditorOpen" class="modal-backdrop event-editor-modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="event-editor-title">
<form class="backup-modal event-editor-modal" @submit.prevent="createEventFromModal">
<div class="modal-header">
<div>
<p class="section-kicker">Events</p>
<h2 id="event-editor-title">Add event</h2>
</div>
<button class="modal-close" type="button" aria-label="Close" @click="closeEventEditor"></button>
</div>
<div class="modal-body space-y-4">
<label class="field-label">
Title
<input v-model="eventEditorForm.title" class="field-input" :maxlength="contentLimits.eventTitle" required />
</label>
<label class="field-label">
Date
<input v-model="eventEditorForm.date" class="field-input" :maxlength="contentLimits.eventDate" placeholder="By enquiry" />
</label>
<label class="field-label">
Price
<input v-model="eventEditorForm.price" class="field-input" :maxlength="contentLimits.eventPrice" placeholder="Custom" />
</label>
<label class="field-label">
Image path
<input v-model="eventEditorForm.image" class="field-input" :maxlength="contentLimits.eventImage" required />
</label>
<label class="field-label">
Description
<textarea v-model="eventEditorForm.description" class="field-input" :maxlength="contentLimits.eventDescription" rows="4"></textarea>
</label>
</div>
<div class="modal-footer border-t border-white/10 p-5">
<button class="secondary-action" type="button" @click="closeEventEditor">Cancel</button>
<button class="primary-action" type="submit">Add event</button>
</div>
</form>
</div>
<div v-if="isMemberSigninOpen" class="modal-backdrop" @click.self="closeMemberSignin">
<div class="backup-modal">
<div class="flex items-center justify-between gap-4 border-b border-white/10 p-5">
<div>
<p class="section-kicker">Member Sign In</p>
<h2 class="text-2xl font-black uppercase leading-none">Nostr signer</h2>
</div>
<button class="modal-close" type="button" aria-label="Close" @click="closeMemberSignin"></button>
</div>
<div class="space-y-4 p-5">
<p class="text-sm leading-6 text-white/62">
Sign in with the same npub you received at signup. Your browser extension or Amber must hold that issued key.
</p>
<div class="signin-options">
<button class="primary-action signin-option" type="button" :disabled="isMemberSigninLoading || isRemoteSignerLoading" @click="loginMemberWithExtension">
{{ isMemberSigninLoading ? 'Connecting...' : 'Browser extension' }}
<small>NIP-07 · Alby, nos2x, Primal</small>
</button>
<button class="primary-action signin-option" type="button" :disabled="isMemberSigninLoading || isRemoteSignerLoading" @click="loginMemberWithRemoteApp">
<span v-if="isRemoteSignerLoading" class="signin-loading-label">
<img src="/images/small-logo.svg" alt="" aria-hidden="true" />
Waiting for signer...
</span>
<template v-else>Open signer app</template>
<small>Amber, Primal, or Nostr Connect</small>
</button>
</div>
<p v-if="memberSigninError" class="validation-message text-sm text-red-200">{{ memberSigninError }}</p>
</div>
</div>
</div>
<div v-if="isAgreementOpen && selectedAgreementMember" class="modal-backdrop" @click.self="closeAgreement">
<div class="agreement-modal">
<div class="flex items-center justify-between gap-4 border-b border-white/10 p-5">
<div>
<p class="section-kicker">Membership Agreement</p>
<h2 class="text-2xl font-black uppercase leading-none">{{ selectedAgreementMember.fullName }}</h2>
<p class="mt-2 text-sm text-white/50">{{ selectedAgreementMember.membershipId }}</p>
</div>
<button class="modal-close" type="button" aria-label="Close" @click="closeAgreement"></button>
</div>
<div class="agreement-body">
<div class="agreement-info">
<p><span>Full name</span>{{ selectedAgreementMember.fullName }}</p>
<p><span>Email</span>{{ selectedAgreementMember.email || 'N/A' }}</p>
<p><span>Phone</span>{{ selectedAgreementMember.phone || 'N/A' }}</p>
<p><span>Signed</span>{{ formatDate(selectedAgreementMember.signedDate) }}</p>
<p><span>Expires</span>{{ formatDate(selectedAgreementMember.expiresAt) }}</p>
</div>
<div class="covenant-box">
<h3>L484 Membership Covenant</h3>
<p class="mb-4 text-sm text-white/62">
By submitting this application and becoming a member of L484, I acknowledge and agree that:
</p>
<ol>
<li v-for="item in covenantItems" :key="item">{{ item }}</li>
</ol>
</div>
<div>
<p class="field-label mb-2">Signature</p>
<div class="agreement-signature">
<img
v-if="selectedAgreementMember.signature"
:src="selectedAgreementMember.signature"
alt="Member signature"
/>
<span v-else>No signature image stored</span>
</div>
</div>
</div>
<div class="modal-footer border-t border-white/10 p-5">
<button class="icon-danger-action" type="button" aria-label="Delete member" @click="deleteSelectedAgreementMember">
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M4 7h16M10 11v6M14 11v6M9 7V4h6v3M6 7l1 14h10l1-14" />
</svg>
</button>
<button class="primary-action" type="button" @click="downloadAgreement(selectedAgreementMember)">
Download agreement
</button>
</div>
</div>
</div>
<div v-if="isPaymentOpen && selectedPaymentMember" class="modal-backdrop" @click.self="closePayment">
<div class="backup-modal payment-choice-modal">
<div class="flex items-center justify-between gap-4 border-b border-white/10 p-5">
<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 }} · {{ paymentPeriodLabel }} · {{ formatUsd(paymentTotalUsd) }}</p>
</div>
<button class="modal-close" type="button" aria-label="Close" @click="closePayment"></button>
</div>
<div class="space-y-4 p-5">
<template v-if="paymentModalInvoice">
<div class="bitcoin-invoice-panel">
<div v-if="paymentInvoicePaid" class="invoice-success">
<strong>Payment received</strong>
<p>BTCPay has confirmed this invoice. The member record has been updated.</p>
</div>
<template v-else>
<div class="invoice-awaiting">
<span></span>
<strong>Awaiting payment</strong>
</div>
<div class="invoice-method-tabs" role="tablist" aria-label="Bitcoin payment method">
<button
type="button"
:class="{ active: paymentInvoiceMethod === 'lightning' }"
@click="paymentInvoiceMethod = 'lightning'"
>
Lightning
</button>
<button
type="button"
:class="{ active: paymentInvoiceMethod === 'onchain' }"
@click="paymentInvoiceMethod = 'onchain'"
>
On-chain
</button>
</div>
<div class="invoice-qr-wrap">
<img v-if="paymentInvoiceQrUrl" :src="paymentInvoiceQrUrl" alt="BTCPay invoice QR code" />
<div v-else class="invoice-qr-empty">QR unavailable</div>
</div>
<div class="invoice-actions">
<button class="secondary-action" type="button" @click="copyToClipboard(paymentInvoiceCopyText, 'payment-invoice')">
{{ copiedKey === 'payment-invoice' ? '' : 'Copy' }}
</button>
<a class="primary-action" :href="paymentInvoiceUrl" target="_blank" rel="noreferrer">
BTCPay
</a>
</div>
<p class="invoice-help">
Waiting for BTCPay. This screen updates when the webhook confirms payment.
</p>
</template>
</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>Record {{ paymentPeriodLabel }} paid in person.</small>
</button>
<button class="payment-choice-card comp" type="button" :disabled="isPaymentModalLoading" @click="compMembership">
<span>Comp</span>
<strong>Complimentary</strong>
<small>Grant {{ paymentPeriodLabel }} at no charge.</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 ${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>
</button>
</template>
<p v-if="paymentModalError" class="validation-message text-sm text-red-200">{{ paymentModalError }}</p>
</div>
</div>
</div>
<div v-if="isSignupOpen" class="modal-backdrop" @click.self="closeSignup">
<div class="signup-modal" :class="{ 'card-modal': signupStep === 5 }">
<div class="flex items-center justify-between gap-4 border-b border-white/10 p-5">
<div>
<p class="section-kicker">L484 Membership</p>
<h2 class="text-2xl font-black uppercase leading-none">
{{ signupStep === 5 ? 'Membership Card' : 'Become a member' }}
</h2>
</div>
<button class="modal-close" type="button" aria-label="Close" @click="closeSignup"></button>
</div>
<div v-if="signupStep > 1 && signupStep < 5" class="step-row">
<span :class="{ active: signupStep >= 2 }">1</span>
<i></i>
<span :class="{ active: signupStep >= 3 }">2</span>
<i></i>
<span :class="{ active: signupStep >= 4 }">3</span>
</div>
<div class="modal-body">
<div v-if="signupStep === 0" class="space-y-5">
<p class="text-lg text-white/80">
Create an L484 private membership profile, accept the covenant, and receive a
generated membership card.
</p>
<div class="membership-price-panel">
<p class="field-label">Membership contribution</p>
<div class="membership-price-row">
<strong>$350</strong>
<span>per month</span>
</div>
<div class="membership-btc-row">
<span class="btc-price-pill">{{ membershipBtcText }}</span>
<small>at BTC {{ bitcoinUsdText }}</small>
</div>
</div>
<div class="membership-pickup-card">
<span class="membership-pickup-icon" aria-hidden="true">
<img src="/images/entrance-door-icon.svg" alt="" />
</span>
<p>
Complete signup here, then come to the center to pick up your physical member card and pay in person.
</p>
</div>
</div>
<div v-if="signupStep === 1" class="pwa-install-step">
<div class="pwa-install-hero">
<img src="/images/app-icon-192.png" alt="" aria-hidden="true" />
<div>
<p class="section-kicker">Install required</p>
<h3>{{ pwaInstallTitle }}</h3>
<p>{{ pwaInstallCopy }}</p>
</div>
</div>
<div v-if="installPlatform === 'ios'" class="signin-options">
<div class="pwa-install-card" :class="{ active: installPlatform === 'android' }">
<strong>Android</strong>
<small>Chrome will show an install prompt when available. Otherwise use menu, Install app.</small>
</div>
<div class="pwa-install-card" :class="{ active: installPlatform === 'ios' }">
<strong>iPhone</strong>
<small>Open in Safari, tap Share, Add to Home Screen, then launch L484 from the icon.</small>
</div>
<div class="pwa-install-card" :class="{ active: installPlatform === 'desktop' }">
<strong>Desktop</strong>
<small>Use the browser install icon or menu, then open L484 as an app window.</small>
</div>
</div>
<p v-if="pwaInstallMessage" class="validation-message rounded border border-amber-400/30 bg-amber-400/10 p-3 text-sm text-amber-100">
{{ pwaInstallMessage }}
</p>
<p v-if="pwaInstallMessage" class="validation-message rounded border border-white/15 bg-white/5 p-3 text-sm text-white/70">
Open the L484 app icon on your homescreen. Signup will continue at name entry.
</p>
</div>
<div v-if="signupStep === 2" class="space-y-4">
<label class="field-label">
Full name
<input
v-model="form.fullName"
class="field-input"
type="text"
autocomplete="name"
maxlength="80"
required
/>
</label>
<label class="field-label">
Email <span class="optional-label">(optional)</span>
<input
v-model="form.email"
class="field-input"
type="email"
autocomplete="email"
maxlength="160"
/>
</label>
<label class="field-label">
Phone <span class="optional-label">(optional)</span>
<input
v-model="form.phone"
class="field-input"
type="tel"
autocomplete="tel"
maxlength="32"
pattern="[0-9()+.\\-\\s]{7,32}"
/>
</label>
</div>
<div v-if="signupStep === 3" class="space-y-5">
<div class="covenant-box signup-covenant-box">
<h3>L484 Membership Covenant</h3>
<p class="mb-4 text-sm text-white/62">
By submitting this application and becoming a member of L484, I acknowledge and agree that:
</p>
<ol>
<li v-for="item in covenantItems" :key="item">{{ item }}</li>
</ol>
</div>
</div>
<div v-if="signupStep === 4" class="space-y-4">
<div>
<div class="mb-2 flex items-center justify-between gap-3">
<p class="field-label">Digital signature</p>
<button class="signature-clear" type="button" @click="clearSignature">Clear</button>
</div>
<div class="signature-box" :class="{ signed: signatureHasInk }">
<canvas
ref="signatureCanvas"
class="signature-canvas"
@pointerdown.prevent="startSignature"
@pointermove.prevent="drawSignature"
@pointerup.prevent="endSignature"
@pointercancel.prevent="endSignature"
@pointerleave="endSignature"
></canvas>
</div>
<div class="mt-2 flex items-center justify-between text-sm">
<span class="text-white/55">Sign above with mouse, trackpad, or touch.</span>
<span :class="signatureHasInk ? 'text-emerald-300' : 'text-amber-300/75'">
{{ signatureHasInk ? 'Signed' : 'Signature required' }}
</span>
</div>
</div>
</div>
<div v-if="signupStep === 5 && createdMember" class="card-modal-content space-y-6">
<div class="card-reveal-stage mx-auto" :class="{ 'is-revealing': isCardRevealing }">
<div v-if="isCardRevealing" class="card-spinner" aria-hidden="true">
<img src="/images/small-logo.svg" alt="" />
</div>
<div class="l484-card mx-auto">
<div class="card-shine"></div>
<div class="relative z-10 flex h-full flex-col justify-between">
<div class="flex items-start justify-between">
<img class="burned-card-logo" src="/images/header-logo.svg" alt="L484" />
<span class="card-status" :class="`is-${cardStatusKey(createdMember)}`">{{ cardStatusLabel(createdMember) }}</span>
</div>
<div class="card-midline">
<p class="card-number">{{ formatCardNumber(createdMember.membershipId) }}</p>
</div>
<div>
<div class="mt-8 grid grid-cols-[1fr_auto_auto] gap-5">
<div>
<p class="card-label">Cardholder</p>
<p class="card-value">{{ createdMember.fullName }}</p>
</div>
<div>
<p class="card-label">Valid</p>
<p class="card-value">{{ formatCardDate(createdMember.createdAt) }}</p>
</div>
<div>
<p class="card-label">Expires</p>
<p class="card-value">{{ formatCardDate(createdMember.expiresAt) }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<p class="card-note">
Your card is saved locally in this browser. Keep an encrypted export file to import it later.
</p>
<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 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">
Save this nsec. It lets you recover your card and member information. Back it up with
<a href="https://keys.band" target="_blank" rel="noreferrer">keys.band</a>, import it into a Nostr browser extension, or use Amber on mobile to sign in later.
</p>
<div class="member-key-row">
<span>npub</span>
<code>{{ generatedCredentials.npub }}</code>
<button class="secondary-action compact-action" type="button" @click="copyToClipboard(generatedCredentials.npub, 'npub')">
{{ copiedKey === 'npub' ? '' : 'Copy' }}
</button>
</div>
<div class="member-key-row">
<span>nsec</span>
<code>{{ generatedCredentials.nsec }}</code>
<button class="secondary-action compact-action" type="button" @click="copyToClipboard(generatedCredentials.nsec, 'nsec')">
{{ copiedKey === 'nsec' ? '' : 'Copy' }}
</button>
</div>
</div>
</div>
<p v-if="formError" class="validation-message rounded border border-red-400/40 bg-red-500/10 p-3 text-sm text-red-200">
{{ formError }}
</p>
</div>
<div
class="modal-footer border-t border-white/10 p-5"
:class="{ 'install-step-footer': signupStep === 1, 'has-terms-check': signupStep === 3 }"
>
<template v-if="signupStep === 0">
<button class="primary-action" type="button" @click="startSignup">
Start signup
</button>
</template>
<template v-else-if="signupStep === 1">
<button class="secondary-action" type="button" @click="previousStep">
Back
</button>
<button class="primary-action" type="button" @click="handlePwaInstallPrimary">
{{ pwaInstallPrimaryLabel }}
</button>
<button v-if="installPlatform !== 'ios' && !isPwaStandalone" class="skip-app-action" type="button" @click="continueWithoutInstall">
Skip
</button>
</template>
<template v-else-if="signupStep === 5">
<button class="secondary-action" type="button" @click="openBackup">
Export
</button>
<button class="secondary-action" type="button" @click="openRestore">
Import
</button>
</template>
<template v-else>
<label v-if="signupStep === 3" class="signup-terms-check">
<input v-model="form.accepted" type="checkbox" />
<span>I have read and agree to the L484 Membership Covenant.</span>
</label>
<button class="secondary-action" type="button" @click="previousStep">
Back
</button>
<button v-if="signupStep < 4" class="primary-action" type="button" :disabled="signupStep === 3 && !form.accepted" @click="nextStep">
Continue
</button>
<button v-else-if="signupStep === 4" class="primary-action" type="button" :disabled="isCreatingMembership" @click="createMembership">
<span v-if="isCreatingMembership" class="button-spinner-only" aria-label="Creating card">
<img src="/images/small-logo.svg" alt="" />
</span>
<template v-else>Enable alerts & create card</template>
</button>
</template>
</div>
</div>
</div>
<div v-if="isBackupOpen" class="modal-backdrop" @click.self="isBackupOpen = false">
<div class="backup-modal">
<div class="border-b border-white/10 p-5">
<p class="section-kicker">Encrypted Export</p>
<h2 class="text-2xl font-black uppercase leading-none">Export member file</h2>
</div>
<div class="space-y-4 p-5">
<p class="text-sm leading-6 text-white/62">
Choose a password for the encrypted JSON export. It includes your card and, when available,
your Nostr npub/nsec so you can import the key into keys.band, a browser extension, or Amber.
</p>
<label class="field-label">
Export password
<input v-model="backupPassword" class="field-input" type="password" autocomplete="new-password" />
</label>
<p v-if="backupError" class="validation-message text-sm text-red-200">{{ backupError }}</p>
</div>
<div class="modal-footer border-t border-white/10 p-5">
<button class="secondary-action" type="button" @click="isBackupOpen = false">Cancel</button>
<button class="primary-action" type="button" @click="downloadEncryptedBackup">
Download
</button>
</div>
</div>
</div>
<div v-if="isRestoreOpen" class="modal-backdrop" @click.self="isRestoreOpen = false">
<div class="backup-modal">
<div class="border-b border-white/10 p-5">
<p class="section-kicker">Import Card</p>
<h2 class="text-2xl font-black uppercase leading-none">Encrypted import</h2>
</div>
<div class="space-y-4 p-5">
<p class="text-sm leading-6 text-white/62">
Enter the export password, then choose the encrypted member file.
</p>
<label class="field-label">
Export password
<input v-model="restorePassword" class="field-input" type="password" autocomplete="current-password" />
</label>
<input
ref="backupFileInput"
class="hidden"
type="file"
accept="application/json,.json"
@change="restoreEncryptedBackup"
/>
<button class="primary-action w-full" type="button" @click="backupFileInput?.click()">
Choose encrypted file
</button>
<p v-if="backupError" class="validation-message text-sm text-red-200">{{ backupError }}</p>
<p v-if="backupMessage" class="validation-message text-sm text-emerald-300">{{ backupMessage }}</p>
</div>
<div class="modal-footer border-t border-white/10 p-5">
<button class="secondary-action" type="button" @click="isRestoreOpen = false">Close</button>
</div>
</div>
</div>
</main>
</template>