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) => (
+ -
+
+
+
+
+ {index + 1}. {title}
+
+ {body}
+
+ ))}
+
+
+
+
+ );
+}
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));