<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, subscribeToNotifications } 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 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 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 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 adminUser = ref('')
const adminError = ref('')
const adminLoginMethod = ref('')
const isAdminLoading = ref(false)
const isAdminMenuOpen = ref(false)
const mobileMenuOpen = ref(false)
let isSigning = false
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 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(() => 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 paidPayments = computed(() => payments.value.filter((payment) => payment.status === 'paid'))
const pendingPayments = computed(() => payments.value.filter((payment) => payment.status !== 'paid'))
const paymentTotals = computed(() => {
  const paid = paidPayments.value.reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
  const pending = pendingPayments.value.reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
  const cash = paidPayments.value.filter((payment) => payment.provider !== 'btcpay').reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
  const bitcoin = paidPayments.value.filter((payment) => payment.provider === 'btcpay').reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
  return { paid, pending, cash, bitcoin }
})
const paymentMethodRows = computed(() => {
  const rows = [
    { label: 'Cash', value: paymentTotals.value.cash, count: paidPayments.value.filter((payment) => payment.provider !== 'btcpay').length },
    { label: 'Bitcoin', value: paymentTotals.value.bitcoin, count: paidPayments.value.filter((payment) => payment.provider === 'btcpay').length },
  ]
  const max = Math.max(...rows.map((row) => row.value), 1)
  return rows.map((row) => ({ ...row, percentage: Math.max(4, Math.round((row.value / max) * 100)) }))
})
const paymentTimelineRows = computed(() => {
  const days = Array.from({ length: 7 }, (_, index) => {
    const date = new Date()
    date.setDate(date.getDate() - (6 - index))
    const key = date.toISOString().slice(0, 10)
    return { key, label: date.toLocaleDateString('en-US', { weekday: 'short' }), value: 0 }
  })
  paidPayments.value.forEach((payment) => {
    const key = String(payment.paidAt || payment.createdAt || '').slice(0, 10)
    const day = days.find((item) => item.key === key)
    if (day) day.value += Number(payment.amountUsd || 0)
  })
  const max = Math.max(...days.map((day) => day.value), 1)
  return days.map((day) => ({ ...day, height: Math.max(6, Math.round((day.value / max) * 100)) }))
})
const paymentMethodData = (invoice, method) => {
  if (!invoice) return ''
  if (method === 'lightning') {
    return invoice.lightningInvoice || invoice.lightningPaymentLink || invoice.paymentUrl || invoice.checkoutLink || ''
  }
  return invoice.paymentAddress || invoice.bitcoinPaymentLink || invoice.paymentUrl || invoice.checkoutLink || ''
}
const paymentInvoiceQrData = computed(() => {
  return paymentMethodData(paymentModalInvoice.value, paymentInvoiceMethod.value)
})
const paymentInvoiceCopyText = computed(() => {
  return paymentMethodData(paymentModalInvoice.value, paymentInvoiceMethod.value)
})
const paymentInvoiceUrl = computed(() => paymentModalInvoice.value?.paymentUrl || paymentModalInvoice.value?.checkoutLink || '#')
const paymentInvoiceStatus = computed(() => paymentModalInvoice.value?.status || 'New')
const paymentInvoicePaid = computed(() => ['settled', 'complete'].some((status) => paymentInvoiceStatus.value.toLowerCase().includes(status)))
const paymentInvoiceStatusClass = computed(() => {
  const status = paymentInvoiceStatus.value.toLowerCase()
  if (status.includes('settled') || status.includes('complete')) return 'settled'
  if (status.includes('processing')) return 'processing'
  if (status.includes('expired') || status.includes('invalid')) return 'expired'
  return 'new'
})
const adminDisplayName = computed(() => (adminUser.value ? 'L484 Admin' : 'Admin'))
const adminShortKey = computed(() =>
  adminUser.value ? `${adminUser.value.slice(0, 8)}...${adminUser.value.slice(-6)}` : '',
)
const adminAvatarStyle = computed(() => {
  const hue = adminUser.value
    ? parseInt(adminUser.value.slice(0, 6), 16) % 360
    : 42
  return {
    background: `linear-gradient(135deg, hsl(${hue} 52% 34%), hsl(${(hue + 32) % 360} 62% 18%))`,
  }
})
const canContinue = computed(() => {
  if (signupStep.value === 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'
  if (installPlatform.value === 'ios') return 'Install L484 on iPhone'
  if (installPlatform.value === 'android') return 'Install L484 app'
  return 'Install L484 on this device'
})
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 = () => (adminUser.value ? { Authorization: `Bearer ${adminUser.value}` } : {})

const fetchJson = async (url, options = {}) => {
  const response = await fetch(url, {
    ...options,
    headers: {
      ...(options.body ? { 'Content-Type': 'application/json' } : {}),
      ...(options.headers || {}),
    },
  })
  const data = await response.json().catch(() => ({}))
  if (!response.ok) {
    throw new Error(data.error || data.message || `Request failed: ${response.status}`)
  }
  return data
}

const loadBitcoinPrice = async () => {
  bitcoinPriceError.value = ''
  if (appConfig.value.mode === 'legacy') return
  try {
    const data = await fetchJson('/api/bitcoin-price')
    const price = Number(data.usd)
    if (!Number.isFinite(price) || price <= 0) throw new Error('Bitcoin price unavailable.')
    bitcoinUsdPrice.value = price
    bitcoinPriceIsLive.value = !data.fallback
    bitcoinPriceFetchedAt.value = data.fetchedAt ? new Date(data.fetchedAt).toISOString() : new Date().toISOString()
  } catch (error) {
    bitcoinPriceError.value = error instanceof Error ? error.message : 'Bitcoin price unavailable.'
    bitcoinPriceIsLive.value = false
  }
}

const copyToClipboard = async (value, label) => {
  let copied = false
  if (navigator.clipboard?.writeText && window.isSecureContext) {
    try {
      await navigator.clipboard.writeText(value)
      copied = true
    } catch {
      copied = false
    }
  }

  if (!copied) {
    const textarea = document.createElement('textarea')
    textarea.value = value
    textarea.setAttribute('readonly', '')
    textarea.style.position = 'fixed'
    textarea.style.left = '-9999px'
    textarea.style.top = '0'
    document.body.appendChild(textarea)
    textarea.select()
    textarea.setSelectionRange(0, textarea.value.length)
    document.execCommand('copy')
    textarea.remove()
  }
  copiedKey.value = label
  window.setTimeout(() => {
    if (copiedKey.value === label) copiedKey.value = ''
  }, 1600)
}

const sanitizeForm = () => {
  form.fullName = sanitizeText(form.fullName, MAX_NAME_LENGTH)
  form.email = sanitizeText(form.email, MAX_EMAIL_LENGTH).toLowerCase()
  form.phone = sanitizeText(form.phone, MAX_PHONE_LENGTH)
}

const isValidDateString = (value) => {
  const date = new Date(value)
  return typeof value === 'string' && !Number.isNaN(date.getTime())
}

const validateApplicant = (showError = true) => {
  const fullName = sanitizeText(form.fullName, MAX_NAME_LENGTH)
  const email = sanitizeText(form.email, MAX_EMAIL_LENGTH).toLowerCase()
  const phone = sanitizeText(form.phone, MAX_PHONE_LENGTH)

  if (fullName.length < 2) {
    if (showError) formError.value = 'Enter a valid full name.'
    return false
  }

  if (email && !EMAIL_PATTERN.test(email)) {
    if (showError) formError.value = 'Enter a valid email address or leave it blank.'
    return false
  }

  if (phone && !PHONE_PATTERN.test(phone)) {
    if (showError) formError.value = 'Enter a valid phone number or leave it blank.'
    return false
  }

  return true
}

const normalizeMember = (value) => {
  if (!value || typeof value !== 'object') return null

  const membershipId = sanitizeText(value.membershipId, 24).toUpperCase()
  const fullName = sanitizeText(value.fullName, MAX_NAME_LENGTH)
  const email = sanitizeText(value.email, MAX_EMAIL_LENGTH).toLowerCase()
  const phone = sanitizeText(value.phone, MAX_PHONE_LENGTH)
  const signature = String(value.signature || '')
  const signedDate = String(value.signedDate || '')
  const createdAt = String(value.createdAt || '')
  const expiresAt = String(value.expiresAt || '')
  const userId = sanitizeText(value.userId, 80)
  const npub = sanitizeText(value.npub, 80)
  const nsecHash = sanitizeText(value.nsecHash, 64).toLowerCase()
  const signerNpubs = Array.isArray(value.signerNpubs)
    ? value.signerNpubs.map((item) => sanitizeText(item, 80)).filter((item) => NPUB_PATTERN.test(item))
    : []

  if (!MEMBERSHIP_ID_PATTERN.test(membershipId)) return null
  if (fullName.length < 2) return null
  if (email && !EMAIL_PATTERN.test(email)) return null
  if (phone && !PHONE_PATTERN.test(phone)) return null
  if (signature && !DATA_IMAGE_PATTERN.test(signature)) return null
  if (!isValidDateString(signedDate) || !isValidDateString(createdAt) || !isValidDateString(expiresAt)) return null

  return {
    membershipId,
    fullName,
    email,
    phone,
    userId,
    npub,
    signerNpubs,
    nsecHash,
    signature,
    signedDate,
    createdAt,
    expiresAt,
    status: sanitizeText(value.status || 'requested', 32),
    accessStatus: sanitizeText(value.accessStatus || value.status || 'requested', 32),
  }
}

const loadAppConfig = async () => {
  try {
    appConfig.value = await fetchJson('/api/config')
  } catch {
    appConfig.value = { mode: 'legacy', adminEnabled: true, publicMembershipEnabled: true, accessEnabled: true }
  }
}

const 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 : []
      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 loadAdminSession = () => {
  try {
    const stored = JSON.parse(localStorage.getItem(ADMIN_AUTH_KEY) || 'null')
    adminUser.value = stored?.pubkey?.toLowerCase() || ''
  } catch {
    adminUser.value = ''
  }
}

const saveMembers = () => {
  localStorage.setItem(MEMBERS_KEY, JSON.stringify(members.value))
}

const 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 : ''
    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 = ''
  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 subscribeToNotifications()
    signupNotificationsEnabled.value = true
  } catch (error) {
    formError.value = error instanceof Error ? error.message : 'Could not enable notifications.'
    isCreatingMembership.value = false
    return
  }

  const createdAt = new Date()
  const expiresAt = new Date(createdAt)
  expiresAt.setFullYear(expiresAt.getFullYear() + 1)
  const signerPubkey = await getActiveSignerPublicKey().catch(() => '')
  const privateKey = generateSecretKey()
  const pubkey = getPublicKey(privateKey)
  const nsec = nip19.nsecEncode(privateKey)
  const npub = nip19.npubEncode(pubkey)
  const signerNpub = signerPubkey && signerPubkey !== pubkey ? nip19.npubEncode(signerPubkey) : ''

  const member = {
    membershipId: `L484-${createdAt.getFullYear()}-${Math.random().toString(36).slice(2, 8).toUpperCase()}`,
    userId: getUserId(),
    fullName: form.fullName.trim(),
    email: form.email.trim(),
    phone: form.phone.trim(),
    npub,
    signerNpubs: signerNpub ? [signerNpub] : [],
    nsecHash: await sha256Hex(nsec),
    signature: form.signature.trim(),
    signedDate: createdAt.toISOString(),
    createdAt: createdAt.toISOString(),
    expiresAt: expiresAt.toISOString(),
    status: 'requested',
  }

  try {
    const result = await fetchJson('/api/membership/create', {
      method: 'POST',
      body: JSON.stringify(member),
    })
    const savedMember = normalizeMember(result.membership) || member
    members.value = [savedMember, ...members.value.filter((item) => item.membershipId !== savedMember.membershipId)]
    currentMemberId.value = savedMember.membershipId
    createdMember.value = savedMember
    generatedCredentials.value = { nsec, npub }
    localStorage.setItem(CURRENT_MEMBER_KEY, savedMember.membershipId)
    localStorage.setItem(USER_ID_KEY, savedMember.userId)
    saveMembers()
    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()
  const ratio = Math.max(window.devicePixelRatio || 1, 1)
  canvas.width = rect.width * ratio
  canvas.height = rect.height * ratio

  const context = canvas.getContext('2d')
  context.scale(ratio, ratio)
  context.fillStyle = '#080808'
  context.fillRect(0, 0, rect.width, rect.height)
  context.lineCap = 'round'
  context.lineJoin = 'round'
  context.lineWidth = 2.4
  context.strokeStyle = '#ffffff'

  if (DATA_IMAGE_PATTERN.test(form.signature)) {
    const savedSignature = new Image()
    savedSignature.onload = () => {
      context.drawImage(savedSignature, 0, 0, rect.width, rect.height)
    }
    savedSignature.src = form.signature
  }
}

const getSignaturePoint = (event) => {
  const rect = signatureCanvas.value.getBoundingClientRect()
  return {
    x: event.clientX - rect.left,
    y: event.clientY - rect.top,
  }
}

const startSignature = (event) => {
  if (!signatureCanvas.value) return
  isSigning = true
  signatureCanvas.value.setPointerCapture?.(event.pointerId)
  const context = signatureCanvas.value.getContext('2d')
  const point = getSignaturePoint(event)
  context.beginPath()
  context.moveTo(point.x, point.y)
}

const drawSignature = (event) => {
  if (!isSigning || !signatureCanvas.value) return
  const context = signatureCanvas.value.getContext('2d')
  const point = getSignaturePoint(event)
  context.lineTo(point.x, point.y)
  context.stroke()
  signatureHasInk.value = true
  form.signature = signatureCanvas.value.toDataURL('image/png')
}

const endSignature = () => {
  isSigning = false
}

const clearSignature = () => {
  form.signature = ''
  signatureHasInk.value = false
  syncSignatureCanvas()
}

const deleteMember = async (membershipId) => {
  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 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 markManualPayment = async (membershipId) => {
  adminActionError.value = ''
  adminActionMessage.value = ''
  try {
    await fetchJson('/api/payment/manual', {
      method: 'POST',
      headers: adminHeaders(),
      body: JSON.stringify({ membershipId, provider: 'manual', amountUsd: MEMBERSHIP_MONTHLY_USD }),
    })
    adminActionMessage.value = 'Cash payment recorded.'
    await refreshAdminState()
  } catch (error) {
    adminActionError.value = error instanceof Error ? error.message : 'Could not record cash payment.'
  }
}

const createBtcpayInvoice = async (membershipId) => {
  adminActionError.value = ''
  adminActionMessage.value = ''
  try {
    const result = await fetchJson('/api/payment/btcpay', {
      method: 'POST',
      headers: adminHeaders(),
      body: JSON.stringify({ membershipId }),
    })
    adminActionMessage.value = 'Bitcoin invoice created.'
    if (result.payment?.checkoutLink) window.open(result.payment.checkoutLink, '_blank', 'noopener,noreferrer')
    await refreshAdminState()
  } catch (error) {
    adminActionError.value = error instanceof Error ? error.message : 'Could not create Bitcoin invoice.'
  }
}

const issueCard = async (membershipId) => {
  const cardCredential = sanitizeText(cardCredentialInputs[membershipId], 160)
  adminActionError.value = ''
  adminActionMessage.value = ''
  try {
    await fetchJson('/api/card/issue', {
      method: 'POST',
      headers: adminHeaders(),
      body: JSON.stringify({ membershipId, cardCredential }),
    })
    cardCredentialInputs[membershipId] = ''
    adminActionMessage.value = 'NFC card activated.'
    await refreshAdminState()
  } catch (error) {
    adminActionError.value = error instanceof Error ? error.message : 'Could not activate card.'
  }
}

const simulateAccessCheck = async (membershipId) => {
  const cardCredential = sanitizeText(cardCredentialInputs[membershipId], 160)
  adminActionError.value = ''
  adminActionMessage.value = ''
  try {
    const result = await fetchJson('/api/access/check', {
      method: 'POST',
      headers: adminHeaders(),
      body: JSON.stringify({ doorId: 'mock-front-door', cardCredential }),
    })
    adminActionMessage.value = result.allow ? 'Mock access allowed.' : `Mock access denied: ${result.reason}`
    await refreshAdminState()
  } catch (error) {
    adminActionError.value = error instanceof Error ? error.message : 'Could not run access check.'
  }
}

const paymentForMember = (membershipId) =>
  payments.value.find((payment) => payment.membershipId === membershipId && payment.status === 'paid')

const latestPaymentForMember = (membershipId) =>
  payments.value.find((payment) => payment.membershipId === membershipId)

const cardForMember = (membershipId) =>
  cards.value.find((card) => card.membershipId === membershipId && card.status === 'active')

const openAgreement = (member) => {
  selectedAgreementMember.value = member
  isAgreementOpen.value = true
}

const closeAgreement = () => {
  isAgreementOpen.value = false
  selectedAgreementMember.value = null
}

const deleteSelectedAgreementMember = async () => {
  if (!selectedAgreementMember.value) return
  const membershipId = selectedAgreementMember.value.membershipId
  closeAgreement()
  await deleteMember(membershipId)
}

const openPayment = (member) => {
  selectedPaymentMember.value = member
  paymentModalInvoice.value = null
  paymentModalError.value = ''
  paymentInvoiceMethod.value = 'lightning'
  isPaymentOpen.value = true
}

const closePayment = () => {
  isPaymentOpen.value = false
  selectedPaymentMember.value = null
  paymentModalInvoice.value = null
  paymentModalError.value = ''
  paymentInvoiceMethod.value = 'lightning'
  isPaymentModalLoading.value = false
  paymentModalLoadingMethod.value = ''
}

const takeCashPayment = async () => {
  if (!selectedPaymentMember.value) return
  isPaymentModalLoading.value = true
  paymentModalLoadingMethod.value = 'cash'
  paymentModalError.value = ''
  await markManualPayment(selectedPaymentMember.value.membershipId)
  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 }),
    })
    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 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 () => {
  const data = await fetchJson('/api/admin/access-requests', { headers: adminHeaders() })
  adminAccessRequests.value = Array.isArray(data.requests) ? data.requests : []
  adminIsMaster.value = Boolean(data.isMasterAdmin)
}

