refactor: turn on strict TypeScript and fix the 63 errors it found

My earlier estimate of 34 errors was wrong, and the reason is worth
recording: tsconfig carried "strictNullChecks": false as a duplicate key
AFTER the strict flag, and an explicit option beats the strict umbrella
even when --strict is passed on the CLI. So the measurement that produced
34 had silently excluded every null-safety error. The real number was 63.
Both duplicate keys are gone; strict: true now stands alone.

Most were implicit-any, but strict caught several genuine type lies:

- SendSmsPayload.deviceId and WebhookData._id were optional while both
  are interpolated into request paths, so an absent value would have hit
  /gateway/devices/undefined/send-sms or PATCH /webhooks/undefined.
- webhook-table's deviceName was typed string while buildDeviceLabel
  returns string | string[] and the Device cell already renders the
  array case. The type never described what the code produced.
- webhooks-section read `webhooks?.data?.length > 0`, comparing
  undefined against 0 while the query was still in flight.
- app-header declared a non-null Session while its own body guarded with
  session?.user throughout. Making the type honest surfaced four
  genuinely unguarded accesses.
- api-keys kept a local ApiKeyRow duplicating the shared ApiKey type, so
  the list callback annotated rows as one type while the hook returned
  the other. ApiKeyRow is now an alias and the two extra fields moved
  onto ApiKey.
- The notifications envelope typed its rows as unknown[], so the
  deliveries table's row type went entirely unchecked. Now a real
  WebhookNotification type.

The react-hook-form cluster (20 of the 63) was one root cause: zod's
.default() makes the input and output types differ, so z.infer (the
output) is not what the resolver takes. Fixed by typing the forms with
z.input and z.output separately, which changes nothing at runtime.

Also bumped target es5 to ES2017, which fixes the Set-iteration error
that made tsc --noEmit fail before any of this. Next compiles browser
output via SWC and its own browserslist, so bundle targeting is
unaffected. Added @types/papaparse and @types/react-syntax-highlighter,
and a typecheck script, since next build does not check test files.

No @ts-expect-error and no new any were used.

Verified: typecheck clean, build clean, 0 lint errors (21 warnings,
unchanged), 155 unit tests, 78 e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isra el
2026-07-19 04:19:35 +03:00
parent c1942a4ba3
commit 3e4597294c
22 changed files with 196 additions and 66 deletions

View File

