feat: enhance zap functionality with stats tracking and pubkey support
- Added a new endpoint in ZapsController to retrieve zap statistics by project IDs, including total counts, amounts, and recent zapper pubkeys. - Updated ZapsService to record zap statistics, including optional zapper pubkey for tracking who zapped. - Enhanced CreateZapInvoiceDto to include an optional zapperPubkey field. - Modified frontend components to display zap stats and integrate with the new backend functionality, improving user engagement and transparency. These changes improve the overall zap experience by providing detailed insights into zap activities and enhancing the tracking of contributors.
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddZapStats1762000000000 implements MigrationInterface {
|
||||||
|
name = 'AddZapStats1762000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "zap_stats" (
|
||||||
|
"project_id" character varying NOT NULL,
|
||||||
|
"zap_count" integer NOT NULL DEFAULT 0,
|
||||||
|
"zap_amount_sats" integer NOT NULL DEFAULT 0,
|
||||||
|
"recent_zapper_pubkeys" jsonb NOT NULL DEFAULT '[]',
|
||||||
|
CONSTRAINT "PK_zap_stats" PRIMARY KEY ("project_id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE "zap_stats"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsNumber, IsString, Min } from 'class-validator';
|
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
export class CreateZapInvoiceDto {
|
export class CreateZapInvoiceDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -10,4 +10,9 @@ export class CreateZapInvoiceDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1, { message: 'Amount must be at least 1 satoshi' })
|
@Min(1, { message: 'Amount must be at least 1 satoshi' })
|
||||||
amountSats: number;
|
amountSats: number;
|
||||||
|
|
||||||
|
/** Nostr pubkey of the zapper (hex) — used to show "who zapped" on film cards. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
zapperPubkey?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
16
backend/src/zaps/entities/zap-stats.entity.ts
Normal file
16
backend/src/zaps/entities/zap-stats.entity.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('zap_stats')
|
||||||
|
export class ZapStats {
|
||||||
|
@PrimaryColumn()
|
||||||
|
projectId: string;
|
||||||
|
|
||||||
|
@Column('int', { default: 0 })
|
||||||
|
zapCount: number;
|
||||||
|
|
||||||
|
@Column('int', { default: 0 })
|
||||||
|
zapAmountSats: number;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: [] })
|
||||||
|
recentZapperPubkeys: string[];
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
import { ZapsService } from './zaps.service';
|
import { ZapsService } from './zaps.service';
|
||||||
import { CreateZapInvoiceDto } from './dto/create-zap-invoice.dto';
|
import { CreateZapInvoiceDto } from './dto/create-zap-invoice.dto';
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ export class ZapsController {
|
|||||||
dto.projectId,
|
dto.projectId,
|
||||||
dto.filmmakerId,
|
dto.filmmakerId,
|
||||||
dto.amountSats,
|
dto.amountSats,
|
||||||
|
dto.zapperPubkey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,4 +20,14 @@ export class ZapsController {
|
|||||||
async getQuote(@Param('invoiceId') invoiceId: string) {
|
async getQuote(@Param('invoiceId') invoiceId: string) {
|
||||||
return this.zapsService.getQuote(invoiceId);
|
return this.zapsService.getQuote(invoiceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get zap stats for film cards: total count, amount, and recent zapper pubkeys.
|
||||||
|
* Query: projectIds=id1,id2,id3
|
||||||
|
*/
|
||||||
|
@Get('stats')
|
||||||
|
async getStats(@Query('projectIds') projectIds: string) {
|
||||||
|
const ids = projectIds ? projectIds.split(',').filter(Boolean) : [];
|
||||||
|
return this.zapsService.getStats(ids);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ZapsController } from './zaps.controller';
|
import { ZapsController } from './zaps.controller';
|
||||||
import { ZapsService } from './zaps.service';
|
import { ZapsService } from './zaps.service';
|
||||||
|
import { ZapStats } from './entities/zap-stats.entity';
|
||||||
import { PaymentModule } from 'src/payment/payment.module';
|
import { PaymentModule } from 'src/payment/payment.module';
|
||||||
import { FilmmakersModule } from 'src/filmmakers/filmmakers.module';
|
import { FilmmakersModule } from 'src/filmmakers/filmmakers.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PaymentModule, FilmmakersModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ZapStats]),
|
||||||
|
PaymentModule,
|
||||||
|
FilmmakersModule,
|
||||||
|
],
|
||||||
controllers: [ZapsController],
|
controllers: [ZapsController],
|
||||||
providers: [ZapsService],
|
providers: [ZapsService],
|
||||||
exports: [ZapsService],
|
exports: [ZapsService],
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { In, Repository } from 'typeorm';
|
||||||
import { BTCPayService } from 'src/payment/providers/services/btcpay.service';
|
import { BTCPayService } from 'src/payment/providers/services/btcpay.service';
|
||||||
import { FilmmakersService } from 'src/filmmakers/filmmakers.service';
|
import { FilmmakersService } from 'src/filmmakers/filmmakers.service';
|
||||||
import Invoice from 'src/payment/providers/dto/strike/invoice';
|
import Invoice from 'src/payment/providers/dto/strike/invoice';
|
||||||
import InvoiceQuote from 'src/payment/providers/dto/strike/invoice-quote';
|
import InvoiceQuote from 'src/payment/providers/dto/strike/invoice-quote';
|
||||||
|
import { ZapStats } from './entities/zap-stats.entity';
|
||||||
|
|
||||||
const ZAP_ORDER_PREFIX = 'zap:';
|
const ZAP_ORDER_PREFIX = 'zap:';
|
||||||
|
const MAX_RECENT_ZAPPERS = 5;
|
||||||
|
|
||||||
|
export interface ProjectZapStats {
|
||||||
|
zapCount: number;
|
||||||
|
zapAmountSats: number;
|
||||||
|
recentZapperPubkeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ZapsService {
|
export class ZapsService {
|
||||||
private readonly logger = new Logger(ZapsService.name);
|
private readonly logger = new Logger(ZapsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@InjectRepository(ZapStats)
|
||||||
|
private readonly zapStatsRepository: Repository<ZapStats>,
|
||||||
private readonly btcpayService: BTCPayService,
|
private readonly btcpayService: BTCPayService,
|
||||||
private readonly filmmakersService: FilmmakersService,
|
private readonly filmmakersService: FilmmakersService,
|
||||||
) {}
|
) {}
|
||||||
@@ -18,12 +30,21 @@ export class ZapsService {
|
|||||||
/**
|
/**
|
||||||
* Create a BTCPay invoice for a zap. User pays this invoice; on settlement
|
* Create a BTCPay invoice for a zap. User pays this invoice; on settlement
|
||||||
* we pay out to the creator's Lightning address. Correlation is stored so
|
* we pay out to the creator's Lightning address. Correlation is stored so
|
||||||
* the webhook can route the payout.
|
* the webhook can route the payout. Optional zapperPubkey is stored so we
|
||||||
|
* can show "who zapped" on film cards.
|
||||||
*/
|
*/
|
||||||
async createInvoice(projectId: string, filmmakerId: string, amountSats: number) {
|
async createInvoice(
|
||||||
|
projectId: string,
|
||||||
|
filmmakerId: string,
|
||||||
|
amountSats: number,
|
||||||
|
zapperPubkey?: string,
|
||||||
|
) {
|
||||||
await this.filmmakersService.getFilmmakerLightningAddress(filmmakerId);
|
await this.filmmakersService.getFilmmakerLightningAddress(filmmakerId);
|
||||||
|
|
||||||
const correlationId = `${ZAP_ORDER_PREFIX}${projectId}:${filmmakerId}`;
|
let correlationId = `${ZAP_ORDER_PREFIX}${projectId}:${filmmakerId}`;
|
||||||
|
if (zapperPubkey) {
|
||||||
|
correlationId += `:${zapperPubkey}`;
|
||||||
|
}
|
||||||
const invoice = await this.btcpayService.issueInvoice(
|
const invoice = await this.btcpayService.issueInvoice(
|
||||||
amountSats,
|
amountSats,
|
||||||
`Zap — project ${projectId}`,
|
`Zap — project ${projectId}`,
|
||||||
@@ -62,7 +83,8 @@ export class ZapsService {
|
|||||||
this.logger.warn(`Invalid zap correlation: ${invoice.correlationId}`);
|
this.logger.warn(`Invalid zap correlation: ${invoice.correlationId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [_projectId, filmmakerId] = parts;
|
const [projectId, filmmakerId] = parts;
|
||||||
|
const zapperPubkey = parts.length >= 3 ? parts[2] : undefined;
|
||||||
|
|
||||||
const amountBtc = parseFloat(invoice.amount?.amount ?? '0');
|
const amountBtc = parseFloat(invoice.amount?.amount ?? '0');
|
||||||
const sats = Math.floor(amountBtc * 100_000_000);
|
const sats = Math.floor(amountBtc * 100_000_000);
|
||||||
@@ -84,8 +106,52 @@ export class ZapsService {
|
|||||||
await this.btcpayService.payToLightningAddress(
|
await this.btcpayService.payToLightningAddress(
|
||||||
address,
|
address,
|
||||||
sats,
|
sats,
|
||||||
`IndeeHub zap — project ${parts[0]}`,
|
`IndeeHub zap — project ${projectId}`,
|
||||||
);
|
);
|
||||||
|
await this.recordZapStats(projectId, sats, zapperPubkey);
|
||||||
this.logger.log(`Zap payout completed: ${invoiceId}`);
|
this.logger.log(`Zap payout completed: ${invoiceId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async recordZapStats(projectId: string, sats: number, zapperPubkey?: string): Promise<void> {
|
||||||
|
let row = await this.zapStatsRepository.findOneBy({ projectId });
|
||||||
|
if (!row) {
|
||||||
|
row = this.zapStatsRepository.create({
|
||||||
|
projectId,
|
||||||
|
zapCount: 0,
|
||||||
|
zapAmountSats: 0,
|
||||||
|
recentZapperPubkeys: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
row.zapCount += 1;
|
||||||
|
row.zapAmountSats += sats;
|
||||||
|
if (
|
||||||
|
zapperPubkey &&
|
||||||
|
Array.isArray(row.recentZapperPubkeys) &&
|
||||||
|
row.recentZapperPubkeys.length < MAX_RECENT_ZAPPERS &&
|
||||||
|
!row.recentZapperPubkeys.includes(zapperPubkey)
|
||||||
|
) {
|
||||||
|
row.recentZapperPubkeys = [...row.recentZapperPubkeys, zapperPubkey];
|
||||||
|
}
|
||||||
|
await this.zapStatsRepository.save(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get zap stats for the given project IDs (for film cards: total + who zapped).
|
||||||
|
* Persisted in DB so stats survive restarts.
|
||||||
|
*/
|
||||||
|
async getStats(projectIds: string[]): Promise<Record<string, ProjectZapStats>> {
|
||||||
|
if (projectIds.length === 0) return {};
|
||||||
|
const rows = await this.zapStatsRepository.find({
|
||||||
|
where: { projectId: In(projectIds) },
|
||||||
|
});
|
||||||
|
const result: Record<string, ProjectZapStats> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
result[row.projectId] = {
|
||||||
|
zapCount: row.zapCount,
|
||||||
|
zapAmountSats: row.zapAmountSats,
|
||||||
|
recentZapperPubkeys: Array.isArray(row.recentZapperPubkeys) ? row.recentZapperPubkeys : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,22 +89,46 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import type { Content } from '../types/content'
|
import type { Content } from '../types/content'
|
||||||
import { useContentDiscovery } from '../composables/useContentDiscovery'
|
import { useContentDiscovery } from '../composables/useContentDiscovery'
|
||||||
|
import { indeehubApiService } from '../services/indeehub-api.service'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
contents: Content[]
|
contents: Content[]
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
'content-click': [content: Content]
|
'content-click': [content: Content]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { getStats } = useContentDiscovery()
|
const { getStats } = useContentDiscovery()
|
||||||
|
|
||||||
|
/** Backend zap stats (BTCPay zaps) so film cards show total + who zapped. */
|
||||||
|
const backendZapStats = ref<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>>({})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.contents,
|
||||||
|
(contents) => {
|
||||||
|
const ids = contents?.map((c) => c.id).filter(Boolean) ?? []
|
||||||
|
if (ids.length === 0) {
|
||||||
|
backendZapStats.value = {}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
indeehubApiService
|
||||||
|
.getZapStats(ids)
|
||||||
|
.then((data) => {
|
||||||
|
backendZapStats.value = data
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
backendZapStats.value = {}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
function getReactionCount(contentId: string): number {
|
function getReactionCount(contentId: string): number {
|
||||||
return getStats(contentId).plusCount ?? 0
|
return getStats(contentId).plusCount ?? 0
|
||||||
}
|
}
|
||||||
@@ -114,11 +138,22 @@ function getCommentCount(contentId: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getZapCount(contentId: string): number {
|
function getZapCount(contentId: string): number {
|
||||||
return getStats(contentId).zapCount ?? 0
|
const discovery = getStats(contentId).zapCount ?? 0
|
||||||
|
const backend = backendZapStats.value[contentId]?.zapCount ?? 0
|
||||||
|
return discovery + backend
|
||||||
}
|
}
|
||||||
|
|
||||||
function getZapperPubkeys(contentId: string): string[] {
|
function getZapperPubkeys(contentId: string): string[] {
|
||||||
return getStats(contentId).recentZapperPubkeys ?? []
|
const discovery = getStats(contentId).recentZapperPubkeys ?? []
|
||||||
|
const backend = backendZapStats.value[contentId]?.recentZapperPubkeys ?? []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const merged: string[] = []
|
||||||
|
for (const pk of [...discovery, ...backend]) {
|
||||||
|
if (seen.has(pk) || merged.length >= 5) continue
|
||||||
|
seen.add(pk)
|
||||||
|
merged.push(pk)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
function zapperAvatarUrl(pubkey: string): string {
|
function zapperAvatarUrl(pubkey: string): string {
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ import { ref, computed, watch, onUnmounted } from 'vue'
|
|||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import type { Content } from '../types/content'
|
import type { Content } from '../types/content'
|
||||||
import { indeehubApiService } from '../services/indeehub-api.service'
|
import { indeehubApiService } from '../services/indeehub-api.service'
|
||||||
|
import { useAuth } from '../composables/useAuth'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -244,6 +245,8 @@ const creatorName = ref<string | null>(null)
|
|||||||
const noCreator = ref(false)
|
const noCreator = ref(false)
|
||||||
const successQuote = ref('')
|
const successQuote = ref('')
|
||||||
|
|
||||||
|
const { nostrPubkey } = useAuth()
|
||||||
|
|
||||||
// Creator id for backend zap invoice (we pay user → BTCPay → we pay creator)
|
// Creator id for backend zap invoice (we pay user → BTCPay → we pay creator)
|
||||||
const ownerFilmmakerId = ref<string | null>(null)
|
const ownerFilmmakerId = ref<string | null>(null)
|
||||||
const zapInvoiceId = ref<string | null>(null)
|
const zapInvoiceId = ref<string | null>(null)
|
||||||
@@ -436,6 +439,7 @@ async function handleZap() {
|
|||||||
projectId: props.content.id,
|
projectId: props.content.id,
|
||||||
filmmakerId: ownerFilmmakerId.value,
|
filmmakerId: ownerFilmmakerId.value,
|
||||||
amountSats,
|
amountSats,
|
||||||
|
...(nostrPubkey.value ? { zapperPubkey: nostrPubkey.value } : {}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ function getTagValue(event: NostrEvent, tagName: string): string | undefined {
|
|||||||
return event.tags.find((t) => t[0] === tagName)?.[1]
|
return event.tags.find((t) => t[0] === tagName)?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** If externalId is a URL like .../content/ID, return ID so we can index by both. */
|
||||||
|
function bareContentIdFromExternal(externalId: string): string | null {
|
||||||
|
const match = externalId.match(/\/content\/([^/]+)$/)
|
||||||
|
return match ? match[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild the stats map from current EventStore data
|
* Rebuild the stats map from current EventStore data
|
||||||
*/
|
*/
|
||||||
@@ -98,6 +104,9 @@ function rebuildStats() {
|
|||||||
recentEvents: [],
|
recentEvents: [],
|
||||||
}
|
}
|
||||||
map.set(id, stats)
|
map.set(id, stats)
|
||||||
|
// Index by bare content id too (e.g. project id) so getStats(content.id) finds it
|
||||||
|
const bare = bareContentIdFromExternal(id)
|
||||||
|
if (bare && bare !== id) map.set(bare, stats)
|
||||||
}
|
}
|
||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
@@ -279,10 +288,11 @@ export function useContentDiscovery() {
|
|||||||
|
|
||||||
const statsMap = contentStatsMap.value
|
const statsMap = contentStatsMap.value
|
||||||
|
|
||||||
// Build entries array: [Content, stats] for each content item
|
// Build entries array: [Content, stats] for each content item (same dual-key as getStats)
|
||||||
const withStats: [Content, ContentStats][] = contents.map((c) => {
|
const withStats: [Content, ContentStats][] = contents.map((c) => {
|
||||||
const externalId = getExternalContentId(c.id)
|
const externalId = getExternalContentId(c.id)
|
||||||
return [c, statsMap.get(externalId) || EMPTY_STATS]
|
const stats = statsMap.get(externalId) || statsMap.get(c.id) || EMPTY_STATS
|
||||||
|
return [c, stats]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort entries
|
// Sort entries
|
||||||
@@ -316,11 +326,16 @@ export function useContentDiscovery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get stats for a specific content item
|
* Get stats for a specific content item (Nostr discovery).
|
||||||
|
* Lookup by full external URL and by bare content id so relay data matches either format.
|
||||||
*/
|
*/
|
||||||
function getStats(contentId: string): ContentStats {
|
function getStats(contentId: string): ContentStats {
|
||||||
const externalId = getExternalContentId(contentId)
|
const externalId = getExternalContentId(contentId)
|
||||||
return contentStatsMap.value.get(externalId) || EMPTY_STATS
|
return (
|
||||||
|
contentStatsMap.value.get(externalId) ||
|
||||||
|
contentStatsMap.value.get(contentId) ||
|
||||||
|
EMPTY_STATS
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -204,6 +204,19 @@ class IndeehubApiService {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get zap stats for film cards (count, amount, recent zapper pubkeys) by project id.
|
||||||
|
*/
|
||||||
|
async getZapStats(projectIds: string[]): Promise<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>> {
|
||||||
|
if (projectIds.length === 0) return {}
|
||||||
|
const ids = projectIds.join(',')
|
||||||
|
const response = await this.client.get<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>>(
|
||||||
|
'/zaps/stats',
|
||||||
|
{ params: { projectIds: ids } },
|
||||||
|
)
|
||||||
|
return response.data ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the CDN URL for a storage path
|
* Get the CDN URL for a storage path
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user