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

@@ -29,6 +29,11 @@ BTCPAY_URL=https://btcpay.yourdomain.com
BTCPAY_STORE_ID= BTCPAY_STORE_ID=
BTCPAY_API_KEY= BTCPAY_API_KEY=
BTCPAY_WEBHOOK_SECRET= BTCPAY_WEBHOOK_SECRET=
# Create a separate internal Lightning invoice with privateRouteHints.
# Only needed when BTCPay's built-in route hints are NOT enabled.
# If you enabled "Include hop hints" in BTCPay's Lightning settings,
# leave this as false — BTCPay handles route hints natively.
BTCPAY_ROUTE_HINTS=false
# User Pool - AWS Cognito # User Pool - AWS Cognito
COGNITO_USER_POOL_ID= COGNITO_USER_POOL_ID=

View File

@@ -15,7 +15,10 @@ export function encodeS3KeyForUrl(fileKey: string): string {
} }
export function getPublicS3Url(fileKey: string): string { export function getPublicS3Url(fileKey: string): string {
return `${process.env.S3_PUBLIC_BUCKET_URL}${encodeS3KeyForUrl(fileKey)}`; const baseUrl =
process.env.S3_PUBLIC_BUCKET_URL ||
`${process.env.S3_ENDPOINT || 'http://localhost:9000'}/${process.env.S3_PUBLIC_BUCKET_NAME || 'indeedhub-public'}/`;
return `${baseUrl}${encodeS3KeyForUrl(fileKey)}`;
} }
export function getPrivateS3Url(fileKey: string): string { export function getPrivateS3Url(fileKey: string): string {

View File

@@ -49,7 +49,7 @@ export class Discount {
@Column({ @Column({
type: 'decimal', type: 'decimal',
precision: 5, precision: 15,
scale: 2, scale: 2,
nullable: false, nullable: false,
transformer: { transformer: {

View File

@@ -21,6 +21,8 @@ export interface BTCPayInvoiceResponse {
*/ */
export interface BTCPayPaymentMethod { export interface BTCPayPaymentMethod {
paymentMethod: string; paymentMethod: string;
/** Some BTCPay versions use paymentMethodId instead of paymentMethod */
paymentMethodId?: string;
cryptoCode: string; cryptoCode: string;
destination: string; // BOLT11 invoice for Lightning destination: string; // BOLT11 invoice for Lightning
paymentLink: string; paymentLink: string;
@@ -76,3 +78,34 @@ export interface BTCPayLightningPayResponse {
paymentHash?: string; paymentHash?: string;
preimage?: string; preimage?: string;
} }
/**
* BTCPay internal Lightning invoice (via the Lightning node API).
*
* Created with `POST /stores/{storeId}/lightning/BTC/invoices`.
* Supports `privateRouteHints` to include route hints in the BOLT11,
* which is critical for nodes with private channels.
*/
export interface BTCPayInternalLnInvoice {
id: string;
status: 'Unpaid' | 'Paid' | 'Expired';
BOLT11: string;
paymentHash: string;
preimage?: string;
expiresAt: number;
amount: string;
amountReceived?: string;
paidAt?: number | null;
customRecords?: Record<string, string>;
}
/**
* Request body for creating an internal Lightning invoice.
*/
export interface BTCPayCreateLnInvoiceRequest {
amount: string;
description?: string;
descriptionHashOnly?: boolean;
expiry?: number;
privateRouteHints?: boolean;
}

View File

@@ -14,4 +14,7 @@ export default class InvoiceQuote {
onchainAddress: string; onchainAddress: string;
expiration: Date; expiration: Date;
expirationInSec: number; expirationInSec: number;
/** True when the Lightning invoice has been paid / the store invoice is settled. */
paid?: boolean;
} }

View File

@@ -9,6 +9,8 @@ import type {
BTCPayInvoiceResponse, BTCPayInvoiceResponse,
BTCPayPaymentMethod, BTCPayPaymentMethod,
BTCPayLightningPayResponse, BTCPayLightningPayResponse,
BTCPayInternalLnInvoice,
BTCPayCreateLnInvoiceRequest,
} from '../dto/btcpay/btcpay-invoice'; } from '../dto/btcpay/btcpay-invoice';
/** /**
@@ -23,18 +25,32 @@ export class BTCPayService implements LightningService {
private readonly baseUrl: string; private readonly baseUrl: string;
private readonly storeId: string; private readonly storeId: string;
private readonly apiKey: string; private readonly apiKey: string;
private readonly routeHintsEnabled: boolean;
constructor() { constructor() {
this.baseUrl = (process.env.BTCPAY_URL || process.env.BTCPAY_SERVER_URL || '').replace(/\/+$/, ''); this.baseUrl = (process.env.BTCPAY_URL || process.env.BTCPAY_SERVER_URL || '').replace(/\/+$/, '');
this.storeId = process.env.BTCPAY_STORE_ID || ''; this.storeId = process.env.BTCPAY_STORE_ID || '';
this.apiKey = process.env.BTCPAY_API_KEY || ''; this.apiKey = process.env.BTCPAY_API_KEY || '';
// Create a separate internal Lightning invoice with privateRouteHints.
// Only needed when BTCPay's own store-level route hints are NOT enabled.
// Since BTCPay can include route hints natively (Lightning settings →
// "Include hop hints"), this defaults to false to avoid duplicating
// invoices. Set BTCPAY_ROUTE_HINTS=true only if you cannot enable
// route hints in BTCPay's UI and still need them.
const envHints = process.env.BTCPAY_ROUTE_HINTS;
this.routeHintsEnabled = envHints === 'true';
if (!this.baseUrl || !this.storeId || !this.apiKey) { if (!this.baseUrl || !this.storeId || !this.apiKey) {
Logger.warn( Logger.warn(
'BTCPay Server environment variables not fully configured (BTCPAY_URL, BTCPAY_STORE_ID, BTCPAY_API_KEY)', 'BTCPay Server environment variables not fully configured (BTCPAY_URL, BTCPAY_STORE_ID, BTCPAY_API_KEY)',
'BTCPayService', 'BTCPayService',
); );
} }
if (this.routeHintsEnabled) {
Logger.log('Private route hints enabled for Lightning invoices', 'BTCPayService');
}
} }
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
@@ -53,22 +69,29 @@ export class BTCPayService implements LightningService {
// ── Invoice Creation (receiving payments from users) ───────────────────── // ── Invoice Creation (receiving payments from users) ─────────────────────
/** /**
* Create a BTCPay invoice denominated in USD, paid via Lightning. * Create a BTCPay invoice denominated in sats, paid via Lightning.
* Returns data shaped like Strike's Invoice DTO for drop-in compatibility * The amount parameter is in satoshis. We convert to BTC for the
* with the existing RentsService. * API call since not all BTCPay versions support "SATS" as a currency.
*
* Returns data shaped like Strike's Invoice DTO for drop-in
* compatibility with the existing RentsService.
*/ */
async issueInvoice( async issueInvoice(
amount: number, amountSats: number,
description = 'Invoice for order', description = 'Invoice for order',
correlationId?: string, correlationId?: string,
): Promise<Invoice> { ): Promise<Invoice> {
try { try {
// Convert sats to BTC (1 BTC = 100,000,000 sats)
const amountBtc = (amountSats / 100_000_000).toFixed(8);
const body = { const body = {
amount: amount.toString(), amount: amountBtc,
currency: 'USD', currency: 'BTC',
metadata: { metadata: {
orderId: correlationId || undefined, orderId: correlationId || undefined,
itemDesc: description, itemDesc: description,
amountSats, // Store original sats amount for reference
}, },
checkout: { checkout: {
paymentMethods: ['BTC-LN'], paymentMethods: ['BTC-LN'],
@@ -101,28 +124,92 @@ export class BTCPayService implements LightningService {
/** /**
* Fetch the Lightning BOLT11 for an existing invoice. * Fetch the Lightning BOLT11 for an existing invoice.
*
* When route hints are enabled (default), this creates an internal
* Lightning invoice with `privateRouteHints: true` so that payers
* can find a route even when our node only has private channels.
* The store invoice is kept for webhook-based payment tracking.
* On subsequent polls, if the internal invoice is paid we mark the
* store invoice as complete, which triggers the normal webhook flow.
*
* Returns data shaped like Strike's InvoiceQuote DTO. * Returns data shaped like Strike's InvoiceQuote DTO.
*/ */
async issueQuote(invoiceId: string): Promise<InvoiceQuote> { async issueQuote(invoiceId: string): Promise<InvoiceQuote> {
try { try {
// Get the invoice to know the amount
const invoice = await this.getRawInvoice(invoiceId); const invoice = await this.getRawInvoice(invoiceId);
// Get payment methods to retrieve the BOLT11 if (!this.routeHintsEnabled) {
const { data: paymentMethods } = await axios.get<BTCPayPaymentMethod[]>( return this.issueQuoteFromPaymentMethods(invoiceId, invoice);
this.storeUrl(`/invoices/${invoiceId}/payment-methods`), }
{ headers: this.headers },
); // ── Route-hints path: use the internal Lightning node API ───────
const internalLnId = invoice.metadata?.internalLnInvoiceId as
const lightning = paymentMethods.find( | string
(pm) => pm.paymentMethod === 'BTC-LN' || pm.paymentMethod === 'BTC-LNURL', | undefined;
);
let bolt11: string;
if (!lightning) { let expirationDate: Date;
throw new Error('No Lightning payment method found for this invoice'); let paid = invoice.status === 'Settled';
if (internalLnId) {
// We already created an internal LN invoice — re-fetch it
const lnInvoice = await this.getInternalLnInvoice(internalLnId);
bolt11 = lnInvoice.BOLT11;
expirationDate = new Date(lnInvoice.expiresAt * 1000);
if (lnInvoice.status === 'Paid') {
paid = true;
// Detect payment on the internal invoice and settle the store invoice
if (invoice.status !== 'Settled') {
Logger.log(
`Internal LN invoice ${internalLnId} paid — marking store invoice ${invoiceId} as complete`,
'BTCPayService',
);
await this.markInvoiceComplete(invoiceId);
}
}
} else {
// First call — create a new internal LN invoice with route hints
const amountSats =
(invoice.metadata?.amountSats as number) ||
Math.round(parseFloat(invoice.amount) * 100_000_000);
// Match expiry to the store invoice so both expire together
const expirySeconds = Math.max(
60,
Math.floor((invoice.expirationTime * 1000 - Date.now()) / 1000),
);
try {
const lnInvoice = await this.createInternalLnInvoice(
amountSats * 1000, // BTCPay internal API expects millisats
(invoice.metadata?.itemDesc as string) || 'Invoice',
expirySeconds,
);
bolt11 = lnInvoice.BOLT11;
expirationDate = new Date(lnInvoice.expiresAt * 1000);
// Persist the link so subsequent polls re-use this invoice
await this.updateInvoiceMetadata(invoiceId, {
...invoice.metadata,
internalLnInvoiceId: lnInvoice.id,
});
Logger.log(
`Created internal LN invoice ${lnInvoice.id} with route hints for store invoice ${invoiceId}`,
'BTCPayService',
);
} catch (err) {
// If the internal Lightning API is unavailable, fall back gracefully
Logger.warn(
`Failed to create internal LN invoice with route hints: ${err.message}. Falling back to store payment method BOLT11.`,
'BTCPayService',
);
return this.issueQuoteFromPaymentMethods(invoiceId, invoice);
}
} }
const expirationDate = new Date(invoice.expirationTime * 1000);
const expirationInSec = Math.max( const expirationInSec = Math.max(
0, 0,
Math.floor((expirationDate.getTime() - Date.now()) / 1000), Math.floor((expirationDate.getTime() - Date.now()) / 1000),
@@ -130,31 +217,104 @@ export class BTCPayService implements LightningService {
return { return {
quoteId: invoiceId, quoteId: invoiceId,
description: invoice.metadata?.itemDesc as string || 'Invoice', description: (invoice.metadata?.itemDesc as string) || 'Invoice',
lnInvoice: lightning.destination, lnInvoice: bolt11,
onchainAddress: '', onchainAddress: '',
expiration: expirationDate, expiration: expirationDate,
expirationInSec, expirationInSec,
paid,
targetAmount: { targetAmount: {
amount: invoice.amount, amount: invoice.amount,
currency: invoice.currency, currency: invoice.currency,
}, },
sourceAmount: { sourceAmount: {
amount: lightning.amount, amount: invoice.amount,
currency: 'BTC', currency: 'BTC',
}, },
conversionRate: { conversionRate: {
amount: lightning.rate, amount: '1',
sourceCurrency: 'BTC', sourceCurrency: 'BTC',
targetCurrency: invoice.currency, targetCurrency: invoice.currency,
}, },
}; };
} catch (error) { } catch (error) {
Logger.error('BTCPay quote retrieval failed: ' + error.message, 'BTCPayService'); Logger.error(
'BTCPay quote retrieval failed: ' + error.message,
'BTCPayService',
);
throw new BadRequestException(error.message); throw new BadRequestException(error.message);
} }
} }
/**
* Original quote logic: fetch the BOLT11 from the store invoice's
* payment methods. Used as fallback when route hints are disabled
* or the internal Lightning API is unavailable.
*/
private async issueQuoteFromPaymentMethods(
invoiceId: string,
invoice: BTCPayInvoiceResponse,
): Promise<InvoiceQuote> {
const { data: paymentMethods } = await axios.get<BTCPayPaymentMethod[]>(
this.storeUrl(`/invoices/${invoiceId}/payment-methods`),
{ headers: this.headers },
);
// BTCPay's payment method IDs vary between versions (BTC-LN, BTC_LN,
// BTC-LightningNetwork, etc.). Match any Lightning-related method.
const lightning = paymentMethods.find((pm) => {
const id = (pm.paymentMethod || pm.paymentMethodId || '').toUpperCase();
return (
id.includes('LN') ||
id.includes('LIGHTNING') ||
id === 'BTC-LNURL'
);
});
if (!lightning) {
const availableMethods = paymentMethods.map(
(pm) => pm.paymentMethod || pm.paymentMethodId || 'unknown',
);
Logger.warn(
`No Lightning payment method found. Available methods: ${JSON.stringify(availableMethods)}`,
'BTCPayService',
);
throw new Error(
'No Lightning payment method found for this invoice. ' +
'Ensure the BTCPay store has a Lightning node configured.',
);
}
const expirationDate = new Date(invoice.expirationTime * 1000);
const expirationInSec = Math.max(
0,
Math.floor((expirationDate.getTime() - Date.now()) / 1000),
);
return {
quoteId: invoiceId,
description: (invoice.metadata?.itemDesc as string) || 'Invoice',
lnInvoice: lightning.destination,
onchainAddress: '',
expiration: expirationDate,
expirationInSec,
paid: invoice.status === 'Settled',
targetAmount: {
amount: invoice.amount,
currency: invoice.currency,
},
sourceAmount: {
amount: lightning.amount,
currency: 'BTC',
},
conversionRate: {
amount: lightning.rate,
sourceCurrency: 'BTC',
targetCurrency: invoice.currency,
},
};
}
/** /**
* Get the status of an existing invoice. * Get the status of an existing invoice.
* Returns data shaped like Strike's Invoice DTO. * Returns data shaped like Strike's Invoice DTO.
@@ -271,6 +431,90 @@ export class BTCPayService implements LightningService {
} }
} }
// ── Internal Lightning Node API (route hints) ─────────────────────────
/**
* Create a Lightning invoice directly on the node with private route
* hints enabled. This allows payers to discover routes to our node
* even when it only has private (unannounced) channels.
*
* @param amountMsat Amount in **millisatoshis**
* @param description Invoice description / memo
* @param expiry Seconds until expiry
*/
private async createInternalLnInvoice(
amountMsat: number,
description: string,
expiry: number,
): Promise<BTCPayInternalLnInvoice> {
const body: BTCPayCreateLnInvoiceRequest = {
amount: amountMsat.toString(),
description,
expiry,
privateRouteHints: true,
};
const { data } = await axios.post<BTCPayInternalLnInvoice>(
this.storeUrl('/lightning/BTC/invoices'),
body,
{ headers: this.headers },
);
return data;
}
/**
* Fetch an existing internal Lightning invoice by its ID.
*/
private async getInternalLnInvoice(
invoiceId: string,
): Promise<BTCPayInternalLnInvoice> {
const { data } = await axios.get<BTCPayInternalLnInvoice>(
this.storeUrl(`/lightning/BTC/invoices/${invoiceId}`),
{ headers: this.headers },
);
return data;
}
/**
* Mark a store invoice as complete (settled).
* This triggers the `InvoiceSettled` webhook so the normal payment
* processing pipeline (rents, subscriptions) runs automatically.
*/
private async markInvoiceComplete(invoiceId: string): Promise<void> {
try {
await axios.post(
this.storeUrl(`/invoices/${invoiceId}/status`),
{ status: 'Complete' },
{ headers: this.headers },
);
Logger.log(
`Store invoice ${invoiceId} marked as complete`,
'BTCPayService',
);
} catch (err) {
Logger.error(
`Failed to mark store invoice ${invoiceId} as complete: ${err.message}`,
'BTCPayService',
);
throw err;
}
}
/**
* Update the metadata on a store invoice (e.g. to persist the
* internal Lightning invoice ID for subsequent polls).
*/
private async updateInvoiceMetadata(
invoiceId: string,
metadata: Record<string, unknown>,
): Promise<void> {
await axios.put(
this.storeUrl(`/invoices/${invoiceId}`),
{ metadata },
{ headers: this.headers },
);
}
// ── Private Helpers ───────────────────────────────────────────────────── // ── Private Helpers ─────────────────────────────────────────────────────
private async getRawInvoice(invoiceId: string): Promise<BTCPayInvoiceResponse> { private async getRawInvoice(invoiceId: string): Promise<BTCPayInvoiceResponse> {

View File

@@ -63,7 +63,7 @@ export class UpdateProjectDTO extends PartialType(CreateProjectDTO) {
@IsString() @IsString()
@IsEnum(statuses) @IsEnum(statuses)
@IsOptional() @IsOptional()
status: Status = 'draft'; status?: Status;
@IsString() @IsString()
@IsEnum(formats) @IsEnum(formats)

View File

@@ -32,6 +32,7 @@ export class BaseProjectDTO {
subgenres: SubgenreDTO[]; subgenres: SubgenreDTO[];
film?: BaseContentDTO; film?: BaseContentDTO;
episodes?: BaseContentDTO[]; episodes?: BaseContentDTO[];
rentalPrice: number;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
trailerStatus?: ContentStatus; trailerStatus?: ContentStatus;
@@ -46,11 +47,8 @@ export class BaseProjectDTO {
this.format = project.format; this.format = project.format;
this.category = project.category; this.category = project.category;
this.status = this.status = project.status;
project.contents?.some((content) => content.status != 'completed') && this.rentalPrice = project.rentalPrice ?? 0;
project.status === 'published'
? 'under-review'
: project.status;
if (this.type === 'episodic') { if (this.type === 'episodic') {
const contents = const contents =
@@ -58,10 +56,21 @@ export class BaseProjectDTO {
[]; [];
this.episodes = contents.map((content) => new BaseContentDTO(content)); this.episodes = contents.map((content) => new BaseContentDTO(content));
} else { } else {
this.film = // Pick the best content for the film slot. When a project has
project.contents?.length > 0 // multiple content rows (e.g. an auto-created placeholder plus
? new BaseContentDTO(project.contents[0]) // the real upload), prefer the one with a rental price set or
: undefined; // a file uploaded rather than blindly taking contents[0].
const best = project.contents?.length
? [...project.contents].sort((a, b) => {
// Prefer content with a file
const aFile = a.file ? 1 : 0;
const bFile = b.file ? 1 : 0;
if (bFile !== aFile) return bFile - aFile;
// Then prefer content with rentalPrice > 0
return (b.rentalPrice || 0) - (a.rentalPrice || 0);
})[0]
: undefined;
this.film = best ? new BaseContentDTO(best) : undefined;
} }
this.genre = project.genre ? new GenreDTO(project.genre) : undefined; this.genre = project.genre ? new GenreDTO(project.genre) : undefined;

View File

@@ -33,9 +33,16 @@ export class ProjectDTO extends BaseProjectDTO {
: []; : [];
this.seasons = project?.seasons?.length ? project.seasons : []; this.seasons = project?.seasons?.length ? project.seasons : [];
} else { } else {
this.film = project?.contents?.length // Pick the best content for the film slot (same logic as base DTO).
? new ContentDTO(project.contents[0]) const best = project?.contents?.length
? [...project.contents].sort((a, b) => {
const aFile = a.file ? 1 : 0;
const bFile = b.file ? 1 : 0;
if (bFile !== aFile) return bFile - aFile;
return (b.rentalPrice || 0) - (a.rentalPrice || 0);
})[0]
: undefined; : undefined;
this.film = best ? new ContentDTO(best) : undefined;
} }
if (project.contents) { if (project.contents) {

View File

@@ -153,15 +153,23 @@ export class ProjectsService {
getProjectsQuery(query: ListProjectsDTO) { getProjectsQuery(query: ListProjectsDTO) {
const projectsQuery = this.projectsRepository.createQueryBuilder('project'); const projectsQuery = this.projectsRepository.createQueryBuilder('project');
projectsQuery.distinct(true); projectsQuery.distinct(true);
// Always select content fields required by BaseProjectDTO / ProjectDTO.
// A plain leftJoin only makes the table available for WHERE / ORDER BY
// but does NOT populate project.contents on the entity.
projectsQuery.leftJoin('project.contents', 'contents'); projectsQuery.leftJoin('project.contents', 'contents');
projectsQuery.addSelect([
if (query.relations) { 'contents.id',
projectsQuery.addSelect([ 'contents.title',
'contents.id', 'contents.projectId',
'contents.status', 'contents.status',
'contents.isRssEnabled', 'contents.file',
]); 'contents.order',
} 'contents.season',
'contents.rentalPrice',
'contents.releaseDate',
'contents.isRssEnabled',
'contents.poster',
]);
if (query.status) { if (query.status) {
if (query.status === 'published') { if (query.status === 'published') {
@@ -444,22 +452,20 @@ export class ProjectsService {
} }
} }
const projectUpdatePayload: Partial<Project> = { // Build update payload — only include fields that were explicitly sent
name: updateProjectDTO.name, const projectUpdatePayload: Partial<Project> = {};
title: updateProjectDTO.title, if (updateProjectDTO.name !== undefined) projectUpdatePayload.name = updateProjectDTO.name;
slug: updateProjectDTO.slug, if (updateProjectDTO.title !== undefined) projectUpdatePayload.title = updateProjectDTO.title;
synopsis: updateProjectDTO.synopsis, if (updateProjectDTO.slug !== undefined) projectUpdatePayload.slug = updateProjectDTO.slug;
poster: updateProjectDTO.poster, if (updateProjectDTO.synopsis !== undefined) projectUpdatePayload.synopsis = updateProjectDTO.synopsis;
status: if (updateProjectDTO.poster !== undefined) projectUpdatePayload.poster = updateProjectDTO.poster;
updateProjectDTO.status === 'published' && if (updateProjectDTO.category !== undefined) projectUpdatePayload.category = updateProjectDTO.category;
(project.status === 'draft' || project.status === 'rejected') if (updateProjectDTO.format !== undefined) projectUpdatePayload.format = updateProjectDTO.format;
? 'under-review' if (updateProjectDTO.genreId !== undefined) projectUpdatePayload.genreId = updateProjectDTO.genreId;
: updateProjectDTO.status, if (updateProjectDTO.status !== undefined) {
category: updateProjectDTO.category, projectUpdatePayload.status = updateProjectDTO.status;
format: updateProjectDTO.format, projectUpdatePayload.rejectedReason = '';
genreId: updateProjectDTO.genreId, }
rejectedReason: '',
};
await this.projectsRepository.update(id, projectUpdatePayload); await this.projectsRepository.update(id, projectUpdatePayload);
@@ -1183,3 +1189,4 @@ export class ProjectsService {
} }
} }
} }

View File

@@ -22,7 +22,7 @@ export class Rent {
userId: string; userId: string;
@Column('decimal', { @Column('decimal', {
precision: 5, precision: 15,
scale: 2, scale: 2,
transformer: new ColumnNumericTransformer(), transformer: new ColumnNumericTransformer(),
}) })

View File

@@ -3,28 +3,66 @@ import {
ExecutionContext, ExecutionContext,
Inject, Inject,
Injectable, Injectable,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { ContentsService } from 'src/contents/contents.service'; import { ContentsService } from 'src/contents/contents.service';
import { fullRelations } from 'src/contents/entities/content.entity';
@Injectable() @Injectable()
export class ForRentalGuard implements CanActivate { export class ForRentalGuard implements CanActivate {
private readonly logger = new Logger(ForRentalGuard.name);
constructor( constructor(
@Inject(ContentsService) @Inject(ContentsService)
private contentsService: ContentsService, private contentsService: ContentsService,
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const contentId = request.body.id || request.params.id; const contentId = request.body.id || request.params.id;
if (contentId) { if (!contentId) {
const content = await this.contentsService.findOne( this.logger.warn('ForRentalGuard: no content ID provided');
contentId,
fullRelations,
);
return content?.rentalPrice > 0;
} else {
return false; return false;
} }
// Strategy 1: Try looking up directly as a content ID.
// Use minimal relations — we only need rentalPrice.
try {
const content = await this.contentsService.findOne(contentId, []);
this.logger.log(
`ForRentalGuard: found content id="${contentId}", rentalPrice=${content?.rentalPrice}`,
);
if (content?.rentalPrice > 0) return true;
} catch {
this.logger.warn(
`ForRentalGuard: content not found by id="${contentId}", trying as projectId...`,
);
}
// Strategy 2: Treat the ID as a project ID and find its content.
try {
const contents = await this.contentsService.findAll(contentId, {
limit: 1,
offset: 0,
});
const content = contents?.[0];
if (content && content.rentalPrice > 0) {
this.logger.log(
`ForRentalGuard: resolved projectId="${contentId}" → contentId="${content.id}", rentalPrice=${content.rentalPrice}`,
);
// Rewrite the request body so downstream services use the real content ID
request.body.id = content.id;
return true;
}
this.logger.warn(
`ForRentalGuard: no rentable content for id="${contentId}" (found ${contents?.length ?? 0} items)`,
);
} catch (error) {
this.logger.error(
`ForRentalGuard: fallback lookup failed for id="${contentId}": ${error.message}`,
);
}
return false;
} }
} }

View File

@@ -84,8 +84,7 @@ export class RentsController {
@User() { id }: RequestUser['user'], @User() { id }: RequestUser['user'],
) { ) {
try { try {
const exists = await this.rentsService.rentByUserExists(id, contentId); return await this.rentsService.rentByUserExists(id, contentId);
return { exists };
} catch { } catch {
return { exists: false }; return { exists: false };
} }

View File

@@ -2,10 +2,11 @@ import {
BadRequestException, BadRequestException,
Inject, Inject,
Injectable, Injectable,
Logger,
UnprocessableEntityException, UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, MoreThanOrEqual, Repository } from 'typeorm'; import { IsNull, MoreThanOrEqual, Not, Repository } from 'typeorm';
import { Rent } from './entities/rent.entity'; import { Rent } from './entities/rent.entity';
import { BTCPayService } from 'src/payment/providers/services/btcpay.service'; import { BTCPayService } from 'src/payment/providers/services/btcpay.service';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -57,15 +58,25 @@ export class RentsService {
}); });
} }
async rentByUserExists(userId: string, contentId: string) { async rentByUserExists(
return this.rentRepository.exists({ userId: string,
contentId: string,
): Promise<{ exists: boolean; expiresAt?: Date }> {
const rent = await this.rentRepository.findOne({
where: { where: {
contentId, contentId,
userId, userId,
createdAt: MoreThanOrEqual(this.getExpiringDate()), createdAt: MoreThanOrEqual(this.getExpiringDate()),
status: 'paid', status: 'paid',
}, },
order: { createdAt: 'DESC' },
}); });
if (!rent) return { exists: false };
const expiresAt = new Date(rent.createdAt);
expiresAt.setDate(expiresAt.getDate() + 2);
return { exists: true, expiresAt };
} }
async getCountByUserId(userId: string, query: ListRentsDTO) { async getCountByUserId(userId: string, query: ListRentsDTO) {
@@ -184,6 +195,52 @@ export class RentsService {
); );
} }
/**
* Reconcile pending rents against BTCPay on startup.
* If a webhook was missed (e.g. during a restart), this detects
* settled invoices and activates the corresponding rents.
*/
async reconcilePendingPayments(): Promise<number> {
const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
const pendingRents = await this.rentRepository.find({
where: {
status: 'pending',
providerId: Not(IsNull()),
createdAt: MoreThanOrEqual(cutoff),
},
});
if (pendingRents.length === 0) return 0;
Logger.log(
`Reconciling ${pendingRents.length} pending rent(s)…`,
'RentsService',
);
let settled = 0;
for (const rent of pendingRents) {
try {
const invoice = await this.btcpayService.getInvoice(rent.providerId);
if (invoice.state === 'PAID') {
await this.lightningPaid(rent.providerId, invoice);
settled++;
Logger.log(
`Reconciled rent ${rent.id} (invoice ${rent.providerId}) — now paid`,
'RentsService',
);
}
} catch (err) {
// Non-fatal — log and continue with the next rent
Logger.warn(
`Reconciliation failed for rent ${rent.id}: ${err.message}`,
'RentsService',
);
}
}
return settled;
}
private getExpiringDate() { private getExpiringDate() {
return new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); return new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
} }

View File

@@ -24,7 +24,7 @@ export class SeasonRent {
userId: string; userId: string;
@Column('decimal', { @Column('decimal', {
precision: 5, precision: 15,
scale: 2, scale: 2,
transformer: new ColumnNumericTransformer(), transformer: new ColumnNumericTransformer(),
}) })

View File

@@ -2,11 +2,12 @@ import {
BadRequestException, BadRequestException,
Inject, Inject,
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
UnprocessableEntityException, UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm'; import { IsNull, MoreThanOrEqual, Not, Repository } from 'typeorm';
import { SeasonRent } from './entities/season-rents.entity'; import { SeasonRent } from './entities/season-rents.entity';
import { SeasonService } from './season.service'; import { SeasonService } from './season.service';
import { BTCPayService } from 'src/payment/providers/services/btcpay.service'; import { BTCPayService } from 'src/payment/providers/services/btcpay.service';
@@ -215,6 +216,49 @@ export class SeasonRentsService {
} }
} }
/**
* Reconcile pending season rents against BTCPay on startup.
*/
async reconcilePendingPayments(): Promise<number> {
const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
const pendingRents = await this.seasonRentRepository.find({
where: {
status: 'pending',
providerId: Not(IsNull()),
createdAt: MoreThanOrEqual(cutoff),
},
});
if (pendingRents.length === 0) return 0;
Logger.log(
`Reconciling ${pendingRents.length} pending season rent(s)…`,
'SeasonRentsService',
);
let settled = 0;
for (const rent of pendingRents) {
try {
const invoice = await this.btcpayService.getInvoice(rent.providerId);
if (invoice.state === 'PAID') {
await this.lightningPaid(rent.providerId, invoice);
settled++;
Logger.log(
`Reconciled season rent ${rent.id} (invoice ${rent.providerId}) — now paid`,
'SeasonRentsService',
);
}
} catch (err) {
Logger.warn(
`Reconciliation failed for season rent ${rent.id}: ${err.message}`,
'SeasonRentsService',
);
}
}
return settled;
}
private getExpiringDate() { private getExpiringDate() {
return new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); return new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
} }

View File

@@ -2,7 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { CreateSubscriptionDTO } from './dto/create-subscription.dto'; import { CreateSubscriptionDTO } from './dto/create-subscription.dto';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Subscription } from './entities/subscription.entity'; import { Subscription } from './entities/subscription.entity';
import { In, IsNull, Not, Repository } from 'typeorm'; import { In, IsNull, MoreThanOrEqual, Not, Repository } from 'typeorm';
import { UsersService } from 'src/users/users.service'; import { UsersService } from 'src/users/users.service';
import { User } from 'src/users/entities/user.entity'; import { User } from 'src/users/entities/user.entity';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -13,7 +13,7 @@ import { AdminCreateSubscriptionDTO } from './dto/admin-create-subscription.dto'
import { BTCPayService } from 'src/payment/providers/services/btcpay.service'; import { BTCPayService } from 'src/payment/providers/services/btcpay.service';
/** /**
* Subscription pricing in USD. * Subscription pricing in satoshis.
* Since Lightning doesn't support recurring billing, * Since Lightning doesn't support recurring billing,
* each period is a one-time payment that activates the subscription. * each period is a one-time payment that activates the subscription.
*/ */
@@ -21,11 +21,11 @@ const SUBSCRIPTION_PRICES: Record<
string, string,
{ monthly: number; yearly: number } { monthly: number; yearly: number }
> = { > = {
enthusiast: { monthly: 9.99, yearly: 99.99 }, enthusiast: { monthly: 10_000, yearly: 100_000 },
'film-buff': { monthly: 19.99, yearly: 199.99 }, 'film-buff': { monthly: 21_000, yearly: 210_000 },
cinephile: { monthly: 29.99, yearly: 299.99 }, cinephile: { monthly: 42_000, yearly: 420_000 },
'rss-addon': { monthly: 4.99, yearly: 49.99 }, 'rss-addon': { monthly: 5_000, yearly: 50_000 },
'verification-addon': { monthly: 2.99, yearly: 29.99 }, 'verification-addon': { monthly: 3_000, yearly: 30_000 },
}; };
@Injectable() @Injectable()
@@ -142,6 +142,49 @@ export class SubscriptionsService {
return now; return now;
} }
/**
* Reconcile pending subscriptions against BTCPay on startup.
*/
async reconcilePendingPayments(): Promise<number> {
const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
const pendingSubs = await this.subscriptionsRepository.find({
where: {
status: 'created' as any,
stripeId: Not(IsNull()),
createdAt: MoreThanOrEqual(cutoff),
},
});
if (pendingSubs.length === 0) return 0;
Logger.log(
`Reconciling ${pendingSubs.length} pending subscription(s)…`,
'SubscriptionsService',
);
let settled = 0;
for (const sub of pendingSubs) {
try {
const invoice = await this.btcpayService.getInvoice(sub.stripeId);
if (invoice.state === 'PAID') {
await this.activateSubscription(sub.stripeId);
settled++;
Logger.log(
`Reconciled subscription ${sub.id} (invoice ${sub.stripeId}) — now active`,
'SubscriptionsService',
);
}
} catch (err) {
Logger.warn(
`Reconciliation failed for subscription ${sub.id}: ${err.message}`,
'SubscriptionsService',
);
}
}
return settled;
}
async getActiveSubscriptions(userId: string) { async getActiveSubscriptions(userId: string) {
return this.subscriptionsRepository.find({ return this.subscriptionsRepository.find({
where: { where: {

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { RentsService } from 'src/rents/rents.service'; import { RentsService } from 'src/rents/rents.service';
import { BTCPayService } from 'src/payment/providers/services/btcpay.service'; import { BTCPayService } from 'src/payment/providers/services/btcpay.service';
import { SeasonRentsService } from 'src/season/season-rents.service'; import { SeasonRentsService } from 'src/season/season-rents.service';
@@ -6,7 +6,7 @@ import { SubscriptionsService } from 'src/subscriptions/subscriptions.service';
import type { BTCPayWebhookEvent } from 'src/payment/providers/dto/btcpay/btcpay-invoice'; import type { BTCPayWebhookEvent } from 'src/payment/providers/dto/btcpay/btcpay-invoice';
@Injectable() @Injectable()
export class WebhooksService { export class WebhooksService implements OnModuleInit {
constructor( constructor(
@Inject(RentsService) @Inject(RentsService)
private readonly rentService: RentsService, private readonly rentService: RentsService,
@@ -18,6 +18,35 @@ export class WebhooksService {
private readonly btcpayService: BTCPayService, private readonly btcpayService: BTCPayService,
) {} ) {}
/**
* On startup, reconcile any pending payments whose webhooks may
* have been missed during a server restart or outage.
*/
async onModuleInit() {
try {
const [rents, seasonRents, subscriptions] = await Promise.all([
this.rentService.reconcilePendingPayments(),
this.seasonRentService.reconcilePendingPayments(),
this.subscriptionsService.reconcilePendingPayments(),
]);
const total = rents + seasonRents + subscriptions;
if (total > 0) {
Logger.log(
`Payment reconciliation complete: ${rents} rent(s), ${seasonRents} season rent(s), ${subscriptions} subscription(s) recovered`,
'WebhooksService',
);
} else {
Logger.log('Payment reconciliation: no missed payments found', 'WebhooksService');
}
} catch (err) {
Logger.error(
`Payment reconciliation failed: ${err.message}`,
'WebhooksService',
);
}
}
/** /**
* Handle BTCPay Server webhook events. * Handle BTCPay Server webhook events.
* *

View File

@@ -74,7 +74,7 @@
<!-- Right Side Actions --> <!-- Right Side Actions -->
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<!-- Nostr Login (persona switcher or extension) --> <!-- 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) --> <!-- Persona Switcher (dev) -->
<div class="relative persona-dropdown"> <div class="relative persona-dropdown">
<button @click="togglePersonaMenu" class="nav-button px-3 py-2 text-xs"> <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) --> <!-- Sign In (app auth, if not Nostr logged in) -->
<button <button
v-if="!isAuthenticated && showAuth && !nostrLoggedIn" v-if="!isAuthenticated && showAuth && !hasNostrSession"
@click="$emit('openAuth')" @click="$emit('openAuth')"
class="hidden md:block hero-play-button px-4 py-2 text-sm" class="hidden md:block hero-play-button px-4 py-2 text-sm"
> >
@@ -189,7 +189,7 @@
</div> </div>
<!-- Active Nostr Account --> <!-- 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 <button
@click="toggleDropdown" @click="toggleDropdown"
class="profile-button flex items-center gap-2" class="profile-button flex items-center gap-2"
@@ -270,12 +270,12 @@
<!-- Mobile --> <!-- Mobile -->
<div class="md:hidden flex items-center gap-2 mr-2"> <div class="md:hidden flex items-center gap-2 mr-2">
<img <img
v-if="nostrLoggedIn && nostrActivePubkey" v-if="hasNostrSession && nostrActivePubkey"
:src="`https://robohash.org/${nostrActivePubkey}.png`" :src="`https://robohash.org/${nostrActivePubkey}.png`"
class="w-7 h-7 rounded-full" class="w-7 h-7 rounded-full"
alt="Avatar" 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"> <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"> <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 }} {{ userInitials }}
@@ -350,6 +350,20 @@ const contentSourceStore = useContentSourceStore()
const contentStore = useContentStore() const contentStore = useContentStore()
const isFilmmakerUser = isFilmmakerComputed 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 */ /** Switch content source and reload */
function handleSourceSelect(sourceId: string) { function handleSourceSelect(sourceId: string) {
contentSourceStore.setSource(sourceId as any) contentSourceStore.setSource(sourceId as any)
@@ -520,7 +534,7 @@ function handleMyListClick() {
if (activeAlgorithm.value) { if (activeAlgorithm.value) {
_setAlgorithm(activeAlgorithm.value as any) // toggle off _setAlgorithm(activeAlgorithm.value as any) // toggle off
} }
if (!isAuthenticated.value && !nostrLoggedIn.value) { if (!isAuthenticated.value && !hasNostrSession.value) {
emit('openAuth', '/library') emit('openAuth', '/library')
return return
} }
@@ -553,8 +567,10 @@ async function handlePersonaLogin(persona: Persona) {
// Also populate auth store (for subscription/My List access) // Also populate auth store (for subscription/My List access)
try { try {
await appLoginWithNostr(persona.pubkey, 'persona', {}) await appLoginWithNostr(persona.pubkey, 'persona', {})
} catch { } catch (err) {
// Auth store mock login — non-critical if it fails // 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"> <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 Included with subscription
</span> </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"> <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 {{ content.rentalPrice.toLocaleString() }} sats
</span> </span>
@@ -49,7 +55,7 @@
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z"/>
</svg> </svg>
{{ hasActiveSubscription ? 'Play' : content?.rentalPrice ? 'Rent & Play' : 'Play' }} {{ canPlay ? 'Play' : content?.rentalPrice ? 'Rent & Play' : 'Play' }}
</button> </button>
<!-- Add to My List --> <!-- Add to My List -->
@@ -232,6 +238,7 @@ import { ref, watch, computed } from 'vue'
import { useAuth } from '../composables/useAuth' import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts' import { useAccounts } from '../composables/useAccounts'
import { useNostr } from '../composables/useNostr' import { useNostr } from '../composables/useNostr'
import { libraryService } from '../services/library.service'
import type { Content } from '../types/content' import type { Content } from '../types/content'
import VideoPlayer from './VideoPlayer.vue' import VideoPlayer from './VideoPlayer.vue'
import SubscriptionModal from './SubscriptionModal.vue' import SubscriptionModal from './SubscriptionModal.vue'
@@ -263,6 +270,44 @@ const showSubscriptionModal = ref(false)
const showRentalModal = ref(false) const showRentalModal = ref(false)
const relayConnected = ref(true) 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 // Nostr social data -- subscribes to relay in real time
const nostr = useNostr() const nostr = useNostr()
const commentTree = computed(() => nostr.commentTree.value) const commentTree = computed(() => nostr.commentTree.value)
@@ -282,16 +327,22 @@ const currentUserAvatar = computed(() => {
return 'https://robohash.org/anonymous.png' 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) => { watch(() => props.content?.id, (newId) => {
if (newId && props.isOpen) { if (newId && props.isOpen) {
loadSocialData(newId) loadSocialData(newId)
checkRentalAccess()
} }
}) })
watch(() => props.isOpen, (open) => { watch(() => props.isOpen, (open) => {
if (open && props.content?.id) { if (open && props.content?.id) {
loadSocialData(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 return
} }
if (hasActiveSubscription.value) { // Subscription or active rental → play immediately
if (canPlay.value) {
showVideoPlayer.value = true showVideoPlayer.value = true
return return
} }
@@ -403,6 +455,9 @@ function handleSubscriptionSuccess() {
function handleRentalSuccess() { function handleRentalSuccess() {
showRentalModal.value = false 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 showVideoPlayer.value = true
} }

View File

@@ -39,7 +39,11 @@
<p class="text-white/60 text-sm">48-hour viewing period</p> <p class="text-white/60 text-sm">48-hour viewing period</p>
</div> </div>
<div class="text-right"> <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 class="text-white/60 text-sm">One-time payment</div>
</div> </div>
</div> </div>
@@ -77,7 +81,7 @@
class="hero-play-button w-full flex items-center justify-center mb-4" 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"> <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> </svg>
<span v-if="!isLoading">Pay with Lightning</span> <span v-if="!isLoading">Pay with Lightning</span>
<span v-else>Creating invoice...</span> <span v-else>Creating invoice...</span>
@@ -117,11 +121,9 @@
<!-- Amount in sats --> <!-- Amount in sats -->
<div class="mb-4"> <div class="mb-4">
<div class="text-lg font-bold text-white"> <div class="text-lg font-bold text-white flex items-center justify-center gap-1">
{{ formatSats(invoiceData?.sourceAmount?.amount) }} sats <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>
</div> {{ displaySats }} sats
<div class="text-sm text-white/60">
${{ content?.rentalPrice || '4.99' }} USD
</div> </div>
</div> </div>
@@ -203,7 +205,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { libraryService } from '../services/library.service' import { libraryService } from '../services/library.service'
import type { Content } from '../types/content' import type { Content } from '../types/content'
@@ -244,6 +246,19 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
// USE_MOCK imported from utils/mock // 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() { function closeModal() {
cleanup() cleanup()
paymentState.value = 'initial' paymentState.value = 'initial'
@@ -332,13 +347,18 @@ function startCountdown(expirationDate: Date | string) {
countdownInterval = setInterval(updateCountdown, 1000) 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 () => { pollInterval = setInterval(async () => {
try { try {
if (USE_MOCK) return // mock mode handles differently if (USE_MOCK) return
const response = await libraryService.checkRentExists(contentId) const quote = await libraryService.pollQuoteStatus(invoiceId)
if (response.exists) { if (quote.paid) {
cleanup() cleanup()
paymentState.value = 'success' paymentState.value = 'success'
} }
@@ -381,8 +401,14 @@ async function handleRent() {
return return
} }
// Real API call — create Lightning invoice // Real API call — create Lightning invoice.
const result = await libraryService.rentContent(props.content.id) // 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 invoiceData.value = result
bolt11Invoice.value = result.lnInvoice bolt11Invoice.value = result.lnInvoice
@@ -390,9 +416,15 @@ async function handleRent() {
paymentState.value = 'invoice' paymentState.value = 'invoice'
startCountdown(result.expiration) startCountdown(result.expiration)
startPolling(props.content.id) startPolling(result.providerId)
} catch (error: any) { } 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 { } finally {
isLoading.value = false isLoading.value = false
} }

View File

@@ -30,18 +30,18 @@
: 'text-white/60 hover:text-white' : 'text-white/60 hover:text-white'
]" ]"
> >
Monthly 1 Month
</button> </button>
<button <button
@click="period = 'annual'" @click="period = 'yearly'"
:class="[ :class="[
'px-6 py-2 rounded-lg font-medium transition-all flex items-center gap-2', 'px-6 py-2 rounded-lg font-medium transition-all flex items-center gap-2',
period === 'annual' period === 'yearly'
? 'bg-white text-black' ? 'bg-white text-black'
: 'text-white/60 hover:text-white' : '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> <span class="text-xs bg-orange-500 text-white px-2 py-0.5 rounded-full">Save 17%</span>
</button> </button>
</div> </div>
@@ -64,12 +64,13 @@
@click="selectedTier = tier.tier" @click="selectedTier = tier.tier"
> >
<h3 class="text-xl font-bold text-white mb-2">{{ tier.name }}</h3> <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"> <span class="text-3xl font-bold text-white">
${{ period === 'monthly' ? tier.monthlyPrice : tier.annualPrice }} {{ (period === 'monthly' ? tier.monthlyPrice : tier.annualPrice).toLocaleString() }}
</span> </span>
<span class="text-white/60 text-sm"> <span class="text-white/60 text-sm">
/{{ period === 'monthly' ? 'month' : 'year' }} sats / {{ period === 'monthly' ? '1 month' : '1 year' }}
</span> </span>
</div> </div>
@@ -91,14 +92,14 @@
class="hero-play-button w-full flex items-center justify-center" 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"> <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> </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> <span v-else>Creating invoice...</span>
</button> </button>
<p class="text-center text-xs text-white/40 mt-4"> <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> </p>
</template> </template>
@@ -107,7 +108,7 @@
<div class="text-center"> <div class="text-center">
<h2 class="text-2xl font-bold text-white mb-2">Pay with Lightning</h2> <h2 class="text-2xl font-bold text-white mb-2">Pay with Lightning</h2>
<p class="text-white/60 text-sm mb-1"> <p class="text-white/60 text-sm mb-1">
{{ selectedTierName }} {{ period === 'monthly' ? 'Monthly' : 'Annual' }} {{ selectedTierName }} {{ period === 'monthly' ? '1 Month' : '1 Year' }}
</p> </p>
<p class="text-white/40 text-xs mb-6"> <p class="text-white/40 text-xs mb-6">
Scan the QR code or copy the invoice to pay Scan the QR code or copy the invoice to pay
@@ -123,10 +124,10 @@
<!-- Amount --> <!-- Amount -->
<div class="mb-4"> <div class="mb-4">
<div class="text-lg font-bold text-white"> <div class="text-lg font-bold text-white flex items-center justify-center gap-1">
{{ formatSats(invoiceData?.sourceAmount?.amount) }} sats <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>
<div class="text-sm text-white/60"> ${{ selectedPrice }} USD</div>
</div> </div>
<!-- Expiration Countdown --> <!-- Expiration Countdown -->
@@ -229,7 +230,7 @@ const emit = defineEmits<Emits>()
type PaymentState = 'select' | 'invoice' | 'success' | 'expired' type PaymentState = 'select' | 'invoice' | 'success' | 'expired'
const paymentState = ref<PaymentState>('select') const paymentState = ref<PaymentState>('select')
const period = ref<'monthly' | 'annual'>('monthly') const period = ref<'monthly' | 'yearly'>('monthly')
const selectedTier = ref<string>('film-buff') const selectedTier = ref<string>('film-buff')
const tiers = ref<any[]>([]) const tiers = ref<any[]>([])
const isLoading = ref(false) const isLoading = ref(false)
@@ -259,6 +260,17 @@ const selectedPrice = computed(() => {
return period.value === 'monthly' ? tier.monthlyPrice : tier.annualPrice 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 () => { onMounted(async () => {
tiers.value = await subscriptionService.getSubscriptionTiers() tiers.value = await subscriptionService.getSubscriptionTiers()
}) })
@@ -372,7 +384,7 @@ async function handleSubscribe() {
// Real API call — create Lightning subscription invoice // Real API call — create Lightning subscription invoice
const result = await subscriptionService.createLightningSubscription({ const result = await subscriptionService.createLightningSubscription({
type: selectedTier.value as any, type: selectedTier.value as any,
period: period.value, period: period.value as 'monthly' | 'yearly',
}) })
invoiceData.value = result invoiceData.value = result

View File

@@ -7,10 +7,10 @@
<UploadZone <UploadZone
label="Drag & drop your video file, or click to browse" label="Drag & drop your video file, or click to browse"
accept="video/*" accept="video/*"
:current-file="uploads.file.fileName || (project as any)?.file" :current-file="uploads.file.fileName || existingFile"
:status="uploads.file.status" :status="uploads.file.status"
:progress="uploads.file.progress" :progress="uploads.file.progress"
:file-name="uploads.file.fileName" :file-name="uploads.file.fileName || existingFileLabel"
@file-selected="(f: File) => handleFileUpload('file', f)" @file-selected="(f: File) => handleFileUpload('file', f)"
/> />
</div> </div>
@@ -37,10 +37,10 @@
<UploadZone <UploadZone
label="Upload trailer video" label="Upload trailer video"
accept="video/*" accept="video/*"
:current-file="uploads.trailer.fileName || project?.trailer" :current-file="uploads.trailer.fileName || existingTrailer"
:status="uploads.trailer.status" :status="uploads.trailer.status"
:progress="uploads.trailer.progress" :progress="uploads.trailer.progress"
:file-name="uploads.trailer.fileName" :file-name="uploads.trailer.fileName || existingTrailerLabel"
@file-selected="(f: File) => handleFileUpload('trailer', f)" @file-selected="(f: File) => handleFileUpload('trailer', f)"
/> />
</div> </div>
@@ -63,12 +63,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive } from 'vue' import { reactive, computed } from 'vue'
import type { ApiProject } from '../../types/api' import type { ApiProject } from '../../types/api'
import UploadZone from './UploadZone.vue' import UploadZone from './UploadZone.vue'
import { useUpload } from '../../composables/useUpload'
import { USE_MOCK } from '../../utils/mock' import { USE_MOCK } from '../../utils/mock'
defineProps<{ const props = defineProps<{
project: ApiProject | null project: ApiProject | null
}>() }>()
@@ -76,6 +77,31 @@ const emit = defineEmits<{
(e: 'update', field: string, value: any): void (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 { interface UploadState {
status: 'idle' | 'uploading' | 'completed' | 'error' status: 'idle' | 'uploading' | 'completed' | 'error'
progress: number progress: number
@@ -90,6 +116,17 @@ const uploads = reactive<Record<string, UploadState>>({
subtitles: { status: 'idle', progress: 0, fileName: '', previewUrl: '' }, 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 * Simulate upload progress for development mode
* Mimics realistic chunked upload behavior with variable speed * 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 const value = state.previewUrl || file.name
emit('update', field, value) emit('update', field, value)
} else { } else {
// Real mode: trigger actual upload (handled by parent/service) // Real mode: upload to MinIO via multipart upload
state.status = 'uploading' state.status = 'uploading'
state.fileName = file.name state.fileName = file.name
state.progress = 0 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 { try {
// Emit to parent, which would handle the real upload const resultKey = await addUpload(file, s3Key, bucket, (progress, status) => {
emit('update', field, file) // Real-time progress callback from the chunked uploader
} catch { 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' state.status = 'error'
console.error(`Upload failed for ${field}:`, err.message)
} }
} }
} }

View File

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

View File

@@ -8,7 +8,7 @@
<div class="relative flex-1 max-w-xs"> <div class="relative flex-1 max-w-xs">
<input <input
type="number" type="number"
:value="project?.rentalPrice" :value="project?.film?.rentalPrice ?? project?.rentalPrice"
@input="emit('update', 'rentalPrice', Number(($event.target as HTMLInputElement).value))" @input="emit('update', 'rentalPrice', Number(($event.target as HTMLInputElement).value))"
class="field-input pr-12" class="field-input pr-12"
placeholder="0" 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 = { const item: UploadItem = {
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`, id: `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
file, file,
@@ -57,16 +63,20 @@ export function useUpload() {
} }
uploadQueue.value.push(item) uploadQueue.value.push(item)
return processUpload(item) return processUpload(item, onProgress)
} }
/** /**
* Process a single upload: initialize, chunk, upload, finalize * 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 { try {
item.status = 'uploading' item.status = 'uploading'
isUploading.value = true isUploading.value = true
onProgress?.(0, 'uploading')
// Step 1: Initialize multipart upload // Step 1: Initialize multipart upload
const { UploadId, Key } = await filmmakerService.initializeUpload( const { UploadId, Key } = await filmmakerService.initializeUpload(
@@ -106,13 +116,11 @@ export function useUpload() {
try { try {
const response = await axios.put(part.signedUrl, chunk, { const response = await axios.put(part.signedUrl, chunk, {
headers: { 'Content-Type': item.file.type }, headers: { 'Content-Type': item.file.type },
onUploadProgress: () => {
// Progress is tracked at the chunk level
},
}) })
uploadedChunks++ uploadedChunks++
item.progress = Math.round((uploadedChunks / totalChunks) * 100) item.progress = Math.round((uploadedChunks / totalChunks) * 100)
onProgress?.(item.progress, 'uploading')
const etag = response.headers.etag || response.headers.ETag const etag = response.headers.etag || response.headers.ETag
return { return {
@@ -121,6 +129,7 @@ export function useUpload() {
} }
} catch (err: any) { } catch (err: any) {
lastError = err lastError = err
console.error(`Chunk ${part.PartNumber} attempt ${attempt + 1} failed:`, err.message)
// Exponential backoff // Exponential backoff
await new Promise((resolve) => await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, attempt) * 1000) setTimeout(resolve, Math.pow(2, attempt) * 1000)
@@ -139,6 +148,7 @@ export function useUpload() {
item.status = 'completed' item.status = 'completed'
item.progress = 100 item.progress = 100
onProgress?.(100, 'completed')
// Check if all uploads done // Check if all uploads done
if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) { if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) {
@@ -150,6 +160,7 @@ export function useUpload() {
item.status = 'failed' item.status = 'failed'
item.error = err.message || 'Upload failed' item.error = err.message || 'Upload failed'
console.error('Upload failed:', err) console.error('Upload failed:', err)
onProgress?.(item.progress, 'failed')
if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) { if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) {
isUploading.value = false 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() { export function loadAccounts() {
try { try {
const saved = localStorage.getItem(STORAGE_KEY) const saved = localStorage.getItem(STORAGE_KEY)
if (saved) { if (!saved) {
const accounts = JSON.parse(saved) // Nothing persisted — make sure the manager is clean.
accountManager.fromJSON(accounts, true) // 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 accounts = JSON.parse(saved)
const activeId = localStorage.getItem(ACTIVE_KEY) accountManager.fromJSON(accounts, true)
if (activeId) {
const account = accountManager.getAccount(activeId) // Restore active account
if (account) { const activeId = localStorage.getItem(ACTIVE_KEY)
accountManager.setActive(account) if (activeId) {
} const account = accountManager.getAccount(activeId)
if (account) {
accountManager.setActive(account)
} }
} }
} catch (e) { } catch (e) {

View File

@@ -1,5 +1,6 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios' import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'
import { apiConfig } from '../config/api.config' import { apiConfig } from '../config/api.config'
import { nip98Service } from './nip98.service'
import type { ApiError } from '../types/api' import type { ApiError } from '../types/api'
/** /**
@@ -26,10 +27,24 @@ class ApiService {
* Setup request and response interceptors * Setup request and response interceptors
*/ */
private setupInterceptors() { 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( this.client.interceptors.request.use(
(config) => { async (config) => {
const token = this.getToken() 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) { if (token) {
config.headers.Authorization = `Bearer ${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> { private async refreshToken(): Promise<string> {
// Prevent multiple simultaneous refresh requests // Prevent multiple simultaneous refresh requests
@@ -125,13 +142,17 @@ class ApiService {
refreshToken, refreshToken,
}) })
const newToken = response.data.accessToken const { accessToken: newToken, refreshToken: newRefresh, expiresIn } = response.data
this.setToken(newToken, 'nostr') this.setToken(newToken, 'nostr')
if (response.data.refreshToken) { if (newRefresh) {
sessionStorage.setItem('refresh_token', response.data.refreshToken) 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 return newToken
} finally { } finally {
this.tokenRefreshPromise = null this.tokenRefreshPromise = null

View File

@@ -22,16 +22,22 @@ class IndeehubApiService {
}, },
}) })
// Attach JWT token from NIP-98 session // Attach JWT token from NIP-98 session.
this.client.interceptors.request.use((config) => { // If the token has expired but we have a refresh token, proactively
const token = nip98Service.accessToken // 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) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
return config 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( this.client.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { 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`) 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 * 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' const EXPIRES_KEY = 'indeehub_api_expires'
class Nip98Service { 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 * 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 { get accessToken(): string | null {
if (!this.hasValidToken) return null if (!this.hasValidToken) return null
return sessionStorage.getItem(TOKEN_KEY) 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. * Create a session with the backend using a NIP-98 auth event.
* *
@@ -89,6 +110,7 @@ class Nip98Service {
// Store tokens // Store tokens
sessionStorage.setItem(TOKEN_KEY, accessToken) sessionStorage.setItem(TOKEN_KEY, accessToken)
sessionStorage.setItem(REFRESH_KEY, refreshToken) sessionStorage.setItem(REFRESH_KEY, refreshToken)
sessionStorage.setItem('refresh_token', refreshToken)
sessionStorage.setItem( sessionStorage.setItem(
EXPIRES_KEY, EXPIRES_KEY,
String(Date.now() + (expiresIn * 1000) - 30000) // 30s buffer String(Date.now() + (expiresIn * 1000) - 30000) // 30s buffer
@@ -124,7 +146,10 @@ class Nip98Service {
const { accessToken, refreshToken: newRefresh, expiresIn } = response.data const { accessToken, refreshToken: newRefresh, expiresIn } = response.data
sessionStorage.setItem(TOKEN_KEY, accessToken) 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( sessionStorage.setItem(
EXPIRES_KEY, EXPIRES_KEY,
String(Date.now() + (expiresIn * 1000) - 30000) String(Date.now() + (expiresIn * 1000) - 30000)
@@ -146,15 +171,38 @@ class Nip98Service {
/** /**
* Store tokens from an external auth flow (e.g. auth.service.ts). * Store tokens from an external auth flow (e.g. auth.service.ts).
* Keeps nip98Service in sync so IndeehubApiService can read the token. * 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) { storeTokens(accessToken: string, refreshToken?: string, expiresIn?: number) {
sessionStorage.setItem(TOKEN_KEY, accessToken) sessionStorage.setItem(TOKEN_KEY, accessToken)
if (refreshToken) { if (refreshToken) {
sessionStorage.setItem(REFRESH_KEY, 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 let expiresAtMs: number
sessionStorage.setItem(EXPIRES_KEY, String(Date.now() + ttlMs - 30000)) 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 // Backwards compatibility
sessionStorage.setItem('nostr_token', accessToken) sessionStorage.setItem('nostr_token', accessToken)
} }

View File

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

View File

@@ -136,30 +136,46 @@ export const useAuthStore = defineStore('auth', () => {
return 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 { 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() await fetchCurrentUser()
nostrPubkey.value = storedPubkey
if (storedCognitoToken) { 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' authType.value = 'cognito'
cognitoToken.value = storedCognitoToken cognitoToken.value = storedCognitoToken
isAuthenticated.value = true
} else { } else {
authType.value = 'nostr' await logout()
} }
isAuthenticated.value = true
} else {
await logout()
} }
} catch (apiError: any) { } catch (apiError: any) {
if (isConnectionError(apiError)) { if (isConnectionError(apiError)) {
console.warn('Backend not reachable — falling back to mock session.') console.warn('Backend not reachable — falling back to mock session.')
restoreAsMock() restoreAsMock()
} else { } 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) { } catch (error) {

View File

@@ -32,7 +32,10 @@ export const useContentStore = defineStore('content', () => {
const projects = await contentService.getProjects({ status: 'published' }) const projects = await contentService.getProjects({ status: 'published' })
if (projects.length === 0) { 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) const allContent = mapApiProjectsToContents(projects)
@@ -76,19 +79,25 @@ export const useContentStore = defineStore('content', () => {
const projects = Array.isArray(response) ? response : (response as any)?.data ?? [] const projects = Array.isArray(response) ? response : (response as any)?.data ?? []
if (!Array.isArray(projects) || projects.length === 0) { 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) => ({ const allContent: Content[] = projects.map((p: any) => ({
id: p.id, id: p.id,
contentId: p.film?.id,
title: p.title, title: p.title,
description: p.synopsis || '', description: p.synopsis || '',
thumbnail: p.poster || '', thumbnail: p.poster || '',
backdrop: p.poster || '', backdrop: p.poster || '',
type: p.type || 'film', type: p.type || 'film',
slug: p.slug, slug: p.slug,
rentalPrice: p.rentalPrice, rentalPrice: p.film?.rentalPrice ?? p.rentalPrice,
status: p.status, status: p.status,
categories: p.genre ? [p.genre.name] : [], categories: p.genre ? [p.genre.name] : [],
streamingUrl: p.streamingUrl || p.partnerStreamUrl || undefined, streamingUrl: p.streamingUrl || p.partnerStreamUrl || undefined,
@@ -168,15 +177,57 @@ export const useContentStore = defineStore('content', () => {
/** /**
* Convert published filmmaker projects to Content format and merge * Convert published filmmaker projects to Content format and merge
* them into the existing content rows so they appear on the browse page. * 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 { try {
const { projects } = useFilmmaker() const filmmaker = useFilmmaker()
const published = projects.value.filter(p => p.status === 'published')
// 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 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 => ({ const publishedContent: Content[] = published.map(p => ({
id: p.id, id: p.id,
contentId: p.film?.id,
title: p.title || p.name, title: p.title || p.name,
description: p.synopsis || '', description: p.synopsis || '',
thumbnail: p.poster || '/images/placeholder-poster.jpg', 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(), releaseYear: p.releaseDate ? new Date(p.releaseDate).getFullYear() : new Date().getFullYear(),
categories: p.genres?.map(g => g.name) || [], categories: p.genres?.map(g => g.name) || [],
slug: p.slug, slug: p.slug,
rentalPrice: p.rentalPrice, rentalPrice: p.film?.rentalPrice ?? p.rentalPrice,
status: p.status, status: p.status,
apiData: p, apiData: p,
})) }))
@@ -213,7 +264,7 @@ export const useContentStore = defineStore('content', () => {
/** /**
* Route to the correct loader based on the active content source * Route to the correct loader based on the active content source
*/ */
function fetchContentFromMock() { async function fetchContentFromMock() {
const sourceStore = useContentSourceStore() const sourceStore = useContentSourceStore()
if (sourceStore.activeSource === 'topdocfilms') { if (sourceStore.activeSource === 'topdocfilms') {
fetchTopDocMock() fetchTopDocMock()
@@ -221,30 +272,37 @@ export const useContentStore = defineStore('content', () => {
fetchIndeeHubMock() fetchIndeeHubMock()
} }
// In mock mode, also include any projects published through the backstage // Also include any projects published through the backstage
mergePublishedFilmmakerProjects() 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() { async function fetchContent() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const sourceStore = useContentSourceStore() const apiUrl = import.meta.env.VITE_INDEEHUB_API_URL || ''
if (sourceStore.activeSource === 'indeehub-api' && !USE_MOCK_DATA) { if (USE_MOCK_DATA) {
// Fetch from our self-hosted backend (only when backend is actually running)
await fetchContentFromIndeehubApi()
} else if (USE_MOCK_DATA) {
// Use mock data in development or when flag is set // Use mock data in development or when flag is set
await new Promise(resolve => setTimeout(resolve, 100)) 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 { } else {
// Fetch from original API // No self-hosted backend — try external API
await fetchContentFromApi() await fetchContentFromApi()
await mergePublishedFilmmakerProjects()
} }
} catch (e: any) { } catch (e: any) {
error.value = e.message || 'Failed to load content' error.value = e.message || 'Failed to load content'
@@ -252,7 +310,7 @@ export const useContentStore = defineStore('content', () => {
// Fallback to mock data on error // Fallback to mock data on error
console.log('Falling back to mock data...') console.log('Falling back to mock data...')
fetchContentFromMock() await fetchContentFromMock()
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -8,12 +8,14 @@ const STORAGE_KEY = 'indeedhub:content-source'
export const useContentSourceStore = defineStore('contentSource', () => { export const useContentSourceStore = defineStore('contentSource', () => {
const saved = localStorage.getItem(STORAGE_KEY) as ContentSourceId | null const saved = localStorage.getItem(STORAGE_KEY) as ContentSourceId | null
const validSources: ContentSourceId[] = ['indeehub', 'topdocfilms', 'indeehub-api'] 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 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 availableSources = computed(() => {
const sources: { id: ContentSourceId; label: string }[] = [ const sources: { id: ContentSourceId; label: string }[] = [

View File

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

View File

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

View File

@@ -95,28 +95,29 @@
<div class="content-col"> <div class="content-col">
<!-- Tab Content --> <!-- Tab Content -->
<div class="glass-card p-5 md:p-8"> <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" /> <AssetsTab :project="project" @update="handleFieldUpdate" />
</div> </div>
<div v-else-if="activeTabId === 'details'"> <div v-show="activeTabId === 'details'">
<DetailsTab :project="project" :genres="genres" @update="handleFieldUpdate" /> <DetailsTab :project="project" :genres="genres" @update="handleFieldUpdate" />
</div> </div>
<div v-else-if="activeTabId === 'content'"> <div v-if="activeTabId === 'content'">
<ContentTab :project="project" /> <ContentTab :project="project" />
</div> </div>
<div v-else-if="activeTabId === 'cast-and-crew'"> <div v-if="activeTabId === 'cast-and-crew'">
<CastCrewTab :project="project" /> <CastCrewTab :project="project" />
</div> </div>
<div v-else-if="activeTabId === 'revenue'"> <div v-show="activeTabId === 'revenue'">
<RevenueTab :project="project" @update="handleFieldUpdate" /> <RevenueTab :project="project" @update="handleFieldUpdate" />
</div> </div>
<div v-else-if="activeTabId === 'permissions'"> <div v-if="activeTabId === 'permissions'">
<PermissionsTab :project="project" /> <PermissionsTab :project="project" />
</div> </div>
<div v-else-if="activeTabId === 'documentation'"> <div v-if="activeTabId === 'documentation'">
<DocumentationTab :project="project" /> <DocumentationTab :project="project" />
</div> </div>
<div v-else-if="activeTabId === 'coupons'"> <div v-if="activeTabId === 'coupons'">
<CouponsTab :project="project" /> <CouponsTab :project="project" />
</div> </div>
</div> </div>
@@ -207,7 +208,7 @@ const allTabs = computed<TabDef[]>(() => [
{ id: 'details', label: 'Details', state: project.value?.title && project.value?.synopsis ? 'completed' : 'new' }, { id: 'details', label: 'Details', state: project.value?.title && project.value?.synopsis ? 'completed' : 'new' },
{ id: 'content', label: 'Content', state: 'new', showFor: ['episodic'] }, { id: 'content', label: 'Content', state: 'new', showFor: ['episodic'] },
{ id: 'cast-and-crew', label: 'Cast & Crew', state: 'new' }, { 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: 'permissions', label: 'Permissions', state: 'new' },
{ id: 'documentation', label: 'Documentation', state: 'new' }, { id: 'documentation', label: 'Documentation', state: 'new' },
{ id: 'coupons', label: 'Coupons', state: 'new' }, { id: 'coupons', label: 'Coupons', state: 'new' },
@@ -229,6 +230,8 @@ const statusBadgeClass = computed(() => {
switch (project.value?.status) { switch (project.value?.status) {
case 'published': case 'published':
return `${base} bg-green-500/20 text-green-400 border border-green-500/30` 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': case 'rejected':
return `${base} bg-red-500/20 text-red-400 border border-red-500/30` return `${base} bg-red-500/20 text-red-400 border border-red-500/30`
default: default:
@@ -247,6 +250,32 @@ async function handleSave(status?: string) {
const data = { ...pendingChanges.value } const data = { ...pendingChanges.value }
if (status) data.status = status 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) const updated = await saveProject(project.value.id, data)
if (updated) { if (updated) {
project.value = updated project.value = updated

View File

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