diff --git a/.env.example b/.env.example index 13eb0980b34b9c77120a7b697b0b730bd3d91535..a4654d409c9b8594c20a9046cd4f7cc084a4cbc9 100644 --- a/.env.example +++ b/.env.example @@ -1,40 +1,45 @@ -# Database Configuration -DATABASE_URL=postgresql://username:password@host:port/database - -# Supabase Configuration -NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co -SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key -SUPABASE_STORAGE_BUCKET=tender-documents - -# WebAI Model Configuration -# Optional: Override default model name -# WEBAI_MODEL_NAME=webAI-Official/webAI-ColVec1-4b - -# Optional: Configure quantization (default: 8-bit for 4B model) -# USE_8BIT_QUANTIZATION=true -# USE_4BIT_QUANTIZATION=false - -# Optional: Configure FlashAttention-2 for memory optimization (default: true) -# USE_FLASH_ATTENTION_2=true - -# Optional: Configure DPI for PDF conversion (default: 200) -# Higher DPI improves extraction quality but uses more memory -# PDF_DPI=300 - -# Optional: Enable adaptive DPI scaling based on available memory (default: true) -# ADAPTIVE_DPI=true - -# Optional: Configure max tokens (default: 512) -# MAX_NEW_TOKENS=512 - -# Optional: Configure image size (default: 336) -# IMAGE_SIZE=336 - -# Optional: Configure batch size (default: 1) -# BATCH_SIZE=1 - -# Optional: Configure timeout (default: 300) -# TIMEOUT_SECONDS=300 - -# HF Spaces Configuration -PORT=7860 +# Web +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Supabase +NEXT_PUBLIC_SUPABASE_URL=https://weeiosqgiyjeeftvpyjr.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndlZWlvc3FnaXlqZWVmdHZweWpyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzU1NzA4NTQsImV4cCI6MjA5MTE0Njg1NH0.15tz39svACp7QZZJeyVVDzhU-IuXwlW6BG5RZQDF97g +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndlZWlvc3FnaXlqZWVmdHZweWpyIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NTU3MDg1NCwiZXhwIjoyMDkxMTQ2ODU0fQ.rs0jJRVhEOGrdUDXvGm_a5M9Q717w0I56HddiD8m2WE +SUPABASE_STORAGE_BUCKET=TenderDocs +# Local dev identity fallback (must be valid UUIDs) +DEV_ORGANIZATION_ID= +DEV_USER_ID= +DEV_AUTO_BOOTSTRAP=1 +INLINE_PROCESSING_ENABLED=0 + +# Worker +DATABASE_URL= +# Optional fallback IP for DATABASE_URL host resolution issues +DATABASE_HOSTADDR= +WORKER_POLL_INTERVAL_SECONDS=5 +WORKER_RUN_ONCE=0 + +# LLM Configuration +LLM_ENABLED=true +LLM_PRIMARY_MODEL=claude-3-5-sonnet-20241022 +LLM_FALLBACK_MODELS=gpt-4.1-mini,gpt-3.5-turbo +LLM_TIMEOUT_SECONDS=60.0 + +# AI Gateway (LiteLLM compatible) +LITELLM_BASE_URL=http://localhost:4000 +LITELLM_API_KEY= +LITELLM_EXTRACT_MODEL=anthropic/claude-3-5-sonnet +LITELLM_DRAFT_MODEL=openai/gpt-4.1-mini +# Optional comma-separated fallback chain for draft generation retries +LITELLM_DRAFT_FALLBACK_MODELS= +# Retry controls for transient provider failures / rate limits +LITELLM_RETRY_MAX_ATTEMPTS=2 +LITELLM_RETRY_BACKOFF_MS=500 +LITELLM_REQUEST_TIMEOUT_MS=45000 + +# Billing +MPESA_CONSUMER_KEY= +MPESA_CONSUMER_SECRET= +MPESA_PASSKEY= +MPESA_SHORTCODE= +MPESA_CALLBACK_URL= diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..d50beeec0ce03445352cd020c96ed5b011c1fdca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +tender-win-engine/bun.lockb filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c0feb74d87c73b456c8ec4a7f2b115cab80bd53e --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +node_modules +.next +out +dist +build +.turbo +.vercel +.env +.env.local +.env.*.local +*.log +coverage +tmp +temp +apps/web/.next +apps/web/node_modules +packages/schemas/node_modules +.venv +__pycache__ +*.pyc +*.egg-info +.git-credentials + +.vercel +.env*.local diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.pytest_cache/.gitignore b/.pytest_cache/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..08a7f458f1f002823bc794c47ca1996a57e72c86 --- /dev/null +++ b/.pytest_cache/.gitignore @@ -0,0 +1,2 @@ +# Created by pytest automatically. +* diff --git a/.pytest_cache/CACHEDIR.TAG b/.pytest_cache/CACHEDIR.TAG new file mode 100644 index 0000000000000000000000000000000000000000..fce15ad7eaa74e5682b644c84efb75334c112f95 --- /dev/null +++ b/.pytest_cache/CACHEDIR.TAG @@ -0,0 +1,4 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by pytest. +# For information about cache directory tags, see: +# https://bford.info/cachedir/spec.html diff --git a/.pytest_cache/README.md b/.pytest_cache/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c7526af2448672de4537dfed042ed74daadb17bf --- /dev/null +++ b/.pytest_cache/README.md @@ -0,0 +1,8 @@ +# pytest cache directory # + +This directory contains data from the pytest's cache plugin, +which provides the `--lf` and `--ff` options, as well as the `cache` fixture. + +**Do not** commit this to version control. + +See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information. diff --git a/.pytest_cache/v/cache/nodeids b/.pytest_cache/v/cache/nodeids new file mode 100644 index 0000000000000000000000000000000000000000..0637a088a01e8ddab3bf3fa98dbe804cbde1a0dc --- /dev/null +++ b/.pytest_cache/v/cache/nodeids @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..d73591e80fe34c374f7c5be70cd5ea0bdf7267c4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# Agent Context & Rules +This file contains rules, project knowledge, and context for AI agents working on this project. Future agent iterations will read this to maintain context. + +## Discovered Patterns +- (None yet) + +## Gotchas +- (None yet) diff --git a/TenderHub Kenya Build Plan.md b/TenderHub Kenya Build Plan.md new file mode 100644 index 0000000000000000000000000000000000000000..a5c3ad5afe9da50ee0aeb45337f7831b8fc26336 --- /dev/null +++ b/TenderHub Kenya Build Plan.md @@ -0,0 +1,66 @@ +# TenderHub Kenya Build Plan + +## Summary +- Build one product first: a mobile-first Tender-Winning Engine for Kenyan SMEs that starts with an uploaded tender PDF and ends with a compliance matrix, bid/no-bid recommendation, and editable proposal draft. +- Optimize for a solo founder: keep the product surface narrow, avoid scraper-heavy scope, and ship an upload-first workflow that proves people will pay for analysis and drafting. +- Prioritize open-source building blocks where they reduce lock-in or variable cost, but use managed hosting where it materially improves speed and reliability. + +## Product Plan +- Launch wedge: self-serve Kenyan contractors, not full consultant white-label. This keeps onboarding, permissions, and support simple while still solving the highest-value workflow. +- Free experience: sign up, create company profile, upload PDF, and see a preview with procuring entity, deadline, tender category, and top blockers. +- Paid experience: unlock full mandatory-requirements matrix, risk summary, bid/no-bid score, and editable draft sections. +- Output format: generate DOCX first, not PDF. Users will edit proposals before submission, and DOCX is cheaper and more reliable to produce. +- Explicitly defer from v1: large-scale portal scraping, full white-label branding, shared team workspaces, and deep consultant collaboration flows. +- Build the data model with `organizations` and `company_profiles` now so consultant and white-label expansion does not require a painful migration later. + +## Architecture And Tooling +- Frontend: Next.js 15 PWA with App Router, mobile-first dashboard, resumable uploads, background job polling, and offline-friendly caching for weak mobile connections. +- Core data layer: Supabase Cloud for PostgreSQL, Auth, Storage, and pgvector. This is open-core, fast to launch, and good enough until volume justifies more self-hosting. +- Backend split: TypeScript for the web/control plane and Python for document processing. This is the best speed-to-quality tradeoff because PDF/OCR tooling is materially stronger in Python. +- Repo shape: monorepo with `apps/web`, `apps/worker`, and `packages/schemas` for shared request/response and extraction contracts. +- Queue/orchestration: Postgres-backed job system using a `processing_jobs` table, retries, idempotency keys, and `FOR UPDATE SKIP LOCKED`. Do not add Redis or Temporal in v1. +- Storage: original PDFs, OCR-normalized PDFs, extracted artifacts, and generated DOCX files in Supabase Storage with signed access URLs. +- Observability: PostHog for product analytics, Sentry for exceptions, OpenTelemetry-based structured logging for long-running jobs. + +## AI Engine +- Put an internal AI gateway between the app and model vendors. Use LiteLLM so the product talks to one interface and can switch between Anthropic, OpenAI, Gemini, OpenRouter, vLLM, or Ollama later. +- Define task contracts, not provider-specific calls: `extractTenderStructure`, `generateComplianceMatrix`, `scoreBid`, `draftProposalSection`, and `embedChunks`. +- Keep prompts versioned and schema-bound. Every model response must parse into strict typed JSON with evidence spans such as page number and source chunk ID. +- Use deterministic scoring for hard blockers and most of the bid/no-bid result. The LLM should explain and synthesize, not decide mandatory eligibility on its own. +- Primary extraction path: PyMuPDF for text extraction and rendering, selective `pdfplumber` or Camelot for tables, and OCRmyPDF plus Tesseract only when the file lacks a usable text layer. +- Retrieval layer: normalize the tender into sections, chunk by semantic headings, store embeddings in pgvector, and anchor every generated output to retrieved evidence. +- Embeddings: run a local open-source model such as `bge-small-en-v1.5` or `e5-base-v2` through FastEmbed/ONNX in the worker so chunking and retrieval do not consume third-party API quota. +- Model policy: use a strong frontier model for extraction and legal-style reasoning, a cheaper fallback model for retries and lower-value drafting, and reserve local open-source LLMs for later experimentation rather than core extraction quality on day one. +- Rate-limit strategy: cache by file hash, run extraction once per document, reuse structured results for scoring and drafting, generate drafts section-by-section, and let LiteLLM handle retry/fallback for 429s and timeouts. + +## Data Model And Interfaces +- Core tables: `organizations`, `members`, `company_profiles`, `tenders`, `documents`, `processing_jobs`, `extractions`, `compliance_items`, `bid_scores`, `drafts`, `payments`, `usage_events`, `provider_logs`. +- `CompanyProfile` should include legal entity details, AGPO status, KRA/VAT status, registration classes such as NCA, operating counties, past projects, team capacity, and reusable proposal facts. +- `TenderExtraction` should include procuring entity, deadline, mandatory documents, technical scope, evaluation criteria, commercial terms, penalty clauses, ambiguity flags, and evidence spans. +- `ComplianceItem` should include requirement text, severity, pass/fail/unknown state, evidence pointer, and remediation guidance. +- `BidScore` should include weighted factors, hard blockers, explanation, confidence, and explicit reason codes that the UI can render. +- Public APIs: `POST /api/uploads/init`, `POST /api/tenders/:id/process`, `GET /api/tenders/:id/status`, `GET /api/tenders/:id/preview`, `POST /api/tenders/:id/unlock`, `GET /api/tenders/:id/analysis`, `POST /api/tenders/:id/drafts`, and `POST /api/payments/mpesa/callback`. +- All schemas should be shared between web and worker so model swaps do not change the product contract. + +## Delivery Plan +- Week 1: set up monorepo, Supabase project, auth, org/company-profile schema, storage buckets, upload flow, preview UI shell, and job-state plumbing. +- Week 2: build the extraction pipeline, OCR fallback, strict JSON parsing, evidence-linked preview, local embeddings, and pgvector retrieval. +- Week 3: implement blocker detection, bid/no-bid scoring, compliance matrix UI, draft generation, usage metering, and paywall boundaries. +- Week 4: integrate M-Pesa STK Push and webhook reconciliation, harden retries and provider fallback, add analytics/monitoring, and run a manual-review-assisted beta. + +## Test Plan +- Digital PDF path: upload, extract, preview, unlock, analyze, and download DOCX successfully. +- Scanned PDF path: OCR fallback triggers automatically and still returns deadline, mandatory docs, and cited evidence. +- Hard-blocker path: missing bid bond, AGPO mismatch, or invalid registration produces deterministic fail states even if the LLM output is uncertain. +- Rate-limit path: primary model 429/timeout retries cleanly and falls back without duplicate jobs or duplicate charges. +- Idempotency path: repeated upload, process, unlock, or webhook calls do not create duplicate tenders, payments, or drafts. +- Security path: one organization cannot read another organization’s files, drafts, or analysis records. +- Quality path: generated text always cites extracted evidence and never invents unsupported facts. +- Performance targets: preview in under 60 seconds for normal digital PDFs, full analysis in 2 to 5 minutes for typical 30 to 80 page tenders, OCR-heavy cases in under 10 minutes with visible progress and manual-review fallback. + +## Assumptions +- Kenya is the only launch market and most tender documents are English-language PDFs. +- v1 is upload-first. Tender scraping is intentionally out of the critical path because it adds fragility without proving willingness to pay. +- Supabase Cloud is acceptable even with an open-source bias because it dramatically shortens time to market while preserving open-source primitives underneath. +- White-label is a phase-2 revenue expansion, not a launch requirement. +- The first business milestone is paid validation, not perfect autonomy. During beta, low-confidence jobs can route to a manual review queue. diff --git a/WORKERS_ARCHITECTURE.md b/WORKERS_ARCHITECTURE.md new file mode 100644 index 0000000000000000000000000000000000000000..a29c0125f4dfcfad1707147da66baab566edc544 --- /dev/null +++ b/WORKERS_ARCHITECTURE.md @@ -0,0 +1,438 @@ +# TenderHub Workers Architecture + +This document describes the technical architecture and implementation details of both workers in the TenderHub platform: the primary worker and the WebAI verification worker. + +## Overview + +TenderHub employs a dual-worker architecture for tender document processing: + +1. **Primary Worker**: Text-based extraction and analysis using traditional OCR + LLM pipeline +2. **WebAI Verification Worker**: Multimodal vision-language model for cross-validation + +This approach provides redundancy, quality assurance, and competitive advantage through multiple AI analysis perspectives. + +--- + +## Primary Worker Architecture + +### Purpose +The primary worker handles the main document processing pipeline for tender analysis and proposal generation. + +### Technology Stack +- **Language**: Python 3.11 +- **Database**: PostgreSQL with psycopg +- **Storage**: Supabase for document storage +- **OCR**: Stirling-PDF (optional, for image-only PDFs) +- **Text Extraction**: Kreuzberg with marker LLM mode +- **AI Analysis**: Google Gemini with caching +- **Deployment**: Docker/Local execution + +### Processing Pipeline + +#### 1. Document Ingestion +```python +# Downloads documents from Supabase storage +document_bytes = download_storage_object(config, storage_path) +mime_type = infer_mime_type(storage_path) +``` + +#### 2. PDF Signal Analysis +```python +# Analyzes PDF characteristics +signal = analyze_pdf_signal(pdf_bytes) +page_count = signal["page_count"] +is_image_only = signal["is_image_only"] +avg_chars_per_page = signal["average_chars_per_page"] +``` + +#### 3. OCR Processing (if needed) +For image-only PDFs: +```python +stirling = StirlingClient(config) +ocr_bytes = stirling.ocr_pdf(source_bytes) +compressed = stirling.compress_pdf(ocr_bytes) +``` + +#### 4. Text Extraction +```python +# Batch processing with Kreuzberg +batches = split_pdf_batches(pdf_bytes, config.kreuzberg_batch_pages) +for batch in batches: + markdown, tables, elements = extract_with_kreuzberg( + batch["pdf_bytes"], mime_type, config.marker_llm_mode + ) +``` + +#### 5. AI Analysis with Gemini +```python +# Creates cache for efficient processing +cache = gemini.create_cache( + display_name=f"tender-{tender_id[:8]}-{doc_hash[:8]}", + content=markdown_text, + ttl_hours=config.gemini_cache_ttl_hours, +) + +# Generates structured analysis +analysis = gemini.generate_json_with_cache(cache_name=cache_name, prompt=prompt) +``` + +#### 6. Database Storage +```python +# Stores structured results +upsert_analysis(conn, tender_id, organization_id, analysis) +``` + +### Key Features + +#### Memory Optimization +- **4-bit/8-bit Quantization**: Reduces model memory footprint +- **Batch Processing**: Processes documents in configurable batch sizes +- **GC Flush**: Optional garbage collection between batches +- **Marker LLM Mode**: Enhanced text extraction quality + +#### Caching Strategy +- **Gemini Cache**: 72-hour TTL for document content +- **Cache Validation**: Hash-based cache invalidation +- **Reuse Logic**: Automatic cache reuse for identical documents + +#### Error Handling +- **Retry Logic**: Configurable retry delays and max attempts +- **Fallback Mechanisms**: Graceful degradation on component failures +- **Comprehensive Logging**: Structured JSON logging for monitoring + +### Job Types + +#### ANALYZE Jobs +- Extracts and analyzes tender documents +- Generates compliance items and bid scores +- Produces structured JSON output + +#### DRAFT Jobs +- Generates proposal sections (Executive Summary, Technical Approach, etc.) +- Supports different tones (Formal, Persuasive, Concise) +- Uses cached analysis for consistency + +--- + +## WebAI Verification Worker Architecture + +### Purpose +The WebAI verification worker provides a secondary analysis pipeline using multimodal AI to cross-validate primary worker results. + +### Technology Stack +- **Language**: Python 3.11 +- **Framework**: Gradio + HF Spaces +- **Model**: webAI-ColVec1-4b (multimodal vision-language) +- **GPU**: ZeroGPU for on-demand acceleration +- **Memory**: FlashAttention-2 + 8-bit quantization +- **Deployment**: Hugging Face Spaces + +### Processing Pipeline + +#### 1. Document Ingestion +```python +# Same storage access as primary worker +document_bytes = download_document(context["storage_path"]) +mime_type = infer_mime_type(context["storage_path"]) +``` + +#### 2. High DPI Image Conversion +```python +# Adaptive DPI based on available memory +optimal_dpi = get_optimal_dpi(config) +images = convert_from_bytes( + document_bytes, + dpi=optimal_dpi, + first_page=True, + fmt='JPEG' +) +``` + +#### 3. Vision-Language Processing +```python +# Direct multimodal understanding +@spaces.GPU +def process_with_webai(image: Image.Image, prompt: str): + inputs = processor(images=image, text=prompt, return_tensors="pt").to("cuda") + outputs = model.generate(**inputs, max_new_tokens=512) + response = processor.tokenizer.decode(outputs[0], skip_special_tokens=True) + return response +``` + +#### 4. Structured Analysis +```python +# Parses JSON response from multimodal model +webai_analysis = parse_webai_response(response_text) +``` + +#### 5. Cross-Validation +```python +# Compares with primary worker results +comparison = compare_analyses(primary_analysis, webai_analysis) +agreement_score = comparison["agreement_score"] +``` + +#### 6. Aggressive Memory Cleanup +```python +# Prevents ghost memory from vision tensors +def aggressive_memory_cleanup(): + torch.cuda.empty_cache() + gc.collect() # Multiple generations + # Clear PIL caches and monitor effectiveness +``` + +### Key Features + +#### Memory Optimization +- **4B Model**: Smaller footprint vs 9B models +- **8-bit Quantization**: Balance of quality and memory efficiency +- **FlashAttention-2**: 40% reduction in attention memory +- **Aggressive Cleanup**: Prevents 4GB+ ghost memory accumulation + +#### Adaptive DPI Scaling +```python +# Memory-aware DPI adjustment +if available_memory_gb >= 12: optimal_dpi = 300 +elif available_memory_gb >= 8: optimal_dpi = 250 +elif available_memory_gb >= 4: optimal_dpi = 200 +else: optimal_dpi = 150 +``` + +#### ZeroGPU Integration +- **On-demand GPU**: Dynamic allocation for processing tasks +- **Cost Efficiency**: Free tier with automatic scaling +- **Fallback**: CPU processing when GPU unavailable + +### Comparison Engine + +The verification worker implements sophisticated comparison logic: + +#### Agreement Scoring +```python +# Weighted agreement calculation +agreement_factors = [ + bid_decision_agreement * 0.4, # Bid decision (40%) + confidence_similarity * 0.3, # Confidence scores (30%) + category_similarity * 0.2, # Tender categories (20%) + deadline_agreement * 0.1 # Deadlines (10%) +] +agreement_score = sum(agreement_factors) +``` + +#### Discrepancy Detection +- **High Severity**: Bid decision differences +- **Medium Severity**: Confidence score gaps > 0.3 +- **Low Severity**: Category or deadline mismatches + +--- + +## Database Integration + +### Shared Tables +Both workers interact with these core tables: + +#### tenders +```sql +CREATE TABLE tenders ( + id UUID PRIMARY KEY, + organization_id UUID, + title TEXT, + status TEXT, -- PROCESSING, ANALYSIS_READY, FAILED + storage_path TEXT, + verification_status TEXT, -- PENDING, PROCESSING, COMPLETED + verification_score FLOAT +); +``` + +#### processing_jobs +```sql +CREATE TABLE processing_jobs ( + id UUID PRIMARY KEY, + tender_id UUID, + job_type TEXT, -- ANALYZE, DRAFT, VERIFY + status TEXT, -- QUEUED, RUNNING, SUCCEEDED, FAILED + payload JSONB, + attempt_count INTEGER, + max_attempts INTEGER, + lock_owner TEXT, + available_at TIMESTAMP, + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +#### extractions (Primary Worker) +```sql +CREATE TABLE extractions ( + tender_id UUID PRIMARY KEY, + structured_output JSONB, + summary TEXT, + updated_at TIMESTAMP +); +``` + +#### webai_verifications (Verification Worker) +```sql +CREATE TABLE webai_verifications ( + id UUID PRIMARY KEY, + tender_id UUID, + analysis JSONB, + comparison JSONB, + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### Job Queue System + +#### Job Claiming +```python +# Atomic job claiming with row-level locking +cur.execute(""" + SELECT * FROM processing_jobs + WHERE status = 'QUEUED' AND available_at <= now() + ORDER BY created_at ASC + FOR UPDATE SKIP LOCKED + LIMIT 1 +""") +``` + +#### Status Management +- **QUEUED**: Ready for processing +- **RUNNING**: Being processed by a worker +- **SUCCEEDED**: Completed successfully +- **FAILED**: Failed after max retries + +--- + +## Performance Characteristics + +### Primary Worker +- **Throughput**: ~15-25 documents/hour (CPU optimized) +- **Memory Usage**: ~2-4GB per document +- **Accuracy**: High for text-heavy documents +- **Strengths**: Structured data extraction, compliance analysis + +### WebAI Verification Worker +- **Throughput**: ~20-30 documents/hour (GPU accelerated) +- **Memory Usage**: ~4-8GB per document (with cleanup) +- **Accuracy**: Superior for visually complex documents +- **Strengths**: Multimodal understanding, cross-validation + +### Memory Optimization Strategies + +#### Primary Worker +1. **Quantization**: 4-bit/8-bit model loading +2. **Batching**: Configurable batch sizes (10-20 pages) +3. **GC Management**: Optional garbage collection +4. **Caching**: Gemini content caching + +#### WebAI Worker +1. **Model Selection**: 4B vs 9B model choice +2. **FlashAttention-2**: Attention mechanism optimization +3. **Aggressive Cleanup**: Prevent ghost memory +4. **Adaptive DPI**: Memory-aware resolution scaling + +--- + +## Deployment Architecture + +### Primary Worker +- **Environment**: Docker containers or local execution +- **Scaling**: Horizontal scaling via multiple instances +- **Monitoring**: Structured logging + health checks +- **Configuration**: Environment-based configuration + +### WebAI Verification Worker +- **Platform**: Hugging Face Spaces +- **GPU**: ZeroGPU on-demand allocation +- **Interface**: Gradio web interface + API +- **Cost**: Free tier with dynamic scaling + +--- + +## Cross-Validation Benefits + +### Quality Assurance +1. **Dual Perspectives**: Text-only vs multimodal analysis +2. **Discrepancy Detection**: Automatic identification of differences +3. **Confidence Scoring**: Agreement metrics for reliability assessment +4. **Human Review**: Flagged items for manual verification + +### Competitive Advantages +1. **Accuracy**: Multiple AI approaches reduce error rates +2. **Reliability**: Cross-validation prevents single-model failures +3. **Insights**: Different models catch different issues +4. **Trust**: Transparent verification builds user confidence + +### Use Cases +- **High-Value Tenders**: Critical bids requiring maximum accuracy +- **Complex Documents**: Visually dense or poorly scanned PDFs +- **Regulatory Compliance**: Industries requiring verification +- **Quality Control**: Organizations with zero-tolerance for errors + +--- + +## Monitoring and Observability + +### Logging Strategy +Both workers use structured JSON logging: +```json +{ + "timestamp": "2024-01-01T12:00:00Z", + "event": "job.completed", + "service": "primary-worker", + "tender_id": "uuid", + "job_type": "ANALYZE", + "processing_time_ms": 15000, + "memory_peak_gb": 3.2 +} +``` + +### Key Metrics +- **Throughput**: Documents processed per hour +- **Latency**: Processing time per document +- **Memory**: Peak memory usage +- **Success Rate**: Job completion percentage +- **Agreement Score**: Cross-validation accuracy + +### Health Checks +- **Database Connectivity**: Connection pool status +- **Storage Access**: Supabase connectivity +- **Model Availability**: AI service health +- **Memory Usage**: System resource monitoring + +--- + +## Future Enhancements + +### Primary Worker +- **Advanced OCR**: Integration with additional OCR services +- **Multi-language**: Support for non-English documents +- **Parallel Processing**: Multi-threaded document processing +- **Enhanced Caching**: Redis-based caching layer + +### WebAI Verification Worker +- **Model Upgrades**: Latest multimodal models +- **Batch Processing**: Multiple document processing +- **Custom Training**: Fine-tuned models for tender documents +- **Real-time Processing**: Streaming document analysis + +### Integration Improvements +- **Unified API**: Single endpoint for both workers +- **Load Balancing**: Intelligent job routing +- **Failover**: Automatic worker switching +- **Metrics Dashboard**: Real-time monitoring interface + +--- + +## Conclusion + +The dual-worker architecture provides TenderHub with a robust, scalable, and accurate document processing system. By combining traditional text-based extraction with modern multimodal AI, the platform achieves: + +- **Higher Accuracy**: Cross-validation reduces errors +- **Better Reliability**: Multiple processing paths prevent failures +- **Competitive Advantage**: Superior analysis quality +- **Future-Proof**: Extensible architecture for new AI models + +This architecture positions TenderHub as a leader in AI-powered tender analysis, providing customers with the confidence and accuracy needed for critical business decisions. diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..42f29adb9ca69af331ebc6a48492b0671fedfbec --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,2 @@ +.vercel +.env*.local diff --git a/apps/web/e2e/basic.spec.ts b/apps/web/e2e/basic.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbc2326225f2b85a488772c0fd3406e9190093c4 --- /dev/null +++ b/apps/web/e2e/basic.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; + +test.describe('TenderHub Core Flows', () => { + test('landing page loads and has expected elements', async ({ page }) => { + await page.goto('/'); + + // Check for standard landing page elements. + // If the landing page changes, update these assertions. + await expect(page).toHaveTitle(/TenderHub/i); + + // Check if the dashboard console is still reachable (for dev check) + await page.goto('/console'); + await expect(page.locator('body')).toBeVisible(); + }); +}); diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..57c5779a56913e734b7c9096cd60496b6b90cee9 --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,5 @@ +import nextVitals from "eslint-config-next/core-web-vitals"; + +const config = [...nextVitals]; + +export default config; diff --git a/apps/web/jest.config.cjs b/apps/web/jest.config.cjs new file mode 100644 index 0000000000000000000000000000000000000000..3c2d4d8ad3379bd9e4e0fdeac9fa8cfeb95e2a77 --- /dev/null +++ b/apps/web/jest.config.cjs @@ -0,0 +1,28 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/jest.setup.js'], + testPathIgnorePatterns: [ + '/node_modules/', + '/.next/', + ], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '^@tenderhub/schemas$': '/../../packages/schemas/src/index.ts', + }, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: { + jsx: 'react-jsx', + }, + }, + ], + }, + testMatch: [ + '**/__tests__/**/*.test.ts', + '**/__tests__/**/*.test.tsx', + ], +}; diff --git a/apps/web/jest.setup.js b/apps/web/jest.setup.js new file mode 100644 index 0000000000000000000000000000000000000000..3460a43c5c727851aa136c1dfa1c2efbedca63ad --- /dev/null +++ b/apps/web/jest.setup.js @@ -0,0 +1,37 @@ +/** + * Jest Setup + * Configure test environment + */ +require('@testing-library/jest-dom') + +// Mock Next.js headers +jest.mock('next/headers', () => ({ + cookies: jest.fn(() => ({ + get: jest.fn(), + set: jest.fn(), + })), + headers: jest.fn(() => new Headers()), +})) + +// Mock environment variables +process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321' +process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key' +process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-role-key' +process.env.LITELLM_BASE_URL = 'http://localhost:4000' +process.env.LITELLM_API_KEY = 'test-litellm-key' + +// Global test utilities +global.mockFetch = (response) => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(response), + text: () => Promise.resolve(JSON.stringify(response)), + }) + ) +} + +// Clean up after each test +afterEach(() => { + jest.clearAllMocks() +}) diff --git a/apps/web/netlify.toml b/apps/web/netlify.toml new file mode 100644 index 0000000000000000000000000000000000000000..771d8228dbc885326730fb09a952eaa18289e71a --- /dev/null +++ b/apps/web/netlify.toml @@ -0,0 +1,6 @@ +[build] + command = "npm run build" + publish = ".next" + +[[plugins]] + package = "@netlify/plugin-nextjs" diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4b7818fbb2c2c34c24feb1b627ee824507c5600 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..86a0158a976ca1b5d578e330899cf32d892d5410 --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from "next"; +import withPWAInit from "@ducanh2912/next-pwa"; + +const withPWA = withPWAInit({ + dest: "public", + disable: process.env.NODE_ENV === "development", + register: true, +}); + +const nextConfig: NextConfig = { + transpilePackages: ["@tenderhub/schemas"] +}; + +export default withPWA(nextConfig); diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..0d872f0665e99adfa3231ebb6086cff1eb54ccd9 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,92 @@ +{ + "name": "@tenderhub/web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "next dev --webpack", + "build": "next build --webpack", + "start": "next start", + "lint": "eslint . --max-warnings=0", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "dependencies": { + "@ducanh2912/next-pwa": "^10.2.9", + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.8", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@sentry/nextjs": "^10.48.0", + "@supabase/ssr": "^0.10.2", + "@supabase/supabase-js": "^2.49.4", + "@tailwindcss/postcss": "^4.2.2", + "@tanstack/react-query": "^5.99.0", + "@tenderhub/schemas": "0.1.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "docx": "^9.6.1", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^1.8.0", + "next": "^16.2.2", + "next-themes": "^0.4.6", + "posthog-js": "^1.367.0", + "react": "^19.2.0", + "react-day-picker": "^9.14.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.72.1", + "react-resizable-panels": "^4.10.0", + "recharts": "^3.8.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", + "zod": "^3.24.4" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/jest": "^30.0.0", + "@types/node": "^22.19.17", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "autoprefixer": "^10.5.0", + "eslint": "^9.25.0", + "eslint-config-next": "^16.2.2", + "jest": "^30.3.0", + "jest-environment-jsdom": "^30.3.0", + "node-mocks-http": "^1.17.2", + "postcss": "^8.4.31", + "tailwindcss": "^4.2.2", + "ts-jest": "^29.4.9", + "typescript": "^5.8.3" + } +} diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..02172c2db9d2a4608acc2e30bbceaa581a2689dc --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..f69c5d4119626ef8d9bcf980b8635b3e75315b7b --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/public/icons/icon-192.png b/apps/web/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..c126ad4f29968cd9a4a602ba15509ed6504fd12e Binary files /dev/null and b/apps/web/public/icons/icon-192.png differ diff --git a/apps/web/public/icons/icon-512.png b/apps/web/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..c126ad4f29968cd9a4a602ba15509ed6504fd12e Binary files /dev/null and b/apps/web/public/icons/icon-512.png differ diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..6be388b4eec6f88d59b6ce232b15a44e11bc65cf --- /dev/null +++ b/apps/web/public/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "TenderHub Kenya", + "short_name": "TenderHub", + "description": "AI-powered tender execution platform for Kenyan SMEs and consultants", + "start_url": "/dashboard", + "display": "standalone", + "background_color": "#f8fafb", + "theme_color": "#059669", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["business", "productivity"], + "lang": "en", + "dir": "ltr" +} diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000000000000000000000000000000000000..6a8da061a30853a59b4a94c4c912870b00163e68 --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1 @@ +if(!self.define){let e,s={};const t=(t,n)=>(t=new URL(t+".js",n).href,s[t]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=t,e.onload=s,document.head.appendChild(e)}else e=t,importScripts(t),s()}).then(()=>{let e=s[t];if(!e)throw new Error(`Module ${t} didn’t register its module`);return e}));self.define=(n,a)=>{const i=e||("document"in self?document.currentScript.src:"")||location.href;if(s[i])return;let c={};const d=e=>t(e,i),r={module:{uri:i},exports:c,require:d};s[i]=Promise.all(n.map(e=>r[e]||d(e))).then(e=>(a(...e),c))}}define(["./workbox-b52a85cb"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/L4hQgc15WqfIHZwcLSX3G/_buildManifest.js",revision:"1f8cdb6d84803b62bc3866b6c120a3d8"},{url:"/_next/static/L4hQgc15WqfIHZwcLSX3G/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/1446.2a26417c759e67b8.js",revision:"2a26417c759e67b8"},{url:"/_next/static/chunks/445-80c5b3bdef721e07.js",revision:"80c5b3bdef721e07"},{url:"/_next/static/chunks/6387.9a7bf416cbcd193d.js",revision:"9a7bf416cbcd193d"},{url:"/_next/static/chunks/7453-5362801bf71a3662.js",revision:"5362801bf71a3662"},{url:"/_next/static/chunks/75504863-ea0a4eadd7cf0a00.js",revision:"ea0a4eadd7cf0a00"},{url:"/_next/static/chunks/87c73c54-f46fec743414da25.js",revision:"f46fec743414da25"},{url:"/_next/static/chunks/9348.63f2aee1edfebcb6.js",revision:"63f2aee1edfebcb6"},{url:"/_next/static/chunks/app/_global-error/page-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/_not-found/page-9606f522f24bf594.js",revision:"9606f522f24bf594"},{url:"/_next/static/chunks/app/admin/layout-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/admin/page-7814eb33274edf87.js",revision:"7814eb33274edf87"},{url:"/_next/static/chunks/app/api/admin/activity/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/admin/check/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/admin/jobs/retry/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/admin/jobs/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/admin/stats/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/admin/users/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/admin/users/toggle-superuser/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/company-profile/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/payments/mpesa/callback/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/payments/mpesa/status/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/payments/mpesa/stk/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/analysis/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/drafts/batch/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/drafts/export/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/drafts/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/events/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/jobs/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/outcome/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/preview/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/process/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/review-action/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/status/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/unlock/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/review-queue/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/uploads/init/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/auth/callback/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/console/page-0fbc7f6f1669ba32.js",revision:"0fbc7f6f1669ba32"},{url:"/_next/static/chunks/app/dashboard/layout-1b9b9baa668911be.js",revision:"1b9b9baa668911be"},{url:"/_next/static/chunks/app/dashboard/page-784500552c688434.js",revision:"784500552c688434"},{url:"/_next/static/chunks/app/dashboard/profile/page-416e752a44c50a5d.js",revision:"416e752a44c50a5d"},{url:"/_next/static/chunks/app/dashboard/tenders/%5Bid%5D/page-9ab4af1e3c7459b1.js",revision:"9ab4af1e3c7459b1"},{url:"/_next/static/chunks/app/dashboard/upload/page-b6a0282b19a09a9b.js",revision:"b6a0282b19a09a9b"},{url:"/_next/static/chunks/app/layout-b8eea8c8fef7c339.js",revision:"b8eea8c8fef7c339"},{url:"/_next/static/chunks/app/login/page-a70d1683c9c64722.js",revision:"a70d1683c9c64722"},{url:"/_next/static/chunks/app/page-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/signup/page-3a7db5ae5b62c86f.js",revision:"3a7db5ae5b62c86f"},{url:"/_next/static/chunks/framework-af3c63e39f557570.js",revision:"af3c63e39f557570"},{url:"/_next/static/chunks/main-app-6f1d7875af57e840.js",revision:"6f1d7875af57e840"},{url:"/_next/static/chunks/main-d6e97713b784943a.js",revision:"d6e97713b784943a"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-99b983b19fb84353.js",revision:"99b983b19fb84353"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-7854310dc8f9cd55.js",revision:"7854310dc8f9cd55"},{url:"/_next/static/css/0ac91d5f0f908c34.css",revision:"0ac91d5f0f908c34"},{url:"/icons/icon-192.png",revision:"2a637d3d825673c0e3462fa4ed9a1c5c"},{url:"/icons/icon-512.png",revision:"2a637d3d825673c0e3462fa4ed9a1c5c"},{url:"/manifest.json",revision:"5c60071d592bb2d645b2268625f4b573"}],{ignoreURLParametersMatching:[/^utm_/,/^fbclid$/]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({response:e})=>e&&"opaqueredirect"===e.type?new Response(e.body,{status:200,statusText:"OK",headers:e.headers}):e}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\/_next\/static.+\.js$/i,new e.CacheFirst({cacheName:"next-static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4|webm)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:48,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({sameOrigin:e,url:{pathname:s}})=>!(!e||s.startsWith("/api/auth/callback")||!s.startsWith("/api/")),new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({request:e,url:{pathname:s},sameOrigin:t})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&t&&!s.startsWith("/api/"),new e.NetworkFirst({cacheName:"pages-rsc-prefetch",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({request:e,url:{pathname:s},sameOrigin:t})=>"1"===e.headers.get("RSC")&&t&&!s.startsWith("/api/"),new e.NetworkFirst({cacheName:"pages-rsc",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:{pathname:e},sameOrigin:s})=>s&&!e.startsWith("/api/"),new e.NetworkFirst({cacheName:"pages",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({sameOrigin:e})=>!e,new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); diff --git a/apps/web/public/workbox-b52a85cb.js b/apps/web/public/workbox-b52a85cb.js new file mode 100644 index 0000000000000000000000000000000000000000..8d64e054824f537c59adf3c5e1bd9671ad00aee3 --- /dev/null +++ b/apps/web/public/workbox-b52a85cb.js @@ -0,0 +1 @@ +define(["exports"],function(t){"use strict";try{self["workbox:core:7.0.0"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:7.0.0"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class r{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class i extends r{constructor(t,e,s){super(({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)},e,s)}}class a{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)})}addCacheListener(){self.addEventListener("message",t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map(e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})}));t.waitUntil(s),t.ports&&t.ports[0]&&s.then(()=>t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:i}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let a=i&&i.handler;const o=t.method;if(!a&&this.i.has(o)&&(a=this.i.get(o)),!a)return;let c;try{c=a.handle({url:s,request:t,event:e,params:r})}catch(t){c=Promise.reject(t)}const h=i&&i.catchHandler;return c instanceof Promise&&(this.o||h)&&(c=c.catch(async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:r})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n})),c}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const r=this.t.get(s.method)||[];for(const i of r){let r;const a=i.match({url:t,sameOrigin:e,request:s,event:n});if(a)return r=a,(Array.isArray(r)&&0===r.length||a.constructor===Object&&0===Object.keys(a).length||"boolean"==typeof a)&&(r=void 0),{route:i,params:r}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let o;const c=()=>(o||(o=new a,o.addFetchListener(),o.addCacheListener()),o);function h(t,e,n){let a;if("string"==typeof t){const s=new URL(t,location.href);a=new r(({url:t})=>t.href===s.href,e,n)}else if(t instanceof RegExp)a=new i(t,e,n);else if("function"==typeof t)a=new r(t,e,n);else{if(!(t instanceof r))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});a=t}return c().registerRoute(a),a}try{self["workbox:strategies:7.0.0"]&&_()}catch(t){}const u={cacheWillUpdate:async({response:t})=>200===t.status||0===t.status?t:null},l={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},f=t=>[l.prefix,t,l.suffix].filter(t=>t&&t.length>0).join("-"),w=t=>t||f(l.precache),d=t=>t||f(l.runtime);function p(t,e){const s=new URL(t);for(const t of e)s.searchParams.delete(t);return s.href}class y{constructor(){this.promise=new Promise((t,e)=>{this.resolve=t,this.reject=e})}}const g=new Set;function m(t){return"string"==typeof t?new Request(t):t}class v{constructor(t,e){this.h={},Object.assign(this,e),this.event=e.event,this.u=t,this.l=new y,this.p=[],this.m=[...t.plugins],this.v=new Map;for(const t of this.m)this.v.set(t,{});this.event.waitUntil(this.l.promise)}async fetch(t){const{event:e}=this;let n=m(t);if("navigate"===n.mode&&e instanceof FetchEvent&&e.preloadResponse){const t=await e.preloadResponse;if(t)return t}const r=this.hasCallback("fetchDidFail")?n.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))n=await t({request:n.clone(),event:e})}catch(t){if(t instanceof Error)throw new s("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const i=n.clone();try{let t;t=await fetch(n,"navigate"===n.mode?void 0:this.u.fetchOptions);for(const s of this.iterateCallbacks("fetchDidSucceed"))t=await s({event:e,request:i,response:t});return t}catch(t){throw r&&await this.runCallbacks("fetchDidFail",{error:t,event:e,originalRequest:r.clone(),request:i.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=m(t);let s;const{cacheName:n,matchOptions:r}=this.u,i=await this.getCacheKey(e,"read"),a=Object.assign(Object.assign({},r),{cacheName:n});s=await caches.match(i,a);for(const t of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await t({cacheName:n,matchOptions:r,cachedResponse:s,request:i,event:this.event})||void 0;return s}async cachePut(t,e){const n=m(t);var r;await(r=0,new Promise(t=>setTimeout(t,r)));const i=await this.getCacheKey(n,"write");if(!e)throw new s("cache-put-with-no-response",{url:(a=i.url,new URL(String(a),location.href).href.replace(new RegExp(`^${location.origin}`),""))});var a;const o=await this.R(e);if(!o)return!1;const{cacheName:c,matchOptions:h}=this.u,u=await self.caches.open(c),l=this.hasCallback("cacheDidUpdate"),f=l?await async function(t,e,s,n){const r=p(e.url,s);if(e.url===r)return t.match(e,n);const i=Object.assign(Object.assign({},n),{ignoreSearch:!0}),a=await t.keys(e,i);for(const e of a)if(r===p(e.url,s))return t.match(e,n)}(u,i.clone(),["__WB_REVISION__"],h):null;try{await u.put(i,l?o.clone():o)}catch(t){if(t instanceof Error)throw"QuotaExceededError"===t.name&&await async function(){for(const t of g)await t()}(),t}for(const t of this.iterateCallbacks("cacheDidUpdate"))await t({cacheName:c,oldResponse:f,newResponse:o.clone(),request:i,event:this.event});return!0}async getCacheKey(t,e){const s=`${t.url} | ${e}`;if(!this.h[s]){let n=t;for(const t of this.iterateCallbacks("cacheKeyWillBeUsed"))n=m(await t({mode:e,request:n,event:this.event,params:this.params}));this.h[s]=n}return this.h[s]}hasCallback(t){for(const e of this.u.plugins)if(t in e)return!0;return!1}async runCallbacks(t,e){for(const s of this.iterateCallbacks(t))await s(e)}*iterateCallbacks(t){for(const e of this.u.plugins)if("function"==typeof e[t]){const s=this.v.get(e),n=n=>{const r=Object.assign(Object.assign({},n),{state:s});return e[t](r)};yield n}}waitUntil(t){return this.p.push(t),t}async doneWaiting(){let t;for(;t=this.p.shift();)await t}destroy(){this.l.resolve(null)}async R(t){let e=t,s=!1;for(const t of this.iterateCallbacks("cacheWillUpdate"))if(e=await t({request:this.request,response:e,event:this.event})||void 0,s=!0,!e)break;return s||e&&200!==e.status&&(e=void 0),e}}class R{constructor(t={}){this.cacheName=d(t.cacheName),this.plugins=t.plugins||[],this.fetchOptions=t.fetchOptions,this.matchOptions=t.matchOptions}handle(t){const[e]=this.handleAll(t);return e}handleAll(t){t instanceof FetchEvent&&(t={event:t,request:t.request});const e=t.event,s="string"==typeof t.request?new Request(t.request):t.request,n="params"in t?t.params:void 0,r=new v(this,{event:e,request:s,params:n}),i=this.q(r,s,e);return[i,this.D(i,r,s,e)]}async q(t,e,n){let r;await t.runCallbacks("handlerWillStart",{event:n,request:e});try{if(r=await this.U(e,t),!r||"error"===r.type)throw new s("no-response",{url:e.url})}catch(s){if(s instanceof Error)for(const i of t.iterateCallbacks("handlerDidError"))if(r=await i({error:s,event:n,request:e}),r)break;if(!r)throw s}for(const s of t.iterateCallbacks("handlerWillRespond"))r=await s({event:n,request:e,response:r});return r}async D(t,e,s,n){let r,i;try{r=await t}catch(i){}try{await e.runCallbacks("handlerDidRespond",{event:n,request:s,response:r}),await e.doneWaiting()}catch(t){t instanceof Error&&(i=t)}if(await e.runCallbacks("handlerDidComplete",{event:n,request:s,response:r,error:i}),e.destroy(),i)throw i}}function b(t){t.then(()=>{})}function q(){return q=Object.assign?Object.assign.bind():function(t){for(var e=1;e(t[e]=s,!0),has:(t,e)=>t instanceof IDBTransaction&&("done"===e||"store"===e)||e in t};function O(t){return t!==IDBDatabase.prototype.transaction||"objectStoreNames"in IDBTransaction.prototype?(U||(U=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(t)?function(...e){return t.apply(B(this),e),k(x.get(this))}:function(...e){return k(t.apply(B(this),e))}:function(e,...s){const n=t.call(B(this),e,...s);return I.set(n,e.sort?e.sort():[e]),k(n)}}function T(t){return"function"==typeof t?O(t):(t instanceof IDBTransaction&&function(t){if(L.has(t))return;const e=new Promise((e,s)=>{const n=()=>{t.removeEventListener("complete",r),t.removeEventListener("error",i),t.removeEventListener("abort",i)},r=()=>{e(),n()},i=()=>{s(t.error||new DOMException("AbortError","AbortError")),n()};t.addEventListener("complete",r),t.addEventListener("error",i),t.addEventListener("abort",i)});L.set(t,e)}(t),e=t,(D||(D=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])).some(t=>e instanceof t)?new Proxy(t,N):t);var e}function k(t){if(t instanceof IDBRequest)return function(t){const e=new Promise((e,s)=>{const n=()=>{t.removeEventListener("success",r),t.removeEventListener("error",i)},r=()=>{e(k(t.result)),n()},i=()=>{s(t.error),n()};t.addEventListener("success",r),t.addEventListener("error",i)});return e.then(e=>{e instanceof IDBCursor&&x.set(e,t)}).catch(()=>{}),E.set(e,t),e}(t);if(C.has(t))return C.get(t);const e=T(t);return e!==t&&(C.set(t,e),E.set(e,t)),e}const B=t=>E.get(t);const P=["get","getKey","getAll","getAllKeys","count"],M=["put","add","delete","clear"],W=new Map;function j(t,e){if(!(t instanceof IDBDatabase)||e in t||"string"!=typeof e)return;if(W.get(e))return W.get(e);const s=e.replace(/FromIndex$/,""),n=e!==s,r=M.includes(s);if(!(s in(n?IDBIndex:IDBObjectStore).prototype)||!r&&!P.includes(s))return;const i=async function(t,...e){const i=this.transaction(t,r?"readwrite":"readonly");let a=i.store;return n&&(a=a.index(e.shift())),(await Promise.all([a[s](...e),r&&i.done]))[0]};return W.set(e,i),i}N=(t=>q({},t,{get:(e,s,n)=>j(e,s)||t.get(e,s,n),has:(e,s)=>!!j(e,s)||t.has(e,s)}))(N);try{self["workbox:expiration:7.0.0"]&&_()}catch(t){}const S="cache-entries",K=t=>{const e=new URL(t,location.href);return e.hash="",e.href};class A{constructor(t){this._=null,this.L=t}I(t){const e=t.createObjectStore(S,{keyPath:"id"});e.createIndex("cacheName","cacheName",{unique:!1}),e.createIndex("timestamp","timestamp",{unique:!1})}C(t){this.I(t),this.L&&function(t,{blocked:e}={}){const s=indexedDB.deleteDatabase(t);e&&s.addEventListener("blocked",t=>e(t.oldVersion,t)),k(s).then(()=>{})}(this.L)}async setTimestamp(t,e){const s={url:t=K(t),timestamp:e,cacheName:this.L,id:this.N(t)},n=(await this.getDb()).transaction(S,"readwrite",{durability:"relaxed"});await n.store.put(s),await n.done}async getTimestamp(t){const e=await this.getDb(),s=await e.get(S,this.N(t));return null==s?void 0:s.timestamp}async expireEntries(t,e){const s=await this.getDb();let n=await s.transaction(S).store.index("timestamp").openCursor(null,"prev");const r=[];let i=0;for(;n;){const s=n.value;s.cacheName===this.L&&(t&&s.timestamp=e?r.push(n.value):i++),n=await n.continue()}const a=[];for(const t of r)await s.delete(S,t.id),a.push(t.url);return a}N(t){return this.L+"|"+K(t)}async getDb(){return this._||(this._=await function(t,e,{blocked:s,upgrade:n,blocking:r,terminated:i}={}){const a=indexedDB.open(t,e),o=k(a);return n&&a.addEventListener("upgradeneeded",t=>{n(k(a.result),t.oldVersion,t.newVersion,k(a.transaction),t)}),s&&a.addEventListener("blocked",t=>s(t.oldVersion,t.newVersion,t)),o.then(t=>{i&&t.addEventListener("close",()=>i()),r&&t.addEventListener("versionchange",t=>r(t.oldVersion,t.newVersion,t))}).catch(()=>{}),o}("workbox-expiration",1,{upgrade:this.C.bind(this)})),this._}}class F{constructor(t,e={}){this.O=!1,this.T=!1,this.k=e.maxEntries,this.B=e.maxAgeSeconds,this.P=e.matchOptions,this.L=t,this.M=new A(t)}async expireEntries(){if(this.O)return void(this.T=!0);this.O=!0;const t=this.B?Date.now()-1e3*this.B:0,e=await this.M.expireEntries(t,this.k),s=await self.caches.open(this.L);for(const t of e)await s.delete(t,this.P);this.O=!1,this.T&&(this.T=!1,b(this.expireEntries()))}async updateTimestamp(t){await this.M.setTimestamp(t,Date.now())}async isURLExpired(t){if(this.B){const e=await this.M.getTimestamp(t),s=Date.now()-1e3*this.B;return void 0===e||er||e&&e<0)throw new s("range-not-satisfiable",{size:r,end:n,start:e});let i,a;return void 0!==e&&void 0!==n?(i=e,a=n+1):void 0!==e&&void 0===n?(i=e,a=r):void 0!==n&&void 0===e&&(i=r-n,a=r),{start:i,end:a}}(i,r.start,r.end),o=i.slice(a.start,a.end),c=o.size,h=new Response(o,{status:206,statusText:"Partial Content",headers:e.headers});return h.headers.set("Content-Length",String(c)),h.headers.set("Content-Range",`bytes ${a.start}-${a.end-1}/${i.size}`),h}catch(t){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}}function $(t,e){const s=e();return t.waitUntil(s),s}try{self["workbox:precaching:7.0.0"]&&_()}catch(t){}function z(t){if(!t)throw new s("add-to-cache-list-unexpected-type",{entry:t});if("string"==typeof t){const e=new URL(t,location.href);return{cacheKey:e.href,url:e.href}}const{revision:e,url:n}=t;if(!n)throw new s("add-to-cache-list-unexpected-type",{entry:t});if(!e){const t=new URL(n,location.href);return{cacheKey:t.href,url:t.href}}const r=new URL(n,location.href),i=new URL(n,location.href);return r.searchParams.set("__WB_REVISION__",e),{cacheKey:r.href,url:i.href}}class G{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:t,state:e})=>{e&&(e.originalRequest=t)},this.cachedResponseWillBeUsed=async({event:t,state:e,cachedResponse:s})=>{if("install"===t.type&&e&&e.originalRequest&&e.originalRequest instanceof Request){const t=e.originalRequest.url;s?this.notUpdatedURLs.push(t):this.updatedURLs.push(t)}return s}}}class V{constructor({precacheController:t}){this.cacheKeyWillBeUsed=async({request:t,params:e})=>{const s=(null==e?void 0:e.cacheKey)||this.W.getCacheKeyForURL(t.url);return s?new Request(s,{headers:t.headers}):t},this.W=t}}let J,Q;async function X(t,e){let n=null;if(t.url){n=new URL(t.url).origin}if(n!==self.location.origin)throw new s("cross-origin-copy-response",{origin:n});const r=t.clone(),i={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},a=e?e(i):i,o=function(){if(void 0===J){const t=new Response("");if("body"in t)try{new Response(t.body),J=!0}catch(t){J=!1}J=!1}return J}()?r.body:await r.blob();return new Response(o,a)}class Y extends R{constructor(t={}){t.cacheName=w(t.cacheName),super(t),this.j=!1!==t.fallbackToNetwork,this.plugins.push(Y.copyRedirectedCacheableResponsesPlugin)}async U(t,e){const s=await e.cacheMatch(t);return s||(e.event&&"install"===e.event.type?await this.S(t,e):await this.K(t,e))}async K(t,e){let n;const r=e.params||{};if(!this.j)throw new s("missing-precache-entry",{cacheName:this.cacheName,url:t.url});{const s=r.integrity,i=t.integrity,a=!i||i===s;n=await e.fetch(new Request(t,{integrity:"no-cors"!==t.mode?i||s:void 0})),s&&a&&"no-cors"!==t.mode&&(this.A(),await e.cachePut(t,n.clone()))}return n}async S(t,e){this.A();const n=await e.fetch(t);if(!await e.cachePut(t,n.clone()))throw new s("bad-precaching-response",{url:t.url,status:n.status});return n}A(){let t=null,e=0;for(const[s,n]of this.plugins.entries())n!==Y.copyRedirectedCacheableResponsesPlugin&&(n===Y.defaultPrecacheCacheabilityPlugin&&(t=s),n.cacheWillUpdate&&e++);0===e?this.plugins.push(Y.defaultPrecacheCacheabilityPlugin):e>1&&null!==t&&this.plugins.splice(t,1)}}Y.defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:t})=>!t||t.status>=400?null:t},Y.copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:t})=>t.redirected?await X(t):t};class Z{constructor({cacheName:t,plugins:e=[],fallbackToNetwork:s=!0}={}){this.F=new Map,this.H=new Map,this.$=new Map,this.u=new Y({cacheName:w(t),plugins:[...e,new V({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this.u}precache(t){this.addToCacheList(t),this.G||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this.G=!0)}addToCacheList(t){const e=[];for(const n of t){"string"==typeof n?e.push(n):n&&void 0===n.revision&&e.push(n.url);const{cacheKey:t,url:r}=z(n),i="string"!=typeof n&&n.revision?"reload":"default";if(this.F.has(r)&&this.F.get(r)!==t)throw new s("add-to-cache-list-conflicting-entries",{firstEntry:this.F.get(r),secondEntry:t});if("string"!=typeof n&&n.integrity){if(this.$.has(t)&&this.$.get(t)!==n.integrity)throw new s("add-to-cache-list-conflicting-integrities",{url:r});this.$.set(t,n.integrity)}if(this.F.set(r,t),this.H.set(r,i),e.length>0){const t=`Workbox is precaching URLs without revision info: ${e.join(", ")}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(t)}}}install(t){return $(t,async()=>{const e=new G;this.strategy.plugins.push(e);for(const[e,s]of this.F){const n=this.$.get(s),r=this.H.get(e),i=new Request(e,{integrity:n,cache:r,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:s},request:i,event:t}))}const{updatedURLs:s,notUpdatedURLs:n}=e;return{updatedURLs:s,notUpdatedURLs:n}})}activate(t){return $(t,async()=>{const t=await self.caches.open(this.strategy.cacheName),e=await t.keys(),s=new Set(this.F.values()),n=[];for(const r of e)s.has(r.url)||(await t.delete(r),n.push(r.url));return{deletedURLs:n}})}getURLsToCacheKeys(){return this.F}getCachedURLs(){return[...this.F.keys()]}getCacheKeyForURL(t){const e=new URL(t,location.href);return this.F.get(e.href)}getIntegrityForCacheKey(t){return this.$.get(t)}async matchPrecache(t){const e=t instanceof Request?t.url:t,s=this.getCacheKeyForURL(e);if(s){return(await self.caches.open(this.strategy.cacheName)).match(s)}}createHandlerBoundToURL(t){const e=this.getCacheKeyForURL(t);if(!e)throw new s("non-precached-url",{url:t});return s=>(s.request=new Request(t),s.params=Object.assign({cacheKey:e},s.params),this.strategy.handle(s))}}const tt=()=>(Q||(Q=new Z),Q);class et extends r{constructor(t,e){super(({request:s})=>{const n=t.getURLsToCacheKeys();for(const r of function*(t,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:s="index.html",cleanURLs:n=!0,urlManipulation:r}={}){const i=new URL(t,location.href);i.hash="",yield i.href;const a=function(t,e=[]){for(const s of[...t.searchParams.keys()])e.some(t=>t.test(s))&&t.searchParams.delete(s);return t}(i,e);if(yield a.href,s&&a.pathname.endsWith("/")){const t=new URL(a.href);t.pathname+=s,yield t.href}if(n){const t=new URL(a.href);t.pathname+=".html",yield t.href}if(r){const t=r({url:i});for(const e of t)yield e.href}}(s.url,e)){const e=n.get(r);if(e){return{cacheKey:e,integrity:t.getIntegrityForCacheKey(e)}}}},t.strategy)}}t.CacheFirst=class extends R{async U(t,e){let n,r=await e.cacheMatch(t);if(!r)try{r=await e.fetchAndCachePut(t)}catch(t){t instanceof Error&&(n=t)}if(!r)throw new s("no-response",{url:t.url,error:n});return r}},t.ExpirationPlugin=class{constructor(t={}){this.cachedResponseWillBeUsed=async({event:t,request:e,cacheName:s,cachedResponse:n})=>{if(!n)return null;const r=this.V(n),i=this.J(s);b(i.expireEntries());const a=i.updateTimestamp(e.url);if(t)try{t.waitUntil(a)}catch(t){}return r?n:null},this.cacheDidUpdate=async({cacheName:t,request:e})=>{const s=this.J(t);await s.updateTimestamp(e.url),await s.expireEntries()},this.X=t,this.B=t.maxAgeSeconds,this.Y=new Map,t.purgeOnQuotaError&&function(t){g.add(t)}(()=>this.deleteCacheAndMetadata())}J(t){if(t===d())throw new s("expire-custom-caches-only");let e=this.Y.get(t);return e||(e=new F(t,this.X),this.Y.set(t,e)),e}V(t){if(!this.B)return!0;const e=this.Z(t);if(null===e)return!0;return e>=Date.now()-1e3*this.B}Z(t){if(!t.headers.has("date"))return null;const e=t.headers.get("date"),s=new Date(e).getTime();return isNaN(s)?null:s}async deleteCacheAndMetadata(){for(const[t,e]of this.Y)await self.caches.delete(t),await e.delete();this.Y=new Map}},t.NetworkFirst=class extends R{constructor(t={}){super(t),this.plugins.some(t=>"cacheWillUpdate"in t)||this.plugins.unshift(u),this.tt=t.networkTimeoutSeconds||0}async U(t,e){const n=[],r=[];let i;if(this.tt){const{id:s,promise:a}=this.et({request:t,logs:n,handler:e});i=s,r.push(a)}const a=this.st({timeoutId:i,request:t,logs:n,handler:e});r.push(a);const o=await e.waitUntil((async()=>await e.waitUntil(Promise.race(r))||await a)());if(!o)throw new s("no-response",{url:t.url});return o}et({request:t,logs:e,handler:s}){let n;return{promise:new Promise(e=>{n=setTimeout(async()=>{e(await s.cacheMatch(t))},1e3*this.tt)}),id:n}}async st({timeoutId:t,request:e,logs:s,handler:n}){let r,i;try{i=await n.fetchAndCachePut(e)}catch(t){t instanceof Error&&(r=t)}return t&&clearTimeout(t),!r&&i||(i=await n.cacheMatch(e)),i}},t.RangeRequestsPlugin=class{constructor(){this.cachedResponseWillBeUsed=async({request:t,cachedResponse:e})=>e&&t.headers.has("range")?await H(t,e):e}},t.StaleWhileRevalidate=class extends R{constructor(t={}){super(t),this.plugins.some(t=>"cacheWillUpdate"in t)||this.plugins.unshift(u)}async U(t,e){const n=e.fetchAndCachePut(t).catch(()=>{});e.waitUntil(n);let r,i=await e.cacheMatch(t);if(i);else try{i=await n}catch(t){t instanceof Error&&(r=t)}if(!i)throw new s("no-response",{url:t.url,error:r});return i}},t.cleanupOutdatedCaches=function(){self.addEventListener("activate",t=>{const e=w();t.waitUntil((async(t,e="-precache-")=>{const s=(await self.caches.keys()).filter(s=>s.includes(e)&&s.includes(self.registration.scope)&&s!==t);return await Promise.all(s.map(t=>self.caches.delete(t))),s})(e).then(t=>{}))})},t.clientsClaim=function(){self.addEventListener("activate",()=>self.clients.claim())},t.precacheAndRoute=function(t,e){!function(t){tt().precache(t)}(t),function(t){const e=tt();h(new et(e,t))}(e)},t.registerRoute=h}); diff --git a/apps/web/scratch/check_user.cjs b/apps/web/scratch/check_user.cjs new file mode 100644 index 0000000000000000000000000000000000000000..5c8730f0d41d6b998cd2a684f1f1508f9f485b2c --- /dev/null +++ b/apps/web/scratch/check_user.cjs @@ -0,0 +1,31 @@ +const { createClient } = require('@supabase/supabase-js'); +const dotenv = require('dotenv'); +const path = require('path'); + +dotenv.config({ path: path.resolve(__dirname, '../../../.env.local') }); + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseServiceKey) { + console.error('Missing Supabase credentials'); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, supabaseServiceKey); + +async function checkUser() { + const { data: { users }, error } = await supabase.auth.admin.listUsers(); + if (error) { + console.error('Error listing users:', error); + return; + } + const tester = users.find(u => u.email === 'tester@tenderhub.co.ke'); + if (tester) { + console.log('User exists:', tester.id); + } else { + console.log('User does not exist'); + } +} + +checkUser(); diff --git a/apps/web/scratch/delete_user.cjs b/apps/web/scratch/delete_user.cjs new file mode 100644 index 0000000000000000000000000000000000000000..2ffee6d16baa56bc3b8318c7e0140dcfe2c2ebbf --- /dev/null +++ b/apps/web/scratch/delete_user.cjs @@ -0,0 +1,37 @@ +const { createClient } = require('@supabase/supabase-js'); +const dotenv = require('dotenv'); +const path = require('path'); + +dotenv.config({ path: path.resolve(__dirname, '../../../.env.local') }); + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseServiceKey) { + console.error('Missing Supabase credentials'); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, supabaseServiceKey); + +async function deleteUser() { + const { data: { users }, error: listError } = await supabase.auth.admin.listUsers(); + if (listError) { + console.error('Error listing users:', listError); + return; + } + const tester = users.find(u => u.email === 'tester@tenderhub.co.ke'); + if (tester) { + console.log('User found:', tester.id, '. Deleting...'); + const { error: deleteError } = await supabase.auth.admin.deleteUser(tester.id); + if (deleteError) { + console.error('Error deleting user:', deleteError); + } else { + console.log('User deleted successfully.'); + } + } else { + console.log('User not found, nothing to delete.'); + } +} + +deleteUser(); diff --git a/apps/web/scratch/get_org_id.cjs b/apps/web/scratch/get_org_id.cjs new file mode 100644 index 0000000000000000000000000000000000000000..04bd3dc5ace7b928db99f670fb781a323a390f7f --- /dev/null +++ b/apps/web/scratch/get_org_id.cjs @@ -0,0 +1,51 @@ +const { createClient } = require('@supabase/supabase-js'); +const dotenv = require('dotenv'); +const path = require('path'); + +dotenv.config({ path: path.resolve(__dirname, '../../../.env.local') }); + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseServiceKey) { + console.error('Missing Supabase credentials'); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, supabaseServiceKey); + +async function getOrgId() { + // 1. Get user ID from email + const { data: { users }, error: userError } = await supabase.auth.admin.listUsers(); + if (userError) { + console.error('Error listing users:', userError); + return; + } + const tester = users.find(u => u.email === 'tester@tenderhub.co.ke'); + if (!tester) { + console.error('Tester user not found'); + return; + } + console.log('User ID:', tester.id); + + // 2. Get organization ID from memberships + const { data: memberships, error: membershipError } = await supabase + .from('members') + .select('organization_id') + .eq('user_id', tester.id); + + if (membershipError) { + console.error('Error getting memberships:', membershipError); + return; + } + + if (memberships.length === 0) { + console.error('No organization membership found for user'); + return; + } + + const orgId = memberships[0].organization_id; + console.log('Organization ID:', orgId); +} + +getOrgId(); diff --git a/apps/web/scratch/setup_org.cjs b/apps/web/scratch/setup_org.cjs new file mode 100644 index 0000000000000000000000000000000000000000..216bd824695492d90f888f3a04d7108adab7d645 --- /dev/null +++ b/apps/web/scratch/setup_org.cjs @@ -0,0 +1,42 @@ +const { createClient } = require('@supabase/supabase-js'); +const dotenv = require('dotenv'); +const path = require('path'); + +dotenv.config({ path: path.resolve(__dirname, '../../../.env.local') }); + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseServiceKey) { + console.error('Missing Supabase credentials'); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, supabaseServiceKey); +const orgId = '4584c0bd-128d-40c1-b212-4ac7d51b554e'; + +async function setupOrg() { + const branding = { + logo_url: 'https://tenderhubkenya.vercel.app/logo.png', + primary_color: '#2563eb', + theme: 'dark', + company_name: 'Test Corp - White Label' + }; + + console.log('Updating organization:', orgId); + const { data, error } = await supabase + .from('organizations') + .update({ + plan_type: 'WHITE_LABEL', + white_label_branding: branding + }) + .eq('id', orgId); + + if (error) { + console.error('Error updating organization:', error); + } else { + console.log('Organization updated successfully.'); + } +} + +setupOrg(); diff --git a/apps/web/src/__tests__/api/mpesa-stk.test.ts b/apps/web/src/__tests__/api/mpesa-stk.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..391ff6ad107df763331821ee41ff4df35095ac22 --- /dev/null +++ b/apps/web/src/__tests__/api/mpesa-stk.test.ts @@ -0,0 +1,18 @@ +import { POST as stkPush } from "../../app/api/payments/mpesa/stk/route"; + +describe("M-Pesa STK API", () => { + it("returns 400 for invalid payload", async () => { + // Construct a standard Fetch Request as expected by App Router + const req = new Request("http://localhost/api/payments/mpesa/stk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + const res = await stkPush(req); + expect(res.status).toBe(400); + + const data = await res.json(); + expect(data.error).toBe("Invalid STK push payload"); + }); +}); diff --git a/apps/web/src/__tests__/api/tenders-drafts-export.test.ts b/apps/web/src/__tests__/api/tenders-drafts-export.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5849a5cd16328f7510962c72fbeeecefefb4688 --- /dev/null +++ b/apps/web/src/__tests__/api/tenders-drafts-export.test.ts @@ -0,0 +1,14 @@ +import { POST as exportDrafts } from "../../app/api/tenders/[id]/drafts/export/route"; + +describe("Tenders Drafts Export API", () => { + it("returns 400 or 401 when requested incorrectly", async () => { + // Construct a standard Fetch Request as expected by App Router + const req = new Request("http://localhost/api/tenders/test-id/drafts/export", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + + const res = await exportDrafts(req, { params: { id: "test-id" } } as any); + expect(res.status).toBeGreaterThanOrEqual(400); + }); +}); diff --git a/apps/web/src/__tests__/api/tenders-unlock.test.ts b/apps/web/src/__tests__/api/tenders-unlock.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3808191247620580ea7666942aefe38e19ee3e6 --- /dev/null +++ b/apps/web/src/__tests__/api/tenders-unlock.test.ts @@ -0,0 +1,16 @@ +import { POST as unlockTender } from "../../app/api/tenders/[id]/unlock/route"; + +describe("Tenders Unlock API", () => { + it("returns 400 or 401 when missing proper headers/payloads", async () => { + // Construct a standard Fetch Request as expected by App Router + const req = new Request("http://localhost/api/tenders/test-id/unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + const res = await unlockTender(req, { params: { id: "test-id" } } as any); + // Should fail cleanly (either 400 or 401) + expect(res.status).toBeGreaterThanOrEqual(400); + }); +}); diff --git a/apps/web/src/__tests__/api/uploads-init.test.ts b/apps/web/src/__tests__/api/uploads-init.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b41f625143be6f0609fd782a60fe888d74810ea --- /dev/null +++ b/apps/web/src/__tests__/api/uploads-init.test.ts @@ -0,0 +1,18 @@ +import { POST as initUpload } from "../../app/api/uploads/init/route"; + +describe("Uploads API", () => { + it("returns 400 for invalid payload", async () => { + // Construct a standard Fetch Request as expected by App Router + const req = new Request("http://localhost/api/uploads/init", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + const res = await initUpload(req); + expect(res.status).toBe(400); + + const data = await res.json(); + expect(data.error).toBe("Invalid upload init payload"); + }); +}); diff --git a/apps/web/src/app/(dashboard)/consultant/page.tsx b/apps/web/src/app/(dashboard)/consultant/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..36dfe59af6ae938cc37df7d4c47e9c572b5ac751 --- /dev/null +++ b/apps/web/src/app/(dashboard)/consultant/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Users, Building2, FileText, BarChart3, Plus } from "lucide-react"; + +const clients = [ + { name: "BuildRight Construction", tenders: 12, won: 5, active: 3 }, + { name: "MedSupply Kenya", tenders: 8, won: 3, active: 2 }, + { name: "CleanPro Services", tenders: 15, won: 7, active: 4 }, + { name: "TechVentures Ltd", tenders: 6, won: 2, active: 1 }, +]; + +export default function ConsultantPortalPage() { + return ( +
+
+
+
+

