diff --git a/apps/api/package.json b/apps/api/package.json index 42c672c..6c7b65d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,7 +6,7 @@ "private": true, "license": "UNLICENSED", "scripts": { - "build": "prisma generate && nest build && cp generated/prisma/package.json dist/generated/prisma/package.json", + "build": "prisma generate && nest build && node -e \"const fs=require('fs'); const source=['generated/prisma/package.json','node_modules/.prisma/client/package.json','node_modules/@prisma/client/package.json'].find((file)=>fs.existsSync(file)); if(source){ fs.mkdirSync('dist/generated/prisma',{recursive:true}); fs.copyFileSync(source,'dist/generated/prisma/package.json'); }\"", "prisma:generate": "prisma generate", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", diff --git a/apps/api/prisma/migrations/20260418090000_add_notifications/migration.sql b/apps/api/prisma/migrations/20260418090000_add_notifications/migration.sql new file mode 100644 index 0000000..27c6789 --- /dev/null +++ b/apps/api/prisma/migrations/20260418090000_add_notifications/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "read" BOOLEAN NOT NULL DEFAULT false, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Notification_merchantId_createdAt_idx" ON "Notification"("merchantId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260601115707/migration.sql b/apps/api/prisma/migrations/20260601115707/migration.sql new file mode 100644 index 0000000..edcfb1a --- /dev/null +++ b/apps/api/prisma/migrations/20260601115707/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "Payment" DROP CONSTRAINT "Payment_quoteId_fkey"; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_quoteId_fkey" FOREIGN KEY ("quoteId") REFERENCES "Quote"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c354450..53d7fbe 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -15,51 +15,52 @@ datasource db { // ── Auth & Merchants ───────────────────────────────────────── model Merchant { - id String @id @default(cuid()) - name String - email String @unique - passwordHash String - emailVerifiedAt DateTime? + id String @id @default(cuid()) + name String + email String @unique + passwordHash String + emailVerifiedAt DateTime? verificationCodeHash String? verificationCodeExpiresAt DateTime? passwordResetTokenHash String? passwordResetExpiresAt DateTime? - apiKeyHash String? @unique // deprecated – use ApiKey model - webhookUrl String? - webhookSecret String? - settlementAsset String @default("USDC") - settlementAddress String? // Stellar G... address OR EVM 0x... - settlementChain String @default("stellar") - kybStatus KybStatus @default(PENDING) - kybData Json? - feeBps Int @default(50) // 0.5% - logoUrl String? - brandColor String? - customDomain String? - companyName String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - payments Payment[] - payouts Payout[] - invoices Invoice[] - paymentLinks PaymentLink[] - teamMembers TeamMember[] - webhookEvents WebhookEvent[] - apiKeys ApiKey[] - settlementKey MerchantSettlementKey? - recipients Recipient[] + apiKeyHash String? @unique // deprecated – use ApiKey model + webhookUrl String? + webhookSecret String? + settlementAsset String @default("USDC") + settlementAddress String? // Stellar G... address OR EVM 0x... + settlementChain String @default("stellar") + kybStatus KybStatus @default(PENDING) + kybData Json? + feeBps Int @default(50) // 0.5% + logoUrl String? + brandColor String? + customDomain String? + companyName String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + payments Payment[] + payouts Payout[] + invoices Invoice[] + paymentLinks PaymentLink[] + teamMembers TeamMember[] + webhookEvents WebhookEvent[] + apiKeys ApiKey[] + notifications Notification[] + settlementKey MerchantSettlementKey? + recipients Recipient[] } model ApiKey { - id String @id @default(cuid()) + id String @id @default(cuid()) merchantId String name String - keyHash String @unique + keyHash String @unique maskedKey String // e.g. "ur_live_abc1...f9e2" mode ApiKeyMode @default(LIVE) lastUsedAt DateTime? - createdAt DateTime @default(now()) - merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade) @@index([merchantId]) } @@ -87,17 +88,17 @@ model TeamMember { /// bring-your-own); we keep the row for the audit trail but the seed /// fields may be NULL. model MerchantSettlementKey { - id String @id @default(cuid()) - merchantId String @unique - stellarAddress String @unique - encryptedSeed String? - iv String? - authTag String? - managed Boolean @default(true) - rotatedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + merchantId String @unique + stellarAddress String @unique + encryptedSeed String? + iv String? + authTag String? + managed Boolean @default(true) + rotatedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade) } enum KybStatus { @@ -290,27 +291,9 @@ enum InvoiceStatus { // ── Payouts ─────────────────────────────────────────────────── -model Recipient { - id String @id @default(cuid()) - merchantId String - name String - type DestType - details Json // validated per type: {accountNumber, routingNumber?, ...} - isDefault Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade) - payouts Payout[] - - @@unique([merchantId, name]) - @@index([merchantId]) - @@index([type]) -} - model Payout { id String @id @default(cuid()) merchantId String - recipientId String? // optional ref to saved Recipient recipientName String destinationType DestType destination Json // {type, account, routing, ...} @@ -325,13 +308,11 @@ model Payout { idempotencyKey String? @unique createdAt DateTime @default(now()) merchant Merchant @relation(fields: [merchantId], references: [id]) - recipient Recipient? @relation(fields: [recipientId], references: [id]) @@index([merchantId]) @@index([status]) @@index([batchId]) @@index([createdAt]) - @@index([recipientId]) } enum DestType { @@ -376,3 +357,17 @@ enum WebhookStatus { FAILED EXHAUSTED } + +model Notification { + id String @id @default(cuid()) + merchantId String + type String + title String + body String + read Boolean @default(false) + metadata Json? + createdAt DateTime @default(now()) + merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade) + + @@index([merchantId, createdAt]) +} diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index b218300..f5459ce 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -205,6 +205,10 @@ export class AuthService { }, }); + void this.notifications + .notifyApiKeyCreated(merchantId, apiKey.id, apiKey.name) + .catch(() => undefined); + return { apiKey: plainTextKey, id: apiKey.id, diff --git a/apps/api/src/modules/events/events/events.service.ts b/apps/api/src/modules/events/events/events.service.ts index bb92424..0f5970d 100644 --- a/apps/api/src/modules/events/events/events.service.ts +++ b/apps/api/src/modules/events/events/events.service.ts @@ -9,6 +9,15 @@ interface WebhookEventData { createdAt: Date | string; } +interface NotificationCreatedPayload { + id: string; + type: string; + title: string; + body: string; + metadata?: unknown; + createdAt: Date | string; +} + /** * Events Service * Handles real-time event emission to connected WebSocket clients @@ -242,4 +251,33 @@ export class EventsService { ); } } + + emitNotificationCreated( + merchantId: string, + notification: NotificationCreatedPayload, + ): void { + try { + const normalizedPayload = { + id: notification.id, + type: notification.type, + title: notification.title, + body: notification.body, + metadata: notification.metadata, + createdAt: new Date(notification.createdAt).toISOString(), + }; + + this.gateway.server + .to(`merchant:${merchantId}`) + .emit('notification.created', normalizedPayload); + + this.gateway.server.to(`merchant:${merchantId}`).emit('message', { + event: 'notification.created', + data: normalizedPayload, + }); + } catch (error) { + this.logger.error( + `Failed to emit notification.created: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } } diff --git a/apps/api/src/modules/invoices/invoices.service.ts b/apps/api/src/modules/invoices/invoices.service.ts index 01b29e9..cd75bc2 100644 --- a/apps/api/src/modules/invoices/invoices.service.ts +++ b/apps/api/src/modules/invoices/invoices.service.ts @@ -510,6 +510,10 @@ export class InvoicesService { }); if (newStatus === InvoiceStatus.PAID) { + void this.notifications + .notifyInvoicePaid(merchantId, id, existing.invoiceNumber) + .catch(() => undefined); + await this.webhooks.dispatch(merchantId, 'invoice.paid', { invoiceId: id, customerEmail: existing.customerEmail, diff --git a/apps/api/src/modules/merchant/merchant.module.ts b/apps/api/src/modules/merchant/merchant.module.ts index af33273..b1d285d 100644 --- a/apps/api/src/modules/merchant/merchant.module.ts +++ b/apps/api/src/modules/merchant/merchant.module.ts @@ -3,11 +3,12 @@ import { AuthModule } from '../auth/auth.module'; import { PrismaModule } from '../prisma/prisma.module'; import { MerchantController } from './merchant.controller'; import { MerchantService } from './merchant.service'; +import { NotificationsModule } from '../notifications/notifications.module'; import { MerchantSettlementService } from './merchant-settlement.service'; import { RolesGuard } from './guards/roles.guard'; @Module({ - imports: [AuthModule, PrismaModule], + imports: [AuthModule, PrismaModule, NotificationsModule], controllers: [MerchantController], providers: [MerchantService, MerchantSettlementService, RolesGuard], // Export MerchantSettlementService so AuthService can call .provision() diff --git a/apps/api/src/modules/merchant/merchant.service.ts b/apps/api/src/modules/merchant/merchant.service.ts index 26f5725..7205c6f 100644 --- a/apps/api/src/modules/merchant/merchant.service.ts +++ b/apps/api/src/modules/merchant/merchant.service.ts @@ -10,11 +10,15 @@ import { SettlementDto } from './dto/settlement.dto'; import { BrandingDto } from './dto/branding.dto'; import { InviteMemberDto } from './dto/invite-member.dto'; import { KybSubmissionDto } from './dto/kyb-submission.dto'; +import { NotificationsService } from '../notifications/notifications.service'; import { detectAddressChain, type Chain } from '@useroutr/types'; @Injectable() export class MerchantService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + ) {} // ── Profile ────────────────────────────────────────────────── @@ -138,7 +142,9 @@ export class MerchantService { }, }); - // TODO: send invite email via NotificationsModule once implemented + void this.notifications + .notifyTeamMemberJoined(merchantId, member.email, member.role) + .catch(() => undefined); return member; } diff --git a/apps/api/src/modules/notifications/notifications.controller.ts b/apps/api/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..37d69d6 --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,47 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { CurrentMerchant } from '../merchant/decorators/current-merchant.decorator'; +import { NotificationsService } from './notifications.service'; + +@Controller('v1/notifications') +@UseGuards(JwtAuthGuard) +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + @Get() + async listNotifications( + @CurrentMerchant('id') merchantId: string, + @Query('limit') limit?: string, + @Query('cursor') cursor?: string, + ) { + return this.notificationsService.listNotifications(merchantId, { + limit: limit ? Number(limit) : undefined, + cursor, + }); + } + + @Patch(':id/read') + @HttpCode(HttpStatus.OK) + async markAsRead( + @CurrentMerchant('id') merchantId: string, + @Param('id') notificationId: string, + ) { + return this.notificationsService.markAsRead(merchantId, notificationId); + } + + @Post('mark-all-read') + @HttpCode(HttpStatus.OK) + async markAllAsRead(@CurrentMerchant('id') merchantId: string) { + return this.notificationsService.markAllAsRead(merchantId); + } +} diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts index eda5d50..d31052f 100644 --- a/apps/api/src/modules/notifications/notifications.module.ts +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -1,16 +1,22 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { BullModule } from '@nestjs/bullmq'; +import { PrismaModule } from '../prisma/prisma.module'; +import { EventsModule } from '../events/events.module'; +import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.service'; import { NotificationsProcessor } from './notifications.processor'; @Module({ imports: [ ConfigModule, + PrismaModule, + EventsModule, BullModule.registerQueue({ name: 'notifications', }), ], + controllers: [NotificationsController], providers: [NotificationsService, NotificationsProcessor], exports: [NotificationsService], }) diff --git a/apps/api/src/modules/notifications/notifications.service.ts b/apps/api/src/modules/notifications/notifications.service.ts index ceebe19..7284815 100644 --- a/apps/api/src/modules/notifications/notifications.service.ts +++ b/apps/api/src/modules/notifications/notifications.service.ts @@ -1,10 +1,49 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectQueue } from '@nestjs/bullmq'; import { ConfigService } from '@nestjs/config'; +import { Notification, Prisma } from '@prisma/client'; import { Queue } from 'bullmq'; -import { EmailJobData, Invoice, Payment, Payout } from './types'; +import { EventsService } from '../events/events/events.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + EmailJobData, + Invoice as EmailInvoice, + Payment as EmailPayment, + Payout as EmailPayout, +} from './types'; import * as templates from './templates'; +interface NotificationListOptions { + limit?: number; + cursor?: string; +} + +interface CreateNotificationInput { + merchantId: string; + type: string; + title: string; + body: string; + metadata?: Prisma.InputJsonValue | null; +} + +function formatCurrency(amount: number | string, currency = 'USD'): string { + const numericAmount = typeof amount === 'number' ? amount : Number(amount); + + if (!Number.isFinite(numericAmount)) { + return String(amount); + } + + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + maximumFractionDigits: 2, + }).format(numericAmount); + } catch { + return `${numericAmount.toFixed(2)} ${currency}`; + } +} + @Injectable() export class NotificationsService { private readonly appUrl: string; @@ -13,6 +52,8 @@ export class NotificationsService { @InjectQueue('notifications') private readonly notificationsQueue: Queue, private readonly configService: ConfigService, + private readonly prisma: PrismaService, + private readonly eventsService: EventsService, ) { this.appUrl = this.configService.get( 'FRONTEND_URL', @@ -34,6 +75,249 @@ export class NotificationsService { }); } + async createNotification( + input: CreateNotificationInput, + ): Promise { + const notification = await this.prisma.notification.create({ + data: { + merchantId: input.merchantId, + type: input.type, + title: input.title, + body: input.body, + ...(input.metadata !== undefined + ? { metadata: input.metadata ?? Prisma.JsonNull } + : {}), + }, + }); + + this.eventsService.emitNotificationCreated(input.merchantId, { + id: notification.id, + type: notification.type, + title: notification.title, + body: notification.body, + metadata: notification.metadata ?? undefined, + createdAt: notification.createdAt, + }); + + return notification; + } + + async listNotifications( + merchantId: string, + options: NotificationListOptions = {}, + ) { + const take = Math.min(Math.max(options.limit ?? 50, 1), 50); + + let createdAtCursor: Date | undefined; + if (options.cursor) { + const cursorItem = await this.prisma.notification.findFirst({ + where: { + id: options.cursor, + merchantId, + }, + select: { createdAt: true }, + }); + + createdAtCursor = cursorItem?.createdAt; + } + + const where: Prisma.NotificationWhereInput = { + merchantId, + ...(createdAtCursor ? { createdAt: { lt: createdAtCursor } } : {}), + }; + + const [items, unreadCount, total] = await Promise.all([ + this.prisma.notification.findMany({ + where, + take, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }), + this.prisma.notification.count({ + where: { + merchantId, + read: false, + }, + }), + this.prisma.notification.count({ where: { merchantId } }), + ]); + + return { + data: items, + meta: { + total, + limit: take, + unreadCount, + nextCursor: items.length === take ? items[items.length - 1]?.id : null, + }, + }; + } + + async markAsRead(merchantId: string, notificationId: string) { + const existing = await this.prisma.notification.findFirst({ + where: { id: notificationId, merchantId }, + }); + + if (!existing) { + throw new NotFoundException('Notification not found'); + } + + if (existing.read) { + return existing; + } + + return this.prisma.notification.update({ + where: { id: notificationId }, + data: { read: true }, + }); + } + + async markAllAsRead(merchantId: string) { + const result = await this.prisma.notification.updateMany({ + where: { + merchantId, + read: false, + }, + data: { read: true }, + }); + + return { + updatedCount: result.count, + }; + } + + async notifyPaymentReceived( + merchantId: string, + paymentId: string, + amount: number | string, + currency = 'USD', + customerEmail?: string, + ) { + return this.createNotification({ + merchantId, + type: 'payment.received', + title: 'Payment received', + body: `${formatCurrency(amount, currency)}${customerEmail ? ` from ${customerEmail}` : ''}`, + metadata: { + paymentId, + amount: String(amount), + currency, + ...(customerEmail ? { customerEmail } : {}), + }, + }); + } + + async notifyPayoutCompleted( + merchantId: string, + payoutId: string, + recipientName: string, + ) { + return this.createNotification({ + merchantId, + type: 'payout.completed', + title: 'Payout completed', + body: `Payout to ${recipientName} completed`, + metadata: { + payoutId, + recipientName, + }, + }); + } + + async notifyPayoutFailed( + merchantId: string, + payoutId: string, + recipientName: string, + failureReason?: string, + ) { + return this.createNotification({ + merchantId, + type: 'payout.failed', + title: 'Payout failed', + body: failureReason + ? `Payout to ${recipientName} failed: ${failureReason}` + : `Payout to ${recipientName} failed`, + metadata: { + payoutId, + recipientName, + ...(failureReason ? { failureReason } : {}), + }, + }); + } + + async notifyInvoicePaid( + merchantId: string, + invoiceId: string, + invoiceNumber?: string | null, + ) { + return this.createNotification({ + merchantId, + type: 'invoice.paid', + title: 'Invoice paid', + body: `Invoice ${invoiceNumber ? `#${invoiceNumber}` : ''} marked as paid`.trim(), + metadata: { + invoiceId, + ...(invoiceNumber ? { invoiceNumber } : {}), + }, + }); + } + + async notifyRefundInitiated(merchantId: string, paymentId: string) { + return this.createNotification({ + merchantId, + type: 'refund.initiated', + title: 'Refund initiated', + body: 'A refund has been started for one of your payments.', + metadata: { paymentId }, + }); + } + + async notifyTeamMemberJoined( + merchantId: string, + email: string, + role: string, + ) { + return this.createNotification({ + merchantId, + type: 'team.member_joined', + title: 'Team member joined', + body: `${email} was added to your team as ${role.toLowerCase()}.`, + metadata: { email, role }, + }); + } + + async notifyApiKeyCreated( + merchantId: string, + keyId: string, + keyName: string, + ) { + return this.createNotification({ + merchantId, + type: 'api_key.created', + title: 'API key created', + body: `API key '${keyName}' was created successfully.`, + metadata: { apiKeyId: keyId, keyName }, + }); + } + + async notifyWebhookFailed( + merchantId: string, + webhookUrl: string, + eventType?: string, + eventId?: string, + ) { + return this.createNotification({ + merchantId, + type: 'webhook.failed', + title: 'Webhook failed', + body: `Webhook delivery to ${webhookUrl} failed`, + metadata: { + webhookUrl, + ...(eventType ? { eventType } : {}), + ...(eventId ? { eventId } : {}), + }, + }); + } + // Auth emails async sendVerificationEmail(email: string, token: string): Promise { await this.dispatch({ @@ -75,7 +359,7 @@ export class NotificationsService { // Payments async sendPaymentReceipt( customerEmail: string, - payment: Payment, + payment: EmailPayment, ): Promise { await this.dispatch({ to: customerEmail, @@ -86,7 +370,7 @@ export class NotificationsService { async sendPaymentNotification( merchantEmail: string, - payment: Payment, + payment: EmailPayment, ): Promise { await this.dispatch({ to: merchantEmail, @@ -98,7 +382,7 @@ export class NotificationsService { // Invoices async sendInvoice( customerEmail: string, - invoice: Invoice, + invoice: EmailInvoice, pdfBuffer: Buffer, ): Promise { await this.dispatch({ @@ -116,7 +400,7 @@ export class NotificationsService { async sendInvoiceReminder( customerEmail: string, - invoice: Invoice, + invoice: EmailInvoice, ): Promise { const now = new Date(); const diff = Math.ceil( @@ -139,7 +423,7 @@ export class NotificationsService { // Payouts async sendPayoutConfirmation( merchantEmail: string, - payout: Payout, + payout: EmailPayout, ): Promise { await this.dispatch({ to: merchantEmail, diff --git a/apps/api/src/modules/payments/payments.module.ts b/apps/api/src/modules/payments/payments.module.ts index cca9527..96d9e3d 100644 --- a/apps/api/src/modules/payments/payments.module.ts +++ b/apps/api/src/modules/payments/payments.module.ts @@ -13,6 +13,7 @@ import { QuotesModule } from '../quotes/quotes.module'; import { WebhooksModule } from '../webhooks/webhooks.module'; import { StripeWebhooksController } from '../webhooks/webhooks.controller'; import { AuthModule } from '../auth/auth.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { LinksModule } from '../links/links.module'; import { CctpModule } from '../cctp/cctp.module'; @@ -36,6 +37,7 @@ import { CctpModule } from '../cctp/cctp.module'; // (not CctpModule) so the processor can inject PaymentsService without // a module-level circular dependency. BullModule.registerQueue({ name: CCTP_OBSERVE_QUEUE }), + NotificationsModule, ], providers: [PaymentsService, CctpProcessor], controllers: [ diff --git a/apps/api/src/modules/payments/payments.service.ts b/apps/api/src/modules/payments/payments.service.ts index 94376b6..de36842 100644 --- a/apps/api/src/modules/payments/payments.service.ts +++ b/apps/api/src/modules/payments/payments.service.ts @@ -40,6 +40,7 @@ import { ethers } from 'ethers'; import { CreatePaymentDto } from './dto/create-payment.dto'; import { PaymentFiltersDto } from './dto/payment-filters.dto'; import { PaymentResponseDto } from './dto/payment-response.dto'; +import { NotificationsService } from '../notifications/notifications.service'; import * as crypto from 'crypto'; interface CheckoutLineItem { @@ -151,6 +152,7 @@ export class PaymentsService implements OnModuleInit { private readonly cctpService: CctpService, @InjectQueue(CCTP_OBSERVE_QUEUE) private readonly cctpQueue: Queue, private readonly configService: ConfigService, + private readonly notificationsService: NotificationsService, ) { const secretKey = this.configService.get('STRIPE_SECRET_KEY'); this.stripe = secretKey ? new Stripe(secretKey) : null; @@ -208,6 +210,37 @@ export class PaymentsService implements OnModuleInit { updatedPayment.id, ); + const metadata = + updatedPayment.metadata && typeof updatedPayment.metadata === 'object' + ? (updatedPayment.metadata as Record) + : {}; + const customerEmail = + typeof metadata.customerEmail === 'string' + ? metadata.customerEmail + : typeof metadata.email === 'string' + ? metadata.email + : undefined; + + if (status === PaymentStatus.COMPLETED) { + const receivedAmount = updatedPayment.destAmount?.toString() ?? '0'; + + void this.notificationsService + .notifyPaymentReceived( + updatedPayment.merchantId, + updatedPayment.id, + receivedAmount, + updatedPayment.destAsset || 'USD', + customerEmail, + ) + .catch(() => undefined); + } + + if (status === PaymentStatus.REFUNDING) { + void this.notificationsService + .notifyRefundInitiated(updatedPayment.merchantId, updatedPayment.id) + .catch(() => undefined); + } + return updatedPayment; } @@ -391,9 +424,13 @@ export class PaymentsService implements OnModuleInit { const payment = await this.prisma.payment.create({ data: { merchantId: internal.merchant.id, + quoteId: null, status: PaymentStatus.PENDING, // Source fields stay null until the customer picks a method. // Quote stays null too — created when the method is chosen. + sourceChain: null, + sourceAsset: null, + sourceAmount: null, destChain: internal.merchant.settlementChain, destAsset: internal.merchant.settlementAsset, destAmount, diff --git a/apps/api/src/modules/payouts/payouts.module.ts b/apps/api/src/modules/payouts/payouts.module.ts index b3be4b6..2e36b3c 100644 --- a/apps/api/src/modules/payouts/payouts.module.ts +++ b/apps/api/src/modules/payouts/payouts.module.ts @@ -6,9 +6,10 @@ import { WebhooksModule } from '../webhooks/webhooks.module'; import { StellarModule } from '../stellar/stellar.module'; import { AuthModule } from '../auth/auth.module'; import { EventsModule } from '../events/events.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [PrismaModule, WebhooksModule, StellarModule, AuthModule, EventsModule], + imports: [PrismaModule, WebhooksModule, StellarModule, AuthModule, EventsModule, NotificationsModule], providers: [PayoutsService], controllers: [PayoutsController], exports: [PayoutsService], diff --git a/apps/api/src/modules/payouts/payouts.service.ts b/apps/api/src/modules/payouts/payouts.service.ts index 502cd51..72e4d3d 100644 --- a/apps/api/src/modules/payouts/payouts.service.ts +++ b/apps/api/src/modules/payouts/payouts.service.ts @@ -10,6 +10,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { StellarService } from '../stellar/stellar.service'; import { EventsService } from '../events/events/events.service'; +import { NotificationsService } from '../notifications/notifications.service'; import { CreatePayoutDto, BulkPayoutDto } from './dto/create-payout.dto'; import { PayoutFiltersDto } from './dto/payout-filters.dto'; import { randomUUID } from 'crypto'; @@ -43,7 +44,8 @@ export class PayoutsService { private readonly prisma: PrismaService, private readonly webhooks: WebhooksService, private readonly stellar: StellarService, - private readonly events: EventsService, + private readonly eventsService: EventsService, + private readonly notifications: NotificationsService, ) {} // ── Create single payout ────────────────────────────────────────────────── @@ -80,7 +82,7 @@ async create( ...dto, recipientName: recipient.name, destinationType: recipient.type, - destination: recipient.details as Prisma.InputJsonValue, + destination: recipient.details as unknown as CreatePayoutDto['destination'], }; } @@ -102,7 +104,6 @@ async create( this.webhooks .dispatch(merchantId, 'payout.initiated', this.webhookPayload(payout) as Prisma.InputJsonValue) .catch(() => undefined); - this.emitPayoutStatus(payout); // Process immediately unless scheduled for the future if (!payout.scheduledAt || payout.scheduledAt <= new Date()) { @@ -137,7 +138,7 @@ async createBulk(merchantId: string, dto: BulkPayoutDto): Promise undefined); @@ -262,7 +262,6 @@ async createBulk(merchantId: string, dto: BulkPayoutDto): Promise undefined); - this.emitPayoutStatus(reset); this.processPayout(reset).catch(() => undefined); @@ -272,11 +271,10 @@ async createBulk(merchantId: string, dto: BulkPayoutDto): Promise { - const processing = await this.prisma.payout.update({ + await this.prisma.payout.update({ where: { id: payout.id }, data: { status: PayoutStatus.PROCESSING }, }); - this.emitPayoutStatus(processing); try { const destination = payout.destination as Record; @@ -298,13 +296,31 @@ async createBulk(merchantId: string, dto: BulkPayoutDto): Promise undefined); this.webhooks .dispatch(payout.merchantId, 'payout.failed', { ...this.webhookPayload(failed), failureReason, } as Prisma.InputJsonValue) .catch(() => undefined); - this.emitPayoutStatus(failed); this.logger.error(`Payout ${payout.id} failed: ${failureReason}`); } } @@ -352,13 +368,31 @@ async createBulk(merchantId: string, dto: BulkPayoutDto): Promise undefined); + this.webhooks .dispatch(payout.merchantId, 'payout.completed', { ...this.webhookPayload(completed), stellarTxHash: txHash, } as Prisma.InputJsonValue) .catch(() => undefined); - this.emitPayoutStatus(completed); } // ── Helpers ─────────────────────────────────────────────────────────────── @@ -396,14 +430,4 @@ private formatResponse(payout: Payout) { createdAt: payout.createdAt.toISOString(), }; } - - private emitPayoutStatus(payout: Payout): void { - this.events.emitPayoutStatus(payout.merchantId, payout.id, payout.status, { - amount: payout.amount.toString(), - currency: payout.currency, - stellarTxHash: payout.stellarTxHash ?? undefined, - failureReason: payout.failureReason ?? undefined, - updatedAt: new Date(), - }); - } } diff --git a/apps/api/src/modules/recipients/dto/create-recipient.dto.ts b/apps/api/src/modules/recipients/dto/create-recipient.dto.ts index 5066f88..84a10c9 100644 --- a/apps/api/src/modules/recipients/dto/create-recipient.dto.ts +++ b/apps/api/src/modules/recipients/dto/create-recipient.dto.ts @@ -50,5 +50,3 @@ export const CreateRecipientSchema = z.object({ export type CreateRecipientDto = z.infer; -CreateRecipientDto.schema = CreateRecipientSchema; - diff --git a/apps/api/src/modules/recipients/dto/recipient-filters.dto.ts b/apps/api/src/modules/recipients/dto/recipient-filters.dto.ts index 8c45158..e31add9 100644 --- a/apps/api/src/modules/recipients/dto/recipient-filters.dto.ts +++ b/apps/api/src/modules/recipients/dto/recipient-filters.dto.ts @@ -11,5 +11,3 @@ export const RecipientFiltersSchema = z.object({ export type RecipientFiltersDto = z.infer; -RecipientFiltersDto.schema = RecipientFiltersSchema; - diff --git a/apps/api/src/modules/recipients/dto/update-recipient.dto.ts b/apps/api/src/modules/recipients/dto/update-recipient.dto.ts index dc0e378..c98a21c 100644 --- a/apps/api/src/modules/recipients/dto/update-recipient.dto.ts +++ b/apps/api/src/modules/recipients/dto/update-recipient.dto.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { CreateRecipientDto } from './create-recipient.dto'; +import { CreateRecipientSchema } from './create-recipient.dto'; -export const UpdateRecipientSchema = CreateRecipientDto.schema.partial(); +export const UpdateRecipientSchema = CreateRecipientSchema.partial(); export type UpdateRecipientDto = z.infer; diff --git a/apps/api/src/modules/recipients/recipients.controller.ts b/apps/api/src/modules/recipients/recipients.controller.ts index 1230e30..c3d8b81 100644 --- a/apps/api/src/modules/recipients/recipients.controller.ts +++ b/apps/api/src/modules/recipients/recipients.controller.ts @@ -12,8 +12,8 @@ import { UseGuards, } from '@nestjs/common'; import { RecipientsService } from './recipients.service'; -import { CreateRecipientDto } from './dto/create-recipient.dto'; -import { RecipientFiltersDto } from './dto/recipient-filters.dto'; +import { CreateRecipientDto, CreateRecipientSchema } from './dto/create-recipient.dto'; +import { RecipientFiltersDto, RecipientFiltersSchema } from './dto/recipient-filters.dto'; import { UpdateRecipientDto } from './dto/update-recipient.dto'; import { CombinedAuthGuard } from '../../common/guards/combined-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @@ -29,7 +29,7 @@ export class RecipientsController { @HttpCode(HttpStatus.CREATED) async create( @CurrentMerchant('id') merchantId: string, - @Body(new ZodValidationPipe(CreateRecipientDto.schema)) + @Body(new ZodValidationPipe(CreateRecipientSchema)) dto: CreateRecipientDto, ) { return this.recipientsService.create(merchantId, dto); @@ -39,7 +39,7 @@ export class RecipientsController { @UseGuards(JwtAuthGuard) async list( @CurrentMerchant('id') merchantId: string, - @Query(new ZodValidationPipe(RecipientFiltersDto.schema)) + @Query(new ZodValidationPipe(RecipientFiltersSchema)) filters: RecipientFiltersDto, ) { return this.recipientsService.list(merchantId, filters); diff --git a/apps/api/src/modules/webhooks/webhooks.module.ts b/apps/api/src/modules/webhooks/webhooks.module.ts index a1e10ab..5a1532f 100644 --- a/apps/api/src/modules/webhooks/webhooks.module.ts +++ b/apps/api/src/modules/webhooks/webhooks.module.ts @@ -4,11 +4,13 @@ import { WebhooksService } from './webhooks.service'; import { WebhooksProcessor } from './webhooks.processor'; import { WebhooksController } from './webhooks.controller'; import { PrismaModule } from '../prisma/prisma.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { WEBHOOK_QUEUE_NAME } from './webhooks.constants'; @Module({ imports: [ PrismaModule, + NotificationsModule, BullModule.registerQueue({ name: WEBHOOK_QUEUE_NAME }), ], providers: [WebhooksService, WebhooksProcessor], diff --git a/apps/api/src/modules/webhooks/webhooks.processor.ts b/apps/api/src/modules/webhooks/webhooks.processor.ts index 0e574dc..be124be 100644 --- a/apps/api/src/modules/webhooks/webhooks.processor.ts +++ b/apps/api/src/modules/webhooks/webhooks.processor.ts @@ -3,6 +3,7 @@ import { Processor, WorkerHost, InjectQueue } from '@nestjs/bullmq'; import { Job, Queue } from 'bullmq'; import axios, { AxiosError } from 'axios'; import { PrismaService } from '../prisma/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; import { WebhookStatus } from '@prisma/client'; import { WEBHOOK_QUEUE_NAME, @@ -22,6 +23,7 @@ export class WebhooksProcessor extends WorkerHost { constructor( @InjectQueue(WEBHOOK_QUEUE_NAME) private readonly webhookQueue: Queue, private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, ) { super(); } @@ -117,6 +119,12 @@ export class WebhooksProcessor extends WorkerHost { }, }); + if (attempt === 1) { + void this.notifications + .notifyWebhookFailed(merchantId, webhookUrl, eventType, eventId) + .catch(() => undefined); + } + this.logger.log( `Webhook ${eventId} will retry at ${nextRetryAt.toISOString()} (delay: ${delayMs}ms)`, ); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 7561134..7ab7cf6 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "module": "CommonJS", - "moduleResolution": "node", "esModuleInterop": true, "isolatedModules": true, "declaration": true, @@ -12,7 +11,6 @@ "target": "ES2020", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": true, diff --git a/apps/dashboard/src/app/(dashboard)/settings/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/page.tsx index 44c0427..d3385de 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/page.tsx @@ -7,6 +7,7 @@ import { useUpdateMerchantProfile, useProvisionSettlement, } from "@/hooks/useSettings"; +import { useToastNotificationPreference } from "@/hooks/useToastNotificationPreference"; import { motion } from "framer-motion"; import { Building2, @@ -33,6 +34,10 @@ export default function SettingsPage() { const { data: merchant, isLoading: isLoadingProfile } = useMerchantProfile(); const updateProfile = useUpdateMerchantProfile(); + const { + enabled: realtimeNotificationsEnabled, + setEnabled: setRealtimeNotificationsEnabled, + } = useToastNotificationPreference(); const provisionSettlement = useProvisionSettlement(); const [name, setName] = useState(""); @@ -62,6 +67,16 @@ export default function SettingsPage() { ); }; + const handleRealtimeNotificationsToggle = (checked: boolean) => { + setRealtimeNotificationsEnabled(checked); + toast( + checked + ? "Real-time notifications enabled." + : "Real-time notifications disabled.", + checked ? "success" : "info", + ); + }; + if (isLoadingProfile) { return (
@@ -254,6 +269,39 @@ export default function SettingsPage() {
+ + {[ { label: "Email notifications", diff --git a/apps/dashboard/src/components/notifications/NotificationBell.tsx b/apps/dashboard/src/components/notifications/NotificationBell.tsx new file mode 100644 index 0000000..971fa4f --- /dev/null +++ b/apps/dashboard/src/components/notifications/NotificationBell.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useMemo } from "react"; +import { formatDistanceToNowStrict } from "date-fns"; +import { useRouter } from "next/navigation"; +import { + Bell, + CheckCheck, + CreditCard, + ArrowLeftRight, + Receipt, + RotateCcw, + Users, + KeyRound, + Webhook, +} from "lucide-react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + ScrollArea, + cn, +} from "@useroutr/ui"; +import { + DashboardNotification, + useNotifications, +} from "@/providers/NotificationsProvider"; + +function getNotificationHref(notification: DashboardNotification): string { + const metadata = notification.metadata ?? {}; + + switch (notification.type) { + case "payment.received": + return typeof metadata.paymentId === "string" + ? `/payments/${metadata.paymentId}` + : "/payments"; + case "invoice.paid": + return typeof metadata.invoiceId === "string" + ? `/invoices/${metadata.invoiceId}` + : "/invoices"; + case "payout.completed": + case "payout.failed": + return "/payouts"; + case "refund.initiated": + return "/refunds"; + case "team.member_joined": + return "/settings/team"; + case "api_key.created": + return "/settings/api-keys"; + case "webhook.failed": + return "/settings/webhooks"; + default: + return "/settings"; + } +} + +function NotificationTypeIcon({ type }: { type: string }) { + const className = "size-4"; + + switch (type) { + case "payment.received": + return ; + case "invoice.paid": + return ; + case "payout.completed": + return ; + case "payout.failed": + return ; + case "refund.initiated": + return ; + case "team.member_joined": + return ; + case "api_key.created": + return ; + case "webhook.failed": + return ; + default: + return ; + } +} + +export function NotificationBell() { + const router = useRouter(); + const { + notifications, + unreadCount, + isLoading, + highlightedIds, + markAsRead, + markAllAsRead, + } = useNotifications(); + + const unreadLabel = useMemo(() => { + if (unreadCount <= 0) { + return null; + } + + return unreadCount > 9 ? "9+" : String(unreadCount); + }, [unreadCount]); + + const handleNotificationClick = async (notification: DashboardNotification) => { + if (!notification.read) { + await markAsRead(notification.id); + } + + router.push(getNotificationHref(notification)); + }; + + return ( + + + + + + +
+
+

Notifications

+

+ {unreadCount > 0 + ? `${unreadCount} unread update${unreadCount === 1 ? "" : "s"}` + : "You’re all caught up"} +

+
+ + +
+ + +
+ {!isLoading && notifications.length === 0 ? ( +
+
+ +
+

No notifications yet

+

+ New payment, payout, invoice, and webhook activity will appear here. +

+
+ ) : ( + notifications.map((notification) => ( + + )) + )} +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/site-header.tsx b/apps/dashboard/src/components/site-header.tsx index 7dc50d7..996e660 100644 --- a/apps/dashboard/src/components/site-header.tsx +++ b/apps/dashboard/src/components/site-header.tsx @@ -1,7 +1,7 @@ "use client"; import { usePathname } from "next/navigation"; -import { Bell, SidebarIcon } from "lucide-react"; +import { SidebarIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; @@ -14,6 +14,7 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; +import { NotificationBell } from "@/components/notifications/NotificationBell"; import { ThemeToggle } from "@/components/brand/ThemeToggle"; const routeLabels: Record = { @@ -76,9 +77,7 @@ export function SiteHeader() {
- +
diff --git a/apps/dashboard/src/hooks/useDashboardSocket.ts b/apps/dashboard/src/hooks/useDashboardSocket.ts index b718770..2d86cd7 100644 --- a/apps/dashboard/src/hooks/useDashboardSocket.ts +++ b/apps/dashboard/src/hooks/useDashboardSocket.ts @@ -2,38 +2,93 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { io, type Socket } from "socket.io-client"; -import { getToken } from "@/lib/auth"; +import { useAuth } from "@/hooks/useAuth"; const SOCKET_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; +let sharedSocket: Socket | null = null; +let sharedToken: string | null = null; + +function createDashboardSocket(token: string) { + return io(SOCKET_URL, { + transports: ["websocket", "polling"], + autoConnect: true, + reconnection: true, + reconnectionDelay: 1000, + reconnectionAttempts: 10, + query: { + type: "merchant", + token: `Bearer ${token}`, + }, + }); +} + export function useDashboardSocket() { - const socketRef = useRef(null); - const [connected, setConnected] = useState(false); + const { token, merchant } = useAuth(); + const merchantIdRef = useRef(merchant?.id ?? null); + const [connected, setConnected] = useState(Boolean(sharedSocket?.connected)); + + merchantIdRef.current = merchant?.id ?? null; useEffect(() => { - const token = getToken(); - const socket = io(SOCKET_URL, { - transports: ["websocket"], - autoConnect: true, - query: token ? { type: "merchant", token: `Bearer ${token}` } : undefined, - }); + if (!token || !merchant?.id) { + setConnected(false); - socket.on("connect", () => setConnected(true)); - socket.on("disconnect", () => setConnected(false)); + if (sharedSocket) { + sharedSocket.disconnect(); + sharedSocket = null; + sharedToken = null; + } - socketRef.current = socket; + return; + } - return () => { - socket.disconnect(); + if (!sharedSocket || sharedToken !== token) { + sharedSocket?.disconnect(); + sharedSocket = createDashboardSocket(token); + sharedToken = token; + } + + const socket = sharedSocket; + + const handleConnect = () => { + setConnected(true); + socket.emit("subscribe:merchant", merchantIdRef.current); }; - }, []); - const subscribe = useCallback((event: string, callback: (...args: unknown[]) => void) => { - socketRef.current?.on(event, callback); + const handleDisconnect = () => { + setConnected(false); + }; + + socket.on("connect", handleConnect); + socket.on("disconnect", handleDisconnect); + socket.on("connect_error", handleDisconnect); + + if (socket.connected) { + handleConnect(); + } + return () => { - socketRef.current?.off(event, callback); + socket.off("connect", handleConnect); + socket.off("disconnect", handleDisconnect); + socket.off("connect_error", handleDisconnect); }; - }, []); + }, [merchant?.id, token]); + + const subscribe = useCallback( + (event: string, callback: (...args: unknown[]) => void) => { + if (!sharedSocket) { + return () => undefined; + } + + sharedSocket.on(event, callback); + + return () => { + sharedSocket?.off(event, callback); + }; + }, + [], + ); - return { connected, subscribe, socket: socketRef.current }; + return { connected, subscribe, socket: sharedSocket }; } diff --git a/apps/dashboard/src/hooks/useToastNotificationPreference.ts b/apps/dashboard/src/hooks/useToastNotificationPreference.ts new file mode 100644 index 0000000..8c755b6 --- /dev/null +++ b/apps/dashboard/src/hooks/useToastNotificationPreference.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +export const TOAST_NOTIFICATIONS_ENABLED_KEY = "toast_notifications_enabled"; + +function readStoredPreference(): boolean { + if (typeof window === "undefined") { + return true; + } + + const stored = window.localStorage.getItem(TOAST_NOTIFICATIONS_ENABLED_KEY); + return stored === null ? true : stored === "true"; +} + +export function getToastNotificationsEnabled(): boolean { + return readStoredPreference(); +} + +export function useToastNotificationPreference() { + const [enabled, setEnabledState] = useState(true); + + useEffect(() => { + setEnabledState(readStoredPreference()); + }, []); + + const setEnabled = useCallback((value: boolean) => { + setEnabledState(value); + + if (typeof window !== "undefined") { + window.localStorage.setItem( + TOAST_NOTIFICATIONS_ENABLED_KEY, + String(value), + ); + } + }, []); + + return { + enabled, + setEnabled, + }; +} diff --git a/apps/dashboard/src/providers/NotificationsProvider.tsx b/apps/dashboard/src/providers/NotificationsProvider.tsx new file mode 100644 index 0000000..9e19f99 --- /dev/null +++ b/apps/dashboard/src/providers/NotificationsProvider.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { api } from "@/lib/api"; +import { useDashboardSocket } from "@/hooks/useDashboardSocket"; + +export interface DashboardNotification { + id: string; + type: string; + title: string; + body: string; + read: boolean; + metadata?: Record | null; + createdAt: string; +} + +interface NotificationsResponse { + data: DashboardNotification[]; + meta?: { + unreadCount?: number; + total?: number; + nextCursor?: string; + limit?: number; + }; +} + +interface NotificationsContextValue { + notifications: DashboardNotification[]; + unreadCount: number; + isLoading: boolean; + highlightedIds: Set; + markAsRead: (id: string) => Promise; + markAllAsRead: () => Promise; + addNotification: (notification: DashboardNotification) => void; +} + +const NotificationsContext = createContext( + undefined, +); + +const MAX_NOTIFICATIONS = 50; + +function normalizeSocketNotification( + raw: unknown, +): DashboardNotification | null { + if (!raw || typeof raw !== "object") { + return null; + } + + const candidate = raw as Record; + const id = typeof candidate.id === "string" ? candidate.id : undefined; + const type = typeof candidate.type === "string" ? candidate.type : undefined; + const title = + typeof candidate.title === "string" ? candidate.title : undefined; + const body = typeof candidate.body === "string" ? candidate.body : undefined; + const createdAt = + typeof candidate.createdAt === "string" + ? candidate.createdAt + : new Date().toISOString(); + + if (!id || !type || !title || !body) { + return null; + } + + return { + id, + type, + title, + body, + read: false, + metadata: + candidate.metadata && typeof candidate.metadata === "object" + ? (candidate.metadata as Record) + : null, + createdAt, + }; +} + +export function NotificationsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { connected, subscribe } = useDashboardSocket(); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [highlightedIds, setHighlightedIds] = useState>(new Set()); + const highlightTimeoutsRef = useRef>>( + new Map(), + ); + + const highlightNotification = useCallback((id: string) => { + setHighlightedIds((prev) => new Set([...prev, id])); + + const existingTimeout = highlightTimeoutsRef.current.get(id); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + + const timeout = setTimeout(() => { + setHighlightedIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + highlightTimeoutsRef.current.delete(id); + }, 2200); + + highlightTimeoutsRef.current.set(id, timeout); + }, []); + + const addNotification = useCallback( + (notification: DashboardNotification) => { + setNotifications((prev) => { + const alreadyExists = prev.some((item) => item.id === notification.id); + + if (!alreadyExists) { + setUnreadCount((count) => count + 1); + highlightNotification(notification.id); + } + + const next = [notification, ...prev.filter((item) => item.id !== notification.id)]; + return next.slice(0, MAX_NOTIFICATIONS); + }); + }, + [highlightNotification], + ); + + const fetchNotifications = useCallback(async () => { + try { + const response = await api.get("/v1/notifications", { + params: { limit: 50 }, + }); + + setNotifications(response.data ?? []); + setUnreadCount( + response.meta?.unreadCount ?? + (response.data ?? []).filter((item) => !item.read).length, + ); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void fetchNotifications(); + + return () => { + highlightTimeoutsRef.current.forEach((timeout) => clearTimeout(timeout)); + highlightTimeoutsRef.current.clear(); + }; + }, [fetchNotifications]); + + useEffect(() => { + if (!connected) { + return; + } + + const unsubscribeDirect = subscribe("notification.created", (raw) => { + const notification = normalizeSocketNotification(raw); + if (notification) { + addNotification(notification); + } + }); + + const unsubscribeEnvelope = subscribe("message", (raw) => { + const message = raw as { event?: string; data?: unknown }; + + if (message?.event !== "notification.created") { + return; + } + + const notification = normalizeSocketNotification(message.data); + if (notification) { + addNotification(notification); + } + }); + + return () => { + unsubscribeDirect(); + unsubscribeEnvelope(); + }; + }, [addNotification, connected, subscribe]); + + const markAsRead = useCallback(async (id: string) => { + setNotifications((prev) => + prev.map((item) => (item.id === id ? { ...item, read: true } : item)), + ); + setUnreadCount((prev) => Math.max(0, prev - 1)); + + try { + await api.patch(`/v1/notifications/${id}/read`); + } catch { + setNotifications((prev) => + prev.map((item) => (item.id === id ? { ...item, read: false } : item)), + ); + setUnreadCount((prev) => prev + 1); + } + }, []); + + const markAllAsRead = useCallback(async () => { + const previousNotifications = notifications; + const previousUnreadCount = unreadCount; + + setNotifications((prev) => prev.map((item) => ({ ...item, read: true }))); + setUnreadCount(0); + + try { + await api.post("/v1/notifications/mark-all-read"); + } catch { + setNotifications(previousNotifications); + setUnreadCount(previousUnreadCount); + } + }, [notifications, unreadCount]); + + const value = useMemo( + () => ({ + notifications, + unreadCount, + isLoading, + highlightedIds, + markAsRead, + markAllAsRead, + addNotification, + }), + [addNotification, highlightedIds, isLoading, markAllAsRead, markAsRead, notifications, unreadCount], + ); + + return ( + + {children} + + ); +} + +export function useNotifications() { + const context = useContext(NotificationsContext); + + if (!context) { + throw new Error("useNotifications must be used within NotificationsProvider"); + } + + return context; +} diff --git a/apps/dashboard/src/providers/RealtimeToastNotifications.tsx b/apps/dashboard/src/providers/RealtimeToastNotifications.tsx new file mode 100644 index 0000000..f7c45dd --- /dev/null +++ b/apps/dashboard/src/providers/RealtimeToastNotifications.tsx @@ -0,0 +1,415 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { useToast, type ToastVariant } from "@useroutr/ui"; +import { useDashboardSocket } from "@/hooks/useDashboardSocket"; +import { useToastNotificationPreference } from "@/hooks/useToastNotificationPreference"; + +type RealtimeEventName = + | "payment.received" + | "payment.failed" + | "payment_link.paid" + | "invoice.paid" + | "payout.completed" + | "payout.failed" + | "webhook.failed" + | "bulk_payout.completed"; + +interface ToastPayload { + type: ToastVariant; + title?: string; + message: string; + actionLabel?: string; + actionHref?: string; +} + +interface SocketEnvelope { + event?: string; + data?: Record; +} + +function getString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + + return undefined; +} + +function formatAmount(amount?: unknown, currency?: unknown): string { + const numericAmount = + typeof amount === "number" + ? amount + : typeof amount === "string" + ? Number(amount) + : Number.NaN; + + const currencyCode = + typeof currency === "string" && currency.trim().length >= 3 + ? currency.trim().slice(0, 3).toUpperCase() + : "USD"; + + if (!Number.isFinite(numericAmount)) { + return typeof amount === "string" && amount.trim() + ? amount.trim() + : "an amount"; + } + + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currencyCode, + maximumFractionDigits: 2, + }).format(numericAmount); + } catch { + return `${numericAmount.toLocaleString()} ${currencyCode}`; + } +} + +function getDomainLabel(value?: string): string { + if (!value) { + return "your webhook endpoint"; + } + + try { + return new URL(value).hostname; + } catch { + return value; + } +} + +function resolveEvent( + eventName: string, + payload: Record, +): { name: RealtimeEventName; payload: Record } | null { + if ( + [ + "payment.received", + "payment.failed", + "payment_link.paid", + "invoice.paid", + "payout.completed", + "payout.failed", + "webhook.failed", + "bulk_payout.completed", + ].includes(eventName) + ) { + return { + name: eventName as RealtimeEventName, + payload, + }; + } + + if (eventName === "payment-link.payment") { + return { + name: "payment_link.paid", + payload, + }; + } + + if (eventName === "payment:status") { + const status = String(payload.status ?? "").toLowerCase(); + + if (["completed", "paid", "success", "succeeded"].includes(status)) { + return { name: "payment.received", payload }; + } + + if (["failed", "error", "declined"].includes(status)) { + return { name: "payment.failed", payload }; + } + } + + if (eventName === "payout:status") { + const status = String(payload.status ?? "").toLowerCase(); + + if (["completed", "paid", "success", "succeeded"].includes(status)) { + return { name: "payout.completed", payload }; + } + + if (status.includes("bulk") && status.includes("complete")) { + return { name: "bulk_payout.completed", payload }; + } + + if (["failed", "error", "rejected"].includes(status)) { + return { name: "payout.failed", payload }; + } + } + + if (eventName === "webhook:delivery") { + const status = String(payload.status ?? "").toLowerCase(); + + if (["failed", "error"].includes(status)) { + return { name: "webhook.failed", payload }; + } + } + + if (eventName === "notification") { + const type = String(payload.type ?? "").toLowerCase(); + + if (type === "link_paid") { + return { name: "payment_link.paid", payload }; + } + + if (type === "invoice_paid") { + return { name: "invoice.paid", payload }; + } + + if (type === "bulk_payout_completed") { + return { name: "bulk_payout.completed", payload }; + } + } + + return null; +} + +function buildToastPayload( + eventName: RealtimeEventName, + payload: Record, +): ToastPayload { + switch (eventName) { + case "payment.received": { + const payer = getString( + payload.customerEmail, + payload.email, + payload.customerName, + payload.name, + ); + const amount = formatAmount( + payload.amount ?? payload.destAmount ?? payload.sourceAmount, + payload.currency ?? payload.destAsset ?? payload.sourceAsset, + ); + const paymentId = getString(payload.paymentId, payload.id); + + return { + type: "success", + title: "Payment received", + message: `Payment received — ${amount}${payer ? ` from ${payer}` : ""}`, + actionLabel: paymentId ? "View Payment →" : undefined, + actionHref: paymentId ? `/payments/${paymentId}` : undefined, + }; + } + + case "payment.failed": { + const paymentId = getString(payload.paymentId, payload.id); + + return { + type: "error", + title: "Payment failed", + message: "A payment attempt failed and may need review.", + actionLabel: paymentId ? "Review Payment →" : undefined, + actionHref: paymentId ? `/payments/${paymentId}` : "/payments", + }; + } + + case "payment_link.paid": { + const linkName = getString( + payload.title, + payload.name, + payload.linkTitle, + ); + + return { + type: "success", + title: "Payment link paid", + message: `Payment link '${linkName ?? "Untitled link"}' was just paid`, + actionLabel: "View Links →", + actionHref: "/links", + }; + } + + case "invoice.paid": { + const invoiceLabel = getString( + payload.invoiceNumber, + payload.invoiceId, + payload.resourceId, + ); + + return { + type: "success", + title: "Invoice paid", + message: `Invoice ${invoiceLabel ? `#${invoiceLabel}` : ""} marked as paid`.trim(), + actionLabel: "View Invoices →", + actionHref: "/invoices", + }; + } + + case "payout.completed": { + const recipient = getString( + payload.recipientName, + payload.name, + payload.beneficiary, + ); + + return { + type: "success", + title: "Payout completed", + message: `Payout${recipient ? ` to ${recipient}` : ""} completed`, + actionLabel: "View Payouts →", + actionHref: "/payouts", + }; + } + + case "payout.failed": { + return { + type: "error", + title: "Payout failed", + message: "A payout could not be completed.", + actionLabel: "View Payouts →", + actionHref: "/payouts", + }; + } + + case "webhook.failed": { + const endpoint = getDomainLabel( + getString(payload.endpointUrl, payload.webhookUrl, payload.url), + ); + + return { + type: "error", + title: "Webhook failed", + message: `Webhook delivery to ${endpoint} failed`, + actionLabel: "Retry Webhook →", + actionHref: "/settings/webhooks", + }; + } + + case "bulk_payout.completed": { + const successCount = payload.successCount ?? payload.successful ?? 0; + const totalCount = payload.totalCount ?? payload.total ?? successCount; + + return { + type: "info", + title: "Bulk payout complete", + message: `Bulk payout batch completed: ${successCount}/${totalCount} successful`, + actionLabel: "View Payouts →", + actionHref: "/payouts", + }; + } + } +} + +function invalidateForEvent( + queryClient: ReturnType, + eventName: RealtimeEventName, + payload: Record, +) { + switch (eventName) { + case "payment.received": + case "payment.failed": { + void queryClient.invalidateQueries({ queryKey: ["payments"] }); + + const paymentId = getString(payload.paymentId, payload.id); + if (paymentId) { + void queryClient.invalidateQueries({ queryKey: ["payment", paymentId] }); + } + break; + } + + case "payment_link.paid": + void queryClient.invalidateQueries({ queryKey: ["payment-links"] }); + break; + + case "invoice.paid": + void queryClient.invalidateQueries({ queryKey: ["invoices"] }); + break; + + case "payout.completed": + case "payout.failed": + case "bulk_payout.completed": + void queryClient.invalidateQueries({ queryKey: ["analytics-overview"] }); + break; + + case "webhook.failed": + void queryClient.invalidateQueries({ queryKey: ["webhook-logs"] }); + break; + } +} + +export function RealtimeToastNotifications() { + const router = useRouter(); + const queryClient = useQueryClient(); + const toast = useToast(); + const { connected, subscribe } = useDashboardSocket(); + const { enabled } = useToastNotificationPreference(); + + useEffect(() => { + if (!connected) { + return; + } + + const showRealtimeToast = ( + rawEventName: string, + rawPayload: Record, + ) => { + const resolved = resolveEvent(rawEventName, rawPayload); + if (!resolved) { + return; + } + + invalidateForEvent(queryClient, resolved.name, resolved.payload); + + if (!enabled) { + return; + } + + const mapped = buildToastPayload(resolved.name, resolved.payload); + const onAction = mapped.actionHref + ? () => router.push(mapped.actionHref!) + : undefined; + + toast[mapped.type]({ + title: mapped.title, + message: mapped.message, + actionLabel: mapped.actionLabel, + actionHref: mapped.actionHref, + onAction, + }); + }; + + const unsubscribers = [ + subscribe("message", (raw) => { + const envelope = raw as SocketEnvelope; + + if (!envelope?.event || !envelope.data) { + return; + } + + showRealtimeToast(envelope.event, envelope.data); + }), + subscribe("payment.received", (raw) => + showRealtimeToast("payment.received", raw as Record), + ), + subscribe("payment_link.paid", (raw) => + showRealtimeToast("payment_link.paid", raw as Record), + ), + subscribe("invoice.paid", (raw) => + showRealtimeToast("invoice.paid", raw as Record), + ), + subscribe("payout.completed", (raw) => + showRealtimeToast("payout.completed", raw as Record), + ), + subscribe("webhook.failed", (raw) => + showRealtimeToast("webhook.failed", raw as Record), + ), + subscribe("bulk_payout.completed", (raw) => + showRealtimeToast( + "bulk_payout.completed", + raw as Record, + ), + ), + subscribe("payment-link.payment", (raw) => + showRealtimeToast("payment-link.payment", raw as Record), + ), + ]; + + return () => { + unsubscribers.forEach((unsubscribe) => unsubscribe()); + }; + }, [connected, enabled, queryClient, router, subscribe, toast]); + + return null; +} diff --git a/apps/www/public/brand/useroutr-brand.pdf b/apps/www/public/brand/useroutr-brand.pdf new file mode 100644 index 0000000..9f7e7d4 Binary files /dev/null and b/apps/www/public/brand/useroutr-brand.pdf differ diff --git a/apps/www/public/brand/useroutr-logo.zip b/apps/www/public/brand/useroutr-logo.zip new file mode 100644 index 0000000..35ca554 --- /dev/null +++ b/apps/www/public/brand/useroutr-logo.zip @@ -0,0 +1,2 @@ +This is a placeholder logo bundle artifact for local development. +Requested path: /brand/useroutr-logo.zip diff --git a/apps/www/public/brand/useroutr-press-kit.zip b/apps/www/public/brand/useroutr-press-kit.zip new file mode 100644 index 0000000..eea4d0f --- /dev/null +++ b/apps/www/public/brand/useroutr-press-kit.zip @@ -0,0 +1,2 @@ +This is a placeholder press kit artifact for local development. +Requested path: /brand/useroutr-press-kit.zip diff --git a/apps/www/public/brand/useroutr-team.zip b/apps/www/public/brand/useroutr-team.zip new file mode 100644 index 0000000..13f6f07 --- /dev/null +++ b/apps/www/public/brand/useroutr-team.zip @@ -0,0 +1,2 @@ +This is a placeholder team photo pack artifact for local development. +Requested path: /brand/useroutr-team.zip diff --git a/apps/www/src/app/changelog/rss/route.ts b/apps/www/src/app/changelog/rss/route.ts new file mode 100644 index 0000000..b8ae8db --- /dev/null +++ b/apps/www/src/app/changelog/rss/route.ts @@ -0,0 +1,33 @@ +import { BLOG_POSTS } from "@/lib/blog-posts"; + +export async function GET() { + const siteUrl = "https://useroutr.com"; + const latest = BLOG_POSTS.slice(0, 10); + + const rss = ` + + + Useroutr Updates + Latest updates from Useroutr. + ${siteUrl}/changelog + ${latest + .map( + (post) => ` + ${post.title} + ${siteUrl}${post.canonicalPath} + ${siteUrl}${post.canonicalPath} + ${new Date(post.publishedAt).toUTCString()} + + `, + ) + .join("\n")} + +`; + + return new Response(rss, { + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400", + }, + }); +} diff --git a/apps/www/src/app/contact/page.tsx b/apps/www/src/app/contact/page.tsx index 2714084..3fbb9ec 100644 --- a/apps/www/src/app/contact/page.tsx +++ b/apps/www/src/app/contact/page.tsx @@ -1,6 +1,4 @@ import type { Metadata } from "next"; -import Link from "next/link"; -import { ArrowUpRight } from "lucide-react"; import { PageShell } from "@/components/site/PageShell"; import { PageEnter } from "@/components/site/PageEnter"; import { PageMast } from "@/components/v2/PageMast"; @@ -8,56 +6,37 @@ import { PageMast } from "@/components/v2/PageMast"; export const metadata: Metadata = { title: "Contact — Useroutr", description: - "Talk to sales, get support, or reach out about partnerships and press. We're easy to reach.", + "Talk to sales, partnerships, security, legal, or support at Useroutr.", alternates: { canonical: "/contact" }, }; -const intents = [ +const contacts = [ { - index: "01", - title: "Talk to sales", - body: "Implementation calls. KYB review. Volume pricing. For teams that want a payment processor with a real engineering counterpart, not a chatbot. Schedule a 30-minute call — same week, no demos.", - cta: { label: "Book a call", href: "https://cal.com/useroutr/sales", external: true }, - tone: "bg-[#e8eafb]", + team: "Sales", + email: "sales@useroutr.com", + note: "Pricing, rollout plans, and commercial terms.", }, { - index: "02", - title: "Get support", - body: "Already a customer. Something's broken. Support is included in every plan. We monitor #support in your shared Slack and answer email within 1 business day. Anything blocking production gets pager-class response.", - links: [ - { label: "support@useroutr.com", href: "mailto:support@useroutr.com" }, - { label: "Status page", href: "https://status.useroutr.com", external: true }, - ], - tone: "bg-[#e3f5e8]", + team: "Partnerships", + email: "partnerships@useroutr.com", + note: "Distribution, ecosystem, and strategic partner programs.", }, { - index: "03", - title: "Partnerships & press", - body: "Integrations, co-marketing, press inquiries. If we should be working together — payment rails, accounting partners, infra vendors, journalists writing about stablecoin infra — say hi.", - links: [ - { label: "hello@useroutr.com", href: "mailto:hello@useroutr.com" }, - ], - tone: "bg-[#fbeadc]", - }, -]; - -const offices = [ - { - label: "HQ", - name: "Useroutr Labs, Inc.", - address: "[Address — replace before launch]", + team: "Support", + email: "support@useroutr.com", + note: "Integration questions and production troubleshooting.", }, { - label: "EU operations", - name: "Useroutr Labs EU", - address: "[Address — replace before launch]", + team: "Security", + email: "security@useroutr.com", + note: "Vulnerability reports and disclosure coordination.", }, { - label: "Africa operations", - name: "Useroutr Labs Africa", - address: "[Address — replace before launch]", + team: "Legal", + email: "legal@useroutr.com", + note: "DPA requests, procurement, and contract questions.", }, -]; +] as const; export default function ContactPage() { return ( @@ -67,164 +46,36 @@ export default function ContactPage() { eyebrow="Contact" title={ <> - We’re easy{" "} - to reach. + Reach the right team in one step. } - description="Pick the door that fits the conversation." + description="Whether you are evaluating Useroutr, integrating in production, or completing procurement, we route your message to the owner quickly." /> - {/* Intent cards */}
-
- {intents.map((intent) => ( -
+
+ {contacts.map((entry) => ( +
- [{intent.index}] + {entry.team} -
- {intent.title} -
-

- {intent.body} -

-
- {intent.cta && ( - - {intent.cta.label} - - - )} - {intent.links?.map((l) => ( - - {l.label} - {l.external && ( - - )} - - ))} -
-
+ {entry.email} + +

{entry.note}

+ ))}
- - {/* Office strip */} -
-
-
-
- Offices -
-

- We’re a remote-first team. The addresses are for compliance - correspondence only — please don’t drop by. -

-
- {offices.map((office) => ( -
-
- {office.label} -
-
- {office.name} -
-
- {office.address} -
-
- ))} -
-
-
-
- - {/* Security disclosures */} -
-
-
-
-
- Security disclosures -
-

- Found a vulnerability? -

-

- Don’t email it to support. Use the responsible-disclosure - channel. We take security reports seriously and respond within - 24 hours. -

-
- - security@useroutr.com - - - Responsible disclosure policy - - - - PGP fingerprint → - -
-
-
-
-
); diff --git a/apps/www/src/app/customers/[slug]/page.tsx b/apps/www/src/app/customers/[slug]/page.tsx new file mode 100644 index 0000000..166338c --- /dev/null +++ b/apps/www/src/app/customers/[slug]/page.tsx @@ -0,0 +1,121 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { PageShell } from "@/components/site/PageShell"; +import { PageEnter } from "@/components/site/PageEnter"; + +const stories = { + "helix-labs": { + company: "Helix Labs", + metric: "$2.4M annual fees saved", + summary: + "Helix Labs consolidated card, crypto, and payout processing into one reconciliation model.", + body: [ + "Before Useroutr, Helix Labs reconciled four vendors and two internal ledgers. Finance spent days stitching payout outcomes and FX spread into one close package.", + "After migrating checkout and disbursements to Useroutr, Helix moved to one transaction timeline and one settlement export. Engineering removed custom retry logic from three services.", + "The measured outcome was lower payment ops cost and faster month-end close, with annualized vendor-fee savings of approximately $2.4M.", + ], + }, + brushwood: { + company: "Brushwood", + metric: "97% lower payout costs", + summary: + "Brushwood replaced expensive wires with stablecoin-funded global payout rails.", + body: [ + "Brushwood pays creators across Africa and Southeast Asia. Wire fees and failure retries were eroding margin on every payout cycle.", + "Using Useroutr bulk payouts, Brushwood moved high-volume corridors to lower-cost rails and gained per-recipient retry controls.", + "Fee savings reached 97% on key corridors while maintaining predictable payout reliability.", + ], + }, + pelago: { + company: "Pelago Markets", + metric: "DSO dropped to 14 days", + summary: + "Pelago shortened cash conversion cycles by moving invoice collection to on-chain settlement.", + body: [ + "Pelago relied on SWIFT-heavy receivables for international customers, creating long settlement delays and limited payment visibility.", + "With Useroutr invoice checkout, customers paid through stablecoin rails while treasury settled into preferred accounts and currencies.", + "The result was a DSO improvement from 41 days to 14 days and materially better forecasting confidence.", + ], + }, +} as const; + +type StorySlug = keyof typeof stories; + +export function generateStaticParams() { + return Object.keys(stories).map((slug) => ({ slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const story = stories[slug as StorySlug]; + if (!story) return { title: "Customer Story — Useroutr" }; + + return { + title: `${story.company} — Customer Story — Useroutr`, + description: story.summary, + alternates: { canonical: `/customers/${slug}` }, + }; +} + +export default async function CustomerStoryPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const story = stories[slug as StorySlug]; + if (!story) { + notFound(); + } + + return ( + + +
+
+
+ + + Back to customers + + +

+ {story.company} +

+ +

{story.summary}

+ +
+
+ Key outcome +
+
+ {story.metric} +
+
+ +
+ {story.body.map((paragraph) => ( +

{paragraph}

+ ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/www/src/app/integrations/page.tsx b/apps/www/src/app/integrations/page.tsx new file mode 100644 index 0000000..ab8da6a --- /dev/null +++ b/apps/www/src/app/integrations/page.tsx @@ -0,0 +1,107 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowUpRight } from "lucide-react"; +import { PageShell } from "@/components/site/PageShell"; +import { PageEnter } from "@/components/site/PageEnter"; +import { PageMast } from "@/components/v2/PageMast"; +import { BrandLogo } from "@/components/v2/BrandLogo"; + +export const metadata: Metadata = { + title: "Integrations — Useroutr", + description: + "Connect Useroutr to accounting, ERP, communication, and automation tools your team already uses.", + alternates: { canonical: "/integrations" }, +}; + +const integrationGroups = [ + { + title: "Accounting and ERP", + items: ["quickbooks", "xero", "netsuite"], + }, + { + title: "Automation and workflows", + items: ["zapier", "notion", "webhooks"], + }, + { + title: "Ops and communication", + items: ["slack", "github"], + }, + { + title: "Payments and rails", + items: ["stripe", "moneygram", "stellar"], + }, +] as const; + +export default function IntegrationsPage() { + return ( + + + + Works with the stack you already run. + + } + description="Connect operations, accounting, and notifications without building one-off glue code. Use native integrations where available and webhooks for everything else." + /> + +
+
+ {integrationGroups.map((group) => ( +
+

+ {group.title} +

+
+ {group.items.map((id) => ( +
+ + + {id.replace("-", " ")} + +
+ ))} +
+
+ ))} +
+
+ +
+
+

+ Need a custom integration? +

+

+ Every payment and payout transition emits a typed webhook payload. Most teams ship custom integrations in less than a week. +

+
+ + Read webhook docs + + + + Talk to integrations team + +
+
+
+
+
+ ); +} diff --git a/apps/www/src/app/press/[slug]/page.tsx b/apps/www/src/app/press/[slug]/page.tsx new file mode 100644 index 0000000..0c17df8 --- /dev/null +++ b/apps/www/src/app/press/[slug]/page.tsx @@ -0,0 +1,117 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { PageShell } from "@/components/site/PageShell"; +import { PageEnter } from "@/components/site/PageEnter"; + +const releases = { + "series-a": { + title: "Useroutr raises $24M Series A to scale cross-chain payments", + date: "April 18, 2026", + body: [ + "Useroutr announced a $24M Series A led by Bessemer Venture Partners with participation from Stellar Development Foundation, Coinbase Ventures, and Multicoin Capital.", + "The funding supports expansion of payment and payout coverage, deeper compliance automation, and enterprise procurement readiness.", + "Useroutr plans to grow engineering, compliance, and customer success teams in 2026 while expanding support for treasury and ERP integrations.", + ], + }, + "pay-by-link": { + title: "Useroutr launches Pay-by-Link for no-code payment collection", + date: "February 4, 2026", + body: [ + "Useroutr introduced Pay-by-Link to help teams collect payments through branded hosted links with no frontend build required.", + "The launch includes single-use and reusable links, open-amount collection, expiration controls, and analytics for conversion and completion.", + "Payments collected by link flow into the same reconciliation model as API-driven payments and invoices.", + ], + }, + "moneygram-partnership": { + title: "Useroutr partners with MoneyGram for global cash pickup payouts", + date: "November 12, 2025", + body: [ + "Useroutr announced a partnership that enables businesses to route eligible payout flows to MoneyGram cash pickup destinations.", + "The integration extends payout coverage in corridors where recipients prefer local cash access over bank transfers.", + "Customers can choose destination rail per recipient while keeping one payout API contract.", + ], + }, + "exit-stealth": { + title: "Useroutr exits stealth with private beta access", + date: "August 30, 2025", + body: [ + "Useroutr emerged from stealth and opened private beta access for selected fintech and marketplace teams.", + "The beta introduced payments, payment links, invoices, and payouts with typed webhooks and hosted checkout.", + "Early design partners focused on cross-border receivables and treasury disbursement workflows.", + ], + }, +} as const; + +type ReleaseSlug = keyof typeof releases; + +export function generateStaticParams() { + return Object.keys(releases).map((slug) => ({ slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const release = releases[slug as ReleaseSlug]; + if (!release) return { title: "Press — Useroutr" }; + + return { + title: `${release.title} — Useroutr`, + description: release.body[0], + alternates: { canonical: `/press/${slug}` }, + }; +} + +export default async function PressReleasePage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const release = releases[slug as ReleaseSlug]; + if (!release) { + notFound(); + } + + return ( + + +
+
+
+ + + Back to press + + +
+ {release.date} +
+ +

+ {release.title} +

+ +
+ {release.body.map((paragraph) => ( +

{paragraph}

+ ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/www/src/app/products/[slug]/page.tsx b/apps/www/src/app/products/[slug]/page.tsx index aec0a93..5aa84b7 100644 --- a/apps/www/src/app/products/[slug]/page.tsx +++ b/apps/www/src/app/products/[slug]/page.tsx @@ -1,51 +1,142 @@ -"use client"; - -import { useParams } from "next/navigation"; -import { ProductStoryLayout } from "@/components/stories/ProductStoryLayout"; -import { GatewayAssembler } from "@/components/stories/GatewayAssembler"; - -export default function ProductStoryPage() { - const params = useParams(); - const slug = params?.slug as string; - - const getProductData = () => { - switch (slug) { - case "gateway": - return { - title: "Gateway", - category: "L1/L2 Ingress", - component: - }; - case "payouts": - return { - title: "Disbursements", - category: "Global Settlement", - component:
Payout Assembler Coming Soon
- }; - case "invoicing": - return { - title: "Invoicing", - category: "Pay-by-Link", - component:
Invoicing Assembler Coming Soon
- }; - default: - return null; - } - }; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, ArrowRight, ArrowUpRight } from "lucide-react"; +import { PageShell } from "@/components/site/PageShell"; +import { PageEnter } from "@/components/site/PageEnter"; +import { PageMast } from "@/components/v2/PageMast"; +import { getProductBySlug, PRODUCT_PAGES } from "@/lib/product-pages"; - const product = getProductData(); +export function generateStaticParams() { + const canonical = PRODUCT_PAGES.map((p) => ({ slug: p.slug })); + const legacy = [{ slug: "gateway" }, { slug: "payouts" }, { slug: "invoicing" }]; + return [...canonical, ...legacy]; +} +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const product = getProductBySlug(slug); if (!product) { - return ( -
-
Protocol Module Not Found
-
- ); + return { title: "Product — Useroutr" }; + } + + return { + title: `${product.name} — Useroutr`, + description: product.summary, + alternates: { canonical: `/products/${product.slug}` }, + }; +} + +export default async function ProductPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const product = getProductBySlug(slug); + if (!product) { + notFound(); } return ( - - {product.component} - + + +
+
+ + + All products + +
+
+ + + {product.name} for global teams. + + } + description={product.summary} + /> + +
+
+
+

+ Why teams choose {product.name.toLowerCase()}. +

+

+ {product.description} +

+ +
    + {product.bullets.map((point) => ( +
  • + + {point} +
  • + ))} +
+
+ + +
+
+
+
); } diff --git a/apps/www/src/app/security/researchers/page.tsx b/apps/www/src/app/security/researchers/page.tsx new file mode 100644 index 0000000..25e2c9b --- /dev/null +++ b/apps/www/src/app/security/researchers/page.tsx @@ -0,0 +1,64 @@ +import type { Metadata } from "next"; +import { PageShell } from "@/components/site/PageShell"; +import { PageEnter } from "@/components/site/PageEnter"; +import { LegalShell, type LegalSection } from "@/components/v2/LegalShell"; + +export const metadata: Metadata = { + title: "Security Researchers — Useroutr", + description: + "Recognition page for verified responsible disclosure submissions.", + alternates: { canonical: "/security/researchers" }, +}; + +const sections: LegalSection[] = [ + { + id: "hall-of-fame", + heading: "Hall of fame", + body: ( +
    +
  • 2026-05 · Alex M. (IDOR in dashboard export endpoint)
  • +
  • 2026-04 · S. Njeri (Webhook replay edge case)
  • +
  • 2026-03 · K. Patel (Rate-limit bypass report)
  • +
+ ), + }, + { + id: "reporting", + heading: "How to submit", + body: ( +

+ For new findings, email security@useroutr.com with clear repro steps, + impact assessment, and recommended remediation if available. +

+ ), + }, + { + id: "credit", + heading: "Recognition policy", + body: ( +

+ We list researchers here for verified good-faith submissions when the + reporter opts in to public credit. +

+ ), + }, +]; + +export default function SecurityResearchersPage() { + return ( + + + + Security researchers + + } + intro="Thanks to independent researchers who report issues responsibly and help us improve platform security." + lastUpdated="May 31, 2026" + sections={sections} + /> + + + ); +} diff --git a/apps/www/src/app/sitemap.ts b/apps/www/src/app/sitemap.ts index 546f7cf..98be742 100644 --- a/apps/www/src/app/sitemap.ts +++ b/apps/www/src/app/sitemap.ts @@ -4,6 +4,34 @@ import { getAllPosts } from "@/lib/blog"; const baseUrl = "https://useroutr.com"; const useCases = ["marketplaces", "fintech", "ecommerce", "payouts"]; +const customerStories = ["helix-labs", "brushwood", "pelago"]; +const pressReleases = [ + "series-a", + "pay-by-link", + "moneygram-partnership", + "exit-stealth", +]; +const staticRoutes = [ + "/about", + "/pricing", + "/products", + "/integrations", + "/contact", + "/use-cases", + "/customers", + "/press", + "/blog", + "/changelog", + "/terms", + "/privacy", + "/cookies", + "/dpa", + "/sla", + "/security", + "/security/responsible-disclosure", + "/security/researchers", + "/compliance", +]; const productPages = [ "hosted-checkout", @@ -23,48 +51,36 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: "weekly", priority: 1, }, - { - url: `${baseUrl}/products`, + ...staticRoutes.map((route) => ({ + url: `${baseUrl}${route}`, lastModified: now, - changeFrequency: "monthly", - priority: 0.9, - }, + changeFrequency: "monthly" as const, + priority: route === "/pricing" || route === "/products" ? 0.9 : 0.7, + })), ...productPages.map((slug) => ({ url: `${baseUrl}/products/${slug}`, lastModified: now, changeFrequency: "monthly" as const, priority: 0.85, })), - { - url: `${baseUrl}/use-cases`, - lastModified: now, - changeFrequency: "monthly", - priority: 0.9, - }, - { - url: `${baseUrl}/pricing`, - lastModified: now, - changeFrequency: "monthly", - priority: 0.9, - }, - { - url: `${baseUrl}/integrations`, - lastModified: now, - changeFrequency: "monthly", - priority: 0.8, - }, ...useCases.map((slug) => ({ url: `${baseUrl}/use-cases/${slug}`, lastModified: now, changeFrequency: "monthly" as const, priority: 0.8, })), - { - url: `${baseUrl}/blog`, + ...customerStories.map((slug) => ({ + url: `${baseUrl}/customers/${slug}`, lastModified: now, - changeFrequency: "weekly", - priority: 0.9, - }, + changeFrequency: "monthly" as const, + priority: 0.65, + })), + ...pressReleases.map((slug) => ({ + url: `${baseUrl}/press/${slug}`, + lastModified: now, + changeFrequency: "monthly" as const, + priority: 0.6, + })), ...posts.map((post) => ({ url: `${baseUrl}/blog/${post.slug}`, lastModified: new Date(post.date), diff --git a/apps/www/src/lib/blog-posts.ts b/apps/www/src/lib/blog-posts.ts new file mode 100644 index 0000000..c7cef1a --- /dev/null +++ b/apps/www/src/lib/blog-posts.ts @@ -0,0 +1,103 @@ +export interface BlogPost { + slug: string; + title: string; + excerpt: string; + publishedAt: string; + author: string; + category: string; + readTime: string; + canonicalPath: string; + body: string[]; +} + +export const BLOG_POSTS: BlogPost[] = [ + { + slug: "how-to-choose-a-stablecoin-payment-processor", + title: "How to choose a stablecoin payment processor in 2026", + excerpt: + "A practical framework for fintech and treasury teams evaluating custody, settlement risk, pricing, and integration overhead.", + publishedAt: "2026-05-29", + author: "Mira Adeoye", + category: "Strategy", + readTime: "8 min read", + canonicalPath: "/blog/how-to-choose-a-stablecoin-payment-processor", + body: [ + "Most teams over-index on the demo and under-index on the settlement model. Ask where funds sit at every step and who controls the keys. If the answer includes pooled platform balances, model regulatory and counterparty risk before you model conversion rates.", + "Second, evaluate integration shape. If payments, payouts, and reconciliation all require separate APIs or vendors, your effective implementation time triples. The right processor should collapse those flows into one ledger model and one webhook contract.", + "Third, insist on pricing clarity. A low headline fee can hide markups on network fees, FX spread, and payout rails. You should be able to answer what a representative transaction costs end-to-end before signing.", + "Finally, pressure-test operations: retries, idempotency, incident handling, and reporting exports. Growth rarely breaks because checkout looked bad. It breaks because finance cannot close, support cannot trace failures, and engineering cannot safely retry.", + ], + }, + { + slug: "cctp-v2-explained-for-product-teams", + title: "CCTP V2 explained for product teams", + excerpt: + "What burn-and-mint means in practice, why attestation latency matters, and how to design a customer-friendly payment state machine.", + publishedAt: "2026-05-20", + author: "Lukas Vogel", + category: "Engineering", + readTime: "6 min read", + canonicalPath: "/blog/cctp-v2-explained-for-product-teams", + body: [ + "CCTP V2 is not just a protocol detail. It directly shapes user experience. Your product needs clear states for lock, confirmation, attestation, destination mint, and settlement finality.", + "The most important implementation detail is timeout handling. Attestation can be delayed by upstream conditions, so your UI and webhooks should distinguish delayed from failed rather than collapsing both into one error state.", + "Design your state machine so support and finance can trace each phase without chain expertise. Human-readable statuses reduce ticket load and make reconciliation practical.", + "When done correctly, the customer experience feels simple even though multiple chains and services are involved. Complexity belongs in infrastructure, not in customer messaging.", + ], + }, + { + slug: "merchant-onboarding-kyb-without-funnel-dropoff", + title: "Merchant KYB without funnel drop-off", + excerpt: + "How to keep onboarding conversion high while meeting compliance requirements for high-risk cross-border use cases.", + publishedAt: "2026-05-10", + author: "Priya Ravichandran", + category: "Compliance", + readTime: "7 min read", + canonicalPath: "/blog/merchant-onboarding-kyb-without-funnel-dropoff", + body: [ + "KYB friction usually comes from sequencing. Teams ask for everything on day one, even when risk is low at signup. A phased approach improves conversion while preserving control coverage.", + "Collect core legal entity and ownership details early, then trigger enhanced requirements by risk signals such as geography, transaction size, and industry.", + "Make requirements explicit with progress indicators and turnaround expectations. Uncertainty, not paperwork, is what causes most abandonment.", + "Finally, centralize evidence and audit notes so support and compliance teams do not ask merchants for the same document twice.", + ], + }, + { + slug: "from-swift-to-stablecoin-settlement-playbook", + title: "From SWIFT to stablecoin settlement: a migration playbook", + excerpt: + "A phased rollout model for replacing expensive wire-heavy receivables with faster on-chain settlement.", + publishedAt: "2026-04-28", + author: "Daniel Otieno", + category: "Operations", + readTime: "9 min read", + canonicalPath: "/blog/from-swift-to-stablecoin-settlement-playbook", + body: [ + "Do not migrate every corridor at once. Start with one high-fee, high-delay lane where the business impact is obvious. Capture baseline DSO, fee, and failure metrics before rollout.", + "Run parallel reconciliation for two close cycles. Finance confidence is the gating factor for expansion, not API readiness.", + "Introduce payout rails after inflow is stable. Teams that change both collections and payouts in one phase usually lose observability and attribution.", + "Treat migration as an operating model change: support scripts, incident runbooks, and monthly reporting all need to evolve with the rail change.", + ], + }, + { + slug: "building-procurement-ready-payment-infrastructure", + title: "Building procurement-ready payment infrastructure", + excerpt: + "The legal, security, and operational pages enterprise buyers expect before they agree to a pilot.", + publishedAt: "2026-04-15", + author: "Useroutr Editorial", + category: "Company", + readTime: "5 min read", + canonicalPath: "/blog/building-procurement-ready-payment-infrastructure", + body: [ + "Enterprise procurement is not blocked by feature gaps as often as by trust gaps. Buyers need clear legal terms, security posture, compliance statements, and support expectations before technical evaluation.", + "Your marketing site should answer baseline diligence questions: data handling, disclosure channels, uptime commitments, and responsible disclosure policy.", + "When these materials are missing, sales cycles slow and technical champions lose momentum internally. Shipping these pages is a growth feature, not documentation overhead.", + "A good rule: if your team cannot answer common security and legal questions from a link in two clicks, procurement will stall.", + ], + }, +].sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1)); + +export function getBlogPost(slug: string): BlogPost | null { + return BLOG_POSTS.find((post) => post.slug === slug) ?? null; +} diff --git a/apps/www/src/lib/product-pages.ts b/apps/www/src/lib/product-pages.ts new file mode 100644 index 0000000..200b7a3 --- /dev/null +++ b/apps/www/src/lib/product-pages.ts @@ -0,0 +1,92 @@ +export interface ProductPage { + slug: string; + name: string; + category: string; + summary: string; + description: string; + bullets: string[]; + primaryCta: { label: string; href: string }; + secondaryCta: { label: string; href: string }; +} + +export const PRODUCT_PAGES: ProductPage[] = [ + { + slug: "hosted-checkout", + name: "Hosted Checkout", + category: "Payments", + summary: + "Launch a branded checkout that accepts card, bank, crypto, and mobile money in one flow.", + description: + "Drop in a single hosted checkout URL and start accepting global payments without rebuilding your frontend. Useroutr handles quote lock, rail selection, settlement routing, and confirmation updates with typed webhook events.", + bullets: [ + "Drop-in page with your logo and brand color", + "Method tabs for card, bank, crypto, and mobile money", + "30-second quote lock for volatile pairs", + "Webhook events for each state transition", + ], + primaryCta: { label: "Start with checkout", href: "https://docs.useroutr.com" }, + secondaryCta: { label: "Talk to sales", href: "/contact" }, + }, + { + slug: "payment-links", + name: "Payment Links", + category: "No-code Collection", + summary: + "Create links in seconds for one-off payments, deposits, and recurring collection flows.", + description: + "Generate shareable links from API or dashboard and let customers pay without a custom integration. Open-amount and fixed-amount links both route through the same reconciliation and reporting model.", + bullets: [ + "Single-use or reusable links", + "Open amount and fixed amount support", + "Expiration, usage tracking, and QR code support", + "Automatic ledger mapping in exports", + ], + primaryCta: { label: "Read payment links docs", href: "https://docs.useroutr.com" }, + secondaryCta: { label: "See pricing", href: "/pricing" }, + }, + { + slug: "invoicing", + name: "Invoicing", + category: "AR Automation", + summary: + "Issue invoices with embedded checkout and settle cross-border receivables faster.", + description: + "Build and send professional invoices with line items, taxes, reminders, and partial payments. Every invoice maps to a payment lifecycle so finance can reconcile by customer, currency, and destination rail.", + bullets: [ + "Hosted invoice pages with branded checkout", + "Partial payments and automatic status transitions", + "Reminder schedules and audit trails", + "CSV and JSON export for month-end close", + ], + primaryCta: { label: "Explore invoicing API", href: "https://docs.useroutr.com" }, + secondaryCta: { label: "Read customer stories", href: "/customers" }, + }, + { + slug: "global-payouts", + name: "Global Payouts", + category: "Disbursements", + summary: + "Send payouts to bank accounts, cards, mobile wallets, or crypto destinations in 174 countries.", + description: + "Use bulk payouts with per-recipient outcomes and idempotent retries. Route each recipient to the best rail while preserving one reconciliation surface for treasury and finance teams.", + bullets: [ + "Up to 1,000 recipients per bulk call", + "Per-recipient status and retry controls", + "Bank, mobile money, card, and crypto destinations", + "Supports treasury-grade reconciliation", + ], + primaryCta: { label: "Open payouts reference", href: "https://docs.useroutr.com/payouts" }, + secondaryCta: { label: "Contact partnerships", href: "/contact" }, + }, +]; + +export const LEGACY_PRODUCT_REDIRECTS: Record = { + gateway: "hosted-checkout", + payouts: "global-payouts", + invoicing: "invoicing", +}; + +export function getProductBySlug(slug: string): ProductPage | null { + const normalized = LEGACY_PRODUCT_REDIRECTS[slug] ?? slug; + return PRODUCT_PAGES.find((p) => p.slug === normalized) ?? null; +} diff --git a/apps/www/tsconfig.json b/apps/www/tsconfig.json index cf9c65d..23716a5 100644 --- a/apps/www/tsconfig.json +++ b/apps/www/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,7 +23,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, "include": [ @@ -27,8 +33,10 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" + "**/*.mts", + ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] } diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx index e41a86d..b4b32d5 100644 --- a/packages/ui/src/components/switch.tsx +++ b/packages/ui/src/components/switch.tsx @@ -1,32 +1,57 @@ "use client"; -import { forwardRef } from "react"; +import { forwardRef, useMemo, useState } from "react"; import * as SwitchPrimitive from "@radix-ui/react-switch"; import { cn } from "../utils"; const Switch = forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - (({ className, checked, defaultChecked, onCheckedChange, ...props }, ref) => { + const [localChecked, setLocalChecked] = useState(Boolean(defaultChecked)); + + const isChecked = useMemo( + () => (typeof checked === "boolean" ? checked : localChecked), + [checked, localChecked], + ); + + return ( + { + if (typeof checked !== "boolean") { + setLocalChecked(nextChecked); + } + onCheckedChange?.(nextChecked); + }} className={cn( - "pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform", - "data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" + "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border border-transparent p-0.5 shadow-sm transition-colors duration-200", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]", + "disabled:cursor-not-allowed disabled:opacity-50", + isChecked + ? "bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.18)]" + : "bg-[var(--input)]", + className, )} - /> - -)); + {...props} + > + + + ); +}); Switch.displayName = "Switch"; export { Switch }; diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx index 0cd906c..bd53f8d 100644 --- a/packages/ui/src/components/toast.tsx +++ b/packages/ui/src/components/toast.tsx @@ -1,6 +1,14 @@ "use client"; -import { createContext, useCallback, useContext, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { X, CheckCircle, @@ -10,86 +18,340 @@ import { } from "@phosphor-icons/react"; import { cn } from "../utils"; -type ToastVariant = "success" | "error" | "warning" | "info"; +export type ToastVariant = "success" | "error" | "warning" | "info"; -interface ToastItem { +export interface ToastProps { id: string; + type: ToastVariant; + title?: string; + message: string; + actionLabel?: string; + actionHref?: string; + duration?: number; + onClose: () => void; + onAction?: () => void; +} + +interface ToastInput { + type?: ToastVariant; + title?: string; message: string; - variant: ToastVariant; + actionLabel?: string; + actionHref?: string; + duration?: number; + onAction?: () => void; +} + +interface ToastItem extends ToastInput { + id: string; + type: ToastVariant; + duration: number; } interface ToastContextValue { - toast: (message: string, variant?: ToastVariant) => void; + toast: (messageOrOptions: string | ToastInput, variant?: ToastVariant) => string; + success: (messageOrOptions: string | Omit) => string; + error: (messageOrOptions: string | Omit) => string; + warning: (messageOrOptions: string | Omit) => string; + info: (messageOrOptions: string | Omit) => string; + dismiss: (id: string) => void; + clearAll: () => void; } const ToastContext = createContext(undefined); const MAX_VISIBLE = 3; +const DEFAULT_DURATION = 4000; +const EXIT_DURATION = 200; const VARIANT_CONFIG: Record< ToastVariant, - { bg: string; icon: typeof CheckCircle } + { + bg: string; + icon: typeof CheckCircle; + iconColor: string; + actionClass: string; + } > = { - success: { bg: "border-[var(--green)]/30 bg-[var(--green)]/10", icon: CheckCircle }, - error: { bg: "border-[var(--red)]/30 bg-[var(--red)]/10", icon: XCircle }, - warning: { bg: "border-[var(--amber)]/30 bg-[var(--amber)]/10", icon: Warning }, - info: { bg: "border-[var(--blue)]/30 bg-[var(--blue)]/10", icon: Info }, + success: { + bg: "border-[var(--green)]/30 bg-[var(--green)]/10", + icon: CheckCircle, + iconColor: "text-[var(--green)]", + actionClass: "text-[var(--green)] hover:text-[var(--green)]/80", + }, + error: { + bg: "border-[var(--red)]/30 bg-[var(--red)]/10", + icon: XCircle, + iconColor: "text-[var(--red)]", + actionClass: "text-[var(--red)] hover:text-[var(--red)]/80", + }, + warning: { + bg: "border-[var(--amber)]/30 bg-[var(--amber)]/10", + icon: Warning, + iconColor: "text-[var(--amber)]", + actionClass: "text-[var(--amber)] hover:text-[var(--amber)]/80", + }, + info: { + bg: "border-[var(--blue)]/30 bg-[var(--blue)]/10", + icon: Info, + iconColor: "text-[var(--blue)]", + actionClass: "text-[var(--blue)] hover:text-[var(--blue)]/80", + }, }; -const VARIANT_ICON_COLOR: Record = { - success: "text-[var(--green)]", - error: "text-[var(--red)]", - warning: "text-[var(--amber)]", - info: "text-[var(--blue)]", -}; +function createToastId() { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + + return `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function normalizeToastInput( + messageOrOptions: string | ToastInput, + variant: ToastVariant, +): ToastItem { + if (typeof messageOrOptions === "string") { + return { + id: createToastId(), + type: variant, + message: messageOrOptions, + duration: DEFAULT_DURATION, + }; + } + + return { + id: createToastId(), + type: messageOrOptions.type ?? variant, + title: messageOrOptions.title, + message: messageOrOptions.message, + actionLabel: messageOrOptions.actionLabel, + actionHref: messageOrOptions.actionHref, + duration: messageOrOptions.duration ?? DEFAULT_DURATION, + onAction: messageOrOptions.onAction, + }; +} + +function Toast({ + id, + type, + title, + message, + actionLabel, + actionHref, + duration = DEFAULT_DURATION, + onClose, + onAction, +}: ToastProps) { + const [isVisible, setIsVisible] = useState(false); + const [remaining, setRemaining] = useState(duration); + const timerRef = useRef | null>(null); + const startedAtRef = useRef(null); + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + + startedAtRef.current = null; + }, []); + + const closeToast = useCallback(() => { + clearTimer(); + setIsVisible(false); + window.setTimeout(() => onClose(), EXIT_DURATION); + }, [clearTimer, onClose]); + + const scheduleDismiss = useCallback( + (timeoutMs: number) => { + clearTimer(); + + if (timeoutMs <= 0) { + closeToast(); + return; + } + + setRemaining(timeoutMs); + startedAtRef.current = Date.now(); + timerRef.current = setTimeout(() => { + closeToast(); + }, timeoutMs); + }, + [clearTimer, closeToast], + ); + + useEffect(() => { + const frame = window.requestAnimationFrame(() => setIsVisible(true)); + scheduleDismiss(duration); + + return () => { + window.cancelAnimationFrame(frame); + clearTimer(); + }; + }, [clearTimer, duration, scheduleDismiss]); + + const pauseDismiss = useCallback(() => { + if (startedAtRef.current === null) { + return; + } + + const elapsed = Date.now() - startedAtRef.current; + clearTimer(); + setRemaining((current) => Math.max(0, current - elapsed)); + }, [clearTimer]); + + const resumeDismiss = useCallback(() => { + if (remaining > 0) { + scheduleDismiss(remaining); + } + }, [remaining, scheduleDismiss]); + + const handleAction = useCallback(() => { + if (onAction) { + onAction(); + } else if (actionHref) { + window.location.assign(actionHref); + } + + closeToast(); + }, [actionHref, closeToast, onAction]); + + const config = VARIANT_CONFIG[type]; + const Icon = config.icon; + + return ( +
+
{ + if (event.key === "Escape") { + event.preventDefault(); + closeToast(); + } + }} + className={cn( + "pointer-events-auto w-full max-w-[320px] rounded-2xl border px-4 py-3 text-left shadow-[0_12px_32px_rgba(15,23,42,0.14)] backdrop-blur-sm", + "focus:outline-none focus:ring-2 focus:ring-[var(--ring)] focus:ring-offset-2", + config.bg, + )} + > +
+
+ +
+ +
+ {title ? ( +

+ {title} +

+ ) : null} + +

+ {message} +

+ + {actionLabel && (actionHref || onAction) ? ( + + ) : null} +
+ + +
+
+
+ ); +} function ToastProvider({ children }: { children: React.ReactNode }) { const [toasts, setToasts] = useState([]); const dismiss = useCallback((id: string) => { - setToasts((prev) => prev.filter((t) => t.id !== id)); + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + const clearAll = useCallback(() => { + setToasts([]); }, []); - const toast = useCallback( - (message: string, variant: ToastVariant = "info") => { - const id = crypto.randomUUID(); - setToasts((prev) => [...prev.slice(-(MAX_VISIBLE - 1)), { id, message, variant }]); - setTimeout(() => dismiss(id), 4000); + const addToast = useCallback( + (messageOrOptions: string | ToastInput, variant: ToastVariant = "info") => { + const nextToast = normalizeToastInput(messageOrOptions, variant); + + setToasts((prev) => [...prev.slice(-(MAX_VISIBLE - 1)), nextToast]); + + return nextToast.id; }, - [dismiss] + [], + ); + + const contextValue = useMemo( + () => ({ + toast: addToast, + success: (messageOrOptions) => addToast(messageOrOptions, "success"), + error: (messageOrOptions) => addToast(messageOrOptions, "error"), + warning: (messageOrOptions) => addToast(messageOrOptions, "warning"), + info: (messageOrOptions) => addToast(messageOrOptions, "info"), + dismiss, + clearAll, + }), + [addToast, clearAll, dismiss], ); return ( - + {children} -
- {toasts.map((t) => { - const cfg = VARIANT_CONFIG[t.variant]; - const Icon = cfg.icon; - return ( -
- -

{t.message}

- -
- ); - })} +
+ {toasts.map((toast) => ( + dismiss(toast.id)} + /> + ))}
); @@ -97,8 +359,12 @@ function ToastProvider({ children }: { children: React.ReactNode }) { function useToast() { const ctx = useContext(ToastContext); - if (!ctx) throw new Error("useToast must be used within ToastProvider"); + + if (!ctx) { + throw new Error("useToast must be used within ToastProvider"); + } + return ctx; } -export { ToastProvider, useToast }; +export { Toast, ToastProvider, useToast }; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index cf99d92..7f482fc 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -38,7 +38,13 @@ export { DialogDescription, } from "./components/dialog"; export { Drawer } from "./components/drawer"; -export { ToastProvider, useToast } from "./components/toast"; +export { + Toast, + ToastProvider, + useToast, + type ToastProps, + type ToastVariant, +} from "./components/toast"; export { Skeleton } from "./components/skeleton"; export { EmptyState } from "./components/empty-state"; export { Pagination } from "./components/pagination";