Files
sapien/src/App.vue
2026-05-14 12:44:15 -05:00

2422 lines
96 KiB
Vue

<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { sha256 } from '@noble/hashes/sha256'
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
import {
cancelPendingRemoteAppLogin,
clearSigner,
getActiveSignerPublicKey,
hasPendingRemoteAppLogin,
loginWithExtension,
loginWithRemoteApp,
resumeRemoteAppLogin,
} from './services/signer'
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 facilityBackgrounds = Object.entries(
import.meta.glob('../public/images/{sauna,plunge,gym,firepit}.avif', {
eager: true,
query: '?url',
import: 'default',
}),
)
.sort(([first], [second]) => first.localeCompare(second, undefined, { numeric: true }))
.map(([, src]) => src)
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 adminPubkeys = [
'7b849efa5604b58d50c419637b9873847dbf957081d526136c3a49b7357cd617',
'2be35e9237eaf98fe0a7d1ce3ceb666dccde9c8cc800ff52f138a62c91d90783',
]
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 activeFacilityBackground = ref(0)
const hasRotatingBackgrounds = computed(() => heroBackgrounds.length > 1)
const hasRotatingFacilityBackgrounds = computed(() => facilityBackgrounds.length > 1)
const isSignupOpen = ref(false)
const isMemberSigninOpen = ref(false)
const signupStep = ref(0)
const members = ref([])
const currentMemberId = ref('')
const createdMember = ref(null)
const isCardRevealing = ref(false)
const generatedCredentials = ref(null)
const copiedKey = ref('')
const memberSigninError = ref('')
const isMemberSigninLoading = ref(false)
const isRemoteSignerLoading = ref(false)
const formError = ref('')
const signatureCanvas = ref(null)
const signatureHasInk = ref(false)
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 paymentInvoiceMethod = ref('lightning')
const currentPath = ref(window.location.pathname)
const appConfig = ref({ mode: 'all', adminEnabled: true, publicMembershipEnabled: true, accessEnabled: true })
const payments = ref([])
const cards = ref([])
const accessLogs = ref([])
const adminTab = ref('requested')
const adminActionMessage = ref('')
const adminActionError = ref('')
const cardCredentialInputs = reactive({})
const adminUser = ref('')
const adminNsec = ref('')
const adminError = ref('')
const adminLoginMethod = ref('')
const isAdminLoading = ref(false)
const isAdminMenuOpen = ref(false)
let isSigning = false
const form = reactive({
fullName: '',
email: '',
phone: '',
signature: '',
accepted: false,
})
let backgroundTimer
let facilityBackgroundTimer
let adminToastTimer
let parallaxFrame = 0
let adminEvents = null
const currentMember = computed(() =>
members.value.find((member) => member.membershipId === currentMemberId.value) || null,
)
const isAdminRoute = computed(() => currentPath.value === '/admin')
const isAdminAuthenticated = computed(() => adminPubkeys.includes(adminUser.value))
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 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 paidPayments = computed(() => payments.value.filter((payment) => payment.status === 'paid'))
const pendingPayments = computed(() => payments.value.filter((payment) => payment.status !== 'paid'))
const paymentTotals = computed(() => {
const paid = paidPayments.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 = paidPayments.value.filter((payment) => payment.provider !== 'btcpay').reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
const bitcoin = paidPayments.value.filter((payment) => payment.provider === 'btcpay').reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
return { paid, pending, cash, bitcoin }
})
const paymentMethodRows = computed(() => {
const rows = [
{ label: 'Cash', value: paymentTotals.value.cash, count: paidPayments.value.filter((payment) => payment.provider !== 'btcpay').length },
{ label: 'Bitcoin', value: paymentTotals.value.bitcoin, count: paidPayments.value.filter((payment) => payment.provider === 'btcpay').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 }
})
paidPayments.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 paymentInvoiceQrUrl = computed(() =>
paymentInvoiceQrData.value
? `https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&ecc=H&data=${encodeURIComponent(paymentInvoiceQrData.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 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 === 1) return validateApplicant(false)
if (signupStep.value === 2) return form.accepted
if (signupStep.value === 3) return DATA_IMAGE_PATTERN.test(form.signature)
return true
})
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 sanitizeText = (value, maxLength) =>
String(value ?? '')
.normalize('NFKC')
.replace(/[\u0000-\u001f\u007f<>`{}[\]\\]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength)
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 = () => (adminUser.value ? { Authorization: `Bearer ${adminUser.value}` } : {})
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 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 : []
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 loadAdminSession = () => {
try {
const stored = JSON.parse(localStorage.getItem(ADMIN_AUTH_KEY) || 'null')
adminUser.value = stored?.pubkey?.toLowerCase() || ''
} catch {
adminUser.value = ''
}
}
const saveMembers = () => {
localStorage.setItem(MEMBERS_KEY, JSON.stringify(members.value))
}
const openSignup = () => {
isSignupOpen.value = true
loadBitcoinPrice()
signupStep.value = currentMember.value ? 4 : 0
createdMember.value = currentMember.value
isCardRevealing.value = false
generatedCredentials.value = null
formError.value = ''
}
const openMemberSignin = () => {
isMemberSigninOpen.value = true
memberSigninError.value = ''
}
const navigateTo = (path) => {
window.history.pushState({}, '', path)
currentPath.value = window.location.pathname
}
const closeSignup = () => {
isSignupOpen.value = false
isCardRevealing.value = false
generatedCredentials.value = null
}
const closeMemberSignin = () => {
isMemberSigninOpen.value = false
memberSigninError.value = ''
isRemoteSignerLoading.value = false
cancelPendingRemoteAppLogin()
}
const signOutMember = () => {
clearSigner()
cancelPendingRemoteAppLogin()
localStorage.removeItem('l484-member-keys')
currentMemberId.value = ''
createdMember.value = null
generatedCredentials.value = null
isSignupOpen.value = false
isMemberSigninOpen.value = false
isRemoteSignerLoading.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 = ''
signatureHasInk.value = false
}
const nextStep = () => {
formError.value = ''
sanitizeForm()
if (signupStep.value === 1 && !validateApplicant()) {
return
}
if (signupStep.value !== 1 && !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
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
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 () => {
await loadMembers()
if (!currentMember.value) return
isRemoteSignerLoading.value = false
isMemberSigninLoading.value = false
isMemberSigninOpen.value = false
memberSigninError.value = ''
}
const createMembership = async () => {
sanitizeForm()
if (!validateApplicant()) {
return
}
if (!canContinue.value) {
formError.value = 'Please sign the agreement before creating your membership.'
return
}
const createdAt = new Date()
const expiresAt = new Date(createdAt)
expiresAt.setFullYear(expiresAt.getFullYear() + 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 }
localStorage.setItem(CURRENT_MEMBER_KEY, savedMember.membershipId)
localStorage.setItem(USER_ID_KEY, savedMember.userId)
saveMembers()
resetForm()
isCardRevealing.value = true
signupStep.value = 4
window.setTimeout(() => {
isCardRevealing.value = false
}, 2400)
} catch (error) {
formError.value = error instanceof Error ? error.message : 'Could not save membership.'
}
}
const syncSignatureCanvas = () => {
const canvas = signatureCanvas.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const ratio = Math.max(window.devicePixelRatio || 1, 1)
canvas.width = rect.width * ratio
canvas.height = rect.height * ratio
const context = canvas.getContext('2d')
context.scale(ratio, ratio)
context.fillStyle = '#080808'
context.fillRect(0, 0, rect.width, rect.height)
context.lineCap = 'round'
context.lineJoin = 'round'
context.lineWidth = 2.4
context.strokeStyle = '#ffffff'
}
const getSignaturePoint = (event) => {
const rect = signatureCanvas.value.getBoundingClientRect()
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
}
}
const startSignature = (event) => {
if (!signatureCanvas.value) return
isSigning = true
signatureCanvas.value.setPointerCapture?.(event.pointerId)
const context = signatureCanvas.value.getContext('2d')
const point = getSignaturePoint(event)
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)
context.lineTo(point.x, point.y)
context.stroke()
signatureHasInk.value = true
form.signature = signatureCanvas.value.toDataURL('image/png')
}
const endSignature = () => {
isSigning = false
}
const clearSignature = () => {
form.signature = ''
signatureHasInk.value = false
syncSignatureCanvas()
}
const deleteMember = async (membershipId) => {
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 disconnectAdminEvents = () => {
if (!adminEvents) return
adminEvents.close()
adminEvents = null
}
const connectAdminEvents = () => {
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 = () => {}
}
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 markManualPayment = async (membershipId) => {
adminActionError.value = ''
adminActionMessage.value = ''
try {
await fetchJson('/api/payment/manual', {
method: 'POST',
headers: adminHeaders(),
body: JSON.stringify({ membershipId, provider: 'manual', amountUsd: MEMBERSHIP_MONTHLY_USD }),
})
adminActionMessage.value = 'Cash payment recorded.'
await refreshAdminState()
} catch (error) {
adminActionError.value = error instanceof Error ? error.message : 'Could not record cash payment.'
}
}
const createBtcpayInvoice = async (membershipId) => {
adminActionError.value = ''
adminActionMessage.value = ''
try {
const result = await fetchJson('/api/payment/btcpay', {
method: 'POST',
headers: adminHeaders(),
body: JSON.stringify({ membershipId }),
})
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'
isPaymentOpen.value = true
}
const closePayment = () => {
isPaymentOpen.value = false
selectedPaymentMember.value = null
paymentModalInvoice.value = null
paymentModalError.value = ''
paymentInvoiceMethod.value = 'lightning'
isPaymentModalLoading.value = false
}
const takeCashPayment = async () => {
if (!selectedPaymentMember.value) return
isPaymentModalLoading.value = true
paymentModalError.value = ''
await markManualPayment(selectedPaymentMember.value.membershipId)
isPaymentModalLoading.value = false
if (!adminActionError.value) closePayment()
}
const createBitcoinPayment = async () => {
if (!selectedPaymentMember.value) return
isPaymentModalLoading.value = true
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 }),
})
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
}
}
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 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)
if (!adminPubkeys.includes(normalized)) {
throw new Error(`Access denied. The key "${normalized.slice(0, 8)}..." is not an authorized administrator.`)
}
adminUser.value = normalized
localStorage.setItem(ADMIN_AUTH_KEY, JSON.stringify({ pubkey: normalized, lastLogin: new Date().toISOString() }))
}
const loginWithNip07 = async () => {
adminError.value = ''
adminLoginMethod.value = 'nip07'
isAdminLoading.value = true
try {
if (!window.nostr?.getPublicKey) {
throw new Error('Nostr extension not found. Install or unlock a NIP-07 extension and try again.')
}
const pubkey = await window.nostr.getPublicKey()
setAdminSession(pubkey)
await loadMembers()
connectAdminEvents()
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nostr extension login failed.'
} finally {
isAdminLoading.value = false
adminLoginMethod.value = ''
}
}
const loginWithNsec = async () => {
adminError.value = ''
adminLoginMethod.value = 'nsec'
isAdminLoading.value = true
try {
const cleanNsec = adminNsec.value.trim()
if (!cleanNsec.startsWith('nsec1')) {
throw new Error('Invalid nsec format. It should start with nsec1.')
}
const decoded = nip19.decode(cleanNsec)
if (decoded.type !== 'nsec') {
throw new Error('Invalid nsec format.')
}
const privateKey = decoded.data
const pubkey = getPublicKey(privateKey instanceof Uint8Array ? privateKey : bytesToHex(privateKey))
setAdminSession(pubkey)
await loadMembers()
connectAdminEvents()
adminNsec.value = ''
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nsec login failed.'
} finally {
isAdminLoading.value = false
adminLoginMethod.value = ''
}
}
const logoutAdmin = () => {
disconnectAdminEvents()
adminUser.value = ''
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 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)
}
onMounted(async () => {
localStorage.removeItem('l484-member-keys')
await loadAppConfig()
loadAdminSession()
await loadMembers()
connectAdminEvents()
if (appConfig.value.mode !== 'legacy') loadBitcoinPrice()
const navigationEntry = performance.getEntriesByType?.('navigation')?.[0]
const isPageReload = navigationEntry?.type === 'reload'
if (isPageReload) {
cancelPendingRemoteAppLogin()
isRemoteSignerLoading.value = false
if (window.location.pathname === '/auth/nostr-callback') {
window.history.replaceState({}, '', '/')
currentPath.value = '/'
}
} else if (window.location.pathname === '/auth/nostr-callback') {
resumePendingRemoteSignin()
}
window.addEventListener('popstate', () => {
currentPath.value = window.location.pathname
})
window.addEventListener('storage', (event) => {
if (event.key === SIGNER_LOGIN_COMPLETE_KEY && !isAdminRoute.value) {
handleSignerCompletion()
}
})
window.addEventListener('resize', syncSignatureCanvas)
window.addEventListener('scroll', requestHeroParallax, { passive: true })
window.addEventListener('resize', requestHeroParallax)
syncHeroParallax()
if (hasRotatingBackgrounds.value) {
backgroundTimer = window.setInterval(() => {
activeBackground.value = (activeBackground.value + 1) % heroBackgrounds.length
}, 6500)
}
if (hasRotatingFacilityBackgrounds.value) {
facilityBackgroundTimer = window.setInterval(() => {
activeFacilityBackground.value = (activeFacilityBackground.value + 1) % facilityBackgrounds.length
}, 7600)
}
})
onBeforeUnmount(() => {
window.clearInterval(backgroundTimer)
window.clearInterval(facilityBackgroundTimer)
window.clearTimeout(adminToastTimer)
disconnectAdminEvents()
window.removeEventListener('resize', syncSignatureCanvas)
window.removeEventListener('scroll', requestHeroParallax)
window.removeEventListener('resize', requestHeroParallax)
if (parallaxFrame) window.cancelAnimationFrame(parallaxFrame)
})
watch(signupStep, async (step) => {
if (step !== 3) return
await nextTick()
syncSignatureCanvas()
})
watch([adminActionMessage, adminActionError], ([message, error]) => {
window.clearTimeout(adminToastTimer)
if (!message && !error) return
adminToastTimer = window.setTimeout(() => {
adminActionMessage.value = ''
adminActionError.value = ''
}, error ? 5200 : 3200)
})
</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>
</svg>
<header v-if="!isAdminRoute" 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">
<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>
<div class="flex items-center gap-2">
<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>
<section v-if="!isAdminRoute" class="hero-fold relative isolate min-h-screen overflow-hidden">
<div class="hero-bg-layer">
<img
v-for="(background, index) in heroBackgrounds"
:key="background"
class="hero-bg absolute inset-0 h-full w-full object-cover"
:class="{ 'is-active': index === activeBackground }"
:src="background"
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="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-black via-black/70 to-transparent"></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">Decentralization</span>
<span class="hero-title-line hero-title-line-secondary">In Motion</span>
</h1>
</div>
</div>
<a class="benefits-cue" href="#members-get" aria-label="Scroll to member benefits">
<span>Benefits</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="!isAdminRoute" 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">Members Get</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 class="members-get-item">
<span>01</span>
<span class="list-icon" aria-hidden="true"><svg><use href="#icon-milk" /></svg></span>
<div>
<h3>Raw milk</h3>
<p>Placeholder member benefit.</p>
</div>
</article>
<article class="members-get-item">
<span>02</span>
<span class="list-icon" aria-hidden="true"><svg><use href="#icon-beef" /></svg></span>
<div>
<h3>Grass fed beef</h3>
<p>Placeholder member benefit.</p>
</div>
</article>
<article class="members-get-item">
<span>03</span>
<span class="list-icon" aria-hidden="true"><svg><use href="#icon-upgrade" /></svg></span>
<div>
<h3>Upgrade Labs</h3>
<p>Placeholder member benefit.</p>
</div>
</article>
<article class="members-get-item">
<span>04</span>
<span class="list-icon" aria-hidden="true"><svg><use href="#icon-meals" /></svg></span>
<div>
<h3>5 meals a month</h3>
<p>Placeholder member benefit.</p>
</div>
</article>
<article class="members-get-item">
<span>05</span>
<span class="list-icon" aria-hidden="true"><svg><use href="#icon-drinks" /></svg></span>
<div>
<h3>Free drinks</h3>
<p>Placeholder member benefit.</p>
</div>
</article>
</div>
</div>
</section>
<section v-if="!isAdminRoute" 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"
alt=""
/>
</div>
<div class="facilities-inner">
<div class="facilities-list" aria-label="Facilities">
<article class="facilities-item">
<span>01</span>
<span class="list-icon" aria-hidden="true"><svg><use href="#icon-sauna" /></svg></span>
<div>
<h3>Sauna</h3>
<p>6-8 person sauna available for member use.</p>
</div>
</article>
<article class="facilities-item">
<span>02</span>
<span class="list-icon" aria-hidden="true"><svg><use href="#icon-plunge" /></svg></span>
<div>
<h3>Cold plunge</h3>
<p>Two XL plunges filtered and cooled to your chosen temperature.</p>
</div>
</article>
<article class="facilities-item">
<span>03</span>
<span class="list-icon" aria-hidden="true"><svg><use href="#icon-gym" /></svg></span>
<div>
<h3>Gym</h3>
<p>Outdoor turf space set up for fitness and group workouts.</p>
</div>
</article>
<article class="facilities-item">
<span>04</span>
<span class="list-icon" aria-hidden="true"><svg><use href="#icon-event" /></svg></span>
<div>
<h3>Event space</h3>
<p>Customizable indoor and outdoor space for private gatherings.</p>
</div>
</article>
<article class="facilities-item">
<span>05</span>
<span class="list-icon" aria-hidden="true"><svg><use href="#icon-fire" /></svg></span>
<div>
<h3>Grill area / firepit</h3>
<p>Multiple grills and a custom sandbox firepit outside.</p>
</div>
</article>
</div>
<div class="facilities-copy">
<h2 class="facilities-title">Facilities</h2>
</div>
</div>
</section>
<section v-if="isAdminRoute && !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">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 the same NIP-07 extension or nsec private key flow used by K484.
</p>
</div>
<div class="mt-8 space-y-4">
<button class="primary-action w-full" type="button" :disabled="isAdminLoading" @click="loginWithNip07">
{{ isAdminLoading && adminLoginMethod === 'nip07' ? 'Connecting...' : 'Login with Nostr extension' }}
</button>
<div class="relative py-2 text-center">
<span class="bg-[#080808] px-3 text-xs uppercase tracking-[0.18em] text-white/40">Or</span>
<div class="absolute left-0 right-0 top-1/2 -z-10 h-px bg-white/10"></div>
</div>
<label class="field-label">
Admin nsec
<input
v-model="adminNsec"
class="field-input"
type="password"
autocomplete="current-password"
placeholder="nsec1..."
@keyup.enter="loginWithNsec"
/>
</label>
<button class="secondary-action w-full" type="button" :disabled="isAdminLoading || !adminNsec.trim()" @click="loginWithNsec">
{{ isAdminLoading && adminLoginMethod === 'nsec' ? 'Logging in...' : 'Login with nsec' }}
</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>
</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 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 === '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>{{ formatDate(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>{{ paidPayments.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>
</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>Cash / Bitcoin</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>{{ payment.provider === 'btcpay' ? 'Bitcoin' : 'Cash' }}</span>
<p>{{ payment.membershipId }}</p>
<strong>{{ formatUsd(payment.amountUsd || MEMBERSHIP_MONTHLY_USD) }}</strong>
<small>{{ payment.status }}</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 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="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">
{{ isRemoteSignerLoading ? 'Waiting for signer...' : 'Open signer app' }}
<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="secondary-action" type="button" @click="closeAgreement">Close</button>
<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 }} · {{ formatUsd(MEMBERSHIP_MONTHLY_USD) }}</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>
<button class="payment-choice-card" type="button" :disabled="isPaymentModalLoading" @click="takeCashPayment">
<span>$ Cash</span>
<strong>Paid in cash</strong>
<small>Use when the member pays in person.</small>
</button>
<button class="payment-choice-card bitcoin" type="button" :disabled="isPaymentModalLoading || !appConfig.btcpayEnabled" @click="createBitcoinPayment">
<span> Bitcoin</span>
<strong>BTCPay invoice</strong>
<small>{{ appConfig.btcpayEnabled ? 'Create a Bitcoin checkout invoice.' : 'BTCPay is not configured.' }}</small>
</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 === 4 }">
<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 === 4 ? 'Membership Card' : 'Become a member' }}
</h2>
</div>
<button class="modal-close" type="button" aria-label="Close" @click="closeSignup"></button>
</div>
<div v-if="signupStep > 0 && signupStep < 4" class="step-row">
<span :class="{ active: signupStep >= 1 }">1</span>
<i></i>
<span :class="{ active: signupStep >= 2 }">2</span>
<i></i>
<span :class="{ active: signupStep >= 3 }">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="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 === 2" class="space-y-5">
<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>
<label class="flex items-center gap-3 text-sm leading-none text-white/75">
<input v-model="form.accepted" class="h-4 w-4 shrink-0 accent-amber-400" type="checkbox" />
<span>I have read and agree to the L484 Membership Covenant.</span>
</label>
</div>
<div v-if="signupStep === 3" 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 === 4 && 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>
<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">
<button v-if="signupStep > 0 && signupStep < 4" class="secondary-action" type="button" @click="previousStep">
Back
</button>
<template v-if="signupStep === 4">
<button class="secondary-action" type="button" @click="openBackup">
Export
</button>
<button class="secondary-action" type="button" @click="openRestore">
Import
</button>
</template>
<button v-if="signupStep === 0" class="primary-action" type="button" @click="nextStep">
Start signup
</button>
<button v-else-if="signupStep < 3" class="primary-action" type="button" @click="nextStep">
Continue
</button>
<button v-else-if="signupStep === 3" class="primary-action" type="button" @click="createMembership">
Create card
</button>
</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>