@@ -36,12 +36,18 @@ const registerSchema = z.object({
.min(1, { message: 'Please complete the bot verification' }),
})
type RegisterFormValues = z.infer<typeof registerSchema>
// marketingOptIn is `.optional().default(true)`, so zod's input and output
// types differ: optional going in, guaranteed coming out. The form holds the
// input shape and the submit handler receives the output shape, which is what
// useForm's third generic is for. Using z.infer (the output) for both made the
// resolver unassignable.
type RegisterFormInput = z.input<typeof registerSchema>
type RegisterFormValues = z.output<typeof registerSchema>
export default function RegisterForm() {
const router = useRouter()
const form = useForm<RegisterFormValues>({
const form = useForm<RegisterFormInput, unknown, RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: '',

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from 'react'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { apiErrorMessage } from '@/lib/utils/errorHandler'
import { useSession } from 'next-auth/react'
import { redirect } from 'next/navigation'
import { Loader, CheckCircle, ArrowRight } from 'lucide-react'
@@ -19,7 +20,11 @@ interface PlanChangePreview {
const formatPlan = (plan: string, interval: string) =>
`${plan.charAt(0).toUpperCase() + plan.slice(1)} (${interval})`
export default function CheckoutPage({ params }) {
export default function CheckoutPage({
params,
}: {
params: { planName: string }
}) {
const [error, setError] = useState<string | null>(null)
const [planChange, setPlanChange] = useState<PlanChangePreview | null>(null)
const [isConfirming, setIsConfirming] = useState(false)
@@ -60,8 +65,12 @@ export default function CheckoutPage({ params }) {
if (retries > 0) {
initiateCheckout(retries - 1)
} else {
setError(error.response?.data?.message || 'Failed to create checkout session. Please try again or contact billing@textbee.dev.')
console.error(error.response?.data?.message)
const serverMessage = apiErrorMessage(error)
setError(
serverMessage ||
'Failed to create checkout session. Please try again or contact billing@textbee.dev.'
)
console.error(serverMessage)
}
}
},
@@ -79,11 +88,12 @@ export default function CheckoutPage({ params }) {
} catch (error) {
// no auto-retry here: the request may have charged the card
setPlanChange(null)
const serverMessage = apiErrorMessage(error)
setError(
error.response?.data?.message ||
serverMessage ||
'Failed to change your plan. Please try again or contact billing@textbee.dev.',
)
console.error(error.response?.data?.message)
console.error(serverMessage)
setIsConfirming(false)
}
}

View File

@@ -26,6 +26,7 @@ import {
useRenameApiKey,
useRevokeApiKey,
} from '@/lib/api'
import type { ApiKey } from '@/lib/api/types'
import { Skeleton } from '@/components/ui/skeleton'
import EmptyState from '@/components/shared/empty-state'
import ErrorState from '@/components/shared/error-state'
@@ -35,15 +36,10 @@ import GenerateApiKey, {
} from './generate-api-key'
import { Alert, AlertDescription } from '@/components/ui/alert'
type ApiKeyRow = {
_id: string
apiKey: string
name?: string
revokedAt?: string
createdAt: string
lastUsedAt?: string
usageCount?: number
}
// Was a local duplicate of the shared ApiKey type, which meant the list
// callback annotated its rows as one type while the hook returned the other.
// The two extra fields it carried now live on ApiKey itself.
type ApiKeyRow = ApiKey
export default function ApiKeys() {
const addApiKeyRef = useRef<GenerateApiKeyHandle>(null)
@@ -438,7 +434,8 @@ export default function ApiKeys() {
</Button>
<Button
onClick={() =>
handleRenameApiKey(selectedKey?._id, newKeyName?.trim())
selectedKey &&
handleRenameApiKey(selectedKey._id, newKeyName.trim())
}
disabled={isRenamingApiKey || !newKeyName?.trim()}
>

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { http, HttpResponse } from 'msw'
import { http, HttpResponse, type JsonBodyType } from 'msw'
import { renderWithProviders, screen, waitFor } from '@/test/render'
import { server } from '@/test/msw/server'
import { API_BASE_URL, mockSubscription } from '@/test/fixtures'
@@ -9,7 +9,7 @@ import UsageSummary from './usage-summary'
// guard the two ways that can go wrong: inventing a meter for an unlimited
// plan, and failing to warn when a real limit is nearly spent.
const subscriptionResponding = (subscription: unknown) =>
const subscriptionResponding = (subscription: JsonBodyType) =>
server.use(
http.get(`${API_BASE_URL}/billing/current-subscription`, () =>
HttpResponse.json(subscription)

View File

@@ -1,6 +1,6 @@
'use client'
import ProductClient from '@/app/(app)/dashboard/webhooks/(components)/webhook-table'
import WebhookDeliveriesTable from '@/app/(app)/dashboard/webhooks/(components)/webhook-table'
import NumberedPagination from '@/components/shared/numbered-pagination'
import {
useDevices,
@@ -53,9 +53,9 @@ export default function WebhooksHistory() {
/>
{isLoadingNotifications ? (
<ProductClient data={[]} isLoading={true} />
<WebhookDeliveriesTable data={[]} isLoading={true} />
) : (
<ProductClient
<WebhookDeliveriesTable
data={webhookNotifications?.data?.data || []}
isLoading={false}
status={status}

View File

@@ -53,6 +53,12 @@ const formSchema = z.object({
signingSecret: z.string().min(1, { message: 'Signing secret is required' }),
})
// isActive is `.default(true)`, so zod's input type has it optional while the
// output type has it guaranteed. The form holds the input shape; the submit
// handler receives the output shape.
type WebhookFormInput = z.input<typeof formSchema>
type WebhookFormValues = z.output<typeof formSchema>
interface CreateWebhookDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -70,7 +76,7 @@ export function CreateWebhookDialog({
// exactly once and a bare form.reset() restored that same object: every
// webhook created in one session ended up sharing a single signing secret,
// which defeats the point of per-endpoint verification.
const buildDefaults = (): z.infer<typeof formSchema> => ({
const buildDefaults = (): WebhookFormInput => ({
name: '',
deliveryUrl: '',
events: [WEBHOOK_EVENTS.MESSAGE_RECEIVED],
@@ -78,7 +84,7 @@ export function CreateWebhookDialog({
signingSecret: uuidv4(),
})
const form = useForm<z.infer<typeof formSchema>>({
const form = useForm<WebhookFormInput, unknown, WebhookFormValues>({
resolver: zodResolver(formSchema),
defaultValues: buildDefaults(),
})
@@ -91,7 +97,7 @@ export function CreateWebhookDialog({
}, [open])
const createWebhookMutation = useMutation({
mutationFn: (values: z.infer<typeof formSchema>) => {
mutationFn: (values: WebhookFormValues) => {
const payload = {
...values,
name: values.name?.trim() ? values.name.trim() : undefined,
@@ -120,7 +126,7 @@ export function CreateWebhookDialog({
},
})
const onSubmit = (values: z.infer<typeof formSchema>) => {
const onSubmit = (values: WebhookFormValues) => {
createWebhookMutation.mutate(values)
}

View File

@@ -53,6 +53,12 @@ const formSchema = z.object({
signingSecret: z.string().min(1, { message: 'Signing secret is required' }),
})
// isActive is `.default(true)`, so zod's input type has it optional while the
// output type has it guaranteed. The form holds the input shape; the submit
// handler receives the output shape.
type WebhookFormInput = z.input<typeof formSchema>
type WebhookFormValues = z.output<typeof formSchema>
interface EditWebhookDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -67,7 +73,7 @@ export function EditWebhookDialog({
const queryClient = useQueryClient()
const { toast } = useToast()
const form = useForm<z.infer<typeof formSchema>>({
const form = useForm<WebhookFormInput, unknown, WebhookFormValues>({
resolver: zodResolver(formSchema),
values: {
name: webhook.name ?? '',
@@ -79,7 +85,7 @@ export function EditWebhookDialog({
})
const { mutate: updateWebhook, isPending } = useMutation({
mutationFn: async (values: z.infer<typeof formSchema>) => {
mutationFn: async (values: WebhookFormValues) => {
const payload = {
...values,
name: values.name?.trim() ? values.name.trim() : '',
@@ -108,7 +114,7 @@ export function EditWebhookDialog({
},
})
const onSubmit = (values: z.infer<typeof formSchema>) => {
const onSubmit = (values: WebhookFormValues) => {
updateWebhook(values)
}

View File

@@ -5,6 +5,7 @@ import { PlusCircle, Webhook } from 'lucide-react'
import ErrorState from '@/components/shared/error-state'
import { useState } from 'react'
import { WebhookData } from '@/lib/types'
import { queryKeys } from '@/lib/api/query-keys'
import { WebhookCard } from './webhook-card'
import { WebhookDocs } from './webhook-docs'
import { CreateWebhookDialog } from './create-webhook-dialog'
@@ -46,13 +47,17 @@ export default function WebhooksSection() {
error,
refetch,
} = useQuery({
queryKey: ['webhooks'],
queryKey: queryKeys.webhooks,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.getWebhooks())
.then((res) => res.data),
.then((res) => res.data as { data: WebhookData[] }),
})
// The list was previously read as `webhooks?.data?.length > 0`, which
// compares undefined against 0 while the query is still in flight.
const webhookList = webhooks?.data ?? []
const handleCreateClick = () => {
setCreateDialogOpen(true)
}
@@ -111,14 +116,14 @@ export default function WebhooksSection() {
icon={Webhook}
onRetry={() => refetch()}
/>
) : webhooks?.data?.length > 0 ? (
) : webhookList.length > 0 ? (
<div className='rounded-md border divide-y bg-card overflow-hidden'>
{webhooks.data.map((webhook) => (
{webhookList.map((webhook) => (
<WebhookCard
key={webhook._id}
webhook={webhook}
onEdit={() => handleEditClick(webhook)}
defaultOpen={webhooks.data.length === 1}
defaultOpen={webhookList.length === 1}
/>
))}
</div>

View File

@@ -1,5 +1,4 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { DataTable } from './data-table'
@@ -7,13 +6,21 @@ import type { ColumnDef } from '@tanstack/react-table'
import { format } from 'date-fns'
import { Eye } from 'lucide-react'
interface ProductClientProps {
// This interface was declared but never applied to the component, which
// destructured its props untyped, so none of it was enforced. isLoading and
// status were missing from it too.
interface WebhookDeliveriesTableProps {
data: ProductColumns[]
isLoading?: boolean
status?: string
}
export type ProductColumns = {
event?: string
deviceName?: string
// buildDeviceLabel returns two lines (brand/model plus SMS id) when it has
// both, and the Device cell already renders that array case. The type said
// string, so the shape the code actually produces was never described here.
deviceName?: string | string[]
webhookEvent?: string
deliveryUrl?: string
webhookSubscription?: {
@@ -100,10 +107,11 @@ const buildDeviceLabel = (d: any): string | string[] => {
return 'Unknown device'
}
const ProductClient = ({ data, isLoading, status = 'delivered' }) => {
const { storeId } = useParams()
const router = useRouter()
const WebhookDeliveriesTable = ({
data,
isLoading = false,
status = 'delivered',
}: WebhookDeliveriesTableProps) => {
const formatted = data.map((d) => ({
...d,
deviceName: buildDeviceLabel(d),
@@ -124,4 +132,4 @@ const ProductClient = ({ data, isLoading, status = 'delivered' }) => {
)
}
export default ProductClient
export default WebhookDeliveriesTable

View File

@@ -15,12 +15,12 @@ export default class ErrorBoundary extends Component<
> {
state = { hasError: false }
static getDerivedStateFromError(error) {
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
console.log(error)
return { hasError: true }
}
componentDidCatch(error, errorInfo) {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.log(error, errorInfo)
}

View File

@@ -2,7 +2,10 @@
import Script from 'next/script'
const Analytics = ({ user }) => {
// Only the email is read, to tag the Clarity session.
type AnalyticsProps = { user?: { email?: string | null } | null }
const Analytics = ({ user }: AnalyticsProps) => {
return (
<>
{/* Global Site Tag (gtag.js) - Google Analytics */}

View File

@@ -22,7 +22,9 @@ import { Session } from 'next-auth'
// Deliberately minimal: identity and brand only. Navigation lives in the
// sidebar (desktop) and the bottom tab bar (mobile), search in the command
// palette, and the theme control in the sidebar footer.
export default function AppHeader({ session }: { session: Session }) {
// Nullable: the root layout renders this for signed-out visitors too, and the
// body already guards with session?.user throughout.
export default function AppHeader({ session }: { session: Session | null }) {
const router = useRouter()
const handleLogout = () => {
@@ -37,19 +39,22 @@ export default function AppHeader({ session }: { session: Session }) {
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='relative h-8 gap-2 px-1.5'>
<Avatar className='h-8 w-8'>
<AvatarImage src={session.user?.avatar} alt={session.user?.name} />
<AvatarFallback>{session.user?.name?.charAt(0)}</AvatarFallback>
<AvatarImage
src={session?.user?.avatar ?? undefined}
alt={session?.user?.name ?? undefined}
/>
<AvatarFallback>{session?.user?.name?.charAt(0)}</AvatarFallback>
</Avatar>
<span className='hidden text-sm font-medium md:block'>
{session.user?.name?.split(' ')[0]}
{session?.user?.name?.split(' ')[0]}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56' align='end' forceMount>
<DropdownMenuItem className='flex flex-col items-start'>
<div className='font-medium'>{session.user?.name}</div>
<div className='font-medium'>{session?.user?.name}</div>
<div className='text-xs text-muted-foreground'>
{session.user?.email}
{session?.user?.email}
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />

View File

@@ -15,6 +15,7 @@ import type {
Plan,
Subscription,
User,
WebhookNotification,
WebhookSubscription,
} from './types'
@@ -186,7 +187,7 @@ export type WebhookNotificationFilters = {
// Raw body: { data: { data: rows[], meta: { totalPages, ... } } }
export type WebhookNotificationsEnvelope = {
data?: {
data?: unknown[]
data?: WebhookNotification[]
meta?: { totalPages?: number; total?: number }
}
}
@@ -223,10 +224,12 @@ export function useWebhookNotifications(filters: WebhookNotificationFilters) {
// ---------- messaging ----------
// Matches the zod-inferred SendSmsFormData shape (fields optional because the
// project runs without strict mode; validation happens via the schema).
// Matches the zod-inferred SendSmsFormData shape; validation happens via the
// schema before the payload reaches here.
export type SendSmsPayload = {
deviceId?: string
// Required: it goes into the request path, so an absent one would POST to
// /gateway/devices/undefined/send-sms. Both callers validate it first.
deviceId: string
recipients?: string[]
message?: string
simSubscriptionId?: number

View File

@@ -49,6 +49,8 @@ export interface ApiKey {
status?: 'active' | 'revoked'
lastUsedAt?: string | null
createdAt?: string
revokedAt?: string
usageCount?: number
}
export interface Plan {
@@ -99,6 +101,26 @@ export interface Subscription {
export type ApiKeyStatusFilter = 'active' | 'revoked' | 'all'
/**
* One webhook delivery attempt, as the notifications endpoint returns it.
*
* Previously typed as `unknown[]` on the envelope, which meant the deliveries
* table received `unknown[]` and its own row type went unchecked.
*/
export interface WebhookNotification {
_id?: string
event?: string
webhookEvent?: string
deliveryUrl?: string
webhookSubscription?: { deliveryUrl: string }
createdAt?: string
status: string
computedStatus?: string
deviceData?: { brand?: string; model?: string }
smsData?: { _id?: string }
payload?: unknown
}
export interface WebhookSubscription {
_id: string
name?: string

View File

@@ -1,6 +1,6 @@
import CredentialsProvider from 'next-auth/providers/credentials'
import { httpServerClient } from './httpServerClient'
import { DefaultSession } from 'next-auth'
import type { DefaultSession, NextAuthOptions } from 'next-auth'
import { ApiEndpoints } from '@/config/api'
import { Routes } from '@/config/routes'
@@ -17,13 +17,29 @@ declare module 'next-auth' {
}
interface User {
// The backend returns Mongo documents, so the id arrives as _id and is
// copied onto the token below.
_id?: string
role?: string
phone?: string
avatar?: string
accessToken?: string
}
}
export const authOptions = {
// The jwt/session callbacks read and write these, so the token has to declare
// them or every callback parameter falls back to an implicit any.
declare module 'next-auth/jwt' {
interface JWT {
id?: string
role?: string
phone?: string
avatar?: string
accessToken?: string
}
}
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
id: 'email-password-login',
@@ -34,6 +50,10 @@ export const authOptions = {
turnstileToken: { label: 'Turnstile Token', type: 'text' },
},
async authorize(credentials) {
// NextAuth can invoke authorize with no credentials at all. There is
// nothing to authenticate in that case, so refuse rather than posting
// undefined fields to the login endpoint.
if (!credentials) return null
const { email, password, turnstileToken } = credentials
try {
const res = await httpServerClient.post(ApiEndpoints.auth.login(), {
@@ -67,6 +87,7 @@ export const authOptions = {
turnstileToken: { label: 'Turnstile Token', type: 'text' },
},
async authorize(credentials) {
if (!credentials) return null
const { email, password, name, phone, turnstileToken } = credentials
try {
const res = await httpServerClient.post(
@@ -100,6 +121,7 @@ export const authOptions = {
idToken: { label: 'idToken', type: 'text' },
},
async authorize(credentials) {
if (!credentials) return null
const { idToken } = credentials
try {
const res = await httpServerClient.post(

View File

@@ -71,7 +71,8 @@ type UseTurnstileOptions = {
}
type UseTurnstileResult = {
containerRef: RefObject<HTMLDivElement>
// Nullable: useRef starts at null and only holds the node once mounted.
containerRef: RefObject<HTMLDivElement | null>
token: string
error: string | null
isReady: boolean

View File

@@ -1,5 +1,7 @@
export interface WebhookData {
_id?: string
// Required: it goes into the update and delete request paths, so an absent
// one would PATCH /webhooks/undefined. Anything the API returns has one.
_id: string
name?: string
deliveryUrl: string
events: string[]

View File

@@ -76,6 +76,18 @@ export function formatError(error: unknown): FormattedError {
}
}
/**
* The `message` an API error carried, or undefined if it had none.
*
* Distinct from formatError, which always substitutes a generic message.
* Use this where the caller has its own fallback copy worth preserving, and
* needs to know whether the server actually said anything.
*/
export function apiErrorMessage(error: unknown): string | undefined {
const data = (error as AxiosError<{ message?: string }>)?.response?.data
return data?.message
}
/**
* Checks if an error is a rate limit (429) error
*/

View File

@@ -8,6 +8,7 @@
"vercel-build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
@@ -70,8 +71,10 @@
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^20.11.5",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"@vitejs/plugin-react": "^4.3.4",

20
web/pnpm-lock.yaml generated
View File

@@ -159,12 +159,18 @@ importers:
'@types/node':
specifier: ^20.11.5
version: 20.11.5
'@types/papaparse':
specifier: ^5.5.2
version: 5.5.2
'@types/react':
specifier: ^19.2.0
version: 19.2.17
'@types/react-dom':
specifier: ^19.2.0
version: 19.2.3(@types/react@19.2.17)
'@types/react-syntax-highlighter':
specifier: ^15.5.13
version: 15.5.13
'@typescript-eslint/eslint-plugin':
specifier: ^8.18.0
version: 8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
@@ -1800,11 +1806,17 @@ packages:
'@types/node@20.11.5':
resolution: {integrity: sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==}
'@types/papaparse@5.5.2':
resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react-syntax-highlighter@15.5.13':
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
@@ -5720,10 +5732,18 @@ snapshots:
dependencies:
undici-types: 5.26.5
'@types/papaparse@5.5.2':
dependencies:
'@types/node': 20.11.5
'@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies:
'@types/react': 19.2.17
'@types/react-syntax-highlighter@15.5.13':
dependencies:
'@types/react': 19.2.17
'@types/react@19.2.17':
dependencies:
csstype: 3.2.3

View File

@@ -1,4 +1,4 @@
import { http, HttpResponse } from 'msw'
import { http, HttpResponse, type JsonBodyType } from 'msw'
import { ApiEndpoints } from '@/config/api'
import {
API_BASE_URL,
@@ -19,8 +19,8 @@ import {
const url = (path: string) => `${API_BASE_URL}${path.split('?')[0]}`
// Envelope helpers matching the real API's response shapes.
const dataEnvelope = (data: unknown) => HttpResponse.json({ data })
const raw = (body: unknown) => HttpResponse.json(body)
const dataEnvelope = (data: JsonBodyType) => HttpResponse.json({ data })
const raw = (body: JsonBodyType) => HttpResponse.json(body)
export const handlers = [
// --- auth ---

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
@@ -14,7 +14,7 @@
},
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
@@ -28,8 +28,7 @@
{
"name": "next"
}
],
"strictNullChecks": false
]
},
"include": [
"next-env.d.ts",