Consultant Dashboard

+ White-Label +
+

Manage multiple clients from one portal

+
+ +
+ + {/* Stats */} +
+ + +
+ Total Clients + +
+
4
+
+
+ + +
+ Active Tenders + +
+
10
+
+
+ + +
+ Total Won + +
+
17
+
+
+ + +
+ Win Rate + +
+
41%
+
+
+
+ + {/* Clients */} + + + Your Clients + + +
+ {clients.map((client) => ( +
+
+ +
+
+

{client.name}

+

{client.active} active tenders

+
+
+
+
{client.tenders}
+
Total
+
+
+
{client.won}
+
Won
+
+ + {Math.round((client.won / client.tenders) * 100)}% win + +
+
+ ))} +
+
+
+ + {/* Placeholder notice */} + + + +

White-Label Features Coming Soon

+

+ Custom branding, client-specific portals, team collaboration, and API access will be available in the Consultant tier. +

+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8f9e1d2c024e78caf21f266b8d4f5fa70dd30c24 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useTenders } from "@/hooks/use-tenders"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + FileText, + TrendingUp, + AlertCircle, + Clock, + ArrowRight, + Plus, + CheckCircle2, + Loader2 +} from "lucide-react"; +import Link from "next/link"; + +export default function DashboardPage() { + const { data: tenders, isLoading } = useTenders({ limit: 5 }); + + // Simple stats calculation based on available data + const stats = { + totalAnalyzed: tenders?.length || 0, + winningChances: "72%", // Mocked for now as score needs deeper join + complianceAlerts: tenders?.filter(t => t.status === "FAILED").length || 0, + proposalsGenerated: 5 // Mocked for now + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Overview

+

Welcome back to your tender control center

+
+ + + +
+ + {/* Stats Grid */} +
+ {[ + { label: "Total Analyzed", value: stats.totalAnalyzed, icon: FileText, color: "text-primary" }, + { label: "Winning Chances", value: stats.winningChances, icon: TrendingUp, color: "text-success" }, + { label: "Compliance Alerts", value: stats.complianceAlerts, icon: AlertCircle, color: "text-warning" }, + { label: "Proposals Generated", value: stats.proposalsGenerated, icon: CheckCircle2, color: "text-primary" }, + ].map((stat) => ( + + +
+ {stat.label} + +
+
{stat.value}
+
+
+ ))} +
+ +
+ {/* Recent Tenders */} + + + Recent Tenders + + + + + +
+ {tenders?.length === 0 ? ( +
+ No tenders found. Upload your first tender PDF to get started. +
+ ) : ( + tenders?.map((tender) => ( + +
+
+ +
+
+

+ {tender.fileName} +

+
+ Updated {new Date(tender.updatedAt).toLocaleDateString()} + + {tender.unlocked ? ( + Unlocked + ) : ( + Locked + )} +
+
+
+
+
{tender.score || "--"}
+
Score
+
+ +
+
+ + )) + )} +
+
+
+ + {/* System Status / Upgrades */} + + + Win More Tenders + + +

+ Upgrade to the Unlimited Plan to access deep compliance validation and AI-powered proposal scoring. +

+
+
+ Unlimited AI Analysis +
+
+ Export to PDF/Word +
+
+ Consultant Portal Access +
+
+ +
+
+
+
+ ); +} + +function ChevronRight(props: any) { + return ( + + + + ); +} diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8560e269ee28ae805ef7a9949b7f7de2bf9db917 --- /dev/null +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { AppSidebar } from "@/components/AppSidebar"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + +
+ +
+
+ +
+ TenderHub +
+
+
+ {children} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/proposals/[id]/page.tsx b/apps/web/src/app/(dashboard)/proposals/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e807784eb4671266d9c89384288f501c098e7547 --- /dev/null +++ b/apps/web/src/app/(dashboard)/proposals/[id]/page.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { use, useState, useEffect } from "react"; +import { useTenderDrafts, useGenerateDraft } from "@/hooks/use-proposals"; +import { useTenderStatus } from "@/hooks/use-tenders"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ArrowLeft, Download, FileText, Edit3, Save, Loader2, Sparkles, AlertTriangle } from "lucide-react"; +import Link from "next/link"; +import { toast } from "sonner"; + +export default function ProposalBuilderPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const { data: statusData } = useTenderStatus(id); + const { data: draftsData, isLoading: draftsLoading, error: draftsError } = useTenderDrafts(id); + const generateMutation = useGenerateDraft(id); + + const [activeTab, setActiveTab] = useState("EXECUTIVE_SUMMARY"); + const [tone, setTone] = useState("professional"); + const [editingContent, setEditingContent] = useState(""); + const [isEditing, setIsEditing] = useState(false); + + // Sync editing content when tab changes or drafts load + useEffect(() => { + const currentDraft = draftsData?.drafts.find(d => d.section === activeTab); + setEditingContent(currentDraft?.content || ""); + setIsEditing(false); + }, [activeTab, draftsData]); + + const handleGenerate = () => { + generateMutation.mutate({ section: activeTab, tone }, { + onSuccess: () => { + toast.success(`Generated ${activeTab.replace("_", " ")} successfully!`); + }, + onError: (err) => { + toast.error(`Generation failed: ${err.message}`); + } + }); + }; + + if (draftsLoading) { + return ( +
+ +

Loading proposal drafts...

+
+ ); + } + + if (draftsError?.message === "PAYMENT_REQUIRED") { + return ( +
+ +

Proposal Builder Locked

+

+ You need to unlock the analysis for this tender before you can generate AI-powered proposal drafts. +

+ + + +
+ ); + } + + const sections = [ + { id: "EXECUTIVE_SUMMARY", title: "Executive Summary" }, + { id: "TECHNICAL_APPROACH", title: "Technical Approach" }, + { id: "PROJECT_TEAM", title: "Project Team" }, + { id: "WORKPLAN", title: "Workplan" }, + { id: "COMMERCIAL_RESPONSE", title: "Commercial Response" }, + ]; + + return ( +
+ {/* Header */} +
+ + Back to Analysis + +
+
+

Proposal Builder

+

{statusData?.fileName || "Tender Document"}

+
+
+ + +
+
+
+ + {/* Proposal Editor */} + + + {sections.map((section) => ( + + {section.title} + + ))} + + + {sections.map((section) => ( + + + + + + {section.title} + +
+ + {editingContent && ( + + )} +
+
+ + {generateMutation.isPending && !editingContent ? ( +
+ +

AI is drafting your proposal section...

+
+ ) : editingContent ? ( + isEditing ? ( +