diff --git a/.gitignore b/.gitignore index cb81549..d0435a3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.cursorignore cursor-logs.md *.sql +!supabase/migrations/*.sql __pypackages__/ __pycache__ .pytest_cache/ diff --git a/TEACHING_ROADMAP_IMPLEMENTATION.md b/TEACHING_ROADMAP_IMPLEMENTATION.md deleted file mode 100644 index 026e0e9..0000000 --- a/TEACHING_ROADMAP_IMPLEMENTATION.md +++ /dev/null @@ -1,22 +0,0 @@ -# Teaching Roadmap Implementation Log - -## Goal -Evolve the roadmap feature into a teaching-oriented learning path for beginners. - -## Plan -1. **Backend Updates** - * [x] Update `TimelineStage` model in `app/models/roadmap.py` (added `prerequisites`, `checkpoints`). - * [x] Update `GeminiRoadmapGenerator` in `app/services/ai/gemini.py`. - * [x] Implement multi-step pipeline (Plan -> Expand). - * [x] Implement time-based commit clustering. - * [x] Update prompts for teaching focus. - * [x] Linting & Formatting. - -2. **Frontend Updates** - * [ ] Update types in `commitly-frontend`. - * [ ] Update UI components to render goals, structured tasks, code examples, etc. - -## Progress -- [x] Backend models updated. -- [x] Gemini service refactored with multi-step pipeline. -- [x] Linting checks. diff --git a/UI_UX_TWEAKS.md b/UI_UX_TWEAKS.md deleted file mode 100644 index fc198af..0000000 --- a/UI_UX_TWEAKS.md +++ /dev/null @@ -1,38 +0,0 @@ -# UI/UX Tweaks for Timeline - -## Overview -Implemented UI/UX improvements for the roadmap timeline view to enhance readability, visual hierarchy, and user guidance. - -## Changes - -### 1. Navigation & Structure -- **Sticky Navigation Pills**: Added a sticky, horizontally scrollable list of stage buttons above the timeline for quick navigation between stages. -- **Increased Spacing**: Increased the vertical gap between timeline stages (`gap-y-16`) to reduce visual clutter and separate "episodes". - -### 2. Timeline Card Visual Hierarchy -- **Header**: - - Added explicit "Stage X · Category · Difficulty" meta-line. - - Improved title and status badge alignment. -- **Goals**: - - Moved to the top of the content. - - Styled with a small uppercase heading and a separator. -- **Tasks**: - - Refactored into individual "subcards" with a clearer border and background. - - Steps are bulleted lists within the subcard. - - Files and commands are clearly distinguished. -- **Code Examples**: - - Made collapsible by default to prevent long snippets from dominating the view. - - Visible file name and language badge in the collapsed state. -- **Resources**: - - Moved to the bottom of the content. - - Styled as pill-shaped links. - -### 3. Call to Action (CTA) -- **Dynamic Buttons**: - - "Start this stage" for not-started stages (when signed in). - - "Continue" for in-progress stages. - - "Review" for completed stages. - - "Details" for signed-out users. - -## Files Modified -- `commitly-frontend/app/repo/[repoId]/timeline/page.tsx` diff --git a/commitly-backend/alembic/versions/20241124_add_guide_chat_sessions.py b/commitly-backend/alembic/versions/20241124_add_guide_chat_sessions.py new file mode 100644 index 0000000..6a89f0c --- /dev/null +++ b/commitly-backend/alembic/versions/20241124_add_guide_chat_sessions.py @@ -0,0 +1,60 @@ +"""add guide chat sessions table + +Revision ID: 20241124_add_guide_chat_sessions +Revises: aae1346a34e5 +Create Date: 2025-11-24 19:20:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "20241124_add_guide_chat_sessions" +down_revision = "aae1346a34e5" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "guide_chat_sessions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.String(length=255), nullable=False), + sa.Column("repo_full_name", sa.String(length=255), nullable=False), + sa.Column("stage_id", sa.String(length=255), nullable=True), + sa.Column("messages", sa.JSON(), nullable=False, server_default="[]"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + onupdate=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint( + "user_id", + "repo_full_name", + "stage_id", + name="uq_guide_chat_user_repo_stage", + ), + ) + op.create_index("ix_guide_chat_sessions_user", "guide_chat_sessions", ["user_id"]) + op.create_index( + "ix_guide_chat_sessions_repo", "guide_chat_sessions", ["repo_full_name"] + ) + op.create_index("ix_guide_chat_sessions_stage", "guide_chat_sessions", ["stage_id"]) + + +def downgrade(): + op.drop_index("ix_guide_chat_sessions_stage", table_name="guide_chat_sessions") + op.drop_index("ix_guide_chat_sessions_repo", table_name="guide_chat_sessions") + op.drop_index("ix_guide_chat_sessions_user", table_name="guide_chat_sessions") + op.drop_table("guide_chat_sessions") diff --git a/commitly-backend/app/api/roadmap.py b/commitly-backend/app/api/roadmap.py index 89abcba..d927ad3 100644 --- a/commitly-backend/app/api/roadmap.py +++ b/commitly-backend/app/api/roadmap.py @@ -12,14 +12,16 @@ from app.core.database import get_db from app.models.roadmap import ( CatalogPage, + ChatHistoryResponse, ChatRequest, - ChatResponse, RatingRequest, RatingResponse, RoadmapRequest, RoadmapResponse, + SaveChatRequest, UserRepoStateResponse, ) +from app.models.roadmap import GuideChatSession from app.services.ai.chat import GeminiChatService from app.services.roadmap_service import RoadmapService, build_roadmap_service @@ -41,6 +43,15 @@ def get_user_id(claims: ClerkClaims) -> str: return user_id +def get_chat_session( + session: Session, user_id: str, repo_full_name: str, stage_id: str | None +) -> GuideChatSession | None: + query = session.query(GuideChatSession).filter_by( + user_id=user_id, repo_full_name=repo_full_name, stage_id=stage_id + ) + return query.first() + + @router.get("/catalog", response_model=CatalogPage) async def list_roadmaps( page: int = Query(1, ge=1, description="Page number"), @@ -251,25 +262,112 @@ async def record_roadmap_view( return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.post("/chat", response_model=ChatResponse) +@router.post("/chat") async def chat_with_guide( payload: ChatRequest, session: Session = Depends(get_db), - current_user: ClerkClaims = Depends(require_clerk_auth), -) -> ChatResponse: + # current_user: ClerkClaims = Depends(require_clerk_auth), +) -> StreamingResponse: """ Chat with the AI guide about the repository or a specific stage. """ + if not settings.gemini_api_key: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Gemini API key not configured.", + ) + chat_service = GeminiChatService( session=session, api_key=settings.gemini_api_key, model=settings.gemini_model, ) - response = await chat_service.chat( - repo_full_name=payload.repo_full_name, - message=payload.message, - stage_id=payload.stage_id, + # If messages are provided (from useChat), use them. + # Otherwise, construct a single message list from payload.message. + messages = payload.messages + if not messages: + if not payload.message: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Either 'messages' or 'message' must be provided.", + ) + messages = [{"role": "user", "content": payload.message}] + + return StreamingResponse( + chat_service.chat_stream( + repo_full_name=payload.repo_full_name, + messages=messages, + stage_id=payload.stage_id, + ), + media_type="text/plain", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Vercel-AI-Data-Stream": "v1", + }, ) - return ChatResponse(response=response) + +@router.get("/chat/history", response_model=ChatHistoryResponse) +async def get_chat_history( + repo_full_name: str, + stage_id: Optional[str] = None, + session: Session = Depends(get_db), + current_user: ClerkClaims = Depends(require_clerk_auth), +): + user_id = get_user_id(current_user) + try: + record = get_chat_session(session, user_id, repo_full_name, stage_id) + if not record: + return ChatHistoryResponse( + repo_full_name=repo_full_name, stage_id=stage_id, messages=[] + ) + return ChatHistoryResponse( + repo_full_name=record.repo_full_name, + stage_id=record.stage_id, + messages=record.messages or [], + ) + except Exception as exc: # pragma: no cover - defensive fallback + # If the table doesn't exist yet (migration pending), return empty history + import logging + + logging.getLogger(__name__).warning( + "chat history fetch failed: %s", exc, exc_info=True + ) + return ChatHistoryResponse( + repo_full_name=repo_full_name, stage_id=stage_id, messages=[] + ) + + +@router.post("/chat/history", status_code=status.HTTP_204_NO_CONTENT) +async def save_chat_history( + payload: SaveChatRequest, + session: Session = Depends(get_db), + current_user: ClerkClaims = Depends(require_clerk_auth), +): + user_id = get_user_id(current_user) + try: + record = get_chat_session( + session, user_id, payload.repo_full_name, payload.stage_id + ) + + if record: + record.messages = payload.messages + else: + record = GuideChatSession( + user_id=user_id, + repo_full_name=payload.repo_full_name, + stage_id=payload.stage_id, + messages=payload.messages, + ) + session.add(record) + + session.commit() + except Exception as exc: # pragma: no cover + import logging + + logging.getLogger(__name__).warning( + "chat history save failed: %s", exc, exc_info=True + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/commitly-backend/app/core/config.py b/commitly-backend/app/core/config.py index 808b6c8..9a682b2 100644 --- a/commitly-backend/app/core/config.py +++ b/commitly-backend/app/core/config.py @@ -1,4 +1,5 @@ from functools import lru_cache +import json from typing import Any, List, Optional, Union from pydantic import Field, HttpUrl, field_validator @@ -44,7 +45,7 @@ class Settings(BaseSettings): ) # GitHub ingestion - github_api_base: HttpUrl = Field( + github_api_base: HttpUrl = Field( # type: ignore "https://api.github.com", validation_alias="GITHUB_API_BASE" ) github_token: Optional[str] = Field(default=None, validation_alias="GITHUB_TOKEN") @@ -91,8 +92,6 @@ def _coerce_list(value: Any) -> List[str]: return [] if cleaned.startswith("["): try: - import json - data = json.loads(cleaned) if isinstance(data, list): return [ @@ -136,7 +135,7 @@ def parse_authorized_parties(cls, value: Any) -> List[str]: @lru_cache def get_settings() -> Settings: """Return a cached instance of the application settings.""" - return Settings() + return Settings() # type: ignore settings = get_settings() diff --git a/commitly-backend/app/models/roadmap.py b/commitly-backend/app/models/roadmap.py index d6197b0..f7a2937 100644 --- a/commitly-backend/app/models/roadmap.py +++ b/commitly-backend/app/models/roadmap.py @@ -130,6 +130,37 @@ class RoadmapRating(Base): ) +class GuideChatSession(Base): + """Stores the latest guide chat history per user/repo/stage.""" + + __tablename__ = "guide_chat_sessions" + __table_args__ = ( + UniqueConstraint( + "user_id", + "repo_full_name", + "stage_id", + name="uq_guide_chat_user_repo_stage", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + repo_full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + stage_id: Mapped[Optional[str]] = mapped_column( + String(255), nullable=True, index=True + ) + messages: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + class RoadmapViewTracker(Base): """Tracks views to prevent spam and implement anti-spam logic.""" @@ -304,10 +335,23 @@ class RatingResponse(BaseModel): class ChatRequest(BaseModel): - message: str + message: Optional[str] = None repo_full_name: str stage_id: Optional[str] = None + messages: Optional[List[dict]] = None # For full chat history context class ChatResponse(BaseModel): response: str + + +class SaveChatRequest(BaseModel): + repo_full_name: str + stage_id: Optional[str] = None + messages: List[dict] + + +class ChatHistoryResponse(BaseModel): + repo_full_name: str + stage_id: Optional[str] = None + messages: List[dict] diff --git a/commitly-backend/app/services/ai/chat.py b/commitly-backend/app/services/ai/chat.py index 7dba79c..b53fde4 100644 --- a/commitly-backend/app/services/ai/chat.py +++ b/commitly-backend/app/services/ai/chat.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging from typing import Optional @@ -14,7 +15,8 @@ MAX_OUTPUT_TOKENS = 2048 SYSTEM_PROMPT_TEMPLATE = """ -You are Commitly, an expert engineering mentor. You are guiding a developer who is rebuilding the repository "{repo_name}". +You are Commitly, an expert engineering mentor. You are guiding a developer who is +rebuilding the repository "{repo_name}". Context: {context} @@ -25,7 +27,8 @@ - Be helpful, encouraging, and technical. - If the context contains code snippets, reference them. - If the user asks about a specific task in the stage, guide them through it. -- If the answer is not in the context, use your general programming knowledge but mention that it's not explicitly in the provided commit history. +- If the answer is not in the context, use your general programming knowledge + but mention that it's not explicitly in the provided commit history. """ @@ -33,17 +36,15 @@ class GeminiChatService: def __init__(self, session: Session, api_key: str, model: str) -> None: self._session = session self._api_key = api_key + self._model = model self._endpoint = ( f"https://generativelanguage.googleapis.com/v1beta/models/" f"{model}:generateContent" ) - async def chat( - self, - repo_full_name: str, - message: str, - stage_id: Optional[str] = None, - ) -> str: + def _build_context( + self, repo_full_name: str, stage_id: Optional[str] = None + ) -> str | None: # 1. Fetch Roadmap roadmap = ( self._session.query(GeneratedRoadmap) @@ -51,7 +52,7 @@ async def chat( .first() ) if not roadmap: - return "I don't have a roadmap for this repository yet. Please generate one first." + return None context = "" @@ -88,6 +89,21 @@ async def chat( for stage in roadmap.timeline: context += f"- {stage['title']}: {stage['summary']}\n" + return context + + async def chat( + self, + repo_full_name: str, + message: str, + stage_id: Optional[str] = None, + ) -> str: + context = self._build_context(repo_full_name, stage_id) + if not context: + return ( + "I don't have a roadmap for this repository yet." + " Please generate one first." + ) + # 2. Call Gemini prompt = SYSTEM_PROMPT_TEMPLATE.format( repo_name=repo_full_name, context=context, user_query=message @@ -95,12 +111,155 @@ async def chat( return await self._call_gemini(prompt) + async def chat_stream( + self, + repo_full_name: str, + messages: list[dict], + stage_id: Optional[str] = None, + ): + context = self._build_context(repo_full_name, stage_id) + if not context: + yield self._format_protocol_text( + "I don't have a roadmap for this repository yet." + " Please generate one first." + ) + return + + # Use the last message from the user + user_query = messages[-1]["content"] if messages else "" + prompt = SYSTEM_PROMPT_TEMPLATE.format( + repo_name=repo_full_name, context=context, user_query=user_query + ) + + payload = { + "contents": [{"role": "user", "parts": [{"text": prompt}]}], + "generation_config": { + "temperature": 0.4, + "maxOutputTokens": MAX_OUTPUT_TOKENS, + }, + "safetySettings": [ + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_NONE", + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_NONE", + }, + ], + } + + stream_endpoint = ( + f"https://generativelanguage.googleapis.com/v1beta/models/" + f"{self._model}:streamGenerateContent" + ) + + logger.info(f"Starting chat stream for {repo_full_name}") + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream( + "POST", + stream_endpoint, + params={"key": self._api_key}, + json=payload, + ) as response: + logger.info(f"Gemini stream response status: {response.status_code}") + if response.status_code >= 400: + error_text = await response.aread() + logger.error(f"Gemini stream error: {error_text}") + yield self._format_protocol_error( + f"Gemini API error: {response.status_code}" + ) + return + + decoder = json.JSONDecoder() + buffer = "" + has_yielded = False + try: + async for chunk in response.aiter_text(): + logger.info(f"Received chunk length: {len(chunk)}") + buffer += chunk + while True: + # Skip whitespace + buffer = buffer.lstrip() + if not buffer: + break + + # Skip array start/end/separators + if buffer[0] in ["[", "]", ","]: + buffer = buffer[1:] + continue + + try: + obj, idx = decoder.raw_decode(buffer) + # logger.info(f"Parsed object keys: {obj.keys()}") + + # Process obj + candidates = obj.get("candidates", []) + if candidates: + parts = ( + candidates[0] + .get("content", {}) + .get("parts", []) + ) + if parts: + text_chunk = parts[0].get("text", "") + if text_chunk: + yield self._format_protocol_text(text_chunk) + has_yielded = True + else: + # Check for promptFeedback or just metadata + if "promptFeedback" in obj: + block_reason = obj["promptFeedback"].get( + "blockReason" + ) + if block_reason: + msg = ( + f"\n[Response blocked: {block_reason}]" + ) + logger.warning( + f"Response blocked: {block_reason}" + ) + yield self._format_protocol_text(msg) + has_yielded = True + elif "usageMetadata" in obj: + logger.info("Received usage metadata") + else: + logger.warning( + f"Unexpected Gemini response chunk: {obj}" + ) + + buffer = buffer[idx:] + except json.JSONDecodeError: + # Incomplete JSON, wait for more data + break + except Exception as e: + logger.error(f"Error during stream iteration: {e}") + yield self._format_protocol_error(f"Stream error: {str(e)}") + return + + if not has_yielded: + logger.warning("Stream finished without yielding any content") + yield self._format_protocol_text("I couldn't generate a response.") + + def _format_protocol_error(self, error: str) -> str: + """Format error for Vercel AI data stream v1.""" + return f"0:{json.dumps('Error: ' + error)}\n" + + def _format_protocol_text(self, text: str) -> str: + """Format text chunk for Vercel AI data stream v1.""" + return f"0:{json.dumps(text)}\n" + def _find_commits_for_window( self, repo_full_name: str, window: list[str] ) -> list[RepoCommitChunk]: - # This is a simplified version. In reality, we might need to traverse the graph or rely on authored_at. - # For now, let's fetch all chunks for the repo and filter in python as in gemini.py - # Optimization: In a real app, we'd query by range if we had order, or just fetch all (expensive). + # This is a simplified version. In reality, we might need to traverse the + # graph or rely on authored_at. + # For now, let's fetch all chunks for the repo and filter in python as in + # gemini.py + # Optimization: In a real app, we'd query by range if we had order, or + # just fetch all (expensive). # Let's try to fetch by repo_full_name and filter. all_chunks = ( @@ -138,6 +297,18 @@ async def _call_gemini(self, prompt: str) -> str: "temperature": 0.4, "maxOutputTokens": MAX_OUTPUT_TOKENS, }, + "safetySettings": [ + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_NONE", + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_NONE", + }, + ], } try: diff --git a/commitly-backend/app/services/roadmap_repository.py b/commitly-backend/app/services/roadmap_repository.py index 6b4eace..72e1fbe 100644 --- a/commitly-backend/app/services/roadmap_repository.py +++ b/commitly-backend/app/services/roadmap_repository.py @@ -11,6 +11,7 @@ from app.models.roadmap import ( GeneratedRoadmap, + GuideChatSession, RoadmapRepoSummary, RoadmapResponse, TimelineStage, @@ -374,6 +375,21 @@ def operation() -> None: self._run_with_schema_guard(operation) + def clear_chat_history(self, user_id: str, full_name: str) -> None: + """Delete all chat sessions for a user and repository.""" + + def operation() -> None: + try: + self._session.query(GuideChatSession).filter_by( + user_id=user_id, repo_full_name=full_name + ).delete() + self._session.commit() + except SQLAlchemyError: + self._session.rollback() + raise + + self._run_with_schema_guard(operation) + def list(self, user_id: str | None) -> list[RoadmapResponse]: if not user_id: return [] diff --git a/commitly-backend/app/services/roadmap_service.py b/commitly-backend/app/services/roadmap_service.py index 2e35b11..b4eedfc 100644 --- a/commitly-backend/app/services/roadmap_service.py +++ b/commitly-backend/app/services/roadmap_service.py @@ -532,6 +532,7 @@ def desync(): states = self._pin_store.list_states(user_id) had_state = any(state.repo_full_name == full_name for state in states) self._pin_store.unpin(user_id, full_name) + self._pin_store.clear_chat_history(user_id, full_name) if had_state: self._result_store.decrement_sync_count(full_name) diff --git a/commitly-frontend/README.md b/commitly-frontend/README.md index 2e8a5da..20fc534 100644 --- a/commitly-frontend/README.md +++ b/commitly-frontend/README.md @@ -1,6 +1,6 @@ # Commitly Frontend -Commitly’s marketing/learning experience built with the Next.js App Router, Tailwind CSS, and TypeScript. The visual copy/layout matches the design system exactly – restructuring focused on project hygiene, not UI changes. +Commitly’s dashboard app built with the Next.js App Router, Tailwind CSS, and TypeScript. ## Project structure @@ -9,7 +9,7 @@ app/ // App Router routes, layouts, and pages components/ // Shared UI primitives + layout/navigation building blocks data/ // Static seed data used while the backend is under construction lib/ - api/ // Fetch helpers that know how to talk to the FastAPI backend + api/ // Edge route handlers that proxy chat/history requests config/ // Environment helpers (API base URL, Supabase, Clerk keys) services/ // Domain-level accessors (e.g. repoService) public/ // Static assets @@ -22,11 +22,11 @@ Next.js follows the [official environment-variable guidance](https://nextjs.org/ | Variable | Purpose | | --- | --- | -| `NEXT_PUBLIC_API_BASE_URL` | FastAPI gateway URL (e.g. `https://api.commitly.dev`) | +| `NEXT_PUBLIC_EDGE_API_BASE_URL` | Supabase Edge base URL (e.g. `https://.supabase.co/functions/v1/api-v1`) | | `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Used once Supabase client wiring lands | | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Enables Clerk’s frontend SDK | -Add backend-only secrets (Supabase service key, Clerk secret key, Stripe billing keys, etc.) to `.env.local` without the prefix and read them inside the FastAPI project or dedicated server-only scripts. +Add backend-only secrets (Supabase service key, Clerk secret key, Stripe billing keys, etc.) to `.env.local` without the prefix and read them in Edge Functions or dedicated server-only scripts. ## Development scripts @@ -40,13 +40,12 @@ npm run lint # Next.js ESLint rules + Tailwind-aware Stylelint - `lib/config/env.ts` centralizes all browser-safe configuration so components never read from `process.env` directly. - `lib/api/client.ts` is the single fetch helper that applies consistent headers, caching, and error handling. -- `lib/services/repos.ts` exposes `repoService`, which pages/components now use instead of importing from `data/`. Today it proxies to the static fixtures, but it already knows how to queue repo imports against the FastAPI backend (it gracefully skips the network call when `NEXT_PUBLIC_API_BASE_URL` is not set). +- `lib/services/repos.ts` exposes `repoService`, which pages/components use for all `/api/v1/*` calls to the Supabase edge router. - The new ESLint rule prevents UI code from bypassing the service layer, keeping the future API seam clean. -## FastAPI integration game plan +## Edge integration notes -1. **Expose REST endpoints** such as `POST /roadmap/generate`, `GET /repos/:id`, and `GET /repos` from FastAPI. Attach Supabase from the backend for persistence and Clerk webhooks for identity. -2. **Configure auth**: Clerk issues JWTs; validate them in FastAPI (Clerk publishes the JWKS). Return Clerk user IDs to map workspaces/billing in Supabase. -3. **Billing**: Use Clerk’s built-in Stripe portal or call Stripe from FastAPI. Store subscription state in Supabase and surface it via `/billing` endpoints. -4. **Wire the frontend**: set `NEXT_PUBLIC_API_BASE_URL`, `NEXT_PUBLIC_SUPABASE_URL`, etc. `repoService.generateRoadmap` posts to `/roadmap/generate` after the user connects GitHub through the OAuth flow that lives under `/github/oauth/*` endpoints. -5. **Secure data fetching**: when you add authenticated pages, hydrate Clerk on the client and forward the session token via `fetch` headers inside the service layer. Supabase client helpers can also live in `lib/services` so every request gets the same auth context. +1. Set `NEXT_PUBLIC_EDGE_API_BASE_URL` to your Supabase edge router (`.../functions/v1/api-v1`). +2. Clerk JWTs are forwarded from the frontend and verified in edge handlers. +3. `repoService.generateRoadmap` posts to `/api/v1/roadmap/generate` after GitHub OAuth under `/api/v1/github/oauth/*`. +4. The home page reads `/api/v1/usage/global` to show shared token pool status. diff --git a/commitly-frontend/app/api/chat/history/route.ts b/commitly-frontend/app/api/chat/history/route.ts new file mode 100644 index 0000000..79e2872 --- /dev/null +++ b/commitly-frontend/app/api/chat/history/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "edge"; + +const backendBase = (() => { + const value = process.env.NEXT_PUBLIC_EDGE_API_BASE_URL; + return value + ? value.trim().replace(/\/+$/, "").replace(/\/api\/v1$/i, "") + : ""; +})(); + +async function forward(req: NextRequest) { + if (!backendBase) { + return NextResponse.json( + { error: "Missing NEXT_PUBLIC_EDGE_API_BASE_URL" }, + { status: 500 } + ); + } + const url = new URL(req.url); + const search = url.search ? url.search : ""; + const target = `${backendBase}/api/v1/roadmap/chat/history${search}`; + + const method = req.method; + const headers: Record = { + "Content-Type": "application/json", + }; + const auth = req.headers.get("authorization"); + if (auth) headers.Authorization = auth; + + const init: RequestInit = { + method, + headers, + cache: "no-store", + }; + + if (method !== "GET") { + const body = await req.text(); + init.body = body; + } + + const res = await fetch(target, init); + if (!res.ok) { + const text = await res.text(); + return NextResponse.json({ error: text || res.statusText }, { status: res.status }); + } + + if (method === "GET") { + const data = await res.json(); + return NextResponse.json(data, { status: 200 }); + } + + return NextResponse.json({ ok: true }, { status: 200 }); +} + +export async function GET(req: NextRequest) { + return forward(req); +} + +export async function POST(req: NextRequest) { + return forward(req); +} diff --git a/commitly-frontend/app/api/chat/route.ts b/commitly-frontend/app/api/chat/route.ts new file mode 100644 index 0000000..77c9d12 --- /dev/null +++ b/commitly-frontend/app/api/chat/route.ts @@ -0,0 +1,67 @@ +import { type NextRequest, NextResponse } from "next/server"; + +export const runtime = "edge"; + +const normalizeEdgeBaseUrl = (value?: string | null) => + value ? value.trim().replace(/\/+$/, "").replace(/\/api\/v1$/i, "") : ""; + +export async function POST(req: NextRequest) { + console.log("Chat API route hit"); + try { + const body = await req.json(); + const { messages, ...rest } = body; + + const apiBaseUrl = normalizeEdgeBaseUrl( + process.env.NEXT_PUBLIC_EDGE_API_BASE_URL + ); + if (!apiBaseUrl) { + return NextResponse.json( + { error: "Missing NEXT_PUBLIC_EDGE_API_BASE_URL" }, + { status: 500 } + ); + } + const backendUrl = `${apiBaseUrl}/api/v1/roadmap/chat`; + + // Forward the request to the backend + const response = await fetch(backendUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + // Forward the Authorization header if present + Authorization: req.headers.get("Authorization") || "", + }, + body: JSON.stringify({ + messages, + ...rest, + }), + cache: "no-store", + // @ts-expect-error - duplex is needed for some node environments but might not be strictly needed for edge, adding for safety if runtime changes + duplex: "half", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Backend chat error:", response.status, errorText); + return NextResponse.json( + { error: `Backend error: ${response.statusText}` }, + { status: response.status } + ); + } + + // Stream the response back + return new Response(response.body, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Vercel-AI-Data-Stream": "v1", + }, + }); + } catch (error) { + console.error("Chat route error:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 } + ); + } +} diff --git a/commitly-frontend/app/help-center/faq/page.tsx b/commitly-frontend/app/help-center/faq/page.tsx index 003c6e1..2e9e09f 100644 --- a/commitly-frontend/app/help-center/faq/page.tsx +++ b/commitly-frontend/app/help-center/faq/page.tsx @@ -1,28 +1,69 @@ +"use client"; + import Link from "next/link"; +import { usePreferences } from "@/components/providers/preferences-provider"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { faqs } from "@/data/help-center"; + +const FAQ_KEYS = [ + { + q: "faq_q1", + qFallback: "What does Commitly actually do?", + a: "faq_a1", + aFallback: + "It converts a GitHub repository into a beginner-friendly learning path with concrete tasks and validation checkpoints.", + }, + { + q: "faq_q2", + qFallback: "Can I use private repositories?", + a: "faq_a2", + aFallback: + "Yes on paid tiers. You can revoke OAuth access at any time.", + }, + { + q: "faq_q3", + qFallback: "How does pricing work?", + a: "faq_a3", + aFallback: + "Pricing is fixed by plan. Billing is still in waitlist mode during this beta.", + }, + { + q: "faq_q4", + qFallback: "Where should I report bugs?", + a: "faq_a4", + aFallback: + "Open Settings -> Report a bug and include steps, screenshots, and expected behavior.", + }, +] as const; export default function FAQPage() { + const { t } = usePreferences(); + return (
-

FAQ

-

Frequently asked questions

+

+ {t("faq_kicker", "FAQ")} +

+

+ {t("faq_title", "Frequently asked questions")} +

- Still unsure? Reach out inside the product or reply to any - onboarding email. + {t( + "faq_subtitle", + "Still unsure? Reach out in-product or reply to any onboarding email." + )}

- {faqs.map((faq) => ( - + {FAQ_KEYS.map((faq) => ( + - {faq.question} + {t(faq.q, faq.qFallback)} - {faq.answer} + {t(faq.a, faq.aFallback)} ))} @@ -30,7 +71,7 @@ export default function FAQPage() {
- ← Back to Help center + {t("back_help_center", "Back to Help center")}
diff --git a/commitly-frontend/app/help-center/getting-started/page.tsx b/commitly-frontend/app/help-center/getting-started/page.tsx index 6a6cc19..eeab0f9 100644 --- a/commitly-frontend/app/help-center/getting-started/page.tsx +++ b/commitly-frontend/app/help-center/getting-started/page.tsx @@ -1,31 +1,54 @@ +"use client"; + import Link from "next/link"; +import { usePreferences } from "@/components/providers/preferences-provider"; import { Card, CardContent } from "@/components/ui/card"; -import { onboardingSteps } from "@/data/help-center"; + +const STEP_KEYS = [ + { + key: "getting_started_step_1", + fallback: "Paste any GitHub repo URL from the home screen.", + }, + { + key: "getting_started_step_2", + fallback: "Commitly reads commit history and compiles a staged learning roadmap.", + }, + { + key: "getting_started_step_3", + fallback: "Read stages, use the guide coach, and save useful roadmaps to your library.", + }, +] as const; export default function GettingStartedPage() { + const { t } = usePreferences(); + return (

- Guide + {t("guide", "Guide")}

-

Getting started

+

+ {t("getting_started_title", "Getting started")} +

- Follow these essentials to run your first commitly timeline and keep - the workflow tidy. + {t( + "getting_started_subtitle", + "Follow these essentials to generate your first roadmap and keep your workflow clean." + )}

- +
    - {onboardingSteps.map((step, index) => ( -
  1. + {STEP_KEYS.map((step, index) => ( +
  2. {String(index + 1).padStart(2, "0")} -

    {step}

    +

    {t(step.key, step.fallback)}

  3. ))}
@@ -34,7 +57,7 @@ export default function GettingStartedPage() {
- ← Back to Help center + {t("back_help_center", "Back to Help center")}
diff --git a/commitly-frontend/app/help-center/page.tsx b/commitly-frontend/app/help-center/page.tsx index c64541a..0eb7112 100644 --- a/commitly-frontend/app/help-center/page.tsx +++ b/commitly-frontend/app/help-center/page.tsx @@ -1,5 +1,8 @@ +"use client"; + import { ArrowRight, Headphones, Mail } from "lucide-react"; import Link from "next/link"; +import { usePreferences } from "@/components/providers/preferences-provider"; import { Button } from "@/components/ui/button"; import { Card, @@ -8,41 +11,77 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { helpCards } from "@/data/help-center"; + +const HELP_CARD_KEYS = [ + { + href: "/help-center/getting-started", + titleKey: "help_card_getting_started_title", + titleFallback: "Getting started", + descriptionKey: "help_card_getting_started_desc", + descriptionFallback: + "Bootstrap Commitly, connect GitHub, and run your first repo.", + }, + { + href: "/help-center/faq", + titleKey: "help_card_faq_title", + titleFallback: "FAQ", + descriptionKey: "help_card_faq_desc", + descriptionFallback: "Quick answers for account, timelines, and retention.", + }, + { + href: "/policies", + titleKey: "help_card_policies_title", + titleFallback: "Terms & policies", + descriptionKey: "help_card_policies_desc", + descriptionFallback: "Privacy, security, and acceptable use policies.", + }, + { + href: "/release-notes", + titleKey: "help_card_release_notes_title", + titleFallback: "Release notes", + descriptionKey: "help_card_release_notes_desc", + descriptionFallback: "Highlights from the latest product drops.", + }, +] as const; export default function HelpCenterPage() { + const { t } = usePreferences(); + return (
-
+

- Support + {t("help_kicker", "Support")}

-

How can we help?

+

+ {t("help_title", "How can we help?")} +

- Browse guides, track product changes, or reach a human. Commitly - support stays close to the product team, so every ticket helps - improve the roadmap. + {t( + "help_subtitle", + "Browse guides, track product changes, or reach a human. Every ticket helps us improve roadmap quality." + )}

-
+
-

Live support

+

+ {t("help_live_support", "Live support")} +

- Mon‒Fri, 9am–6pm UTC + {t("help_live_support_hours", "Mon-Fri, 9am-6pm UTC")}

-

Email

-

- support@commitly.dev -

+

{t("help_email", "Email")}

+

support@commitly.dev

@@ -51,31 +90,26 @@ export default function HelpCenterPage() {

- Library + {t("help_library_kicker", "Library")}

- Start with the essentials + {t("help_library_title", "Start with the essentials")}

- {helpCards.map((card) => ( - + {HELP_CARD_KEYS.map((card) => ( + - {card.title} - {card.description} + {t(card.titleKey, card.titleFallback)} + + {t(card.descriptionKey, card.descriptionFallback)} + - diff --git a/commitly-frontend/app/layout.tsx b/commitly-frontend/app/layout.tsx index 2bbc7c9..25790e8 100644 --- a/commitly-frontend/app/layout.tsx +++ b/commitly-frontend/app/layout.tsx @@ -1,14 +1,14 @@ import "@/styles/globals.css"; import { ClerkProvider } from "@clerk/nextjs"; -import { dark } from "@clerk/themes"; import { Analytics } from "@vercel/analytics/next"; import { SpeedInsights } from "@vercel/speed-insights/next"; import type { Metadata, Viewport } from "next"; import { Inter, JetBrains_Mono } from "next/font/google"; import { type ReactNode, Suspense } from "react"; -import HomeBackground from "@/components/layout/home-background"; import SidebarWrapper from "@/components/layout/sidebar/sidebar-wrapper"; +import { LayoutProvider } from "@/components/providers/layout-provider"; +import { PreferencesProvider } from "@/components/providers/preferences-provider"; import { RoadmapCatalogProvider } from "@/components/providers/roadmap-catalog-provider"; const inter = Inter({ @@ -38,43 +38,70 @@ export const viewport: Viewport = { initialScale: 1, }; +const clerkPublishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY ?? ""; +const isProdRuntime = + process.env.NODE_ENV === "production" || + process.env.VERCEL_ENV === "production"; +const hasTestClerkKeyInProd = + isProdRuntime && clerkPublishableKey.startsWith("pk_test_"); + +if (hasTestClerkKeyInProd) { + console.error( + "[commitly] Production runtime is using a Clerk test publishable key (pk_test_*). Switch to production Clerk keys immediately." + ); +} + export default function RootLayout({ children }: { children: ReactNode }) { return ( - + - -
- - - - -
- {children} -
+ {hasTestClerkKeyInProd && ( +
+ Clerk production key is not configured. Replace `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` with a production key.
- + )} + + + +
+ + + +
+ {children} +
+
+
+
+
diff --git a/commitly-frontend/app/oauth/github/page.tsx b/commitly-frontend/app/oauth/github/page.tsx index 99d4558..c768c82 100644 --- a/commitly-frontend/app/oauth/github/page.tsx +++ b/commitly-frontend/app/oauth/github/page.tsx @@ -2,33 +2,50 @@ import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect } from "react"; +import { usePreferences } from "@/components/providers/preferences-provider"; import { Button } from "@/components/ui/button"; +import { mapGithubOAuthError } from "@/lib/services/error-messages"; function GithubOAuthResultContent() { + const { t } = usePreferences(); const router = useRouter(); const params = useSearchParams(); const status = params.get("status"); + const errorCode = params.get("error"); + const detail = params.get("detail"); + const isError = status === "error"; + const title = isError + ? t("github_connection_failed", "GitHub connection failed") + : status === "success" + ? t("github_connection_success", "GitHub connection successful") + : t("github_connection_updated", "GitHub connection updated"); useEffect(() => { const timer = setTimeout(() => { router.push("/"); - }, 3000); + }, isError ? 6000 : 3000); return () => clearTimeout(timer); - }, [router]); + }, [isError, router]); return (
-
-

- GitHub connection {status === "success" ? "successful" : "updated"} -

-

- You can close this tab. We'll refresh your dashboard - automatically. -

+
+

{title}

+ {isError ? ( +

+ {mapGithubOAuthError(errorCode, detail ?? undefined, t)} +

+ ) : ( +

+ {t( + "github_oauth_success_body", + "You can close this tab. We'll refresh your dashboard automatically." + )} +

+ )}
@@ -37,7 +54,7 @@ function GithubOAuthResultContent() { export default function GithubOAuthResult() { return ( - Loading...
}> + }> ); diff --git a/commitly-frontend/app/page.tsx b/commitly-frontend/app/page.tsx index 275842c..d2014e7 100644 --- a/commitly-frontend/app/page.tsx +++ b/commitly-frontend/app/page.tsx @@ -3,9 +3,13 @@ import { useAuth } from "@clerk/nextjs"; import { useRouter } from "next/navigation"; import { type FormEvent, useEffect, useState } from "react"; +import { usePreferences } from "@/components/providers/preferences-provider"; +import type { GlobalUsage } from "@/lib/services/repos"; import { useRoadmapCatalog } from "@/components/providers/roadmap-catalog-provider"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { SharedTokenPoolCard } from "@/components/ui/shared-token-pool-card"; +import { mapGithubOAuthError } from "@/lib/services/error-messages"; import { githubService } from "@/lib/services/github"; import { repoService } from "@/lib/services/repos"; @@ -17,7 +21,9 @@ export default function Home() { const [githubConnected, setGithubConnected] = useState(false); const [githubLogin, setGithubLogin] = useState(null); const [isCheckingGithub, setIsCheckingGithub] = useState(false); + const [globalUsage, setGlobalUsage] = useState(null); const { isSignedIn, getToken } = useAuth(); + const { t } = usePreferences(); const { markPending } = useRoadmapCatalog(); useEffect(() => { @@ -60,6 +66,23 @@ export default function Home() { }; }, [getToken, isSignedIn]); + useEffect(() => { + let cancelled = false; + const fetchUsage = async () => { + const token = await getToken?.(); + const response = await repoService.getGlobalUsage(token ?? undefined); + if (!(cancelled || !response.ok || !response.data)) { + setGlobalUsage(response.data); + } + }; + fetchUsage(); + const intervalId = window.setInterval(fetchUsage, 30_000); + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [getToken]); + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); const value = repoLink.trim(); @@ -73,7 +96,7 @@ export default function Home() { } if (!githubConnected) { - setError("Connect GitHub before generating a roadmap."); + setError(t("connect_github_before_generate", "Connect GitHub before generating a roadmap.")); return; } @@ -82,22 +105,18 @@ export default function Home() { const identity = repoService.parseRepoInput(value); if (!identity) { setIsSubmitting(false); - setError("Enter a valid GitHub repository URL (owner/name)."); + setError(t("invalid_repo_url", "Enter a valid GitHub repository URL (owner/name).")); return; } const canonicalUrl = `https://github.com/${identity.fullName}`; markPending(identity); - const params = new URLSearchParams({ - repoUrl: canonicalUrl, - fullName: identity.fullName, - intent: "generate", - }); - - router.push(`/repo/${identity.slug}/timeline?${params.toString()}`); - setRepoLink(""); - setIsSubmitting(false); + const params = new URLSearchParams(); + if (identity.fullName) params.set("fullName", identity.fullName); + if (canonicalUrl) params.set("repoUrl", canonicalUrl); + params.set("intent", "generate"); + router.push(`/repo/${identity.slug}?view=timeline&${params.toString()}`); }; const handleConnectGithub = async () => { @@ -114,7 +133,9 @@ export default function Home() { if (response.ok && response.data) { window.location.href = response.data.authorize_url; } else if (response.error) { - setError(response.error); + const errorCode = + "errorCode" in response ? response.errorCode : undefined; + setError(mapGithubOAuthError(errorCode, response.error, t)); } }; @@ -122,37 +143,41 @@ export default function Home() {
-

- Repo-first learning +

+ {t("home_kicker", "Repo-first learning")}

- Hey, builder. Ready to learn? + {t("home_title", "Hey, builder. Ready to learn?")}

-

- Drop a GitHub repo and we'll draft a roadmap that mirrors how - the authors shipped it. +

+ {t( + "home_subtitle", + "Drop a GitHub repo and we'll draft a roadmap that mirrors how the authors shipped it." + )}

-
+
setRepoLink(event.target.value)} placeholder="https://github.com/your-org/your-repo" value={repoLink} />
{error && ( @@ -162,35 +187,31 @@ export default function Home() {

{isCheckingGithub - ? "Checking your GitHub connection..." - : "Connect GitHub to allow Commitly to read repository history."} + ? t("github_checking", "Checking your GitHub connection...") + : t( + "github_connect_required", + "Connect GitHub to allow Commitly to read repository history." + )}

{!isCheckingGithub && ( )}
)} {githubConnected && githubLogin && (

- Connected as {githubLogin} + {t("connected_as", "Connected as")} {githubLogin}

)} - {/* -
-

Examples

-
- {/* Examples removed as mock data is deleted -
-
- */} +
); diff --git a/commitly-frontend/app/plans/page.tsx b/commitly-frontend/app/plans/page.tsx index b78168c..e366d9a 100644 --- a/commitly-frontend/app/plans/page.tsx +++ b/commitly-frontend/app/plans/page.tsx @@ -2,6 +2,8 @@ import { Check, X } from "lucide-react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { usePreferences } from "@/components/providers/preferences-provider"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -16,26 +18,29 @@ import { plans } from "@/data/plans"; export default function PlansPage() { const router = useRouter(); + const { t } = usePreferences(); + const paidPlansWaitlistUrl = "https://commitly.one"; return ( -
+

