diff --git a/env.example b/env.example index a552315..f16fa20 100644 --- a/env.example +++ b/env.example @@ -1,5 +1,13 @@ -# Copy to .env.local and fill in real values. NEXT_PUBLIC_* vars are exposed to the browser. +# ============================================================================ +# Community Pro frontend — environment template +# Copy to `.env.local` and fill in real values. NEXT_PUBLIC_* vars are exposed +# to the browser — never put secrets in them. +# ============================================================================ + NEXT_PUBLIC_API_URL=http://localhost:5080 -NEXT_PUBLIC_POSTHOG_KEY= -NEXT_PUBLIC_GITHUB_ORG=https://github.com/CommunityPro -NEXT_PUBLIC_PRACTICE_REPO=https://github.com/CommunityPro/Practice-Contribution +NEXT_PUBLIC_GITHUB_ORG=CommunityPro +NEXT_PUBLIC_PRACTICE_REPO=Practice-Contribution +NEXT_PUBLIC_MEILISEARCH_HOST=http://localhost:7700 +REVALIDATE_SECRET= +NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN= +NEXT_PUBLIC_POSTHOG_HOST= diff --git a/package.json b/package.json index ae98e33..0358b0f 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,13 @@ }, "dependencies": { "@base-ui/react": "^1.5.0", + "@posthog/react": "^1.10.3", "@react-spring/three": "^10.1.2", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", "@tanstack/react-query": "^5.100.14", "@tanstack/react-query-devtools": "^5.100.14", - "axios": "^1.16.1", + "axios": "^1.18.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.42.2", @@ -28,12 +29,13 @@ "maath": "^0.10.8", "next": "16.2.6", "next-themes": "^0.4.6", + "posthog-js": "^1.399.2", + "posthog-node": "^5.41.0", "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.8.0", "sonner": "^2.0.7", - "stunk": "^2.8.1", "tailwind-merge": "^3.6.0", "three": "^0.185.0", "tw-animate-css": "^1.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0095989..dd5e8ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@base-ui/react': specifier: ^1.5.0 version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@posthog/react': + specifier: ^1.10.3 + version: 1.10.3(@types/react@19.2.17)(posthog-js@1.399.2)(react@19.2.4) '@react-spring/three': specifier: ^10.1.2 version: 10.1.2(@react-three/fiber@9.6.1(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.185.1))(react@19.2.4)(three@0.185.1) @@ -27,7 +30,7 @@ importers: specifier: ^5.100.14 version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.4))(react@19.2.4) axios: - specifier: ^1.16.1 + specifier: ^1.18.1 version: 1.18.1 class-variance-authority: specifier: ^0.7.1 @@ -50,6 +53,12 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + posthog-js: + specifier: ^1.399.2 + version: 1.399.2 + posthog-node: + specifier: ^5.41.0 + version: 5.41.0 radix-ui: specifier: ^1.4.3 version: 1.6.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -65,9 +74,6 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - stunk: - specifier: ^2.8.1 - version: 2.8.1(react@19.2.4) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -632,6 +638,22 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@posthog/core@1.40.2': + resolution: {integrity: sha512-H12j7O9iHGvpK9t2ko8W4pvfbV1pBDxrsWC1LA6yp2RhzwvC4T3sWhu+AekDQJSRSrJEWlB0t/Ueq9QhPSq7FQ==} + + '@posthog/react@1.10.3': + resolution: {integrity: sha512-Qu//fGQmVlX0B9kTA3LLg67e7AYLEmeuA0Bf1qSyUM0uUILcRQGjQezhNQPLYSTakOqvXEnl6fM2iQBF6Toxrw==} + peerDependencies: + '@types/react': '>=16.8.0' + posthog-js: '>=1.257.2' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@posthog/types@1.393.0': + resolution: {integrity: sha512-vzWeEJZ7ERQhFRoQYaP5jzN1JvIu46UJyHXsuv+dTGW2r3sMgREOhNxXLZjmFHwZ8/FOHQoyqqQmXTCXZSfMSg==} + '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -1550,6 +1572,9 @@ packages: '@types/three@0.185.1': resolution: {integrity: sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -2017,6 +2042,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -2148,6 +2176,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + dot-prop@6.0.1: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} @@ -2428,6 +2459,9 @@ packages: picomatch: optional: true + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.6.10: resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} @@ -3384,6 +3418,18 @@ packages: resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} engines: {node: ^10 || ^12 || >=14} + posthog-js@1.399.2: + resolution: {integrity: sha512-xcvrGEgUYtIVcWPRlVfc/NkMo0IP9nwnM/dzJIAzjDOSewkdDk/9T4Vz1+gooEhXdQCPfG44jSK95mVJsoySqA==} + + posthog-node@5.41.0: + resolution: {integrity: sha512-jOkX6THOr5WD+FGUEaTxekas8c7NOC3TqJ2Byfe2KMimQdL/F/osz17uSbkNzR4V9WFZoe8YaGP3Xp0EUpPKGg==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + potpack@1.0.2: resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} @@ -3391,6 +3437,14 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + preact@10.29.7: + resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3485,6 +3539,9 @@ packages: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3796,17 +3853,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - stunk@2.8.1: - resolution: {integrity: sha512-NolnLmUXx/0D8na1lUjIhT4EdqCVNYCaFGKKIF+gUjmJcvXVj//SZ6Dvp7OSllW2XyfFembUi6BCUdhAb3dI7Q==} - peerDependencies: - react: ^17.0.0 || ^18.0.0 || ^19.0.0 - vue: ^3.5.13 - peerDependenciesMeta: - react: - optional: true - vue: - optional: true - styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -4026,6 +4072,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + web-vitals@5.3.0: + resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==} + webgl-constants@1.1.1: resolution: {integrity: sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==} @@ -4672,6 +4721,19 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@posthog/core@1.40.2': + dependencies: + '@posthog/types': 1.393.0 + + '@posthog/react@1.10.3(@types/react@19.2.17)(posthog-js@1.399.2)(react@19.2.4)': + dependencies: + posthog-js: 1.399.2 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.17 + + '@posthog/types@1.393.0': {} + '@radix-ui/number@1.1.2': {} '@radix-ui/primitive@1.1.5': {} @@ -5648,6 +5710,9 @@ snapshots: fflate: 0.8.3 meshoptimizer: 1.1.1 + '@types/trusted-types@2.0.7': + optional: true + '@types/validate-npm-package-name@4.0.2': {} '@types/webxr@0.5.24': {} @@ -6121,6 +6186,8 @@ snapshots: cookie@0.7.2: {} + core-js@3.49.0: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -6228,6 +6295,10 @@ snapshots: dependencies: esutils: 2.0.3 + dompurify@3.4.12: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dot-prop@6.0.1: dependencies: is-obj: 2.0.0 @@ -6697,6 +6768,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fflate@0.4.8: {} + fflate@0.6.10: {} fflate@0.8.3: {} @@ -7569,10 +7642,29 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-js@1.399.2: + dependencies: + '@posthog/core': 1.40.2 + '@posthog/types': 1.393.0 + core-js: 3.49.0 + dompurify: 3.4.12 + fflate: 0.4.8 + preact: 10.29.7 + query-selector-shadow-dom: 1.0.1 + web-vitals: 5.3.0 + transitivePeerDependencies: + - preact-render-to-string + + posthog-node@5.41.0: + dependencies: + '@posthog/core': 1.40.2 + potpack@1.0.2: {} powershell-utils@0.1.0: {} + preact@10.29.7: {} + prelude-ls@1.2.1: {} prettier-plugin-tailwindcss@0.8.0(prettier@3.9.5): @@ -7615,6 +7707,8 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 + query-selector-shadow-dom@1.0.1: {} + queue-microtask@1.2.3: {} radix-ui@1.6.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -8093,10 +8187,6 @@ snapshots: strip-json-comments@3.1.1: {} - stunk@2.8.1(react@19.2.4): - optionalDependencies: - react: 19.2.4 - styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4): dependencies: client-only: 0.0.1 @@ -8336,6 +8426,8 @@ snapshots: vary@1.1.2: {} + web-vitals@5.3.0: {} + webgl-constants@1.1.1: {} webgl-sdf-generator@1.1.1: {} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..2bbb63c --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,15 @@ +import type React from "react"; + +interface Props { + children: React.ReactNode; +} + +const DashboardLayout = ({}: Props) => { + return ( +
+
+
+ ); +}; + +export default DashboardLayout; diff --git a/src/app/(member)/layout.tsx b/src/app/(member)/layout.tsx new file mode 100644 index 0000000..d08836b --- /dev/null +++ b/src/app/(member)/layout.tsx @@ -0,0 +1,9 @@ +import { RequireActiveMember } from "@/features/auth"; + +export default function MemberLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return {children}; +} diff --git a/src/app/(member)/profile/page.tsx b/src/app/(member)/profile/page.tsx new file mode 100644 index 0000000..60b71d3 --- /dev/null +++ b/src/app/(member)/profile/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Your profile | Community Pro", +}; + +export default function Page() { + return ( +
+

