Enhance payment processing and rental features

- Updated the BTCPay service to support internal Lightning invoices with private route hints, improving payment routing for users with private channels.
- Added reconciliation methods for pending rents and subscriptions to ensure missed payments are processed on startup.
- Enhanced the rental and subscription services to handle payments in satoshis, aligning with Lightning Network standards.
- Improved the rental modal and content detail components to display rental status and pricing more clearly, including a countdown for rental expiration.
- Refactored various components to streamline user experience and ensure accurate rental access checks.
This commit is contained in:
Dorian
2026-02-12 23:24:25 +00:00
parent cdd24a5def
commit 0da83f461c
39 changed files with 1182 additions and 270 deletions

View File

@@ -136,30 +136,46 @@ export const useAuthStore = defineStore('auth', () => {
return
}
// Real mode: validate session with backend API
// Real mode: validate session with backend API.
// For Nostr sessions, skip the Cognito-only validate-session endpoint
// and go straight to /auth/me which uses HybridAuthGuard.
try {
const isValid = await authService.validateSession()
if (storedNostrToken && storedPubkey) {
// Nostr session: restore nip98Service state then fetch user profile
nip98Service.storeTokens(
storedNostrToken,
sessionStorage.getItem('indeehub_api_refresh') ?? sessionStorage.getItem('refresh_token') ?? '',
)
if (isValid) {
await fetchCurrentUser()
if (storedCognitoToken) {
nostrPubkey.value = storedPubkey
authType.value = 'nostr'
isAuthenticated.value = true
} else if (storedCognitoToken) {
// Cognito session: use legacy validate-session
const isValid = await authService.validateSession()
if (isValid) {
await fetchCurrentUser()
authType.value = 'cognito'
cognitoToken.value = storedCognitoToken
isAuthenticated.value = true
} else {
authType.value = 'nostr'
await logout()
}
isAuthenticated.value = true
} else {
await logout()
}
} catch (apiError: any) {
if (isConnectionError(apiError)) {
console.warn('Backend not reachable — falling back to mock session.')
restoreAsMock()
} else {
throw apiError
// Token likely expired or invalid
console.warn('Session validation failed:', apiError.message)
if (accountManager.active) {
// Still have a Nostr signer — try re-authenticating
restoreAsMock()
} else {
await logout()
}
}
}
} catch (error) {

View File

@@ -32,7 +32,10 @@ export const useContentStore = defineStore('content', () => {
const projects = await contentService.getProjects({ status: 'published' })
if (projects.length === 0) {
throw new Error('No content available')
// No published content yet — not an error, use placeholder content
console.info('No published content from API, using placeholder content.')
await fetchContentFromMock()
return
}
const allContent = mapApiProjectsToContents(projects)
@@ -76,19 +79,25 @@ export const useContentStore = defineStore('content', () => {
const projects = Array.isArray(response) ? response : (response as any)?.data ?? []
if (!Array.isArray(projects) || projects.length === 0) {
throw new Error('No content available from IndeeHub API')
// No published content yet — not an error, just use mock/placeholder data
console.info('No published content in backend yet, using placeholder content.')
await fetchContentFromMock()
return
}
// Map API projects to frontend Content format
// Map API projects to frontend Content format.
// The backend returns projects with a nested `film` object (the Content entity).
// We need the content ID (film.id) for the rental/payment flow.
const allContent: Content[] = projects.map((p: any) => ({
id: p.id,
contentId: p.film?.id,
title: p.title,
description: p.synopsis || '',
thumbnail: p.poster || '',
backdrop: p.poster || '',
type: p.type || 'film',
slug: p.slug,
rentalPrice: p.rentalPrice,
rentalPrice: p.film?.rentalPrice ?? p.rentalPrice,
status: p.status,
categories: p.genre ? [p.genre.name] : [],
streamingUrl: p.streamingUrl || p.partnerStreamUrl || undefined,
@@ -168,15 +177,57 @@ export const useContentStore = defineStore('content', () => {
/**
* Convert published filmmaker projects to Content format and merge
* them into the existing content rows so they appear on the browse page.
*
* If the filmmaker projects haven't been loaded yet (e.g. first visit
* is the homepage, not backstage), attempt to fetch them first — but
* only when the user has an active session.
*/
function mergePublishedFilmmakerProjects() {
async function mergePublishedFilmmakerProjects() {
try {
const { projects } = useFilmmaker()
const published = projects.value.filter(p => p.status === 'published')
const filmmaker = useFilmmaker()
// If projects aren't loaded yet, check whether the user has
// session tokens. We check sessionStorage directly because
// authStore.initialize() may still be in-flight on first load.
if (filmmaker.projects.value.length === 0) {
const nostrToken = sessionStorage.getItem('nostr_token')
const cognitoToken = sessionStorage.getItem('auth_token')
const hasSession = !!nostrToken || !!cognitoToken
if (hasSession) {
// Always sync nip98Service tokens on page load. The token
// may exist in sessionStorage but its expiry record may be
// stale, causing nip98Service.accessToken to return null.
// Re-storing refreshes the expiry so the interceptor can
// include the token; if it really expired the backend 401
// interceptor will auto-refresh via the refresh token.
if (nostrToken) {
const { nip98Service } = await import('../services/nip98.service')
nip98Service.storeTokens(
nostrToken,
sessionStorage.getItem('indeehub_api_refresh') ?? sessionStorage.getItem('refresh_token') ?? '',
)
}
try {
await filmmaker.fetchProjects()
} catch (err) {
console.warn('[content-store] Failed to fetch filmmaker projects for merge:', err)
}
}
}
const published = filmmaker.projects.value.filter(p => p.status === 'published')
if (published.length === 0) return
console.debug(
'[content-store] Merging published projects:',
published.map(p => ({ id: p.id, title: p.title, filmId: p.film?.id, rentalPrice: p.film?.rentalPrice ?? p.rentalPrice })),
)
const publishedContent: Content[] = published.map(p => ({
id: p.id,
contentId: p.film?.id,
title: p.title || p.name,
description: p.synopsis || '',
thumbnail: p.poster || '/images/placeholder-poster.jpg',
@@ -186,7 +237,7 @@ export const useContentStore = defineStore('content', () => {
releaseYear: p.releaseDate ? new Date(p.releaseDate).getFullYear() : new Date().getFullYear(),
categories: p.genres?.map(g => g.name) || [],
slug: p.slug,
rentalPrice: p.rentalPrice,
rentalPrice: p.film?.rentalPrice ?? p.rentalPrice,
status: p.status,
apiData: p,
}))
@@ -213,7 +264,7 @@ export const useContentStore = defineStore('content', () => {
/**
* Route to the correct loader based on the active content source
*/
function fetchContentFromMock() {
async function fetchContentFromMock() {
const sourceStore = useContentSourceStore()
if (sourceStore.activeSource === 'topdocfilms') {
fetchTopDocMock()
@@ -221,30 +272,37 @@ export const useContentStore = defineStore('content', () => {
fetchIndeeHubMock()
}
// In mock mode, also include any projects published through the backstage
mergePublishedFilmmakerProjects()
// Also include any projects published through the backstage
await mergePublishedFilmmakerProjects()
}
/**
* Main fetch content method
* Main fetch content method.
* When USE_MOCK is false and the self-hosted API URL is configured,
* always try the self-hosted backend first (regardless of the
* content-source toggle, which only affects mock catalogues).
*/
async function fetchContent() {
loading.value = true
error.value = null
try {
const sourceStore = useContentSourceStore()
const apiUrl = import.meta.env.VITE_INDEEHUB_API_URL || ''
if (sourceStore.activeSource === 'indeehub-api' && !USE_MOCK_DATA) {
// Fetch from our self-hosted backend (only when backend is actually running)
await fetchContentFromIndeehubApi()
} else if (USE_MOCK_DATA) {
if (USE_MOCK_DATA) {
// Use mock data in development or when flag is set
await new Promise(resolve => setTimeout(resolve, 100))
fetchContentFromMock()
await fetchContentFromMock()
} else if (apiUrl) {
// Self-hosted backend is configured — always prefer it
await fetchContentFromIndeehubApi()
// Also merge filmmaker's published projects that may not be in the
// public results yet (e.g. content still transcoding)
await mergePublishedFilmmakerProjects()
} else {
// Fetch from original API
// No self-hosted backend — try external API
await fetchContentFromApi()
await mergePublishedFilmmakerProjects()
}
} catch (e: any) {
error.value = e.message || 'Failed to load content'
@@ -252,7 +310,7 @@ export const useContentStore = defineStore('content', () => {
// Fallback to mock data on error
console.log('Falling back to mock data...')
fetchContentFromMock()
await fetchContentFromMock()
} finally {
loading.value = false
}

View File

@@ -8,12 +8,14 @@ const STORAGE_KEY = 'indeedhub:content-source'
export const useContentSourceStore = defineStore('contentSource', () => {
const saved = localStorage.getItem(STORAGE_KEY) as ContentSourceId | null
const validSources: ContentSourceId[] = ['indeehub', 'topdocfilms', 'indeehub-api']
const activeSource = ref<ContentSourceId>(
saved && validSources.includes(saved) ? saved : 'indeehub'
)
// API source is only available when the backend URL is configured
// Default to 'indeehub-api' when the self-hosted backend URL is configured
const apiUrl = import.meta.env.VITE_INDEEHUB_API_URL || ''
const defaultSource: ContentSourceId = apiUrl ? 'indeehub-api' : 'indeehub'
const activeSource = ref<ContentSourceId>(
saved && validSources.includes(saved) ? saved : defaultSource
)
const availableSources = computed(() => {
const sources: { id: ContentSourceId; label: string }[] = [