2422 lines
96 KiB
Vue
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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''')
|
|
|
|
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>
|