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

@@ -74,7 +74,7 @@
<!-- Right Side Actions -->
<div class="flex items-center gap-4">
<!-- Nostr Login (persona switcher or extension) -->
<div v-if="!nostrLoggedIn" class="hidden md:flex items-center gap-2">
<div v-if="!hasNostrSession" class="hidden md:flex items-center gap-2">
<!-- Persona Switcher (dev) -->
<div class="relative persona-dropdown">
<button @click="togglePersonaMenu" class="nav-button px-3 py-2 text-xs">
@@ -118,7 +118,7 @@
<!-- Sign In (app auth, if not Nostr logged in) -->
<button
v-if="!isAuthenticated && showAuth && !nostrLoggedIn"
v-if="!isAuthenticated && showAuth && !hasNostrSession"
@click="$emit('openAuth')"
class="hidden md:block hero-play-button px-4 py-2 text-sm"
>
@@ -189,7 +189,7 @@
</div>
<!-- Active Nostr Account -->
<div v-if="nostrLoggedIn" class="hidden md:block relative profile-dropdown">
<div v-if="hasNostrSession" class="hidden md:block relative profile-dropdown">
<button
@click="toggleDropdown"
class="profile-button flex items-center gap-2"
@@ -270,12 +270,12 @@
<!-- Mobile -->
<div class="md:hidden flex items-center gap-2 mr-2">
<img
v-if="nostrLoggedIn && nostrActivePubkey"
v-if="hasNostrSession && nostrActivePubkey"
:src="`https://robohash.org/${nostrActivePubkey}.png`"
class="w-7 h-7 rounded-full"
alt="Avatar"
/>
<span v-if="nostrLoggedIn" class="text-white text-sm font-medium">{{ nostrActiveName }}</span>
<span v-if="hasNostrSession" class="text-white text-sm font-medium">{{ nostrActiveName }}</span>
<template v-else-if="isAuthenticated">
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-orange-500 to-pink-500 flex items-center justify-center text-xs font-bold text-white">
{{ userInitials }}
@@ -350,6 +350,20 @@ const contentSourceStore = useContentSourceStore()
const contentStore = useContentStore()
const isFilmmakerUser = isFilmmakerComputed
/**
* Treat the user as "truly Nostr-logged-in" only when the
* accountManager has an active account AND there's a matching
* session token. This prevents showing a stale profile pill
* after site data has been cleared.
*/
const hasNostrSession = computed(() => {
if (!nostrLoggedIn.value) return false
// If we also have a backend session, the login is genuine
if (isAuthenticated.value) return true
// Otherwise verify there's at least a stored token to back the account
return !!(sessionStorage.getItem('nostr_token') || sessionStorage.getItem('auth_token'))
})
/** Switch content source and reload */
function handleSourceSelect(sourceId: string) {
contentSourceStore.setSource(sourceId as any)
@@ -520,7 +534,7 @@ function handleMyListClick() {
if (activeAlgorithm.value) {
_setAlgorithm(activeAlgorithm.value as any) // toggle off
}
if (!isAuthenticated.value && !nostrLoggedIn.value) {
if (!isAuthenticated.value && !hasNostrSession.value) {
emit('openAuth', '/library')
return
}
@@ -553,8 +567,10 @@ async function handlePersonaLogin(persona: Persona) {
// Also populate auth store (for subscription/My List access)
try {
await appLoginWithNostr(persona.pubkey, 'persona', {})
} catch {
// Auth store mock login — non-critical if it fails
} catch (err) {
// Backend auth failed — persona still works for commenting/reactions
// via accountManager, just won't have full API access
console.warn('[PersonaLogin] Backend auth failed:', (err as Error).message)
}
}

View File

@@ -37,6 +37,12 @@
<span v-if="hasActiveSubscription" class="bg-green-500/20 text-green-400 border border-green-500/30 px-2.5 py-0.5 rounded font-medium">
Included with subscription
</span>
<span v-else-if="hasActiveRental" class="bg-green-500/20 text-green-400 border border-green-500/30 px-2.5 py-0.5 rounded font-medium flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Rented &middot; {{ rentalTimeRemaining }}
</span>
<span v-else-if="content.rentalPrice" class="bg-amber-500/20 text-amber-400 border border-amber-500/30 px-2.5 py-0.5 rounded font-medium">
{{ content.rentalPrice.toLocaleString() }} sats
</span>
@@ -49,7 +55,7 @@
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{{ hasActiveSubscription ? 'Play' : content?.rentalPrice ? 'Rent & Play' : 'Play' }}
{{ canPlay ? 'Play' : content?.rentalPrice ? 'Rent & Play' : 'Play' }}
</button>
<!-- Add to My List -->
@@ -232,6 +238,7 @@ import { ref, watch, computed } from 'vue'
import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { useNostr } from '../composables/useNostr'
import { libraryService } from '../services/library.service'
import type { Content } from '../types/content'
import VideoPlayer from './VideoPlayer.vue'
import SubscriptionModal from './SubscriptionModal.vue'
@@ -263,6 +270,44 @@ const showSubscriptionModal = ref(false)
const showRentalModal = ref(false)
const relayConnected = ref(true)
// ── Rental access state ──────────────────────────────────────────────
const hasActiveRental = ref(false)
const rentalExpiresAt = ref<Date | null>(null)
/** Human-readable time remaining on the rental (e.g. "23h 15m") */
const rentalTimeRemaining = computed(() => {
if (!rentalExpiresAt.value) return ''
const diff = rentalExpiresAt.value.getTime() - Date.now()
if (diff <= 0) return 'Expired'
const hours = Math.floor(diff / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
if (hours > 0) return `${hours}h ${minutes}m remaining`
return `${minutes}m remaining`
})
/** True when the user can play without paying (subscription or active rental) */
const canPlay = computed(() =>
hasActiveSubscription.value || hasActiveRental.value,
)
async function checkRentalAccess() {
if (!props.content) return
if (hasActiveSubscription.value) return // subscription trumps rental
if (!isAuthenticated.value && !isNostrLoggedIn.value) return
const contentId = props.content.contentId || props.content.id
if (!contentId) return
try {
const result = await libraryService.checkRentExists(contentId)
hasActiveRental.value = result.exists
rentalExpiresAt.value = result.expiresAt ? new Date(result.expiresAt) : null
} catch {
hasActiveRental.value = false
rentalExpiresAt.value = null
}
}
// Nostr social data -- subscribes to relay in real time
const nostr = useNostr()
const commentTree = computed(() => nostr.commentTree.value)
@@ -282,16 +327,22 @@ const currentUserAvatar = computed(() => {
return 'https://robohash.org/anonymous.png'
})
// Subscribe to Nostr data when content changes
// Subscribe to Nostr data and check rental status when content changes
watch(() => props.content?.id, (newId) => {
if (newId && props.isOpen) {
loadSocialData(newId)
checkRentalAccess()
}
})
watch(() => props.isOpen, (open) => {
if (open && props.content?.id) {
loadSocialData(props.content.id)
checkRentalAccess()
} else if (!open) {
// Reset rental state when modal closes
hasActiveRental.value = false
rentalExpiresAt.value = null
}
})
@@ -316,7 +367,8 @@ function handlePlay() {
return
}
if (hasActiveSubscription.value) {
// Subscription or active rental → play immediately
if (canPlay.value) {
showVideoPlayer.value = true
return
}
@@ -403,6 +455,9 @@ function handleSubscriptionSuccess() {
function handleRentalSuccess() {
showRentalModal.value = false
// Set rental active immediately (48-hour window)
hasActiveRental.value = true
rentalExpiresAt.value = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000)
showVideoPlayer.value = true
}

View File

@@ -39,7 +39,11 @@
<p class="text-white/60 text-sm">48-hour viewing period</p>
</div>
<div class="text-right">
<div class="text-3xl font-bold text-white">${{ content?.rentalPrice || '4.99' }}</div>
<div class="text-3xl font-bold text-white flex items-center gap-1 justify-end">
<svg class="w-6 h-6 text-yellow-500" viewBox="0 0 24 24" fill="currentColor"><path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/></svg>
{{ (content?.rentalPrice || 5000).toLocaleString() }}
<span class="text-lg font-normal text-white/60">sats</span>
</div>
<div class="text-white/60 text-sm">One-time payment</div>
</div>
</div>
@@ -77,7 +81,7 @@
class="hero-play-button w-full flex items-center justify-center mb-4"
>
<svg v-if="!isLoading" class="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 10h-3V7H7v3H4v3h3v3h3v-3h3v-3zm8-6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V4zm-2 0l.01 14H5V4h14z"/>
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
<span v-if="!isLoading">Pay with Lightning</span>
<span v-else>Creating invoice...</span>
@@ -117,11 +121,9 @@
<!-- Amount in sats -->
<div class="mb-4">
<div class="text-lg font-bold text-white">
{{ formatSats(invoiceData?.sourceAmount?.amount) }} sats
</div>
<div class="text-sm text-white/60">
${{ content?.rentalPrice || '4.99' }} USD
<div class="text-lg font-bold text-white flex items-center justify-center gap-1">
<svg class="w-5 h-5 text-yellow-500" viewBox="0 0 24 24" fill="currentColor"><path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/></svg>
{{ displaySats }} sats
</div>
</div>
@@ -203,7 +205,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue'
import { ref, computed, watch, onUnmounted } from 'vue'
import QRCode from 'qrcode'
import { libraryService } from '../services/library.service'
import type { Content } from '../types/content'
@@ -244,6 +246,19 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
// USE_MOCK imported from utils/mock
/**
* Display sats — prefer the invoice amount (from BTCPay), fall back to content rental price.
*/
const displaySats = computed(() => {
// If we have an invoice with a source amount, use it
const sourceAmount = invoiceData.value?.sourceAmount?.amount
if (sourceAmount) {
return formatSats(sourceAmount)
}
// Otherwise show the rental price directly (already in sats)
return (props.content?.rentalPrice || 5000).toLocaleString()
})
function closeModal() {
cleanup()
paymentState.value = 'initial'
@@ -332,13 +347,18 @@ function startCountdown(expirationDate: Date | string) {
countdownInterval = setInterval(updateCountdown, 1000)
}
function startPolling(contentId: string) {
/**
* Poll the quote endpoint for the specific BTCPay invoice.
* This also triggers server-side payment detection for route-hint invoices.
* Only transitions to 'success' when the backend confirms THIS invoice is paid.
*/
function startPolling(invoiceId: string) {
pollInterval = setInterval(async () => {
try {
if (USE_MOCK) return // mock mode handles differently
if (USE_MOCK) return
const response = await libraryService.checkRentExists(contentId)
if (response.exists) {
const quote = await libraryService.pollQuoteStatus(invoiceId)
if (quote.paid) {
cleanup()
paymentState.value = 'success'
}
@@ -381,8 +401,14 @@ async function handleRent() {
return
}
// Real API call — create Lightning invoice
const result = await libraryService.rentContent(props.content.id)
// Real API call — create Lightning invoice.
// Prefer the content entity ID (film.id) for the rental flow.
// Fall back to the project ID — the backend guard can resolve it.
const contentId = props.content.contentId || props.content.id
if (!contentId) {
throw new Error('This content is not available for rental yet.')
}
const result = await libraryService.rentContent(contentId)
invoiceData.value = result
bolt11Invoice.value = result.lnInvoice
@@ -390,9 +416,15 @@ async function handleRent() {
paymentState.value = 'invoice'
startCountdown(result.expiration)
startPolling(props.content.id)
startPolling(result.providerId)
} catch (error: any) {
errorMessage.value = error.message || 'Failed to create invoice. Please try again.'
const status = error?.response?.status || error?.statusCode
const serverMsg = error?.response?.data?.message || error?.message || ''
if (status === 403 || serverMsg.includes('Forbidden')) {
errorMessage.value = 'This content is not available for rental. The rental price may not be set yet.'
} else {
errorMessage.value = serverMsg || 'Failed to create invoice. Please try again.'
}
} finally {
isLoading.value = false
}

View File

@@ -30,18 +30,18 @@
: 'text-white/60 hover:text-white'
]"
>
Monthly
1 Month
</button>
<button
@click="period = 'annual'"
@click="period = 'yearly'"
:class="[
'px-6 py-2 rounded-lg font-medium transition-all flex items-center gap-2',
period === 'annual'
period === 'yearly'
? 'bg-white text-black'
: 'text-white/60 hover:text-white'
]"
>
Annual
1 Year
<span class="text-xs bg-orange-500 text-white px-2 py-0.5 rounded-full">Save 17%</span>
</button>
</div>
@@ -64,12 +64,13 @@
@click="selectedTier = tier.tier"
>
<h3 class="text-xl font-bold text-white mb-2">{{ tier.name }}</h3>
<div class="mb-4">
<div class="mb-4 flex items-baseline gap-1">
<svg class="w-5 h-5 text-yellow-500 self-center" viewBox="0 0 24 24" fill="currentColor"><path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/></svg>
<span class="text-3xl font-bold text-white">
${{ period === 'monthly' ? tier.monthlyPrice : tier.annualPrice }}
{{ (period === 'monthly' ? tier.monthlyPrice : tier.annualPrice).toLocaleString() }}
</span>
<span class="text-white/60 text-sm">
/{{ period === 'monthly' ? 'month' : 'year' }}
sats / {{ period === 'monthly' ? '1 month' : '1 year' }}
</span>
</div>
@@ -91,14 +92,14 @@
class="hero-play-button w-full flex items-center justify-center"
>
<svg v-if="!isLoading" class="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 10h-3V7H7v3H4v3h3v3h3v-3h3v-3zm8-6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V4zm-2 0l.01 14H5V4h14z"/>
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
<span v-if="!isLoading">Pay with Lightning ${{ selectedPrice }}</span>
<span v-if="!isLoading">Pay with Lightning {{ Number(selectedPrice).toLocaleString() }} sats</span>
<span v-else>Creating invoice...</span>
</button>
<p class="text-center text-xs text-white/40 mt-4">
Pay once per period. Renew manually when your plan expires.
One-time payment. Renew manually when your plan expires.
</p>
</template>
@@ -107,7 +108,7 @@
<div class="text-center">
<h2 class="text-2xl font-bold text-white mb-2">Pay with Lightning</h2>
<p class="text-white/60 text-sm mb-1">
{{ selectedTierName }} {{ period === 'monthly' ? 'Monthly' : 'Annual' }}
{{ selectedTierName }} {{ period === 'monthly' ? '1 Month' : '1 Year' }}
</p>
<p class="text-white/40 text-xs mb-6">
Scan the QR code or copy the invoice to pay
@@ -123,10 +124,10 @@
<!-- Amount -->
<div class="mb-4">
<div class="text-lg font-bold text-white">
{{ formatSats(invoiceData?.sourceAmount?.amount) }} sats
<div class="text-lg font-bold text-white flex items-center justify-center gap-1">
<svg class="w-5 h-5 text-yellow-500" viewBox="0 0 24 24" fill="currentColor"><path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/></svg>
{{ displaySats }} sats
</div>
<div class="text-sm text-white/60"> ${{ selectedPrice }} USD</div>
</div>
<!-- Expiration Countdown -->
@@ -229,7 +230,7 @@ const emit = defineEmits<Emits>()
type PaymentState = 'select' | 'invoice' | 'success' | 'expired'
const paymentState = ref<PaymentState>('select')
const period = ref<'monthly' | 'annual'>('monthly')
const period = ref<'monthly' | 'yearly'>('monthly')
const selectedTier = ref<string>('film-buff')
const tiers = ref<any[]>([])
const isLoading = ref(false)
@@ -259,6 +260,17 @@ const selectedPrice = computed(() => {
return period.value === 'monthly' ? tier.monthlyPrice : tier.annualPrice
})
/**
* Display sats — prefer invoice source amount (from BTCPay), fall back to tier price.
*/
const displaySats = computed(() => {
const sourceAmount = invoiceData.value?.sourceAmount?.amount
if (sourceAmount) {
return formatSats(sourceAmount)
}
return Number(selectedPrice.value).toLocaleString()
})
onMounted(async () => {
tiers.value = await subscriptionService.getSubscriptionTiers()
})
@@ -372,7 +384,7 @@ async function handleSubscribe() {
// Real API call — create Lightning subscription invoice
const result = await subscriptionService.createLightningSubscription({
type: selectedTier.value as any,
period: period.value,
period: period.value as 'monthly' | 'yearly',
})
invoiceData.value = result

View File

@@ -7,10 +7,10 @@
<UploadZone
label="Drag & drop your video file, or click to browse"
accept="video/*"
:current-file="uploads.file.fileName || (project as any)?.file"
:current-file="uploads.file.fileName || existingFile"
:status="uploads.file.status"
:progress="uploads.file.progress"
:file-name="uploads.file.fileName"
:file-name="uploads.file.fileName || existingFileLabel"
@file-selected="(f: File) => handleFileUpload('file', f)"
/>
</div>
@@ -37,10 +37,10 @@
<UploadZone
label="Upload trailer video"
accept="video/*"
:current-file="uploads.trailer.fileName || project?.trailer"
:current-file="uploads.trailer.fileName || existingTrailer"
:status="uploads.trailer.status"
:progress="uploads.trailer.progress"
:file-name="uploads.trailer.fileName"
:file-name="uploads.trailer.fileName || existingTrailerLabel"
@file-selected="(f: File) => handleFileUpload('trailer', f)"
/>
</div>
@@ -63,12 +63,13 @@
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { reactive, computed } from 'vue'
import type { ApiProject } from '../../types/api'
import UploadZone from './UploadZone.vue'
import { useUpload } from '../../composables/useUpload'
import { USE_MOCK } from '../../utils/mock'
defineProps<{
const props = defineProps<{
project: ApiProject | null
}>()
@@ -76,6 +77,31 @@ const emit = defineEmits<{
(e: 'update', field: string, value: any): void
}>()
const { addUpload } = useUpload()
/**
* Extract the human-readable file name from an S3 key.
* e.g. "projects/abc/file/uuid.mp4" → "uuid.mp4"
*/
function labelFromKey(key: string | undefined | null): string {
if (!key) return ''
const parts = key.split('/')
return parts[parts.length - 1] || key
}
// The actual video file lives in the nested content entity (project.film.file)
const existingFile = computed(() => props.project?.film?.file || '')
const existingFileLabel = computed(() => labelFromKey(existingFile.value))
// Trailer can be on the content entity's trailer object or the top-level project
const existingTrailer = computed(() => {
const filmTrailer = (props.project?.film as any)?.trailer
// trailer might be an object with a `file` property or a string
if (filmTrailer && typeof filmTrailer === 'object') return filmTrailer.file || ''
return filmTrailer || props.project?.trailer || ''
})
const existingTrailerLabel = computed(() => labelFromKey(existingTrailer.value))
interface UploadState {
status: 'idle' | 'uploading' | 'completed' | 'error'
progress: number
@@ -90,6 +116,17 @@ const uploads = reactive<Record<string, UploadState>>({
subtitles: { status: 'idle', progress: 0, fileName: '', previewUrl: '' },
})
/**
* Build an S3 key for a given field and file.
* Pattern: projects/{projectId}/{field}/{uuid}.{ext}
*/
function buildS3Key(field: string, file: File): string {
const projectId = props.project?.id ?? 'unknown'
const ext = file.name.split('.').pop() || 'bin'
const uuid = crypto.randomUUID()
return `projects/${projectId}/${field}/${uuid}.${ext}`
}
/**
* Simulate upload progress for development mode
* Mimics realistic chunked upload behavior with variable speed
@@ -143,16 +180,36 @@ async function handleFileUpload(field: string, file: File) {
const value = state.previewUrl || file.name
emit('update', field, value)
} else {
// Real mode: trigger actual upload (handled by parent/service)
// Real mode: upload to MinIO via multipart upload
state.status = 'uploading'
state.fileName = file.name
state.progress = 0
const s3Key = buildS3Key(field, file)
// Posters go to the public bucket so they're directly accessible via URL
const isPublicAsset = field === 'poster'
const bucket = isPublicAsset
? (import.meta.env.VITE_S3_PUBLIC_BUCKET || 'indeedhub-public')
: (import.meta.env.VITE_S3_PRIVATE_BUCKET || 'indeedhub-private')
try {
// Emit to parent, which would handle the real upload
emit('update', field, file)
} catch {
const resultKey = await addUpload(file, s3Key, bucket, (progress, status) => {
// Real-time progress callback from the chunked uploader
state.progress = progress
if (status === 'uploading') state.status = 'uploading'
})
if (resultKey) {
state.status = 'completed'
state.progress = 100
// Emit the S3 key so the parent can save it to the project
emit('update', field, resultKey)
} else {
state.status = 'error'
}
} catch (err: any) {
state.status = 'error'
console.error(`Upload failed for ${field}:`, err.message)
}
}
}

View File

@@ -69,15 +69,15 @@
</div>
</div>
<!-- Genres -->
<!-- Genre -->
<div>
<label class="field-label">Genres</label>
<label class="field-label">Genre</label>
<div class="flex flex-wrap gap-2">
<button
v-for="genre in genres"
:key="genre.id"
@click="toggleGenre(genre.slug)"
:class="selectedGenres.includes(genre.slug) ? 'genre-tag-active' : 'genre-tag'"
@click="selectGenre(genre.id)"
:class="selectedGenreId === genre.id ? 'genre-tag-active' : 'genre-tag'"
>
{{ genre.name }}
</button>
@@ -90,7 +90,7 @@
<label class="field-label">Release Date</label>
<input
type="date"
:value="project?.releaseDate?.split('T')[0]"
:value="(project?.film?.releaseDate ?? project?.releaseDate)?.split('T')[0]"
@input="emit('update', 'releaseDate', ($event.target as HTMLInputElement).value)"
class="field-input"
/>
@@ -111,27 +111,28 @@ const emit = defineEmits<{
(e: 'update', field: string, value: any): void
}>()
const selectedGenres = ref<string[]>([])
const selectedGenreId = ref<string | null>(null)
// Sync genres from project
// Sync genre from project (backend returns `genre` as a single object)
watch(
() => props.project?.genres,
(genres) => {
if (genres) {
selectedGenres.value = genres.map((g) => g.slug)
() => (props.project as any)?.genre,
(genre) => {
if (genre?.id) {
selectedGenreId.value = genre.id
}
},
{ immediate: true }
)
function toggleGenre(slug: string) {
const idx = selectedGenres.value.indexOf(slug)
if (idx === -1) {
selectedGenres.value.push(slug)
function selectGenre(id: string) {
// Toggle: clicking the already-selected genre deselects it
if (selectedGenreId.value === id) {
selectedGenreId.value = null
emit('update', 'genreId', null)
} else {
selectedGenres.value.splice(idx, 1)
selectedGenreId.value = id
emit('update', 'genreId', id)
}
emit('update', 'genres', [...selectedGenres.value])
}
</script>

View File

@@ -8,7 +8,7 @@
<div class="relative flex-1 max-w-xs">
<input
type="number"
:value="project?.rentalPrice"
:value="project?.film?.rentalPrice ?? project?.rentalPrice"
@input="emit('update', 'rentalPrice', Number(($event.target as HTMLInputElement).value))"
class="field-input pr-12"
placeholder="0"

View File

@@ -44,9 +44,15 @@ export function useUpload() {
)
/**
* Add a file to the upload queue and start uploading
* Add a file to the upload queue and start uploading.
* Optional onProgress callback fires with (progress: 0-100, status) on each chunk.
*/
async function addUpload(file: File, key: string, bucket: string = 'indeedhub-private'): Promise<string | null> {
async function addUpload(
file: File,
key: string,
bucket: string = 'indeedhub-private',
onProgress?: (progress: number, status: UploadItem['status']) => void
): Promise<string | null> {
const item: UploadItem = {
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
file,
@@ -57,16 +63,20 @@ export function useUpload() {
}
uploadQueue.value.push(item)
return processUpload(item)
return processUpload(item, onProgress)
}
/**
* Process a single upload: initialize, chunk, upload, finalize
*/
async function processUpload(item: UploadItem): Promise<string | null> {
async function processUpload(
item: UploadItem,
onProgress?: (progress: number, status: UploadItem['status']) => void
): Promise<string | null> {
try {
item.status = 'uploading'
isUploading.value = true
onProgress?.(0, 'uploading')
// Step 1: Initialize multipart upload
const { UploadId, Key } = await filmmakerService.initializeUpload(
@@ -106,13 +116,11 @@ export function useUpload() {
try {
const response = await axios.put(part.signedUrl, chunk, {
headers: { 'Content-Type': item.file.type },
onUploadProgress: () => {
// Progress is tracked at the chunk level
},
})
uploadedChunks++
item.progress = Math.round((uploadedChunks / totalChunks) * 100)
onProgress?.(item.progress, 'uploading')
const etag = response.headers.etag || response.headers.ETag
return {
@@ -121,6 +129,7 @@ export function useUpload() {
}
} catch (err: any) {
lastError = err
console.error(`Chunk ${part.PartNumber} attempt ${attempt + 1} failed:`, err.message)
// Exponential backoff
await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
@@ -139,6 +148,7 @@ export function useUpload() {
item.status = 'completed'
item.progress = 100
onProgress?.(100, 'completed')
// Check if all uploads done
if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) {
@@ -150,6 +160,7 @@ export function useUpload() {
item.status = 'failed'
item.error = err.message || 'Upload failed'
console.error('Upload failed:', err)
onProgress?.(item.progress, 'failed')
if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) {
isUploading.value = false

View File

@@ -34,22 +34,33 @@ NostrConnectSigner.publishMethod = (relays, event) => {
}
/**
* Load saved accounts from localStorage
* Load saved accounts from localStorage.
* When no saved data exists (e.g. site data was cleared),
* explicitly reset the accountManager so stale in-memory
* state doesn't keep the UI in a "logged in" limbo.
*/
export function loadAccounts() {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const accounts = JSON.parse(saved)
accountManager.fromJSON(accounts, true)
if (!saved) {
// Nothing persisted — make sure the manager is clean.
// This handles partial site-data clears and HMR reloads.
const current = accountManager.active
if (current) {
try { accountManager.removeAccount(current) } catch { /* noop */ }
}
return
}
// Restore active account
const activeId = localStorage.getItem(ACTIVE_KEY)
if (activeId) {
const account = accountManager.getAccount(activeId)
if (account) {
accountManager.setActive(account)
}
const accounts = JSON.parse(saved)
accountManager.fromJSON(accounts, true)
// Restore active account
const activeId = localStorage.getItem(ACTIVE_KEY)
if (activeId) {
const account = accountManager.getAccount(activeId)
if (account) {
accountManager.setActive(account)
}
}
} catch (e) {

View File

@@ -1,5 +1,6 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'
import { apiConfig } from '../config/api.config'
import { nip98Service } from './nip98.service'
import type { ApiError } from '../types/api'
/**
@@ -26,10 +27,24 @@ class ApiService {
* Setup request and response interceptors
*/
private setupInterceptors() {
// Request interceptor - Add auth token
// Request interceptor - Add auth token.
// If the nip98 token is expired but a refresh token exists,
// proactively refresh before sending to avoid unnecessary 401s.
this.client.interceptors.request.use(
(config) => {
const token = this.getToken()
async (config) => {
let token = this.getToken()
// If the token appears stale (nip98 says expired but we still
// have it in sessionStorage), try a proactive refresh
if (token && !nip98Service.hasValidToken && sessionStorage.getItem('refresh_token')) {
try {
const fresh = await nip98Service.refresh()
if (fresh) token = fresh
} catch {
// Non-fatal — let the request go; 401 interceptor will retry
}
}
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
@@ -105,7 +120,9 @@ class ApiService {
}
/**
* Refresh authentication token
* Refresh authentication token.
* Syncs both apiService (nostr_token) and nip98Service so the two
* auth layers stay in lockstep and don't send stale tokens.
*/
private async refreshToken(): Promise<string> {
// Prevent multiple simultaneous refresh requests
@@ -125,13 +142,17 @@ class ApiService {
refreshToken,
})
const newToken = response.data.accessToken
const { accessToken: newToken, refreshToken: newRefresh, expiresIn } = response.data
this.setToken(newToken, 'nostr')
if (response.data.refreshToken) {
sessionStorage.setItem('refresh_token', response.data.refreshToken)
if (newRefresh) {
sessionStorage.setItem('refresh_token', newRefresh)
}
// Keep nip98Service in sync so IndeehubApiService uses the
// fresh token too (and its hasValidToken check is accurate).
nip98Service.storeTokens(newToken, newRefresh ?? refreshToken, expiresIn)
return newToken
} finally {
this.tokenRefreshPromise = null

View File

@@ -22,16 +22,22 @@ class IndeehubApiService {
},
})
// Attach JWT token from NIP-98 session
this.client.interceptors.request.use((config) => {
const token = nip98Service.accessToken
// Attach JWT token from NIP-98 session.
// If the token has expired but we have a refresh token, proactively
// refresh before sending the request to avoid unnecessary 401 round-trips.
this.client.interceptors.request.use(async (config) => {
let token = nip98Service.accessToken
if (!token && sessionStorage.getItem('indeehub_api_refresh')) {
token = await nip98Service.refresh()
}
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Auto-refresh on 401
// Auto-refresh on 401 (fallback if the proactive refresh above
// didn't happen or the token expired mid-flight)
this.client.interceptors.response.use(
(response) => response,
async (error) => {

View File

@@ -98,12 +98,22 @@ class LibraryService {
}
/**
* Check if a rent exists for a given content ID (for polling after payment).
* Check if an active (non-expired) rent exists for a given content ID.
* Returns the rental expiry when one exists.
*/
async checkRentExists(contentId: string): Promise<{ exists: boolean }> {
async checkRentExists(contentId: string): Promise<{ exists: boolean; expiresAt?: string }> {
return apiService.get(`/rents/content/${contentId}/exists`)
}
/**
* Poll the quote endpoint for a specific BTCPay invoice.
* Returns the quote which includes a `paid` flag indicating settlement.
* This also triggers server-side payment detection for route-hint invoices.
*/
async pollQuoteStatus(invoiceId: string): Promise<{ paid?: boolean }> {
return apiService.patch(`/rents/lightning/${invoiceId}/quote`)
}
/**
* Check if user has access to content
*/

View File

@@ -15,7 +15,7 @@ const REFRESH_KEY = 'indeehub_api_refresh'
const EXPIRES_KEY = 'indeehub_api_expires'
class Nip98Service {
private refreshPromise: Promise<string> | null = null
private refreshPromise: Promise<string | null> | null = null
/**
* Check if we have a valid (non-expired) API token
@@ -28,13 +28,34 @@ class Nip98Service {
}
/**
* Get the current access token
* Get the current access token.
* Returns null if the token is missing or the client-side expiry
* has passed. The 401 interceptor will then trigger a refresh.
*/
get accessToken(): string | null {
if (!this.hasValidToken) return null
return sessionStorage.getItem(TOKEN_KEY)
}
/**
* Parse the `exp` claim from a JWT without verifying the signature.
* Returns the expiry as a Unix **millisecond** timestamp, or null
* if the token can't be decoded.
*/
private parseJwtExpiryMs(token: string): number | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = JSON.parse(atob(parts[1]))
if (typeof payload.exp === 'number') {
return payload.exp * 1000 // convert seconds → ms
}
return null
} catch {
return null
}
}
/**
* Create a session with the backend using a NIP-98 auth event.
*
@@ -89,6 +110,7 @@ class Nip98Service {
// Store tokens
sessionStorage.setItem(TOKEN_KEY, accessToken)
sessionStorage.setItem(REFRESH_KEY, refreshToken)
sessionStorage.setItem('refresh_token', refreshToken)
sessionStorage.setItem(
EXPIRES_KEY,
String(Date.now() + (expiresIn * 1000) - 30000) // 30s buffer
@@ -124,7 +146,10 @@ class Nip98Service {
const { accessToken, refreshToken: newRefresh, expiresIn } = response.data
sessionStorage.setItem(TOKEN_KEY, accessToken)
if (newRefresh) sessionStorage.setItem(REFRESH_KEY, newRefresh)
if (newRefresh) {
sessionStorage.setItem(REFRESH_KEY, newRefresh)
sessionStorage.setItem('refresh_token', newRefresh)
}
sessionStorage.setItem(
EXPIRES_KEY,
String(Date.now() + (expiresIn * 1000) - 30000)
@@ -146,15 +171,38 @@ class Nip98Service {
/**
* Store tokens from an external auth flow (e.g. auth.service.ts).
* Keeps nip98Service in sync so IndeehubApiService can read the token.
*
* When `expiresIn` is omitted the method parses the JWT's `exp`
* claim so we know the **real** backend expiry rather than blindly
* assuming 1 hour. If the token is already expired (e.g. restored
* from sessionStorage after a long idle period), hasValidToken will
* correctly return false and the 401 interceptor will trigger a
* refresh via the refresh token.
*/
storeTokens(accessToken: string, refreshToken?: string, expiresIn?: number) {
sessionStorage.setItem(TOKEN_KEY, accessToken)
if (refreshToken) {
sessionStorage.setItem(REFRESH_KEY, refreshToken)
// Also store as 'refresh_token' so apiService can auto-refresh
sessionStorage.setItem('refresh_token', refreshToken)
}
// Default to 1 hour if expiresIn is not provided
const ttlMs = (expiresIn ?? 3600) * 1000
sessionStorage.setItem(EXPIRES_KEY, String(Date.now() + ttlMs - 30000))
let expiresAtMs: number
if (expiresIn !== undefined) {
// Explicit TTL provided by the backend response
expiresAtMs = Date.now() + expiresIn * 1000 - 30_000 // 30s safety buffer
} else {
// Try to extract the real expiry from the JWT
const jwtExpiry = this.parseJwtExpiryMs(accessToken)
if (jwtExpiry) {
expiresAtMs = jwtExpiry - 30_000 // 30s safety buffer
} else {
// Absolute fallback — assume 1 hour (but this path should be rare)
expiresAtMs = Date.now() + 3_600_000 - 30_000
}
}
sessionStorage.setItem(EXPIRES_KEY, String(expiresAtMs))
// Backwards compatibility
sessionStorage.setItem('nostr_token', accessToken)
}

View File

@@ -52,7 +52,7 @@ class SubscriptionService {
*/
async createLightningSubscription(data: {
type: 'enthusiast' | 'film-buff' | 'cinephile'
period: 'monthly' | 'annual'
period: 'monthly' | 'yearly'
}): Promise<{
lnInvoice: string
expiration: string
@@ -72,7 +72,7 @@ class SubscriptionService {
}
/**
* Get subscription tiers with pricing
* Get subscription tiers with pricing (in sats)
*/
async getSubscriptionTiers(): Promise<Array<{
tier: string
@@ -81,26 +81,23 @@ class SubscriptionService {
annualPrice: number
features: string[]
}>> {
// This might be a static endpoint or hardcoded
// Adjust based on actual API
return [
{
tier: 'enthusiast',
name: 'Enthusiast',
monthlyPrice: 9.99,
annualPrice: 99.99,
monthlyPrice: 10000,
annualPrice: 100000,
features: [
'Access to all films and series',
'HD streaming',
'Watch on 2 devices',
'Cancel anytime',
],
},
{
tier: 'film-buff',
name: 'Film Buff',
monthlyPrice: 19.99,
annualPrice: 199.99,
monthlyPrice: 21000,
annualPrice: 210000,
features: [
'Everything in Enthusiast',
'4K streaming',
@@ -112,14 +109,13 @@ class SubscriptionService {
{
tier: 'cinephile',
name: 'Cinephile',
monthlyPrice: 29.99,
annualPrice: 299.99,
monthlyPrice: 42000,
annualPrice: 420000,
features: [
'Everything in Film Buff',
'Watch on unlimited devices',
'Offline downloads',
'Director commentary tracks',
'Virtual festival access',
'Support independent filmmakers',
],
},

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 }[] = [

View File

@@ -10,7 +10,7 @@ export interface ApiProject {
title: string
slug: string
synopsis: string
status: 'draft' | 'published' | 'rejected'
status: 'draft' | 'published' | 'rejected' | 'under-review'
type: 'film' | 'episodic' | 'music-video'
format: string
category: string
@@ -20,7 +20,9 @@ export interface ApiProject {
releaseDate: string
createdAt: string
updatedAt: string
genre?: ApiGenre
genres?: ApiGenre[]
film?: ApiContent
filmmaker?: ApiFilmmaker
}
@@ -102,7 +104,8 @@ export interface ApiRent {
export interface ApiGenre {
id: string
name: string
slug: string
slug?: string
type?: string
}
export interface ApiFestival {
@@ -174,15 +177,15 @@ export interface PaginatedResponse<T> {
export interface ApiPaymentMethod {
id: string
filmmakerUserId: string
type: 'lightning' | 'bank'
type: 'lightning' | 'bank' | 'LIGHTNING' | 'BANK'
lightningAddress?: string
bankName?: string
accountNumber?: string
routingNumber?: string
withdrawalFrequency: 'manual' | 'weekly' | 'monthly'
isSelected: boolean
withdrawalFrequency: 'manual' | 'weekly' | 'monthly' | 'automatic' | 'daily'
selected: boolean
createdAt: string
updatedAt: string
updatedAt?: string
}
export interface ApiFilmmakerAnalytics {
@@ -261,16 +264,21 @@ export interface UpdateProjectData {
title?: string
slug?: string
synopsis?: string
status?: ProjectStatus
status?: string
type?: ProjectType
format?: string
category?: string
poster?: string
trailer?: string
rentalPrice?: number
releaseDate?: string
genres?: string[]
genreId?: string | null
deliveryMode?: 'native' | 'partner'
film?: {
rentalPrice?: number
releaseDate?: string
file?: string
[key: string]: any
}
[key: string]: any
}
export interface UploadInitResponse {

View File

@@ -22,6 +22,8 @@ export interface Content {
drmEnabled?: boolean
streamingUrl?: string
apiData?: any
/** The Content entity ID (film/episode) for rental/payment flows */
contentId?: string
// Dual-mode content delivery
deliveryMode?: 'native' | 'partner'

View File

@@ -95,28 +95,29 @@
<div class="content-col">
<!-- Tab Content -->
<div class="glass-card p-5 md:p-8">
<div v-if="activeTabId === 'assets'">
<!-- Assets tab uses v-show to preserve upload state across tab switches -->
<div v-show="activeTabId === 'assets'">
<AssetsTab :project="project" @update="handleFieldUpdate" />
</div>
<div v-else-if="activeTabId === 'details'">
<div v-show="activeTabId === 'details'">
<DetailsTab :project="project" :genres="genres" @update="handleFieldUpdate" />
</div>
<div v-else-if="activeTabId === 'content'">
<div v-if="activeTabId === 'content'">
<ContentTab :project="project" />
</div>
<div v-else-if="activeTabId === 'cast-and-crew'">
<div v-if="activeTabId === 'cast-and-crew'">
<CastCrewTab :project="project" />
</div>
<div v-else-if="activeTabId === 'revenue'">
<div v-show="activeTabId === 'revenue'">
<RevenueTab :project="project" @update="handleFieldUpdate" />
</div>
<div v-else-if="activeTabId === 'permissions'">
<div v-if="activeTabId === 'permissions'">
<PermissionsTab :project="project" />
</div>
<div v-else-if="activeTabId === 'documentation'">
<div v-if="activeTabId === 'documentation'">
<DocumentationTab :project="project" />
</div>
<div v-else-if="activeTabId === 'coupons'">
<div v-if="activeTabId === 'coupons'">
<CouponsTab :project="project" />
</div>
</div>
@@ -207,7 +208,7 @@ const allTabs = computed<TabDef[]>(() => [
{ id: 'details', label: 'Details', state: project.value?.title && project.value?.synopsis ? 'completed' : 'new' },
{ id: 'content', label: 'Content', state: 'new', showFor: ['episodic'] },
{ id: 'cast-and-crew', label: 'Cast & Crew', state: 'new' },
{ id: 'revenue', label: 'Revenue', state: project.value?.rentalPrice ? 'completed' : 'new' },
{ id: 'revenue', label: 'Revenue', state: (project.value?.film?.rentalPrice ?? project.value?.rentalPrice) ? 'completed' : 'new' },
{ id: 'permissions', label: 'Permissions', state: 'new' },
{ id: 'documentation', label: 'Documentation', state: 'new' },
{ id: 'coupons', label: 'Coupons', state: 'new' },
@@ -229,6 +230,8 @@ const statusBadgeClass = computed(() => {
switch (project.value?.status) {
case 'published':
return `${base} bg-green-500/20 text-green-400 border border-green-500/30`
case 'under-review':
return `${base} bg-yellow-500/20 text-yellow-400 border border-yellow-500/30`
case 'rejected':
return `${base} bg-red-500/20 text-red-400 border border-red-500/30`
default:
@@ -247,6 +250,32 @@ async function handleSave(status?: string) {
const data = { ...pendingChanges.value }
if (status) data.status = status
// Fields that belong to the Content entity (film sub-object) rather
// than the Project entity itself — restructure before sending.
const contentFields = ['rentalPrice', 'releaseDate', 'file'] as const
const filmUpdates: Record<string, any> = {}
for (const field of contentFields) {
if (field in data) {
filmUpdates[field] = data[field]
delete data[field]
}
}
if (Object.keys(filmUpdates).length > 0) {
// Include the content ID so the backend updates the existing
// content entry rather than creating a new one
const filmId = project.value?.film?.id
data.film = {
...(filmId ? { id: filmId } : {}),
...filmUpdates,
...(data.film || {}),
}
}
// Genre slugs → genreId (UUID)
if (data.genreId !== undefined && typeof data.genreId === 'string') {
// Already a UUID — keep as-is
}
const updated = await saveProject(project.value.id, data)
if (updated) {
project.value = updated

View File

@@ -136,8 +136,8 @@
<div v-if="paymentMethods.length > 0" class="space-y-3">
<div v-for="method in paymentMethods" :key="method.id" class="method-row">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0" :class="method.type === 'lightning' ? 'bg-[#F7931A]/20 text-[#F7931A]' : 'bg-white/10 text-white/60'">
<svg v-if="method.type === 'lightning'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0" :class="method.type?.toLowerCase() === 'lightning' ? 'bg-[#F7931A]/20 text-[#F7931A]' : 'bg-white/10 text-white/60'">
<svg v-if="method.type?.toLowerCase() === 'lightning'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -146,13 +146,13 @@
</div>
<div class="min-w-0">
<p class="text-white font-medium text-sm truncate">
{{ method.type === 'lightning' ? method.lightningAddress : method.bankName }}
{{ method.type?.toLowerCase() === 'lightning' ? method.lightningAddress : method.bankName }}
</p>
<p class="text-white/40 text-xs capitalize">{{ method.type }} · {{ method.withdrawalFrequency }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span v-if="method.isSelected" class="selected-badge">Active</span>
<span v-if="method.selected" class="selected-badge">Active</span>
<button v-else @click="selectMethod(method.id)" class="text-xs text-white/30 hover:text-white transition-colors">
Set Active
</button>
@@ -335,8 +335,8 @@ async function handleAddMethod() {
type: 'lightning',
lightningAddress: newLightningAddress.value,
withdrawalFrequency: newFrequency.value,
isSelected: true,
} as any, ...paymentMethods.value]
selected: true,
} as any, ...paymentMethods.value.map(m => ({ ...m, selected: false }))]
showAddMethodModal.value = false
newLightningAddress.value = ''
return
@@ -372,7 +372,7 @@ async function selectMethod(id: string) {
if (USE_MOCK) {
paymentMethods.value = paymentMethods.value.map(m => ({
...m,
isSelected: m.id === id,
selected: m.id === id,
}))
return
}