const loginAdminWithExtension = async () => {
  adminError.value = ''
  adminLoginMethod.value = 'extension'
  isAdminLoading.value = true

  try {
    setAdminSession(await loginWithExtension())
    await verifyAdminSession()
    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 {
    setAdminSession(await loginWithRemoteApp())
    await verifyAdminSession()
    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 {
    setAdminSession(await resumeRemoteAppLogin())
    await verifyAdminSession()
    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
  isAdminMenuOpen.value = false
  localStorage.removeItem(ADMIN_AUTH_KEY)
}

const encodeBase64 = (bytes) => btoa(String.fromCharCode(...bytes))

const decodeBase64 = (value) => Uint8Array.from(atob(value), (char) => char.charCodeAt(0))

const deriveBackupKey = async (password, salt) => {
  const baseKey = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(password),
    'PBKDF2',
    false,
    ['deriveKey'],
  )

  return crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt,
      iterations: 150000,
      hash: 'SHA-256',
    },
    baseKey,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt'],
  )
}

const openBackup = () => {
  backupPassword.value = ''
  backupMessage.value = ''
  backupError.value = ''
  isBackupOpen.value = true
}

const openRestore = () => {
  restorePassword.value = ''
  backupMessage.value = ''
  backupError.value = ''
  isRestoreOpen.value = true
}