You're in

+

Welcome to Community Pro

+ {/* why: placeholder until Phase 2 ships profile editing — keeps the post-activation + redirect from landing on a 404. */} +

+ Profile setup is on its way. Soon you'll add your skills, portfolio link, and avatar here. +

+
+ ); +} diff --git a/src/app/(public)/join-community/page.tsx b/src/app/(public)/join-community/page.tsx index f431a64..42a16b1 100644 --- a/src/app/(public)/join-community/page.tsx +++ b/src/app/(public)/join-community/page.tsx @@ -1,9 +1,33 @@ -const Page = () => { - return ( -
-

Join Community

-
- ); +import type { Metadata } from "next"; + +import { SignInButton } from "@/features/auth"; + +export const metadata: Metadata = { + title: "Join the community | Community Pro", + description: "Sign in with GitHub, raise one pull request, and earn your membership.", }; -export default Page; +const errorMessages: Record = { + deactivated: "This account has been deactivated. Reach out to an admin if you think that's a mistake.", + oauth_failed: "GitHub sign-in didn't complete. Give it another try.", + status_unavailable: "We couldn't confirm your membership status. Please sign in again.", +}; + +export default async function Page({ searchParams }: { searchParams: Promise<{ error?: string }> }) { + const { error } = await searchParams; + const errorMessage = error ? errorMessages[error] : undefined; + + return ( +
+
+

Join Community Pro

+

+ Membership is earned with a single merged pull request. Sign in with GitHub to get started — we'll walk + you through the rest. +

+
+ {errorMessage ?

{errorMessage}

: null} + +
+ ); +} diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 6eca10f..aad6801 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -1,15 +1,17 @@ "use client"; import { motion, useMotionValue, useSpring } from "framer-motion"; -import { useRef } from "react"; +import { usePostHog } from "@posthog/react"; +import { useEffect, useRef } from "react"; import Link from "next/link"; import { Avatar, AvatarGroup, AvatarImage } from "@/components/ui/avatar"; -// import { ScrollLock } from "@/components/shared"; import { Button } from "@/components/ui/button"; import { AVATARS, METRICS } from "@/constants"; const Page = () => { + const posthong = usePostHog(); + const ref = useRef(null); const rotateX = useMotionValue(0); const rotateY = useMotionValue(0); @@ -31,6 +33,10 @@ const Page = () => { rotateY.set(0); }; + useEffect(() => { + posthong.capture("page_view"); + }, [posthong]); + return (
+
+

Almost there

+

One PR away from membership

+

+ Community Pro membership is earned, not granted. Show us a merged pull request in our practice repo and + you're in. +

+
+ +
    + {steps.map(({ icon: Icon, title, body }, index) => ( +
  1. + + + +

    + {index + 1}. {title} +

    +

    {body}

    +
  2. + ))} +
+ +
+ + Open the practice repo + + + +
+ + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ccebaa1..c00db02 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,7 +3,9 @@ import { ThemeProvider } from "next-themes"; import type { Metadata } from "next"; import "./globals.css"; -import { AppProvider, ErrorBoundary, QueryProvider } from "@/components/providers"; +import { AppProvider, ErrorBoundary, PostHogProvider, QueryProvider } from "@/components/providers"; +import { Toaster } from "@/components/ui/sonner"; +import { AuthProvider } from "@/features/auth"; const space_grotesk = Space_Grotesk({ style: ["normal"], subsets: ["latin"], variable: "--font-space-grotesk" }); const raleway = Raleway({ style: ["italic", "normal"], subsets: ["latin"], variable: "--font-raleway" }); @@ -28,11 +30,16 @@ export default function RootLayout({ - - - {children} - - + + + + + {children} + + + + + diff --git a/src/components/providers/index.ts b/src/components/providers/index.ts index 7d45e82..5b91b0c 100644 --- a/src/components/providers/index.ts +++ b/src/components/providers/index.ts @@ -1,3 +1,4 @@ export * from "./app-context"; export * from "./error-boundary"; +export * from "./post-hog"; export * from "./query-provider"; diff --git a/src/components/providers/post-hog.tsx b/src/components/providers/post-hog.tsx new file mode 100644 index 0000000..4747273 --- /dev/null +++ b/src/components/providers/post-hog.tsx @@ -0,0 +1,18 @@ +"use client"; + +// import { usePathname, useSearchParams } from "next/navigation" +import { useEffect } from "react"; + +import posthog from "posthog-js"; +import { PostHogProvider as PHProvider } from "@posthog/react"; + +export function PostHogProvider({ children }: { children: React.ReactNode }) { + useEffect(() => { + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN as string, { + api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + defaults: "2026-05-30", + }); + }, []); + + return {children}; +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx new file mode 100644 index 0000000..190a5bc --- /dev/null +++ b/src/components/shared/sidebar.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useGlobalStore } from "@/store"; +import { cn } from "@/lib"; + +export const Sidebar = () => { + const { isCollapsed } = useGlobalStore(); + + return ( + + ); +}; diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts index 7b5a29f..f65ad3f 100644 --- a/src/features/auth/index.ts +++ b/src/features/auth/index.ts @@ -1,6 +1,7 @@ export * from "./api"; export * from "./auth-provider"; export * from "./guards"; +export * from "./pending-status"; export * from "./session"; export * from "./sign-in-button"; export * from "./store"; diff --git a/src/features/auth/pending-status.tsx b/src/features/auth/pending-status.tsx new file mode 100644 index 0000000..d193999 --- /dev/null +++ b/src/features/auth/pending-status.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { RefreshCw } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; + +import { useAuthStore } from "./store"; + +/** + * Interactive footer of the `/welcome` pending screen: redirects visitors who don't + * belong here (anonymous → join page, already-active → profile) and offers a + * "Check my status" action that re-resolves `GET /me` after their practice PR merges. + * + * @returns The status-check button with contextual redirects wired in. + * @example + * + */ +export const PendingStatusActions = () => { + const router = useRouter(); + const status = useAuthStore((state) => state.status); + const user = useAuthStore((state) => state.user); + const hydrate = useAuthStore((state) => state.hydrate); + const [checking, setChecking] = useState(false); + + useEffect(() => { + if (status === "loading") return; + if (status === "anonymous" || user === null) { + router.replace("/join-community"); + return; + } + if (user.status === "active") router.replace("/profile"); + }, [router, status, user]); + + const checkStatus = async () => { + setChecking(true); + try { + await hydrate(); + const fresh = useAuthStore.getState().user; + if (fresh?.status === "active") { + toast.success("Your PR is merged — welcome aboard!"); + router.replace("/profile"); + } else { + toast.info("Not merged yet. We'll activate you the moment it lands."); + } + } finally { + setChecking(false); + } + }; + + return ( + + ); +}; diff --git a/src/features/auth/store.ts b/src/features/auth/store.ts index bf4f9fc..dc3b391 100644 --- a/src/features/auth/store.ts +++ b/src/features/auth/store.ts @@ -35,9 +35,11 @@ type AuthState = { * const status = useAuthStore((state) => state.status); * if (status === "anonymous") return ; */ +// why: starting at "loading" (not "anonymous") keeps guards from redirecting during the +// window between first render and AuthProvider's hydrate effect settling the real status. export const useAuthStore = create((set) => ({ user: null, - status: "anonymous", + status: "loading", hydrate: async () => { set({ status: "loading" }); try { diff --git a/src/lib/index.ts b/src/lib/index.ts index 735ce07..68155ba 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,3 +1,2 @@ export * from "./utils"; -export * from "./client"; -export * from "./query"; +export * from "./env"; diff --git a/src/lib/query.ts b/src/lib/query.ts deleted file mode 100644 index 028e374..0000000 --- a/src/lib/query.ts +++ /dev/null @@ -1,120 +0,0 @@ -"use client"; - -import { QueryClient, useMutation, useQuery } from "@tanstack/react-query"; -import { useState } from "react"; - -import { http } from "./client"; -import type { - MutationConfig, - MutationReturn, - PaginatedQueryConfig, - PaginatedQueryReturn, - PaginatedResponse, - QueryConfig, - QueryReturn, -} from "@/types"; -import { removeNullOrUndefined } from "./utils"; - -export function makeQueryClient() { - return new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - retry: 3, - retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 30_000), - staleTime: 30_000, - }, - mutations: { - retry: 0, - }, - }, - }); -} - -const fetcher = async ( - url: string, - params?: TParams, - transform?: (raw: unknown) => TData, -): Promise => { - const raw = await http.get(url, { params }); - return transform ? transform(raw) : (raw as TData); -}; - -const mutator = async ( - method: "POST" | "PUT" | "DELETE" | "PATCH", - url: string, - variables: TVariables, - transform?: (raw: unknown) => TData, -): Promise => { - const raw = await http[method.toLowerCase() as "post" | "put" | "delete" | "patch"](url, variables); - return transform ? transform(raw) : (raw as TData); -}; - -export const useApiMutation = ( - config: MutationConfig, -): MutationReturn => { - const queryClient = makeQueryClient(); - - const result = useMutation({ - mutationFn: (variables: TVariables) => - mutator(config.method, config.url, variables, config.transform), - onMutate: async (variables) => { - if (!config.optimistic || !config.optimisticQueryKey) return; - await queryClient.cancelQueries({ queryKey: config.optimisticQueryKey }); - const snapshot = queryClient.getQueryData(config.optimisticQueryKey); - queryClient.setQueryData(config.optimisticQueryKey, (current) => - config.optimistic!.updater(current, variables), - ); - return snapshot; - }, - onError: (_, variables, context) => { - if (config.optimistic && config.optimisticQueryKey) { - const ctx = context as { snapshot: TCache } | undefined; - queryClient.setQueryData( - config.optimisticQueryKey, - config.optimistic.rollback - ? (current) => config.optimistic!.rollback!(current, variables) - : () => ctx?.snapshot, - ); - } - }, - onSuccess: (data) => { - const keys = typeof config.invalidates === "function" ? config.invalidates(data) : (config.invalidates ?? []); - keys.forEach((key) => queryClient.invalidateQueries({ queryKey: key as readonly unknown[] })); - }, - }); - - return { ...result, kind: "mutation" }; -}; - -export const useApiQuery = ( - config: QueryConfig, -): QueryReturn => { - const _params = removeNullOrUndefined(config.params || {}); - - const result = useQuery({ - queryKey: [config.url, JSON.stringify(_params)], - queryFn: () => fetcher(config.url, { params: { ..._params } }), - enabled: config.enabled || true, - staleTime: 30_000, - }); - - return { ...result, kind: "query" }; -}; - -export const useApiPaginatedQuery = ( - config: PaginatedQueryConfig, -): PaginatedQueryReturn => { - const [pageSize, setPageSize] = useState(config.pageSize || 10); - const [page, setPage] = useState(config.initialPage || 1); - const _params = removeNullOrUndefined(config.params || {}); - - const result = useQuery, Error>({ - queryKey: [config.url, JSON.stringify(_params), { page, pageSize }], - queryFn: () => http.get>(config.url, { params: { ..._params, page, pageSize } }), - staleTime: 30_000, - enabled: config.enabled || true, - }); - - return { ...result, kind: "paginated", page, pageSize, setPage, setPageSize }; -}; diff --git a/src/store/.gitkeep b/src/store/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/store/global.ts b/src/store/global.ts new file mode 100644 index 0000000..64f042d --- /dev/null +++ b/src/store/global.ts @@ -0,0 +1,16 @@ +import { createPersistMiddleware } from "./middleware"; + +interface GlobalState { + isCollapsed: boolean; + onCollapsedChange: () => void; +} + +const initialState: GlobalState = { + isCollapsed: false, + onCollapsedChange: () => {}, +}; + +export const useGlobalStore = createPersistMiddleware("CP_GLOBAL", (set) => ({ + ...initialState, + onCollapsedChange: () => set((state) => ({ isCollapsed: !state.isCollapsed })), +})); diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..0937023 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1 @@ +export * from "./global"; diff --git a/src/store/middleware/index.ts b/src/store/middleware/index.ts new file mode 100644 index 0000000..8bf097f --- /dev/null +++ b/src/store/middleware/index.ts @@ -0,0 +1,2 @@ +export * from "./persist"; +export * from "./report"; diff --git a/src/store/middleware/persist.ts b/src/store/middleware/persist.ts new file mode 100644 index 0000000..c5910b4 --- /dev/null +++ b/src/store/middleware/persist.ts @@ -0,0 +1,8 @@ +import type { StateCreator } from "zustand/vanilla"; +import { persist } from "zustand/middleware"; +import { create } from "zustand/react"; + +import { reportException } from "./report"; + +export const createPersistMiddleware = (name: string, storeCreator: StateCreator) => + create(reportException(persist(storeCreator, { name: name || "z:root" }) as StateCreator)); diff --git a/src/store/middleware/report.ts b/src/store/middleware/report.ts new file mode 100644 index 0000000..70d6035 --- /dev/null +++ b/src/store/middleware/report.ts @@ -0,0 +1,21 @@ +import type { StateCreator } from "zustand/vanilla"; +import { create } from "zustand/react"; + +export const reportException = + (stateCreator: StateCreator): StateCreator => + (set, get, api) => + stateCreator( + (args) => { + try { + set(args); + } catch (error: unknown) { + console.error("Error :(", error); + throw error; + } + }, + get, + api, + ); + +export const createReportableStore = (storeCreator: StateCreator) => + create()(reportException(storeCreator));