Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ JWT_DEFAULT_DURATION=3600 # 1 hour
JWT_REFRESH_DURATION=2592000 # 30 days

# Comma-separated exact origins allowed for credentialed CORS.
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173
# Include http://localhost:3001 (and 127.0.0.1) when @studiqo/web runs on 3001 with API on PORT=3000.
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001,http://localhost:5173,http://127.0.0.1:5173
# Comma-separated hostname suffixes allowed for credentialed CORS.
CORS_ALLOWED_ORIGIN_SUFFIXES=.studiqo.io

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ coverage
postman/
.cursor/
apps/web/.next
apps/web/.env.local
2 changes: 2 additions & 0 deletions apps/api/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ export const config: Config = {
corsAllowedOrigins: csvEnvOrDefault("CORS_ALLOWED_ORIGINS", [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:3001",
"http://127.0.0.1:3001",
"http://localhost:5173",
"http://127.0.0.1:5173",
]),
Expand Down
12 changes: 12 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Studiqo HTTP API base URL (must include /api/v1).
# Local default matches apps/api when PORT=3000.
NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/api/v1

# `npm run dev -w apps/web` uses port 3001 (see apps/web/package.json) so the API can use 3000.

# Local dev: the API and Next.js both default to port 3000 — pick one:
# - Run API on 3000 and Next with: next dev -p 3001
# Then add http://localhost:3001 (and http://127.0.0.1:3001) to the API's
# CORS_ALLOWED_ORIGINS in .env (see repo root .env.example).
# - Or use a same-origin proxy (e.g. Next rewrites) so the browser only talks
# to one origin; verify HttpOnly refresh cookies still work for your setup.
30 changes: 30 additions & 0 deletions apps/web/app/dev-health-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import { useEffect, useState } from "react";

import { fetchHealth } from "@/lib/api/health";

/** Fetches `GET /health` once in development to exercise typed OpenAPI client usage. */
export function DevHealthStatus() {
const [line, setLine] = useState<string>("…");

useEffect(() => {
if (process.env.NODE_ENV !== "development") return;
void (async () => {
try {
const { message } = await fetchHealth();
setLine(message);
} catch (e) {
setLine(e instanceof Error ? e.message : "error");
}
})();
}, []);

if (process.env.NODE_ENV !== "development") {
return null;
}

return (
<p style={{ fontSize: 14, opacity: 0.85 }}>API health (dev): {line}</p>
);
}
6 changes: 5 additions & 1 deletion apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { ReactNode } from "react";

import { Providers } from "./providers";

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
16 changes: 13 additions & 3 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { DevHealthStatus } from "./dev-health-status";

import { formatIsoDateTime } from "@/lib/datetime";

const sampleApiDateTime = "2026-04-01T14:00:00.000Z";

export default function Page() {
return (
<main>
<h1>Studiqo Frontend</h1>
<p>Next.js workspace scaffold complete.</p>
<main style={{ padding: "1.5rem", maxWidth: 640 }}>
<h1>Studiqo</h1>
<p>Next.js workspace scaffold with Phase 0 providers and API client.</p>
<p style={{ fontSize: 14, opacity: 0.85 }}>
Sample formatted API date-time: {formatIsoDateTime(sampleApiDateTime)}
</p>
<DevHealthStatus />
</main>
);
}
16 changes: 16 additions & 0 deletions apps/web/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";

import { SessionProvider } from "@/lib/auth/session";

export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());

return (
<QueryClientProvider client={queryClient}>
<SessionProvider>{children}</SessionProvider>
</QueryClientProvider>
);
}
11 changes: 11 additions & 0 deletions apps/web/lib/api/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createStudiqoClient } from "@studiqo/api-client/client";
import { unwrapStudiqoResponse } from "@studiqo/api-client/errors";

import { getPublicApiBaseUrl } from "@/lib/env";

/** Typed smoke path: `GET /health` (OpenAPI `getHealth`). */
export async function fetchHealth() {
const client = createStudiqoClient(getPublicApiBaseUrl());
const result = await client.GET("/health");
return unwrapStudiqoResponse(result);
}
92 changes: 92 additions & 0 deletions apps/web/lib/auth/session.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"use client";