const downloadEncryptedBackup = async () => {
  backupError.value = ''
  backupMessage.value = ''

  if (!currentMember.value) {
    backupError.value = 'Create a membership card before exporting.'
    return
  }

  if (backupPassword.value.length < 8) {
    backupError.value = 'Use at least 8 characters for the export password.'
    return
  }

  try {
    const salt = crypto.getRandomValues(new Uint8Array(16))
    const iv = crypto.getRandomValues(new Uint8Array(12))
    const key = await deriveBackupKey(backupPassword.value, salt)
    const payload = {
      type: 'l484-membership-card-backup',
      version: 2,
      exportedAt: new Date().toISOString(),
      member: currentMember.value,
      keys: generatedCredentials.value,
    }
    const encrypted = await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv },
      key,
      new TextEncoder().encode(JSON.stringify(payload)),
    )
    const backup = {
      type: payload.type,
      version: payload.version,
      kdf: 'PBKDF2-SHA256',
      iterations: 150000,
      salt: encodeBase64(salt),
      iv: encodeBase64(iv),
      data: encodeBase64(new Uint8Array(encrypted)),
    }
    const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
    const url = URL.createObjectURL(blob)
    const anchor = document.createElement('a')
    anchor.href = url
    anchor.download = `${currentMember.value.membershipId}-encrypted-member-export.json`
    anchor.click()
    URL.revokeObjectURL(url)
    backupMessage.value = 'Encrypted member export created.'
    isBackupOpen.value = false
  } catch {
    backupError.value = 'Could not create the encrypted export.'
  }
}

