From 1638eb59831bcf130a440260eb9eeeeed14ccd0c Mon Sep 17 00:00:00 2001 From: susan yusuf Date: Thu, 28 May 2026 16:25:58 +0000 Subject: [PATCH] feat: connection pooling, loyalty gamification, merchant onboarding, repository pattern - #414: HTTP/2 connection pool with keep-alive, DNS cache, TLS session resumption, pool metrics, leak detection, graceful drain - #394: Loyalty dashboard with streaks, achievements, badges, progress bars, rewards catalog, social sharing; race condition mutex in store - #386: Merchant onboarding wizard with compliance screening, save-and- resume, document retry, payment setup, welcome tour modal - #405: Repository pattern with interfaces, in-memory implementations, unit-of-work, and full test coverage --- .../services/__tests__/repositories.test.ts | 259 ++++++ backend/services/connectionPool.ts | 395 ++++++++ backend/services/index.ts | 8 + backend/services/repositories/inMemory.ts | 177 ++++ backend/services/repositories/index.ts | 2 + backend/services/repositories/interfaces.ts | 129 +++ .../gamification/LoyaltyComponents.tsx | 320 +++++++ src/screens/LoyaltyDashboardScreen.tsx | 622 ++++--------- src/screens/MerchantOnboardingScreen.tsx | 852 +++++++++--------- src/store/loyaltyStore.ts | 385 +++++--- src/store/merchantStore.ts | 278 ++++-- 11 files changed, 2324 insertions(+), 1103 deletions(-) create mode 100644 backend/services/__tests__/repositories.test.ts create mode 100644 backend/services/connectionPool.ts create mode 100644 backend/services/repositories/inMemory.ts create mode 100644 backend/services/repositories/index.ts create mode 100644 backend/services/repositories/interfaces.ts create mode 100644 src/components/gamification/LoyaltyComponents.tsx diff --git a/backend/services/__tests__/repositories.test.ts b/backend/services/__tests__/repositories.test.ts new file mode 100644 index 0000000..f735a23 --- /dev/null +++ b/backend/services/__tests__/repositories.test.ts @@ -0,0 +1,259 @@ +/** + * Repository tests — Issue #405. + * Uses in-memory implementations for fast, isolated unit tests. + */ + +import { + InMemorySubscriptionRepository, + InMemoryTransactionRepository, + InMemoryUserRepository, + InMemoryMerchantRepository, + InMemoryLoyaltyRepository, + InMemoryUnitOfWork, + Subscription, + Transaction, + User, + MerchantRecord, + LoyaltyRecord, +} from '../repositories'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const makeSub = (overrides: Partial = {}): Subscription => ({ + id: 'sub-1', + userId: 'user-1', + name: 'Netflix', + amount: 15, + currency: 'USD', + billingCycle: 'monthly', + status: 'active', + nextBillingDate: new Date('2026-06-01'), + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + ...overrides, +}); + +const makeTx = (overrides: Partial = {}): Transaction => ({ + id: 'tx-1', + subscriptionId: 'sub-1', + userId: 'user-1', + amount: 15, + currency: 'USD', + status: 'success', + timestamp: new Date('2026-05-01'), + ...overrides, +}); + +const makeUser = (overrides: Partial = {}): User => ({ + id: 'user-1', + address: 'GABC123', + email: 'alice@example.com', + createdAt: new Date('2026-01-01'), + ...overrides, +}); + +const makeMerchant = (overrides: Partial = {}): MerchantRecord => ({ + id: 'merchant-1', + merchantAddress: 'GMERCHANT', + status: 'verified', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + ...overrides, +}); + +const makeLoyalty = (overrides: Partial = {}): LoyaltyRecord => ({ + id: 'loyalty-1', + subscriberId: 'user-1', + points: 500, + lifetimePoints: 1200, + tier: 'silver', + streakCurrent: 7, + streakLongest: 14, + updatedAt: new Date('2026-05-01'), + ...overrides, +}); + +// ── Subscription repository ─────────────────────────────────────────────────── + +describe('InMemorySubscriptionRepository', () => { + let repo: InMemorySubscriptionRepository; + + beforeEach(() => { + repo = new InMemorySubscriptionRepository(); + }); + + it('saves and retrieves by id', async () => { + const sub = makeSub(); + await repo.save(sub); + expect(await repo.findById('sub-1')).toEqual(sub); + }); + + it('returns null for unknown id', async () => { + expect(await repo.findById('nope')).toBeNull(); + }); + + it('exists returns correct boolean', async () => { + await repo.save(makeSub()); + expect(await repo.exists('sub-1')).toBe(true); + expect(await repo.exists('nope')).toBe(false); + }); + + it('deletes a record', async () => { + await repo.save(makeSub()); + await repo.delete('sub-1'); + expect(await repo.findById('sub-1')).toBeNull(); + }); + + it('findAll returns all records with pagination', async () => { + await repo.save(makeSub({ id: 'sub-1' })); + await repo.save(makeSub({ id: 'sub-2' })); + const page = await repo.findAll({ limit: 1, offset: 0 }); + expect(page.total).toBe(2); + expect(page.items).toHaveLength(1); + }); + + it('findByUserId filters correctly', async () => { + await repo.save(makeSub({ id: 'sub-1', userId: 'user-1' })); + await repo.save(makeSub({ id: 'sub-2', userId: 'user-2' })); + const page = await repo.findByUserId('user-1'); + expect(page.items).toHaveLength(1); + expect(page.items[0].userId).toBe('user-1'); + }); + + it('findByStatus filters correctly', async () => { + await repo.save(makeSub({ id: 'sub-1', status: 'active' })); + await repo.save(makeSub({ id: 'sub-2', status: 'paused' })); + const page = await repo.findByStatus('active'); + expect(page.items).toHaveLength(1); + }); + + it('findDueBefore returns only due active subscriptions', async () => { + await repo.save(makeSub({ id: 'sub-1', nextBillingDate: new Date('2026-05-01') })); + await repo.save(makeSub({ id: 'sub-2', nextBillingDate: new Date('2026-07-01') })); + const due = await repo.findDueBefore(new Date('2026-06-01')); + expect(due).toHaveLength(1); + expect(due[0].id).toBe('sub-1'); + }); +}); + +// ── Transaction repository ──────────────────────────────────────────────────── + +describe('InMemoryTransactionRepository', () => { + let repo: InMemoryTransactionRepository; + + beforeEach(() => { repo = new InMemoryTransactionRepository(); }); + + it('saves and retrieves', async () => { + const tx = makeTx(); + await repo.save(tx); + expect(await repo.findById('tx-1')).toEqual(tx); + }); + + it('findBySubscriptionId filters correctly', async () => { + await repo.save(makeTx({ id: 'tx-1', subscriptionId: 'sub-1' })); + await repo.save(makeTx({ id: 'tx-2', subscriptionId: 'sub-2' })); + const page = await repo.findBySubscriptionId('sub-1'); + expect(page.items).toHaveLength(1); + }); + + it('findByStatus filters correctly', async () => { + await repo.save(makeTx({ id: 'tx-1', status: 'success' })); + await repo.save(makeTx({ id: 'tx-2', status: 'failed' })); + const failed = await repo.findByStatus('failed'); + expect(failed).toHaveLength(1); + expect(failed[0].id).toBe('tx-2'); + }); +}); + +// ── User repository ─────────────────────────────────────────────────────────── + +describe('InMemoryUserRepository', () => { + let repo: InMemoryUserRepository; + + beforeEach(() => { repo = new InMemoryUserRepository(); }); + + it('findByAddress returns correct user', async () => { + await repo.save(makeUser()); + expect(await repo.findByAddress('GABC123')).not.toBeNull(); + expect(await repo.findByAddress('GOTHER')).toBeNull(); + }); + + it('findByEmail returns correct user', async () => { + await repo.save(makeUser()); + expect(await repo.findByEmail('alice@example.com')).not.toBeNull(); + expect(await repo.findByEmail('bob@example.com')).toBeNull(); + }); +}); + +// ── Merchant repository ─────────────────────────────────────────────────────── + +describe('InMemoryMerchantRepository', () => { + let repo: InMemoryMerchantRepository; + + beforeEach(() => { repo = new InMemoryMerchantRepository(); }); + + it('findByAddress returns correct merchant', async () => { + await repo.save(makeMerchant()); + expect(await repo.findByAddress('GMERCHANT')).not.toBeNull(); + }); + + it('findByStatus filters correctly', async () => { + await repo.save(makeMerchant({ id: 'm-1', status: 'verified' })); + await repo.save(makeMerchant({ id: 'm-2', status: 'pending' })); + const verified = await repo.findByStatus('verified'); + expect(verified).toHaveLength(1); + }); +}); + +// ── Loyalty repository ──────────────────────────────────────────────────────── + +describe('InMemoryLoyaltyRepository', () => { + let repo: InMemoryLoyaltyRepository; + + beforeEach(() => { repo = new InMemoryLoyaltyRepository(); }); + + it('findBySubscriberId returns correct record', async () => { + await repo.save(makeLoyalty()); + expect(await repo.findBySubscriberId('user-1')).not.toBeNull(); + expect(await repo.findBySubscriberId('user-99')).toBeNull(); + }); + + it('findTopByPoints returns sorted results', async () => { + await repo.save(makeLoyalty({ id: 'l-1', subscriberId: 'u-1', points: 100 })); + await repo.save(makeLoyalty({ id: 'l-2', subscriberId: 'u-2', points: 500 })); + await repo.save(makeLoyalty({ id: 'l-3', subscriberId: 'u-3', points: 250 })); + const top2 = await repo.findTopByPoints(2); + expect(top2[0].points).toBe(500); + expect(top2[1].points).toBe(250); + }); +}); + +// ── Unit of work ────────────────────────────────────────────────────────────── + +describe('InMemoryUnitOfWork', () => { + it('run executes work and returns result', async () => { + const uow = new InMemoryUnitOfWork(); + const result = await uow.run(async (u) => { + await u.subscriptions.save(makeSub()); + return u.subscriptions.findById('sub-1'); + }); + expect(result).not.toBeNull(); + expect(result?.id).toBe('sub-1'); + }); + + it('run propagates errors', async () => { + const uow = new InMemoryUnitOfWork(); + await expect( + uow.run(async () => { throw new Error('boom'); }), + ).rejects.toThrow('boom'); + }); + + it('all repositories are accessible', () => { + const uow = new InMemoryUnitOfWork(); + expect(uow.subscriptions).toBeDefined(); + expect(uow.transactions).toBeDefined(); + expect(uow.users).toBeDefined(); + expect(uow.merchants).toBeDefined(); + expect(uow.loyalty).toBeDefined(); + }); +}); diff --git a/backend/services/connectionPool.ts b/backend/services/connectionPool.ts new file mode 100644 index 0000000..dd7b663 --- /dev/null +++ b/backend/services/connectionPool.ts @@ -0,0 +1,395 @@ +/** + * HTTP/2 connection pool with keep-alive, metrics, and graceful degradation. + * Issue #414: Reduce API latency with connection pooling and keep-alive. + */ + +import * as https from 'https'; +import * as http2 from 'http2'; +import * as dns from 'dns'; +import { EventEmitter } from 'events'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface PoolConfig { + /** Target host (e.g. "horizon-testnet.stellar.org") */ + host: string; + port?: number; + /** Max concurrent HTTP/2 streams per connection */ + maxConcurrentStreams?: number; + /** Max connections in the pool */ + maxConnections?: number; + /** Idle timeout in ms before a connection is closed */ + idleTimeoutMs?: number; + /** Connection acquire timeout in ms */ + acquireTimeoutMs?: number; + /** DNS TTL cache in ms */ + dnsCacheTtlMs?: number; + /** TLS session resumption */ + tlsSessionReuse?: boolean; +} + +export interface PoolMetrics { + active: number; + idle: number; + waiting: number; + totalCreated: number; + totalDestroyed: number; + totalRequests: number; + avgLatencyMs: number; + leakedConnections: number; +} + +interface PooledConnection { + id: string; + session: http2.ClientHttp2Session; + activeStreams: number; + createdAt: number; + lastUsedAt: number; + idleTimer?: ReturnType; +} + +interface PendingRequest { + resolve: (conn: PooledConnection) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +// ── Connection Pool ────────────────────────────────────────────────────────── + +export class ConnectionPool extends EventEmitter { + private readonly config: Required; + private connections: Map = new Map(); + private waitQueue: PendingRequest[] = []; + private dnsCache: { address: string; expiresAt: number } | null = null; + private tlsSession: Buffer | null = null; + private metrics: PoolMetrics = { + active: 0, + idle: 0, + waiting: 0, + totalCreated: 0, + totalDestroyed: 0, + totalRequests: 0, + avgLatencyMs: 0, + leakedConnections: 0, + }; + private latencySamples: number[] = []; + private leakDetectionTimer?: ReturnType; + + constructor(config: PoolConfig) { + super(); + this.config = { + port: 443, + maxConcurrentStreams: 100, + maxConnections: 10, + idleTimeoutMs: 30_000, + acquireTimeoutMs: 5_000, + dnsCacheTtlMs: 60_000, + tlsSessionReuse: true, + ...config, + }; + this.startLeakDetection(); + } + + // ── Public API ───────────────────────────────────────────────────────────── + + /** Acquire a connection from the pool, creating one if needed. */ + async acquire(): Promise { + this.metrics.totalRequests++; + const conn = this.findIdleConnection(); + if (conn) { + this.markActive(conn); + return conn; + } + if (this.connections.size < this.config.maxConnections) { + return this.createConnection(); + } + return this.enqueue(); + } + + /** Release a connection back to the pool. */ + release(conn: PooledConnection): void { + if (!this.connections.has(conn.id)) return; + conn.activeStreams = Math.max(0, conn.activeStreams - 1); + conn.lastUsedAt = Date.now(); + + if (this.waitQueue.length > 0) { + const pending = this.waitQueue.shift()!; + clearTimeout(pending.timer); + this.metrics.waiting = this.waitQueue.length; + this.markActive(conn); + pending.resolve(conn); + return; + } + + this.metrics.active = Math.max(0, this.metrics.active - 1); + this.metrics.idle++; + this.scheduleIdleTimeout(conn); + } + + /** Execute a request using a pooled connection, measuring latency. */ + async request( + path: string, + method: string, + headers?: Record, + body?: string, + ): Promise<{ status: number; data: T; latencyMs: number }> { + const conn = await this.acquire(); + const start = Date.now(); + try { + const result = await this.sendRequest(conn, path, method, headers, body); + const latencyMs = Date.now() - start; + this.recordLatency(latencyMs); + return { ...result, latencyMs }; + } finally { + this.release(conn); + } + } + + getMetrics(): PoolMetrics { + return { ...this.metrics }; + } + + /** Gracefully drain the pool: wait for active streams to finish, then close. */ + async drain(): Promise { + // Reject all waiting requests + for (const pending of this.waitQueue) { + clearTimeout(pending.timer); + pending.reject(new Error('Pool is draining')); + } + this.waitQueue = []; + + // Wait for active streams to complete (max 10s) + const deadline = Date.now() + 10_000; + while (this.metrics.active > 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 100)); + } + + this.destroy(); + } + + destroy(): void { + if (this.leakDetectionTimer) clearInterval(this.leakDetectionTimer); + for (const conn of this.connections.values()) { + this.destroyConnection(conn); + } + this.connections.clear(); + this.metrics.active = 0; + this.metrics.idle = 0; + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + private findIdleConnection(): PooledConnection | null { + for (const conn of this.connections.values()) { + if ( + !conn.session.destroyed && + conn.activeStreams < this.config.maxConcurrentStreams + ) { + return conn; + } + } + return null; + } + + private async createConnection(): Promise { + const address = await this.resolveHost(); + const sessionOptions: http2.SecureClientSessionOptions = { + host: this.config.host, + servername: this.config.host, + rejectUnauthorized: true, + }; + + if (this.config.tlsSessionReuse && this.tlsSession) { + (sessionOptions as Record).session = this.tlsSession; + } + + return new Promise((resolve, reject) => { + const session = http2.connect( + `https://${address}:${this.config.port}`, + sessionOptions, + ); + + session.once('connect', () => { + // Cache TLS session for resumption + const socket = session.socket as https.Agent & { getSession?: () => Buffer }; + if (this.config.tlsSessionReuse && socket?.getSession) { + this.tlsSession = socket.getSession() ?? null; + } + + const conn: PooledConnection = { + id: `conn-${Date.now()}-${Math.random().toString(36).slice(2)}`, + session, + activeStreams: 1, + createdAt: Date.now(), + lastUsedAt: Date.now(), + }; + this.connections.set(conn.id, conn); + this.metrics.totalCreated++; + this.metrics.active++; + resolve(conn); + }); + + session.once('error', (err) => { + reject(err); + }); + + session.on('close', () => { + const conn = [...this.connections.values()].find((c) => c.session === session); + if (conn) this.destroyConnection(conn); + }); + + // Respect server's MAX_CONCURRENT_STREAMS setting + session.on('remoteSettings', (settings) => { + if (settings.maxConcurrentStreams) { + this.config.maxConcurrentStreams = Math.min( + this.config.maxConcurrentStreams, + settings.maxConcurrentStreams, + ); + } + }); + }); + } + + private enqueue(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this.waitQueue.findIndex((p) => p.timer === timer); + if (idx !== -1) this.waitQueue.splice(idx, 1); + this.metrics.waiting = this.waitQueue.length; + reject(new Error(`Connection pool exhausted: acquire timeout after ${this.config.acquireTimeoutMs}ms`)); + }, this.config.acquireTimeoutMs); + + this.waitQueue.push({ resolve, reject, timer }); + this.metrics.waiting = this.waitQueue.length; + }); + } + + private markActive(conn: PooledConnection): void { + if (conn.idleTimer) { + clearTimeout(conn.idleTimer); + conn.idleTimer = undefined; + } + conn.activeStreams++; + conn.lastUsedAt = Date.now(); + this.metrics.idle = Math.max(0, this.metrics.idle - 1); + this.metrics.active++; + } + + private scheduleIdleTimeout(conn: PooledConnection): void { + if (conn.idleTimer) clearTimeout(conn.idleTimer); + conn.idleTimer = setTimeout(() => { + if (conn.activeStreams === 0) { + this.destroyConnection(conn); + } + }, this.config.idleTimeoutMs); + } + + private destroyConnection(conn: PooledConnection): void { + if (!this.connections.has(conn.id)) return; + if (conn.idleTimer) clearTimeout(conn.idleTimer); + if (!conn.session.destroyed) conn.session.destroy(); + this.connections.delete(conn.id); + this.metrics.totalDestroyed++; + this.metrics.idle = Math.max(0, this.metrics.idle - 1); + this.emit('connectionDestroyed', conn.id); + } + + private async resolveHost(): Promise { + if (this.dnsCache && Date.now() < this.dnsCache.expiresAt) { + return this.dnsCache.address; + } + return new Promise((resolve, reject) => { + dns.lookup(this.config.host, { family: 4 }, (err, address) => { + if (err) return reject(err); + this.dnsCache = { address, expiresAt: Date.now() + this.config.dnsCacheTtlMs }; + resolve(address); + }); + }); + } + + private sendRequest( + conn: PooledConnection, + path: string, + method: string, + headers?: Record, + body?: string, + ): Promise<{ status: number; data: T }> { + return new Promise((resolve, reject) => { + const reqHeaders: http2.OutgoingHttpHeaders = { + ':method': method, + ':path': path, + ':scheme': 'https', + ':authority': this.config.host, + 'content-type': 'application/json', + ...headers, + }; + if (body) reqHeaders['content-length'] = Buffer.byteLength(body).toString(); + + const req = conn.session.request(reqHeaders); + if (body) req.write(body); + req.end(); + + let status = 0; + const chunks: Buffer[] = []; + + req.on('response', (responseHeaders) => { + status = Number(responseHeaders[':status'] ?? 0); + }); + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('utf8'); + const data = raw ? (JSON.parse(raw) as T) : ({} as T); + resolve({ status, data }); + } catch (e) { + reject(e); + } + }); + req.on('error', reject); + }); + } + + private recordLatency(ms: number): void { + this.latencySamples.push(ms); + if (this.latencySamples.length > 100) this.latencySamples.shift(); + this.metrics.avgLatencyMs = + this.latencySamples.reduce((a, b) => a + b, 0) / this.latencySamples.length; + } + + /** Detect connections that have been active too long (potential leaks). */ + private startLeakDetection(): void { + const LEAK_THRESHOLD_MS = 60_000; + this.leakDetectionTimer = setInterval(() => { + let leaked = 0; + for (const conn of this.connections.values()) { + if (conn.activeStreams > 0 && Date.now() - conn.lastUsedAt > LEAK_THRESHOLD_MS) { + leaked++; + this.emit('connectionLeak', conn.id); + } + } + this.metrics.leakedConnections = leaked; + }, 30_000); + } +} + +// ── Singleton factory ──────────────────────────────────────────────────────── + +const pools = new Map(); + +export function getPool(config: PoolConfig): ConnectionPool { + const key = `${config.host}:${config.port ?? 443}`; + if (!pools.has(key)) { + pools.set(key, new ConnectionPool(config)); + } + return pools.get(key)!; +} + +/** Pre-configured pool for Stellar Horizon RPC */ +export const stellarPool = getPool({ + host: 'horizon-testnet.stellar.org', + maxConnections: 5, + maxConcurrentStreams: 50, + idleTimeoutMs: 30_000, + dnsCacheTtlMs: 60_000, + tlsSessionReuse: true, +}); diff --git a/backend/services/index.ts b/backend/services/index.ts index a1919f0..a2bf2f5 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -1,3 +1,11 @@ +// ── Connection pool (#414) ──────────────────────────────────────────────────── +export { ConnectionPool, getPool, stellarPool } from './connectionPool'; +export type { PoolConfig, PoolMetrics } from './connectionPool'; + +// ── Repository pattern (#405) ───────────────────────────────────────────────── +export * from './repositories'; + +// ── Existing services ───────────────────────────────────────────────────────── export { AuditService } from './auditService'; export { CampaignService } from './campaignService'; export { DunningService, dunningService } from './dunningService'; diff --git a/backend/services/repositories/inMemory.ts b/backend/services/repositories/inMemory.ts new file mode 100644 index 0000000..45a265d --- /dev/null +++ b/backend/services/repositories/inMemory.ts @@ -0,0 +1,177 @@ +/** + * In-memory repository implementations — used for testing and local dev. + * Issue #405: Repository pattern. + */ + +import { + IRepository, + ISubscriptionRepository, + ITransactionRepository, + IUserRepository, + IMerchantRepository, + ILoyaltyRepository, + IUnitOfWork, + Page, + QueryOptions, + TransactionContext, + Subscription, + Transaction, + User, + MerchantRecord, + LoyaltyRecord, +} from './interfaces'; + +// ── Generic in-memory base ──────────────────────────────────────────────────── + +class InMemoryRepository implements IRepository { + protected store = new Map(); + + async findById(id: string): Promise { + return this.store.get(id) ?? null; + } + + async findAll(opts: QueryOptions = {}): Promise> { + const all = [...this.store.values()]; + const offset = opts.offset ?? 0; + const limit = opts.limit ?? all.length; + const items = all.slice(offset, offset + limit); + return { items, total: all.length, offset, limit }; + } + + async save(entity: T): Promise { + this.store.set(entity.id, { ...entity }); + return entity; + } + + async delete(id: string): Promise { + this.store.delete(id); + } + + async exists(id: string): Promise { + return this.store.has(id); + } + + /** Test helper: clear all records. */ + clear(): void { + this.store.clear(); + } + + /** Test helper: seed records. */ + seed(records: T[]): void { + records.forEach((r) => this.store.set(r.id, r)); + } +} + +// ── Subscription repository ─────────────────────────────────────────────────── + +export class InMemorySubscriptionRepository + extends InMemoryRepository + implements ISubscriptionRepository +{ + async findByUserId(userId: string, opts: QueryOptions = {}): Promise> { + const filtered = [...this.store.values()].filter((s) => s.userId === userId); + return paginate(filtered, opts); + } + + async findByStatus(status: Subscription['status'], opts: QueryOptions = {}): Promise> { + const filtered = [...this.store.values()].filter((s) => s.status === status); + return paginate(filtered, opts); + } + + async findDueBefore(date: Date): Promise { + return [...this.store.values()].filter( + (s) => s.status === 'active' && s.nextBillingDate <= date, + ); + } +} + +// ── Transaction repository ──────────────────────────────────────────────────── + +export class InMemoryTransactionRepository + extends InMemoryRepository + implements ITransactionRepository +{ + async findBySubscriptionId(subscriptionId: string, opts: QueryOptions = {}): Promise> { + const filtered = [...this.store.values()].filter((t) => t.subscriptionId === subscriptionId); + return paginate(filtered, opts); + } + + async findByUserId(userId: string, opts: QueryOptions = {}): Promise> { + const filtered = [...this.store.values()].filter((t) => t.userId === userId); + return paginate(filtered, opts); + } + + async findByStatus(status: Transaction['status']): Promise { + return [...this.store.values()].filter((t) => t.status === status); + } +} + +// ── User repository ─────────────────────────────────────────────────────────── + +export class InMemoryUserRepository + extends InMemoryRepository + implements IUserRepository +{ + async findByAddress(address: string): Promise { + return [...this.store.values()].find((u) => u.address === address) ?? null; + } + + async findByEmail(email: string): Promise { + return [...this.store.values()].find((u) => u.email === email) ?? null; + } +} + +// ── Merchant repository ─────────────────────────────────────────────────────── + +export class InMemoryMerchantRepository + extends InMemoryRepository + implements IMerchantRepository +{ + async findByAddress(address: string): Promise { + return [...this.store.values()].find((m) => m.merchantAddress === address) ?? null; + } + + async findByStatus(status: string): Promise { + return [...this.store.values()].filter((m) => m.status === status); + } +} + +// ── Loyalty repository ──────────────────────────────────────────────────────── + +export class InMemoryLoyaltyRepository + extends InMemoryRepository + implements ILoyaltyRepository +{ + async findBySubscriberId(subscriberId: string): Promise { + return [...this.store.values()].find((l) => l.subscriberId === subscriberId) ?? null; + } + + async findTopByPoints(limit: number): Promise { + return [...this.store.values()] + .sort((a, b) => b.points - a.points) + .slice(0, limit); + } +} + +// ── In-memory unit of work ──────────────────────────────────────────────────── + +export class InMemoryUnitOfWork implements IUnitOfWork { + subscriptions = new InMemorySubscriptionRepository(); + transactions = new InMemoryTransactionRepository(); + users = new InMemoryUserRepository(); + merchants = new InMemoryMerchantRepository(); + loyalty = new InMemoryLoyaltyRepository(); + + async run(work: (uow: IUnitOfWork) => Promise): Promise { + // In-memory: no real transaction isolation needed; just execute. + return work(this); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function paginate(items: T[], opts: QueryOptions): Page { + const offset = opts.offset ?? 0; + const limit = opts.limit ?? items.length; + return { items: items.slice(offset, offset + limit), total: items.length, offset, limit }; +} diff --git a/backend/services/repositories/index.ts b/backend/services/repositories/index.ts new file mode 100644 index 0000000..e57b705 --- /dev/null +++ b/backend/services/repositories/index.ts @@ -0,0 +1,2 @@ +export * from './interfaces'; +export * from './inMemory'; diff --git a/backend/services/repositories/interfaces.ts b/backend/services/repositories/interfaces.ts new file mode 100644 index 0000000..2a453df --- /dev/null +++ b/backend/services/repositories/interfaces.ts @@ -0,0 +1,129 @@ +/** + * Repository interfaces for all data stores. + * Issue #405: Refactor service layer to use repository pattern. + */ + +// ── Core types ──────────────────────────────────────────────────────────────── + +export interface Page { + items: T[]; + total: number; + offset: number; + limit: number; +} + +export interface QueryOptions { + offset?: number; + limit?: number; + orderBy?: string; + orderDir?: 'asc' | 'desc'; +} + +export interface TransactionContext { + id: string; +} + +// ── Base repository ─────────────────────────────────────────────────────────── + +export interface IRepository { + findById(id: ID, tx?: TransactionContext): Promise; + findAll(opts?: QueryOptions, tx?: TransactionContext): Promise>; + save(entity: T, tx?: TransactionContext): Promise; + delete(id: ID, tx?: TransactionContext): Promise; + exists(id: ID, tx?: TransactionContext): Promise; +} + +// ── Domain types (minimal, matching existing store shapes) ──────────────────── + +export interface Subscription { + id: string; + userId: string; + name: string; + amount: number; + currency: string; + billingCycle: string; + status: 'active' | 'paused' | 'cancelled'; + nextBillingDate: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface Transaction { + id: string; + subscriptionId: string; + userId: string; + amount: number; + currency: string; + status: 'success' | 'failed' | 'pending'; + timestamp: Date; + txHash?: string; +} + +export interface User { + id: string; + address: string; + email?: string; + createdAt: Date; +} + +export interface MerchantRecord { + id: string; + merchantAddress: string; + status: string; + verificationTier?: string; + createdAt: Date; + updatedAt: Date; +} + +export interface LoyaltyRecord { + id: string; + subscriberId: string; + points: number; + lifetimePoints: number; + tier: string; + streakCurrent: number; + streakLongest: number; + updatedAt: Date; +} + +// ── Domain-specific repository interfaces ───────────────────────────────────── + +export interface ISubscriptionRepository extends IRepository { + findByUserId(userId: string, opts?: QueryOptions): Promise>; + findByStatus(status: Subscription['status'], opts?: QueryOptions): Promise>; + findDueBefore(date: Date): Promise; +} + +export interface ITransactionRepository extends IRepository { + findBySubscriptionId(subscriptionId: string, opts?: QueryOptions): Promise>; + findByUserId(userId: string, opts?: QueryOptions): Promise>; + findByStatus(status: Transaction['status']): Promise; +} + +export interface IUserRepository extends IRepository { + findByAddress(address: string): Promise; + findByEmail(email: string): Promise; +} + +export interface IMerchantRepository extends IRepository { + findByAddress(address: string): Promise; + findByStatus(status: string): Promise; +} + +export interface ILoyaltyRepository extends IRepository { + findBySubscriberId(subscriberId: string): Promise; + findTopByPoints(limit: number): Promise; +} + +// ── Unit-of-work / transaction manager ─────────────────────────────────────── + +export interface IUnitOfWork { + subscriptions: ISubscriptionRepository; + transactions: ITransactionRepository; + users: IUserRepository; + merchants: IMerchantRepository; + loyalty: ILoyaltyRepository; + + /** Run a set of operations atomically. Rolls back on error. */ + run(work: (uow: IUnitOfWork) => Promise): Promise; +} diff --git a/src/components/gamification/LoyaltyComponents.tsx b/src/components/gamification/LoyaltyComponents.tsx new file mode 100644 index 0000000..040c343 --- /dev/null +++ b/src/components/gamification/LoyaltyComponents.tsx @@ -0,0 +1,320 @@ +/** + * Gamification components for the Loyalty Dashboard. + * Issue #394: streaks, achievements, rewards catalog, progress bars. + */ +import React, { useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + FlatList, + Share, + ScrollView, +} from 'react-native'; +import { useTheme } from '../../theme/useTheme'; +import { Card } from '../common/Card'; +import { Achievement, StreakData } from '../../store/loyaltyStore'; +import { Reward, LoyaltyTier } from '../../types/loyalty'; + +// ── StreakCard ─────────────────────────────────────────────────────────────── + +interface StreakCardProps { + streak: StreakData; + onShare?: () => void; +} + +export const StreakCard: React.FC = ({ streak, onShare }) => { + const theme = useTheme(); + + const handleShare = useCallback(async () => { + if (onShare) { onShare(); return; } + await Share.share({ + message: `🔥 I'm on a ${streak.current}-day payment streak on SubTrackr! My longest is ${streak.longest} days. Join me!`, + }); + }, [streak, onShare]); + + return ( + + + 🔥 + + + {streak.current} + + + day streak + + + + + Best + + + {streak.longest} + + + + {streak.frozenUntil && ( + + ❄️ Streak frozen until {streak.frozenUntil} + + )} + + Share 🔗 + + + ); +}; + +// ── AchievementCard ────────────────────────────────────────────────────────── + +interface AchievementCardProps { + achievement: Achievement; + onShare?: (achievement: Achievement) => void; +} + +export const AchievementCard: React.FC = ({ achievement, onShare }) => { + const theme = useTheme(); + const isUnlocked = !!achievement.unlockedAt; + + const handleShare = useCallback(async () => { + if (onShare) { onShare(achievement); return; } + if (!isUnlocked) return; + await Share.share({ + message: `${achievement.icon} I just unlocked "${achievement.name}" on SubTrackr! ${achievement.description}`, + }); + }, [achievement, isUnlocked, onShare]); + + return ( + + + {achievement.icon} + + + {achievement.name} + + + {achievement.description} + + {isUnlocked ? ( + + Share 🔗 + + ) : ( + Locked + )} + + ); +}; + +// ── TierProgressBar ────────────────────────────────────────────────────────── + +const TIER_THRESHOLDS: Record = { + [LoyaltyTier.BRONZE]: 0, + [LoyaltyTier.SILVER]: 1000, + [LoyaltyTier.GOLD]: 5000, + [LoyaltyTier.PLATINUM]: 15000, +}; + +const NEXT_TIER: Partial> = { + [LoyaltyTier.BRONZE]: LoyaltyTier.SILVER, + [LoyaltyTier.SILVER]: LoyaltyTier.GOLD, + [LoyaltyTier.GOLD]: LoyaltyTier.PLATINUM, +}; + +interface TierProgressBarProps { + currentTier: LoyaltyTier; + lifetimePoints: number; +} + +export const TierProgressBar: React.FC = ({ + currentTier, + lifetimePoints, +}) => { + const theme = useTheme(); + const nextTier = NEXT_TIER[currentTier]; + + if (!nextTier) { + return ( + + + 🏆 Maximum tier reached! + + + ); + } + + const from = TIER_THRESHOLDS[currentTier]; + const to = TIER_THRESHOLDS[nextTier]; + const progress = Math.min(1, Math.max(0, (lifetimePoints - from) / (to - from))); + + return ( + + + + {currentTier.toUpperCase()} → {nextTier.toUpperCase()} + + + {lifetimePoints.toLocaleString()} / {to.toLocaleString()} pts + + + + + + + {(to - lifetimePoints).toLocaleString()} pts to {nextTier} + + + ); +}; + +// ── RewardsCatalog ─────────────────────────────────────────────────────────── + +interface RewardsCatalogProps { + rewards: Reward[]; + currentPoints: number; + onRedeem: (rewardId: string) => void; +} + +export const RewardsCatalog: React.FC = ({ + rewards, + currentPoints, + onRedeem, +}) => { + const theme = useTheme(); + + const renderReward = useCallback( + ({ item }: { item: Reward }) => { + const canRedeem = item.isActive && currentPoints >= item.pointsCost; + return ( + + + {item.name} + + {item.pointsCost.toLocaleString()} pts + + + + {item.description} + + canRedeem && onRedeem(item.id)} + disabled={!canRedeem} + accessibilityLabel={`Redeem ${item.name}`} + accessibilityRole="button" + accessibilityState={{ disabled: !canRedeem }}> + + {canRedeem ? 'Redeem' : `Need ${(item.pointsCost - currentPoints).toLocaleString()} more`} + + + + ); + }, + [currentPoints, onRedeem, theme], + ); + + return ( + + Rewards Catalog + r.isActive)} + keyExtractor={(r) => r.id} + renderItem={renderReward} + scrollEnabled={false} + /> + + ); +}; + +// ── AchievementsList ───────────────────────────────────────────────────────── + +interface AchievementsListProps { + achievements: Achievement[]; +} + +export const AchievementsList: React.FC = ({ achievements }) => { + const theme = useTheme(); + return ( + + Achievements + + {achievements.map((a) => ( + + ))} + + + ); +}; + +// ── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + streakCard: { padding: 16, marginBottom: 12 }, + streakRow: { flexDirection: 'row', alignItems: 'center' }, + streakFlame: { fontSize: 36, marginRight: 12 }, + streakInfo: { flex: 1 }, + streakCount: { fontSize: 40, fontWeight: 'bold' }, + streakLabel: { fontSize: 14 }, + streakBest: { alignItems: 'flex-end' }, + streakBestLabel: { fontSize: 12 }, + streakBestCount: { fontSize: 20, fontWeight: 'bold' }, + frozenBadge: { marginTop: 8, fontSize: 13 }, + shareBtn: { marginTop: 12, borderWidth: 1, borderRadius: 8, padding: 8, alignItems: 'center' }, + shareBtnText: { fontWeight: '600' }, + + achievementCard: { width: 110, padding: 12, alignItems: 'center', marginRight: 10 }, + achievementIcon: { width: 52, height: 52, borderRadius: 26, justifyContent: 'center', alignItems: 'center', marginBottom: 8 }, + achievementEmoji: { fontSize: 26 }, + achievementName: { fontSize: 12, fontWeight: 'bold', textAlign: 'center' }, + achievementDesc: { fontSize: 10, textAlign: 'center', marginTop: 4 }, + shareSmall: { fontSize: 11, marginTop: 6 }, + lockedText: { fontSize: 10, marginTop: 6 }, + + progressContainer: { marginVertical: 12 }, + progressHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 }, + progressLabel: { fontSize: 14, fontWeight: '600' }, + progressPoints: { fontSize: 12 }, + barBg: { height: 10, borderRadius: 5, overflow: 'hidden' }, + barFg: { height: '100%' }, + progressRemaining: { fontSize: 11, marginTop: 4 }, + + catalogTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 12, marginTop: 8 }, + rewardCard: { padding: 14, marginBottom: 10 }, + rewardHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }, + rewardName: { fontSize: 16, fontWeight: '600' }, + rewardCost: { fontSize: 14, fontWeight: 'bold' }, + rewardDesc: { fontSize: 13, marginBottom: 10 }, + redeemBtn: { borderRadius: 8, padding: 10, alignItems: 'center' }, + redeemBtnText: { fontWeight: '600', fontSize: 14 }, +}); diff --git a/src/screens/LoyaltyDashboardScreen.tsx b/src/screens/LoyaltyDashboardScreen.tsx index 6300ec0..d51d81f 100644 --- a/src/screens/LoyaltyDashboardScreen.tsx +++ b/src/screens/LoyaltyDashboardScreen.tsx @@ -15,7 +15,13 @@ import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { useLoyaltyStore } from '../store/loyaltyStore'; import { useWalletStore } from '../store/walletStore'; import { Card } from '../components/common/Card'; -import { LoyaltyTier, RewardType, TierBenefits } from '../types/loyalty'; +import { LoyaltyTier, TierBenefits } from '../types/loyalty'; +import { + StreakCard, + AchievementsList, + TierProgressBar, + RewardsCatalog, +} from '../components/gamification/LoyaltyComponents'; const LoyaltyDashboardScreen: React.FC = () => { const { @@ -23,121 +29,115 @@ const LoyaltyDashboardScreen: React.FC = () => { transactions, rewards, program, + streak, + achievements, + newlyUnlocked, isLoading, initializeProgram, - accumulatePoints, redeemPoints, + clearNewlyUnlocked, + evaluateAchievements, } = useLoyaltyStore(); const { address } = useWalletStore(); const [modalVisible, setModalVisible] = useState(false); const [selectedReward, setSelectedReward] = useState(''); + // Initialize program on mount useEffect(() => { - if (!program) { - initializeProgram(); - } + if (!program) initializeProgram(); }, [program, initializeProgram]); + // Retroactive achievement evaluation on mount useEffect(() => { - if (address && loyaltyStatus) { - const timer = setInterval(() => { - useLoyaltyStore.getState().checkTierUpgrade(); - }, 60000); - return () => clearInterval(timer); - } + evaluateAchievements(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Tier upgrade check every minute + useEffect(() => { + if (!address || !loyaltyStatus) return; + const timer = setInterval(() => useLoyaltyStore.getState().checkTierUpgrade(), 60_000); + return () => clearInterval(timer); }, [address, loyaltyStatus]); + // Show newly unlocked achievement toasts + useEffect(() => { + if (newlyUnlocked.length === 0) return; + const names = newlyUnlocked.map((a) => `${a.icon} ${a.name}`).join('\n'); + Alert.alert('Achievement Unlocked! 🎉', names); + clearNewlyUnlocked(); + }, [newlyUnlocked, clearNewlyUnlocked]); + const handleRedeemReward = useCallback(async () => { if (!selectedReward) { Alert.alert('Error', 'Please select a reward'); return; } - const success = await redeemPoints(selectedReward); - if (success) { - Alert.alert('Success', 'Reward redeemed successfully!'); - } else { - Alert.alert('Error', 'Not enough points or reward unavailable'); - } + Alert.alert(success ? 'Success' : 'Error', success ? 'Reward redeemed!' : 'Not enough points or reward unavailable'); setModalVisible(false); setSelectedReward(''); }, [selectedReward, redeemPoints]); const getTierColor = (tier: LoyaltyTier): string => { switch (tier) { - case LoyaltyTier.PLATINUM: - return '#E5E4E2'; - case LoyaltyTier.GOLD: - return '#FFD700'; - case LoyaltyTier.SILVER: - return '#C0C0C0'; - default: - return '#CD7F32'; + case LoyaltyTier.PLATINUM: return '#E5E4E2'; + case LoyaltyTier.GOLD: return '#FFD700'; + case LoyaltyTier.SILVER: return '#C0C0C0'; + default: return '#CD7F32'; } }; const getNextTierInfo = (): TierBenefits | null => { if (!program || !loyaltyStatus) return null; - const currentTierIndex = program.tiers.findIndex(t => t.tier === loyaltyStatus.tier); - if (currentTierIndex >= program.tiers.length - 1) return null; - return program.tiers[currentTierIndex + 1]; + const idx = program.tiers.findIndex((t) => t.tier === loyaltyStatus.tier); + return idx >= program.tiers.length - 1 ? null : program.tiers[idx + 1]; }; + // ── Render helpers ───────────────────────────────────────────────────────── + const renderStatusCard = () => { if (!loyaltyStatus) { return ( No loyalty status yet - - Start subscribing to earn rewards! - + Start subscribing to earn rewards! ); } const nextTier = getNextTierInfo(); - const pointsToNextTier = nextTier - ? nextTier.pointsThreshold - loyaltyStatus.lifetimePoints - : 0; + const pointsToNextTier = nextTier ? nextTier.pointsThreshold - loyaltyStatus.lifetimePoints : 0; return ( - - - {loyaltyStatus.tier.toUpperCase()} - + + {loyaltyStatus.tier.toUpperCase()} - Member since{' '} - {new Date(loyaltyStatus.memberSince).toLocaleDateString()} + Member since {new Date(loyaltyStatus.memberSince).toLocaleDateString()} - {loyaltyStatus.points} + {loyaltyStatus.points.toLocaleString()} Available Points + {/* Tier progress bar */} + + {nextTier && pointsToNextTier > 0 && ( - - - {pointsToNextTier.toLocaleString()} points to {nextTier.tier} - - @@ -146,15 +146,11 @@ const LoyaltyDashboardScreen: React.FC = () => { - - {loyaltyStatus.lifetimePoints.toLocaleString()} - + {loyaltyStatus.lifetimePoints.toLocaleString()} Lifetime Points - - ${loyaltyStatus.totalSpent.toFixed(0)} - + ${loyaltyStatus.totalSpent.toFixed(0)} Total Spent @@ -162,42 +158,6 @@ const LoyaltyDashboardScreen: React.FC = () => { ); }; - const renderRewardsCard = () => ( - - - Available Rewards - setModalVisible(true)} - accessibilityRole="button" - accessibilityLabel="Redeem rewards"> - Redeem → - - - - r.isActive)} - keyExtractor={(item) => item.id} - renderItem={({ item: reward }) => ( - { - setSelectedReward(reward.id); - setModalVisible(true); - }}> - - {reward.name} - {reward.description} - - - {reward.pointsCost} - pts - - - )} - /> - - ); - const renderTransactionsCard = () => ( Points History @@ -212,13 +172,8 @@ const LoyaltyDashboardScreen: React.FC = () => { {new Date(tx.createdAt).toLocaleDateString()} - 0 ? styles.positiveAmount : styles.negativeAmount, - ]}> - {tx.amount > 0 ? '+' : ''} - {tx.amount} pts + 0 ? styles.positiveAmount : styles.negativeAmount]}> + {tx.amount > 0 ? '+' : ''}{tx.amount} pts )) @@ -226,7 +181,7 @@ const LoyaltyDashboardScreen: React.FC = () => { ); - const renderMembers = () => { + const renderTierBenefits = () => { if (!program) return null; return ( @@ -234,17 +189,10 @@ const LoyaltyDashboardScreen: React.FC = () => { {program.tiers.map((tier) => ( - + {tier.tier.toUpperCase()} - - {tier.pointsThreshold.toLocaleString()} pts - + {tier.pointsThreshold.toLocaleString()} pts ))} @@ -270,41 +218,61 @@ const LoyaltyDashboardScreen: React.FC = () => { Earn points, unlock rewards + {/* Status + tier progress */} {renderStatusCard()} - {renderRewardsCard()} + + {/* Streak card */} + + + + + {/* Achievements */} + + + + + {/* Rewards catalog with inline redemption */} + + { + setSelectedReward(id); + setModalVisible(true); + }} + /> + + + {/* Points history */} {renderTransactionsCard()} - {renderMembers()} + + {/* Tier benefits */} + {renderTierBenefits()} + {/* Redemption confirmation modal */} setModalVisible(false)}> Redeem Reward - - Select a reward to redeem your points - + Select a reward to redeem your points r.isActive)} keyExtractor={(item) => item.id} renderItem={({ item: reward }) => ( setSelectedReward(reward.id)}> {reward.name} {reward.description} - - {reward.pointsCost} pts - + {reward.pointsCost} pts )} /> @@ -312,15 +280,10 @@ const LoyaltyDashboardScreen: React.FC = () => { { - setModalVisible(false); - setSelectedReward(''); - }}> + onPress={() => { setModalVisible(false); setSelectedReward(''); }}> Cancel - + Redeem @@ -332,345 +295,62 @@ const LoyaltyDashboardScreen: React.FC = () => { }; const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background, - }, + container: { flex: 1, backgroundColor: colors.background }, scrollView: { flex: 1 }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - loadingText: { - marginTop: spacing.sm, - color: colors.textSecondary, - fontSize: typography.fontSizeMd, - }, - header: { - padding: spacing.md, - paddingTop: spacing.lg, - }, - title: { - fontSize: typography.fontSizeXl, - fontWeight: typography.fontWeightBold, - color: colors.text, - }, - subtitle: { - fontSize: typography.fontSizeMd, - color: colors.textSecondary, - marginTop: spacing.xs, - }, - statusCard: { - padding: spacing.md, - margin: spacing.md, - marginTop: 0, - }, - tierHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.md, - }, - tierBadge: { - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs, - borderRadius: borderRadius.md, - }, - tierBadgeText: { - color: colors.text, - fontSize: typography.fontSizeSm, - fontWeight: typography.fontWeightBold, - }, - memberSince: { - fontSize: typography.fontSizeSm, - color: colors.textSecondary, - }, - pointsDisplay: { - alignItems: 'center', - paddingVertical: spacing.lg, - }, - pointsValue: { - fontSize: 48, - fontWeight: typography.fontWeightBold, - color: colors.text, - }, - pointsLabel: { - fontSize: typography.fontSizeMd, - color: colors.textSecondary, - marginTop: spacing.xs, - }, - progressSection: { - marginTop: spacing.md, - }, - progressHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: spacing.xs, - }, - progressText: { - fontSize: typography.fontSizeSm, - color: colors.textSecondary, - }, - progressBar: { - height: 8, - backgroundColor: colors.border, - borderRadius: 4, - overflow: 'hidden', - }, - progressFill: { - height: '100%', - backgroundColor: colors.primary, - }, - statsGrid: { - flexDirection: 'row', - justifyContent: 'space-around', - paddingTop: spacing.lg, - borderTopWidth: 1, - borderTopColor: colors.border, - marginTop: spacing.lg, - }, - statItem: { - alignItems: 'center', - }, - statValue: { - fontSize: typography.fontSizeLg, - fontWeight: typography.fontWeightBold, - color: colors.text, - }, - statLabel: { - fontSize: typography.fontSizeSm, - color: colors.textSecondary, - marginTop: spacing.xs, - }, - rewardsCard: { - padding: spacing.md, - margin: spacing.md, - marginTop: 0, - }, - rewardsHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.md, - }, - rewardsTitle: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, - color: colors.text, - }, - redeemLink: { - fontSize: typography.fontSizeMd, - color: colors.primary, - fontWeight: typography.fontWeightMedium, - }, - rewardItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: spacing.md, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - rewardInfo: { - flex: 1, - }, - rewardName: { - fontSize: typography.fontSizeMd, - color: colors.text, - fontWeight: typography.fontWeightMedium, - }, - rewardDesc: { - fontSize: typography.fontSizeSm, - color: colors.textSecondary, - marginTop: spacing.xs, - }, - rewardCost: { - alignItems: 'flex-end', - }, - costValue: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, - color: colors.primary, - }, - costLabel: { - fontSize: typography.fontSizeXs, - color: colors.textSecondary, - }, - transactionsCard: { - padding: spacing.md, - margin: spacing.md, - marginTop: 0, - }, - transactionsTitle: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, - color: colors.text, - marginBottom: spacing.md, - }, - transactionItem: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingVertical: spacing.sm, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - transactionInfo: { - flex: 1, - }, - transactionDesc: { - fontSize: typography.fontSizeSm, - color: colors.text, - }, - transactionDate: { - fontSize: typography.fontSizeXs, - color: colors.textSecondary, - marginTop: spacing.xs, - }, - transactionAmount: { - fontSize: typography.fontSizeSm, - fontWeight: typography.fontWeightBold, - }, - positiveAmount: { - color: colors.success, - }, - negativeAmount: { - color: colors.danger, - }, - membersCard: { - padding: spacing.md, - margin: spacing.md, - marginTop: 0, - marginBottom: spacing.lg, - }, - membersTitle: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, - color: colors.text, - marginBottom: spacing.md, - }, - tierItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: spacing.sm, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - tierInfo: { - flexDirection: 'row', - alignItems: 'center', - }, - tierDot: { - width: 12, - height: 12, - borderRadius: 6, - marginRight: spacing.sm, - }, - tierName: { - fontSize: typography.fontSizeSm, - color: colors.text, - fontWeight: typography.fontWeightMedium, - }, - tierThreshold: { - fontSize: typography.fontSizeSm, - color: colors.textSecondary, - }, - emptyText: { - fontSize: typography.fontSizeMd, - color: colors.textSecondary, - textAlign: 'center', - }, - emptySubtext: { - fontSize: typography.fontSizeSm, - color: colors.textSecondary, - textAlign: 'center', - marginTop: spacing.xs, - }, - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - justifyContent: 'flex-end', - }, - modalContent: { - backgroundColor: colors.surface, - borderTopLeftRadius: borderRadius.lg, - borderTopRightRadius: borderRadius.lg, - padding: spacing.lg, - maxHeight: '70%', - }, - modalTitle: { - fontSize: typography.fontSizeLg, - fontWeight: typography.fontWeightBold, - color: colors.text, - marginBottom: spacing.xs, - }, - modalSubtitle: { - fontSize: typography.fontSizeMd, - color: colors.textSecondary, - marginBottom: spacing.lg, - }, - rewardOption: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: spacing.md, - borderRadius: borderRadius.md, - marginBottom: spacing.sm, - backgroundColor: colors.background, - }, - rewardOptionSelected: { - borderWidth: 2, - borderColor: colors.primary, - }, - rewardOptionInfo: { - flex: 1, - }, - rewardOptionName: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightMedium, - color: colors.text, - }, - rewardOptionDesc: { - fontSize: typography.fontSizeSm, - color: colors.textSecondary, - marginTop: spacing.xs, - }, - rewardOptionCost: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, - color: colors.primary, - }, - modalButtons: { - flexDirection: 'row', - justifyContent: 'space-between', - marginTop: spacing.lg, - gap: spacing.md, - }, - cancelButton: { - flex: 1, - backgroundColor: colors.background, - borderRadius: borderRadius.md, - padding: spacing.md, - alignItems: 'center', - }, - cancelButtonText: { - color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightMedium, - }, - confirmButton: { - flex: 1, - backgroundColor: colors.primary, - borderRadius: borderRadius.md, - padding: spacing.md, - alignItems: 'center', - }, - confirmButtonText: { - color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, - }, + loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + loadingText: { marginTop: spacing.sm, color: colors.textSecondary, fontSize: typography.fontSizeMd }, + header: { padding: spacing.md, paddingTop: spacing.lg }, + title: { fontSize: typography.fontSizeXl, fontWeight: typography.fontWeightBold, color: colors.text }, + subtitle: { fontSize: typography.fontSizeMd, color: colors.textSecondary, marginTop: spacing.xs }, + section: { marginHorizontal: spacing.md, marginBottom: spacing.sm }, + statusCard: { padding: spacing.md, margin: spacing.md, marginTop: 0 }, + tierHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: spacing.md }, + tierBadge: { paddingHorizontal: spacing.md, paddingVertical: spacing.xs, borderRadius: borderRadius.md }, + tierBadgeText: { color: colors.text, fontSize: typography.fontSizeSm, fontWeight: typography.fontWeightBold }, + memberSince: { fontSize: typography.fontSizeSm, color: colors.textSecondary }, + pointsDisplay: { alignItems: 'center', paddingVertical: spacing.lg }, + pointsValue: { fontSize: 48, fontWeight: typography.fontWeightBold, color: colors.text }, + pointsLabel: { fontSize: typography.fontSizeMd, color: colors.textSecondary, marginTop: spacing.xs }, + progressSection: { marginTop: spacing.md }, + progressBar: { height: 8, backgroundColor: colors.border, borderRadius: 4, overflow: 'hidden' }, + progressFill: { height: '100%', backgroundColor: colors.primary }, + statsGrid: { flexDirection: 'row', justifyContent: 'space-around', paddingTop: spacing.lg, borderTopWidth: 1, borderTopColor: colors.border, marginTop: spacing.lg }, + statItem: { alignItems: 'center' }, + statValue: { fontSize: typography.fontSizeLg, fontWeight: typography.fontWeightBold, color: colors.text }, + statLabel: { fontSize: typography.fontSizeSm, color: colors.textSecondary, marginTop: spacing.xs }, + transactionsCard: { padding: spacing.md, margin: spacing.md, marginTop: 0 }, + transactionsTitle: { fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.md }, + transactionItem: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.border }, + transactionInfo: { flex: 1 }, + transactionDesc: { fontSize: typography.fontSizeSm, color: colors.text }, + transactionDate: { fontSize: typography.fontSizeXs, color: colors.textSecondary, marginTop: spacing.xs }, + transactionAmount: { fontSize: typography.fontSizeSm, fontWeight: typography.fontWeightBold }, + positiveAmount: { color: colors.success }, + negativeAmount: { color: colors.danger }, + membersCard: { padding: spacing.md, margin: spacing.md, marginTop: 0, marginBottom: spacing.lg }, + membersTitle: { fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.md }, + tierItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.border }, + tierInfo: { flexDirection: 'row', alignItems: 'center' }, + tierDot: { width: 12, height: 12, borderRadius: 6, marginRight: spacing.sm }, + tierName: { fontSize: typography.fontSizeSm, color: colors.text, fontWeight: typography.fontWeightMedium }, + tierThreshold: { fontSize: typography.fontSizeSm, color: colors.textSecondary }, + emptyText: { fontSize: typography.fontSizeMd, color: colors.textSecondary, textAlign: 'center' }, + emptySubtext: { fontSize: typography.fontSizeSm, color: colors.textSecondary, textAlign: 'center', marginTop: spacing.xs }, + modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, + modalContent: { backgroundColor: colors.surface, borderTopLeftRadius: borderRadius.lg, borderTopRightRadius: borderRadius.lg, padding: spacing.lg, maxHeight: '70%' }, + modalTitle: { fontSize: typography.fontSizeLg, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.xs }, + modalSubtitle: { fontSize: typography.fontSizeMd, color: colors.textSecondary, marginBottom: spacing.lg }, + rewardOption: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: spacing.md, borderRadius: borderRadius.md, marginBottom: spacing.sm, backgroundColor: colors.background }, + rewardOptionSelected: { borderWidth: 2, borderColor: colors.primary }, + rewardOptionInfo: { flex: 1 }, + rewardOptionName: { fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightMedium, color: colors.text }, + rewardOptionDesc: { fontSize: typography.fontSizeSm, color: colors.textSecondary, marginTop: spacing.xs }, + rewardOptionCost: { fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold, color: colors.primary }, + modalButtons: { flexDirection: 'row', justifyContent: 'space-between', marginTop: spacing.lg, gap: spacing.md }, + cancelButton: { flex: 1, backgroundColor: colors.background, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center' }, + cancelButtonText: { color: colors.text, fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightMedium }, + confirmButton: { flex: 1, backgroundColor: colors.primary, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center' }, + confirmButtonText: { color: colors.text, fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold }, }); -export default LoyaltyDashboardScreen; \ No newline at end of file +export default LoyaltyDashboardScreen; diff --git a/src/screens/MerchantOnboardingScreen.tsx b/src/screens/MerchantOnboardingScreen.tsx index 364dee4..3be9ec3 100644 --- a/src/screens/MerchantOnboardingScreen.tsx +++ b/src/screens/MerchantOnboardingScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { View, Text, @@ -9,6 +9,7 @@ import { TextInput, Alert, ActivityIndicator, + Modal, } from 'react-native'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { useMerchantStore } from '../store/merchantStore'; @@ -19,11 +20,32 @@ import { RootStackParamList } from '../navigation/types'; import { OnboardingStep, OnboardingStatus, - VerificationTier, DocumentType, MerchantOnboardingFormData, } from '../types/merchant'; +// ── Welcome tour steps ──────────────────────────────────────────────────────── + +const TOUR_STEPS = [ + { icon: '🏢', title: 'Business Info', desc: 'Tell us about your business' }, + { icon: '📄', title: 'Documents', desc: 'Upload ID and business license' }, + { icon: '💳', title: 'Payment Setup', desc: 'Configure how you get paid' }, + { icon: '✅', title: 'Review', desc: 'Submit for compliance screening' }, +]; + +// ── Status colors ───────────────────────────────────────────────────────────── + +const STATUS_COLORS: Record = { + [OnboardingStatus.VERIFIED]: colors.success, + [OnboardingStatus.REJECTED]: colors.danger, + [OnboardingStatus.PENDING_REVIEW]: colors.warning, + [OnboardingStatus.IN_PROGRESS]: colors.primary, + [OnboardingStatus.NOT_STARTED]: colors.textSecondary, + [OnboardingStatus.EXPIRED]: colors.danger, +}; + +// ── Component ───────────────────────────────────────────────────────────────── + const MerchantOnboardingScreen: React.FC = () => { const navigation = useNavigation>(); const { @@ -31,10 +53,16 @@ const MerchantOnboardingScreen: React.FC = () => { isLoading, error, startOnboarding, + saveProgress, submitDocument, + retryRejectedDocument, nextStep, previousStep, + runComplianceScreening, + configurePayment, requestVerification, + completeWelcomeTour, + canResume, } = useMerchantStore(); const [formData, setFormData] = useState({ @@ -44,52 +72,125 @@ const MerchantOnboardingScreen: React.FC = () => { phoneNumber: '', email: '', }); + const [showTour, setShowTour] = useState(false); + const [tourStep, setTourStep] = useState(0); + const [walletAddress, setWalletAddress] = useState(''); + + // Pre-fill form from saved progress on mount + useEffect(() => { + if (onboarding?.formData) { + setFormData((prev) => ({ ...prev, ...(onboarding.formData as MerchantOnboardingFormData) })); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Show error alerts + useEffect(() => { + if (error) Alert.alert('Error', error); + }, [error]); + + // ── Handlers ──────────────────────────────────────────────────────────────── const handleStartOnboarding = useCallback(async () => { if (!formData.businessName || !formData.email) { - Alert.alert('Error', 'Please fill in required fields'); + Alert.alert('Error', 'Business name and email are required'); return; } - await startOnboarding(formData); - }, [formData, startOnboarding]); + if (canResume()) { + Alert.alert( + 'Resume Onboarding', + 'You have an incomplete onboarding session. Would you like to resume it?', + [ + { text: 'Start Fresh', onPress: () => startOnboarding(formData) }, + { text: 'Resume', onPress: () => startOnboarding(formData) }, + ], + ); + } else { + await startOnboarding(formData); + setShowTour(true); + } + }, [formData, startOnboarding, canResume]); + + const handleSaveAndExit = useCallback(() => { + saveProgress(formData); + Alert.alert('Saved', 'Your progress has been saved. You can resume later.'); + navigation.goBack(); + }, [formData, saveProgress, navigation]); const handleDocumentUpload = useCallback( async (docType: DocumentType) => { - await submitDocument(docType, `doc_${Date.now()}`); - Alert.alert('Success', 'Document uploaded successfully'); + // In production: launch image picker and get real URI + const mockUri = `file://doc_${docType}_${Date.now()}.jpg`; + await submitDocument(docType, mockUri); + Alert.alert('Uploaded', 'Document submitted for review'); }, - [submitDocument] + [submitDocument], ); + const handleRetryDocument = useCallback( + async (docId: string) => { + const mockUri = `file://retry_${docId}_${Date.now()}.jpg`; + await retryRejectedDocument(docId, mockUri); + Alert.alert('Re-uploaded', 'Document resubmitted'); + }, + [retryRejectedDocument], + ); + + const handleConfigurePayment = useCallback(() => { + if (!walletAddress) { + Alert.alert('Error', 'Please enter a Stellar wallet address'); + return; + } + configurePayment({ method: 'stellar_xlm', walletAddress }); + Alert.alert('Saved', 'Payment method configured'); + }, [walletAddress, configurePayment]); + + const handleRunCompliance = useCallback(async () => { + try { + const result = await runComplianceScreening(); + if (!result.passed) { + Alert.alert( + 'Compliance Failed', + result.sanctionsHit + ? 'Your country is on the sanctions list. We cannot proceed.' + : 'Compliance check failed. Please contact support.', + ); + } else { + Alert.alert('Compliance Passed', 'Your business passed all compliance checks ✅'); + } + } catch { + Alert.alert('Error', 'Compliance check failed. Please try again.'); + } + }, [runComplianceScreening]); + + const handleNextStep = useCallback(async () => { + saveProgress(formData); + await nextStep(); + }, [formData, saveProgress, nextStep]); + + const handleTourFinish = useCallback(() => { + setShowTour(false); + completeWelcomeTour(); + }, [completeWelcomeTour]); + + // ── Render helpers ────────────────────────────────────────────────────────── + const renderStepIndicator = () => { if (!onboarding) return null; - return ( {onboarding.steps.map((step, index) => { + const currentIdx = onboarding.steps.indexOf(onboarding.currentStep); const isActive = step === onboarding.currentStep; - const isCompleted = onboarding.steps.indexOf(onboarding.currentStep) > index; - + const isCompleted = currentIdx > index; return ( - - + + {isCompleted ? '✓' : index + 1} - - {step.replace('_', ' ')} + + {step.replace(/_/g, ' ')} ); @@ -101,184 +202,168 @@ const MerchantOnboardingScreen: React.FC = () => { const renderBusinessInfoStep = () => ( Business Information - - Business Name * - setFormData({ ...formData, businessName: text })} - placeholder="Enter business name" - placeholderTextColor={colors.textSecondary} - /> - - - Business Type - setFormData({ ...formData, businessType: text })} - placeholder="e.g., LLC, Corporation" - placeholderTextColor={colors.textSecondary} - /> - - - Country - setFormData({ ...formData, country: text })} - placeholder="Enter country" - placeholderTextColor={colors.textSecondary} - /> - - - Phone Number - setFormData({ ...formData, phoneNumber: text })} - placeholder="Enter phone number" - placeholderTextColor={colors.textSecondary} - keyboardType="phone-pad" - /> - - - Email * - setFormData({ ...formData, email: text })} - placeholder="Enter email" - placeholderTextColor={colors.textSecondary} - keyboardType="email-address" - autoCapitalize="none" - /> - + {(['businessName', 'businessType', 'country', 'phoneNumber', 'email'] as const).map((field) => ( + + + {field.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase())} + {(field === 'businessName' || field === 'email') ? ' *' : ''} + + setFormData((prev) => ({ ...prev, [field]: text }))} + placeholder={`Enter ${field.replace(/([A-Z])/g, ' $1').toLowerCase()}`} + placeholderTextColor={colors.textSecondary} + keyboardType={field === 'email' ? 'email-address' : field === 'phoneNumber' ? 'phone-pad' : 'default'} + autoCapitalize={field === 'email' ? 'none' : 'words'} + /> + + ))} ); - const renderDocumentStep = () => ( - - Document Upload - - Please upload the required documents for verification - - - handleDocumentUpload(DocumentType.ID_FRONT)} - accessibilityRole="button" - accessibilityLabel="Upload ID document front"> - 📄 - ID Document (Front) - Tap to upload - + const renderDocumentStep = () => { + const docTypes = + onboarding?.currentStep === OnboardingStep.ID_DOCUMENT + ? [DocumentType.ID_FRONT, DocumentType.ID_BACK] + : [DocumentType.BUSINESS_LICENSE]; - handleDocumentUpload(DocumentType.ID_BACK)} - accessibilityRole="button" - accessibilityLabel="Upload ID document back"> - 📄 - ID Document (Back) - Tap to upload - + return ( + + Document Upload + Upload clear photos of the required documents + {docTypes.map((docType) => { + const uploaded = onboarding?.documents.find((d) => d.type === docType); + const isRejected = uploaded?.status === 'rejected'; + return ( + isRejected && uploaded ? handleRetryDocument(uploaded.id) : handleDocumentUpload(docType)} + accessibilityRole="button" + accessibilityLabel={`Upload ${docType.replace(/_/g, ' ')}`}> + {uploaded ? (isRejected ? '❌' : '✅') : '📄'} + {docType.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())} + + {isRejected ? 'Tap to re-upload' : uploaded ? 'Uploaded — pending review' : 'Tap to upload'} + + + ); + })} + + ); + }; - handleDocumentUpload(DocumentType.BUSINESS_LICENSE)} - accessibilityRole="button" - accessibilityLabel="Upload business license"> - 🏢 - Business License - Tap to upload - + const renderPaymentStep = () => ( + + Payment Setup + Configure how you receive subscription payments + {onboarding?.paymentSetup ? ( + + ✅ Payment configured: {onboarding.paymentSetup.method} + {onboarding.paymentSetup.walletAddress && ( + {onboarding.paymentSetup.walletAddress} + )} + + ) : ( + <> + + Stellar Wallet Address * + + + + Save Payment Method + + + )} + + ); + + const renderComplianceStep = () => ( + + Compliance Screening + We run sanctions and PEP checks to keep the platform safe. + {onboarding?.compliance ? ( + + + Result + + {onboarding.compliance.passed ? '✅ Passed' : '❌ Failed'} + + + + Sanctions + {onboarding.compliance.sanctionsHit ? 'Hit' : 'Clear'} + + + PEP + {onboarding.compliance.pepHit ? 'Hit' : 'Clear'} + + + ) : ( + + Run Compliance Check + + )} ); const renderReviewStep = () => ( Review & Submit - - Review your information and submit for verification - - - - Business Name - {formData.businessName} - - - Business Type - {formData.businessType || 'N/A'} - - - Country - {formData.country || 'N/A'} - - - Email - {formData.email} - - - Documents - - {onboarding?.documents.length || 0} uploaded - - + {([ + ['Business Name', onboarding?.formData?.businessName], + ['Business Type', onboarding?.formData?.businessType || 'N/A'], + ['Country', onboarding?.formData?.country || 'N/A'], + ['Email', onboarding?.formData?.email], + ['Documents', `${onboarding?.documents.length ?? 0} uploaded`], + ['Payment', onboarding?.paymentSetup ? onboarding.paymentSetup.method : 'Not configured'], + ['Compliance', onboarding?.compliance ? (onboarding.compliance.passed ? '✅ Passed' : '❌ Failed') : 'Not run'], + ] as [string, string | undefined][]).map(([label, value]) => ( + + {label} + {value ?? '—'} + + ))} - - + {onboarding?.verificationDeadline && ( + + ⏱ Verification deadline: {new Date(onboarding.verificationDeadline).toLocaleDateString()} + + )} + Submit for Verification ); - const renderStatus = () => { + const renderStatusCard = () => { if (!onboarding) return null; - - const statusColors: Record = { - [OnboardingStatus.VERIFIED]: colors.success, - [OnboardingStatus.REJECTED]: colors.danger, - [OnboardingStatus.PENDING_REVIEW]: colors.warning, - [OnboardingStatus.IN_PROGRESS]: colors.primary, - }; - return ( Verification Status - - {onboarding.status.replace('_', ' ')} + + {onboarding.status.replace(/_/g, ' ')} {onboarding.verificationResult && ( <> - Verification Tier - - {onboarding.verificationResult.tier} - + Tier + {onboarding.verificationResult.tier} Monthly Limit - - ${onboarding.verificationResult.limits.monthlyVolume.toLocaleString()} - - - - Max Transactions - - {onboarding.verificationResult.limits.maxTransactions.toLocaleString()} - + ${onboarding.verificationResult.limits.monthlyVolume.toLocaleString()} )} @@ -286,12 +371,25 @@ const MerchantOnboardingScreen: React.FC = () => { ); }; + const renderCurrentStep = () => { + if (!onboarding) return null; + switch (onboarding.currentStep) { + case OnboardingStep.BUSINESS_INFO: return renderBusinessInfoStep(); + case OnboardingStep.ID_DOCUMENT: return renderDocumentStep(); + case OnboardingStep.BUSINESS_LICENSE: return renderDocumentStep(); + case OnboardingStep.REVIEW: return renderReviewStep(); + default: return null; + } + }; + + // ── Main render ───────────────────────────────────────────────────────────── + if (isLoading) { return ( - Loading... + Processing... ); @@ -302,40 +400,27 @@ const MerchantOnboardingScreen: React.FC = () => { Merchant Onboarding - - Complete verification to start accepting payments - + Complete verification to start accepting payments {onboarding ? ( <> {renderStepIndicator()} - {renderStatus()} - {onboarding.currentStep === OnboardingStep.BUSINESS_INFO && - renderBusinessInfoStep()} - {onboarding.currentStep === OnboardingStep.ID_DOCUMENT && - renderDocumentStep()} - {onboarding.currentStep === OnboardingStep.BUSINESS_LICENSE && - renderDocumentStep()} - {onboarding.currentStep === OnboardingStep.REVIEW && renderReviewStep()} + {renderStatusCard()} + {renderCurrentStep()} {onboarding.steps.indexOf(onboarding.currentStep) > 0 && ( - + Back )} + + Save & Exit + {onboarding.currentStep !== OnboardingStep.REVIEW && ( - - Next + + Next → )} @@ -344,269 +429,138 @@ const MerchantOnboardingScreen: React.FC = () => { Get Started - Complete our merchant verification process to start accepting - subscription payments + Complete our merchant verification process to start accepting subscription payments on Stellar. - + + Business Name * + setFormData((p) => ({ ...p, businessName: t }))} + placeholder="Enter business name" + placeholderTextColor={colors.textSecondary} + /> + + + Email * + setFormData((p) => ({ ...p, email: t }))} + placeholder="Enter email" + placeholderTextColor={colors.textSecondary} + keyboardType="email-address" + autoCapitalize="none" + /> + + Start Onboarding )} + + {/* Welcome tour modal */} + + + + {TOUR_STEPS[tourStep].icon} + {TOUR_STEPS[tourStep].title} + {TOUR_STEPS[tourStep].desc} + + {TOUR_STEPS.map((_, i) => ( + + ))} + + + + Skip + + { + if (tourStep < TOUR_STEPS.length - 1) setTourStep((s) => s + 1); + else handleTourFinish(); + }} + accessibilityRole="button"> + + {tourStep < TOUR_STEPS.length - 1 ? 'Next' : 'Get Started'} + + + + + + ); }; +// ── Styles ──────────────────────────────────────────────────────────────────── + const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background, - }, - scrollView: { - flex: 1, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - loadingText: { - marginTop: spacing.sm, - color: colors.textSecondary, - fontSize: typography.fontSizeMd, - }, - header: { - padding: spacing.md, - paddingTop: spacing.lg, - }, - title: { - fontSize: typography.fontSizeXl, - fontWeight: typography.fontWeightBold, - color: colors.text, - }, - subtitle: { - fontSize: typography.fontSizeMd, - color: colors.textSecondary, - marginTop: spacing.xs, - }, - stepIndicator: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: spacing.md, - marginBottom: spacing.md, - }, - stepItem: { - alignItems: 'center', - flex: 1, - }, - stepCircle: { - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: colors.border, - justifyContent: 'center', - alignItems: 'center', - }, - stepCircleActive: { - backgroundColor: colors.primary, - }, - stepCircleCompleted: { - backgroundColor: colors.success, - }, - stepNumber: { - color: colors.textSecondary, - fontSize: typography.fontSizeSm, - fontWeight: typography.fontWeightBold, - }, - stepNumberActive: { - color: colors.text, - }, - stepLabel: { - marginTop: spacing.xs, - fontSize: typography.fontSizeXs, - color: colors.textSecondary, - }, - stepLabelActive: { - color: colors.primary, - fontWeight: typography.fontWeightBold, - }, - stepContent: { - padding: spacing.md, - }, - sectionTitle: { - fontSize: typography.fontSizeLg, - fontWeight: typography.fontWeightBold, - color: colors.text, - marginBottom: spacing.md, - }, - stepDescription: { - fontSize: typography.fontSizeMd, - color: colors.textSecondary, - marginBottom: spacing.md, - }, - inputGroup: { - marginBottom: spacing.md, - }, - inputLabel: { - fontSize: typography.fontSizeSm, - color: colors.textSecondary, - marginBottom: spacing.xs, - }, - input: { - backgroundColor: colors.surface, - borderRadius: borderRadius.md, - padding: spacing.md, - fontSize: typography.fontSizeMd, - color: colors.text, - borderWidth: 1, - borderColor: colors.border, - }, - uploadBox: { - backgroundColor: colors.surface, - borderRadius: borderRadius.md, - padding: spacing.lg, - alignItems: 'center', - marginBottom: spacing.md, - borderWidth: 1, - borderColor: colors.border, - borderStyle: 'dashed', - }, - uploadIcon: { - fontSize: 32, - marginBottom: spacing.sm, - }, - uploadText: { - fontSize: typography.fontSizeMd, - color: colors.text, - fontWeight: typography.fontWeightMedium, - }, - uploadHint: { - fontSize: typography.fontSizeSm, - color: colors.textSecondary, - marginTop: spacing.xs, - }, - summaryCard: { - padding: spacing.md, - marginBottom: spacing.md, - }, - summaryRow: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingVertical: spacing.sm, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - summaryLabel: { - fontSize: typography.fontSizeSm, - color: colors.textSecondary, - }, - summaryValue: { - fontSize: typography.fontSizeSm, - color: colors.text, - fontWeight: typography.fontWeightMedium, - }, - submitButton: { - backgroundColor: colors.primary, - borderRadius: borderRadius.md, - padding: spacing.md, - alignItems: 'center', - }, - submitButtonText: { - color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, - }, - navigationButtons: { - flexDirection: 'row', - justifyContent: 'space-between', - padding: spacing.md, - gap: spacing.md, - }, - backButton: { - flex: 1, - backgroundColor: colors.surface, - borderRadius: borderRadius.md, - padding: spacing.md, - alignItems: 'center', - marginRight: spacing.sm, - }, - backButtonText: { - color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightMedium, - }, - nextButton: { - flex: 1, - backgroundColor: colors.primary, - borderRadius: borderRadius.md, - padding: spacing.md, - alignItems: 'center', - }, - nextButtonText: { - color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, - }, - statusCard: { - padding: spacing.md, - margin: spacing.md, - marginTop: 0, - }, - statusTitle: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, - color: colors.text, - marginBottom: spacing.sm, - }, - statusRow: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.sm, - }, - statusBadge: { - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs, - borderRadius: borderRadius.md, - }, - statusBadgeText: { - color: colors.text, - fontSize: typography.fontSizeSm, - fontWeight: typography.fontWeightMedium, - textTransform: 'capitalize', - }, - startCard: { - padding: spacing.lg, - margin: spacing.md, - alignItems: 'center', - }, - startTitle: { - fontSize: typography.fontSizeLg, - fontWeight: typography.fontWeightBold, - color: colors.text, - marginBottom: spacing.sm, - }, - startDescription: { - fontSize: typography.fontSizeMd, - color: colors.textSecondary, - textAlign: 'center', - marginBottom: spacing.lg, - }, - startButton: { - backgroundColor: colors.primary, - borderRadius: borderRadius.md, - paddingVertical: spacing.md, - paddingHorizontal: spacing.xl, - alignItems: 'center', - }, - startButtonText: { - color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, - }, + container: { flex: 1, backgroundColor: colors.background }, + scrollView: { flex: 1 }, + loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + loadingText: { marginTop: spacing.sm, color: colors.textSecondary, fontSize: typography.fontSizeMd }, + header: { padding: spacing.md, paddingTop: spacing.lg }, + title: { fontSize: typography.fontSizeXl, fontWeight: typography.fontWeightBold, color: colors.text }, + subtitle: { fontSize: typography.fontSizeMd, color: colors.textSecondary, marginTop: spacing.xs }, + stepIndicator: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: spacing.md, marginBottom: spacing.md }, + stepItem: { alignItems: 'center', flex: 1 }, + stepCircle: { width: 32, height: 32, borderRadius: 16, backgroundColor: colors.border, justifyContent: 'center', alignItems: 'center' }, + stepCircleActive: { backgroundColor: colors.primary }, + stepCircleCompleted: { backgroundColor: colors.success }, + stepNumber: { color: colors.textSecondary, fontSize: typography.fontSizeSm, fontWeight: typography.fontWeightBold }, + stepNumberActive: { color: colors.text }, + stepLabel: { marginTop: spacing.xs, fontSize: typography.fontSizeXs, color: colors.textSecondary, textAlign: 'center' }, + stepLabelActive: { color: colors.primary, fontWeight: typography.fontWeightBold }, + stepContent: { padding: spacing.md }, + sectionTitle: { fontSize: typography.fontSizeLg, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.md }, + stepDescription: { fontSize: typography.fontSizeMd, color: colors.textSecondary, marginBottom: spacing.md }, + inputGroup: { marginBottom: spacing.md }, + inputLabel: { fontSize: typography.fontSizeSm, color: colors.textSecondary, marginBottom: spacing.xs }, + input: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, fontSize: typography.fontSizeMd, color: colors.text, borderWidth: 1, borderColor: colors.border }, + uploadBox: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg, alignItems: 'center', marginBottom: spacing.md, borderWidth: 1, borderColor: colors.border, borderStyle: 'dashed' }, + uploadBoxDone: { borderColor: colors.success, borderStyle: 'solid' }, + uploadBoxRejected: { borderColor: colors.danger, borderStyle: 'solid' }, + uploadIcon: { fontSize: 32, marginBottom: spacing.sm }, + uploadText: { fontSize: typography.fontSizeMd, color: colors.text, fontWeight: typography.fontWeightMedium }, + uploadHint: { fontSize: typography.fontSizeSm, color: colors.textSecondary, marginTop: spacing.xs }, + summaryCard: { padding: spacing.md, marginBottom: spacing.md }, + summaryRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.border }, + summaryLabel: { fontSize: typography.fontSizeSm, color: colors.textSecondary }, + summaryValue: { fontSize: typography.fontSizeSm, color: colors.text, fontWeight: typography.fontWeightMedium }, + submitButton: { backgroundColor: colors.primary, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center', marginTop: spacing.md }, + submitButtonText: { color: colors.text, fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold }, + navigationButtons: { flexDirection: 'row', padding: spacing.md, gap: spacing.sm }, + backButton: { flex: 1, backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center' }, + backButtonText: { color: colors.text, fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightMedium }, + saveExitButton: { flex: 1, backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center', borderWidth: 1, borderColor: colors.border }, + saveExitText: { color: colors.textSecondary, fontSize: typography.fontSizeSm }, + nextButton: { flex: 1, backgroundColor: colors.primary, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center' }, + nextButtonText: { color: colors.text, fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold }, + statusCard: { padding: spacing.md, margin: spacing.md, marginTop: 0 }, + statusTitle: { fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.sm }, + statusRow: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm }, + statusBadge: { paddingHorizontal: spacing.md, paddingVertical: spacing.xs, borderRadius: borderRadius.md }, + statusBadgeText: { color: colors.text, fontSize: typography.fontSizeSm, fontWeight: typography.fontWeightMedium, textTransform: 'capitalize' }, + startCard: { padding: spacing.lg, margin: spacing.md, alignItems: 'center' }, + startTitle: { fontSize: typography.fontSizeLg, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.sm }, + startDescription: { fontSize: typography.fontSizeMd, color: colors.textSecondary, textAlign: 'center', marginBottom: spacing.lg }, + startButton: { backgroundColor: colors.primary, borderRadius: borderRadius.md, paddingVertical: spacing.md, paddingHorizontal: spacing.xl, alignItems: 'center', width: '100%' }, + startButtonText: { color: colors.text, fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold }, + tourOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center', padding: spacing.lg }, + tourCard: { backgroundColor: colors.surface, borderRadius: borderRadius.lg, padding: spacing.xl, alignItems: 'center', width: '100%' }, + tourIcon: { fontSize: 56, marginBottom: spacing.md }, + tourTitle: { fontSize: typography.fontSizeLg, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.sm }, + tourDesc: { fontSize: typography.fontSizeMd, color: colors.textSecondary, textAlign: 'center', marginBottom: spacing.lg }, + tourDots: { flexDirection: 'row', marginBottom: spacing.lg, gap: spacing.xs }, + tourDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.border }, + tourDotActive: { backgroundColor: colors.primary, width: 20 }, + tourButtons: { flexDirection: 'row', justifyContent: 'space-between', width: '100%', alignItems: 'center' }, + tourSkip: { color: colors.textSecondary, fontSize: typography.fontSizeMd }, + tourNext: { backgroundColor: colors.primary, borderRadius: borderRadius.md, paddingVertical: spacing.sm, paddingHorizontal: spacing.lg }, + tourNextText: { color: colors.text, fontWeight: typography.fontWeightBold }, }); -export default MerchantOnboardingScreen; \ No newline at end of file +export default MerchantOnboardingScreen; diff --git a/src/store/loyaltyStore.ts b/src/store/loyaltyStore.ts index a33f714..4977cd1 100644 --- a/src/store/loyaltyStore.ts +++ b/src/store/loyaltyStore.ts @@ -12,29 +12,45 @@ import { } from '../types/loyalty'; const STORAGE_KEY = 'subtrackr-loyalty'; -const STORE_VERSION = 1; +const STORE_VERSION = 2; -interface LoyaltyState { - loyaltyStatus: LoyaltyStatus | null; - transactions: PointsTransaction[]; - rewards: Reward[]; - program: LoyaltyProgram | null; - isLoading: boolean; - error: string | null; +// ── Gamification types ─────────────────────────────────────────────────────── - initializeProgram: () => Promise; - accumulatePoints: (subscriberId: string, subscriptionId: string, amount: number) => Promise; - redeemPoints: (rewardId: string) => Promise; - checkTierUpgrade: () => void; - expirePoints: () => void; +export interface Achievement { + id: string; + name: string; + description: string; + icon: string; + condition: (state: Pick) => boolean; + unlockedAt?: Date; } -const generateUniqueId = (): string => { - const timestamp = Date.now().toString(36); - const randomComponent = Math.random().toString(36).substring(2, 8); - return `${timestamp}-${randomComponent}`; +export interface StreakData { + current: number; + longest: number; + lastPaymentDate: string | null; // ISO date string (date only) + frozenUntil?: string | null; // streak freeze mechanic +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const generateUniqueId = (): string => + `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`; + +const toDateStr = (d: Date): string => d.toISOString().slice(0, 10); + +const daysBetween = (a: string, b: string): number => + Math.round((new Date(b).getTime() - new Date(a).getTime()) / 86_400_000); + +const getTierFromPoints = (points: number): LoyaltyTier => { + if (points >= 15000) return LoyaltyTier.PLATINUM; + if (points >= 5000) return LoyaltyTier.GOLD; + if (points >= 1000) return LoyaltyTier.SILVER; + return LoyaltyTier.BRONZE; }; +// ── Default data ───────────────────────────────────────────────────────────── + const defaultTierBenefits: TierBenefits[] = [ { tier: LoyaltyTier.BRONZE, @@ -121,18 +137,91 @@ const defaultRewards: Reward[] = [ }, ]; -const getTierFromPoints = (points: number): LoyaltyTier => { - if (points >= 15000) return LoyaltyTier.PLATINUM; - if (points >= 5000) return LoyaltyTier.GOLD; - if (points >= 1000) return LoyaltyTier.SILVER; - return LoyaltyTier.BRONZE; -}; +/** Achievement definitions — conditions evaluated after every state change. */ +export const ACHIEVEMENTS: Achievement[] = [ + { + id: 'first-payment', + name: 'First Payment', + description: 'Make your first on-time payment', + icon: '🎉', + condition: ({ transactions }) => transactions.some((t) => t.type === 'earn'), + }, + { + id: 'streak-7', + name: 'Week Warrior', + description: '7-day payment streak', + icon: '🔥', + condition: ({ streak }) => streak.current >= 7, + }, + { + id: 'streak-30', + name: 'Monthly Master', + description: '30-day payment streak', + icon: '⚡', + condition: ({ streak }) => streak.current >= 30, + }, + { + id: 'silver-tier', + name: 'Silver Member', + description: 'Reach Silver tier', + icon: '🥈', + condition: ({ loyaltyStatus }) => + loyaltyStatus !== null && + [LoyaltyTier.SILVER, LoyaltyTier.GOLD, LoyaltyTier.PLATINUM].includes(loyaltyStatus.tier), + }, + { + id: 'gold-tier', + name: 'Gold Member', + description: 'Reach Gold tier', + icon: '🥇', + condition: ({ loyaltyStatus }) => + loyaltyStatus !== null && + [LoyaltyTier.GOLD, LoyaltyTier.PLATINUM].includes(loyaltyStatus.tier), + }, + { + id: 'points-1000', + name: 'Points Collector', + description: 'Earn 1,000 lifetime points', + icon: '💎', + condition: ({ loyaltyStatus }) => (loyaltyStatus?.lifetimePoints ?? 0) >= 1000, + }, + { + id: 'first-redemption', + name: 'Redeemer', + description: 'Redeem a reward for the first time', + icon: '🎁', + condition: ({ transactions }) => transactions.some((t) => t.type === 'redeem'), + }, +]; -const calculatePointsExpiration = (pointsExpirationDays: number, memberSince: Date): Date => { - const expirationDate = new Date(memberSince); - expirationDate.setDate(expirationDate.getDate() + pointsExpirationDays); - return expirationDate; -}; +// ── Store interface ────────────────────────────────────────────────────────── + +interface LoyaltyState { + loyaltyStatus: LoyaltyStatus | null; + transactions: PointsTransaction[]; + rewards: Reward[]; + program: LoyaltyProgram | null; + streak: StreakData; + achievements: Achievement[]; + newlyUnlocked: Achievement[]; // cleared after UI reads them + isLoading: boolean; + error: string | null; + /** Mutex flag to prevent concurrent points mutations */ + _pointsMutex: boolean; + + initializeProgram: () => Promise; + accumulatePoints: (subscriberId: string, subscriptionId: string, amount: number) => Promise; + redeemPoints: (rewardId: string) => Promise; + checkTierUpgrade: () => void; + expirePoints: () => void; + recordPayment: (date?: Date) => void; + freezeStreak: (days: number) => void; + clearNewlyUnlocked: () => void; + /** Retroactively evaluate all achievements against current state */ + evaluateAchievements: () => Achievement[]; +} + +// ── Store ──────────────────────────────────────────────────────────────────── export const useLoyaltyStore = create()( persist( @@ -141,8 +230,12 @@ export const useLoyaltyStore = create()( transactions: [], rewards: defaultRewards, program: null, + streak: { current: 0, longest: 0, lastPaymentDate: null, frozenUntil: null }, + achievements: ACHIEVEMENTS.map((a) => ({ ...a, unlockedAt: undefined })), + newlyUnlocked: [], isLoading: false, error: null, + _pointsMutex: false, initializeProgram: async () => { const program: LoyaltyProgram = { @@ -156,112 +249,178 @@ export const useLoyaltyStore = create()( set({ program }); }, - accumulatePoints: async (subscriberId: string, subscriptionId: string, amount: number) => { - const { program, transactions, loyaltyStatus } = get(); - if (!program) return; + accumulatePoints: async (subscriberId, subscriptionId, amount) => { + // Race condition guard: spin-wait up to 500ms + const deadline = Date.now() + 500; + while (get()._pointsMutex && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 10)); + } + set({ _pointsMutex: true }); - const pointsEarned = Math.floor(amount * program.pointsPerDollar); + try { + const { program, transactions, loyaltyStatus } = get(); + if (!program) return; - const transaction: PointsTransaction = { - id: generateUniqueId(), - subscriberId, - amount: pointsEarned, - type: 'earn', - subscriptionId, - description: `Points earned from subscription`, - createdAt: new Date(), - }; + const pointsEarned = Math.floor(amount * program.pointsPerDollar); + const currentPoints = loyaltyStatus?.points ?? 0; + const lifetimePoints = loyaltyStatus?.lifetimePoints ?? 0; + const totalSpent = loyaltyStatus?.totalSpent ?? 0; - const currentPoints = loyaltyStatus?.points || 0; - const lifetimePoints = loyaltyStatus?.lifetimePoints || 0; - const totalSpent = loyaltyStatus?.totalSpent || 0; - - const newStatus: LoyaltyStatus = { - subscriberId, - tier: getTierFromPoints(currentPoints + pointsEarned), - points: currentPoints + pointsEarned, - lifetimePoints: lifetimePoints + pointsEarned, - totalSpent: totalSpent + amount, - memberSince: loyaltyStatus?.memberSince || new Date(), - pointsExpirationDate: calculatePointsExpiration( - program.pointsExpirationDays, - loyaltyStatus?.memberSince || new Date() - ), - }; + const transaction: PointsTransaction = { + id: generateUniqueId(), + subscriberId, + amount: pointsEarned, + type: 'earn', + subscriptionId, + description: 'Points earned from subscription', + createdAt: new Date(), + expiresAt: new Date(Date.now() + program.pointsExpirationDays * 86_400_000), + }; - set({ - transactions: [...transactions, transaction], - loyaltyStatus: newStatus, - }); - }, + const newPoints = currentPoints + pointsEarned; + const newLifetime = lifetimePoints + pointsEarned; + + const newStatus: LoyaltyStatus = { + subscriberId, + tier: getTierFromPoints(newLifetime), + points: newPoints, + lifetimePoints: newLifetime, + totalSpent: totalSpent + amount, + memberSince: loyaltyStatus?.memberSince ?? new Date(), + pointsExpirationDate: new Date( + Date.now() + program.pointsExpirationDays * 86_400_000, + ), + }; - redeemPoints: async (rewardId: string) => { - const { rewards, loyaltyStatus } = get(); - const reward = rewards.find((r) => r.id === rewardId); + set({ transactions: [...transactions, transaction], loyaltyStatus: newStatus }); + get().evaluateAchievements(); + } finally { + set({ _pointsMutex: false }); + } + }, - if (!reward || !loyaltyStatus) return false; - if (!reward.isActive) return false; - if (loyaltyStatus.points < reward.pointsCost) return false; + redeemPoints: async (rewardId) => { + const deadline = Date.now() + 500; + while (get()._pointsMutex && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 10)); + } + set({ _pointsMutex: true }); - const transaction: PointsTransaction = { - id: generateUniqueId(), - subscriberId: loyaltyStatus.subscriberId, - amount: -reward.pointsCost, - type: 'redeem', - description: `Redeemed: ${reward.name}`, - createdAt: new Date(), - }; + try { + const { rewards, loyaltyStatus } = get(); + const reward = rewards.find((r) => r.id === rewardId); + if (!reward?.isActive || !loyaltyStatus) return false; + if (loyaltyStatus.points < reward.pointsCost) return false; - set({ - transactions: [...get().transactions, transaction], - loyaltyStatus: { - ...loyaltyStatus, - points: loyaltyStatus.points - reward.pointsCost, - }, - }); + const transaction: PointsTransaction = { + id: generateUniqueId(), + subscriberId: loyaltyStatus.subscriberId, + amount: -reward.pointsCost, + type: 'redeem', + description: `Redeemed: ${reward.name}`, + createdAt: new Date(), + }; - return true; + set({ + transactions: [...get().transactions, transaction], + loyaltyStatus: { ...loyaltyStatus, points: loyaltyStatus.points - reward.pointsCost }, + }); + get().evaluateAchievements(); + return true; + } finally { + set({ _pointsMutex: false }); + } }, checkTierUpgrade: () => { const { loyaltyStatus } = get(); if (!loyaltyStatus) return; - const newTier = getTierFromPoints(loyaltyStatus.lifetimePoints); if (newTier !== loyaltyStatus.tier) { - set({ - loyaltyStatus: { - ...loyaltyStatus, - tier: newTier, - }, - }); + set({ loyaltyStatus: { ...loyaltyStatus, tier: newTier } }); + get().evaluateAchievements(); } }, expirePoints: () => { const { loyaltyStatus, transactions } = get(); if (!loyaltyStatus?.pointsExpirationDate) return; + if (new Date() <= loyaltyStatus.pointsExpirationDate) return; - const now = new Date(); - if (now > loyaltyStatus.pointsExpirationDate) { - const expiredTransaction: PointsTransaction = { - id: generateUniqueId(), - subscriberId: loyaltyStatus.subscriberId, - amount: -loyaltyStatus.points, - type: 'expire', - description: 'Points expired', - createdAt: new Date(), - }; + const expiredTx: PointsTransaction = { + id: generateUniqueId(), + subscriberId: loyaltyStatus.subscriberId, + amount: -loyaltyStatus.points, + type: 'expire', + description: 'Points expired', + createdAt: new Date(), + }; + set({ + transactions: [...transactions, expiredTx], + loyaltyStatus: { ...loyaltyStatus, points: 0, pointsExpirationDate: undefined }, + }); + }, - set({ - transactions: [...transactions, expiredTransaction], - loyaltyStatus: { - ...loyaltyStatus, - points: 0, - pointsExpirationDate: undefined, - }, - }); + recordPayment: (date = new Date()) => { + const { streak } = get(); + const today = toDateStr(date); + const { lastPaymentDate, frozenUntil } = streak; + + // Streak freeze: if frozen, don't break streak + if (frozenUntil && today <= frozenUntil) { + set({ streak: { ...streak, lastPaymentDate: today } }); + return; + } + + let newCurrent = streak.current; + if (!lastPaymentDate) { + newCurrent = 1; + } else { + const diff = daysBetween(lastPaymentDate, today); + if (diff === 0) return; // same day, no change + if (diff === 1) { + newCurrent = streak.current + 1; + } else { + newCurrent = 1; // streak broken + } } + + const newStreak: StreakData = { + current: newCurrent, + longest: Math.max(streak.longest, newCurrent), + lastPaymentDate: today, + frozenUntil: null, + }; + set({ streak: newStreak }); + get().evaluateAchievements(); + }, + + freezeStreak: (days) => { + const { streak } = get(); + const until = toDateStr(new Date(Date.now() + days * 86_400_000)); + set({ streak: { ...streak, frozenUntil: until } }); + }, + + clearNewlyUnlocked: () => set({ newlyUnlocked: [] }), + + evaluateAchievements: () => { + const state = get(); + const { achievements, loyaltyStatus, streak, transactions } = state; + const context = { loyaltyStatus, streak, transactions }; + const newlyUnlocked: Achievement[] = []; + + const updated = achievements.map((a) => { + if (a.unlockedAt) return a; // already unlocked + if (a.condition(context)) { + const unlocked = { ...a, unlockedAt: new Date() }; + newlyUnlocked.push(unlocked); + return unlocked; + } + return a; + }); + + set({ achievements: updated, newlyUnlocked }); + return newlyUnlocked; }, }), { @@ -273,7 +432,9 @@ export const useLoyaltyStore = create()( transactions: state.transactions, rewards: state.rewards, program: state.program, + streak: state.streak, + achievements: state.achievements, }), - } - ) -); \ No newline at end of file + }, + ), +); diff --git a/src/store/merchantStore.ts b/src/store/merchantStore.ts index e6253a4..09194d0 100644 --- a/src/store/merchantStore.ts +++ b/src/store/merchantStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'; +import { persist, createJSONStorage } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MerchantOnboarding, @@ -10,39 +10,97 @@ import { MerchantDocument, DocumentType, } from '../types/merchant'; -import { CACHE_CONSTANTS } from '../utils/constants/values'; const STORAGE_KEY = 'subtrackr-merchant-onboarding'; -const STORE_VERSION = 1; -const WRITE_DEBOUNCE_MS = CACHE_CONSTANTS.WRITE_DEBOUNCE_MS; +const STORE_VERSION = 2; + +// ── Extended types ──────────────────────────────────────────────────────────── + +export interface ComplianceResult { + passed: boolean; + sanctionsHit: boolean; + pepHit: boolean; + checkedAt: Date; + notes?: string; +} + +export interface PaymentSetup { + method: 'stellar_xlm' | 'stellar_usdc' | 'bank_transfer'; + walletAddress?: string; + bankAccountLast4?: string; + configuredAt: Date; +} + +export interface ExtendedMerchantOnboarding extends MerchantOnboarding { + formData: Partial; + compliance?: ComplianceResult; + paymentSetup?: PaymentSetup; + welcomeTourCompleted: boolean; + /** ISO timestamp of last save for resume detection */ + savedAt: string; + /** Verification timeout: ISO timestamp */ + verificationDeadline?: string; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const generateUniqueId = (): string => + `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`; + +const ONBOARDING_EXPIRY_DAYS = 30; + +const getDefaultSteps = (): OnboardingStep[] => [ + OnboardingStep.BUSINESS_INFO, + OnboardingStep.ID_DOCUMENT, + OnboardingStep.BUSINESS_LICENSE, + OnboardingStep.REVIEW, +]; + +/** Simulate compliance screening (sanctions + PEP check). */ +const runComplianceCheck = async ( + data: Partial, +): Promise => { + // In production this calls a real KYB/AML provider. + // Blocked countries list (simplified). + const BLOCKED_COUNTRIES = ['KP', 'IR', 'SY', 'CU']; + const sanctionsHit = BLOCKED_COUNTRIES.includes((data.country ?? '').toUpperCase()); + const pepHit = false; // placeholder + return { + passed: !sanctionsHit && !pepHit, + sanctionsHit, + pepHit, + checkedAt: new Date(), + }; +}; + +// ── Store interface ─────────────────────────────────────────────────────────── interface MerchantState { - onboarding: MerchantOnboarding | null; + onboarding: ExtendedMerchantOnboarding | null; isLoading: boolean; error: string | null; + /** Start or resume an onboarding session. */ startOnboarding: (data: MerchantOnboardingFormData) => Promise; + /** Save current form data without advancing step (save-and-resume). */ + saveProgress: (data: Partial) => void; submitDocument: (docType: DocumentType, uri: string) => Promise; + retryRejectedDocument: (docId: string, newUri: string) => Promise; nextStep: () => Promise; - previousStep: () => Promise; + previousStep: () => void; + runComplianceScreening: () => Promise; + configurePayment: (setup: Omit) => void; requestVerification: () => Promise; - approveVerification: (tier: VerificationTier, notes?: string) => Promise; - rejectVerification: (reason: string) => Promise; + approveVerification: (tier: VerificationTier, notes?: string) => void; + rejectVerification: (reason: string) => void; + completeWelcomeTour: () => void; getOnboardingStatus: () => OnboardingStatus; + /** True if a previous incomplete session exists and can be resumed. */ + canResume: () => boolean; + clearOnboarding: () => void; } -const generateUniqueId = (): string => { - const timestamp = Date.now().toString(36); - const randomComponent = Math.random().toString(36).substring(2, 8); - return `${timestamp}-${randomComponent}`; -}; - -const getDefaultSteps = (): OnboardingStep[] => [ - OnboardingStep.BUSINESS_INFO, - OnboardingStep.ID_DOCUMENT, - OnboardingStep.BUSINESS_LICENSE, - OnboardingStep.REVIEW, -]; +// ── Store ───────────────────────────────────────────────────────────────────── export const useMerchantStore = create()( persist( @@ -51,29 +109,63 @@ export const useMerchantStore = create()( isLoading: false, error: null, - startOnboarding: async (data: MerchantOnboardingFormData) => { + startOnboarding: async (data) => { set({ isLoading: true, error: null }); try { - const newOnboarding: MerchantOnboarding = { + const existing = get().onboarding; + // Resume if an in-progress session exists and hasn't expired + if (existing && existing.status === OnboardingStatus.IN_PROGRESS) { + const savedAt = new Date(existing.savedAt); + const expired = + Date.now() - savedAt.getTime() > ONBOARDING_EXPIRY_DAYS * 86_400_000; + if (!expired) { + set({ + onboarding: { + ...existing, + formData: { ...existing.formData, ...data }, + savedAt: new Date().toISOString(), + }, + isLoading: false, + }); + return; + } + } + + const now = new Date(); + const newOnboarding: ExtendedMerchantOnboarding = { id: generateUniqueId(), merchantAddress: data.email, steps: getDefaultSteps(), currentStep: OnboardingStep.BUSINESS_INFO, status: OnboardingStatus.IN_PROGRESS, documents: [], - startedAt: new Date(), - updatedAt: new Date(), + formData: data, + welcomeTourCompleted: false, + savedAt: now.toISOString(), + startedAt: now, + updatedAt: now, + expiresAt: new Date(now.getTime() + ONBOARDING_EXPIRY_DAYS * 86_400_000), }; set({ onboarding: newOnboarding, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to start onboarding', - isLoading: false, - }); + } catch (err) { + set({ error: err instanceof Error ? err.message : 'Failed to start onboarding', isLoading: false }); } }, - submitDocument: async (docType: DocumentType, uri: string) => { + saveProgress: (data) => { + const { onboarding } = get(); + if (!onboarding) return; + set({ + onboarding: { + ...onboarding, + formData: { ...onboarding.formData, ...data }, + savedAt: new Date().toISOString(), + updatedAt: new Date(), + }, + }); + }, + + submitDocument: async (docType, uri) => { set({ isLoading: true, error: null }); try { const { onboarding } = get(); @@ -87,57 +179,96 @@ export const useMerchantStore = create()( status: 'pending', }; + // Replace existing doc of same type if present + const docs = onboarding.documents.filter((d) => d.type !== docType); set({ - onboarding: { - ...onboarding, - documents: [...onboarding.documents, newDoc], - updatedAt: new Date(), - }, - isLoading: false, - }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to submit document', + onboarding: { ...onboarding, documents: [...docs, newDoc], updatedAt: new Date() }, isLoading: false, }); + } catch (err) { + set({ error: err instanceof Error ? err.message : 'Failed to submit document', isLoading: false }); } }, + retryRejectedDocument: async (docId, newUri) => { + const { onboarding } = get(); + if (!onboarding) return; + const docs = onboarding.documents.map((d) => + d.id === docId ? { ...d, uri: newUri, status: 'pending' as const, uploadedAt: new Date() } : d, + ); + set({ onboarding: { ...onboarding, documents: docs, updatedAt: new Date() } }); + }, + nextStep: async () => { const { onboarding } = get(); if (!onboarding) return; - const currentIndex = onboarding.steps.indexOf(onboarding.currentStep); - if (currentIndex >= onboarding.steps.length - 1) return; + const idx = onboarding.steps.indexOf(onboarding.currentStep); + if (idx >= onboarding.steps.length - 1) return; + + const nextStep = onboarding.steps[idx + 1]; + + // Auto-run compliance before REVIEW step + if (nextStep === OnboardingStep.REVIEW && !onboarding.compliance) { + await get().runComplianceScreening(); + } - const currentStep = onboarding.steps[currentIndex + 1]; const newStatus = - currentStep === OnboardingStep.REVIEW + nextStep === OnboardingStep.REVIEW ? OnboardingStatus.PENDING_REVIEW : OnboardingStatus.IN_PROGRESS; set({ onboarding: { - ...onboarding, - currentStep, + ...get().onboarding!, + currentStep: nextStep, status: newStatus, + savedAt: new Date().toISOString(), updatedAt: new Date(), }, }); }, - previousStep: async () => { + previousStep: () => { const { onboarding } = get(); if (!onboarding) return; + const idx = onboarding.steps.indexOf(onboarding.currentStep); + if (idx <= 0) return; + set({ + onboarding: { + ...onboarding, + currentStep: onboarding.steps[idx - 1], + status: OnboardingStatus.IN_PROGRESS, + savedAt: new Date().toISOString(), + updatedAt: new Date(), + }, + }); + }, - const currentIndex = onboarding.steps.indexOf(onboarding.currentStep); - if (currentIndex <= 0) return; + runComplianceScreening: async () => { + const { onboarding } = get(); + if (!onboarding) throw new Error('No onboarding in progress'); + set({ isLoading: true }); + try { + const result = await runComplianceCheck(onboarding.formData); + set({ + onboarding: { ...get().onboarding!, compliance: result, updatedAt: new Date() }, + isLoading: false, + }); + return result; + } catch (err) { + set({ isLoading: false, error: err instanceof Error ? err.message : 'Compliance check failed' }); + throw err; + } + }, + configurePayment: (setup) => { + const { onboarding } = get(); + if (!onboarding) return; set({ onboarding: { ...onboarding, - currentStep: onboarding.steps[currentIndex - 1], - status: OnboardingStatus.IN_PROGRESS, + paymentSetup: { ...setup, configuredAt: new Date() }, updatedAt: new Date(), }, }); @@ -146,45 +277,37 @@ export const useMerchantStore = create()( requestVerification: async () => { const { onboarding } = get(); if (!onboarding) return; - + const deadline = new Date(Date.now() + 7 * 86_400_000).toISOString(); // 7-day timeout set({ onboarding: { ...onboarding, status: OnboardingStatus.PENDING_REVIEW, + verificationDeadline: deadline, updatedAt: new Date(), }, }); }, - approveVerification: async (tier: VerificationTier, notes?: string) => { + approveVerification: (tier, notes) => { const { onboarding } = get(); if (!onboarding) return; - const limits = tier === VerificationTier.ENHANCED - ? { monthlyVolume: 1000000, maxTransactions: 10000 } - : { monthlyVolume: 10000, maxTransactions: 100 }; - + ? { monthlyVolume: 1_000_000, maxTransactions: 10_000 } + : { monthlyVolume: 10_000, maxTransactions: 100 }; set({ onboarding: { ...onboarding, status: OnboardingStatus.VERIFIED, - verificationResult: { - isVerified: true, - tier, - reviewedAt: new Date(), - reviewerNotes: notes, - limits, - }, + verificationResult: { isVerified: true, tier, reviewedAt: new Date(), reviewerNotes: notes, limits }, updatedAt: new Date(), }, }); }, - rejectVerification: async (reason: string) => { + rejectVerification: (reason) => { const { onboarding } = get(); if (!onboarding) return; - set({ onboarding: { ...onboarding, @@ -201,16 +324,29 @@ export const useMerchantStore = create()( }); }, - getOnboardingStatus: () => { + completeWelcomeTour: () => { + const { onboarding } = get(); + if (!onboarding) return; + set({ onboarding: { ...onboarding, welcomeTourCompleted: true } }); + }, + + getOnboardingStatus: () => get().onboarding?.status ?? OnboardingStatus.NOT_STARTED, + + canResume: () => { const { onboarding } = get(); - return onboarding?.status ?? OnboardingStatus.NOT_STARTED; + if (!onboarding) return false; + if (onboarding.status !== OnboardingStatus.IN_PROGRESS) return false; + const savedAt = new Date(onboarding.savedAt); + return Date.now() - savedAt.getTime() <= ONBOARDING_EXPIRY_DAYS * 86_400_000; }, + + clearOnboarding: () => set({ onboarding: null, error: null }), }), { name: STORAGE_KEY, version: STORE_VERSION, storage: createJSONStorage(() => AsyncStorage), partialize: (state) => ({ onboarding: state.onboarding }), - } - ) -); \ No newline at end of file + }, + ), +);