diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80db398a..34927b8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,11 +24,11 @@ jobs: version: 11.2.2 - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run vp check - run: pnpm exec vp check + - name: Run quality checks + run: pnpm quality - name: Run tests run: pnpm test diff --git a/CLAUDE.md b/CLAUDE.md index 44c5194c..d8ea46d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Code Style - TypeScript for all code with strict typing -- Formatting/linting via Vite+ (oxfmt + oxlint): 2-space indentation, single quotes, avoid semicolons. Run `pnpm exec vp check` (or `vp check --fix`). +- Formatting/linting via standalone Oxfmt + Oxlint: 2-space indentation, single quotes, avoid semicolons. Run `pnpm check` (or `pnpm format:fix && pnpm lint:fix`). Run `pnpm quality` for the full gate, including Knip's unused-code/dependency analysis. - Use React functional components with hooks - Use `@/` imports with paths configured in tsconfig.json - Follow mobile-first responsive design with Tailwind CSS @@ -24,7 +24,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Test Guidelines - Place tests in `__tests__` folders next to source files -- Use Vitest's `vi.mock()` at the top of test files +- Test through dependency seams and injected fakes instead of `vi.mock()`; legacy module mocks are migration violations under the anti-slop rules - Follow Arrange-Act-Assert pattern - Use `vi.stubEnv()` instead of direct environment variable assignment - Reset mocks between tests with `vi.resetAllMocks()` @@ -37,19 +37,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Use redux for state management - Follow folder structure conventions - - -# Using Vite+, the Unified Toolchain for the Web - -This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, and it invokes Vite through `vp dev` and `vp build`. Run `vp help` to print a list of commands and `vp --help` for information about a specific command. - -Docs are local at `node_modules/vite-plus/docs` or online at https://viteplus.dev/guide/. - ## Review Checklist -- [ ] Run `vp install` after pulling remote changes and before getting started. -- [ ] Run `vp check` and `vp test` to format, lint, type check and test changes. -- [ ] Check if there are `vite.config.ts` tasks or `package.json` scripts necessary for validation, run via `vp run ' + const serialized = serializeJsonLd({ + '@context': 'https://schema.org', + '@type': 'ProfilePage', + mainEntity: { + '@type': 'Person', + name: hostileName, + url: 'https://twitch.tv/dotabod', + }, + url: 'https://dotabod.com/dotabod', + }) + + expect(serialized).not.toContain('') + expect(serialized).not.toContain('') + + expect(result.success).toBeFalsy() + }) + + it('allows ordinary Unicode gift text', () => { + const result = giftTextSchema.safeParse('Congrats, you earned $10 & a 🎉!') + + expect(result.success).toBeTruthy() + }) +}) diff --git a/src/__tests__/pages/api/stripe/crypto-invoice.test.ts b/src/__tests__/pages/api/stripe/crypto-invoice.test.ts index 5469434d..abb6057e 100644 --- a/src/__tests__/pages/api/stripe/crypto-invoice.test.ts +++ b/src/__tests__/pages/api/stripe/crypto-invoice.test.ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { createMocks } from 'node-mocks-http' -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' vi.stubEnv('NOWPAYMENTS_API_KEY', 'test-api-key') vi.stubEnv('NOWPAYMENTS_IPN_SECRET', 'test-ipn-secret') @@ -43,6 +44,8 @@ vi.mock('@/utils/subscription', () => ({ let handler: typeof import('@/pages/api/stripe/crypto-invoice').default +const cryptoInvoiceResponseSchema = z.object({ url: z.string().url() }) + beforeAll(async () => { ;({ default: handler } = await import('@/pages/api/stripe/crypto-invoice')) }) @@ -86,7 +89,9 @@ describe('POST /api/stripe/crypto-invoice', () => { await handler(req, res) expect(res._getStatusCode()).toBe(200) - expect(res._getJSONData().url).toBe('https://nowpayments.io/payment/?iid=existing') + expect(cryptoInvoiceResponseSchema.parse(res._getJSONData()).url).toBe( + 'https://nowpayments.io/payment/?iid=existing', + ) expect(mocks.createNowPaymentsInvoice).not.toHaveBeenCalled() }) @@ -115,7 +120,9 @@ describe('POST /api/stripe/crypto-invoice', () => { where: { stripeInvoiceId: 'in_renew_1' }, }) expect(res._getStatusCode()).toBe(200) - expect(res._getJSONData().url).toBe('https://nowpayments.io/payment/?iid=replacement') + expect(cryptoInvoiceResponseSchema.parse(res._getJSONData()).url).toBe( + 'https://nowpayments.io/payment/?iid=replacement', + ) }) it('creates a fresh NOWPayments invoice when none exists for this renewal', async () => { @@ -138,7 +145,9 @@ describe('POST /api/stripe/crypto-invoice', () => { await handler(req, res) expect(res._getStatusCode()).toBe(200) - expect(res._getJSONData().url).toBe('https://nowpayments.io/payment/?iid=fresh') + expect(cryptoInvoiceResponseSchema.parse(res._getJSONData()).url).toBe( + 'https://nowpayments.io/payment/?iid=fresh', + ) expect(mocks.createNowPaymentsInvoice).toHaveBeenCalledWith( expect.objectContaining({ ipn_callback_url: 'https://dotabod.com/api/webhooks/nowpayments', @@ -183,7 +192,9 @@ describe('POST /api/stripe/crypto-invoice', () => { expect(mocks.stripe.invoices.finalizeInvoice).toHaveBeenCalledWith('in_renew_1') expect(res._getStatusCode()).toBe(200) - expect(res._getJSONData().url).toBe('https://nowpayments.io/payment/?iid=fresh2') + expect(cryptoInvoiceResponseSchema.parse(res._getJSONData()).url).toBe( + 'https://nowpayments.io/payment/?iid=fresh2', + ) }) it('rejects a void or uncollectible invoice', async () => { diff --git a/src/__tests__/pages/api/stripe/crypto-subscription.test.ts b/src/__tests__/pages/api/stripe/crypto-subscription.test.ts index f0c1df15..6aacea07 100644 --- a/src/__tests__/pages/api/stripe/crypto-subscription.test.ts +++ b/src/__tests__/pages/api/stripe/crypto-subscription.test.ts @@ -2,12 +2,13 @@ import type { Prisma } from '@prisma/client' import { SubscriptionStatus } from '@prisma/client' import type Stripe from 'stripe' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { stripe } from '@/lib/stripe-server' import { createCryptoSubscription, findExistingCryptoSubscription, } from '@/lib/stripe/utils/subscription-utils' -import { stripe } from '@/lib/stripe-server' import { CRYPTO_PRICE_IDS, getCurrentPeriod } from '@/utils/subscription' // Mock subscription utils @@ -78,7 +79,7 @@ describe('Crypto Subscription Utilities', () => { vi.restoreAllMocks() }) - describe('findExistingCryptoSubscription', () => { + describe(findExistingCryptoSubscription, () => { it('should find existing crypto subscription by customer ID', async () => { const mockSubscription = { id: 'sub_123', @@ -96,7 +97,7 @@ describe('Crypto Subscription Utilities', () => { mockTx, ) - expect(result).toEqual(mockSubscription) + expect(result).toStrictEqual(mockSubscription) expect(mockTx.subscription.findFirst).toHaveBeenCalledWith({ where: { OR: [ @@ -131,7 +132,7 @@ describe('Crypto Subscription Utilities', () => { }) }) - describe('createCryptoSubscription', () => { + describe(createCryptoSubscription, () => { const mockPriceId = 'crypto_monthly' const mockCustomerId = 'cus_test_123' const mockUserId = 'user_123' @@ -172,7 +173,7 @@ describe('Crypto Subscription Utilities', () => { mockTx, ) - expect(result).toBe(true) + expect(result).toBeTruthy() expect(stripe.invoices.create).toHaveBeenCalledWith({ auto_advance: true, automatically_finalizes_at: expect.any(Number), @@ -216,7 +217,7 @@ describe('Crypto Subscription Utilities', () => { mockTx, ) - expect(result).toBe(true) + expect(result).toBeTruthy() expect(stripe.invoices.create).toHaveBeenCalledWith({ auto_advance: true, automatically_finalizes_at: expect.any(Number), @@ -253,7 +254,7 @@ describe('Crypto Subscription Utilities', () => { mockTx, ) - expect(result).toBe(true) + expect(result).toBeTruthy() expect(mockTx.subscription.create).toHaveBeenCalledWith({ data: { cancelAtPeriodEnd: false, @@ -286,7 +287,7 @@ describe('Crypto Subscription Utilities', () => { mockTx, ) - expect(result).toBe(true) + expect(result).toBeTruthy() expect(mockTx.subscription.create).not.toHaveBeenCalled() expect(mockTx.subscription.findFirst).toHaveBeenCalledWith({ select: { @@ -311,7 +312,7 @@ describe('Crypto Subscription Utilities', () => { mockTx, ) - expect(result).toBe(true) // Function still succeeds but creates subscription without renewal + expect(result).toBeTruthy() // Function still succeeds but creates subscription without renewal }) it('should throw error when subscription creation fails for regular subscriptions', async () => { @@ -325,10 +326,10 @@ describe('Crypto Subscription Utilities', () => { }) }) - describe('CRYPTO_PRICE_IDS', () => { + describe(CRYPTO_PRICE_IDS, () => { it('should contain valid crypto price IDs', () => { expect(CRYPTO_PRICE_IDS).toBeDefined() - expect(Array.isArray(CRYPTO_PRICE_IDS)).toBe(true) + expect(Array.isArray(CRYPTO_PRICE_IDS)).toBeTruthy() expect(CRYPTO_PRICE_IDS.length).toBeGreaterThan(0) // Check that all crypto price IDs start with 'crypto_' @@ -409,7 +410,7 @@ describe('Crypto Subscription Utilities', () => { existingSubscription.currentPeriodEnd, ) - expect(result).toBe(true) + expect(result).toBeTruthy() expect(mockTx.subscription.update).toHaveBeenCalledWith({ data: { cancelAtPeriodEnd: true, @@ -487,7 +488,7 @@ describe('Crypto Subscription Utilities', () => { existingSubscription.currentPeriodEnd, ) - expect(result).toBe(true) + expect(result).toBeTruthy() expect(mockTx.subscription.update).toHaveBeenCalledWith({ data: { cancelAtPeriodEnd: true, @@ -539,7 +540,7 @@ describe('Crypto Subscription Utilities', () => { mockTx, ) - expect(result).toEqual(existingSubscription) + expect(result).toStrictEqual(existingSubscription) // No upgrade should occur - lifetime subscriptions don't get upgraded }) @@ -568,7 +569,7 @@ describe('Crypto Subscription Utilities', () => { mockTx, ) - expect(result).toEqual(existingSubscription) + expect(result).toStrictEqual(existingSubscription) // No upgrade operations should be performed }) @@ -594,7 +595,7 @@ describe('Crypto Subscription Utilities', () => { from !== to && (from === 'monthly' || from === 'annual') && (to === 'monthly' || to === 'annual'), - ).toBe(true) + ).toBeTruthy() }) // Invalid upgrades should not meet the criteria @@ -605,7 +606,7 @@ describe('Crypto Subscription Utilities', () => { (from === 'monthly' || from === 'annual') && (to === 'monthly' || to === 'annual') ), - ).toBe(true) + ).toBeTruthy() }) }) }) diff --git a/src/__tests__/pages/api/stripe/gift-webhook.test.ts b/src/__tests__/pages/api/stripe/gift-webhook.test.ts index 2c74360b..e950dc8c 100644 --- a/src/__tests__/pages/api/stripe/gift-webhook.test.ts +++ b/src/__tests__/pages/api/stripe/gift-webhook.test.ts @@ -1,5 +1,5 @@ import { createMocks } from 'node-mocks-http' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' /** * Tests for the Stripe webhook handler specifically for gift subscriptions @@ -137,7 +137,7 @@ describe('Gift Subscription Webhook Handler', () => { }) it('should have the correct API config', () => { - expect(config).toEqual({ + expect(config).toStrictEqual({ api: { bodyParser: false, }, @@ -157,7 +157,7 @@ describe('Gift Subscription Webhook Handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ endDate: '2025-04-15T15:22:58.000Z', gift: true, giftType: 'monthly', @@ -179,7 +179,7 @@ describe('Gift Subscription Webhook Handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ endDate: '2026-03-15T15:22:58.000Z', gift: true, giftType: 'annual', @@ -201,7 +201,7 @@ describe('Gift Subscription Webhook Handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ endDate: '2099-12-31T23:59:59.999Z', gift: true, giftType: 'lifetime', @@ -223,7 +223,7 @@ describe('Gift Subscription Webhook Handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ endDate: '2025-04-15T15:22:58.000Z', gift: true, hasExistingSubscription: true, @@ -245,7 +245,7 @@ describe('Gift Subscription Webhook Handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ endDate: '2025-06-15T15:22:58.000Z', finalQuantity: 3, gift: true, diff --git a/src/__tests__/pages/api/stripe/portal.test.ts b/src/__tests__/pages/api/stripe/portal.test.ts index 459f028f..cc849ddc 100644 --- a/src/__tests__/pages/api/stripe/portal.test.ts +++ b/src/__tests__/pages/api/stripe/portal.test.ts @@ -1,5 +1,5 @@ import { createMocks } from 'node-mocks-http' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { beforeEach, describe, expect, it, vi } from 'vitest' const getServerSessionMock = vi.fn() const getSubscriptionMock = vi.fn() @@ -50,7 +50,7 @@ describe('/api/stripe/portal', () => { await handler(req, res) expect(res.statusCode).toBe(405) - expect(res._getJSONData()).toEqual({ error: 'Method not allowed' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Method not allowed' }) }) it('returns 401 when user is not authenticated', async () => { @@ -60,7 +60,7 @@ describe('/api/stripe/portal', () => { await handler(req, res) expect(res.statusCode).toBe(401) - expect(res._getJSONData()).toEqual({ error: 'Unauthorized' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Unauthorized' }) }) it('creates portal session with active subscription customer ID', async () => { @@ -77,7 +77,7 @@ describe('/api/stripe/portal', () => { return_url: 'https://dotabod.com/dashboard/billing', }) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ url: 'https://billing.stripe.com/session/active' }) + expect(res._getJSONData()).toStrictEqual({ url: 'https://billing.stripe.com/session/active' }) }) it('falls back to historical customer ID when active subscription has none', async () => { @@ -102,7 +102,9 @@ describe('/api/stripe/portal', () => { return_url: 'https://dotabod.com/dashboard/billing', }) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ url: 'https://billing.stripe.com/session/historical' }) + expect(res._getJSONData()).toStrictEqual({ + url: 'https://billing.stripe.com/session/historical', + }) }) it('returns actionable error when no Stripe customer exists', async () => { @@ -114,7 +116,7 @@ describe('/api/stripe/portal', () => { await handler(req, res) expect(res.statusCode).toBe(400) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ code: 'NO_STRIPE_CUSTOMER', error: 'No Stripe customer found', guidance: 'No active Stripe billing profile found. If you need help, contact support.', diff --git a/src/__tests__/pages/api/stripe/services/customer-service.test.ts b/src/__tests__/pages/api/stripe/services/customer-service.test.ts index e339ec3e..02d43761 100644 --- a/src/__tests__/pages/api/stripe/services/customer-service.test.ts +++ b/src/__tests__/pages/api/stripe/services/customer-service.test.ts @@ -1,8 +1,9 @@ // @ts-nocheck import type { Prisma } from '@prisma/client' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { CustomerService } from '@/lib/stripe/services/customer-service' +import { beforeEach, describe, expect, it, vi } from 'vitest' + import { stripe } from '@/lib/stripe-server' +import { CustomerService } from '@/lib/stripe/services/customer-service' vi.mock('@/lib/stripe-server', () => ({ stripe: { diff --git a/src/__tests__/pages/api/stripe/utils/idempotency.test.ts b/src/__tests__/pages/api/stripe/utils/idempotency.test.ts index 845b10eb..6914d9e1 100644 --- a/src/__tests__/pages/api/stripe/utils/idempotency.test.ts +++ b/src/__tests__/pages/api/stripe/utils/idempotency.test.ts @@ -1,9 +1,10 @@ // @ts-nocheck import type { Prisma } from '@prisma/client' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { beforeEach, describe, expect, it, vi } from 'vitest' + import { processEventIdempotently } from '@/lib/stripe/utils/idempotency' -describe('processEventIdempotently', () => { +describe(processEventIdempotently, () => { const mockTx: Pick = { webhookEvent: { create: vi.fn(), @@ -31,7 +32,7 @@ describe('processEventIdempotently', () => { mockTx, ) - expect(result).toBe(false) + expect(result).toBeFalsy() expect(mockTx.webhookEvent.create).toHaveBeenCalledWith({ data: { eventType: 'checkout.session.completed', diff --git a/src/__tests__/pages/api/stripe/webhook.test.ts b/src/__tests__/pages/api/stripe/webhook.test.ts index fe17563f..2074e68b 100644 --- a/src/__tests__/pages/api/stripe/webhook.test.ts +++ b/src/__tests__/pages/api/stripe/webhook.test.ts @@ -1,5 +1,5 @@ import { createMocks } from 'node-mocks-http' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' /** * Tests for the Stripe webhook handler @@ -134,7 +134,7 @@ describe('Stripe webhook handler', () => { }) it('should have the correct API config', () => { - expect(config).toEqual({ + expect(config).toStrictEqual({ api: { bodyParser: false, }, @@ -149,7 +149,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(405) - expect(res._getJSONData()).toEqual({ error: 'Method not allowed' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Method not allowed' }) }) it('should return 400 if stripe-signature is missing', async () => { @@ -161,7 +161,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(400) - expect(res._getJSONData()).toEqual({ error: 'Webhook configuration error' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Webhook configuration error' }) }) it('should return 400 if webhook verification fails', async () => { @@ -176,7 +176,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(400) - expect(res._getJSONData()).toEqual({ error: 'Webhook verification failed' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Webhook verification failed' }) }) it('should return 500 if webhook processing fails', async () => { @@ -191,7 +191,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(500) - expect(res._getJSONData()).toEqual({ error: 'Webhook processing failed' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Webhook processing failed' }) }) it('should return 200 for successful webhook processing', async () => { @@ -206,7 +206,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ received: true }) + expect(res._getJSONData()).toStrictEqual({ received: true }) }) it('should return 400 if webhook secret is missing', async () => { @@ -220,7 +220,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(400) - expect(res._getJSONData()).toEqual({ error: 'Missing webhook secret' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Missing webhook secret' }) }) it('should handle duplicate events correctly (idempotency)', async () => { @@ -235,7 +235,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ idempotent: true, received: true }) + expect(res._getJSONData()).toStrictEqual({ idempotent: true, received: true }) }) it('should ignore irrelevant event types', async () => { @@ -251,7 +251,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ ignored: true, received: true }) + expect(res._getJSONData()).toStrictEqual({ ignored: true, received: true }) }) describe('Event type handling', () => { @@ -280,7 +280,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ received: true }) + expect(res._getJSONData()).toStrictEqual({ received: true }) }) } }) @@ -300,7 +300,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ endDate: '2025-04-15T15:22:58.000Z', gift: true, received: true, @@ -322,7 +322,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ endDate: '2025-04-15T15:22:58.000Z', gift: true, hasExistingSubscription: true, @@ -345,7 +345,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ endDate: '2025-06-15T15:22:58.000Z', finalQuantity: 3, gift: true, @@ -442,7 +442,7 @@ describe('Stripe webhook handler', () => { describe('Error handling', () => { it('should handle malformed request bodies gracefully', async () => { const { req, res } = createMocks({ - body: {} as Record, + body: {}, headers: { 'stripe-signature': 'valid_signature', 'stripe-webhook-secret': 'test_secret', @@ -453,7 +453,7 @@ describe('Stripe webhook handler', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ received: true }) + expect(res._getJSONData()).toStrictEqual({ received: true }) }) it('should retry failed transactions', async () => { @@ -480,15 +480,15 @@ describe('Stripe webhook handler', () => { }) // Replace the mocked handler temporarily - await vi.mocked(handler).withImplementation(mockHandler, () => handler(req, res)) + await vi.mocked(handler).withImplementation(mockHandler, async () => handler(req, res)) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ attempts: 2, received: true, retried: true, }) - expect(mockHandler).toHaveBeenCalledTimes(1) + expect(mockHandler).toHaveBeenCalledOnce() }) it('should handle transaction timeouts', async () => { @@ -514,14 +514,14 @@ describe('Stripe webhook handler', () => { }) // Replace the mocked handler temporarily - await vi.mocked(handler).withImplementation(mockHandler, () => handler(req, res)) + await vi.mocked(handler).withImplementation(mockHandler, async () => handler(req, res)) expect(res.statusCode).toBe(500) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ error: 'Webhook processing failed', timeout: true, }) - expect(mockHandler).toHaveBeenCalledTimes(1) + expect(mockHandler).toHaveBeenCalledOnce() }) }) }) diff --git a/src/__tests__/pages/api/subscription/by-username.test.ts b/src/__tests__/pages/api/subscription/by-username.test.ts index 566b8ae5..6440fd96 100644 --- a/src/__tests__/pages/api/subscription/by-username.test.ts +++ b/src/__tests__/pages/api/subscription/by-username.test.ts @@ -1,5 +1,6 @@ import { createMocks } from 'node-mocks-http' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + import handler from '@/pages/api/subscription/by-username' // Mock prisma @@ -43,7 +44,7 @@ describe('subscription/by-username API', () => { await handler(req, res) expect(res.statusCode).toBe(400) - expect(res._getJSONData()).toEqual({ error: 'Username is required' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Username is required' }) }) it('returns 404 when user is not found', async () => { @@ -68,7 +69,7 @@ describe('subscription/by-username API', () => { }, }) expect(res.statusCode).toBe(404) - expect(res._getJSONData()).toEqual({ error: 'User not found' }) + expect(res._getJSONData()).toStrictEqual({ error: 'User not found' }) }) it('returns FREE tier when user has no subscription', async () => { @@ -81,6 +82,9 @@ describe('subscription/by-username API', () => { // Mock user found vi.mocked(prisma.user.findFirst).mockResolvedValueOnce({ + bannedAt: null, + bannedBy: null, + bannedReason: null, beta_tester: false, createdAt: new Date(), currentViewers: null, @@ -110,9 +114,6 @@ describe('subscription/by-username API', () => { updatedAt: new Date(), youtube: null, youtubeChannelId: null, - bannedAt: null, - bannedReason: null, - bannedBy: null, }) // Mock no subscription found @@ -124,9 +125,9 @@ describe('subscription/by-username API', () => { await handler(req, res) expect(getSubscription).toHaveBeenCalledWith('user-123') - expect(isInGracePeriod).toHaveBeenCalled() + expect(isInGracePeriod).toHaveBeenCalledOnce() expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ inGracePeriod: false, isGracePeriodPro: false, isLifetime: false, @@ -146,6 +147,9 @@ describe('subscription/by-username API', () => { // Mock user found vi.mocked(prisma.user.findFirst).mockResolvedValueOnce({ + bannedAt: null, + bannedBy: null, + bannedReason: null, beta_tester: false, createdAt: new Date(), currentViewers: null, @@ -175,9 +179,6 @@ describe('subscription/by-username API', () => { updatedAt: new Date(), youtube: null, youtubeChannelId: null, - bannedAt: null, - bannedReason: null, - bannedBy: null, }) // Mock active PRO subscription @@ -204,7 +205,7 @@ describe('subscription/by-username API', () => { expect(getSubscription).toHaveBeenCalledWith('user-456') expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ inGracePeriod: false, isGracePeriodPro: false, isLifetime: false, @@ -224,6 +225,9 @@ describe('subscription/by-username API', () => { // Mock user found vi.mocked(prisma.user.findFirst).mockResolvedValueOnce({ + bannedAt: null, + bannedBy: null, + bannedReason: null, beta_tester: false, createdAt: new Date(), currentViewers: null, @@ -253,9 +257,6 @@ describe('subscription/by-username API', () => { updatedAt: new Date(), youtube: null, youtubeChannelId: null, - bannedAt: null, - bannedReason: null, - bannedBy: null, }) // Mock lifetime PRO subscription @@ -282,7 +283,7 @@ describe('subscription/by-username API', () => { expect(getSubscription).toHaveBeenCalledWith('user-789') expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ inGracePeriod: false, isGracePeriodPro: false, isLifetime: true, @@ -302,6 +303,9 @@ describe('subscription/by-username API', () => { // Mock user found vi.mocked(prisma.user.findFirst).mockResolvedValueOnce({ + bannedAt: null, + bannedBy: null, + bannedReason: null, beta_tester: false, createdAt: new Date(), currentViewers: null, @@ -331,9 +335,6 @@ describe('subscription/by-username API', () => { updatedAt: new Date(), youtube: null, youtubeChannelId: null, - bannedAt: null, - bannedReason: null, - bannedBy: null, }) // Mock gift PRO subscription vi.mocked(getSubscription).mockResolvedValueOnce({ @@ -364,7 +365,7 @@ describe('subscription/by-username API', () => { expect(getSubscription).toHaveBeenCalledWith('user-101') expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ inGracePeriod: false, isGracePeriodPro: false, isLifetime: false, @@ -384,6 +385,9 @@ describe('subscription/by-username API', () => { // Mock user found vi.mocked(prisma.user.findFirst).mockResolvedValueOnce({ + bannedAt: null, + bannedBy: null, + bannedReason: null, beta_tester: false, createdAt: new Date(), currentViewers: null, @@ -413,9 +417,6 @@ describe('subscription/by-username API', () => { updatedAt: new Date(), youtube: null, youtubeChannelId: null, - bannedAt: null, - bannedReason: null, - bannedBy: null, }) // Mock FREE tier subscription vi.mocked(getSubscription).mockResolvedValueOnce({ @@ -441,7 +442,7 @@ describe('subscription/by-username API', () => { expect(getSubscription).toHaveBeenCalledWith('user-202') expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ inGracePeriod: true, isGracePeriodPro: true, isLifetime: false, @@ -465,6 +466,6 @@ describe('subscription/by-username API', () => { await handler(req, res) expect(res.statusCode).toBe(500) - expect(res._getJSONData()).toEqual({ error: 'Internal Server Error' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Internal Server Error' }) }) }) diff --git a/src/__tests__/pages/api/test-emote-set.test.ts b/src/__tests__/pages/api/test-emote-set.test.ts index 243c0010..98a1353a 100644 --- a/src/__tests__/pages/api/test-emote-set.test.ts +++ b/src/__tests__/pages/api/test-emote-set.test.ts @@ -1,7 +1,8 @@ // @ts-nocheck import { captureException, withScope } from '@sentry/nextjs' import { createMocks } from 'node-mocks-http' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + import { create7TVClient, get7TVUser } from '@/lib/7tv' import handler from '@/pages/api/test-emote-set' @@ -81,7 +82,7 @@ describe('test-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(401) - expect(res._getJSONData()).toEqual({ success: false }) + expect(res._getJSONData()).toStrictEqual({ success: false }) }) it('returns 401 when authorization header is invalid in production', async () => { @@ -98,7 +99,7 @@ describe('test-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(401) - expect(res._getJSONData()).toEqual({ success: false }) + expect(res._getJSONData()).toStrictEqual({ success: false }) }) it('returns 405 for non-GET methods', async () => { @@ -109,7 +110,7 @@ describe('test-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(405) - expect(res._getJSONData()).toEqual({ message: 'Method not allowed' }) + expect(res._getJSONData()).toStrictEqual({ message: 'Method not allowed' }) }) it('returns 403 when CRON_TWITCH_ID is missing', async () => { @@ -122,7 +123,7 @@ describe('test-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(403) - expect(res._getJSONData()).toEqual({ message: 'Forbidden' }) + expect(res._getJSONData()).toStrictEqual({ message: 'Forbidden' }) }) it('returns 500 when SEVENTV_AUTH is missing', async () => { @@ -135,7 +136,7 @@ describe('test-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(500) - expect(res._getJSONData()).toEqual({ message: 'Server configuration error' }) + expect(res._getJSONData()).toStrictEqual({ message: 'Server configuration error' }) }) it('returns 500 when the user has no active emote set', async () => { @@ -154,7 +155,7 @@ describe('test-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(500) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ error: 'No active 7TV emote set found', message: 'Internal server error', }) @@ -186,7 +187,7 @@ describe('test-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ message: 'Emote set test completed successfully' }) + expect(res._getJSONData()).toStrictEqual({ message: 'Emote set test completed successfully' }) expect(get7TVUser).toHaveBeenCalledWith('test-twitch-id') expect(create7TVClient).toHaveBeenCalledWith('test-auth-token') expect(mockClient.request).toHaveBeenCalledWith('mock-change-emote-query', { @@ -232,7 +233,7 @@ describe('test-emote-set API', () => { const mutationCalls = mockClient.request.mock.calls.filter( ([query]) => query === 'mock-change-emote-query', ) - expect(mutationCalls.map(([, variables]) => variables.action)).toEqual([ + expect(mutationCalls.map(([, variables]) => variables.action)).toStrictEqual([ 'REMOVE', 'ADD', 'REMOVE', @@ -264,7 +265,7 @@ describe('test-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(500) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ error: 'Verification failed', message: 'Internal server error', }) @@ -290,11 +291,11 @@ describe('test-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(500) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ error: 'Test error', message: 'Internal server error', }) - expect(captureException).toHaveBeenCalled() - expect(withScope).toHaveBeenCalled() + expect(captureException).toHaveBeenCalledOnce() + expect(withScope).toHaveBeenCalledOnce() }) }) diff --git a/src/__tests__/pages/api/test-gift-notification.test.ts b/src/__tests__/pages/api/test-gift-notification.test.ts index faba7123..31b3f3df 100644 --- a/src/__tests__/pages/api/test-gift-notification.test.ts +++ b/src/__tests__/pages/api/test-gift-notification.test.ts @@ -1,7 +1,8 @@ // @ts-nocheck import type { GiftSubscription, SubscriptionStatus, SubscriptionTier } from '@prisma/client' import { createMocks } from 'node-mocks-http' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { beforeEach, describe, expect, it, vi } from 'vitest' + import handler from '@/pages/api/test-gift-notification' // Mock dependencies @@ -60,11 +61,11 @@ describe('test-gift-notification API', () => { id: 'sub-123', isGift: true, metadata: null, - status: 'ACTIVE' as SubscriptionStatus, + status: 'ACTIVE', stripeCustomerId: null, stripePriceId: null, stripeSubscriptionId: null, - tier: 'PRO' as SubscriptionTier, + tier: 'PRO', transactionType: 'RECURRING', updatedAt: mockDate, userId: 'user-123', @@ -103,7 +104,7 @@ describe('test-gift-notification API', () => { await handler(req, res) expect(res.statusCode).toBe(401) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ message: 'Unauthorized', }) }) @@ -118,7 +119,7 @@ describe('test-gift-notification API', () => { await handler(req, res) expect(res.statusCode).toBe(401) - expect(res._getJSONData()).toEqual({ message: 'Unauthorized' }) + expect(res._getJSONData()).toStrictEqual({ message: 'Unauthorized' }) }) it('returns 401 when user is not an admin', async () => { @@ -144,7 +145,7 @@ describe('test-gift-notification API', () => { await handler(req, res) expect(res.statusCode).toBe(401) - expect(res._getJSONData()).toEqual({ error: 'Unauthorized' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Unauthorized' }) }) it('returns 400 for invalid gift type', async () => { @@ -173,7 +174,7 @@ describe('test-gift-notification API', () => { await handler(req, res) expect(res.statusCode).toBe(400) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ message: 'Invalid gift type. Must be monthly, annual, or lifetime', }) }) @@ -207,7 +208,7 @@ describe('test-gift-notification API', () => { await handler(req, res) expect(res.statusCode).toBe(400) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ message: 'Gift quantity must be a positive number', }) }) @@ -256,13 +257,13 @@ describe('test-gift-notification API', () => { expect(res.statusCode).toBe(200) const responseData = res._getJSONData() - expect(responseData.success).toBe(true) + expect(responseData.success).toBeTruthy() expect(responseData.message).toBe('Test gift notification created') expect(responseData.notification.id).toBe('notification-123') expect(responseData.giftSubscription.giftType).toBe('monthly') expect(responseData.giftSubscription.giftQuantity).toBe(2) expect(responseData.totalGiftedMonths).toBe(0) - expect(responseData.hasLifetime).toBe(false) + expect(responseData.hasLifetime).toBeFalsy() }) it('successfully creates a lifetime gift notification', async () => { @@ -335,13 +336,13 @@ describe('test-gift-notification API', () => { expect(res.statusCode).toBe(200) const responseData = res._getJSONData() - expect(responseData.success).toBe(true) + expect(responseData.success).toBeTruthy() expect(responseData.message).toBe('Test gift notification created') expect(responseData.notification.id).toBe('notification-123') expect(responseData.giftSubscription.giftType).toBe('lifetime') expect(responseData.giftSubscription.giftQuantity).toBe(1) expect(responseData.totalGiftedMonths).toBe('lifetime') - expect(responseData.hasLifetime).toBe(true) + expect(responseData.hasLifetime).toBeTruthy() }) it('handles existing lifetime subscription', async () => { @@ -403,10 +404,10 @@ describe('test-gift-notification API', () => { await handler(req, res) - expect(consoleWarnSpy).toHaveBeenCalled() + expect(consoleWarnSpy).toHaveBeenCalledOnce() expect(res.statusCode).toBe(200) const responseData = res._getJSONData() - expect(responseData.success).toBe(true) + expect(responseData.success).toBeTruthy() }) it('handles server error', async () => { @@ -440,7 +441,7 @@ describe('test-gift-notification API', () => { await handler(req, res) - expect(consoleErrorSpy).toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledOnce() expect(res.statusCode).toBe(500) expect(res._getJSONData().message).toBe('Internal server error') }) diff --git a/src/__tests__/pages/api/update-emote-set.test.ts b/src/__tests__/pages/api/update-emote-set.test.ts index cacd41de..4901c1d9 100644 --- a/src/__tests__/pages/api/update-emote-set.test.ts +++ b/src/__tests__/pages/api/update-emote-set.test.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { createMocks } from 'node-mocks-http' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + import handler from '@/pages/api/update-emote-set' const mockChatBotModule = vi.hoisted(() => ({ @@ -85,6 +86,7 @@ vi.mock('@/lib/getTwitchTokens', () => ({ })) import { GraphQLClient } from 'graphql-request' + // Import mocked modules import { get7TVUser } from '@/lib/7tv' import { getServerSession } from '@/lib/api/getServerSession' @@ -139,7 +141,7 @@ describe('update-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(403) - expect(res._getJSONData()).toEqual({ message: 'Forbidden' }) + expect(res._getJSONData()).toStrictEqual({ message: 'Forbidden' }) }) it('returns 403 when user is not authenticated', async () => { @@ -152,7 +154,7 @@ describe('update-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(403) - expect(res._getJSONData()).toEqual({ message: 'Forbidden' }) + expect(res._getJSONData()).toStrictEqual({ message: 'Forbidden' }) }) it('returns 403 when user does not have access to auto7TV feature', async () => { @@ -198,7 +200,7 @@ describe('update-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(403) - expect(res._getJSONData()).toEqual({ message: 'Forbidden' }) + expect(res._getJSONData()).toStrictEqual({ message: 'Forbidden' }) }) it('returns 400 when Twitch ID is missing', async () => { @@ -244,7 +246,7 @@ describe('update-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(400) - expect(res._getJSONData()).toEqual({ message: 'Twitch ID is required' }) + expect(res._getJSONData()).toStrictEqual({ message: 'Twitch ID is required' }) }) it('returns 400 when emotesRequired is not defined', async () => { @@ -292,7 +294,7 @@ describe('update-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(400) - expect(res._getJSONData()).toEqual({ message: 'No emotes defined for addition' }) + expect(res._getJSONData()).toStrictEqual({ message: 'No emotes defined for addition' }) }) it('returns 500 when SEVENTV_AUTH is not set', async () => { @@ -341,7 +343,7 @@ describe('update-emote-set API', () => { await handler(req, res) expect(res.statusCode).toBe(500) - expect(res._getJSONData()).toEqual({ message: 'Server configuration error' }) + expect(res._getJSONData()).toStrictEqual({ message: 'Server configuration error' }) }) it('returns 404 when 7TV user is not found', async () => { @@ -506,8 +508,8 @@ describe('update-emote-set API', () => { expect(res.statusCode).toBe(200) expect(res._getJSONData().message).toBe('Emote set already updated') const requestedQueries = mockRequest.mock.calls.map(([query]) => String(query)) - expect(requestedQueries.some((query) => query.includes('UpdateUserConnection'))).toBe(false) - expect(requestedQueries.some((query) => query.includes('ChangeEmoteInSet'))).toBe(false) + expect(requestedQueries.some((query) => query.includes('UpdateUserConnection'))).toBeFalsy() + expect(requestedQueries.some((query) => query.includes('ChangeEmoteInSet'))).toBeFalsy() }) it('successfully updates emote set', async () => { @@ -590,7 +592,7 @@ describe('update-emote-set API', () => { expect(addEmoteCalls).toHaveLength(2) expect(addEmoteCalls[0][1]).toMatchObject({ id: 'active-set-123' }) const requestedQueries = mockRequest.mock.calls.map(([query]) => String(query)) - expect(requestedQueries.some((query) => query.includes('UpdateUserConnection'))).toBe(false) + expect(requestedQueries.some((query) => query.includes('UpdateUserConnection'))).toBeFalsy() }) it('handles errors when adding emotes', async () => { diff --git a/src/__tests__/pages/api/update-followers.test.ts b/src/__tests__/pages/api/update-followers.test.ts index 12e0dab7..ae9baa69 100644 --- a/src/__tests__/pages/api/update-followers.test.ts +++ b/src/__tests__/pages/api/update-followers.test.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { createMocks } from 'node-mocks-http' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + import handler from '@/pages/api/update-followers' // Mock the middleware @@ -50,6 +51,7 @@ vi.mock('@/lib/auth', () => ({ })) import { captureException } from '@sentry/nextjs' + import { getServerSession } from '@/lib/api/getServerSession' import prisma from '@/lib/db' import { getTwitchTokens } from '@/lib/getTwitchTokens' @@ -77,7 +79,7 @@ describe('update-followers API', () => { await handler(req, res) expect(res.statusCode).toBe(405) - expect(res._getJSONData()).toEqual({ message: 'Method not allowed' }) + expect(res._getJSONData()).toStrictEqual({ message: 'Method not allowed' }) }) it('returns 403 when user is not authenticated', async () => { @@ -115,7 +117,7 @@ describe('update-followers API', () => { await handler(req, res) expect(res.statusCode).toBe(403) - expect(res._getJSONData()).toEqual({ message: 'Forbidden' }) + expect(res._getJSONData()).toStrictEqual({ message: 'Forbidden' }) }) it('successfully updates followers', async () => { @@ -144,7 +146,7 @@ describe('update-followers API', () => { // Mock fetch response for follower count vi.mocked(global.fetch).mockResolvedValueOnce({ - json: () => Promise.resolve({ total: 100 }), + json: async () => Promise.resolve({ total: 100 }), ok: true, } as unknown as Response) @@ -169,9 +171,9 @@ describe('update-followers API', () => { }, }, ) - expect(prisma.user.update).toHaveBeenCalled() + expect(prisma.user.update).toHaveBeenCalledOnce() const updateCall = vi.mocked(prisma.user.update).mock.calls[0][0] - expect(updateCall.where).toEqual({ id: 'user-123' }) + expect(updateCall.where).toStrictEqual({ id: 'user-123' }) expect(updateCall.data.followers).toBe(100) expect(updateCall.data.updatedAt).toBeDefined() }) @@ -212,8 +214,8 @@ describe('update-followers API', () => { expect(res._getData()).toBe('Followers updated successfully') expect(getTwitchTokens).toHaveBeenCalledWith('user-123') - expect(global.fetch).toHaveBeenCalled() - expect(captureException).toHaveBeenCalled() + expect(global.fetch).toHaveBeenCalledOnce() + expect(captureException).toHaveBeenCalledOnce() }) it('handles Twitch token errors gracefully', async () => { @@ -285,6 +287,6 @@ describe('update-followers API', () => { expect(res.statusCode).toBe(500) expect(res._getData()).toBe('Failed to update followers') - expect(captureException).toHaveBeenCalled() + expect(captureException).toHaveBeenCalledOnce() }) }) diff --git a/src/__tests__/pages/api/user/gift-subscriptions.test.ts b/src/__tests__/pages/api/user/gift-subscriptions.test.ts index 875ec987..9d98ac1f 100644 --- a/src/__tests__/pages/api/user/gift-subscriptions.test.ts +++ b/src/__tests__/pages/api/user/gift-subscriptions.test.ts @@ -1,6 +1,7 @@ import type { GiftSubscription, Subscription, User } from '@prisma/client' import { createMocks } from 'node-mocks-http' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + import handler from '@/pages/api/user/gift-subscriptions' // Mock prisma @@ -31,6 +32,7 @@ vi.mock('@/utils/formatDate', () => ({ })) import { getServerSession } from 'next-auth' + // Import mocked modules import prisma from '@/lib/db' @@ -51,7 +53,7 @@ describe('gift-subscriptions API', () => { await handler(req, res) expect(res.statusCode).toBe(405) - expect(res._getJSONData()).toEqual({ error: 'Method not allowed' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Method not allowed' }) }) it('returns 401 for unauthenticated requests', async () => { @@ -64,9 +66,9 @@ describe('gift-subscriptions API', () => { await handler(req, res) - expect(getServerSession).toHaveBeenCalled() + expect(getServerSession).toHaveBeenCalledOnce() expect(res.statusCode).toBe(401) - expect(res._getJSONData()).toEqual({ error: 'Unauthorized' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Unauthorized' }) }) it('returns no gifts when user has no gift subscriptions', async () => { @@ -89,7 +91,7 @@ describe('gift-subscriptions API', () => { await handler(req, res) - expect(getServerSession).toHaveBeenCalled() + expect(getServerSession).toHaveBeenCalledOnce() expect(prisma.subscription.findMany).toHaveBeenCalledWith({ include: { giftDetails: true, @@ -104,7 +106,7 @@ describe('gift-subscriptions API', () => { }, }) expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ + expect(res._getJSONData()).toStrictEqual({ giftCount: 0, giftMessage: '', giftSubscriptions: [], @@ -183,7 +185,7 @@ describe('gift-subscriptions API', () => { expect(res.statusCode).toBe(200) const responseData = res._getJSONData() - expect(responseData).toEqual({ + expect(responseData).toStrictEqual({ giftCount: 1, giftMessage: `Your Pro subscription is active until ${endDate.toLocaleDateString()}`, giftSubscriptions: [ @@ -299,7 +301,7 @@ describe('gift-subscriptions API', () => { expect(res.statusCode).toBe(200) const responseData = res._getJSONData() - expect(responseData).toEqual({ + expect(responseData).toStrictEqual({ giftCount: 2, giftMessage: `Your Pro subscription is active until ${endDate1.toLocaleDateString()}`, giftSubscriptions: [ @@ -407,6 +409,6 @@ describe('gift-subscriptions API', () => { await handler(req, res) expect(res.statusCode).toBe(500) - expect(res._getJSONData()).toEqual({ error: 'Failed to fetch gift subscriptions' }) + expect(res._getJSONData()).toStrictEqual({ error: 'Failed to fetch gift subscriptions' }) }) }) diff --git a/src/__tests__/pages/api/webhook-gift.test.ts b/src/__tests__/pages/api/webhook-gift.test.ts index 5d6cffe1..0622abab 100644 --- a/src/__tests__/pages/api/webhook-gift.test.ts +++ b/src/__tests__/pages/api/webhook-gift.test.ts @@ -1,7 +1,8 @@ import type { PrismaClient } from '@prisma/client' import { createMocks } from 'node-mocks-http' import type { Stripe } from 'stripe' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + import prisma from '@/lib/db' import { stripe } from '@/lib/stripe-server' @@ -108,7 +109,7 @@ vi.mock('@/lib/gift-subscription', () => ({ // Mock the getRawBody function vi.mock('raw-body', () => ({ - default: vi.fn(() => { + default: vi.fn(async () => { const mockEvent = { api_version: '2023-10-16', created: Math.floor(Date.now() / 1000), @@ -209,7 +210,7 @@ describe('Stripe Webhook Handler - Gift Subscriptions', () => { }, } - vi.mocked(prisma.$transaction).mockImplementationOnce((callback) => + vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) => callback(mockTx as unknown as PrismaClient), ) @@ -217,7 +218,7 @@ describe('Stripe Webhook Handler - Gift Subscriptions', () => { // Verify the response expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ received: true }) + expect(res._getJSONData()).toStrictEqual({ received: true }) }) it('processes lifetime gift subscriptions correctly', async () => { @@ -232,7 +233,7 @@ describe('Stripe Webhook Handler - Gift Subscriptions', () => { // Verify the response expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ received: true }) + expect(res._getJSONData()).toStrictEqual({ received: true }) }) it('extends existing subscription when user already has one', async () => { @@ -247,7 +248,7 @@ describe('Stripe Webhook Handler - Gift Subscriptions', () => { // Verify the response expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ received: true }) + expect(res._getJSONData()).toStrictEqual({ received: true }) }) it('handles user subscribing after receiving a gift subscription', async () => { @@ -315,7 +316,7 @@ describe('Stripe Webhook Handler - Gift Subscriptions', () => { }, } - vi.mocked(prisma.$transaction).mockImplementationOnce((callback) => + vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) => callback(mockTx as unknown as PrismaClient), ) @@ -323,6 +324,6 @@ describe('Stripe Webhook Handler - Gift Subscriptions', () => { // Verify the response expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ received: true }) + expect(res._getJSONData()).toStrictEqual({ received: true }) }) }) diff --git a/src/__tests__/pages/api/webhooks/nowpayments.test.ts b/src/__tests__/pages/api/webhooks/nowpayments.test.ts index a151a9de..bf500c7c 100644 --- a/src/__tests__/pages/api/webhooks/nowpayments.test.ts +++ b/src/__tests__/pages/api/webhooks/nowpayments.test.ts @@ -1,7 +1,9 @@ import crypto from 'node:crypto' + import type { NextApiRequest, NextApiResponse } from 'next' import { createMocks } from 'node-mocks-http' -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + import { sortObject } from '@/lib/nowpayments' vi.stubEnv('NOWPAYMENTS_API_KEY', 'test-api-key') diff --git a/src/__tests__/pages/api/webhooks/opennode.test.ts b/src/__tests__/pages/api/webhooks/opennode.test.ts new file mode 100644 index 00000000..7d7f1072 --- /dev/null +++ b/src/__tests__/pages/api/webhooks/opennode.test.ts @@ -0,0 +1,52 @@ +import { formatWithOptions } from 'node:util' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + getDuplicateOpenNodeWebhookAt, + logOpenNodePaymentFailure, +} from '@/pages/api/webhooks/opennode' + +vi.hoisted(() => { + vi.stubEnv('OPENNODE_API_KEY', 'test-api-key') +}) + +describe(logOpenNodePaymentFailure, () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('logs a percent-bearing charge ID literally', () => { + // This fails if chargeId is interpolated into console.error's format string. + const error = new Error('payment database offline') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + logOpenNodePaymentFailure('%s', error) + + const [logArguments] = consoleError.mock.calls + expect(logArguments).toBeDefined() + const output = formatWithOptions({}, ...logArguments) + + expect(output).toContain("chargeId: '%s'") + expect(output).toContain('Error: payment database offline') + }) + + it('skips repeated confirmed events only after their payment succeeded', () => { + const lastWebhookAt = new Date('2026-09-05T00:00:00.000Z') + const existingCharge = { lastWebhookAt, status: 'confirmed' } + + expect( + getDuplicateOpenNodeWebhookAt({ + alreadyProcessedSuccessfully: false, + existingCharge, + status: 'confirmed', + }), + ).toBeNull() + expect( + getDuplicateOpenNodeWebhookAt({ + alreadyProcessedSuccessfully: true, + existingCharge, + status: 'confirmed', + }), + ).toBe(lastWebhookAt) + }) +}) diff --git a/src/__tests__/pages/api/win-loss-adjustments.test.ts b/src/__tests__/pages/api/win-loss-adjustments.test.ts index 5f6a3bc9..ba7613ef 100644 --- a/src/__tests__/pages/api/win-loss-adjustments.test.ts +++ b/src/__tests__/pages/api/win-loss-adjustments.test.ts @@ -1,7 +1,8 @@ import type { NextApiHandler } from 'next' import type { Session } from 'next-auth' import { createMocks } from 'node-mocks-http' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { beforeEach, describe, expect, it, vi } from 'vitest' + import { getServerSession } from '@/lib/api/getServerSession' import prisma from '@/lib/db' import handler from '@/pages/api/win-loss-adjustments' diff --git a/src/__tests__/pages/collection-navigation.test.tsx b/src/__tests__/pages/collection-navigation.test.tsx index 4d759917..f1cb3473 100644 --- a/src/__tests__/pages/collection-navigation.test.tsx +++ b/src/__tests__/pages/collection-navigation.test.tsx @@ -1,5 +1,6 @@ import { render, screen, within } from '@testing-library/react' -import { describe, expect, it, vi } from 'vite-plus/test' +import { describe, expect, it, vi } from 'vitest' + import SetPage from '@/pages/[username]/set' import DetailPage from '@/pages/[username]/set/[heroId]' diff --git a/src/__tests__/pages/dashboard/billing.test.tsx b/src/__tests__/pages/dashboard/billing.test.tsx index e4108296..634a665f 100644 --- a/src/__tests__/pages/dashboard/billing.test.tsx +++ b/src/__tests__/pages/dashboard/billing.test.tsx @@ -1,7 +1,8 @@ // @ts-nocheck import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { useSession } from 'next-auth/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { beforeEach, describe, expect, it, vi } from 'vitest' + import BillingPage from '@/pages/dashboard/billing' const { messageMock } = vi.hoisted(() => ({ diff --git a/src/__tests__/pages/dashboard/index.test.tsx b/src/__tests__/pages/dashboard/index.test.tsx index af890dce..eed7ea6c 100644 --- a/src/__tests__/pages/dashboard/index.test.tsx +++ b/src/__tests__/pages/dashboard/index.test.tsx @@ -1,9 +1,10 @@ // @ts-nocheck import { render, screen } from '@testing-library/react' -import { useRouter } from 'next/router' import { useSession } from 'next-auth/react' +import { useRouter } from 'next/router' import useSWR from 'swr' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { beforeEach, describe, expect, it, vi } from 'vitest' + import { createMockRouter, createMockSession, createMockSWR } from '@/__tests__/utils/mockFactories' import SetupPage from '@/pages/dashboard/index' diff --git a/src/__tests__/pages/dashboard/managers.test.tsx b/src/__tests__/pages/dashboard/managers.test.tsx index eac6194a..7c4952dd 100644 --- a/src/__tests__/pages/dashboard/managers.test.tsx +++ b/src/__tests__/pages/dashboard/managers.test.tsx @@ -2,7 +2,8 @@ import { render } from '@testing-library/react' import { useSession } from 'next-auth/react' import useSWR from 'swr' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { beforeEach, describe, expect, it, vi } from 'vitest' + import { createMockSession } from '@/__tests__/utils/mockFactories' import { canAccessFeature } from '@/utils/subscription' diff --git a/src/__tests__/pages/match-history.test.tsx b/src/__tests__/pages/match-history.test.tsx index 8336efdd..7bb537b6 100644 --- a/src/__tests__/pages/match-history.test.tsx +++ b/src/__tests__/pages/match-history.test.tsx @@ -1,5 +1,6 @@ import { render, screen, within } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { beforeEach, describe, expect, it, vi } from 'vitest' + import MatchHistoryPage, { getServerSideProps } from '@/pages/[username]/matches' const prismaMocks = vi.hoisted(() => ({ @@ -125,7 +126,9 @@ describe('public match history page', () => { nextCursor: expect.any(String), }, }) - if (!('props' in result)) throw new Error('Expected match-history props') + if (!('props' in result)) { + throw new Error('Expected match-history props') + } expect((await result.props).matches).toHaveLength(20) }) @@ -285,7 +288,7 @@ describe('public match history page', () => { expect( screen.getAllByRole('link', { name: /Open match/ }).map((link) => link.getAttribute('href')), - ).toEqual([ + ).toStrictEqual([ 'https://www.opendota.com/matches/8964010930', 'https://www.opendota.com/matches/8964010928', ]) diff --git a/src/__tests__/pages/profile-match-overview.test.tsx b/src/__tests__/pages/profile-match-overview.test.tsx index 43253beb..2b10cba9 100644 --- a/src/__tests__/pages/profile-match-overview.test.tsx +++ b/src/__tests__/pages/profile-match-overview.test.tsx @@ -1,5 +1,6 @@ import { act, render, screen, within } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, describe, expect, it, vi } from 'vitest' + import ProfilePage, { getServerSideProps } from '@/pages/[username]' const prismaMocks = vi.hoisted(() => ({ @@ -14,7 +15,9 @@ const socketState = vi.hoisted(() => { connected: true, disconnect: vi.fn(), emit: vi.fn((event: string, _request: unknown, callback: (response: unknown) => void) => { - if (event !== 'request-wl') return + if (event !== 'request-wl') { + return + } callback({ records: [{ lose: 3, type: 'R', win: 8 }], statsDays: 14, @@ -322,4 +325,29 @@ describe('public profile match overview', () => { '/streamer/matches', ) }) + + it('keeps profile navigation on Dotabod when a supplied username resembles a protocol-relative URL', () => { + const unsafeUsername = '//attacker.example' + const { rerender } = render( + , + ) + + expect( + screen.getByRole('link', { name: '1 heroes collected, open collection' }), + ).toHaveAttribute('href', '/streamers') + + rerender() + + expect( + screen.getByRole('link', { name: '0 heroes collected, learn how the collection works' }), + ).toHaveAttribute('href', '/streamers') + expect(screen.getByRole('link', { name: 'Match history' })).toHaveAttribute( + 'href', + '/streamers', + ) + }) }) diff --git a/src/__tests__/supabase/sync-hubspot/error-response.test.ts b/src/__tests__/supabase/sync-hubspot/error-response.test.ts new file mode 100644 index 00000000..c064e717 --- /dev/null +++ b/src/__tests__/supabase/sync-hubspot/error-response.test.ts @@ -0,0 +1,19 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { internalServerErrorResponse } from '../../../../supabase/functions/sync-hubspot/error-response' + +describe(internalServerErrorResponse, () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('logs failure details without exposing them in the response', async () => { + const error = new Error('HubSpot token private-app-secret was rejected') + const consoleError = vi.spyOn(console, 'error').mockReturnValue() + const response = internalServerErrorResponse(error) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toStrictEqual({ error: 'Internal server error' }) + expect(consoleError).toHaveBeenCalledWith('sync-hubspot fatal', error) + }) +}) diff --git a/src/__tests__/types/twitch.test.ts b/src/__tests__/types/twitch.test.ts new file mode 100644 index 00000000..1176a794 --- /dev/null +++ b/src/__tests__/types/twitch.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +import { parseTwitchProfile } from '@/types/twitch' + +describe('parseTwitchProfile', () => { + it('keeps the supported Twitch OIDC claims', () => { + expect( + parseTwitchProfile({ + email: 'streamer@example.com', + nonce: 'unrelated-provider-claim', + picture: 'https://cdn.example.com/avatar.png', + preferred_username: 'Streamer', + sub: '1234', + }), + ).toStrictEqual({ + email: 'streamer@example.com', + picture: 'https://cdn.example.com/avatar.png', + preferred_username: 'Streamer', + sub: '1234', + }) + }) + + it('rejects malformed Twitch claims instead of treating them as strings', () => { + expect(parseTwitchProfile({ preferred_username: 1234 })).toBeUndefined() + }) +}) diff --git a/src/__tests__/utils/gift-links.test.ts b/src/__tests__/utils/gift-links.test.ts new file mode 100644 index 00000000..e15ce923 --- /dev/null +++ b/src/__tests__/utils/gift-links.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { createGiftLink } from '@/utils/gift-links' + +describe('gift subscription route creation', () => { + it('creates a relative profile route for a valid Twitch login name', () => { + expect(createGiftLink(' Streamer_42 ')).toBe('/streamer_42/gift') + }) + + it('allows a short legacy Twitch login name', () => { + expect(createGiftLink('a')).toBe('/a/gift') + }) + + it('uses the generic gift page when the supplied username could escape the profile route', () => { + expect(createGiftLink('//evil.example')).toBe('/gift') + }) +}) diff --git a/src/__tests__/utils/mockFactories.ts b/src/__tests__/utils/mockFactories.ts index 1ccb848a..c75762c3 100644 --- a/src/__tests__/utils/mockFactories.ts +++ b/src/__tests__/utils/mockFactories.ts @@ -1,72 +1,36 @@ -// @ts-nocheck -import type { UseRouterResult } from 'next/router' import type { Session } from 'next-auth' +import type { SessionContextValue } from 'next-auth/react' +import type { NextRouter } from 'next/router' import type { SWRResponse } from 'swr' +import { vi } from 'vitest' -interface MockSessionData { - user: { - name: string - email: string - id: string - image: string - } - expires: string - status: 'authenticated' | 'unauthenticated' | 'loading' - update: ReturnType -} - -interface MockRouterData { - query: Record - pathname: string - replace: ReturnType - push: ReturnType - reload: ReturnType - back: ReturnType - prefetch: ReturnType - beforePopState: ReturnType - events: { - on: ReturnType - off: ReturnType - emit: ReturnType - } - isFallback: boolean - isReady: boolean - isPreview: boolean - asPath: string - basePath: string - isLocaleDomain: boolean - route: string - forward: ReturnType -} +type AuthenticatedSession = Extract +type StreamStatus = { stream_online: boolean } -interface MockSWRData { - data: T - error: unknown - isLoading: boolean - isValidating: boolean - mutate: ReturnType -} +export function createMockSession(overrides?: Partial): AuthenticatedSession { + const session = { + expires: '1', + user: { + email: 'test@example.com', + id: 'user-123', + image: 'https://example.com/avatar.png', + isImpersonating: false, + locale: 'en', + name: 'Test User', + scope: '', + twitchId: 'twitch-123', + }, + ...overrides, + } satisfies Session -export function createMockSession( - overrides?: Partial, -): Session & { status: 'authenticated'; update: ReturnType } { return { - data: { - expires: '1', - status: 'authenticated', - update: vi.fn(), - user: { - email: 'test@example.com', - id: 'user-123', - image: 'https://example.com/avatar.png', - name: 'Test User', - }, - ...overrides, - }, - } as unknown as Session & { status: 'authenticated'; update: ReturnType } + data: session, + status: 'authenticated', + update: vi.fn<(data?: unknown) => Promise>().mockResolvedValue(session), + } } -export function createMockRouter(overrides?: Partial): UseRouterResult { +export function createMockRouter(overrides?: Partial): NextRouter { return { asPath: '', back: vi.fn(), @@ -90,18 +54,18 @@ export function createMockRouter(overrides?: Partial): UseRouter replace: vi.fn(), route: '', ...overrides, - } as unknown as UseRouterResult + } satisfies NextRouter } -export function createMockSWR( - overrides?: Partial>, -): SWRResponse { +export function createMockSWR( + overrides?: Partial>, +): SWRResponse { return { - data: { stream_online: false } as T, - error: null, + data: { stream_online: false }, + error: undefined, isLoading: false, isValidating: false, mutate: vi.fn(), ...overrides, - } as unknown as SWRResponse + } satisfies SWRResponse } diff --git a/src/__tests__/utils/subscription.test.ts b/src/__tests__/utils/subscription.test.ts index b2c5350f..0e2ade8a 100644 --- a/src/__tests__/utils/subscription.test.ts +++ b/src/__tests__/utils/subscription.test.ts @@ -1,10 +1,6 @@ -import { - type Subscription, - SubscriptionStatus, - SubscriptionTier, - TransactionType, -} from '@prisma/client' -import { describe, expect, it, vi } from 'vite-plus/test' +import { SubscriptionStatus, SubscriptionTier, TransactionType } from '@prisma/client' +import type { Subscription } from '@prisma/client' +import { describe, expect, it, vi } from 'vitest' // We need to mock the module before importing it vi.mock('@/utils/subscription', async () => { @@ -71,6 +67,9 @@ describe('Subscription priority logic', () => { // Mock the database calls with proper types // Set proExpiration to null to avoid the virtual gift subscription vi.mocked(prisma.user.findUnique).mockResolvedValue({ + bannedAt: null, + bannedBy: null, + bannedReason: null, beta_tester: false, createdAt: new Date(), currentViewers: null, @@ -100,9 +99,6 @@ describe('Subscription priority logic', () => { updatedAt: new Date(), youtube: null, youtubeChannelId: null, - bannedAt: null, - bannedReason: null, - bannedBy: null, }) vi.mocked(prisma.subscription.findMany).mockResolvedValue([ @@ -124,6 +120,9 @@ describe('Subscription priority logic', () => { it('returns null when no subscriptions exist and grace period check fails', async () => { // Mock the database calls vi.mocked(prisma.user.findUnique).mockResolvedValue({ + bannedAt: null, + bannedBy: null, + bannedReason: null, beta_tester: false, createdAt: new Date(), currentViewers: null, @@ -153,9 +152,6 @@ describe('Subscription priority logic', () => { updatedAt: new Date(), youtube: null, youtubeChannelId: null, - bannedAt: null, - bannedReason: null, - bannedBy: null, }) // Return empty array to simulate no subscriptions @@ -170,7 +166,7 @@ describe('Subscription priority logic', () => { }) }) -describe('getBillingSummaryInfo', () => { +describe(getBillingSummaryInfo, () => { it('summarizes an active paid subscription with Stripe management', () => { const summary = getBillingSummaryInfo({ cancelAtPeriodEnd: false, @@ -189,7 +185,7 @@ describe('getBillingSummaryInfo', () => { expect(summary.headline).toBe('Your Pro plan is active') expect(summary.nextStepLabel).toBe('Renews') expect(summary.nextStepValue).toBe('April 20, 2026') - expect(summary.canManageInStripe).toBe(true) + expect(summary.canManageInStripe).toBeTruthy() expect(summary.portalButtonLabel).toBe('Open billing portal') }) @@ -229,7 +225,7 @@ describe('getBillingSummaryInfo', () => { }) expect(summary.statusLabel).toBe('Complimentary access') - expect(summary.canManageInStripe).toBe(false) + expect(summary.canManageInStripe).toBeFalsy() expect(summary.portalSummaryLabel).toBe('No Stripe billing profile yet') }) @@ -249,7 +245,7 @@ describe('getBillingSummaryInfo', () => { }) expect(summary.headline).toBe('You have lifetime access to Dotabod Pro') - expect(summary.canManageInStripe).toBe(false) + expect(summary.canManageInStripe).toBeFalsy() expect(summary.creditMessage).toContain('$25.00') }) @@ -271,7 +267,7 @@ describe('getBillingSummaryInfo', () => { expect(summary.headline).toBe('Your subscription has been canceled') expect(summary.statusLabel).toBe('Canceled') expect(summary.tone).toBe('info') - expect(summary.canManageInStripe).toBe(true) + expect(summary.canManageInStripe).toBeTruthy() }) it('summarizes an incomplete subscription with warning tone', () => { @@ -292,7 +288,7 @@ describe('getBillingSummaryInfo', () => { expect(summary.headline).toBe('Your payment is incomplete') expect(summary.statusLabel).toBe('Incomplete') expect(summary.tone).toBe('warning') - expect(summary.canManageInStripe).toBe(true) + expect(summary.canManageInStripe).toBeTruthy() expect(summary.portalButtonLabel).toBe('Update payment method') }) @@ -334,7 +330,7 @@ describe('getBillingSummaryInfo', () => { expect(summary.headline).toBe('Your invoice is unpaid') expect(summary.statusLabel).toBe('Unpaid') expect(summary.tone).toBe('error') - expect(summary.canManageInStripe).toBe(true) + expect(summary.canManageInStripe).toBeTruthy() expect(summary.portalButtonLabel).toBe('Pay invoice') }) @@ -356,7 +352,7 @@ describe('getBillingSummaryInfo', () => { expect(summary.headline).toBe('Your subscription is paused') expect(summary.statusLabel).toBe('Paused') expect(summary.tone).toBe('warning') - expect(summary.canManageInStripe).toBe(true) + expect(summary.canManageInStripe).toBeTruthy() expect(summary.portalButtonLabel).toBe('Resume subscription') }) }) diff --git a/src/components/AccessibleEmoji.tsx b/src/components/AccessibleEmoji.tsx index 5570b5d7..a0346511 100644 --- a/src/components/AccessibleEmoji.tsx +++ b/src/components/AccessibleEmoji.tsx @@ -1,5 +1,3 @@ -import type {} from 'react' - interface AccessibleEmojiProps { emoji: string label: string diff --git a/src/components/AnnouncementBanner.tsx b/src/components/AnnouncementBanner.tsx index 0c9b84d1..2ac806da 100644 --- a/src/components/AnnouncementBanner.tsx +++ b/src/components/AnnouncementBanner.tsx @@ -1,7 +1,7 @@ import { XMarkIcon } from '@heroicons/react/20/solid' import Link from 'next/link' -export interface AnnouncementBannerContent { +interface AnnouncementBannerContent { href: string label: string prefix: string @@ -44,7 +44,7 @@ export default function AnnouncementBanner({ announcement, onDismiss }: Announce {announcement.prefix}: {announcement.title}.{' '} {announcement.label}  diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index c7b838ca..cdc548b1 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -1,4 +1,5 @@ import Image from 'next/image' + import { useTransformRes } from '@/lib/hooks/useTransformRes' interface BadgeProps { diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index d81f17d0..a559fa0c 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import useSWR from 'swr' + import AnnouncementBanner from '@/components/AnnouncementBanner' import { fetcher } from '@/lib/fetcher' import { whatsNewSorted } from '@/lib/whatsNew' diff --git a/src/components/Billing/BillingOverview.tsx b/src/components/Billing/BillingOverview.tsx index 695edd10..de0962f7 100644 --- a/src/components/Billing/BillingOverview.tsx +++ b/src/components/Billing/BillingOverview.tsx @@ -1,6 +1,7 @@ import { Button, Skeleton } from 'antd' import clsx from 'clsx' import { ExternalLinkIcon, GiftIcon } from 'lucide-react' + import { useSubscriptionContext } from '@/contexts/SubscriptionContext' import { Card } from '@/ui/card' import { @@ -8,6 +9,7 @@ import { isPaypalSubscription, isSubscriptionActive, } from '@/utils/subscription' + import { BillingNotice } from './BillingNotice' const chipClasses = { @@ -60,7 +62,7 @@ export function BillingOverview({ isLoading, onOpenPortal }: BillingOverviewProp return (
- + Current plan
-
+
{summary.nextStepLabel}
{summary.nextStepValue}
diff --git a/src/components/Billing/BillingPlans.tsx b/src/components/Billing/BillingPlans.tsx index 3944a0a6..0efc6dd5 100644 --- a/src/components/Billing/BillingPlans.tsx +++ b/src/components/Billing/BillingPlans.tsx @@ -1,16 +1,18 @@ import { StarOutlined } from '@ant-design/icons' -import Image from 'next/image' import { useSession } from 'next-auth/react' +import Image from 'next/image' import { useEffect, useRef, useState } from 'react' + import { useSubscriptionContext } from '@/contexts/SubscriptionContext' import { getCurrentPeriod, gracePeriodPrettyDate, isSubscriptionActive, - type PricePeriod, SUBSCRIPTION_TIERS, } from '@/utils/subscription' -import Plan from '../Plan' +import type { PricePeriod } from '@/utils/subscription' + +import Plan from '../Plan/Plan' import { SubscriptionStatus } from '../Subscription/SubscriptionStatus' import { PeriodToggle } from './PeriodToggle' @@ -130,7 +132,7 @@ export function BillingPlans({ showTitle = true }: BillingPlansProps) {

Two plans. Free covers the basics, Pro covers the rest.

@@ -149,7 +151,7 @@ export function BillingPlans({ showTitle = true }: BillingPlansProps) {

) : ( -

+

Start on Free, no card needed. Move up to Pro when you want the full kit.

)} diff --git a/src/components/Billing/PaymentStatusAlert.tsx b/src/components/Billing/PaymentStatusAlert.tsx index 7067c326..ac17cd62 100644 --- a/src/components/Billing/PaymentStatusAlert.tsx +++ b/src/components/Billing/PaymentStatusAlert.tsx @@ -2,6 +2,7 @@ import { App, Button } from 'antd' import { Loader2Icon } from 'lucide-react' import { useRouter } from 'next/router' import { useCallback, useEffect, useRef, useState } from 'react' + import { BillingNotice } from './BillingNotice' const Spinner = () => @@ -161,7 +162,7 @@ export const PaymentStatusAlert = () => { tone='error' title="We couldn't check your payment" action={ - } @@ -210,7 +211,7 @@ export const PaymentStatusAlert = () => { )} {paymentStatus.statusInfo.type === 'processing' && ( - )} diff --git a/src/components/Billing/PeriodToggle.tsx b/src/components/Billing/PeriodToggle.tsx index 982ddf0d..b78815be 100644 --- a/src/components/Billing/PeriodToggle.tsx +++ b/src/components/Billing/PeriodToggle.tsx @@ -1,8 +1,10 @@ import clsx from 'clsx' import { LayoutGroup, motion, useReducedMotion } from 'framer-motion' import { useId } from 'react' + import { plans } from '@/components/Billing/BillingPlans' -import { calculateSavings, type PricePeriod } from '@/utils/subscription' +import { calculateSavings } from '@/utils/subscription' +import type { PricePeriod } from '@/utils/subscription' interface PeriodToggleProps { activePeriod: PricePeriod @@ -44,7 +46,9 @@ export function PeriodToggle({ activePeriod, onChange }: PeriodToggleProps) { name={`billing-period-${groupId}`} value={period} checked={selected} - onChange={() => onChange(period)} + onChange={() => { + onChange(period) + }} className='peer sr-only' /> {selected && ( @@ -58,7 +62,7 @@ export function PeriodToggle({ activePeriod, onChange }: PeriodToggleProps) { @@ -66,7 +70,7 @@ export function PeriodToggle({ activePeriod, onChange }: PeriodToggleProps) { {showSavings && ( diff --git a/src/components/CookieConsent.tsx b/src/components/CookieConsent.tsx index d593f3ee..b13b72e6 100644 --- a/src/components/CookieConsent.tsx +++ b/src/components/CookieConsent.tsx @@ -3,7 +3,9 @@ import type { CheckboxChangeEvent } from 'antd/es/checkbox' import { Cookie } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' -import { COOKIE_EVENTS, type CookiePreferences, useCookiePreferences } from '@/lib/cookieManager' + +import { COOKIE_EVENTS, useCookiePreferences } from '@/lib/cookieManager' +import type { CookiePreferences } from '@/lib/cookieManager' const { Title, Paragraph } = Typography @@ -228,7 +230,9 @@ const CookieConsent = () => { setShowSettings(false)} + onClose={() => { + setShowSettings(false) + }} open={showSettings} height={600} width='100%' @@ -327,7 +331,7 @@ const CookieConsent = () => { /> ), key: `${key}-cookies`, - label: `View ${category.cookies.length} cookie${category.cookies.length !== 1 ? 's' : ''}`, + label: `View ${category.cookies.length} cookie${category.cookies.length === 1 ? '' : 's'}`, }, ]} /> diff --git a/src/components/CosmeticSet/HeroCard.tsx b/src/components/CosmeticSet/HeroCard.tsx index 83706edf..9fe08f58 100644 --- a/src/components/CosmeticSet/HeroCard.tsx +++ b/src/components/CosmeticSet/HeroCard.tsx @@ -1,5 +1,7 @@ import Link from 'next/link' -import { type PointerEvent, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import type { PointerEvent } from 'react' + import { hexA, RARITY_META } from './cosmetics' export interface HeroCardData { @@ -15,9 +17,9 @@ export interface HeroCardData { type Size = 'sm' | 'md' | 'lg' const SIZE: Record = { - sm: { name: 'text-sm', meta: 'text-[10px]' }, - md: { name: 'text-base', meta: 'text-[11px]' }, - lg: { name: 'text-xl sm:text-2xl', meta: 'text-xs' }, + lg: { meta: 'text-xs', name: 'text-xl sm:text-2xl' }, + md: { meta: 'text-[11px]', name: 'text-base' }, + sm: { meta: 'text-[10px]', name: 'text-sm' }, } // Treatment tiers (the Pokémon holo-vs-common instinct): chase cards earn the most @@ -28,10 +30,14 @@ function useReducedMotion() { const [reduced, setReduced] = useState(true) useEffect(() => { const mq = window.matchMedia('(prefers-reduced-motion: reduce)') - const update = () => setReduced(mq.matches) + const update = () => { + setReduced(mq.matches) + } update() mq.addEventListener('change', update) - return () => mq.removeEventListener('change', update) + return () => { + mq.removeEventListener('change', update) + } }, []) return reduced } @@ -62,7 +68,9 @@ export function HeroCard({ const onMove = (e: PointerEvent) => { const el = planeRef.current - if (!el || !interactive) return + if (!el || !interactive) { + return + } const r = e.currentTarget.getBoundingClientRect() const px = (e.clientX - r.left) / r.width const py = (e.clientY - r.top) / r.height @@ -75,7 +83,9 @@ export function HeroCard({ const reset = () => { const el = planeRef.current - if (!el) return + if (!el) { + return + } el.style.setProperty('--rx', '0deg') el.style.setProperty('--ry', '0deg') el.style.setProperty('--sheen', '0') @@ -86,7 +96,7 @@ export function HeroCard({ href={`/${username}/set/${card.heroId}`} onPointerMove={onMove} onPointerLeave={reset} - className={`group/card block rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-400 ${className}`} + className={`group/card block rounded-2xl focus-visible:ring-2 focus-visible:ring-purple-400 focus-visible:outline-none ${className}`} style={{ perspective: interactive ? '900px' : undefined }} aria-label={`${card.heroName} — ${card.itemCount} cosmetics${meta ? `, up to ${meta.label}` : ''}`} > @@ -95,13 +105,13 @@ export function HeroCard({ className='relative aspect-[5/7] overflow-hidden rounded-2xl border bg-gray-900 transition-transform duration-200 ease-out will-change-transform group-hover/card:-translate-y-0.5' style={{ borderColor: t === 'flat' ? hexA(accent, 0.3) : hexA(accent, 0.55), - transform: interactive ? 'rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg))' : undefined, boxShadow: t === 'chase' ? `0 0 0 1px ${hexA(accent, 0.8)}, 0 12px 40px ${hexA(accent, 0.28)}` : t === 'foil' ? `0 8px 30px ${hexA(accent, 0.16)}` : '0 6px 20px rgba(0,0,0,0.35)', + transform: interactive ? 'rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg))' : undefined, }} > {/* Hero splash as the card face. */} @@ -137,8 +147,8 @@ export function HeroCard({ aria-hidden className='pointer-events-none absolute inset-0 mix-blend-screen transition-opacity duration-200' style={{ - opacity: 'var(--sheen,0)' as unknown as number, background: `radial-gradient(40% 30% at var(--mx,50%) var(--my,0%), ${hexA(accent, 0.5)}, transparent 70%)`, + opacity: 'var(--sheen,0)' as unknown as number, }} /> )} @@ -154,18 +164,18 @@ export function HeroCard({ )} {card.justPlayed && ( - + Just played )} {/* Name / rarity / count strip. */}
-

+

{card.heroName}

{meta && {meta.label}} {meta && ·} @@ -181,7 +191,7 @@ function Corner({ pos, accent }: { pos: string; accent: string }) { return ( ) diff --git a/src/components/CosmeticSet/ItemTile.tsx b/src/components/CosmeticSet/ItemTile.tsx index 46ef251f..916e6bb0 100644 --- a/src/components/CosmeticSet/ItemTile.tsx +++ b/src/components/CosmeticSet/ItemTile.tsx @@ -1,6 +1,8 @@ import { ArrowUpRight } from 'lucide-react' import Link from 'next/link' -import { type CosmeticItem, formatSlot, hexA, initials, marketUrl, rarityOf } from './cosmetics' + +import { formatSlot, hexA, initials, marketUrl, rarityOf } from './cosmetics' +import type { CosmeticItem } from './cosmetics' export function ItemTile({ item, featured = false }: { item: CosmeticItem; featured?: boolean }) { const href = marketUrl(item) @@ -48,7 +50,7 @@ export function ItemTile({ item, featured = false }: { item: CosmeticItem; featu ) : ( {initials(item.name)} @@ -75,7 +77,7 @@ export function ItemTile({ item, featured = false }: { item: CosmeticItem; featu {r && ( {r.label} @@ -84,14 +86,16 @@ export function ItemTile({ item, featured = false }: { item: CosmeticItem; featu
) - if (!href) return inner + if (!href) { + return inner + } return ( {inner} diff --git a/src/components/CosmeticSet/RarityChip.tsx b/src/components/CosmeticSet/RarityChip.tsx index 271970db..828c8cae 100644 --- a/src/components/CosmeticSet/RarityChip.tsx +++ b/src/components/CosmeticSet/RarityChip.tsx @@ -2,13 +2,15 @@ import { hexA, RARITY_META } from './cosmetics' export function RarityChip({ rarity, count }: { rarity: string; count: number }) { const r = RARITY_META[rarity] - if (!r) return null + if (!r) { + return null + } return ( diff --git a/src/components/CosmeticSet/cosmetics.ts b/src/components/CosmeticSet/cosmetics.ts index 34273239..d414636b 100644 --- a/src/components/CosmeticSet/cosmetics.ts +++ b/src/components/CosmeticSet/cosmetics.ts @@ -14,7 +14,7 @@ export interface CosmeticItem { } // In-game loadout order; used only as a tiebreak once items are ranked by rarity. -export const SLOT_ORDER = [ +const SLOT_ORDER = [ 'weapon', 'head', 'shoulder', @@ -37,19 +37,19 @@ export const SLOT_ORDER = [ // not decoration. Kept as hex on purpose: fidelity to the external system matters more // than re-deriving them in OKLCH. export const RARITY_META: Record = { - common: { rank: 0, color: '#b0c3d9', label: 'Common' }, - uncommon: { rank: 1, color: '#5e98d9', label: 'Uncommon' }, - rare: { rank: 2, color: '#4b69ff', label: 'Rare' }, - mythical: { rank: 3, color: '#8847ff', label: 'Mythical' }, - legendary: { rank: 4, color: '#d32ce6', label: 'Legendary' }, - immortal: { rank: 5, color: '#e4ae39', label: 'Immortal' }, - arcana: { rank: 6, color: '#ade55c', label: 'Arcana' }, - ancient: { rank: 7, color: '#eb4b4b', label: 'Ancient' }, + ancient: { color: '#eb4b4b', label: 'Ancient', rank: 7 }, + arcana: { color: '#ade55c', label: 'Arcana', rank: 6 }, + common: { color: '#b0c3d9', label: 'Common', rank: 0 }, + immortal: { color: '#e4ae39', label: 'Immortal', rank: 5 }, + legendary: { color: '#d32ce6', label: 'Legendary', rank: 4 }, + mythical: { color: '#8847ff', label: 'Mythical', rank: 3 }, + rare: { color: '#4b69ff', label: 'Rare', rank: 2 }, + uncommon: { color: '#5e98d9', label: 'Uncommon', rank: 1 }, } // Slots arrive as raw tokens like "body_head"; show them as "Body Head". export const formatSlot = (slot: string) => - slot.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + slot.replaceAll('_', ' ').replaceAll(/\b\w/g, (c) => c.toUpperCase()) // Fallback monogram for items that have no captured icon (most do not). export const initials = (name: string) => @@ -70,18 +70,24 @@ export const rarityOf = (item: CosmeticItem) => (item.rarity ? RARITY_META[item. export const rarityRank = (item: CosmeticItem) => rarityOf(item)?.rank ?? -1 export function marketUrl(item: CosmeticItem): string | null { - if (!item.marketable || !item.marketHashName) return null + if (!item.marketable || !item.marketHashName) { + return null + } return `https://steamcommunity.com/market/listings/570/${encodeURIComponent(item.marketHashName)}` } export function sortByRarity(items: CosmeticItem[]): CosmeticItem[] { return [...items].sort((a, b) => { const byRarity = rarityRank(b) - rarityRank(a) - if (byRarity) return byRarity + if (byRarity) { + return byRarity + } const ai = SLOT_ORDER.indexOf(a.slot) const bi = SLOT_ORDER.indexOf(b.slot) const bySlot = (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) - if (bySlot) return bySlot + if (bySlot) { + return bySlot + } return a.name.localeCompare(b.name) }) } @@ -103,9 +109,11 @@ export function bestRarity(items: CosmeticItem[]): string | undefined { // rarity -> count across a loadout, rarest first. Reused for the collection header // tally and the per-card chip summary. -export function rarityTally(items: CosmeticItem[]): Array<[string, number]> { +function rarityTally(items: CosmeticItem[]): [string, number][] { const counts = new Map() - for (const i of items) if (i.rarity) counts.set(i.rarity, (counts.get(i.rarity) ?? 0) + 1) + for (const i of items) { + if (i.rarity) counts.set(i.rarity, (counts.get(i.rarity) ?? 0) + 1) + } return [...counts.entries()].sort( (a, b) => (RARITY_META[b[0]]?.rank ?? -1) - (RARITY_META[a[0]]?.rank ?? -1), ) diff --git a/src/components/Dashboard/ChatBot.tsx b/src/components/Dashboard/ChatBot.tsx index 0dec75b6..2acbc477 100644 --- a/src/components/Dashboard/ChatBot.tsx +++ b/src/components/Dashboard/ChatBot.tsx @@ -1,25 +1,27 @@ import { Alert, Button, Divider, List, Spin, Tabs, Tooltip } from 'antd' import clsx from 'clsx' import { ExternalLinkIcon } from 'lucide-react' +import { useSession } from 'next-auth/react' import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/router' -import { useSession } from 'next-auth/react' import { useEffect, useState } from 'react' import useSWR from 'swr' + import { TierBadge } from '@/components/Dashboard/Features/TierBadge' import { useFeatureAccess } from '@/hooks/useSubscription' import { Settings } from '@/lib/defaultSettings' import { fetcher } from '@/lib/fetcher' import { useSetupModStatus } from '@/lib/hooks/useSetupModStatus' import { - STABLE_SWR_OPTIONS, + SETTINGS_SWR_OPTIONS, useUpdateAccount, useUpdateSetting, } from '@/lib/hooks/useUpdateSetting' import { useTrack } from '@/lib/track' import { StepComponent } from '@/pages/dashboard/help' import { Card } from '@/ui/card' + import MmrForm from './Features/MmrForm' const SevenTVBaseEmoteURL = (id: string) => `https://cdn.7tv.app/emote/${id}/2x.webp` @@ -117,16 +119,16 @@ export default function ChatBot() { // Pro: fire-and-forget the POST that adds dotabod as a moderator. We don't read its // Result anymore — current mod state comes from useSetupModStatus below — but the // POST still needs to run so the auto-mod action happens for Pro users. - useSWR(hasAutoModeratorAccess ? '/api/make-dotabod-mod' : null, fetcher, STABLE_SWR_OPTIONS) + useSWR(hasAutoModeratorAccess ? '/api/make-dotabod-mod' : null, fetcher, SETTINGS_SWR_OPTIONS) const { data: modStatus } = useSetupModStatus() const { hasAccess: hasAuto7TVAccess } = useFeatureAccess('auto7TV') const { error: updateEmoteSetError } = useSWR( hasAuto7TVAccess && user?.id ? '/api/update-emote-set' : null, - (url) => { + async (url) => { track('updateEmoteSet called') return fetcher(url) }, - STABLE_SWR_OPTIONS, + SETTINGS_SWR_OPTIONS, ) const [activeKey7TV, setActiveKey7TV] = useState('auto') const [activeKeyMod, setActiveKeyMod] = useState('auto') @@ -218,7 +220,9 @@ export default function ChatBot() { // Every 5 seconds const intervalId = setInterval(fetchUserData, 5000) - return () => clearInterval(intervalId) + return () => { + clearInterval(intervalId) + } }, [stvUrl, updateEmoteSetError]) const { data: mmr } = useUpdateSetting(Settings.mmr) @@ -261,20 +265,7 @@ export default function ChatBot() { ]} steps={[ - {!stepOneComplete ? ( - <> -
- - Dotabod doesn't know your MMR right now, so let's tell it - - - {' '} - (you can change it later) - -
- - - ) : ( + {stepOneComplete ? (
Dotabod knows your MMR.{' '} @@ -288,10 +279,25 @@ export default function ChatBot() {
+ ) : ( + <> +
+ + Dotabod doesn't know your MMR right now, so let's tell it + + + {' '} + (you can change it later) + +
+ + )}
, - {!stepModComplete ? ( + {stepModComplete ? ( +
Dotabod is a moderator in your Twitch channel.
+ ) : ( <>
@@ -299,8 +305,6 @@ export default function ChatBot() { properly.
- ) : ( -
Dotabod is a moderator in your Twitch channel.
)}
, ]} @@ -326,20 +330,7 @@ export default function ChatBot() { hideTitle={true} steps={[ - {!stepOneComplete ? ( - <> -
- - Dotabod doesn't know your MMR right now, so let's tell it - - - {' '} - (you can change it later) - -
- - - ) : ( + {stepOneComplete ? (
Dotabod knows your MMR.{' '} @@ -353,12 +344,27 @@ export default function ChatBot() {
+ ) : ( + <> +
+ + Dotabod doesn't know your MMR right now, so let's tell it + + + {' '} + (you can change it later) + +
+ + )}
, Go to your Twitch chat, Type the command: /mod dotabod, - {!stepModComplete ? ( + {stepModComplete ? ( +
Dotabod is a moderator in your Twitch channel.
+ ) : ( <>
@@ -366,8 +372,6 @@ export default function ChatBot() { properly.
- ) : ( -
Dotabod is a moderator in your Twitch channel.
)}
, ]} @@ -416,7 +420,9 @@ export default function ChatBot() {
{loading && } - {!user ? ( + {user ? ( +
You have a 7TV account connected to Twitch.
+ ) : ( <>
You don't have a 7TV account setup yet! Dotabod uses 7TV to display @@ -437,15 +443,15 @@ export default function ChatBot() {
- ) : ( -
You have a 7TV account connected to Twitch.
)}
,
- {!user?.hasDotabodEditor ? ( + {user?.hasDotabodEditor ? ( +
Dotabod is an editor on your 7TV account.
+ ) : (
{user?.hasDotabodEmoteSet ? (
@@ -503,13 +509,11 @@ export default function ChatBot() {
)}
- ) : ( -
Dotabod is an editor on your 7TV account.
)}
,
-
+
{updateEmoteSetError ? (
@@ -519,7 +523,9 @@ export default function ChatBot() { showIcon />
- ) : !user?.hasDotabodEmoteSet ? ( + ) : user?.hasDotabodEmoteSet ? ( +
All required emotes have been added to your channel!
+ ) : (

@@ -527,8 +533,6 @@ export default function ChatBot() { previous steps are completed.

- ) : ( -
All required emotes have been added to your channel!
)}
diff --git a/src/components/Dashboard/CodeBlock.tsx b/src/components/Dashboard/CodeBlock.tsx index beb8ff26..d047666b 100644 --- a/src/components/Dashboard/CodeBlock.tsx +++ b/src/components/Dashboard/CodeBlock.tsx @@ -2,6 +2,7 @@ import { CheckOutlined, CopyOutlined } from '@ant-design/icons' import { Button, Tooltip, Typography } from 'antd' import Link from 'next/link' import { useState } from 'react' + import { useTrack } from '@/lib/track' const CodeBlock = () => { @@ -15,14 +16,16 @@ const CodeBlock = () => { .writeText(`powershell -c "irm https://${window.location.host}/install | iex"`) .then(() => { setCopied(true) - setTimeout(() => setCopied(false), 2500) + setTimeout(() => { + setCopied(false) + }, 2500) }) } return ( -
-
-
+    
+
+
           
             
               powershell 
@@ -51,11 +54,11 @@ const CodeBlock = () => {
           />
         
       
-
+
View source diff --git a/src/components/Dashboard/CommandDetail.tsx b/src/components/Dashboard/CommandDetail.tsx index ffb3e2f7..9571366d 100644 --- a/src/components/Dashboard/CommandDetail.tsx +++ b/src/components/Dashboard/CommandDetail.tsx @@ -1,4 +1,5 @@ import Image from 'next/image' + import { chatterInfo } from '@/components/Dashboard/Features/ChatterCard' import TwitchChat from '@/components/TwitchChat' import type { CommandKeys, commands } from '@/lib/defaultSettings' @@ -427,14 +428,14 @@ const CommandDetail: Record< width={24} height={24} alt='based' - className='ml-1 mr-1 inline' + className='mr-1 ml-1 inline' /> clap . Only mods can type. @@ -478,7 +479,7 @@ const CommandDetail: Record< width={24} height={24} alt='south korea' - className='ml-1 mr-1 inline' + className='mr-1 ml-1 inline' /> DuBu (Shadow Shaman) · russia Collapse (Magnus) · estonia Puppy (Chen) · usa PPD (Tusk) · usa Rajjix (Timbersaw) diff --git a/src/components/Dashboard/CompactDisableToggle.tsx b/src/components/Dashboard/CompactDisableToggle.tsx deleted file mode 100644 index 9d8da75e..00000000 --- a/src/components/Dashboard/CompactDisableToggle.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Switch, Tooltip } from 'antd' -import useSWR from 'swr' -import { Settings } from '@/lib/defaultSettings' -import { fetcher } from '@/lib/fetcher' -import { STABLE_SWR_OPTIONS, useUpdateSetting } from '@/lib/hooks/useUpdateSetting' - -export function CompactDisableToggle() { - const { data } = useSWR('/api/check-ban', fetcher, STABLE_SWR_OPTIONS) - - const { data: isDotabodDisabled, updateSetting } = useUpdateSetting(Settings.commandDisable) - - const checkBanOrDisable = isDotabodDisabled || data?.banned - - return ( - - updateSetting(!checked)} - /> - - ) -} diff --git a/src/components/Dashboard/ConnectSteam.tsx b/src/components/Dashboard/ConnectSteam.tsx index 6ee3f426..1604ee0c 100644 --- a/src/components/Dashboard/ConnectSteam.tsx +++ b/src/components/Dashboard/ConnectSteam.tsx @@ -2,6 +2,7 @@ import { CheckCircleFilled } from '@ant-design/icons' import { Alert, Button, Collapse, Tag } from 'antd' import Link from 'next/link' import { useState } from 'react' + import { useSteamLinkedAccount } from '@/lib/hooks/useSteamLinkedAccount' import { useTrack } from '@/lib/track' import { Card } from '@/ui/card' @@ -36,7 +37,7 @@ const ConnectSteam = ({ isLive }: Props) => { return (
-

Connect your Steam account

+

Connect your Steam account

Play any match or demo a hero while your stream is live. Your Steam account links automatically the first time, once only. @@ -56,8 +57,8 @@ const ConnectSteam = ({ isLive }: Props) => { The page will keep retrying in the background. If this sticks,{' '} {' '} @@ -77,7 +78,7 @@ const ConnectSteam = ({ isLive }: Props) => { Launch Dota 2 {hasLaunchedDota && ( -

+

Once you're in a match (or demo), this page will update on its own.

)} @@ -97,7 +98,9 @@ const ConnectSteam = ({ isLive }: Props) => {
track('setup/collapse_test_dotabod')} + onChange={() => { + track('setup/collapse_test_dotabod') + }} items={[ { children: , @@ -130,7 +133,7 @@ const StatusPanel = ({ } return ( -
+
- {hint &&

{hint}

} + {hint &&

{hint}

}
) @@ -185,7 +188,7 @@ const TroubleshootingContent = ({ isLive }: { isLive: boolean }) => ( /> )}

Two ways to trigger the first connection:

-
    +
    • Quick check: demo any hero, then type !innate in chat to confirm Dotabod sees the game. diff --git a/src/components/Dashboard/DashboardShell.tsx b/src/components/Dashboard/DashboardShell.tsx index e11ea8aa..5a2d6df9 100644 --- a/src/components/Dashboard/DashboardShell.tsx +++ b/src/components/Dashboard/DashboardShell.tsx @@ -1,15 +1,17 @@ import { Bars3Icon } from '@heroicons/react/24/outline' import { CopyButton } from '@mantine/core' import { captureException } from '@sentry/nextjs' -import { Button, Drawer, Layout, Menu, type MenuProps, theme } from 'antd' +import { Button, Drawer, Layout, Menu, theme } from 'antd' +import type { MenuProps } from 'antd' import clsx from 'clsx' +import { useSession } from 'next-auth/react' import Head from 'next/head' import Link from 'next/link' import { useRouter } from 'next/router' -import { useSession } from 'next-auth/react' import type React from 'react' import { useEffect, useState } from 'react' import useSWR from 'swr' + import Banner from '@/components/Banner' import CookieConsent from '@/components/CookieConsent' import { DisableToggle } from '@/components/Dashboard/DisableToggle' @@ -22,7 +24,8 @@ import { useFeatureAccess } from '@/hooks/useSubscription' import { fetcher } from '@/lib/fetcher' import { useBaseUrl } from '@/lib/hooks/useBaseUrl' import useMaybeSignout from '@/lib/hooks/useMaybeSignout' -import { STABLE_SWR_OPTIONS } from '@/lib/hooks/useUpdateSetting' +import { SETTINGS_SWR_OPTIONS } from '@/lib/hooks/useUpdateSetting' + import { HelpMenu } from './HelpMenu' import { filterNav, findBestMatchingMenuItem, navConfig, navItemToMenuItem } from './navigation' import { SettingsSearch } from './SettingsSearch' @@ -69,7 +72,7 @@ export default function DashboardShell({ 'Manage your Dotabod settings, commands, and features to enhance your Dota 2 streaming experience.' const host = process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL || - (typeof window !== 'undefined' ? window.location.host : 'dotabod.com') + (typeof window === 'undefined' ? 'dotabod.com' : window.location.host) const defaultOgImage = `https://${host}/images/welcome.png` const defaultUrl = `https://${host}/dashboard` @@ -98,13 +101,13 @@ export default function DashboardShell({ fetch('/api/update-followers').catch((error) => { captureException(error) - return console.error(error) + console.error(error) }) if (hasAutoModeratorAccess) { fetch('/api/make-dotabod-mod').catch((error) => { captureException(error) - return console.error(error) + console.error(error) }) } } @@ -137,7 +140,7 @@ export default function DashboardShell({ const { data: giftNotificationData, mutate: refreshGiftNotifications } = useSWR( status === 'authenticated' ? '/api/notifications' : null, fetcher, - STABLE_SWR_OPTIONS, + SETTINGS_SWR_OPTIONS, ) const [hasGiftNotification, setHasGiftNotification] = useState(false) @@ -338,7 +341,9 @@ export default function DashboardShell({ setDrawerOpen(false)} + onClose={() => { + setDrawerOpen(false) + }} width={250} closable={false} rootClassName='md:hidden' @@ -351,7 +356,11 @@ export default function DashboardShell({ }, }} > - {renderNav({ onNavigate: () => setDrawerOpen(false) })} + {renderNav({ + onNavigate: () => { + setDrawerOpen(false) + }, + })} @@ -366,9 +375,11 @@ export default function DashboardShell({ aria-label='Open navigation menu' className='flex shrink-0 items-center md:hidden!' icon={} - onClick={() => setDrawerOpen(true)} + onClick={() => { + setDrawerOpen(true) + }} /> -
      +
      @@ -396,7 +407,7 @@ export default function DashboardShell({ { @@ -28,7 +30,9 @@ const Toggle = () => { label={`Dotabod is ${checkBanOrDisable ? 'disabled' : 'enabled'}`} disabled={data?.banned} checked={!checkBanOrDisable} - onChange={(checked) => updateSetting(!checked)} + onChange={(checked) => { + updateSetting(!checked) + }} /> ) diff --git a/src/components/Dashboard/ExportCFG.tsx b/src/components/Dashboard/ExportCFG.tsx index ebadc48d..64e6a01d 100644 --- a/src/components/Dashboard/ExportCFG.tsx +++ b/src/components/Dashboard/ExportCFG.tsx @@ -2,9 +2,11 @@ import { AppleOutlined, LinuxOutlined, WindowsOutlined } from '@ant-design/icons import { Alert, Button, Tabs } from 'antd' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' + import UnixInstaller from '@/components/Dashboard/UnixInstaller' import { useTrack } from '@/lib/track' import { Card } from '@/ui/card' + import WindowsInstaller from './WindowsInstaller' function InstallPage() { @@ -64,8 +66,10 @@ function InstallPage() { The Automatic installer is Windows only. If you play Dota 2 on Windows,{' '} {' '} diff --git a/src/components/Dashboard/Features/AutoCommandsCard.tsx b/src/components/Dashboard/Features/AutoCommandsCard.tsx index ecfd354a..052ac499 100644 --- a/src/components/Dashboard/Features/AutoCommandsCard.tsx +++ b/src/components/Dashboard/Features/AutoCommandsCard.tsx @@ -1,10 +1,12 @@ import { Alert, Checkbox, Collapse, Spin, Tag } from 'antd' import clsx from 'clsx' import { useEffect, useState } from 'react' + import CommandDetail from '@/components/Dashboard/CommandDetail' import { Settings } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import { Card } from '@/ui/card' + import { TierSwitch } from './TierSwitch' const AUTO_COMMAND_KEYS = [ @@ -105,8 +107,12 @@ export const AutoCommandsCard = () => { e.stopPropagation()} - onChange={(e) => handleCommandToggle(key, e.target.checked)} + onClick={(e) => { + e.stopPropagation() + }} + onChange={(e) => { + handleCommandToggle(key, e.target.checked) + }} />
      {command.cmd} diff --git a/src/components/Dashboard/Features/AutoTranslateCard.tsx b/src/components/Dashboard/Features/AutoTranslateCard.tsx index a507b9e2..fa51b300 100644 --- a/src/components/Dashboard/Features/AutoTranslateCard.tsx +++ b/src/components/Dashboard/Features/AutoTranslateCard.tsx @@ -1,9 +1,11 @@ import { InfoCircleOutlined } from '@ant-design/icons' import { Alert, Select, Tag } from 'antd' + import { Settings } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import { localePatchSchema } from '@/lib/validations/setting' import { Card } from '@/ui/card' + import { TierSwitch } from './TierSwitch' // Convert locale schema to options for the Select component @@ -47,7 +49,7 @@ export default function AutoTranslateCard(): React.ReactNode { return ( +
      Automatic Translation New
      } @@ -58,14 +60,14 @@ export default function AutoTranslateCard(): React.ReactNode {
      -

      +

      Choose how you want translations to appear: in your chat, on your stream overlay, or both. When enabled, Dotabod will translate incoming in-game chat messages from other languages to your selected target language, helping international viewers understand conversations.

      -
      +
      -
      +
      @@ -108,7 +110,7 @@ export default function AutoTranslateCard(): React.ReactNode { style={{ width: 200 }} placeholder='Select target language' /> -

      +

      Chat messages from your games will be translated to this language on the overlay.

      @@ -140,7 +142,7 @@ export default function AutoTranslateCard(): React.ReactNode { /> )} -
      +

      How it works: Dotabod uses DeepL to convert incoming in-game chat messages in real-time. Independently toggle where you want translations to appear - in diff --git a/src/components/Dashboard/Features/BetsCard.tsx b/src/components/Dashboard/Features/BetsCard.tsx index 2f745e64..9ed5e7eb 100644 --- a/src/components/Dashboard/Features/BetsCard.tsx +++ b/src/components/Dashboard/Features/BetsCard.tsx @@ -2,14 +2,16 @@ import { Button, Form, Spin, Tag } from 'antd' import clsx from 'clsx' import Image from 'next/image' import { useEffect } from 'react' + import { Settings } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import { Card } from '@/ui/card' + import { Input } from '../../Input' import { TierSwitch } from './TierSwitch' export default function BetsCard() { - const { data: isEnabled } = useUpdateSetting(Settings.bets) + const { data: isEnabled } = useUpdateSetting(Settings.bets) const { data: info, loading, @@ -23,7 +25,9 @@ export default function BetsCard() { const [form] = Form.useForm() - useEffect(() => form.resetFields(), [info]) + useEffect(() => { + form.resetFields() + }, [info]) return ( @@ -62,7 +66,7 @@ export default function BetsCard() { label='Title' name='title' help={ -

      +
      [heroname] will be replaced with the hero name
      diff --git a/src/components/Dashboard/Features/ChatterCard.tsx b/src/components/Dashboard/Features/ChatterCard.tsx index 5ca243a6..a3a78ca9 100644 --- a/src/components/Dashboard/Features/ChatterCard.tsx +++ b/src/components/Dashboard/Features/ChatterCard.tsx @@ -1,10 +1,12 @@ import { Tooltip } from 'antd' import clsx from 'clsx' import Image from 'next/image' + import { Settings } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import { Card } from '@/ui/card' import type { ChatterSettingKeys } from '@/utils/subscription' + import DotabodChatter from './DotabodChatter' import { TierSwitch } from './TierSwitch' @@ -333,20 +335,16 @@ export const chatterInfo = { }, } -const groupedChatterInfo = Object.entries(chatterInfo).reduce( - (acc, [key, value]) => { - const { category } = value - if (!acc[category]) { - acc[category] = [] - } - acc[category].push({ ...value, id: key }) - return acc - }, - {} as Record< - string, - { id: string; tooltip: string; category: CATEGORIES; message: React.ReactNode }[] - >, -) +const groupedChatterInfo = Object.entries(chatterInfo).reduce< + Record +>((acc, [key, value]) => { + const { category } = value + if (!acc[category]) { + acc[category] = [] + } + acc[category].push({ ...value, id: key }) + return acc +}, {}) type GroupedChatterItem = (typeof groupedChatterInfo)[string][number] diff --git a/src/components/Dashboard/Features/ClippingCard.tsx b/src/components/Dashboard/Features/ClippingCard.tsx index f2a5b961..4fb746ca 100644 --- a/src/components/Dashboard/Features/ClippingCard.tsx +++ b/src/components/Dashboard/Features/ClippingCard.tsx @@ -1,8 +1,10 @@ import { InfoCircleOutlined } from '@ant-design/icons' import { Alert, Tag } from 'antd' + import { Settings } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import { Card } from '@/ui/card' + import { TierSwitch } from './TierSwitch' const EXPLICIT_NOTE_COMMANDS = [ @@ -57,12 +59,14 @@ export default function ClippingCard(): React.ReactNode { Valve's live API stops sending roster data at that bracket.
      -
      +
      updateSetting(!checked)} + onChange={(checked) => { + updateSetting(!checked) + }} label='High-MMR match detection' /> @@ -71,35 +75,35 @@ export default function ClippingCard(): React.ReactNode {
      -

      Powers these commands:

      +

      Powers these commands:

      -

      Shows a "no data" message when off:

      +

      Shows a "no data" message when off:

      {EXPLICIT_NOTE_COMMANDS.map(({ cmd, desc }) => ( - + {cmd} · {desc} ))}
      -

      Roster silently comes back empty:

      +

      Roster silently comes back empty:

      {SILENT_COMMANDS.map(({ cmd, desc }) => ( - + {cmd} · {desc} ))}
      -

      +

      Only when asked about a teammate or opponent (asking about yourself always works):

      {LOOKUP_COMMANDS.map((cmd) => ( - + {cmd} ))} @@ -116,10 +120,10 @@ export default function ClippingCard(): React.ReactNode {

      With detection off, these commands lose match data for players with 8500+ MMR:

      -
        +
          {[...EXPLICIT_NOTE_COMMANDS, ...SILENT_COMMANDS].map(({ cmd, desc }) => (
        • - {cmd} - {desc} + {cmd} - {desc}
        • ))}
        • Teammate/opponent lookups: {LOOKUP_COMMANDS.join(', ')}
        • @@ -137,7 +141,7 @@ export default function ClippingCard(): React.ReactNode { /> )} -
          +

          How it works: since Valve won't hand over the data directly, Dotabod grabs a 5-second Twitch clip of the draft/hero bar and reads it with vision AI. The clip diff --git a/src/components/Dashboard/Features/CommandsCard.tsx b/src/components/Dashboard/Features/CommandsCard.tsx index 53fac807..f1076eea 100644 --- a/src/components/Dashboard/Features/CommandsCard.tsx +++ b/src/components/Dashboard/Features/CommandsCard.tsx @@ -1,7 +1,9 @@ import { Collapse, Tag } from 'antd' + import type CommandDetail from '@/components/Dashboard/CommandDetail' import { useFeatureAccess } from '@/hooks/useSubscription' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' + import { TierSwitch } from './TierSwitch' export default function CommandsCard({ @@ -36,7 +38,7 @@ export default function CommandsCard({ > {command.title}

          {command.allowed === 'mods' && ( -
          +
          Mods Streamer
          @@ -58,13 +60,13 @@ export default function CommandsCard({ {command.key && (readonly ? ( - {(publicIsEnabled !== undefined ? publicIsEnabled : isEnabled) + {(publicIsEnabled === undefined ? isEnabled : publicIsEnabled) ? 'Enabled' : 'Disabled'} @@ -72,7 +74,7 @@ export default function CommandsCard({ ))} @@ -86,7 +88,7 @@ export default function CommandsCard({

          Command

          -
          +
          {command.cmd}
          @@ -97,7 +99,7 @@ export default function CommandsCard({

          Alias

          {command.alias.map((alias) => ( -
          +
          !{alias}
          ))} diff --git a/src/components/Dashboard/Features/DotabodChatter.tsx b/src/components/Dashboard/Features/DotabodChatter.tsx index fc366137..3203c00b 100644 --- a/src/components/Dashboard/Features/DotabodChatter.tsx +++ b/src/components/Dashboard/Features/DotabodChatter.tsx @@ -1,6 +1,8 @@ import { Tooltip } from 'antd' + import { Settings } from '@/lib/defaultSettings' import { Card } from '@/ui/card' + import { TierSwitch } from './TierSwitch' export default function DotabodChatter() { diff --git a/src/components/Dashboard/Features/IdeaCard.tsx b/src/components/Dashboard/Features/IdeaCard.tsx index fedd2165..8b4d6229 100644 --- a/src/components/Dashboard/Features/IdeaCard.tsx +++ b/src/components/Dashboard/Features/IdeaCard.tsx @@ -1,5 +1,6 @@ import { Image } from 'antd' import clsx from 'clsx' + import { Card } from '@/ui/card' export default function IdeaCard() { diff --git a/src/components/Dashboard/Features/LanguageCard.tsx b/src/components/Dashboard/Features/LanguageCard.tsx index fe262351..96afb7cd 100644 --- a/src/components/Dashboard/Features/LanguageCard.tsx +++ b/src/components/Dashboard/Features/LanguageCard.tsx @@ -2,11 +2,10 @@ import { Button, Progress, Select, Spin } from 'antd' import clsx from 'clsx' import Image from 'next/image' import { forwardRef } from 'react' + import NumberTicker from '@/components/magicui/number-ticker' -import useLanguageTranslations, { - type CrowdinLanguage, - getLanguageProgress, -} from '@/lib/hooks/useLanguageTranslation' +import useLanguageTranslations, { getLanguageProgress } from '@/lib/hooks/useLanguageTranslation' +import type { CrowdinLanguage } from '@/lib/hooks/useLanguageTranslation' import { useUpdateLocale } from '@/lib/hooks/useUpdateSetting' import { Card } from '@/ui/card' @@ -101,7 +100,7 @@ export default function LanguageCard() { }) const UsedBy = () => ( -
          +
          Used by {isLoading ? ( @@ -184,7 +183,9 @@ export default function LanguageCard() { value: x.value, }))} value={localeOption?.locale} - onChange={(value) => updateLocale(value)} + onChange={(value) => { + updateLocale(value) + }} />
          diff --git a/src/components/Dashboard/Features/LockedFeatureOverlay.tsx b/src/components/Dashboard/Features/LockedFeatureOverlay.tsx index 2cf33786..09929d32 100644 --- a/src/components/Dashboard/Features/LockedFeatureOverlay.tsx +++ b/src/components/Dashboard/Features/LockedFeatureOverlay.tsx @@ -1,7 +1,9 @@ import type { SubscriptionTier } from '@prisma/client' import { Button } from 'antd' import Link from 'next/link' + import { SUBSCRIPTION_TIERS } from '@/utils/subscription' + import { TierBadge } from './TierBadge' interface LockedFeatureOverlayProps { @@ -23,17 +25,17 @@ export function LockedFeatureOverlay({ } return ( -
          -
          +
          +
          -

          {message}

          +

          {message}

          diff --git a/src/components/Dashboard/Features/MinimapCard.tsx b/src/components/Dashboard/Features/MinimapCard.tsx index 6b5b0e8b..d5bf187c 100644 --- a/src/components/Dashboard/Features/MinimapCard.tsx +++ b/src/components/Dashboard/Features/MinimapCard.tsx @@ -1,9 +1,11 @@ import clsx from 'clsx' import Image from 'next/image' import { useEffect, useState } from 'react' + import { Settings } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import { Card } from '@/ui/card' + import { TierBadge } from './TierBadge' import { TierSlider } from './TierSlider' import { TierSwitch } from './TierSwitch' @@ -78,7 +80,7 @@ export default function MinimapCard(): React.ReactNode { Semi-transparent blocker that auto places itself over your minimap to deter people from farming your wards.
          -
          +
          {switches.map((props) => ( @@ -114,7 +116,7 @@ export default function MinimapCard(): React.ReactNode { alt='minimap blocker' width={minimapXl ? 280 : 240} height={minimapXl ? 280 : 240} - src={`/images/overlay/minimap/741-${'Complex'}-${ + src={`/images/overlay/minimap/741-Complex-${ minimapXl ? 'X' : '' }Large-AntiStreamSnipeMap.png`} /> @@ -132,7 +134,7 @@ export default function MinimapCard(): React.ReactNode { alt='minimap blocker' width={minimapXl ? 280 : 240} height={minimapXl ? 280 : 240} - src={`/images/overlay/minimap/741-${'Simple'}-${ + src={`/images/overlay/minimap/741-Simple-${ minimapXl ? 'X' : '' }Large-AntiStreamSnipeMap.png`} /> diff --git a/src/components/Dashboard/Features/MmrForm.tsx b/src/components/Dashboard/Features/MmrForm.tsx index 0168bfd6..35c0cb95 100644 --- a/src/components/Dashboard/Features/MmrForm.tsx +++ b/src/components/Dashboard/Features/MmrForm.tsx @@ -8,6 +8,7 @@ import Link from 'next/link' import { useEffect, useState } from 'react' import useSWR from 'swr' import { useDebouncedCallback } from 'use-debounce' + import { AccessibleEmoji } from '@/components/AccessibleEmoji' import { Input } from '@/components/Input' import { MMRBadge } from '@/components/Overlay/rank/MMRBadge' @@ -15,12 +16,12 @@ import { Settings } from '@/lib/defaultSettings' import { fetcher } from '@/lib/fetcher' import { SETTINGS_SWR_OPTIONS, - type SettingsSteamAccount, - STABLE_SWR_OPTIONS, useUpdateAccount, useUpdateSetting, } from '@/lib/hooks/useUpdateSetting' -import { getRankDetail, getRankImage, type RankType } from '@/lib/ranks' +import type { SettingsSteamAccount } from '@/lib/hooks/useUpdateSetting' +import { getRankDetail, getRankImage } from '@/lib/ranks' +import type { RankType } from '@/lib/ranks' // Add type for form values interface FormValues { @@ -137,7 +138,11 @@ const MmrForm = ({ hideText = false }) => { const steamIds = accounts.map((a) => a.steam32Id) const path = `/api/steam/${steamIds.join('/')}` - const { data: steamData } = useSWR(steamIds.length > 0 ? path : null, fetcher, STABLE_SWR_OPTIONS) + const { data: steamData } = useSWR( + steamIds.length > 0 ? path : null, + fetcher, + SETTINGS_SWR_OPTIONS, + ) useEffect(() => { if (data?.accounts) { @@ -164,7 +169,7 @@ const MmrForm = ({ hideText = false }) => { return ( <> - {form.values.accounts?.length !== 0 ? ( + {form.values.accounts?.length === 0 ? null : (
          { @@ -213,7 +218,7 @@ const MmrForm = ({ hideText = false }) => { className='mx-1 inline' > {multiUsedBy} - + removes it from their dashboard. Or, join our{' '} help page for support. @@ -325,7 +330,7 @@ const MmrForm = ({ hideText = false }) => { )}
          - ) : null} + )} {form.values.accounts.length === 0 && (
          @@ -353,7 +358,7 @@ const MmrForm = ({ hideText = false }) => { onChange={debouncedMmr} /> )} -
          diff --git a/src/components/Dashboard/Features/MmrTrackerCard.tsx b/src/components/Dashboard/Features/MmrTrackerCard.tsx index 848986e7..3ac85808 100644 --- a/src/components/Dashboard/Features/MmrTrackerCard.tsx +++ b/src/components/Dashboard/Features/MmrTrackerCard.tsx @@ -1,7 +1,9 @@ import { Tag, Tooltip } from 'antd' import clsx from 'clsx' + import { Settings } from '@/lib/defaultSettings' import { Card } from '@/ui/card' + import MmrForm from './MmrForm' import { TierSwitch } from './TierSwitch' diff --git a/src/components/Dashboard/Features/NewFeatureChatToggles.tsx b/src/components/Dashboard/Features/NewFeatureChatToggles.tsx index 61781c74..a692a9a8 100644 --- a/src/components/Dashboard/Features/NewFeatureChatToggles.tsx +++ b/src/components/Dashboard/Features/NewFeatureChatToggles.tsx @@ -1,8 +1,10 @@ import Link from 'next/link' import type { ReactNode } from 'react' + import { Settings } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import { Card } from '@/ui/card' + import { TierSwitch } from './TierSwitch' // New-feature chat toggles (cosmetic-set announcements, team smoke alerts). They follow the @@ -19,7 +21,7 @@ function MasterFollowingToggle({ label: string description: ReactNode }) { - const { data: master } = useUpdateSetting(Settings.autoOptInNewFeatures) + const { data: master } = useUpdateSetting(Settings.autoOptInNewFeatures) const { data: value, updateSetting } = useUpdateSetting(settingKey) return ( @@ -27,7 +29,9 @@ function MasterFollowingToggle({ updateSetting(checked)} + onChange={(checked) => { + updateSetting(checked) + }} label={label} />

          {description}

          diff --git a/src/components/Dashboard/Features/NewFeaturesCard.tsx b/src/components/Dashboard/Features/NewFeaturesCard.tsx index 278faa38..d5b205be 100644 --- a/src/components/Dashboard/Features/NewFeaturesCard.tsx +++ b/src/components/Dashboard/Features/NewFeaturesCard.tsx @@ -1,6 +1,8 @@ import Link from 'next/link' + import { Settings } from '@/lib/defaultSettings' import { Card } from '@/ui/card' + import { TierSwitch } from './TierSwitch' // Account-wide control for how new dotabod features (across chat, overlays, betting, etc.) diff --git a/src/components/Dashboard/Features/NotablePlayers.tsx b/src/components/Dashboard/Features/NotablePlayers.tsx index e3ce072b..70a54254 100644 --- a/src/components/Dashboard/Features/NotablePlayers.tsx +++ b/src/components/Dashboard/Features/NotablePlayers.tsx @@ -1,8 +1,10 @@ import clsx from 'clsx' import Image from 'next/image' + import { Settings } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import { Card } from '@/ui/card' + import { TierSwitch } from './TierSwitch' export default function NotablePlayersCard() { @@ -11,7 +13,7 @@ export default function NotablePlayersCard() { return (
          Show notable players for 2 minutes under the hero top bar.
          -
          +
          Players with their country flags
          diff --git a/src/components/Dashboard/Features/PicksCard.tsx b/src/components/Dashboard/Features/PicksCard.tsx index 9ac10d09..e16778c9 100644 --- a/src/components/Dashboard/Features/PicksCard.tsx +++ b/src/components/Dashboard/Features/PicksCard.tsx @@ -1,8 +1,10 @@ import clsx from 'clsx' import Image from 'next/image' + import { Settings } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import { Card } from '@/ui/card' + import { TierSwitch } from './TierSwitch' export default function PicksCard() { @@ -18,7 +20,7 @@ export default function PicksCard() { label='Enable pick blocker' />
          -
          +

          There are several pick blocker overlays phases available. Dotabod intelligently auto chooses which one to show. diff --git a/src/components/Dashboard/Features/QueueCard.tsx b/src/components/Dashboard/Features/QueueCard.tsx index 7cd91297..256448a5 100644 --- a/src/components/Dashboard/Features/QueueCard.tsx +++ b/src/components/Dashboard/Features/QueueCard.tsx @@ -2,9 +2,11 @@ import { Button, Form, Spin } from 'antd' import clsx from 'clsx' import Image from 'next/image' import { useEffect } from 'react' + import { defaultSettings, Settings } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import { Card } from '@/ui/card' + import { TierInput } from './TierInput' import { TierSwitch } from './TierSwitch' @@ -16,7 +18,9 @@ export default function QueueCard() { ) const [form] = Form.useForm() - useEffect(() => form.resetFields(), [findMatchText]) + useEffect(() => { + form.resetFields() + }, [findMatchText]) return ( @@ -30,7 +34,7 @@ export default function QueueCard() { label='Enable queue blocker overlay' />

          -
          +
          updateFindMatchText(form.text)} + onFinish={(form) => { + updateFindMatchText(form.text) + }} > Custom find match text} name='text'> Increase the delay that Dotabod responds to game events.
          -
          +
          {!loading && (
          -
          +
          Maximum: 50 minutes • Current: {minutes}m {seconds}s
          diff --git a/src/components/Dashboard/Features/TierBadge.tsx b/src/components/Dashboard/Features/TierBadge.tsx index 11168b01..9b06d784 100644 --- a/src/components/Dashboard/Features/TierBadge.tsx +++ b/src/components/Dashboard/Features/TierBadge.tsx @@ -2,14 +2,10 @@ import type { SubscriptionTier } from '@prisma/client' import { Button, Tag, Tooltip } from 'antd' import { CrownIcon } from 'lucide-react' import Link from 'next/link' + import { useSubscription } from '@/hooks/useSubscription' -import { - type FeatureTier, - type GenericFeature, - getRequiredTier, - isSubscriptionActive, - SUBSCRIPTION_TIERS, -} from '@/utils/subscription' +import { getRequiredTier, isSubscriptionActive, SUBSCRIPTION_TIERS } from '@/utils/subscription' +import type { FeatureTier, GenericFeature } from '@/utils/subscription' export const TierBadge: React.FC<{ requiredTier?: SubscriptionTier | null diff --git a/src/components/Dashboard/Features/TierInput.tsx b/src/components/Dashboard/Features/TierInput.tsx index 46917628..31ecf1e9 100644 --- a/src/components/Dashboard/Features/TierInput.tsx +++ b/src/components/Dashboard/Features/TierInput.tsx @@ -1,7 +1,10 @@ -import { Input, type InputProps } from 'antd' +import { Input } from 'antd' +import type { InputProps } from 'antd' + import type { SettingKeys } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import type { ChatterSettingKeys } from '@/utils/subscription' + import { TierBadge } from './TierBadge' interface TierInputProps extends Omit { diff --git a/src/components/Dashboard/Features/TierSlider.tsx b/src/components/Dashboard/Features/TierSlider.tsx index ad867969..c248b6a6 100644 --- a/src/components/Dashboard/Features/TierSlider.tsx +++ b/src/components/Dashboard/Features/TierSlider.tsx @@ -1,8 +1,11 @@ -import { Slider, type SliderSingleProps } from 'antd' +import { Slider } from 'antd' +import type { SliderSingleProps } from 'antd' import { useEffect, useState } from 'react' + import type { SettingKeys } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import type { ChatterSettingKeys } from '@/utils/subscription' + import { TierBadge } from './TierBadge' interface TierSliderProps extends Omit { diff --git a/src/components/Dashboard/Features/TierSwitch.tsx b/src/components/Dashboard/Features/TierSwitch.tsx index 8c5d1f70..fb6b2626 100644 --- a/src/components/Dashboard/Features/TierSwitch.tsx +++ b/src/components/Dashboard/Features/TierSwitch.tsx @@ -1,8 +1,10 @@ import { Switch } from 'antd' import { useId } from 'react' + import type { SettingKeys } from '@/lib/defaultSettings' import { useUpdateSetting } from '@/lib/hooks/useUpdateSetting' import type { ChatterSettingKeys } from '@/utils/subscription' + import { TierBadge } from './TierBadge' interface TierSwitchProps { @@ -36,7 +38,7 @@ export function TierSwitch({ const handleChange = externalOnChange ?? updateSetting return (
          -
          +
          = { @@ -72,7 +75,7 @@ export default function WhatsNewFeatureCard({
          -

          +

          {entry.title}

          {entry.description}

          @@ -106,7 +109,7 @@ export default function WhatsNewFeatureCard({ {entry.details && entry.details.length > 0 && (
          - +