const restoreEncryptedBackup = async (event) => {
  backupError.value = ''
  backupMessage.value = ''
  const file = event.target.files?.[0]
  event.target.value = ''

  if (!file) return
  if (restorePassword.value.length < 1) {
    backupError.value = 'Enter the export password before choosing a file.'
    return
  }

  try {
    const backup = JSON.parse(await file.text())
    if (backup.type !== 'l484-membership-card-backup' || !backup.data) {
      throw new Error('Invalid encrypted member file')
    }

    const salt = decodeBase64(backup.salt)
    const iv = decodeBase64(backup.iv)
    const key = await deriveBackupKey(restorePassword.value, salt)
    const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, decodeBase64(backup.data))
    const payload = JSON.parse(new TextDecoder().decode(decrypted))
    const member = normalizeMember(payload.member)

    if (!member) {
      throw new Error('Invalid membership payload')
    }

    members.value = [member, ...members.value.filter((item) => item.membershipId !== member.membershipId)]
    currentMemberId.value = member.membershipId
    createdMember.value = member
    if (payload.keys?.nsec?.startsWith('nsec1') && payload.keys?.npub?.startsWith('npub1')) {
      generatedCredentials.value = payload.keys
    }
    localStorage.setItem(CURRENT_MEMBER_KEY, member.membershipId)
    if (member.userId) localStorage.setItem(USER_ID_KEY, member.userId)
    saveMembers()
    backupMessage.value = 'Encrypted member file imported.'
    isRestoreOpen.value = false
  } catch {
    backupError.value = 'Could not import this file. Check the password and file.'
  }
}

