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

@@ -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"