From c6781cb8d08a5bc8c2f330a427bad066b454ad5b Mon Sep 17 00:00:00 2001 From: Ekene Ngwudike Date: Sat, 30 May 2026 12:47:35 +0100 Subject: [PATCH 1/4] feat: add notifications feature with real-time updates and UI components - Updated package.json build script to copy necessary Prisma package.json files. - Modified schema.prisma to include Notification model and related database migrations. - Implemented NotificationsModule and NotificationsService for handling notifications. - Integrated NotificationsController for API endpoints to list and manage notifications. - Created NotificationBell component for displaying notifications in the dashboard. - Added RealtimeToastNotifications provider for real-time toast notifications based on socket events. - Introduced useToastNotificationPreference hook for managing user preferences on toast notifications. - Updated EventsService to emit notification events when relevant actions occur. - Enhanced AuthService and InvoicesService to trigger notifications on specific actions. --- apps/api/package.json | 2 +- .../migration.sql | 19 + apps/api/prisma/schema.prisma | 15 + apps/api/src/modules/auth/auth.module.ts | 2 + apps/api/src/modules/auth/auth.service.ts | 6 + .../modules/events/events/events.service.ts | 38 ++ .../src/modules/invoices/invoices.service.ts | 4 + .../src/modules/merchant/merchant.module.ts | 3 +- .../src/modules/merchant/merchant.service.ts | 10 +- .../notifications/notifications.controller.ts | 47 ++ .../notifications/notifications.module.ts | 6 + .../notifications/notifications.service.ts | 298 ++++++++++++- .../src/modules/payments/payments.module.ts | 2 + .../src/modules/payments/payments.service.ts | 31 ++ .../api/src/modules/payouts/payouts.module.ts | 4 +- .../src/modules/payouts/payouts.service.ts | 42 ++ .../src/modules/webhooks/webhooks.module.ts | 2 + .../modules/webhooks/webhooks.processor.ts | 8 + .../src/app/(dashboard)/links/page.tsx | 8 +- .../src/app/(dashboard)/settings/page.tsx | 50 ++- apps/dashboard/src/app/globals.css | 2 + apps/dashboard/src/app/layout.tsx | 9 +- .../notifications/NotificationBell.tsx | 217 +++++++++ apps/dashboard/src/components/site-header.tsx | 7 +- .../dashboard/src/hooks/useDashboardSocket.ts | 94 +++- .../hooks/useToastNotificationPreference.ts | 42 ++ .../src/providers/NotificationsProvider.tsx | 253 +++++++++++ .../providers/RealtimeToastNotifications.tsx | 415 ++++++++++++++++++ packages/ui/src/components/switch.tsx | 63 ++- packages/ui/src/components/toast.tsx | 374 +++++++++++++--- packages/ui/src/index.ts | 8 +- 31 files changed, 1966 insertions(+), 115 deletions(-) create mode 100644 apps/api/prisma/migrations/20260418090000_add_notifications/migration.sql create mode 100644 apps/api/src/modules/notifications/notifications.controller.ts create mode 100644 apps/dashboard/src/components/notifications/NotificationBell.tsx create mode 100644 apps/dashboard/src/hooks/useToastNotificationPreference.ts create mode 100644 apps/dashboard/src/providers/NotificationsProvider.tsx create mode 100644 apps/dashboard/src/providers/RealtimeToastNotifications.tsx 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/schema.prisma b/apps/api/prisma/schema.prisma index dd5d9cc..98ff915 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -41,6 +41,7 @@ model Merchant { teamMembers TeamMember[] webhookEvents WebhookEvent[] apiKeys ApiKey[] + notifications Notification[] } model ApiKey { @@ -323,3 +324,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.module.ts b/apps/api/src/modules/auth/auth.module.ts index 43e8729..f9e924c 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -7,6 +7,7 @@ import { JwtStrategy } from './strategies/jwt.strategy.js'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js'; import { ApiKeyGuard } from '../../common/guards/api-key.guard.js'; import { CombinedAuthGuard } from '../../common/guards/combined-auth.guard.js'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ imports: [ @@ -15,6 +16,7 @@ import { CombinedAuthGuard } from '../../common/guards/combined-auth.guard.js'; secret: process.env.JWT_SECRET, signOptions: { expiresIn: '15m' }, }), + NotificationsModule, ], providers: [ AuthService, diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index f7b2dcc..832564a 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -10,6 +10,7 @@ import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; import type { Merchant } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service.js'; +import { NotificationsService } from '../notifications/notifications.service'; import type { RegisterDto } from './dto/register.dto.js'; import type { LoginDto } from './dto/login.dto.js'; import type { JwtPayload } from './strategies/jwt.strategy.js'; @@ -52,6 +53,7 @@ export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, + private readonly notifications: NotificationsService, ) {} async register(dto: RegisterDto): Promise { @@ -171,6 +173,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 d640b38..2f06f48 100644 --- a/apps/api/src/modules/merchant/merchant.module.ts +++ b/apps/api/src/modules/merchant/merchant.module.ts @@ -3,10 +3,11 @@ 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 { RolesGuard } from './guards/roles.guard'; @Module({ - imports: [AuthModule, PrismaModule], + imports: [AuthModule, PrismaModule, NotificationsModule], controllers: [MerchantController], providers: [MerchantService, RolesGuard], exports: [MerchantService], 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 6578565..c5f4cb5 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({ @@ -67,7 +351,7 @@ export class NotificationsService { // Payments async sendPaymentReceipt( customerEmail: string, - payment: Payment, + payment: EmailPayment, ): Promise { await this.dispatch({ to: customerEmail, @@ -78,7 +362,7 @@ export class NotificationsService { async sendPaymentNotification( merchantEmail: string, - payment: Payment, + payment: EmailPayment, ): Promise { await this.dispatch({ to: merchantEmail, @@ -90,7 +374,7 @@ export class NotificationsService { // Invoices async sendInvoice( customerEmail: string, - invoice: Invoice, + invoice: EmailInvoice, pdfBuffer: Buffer, ): Promise { await this.dispatch({ @@ -108,7 +392,7 @@ export class NotificationsService { async sendInvoiceReminder( customerEmail: string, - invoice: Invoice, + invoice: EmailInvoice, ): Promise { const now = new Date(); const diff = Math.ceil( @@ -131,7 +415,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 08b163f..099c83d 100644 --- a/apps/api/src/modules/payments/payments.module.ts +++ b/apps/api/src/modules/payments/payments.module.ts @@ -11,6 +11,7 @@ import { WebhooksModule } from '../webhooks/webhooks.module'; import { StripeWebhooksController } from '../webhooks/webhooks.controller'; import { StellarModule } from '../stellar/stellar.module'; import { AuthModule } from '../auth/auth.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { AuthModule } from '../auth/auth.module'; QuotesModule, WebhooksModule, StellarModule, + NotificationsModule, ], providers: [PaymentsService], controllers: [ diff --git a/apps/api/src/modules/payments/payments.service.ts b/apps/api/src/modules/payments/payments.service.ts index 9dc9d53..6598794 100644 --- a/apps/api/src/modules/payments/payments.service.ts +++ b/apps/api/src/modules/payments/payments.service.ts @@ -24,6 +24,7 @@ import { StellarService } from '../stellar/stellar.service'; 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 { SourceLockEvent } from '@useroutr/types'; import * as crypto from 'crypto'; @@ -84,6 +85,7 @@ export class PaymentsService implements OnModuleInit { private readonly webhooksService: WebhooksService, private readonly stellarService: StellarService, private readonly configService: ConfigService, + private readonly notificationsService: NotificationsService, ) { const secretKey = this.configService.get('STRIPE_SECRET_KEY'); this.stripe = secretKey ? new Stripe(secretKey) : null; @@ -183,6 +185,35 @@ 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) { + void this.notificationsService + .notifyPaymentReceived( + updatedPayment.merchantId, + updatedPayment.id, + updatedPayment.destAmount.toString(), + updatedPayment.destAsset || 'USD', + customerEmail, + ) + .catch(() => undefined); + } + + if (status === PaymentStatus.REFUNDING) { + void this.notificationsService + .notifyRefundInitiated(updatedPayment.merchantId, updatedPayment.id) + .catch(() => undefined); + } + return updatedPayment; } diff --git a/apps/api/src/modules/payouts/payouts.module.ts b/apps/api/src/modules/payouts/payouts.module.ts index 581fcc7..2e36b3c 100644 --- a/apps/api/src/modules/payouts/payouts.module.ts +++ b/apps/api/src/modules/payouts/payouts.module.ts @@ -5,9 +5,11 @@ import { PrismaModule } from '../prisma/prisma.module'; 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], + 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 618ed05..bf013b8 100644 --- a/apps/api/src/modules/payouts/payouts.service.ts +++ b/apps/api/src/modules/payouts/payouts.service.ts @@ -9,6 +9,8 @@ import { DestType, Payout, PayoutStatus, Prisma } from '@prisma/client'; 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'; @@ -42,6 +44,8 @@ export class PayoutsService { private readonly prisma: PrismaService, private readonly webhooks: WebhooksService, private readonly stellar: StellarService, + private readonly eventsService: EventsService, + private readonly notifications: NotificationsService, ) {} // ── Create single payout ────────────────────────────────────────────────── @@ -254,6 +258,25 @@ export class PayoutsService { where: { id: payout.id }, data: { status: PayoutStatus.FAILED, failureReason }, }); + this.eventsService.emitPayoutStatus( + payout.merchantId, + payout.id, + PayoutStatus.FAILED, + { + amount: failed.amount.toString(), + currency: failed.currency, + failureReason, + updatedAt: new Date(), + }, + ); + void this.notifications + .notifyPayoutFailed( + payout.merchantId, + payout.id, + payout.recipientName, + failureReason, + ) + .catch(() => undefined); this.webhooks .dispatch(payout.merchantId, 'payout.failed', { ...this.webhookPayload(failed), @@ -307,6 +330,25 @@ export class PayoutsService { }, }); + this.eventsService.emitPayoutStatus( + payout.merchantId, + payout.id, + PayoutStatus.COMPLETED, + { + amount: completed.amount.toString(), + currency: completed.currency, + stellarTxHash: txHash, + updatedAt: new Date(), + }, + ); + void this.notifications + .notifyPayoutCompleted( + payout.merchantId, + payout.id, + payout.recipientName, + ) + .catch(() => undefined); + this.webhooks .dispatch(payout.merchantId, 'payout.completed', { ...this.webhookPayload(completed), 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/dashboard/src/app/(dashboard)/links/page.tsx b/apps/dashboard/src/app/(dashboard)/links/page.tsx index 41ed600..ff090d2 100644 --- a/apps/dashboard/src/app/(dashboard)/links/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/links/page.tsx @@ -92,15 +92,13 @@ export default function PaymentLinksPage() { const { subscribe } = useDashboardSocket(); useEffect(() => { - // Subscribe to payment link payment events - const unsubscribe = subscribe("payment-link.payment", (...args: unknown[]) => { - const payload = args[0] as { linkId: string; amount: number }; - toast(`Payment received: $${payload.amount}`, "success"); + // Refresh the link list when a real-time link payment event arrives. + const unsubscribe = subscribe("payment-link.payment", () => { refetch(); }); return () => unsubscribe(); - }, [subscribe, toast, refetch]); + }, [subscribe, refetch]); const handleCreate = (data: CreatePaymentLinkInput) => { createMutation.mutate(data, { diff --git a/apps/dashboard/src/app/(dashboard)/settings/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/page.tsx index b488ce8..62fa994 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/page.tsx @@ -6,8 +6,9 @@ import { useMerchantProfile, useUpdateMerchantProfile, } from "@/hooks/useSettings"; +import { useToastNotificationPreference } from "@/hooks/useToastNotificationPreference"; import { motion } from "framer-motion"; -import { Building2, Mail, Bell, Webhook, ShieldAlert } from "lucide-react"; +import { Building2, Mail, Bell, Webhook, ShieldAlert, Radio } from "lucide-react"; const fadeUp = { hidden: { opacity: 0, y: 12 }, @@ -23,6 +24,10 @@ export default function SettingsPage() { const { data: merchant, isLoading: isLoadingProfile } = useMerchantProfile(); const updateProfile = useUpdateMerchantProfile(); + const { + enabled: realtimeNotificationsEnabled, + setEnabled: setRealtimeNotificationsEnabled, + } = useToastNotificationPreference(); const [name, setName] = useState(""); const [companyName, setCompanyName] = useState(""); @@ -51,6 +56,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 (
@@ -150,6 +165,39 @@ export default function SettingsPage() {
+ + {[ { label: "Email notifications", diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 4b2ff09..e1ef20a 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -2,6 +2,8 @@ @import "tw-animate-css"; @import "@useroutr/ui/globals.css"; +@source "../../../../packages/ui/src"; + @custom-variant dark (&:is(.dark *)); @theme inline { diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index ba1fb9f..1f0e430 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -4,6 +4,8 @@ import { Inter, Open_Sans } from "next/font/google"; import { ThemeProvider } from "@/providers/ThemeProvider"; import { QueryProvider } from "@/providers/QueryProvider"; import { AuthProvider } from "@/providers/AuthProvider"; +import { RealtimeToastNotifications } from "@/providers/RealtimeToastNotifications"; +import { NotificationsProvider } from "@/providers/NotificationsProvider"; import "./globals.css"; import { ToastProvider } from "@useroutr/ui"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -48,7 +50,12 @@ export default function RootLayout({ - {children} + + + + {children} + + 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 951d3a6..c5e3c7d 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, Moon, Monitor, SidebarIcon, Sun } from "lucide-react"; +import { Moon, Monitor, SidebarIcon, Sun } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; @@ -20,6 +20,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useTheme } from "@/providers/ThemeProvider"; +import { NotificationBell } from "@/components/notifications/NotificationBell"; const routeLabels: Record = { "/": "Overview", @@ -82,9 +83,7 @@ export function SiteHeader() {
- + + ) : 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"; From 428e064e8105b52dd9bbefdb4eaeb82822b15e5c Mon Sep 17 00:00:00 2001 From: Ekene Ngwudike Date: Sun, 31 May 2026 23:24:32 +0100 Subject: [PATCH 2/4] feat: optimize payment notification logic and clean up unused metadata fields --- .../src/modules/payments/payments.service.ts | 37 +++---------------- apps/api/tsconfig.json | 2 - 2 files changed, 6 insertions(+), 33 deletions(-) diff --git a/apps/api/src/modules/payments/payments.service.ts b/apps/api/src/modules/payments/payments.service.ts index 3d6724f..de36842 100644 --- a/apps/api/src/modules/payments/payments.service.ts +++ b/apps/api/src/modules/payments/payments.service.ts @@ -210,8 +210,6 @@ export class PaymentsService implements OnModuleInit { updatedPayment.id, ); - - const metadata = updatedPayment.metadata && typeof updatedPayment.metadata === 'object' ? (updatedPayment.metadata as Record) @@ -224,40 +222,13 @@ export class PaymentsService implements OnModuleInit { : undefined; if (status === PaymentStatus.COMPLETED) { - void this.notificationsService - .notifyPaymentReceived( - updatedPayment.merchantId, - updatedPayment.id, - updatedPayment.destAmount.toString(), - updatedPayment.destAsset || 'USD', - customerEmail, - ) - .catch(() => undefined); - } - - if (status === PaymentStatus.REFUNDING) { - void this.notificationsService - .notifyRefundInitiated(updatedPayment.merchantId, updatedPayment.id) - .catch(() => undefined); - } - - 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; + const receivedAmount = updatedPayment.destAmount?.toString() ?? '0'; - if (status === PaymentStatus.COMPLETED) { void this.notificationsService .notifyPaymentReceived( updatedPayment.merchantId, updatedPayment.id, - updatedPayment.destAmount.toString(), + receivedAmount, updatedPayment.destAsset || 'USD', customerEmail, ) @@ -453,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/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, From 4002bea3372a29b4294a2d17d0091ded0d0437bb Mon Sep 17 00:00:00 2001 From: Ekene Ngwudike Date: Mon, 1 Jun 2026 13:21:19 +0100 Subject: [PATCH 3/4] feat: add blog and product pages, implement contact and integrations sections - Updated TypeScript configuration for better readability. - Created new blog post pages with dynamic routing and metadata generation. - Added a changelog RSS feed for blog updates. - Implemented a contact page with team contact information. - Developed an integrations page showcasing various integration options. - Introduced a press release page with dynamic content based on slug. - Added product pages with detailed descriptions and CTAs. - Created a security researchers recognition page. - Established data structures for blog posts and product pages. - Included placeholder assets for branding and marketing materials. --- .../migrations/20260601115707/migration.sql | 5 + apps/www/public/brand/useroutr-brand.pdf | Bin 0 -> 48 bytes apps/www/public/brand/useroutr-logo.zip | 2 + apps/www/public/brand/useroutr-press-kit.zip | 2 + apps/www/public/brand/useroutr-team.zip | 2 + apps/www/src/app/blog/[slug]/page.tsx | 84 +++++++++ apps/www/src/app/blog/page.tsx | 63 +++++++ apps/www/src/app/changelog/rss/route.ts | 33 ++++ apps/www/src/app/contact/page.tsx | 82 ++++++++ apps/www/src/app/customers/[slug]/page.tsx | 121 ++++++++++++ apps/www/src/app/integrations/page.tsx | 107 +++++++++++ apps/www/src/app/press/[slug]/page.tsx | 117 ++++++++++++ apps/www/src/app/products/[slug]/page.tsx | 177 +++++++++++++----- apps/www/src/app/products/page.tsx | 91 +++++++++ .../www/src/app/security/researchers/page.tsx | 64 +++++++ apps/www/src/app/sitemap.ts | 68 ++++++- apps/www/src/components/v2/Footer.tsx | 13 +- apps/www/src/lib/blog-posts.ts | 103 ++++++++++ apps/www/src/lib/product-pages.ts | 92 +++++++++ apps/www/tsconfig.json | 18 +- 20 files changed, 1181 insertions(+), 63 deletions(-) create mode 100644 apps/api/prisma/migrations/20260601115707/migration.sql create mode 100644 apps/www/public/brand/useroutr-brand.pdf create mode 100644 apps/www/public/brand/useroutr-logo.zip create mode 100644 apps/www/public/brand/useroutr-press-kit.zip create mode 100644 apps/www/public/brand/useroutr-team.zip create mode 100644 apps/www/src/app/blog/[slug]/page.tsx create mode 100644 apps/www/src/app/blog/page.tsx create mode 100644 apps/www/src/app/changelog/rss/route.ts create mode 100644 apps/www/src/app/contact/page.tsx create mode 100644 apps/www/src/app/customers/[slug]/page.tsx create mode 100644 apps/www/src/app/integrations/page.tsx create mode 100644 apps/www/src/app/press/[slug]/page.tsx create mode 100644 apps/www/src/app/products/page.tsx create mode 100644 apps/www/src/app/security/researchers/page.tsx create mode 100644 apps/www/src/lib/blog-posts.ts create mode 100644 apps/www/src/lib/product-pages.ts 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/www/public/brand/useroutr-brand.pdf b/apps/www/public/brand/useroutr-brand.pdf new file mode 100644 index 0000000000000000000000000000000000000000..9f7e7d4e820291bc02e4eec62e5fb855e8cae1e7 GIT binary patch literal 48 zcmWG7PA$qWEh$oPDoV^tQE)HKOi9hj%u6j+D9A}nPR+>ANl7hINXbtw%}vcK(c=OD Dz&a81 literal 0 HcmV?d00001 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/blog/[slug]/page.tsx b/apps/www/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..bdf8835 --- /dev/null +++ b/apps/www/src/app/blog/[slug]/page.tsx @@ -0,0 +1,84 @@ +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"; +import { BLOG_POSTS, getBlogPost } from "@/lib/blog-posts"; + +export function generateStaticParams() { + return BLOG_POSTS.map((post) => ({ slug: post.slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const post = getBlogPost(slug); + if (!post) { + return { title: "Blog — Useroutr" }; + } + + return { + title: `${post.title} — Useroutr`, + description: post.excerpt, + alternates: { canonical: post.canonicalPath }, + }; +} + +export default async function BlogPostPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const post = getBlogPost(slug); + if (!post) { + notFound(); + } + + return ( + + +
+
+
+ + + Back to blog + + +
+ {post.category} + {post.publishedAt} + {post.readTime} + By {post.author} +
+ +

+ {post.title} +

+ +

{post.excerpt}

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

{paragraph}

+ ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/www/src/app/blog/page.tsx b/apps/www/src/app/blog/page.tsx new file mode 100644 index 0000000..3921e96 --- /dev/null +++ b/apps/www/src/app/blog/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { PageShell } from "@/components/site/PageShell"; +import { PageEnter } from "@/components/site/PageEnter"; +import { PageMast } from "@/components/v2/PageMast"; +import { BLOG_POSTS } from "@/lib/blog-posts"; + +export const metadata: Metadata = { + title: "Blog — Useroutr", + description: + "Insights on stablecoin payments, cross-chain settlement, compliance, and payments operations.", + alternates: { canonical: "/blog" }, +}; + +export default function BlogPage() { + return ( + + + + Notes from the payments edge. + + } + description="Technical explainers, operating playbooks, and strategic guidance for teams building global payment products." + /> + +
+
+
+ {BLOG_POSTS.map((post) => ( +
+
+ {post.category} + {post.publishedAt} + {post.readTime} +
+

+ + {post.title} + +

+

{post.excerpt}

+
+ + Read article + + +
+
+ ))} +
+
+
+
+
+ ); +} 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 new file mode 100644 index 0000000..3fbb9ec --- /dev/null +++ b/apps/www/src/app/contact/page.tsx @@ -0,0 +1,82 @@ +import type { Metadata } from "next"; +import { PageShell } from "@/components/site/PageShell"; +import { PageEnter } from "@/components/site/PageEnter"; +import { PageMast } from "@/components/v2/PageMast"; + +export const metadata: Metadata = { + title: "Contact — Useroutr", + description: + "Talk to sales, partnerships, security, legal, or support at Useroutr.", + alternates: { canonical: "/contact" }, +}; + +const contacts = [ + { + team: "Sales", + email: "sales@useroutr.com", + note: "Pricing, rollout plans, and commercial terms.", + }, + { + team: "Partnerships", + email: "partnerships@useroutr.com", + note: "Distribution, ecosystem, and strategic partner programs.", + }, + { + team: "Support", + email: "support@useroutr.com", + note: "Integration questions and production troubleshooting.", + }, + { + team: "Security", + email: "security@useroutr.com", + note: "Vulnerability reports and disclosure coordination.", + }, + { + team: "Legal", + email: "legal@useroutr.com", + note: "DPA requests, procurement, and contract questions.", + }, +] as const; + +export default function ContactPage() { + return ( + + + + Reach the right team in one step. + + } + description="Whether you are evaluating Useroutr, integrating in production, or completing procurement, we route your message to the owner quickly." + /> + +
+
+
+ {contacts.map((entry) => ( + + ))} +
+
+
+
+
+ ); +} 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/products/page.tsx b/apps/www/src/app/products/page.tsx new file mode 100644 index 0000000..f04d5e8 --- /dev/null +++ b/apps/www/src/app/products/page.tsx @@ -0,0 +1,91 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowRight, ArrowUpRight } from "lucide-react"; +import { PageShell } from "@/components/site/PageShell"; +import { PageEnter } from "@/components/site/PageEnter"; +import { PageMast } from "@/components/v2/PageMast"; +import { PRODUCT_PAGES } from "@/lib/product-pages"; + +export const metadata: Metadata = { + title: "Products — Useroutr", + description: + "Explore Useroutr products for checkout, payment links, invoicing, and global payouts.", + alternates: { canonical: "/products" }, +}; + +export default function ProductsIndexPage() { + return ( + + + + Four products. One API. + + } + description="Each module is standalone, but they share one ledger, one webhook contract, and one reconciliation model. Start with one and add the rest without replatforming." + /> + +
+
+
+ {PRODUCT_PAGES.map((product) => ( +
+ + {product.category} + +

+ {product.name} +

+

+ {product.summary} +

+ +
    + {product.bullets.slice(0, 3).map((point) => ( +
  • + + {point} +
  • + ))} +
+ +
+ + + View product + + + + + {product.primaryCta.label} + + +
+
+ ))} +
+
+
+
+
+ ); +} \ No newline at end of file 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 6847ae2..3d035e1 100644 --- a/apps/www/src/app/sitemap.ts +++ b/apps/www/src/app/sitemap.ts @@ -1,8 +1,38 @@ import { MetadataRoute } from "next"; +import { BLOG_POSTS } from "@/lib/blog-posts"; +import { PRODUCT_PAGES } from "@/lib/product-pages"; 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", +]; export default function sitemap(): MetadataRoute.Sitemap { const now = new Date(); @@ -14,23 +44,41 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: "weekly", priority: 1, }, - { - url: `${baseUrl}/use-cases`, + ...staticRoutes.map((route) => ({ + url: `${baseUrl}${route}`, lastModified: now, - changeFrequency: "monthly", - priority: 0.9, - }, - { - url: `${baseUrl}/pricing`, + changeFrequency: "monthly" as const, + priority: route === "/pricing" || route === "/products" ? 0.9 : 0.7, + })), + ...PRODUCT_PAGES.map((product) => ({ + url: `${baseUrl}/products/${product.slug}`, lastModified: now, - changeFrequency: "monthly", - priority: 0.9, - }, + changeFrequency: "monthly" as const, + priority: 0.8, + })), ...useCases.map((slug) => ({ url: `${baseUrl}/use-cases/${slug}`, lastModified: now, changeFrequency: "monthly" as const, priority: 0.8, })), + ...customerStories.map((slug) => ({ + url: `${baseUrl}/customers/${slug}`, + lastModified: now, + changeFrequency: "monthly" as const, + priority: 0.65, + })), + ...pressReleases.map((slug) => ({ + url: `${baseUrl}/press/${slug}`, + lastModified: now, + changeFrequency: "monthly" as const, + priority: 0.6, + })), + ...BLOG_POSTS.map((post) => ({ + url: `${baseUrl}${post.canonicalPath}`, + lastModified: new Date(post.publishedAt), + changeFrequency: "monthly" as const, + priority: 0.75, + })), ]; } diff --git a/apps/www/src/components/v2/Footer.tsx b/apps/www/src/components/v2/Footer.tsx index 0f12806..e8c2098 100644 --- a/apps/www/src/components/v2/Footer.tsx +++ b/apps/www/src/components/v2/Footer.tsx @@ -12,10 +12,11 @@ const columns: { title: string; links: LinkItem[] }[] = [ { title: "Product", links: [ - { label: "Hosted checkout", href: "/#product" }, - { label: "Pay by link", href: "/#product" }, - { label: "Invoices", href: "/#product" }, - { label: "Global payouts", href: "/#product" }, + { label: "Products", href: "/products" }, + { label: "Hosted checkout", href: "/products/hosted-checkout" }, + { label: "Pay by link", href: "/products/payment-links" }, + { label: "Invoices", href: "/products/invoicing" }, + { label: "Global payouts", href: "/products/global-payouts" }, { label: "Pricing", href: "/pricing" }, ], }, @@ -33,6 +34,7 @@ const columns: { title: string; links: LinkItem[] }[] = [ href: "https://docs.useroutr.com/webhooks", external: true, }, + { label: "Integrations", href: "/integrations" }, { label: "Status", href: "https://status.useroutr.com", external: true }, { label: "Changelog", href: "/changelog" }, ], @@ -44,7 +46,8 @@ const columns: { title: string; links: LinkItem[] }[] = [ { label: "Use cases", href: "/use-cases" }, { label: "Customers", href: "/customers" }, { label: "Press", href: "/press" }, - { label: "Contact", href: "mailto:hello@useroutr.com" }, + { label: "Blog", href: "/blog" }, + { label: "Contact", href: "/contact" }, ], }, { 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" + ] } From 3e7b8f42afec72fb506c471bdd4addcdfc30a882 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Thu, 25 Jun 2026 20:51:42 +0100 Subject: [PATCH 4/4] Fix TypeScript errors introduced by recipients feature - Remove invalid DTO.schema property assignments (types are erased at runtime) - Export schemas directly and reference them by name in controller and update DTO - Cast recipient.details through unknown to match CreatePayoutDto destination union type --- apps/api/src/modules/payouts/payouts.service.ts | 4 ++-- .../src/modules/recipients/dto/create-recipient.dto.ts | 2 -- .../src/modules/recipients/dto/recipient-filters.dto.ts | 2 -- .../src/modules/recipients/dto/update-recipient.dto.ts | 4 ++-- apps/api/src/modules/recipients/recipients.controller.ts | 8 ++++---- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/api/src/modules/payouts/payouts.service.ts b/apps/api/src/modules/payouts/payouts.service.ts index 06c6a8f..72e4d3d 100644 --- a/apps/api/src/modules/payouts/payouts.service.ts +++ b/apps/api/src/modules/payouts/payouts.service.ts @@ -82,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'], }; } @@ -138,7 +138,7 @@ async createBulk(merchantId: string, dto: BulkPayoutDto): Promise; -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);