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:
@@ -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=
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 · {{ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }[] = [
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user