import { createStudiqoClient } from "@studiqo/api-client/client";
import {
unwrapStudiqoResponse,
unwrapStudiqoVoid,
} from "@studiqo/api-client/errors";
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";

import { getPublicApiBaseUrl } from "@/lib/env";

export type SessionContextValue = {
accessToken: string | null;
setAccessToken: (token: string | null) => void;
clearSession: () => void;
refreshAccessToken: () => Promise<void>;
logout: () => Promise<void>;
};

const SessionContext = createContext<SessionContextValue | null>(null);

export function SessionProvider({ children }: { children: ReactNode }) {
const [accessToken, setAccessTokenState] = useState<string | null>(null);
const tokenRef = useRef<string | null>(null);

const setAccessToken = useCallback((token: string | null) => {
tokenRef.current = token;
setAccessTokenState(token);
}, []);

const clearSession = useCallback(() => {
tokenRef.current = null;
setAccessTokenState(null);
}, []);

const client = useMemo(
() =>
createStudiqoClient(getPublicApiBaseUrl(), {
getAccessToken: () => tokenRef.current,
}),
[],
);

const refreshAccessToken = useCallback(async () => {
const result = await client.POST("/auth/refresh");
const data = unwrapStudiqoResponse(result);
setAccessToken(data.token);
}, [client, setAccessToken]);

const logout = useCallback(async () => {
const result = await client.POST("/auth/logout");
unwrapStudiqoVoid(result);
setAccessToken(null);
}, [client, setAccessToken]);

const value = useMemo<SessionContextValue>(
() => ({
accessToken,
setAccessToken,
clearSession,
refreshAccessToken,
logout,
}),
[
accessToken,
setAccessToken,
clearSession,
refreshAccessToken,
logout,
],
);

return (
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
);
}

export function useSession(): SessionContextValue {
const ctx = useContext(SessionContext);
if (!ctx) {
throw new Error("useSession must be used within SessionProvider");
}
return ctx;
}
34 changes: 34 additions & 0 deletions apps/web/lib/datetime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* API date-times are ISO 8601 in UTC (e.g. `2026-04-01T14:00:00.000Z`). Display uses `timeZone`
* (default `undefined` = runtime local zone).
*/

export function parseIsoDateTime(iso: string): Date {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
throw new RangeError(`Invalid ISO date-time: ${iso}`);
}
return d;
}

export function formatIsoDateTime(
iso: string,
options?: {
/** IANA zone (e.g. `Europe/London`); omit for the user's local timezone */
timeZone?: string;
dateStyle?: Intl.DateTimeFormatOptions["dateStyle"];
timeStyle?: Intl.DateTimeFormatOptions["timeStyle"];
},
): string {
const {
timeZone,
dateStyle = "medium",
timeStyle = "short",
} = options ?? {};
const d = parseIsoDateTime(iso);
return new Intl.DateTimeFormat(undefined, {
dateStyle,
timeStyle,
timeZone,
}).format(d);
}
6 changes: 6 additions & 0 deletions apps/web/lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** Public API base URL (includes `/api/v1`). See `apps/web/.env.example`. */
export function getPublicApiBaseUrl(): string {
const raw =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3000/api/v1";
return raw.replace(/\/$/, "");
}
2 changes: 1 addition & 1 deletion apps/web/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
4 changes: 3 additions & 1 deletion apps/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {};
const nextConfig: NextConfig = {
transpilePackages: ["@studiqo/api-client"],
};

export default nextConfig;
6 changes: 4 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,28 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 3002",
"build": "next build",
"start": "next start",
"lint": "eslint ."
},
"dependencies": {
"@studiqo/api-client": "*",
"@tanstack/react-query": "^5.96.2",
"next": "16.2.2",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"typescript": "^5.9.3",
"@types/node": "^25.5.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"eslint": "^10.1.0",
"eslint-config-next": "16.2.2",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.2"
}
}
7 changes: 6 additions & 1 deletion apps/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@
],
"types": [
"node"
]
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
Expand Down
Loading
Loading