const createPrintableSignature = (signature) =>
  new Promise((resolve) => {
    if (!DATA_IMAGE_PATTERN.test(signature)) {
      resolve('')
      return
    }

    const image = new Image()
    image.onload = () => {
      const canvas = document.createElement('canvas')
      canvas.width = image.naturalWidth
      canvas.height = image.naturalHeight
      const context = canvas.getContext('2d')
      context.drawImage(image, 0, 0)
      const pixels = context.getImageData(0, 0, canvas.width, canvas.height)

      for (let index = 0; index < pixels.data.length; index += 4) {
        const red = pixels.data[index]
        const green = pixels.data[index + 1]
        const blue = pixels.data[index + 2]
        const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722
        const inkAlpha = Math.max(0, Math.min(255, (luminance - 32) * 1.5))

        pixels.data[index] = 255 - inkAlpha
        pixels.data[index + 1] = 255 - inkAlpha
        pixels.data[index + 2] = 255 - inkAlpha
        pixels.data[index + 3] = 255
      }

      context.putImageData(pixels, 0, 0)
      resolve(canvas.toDataURL('image/png'))
    }
    image.onerror = () => resolve('')
    image.src = signature
  })

const agreementHtml = (member, printableSignature = '') => {
  const safe = (value) =>
    String(value || '')
      .replaceAll('&', '&amp;')
      .replaceAll('<', '&lt;')
      .replaceAll('>', '&gt;')
      .replaceAll('"', '&quot;')
      .replaceAll("'", '&#039;')

  const signatureImage = DATA_IMAGE_PATTERN.test(printableSignature)
    ? `<img src="${printableSignature}" alt="Member signature" class="signature" />`
    : '<div class="signature-text">No signature image stored</div>'

  const covenantList = covenantItems.map((item) => `<li>${safe(item)}</li>`).join('')

  return `<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>L484 Membership Agreement - ${safe(member.fullName)}</title>
  <style>
    @media print { @page { margin: 0.5in; } }
    body { font-family: Arial, sans-serif; color: #333; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }
    h1 { font-size: 24px; margin-bottom: 10px; text-transform: uppercase; }
    h2 { font-size: 20px; margin-top: 30px; margin-bottom: 10px; }
    h3 { font-size: 18px; margin-top: 20px; margin-bottom: 10px; }
    h4 { font-size: 16px; margin-top: 15px; margin-bottom: 8px; }
    p { margin: 10px 0; }
    ol { margin: 10px 0; padding-left: 30px; }
    li { margin: 8px 0; }
    .header { text-align: center; margin-bottom: 30px; }
    .section { margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; }
    .info-item { margin: 8px 0; }
    .covenant { background: #f5f5f5; padding: 20px; border-radius: 5px; margin: 20px 0; }
    .signature-section { margin-top: 20px; }
    .signature-box { border: 1px solid #ccc; padding: 15px; margin: 10px 0; }
    .signature { max-width: 300px; max-height: 100px; background: #fff; padding: 12px; }
    .signature-text { font-style: italic; color: #666; }
  </style>
</head>
<body>
  <div class="header">
    <h1>L484</h1>
    <h2>A Private Membership Association</h2>
    <p><em>Note: This is a private, members-only association. Participation is absolutely voluntary.</em></p>
  </div>

  <div class="section">
    <h3>Step 1 — Applicant Information</h3>
    <div class="info-item"><strong>Full Name:</strong> ${safe(member.fullName || 'N/A')}</div>
    <div class="info-item"><strong>Email:</strong> ${safe(member.email || 'N/A')}</div>
    <div class="info-item"><strong>Phone:</strong> ${safe(member.phone || 'N/A')}</div>
    <div class="info-item"><strong>Membership ID:</strong> ${safe(member.membershipId)}</div>
    <div class="info-item"><strong>Expires:</strong> ${safe(formatDate(member.expiresAt))}</div>
  </div>

  <div class="section">
    <h3>Step 2 — Membership Covenant</h3>
    <p><em>Please read carefully before continuing.</em></p>
    <div class="covenant">
      <h4>L484 Membership Covenant</h4>
      <p>By submitting this application and becoming a member of L484, I acknowledge and agree that:</p>
      <ol>${covenantList}</ol>
    </div>
  </div>

  <div class="section">
    <h3>Step 3 — Agreement Confirmation</h3>
    <p><strong>Acceptance:</strong></p>
    <p>"I have read and agree to the L484 Membership Covenant, and I voluntarily apply for membership."</p>
    <div class="signature-section">
      <p><strong>Digital Signature:</strong></p>
      <div class="signature-box">${signatureImage}</div>
    </div>
    <p><strong>Date:</strong> ${safe(formatDate(member.signedDate))}</p>
  </div>
</body>
</html>`
}

