diff --git a/.prettierignore b/.prettierignore index b1cd4d7..0b2e219 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,5 @@ coverage pnpm-lock.yaml .agents .claude +.source +content diff --git a/components/blogs-section.tsx b/components/blogs-section.tsx deleted file mode 100644 index f0d5d8d..0000000 --- a/components/blogs-section.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { cn } from "@/lib/utils"; -import { FullWidthDivider } from "@/components/ui/full-width-divider"; - -const blogs = [ - { - title: "The New Design", - date: "May 20 2025", - description: - "What everyone new to the field should know, and how we can help.", - href: "#", - }, - { - title: "Letter Club", - date: "Aug 14 2025", - description: "An ode to the slow web.", - href: "#", - }, - { - title: "Have the Coffee", - date: "Sep 19 2025", - description: "Carve space out for oppurtunity.", - href: "#", - }, - { - title: "Shadcn UI", - date: "Oct 12 2025", - description: "Building modern applications with reusable components.", - href: "#", - }, - { - title: "Fesgin", - date: "Nov 23 2025", - description: "Exploring the intersection of design and development.", - href: "#", - }, -]; - -export function BlogsSection() { - return ( -
-
-

- Latest Blogs -

-

- Discover the latest trends and insights in the world of design and - technology. -

-
- -
- -
- {blogs.map((blog) => ( - - ))} -
- -
-
- ); -} - -function BlogCard({ - title, - date, - description, - className, - ...props -}: React.ComponentProps<"a"> & { - title: string; - date: string; - description: string; -}) { - return ( - -
-

- {title} -

- - - {date} - -
-
- {description} -
-
- ); -} diff --git a/next.config.ts b/next.config.ts index 87dd7bb..c004516 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,12 @@ import { createMDX } from "fumadocs-mdx/next"; const nextConfig: NextConfig = { /* config options here */ + experimental: { + serverActions: { + allowedOrigins: ["localhost:3000", "*.app.github.dev"], + }, + turbopackUseSystemTlsCerts: true, + }, }; const withMDX = createMDX(); diff --git a/package.json b/package.json index 5482e2a..ce1b0df 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,11 @@ "fumadocs-core": "^16.6.17", "fumadocs-mdx": "^14.2.10", "fumadocs-ui": "^16.6.17", + "geist": "^1.7.0", "initials": "^3.1.2", "input-otp": "^1.4.2", "lucide-react": "^0.577.0", + "motion": "^12.38.0", "next": "16.1.7", "next-themes": "^0.4.6", "nuqs": "^2.8.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e4977e..8434b8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: fumadocs-ui: specifier: ^16.6.17 version: 16.6.17(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.17(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1) + geist: + specifier: ^1.7.0 + version: 1.7.0(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) initials: specifier: ^3.1.2 version: 3.1.2 @@ -56,6 +59,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.4) + motion: + specifier: ^12.38.0 + version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: 16.1.7 version: 16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -621,89 +627,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -822,24 +844,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.7': resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.7': resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.7': resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.7': resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==} @@ -1708,24 +1734,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -1950,41 +1980,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3004,6 +3042,11 @@ packages: fuzzysort@3.1.0: resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + geist@1.7.0: + resolution: {integrity: sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==} + peerDependencies: + next: '>=13.2.0' + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -3539,24 +3582,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -8011,6 +8058,10 @@ snapshots: fuzzysort@3.1.0: {} + geist@1.7.0(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + dependencies: + next: 16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts deleted file mode 100644 index b41c111..0000000 --- a/src/app/api/checkout/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@clerk/nextjs/server"; -import { Polar } from "@polar-sh/sdk"; - -// Only these exact product IDs may be used — prevents open-redirect / price-tampering attacks -function getAllowedProductIds(): Set { - return new Set( - [ - process.env.NEXT_PUBLIC_POLAR_PRODUCT_ID_MONTHLY, - process.env.NEXT_PUBLIC_POLAR_PRODUCT_ID_YEARLY, - ].filter((id): id is string => Boolean(id)), - ); -} - -export async function GET(req: NextRequest) { - const { userId } = await auth(); - - const productId = req.nextUrl.searchParams.get("productId"); - - // Validate product ID against known allowlist - if (!productId || !getAllowedProductIds().has(productId)) { - return NextResponse.json({ error: "Invalid product" }, { status: 400 }); - } - - // Redirect unauthenticated users to sign-in first - if (!userId) { - const signInUrl = new URL("/sign-in", req.nextUrl); - signInUrl.searchParams.set( - "redirect_url", - `/api/checkout?productId=${productId}`, - ); - return NextResponse.redirect(signInUrl); - } - - const appUrl = - process.env.NEXT_PUBLIC_APP_URL ?? - `${req.nextUrl.protocol}//${req.nextUrl.host}`; - - try { - const polar = new Polar({ - server: "sandbox", - accessToken: process.env.POLAR_ACCESS_TOKEN!, - }); - - const checkout = await polar.checkouts.create({ - products: [productId], - successUrl: `${appUrl}/app/settings/billing?upgraded=true`, - // Link to Clerk user: pre-fills email, locks it, and ties the order to the account - externalCustomerId: userId, - }); - - return NextResponse.redirect(checkout.url!); - } catch (err) { - console.error("[Polar] checkout error:", err); - return NextResponse.json( - { error: "Failed to create checkout" }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/lookup/route.ts b/src/app/api/lookup/route.ts new file mode 100644 index 0000000..808b028 --- /dev/null +++ b/src/app/api/lookup/route.ts @@ -0,0 +1,76 @@ +import { type NextRequest } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { checkAllPlatforms } from "@/lib/lookup"; +import { checkAndIncrementUsage } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const HANDLE_REGEX = /^[a-zA-Z0-9_.-]{1,50}$/; + +export async function GET(request: NextRequest) { + const handle = request.nextUrl.searchParams.get("handle")?.trim(); + + if (!handle || !HANDLE_REGEX.test(handle)) { + return Response.json({ error: "Invalid handle" }, { status: 400 }); + } + + const { userId, has } = await auth(); + + // Authentication required for all lookups + if (!userId) { + return Response.json( + { error: "Authentication required", code: "UNAUTHENTICATED" }, + { status: 401 }, + ); + } + + // Pro subscribers bypass rate limiting entirely + const isPro = has({ plan: "user:pro" }); + + if (!isPro) { + const { allowed } = await checkAndIncrementUsage(userId); + if (!allowed) { + return Response.json( + { error: "Daily limit reached", code: "RATE_LIMITED" }, + { status: 429 }, + ); + } + } + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + const send = (data: object) => { + try { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(data)}\n\n`), + ); + } catch { + // Client disconnected + } + }; + + try { + await checkAllPlatforms(handle, send); + } finally { + try { + send({ done: true }); + controller.close(); + } catch { + // Already closed + } + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/src/app/api/lookup/status/route.ts b/src/app/api/lookup/status/route.ts new file mode 100644 index 0000000..a97b946 --- /dev/null +++ b/src/app/api/lookup/status/route.ts @@ -0,0 +1,27 @@ +import { auth } from "@clerk/nextjs/server"; +import { getRateLimitInfo } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const { userId, has } = await auth(); + + if (!userId) { + return Response.json({ authenticated: false, isPro: false }); + } + + const isPro = has({ plan: "user:pro" }); + + if (isPro) { + return Response.json({ authenticated: true, isPro: true, unlimited: true }); + } + + const { remaining, limit, allowed } = await getRateLimitInfo(userId); + return Response.json({ + authenticated: true, + isPro: false, + remaining, + limit, + allowed, + }); +} diff --git a/src/app/api/portal/route.ts b/src/app/api/portal/route.ts deleted file mode 100644 index 85bab3e..0000000 --- a/src/app/api/portal/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@clerk/nextjs/server"; -import { Polar } from "@polar-sh/sdk"; - -export async function GET(req: NextRequest) { - const { userId } = await auth(); - - if (!userId) { - return NextResponse.redirect(new URL("/sign-in", req.nextUrl).toString()); - } - - try { - const polar = new Polar({ - server: "sandbox", - accessToken: process.env.POLAR_ACCESS_TOKEN!, - }); - - // Look up the Polar customer by the Clerk user ID (stored as externalId) - const customer = await polar.customers.getExternal({ externalId: userId }); - const session = await polar.customerSessions.create({ - customerId: customer.id, - }); - - return NextResponse.redirect(session.customerPortalUrl); - } catch (err) { - console.error("[Polar] portal error:", err); - return NextResponse.redirect( - new URL("/app/settings/billing", req.nextUrl).toString(), - ); - } -} diff --git a/src/app/api/webhooks/clerk/route.ts b/src/app/api/webhooks/clerk/route.ts deleted file mode 100644 index 94a61b4..0000000 --- a/src/app/api/webhooks/clerk/route.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { headers } from "next/headers"; -import { Webhook } from "svix"; -import type { WebhookEvent } from "@clerk/nextjs/server"; -import { Polar } from "@polar-sh/sdk"; - -export async function POST(req: Request) { - const secret = process.env.CLERK_WEBHOOK_SECRET; - if (!secret) { - return new Response("Missing CLERK_WEBHOOK_SECRET", { status: 500 }); - } - - const headerStore = await headers(); - const svixId = headerStore.get("svix-id"); - const svixTimestamp = headerStore.get("svix-timestamp"); - const svixSignature = headerStore.get("svix-signature"); - - if (!svixId || !svixTimestamp || !svixSignature) { - return new Response("Missing svix headers", { status: 400 }); - } - - const body = await req.text(); - - let evt: WebhookEvent; - try { - evt = new Webhook(secret).verify(body, { - "svix-id": svixId, - "svix-timestamp": svixTimestamp, - "svix-signature": svixSignature, - }) as WebhookEvent; - } catch { - return new Response("Invalid webhook signature", { status: 400 }); - } - - const polar = new Polar({ - server: "sandbox", - accessToken: process.env.POLAR_ACCESS_TOKEN!, - }); - - if (evt.type === "user.created") { - const { - id, - email_addresses, - first_name, - last_name, - primary_email_address_id, - } = evt.data; - const primary = email_addresses.find( - (e) => e.id === primary_email_address_id, - ); - - try { - await polar.customers.create({ - email: primary?.email_address ?? "", - name: [first_name, last_name].filter(Boolean).join(" ") || undefined, - externalId: id, - }); - } catch (err) { - console.error("[Polar] Failed to create customer for", id, err); - } - } - - if (evt.type === "user.updated") { - const { - id, - email_addresses, - first_name, - last_name, - primary_email_address_id, - } = evt.data; - const primary = email_addresses.find( - (e) => e.id === primary_email_address_id, - ); - - try { - await polar.customers.updateExternal({ - externalId: id, - customerUpdateExternalID: { - email: primary?.email_address, - name: [first_name, last_name].filter(Boolean).join(" ") || undefined, - }, - }); - } catch { - // Customer may not exist yet in Polar — safe to ignore - } - } - - if (evt.type === "user.deleted" && evt.data.id) { - try { - await polar.customers.deleteExternal({ externalId: evt.data.id }); - } catch { - // Customer may not exist in Polar — safe to ignore - } - } - - return new Response("OK", { status: 200 }); -} diff --git a/src/app/api/webhooks/polar/route.ts b/src/app/api/webhooks/polar/route.ts deleted file mode 100644 index db59dd6..0000000 --- a/src/app/api/webhooks/polar/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Webhooks } from "@polar-sh/nextjs"; -import { clerkClient } from "@clerk/nextjs/server"; - -function planFromStatus(status: string): "pro" | "free" { - return status === "active" || status === "trialing" ? "pro" : "free"; -} - -async function syncSubscription(payload: { - data: { - id: string; - customerId: string; - status: string; - currentPeriodEnd: Date; - customer: { externalId?: string | null }; - }; -}) { - const userId = payload.data.customer.externalId; - if (!userId) return; - - const clerk = await clerkClient(); - await clerk.users.updateUserMetadata(userId, { - publicMetadata: { - plan: planFromStatus(payload.data.status), - polarSubscriptionId: payload.data.id, - polarCustomerId: payload.data.customerId, - subscriptionStatus: payload.data.status, - currentPeriodEnd: payload.data.currentPeriodEnd, - }, - }); -} - -export const POST = Webhooks({ - webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, - - onSubscriptionCreated: syncSubscription, - onSubscriptionUpdated: syncSubscription, - - onSubscriptionRevoked: async (payload) => { - const userId = payload.data.customer.externalId; - if (!userId) return; - - const clerk = await clerkClient(); - await clerk.users.updateUserMetadata(userId, { - publicMetadata: { - plan: "free", - polarSubscriptionId: null, - polarCustomerId: payload.data.customerId, - subscriptionStatus: "canceled", - currentPeriodEnd: null, - }, - }); - }, - - // Stub for future one-time credit top-ups - onOrderPaid: async (payload) => { - const userId = payload.data.customer.externalId; - if (!userId) return; - console.log( - "[Polar] order.paid for user", - userId, - "order", - payload.data.id, - ); - }, -}); diff --git a/src/app/app/settings/api/api-keys-ui.tsx b/src/app/app/settings/api/api-keys-ui.tsx index 655d399..4597dda 100644 --- a/src/app/app/settings/api/api-keys-ui.tsx +++ b/src/app/app/settings/api/api-keys-ui.tsx @@ -111,21 +111,14 @@ function KeyForm({ }} /> - ); } -function CreateKeyModal({ - onCreated, -}: { - onCreated: (key: Key) => void; -}) { +function CreateKeyModal({ onCreated }: { onCreated: (key: Key) => void }) { const [open, setOpen] = useState(false); const [pending, startTransition] = useTransition(); const isDesktop = useMediaQuery("(min-width: 768px)"); @@ -173,11 +166,7 @@ function CreateKeyModal({ {header.title} {header.description} - + diff --git a/src/app/blog/[slug]/opengraph-image.tsx b/src/app/blog/[slug]/opengraph-image.tsx index 7a69a92..3893f88 100644 --- a/src/app/blog/[slug]/opengraph-image.tsx +++ b/src/app/blog/[slug]/opengraph-image.tsx @@ -11,11 +11,7 @@ const CATEGORY_COLORS: Record = { product: "#f97316", }; -export default async function Image({ - params, -}: { - params: { slug: string }; -}) { +export default async function Image({ params }: { params: { slug: string } }) { const post = getPostBySlug(params.slug); const title = (post?.title as string) ?? "Handle Lookup Blog"; @@ -26,122 +22,120 @@ export default async function Image({ const accentColor = CATEGORY_COLORS[category] ?? "#18181b"; return new ImageResponse( - ( +
+ {/* Top accent bar */}
- {/* Top accent bar */} + /> + + {/* Category label */} + {category && (
- - {/* Category label */} - {category && ( -
+ - - {category} - -
- )} - - {/* Title */} -
50 ? 44 : 52, - fontWeight: 700, - lineHeight: 1.1, - color: "#fafafa", - maxWidth: 900, - marginBottom: 24, - }} - > - {title} + {category} +
+ )} - {/* Description */} -
- {description} -
+ {/* Title */} +
50 ? 44 : 52, + fontWeight: 700, + lineHeight: 1.1, + color: "#fafafa", + maxWidth: 900, + marginBottom: 24, + }} + > + {title} +
- {/* Footer */} + {/* Description */} +
+ {description} +
+ + {/* Footer */} +
-
- - @ - -
- - Handle Lookup + + @
- - handlelookup.com/blog + + Handle Lookup
+ + handlelookup.com/blog +
- ), +
, { ...size }, ); } diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 374e8c9..6bfc1e0 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from "next/navigation"; import { cn } from "@/lib/utils"; import { Header } from "@/components/header"; import { Footer } from "@/components/footer"; -import { JsonLd } from "@/components/json-ld"; +import { JsonLd } from "@/app/json-ld"; import { getAllPosts, getPostBySlug, getPostUrl } from "@/lib/blog"; import { MDXContent, mdxComponents } from "@/components/blog/mdx-content"; import { DecorIcon } from "@/components/ui/decor-icon"; diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index a19a65c..1d9fc97 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -3,7 +3,7 @@ import { Suspense } from "react"; import { cn } from "@/lib/utils"; import { Header } from "@/components/header"; import { Footer } from "@/components/footer"; -import { JsonLd } from "@/components/json-ld"; +import { JsonLd } from "@/app/json-ld"; import { getAllPosts, getAllCategories, serializePost } from "@/lib/blog"; import { BlogPageClient } from "@/components/blog/blog-page-client"; @@ -15,15 +15,13 @@ export const metadata: Metadata = { openGraph: { type: "website", title: "Blog — Handle Lookup", - description: - "Tutorials, guides, and updates from the Handle Lookup team.", + description: "Tutorials, guides, and updates from the Handle Lookup team.", url: "https://handlelookup.com/blog", }, twitter: { card: "summary_large_image", title: "Blog — Handle Lookup", - description: - "Tutorials, guides, and updates from the Handle Lookup team.", + description: "Tutorials, guides, and updates from the Handle Lookup team.", }, }; @@ -64,7 +62,11 @@ export default function BlogPage() { "after:absolute after:-inset-y-14 after:-right-px after:w-px after:bg-border", )} > - Loading…
}> + Loading…
+ } + >