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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 · {{ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user