const downloadAgreement = async (member) => {
  if (!member) return
  const printableSignature = await createPrintableSignature(member.signature)
  const printWindow = window.open('', '_blank')
  if (!printWindow) {
    alert('Please allow popups to generate the PDF.')
    return
  }

  printWindow.document.write(agreementHtml(member, printableSignature))
  printWindow.document.close()
  printWindow.onload = () => {
    setTimeout(() => {
      printWindow.print()
    }, 500)
  }
}

const formatCardNumber = (membershipId) => {
  const cleaned = membershipId.replace(/[^A-Z0-9]/gi, '').toUpperCase().padEnd(16, '0')
  return cleaned.slice(0, 16).match(/.{1,4}/g).join(' ')
}

const formatCardDate = (dateString) => {
  const date = new Date(dateString)
  return `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getFullYear()).slice(-2)}`
}

const cardStatusKey = (member) => {
  const status = member?.accessStatus || member?.status || 'requested'
  if (['suspended', 'revoked', 'expired'].includes(status)) return 'suspended'
  if (paymentForMember(member?.membershipId) || ['active', 'pending_card'].includes(status)) return 'active'
  return 'pending'
}

const cardStatusLabel = (member) => cardStatusKey(member)

const formatDate = (dateString) =>
  new Date(dateString).toLocaleDateString('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
  })

const formatUsd = (value) =>
  new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    maximumFractionDigits: 0,
  }).format(value)

const formatSats = (value) =>
  new Intl.NumberFormat('en-US', {
    maximumFractionDigits: 0,
  }).format(value)

const syncHeroParallax = () => {
  parallaxFrame = 0
  const scrollY = Math.max(0, window.scrollY || window.pageYOffset || 0)
  const viewportHeight = Math.max(1, window.innerHeight || 1)
  const progress = Math.min(scrollY / viewportHeight, 1)
  document.documentElement.style.setProperty('--hero-bg-y', `${scrollY * 0.18}px`)
  document.documentElement.style.setProperty('--hero-copy-y', `${scrollY * -0.12}px`)
  document.documentElement.style.setProperty('--hero-copy-fade', String(Math.max(0.18, 1 - progress * 0.82)))
  document.documentElement.style.setProperty('--members-pattern-y', `${scrollY * -0.08}px`)
  const patternSpinRate = window.innerWidth < 700 ? 0.48 : 0.58
  document.documentElement.style.setProperty('--members-pattern-rotate', `${scrollY * patternSpinRate}deg`)
  const facilities = document.getElementById('facilities')
  if (facilities) {
    const rect = facilities.getBoundingClientRect()
    const localProgress = Math.max(-1, Math.min(1, (viewportHeight - rect.top) / (viewportHeight + rect.height)))
    document.documentElement.style.setProperty('--facilities-bg-y', `${localProgress * -74}px`)
    document.documentElement.style.setProperty('--facilities-copy-y', `${localProgress * 46}px`)
    document.documentElement.style.setProperty('--facilities-list-y', `${localProgress * -34}px`)
  }
}