- Pricing + {t("pricing", "Pricing")}

- Upgrade your workspace when you're ready. + {t("plans_title", "Upgrade your workspace when you're ready.")}

- Pick a plan that matches how often you turn repos into guided - build plans. All tiers include the dark UI and shadcn component - kit. + {t( + "plans_subtitle", + "Pricing is transparent, but billing is not live yet. Paid plans are currently waitlist-only." + )}

@@ -43,48 +48,64 @@ export default function PlansPage() {
{plans.map((plan) => (
- {plan.name} + + {t(plan.nameKey, plan.nameFallback)} + {plan.highlighted && ( - Popular + {t("popular", "Popular")} )}
- {plan.description} + + {t(plan.descriptionKey, plan.descriptionFallback)} +
${plan.price} - /month + /{t("month", "month")}
- + {plan.id === "free" ? ( + + ) : ( + + )}
    {plan.features.map((feature) => ( -
  • +
  • - {feature} + {t(feature.key, feature.fallback)}
  • ))}
{plan.id === "free" - ? "Includes unlimited mock timelines on public repos." - : "Cancel anytime. We prorate upgrades."} + ? t("free_tier_active", "Free tier is active now.") + : t( + "billing_not_active", + "Billing is not active yet. Join waitlist to get notified on launch." + )}
))} diff --git a/commitly-frontend/app/policies/data-use/page.tsx b/commitly-frontend/app/policies/data-use/page.tsx index 356d6fa..637dccc 100644 --- a/commitly-frontend/app/policies/data-use/page.tsx +++ b/commitly-frontend/app/policies/data-use/page.tsx @@ -1,36 +1,43 @@ +"use client"; + +import { usePreferences } from "@/components/providers/preferences-provider"; import { policyMeta } from "@/data/policies"; export default function DataUsePage() { + const { t } = usePreferences(); + return (

- Legal + {t("legal", "Legal")}

-

Data use

+

{t("policy_data_use_title", "Data use")}

- Last updated {policyMeta.updated} + {t("last_updated", "Last updated")} {policyMeta.updated}

- When you connect a repository, commitly clones it temporarily to - extract structure, commits, and metadata. We store derived artifacts - (like dependency graphs) for the lifespan of the timeline so we can - regenerate hints. + {t( + "policy_data_use_p1", + "When you connect a repository, Commitly processes structure, commits, and metadata to build learning roadmaps." + )}

-

Processing

+

{t("processing", "Processing")}

- Processing happens on servers located in the US or EU. We do not - retain Git history longer than necessary, and you can wipe all - derived data by deleting the workspace or revoking GitHub access. + {t( + "policy_data_use_p2", + "Processing happens in US or EU regions. Repo artifacts are retained only as long as needed for roadmap quality and caching." + )}

-

Sharing

+

{t("sharing", "Sharing")}

- We do not sell your data. We only share it with infrastructure - vendors (storage, observability) bound by strict confidentiality and - security commitments. + {t( + "policy_data_use_p3", + "We do not sell user data. We only work with infrastructure providers under strict confidentiality and security obligations." + )}

diff --git a/commitly-frontend/app/policies/page.tsx b/commitly-frontend/app/policies/page.tsx index 753084f..82fa89e 100644 --- a/commitly-frontend/app/policies/page.tsx +++ b/commitly-frontend/app/policies/page.tsx @@ -1,54 +1,86 @@ +"use client"; + import { FileText, Lock, Scale, Shield } from "lucide-react"; import Link from "next/link"; +import { usePreferences } from "@/components/providers/preferences-provider"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { policyLinks, policyMeta } from "@/data/policies"; +import { policyMeta } from "@/data/policies"; -const iconMap = { - "Terms of use": Scale, - "Privacy policy": Lock, - "Data use": FileText, - Security: Shield, -}; +const POLICY_LINKS = [ + { + href: "/policies/terms-of-use", + labelKey: "policy_terms_title", + labelFallback: "Terms of use", + summaryKey: "policy_terms_summary", + summaryFallback: "Rules for using Commitly products and APIs.", + icon: Scale, + }, + { + href: "/policies/privacy-policy", + labelKey: "policy_privacy_title", + labelFallback: "Privacy policy", + summaryKey: "policy_privacy_summary", + summaryFallback: "What data we collect and how requests are handled.", + icon: Lock, + }, + { + href: "/policies/data-use", + labelKey: "policy_data_use_title", + labelFallback: "Data use", + summaryKey: "policy_data_use_summary", + summaryFallback: "How repository content is processed and deleted.", + icon: FileText, + }, + { + href: "/policies/security", + labelKey: "policy_security_title", + labelFallback: "Security", + summaryKey: "policy_security_summary", + summaryFallback: "Controls used to safeguard infrastructure and data.", + icon: Shield, + }, +] as const; export default function PoliciesPage() { + const { t } = usePreferences(); + return (
-
+

- Legal + {t("legal", "Legal")}

-

Terms & policies

+

+ {t("policies_title", "Terms & policies")} +

- Last updated {policyMeta.updated}. For security disclosures email{" "} - {policyMeta.contact}. + {t("last_updated", "Last updated")} {policyMeta.updated}. {t("security_contact", "For security disclosures email")} {policyMeta.contact}.

- {policyLinks.map((link) => { - const Icon = - iconMap[link.label as keyof typeof iconMap] ?? FileText; + {POLICY_LINKS.map((link) => { + const Icon = link.icon; return ( - + -
+
- {link.label} + + {t(link.labelKey, link.labelFallback)} +

- {link.summary} + {t(link.summaryKey, link.summaryFallback)}

- View document + {t("view_document", "View document")}
@@ -56,33 +88,38 @@ export default function PoliciesPage() { })}
-
+

- Compliance + {t("compliance", "Compliance")}

- Your data, your rules + {t("compliance_title", "Your data, your rules")}

- Commitly stores customer data in the EU and US with daily - encrypted backups. SSO, audit trails, and data export are - available on the Team plan. + {t( + "compliance_body", + "Commitly stores customer data in EU and US regions with encrypted backups." + )}

-
-

Data requests

+
+

{t("data_requests", "Data requests")}

- Email privacy@commitly.dev to request data exports or deletions. - We respond within 5 business days. + {t( + "data_requests_body", + "Email privacy@commitly.dev for exports or deletions. We respond within 5 business days." + )}

-
-

Security questions

+
+

{t("security_questions", "Security questions")}

- Contact {policyMeta.contact} for penetration-test results, SOC 2 - roadmap, or vendor reviews. + {t( + "security_questions_body", + "Contact security@commitly.dev for security review requests." + )}

diff --git a/commitly-frontend/app/policies/privacy-policy/page.tsx b/commitly-frontend/app/policies/privacy-policy/page.tsx index e81f5e1..7e45fe2 100644 --- a/commitly-frontend/app/policies/privacy-policy/page.tsx +++ b/commitly-frontend/app/policies/privacy-policy/page.tsx @@ -1,42 +1,49 @@ +"use client"; + +import { usePreferences } from "@/components/providers/preferences-provider"; import { policyMeta } from "@/data/policies"; export default function PrivacyPolicyPage() { + const { t } = usePreferences(); + return (

- Legal + {t("legal", "Legal")}

-

Privacy policy

+

+ {t("policy_privacy_title", "Privacy policy")} +

- Last updated {policyMeta.updated} + {t("last_updated", "Last updated")} {policyMeta.updated}

- This Privacy Policy explains what data we collect when you use - commitly, how we use it, and your choices. We collect information - you provide (like account details) and information generated while - using the product (like device metadata or usage analytics). + {t( + "policy_privacy_p1", + "This policy explains what data we collect in Commitly, how we use it, and your available controls." + )}

- What we collect + {t("policy_privacy_h2_collect", "What we collect")}

- Account basics (name, email), repository metadata, and feature usage - are required to operate the service. When you import a repo we - process its contents to build timelines. We do not train foundation - models on private repo data. + {t( + "policy_privacy_p2", + "We collect account basics, repository metadata, and product usage data required to deliver roadmap generation and guide features." + )}

- How we use information + {t("policy_privacy_h2_use", "How we use information")}

- Data helps us: provide the product, troubleshoot issues, improve - features, secure the platform, and comply with legal obligations. We - retain repo-derived data only while your workspace needs it; - deleting a repo removes cached context within seven days. + {t( + "policy_privacy_p3", + "Data is used to operate the product, improve quality, secure the platform, and satisfy legal obligations." + )}

diff --git a/commitly-frontend/app/policies/security/page.tsx b/commitly-frontend/app/policies/security/page.tsx index 84fdf3e..f0e7a8d 100644 --- a/commitly-frontend/app/policies/security/page.tsx +++ b/commitly-frontend/app/policies/security/page.tsx @@ -1,37 +1,42 @@ +"use client"; + +import { usePreferences } from "@/components/providers/preferences-provider"; import { policyMeta } from "@/data/policies"; export default function SecurityPage() { + const { t } = usePreferences(); + return (

- Legal + {t("legal", "Legal")}

-

Security

+

{t("policy_security_title", "Security")}

- Last updated {policyMeta.updated} + {t("last_updated", "Last updated")} {policyMeta.updated}

- Commitly uses encryption at rest and in transit, role-based access, - secrets management, and continuous monitoring to keep your data - private. Production access is limited to on-call engineers with - hardware keys. + {t( + "policy_security_p1", + "Commitly uses encryption in transit/at rest, role-based access control, secret management, and monitoring." + )}

-

Safeguards

+

{t("safeguards", "Safeguards")}

- We isolate customer data by workspace, store secrets in a dedicated - vault, and monitor for anomalous behavior. Backups are encrypted and - rotated every 12 hours. + {t( + "policy_security_p2", + "Customer data is isolated by workspace. Backups are encrypted and rotated on a regular schedule." + )}

- Responsible disclosure + {t("responsible_disclosure", "Responsible disclosure")}

- If you discover a security issue, email {policyMeta.contact}. - Include steps to reproduce so we can respond quickly. + {t("policy_security_p3", "If you discover a security issue, email")} {policyMeta.contact}. {t("policy_security_p3_suffix", "Include reproduction details so we can respond quickly.")}

diff --git a/commitly-frontend/app/policies/terms-of-use/page.tsx b/commitly-frontend/app/policies/terms-of-use/page.tsx index 5062cdf..f150c53 100644 --- a/commitly-frontend/app/policies/terms-of-use/page.tsx +++ b/commitly-frontend/app/policies/terms-of-use/page.tsx @@ -1,43 +1,49 @@ +"use client"; + +import { usePreferences } from "@/components/providers/preferences-provider"; import { policyMeta } from "@/data/policies"; export default function TermsOfUsePage() { + const { t } = usePreferences(); + return (

- Legal + {t("legal", "Legal")}

-

Terms of use

+

+ {t("policy_terms_title", "Terms of use")} +

- Last updated {policyMeta.updated} + {t("last_updated", "Last updated")} {policyMeta.updated}

- These Terms of Use (“Terms”) govern how you access and use commitly, - the timeline builder, our API, and any related services. By using - the product you agree to these Terms, including updates we may post - in this document. If you do not agree, don’t use the service. + {t( + "policy_terms_p1", + "These Terms govern use of Commitly, including roadmap generation and related services." + )}

- Use of the service + {t("policy_terms_h2_usage", "Use of the service")}

- You may only use commitly for lawful purposes. You are responsible - for the repositories you import, the content you submit, and - ensuring you have rights to grant us access to that content. We may - suspend accounts that abuse rate limits, attempt to reverse engineer - the service, or otherwise disrupt other users. + {t( + "policy_terms_p2", + "Use Commitly only for lawful purposes. You are responsible for imported repositories and granted access rights." + )}

- Intellectual property + {t("policy_terms_h2_ip", "Intellectual property")}

- Commitly retains ownership of the platform, documentation, and brand - assets. You retain ownership of your code and data. We request - limited access to analyze a repo and build timelines, and we delete - derived data once you remove the repo from commitly. + {t( + "policy_terms_p3", + "Commitly owns the platform and brand assets. You keep ownership of your code and data." + )}

diff --git a/commitly-frontend/app/release-notes/page.tsx b/commitly-frontend/app/release-notes/page.tsx index 62bfbfc..e187349 100644 --- a/commitly-frontend/app/release-notes/page.tsx +++ b/commitly-frontend/app/release-notes/page.tsx @@ -1,25 +1,34 @@ +"use client"; + +import { usePreferences } from "@/components/providers/preferences-provider"; import { Badge } from "@/components/ui/badge"; import { releaseNotes } from "@/data/release-notes"; export default function ReleaseNotesPage() { + const { t } = usePreferences(); + return (
-
+

- Changelog + {t("changelog", "Changelog")}

-

Release notes

+

+ {t("release_notes_title", "Release notes")} +

- Product updates, bug fixes, and design refreshes. We ship weekly, - collect feedback in the app, and keep the roadmap transparent. + {t( + "release_notes_subtitle", + "Product updates, bug fixes, and quality improvements shipped to Commitly." + )}

{releaseNotes.map((entry, index) => (
@@ -33,7 +42,7 @@ export default function ReleaseNotesPage() {

{entry.version}

- Release {index + 1} of {releaseNotes.length} in this series. + {t("release_entry_progress", "Release")} {index + 1} {t("of", "of")} {releaseNotes.length}

@@ -41,10 +50,10 @@ export default function ReleaseNotesPage() {
    {entry.highlights.map((item) => (
  • - {item} + {t(item.key, item.fallback)}
  • ))}
diff --git a/commitly-frontend/app/repo/[repoId]/guide/page.tsx b/commitly-frontend/app/repo/[repoId]/guide/page.tsx index 6879f3e..59a8ac7 100644 --- a/commitly-frontend/app/repo/[repoId]/guide/page.tsx +++ b/commitly-frontend/app/repo/[repoId]/guide/page.tsx @@ -1,457 +1,7 @@ "use client"; -import { useAuth } from "@clerk/nextjs"; -import { - ChevronDown, - Copy, - Edit2, - SendHorizontal, - ThumbsDown, - ThumbsUp, -} from "lucide-react"; -import Link from "next/link"; -import { useParams, useSearchParams } from "next/navigation"; -import { - type ChangeEvent, - type FormEvent, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import TabSwitch from "@/components/navigation/tab-switch"; -import { useRoadmapCatalog } from "@/components/providers/roadmap-catalog-provider"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { Textarea } from "@/components/ui/textarea"; -import { repoService } from "@/lib/services/repos"; +import GuideView from "@/components/repo/guide-view"; export default function RepoGuidePage() { - const params = useParams(); - const repoId = params.repoId as string; - const searchParams = useSearchParams(); - const { isSignedIn, getToken } = useAuth(); - const { getBySlug } = useRoadmapCatalog(); - - const cachedRecord = getBySlug(repoId); - - const activeData = useMemo(() => { - if (cachedRecord && "repo" in cachedRecord) { - return { - identity: { - owner: cachedRecord.owner, - repoName: cachedRecord.repoName, - }, - name: cachedRecord.repo.full_name, - timeline: cachedRecord.timeline, - guideThread: [] as Array<{ - id: string; - role: "user" | "guide"; - message: string; - }>, - }; - } - return null; - }, [cachedRecord]); - - const [message, setMessage] = useState(""); - const [chatHistory, setChatHistory] = useState< - Array<{ id: string; role: "user" | "guide"; message: string }> - >([]); - const [isLoading, setIsLoading] = useState(false); - const bottomRef = useRef(null); - const textareaRef = useRef(null); - - // Initialize chat history from static data if available and empty - useEffect(() => { - if ( - activeData?.guideThread && - chatHistory.length === 0 && - activeData.guideThread.length > 0 - ) { - setChatHistory( - activeData.guideThread.map((item) => ({ - ...item, - role: item.role as "user" | "guide", - })) - ); - } - }, [activeData, chatHistory.length]); - - const stageId = searchParams?.get("stage"); - - const stageContext = useMemo(() => { - if (!(activeData && stageId)) { - return null; - } - return activeData.timeline.find((stage) => stage.id === stageId) ?? null; - }, [activeData, stageId]); - - useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, []); - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - if (!(isSignedIn && message.trim()) || isLoading || !activeData) { - return; - } - - const userMsg = message.trim(); - setMessage(""); - if (textareaRef.current) { - textareaRef.current.style.height = "auto"; - } - - const newHistory = [ - ...chatHistory, - { id: Date.now().toString(), role: "user" as const, message: userMsg }, - ]; - setChatHistory(newHistory); - setIsLoading(true); - - try { - const token = await getToken(); - const response = await repoService.chat( - activeData.identity.owner, - activeData.identity.repoName, - userMsg, - stageId ?? undefined, - token ?? undefined - ); - - if (response.ok && response.data) { - setChatHistory((prev) => [ - ...prev, - { - id: (Date.now() + 1).toString(), - role: "guide", - message: response.data!.response, - }, - ]); - } else { - // Fallback error message - setChatHistory((prev) => [ - ...prev, - { - id: (Date.now() + 1).toString(), - role: "guide", - message: "Sorry, I encountered an error. Please try again.", - }, - ]); - } - } catch (error) { - console.error("Chat error:", error); - setChatHistory((prev) => [ - ...prev, - { - id: (Date.now() + 1).toString(), - role: "guide", - message: "Sorry, I encountered an error. Please try again.", - }, - ]); - } finally { - setIsLoading(false); - } - }; - - const handleInputChange = (event: ChangeEvent) => { - setMessage(event.target.value); - if (textareaRef.current) { - textareaRef.current.style.height = "auto"; - textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; - } - }; - - if (!activeData) { - return null; - } - - return ( -
-
-
-

Guide

-

{activeData.name}

-
- -
- -
- {stageContext && ( -
-
-
- Timeline context -
-

{stageContext.title}

-

- {stageContext.summary} -

-
-
- -
- - {/* Goals Section */} - {stageContext.goals && stageContext.goals.length > 0 && ( -
-
-

- Goals -

-
-
-
    - {stageContext.goals.map((goal: string, idx: number) => ( -
  • - - {goal} -
  • - ))} -
-
- )} - - {/* Tasks Section */} -
-
-

- {isSignedIn ? "Tasks" : "Tasks · Sign in to start"} -

-
-
-
- {stageContext.tasks.map((task, idx) => ( -
-

- {task.title} -

-
-

- {task.description} -

-
- {task.file_path && ( -
- - File: - - {task.file_path} -
- )} - {task.code_snippet && ( -
-
- $ {task.code_snippet} -
-
- )} -
- ))} -
-
- - {/* Code Examples Section */} - {stageContext.code_examples && - stageContext.code_examples.length > 0 && ( -
-
-

- Code Examples -

-
-
-
- {stageContext.code_examples.map((example: any, idx: number) => ( - -
- -
-
- - {example.file} - - - {example.language} - -
-

- {example.description} -

-
- -
- -
-

- {example.description} -

-
-                                {example.snippet}
-                              
-
-
-
-
- ))} -
-
- )} - - {/* Resources Section */} - {stageContext.resources.length > 0 && ( -
-
- {stageContext.resources.map((resource: { label: string; href: string }) => ( - - {resource.label} - - - ))} -
-
- )} -
- )} -
- {chatHistory.length === 0 ? ( -
- No guide activity yet. Ask for a walkthrough to start the - conversation. -
- ) : ( - chatHistory.map((messageItem) => ( - - )) - )} - {isLoading && ( -
-
-
-
-
- )} -
-
-
- -
-
-