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