const requestHeroParallax = () => {
  if (parallaxFrame) return
  parallaxFrame = window.requestAnimationFrame(syncHeroParallax)
}

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()
  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()
  }
  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)
  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()
  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(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>
        <button class="mobile-menu-link" type="button" @click="currentMember ? signOutMember() : openMemberSignin(); mobileMenuOpen = false">
          {{ currentMember ? 'Sign out' : 'Sign in' }}
        </button>
        <button class="mobile-menu-link mobile-menu-cta" type="button" @click="openSignup(); mobileMenuOpen = false">
          {{ currentMember ? 'View card' : 'Become a member' }}
        </button>
      </div>
    </div>

    <section
      v-if="isHomeRoute"
      class="hero-fold relative isolate min-h-screen 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="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-black via-black/70 to-transparent"></div>
      <div class="film-grain absolute inset-0 opacity-[0.14] mix-blend-screen"></div>
      <div class="scanline absolute inset-x-0 top-0 h-px bg-amber-300/80"></div>

      <div class="hero-shell relative z-10 mx-auto flex min-h-svh w-full max-w-7xl flex-col px-4 pb-5 pt-24 sm:px-10 sm:pb-7 sm:pt-28 lg:px-12">
        <div class="hero-content grid flex-1 items-center gap-5 py-6 sm:gap-10 sm:py-14 lg:py-16">
          <div class="intro-copy">
            <h1 class="hero-title font-black uppercase leading-[0.86] tracking-normal">
              <span class="hero-title-line hero-title-line-primary">{{ homepageContent.hero.line1 }}</span>
              <span class="hero-title-line hero-title-line-secondary">{{ homepageContent.hero.line2 }}</span>
            </h1>
          </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 && !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 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="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 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>{{ formatDate(log.seenAt) }}</small>
            </article>
          </div>
          <div v-else class="empty-admin">No access logs yet.</div>
        </div>

        <div v-else-if="adminTab === 'payments'" class="admin-payments-dashboard">
          <div class="payment-metric-grid">
            <article class="payment-metric">
              <span>Collected</span>
              <strong>{{ formatUsd(paymentTotals.paid) }}</strong>
              <p>{{ paidPayments.length }} paid</p>
            </article>
            <article class="payment-metric">
              <span>Pending</span>
              <strong>{{ formatUsd(paymentTotals.pending) }}</strong>
              <p>{{ pendingPayments.length }} open</p>
            </article>
            <article class="payment-metric">
              <span>Cash</span>
              <strong>{{ formatUsd(paymentTotals.cash) }}</strong>
              <p>Manual payments</p>
            </article>
            <article class="payment-metric">
              <span>Bitcoin</span>
              <strong>{{ formatUsd(paymentTotals.bitcoin) }}</strong>
              <p>BTCPay invoices</p>
            </article>
          </div>

          <div class="payment-dashboard-grid">
            <section class="payment-chart-panel">
              <div class="payment-panel-heading">
                <p class="section-kicker">Payments</p>
                <h3>Last 7 days</h3>
              </div>
              <div class="payment-bar-chart" aria-label="Payment revenue for the last seven days">
                <div v-for="day in paymentTimelineRows" :key="day.key" class="payment-day-bar">
                  <span :style="{ height: `${day.height}%` }"></span>
                  <small>{{ day.label }}</small>
                </div>
              </div>
            </section>

            <section class="payment-chart-panel">
              <div class="payment-panel-heading">
                <p class="section-kicker">Method Split</p>
                <h3>Cash / Bitcoin</h3>
              </div>
              <div class="payment-method-list">
                <article v-for="method in paymentMethodRows" :key="method.label">
                  <div>
                    <span>{{ method.label }}</span>
                    <strong>{{ formatUsd(method.value) }}</strong>
                  </div>
                  <div class="payment-method-track">
                    <i :style="{ width: `${method.percentage}%` }"></i>
                  </div>
                  <small>{{ method.count }} payments</small>
                </article>
              </div>
            </section>
          </div>

          <section class="payment-chart-panel payment-table-panel">
            <div class="payment-panel-heading">
              <p class="section-kicker">Recent</p>
              <h3>Payment activity</h3>
            </div>
            <div v-if="payments.length" class="payment-table">
              <article v-for="payment in payments.slice(0, 12)" :key="payment.id">
                <span>{{ payment.provider === 'btcpay' ? 'Bitcoin' : 'Cash' }}</span>
                <p>{{ payment.membershipId }}</p>
                <strong>{{ formatUsd(payment.amountUsd || MEMBERSHIP_MONTHLY_USD) }}</strong>
                <small>{{ payment.status }}</small>
              </article>
            </div>
            <div v-else class="empty-admin">No payments yet.</div>
          </section>
        </div>

        <div v-else-if="filteredAdminMembers.length" class="admin-member-grid">
          <article v-for="member in filteredAdminMembers" :key="member.membershipId" class="admin-member-tile">
            <button class="admin-card-button" type="button" @click="openAgreement(member)">
              <div class="l484-card admin-wood-card">
                <div class="card-shine"></div>
                <div class="relative z-10 flex h-full flex-col justify-between">
                  <div class="flex items-start justify-between">
                    <img class="burned-card-logo" src="/images/header-logo.svg" alt="L484" />
                    <span class="card-status" :class="`is-${cardStatusKey(member)}`">{{ cardStatusLabel(member) }}</span>
                  </div>
                  <div class="card-midline">
                    <p class="card-number">{{ formatCardNumber(member.membershipId) }}</p>
                  </div>
                  <div>
                    <div class="mt-8 grid grid-cols-[1fr_auto_auto] gap-5">
                      <div>
                        <p class="card-label">Cardholder</p>
                        <p class="card-value">{{ member.fullName }}</p>
                      </div>
                      <div>
                        <p class="card-label">Valid</p>
                        <p class="card-value">{{ formatCardDate(member.createdAt) }}</p>
                      </div>
                      <div>
                        <p class="card-label">Expires</p>
                        <p class="card-value">{{ formatCardDate(member.expiresAt) }}</p>
                      </div>
                    </div>
                  </div>
                </div>
              </div>
            </button>

            <div class="admin-tile-actions">
              <template v-if="['suspended', 'revoked', 'expired'].includes(member.accessStatus || member.status)">
                <button class="primary-action compact-action" type="button" @click="openPayment(member)">
                  Pay dues
                </button>
              </template>
              <template v-else-if="paymentForMember(member.membershipId)">
                <label class="admin-scan-field" aria-label="NFC card credential">
                  <input
                    v-model="cardCredentialInputs[member.membershipId]"
                    type="password"
                    autocomplete="off"
                    placeholder="Scan card"
                  />
                </label>
                <button v-if="!cardForMember(member.membershipId)" class="primary-action compact-action" type="button" @click="issueCard(member.membershipId)">
                  Activate card
                </button>
                <button class="secondary-action compact-action" type="button" @click="simulateAccessCheck(member.membershipId)">
                  Test access
                </button>
                <button class="secondary-action compact-action" type="button" @click="updateMemberStatus(member.membershipId, 'suspended')">
                  Suspend
                </button>
              </template>
              <template v-else>
                <button class="primary-action compact-action" type="button" @click="openPayment(member)">
                  Pay
                </button>
                <button class="secondary-action compact-action" type="button" @click="updateMemberStatus(member.membershipId, 'suspended')">
                  Suspend
                </button>
              </template>
            </div>

          </article>
        </div>

        <div v-else class="empty-admin">
          No {{ adminTabs.find((tab) => tab.id === adminTab)?.label.toLowerCase() }} members yet.
        </div>
      </div>
    </section>

    <div v-if="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 }} · {{ formatUsd(MEMBERSHIP_MONTHLY_USD) }}</p>
          </div>
          <button class="modal-close" type="button" aria-label="Close" @click="closePayment"></button>
        </div>
        <div class="space-y-4 p-5">
          <template v-if="paymentModalInvoice">
            <div class="bitcoin-invoice-panel">
              <div v-if="paymentInvoicePaid" class="invoice-success">
                <strong>Payment received</strong>
                <p>BTCPay has confirmed this invoice. The member record has been updated.</p>
              </div>
              <template v-else>
                <div class="invoice-awaiting">
                  <span></span>
                  <strong>Awaiting payment</strong>
                </div>
                <div class="invoice-method-tabs" role="tablist" aria-label="Bitcoin payment method">
                  <button
                    type="button"
                    :class="{ active: paymentInvoiceMethod === 'lightning' }"
                    @click="paymentInvoiceMethod = 'lightning'"
                  >
                    Lightning
                  </button>
                  <button
                    type="button"
                    :class="{ active: paymentInvoiceMethod === 'onchain' }"
                    @click="paymentInvoiceMethod = 'onchain'"
                  >
                    On-chain
                  </button>
                </div>
                <div class="invoice-qr-wrap">
                  <img v-if="paymentInvoiceQrUrl" :src="paymentInvoiceQrUrl" alt="BTCPay invoice QR code" />
                  <div v-else class="invoice-qr-empty">QR unavailable</div>
                </div>
                <div class="invoice-actions">
                  <button class="secondary-action" type="button" @click="copyToClipboard(paymentInvoiceCopyText, 'payment-invoice')">
                    {{ copiedKey === 'payment-invoice' ? '✓' : 'Copy' }}
                  </button>
                  <a class="primary-action" :href="paymentInvoiceUrl" target="_blank" rel="noreferrer">
                    BTCPay
                  </a>
                </div>
                <p class="invoice-help">
                  Waiting for BTCPay. This screen updates when the webhook confirms payment.
                </p>
              </template>
            </div>
          </template>
          <template v-else>
            <button class="payment-choice-card" type="button" :disabled="isPaymentModalLoading" @click="takeCashPayment">
              <span>$ Cash</span>
              <strong>Paid in cash</strong>
              <small>Use when the member pays in person.</small>
            </button>
            <button class="payment-choice-card bitcoin" type="button" :disabled="isPaymentModalLoading || !appConfig.btcpayEnabled" @click="createBitcoinPayment">
              <span>₿ Bitcoin</span>
              <strong>BTCPay invoice</strong>
              <small>{{ appConfig.btcpayEnabled ? 'Create a Bitcoin checkout invoice.' : 'BTCPay is not configured.' }}</small>
              <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">
              <h3>L484 Membership Covenant</h3>
              <p class="mb-4 text-sm text-white/62">
                By submitting this application and becoming a member of L484, I acknowledge and agree that:
              </p>
              <ol>
                <li v-for="item in covenantItems" :key="item">{{ item }}</li>
              </ol>
            </div>
            <label class="flex items-center gap-3 text-sm leading-none text-white/75">
              <input v-model="form.accepted" class="h-4 w-4 shrink-0 accent-amber-400" type="checkbox" />
              <span>I have read and agree to the L484 Membership Covenant.</span>
            </label>
          </div>

          <div v-if="signupStep === 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 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 }">
          <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 app
            </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>
            <button class="secondary-action" type="button" @click="previousStep">
              Back
            </button>
            <button v-if="signupStep < 4" class="primary-action" type="button" @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>
