Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fccc0115c4 | ||
|
|
189260bbb3 | ||
|
|
8e7070cbaf | ||
|
|
a8d7db562d | ||
|
|
e220f46d35 | ||
|
|
93460ece19 | ||
|
|
7b67897612 | ||
|
|
c5f79f1fd2 | ||
|
|
41abcaa7a4 | ||
|
|
966f3b23fa | ||
|
|
45b30918a0 | ||
|
|
7eeec7e781 | ||
|
|
527131a462 | ||
|
|
6b07b83583 | ||
|
|
c7e130ce4d | ||
|
|
5a2b68763f | ||
|
|
84624976a8 | ||
|
|
a3dd4b9ffa | ||
|
|
0121641163 | ||
|
|
8d35785a4c | ||
|
|
c1585570c6 | ||
|
|
22251ea82e | ||
|
|
a21aa99e90 | ||
|
|
e697750e22 | ||
|
|
23acac5c4d | ||
|
|
dccc5d3b7f | ||
|
|
be80d63f04 | ||
|
|
95a5af1d9a | ||
|
|
5a6db9c20f | ||
|
|
870075b33f | ||
|
|
8b16729ab4 | ||
|
|
1ab4dc1033 | ||
|
|
a064260c55 | ||
|
|
a7b1ae4800 | ||
|
|
4b7cfc4ab5 | ||
|
|
a79c734fe9 | ||
|
|
8ac0b8a760 | ||
|
|
52b6392f74 | ||
|
|
14aefa4a71 | ||
|
|
d64a191c12 | ||
|
|
4b2e96c681 | ||
|
|
1b922ddda3 | ||
|
|
bc10d5fe4b | ||
|
|
a5c6e32734 | ||
|
|
35feade8e7 | ||
|
|
c86fce6125 | ||
|
|
7f3d94178d | ||
|
|
9a523c038c | ||
|
|
8e60268caf | ||
|
|
2291336300 | ||
|
|
ff2870ba2a | ||
|
|
ad90b92dcd | ||
|
|
5a6847c774 | ||
|
|
e6feb36523 | ||
|
|
01cafba6c1 | ||
|
|
19a0720fe8 | ||
|
|
5c05779541 | ||
|
|
ce67f90f06 | ||
|
|
fb5e1ea281 | ||
|
|
03c37a623f | ||
|
|
721d683eb9 | ||
|
|
0b8c2163f3 | ||
|
|
72a11cfcbc | ||
|
|
3da9c04dce | ||
|
|
58c0cb5165 | ||
|
|
387433cd26 | ||
|
|
4929a1c906 | ||
|
|
8192ed189a | ||
|
|
5e81c4bab5 | ||
|
|
de0426cad9 | ||
|
|
b1a8fa4167 | ||
|
|
3b8e86d81e | ||
|
|
1b7824b265 | ||
|
|
96ea8fc0d1 | ||
|
|
210117e73e | ||
|
|
35c8cc23a9 | ||
|
|
0e37dab05b | ||
|
|
1707a246f7 | ||
|
|
8ae947b1ae | ||
|
|
dc84e3ebe8 | ||
|
|
624f8532a5 | ||
|
|
5e943584d9 | ||
|
|
e418650757 | ||
|
|
3394c246b2 | ||
|
|
8dbbad4277 | ||
|
|
5395a529dd | ||
|
|
462d2058e1 | ||
|
|
16c5f79f2b | ||
|
|
e4b962bb1b | ||
|
|
0893078d73 | ||
|
|
8fb25ded08 | ||
|
|
9057ec8d11 | ||
|
|
f0e755ef87 | ||
|
|
3e276375bf |
|
After Width: | Height: | Size: 336 KiB |
@@ -35,8 +35,8 @@ Both endpoints include server-side validation, rate limiting, spam protection, a
|
||||
- `name` — required, 2–100 characters
|
||||
- `email` — required, valid format, max 254 chars
|
||||
- `phone` — optional; if present, must match phone regex
|
||||
- `subject` — required, allowlist enum (web-development, mobile-development, cloud-services, ai-ml, consulting, support, other)
|
||||
- `message` — required, 10–5000 characters
|
||||
- `subject` — required, allowlist enum (erp-solutions, government-systems, web-development, mobile-development, ui-ux, consulting, other; legacy values cloud-services, ai-ml, support still accepted by the API but no longer offered in the contact-page dropdown)
|
||||
- `message` — required, 10–2000 characters
|
||||
- `website` — honeypot, must be empty
|
||||
|
||||
**Rate limit:** 5 requests per IP per hour
|
||||
|
||||
@@ -312,13 +312,26 @@ All env vars must be configured in your hosting platform, not in the workflow.
|
||||
| `NODE_ENV` | Yes | — | Must be `production` |
|
||||
| `HOST` | Yes | — | `0.0.0.0` |
|
||||
| `PORT` | Yes | — | `10000` |
|
||||
| `CONTACT_EMAIL` | No | — | Where contact form sends emails |
|
||||
| `SMTP_HOST` | No | — | Email server host |
|
||||
| `SMTP_USER` | No | — | Email server username |
|
||||
| `SMTP_PASS` | No | — | Email server password (use platform secrets) |
|
||||
| `CONTACT_EMAIL` | Yes (legacy) | — | Legacy admin recipient for contact form; kept for backward compatibility with older code paths. Mirror of `MAIL_ADMIN_TO`. |
|
||||
| `MAIL_ADMIN_TO` | Yes | — | Address that receives "Tell us about your project" and contact form submissions. |
|
||||
| `SMTP_HOST` | Yes | — | SMTP server hostname (e.g. `smtp.hostinger.com`). |
|
||||
| `SMTP_PORT` | Yes | `587` | SMTP server port. Use `587` for STARTTLS, `465` for implicit TLS. |
|
||||
| `SMTP_USER` | Yes | — | SMTP auth username (full email address). |
|
||||
| `SMTP_PASS` | Yes | — | SMTP auth password — MUST be stored as a platform secret, never plaintext. |
|
||||
| `SMTP_FROM` | Yes | — | `From:` address used by outbound mail (should match `SMTP_USER` or an allowed alias). |
|
||||
| `SMTP_FROM_NAME` | Yes | — | Human-readable display name shown alongside `SMTP_FROM`. |
|
||||
| `SMTP_SECURE` | Yes | `false` | `true` only for implicit-TLS ports (465). For port 587 keep `false` and rely on STARTTLS. |
|
||||
| `SMTP_STARTTLS` | Yes | `true` | Negotiate STARTTLS on the SMTP connection. |
|
||||
| `SMTP_REQUIRE_TLS` | Yes | `true` | Refuse to send if the server does not advertise STARTTLS — prevents silent plaintext fallback. |
|
||||
| `SMTP_AUTH` | Yes | `true` | Authenticate with `SMTP_USER` / `SMTP_PASS`. |
|
||||
| `SMTP_CONNECTION_TIMEOUT_MS` | No | `10000` | Socket connect timeout in milliseconds. |
|
||||
| `SMTP_GREETING_TIMEOUT_MS` | No | `10000` | Wait time for the server greeting (EHLO/HELO) in milliseconds. |
|
||||
| `SMTP_SOCKET_TIMEOUT_MS` | No | `10000` | Idle socket timeout in milliseconds (covers read/write while transferring the message). |
|
||||
| `SENTRY_DSN` | No | — | Sentry error tracking |
|
||||
| `LOG_LEVEL` | No | `info` | Logging verbosity |
|
||||
|
||||
> Production SMTP is provisioned on Hostinger and configured in the **Default** environment via the platform's env-var store (`SMTP_PASS` flagged as secret). After changing any `SMTP_*` value you MUST restart the `frontend` service so the new variables are picked up by the Astro SSR process.
|
||||
|
||||
---
|
||||
|
||||
## Rollback Procedures
|
||||
|
||||
@@ -37,12 +37,32 @@ NODE_ENV=production
|
||||
# ==================================
|
||||
# Contact Form & Email (SMTP)
|
||||
# ==================================
|
||||
# Used by /api/contact for sending contact form submissions
|
||||
# If not set, submissions are logged to console (dev/staging mode)
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=your_email@example.com
|
||||
# SMTP_PASS=your_app_password_here
|
||||
# Used by /api/contact (powers both the contact page form AND the
|
||||
# homepage "Tell us about your project" inline lead form).
|
||||
# If unset, submissions are logged to console (dev/staging mode).
|
||||
#
|
||||
# --- Required for live email delivery ---
|
||||
# SMTP_HOST=smtp.hostinger.com
|
||||
# SMTP_PORT=587 # 587 = STARTTLS (recommended)
|
||||
# SMTP_USER=ghanwat.ajay@workroot.in
|
||||
# SMTP_PASS=your_app_password_here # MARK AS SECRET in platform UI
|
||||
#
|
||||
# --- From header (RFC 5322) ---
|
||||
# SMTP_FROM=ghanwat.ajay@workroot.in
|
||||
# SMTP_FROM_NAME=Ajay Ghanwat
|
||||
#
|
||||
# --- Admin recipient for inbound submissions ---
|
||||
# Falls back to SMTP_FROM, then legacy CONTACT_EMAIL_TO/CONTACT_EMAIL.
|
||||
# MAIL_ADMIN_TO=ghanwat.ajay@workroot.in
|
||||
#
|
||||
# --- TLS & timeouts (optional, sensible defaults applied) ---
|
||||
# SMTP_REQUIRE_TLS=true # set to "false" to disable STARTTLS upgrade
|
||||
# SMTP_CONNECTION_TIMEOUT_MS=10000
|
||||
# SMTP_GREETING_TIMEOUT_MS=10000
|
||||
# SMTP_SOCKET_TIMEOUT_MS=10000
|
||||
#
|
||||
# --- Legacy aliases (still honored for backwards compatibility) ---
|
||||
# CONTACT_EMAIL_TO=hello@workroot.in
|
||||
# CONTACT_EMAIL=hello@workroot.in
|
||||
|
||||
# ==================================
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Graph Report - 2e709500-79ab-403f-9ca0-3f027ac9f36e (2026-06-17)
|
||||
# Graph Report - 2e709500-79ab-403f-9ca0-3f027ac9f36e (2026-06-24)
|
||||
|
||||
## Corpus Check
|
||||
- 56 files · ~324,046 words
|
||||
- 64 files · ~615,668 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 518 nodes · 563 edges · 91 communities (84 shown, 7 thin omitted)
|
||||
- 619 nodes · 640 edges · 117 communities (90 shown, 27 thin omitted)
|
||||
- Extraction: 99% EXTRACTED · 1% INFERRED · 0% AMBIGUOUS · INFERRED: 4 edges (avg confidence: 0.8)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
- [[_COMMUNITY_Community 5|Community 5]]
|
||||
- [[_COMMUNITY_Community 6|Community 6]]
|
||||
- [[_COMMUNITY_Community 7|Community 7]]
|
||||
- [[_COMMUNITY_Community 9|Community 9]]
|
||||
- [[_COMMUNITY_Community 8|Community 8]]
|
||||
- [[_COMMUNITY_Community 10|Community 10]]
|
||||
- [[_COMMUNITY_Community 12|Community 12]]
|
||||
- [[_COMMUNITY_Community 11|Community 11]]
|
||||
- [[_COMMUNITY_Community 13|Community 13]]
|
||||
- [[_COMMUNITY_Community 14|Community 14]]
|
||||
- [[_COMMUNITY_Community 15|Community 15]]
|
||||
@@ -49,7 +49,30 @@
|
||||
- [[_COMMUNITY_Community 33|Community 33]]
|
||||
- [[_COMMUNITY_Community 34|Community 34]]
|
||||
- [[_COMMUNITY_Community 35|Community 35]]
|
||||
- [[_COMMUNITY_Community 40|Community 40]]
|
||||
- [[_COMMUNITY_Community 36|Community 36]]
|
||||
- [[_COMMUNITY_Community 37|Community 37]]
|
||||
- [[_COMMUNITY_Community 38|Community 38]]
|
||||
- [[_COMMUNITY_Community 44|Community 44]]
|
||||
- [[_COMMUNITY_Community 45|Community 45]]
|
||||
- [[_COMMUNITY_Community 46|Community 46]]
|
||||
- [[_COMMUNITY_Community 47|Community 47]]
|
||||
- [[_COMMUNITY_Community 48|Community 48]]
|
||||
- [[_COMMUNITY_Community 49|Community 49]]
|
||||
- [[_COMMUNITY_Community 50|Community 50]]
|
||||
- [[_COMMUNITY_Community 51|Community 51]]
|
||||
- [[_COMMUNITY_Community 52|Community 52]]
|
||||
- [[_COMMUNITY_Community 53|Community 53]]
|
||||
- [[_COMMUNITY_Community 54|Community 54]]
|
||||
- [[_COMMUNITY_Community 55|Community 55]]
|
||||
- [[_COMMUNITY_Community 56|Community 56]]
|
||||
- [[_COMMUNITY_Community 57|Community 57]]
|
||||
- [[_COMMUNITY_Community 58|Community 58]]
|
||||
- [[_COMMUNITY_Community 59|Community 59]]
|
||||
- [[_COMMUNITY_Community 60|Community 60]]
|
||||
- [[_COMMUNITY_Community 61|Community 61]]
|
||||
- [[_COMMUNITY_Community 62|Community 62]]
|
||||
- [[_COMMUNITY_Community 63|Community 63]]
|
||||
- [[_COMMUNITY_Community 64|Community 64]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `Performance Testing Scripts` - 12 edges
|
||||
@@ -73,7 +96,7 @@
|
||||
- `POST()` --calls--> `logApiRequest()` [INFERRED]
|
||||
src/pages/api/newsletter.ts → src/utils/logger.ts
|
||||
|
||||
## Communities (91 total, 7 thin omitted)
|
||||
## Communities (117 total, 27 thin omitted)
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.05
|
||||
@@ -84,8 +107,8 @@ Cohesion: 0.05
|
||||
Nodes (37): 1. `performance_test.py`, 2. `quick-perf-check.sh`, 3. `lighthouse_audit.py`, Available Scripts, Basic Response Time, Best Practices, CI/CD Integration, code:bash (# Quick check (recommended)) (+29 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.14
|
||||
Nodes (27): checkRateLimit(), corsHeaders(), escapeHtml(), OPTIONS(), POST(), sendContactEmail(), validateContactForm(), checkRateLimit() (+19 more)
|
||||
Cohesion: 0.13
|
||||
Nodes (29): checkRateLimit(), corsHeaders(), escapeHtml(), getTransporter(), OPTIONS(), POST(), readSmtpConfig(), sendContactEmail() (+21 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.16
|
||||
@@ -105,92 +128,104 @@ Nodes (15): 1. Modular Monolith Over Premature Microservices, 2. Role-Based Acce
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.14
|
||||
Nodes (13): 1. Multi-District Deployment Complexity, 2. Security and Compliance, 3. Reliability Under Constraint, Architecture Decisions That Paid Off, code:java (@Configuration), code:java (@Service), code:java (@Component), code:yaml (# District-specific feature configuration) (+5 more)
|
||||
Nodes (13): 1. Database-per-district with central aggregation, 2. Offline-first for field-critical modules, 3. Audit logging baked into the data layer, Building something similar?, code:yaml (# Per-district deployment descriptor), code:java (@Scheduled(fixedDelay = 300_000) // every 5 minutes), code:yaml (features:), The Architecture We Committed To (+5 more)
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 0.17
|
||||
Nodes (11): 1. Customer Service Automation, 2. Predictive Analytics, 3. Process Automation, 4. Intelligent Document Processing, code:python (from transformers import pipeline), code:python (import pandas as pd), code:python (# Example: Extracting structured data from forms), Implementation Considerations (+3 more)
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 0.14
|
||||
Nodes (13): 1. Multi-District Deployment Complexity, 2. Security and Compliance, 3. Reliability Under Constraint, Architecture Decisions That Paid Off, code:java (@Configuration), code:java (@Service), code:java (@Component), code:yaml (# District-specific feature configuration) (+5 more)
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
Cohesion: 0.17
|
||||
Nodes (11): 1. Customer Service Automation, 2. Predictive Analytics, 3. Process Automation, 4. Intelligent Document Processing, code:python (from transformers import pipeline), code:python (import pandas as pd), code:python (# Example: Extracting structured data from forms), Implementation Considerations (+3 more)
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 0.17
|
||||
Nodes (11): Code Example, code:astro (---), code:javascript (// Traditional SPA: Everything loads upfront), code:typescript (// src/content/config.ts), code:bash (npm create astro@latest), Content Collections: Type-Safe Content, Getting Started, How Astro Differs from Traditional Frameworks (+3 more)
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 0.18
|
||||
Nodes (10): code:hcl (# Define AWS EC2 instance), code:dockerfile (# Multi-stage build for a Go application), code:yaml (# AWS Cost allocation tags), Common Pitfalls, Conclusion, Containerization as a Migration Strategy, Cost Optimization Strategies, Infrastructure as Code (+2 more)
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.2
|
||||
Nodes (9): Catalogue Listing vs Bidding: How You Actually Win, Common Reasons Software Vendors Get Rejected or Delayed, Document Checklist, Fees, Caution Money, and Timelines, Frequently Asked Questions, MSME and Startup Benefits Worth Claiming, Step-by-Step Registration Process, Turning GeM Access Into Government Contracts (+1 more)
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
Cohesion: 0.22
|
||||
Nodes (8): 2026 Cost Ranges by Project Tier, A Simple Way to Estimate Your Own Cost, Build vs Buy: When Custom Actually Pays Off, Frequently Asked Questions, Getting an Accurate Number for Your Project, Hidden Costs Buyers Forget to Budget, The Biggest Cost Driver: The Team, What "Custom ERP" Actually Means for Pricing
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.43
|
||||
Nodes (6): trackCaseStudyView(), trackEvent(), trackExternalLink(), trackFormSubmit(), trackNewsletterSignup(), trackPortfolioFilter()
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.36
|
||||
Nodes (7): extract_scores(), get_issues(), main(), Run Lighthouse audit on a URL, Extract key scores from Lighthouse data, Extract issues from Lighthouse audit, run_lighthouse()
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 0.46
|
||||
Nodes (7): captureScreenshot(), ensureDir(), generateIndex(), main(), screenshotPath(), setTheme(), waitForPageReady()
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.36
|
||||
Nodes (4): deleteFromDB(), getAllFromDB(), openDB(), processSyncQueue()
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.46
|
||||
Nodes (7): checkRateLimit(), corsHeaders(), escapeHtml(), OPTIONS(), POST(), sendContactEmail(), validateContactForm()
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 0.43
|
||||
Nodes (6): trackCaseStudyView(), trackEvent(), trackExternalLink(), trackFormSubmit(), trackNewsletterSignup(), trackPortfolioFilter()
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
Cohesion: 0.36
|
||||
Nodes (7): extract_scores(), get_issues(), main(), Run Lighthouse audit on a URL, Extract key scores from Lighthouse data, Extract issues from Lighthouse audit, run_lighthouse()
|
||||
Nodes (4): deleteFromDB(), getAllFromDB(), openDB(), processSyncQueue()
|
||||
|
||||
### Community 24 - "Community 24"
|
||||
Cohesion: 0.46
|
||||
Nodes (7): captureScreenshot(), ensureDir(), generateIndex(), main(), screenshotPath(), setTheme(), waitForPageReady()
|
||||
Nodes (7): checkRateLimit(), corsHeaders(), escapeHtml(), OPTIONS(), POST(), sendContactEmail(), validateContactForm()
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
Cohesion: 0.43
|
||||
Nodes (6): trackCaseStudyView(), trackEvent(), trackExternalLink(), trackFormSubmit(), trackNewsletterSignup(), trackPortfolioFilter()
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 0.36
|
||||
Nodes (7): extract_scores(), get_issues(), main(), Run Lighthouse audit on a URL, Extract key scores from Lighthouse data, Extract issues from Lighthouse audit, run_lighthouse()
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 0.46
|
||||
Nodes (7): captureScreenshot(), ensureDir(), generateIndex(), main(), screenshotPath(), setTheme(), waitForPageReady()
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 0.36
|
||||
Nodes (4): deleteFromDB(), getAllFromDB(), openDB(), processSyncQueue()
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 0.48
|
||||
Nodes (5): initAllAnimations(), initButtonRipple(), initCounters(), initProgressBars(), initScrollReveal()
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 0.48
|
||||
Nodes (5): extractJsonLd(), main(), validateOpenGraph(), validatePage(), validateTwitterCard()
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 0.48
|
||||
Nodes (5): initAllAnimations(), initButtonRipple(), initCounters(), initProgressBars(), initScrollReveal()
|
||||
|
||||
### Community 29 - "Community 29"
|
||||
Cohesion: 0.48
|
||||
Nodes (5): extractJsonLd(), main(), validateOpenGraph(), validatePage(), validateTwitterCard()
|
||||
Nodes (5): initAllAnimations(), initButtonRipple(), initCounters(), initProgressBars(), initScrollReveal()
|
||||
|
||||
### Community 30 - "Community 30"
|
||||
Cohesion: 0.47
|
||||
Nodes (5): main(), measure_response_time(), print_stats(), Measure response time for a URL over multiple iterations, Print statistics for response times
|
||||
Cohesion: 0.48
|
||||
Nodes (5): extractJsonLd(), main(), validateOpenGraph(), validatePage(), validateTwitterCard()
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
Cohesion: 0.48
|
||||
Nodes (5): initAllAnimations(), initButtonRipple(), initCounters(), initProgressBars(), initScrollReveal()
|
||||
|
||||
### Community 32 - "Community 32"
|
||||
Cohesion: 0.48
|
||||
Nodes (5): extractJsonLd(), main(), validateOpenGraph(), validatePage(), validateTwitterCard()
|
||||
|
||||
### Community 33 - "Community 33"
|
||||
Cohesion: 0.47
|
||||
Nodes (5): main(), measure_response_time(), print_stats(), Measure response time for a URL over multiple iterations, Print statistics for response times
|
||||
|
||||
### Community 34 - "Community 34"
|
||||
### Community 35 - "Community 35"
|
||||
Cohesion: 0.47
|
||||
Nodes (5): main(), measure_response_time(), print_stats(), Measure response time for a URL over multiple iterations, Print statistics for response times
|
||||
|
||||
### Community 37 - "Community 37"
|
||||
Cohesion: 0.6
|
||||
Nodes (3): formatUptime(), GET(), percentile()
|
||||
|
||||
### Community 35 - "Community 35"
|
||||
### Community 38 - "Community 38"
|
||||
Cohesion: 0.6
|
||||
Nodes (3): formatUptime(), GET(), percentile()
|
||||
|
||||
## Knowledge Gaps
|
||||
- **111 isolated node(s):** `Run Lighthouse audit on a URL`, `Extract key scores from Lighthouse data`, `Extract issues from Lighthouse audit`, `Measure response time for a URL over multiple iterations`, `Print statistics for response times` (+106 more)
|
||||
- **156 isolated node(s):** `Run Lighthouse audit on a URL`, `Extract key scores from Lighthouse data`, `Extract issues from Lighthouse audit`, `Measure response time for a URL over multiple iterations`, `Print statistics for response times` (+151 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **7 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
- **27 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
@@ -198,13 +233,13 @@ _Questions this graph is uniquely positioned to answer:_
|
||||
- **Are the 2 inferred relationships involving `captureException()` (e.g. with `sendContactEmail()` and `subscribeToNewsletter()`) actually correct?**
|
||||
_`captureException()` has 2 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `Run Lighthouse audit on a URL`, `Extract key scores from Lighthouse data`, `Extract issues from Lighthouse audit` to the rest of the system?**
|
||||
_111 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
_156 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `Community 0` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.05 - nodes in this community are weakly interconnected._
|
||||
- **Should `Community 1` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.05 - nodes in this community are weakly interconnected._
|
||||
- **Should `Community 2` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.14 - nodes in this community are weakly interconnected._
|
||||
_Cohesion score 0.13 - nodes in this community are weakly interconnected._
|
||||
- **Should `Community 4` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.09 - nodes in this community are weakly interconnected._
|
||||
- **Should `Community 5` be split into smaller, more focused modules?**
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "label": "middleware.ts", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L1"}, {"id": "src_middleware_validatecsrforigin", "label": "validateCsrfOrigin()", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L19"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "astro_middleware", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L1", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_utils_logger_ts", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L2", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "utils_logger_logger", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L2", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_utils_sentry_ts", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L3", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "utils_sentry_captureexception", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L3", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "src_middleware_validatecsrforigin", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L19", "weight": 1.0}], "raw_calls": [{"caller_nid": "src_middleware_validatecsrforigin", "callee": "includes", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L24"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "startsWith", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L25"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "get", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L36"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "get", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L37"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "startsWith", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L52"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "startsWith", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L52"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_tests_inline_lead_form_spec_ts", "label": "inline-lead-form.spec.ts", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/tests/inline-lead-form.spec.ts", "source_location": "L1"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_tests_inline_lead_form_spec_ts", "target": "test", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/tests/inline-lead-form.spec.ts", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_config_ts", "label": "playwright.config.ts", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright.config.ts", "source_location": "L1"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_config_ts", "target": "test", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright.config.ts", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_test_results_e2e_smoke_suite_smoke_suit_6ed4a_has_proper_structured_data_chromium_error_context_md", "label": "error-context.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-6ed4a--has-proper-structured-data-chromium/error-context.md", "source_location": "L1"}, {"id": "e2e_smoke_suite_smoke_suit_6ed4a_has_proper_structured_data_chromium_error_context_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-6ed4a--has-proper-structured-data-chromium/error-context.md", "source_location": "L1"}, {"id": "e2e_smoke_suite_smoke_suit_6ed4a_has_proper_structured_data_chromium_error_context_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-6ed4a--has-proper-structured-data-chromium/error-context.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_test_results_e2e_smoke_suite_smoke_suit_6ed4a_has_proper_structured_data_chromium_error_context_md", "target": "e2e_smoke_suite_smoke_suit_6ed4a_has_proper_structured_data_chromium_error_context_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-6ed4a--has-proper-structured-data-chromium/error-context.md", "source_location": "L1", "weight": 1.0}, {"source": "e2e_smoke_suite_smoke_suit_6ed4a_has_proper_structured_data_chromium_error_context_page_snapshot", "target": "e2e_smoke_suite_smoke_suit_6ed4a_has_proper_structured_data_chromium_error_context_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-6ed4a--has-proper-structured-data-chromium/error-context.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "label": "middleware.ts", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L1"}, {"id": "src_middleware_validatecsrforigin", "label": "validateCsrfOrigin()", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L19"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "astro_middleware", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L1", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_utils_logger_ts", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L2", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "utils_logger_logger", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L2", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_utils_sentry_ts", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L3", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "utils_sentry_captureexception", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L3", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "src_middleware_validatecsrforigin", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L19", "weight": 1.0}], "raw_calls": [{"caller_nid": "src_middleware_validatecsrforigin", "callee": "includes", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L24"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "startsWith", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L25"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "get", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L36"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "get", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L37"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "startsWith", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L52"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "startsWith", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L52"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_1639575cca1fe4171917a09f8f7cc59c33654911_md", "label": "1639575cca1fe4171917a09f8f7cc59c33654911.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/1639575cca1fe4171917a09f8f7cc59c33654911.md", "source_location": "L1"}, {"id": "data_1639575cca1fe4171917a09f8f7cc59c33654911_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/1639575cca1fe4171917a09f8f7cc59c33654911.md", "source_location": "L1"}, {"id": "data_1639575cca1fe4171917a09f8f7cc59c33654911_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/1639575cca1fe4171917a09f8f7cc59c33654911.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_1639575cca1fe4171917a09f8f7cc59c33654911_md", "target": "data_1639575cca1fe4171917a09f8f7cc59c33654911_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/1639575cca1fe4171917a09f8f7cc59c33654911.md", "source_location": "L1", "weight": 1.0}, {"source": "data_1639575cca1fe4171917a09f8f7cc59c33654911_page_snapshot", "target": "data_1639575cca1fe4171917a09f8f7cc59c33654911_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/1639575cca1fe4171917a09f8f7cc59c33654911.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_test_results_e2e_smoke_suite_smoke_suit_8282a_mepage_has_proper_meta_tags_chromium_error_context_md", "label": "error-context.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-8282a-mepage-has-proper-meta-tags-chromium/error-context.md", "source_location": "L1"}, {"id": "e2e_smoke_suite_smoke_suit_8282a_mepage_has_proper_meta_tags_chromium_error_context_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-8282a-mepage-has-proper-meta-tags-chromium/error-context.md", "source_location": "L1"}, {"id": "e2e_smoke_suite_smoke_suit_8282a_mepage_has_proper_meta_tags_chromium_error_context_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-8282a-mepage-has-proper-meta-tags-chromium/error-context.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_test_results_e2e_smoke_suite_smoke_suit_8282a_mepage_has_proper_meta_tags_chromium_error_context_md", "target": "e2e_smoke_suite_smoke_suit_8282a_mepage_has_proper_meta_tags_chromium_error_context_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-8282a-mepage-has-proper-meta-tags-chromium/error-context.md", "source_location": "L1", "weight": 1.0}, {"source": "e2e_smoke_suite_smoke_suit_8282a_mepage_has_proper_meta_tags_chromium_error_context_page_snapshot", "target": "e2e_smoke_suite_smoke_suit_8282a_mepage_has_proper_meta_tags_chromium_error_context_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-8282a-mepage-has-proper-meta-tags-chromium/error-context.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_a1c157260391dca389fbe38c6259c4efd286b319_md", "label": "a1c157260391dca389fbe38c6259c4efd286b319.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/a1c157260391dca389fbe38c6259c4efd286b319.md", "source_location": "L1"}, {"id": "data_a1c157260391dca389fbe38c6259c4efd286b319_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/a1c157260391dca389fbe38c6259c4efd286b319.md", "source_location": "L1"}, {"id": "data_a1c157260391dca389fbe38c6259c4efd286b319_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/a1c157260391dca389fbe38c6259c4efd286b319.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_a1c157260391dca389fbe38c6259c4efd286b319_md", "target": "data_a1c157260391dca389fbe38c6259c4efd286b319_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/a1c157260391dca389fbe38c6259c4efd286b319.md", "source_location": "L1", "weight": 1.0}, {"source": "data_a1c157260391dca389fbe38c6259c4efd286b319_page_snapshot", "target": "data_a1c157260391dca389fbe38c6259c4efd286b319_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/a1c157260391dca389fbe38c6259c4efd286b319.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_448763de83b442e1e9a6c6e4e70870f8a7246e0a_md", "label": "448763de83b442e1e9a6c6e4e70870f8a7246e0a.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/448763de83b442e1e9a6c6e4e70870f8a7246e0a.md", "source_location": "L1"}, {"id": "data_448763de83b442e1e9a6c6e4e70870f8a7246e0a_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/448763de83b442e1e9a6c6e4e70870f8a7246e0a.md", "source_location": "L1"}, {"id": "data_448763de83b442e1e9a6c6e4e70870f8a7246e0a_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/448763de83b442e1e9a6c6e4e70870f8a7246e0a.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_448763de83b442e1e9a6c6e4e70870f8a7246e0a_md", "target": "data_448763de83b442e1e9a6c6e4e70870f8a7246e0a_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/448763de83b442e1e9a6c6e4e70870f8a7246e0a.md", "source_location": "L1", "weight": 1.0}, {"source": "data_448763de83b442e1e9a6c6e4e70870f8a7246e0a_page_snapshot", "target": "data_448763de83b442e1e9a6c6e4e70870f8a7246e0a_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/448763de83b442e1e9a6c6e4e70870f8a7246e0a.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_test_results_theme_theme_aria_accessibi_8c42a_ria_checked_desktop_mobile_chromium_error_context_md", "label": "error-context.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/theme-Theme-ARIA-Accessibi-8c42a-ria-checked-desktop-mobile--chromium/error-context.md", "source_location": "L1"}, {"id": "theme_theme_aria_accessibi_8c42a_ria_checked_desktop_mobile_chromium_error_context_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/theme-Theme-ARIA-Accessibi-8c42a-ria-checked-desktop-mobile--chromium/error-context.md", "source_location": "L1"}, {"id": "theme_theme_aria_accessibi_8c42a_ria_checked_desktop_mobile_chromium_error_context_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/theme-Theme-ARIA-Accessibi-8c42a-ria-checked-desktop-mobile--chromium/error-context.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_test_results_theme_theme_aria_accessibi_8c42a_ria_checked_desktop_mobile_chromium_error_context_md", "target": "theme_theme_aria_accessibi_8c42a_ria_checked_desktop_mobile_chromium_error_context_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/theme-Theme-ARIA-Accessibi-8c42a-ria-checked-desktop-mobile--chromium/error-context.md", "source_location": "L1", "weight": 1.0}, {"source": "theme_theme_aria_accessibi_8c42a_ria_checked_desktop_mobile_chromium_error_context_page_snapshot", "target": "theme_theme_aria_accessibi_8c42a_ria_checked_desktop_mobile_chromium_error_context_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/theme-Theme-ARIA-Accessibi-8c42a-ria-checked-desktop-mobile--chromium/error-context.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_content_config_ts", "label": "config.ts", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/content/config.ts", "source_location": "L1"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_content_config_ts", "target": "astro_content", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/content/config.ts", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff_md", "label": "3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff.md", "source_location": "L1"}, {"id": "data_3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff.md", "source_location": "L1"}, {"id": "data_3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff_codeblock_1", "label": "code:yaml (- generic [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff_md", "target": "data_3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff.md", "source_location": "L1", "weight": 1.0}, {"source": "data_3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff_page_snapshot", "target": "data_3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/3e3d231801ce8a68ec884e518f6e46e7f3a8d6ff.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_6a86fc05ec2c2ce701392e4df2db4d19695f2488_md", "label": "6a86fc05ec2c2ce701392e4df2db4d19695f2488.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/6a86fc05ec2c2ce701392e4df2db4d19695f2488.md", "source_location": "L1"}, {"id": "data_6a86fc05ec2c2ce701392e4df2db4d19695f2488_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/6a86fc05ec2c2ce701392e4df2db4d19695f2488.md", "source_location": "L1"}, {"id": "data_6a86fc05ec2c2ce701392e4df2db4d19695f2488_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/6a86fc05ec2c2ce701392e4df2db4d19695f2488.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_6a86fc05ec2c2ce701392e4df2db4d19695f2488_md", "target": "data_6a86fc05ec2c2ce701392e4df2db4d19695f2488_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/6a86fc05ec2c2ce701392e4df2db4d19695f2488.md", "source_location": "L1", "weight": 1.0}, {"source": "data_6a86fc05ec2c2ce701392e4df2db4d19695f2488_page_snapshot", "target": "data_6a86fc05ec2c2ce701392e4df2db4d19695f2488_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/6a86fc05ec2c2ce701392e4df2db4d19695f2488.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_test_results_e2e_smoke_suite_smoke_suit_7fcfd_ntact_form_submission_works_chromium_error_context_md", "label": "error-context.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-7fcfd-ntact-form-submission-works-chromium/error-context.md", "source_location": "L1"}, {"id": "e2e_smoke_suite_smoke_suit_7fcfd_ntact_form_submission_works_chromium_error_context_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-7fcfd-ntact-form-submission-works-chromium/error-context.md", "source_location": "L1"}, {"id": "e2e_smoke_suite_smoke_suit_7fcfd_ntact_form_submission_works_chromium_error_context_codeblock_1", "label": "code:yaml (- generic [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-7fcfd-ntact-form-submission-works-chromium/error-context.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_test_results_e2e_smoke_suite_smoke_suit_7fcfd_ntact_form_submission_works_chromium_error_context_md", "target": "e2e_smoke_suite_smoke_suit_7fcfd_ntact_form_submission_works_chromium_error_context_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-7fcfd-ntact-form-submission-works-chromium/error-context.md", "source_location": "L1", "weight": 1.0}, {"source": "e2e_smoke_suite_smoke_suit_7fcfd_ntact_form_submission_works_chromium_error_context_page_snapshot", "target": "e2e_smoke_suite_smoke_suit_7fcfd_ntact_form_submission_works_chromium_error_context_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-7fcfd-ntact-form-submission-works-chromium/error-context.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_e913a96525d005a5baae8ddb2fbea15bbc53abb1_md", "label": "e913a96525d005a5baae8ddb2fbea15bbc53abb1.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/e913a96525d005a5baae8ddb2fbea15bbc53abb1.md", "source_location": "L1"}, {"id": "data_e913a96525d005a5baae8ddb2fbea15bbc53abb1_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/e913a96525d005a5baae8ddb2fbea15bbc53abb1.md", "source_location": "L1"}, {"id": "data_e913a96525d005a5baae8ddb2fbea15bbc53abb1_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/e913a96525d005a5baae8ddb2fbea15bbc53abb1.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_e913a96525d005a5baae8ddb2fbea15bbc53abb1_md", "target": "data_e913a96525d005a5baae8ddb2fbea15bbc53abb1_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/e913a96525d005a5baae8ddb2fbea15bbc53abb1.md", "source_location": "L1", "weight": 1.0}, {"source": "data_e913a96525d005a5baae8ddb2fbea15bbc53abb1_page_snapshot", "target": "data_e913a96525d005a5baae8ddb2fbea15bbc53abb1_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/e913a96525d005a5baae8ddb2fbea15bbc53abb1.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_cc0ed5549c339ad261436d0f63d13eb3259a9dde_md", "label": "cc0ed5549c339ad261436d0f63d13eb3259a9dde.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/cc0ed5549c339ad261436d0f63d13eb3259a9dde.md", "source_location": "L1"}, {"id": "data_cc0ed5549c339ad261436d0f63d13eb3259a9dde_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/cc0ed5549c339ad261436d0f63d13eb3259a9dde.md", "source_location": "L1"}, {"id": "data_cc0ed5549c339ad261436d0f63d13eb3259a9dde_codeblock_1", "label": "code:yaml (- generic [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/cc0ed5549c339ad261436d0f63d13eb3259a9dde.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_cc0ed5549c339ad261436d0f63d13eb3259a9dde_md", "target": "data_cc0ed5549c339ad261436d0f63d13eb3259a9dde_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/cc0ed5549c339ad261436d0f63d13eb3259a9dde.md", "source_location": "L1", "weight": 1.0}, {"source": "data_cc0ed5549c339ad261436d0f63d13eb3259a9dde_page_snapshot", "target": "data_cc0ed5549c339ad261436d0f63d13eb3259a9dde_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/cc0ed5549c339ad261436d0f63d13eb3259a9dde.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_tests_sticky_cta_spec_ts", "label": "sticky-cta.spec.ts", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/tests/sticky-cta.spec.ts", "source_location": "L1"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_tests_sticky_cta_spec_ts", "target": "test", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/tests/sticky-cta.spec.ts", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_690bd8f0a073eceeb87b8e46e681123950f08f93_md", "label": "690bd8f0a073eceeb87b8e46e681123950f08f93.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/690bd8f0a073eceeb87b8e46e681123950f08f93.md", "source_location": "L1"}, {"id": "data_690bd8f0a073eceeb87b8e46e681123950f08f93_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/690bd8f0a073eceeb87b8e46e681123950f08f93.md", "source_location": "L1"}, {"id": "data_690bd8f0a073eceeb87b8e46e681123950f08f93_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/690bd8f0a073eceeb87b8e46e681123950f08f93.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_690bd8f0a073eceeb87b8e46e681123950f08f93_md", "target": "data_690bd8f0a073eceeb87b8e46e681123950f08f93_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/690bd8f0a073eceeb87b8e46e681123950f08f93.md", "source_location": "L1", "weight": 1.0}, {"source": "data_690bd8f0a073eceeb87b8e46e681123950f08f93_page_snapshot", "target": "data_690bd8f0a073eceeb87b8e46e681123950f08f93_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/690bd8f0a073eceeb87b8e46e681123950f08f93.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_806b690efd1ee745a1991725b2d2aea1b5c435f2_md", "label": "806b690efd1ee745a1991725b2d2aea1b5c435f2.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/806b690efd1ee745a1991725b2d2aea1b5c435f2.md", "source_location": "L1"}, {"id": "data_806b690efd1ee745a1991725b2d2aea1b5c435f2_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/806b690efd1ee745a1991725b2d2aea1b5c435f2.md", "source_location": "L1"}, {"id": "data_806b690efd1ee745a1991725b2d2aea1b5c435f2_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/806b690efd1ee745a1991725b2d2aea1b5c435f2.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_806b690efd1ee745a1991725b2d2aea1b5c435f2_md", "target": "data_806b690efd1ee745a1991725b2d2aea1b5c435f2_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/806b690efd1ee745a1991725b2d2aea1b5c435f2.md", "source_location": "L1", "weight": 1.0}, {"source": "data_806b690efd1ee745a1991725b2d2aea1b5c435f2_page_snapshot", "target": "data_806b690efd1ee745a1991725b2d2aea1b5c435f2_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/806b690efd1ee745a1991725b2d2aea1b5c435f2.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_32e7238c826ab5be125e270cc69efc4a37a8ffb3_md", "label": "32e7238c826ab5be125e270cc69efc4a37a8ffb3.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/32e7238c826ab5be125e270cc69efc4a37a8ffb3.md", "source_location": "L1"}, {"id": "data_32e7238c826ab5be125e270cc69efc4a37a8ffb3_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/32e7238c826ab5be125e270cc69efc4a37a8ffb3.md", "source_location": "L1"}, {"id": "data_32e7238c826ab5be125e270cc69efc4a37a8ffb3_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/32e7238c826ab5be125e270cc69efc4a37a8ffb3.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_32e7238c826ab5be125e270cc69efc4a37a8ffb3_md", "target": "data_32e7238c826ab5be125e270cc69efc4a37a8ffb3_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/32e7238c826ab5be125e270cc69efc4a37a8ffb3.md", "source_location": "L1", "weight": 1.0}, {"source": "data_32e7238c826ab5be125e270cc69efc4a37a8ffb3_page_snapshot", "target": "data_32e7238c826ab5be125e270cc69efc4a37a8ffb3_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/32e7238c826ab5be125e270cc69efc4a37a8ffb3.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_tests_inline_lead_form_spec_ts", "label": "inline-lead-form.spec.ts", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/tests/inline-lead-form.spec.ts", "source_location": "L1"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_tests_inline_lead_form_spec_ts", "target": "test", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/tests/inline-lead-form.spec.ts", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_c0712711f6dffbdf3e61da69d7bf618fa7d12cce_md", "label": "c0712711f6dffbdf3e61da69d7bf618fa7d12cce.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/c0712711f6dffbdf3e61da69d7bf618fa7d12cce.md", "source_location": "L1"}, {"id": "data_c0712711f6dffbdf3e61da69d7bf618fa7d12cce_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/c0712711f6dffbdf3e61da69d7bf618fa7d12cce.md", "source_location": "L1"}, {"id": "data_c0712711f6dffbdf3e61da69d7bf618fa7d12cce_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/c0712711f6dffbdf3e61da69d7bf618fa7d12cce.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_c0712711f6dffbdf3e61da69d7bf618fa7d12cce_md", "target": "data_c0712711f6dffbdf3e61da69d7bf618fa7d12cce_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/c0712711f6dffbdf3e61da69d7bf618fa7d12cce.md", "source_location": "L1", "weight": 1.0}, {"source": "data_c0712711f6dffbdf3e61da69d7bf618fa7d12cce_page_snapshot", "target": "data_c0712711f6dffbdf3e61da69d7bf618fa7d12cce_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/c0712711f6dffbdf3e61da69d7bf618fa7d12cce.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "label": "middleware.ts", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L1"}, {"id": "src_middleware_validatecsrforigin", "label": "validateCsrfOrigin()", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L19"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "astro_middleware", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L1", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_utils_logger_ts", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L2", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "utils_logger_logger", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L2", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_utils_sentry_ts", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L3", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "utils_sentry_captureexception", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L3", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_middleware_ts", "target": "src_middleware_validatecsrforigin", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L19", "weight": 1.0}], "raw_calls": [{"caller_nid": "src_middleware_validatecsrforigin", "callee": "includes", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L24"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "startsWith", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L25"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "get", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L36"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "get", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L37"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "startsWith", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L52"}, {"caller_nid": "src_middleware_validatecsrforigin", "callee": "startsWith", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/middleware.ts", "source_location": "L52"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_0f6bc059f79105264bd8619a97b50f0c07ebb8d8_md", "label": "0f6bc059f79105264bd8619a97b50f0c07ebb8d8.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/0f6bc059f79105264bd8619a97b50f0c07ebb8d8.md", "source_location": "L1"}, {"id": "data_0f6bc059f79105264bd8619a97b50f0c07ebb8d8_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/0f6bc059f79105264bd8619a97b50f0c07ebb8d8.md", "source_location": "L1"}, {"id": "data_0f6bc059f79105264bd8619a97b50f0c07ebb8d8_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/0f6bc059f79105264bd8619a97b50f0c07ebb8d8.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_0f6bc059f79105264bd8619a97b50f0c07ebb8d8_md", "target": "data_0f6bc059f79105264bd8619a97b50f0c07ebb8d8_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/0f6bc059f79105264bd8619a97b50f0c07ebb8d8.md", "source_location": "L1", "weight": 1.0}, {"source": "data_0f6bc059f79105264bd8619a97b50f0c07ebb8d8_page_snapshot", "target": "data_0f6bc059f79105264bd8619a97b50f0c07ebb8d8_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/0f6bc059f79105264bd8619a97b50f0c07ebb8d8.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_pages_sitemap_xml_ts", "label": "sitemap.xml.ts", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/pages/sitemap.xml.ts", "source_location": "L1"}, {"id": "pages_sitemap_xml_get", "label": "GET()", "file_type": "code", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/pages/sitemap.xml.ts", "source_location": "L13"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_pages_sitemap_xml_ts", "target": "astro", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/pages/sitemap.xml.ts", "source_location": "L1", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_pages_sitemap_xml_ts", "target": "astro_content", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/pages/sitemap.xml.ts", "source_location": "L2", "weight": 1.0}, {"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_src_pages_sitemap_xml_ts", "target": "pages_sitemap_xml_get", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/pages/sitemap.xml.ts", "source_location": "L13", "weight": 1.0}], "raw_calls": [{"caller_nid": "pages_sitemap_xml_get", "callee": "getCollection", "is_member_call": false, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/pages/sitemap.xml.ts", "source_location": "L15"}, {"caller_nid": "pages_sitemap_xml_get", "callee": "getCollection", "is_member_call": false, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/pages/sitemap.xml.ts", "source_location": "L20"}, {"caller_nid": "pages_sitemap_xml_get", "callee": "map", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/pages/sitemap.xml.ts", "source_location": "L43"}, {"caller_nid": "pages_sitemap_xml_get", "callee": "map", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/pages/sitemap.xml.ts", "source_location": "L52"}, {"caller_nid": "pages_sitemap_xml_get", "callee": "join", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/pages/sitemap.xml.ts", "source_location": "L69"}, {"caller_nid": "pages_sitemap_xml_get", "callee": "map", "is_member_call": true, "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/src/pages/sitemap.xml.ts", "source_location": "L69"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_test_results_e2e_smoke_suite_smoke_suit_742f0_enders_correctly_on_desktop_chromium_error_context_md", "label": "error-context.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-742f0-enders-correctly-on-Desktop-chromium/error-context.md", "source_location": "L1"}, {"id": "e2e_smoke_suite_smoke_suit_742f0_enders_correctly_on_desktop_chromium_error_context_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-742f0-enders-correctly-on-Desktop-chromium/error-context.md", "source_location": "L1"}, {"id": "e2e_smoke_suite_smoke_suit_742f0_enders_correctly_on_desktop_chromium_error_context_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-742f0-enders-correctly-on-Desktop-chromium/error-context.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_test_results_e2e_smoke_suite_smoke_suit_742f0_enders_correctly_on_desktop_chromium_error_context_md", "target": "e2e_smoke_suite_smoke_suit_742f0_enders_correctly_on_desktop_chromium_error_context_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-742f0-enders-correctly-on-Desktop-chromium/error-context.md", "source_location": "L1", "weight": 1.0}, {"source": "e2e_smoke_suite_smoke_suit_742f0_enders_correctly_on_desktop_chromium_error_context_page_snapshot", "target": "e2e_smoke_suite_smoke_suit_742f0_enders_correctly_on_desktop_chromium_error_context_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/test-results/e2e-smoke-suite-Smoke-Suit-742f0-enders-correctly-on-Desktop-chromium/error-context.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_bbfcf94d298e196a738e1c4aa9fcbebf09850749_md", "label": "bbfcf94d298e196a738e1c4aa9fcbebf09850749.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/bbfcf94d298e196a738e1c4aa9fcbebf09850749.md", "source_location": "L1"}, {"id": "data_bbfcf94d298e196a738e1c4aa9fcbebf09850749_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/bbfcf94d298e196a738e1c4aa9fcbebf09850749.md", "source_location": "L1"}, {"id": "data_bbfcf94d298e196a738e1c4aa9fcbebf09850749_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/bbfcf94d298e196a738e1c4aa9fcbebf09850749.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_bbfcf94d298e196a738e1c4aa9fcbebf09850749_md", "target": "data_bbfcf94d298e196a738e1c4aa9fcbebf09850749_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/bbfcf94d298e196a738e1c4aa9fcbebf09850749.md", "source_location": "L1", "weight": 1.0}, {"source": "data_bbfcf94d298e196a738e1c4aa9fcbebf09850749_page_snapshot", "target": "data_bbfcf94d298e196a738e1c4aa9fcbebf09850749_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/bbfcf94d298e196a738e1c4aa9fcbebf09850749.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_6604b3783e14b0fe2e620b288e8828b6f55be330_md", "label": "6604b3783e14b0fe2e620b288e8828b6f55be330.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/6604b3783e14b0fe2e620b288e8828b6f55be330.md", "source_location": "L1"}, {"id": "data_6604b3783e14b0fe2e620b288e8828b6f55be330_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/6604b3783e14b0fe2e620b288e8828b6f55be330.md", "source_location": "L1"}, {"id": "data_6604b3783e14b0fe2e620b288e8828b6f55be330_codeblock_1", "label": "code:yaml (- generic [active] [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/6604b3783e14b0fe2e620b288e8828b6f55be330.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_6604b3783e14b0fe2e620b288e8828b6f55be330_md", "target": "data_6604b3783e14b0fe2e620b288e8828b6f55be330_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/6604b3783e14b0fe2e620b288e8828b6f55be330.md", "source_location": "L1", "weight": 1.0}, {"source": "data_6604b3783e14b0fe2e620b288e8828b6f55be330_page_snapshot", "target": "data_6604b3783e14b0fe2e620b288e8828b6f55be330_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/6604b3783e14b0fe2e620b288e8828b6f55be330.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_9b3c49237ac0c6da58c2e4aee5df00459559a271_md", "label": "9b3c49237ac0c6da58c2e4aee5df00459559a271.md", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/9b3c49237ac0c6da58c2e4aee5df00459559a271.md", "source_location": "L1"}, {"id": "data_9b3c49237ac0c6da58c2e4aee5df00459559a271_page_snapshot", "label": "Page snapshot", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/9b3c49237ac0c6da58c2e4aee5df00459559a271.md", "source_location": "L1"}, {"id": "data_9b3c49237ac0c6da58c2e4aee5df00459559a271_codeblock_1", "label": "code:yaml (- generic [ref=e1]:)", "file_type": "document", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/9b3c49237ac0c6da58c2e4aee5df00459559a271.md", "source_location": "L3"}], "edges": [{"source": "home_agent_ai_agent_backend_workspace_2e709500_79ab_403f_9ca0_3f027ac9f36e_playwright_report_data_9b3c49237ac0c6da58c2e4aee5df00459559a271_md", "target": "data_9b3c49237ac0c6da58c2e4aee5df00459559a271_page_snapshot", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/9b3c49237ac0c6da58c2e4aee5df00459559a271.md", "source_location": "L1", "weight": 1.0}, {"source": "data_9b3c49237ac0c6da58c2e4aee5df00459559a271_page_snapshot", "target": "data_9b3c49237ac0c6da58c2e4aee5df00459559a271_codeblock_1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/agent/ai-agent/backend/workspace/2e709500-79ab-403f-9ca0-3f027ac9f36e/playwright-report/data/9b3c49237ac0c6da58c2e4aee5df00459559a271.md", "source_location": "L3", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -45,10 +45,13 @@
|
||||
"nodemailer": "^8.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright": "^1.58.2",
|
||||
"tailwindcss": "^3.4.1"
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
['list']
|
||||
],
|
||||
use: {
|
||||
baseURL: 'http://localhost:10000',
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:10000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 495 B After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 410 B After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 48" width="160" height="48" role="img" aria-label="Government of Bihar logo placeholder">
|
||||
<title>Govt. of Bihar</title>
|
||||
<circle cx="20" cy="24" r="13" fill="#15803d"/>
|
||||
<path d="M20 13 L21.8 19 L28 19 L23 22.6 L24.8 28.8 L20 25 L15.2 28.8 L17 22.6 L12 19 L18.2 19 Z" fill="#fef3c7"/>
|
||||
<text x="40" y="22" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="800" font-size="13" fill="#0f172a" letter-spacing="0.3">GOVT. OF BIHAR</text>
|
||||
<text x="40" y="36" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="500" font-size="9" fill="#475569" letter-spacing="0.8">11 DISTRICTS · PSCL</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 718 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 48" width="160" height="48" role="img" aria-label="IIT Patna logo placeholder">
|
||||
<title>IIT Patna</title>
|
||||
<path d="M20 8 L34 18 L34 38 L6 38 L6 18 Z" fill="#b91c1c"/>
|
||||
<rect x="12" y="24" width="4" height="12" fill="#fef3c7"/>
|
||||
<rect x="18" y="24" width="4" height="12" fill="#fef3c7"/>
|
||||
<rect x="24" y="24" width="4" height="12" fill="#fef3c7"/>
|
||||
<rect x="6" y="22" width="28" height="2" fill="#fef3c7"/>
|
||||
<text x="42" y="22" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="800" font-size="14" fill="#0f172a" letter-spacing="0.5">IIT PATNA</text>
|
||||
<text x="42" y="36" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="500" font-size="9" fill="#475569" letter-spacing="1">UNIVERSITY SYSTEM</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 832 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 48" width="160" height="48" role="img" aria-label="MOC Soft Technologies logo placeholder">
|
||||
<title>MOC Soft</title>
|
||||
<rect x="6" y="12" width="24" height="24" rx="4" fill="#7c3aed"/>
|
||||
<text x="18" y="30" text-anchor="middle" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="800" font-size="13" fill="#ffffff" letter-spacing="-0.5">MOC</text>
|
||||
<text x="38" y="24" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="800" font-size="14" fill="#0f172a" letter-spacing="0">MOC Soft</text>
|
||||
<text x="38" y="38" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="500" font-size="9" fill="#475569" letter-spacing="0.8">TECHNOLOGIES</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 790 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 48" width="160" height="48" role="img" aria-label="PLNR logo placeholder">
|
||||
<title>PLNR</title>
|
||||
<rect x="6" y="10" width="28" height="28" rx="6" fill="#1d4ed8"/>
|
||||
<text x="20" y="29" text-anchor="middle" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="800" font-size="14" fill="#ffffff" letter-spacing="-0.5">P</text>
|
||||
<text x="44" y="31" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="800" font-size="20" fill="#1e293b" letter-spacing="-0.5">PLNR</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 48" width="160" height="48" role="img" aria-label="PSCL logo placeholder">
|
||||
<title>PSCL</title>
|
||||
<rect x="6" y="10" width="28" height="28" rx="14" fill="#0891b2"/>
|
||||
<text x="20" y="29" text-anchor="middle" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="800" font-size="13" fill="#ffffff" letter-spacing="-0.3">PS</text>
|
||||
<text x="44" y="22" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="800" font-size="18" fill="#0f172a" letter-spacing="0">PSCL</text>
|
||||
<text x="44" y="36" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="500" font-size="9" fill="#475569" letter-spacing="0.8">BIHAR · SCMS</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 766 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 48" width="160" height="48" role="img" aria-label="RENTEC logo placeholder">
|
||||
<title>RENTEC</title>
|
||||
<circle cx="20" cy="24" r="12" fill="#0d9488"/>
|
||||
<path d="M14 20h8a3 3 0 0 1 0 6h-3l4 4h-3l-4-4h-2v4h-2V20zm2 2v2h6a1 1 0 0 0 0-2h-6z" fill="#ffffff"/>
|
||||
<text x="40" y="31" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="800" font-size="18" fill="#0f172a" letter-spacing="0.5">RENTEC</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 492 B |
@@ -0,0 +1,113 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 720" width="1200" height="720" role="img" aria-label="WorkRoot project dashboard preview">
|
||||
<defs>
|
||||
<linearGradient id="wr-accent" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#22d3ee"/>
|
||||
<stop offset="1" stop-color="#0891b2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wr-bar" x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0" stop-color="#0891b2"/>
|
||||
<stop offset="1" stop-color="#22d3ee"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wr-area" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0891b2" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#0891b2" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- App canvas -->
|
||||
<rect width="1200" height="720" fill="#f1f5f9"/>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<rect width="240" height="720" fill="#0f172a"/>
|
||||
<circle cx="44" cy="52" r="14" fill="url(#wr-accent)"/>
|
||||
<rect x="68" y="44" width="104" height="16" rx="6" fill="#e2e8f0"/>
|
||||
<!-- Nav items -->
|
||||
<rect x="20" y="108" width="200" height="40" rx="10" fill="#0e7490"/>
|
||||
<rect x="40" y="120" width="18" height="16" rx="4" fill="#67e8f9"/>
|
||||
<rect x="70" y="121" width="120" height="14" rx="5" fill="#e2e8f0"/>
|
||||
<g fill="#334155">
|
||||
<rect x="40" y="172" width="18" height="16" rx="4"/>
|
||||
<rect x="40" y="224" width="18" height="16" rx="4"/>
|
||||
<rect x="40" y="276" width="18" height="16" rx="4"/>
|
||||
<rect x="40" y="328" width="18" height="16" rx="4"/>
|
||||
</g>
|
||||
<g fill="#475569">
|
||||
<rect x="70" y="173" width="110" height="14" rx="5"/>
|
||||
<rect x="70" y="225" width="96" height="14" rx="5"/>
|
||||
<rect x="70" y="277" width="124" height="14" rx="5"/>
|
||||
<rect x="70" y="329" width="84" height="14" rx="5"/>
|
||||
</g>
|
||||
|
||||
<!-- Top header -->
|
||||
<rect x="240" width="960" height="80" fill="#ffffff"/>
|
||||
<line x1="240" y1="80" x2="1200" y2="80" stroke="#e2e8f0" stroke-width="2"/>
|
||||
<rect x="276" y="30" width="220" height="20" rx="7" fill="#0f172a"/>
|
||||
<rect x="980" y="26" width="150" height="28" rx="14" fill="#f1f5f9"/>
|
||||
<circle cx="1150" cy="40" r="18" fill="url(#wr-accent)"/>
|
||||
|
||||
<!-- Stat cards -->
|
||||
<g>
|
||||
<rect x="276" y="116" width="280" height="120" rx="16" fill="#ffffff" stroke="#e2e8f0" stroke-width="2"/>
|
||||
<rect x="300" y="140" width="100" height="12" rx="5" fill="#94a3b8"/>
|
||||
<text x="300" y="196" font-family="Plus Jakarta Sans, Segoe UI, sans-serif" font-size="44" font-weight="700" fill="#0f172a">20+</text>
|
||||
<rect x="476" y="160" width="56" height="56" rx="14" fill="#ecfeff"/>
|
||||
<rect x="492" y="180" width="24" height="16" rx="4" fill="#0891b2"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="588" y="116" width="280" height="120" rx="16" fill="#ffffff" stroke="#e2e8f0" stroke-width="2"/>
|
||||
<rect x="612" y="140" width="120" height="12" rx="5" fill="#94a3b8"/>
|
||||
<text x="612" y="196" font-family="Plus Jakarta Sans, Segoe UI, sans-serif" font-size="44" font-weight="700" fill="#0f172a">11</text>
|
||||
<rect x="788" y="160" width="56" height="56" rx="14" fill="#fffbeb"/>
|
||||
<rect x="804" y="180" width="24" height="16" rx="4" fill="#f59e0b"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="900" y="116" width="280" height="120" rx="16" fill="#ffffff" stroke="#e2e8f0" stroke-width="2"/>
|
||||
<rect x="924" y="140" width="140" height="12" rx="5" fill="#94a3b8"/>
|
||||
<text x="924" y="196" font-family="Plus Jakarta Sans, Segoe UI, sans-serif" font-size="44" font-weight="700" fill="#0f172a">50,000+</text>
|
||||
<rect x="1100" y="160" width="56" height="56" rx="14" fill="#ecfeff"/>
|
||||
<rect x="1116" y="180" width="24" height="16" rx="4" fill="#0891b2"/>
|
||||
</g>
|
||||
|
||||
<!-- Chart card -->
|
||||
<rect x="276" y="268" width="556" height="416" rx="16" fill="#ffffff" stroke="#e2e8f0" stroke-width="2"/>
|
||||
<rect x="300" y="296" width="160" height="16" rx="6" fill="#0f172a"/>
|
||||
<rect x="300" y="324" width="100" height="12" rx="5" fill="#94a3b8"/>
|
||||
<!-- Area line -->
|
||||
<path d="M300 560 L370 520 L440 540 L510 470 L580 500 L650 430 L720 450 L808 380"
|
||||
fill="none" stroke="url(#wr-bar)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M300 560 L370 520 L440 540 L510 470 L580 500 L650 430 L720 450 L808 380 L808 650 L300 650 Z"
|
||||
fill="url(#wr-area)"/>
|
||||
<g fill="#0891b2">
|
||||
<circle cx="370" cy="520" r="6"/>
|
||||
<circle cx="510" cy="470" r="6"/>
|
||||
<circle cx="650" cy="430" r="6"/>
|
||||
<circle cx="808" cy="380" r="6"/>
|
||||
</g>
|
||||
<line x1="300" y1="650" x2="808" y2="650" stroke="#e2e8f0" stroke-width="2"/>
|
||||
|
||||
<!-- Activity / table card -->
|
||||
<rect x="860" y="268" width="320" height="416" rx="16" fill="#ffffff" stroke="#e2e8f0" stroke-width="2"/>
|
||||
<rect x="884" y="296" width="150" height="16" rx="6" fill="#0f172a"/>
|
||||
<g>
|
||||
<circle cx="900" cy="356" r="12" fill="#ecfeff"/>
|
||||
<rect x="924" y="344" width="180" height="12" rx="5" fill="#334155"/>
|
||||
<rect x="924" y="364" width="110" height="10" rx="5" fill="#94a3b8"/>
|
||||
</g>
|
||||
<g>
|
||||
<circle cx="900" cy="412" r="12" fill="#fffbeb"/>
|
||||
<rect x="924" y="400" width="160" height="12" rx="5" fill="#334155"/>
|
||||
<rect x="924" y="420" width="130" height="10" rx="5" fill="#94a3b8"/>
|
||||
</g>
|
||||
<g>
|
||||
<circle cx="900" cy="468" r="12" fill="#ecfeff"/>
|
||||
<rect x="924" y="456" width="190" height="12" rx="5" fill="#334155"/>
|
||||
<rect x="924" y="476" width="96" height="10" rx="5" fill="#94a3b8"/>
|
||||
</g>
|
||||
<g>
|
||||
<circle cx="900" cy="524" r="12" fill="#fffbeb"/>
|
||||
<rect x="924" y="512" width="150" height="12" rx="5" fill="#334155"/>
|
||||
<rect x="924" y="532" width="120" height="10" rx="5" fill="#94a3b8"/>
|
||||
</g>
|
||||
<rect x="884" y="600" width="272" height="48" rx="12" fill="url(#wr-accent)"/>
|
||||
<rect x="980" y="617" width="80" height="14" rx="6" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600" role="img" aria-label="Placeholder portrait for Ajay Ghanwat — replace with real headshot">
|
||||
<defs>
|
||||
<linearGradient id="ag-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0891b2"/>
|
||||
<stop offset="100%" stop-color="#155e75"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="800" height="600" fill="url(#ag-grad)"/>
|
||||
<text x="400" y="300" text-anchor="middle" dominant-baseline="central" font-family="'Plus Jakarta Sans',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="700" font-size="220" fill="#ffffff" letter-spacing="-6">AG</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 690 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600" role="img" aria-label="Placeholder portrait for Ashok Jamdade — replace with real headshot">
|
||||
<defs>
|
||||
<linearGradient id="aj-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0891b2"/>
|
||||
<stop offset="100%" stop-color="#155e75"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="800" height="600" fill="url(#aj-grad)"/>
|
||||
<text x="400" y="300" text-anchor="middle" dominant-baseline="central" font-family="'Plus Jakarta Sans',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="700" font-size="220" fill="#ffffff" letter-spacing="-6">AJ</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 691 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600" role="img" aria-label="Placeholder portrait for Swapnali Vachkal — replace with real headshot">
|
||||
<defs>
|
||||
<linearGradient id="sv-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0891b2"/>
|
||||
<stop offset="100%" stop-color="#155e75"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="800" height="600" fill="url(#sv-grad)"/>
|
||||
<text x="400" y="300" text-anchor="middle" dominant-baseline="central" font-family="'Plus Jakarta Sans',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="700" font-size="220" fill="#ffffff" letter-spacing="-6">SV</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 694 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200" role="img" aria-label="Avatar placeholder for Ajay Pruthi">
|
||||
<defs>
|
||||
<linearGradient id="ap-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#1d4ed8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="200" height="200" rx="100" fill="url(#ap-grad)"/>
|
||||
<text x="100" y="100" text-anchor="middle" dominant-baseline="central" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="700" font-size="78" fill="#ffffff" letter-spacing="-2">AP</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 644 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200" role="img" aria-label="Avatar placeholder for Dhirendra Singh">
|
||||
<defs>
|
||||
<linearGradient id="ds-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f59e0b"/>
|
||||
<stop offset="100%" stop-color="#b45309"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="200" height="200" rx="100" fill="url(#ds-grad)"/>
|
||||
<text x="100" y="100" text-anchor="middle" dominant-baseline="central" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="700" font-size="78" fill="#ffffff" letter-spacing="-2">DS</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 648 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200" role="img" aria-label="Avatar placeholder for Dilip Raj">
|
||||
<defs>
|
||||
<linearGradient id="dr-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#10b981"/>
|
||||
<stop offset="100%" stop-color="#047857"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="200" height="200" rx="100" fill="url(#dr-grad)"/>
|
||||
<text x="100" y="100" text-anchor="middle" dominant-baseline="central" font-family="system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" font-weight="700" font-size="78" fill="#ffffff" letter-spacing="-2">DR</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 642 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 28 KiB |
@@ -11,6 +11,18 @@
|
||||
"lang": "en-US",
|
||||
"categories": ["business", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
|
||||
@@ -58,6 +58,9 @@ Allow: /
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Allow: /
|
||||
|
||||
# Anthropic/Claude
|
||||
User-agent: anthropic-ai
|
||||
Allow: /
|
||||
@@ -65,10 +68,22 @@ Allow: /
|
||||
User-agent: Claude-Web
|
||||
Allow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-User
|
||||
Allow: /
|
||||
|
||||
# Perplexity AI
|
||||
User-agent: PerplexityBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Perplexity-User
|
||||
Allow: /
|
||||
|
||||
# Google Gemini/Bard
|
||||
User-agent: Google-Extended
|
||||
Allow: /
|
||||
@@ -80,3 +95,40 @@ Allow: /
|
||||
# Meta AI
|
||||
User-agent: FacebookBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Meta-ExternalAgent
|
||||
Allow: /
|
||||
|
||||
User-agent: meta-externalagent
|
||||
Allow: /
|
||||
|
||||
# Apple Intelligence
|
||||
User-agent: Applebot
|
||||
Allow: /
|
||||
|
||||
User-agent: Applebot-Extended
|
||||
Allow: /
|
||||
|
||||
# Amazon AI
|
||||
User-agent: Amazonbot
|
||||
Allow: /
|
||||
|
||||
# ByteDance / TikTok AI
|
||||
User-agent: Bytespider
|
||||
Allow: /
|
||||
|
||||
# Cohere AI
|
||||
User-agent: cohere-ai
|
||||
Allow: /
|
||||
|
||||
# DuckDuckGo AI (DuckAssist)
|
||||
User-agent: DuckAssistBot
|
||||
Allow: /
|
||||
|
||||
# You.com AI
|
||||
User-agent: YouBot
|
||||
Allow: /
|
||||
|
||||
# Diffbot (knowledge graph / AI training)
|
||||
User-agent: Diffbot
|
||||
Allow: /
|
||||
|
||||
@@ -15,6 +15,31 @@
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://workroot.in/services/custom-erp-development/</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://workroot.in/services/university-erp/</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://workroot.in/services/government-it-solutions-gem-vendor/</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://workroot.in/services/police-management-system/</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://workroot.in/services/react-native-app-development/</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://workroot.in/portfolio/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/*!
|
||||
* WRNexus Web Push — service worker.
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
* │ HOST THIS FILE ON YOUR OWN ORIGIN. │
|
||||
* │ │
|
||||
* │ A service worker can only control the origin it is served from, so the │
|
||||
* │ browser will NOT let wrnexus.com register a worker for your site. Copy │
|
||||
* │ this file to the ROOT of your site and serve it at: │
|
||||
* │ │
|
||||
* │ https://your-domain.example/wrnexus-push-sw.js │
|
||||
* │ │
|
||||
* │ It must be served same-origin, over HTTPS, with │
|
||||
* │ `Content-Type: text/javascript`. To control the whole site from a │
|
||||
* │ non-root path, also send `Service-Worker-Allowed: /`. │
|
||||
* └─────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* Core behaviour needs NO configuration — the click/delivery tracking URLs are
|
||||
* carried inside each push payload built by the WRNexus API. The optional
|
||||
* CONFIG block below is only used to silently re-subscribe after the browser
|
||||
* rotates a subscription (`pushsubscriptionchange`); leave it blank to skip
|
||||
* that.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
// ── OPTIONAL: only needed for automatic re-subscription on key rotation ──────
|
||||
var WRNEXUS_PUSH_CONFIG = {
|
||||
// Your site's publicKey (the same value as data-site-id in the snippet).
|
||||
siteKey: '',
|
||||
// The WRNexus API origin (e.g. 'https://api.wrnexus.com').
|
||||
apiBase: '',
|
||||
// Your site's VAPID public key (shown in Push → Settings).
|
||||
vapidPublicKey: '',
|
||||
};
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// CORS-simple POST (text/plain, no custom headers, no credentials) so the
|
||||
// receipt reaches the WRNexus API cross-origin without a preflight.
|
||||
function postReceipt(url) {
|
||||
if (!url) return Promise.resolve();
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
|
||||
credentials: 'omit',
|
||||
keepalive: true,
|
||||
mode: 'cors',
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
var padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
var raw = self.atob(base64);
|
||||
var out = new Uint8Array(raw.length);
|
||||
for (var i = 0; i < raw.length; ++i) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function keyToB64(buf) {
|
||||
if (!buf) return '';
|
||||
var bytes = new Uint8Array(buf);
|
||||
var binary = '';
|
||||
for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return self
|
||||
.btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// ── Incoming notification ─────────────────────────────────────────────────
|
||||
self.addEventListener('push', function (event) {
|
||||
var data = {};
|
||||
try {
|
||||
data = event.data ? event.data.json() : {};
|
||||
} catch (_e) {
|
||||
try {
|
||||
data = { title: event.data && event.data.text() };
|
||||
} catch (_e2) {
|
||||
data = {};
|
||||
}
|
||||
}
|
||||
|
||||
var title = data.title || 'Notification';
|
||||
var options = {
|
||||
body: data.body || '',
|
||||
icon: data.icon || undefined,
|
||||
image: data.image || undefined,
|
||||
badge: data.badge || undefined,
|
||||
tag: data.tag || data.campaignId || undefined,
|
||||
requireInteraction: !!data.requireInteraction,
|
||||
data: {
|
||||
clickUrl: data.clickUrl || data.url || '/',
|
||||
url: data.url || '/',
|
||||
openUrl: data.openUrl || '',
|
||||
campaignId: data.campaignId || '',
|
||||
subscriptionId: data.subscriptionId || '',
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
self.registration.showNotification(title, options),
|
||||
// Delivery receipt — records a `delivered` event for analytics.
|
||||
postReceipt(data.deliveredUrl),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
// ── Click → record + open the destination ─────────────────────────────────
|
||||
self.addEventListener('notificationclick', function (event) {
|
||||
var d = (event.notification && event.notification.data) || {};
|
||||
event.notification.close();
|
||||
|
||||
// `clickUrl` is a signed WRNexus redirect: hitting it records the `clicked`
|
||||
// event and 302s to the real destination, so tracking fires from the open.
|
||||
var target = d.clickUrl || d.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (list) {
|
||||
// Reuse an existing tab on the destination when possible.
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var client = list[i];
|
||||
if (d.url && client.url === d.url && 'focus' in client) return client.focus();
|
||||
}
|
||||
if (self.clients.openWindow) return self.clients.openWindow(target);
|
||||
return undefined;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// ── Optional: re-subscribe after the browser rotates the subscription ──────
|
||||
self.addEventListener('pushsubscriptionchange', function (event) {
|
||||
var cfg = WRNEXUS_PUSH_CONFIG || {};
|
||||
if (!cfg.siteKey || !cfg.apiBase || !cfg.vapidPublicKey) return;
|
||||
event.waitUntil(
|
||||
self.registration.pushManager
|
||||
.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(cfg.vapidPublicKey) })
|
||||
.then(function (sub) {
|
||||
return fetch(cfg.apiBase.replace(/\/+$/, '') + '/api/push/subscriptions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
|
||||
credentials: 'omit',
|
||||
mode: 'cors',
|
||||
body: JSON.stringify({
|
||||
key: cfg.siteKey,
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: keyToB64(sub.getKey('p256dh')),
|
||||
auth: keyToB64(sub.getKey('auth')),
|
||||
},
|
||||
metadata: { source: 'pushsubscriptionchange' },
|
||||
}),
|
||||
}).catch(function () {});
|
||||
})
|
||||
.catch(function () {})
|
||||
);
|
||||
});
|
||||
@@ -44,9 +44,7 @@ const currentYear = new Date().getFullYear();
|
||||
<!-- Company Info -->
|
||||
<div class="lg:col-span-1">
|
||||
<a href="/" class="inline-flex items-center gap-2 mb-4 group" aria-label="WorkRoot IT Solutions LLP - Home">
|
||||
<div class="w-10 h-10 bg-primary rounded-lg flex items-center justify-center transition-transform group-hover:scale-105" aria-hidden="true">
|
||||
<span class="text-white font-bold text-lg">W</span>
|
||||
</div>
|
||||
<img src="/favicon.svg" alt="WorkRoot" width="40" height="40" class="w-10 h-10 transition-transform group-hover:scale-105" />
|
||||
<span class="text-xl font-bold text-white">WorkRoot</span>
|
||||
</a>
|
||||
<p class="text-secondary-400 text-sm leading-relaxed mb-6">
|
||||
@@ -168,6 +166,23 @@ const currentYear = new Date().getFullYear();
|
||||
By subscribing, you agree to our
|
||||
<a href="/privacy" class="text-primary hover:underline">Privacy Policy</a>.
|
||||
</p>
|
||||
|
||||
<!-- Web push opt-in (WRNexus). User-gesture subscribe control.
|
||||
Hidden by default; the client script below reveals it only when the
|
||||
WRNexus SDK + browser push support are available, and sets the
|
||||
correct label based on the visitor's current subscription state. -->
|
||||
<button
|
||||
id="wrnexus-subscribe"
|
||||
type="button"
|
||||
hidden
|
||||
aria-label="Enable notifications"
|
||||
class="mt-6 w-full px-4 py-3 bg-secondary-800 border border-secondary-700 text-white font-medium rounded-lg hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-secondary-900 transition-all duration-200 text-sm flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
<span id="wrnexus-subscribe-label">Enable notifications</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,6 +210,106 @@ const currentYear = new Date().getFullYear();
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- WRNexus push subscribe: state-aware control wired via addEventListener
|
||||
(Astro strips inline on* handlers). The button reflects whether the
|
||||
visitor is already subscribed and lets them toggle the subscription. -->
|
||||
<script is:inline>
|
||||
(function () {
|
||||
function init() {
|
||||
var btn = document.getElementById('wrnexus-subscribe');
|
||||
var label = document.getElementById('wrnexus-subscribe-label');
|
||||
if (!btn || !label) return;
|
||||
|
||||
var push = window.WRNexusPush;
|
||||
var supported =
|
||||
push &&
|
||||
'Notification' in window &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window;
|
||||
|
||||
// No SDK or no browser push support: keep the button hidden entirely.
|
||||
if (!supported) {
|
||||
btn.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Permission permanently blocked by the user: the SDK can't re-prompt,
|
||||
// so surface a clear, non-actionable disabled state instead of a button
|
||||
// that silently does nothing.
|
||||
if (window.Notification && Notification.permission === 'denied') {
|
||||
label.textContent = 'Notifications blocked';
|
||||
btn.setAttribute('aria-label', 'Notifications are blocked in your browser settings');
|
||||
btn.disabled = true;
|
||||
btn.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render the button for a given subscription state.
|
||||
// Default UX: subscribed users get an actionable "Unsubscribe" toggle
|
||||
// rather than a dead "Already subscribed" label.
|
||||
// (Alternative: render a disabled "Already subscribed" indicator instead
|
||||
// of the Unsubscribe toggle if product prefers a non-actionable state.)
|
||||
function render(subscribed) {
|
||||
if (subscribed) {
|
||||
label.textContent = 'Unsubscribe';
|
||||
btn.setAttribute('aria-label', 'Unsubscribe from notifications');
|
||||
} else {
|
||||
label.textContent = 'Enable notifications';
|
||||
btn.setAttribute('aria-label', 'Enable notifications');
|
||||
}
|
||||
btn.dataset.subscribed = subscribed ? 'true' : 'false';
|
||||
btn.disabled = false;
|
||||
btn.hidden = false;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
var subscribed = await push.isSubscribed();
|
||||
render(!!subscribed);
|
||||
} catch (err) {
|
||||
// On error, fall back to the unsubscribed label so the control stays usable.
|
||||
console.error('[wrnexus-push] isSubscribed() failed:', err);
|
||||
render(false);
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', async function () {
|
||||
if (btn.disabled) return;
|
||||
|
||||
var wasSubscribed = btn.dataset.subscribed === 'true';
|
||||
var busyLabel = wasSubscribed ? 'Unsubscribing…' : 'Enabling…';
|
||||
|
||||
// In-progress state to prevent double-clicks while the promise is pending.
|
||||
btn.disabled = true;
|
||||
label.textContent = busyLabel;
|
||||
|
||||
try {
|
||||
if (wasSubscribed) {
|
||||
await push.unsubscribe();
|
||||
} else {
|
||||
// prompt() must run from this real user gesture.
|
||||
await push.prompt();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[wrnexus-push] toggle failed:', err);
|
||||
} finally {
|
||||
// Re-read the authoritative state and re-render the label.
|
||||
await refresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Determine and render the initial state.
|
||||
refresh();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { trackNewsletterSignup } from '../utils/analytics';
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
/**
|
||||
* GuaranteeBand Component
|
||||
*
|
||||
* Risk-reversal band that sits BETWEEN the PricingTimelineBand and the
|
||||
* InlineLeadForm on the homepage. Its single job is to remove last-mile
|
||||
* objections ("how do I know this won't blow up in my face?") right before
|
||||
* the visitor fills in the lead form below.
|
||||
*
|
||||
* Layout:
|
||||
* - 3-up icon + claim row on md+
|
||||
* - Items stack vertically (1 column) on mobile (<md)
|
||||
* - Each item is a single visual unit: icon → headline → short subtext
|
||||
*
|
||||
* Design tokens:
|
||||
* - Uses existing tailwind primitives (`primary-*`, `secondary-*`,
|
||||
* `accent-*`, `rounded-2xl`). No new colours introduced.
|
||||
* - Dark-mode pairs are taken from the .agents/security-auditor/
|
||||
* THEME_ACCESSIBILITY_AUDIT.md verified-passing list (e.g.
|
||||
* `primary-300` on `primary-900/30`, `secondary-200` on
|
||||
* `secondary-800` — all ≥ AA 4.5:1).
|
||||
*
|
||||
* CLS guard:
|
||||
* - Each item card has an explicit `min-h-[...]` so the band reserves
|
||||
* its vertical footprint at first paint, even before icons render.
|
||||
*
|
||||
* The component is intentionally prop-less. The three claims are part of
|
||||
* the brand promise and shouldn't vary across pages; if a future variant
|
||||
* needs different copy, refactor to a `claims` prop then.
|
||||
*/
|
||||
|
||||
interface ClaimEntry {
|
||||
title: string;
|
||||
body: string;
|
||||
/**
|
||||
* Inline SVG path data (already wrapped in <path>). Kept inline so the
|
||||
* component has no extra HTTP requests and the icons can ride along with
|
||||
* the document's currentColor — no FOUC.
|
||||
*/
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const claims: ClaimEntry[] = [
|
||||
{
|
||||
title: '30-day satisfaction guarantee',
|
||||
body: 'Not happy in the first 30 days post-launch? We rework it on us or refund the engagement fee.',
|
||||
// Shield-check icon
|
||||
icon:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 3 4.5 6v6c0 4.5 3 8.5 7.5 9.5 4.5-1 7.5-5 7.5-9.5V6L12 3Z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="m9 12 2 2 4-4"/>',
|
||||
},
|
||||
{
|
||||
title: 'No long-term contracts',
|
||||
body: 'Month-to-month after delivery. Pause, extend, or wind down — you stay in control of the engagement.',
|
||||
// Document-minus icon
|
||||
icon:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M9 12h6M7 21h10a2 2 0 0 0 2-2V7l-5-5H7a2 2 0 0 0-2 2v15a2 2 0 0 0 2 2Z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M13 2v5h5"/>',
|
||||
},
|
||||
{
|
||||
title: 'Fixed-price quotes',
|
||||
body: 'Scope-locked proposals with a clear deliverable and a clear number — no surprise change-orders.',
|
||||
// Currency / receipt icon
|
||||
icon:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 6v12M8 9h6.5a2.5 2.5 0 0 1 0 5h-5a2.5 2.5 0 0 0 0 5H16"/><circle cx="12" cy="12" r="9" stroke-width="1.8" fill="none"/>',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<!--
|
||||
Risk-reversal band. Anchor `#guarantees` lets nav / sticky CTA deep-link
|
||||
visitors straight to the promises right above the lead form.
|
||||
-->
|
||||
<section
|
||||
id="guarantees"
|
||||
class="pt-6 sm:pt-8 pb-14 sm:pb-20 bg-white dark:bg-secondary-900"
|
||||
aria-labelledby="guarantees-heading"
|
||||
>
|
||||
<div class="container-wrapper">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<!-- Visually hidden heading. The three claim titles each read as their
|
||||
own heading inside the cards, so a wrapper heading would be
|
||||
redundant in the visible flow but is still useful for screen
|
||||
readers to anchor the landmark. -->
|
||||
<h2 id="guarantees-heading" class="sr-only">Our guarantees</h2>
|
||||
|
||||
<!--
|
||||
3-up grid.
|
||||
Mobile (<md): single column, items stack with consistent vertical
|
||||
rhythm via `gap-4`.
|
||||
md+ : three equal columns, evenly spaced.
|
||||
-->
|
||||
<ul
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6"
|
||||
role="list"
|
||||
>
|
||||
{claims.map((claim, index) => (
|
||||
<li class="h-full">
|
||||
<article
|
||||
class="h-full min-h-[140px] sm:min-h-[160px] flex md:flex-col items-start gap-4 sm:gap-5 rounded-2xl border border-secondary-100 dark:border-secondary-700 bg-gradient-to-br from-secondary-50 to-white dark:from-secondary-800 dark:to-secondary-900 p-5 sm:p-6 shadow-sm hover:shadow-md transition-shadow duration-300"
|
||||
data-animate="fade-up"
|
||||
data-delay={String(index * 80)}
|
||||
aria-labelledby={`guarantee-${index}-title`}
|
||||
>
|
||||
<!-- Icon chip -->
|
||||
<div
|
||||
class="flex-shrink-0 w-12 h-12 rounded-xl bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
set:html={claim.icon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Copy block. The leading checkmark prefix communicates the
|
||||
"✓ benefit" rhythm the brief asks for ("'✓ 30-day satisfaction
|
||||
guarantee', '✓ No long-term contracts', '✓ Fixed-price
|
||||
quotes'") without relying on an emoji glyph that varies
|
||||
across platforms. -->
|
||||
<div class="min-w-0">
|
||||
<h3
|
||||
id={`guarantee-${index}-title`}
|
||||
class="text-base sm:text-lg font-bold text-secondary-900 dark:text-secondary-100 leading-snug"
|
||||
>
|
||||
<span class="text-primary-600 dark:text-primary-300" aria-hidden="true">✓</span>
|
||||
<span class="ml-1">{claim.title}</span>
|
||||
</h3>
|
||||
<p class="mt-2 text-sm sm:text-base text-secondary-600 dark:text-secondary-300 leading-relaxed">
|
||||
{claim.body}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -12,6 +12,7 @@ const { class: className } = Astro.props;
|
||||
|
||||
const navLinks = [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'WRNexus', href: 'https://wrnexus.com/', external: true },
|
||||
{ name: 'About', href: '/about' },
|
||||
{ name: 'Services', href: '/services' },
|
||||
{ name: 'Portfolio', href: '/portfolio' },
|
||||
@@ -29,13 +30,21 @@ const currentPath = Astro.url.pathname;
|
||||
]}
|
||||
id="main-header"
|
||||
>
|
||||
<div class="container-wrapper">
|
||||
<!-- Full-bleed inner wrapper: the header background already spans the full
|
||||
viewport (the <header> is fixed left-0 right-0); this inner row stretches
|
||||
edge-to-edge too, keeping only responsive horizontal padding so the logo
|
||||
and nav never touch the screen edges and stay legible down to 360px. -->
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 xl:px-12">
|
||||
<nav class="flex items-center justify-between h-16 lg:h-20" aria-label="Main navigation">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2 group" aria-label="WorkRoot IT Solutions LLP - Home">
|
||||
<div class="w-10 h-10 bg-primary rounded-lg flex items-center justify-center transition-transform group-hover:scale-105">
|
||||
<span class="text-white font-bold text-xl">W</span>
|
||||
</div>
|
||||
<img
|
||||
src="/favicon.svg"
|
||||
alt="WorkRoot"
|
||||
width="40"
|
||||
height="40"
|
||||
class="w-10 h-10 transition-transform group-hover:scale-105"
|
||||
/>
|
||||
<div class="hidden sm:block">
|
||||
<span class="font-bold text-xl text-secondary dark:text-white">WorkRoot</span>
|
||||
<span class="text-primary font-semibold text-sm block -mt-1">IT Solutions</span>
|
||||
@@ -45,14 +54,16 @@ const currentPath = Astro.url.pathname;
|
||||
<!-- Desktop Navigation -->
|
||||
<ul class="hidden lg:flex items-center gap-1" role="menubar">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = currentPath === link.href ||
|
||||
(link.href !== '/' && currentPath.startsWith(link.href));
|
||||
const isActive = !link.external && (currentPath === link.href ||
|
||||
(link.href !== '/' && currentPath.startsWith(link.href)));
|
||||
return (
|
||||
<li role="none">
|
||||
<a
|
||||
href={link.href}
|
||||
role="menuitem"
|
||||
data-astro-prefetch="hover"
|
||||
data-astro-prefetch={link.external ? undefined : 'hover'}
|
||||
target={link.external ? '_blank' : undefined}
|
||||
rel={link.external ? 'noopener noreferrer' : undefined}
|
||||
class:list={[
|
||||
'relative px-4 py-2 text-sm font-medium transition-colors rounded-lg',
|
||||
isActive
|
||||
@@ -78,44 +89,68 @@ const currentPath = Astro.url.pathname;
|
||||
href="/contact"
|
||||
class="btn-primary text-sm"
|
||||
>
|
||||
Get Started
|
||||
Get my free proposal
|
||||
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="lg:hidden relative w-10 h-10 flex items-center justify-center text-secondary-600 dark:text-secondary-400 hover:text-primary dark:hover:text-primary-400 transition-colors"
|
||||
id="mobile-menu-toggle"
|
||||
aria-label="Toggle mobile menu"
|
||||
aria-expanded="false"
|
||||
aria-controls="mobile-menu"
|
||||
>
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<!-- Hamburger Icon -->
|
||||
<div class="hamburger-icon" aria-hidden="true">
|
||||
<span class="hamburger-line hamburger-line-1"></span>
|
||||
<span class="hamburger-line hamburger-line-2"></span>
|
||||
<span class="hamburger-line hamburger-line-3"></span>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Mobile actions: prominent click-to-call + hamburger (shown <lg on every page) -->
|
||||
<div class="flex items-center gap-1 lg:hidden">
|
||||
<!-- Click-to-call — always visible in the mobile header so visitors on
|
||||
any page can reach us in one tap without opening the menu. -->
|
||||
<a
|
||||
href="tel:+919561417403"
|
||||
data-cta="header-mobile-call"
|
||||
aria-label="Call WorkRoot at +91 95614 17403"
|
||||
class="inline-flex items-center gap-1.5 h-10 px-3 rounded-lg bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 font-semibold text-sm hover:bg-primary-100 dark:hover:bg-primary-900/50 active:scale-95 transition-all focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
>
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
<span class="hidden xs:inline">Call</span>
|
||||
</a>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="relative w-10 h-10 flex items-center justify-center text-secondary-600 dark:text-secondary-400 hover:text-primary dark:hover:text-primary-400 transition-colors"
|
||||
id="mobile-menu-toggle"
|
||||
aria-label="Toggle mobile menu"
|
||||
aria-expanded="false"
|
||||
aria-controls="mobile-menu"
|
||||
>
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<!-- Hamburger Icon -->
|
||||
<div class="hamburger-icon" aria-hidden="true">
|
||||
<span class="hamburger-line hamburger-line-1"></span>
|
||||
<span class="hamburger-line hamburger-line-2"></span>
|
||||
<span class="hamburger-line hamburger-line-3"></span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Overlay -->
|
||||
<!-- Explicit viewport sizing: the header's `backdrop-blur` makes it the
|
||||
containing block for fixed descendants, so `inset-0` alone would only
|
||||
cover the header. w-screen + h-[100dvh] force true full-viewport cover. -->
|
||||
<div
|
||||
id="mobile-menu-overlay"
|
||||
class="fixed inset-0 bg-secondary-900/50 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300 lg:hidden"
|
||||
class="fixed top-0 left-0 w-screen h-[100dvh] bg-secondary-900/50 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300 lg:hidden"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<!-- Mobile Menu Panel -->
|
||||
<!-- Full-screen overlay panel. Uses explicit viewport units (w-screen /
|
||||
h-[100dvh]) rather than h-full because the header's backdrop-filter is
|
||||
the containing block for this fixed element, so `h-full` would collapse
|
||||
to the header's ~64px height (the original "small box" bug). -->
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="fixed top-0 right-0 h-full w-80 max-w-[85vw] bg-white dark:bg-secondary-900 shadow-2xl transform translate-x-full transition-transform duration-300 ease-out lg:hidden"
|
||||
class="fixed top-0 left-0 w-screen h-[100dvh] min-h-screen bg-white dark:bg-secondary-900 shadow-2xl transform translate-x-full transition-transform duration-300 ease-out lg:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mobile navigation menu"
|
||||
@@ -124,9 +159,7 @@ const currentPath = Astro.url.pathname;
|
||||
<!-- Mobile Menu Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-secondary-100 dark:border-secondary-800">
|
||||
<a href="/" class="flex items-center gap-2" aria-label="WorkRoot IT Solutions LLP - Home">
|
||||
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center" aria-hidden="true">
|
||||
<span class="text-white font-bold text-lg">W</span>
|
||||
</div>
|
||||
<img src="/favicon.svg" alt="WorkRoot" width="32" height="32" class="w-8 h-8" />
|
||||
<span class="font-bold text-lg text-secondary dark:text-white">WorkRoot</span>
|
||||
</a>
|
||||
<button
|
||||
@@ -145,14 +178,16 @@ const currentPath = Astro.url.pathname;
|
||||
<nav class="flex-1 overflow-y-auto py-4">
|
||||
<ul class="space-y-1 px-3" role="menu">
|
||||
{navLinks.map((link, index) => {
|
||||
const isActive = currentPath === link.href ||
|
||||
(link.href !== '/' && currentPath.startsWith(link.href));
|
||||
const isActive = !link.external && (currentPath === link.href ||
|
||||
(link.href !== '/' && currentPath.startsWith(link.href)));
|
||||
return (
|
||||
<li role="none" style={`--delay: ${index * 50}ms`}>
|
||||
<a
|
||||
href={link.href}
|
||||
role="menuitem"
|
||||
data-astro-prefetch="hover"
|
||||
data-astro-prefetch={link.external ? undefined : 'hover'}
|
||||
target={link.external ? '_blank' : undefined}
|
||||
rel={link.external ? 'noopener noreferrer' : undefined}
|
||||
class:list={[
|
||||
'mobile-nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200',
|
||||
isActive
|
||||
@@ -182,7 +217,7 @@ const currentPath = Astro.url.pathname;
|
||||
href="/contact"
|
||||
class="btn-primary w-full justify-center"
|
||||
>
|
||||
Get Started
|
||||
Get my free proposal
|
||||
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
// HomepageFaq — single homepage FAQ section rendered just above the final
|
||||
// CTA band. Covers both buyer-stage objections (cost, timeline, NDA, IP, GeM,
|
||||
// stack) and the process/support/security questions that used to live in the
|
||||
// older "Common Questions" block higher up the page. That earlier block was
|
||||
// removed to avoid a duplicate FAQ rendering and a duplicate FAQPage JSON-LD
|
||||
// emission — this component is now the canonical homepage FAQ.
|
||||
//
|
||||
// Emits a single FAQPage JSON-LD inline (below) for rich-result eligibility.
|
||||
//
|
||||
// Accessibility:
|
||||
// - Each question is a real <button> with aria-expanded toggled in JS.
|
||||
// - aria-controls links to the answer region; the region has role="region"
|
||||
// and aria-labelledby pointing back to the question.
|
||||
// - Max-height transition has no layout shift on load — answers are
|
||||
// collapsed (max-height:0) server-side and only the rotating chevron
|
||||
// animates.
|
||||
// - Keyboard: native <button> handles Enter & Space; Tab moves between
|
||||
// items. No custom key handling needed.
|
||||
|
||||
// Each answer is written as a definitive, self-contained paragraph: it can be
|
||||
// read (or quoted) in isolation without the question for context, and the
|
||||
// buyer-stage answers bundle the WorkRoot name with the canonical numbers
|
||||
// (founded 2022 · 20+ projects · 11+ districts · 50,000+ records · 99.9%
|
||||
// uptime · GeM · IIT Patna). Keep every figure identical to <ProofNumbers />
|
||||
// and the homepage `companyFacts` set — the visible text below is also the
|
||||
// source for the FAQPage JSON-LD emitted at the bottom of this component.
|
||||
const moneyFaqs = [
|
||||
{
|
||||
question: 'How much does a project cost? Do you work with fixed budgets?',
|
||||
answer:
|
||||
'WorkRoot IT Solutions projects start from ₹1,00,000, and most web, mobile, and ERP builds land between ₹2,00,000 and ₹15,00,000 depending on scope. Yes — we work with fixed budgets: tell us the number you have in mind and we will scope exactly what we can deliver within it. Every quote is free, itemised, and carries no obligation.',
|
||||
},
|
||||
{
|
||||
question: 'How much does a custom ERP cost?',
|
||||
answer:
|
||||
'A custom ERP from WorkRoot IT Solutions typically runs ₹2,00,000–₹15,00,000, with a genuinely production-grade single-department system starting around ₹6,00,000. We offer fixed-price quotes for well-defined scopes and time-and-materials pricing for evolving ones, and every estimate is free and itemised.',
|
||||
},
|
||||
{
|
||||
question: 'What is your typical timeline?',
|
||||
answer:
|
||||
'Most WorkRoot IT Solutions engagements run 4–16 weeks from kickoff to launch, and a typical MVP ships in about 6 weeks. We work in 2-week sprints with a live staging environment from day one, so you see and steer working software early rather than waiting for a single big-bang release.',
|
||||
},
|
||||
{
|
||||
question: 'Do you sign NDAs?',
|
||||
answer:
|
||||
'Yes. WorkRoot IT Solutions signs a mutual NDA before any detailed discussion of your idea, data, or codebase — at no cost and with no conditions attached. Your concept and commercial details stay confidential from the very first call.',
|
||||
},
|
||||
{
|
||||
question: 'Who owns the source code?',
|
||||
answer:
|
||||
'You do. Every WorkRoot IT Solutions engagement includes full intellectual-property transfer on delivery: you receive the complete source repositories, deployment access, and documentation, with no per-seat licence fees and no vendor lock-in. You are free to host, extend, or hand the system to another team at any time.',
|
||||
},
|
||||
{
|
||||
question: 'Do you work with government tenders / GeM?',
|
||||
answer:
|
||||
'Yes. WorkRoot IT Solutions is empanelled on the Government e-Marketplace (GeM) for relevant IT service categories and can respond to government tenders. We have delivered public-service platforms live across 11+ districts of Bihar at 99.9% uptime, including a Police Management System and a Smart Crematorium Management System for the Bihar Government.',
|
||||
},
|
||||
{
|
||||
question: 'What technologies do you work with?',
|
||||
answer:
|
||||
'WorkRoot IT Solutions builds on Astro, Next.js, Angular, and React Native on the front end; Spring Boot, Go, and Node.js on the back end; and PostgreSQL or MySQL for data — the same stack behind the IIT Patna University Management System and our multi-district government deployments, all deployed and operated on the AWS and GCP cloud.',
|
||||
},
|
||||
{
|
||||
question: 'Do you offer ongoing support after launch?',
|
||||
answer:
|
||||
'Yes. WorkRoot IT Solutions offers maintenance packages covering bug fixes, security patches, performance monitoring, and feature enhancements, each with a response SLA matched to your uptime needs — the same operational discipline that keeps our government systems running at 99.9% uptime.',
|
||||
},
|
||||
{
|
||||
question: 'What is your development process?',
|
||||
answer:
|
||||
'WorkRoot IT Solutions works in Agile 2-week sprints with weekly check-ins and a live staging environment from day one. You see working software early and steer the roadmap as it ships, rather than waiting for a single final handover at the end of the project.',
|
||||
},
|
||||
{
|
||||
question: 'Can you work with our existing team?',
|
||||
answer:
|
||||
'Absolutely. WorkRoot IT Solutions augments in-house teams as readily as it delivers fully managed builds, adapting to your repository, branch strategy, ticketing system, and code-review process so we slot into the way your team already works.',
|
||||
},
|
||||
{
|
||||
question: 'How do you ensure project security?',
|
||||
answer:
|
||||
'Security is built into every phase at WorkRoot IT Solutions: OWASP-aligned code reviews, encrypted data at rest and in transit, role-based access control, regular dependency audits, and GDPR- and SOC 2-style controls where a project demands them. The same architecture secures a Bihar government police platform deployed across 11 districts.',
|
||||
},
|
||||
];
|
||||
|
||||
// FAQPage JSON-LD — emitted inline so the schema travels with the component.
|
||||
// This is the ONLY FAQPage emitted on the homepage; the site-wide <SEO>
|
||||
// no longer receives a `faq` prop on / so Google sees exactly one valid
|
||||
// FAQPage with every visible Q&A pair.
|
||||
const faqPageSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
'@id': 'https://workroot.in/#homepage-money-faq',
|
||||
mainEntity: moneyFaqs.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
---
|
||||
|
||||
<section
|
||||
id="homepage-money-faq"
|
||||
class="py-20 sm:py-24 bg-secondary-50 dark:bg-secondary-950 border-t border-secondary-100 dark:border-secondary-800"
|
||||
aria-labelledby="homepage-money-faq-heading"
|
||||
>
|
||||
<div class="container-wrapper">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Section heading -->
|
||||
<div class="text-center mb-12 sm:mb-16" data-animate="fade-up">
|
||||
<span
|
||||
class="inline-block px-4 py-2 rounded-full bg-accent-50 dark:bg-accent-900/30 text-accent-700 dark:text-accent-300 text-xs sm:text-sm font-bold uppercase tracking-wider mb-4"
|
||||
>
|
||||
Before you reach out
|
||||
</span>
|
||||
<h2
|
||||
id="homepage-money-faq-heading"
|
||||
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-secondary-900 dark:text-secondary-100 mb-4 leading-tight"
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<p class="text-base sm:text-lg text-secondary-600 dark:text-secondary-300">
|
||||
Quick answers on cost, timelines, ownership, our process, support, security, and the stack we ship.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Accordion list -->
|
||||
<ul class="space-y-3 sm:space-y-4" role="list">
|
||||
{
|
||||
moneyFaqs.map((item, index) => (
|
||||
<li
|
||||
class="bg-white dark:bg-secondary-800 rounded-2xl border border-secondary-200 dark:border-secondary-700 hover:border-primary-300 dark:hover:border-primary-500/60 shadow-sm transition-colors duration-200 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="money-faq-question w-full text-left px-5 sm:px-7 py-5 sm:py-6 flex items-start justify-between gap-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 focus-visible:ring-offset-2 focus-visible:ring-offset-secondary-50 dark:focus-visible:ring-offset-secondary-950 rounded-2xl"
|
||||
aria-expanded="false"
|
||||
aria-controls={`money-faq-answer-${index}`}
|
||||
id={`money-faq-question-${index}`}
|
||||
data-money-faq-index={index}
|
||||
>
|
||||
<span class="font-semibold text-base sm:text-lg text-secondary-900 dark:text-secondary-100 leading-snug">
|
||||
{item.question}
|
||||
</span>
|
||||
<svg
|
||||
class="money-faq-icon w-5 h-5 sm:w-6 sm:h-6 mt-0.5 sm:mt-1 text-primary-500 dark:text-primary-400 flex-shrink-0 transition-transform duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2.5"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="money-faq-answer grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out"
|
||||
id={`money-faq-answer-${index}`}
|
||||
role="region"
|
||||
aria-labelledby={`money-faq-question-${index}`}
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<p class="px-5 sm:px-7 pb-5 sm:pb-6 -mt-1 text-sm sm:text-base text-secondary-600 dark:text-secondary-300 leading-relaxed">
|
||||
{item.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<!-- Soft secondary CTA — links to the contact page for visitors whose
|
||||
question wasn't covered. Avoids competing with the final CTA band
|
||||
below. -->
|
||||
<p class="text-center text-sm sm:text-base text-secondary-600 dark:text-secondary-400 mt-10 sm:mt-12">
|
||||
Have a different question?{' '}
|
||||
<a
|
||||
href="/contact?source=homepage-money-faq"
|
||||
class="font-semibold text-primary-600 dark:text-primary-300 hover:underline"
|
||||
>
|
||||
Ask us directly →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQPage structured data for rich-result eligibility -->
|
||||
<script type="application/ld+json" set:html={JSON.stringify(faqPageSchema)} />
|
||||
|
||||
<style>
|
||||
/* The grid-rows trick gives a true intrinsic-height expand/collapse with
|
||||
no max-height guesswork and no layout shift on load (answers start at
|
||||
0fr). It animates smoothly in every modern browser; older browsers
|
||||
simply snap, which is still accessible. */
|
||||
.money-faq-answer[data-expanded='true'] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.money-faq-question[aria-expanded='true'] .money-faq-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.money-faq-answer,
|
||||
.money-faq-icon {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var buttons = document.querySelectorAll('.money-faq-question');
|
||||
if (!buttons.length) return;
|
||||
|
||||
function setExpanded(button, expanded) {
|
||||
button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
var answerId = button.getAttribute('aria-controls');
|
||||
if (!answerId) return;
|
||||
var answer = document.getElementById(answerId);
|
||||
if (!answer) return;
|
||||
if (expanded) {
|
||||
answer.setAttribute('data-expanded', 'true');
|
||||
} else {
|
||||
answer.removeAttribute('data-expanded');
|
||||
}
|
||||
}
|
||||
|
||||
buttons.forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
var isOpen = button.getAttribute('aria-expanded') === 'true';
|
||||
// Single-open accordion — collapse the rest so only one answer is
|
||||
// visible at a time. Matches the existing FAQ block's UX.
|
||||
buttons.forEach(function (other) {
|
||||
if (other !== button) setExpanded(other, false);
|
||||
});
|
||||
setExpanded(button, !isOpen);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -1,20 +1,25 @@
|
||||
---
|
||||
// InlineLeadForm — Three-field lead capture rendered inline on the homepage.
|
||||
// InlineLeadForm — Compact lead capture rendered inline on the homepage.
|
||||
//
|
||||
// The goal is to cut the friction step of routing every CTA through /contact.
|
||||
// Visitors who reach the bottom of the homepage can submit name + contact
|
||||
// (email OR phone) + project type without a navigation. The submission reuses
|
||||
// /api/contact under the hood and is tagged `source: 'homepage-inline'` so the
|
||||
// backend / analytics layer can segment these leads cleanly.
|
||||
// Goal: let visitors who reach the bottom of the homepage submit a project
|
||||
// enquiry without the friction of navigating to /contact. The submission
|
||||
// reuses the SAME /api/contact endpoint as the full contact page and is tagged
|
||||
// `source: 'homepage-inline'` so the backend / analytics layer can segment
|
||||
// these leads cleanly (it also routes them to the `[Project Inquiry]` admin
|
||||
// subject prefix).
|
||||
//
|
||||
// Why a single "contact" field instead of separate email + phone:
|
||||
// - 3 fields keeps the form psychologically tiny and dramatically lifts
|
||||
// completion. Real-world conversion testing repeatedly shows 3 → 5+ fields
|
||||
// is the biggest single-step drop-off on B2B lead forms.
|
||||
// - We sniff the value on submit. If it has an "@" we treat it as email.
|
||||
// Otherwise we treat it as a phone and synthesize a placeholder email to
|
||||
// satisfy the backend's strict email validation. The phone is also sent
|
||||
// through, so the admin email body shows the real reachable value.
|
||||
// Fields (kept to 5, mobile-first):
|
||||
// 1. Full name — required
|
||||
// 2. Email — required, type="email" (email keyboard on mobile)
|
||||
// 3. Phone — optional, type="tel" (numeric keypad on mobile)
|
||||
// 4. Project type — required, <select> mapped to the backend `subject`
|
||||
// 5. Message — optional <textarea>; synthesized when left blank so
|
||||
// the backend's message>=10 char rule always passes
|
||||
//
|
||||
// Only name + email + project type are *required* — phone and message stay
|
||||
// optional to keep completion friction low on mobile, which is where most of
|
||||
// these submissions originate. The backend already treats `phone` as optional
|
||||
// and only validates its format when present.
|
||||
---
|
||||
|
||||
<section
|
||||
@@ -54,15 +59,26 @@
|
||||
data-testid="inline-lead-form"
|
||||
novalidate
|
||||
>
|
||||
<!-- Honeypot (visually & SR-hidden — bots fill it, real users do not). -->
|
||||
<div class="absolute -left-[9999px] top-auto w-px h-px overflow-hidden" aria-hidden="true">
|
||||
<label for="inline-lead-website">Website</label>
|
||||
<!--
|
||||
Honeypot — off-screen positioned, NOT display:none / visibility:hidden
|
||||
(some bots skip those entirely). aria-hidden + sr-only label keep it
|
||||
silent for screen readers; tabindex="-1" + autocomplete="off" keep it
|
||||
out of the tab order and browser autofill. Bots that blindly fill
|
||||
every field will populate `website`, and /api/contact silently drops
|
||||
the submission.
|
||||
-->
|
||||
<div
|
||||
class="absolute -left-[9999px] top-auto w-px h-px overflow-hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<label for="inline-lead-website" class="sr-only">Website</label>
|
||||
<input
|
||||
type="text"
|
||||
id="inline-lead-website"
|
||||
name="website"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -97,40 +113,68 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Contact (email or phone) -->
|
||||
<div>
|
||||
<label
|
||||
for="inline-lead-contact"
|
||||
class="block text-sm font-semibold text-secondary-700 dark:text-secondary-200 mb-1.5"
|
||||
>
|
||||
Email or phone <span class="text-red-500" aria-hidden="true">*</span>
|
||||
<span class="sr-only">(required)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="inline-lead-contact"
|
||||
name="contact"
|
||||
required
|
||||
autocomplete="email tel"
|
||||
inputmode="email"
|
||||
aria-required="true"
|
||||
aria-describedby="inline-lead-contact-hint inline-lead-contact-error"
|
||||
placeholder="jane@company.com or +91 95614 17403"
|
||||
class="block w-full min-h-[48px] px-4 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 bg-secondary-50 dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 dark:placeholder-secondary-500 text-base focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/30 transition-colors"
|
||||
/>
|
||||
<p
|
||||
id="inline-lead-contact-hint"
|
||||
class="mt-1.5 text-xs text-secondary-500 dark:text-secondary-400"
|
||||
>
|
||||
Drop your best email or phone number — whichever is easiest.
|
||||
</p>
|
||||
<p
|
||||
id="inline-lead-contact-error"
|
||||
class="mt-1.5 text-xs text-red-600 dark:text-red-400 hidden"
|
||||
role="alert"
|
||||
>
|
||||
Please enter a valid email address or phone number.
|
||||
</p>
|
||||
<!-- Email + Phone row -->
|
||||
<div class="grid sm:grid-cols-2 gap-5">
|
||||
<!-- Email (required) -->
|
||||
<div>
|
||||
<label
|
||||
for="inline-lead-email"
|
||||
class="block text-sm font-semibold text-secondary-700 dark:text-secondary-200 mb-1.5"
|
||||
>
|
||||
Email <span class="text-red-500" aria-hidden="true">*</span>
|
||||
<span class="sr-only">(required)</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="inline-lead-email"
|
||||
name="email"
|
||||
required
|
||||
maxlength="254"
|
||||
autocomplete="email"
|
||||
inputmode="email"
|
||||
aria-required="true"
|
||||
aria-describedby="inline-lead-email-error"
|
||||
placeholder="jane@company.com"
|
||||
class="block w-full min-h-[48px] px-4 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 bg-secondary-50 dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 dark:placeholder-secondary-500 text-base focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/30 transition-colors"
|
||||
/>
|
||||
<p
|
||||
id="inline-lead-email-error"
|
||||
class="mt-1.5 text-xs text-red-600 dark:text-red-400 hidden"
|
||||
role="alert"
|
||||
>
|
||||
Please enter a valid email address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Phone (optional) -->
|
||||
<div>
|
||||
<label
|
||||
for="inline-lead-phone"
|
||||
class="block text-sm font-semibold text-secondary-700 dark:text-secondary-200 mb-1.5"
|
||||
>
|
||||
Phone
|
||||
<span class="font-normal text-secondary-400 dark:text-secondary-500">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="inline-lead-phone"
|
||||
name="phone"
|
||||
autocomplete="tel"
|
||||
inputmode="tel"
|
||||
pattern="[+()0-9.\s\-]{7,}"
|
||||
maxlength="32"
|
||||
aria-describedby="inline-lead-phone-error"
|
||||
placeholder="+91 95614 17403"
|
||||
class="block w-full min-h-[48px] px-4 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 bg-secondary-50 dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 dark:placeholder-secondary-500 text-base focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/30 transition-colors"
|
||||
/>
|
||||
<p
|
||||
id="inline-lead-phone-error"
|
||||
class="mt-1.5 text-xs text-red-600 dark:text-red-400 hidden"
|
||||
role="alert"
|
||||
>
|
||||
Please enter a valid phone number, or leave it blank.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project type -->
|
||||
@@ -173,6 +217,33 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Message (optional) -->
|
||||
<div>
|
||||
<label
|
||||
for="inline-lead-message"
|
||||
class="block text-sm font-semibold text-secondary-700 dark:text-secondary-200 mb-1.5"
|
||||
>
|
||||
Project details
|
||||
<span class="font-normal text-secondary-400 dark:text-secondary-500">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="inline-lead-message"
|
||||
name="message"
|
||||
rows="3"
|
||||
maxlength="5000"
|
||||
aria-describedby="inline-lead-message-error"
|
||||
placeholder="A sentence or two about what you want to build, your timeline, or budget…"
|
||||
class="block w-full min-h-[96px] px-4 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 bg-secondary-50 dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 dark:placeholder-secondary-500 text-base focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/30 transition-colors resize-y"
|
||||
></textarea>
|
||||
<p
|
||||
id="inline-lead-message-error"
|
||||
class="mt-1.5 text-xs text-red-600 dark:text-red-400 hidden"
|
||||
role="alert"
|
||||
>
|
||||
Please add a little more detail (at least 10 characters), or leave it blank.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- General form error (server-side / network) -->
|
||||
<div
|
||||
id="inline-lead-form-error"
|
||||
@@ -256,13 +327,17 @@
|
||||
if (!form) return;
|
||||
|
||||
var nameInput = document.getElementById('inline-lead-name');
|
||||
var contactInput = document.getElementById('inline-lead-contact');
|
||||
var emailInput = document.getElementById('inline-lead-email');
|
||||
var phoneInput = document.getElementById('inline-lead-phone');
|
||||
var projectTypeInput = document.getElementById('inline-lead-project-type');
|
||||
var messageInput = document.getElementById('inline-lead-message');
|
||||
var websiteInput = document.getElementById('inline-lead-website');
|
||||
|
||||
var nameError = document.getElementById('inline-lead-name-error');
|
||||
var contactError = document.getElementById('inline-lead-contact-error');
|
||||
var emailError = document.getElementById('inline-lead-email-error');
|
||||
var phoneError = document.getElementById('inline-lead-phone-error');
|
||||
var projectTypeError = document.getElementById('inline-lead-project-type-error');
|
||||
var messageError = document.getElementById('inline-lead-message-error');
|
||||
var formError = document.getElementById('inline-lead-form-error');
|
||||
|
||||
var submitBtn = document.getElementById('inline-lead-submit');
|
||||
@@ -272,7 +347,7 @@
|
||||
var successPanel = document.getElementById('inline-lead-success');
|
||||
|
||||
// Tolerant phone test: allow +, digits, spaces, dashes, parens, dots.
|
||||
// We require at least 7 digits so single-digit / accidental presses fail.
|
||||
// Require at least 7 digits so single-digit / accidental presses fail.
|
||||
var PHONE_REGEX = /^[+]?[()\d\s.\-]{7,}$/;
|
||||
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
@@ -300,6 +375,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function phoneIsValid(val) {
|
||||
return PHONE_REGEX.test(val) && digitsOnly(val).length >= 7;
|
||||
}
|
||||
|
||||
function validate() {
|
||||
var ok = true;
|
||||
|
||||
@@ -311,14 +390,21 @@
|
||||
showError(nameError, false);
|
||||
}
|
||||
|
||||
var contactVal = (contactInput.value || '').trim();
|
||||
var isEmail = EMAIL_REGEX.test(contactVal);
|
||||
var isPhone = PHONE_REGEX.test(contactVal) && digitsOnly(contactVal).length >= 7;
|
||||
if (!contactVal || (!isEmail && !isPhone)) {
|
||||
showError(contactError, true);
|
||||
var emailVal = (emailInput.value || '').trim();
|
||||
if (!EMAIL_REGEX.test(emailVal)) {
|
||||
showError(emailError, true);
|
||||
ok = false;
|
||||
} else {
|
||||
showError(contactError, false);
|
||||
showError(emailError, false);
|
||||
}
|
||||
|
||||
// Phone is optional — only validate format when something was typed.
|
||||
var phoneVal = (phoneInput.value || '').trim();
|
||||
if (phoneVal && !phoneIsValid(phoneVal)) {
|
||||
showError(phoneError, true);
|
||||
ok = false;
|
||||
} else {
|
||||
showError(phoneError, false);
|
||||
}
|
||||
|
||||
var projectVal = projectTypeInput.value || '';
|
||||
@@ -329,13 +415,24 @@
|
||||
showError(projectTypeError, false);
|
||||
}
|
||||
|
||||
// Message is optional — only enforce the min length when something was typed.
|
||||
var messageVal = (messageInput.value || '').trim();
|
||||
if (messageVal && messageVal.length < 10) {
|
||||
showError(messageError, true);
|
||||
ok = false;
|
||||
} else {
|
||||
showError(messageError, false);
|
||||
}
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Live-validate on input/change so the error UI doesn't linger after fix.
|
||||
// Live-validate on blur/change so the error UI clears once a field is fixed.
|
||||
nameInput.addEventListener('blur', validate);
|
||||
contactInput.addEventListener('blur', validate);
|
||||
emailInput.addEventListener('blur', validate);
|
||||
phoneInput.addEventListener('blur', validate);
|
||||
projectTypeInput.addEventListener('change', validate);
|
||||
messageInput.addEventListener('blur', validate);
|
||||
|
||||
// Map the user-friendly project type label to the backend subject code so
|
||||
// the existing /api/contact validator passes. Anything unmapped → 'other'.
|
||||
@@ -348,41 +445,39 @@
|
||||
};
|
||||
|
||||
function buildPayload() {
|
||||
var contactVal = (contactInput.value || '').trim();
|
||||
var isEmail = EMAIL_REGEX.test(contactVal);
|
||||
var nameVal = (nameInput.value || '').trim();
|
||||
var emailVal = (emailInput.value || '').trim();
|
||||
var phoneVal = (phoneInput.value || '').trim();
|
||||
var projectType = projectTypeInput.value || 'Other';
|
||||
var subject = SUBJECT_BY_PROJECT_TYPE[projectType] || 'other';
|
||||
var messageVal = (messageInput.value || '').trim();
|
||||
|
||||
var email, phone;
|
||||
if (isEmail) {
|
||||
email = contactVal;
|
||||
phone = undefined;
|
||||
// The backend requires a message of at least 10 characters. When the
|
||||
// visitor leaves the (optional) details blank, synthesize a concise
|
||||
// summary so the submission still goes through and the admin email
|
||||
// carries the useful context.
|
||||
var message;
|
||||
if (messageVal.length >= 10) {
|
||||
message = messageVal;
|
||||
} else {
|
||||
// Treat as phone. Synthesize a stable placeholder email so the
|
||||
// backend's strict email validator passes — the admin notification
|
||||
// surfaces the real phone in both Phone: and Message: fields so the
|
||||
// team always has a real way to reach the lead.
|
||||
var digits = digitsOnly(contactVal) || 'unknown';
|
||||
email = 'lead-' + digits + '@homepage.workroot.in';
|
||||
phone = contactVal;
|
||||
message =
|
||||
'New project enquiry from the homepage form.\n\n' +
|
||||
'Project type: ' + projectType + '\n' +
|
||||
'Name: ' + nameVal +
|
||||
(phoneVal ? '\nPhone: ' + phoneVal : '') +
|
||||
(messageVal ? '\n\nNote: ' + messageVal : '');
|
||||
}
|
||||
|
||||
var message =
|
||||
'New inline lead from the homepage.\n\n' +
|
||||
'Project type: ' + projectType + '\n' +
|
||||
'Preferred contact: ' + contactVal;
|
||||
|
||||
var payload = {
|
||||
name: nameVal,
|
||||
email: email,
|
||||
email: emailVal,
|
||||
subject: subject,
|
||||
message: message,
|
||||
source: 'homepage-inline',
|
||||
projectType: projectType,
|
||||
website: (websiteInput && websiteInput.value) || '',
|
||||
};
|
||||
if (phone) payload.phone = phone;
|
||||
if (phoneVal) payload.phone = phoneVal;
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
---
|
||||
// MobileStickyCtaBar — Sticky bottom action bar for mobile/tablet (<768px)
|
||||
// 3 equal columns: Call, WhatsApp, Quote
|
||||
// 3 equal columns: Call, Chat (WhatsApp), Quote
|
||||
// - Fixed to bottom of viewport
|
||||
// - Respects safe-area-inset-bottom on iOS
|
||||
// - Hidden at md+ breakpoints (>=768px)
|
||||
// - Hidden on print
|
||||
// - Light + dark theme aware
|
||||
//
|
||||
// Label policy (post-polish):
|
||||
// - Short, action-led verbs (max ~7 chars) so they never truncate even at 320px.
|
||||
// - Visible text intentionally terse; aria-label carries the full descriptive
|
||||
// phrasing for screen readers (Apple HIG / WCAG SC 2.5.3 name-in-name).
|
||||
// - 24px inline SVG icons sit above each label. Tap target is ~64px tall
|
||||
// and >=44px wide per column at 320px (320 / 3 ≈ 106px), satisfying
|
||||
// Apple HIG (44x44) and WCAG 2.5.5 (Target Size).
|
||||
---
|
||||
|
||||
<nav
|
||||
@@ -15,13 +23,15 @@
|
||||
>
|
||||
<a
|
||||
href="tel:+919561417403"
|
||||
aria-label="Call WorkRoot"
|
||||
aria-label="Call us"
|
||||
data-testid="mobile-cta-call"
|
||||
class="mobile-sticky-cta__btn"
|
||||
>
|
||||
<svg
|
||||
class="mobile-sticky-cta__icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -40,13 +50,15 @@
|
||||
href="https://wa.me/919561417403?text=Hi%20WorkRoot%2C%20I%27d%20like%20to%20discuss%20a%20project."
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="Chat on WhatsApp"
|
||||
aria-label="Chat with us on WhatsApp"
|
||||
data-testid="mobile-cta-whatsapp"
|
||||
class="mobile-sticky-cta__btn mobile-sticky-cta__btn--whatsapp"
|
||||
>
|
||||
<svg
|
||||
class="mobile-sticky-cta__icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
@@ -54,18 +66,20 @@
|
||||
>
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.464 3.488"/>
|
||||
</svg>
|
||||
<span class="mobile-sticky-cta__label">WhatsApp</span>
|
||||
<span class="mobile-sticky-cta__label">Chat</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/contact"
|
||||
aria-label="Request a quote"
|
||||
aria-label="Get a free quote"
|
||||
data-testid="mobile-cta-quote"
|
||||
class="mobile-sticky-cta__btn mobile-sticky-cta__btn--accent"
|
||||
>
|
||||
<svg
|
||||
class="mobile-sticky-cta__icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -96,7 +110,7 @@
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 -8px 20px -8px rgba(15, 23, 42, 0.18);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
/* Ensure no horizontal overflow on very narrow viewports */
|
||||
/* Ensure no horizontal overflow on very narrow viewports (down to 320px) */
|
||||
max-width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -112,8 +126,12 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-height: 56px;
|
||||
gap: 4px;
|
||||
/* 44x44 minimum tap target per Apple HIG / WCAG 2.5.5.
|
||||
64px gives comfortable thumb-zone clearance for the 24px icon
|
||||
+ 12px label without ever clipping. */
|
||||
min-height: 64px;
|
||||
min-width: 44px;
|
||||
padding: 8px 6px;
|
||||
text-decoration: none;
|
||||
color: #0f172a;
|
||||
@@ -157,15 +175,35 @@
|
||||
}
|
||||
|
||||
.mobile-sticky-cta__icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-sticky-cta__label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
/* Font tuned so labels (Call/Chat/Quote, ≤5 chars) never overflow
|
||||
even at 320px (column ≈ 106px – 12px padding = 94px usable).
|
||||
The nowrap + ellipsis pair below is a defensive safety net only —
|
||||
at the tuned sizes it should never visibly trigger. */
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.01em;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Drop label font a hair on the smallest phones (iPhone SE: 320px).
|
||||
This keeps the ellipsis safety net from ever triggering even if the
|
||||
system font swaps in slightly wider glyphs. */
|
||||
@media (max-width: 359px) {
|
||||
.mobile-sticky-cta__label {
|
||||
font-size: 11px;
|
||||
}
|
||||
.mobile-sticky-cta__btn {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide on tablets/desktops (>=768px = Tailwind md breakpoint).
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
/**
|
||||
* PricingTimelineBand Component
|
||||
*
|
||||
* Sits directly above the homepage InlineLeadForm and answers the two
|
||||
* questions every late-stage buyer has BEFORE they fill in a contact form:
|
||||
* 1. "Roughly how much does this cost?"
|
||||
* 2. "Roughly how long does it take?"
|
||||
*
|
||||
* Layout:
|
||||
* - 2 cards side-by-side on md+ (Pricing, Timeline)
|
||||
* - Stacked vertically on mobile (<md)
|
||||
* - A subtle "Custom quote available — tell us about your project" line
|
||||
* visually bridges the band into the form below it.
|
||||
*
|
||||
* Anchor: `id="pricing-timeline"` so the mobile sticky CTA bar / nav /
|
||||
* external deep-links can scroll directly to this band.
|
||||
*
|
||||
* CLS guard: each card has an explicit `min-h-[...]` so the band reserves
|
||||
* its own vertical space at first paint, regardless of font loading or
|
||||
* icon-fetch delay. Lighthouse CLS should be unaffected.
|
||||
*
|
||||
* Design tokens: uses the same `text-secondary-*`, `text-primary-*`,
|
||||
* `rounded-3xl`, container-wrapper, and gradient conventions established
|
||||
* by InlineLeadForm and ProofNumbers so the band feels native to the page.
|
||||
*
|
||||
* NOTE on copy: the headline values below are PLACEHOLDERS pending
|
||||
* stakeholder (marketing / leadership) confirmation. See the inline
|
||||
* `data-stakeholder-confirm` markers + HTML comments at each value.
|
||||
*/
|
||||
---
|
||||
|
||||
<!--
|
||||
Pricing + Timeline band.
|
||||
ANCHOR: #pricing-timeline — referenced by the mobile sticky CTA bar
|
||||
and any nav deep-links. Do not rename without updating those callers.
|
||||
-->
|
||||
<section
|
||||
id="pricing-timeline"
|
||||
class="pt-20 sm:pt-24 pb-10 sm:pb-12 bg-gradient-to-b from-white to-white dark:from-secondary-900 dark:to-secondary-900"
|
||||
aria-labelledby="pricing-timeline-heading"
|
||||
>
|
||||
<div class="container-wrapper">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<!-- Eyebrow + section heading. Kept short so the cards below are the
|
||||
visual focal point. -->
|
||||
<div class="text-center mb-10 sm:mb-12" data-animate="fade-up">
|
||||
<p
|
||||
class="text-xs sm:text-sm font-semibold uppercase tracking-[0.18em] text-primary-600 dark:text-primary-400"
|
||||
>
|
||||
What to expect
|
||||
</p>
|
||||
<h2
|
||||
id="pricing-timeline-heading"
|
||||
class="mt-3 text-3xl sm:text-4xl lg:text-5xl font-bold text-secondary-900 dark:text-secondary-100 leading-tight"
|
||||
>
|
||||
Transparent pricing & predictable timelines.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Two-card grid: stacks on mobile, side-by-side on md+. -->
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-8"
|
||||
data-animate="fade-up"
|
||||
>
|
||||
<!-- Card 1: Pricing -->
|
||||
<article
|
||||
class="relative bg-gradient-to-br from-secondary-50 to-white dark:from-secondary-800 dark:to-secondary-900 rounded-3xl border border-secondary-100 dark:border-secondary-700 shadow-lg hover:shadow-xl transition-shadow duration-300 p-8 sm:p-10 min-h-[260px] sm:min-h-[280px] flex flex-col"
|
||||
aria-labelledby="pricing-card-heading"
|
||||
>
|
||||
<!-- Icon chip -->
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-primary-100 dark:bg-primary-500/20 text-primary-600 dark:text-primary-300 flex items-center justify-center mb-5 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-xs sm:text-sm font-semibold uppercase tracking-wider text-secondary-500 dark:text-secondary-400"
|
||||
>
|
||||
Starting at
|
||||
</p>
|
||||
|
||||
<!--
|
||||
Pricing floor — mirrors the Services page "Starter" tier
|
||||
(₹1,00,000). Keep this value in sync with the Starter price in
|
||||
src/pages/services.astro so the homepage band and the pricing
|
||||
table never contradict each other.
|
||||
-->
|
||||
<h3
|
||||
id="pricing-card-heading"
|
||||
class="mt-2 text-4xl sm:text-5xl font-bold text-secondary-900 dark:text-secondary-100 leading-none tracking-tight"
|
||||
>
|
||||
Projects from
|
||||
<span
|
||||
class="block mt-1 bg-gradient-to-br from-primary-600 to-primary-500 dark:from-primary-400 dark:to-primary-300 bg-clip-text text-transparent"
|
||||
>
|
||||
₹1,00,000
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<p
|
||||
class="mt-5 text-base sm:text-lg text-secondary-600 dark:text-secondary-300 leading-relaxed"
|
||||
>
|
||||
<!-- 1-line "what's included" copy — keep to ~14 words so it
|
||||
sits in one comfortable line at md+ widths. -->
|
||||
Includes discovery, design, build, QA, and post-launch support.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<!-- Card 2: Timeline -->
|
||||
<article
|
||||
class="relative bg-gradient-to-br from-secondary-50 to-white dark:from-secondary-800 dark:to-secondary-900 rounded-3xl border border-secondary-100 dark:border-secondary-700 shadow-lg hover:shadow-xl transition-shadow duration-300 p-8 sm:p-10 min-h-[260px] sm:min-h-[280px] flex flex-col"
|
||||
aria-labelledby="timeline-card-heading"
|
||||
>
|
||||
<!-- Icon chip -->
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-accent-100 dark:bg-accent-500/20 text-accent-600 dark:text-accent-300 flex items-center justify-center mb-5 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-xs sm:text-sm font-semibold uppercase tracking-wider text-secondary-500 dark:text-secondary-400"
|
||||
>
|
||||
Typical timeline
|
||||
</p>
|
||||
|
||||
<!--
|
||||
STAKEHOLDER-CONFIRM: the "4–8 weeks" range is the current
|
||||
default placeholder. Update after delivery-lead review if the
|
||||
average shifts (e.g. 6–10 weeks for ERP-heavy scopes).
|
||||
-->
|
||||
<h3
|
||||
id="timeline-card-heading"
|
||||
data-stakeholder-confirm="timeline-range"
|
||||
class="mt-2 text-4xl sm:text-5xl font-bold text-secondary-900 dark:text-secondary-100 leading-none tracking-tight"
|
||||
>
|
||||
<span
|
||||
class="block bg-gradient-to-br from-accent-600 to-accent-500 dark:from-accent-400 dark:to-accent-300 bg-clip-text text-transparent"
|
||||
>
|
||||
4–8 weeks
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<p
|
||||
class="mt-5 text-base sm:text-lg text-secondary-600 dark:text-secondary-300 leading-relaxed"
|
||||
>
|
||||
<!-- 1-line "process" copy — discovery → sprints → launch. -->
|
||||
Discovery in week 1, weekly demos through build, launch by week 8.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Bridging line into the form below. Visually subtle: small text,
|
||||
muted color, centered, with a chevron pointing down to imply
|
||||
"fill the form below to get the custom number". -->
|
||||
<p
|
||||
class="mt-10 sm:mt-12 text-center text-sm sm:text-base text-secondary-600 dark:text-secondary-300"
|
||||
data-animate="fade-up"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span>
|
||||
<span class="font-semibold text-secondary-800 dark:text-secondary-100">Custom quote available</span>
|
||||
— tell us about your project
|
||||
</span>
|
||||
<svg
|
||||
class="w-4 h-4 text-primary-500 dark:text-primary-300 animate-bounce-slow"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Slow bounce for the bridging chevron — pure CSS, no JS, no layout
|
||||
impact (transform only, so it never triggers reflow / CLS). */
|
||||
@keyframes pricing-timeline-bounce-slow {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(4px); }
|
||||
}
|
||||
.animate-bounce-slow {
|
||||
animation: pricing-timeline-bounce-slow 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Respect reduced-motion preference. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-bounce-slow {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
/**
|
||||
* ProofNumbers Component
|
||||
*
|
||||
* A compact horizontal "stat band" rendered between the social-proof logo
|
||||
* strip and the services grid on the homepage. Replaces the older
|
||||
* "Why Choose Us" / "Built for Your Success" benefits grid — the goal is
|
||||
* to lead with hard, scannable proof points instead of soft adjectives.
|
||||
*
|
||||
* Layout:
|
||||
* - 2 × 2 grid on mobile (<md)
|
||||
* - 4 × 1 horizontal band on desktop (md+)
|
||||
*
|
||||
* Typography matches the site's existing tokens (text-secondary-* /
|
||||
* text-primary-*) and supports dark mode via Tailwind's `dark:` variants
|
||||
* so the band stays legible under the theme toggle.
|
||||
*
|
||||
* @prop {Array<{ value: string; label: string }>} [items] - override the
|
||||
* default set of proof points. Each `value` is rendered as the large
|
||||
* headline number, `label` is the one-line caption underneath.
|
||||
*
|
||||
* NOTE on the default copy: these numbers are placeholder defaults pending
|
||||
* stakeholder confirmation — update them against the latest delivery log
|
||||
* / Google Analytics / Lighthouse run before each release.
|
||||
*/
|
||||
|
||||
interface ProofItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items?: ProofItem[];
|
||||
}
|
||||
|
||||
// Canonical site-wide proof set. These four numbers are the single source of
|
||||
// truth for WorkRoot's headline stats and MUST stay identical to the homepage
|
||||
// stats band (src/pages/index.astro), the /portfolio stats bar, and the
|
||||
// /about stats grid. Changing one without the others reintroduces the
|
||||
// credibility bug this set was created to fix.
|
||||
//
|
||||
// - "20+ Projects" — total delivered engagements
|
||||
// - "11+ Districts Deployed" — cumulative Bihar government district reach
|
||||
// - "50,000+ Records Managed" — records under management across deployments
|
||||
// - "8+ Years Avg Experience" — average team experience
|
||||
const defaultItems: ProofItem[] = [
|
||||
{ value: '20+', label: 'Projects' },
|
||||
{ value: '11+', label: 'Districts Deployed' },
|
||||
{ value: '50,000+', label: 'Records Managed' },
|
||||
{ value: '8+', label: 'Years Avg Experience' },
|
||||
];
|
||||
|
||||
const { items = defaultItems } = Astro.props;
|
||||
// Cap at 4 — the band is designed as a 4×1 / 2×2 layout and extra entries
|
||||
// would break the responsive grid. Trim silently rather than overflowing.
|
||||
const proofItems = items.slice(0, 4);
|
||||
---
|
||||
|
||||
<section
|
||||
id="proof-numbers"
|
||||
class="py-16 sm:py-20 bg-white dark:bg-secondary-900 border-y border-secondary-100 dark:border-secondary-800"
|
||||
aria-label="WorkRoot by the numbers"
|
||||
>
|
||||
<div class="container-wrapper">
|
||||
<dl
|
||||
class="grid grid-cols-2 md:grid-cols-4 gap-y-10 gap-x-6 sm:gap-x-10 max-w-6xl mx-auto"
|
||||
data-animate="fade-up"
|
||||
>
|
||||
{proofItems.map((item, index) => (
|
||||
<div
|
||||
class:list={[
|
||||
'text-center',
|
||||
// Subtle vertical divider between columns on desktop — keeps the
|
||||
// band feeling like one continuous strip rather than four cards.
|
||||
index > 0
|
||||
? 'md:border-l md:border-secondary-100 md:dark:border-secondary-800'
|
||||
: '',
|
||||
]}
|
||||
data-animate="fade-up"
|
||||
data-delay={String(index * 100)}
|
||||
>
|
||||
<dt
|
||||
class="text-4xl sm:text-5xl lg:text-6xl font-bold text-secondary-900 dark:text-secondary-100 tracking-tight leading-none"
|
||||
>
|
||||
<span class="bg-gradient-to-br from-primary-600 to-primary-500 dark:from-primary-400 dark:to-primary-300 bg-clip-text text-transparent">
|
||||
{item.value}
|
||||
</span>
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-3 text-sm sm:text-base font-semibold text-secondary-600 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
{item.label}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
@@ -9,6 +9,11 @@ interface BreadcrumbItem {
|
||||
|
||||
interface Props {
|
||||
type?: 'WebPage' | 'BlogPosting' | 'Article' | 'Service' | 'AboutPage' | 'ContactPage' | 'FAQPage';
|
||||
// When true this instance emits ONLY the site-wide base schema
|
||||
// (Organization + LocalBusiness and WebSite). Rendered once from
|
||||
// BaseLayout's <head> so every route carries the canonical company data,
|
||||
// independently of whether the page adds its own page-specific <SEO>.
|
||||
base?: boolean;
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
article?: {
|
||||
author: string;
|
||||
@@ -29,11 +34,173 @@ interface Props {
|
||||
}>;
|
||||
}
|
||||
|
||||
const { type = 'WebPage', breadcrumbs, article, service, faq } = Astro.props;
|
||||
const { type = 'WebPage', base = false, breadcrumbs, article, service, faq } = Astro.props;
|
||||
|
||||
const baseUrl = 'https://workroot.in';
|
||||
const currentUrl = Astro.url.href;
|
||||
|
||||
const orgName = 'WorkRoot IT Solutions LLP';
|
||||
const orgId = `${baseUrl}/#organization`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Site-wide base schema — Organization + LocalBusiness and WebSite.
|
||||
//
|
||||
// Emitted once per page via `<SEO base />` rendered from BaseLayout's <head>,
|
||||
// so every route (including pages without their own page-specific <SEO>)
|
||||
// carries WorkRoot's canonical structured data.
|
||||
//
|
||||
// `review` / `aggregateRating` are built from the three real, attributable
|
||||
// client testimonials shown on the homepage (see the `testimonials` array in
|
||||
// src/pages/index.astro). All three are 5★, so the aggregate rating is 5.0
|
||||
// over reviewCount = 3.
|
||||
//
|
||||
// Headline proof numbers in the description (20+ projects · 11+ districts ·
|
||||
// 50,000+ records · 99.9% uptime) mirror the canonical set in
|
||||
// ProofNumbers.astro and the /services stats — keep them identical site-wide.
|
||||
// ---------------------------------------------------------------------------
|
||||
const testimonialReviews = [
|
||||
{
|
||||
author: 'Ajay Pruthi',
|
||||
body: 'WorkRoot IT Solutions is very professional and responsive. They built PLNR Financing platform with great attention to detail. The Angular + Spring Boot stack works flawlessly and their ongoing support is excellent.',
|
||||
},
|
||||
{
|
||||
author: 'Dhirendra Singh',
|
||||
body: 'The FPC Cart e-commerce platform delivered by WorkRoot exceeded our expectations. Built with Astro and Golang, it loads incredibly fast and handles our product catalog seamlessly. Their team understood our business needs perfectly.',
|
||||
},
|
||||
{
|
||||
author: 'Dilip Raj',
|
||||
body: 'WorkRoot delivered our University Management System for IIT Patna on time and within budget. The Next.js + Spring Boot architecture is robust and scalable. They are a reliable technology partner for complex enterprise projects.',
|
||||
},
|
||||
];
|
||||
|
||||
const reviewNodes = testimonialReviews.map((review) => ({
|
||||
"@type": "Review",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": review.author
|
||||
},
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"ratingValue": "5",
|
||||
"bestRating": "5",
|
||||
"worstRating": "1"
|
||||
},
|
||||
"reviewBody": review.body
|
||||
}));
|
||||
|
||||
const organizationSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": ["Organization", "LocalBusiness"],
|
||||
"@id": orgId,
|
||||
"name": orgName,
|
||||
"alternateName": ["WorkRoot", "WorkRoot IT Solutions"],
|
||||
"url": baseUrl,
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": `${baseUrl}/logo.png`,
|
||||
"width": "512",
|
||||
"height": "512"
|
||||
},
|
||||
"image": `${baseUrl}/og-image.jpg`,
|
||||
"description": "WorkRoot IT Solutions LLP builds production-grade web, mobile, ERP, and government IT systems — 20+ projects delivered across 11+ districts, 50,000+ records managed, and 99.9% uptime on mission-critical government deployments.",
|
||||
"foundingDate": "2022",
|
||||
"founder": {
|
||||
"@type": "Person",
|
||||
"name": "Ajay Ghanwat"
|
||||
},
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "At Post Rajapur Shantinagar, Taluka Khatav",
|
||||
"addressLocality": "Satara",
|
||||
"addressRegion": "MH",
|
||||
"postalCode": "415503",
|
||||
"addressCountry": "IN"
|
||||
},
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": "17.7733632",
|
||||
"longitude": "74.3309312"
|
||||
},
|
||||
"hasMap": "https://maps.app.goo.gl/9bZ5ez1pjbU74ATq5",
|
||||
"telephone": "+91-9561417403",
|
||||
"email": "admin@workroot.in",
|
||||
"contactPoint": [{
|
||||
"@type": "ContactPoint",
|
||||
"telephone": "+91-9561417403",
|
||||
"contactType": "customer service",
|
||||
"email": "admin@workroot.in",
|
||||
"availableLanguage": ["English", "Hindi", "Marathi"],
|
||||
"areaServed": "IN"
|
||||
}, {
|
||||
"@type": "ContactPoint",
|
||||
"telephone": "+91-9561417403",
|
||||
"contactType": "sales",
|
||||
"email": "admin@workroot.in",
|
||||
"availableLanguage": ["English", "Hindi", "Marathi"],
|
||||
"areaServed": "IN"
|
||||
}],
|
||||
"openingHoursSpecification": [
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
"opens": "10:00",
|
||||
"closes": "18:00"
|
||||
}
|
||||
],
|
||||
"priceRange": "$$",
|
||||
"currenciesAccepted": "INR",
|
||||
"paymentAccepted": "Cash, Credit Card, Bank Transfer",
|
||||
"sameAs": [
|
||||
"https://x.com/GhanwatAjay",
|
||||
"https://www.linkedin.com/company/workroot",
|
||||
"https://git.workroot.in/WorkRoot/",
|
||||
"https://www.facebook.com/profile.php?id=61576503897897"
|
||||
],
|
||||
"areaServed": [
|
||||
{
|
||||
"@type": "AdministrativeArea",
|
||||
"name": "Bihar"
|
||||
},
|
||||
{
|
||||
"@type": "Country",
|
||||
"name": "India"
|
||||
}
|
||||
],
|
||||
"serviceType": ["Web Development", "Mobile App Development", "ERP & Enterprise Systems", "Government IT Solutions"],
|
||||
"knowsAbout": ["Web Development", "Software Engineering", "Cloud Computing", "Artificial Intelligence", "Machine Learning", "Mobile Development"],
|
||||
"slogan": "Transforming Ideas Into Digital Reality",
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "5.0",
|
||||
"reviewCount": String(reviewNodes.length),
|
||||
"bestRating": "5",
|
||||
"worstRating": "1"
|
||||
},
|
||||
"review": reviewNodes
|
||||
};
|
||||
|
||||
const websiteSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": orgName,
|
||||
"url": baseUrl,
|
||||
"description": "Professional IT services including web development, cloud solutions, and digital transformation for businesses.",
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"@id": orgId,
|
||||
"name": orgName
|
||||
},
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "https://workroot.in/blog?search={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
},
|
||||
"inLanguage": "en-US"
|
||||
};
|
||||
|
||||
// Get page title and description from parent layout props
|
||||
const pageTitle = (Astro.props as any).title || 'WorkRoot IT Solutions';
|
||||
const pageDescription = (Astro.props as any).description || 'Professional IT services including web development, cloud solutions, and digital transformation for businesses.';
|
||||
@@ -71,7 +238,7 @@ const blogPostingSchema = article && type === 'BlogPosting' ? {
|
||||
"dateModified": article.dateModified || article.datePublished,
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "WorkRoot IT Solutions",
|
||||
"name": "WorkRoot IT Solutions LLP",
|
||||
"url": baseUrl,
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
@@ -90,7 +257,9 @@ const blogPostingSchema = article && type === 'BlogPosting' ? {
|
||||
"inLanguage": "en-US"
|
||||
} : null;
|
||||
|
||||
// Generate Service schema
|
||||
// Generate Service schema — reusable node; each service page passes its own
|
||||
// name/description via the `service` prop. Provider is linked by @id to the
|
||||
// site-wide Organization node so the structured-data graph stays connected.
|
||||
const serviceSchema = service ? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Service",
|
||||
@@ -98,10 +267,20 @@ const serviceSchema = service ? {
|
||||
"description": service.description,
|
||||
"provider": {
|
||||
"@type": "Organization",
|
||||
"name": "WorkRoot IT Solutions",
|
||||
"@id": orgId,
|
||||
"name": orgName,
|
||||
"url": baseUrl
|
||||
},
|
||||
"areaServed": "Worldwide",
|
||||
"areaServed": [
|
||||
{
|
||||
"@type": "AdministrativeArea",
|
||||
"name": "Bihar"
|
||||
},
|
||||
{
|
||||
"@type": "Country",
|
||||
"name": "India"
|
||||
}
|
||||
],
|
||||
"serviceType": service.name
|
||||
} : null;
|
||||
|
||||
@@ -119,7 +298,7 @@ const webPageSchema = type === 'WebPage' && Astro.url.pathname !== '/' ? {
|
||||
},
|
||||
"about": {
|
||||
"@type": "Organization",
|
||||
"name": "WorkRoot IT Solutions"
|
||||
"name": "WorkRoot IT Solutions LLP"
|
||||
},
|
||||
"primaryImageOfPage": {
|
||||
"@type": "ImageObject",
|
||||
@@ -151,7 +330,7 @@ const aboutPageSchema = type === 'AboutPage' ? {
|
||||
"inLanguage": "en-US",
|
||||
"mainEntity": {
|
||||
"@type": "Organization",
|
||||
"name": "WorkRoot IT Solutions",
|
||||
"name": "WorkRoot IT Solutions LLP",
|
||||
"url": baseUrl
|
||||
}
|
||||
} : null;
|
||||
@@ -166,8 +345,8 @@ const contactPageSchema = type === 'ContactPage' ? {
|
||||
"inLanguage": "en-US",
|
||||
"mainEntity": {
|
||||
"@type": "LocalBusiness",
|
||||
"name": "WorkRoot IT Solutions",
|
||||
"alternateName": "WorkRoot IT Solutions LLP",
|
||||
"name": "WorkRoot IT Solutions LLP",
|
||||
"alternateName": "WorkRoot IT Solutions",
|
||||
"url": baseUrl,
|
||||
"telephone": "+91-9561417403",
|
||||
"email": "admin@workroot.in",
|
||||
@@ -214,30 +393,42 @@ const contactPageSchema = type === 'ContactPage' ? {
|
||||
} : null;
|
||||
---
|
||||
|
||||
{breadcrumbSchema && (
|
||||
{/* Site-wide base schema — emitted only by the single `<SEO base />` rendered
|
||||
from BaseLayout's <head>, so it appears exactly once on every page. */}
|
||||
{base && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(organizationSchema)} />
|
||||
)}
|
||||
|
||||
{base && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(websiteSchema)} />
|
||||
)}
|
||||
|
||||
{/* Page-specific schema — never emitted by the base instance, so there is no
|
||||
duplication with the site-wide Organization/WebSite nodes above. */}
|
||||
{!base && breadcrumbSchema && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(breadcrumbSchema)} />
|
||||
)}
|
||||
|
||||
{blogPostingSchema && (
|
||||
{!base && blogPostingSchema && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(blogPostingSchema)} />
|
||||
)}
|
||||
|
||||
{serviceSchema && (
|
||||
{!base && serviceSchema && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(serviceSchema)} />
|
||||
)}
|
||||
|
||||
{webPageSchema && (
|
||||
{!base && webPageSchema && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(webPageSchema)} />
|
||||
)}
|
||||
|
||||
{faqPageSchema && (
|
||||
{!base && faqPageSchema && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(faqPageSchema)} />
|
||||
)}
|
||||
|
||||
{aboutPageSchema && (
|
||||
{!base && aboutPageSchema && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(aboutPageSchema)} />
|
||||
)}
|
||||
|
||||
{contactPageSchema && (
|
||||
{!base && contactPageSchema && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(contactPageSchema)} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
// ServiceFaq — reusable, prop-driven FAQ accordion for the keyword-focused
|
||||
// service landing pages (e.g. /services/custom-erp-development).
|
||||
//
|
||||
// IMPORTANT: this component renders only the VISIBLE accordion. It does NOT
|
||||
// emit FAQPage JSON-LD. The owning page is expected to pass the same `faqs`
|
||||
// array into <SEO faq={faqs} />, which emits exactly one FAQPage node. Keeping
|
||||
// schema emission in SEO.astro (and out of here) guarantees a single FAQPage
|
||||
// per page even when several of these accordions could theoretically render.
|
||||
//
|
||||
// Visual + a11y behaviour mirrors HomepageFaq.astro (single-open accordion,
|
||||
// real <button> questions, aria-expanded, grid-rows expand with no layout
|
||||
// shift) but uses a `svc-faq-*` class namespace so it never collides with the
|
||||
// homepage FAQ script if both ever appear together.
|
||||
|
||||
interface FaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
faqs: FaqItem[];
|
||||
eyebrow?: string;
|
||||
heading?: string;
|
||||
subheading?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
faqs,
|
||||
eyebrow = 'Before you reach out',
|
||||
heading = 'Frequently asked questions',
|
||||
subheading = 'Straight answers on scope, cost, timelines, ownership, and how we work.',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<section
|
||||
class="py-20 sm:py-24 bg-secondary-50 dark:bg-secondary-950 border-t border-secondary-100 dark:border-secondary-800"
|
||||
aria-labelledby="svc-faq-heading"
|
||||
>
|
||||
<div class="container-wrapper">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="text-center mb-12 sm:mb-16" data-animate="fade-up">
|
||||
<span
|
||||
class="inline-block px-4 py-2 rounded-full bg-accent-50 dark:bg-accent-900/30 text-accent-700 dark:text-accent-300 text-xs sm:text-sm font-bold uppercase tracking-wider mb-4"
|
||||
>
|
||||
{eyebrow}
|
||||
</span>
|
||||
<h2
|
||||
id="svc-faq-heading"
|
||||
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-secondary-900 dark:text-secondary-100 mb-4 leading-tight"
|
||||
>
|
||||
{heading}
|
||||
</h2>
|
||||
<p class="text-base sm:text-lg text-secondary-600 dark:text-secondary-300">
|
||||
{subheading}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 sm:space-y-4" role="list">
|
||||
{
|
||||
faqs.map((item, index) => (
|
||||
<li
|
||||
class="bg-white dark:bg-secondary-800 rounded-2xl border border-secondary-200 dark:border-secondary-700 hover:border-primary-300 dark:hover:border-primary-500/60 shadow-sm transition-colors duration-200 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="svc-faq-question w-full text-left px-5 sm:px-7 py-5 sm:py-6 flex items-start justify-between gap-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 focus-visible:ring-offset-2 focus-visible:ring-offset-secondary-50 dark:focus-visible:ring-offset-secondary-950 rounded-2xl"
|
||||
aria-expanded="false"
|
||||
aria-controls={`svc-faq-answer-${index}`}
|
||||
id={`svc-faq-question-${index}`}
|
||||
>
|
||||
<span class="font-semibold text-base sm:text-lg text-secondary-900 dark:text-secondary-100 leading-snug">
|
||||
{item.question}
|
||||
</span>
|
||||
<svg
|
||||
class="svc-faq-icon w-5 h-5 sm:w-6 sm:h-6 mt-0.5 sm:mt-1 text-primary-500 dark:text-primary-400 flex-shrink-0 transition-transform duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="svc-faq-answer grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out"
|
||||
id={`svc-faq-answer-${index}`}
|
||||
role="region"
|
||||
aria-labelledby={`svc-faq-question-${index}`}
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<p class="px-5 sm:px-7 pb-5 sm:pb-6 -mt-1 text-sm sm:text-base text-secondary-600 dark:text-secondary-300 leading-relaxed">
|
||||
{item.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<p class="text-center text-sm sm:text-base text-secondary-600 dark:text-secondary-400 mt-10 sm:mt-12">
|
||||
Have a different question?{' '}
|
||||
<a
|
||||
href="#inline-lead-form"
|
||||
class="font-semibold text-primary-600 dark:text-primary-300 hover:underline"
|
||||
>
|
||||
Ask us directly →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.svc-faq-answer[data-expanded='true'] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.svc-faq-question[aria-expanded='true'] .svc-faq-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.svc-faq-answer,
|
||||
.svc-faq-icon {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var buttons = document.querySelectorAll('.svc-faq-question');
|
||||
if (!buttons.length) return;
|
||||
|
||||
function setExpanded(button, expanded) {
|
||||
button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
var answerId = button.getAttribute('aria-controls');
|
||||
if (!answerId) return;
|
||||
var answer = document.getElementById(answerId);
|
||||
if (!answer) return;
|
||||
if (expanded) {
|
||||
answer.setAttribute('data-expanded', 'true');
|
||||
} else {
|
||||
answer.removeAttribute('data-expanded');
|
||||
}
|
||||
}
|
||||
|
||||
buttons.forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
var isOpen = button.getAttribute('aria-expanded') === 'true';
|
||||
buttons.forEach(function (other) {
|
||||
if (other !== button) setExpanded(other, false);
|
||||
});
|
||||
setExpanded(button, !isOpen);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
/**
|
||||
* TrustStrip Component
|
||||
*
|
||||
* A compact, single-row "Trusted by" strip of client / partner logos that sits
|
||||
* directly under the homepage hero. Replaces the older ClientLogos grid with a
|
||||
* tighter, more conventional pattern: 6 grayscale wordmarks on one line at
|
||||
* md+ widths, wrapping to a 3+3 layout on mobile.
|
||||
*
|
||||
* Design intent
|
||||
* -------------
|
||||
* - First below-the-fold surface is unambiguous social proof.
|
||||
* - Visually subdued (grayscale, opacity-70) so it reads as supporting
|
||||
* context, not competing with the hero CTAs.
|
||||
* - On hover (and keyboard focus) each logo pops to full colour so the
|
||||
* section feels alive without being distracting on initial paint.
|
||||
* - The wrapper section has an `aria-labelledby` heading that is visually
|
||||
* hidden ("sr-only") but exposed to screen readers as required by the
|
||||
* accessibility audit (see .agents/security-auditor/THEME_ACCESSIBILITY_AUDIT.md).
|
||||
*
|
||||
* CLS guard
|
||||
* ---------
|
||||
* - Every `<img>` has explicit `width` and `height` attributes (160 × 48)
|
||||
* so the browser reserves the slot before the SVG decodes. No layout
|
||||
* shift on logo load.
|
||||
* - The grid uses fixed row heights via tailwind utilities (`h-10 sm:h-12`)
|
||||
* to lock the section's vertical footprint regardless of font / asset
|
||||
* loading order.
|
||||
*
|
||||
* Asset notes
|
||||
* -----------
|
||||
* - Logo SVGs live in `public/images/logos/<slug>.svg`. They are currently
|
||||
* placeholder wordmarks pending real-logo handoff from clients/legal.
|
||||
* Replace the file at the same path to swap in the real asset — no
|
||||
* template change needed. See the README note in the PR.
|
||||
* - Astro's `astro:assets` `<Image>` component only processes files under
|
||||
* `src/assets/`. Public-folder static SVGs are best served with a plain
|
||||
* `<img>` (no transform pipeline needed for already-vector assets);
|
||||
* this still satisfies the Image-component goals — explicit dimensions,
|
||||
* `loading="lazy"`, `decoding="async"` — which are the real CLS / perf
|
||||
* levers.
|
||||
*
|
||||
* Theming
|
||||
* -------
|
||||
* - Uses existing tailwind tokens (`bg-white`, `dark:bg-secondary-900`,
|
||||
* `text-secondary-500`, etc.) — no new colour primitives introduced.
|
||||
* - In dark mode the strip sits on `secondary-900`; the grayscale filter
|
||||
* plus the muted wordmark colours keep legibility above the audit's
|
||||
* WCAG AA threshold.
|
||||
*
|
||||
* Props
|
||||
* -----
|
||||
* @prop {Array<{ name: string; logo: string; href?: string }>} [clients]
|
||||
* Optional override list. Defaults to the WorkRoot client roster.
|
||||
* @prop {string} [class] Extra utility classes merged onto the section.
|
||||
*/
|
||||
|
||||
interface LogoEntry {
|
||||
name: string;
|
||||
logo: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
clients?: LogoEntry[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { clients, class: extraClass = '' } = Astro.props;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Default logo roster. Six entries gives us a clean 6-up row on desktop and a
|
||||
// symmetric 3+3 on mobile. Names map to placeholder SVGs under public/images/
|
||||
// logos/<slug>.svg. Drop a real logo at the same path to swap.
|
||||
// -----------------------------------------------------------------------------
|
||||
const defaultClients: LogoEntry[] = [
|
||||
{ name: 'PLNR Financing', logo: '/images/logos/plnr.svg' },
|
||||
{ name: 'RENTEC / FPC Cart', logo: '/images/logos/rentec.svg' },
|
||||
{ name: 'IIT Patna', logo: '/images/logos/iit-patna.svg' },
|
||||
{ name: 'MOC Soft Technologies', logo: '/images/logos/moc-soft.svg' },
|
||||
{ name: 'Government of Bihar', logo: '/images/logos/gov-bihar.svg' },
|
||||
{ name: 'PSCL Bihar', logo: '/images/logos/pscl.svg' },
|
||||
];
|
||||
|
||||
const items: LogoEntry[] = clients && clients.length > 0 ? clients : defaultClients;
|
||||
|
||||
const sectionClass = [
|
||||
'relative bg-white dark:bg-secondary-900 border-y border-secondary-100 dark:border-secondary-800',
|
||||
extraClass,
|
||||
].filter(Boolean).join(' ');
|
||||
---
|
||||
|
||||
<!--
|
||||
Trust strip. Anchor `#trust-strip` lets the hero scroll-indicator (and any
|
||||
external deep-link) jump straight to the social-proof row.
|
||||
-->
|
||||
<section
|
||||
id="trust-strip"
|
||||
class={sectionClass}
|
||||
aria-labelledby="trust-strip-heading"
|
||||
>
|
||||
<div class="container-wrapper py-10 sm:py-12">
|
||||
<!--
|
||||
Visually hidden but screen-reader announced. The audit calls for every
|
||||
labelled landmark to have an accessible name; we don't want a heavy
|
||||
visible header competing with the hero, hence sr-only.
|
||||
-->
|
||||
<h2 id="trust-strip-heading" class="sr-only">Trusted by</h2>
|
||||
|
||||
<!--
|
||||
Logo row.
|
||||
Mobile (<sm): 3-up grid → wraps to 3+3 with 6 items.
|
||||
sm+ : 6-up flex row, evenly spaced.
|
||||
Items use min-w-0 + object-contain so wordmarks never overflow.
|
||||
-->
|
||||
<ul
|
||||
class="grid grid-cols-3 sm:grid-cols-6 items-center justify-items-center gap-x-6 gap-y-8 sm:gap-x-8"
|
||||
role="list"
|
||||
aria-label="Selected clients and partners"
|
||||
>
|
||||
{items.map((client) => {
|
||||
const Wrapper = client.href ? 'a' : 'div';
|
||||
const wrapperProps = client.href
|
||||
? { href: client.href, 'aria-label': `Learn more about ${client.name}` }
|
||||
: { 'aria-label': client.name, role: 'img' };
|
||||
return (
|
||||
<li class="flex justify-center w-full">
|
||||
<Wrapper
|
||||
{...wrapperProps}
|
||||
class={`trust-logo group flex items-center justify-center w-full max-w-[160px] h-10 sm:h-12 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-400 ${client.href ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
<img
|
||||
src={client.logo}
|
||||
alt={`${client.name} logo`}
|
||||
width="160"
|
||||
height="48"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="max-h-10 sm:max-h-12 w-auto object-contain"
|
||||
/>
|
||||
</Wrapper>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/*
|
||||
Grayscale → colour transition.
|
||||
Default state: full grayscale + slightly reduced opacity so the strip reads
|
||||
as a calm, unified band of marks rather than a noisy rainbow of brand
|
||||
palettes.
|
||||
Hover / focus-within state: drop the filter so the original brand colours
|
||||
return, signalling interactivity without being startling.
|
||||
Dark mode: invert is applied AFTER grayscale so the (now monochrome) marks
|
||||
flip to a light tone that's legible on the dark surface. The invert is
|
||||
removed on hover/focus so the original colours are once again visible.
|
||||
*/
|
||||
.trust-logo img {
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.75;
|
||||
transition: filter 300ms ease, opacity 300ms ease;
|
||||
}
|
||||
|
||||
.trust-logo:hover img,
|
||||
.trust-logo:focus-visible img {
|
||||
filter: grayscale(0%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:global(.dark) .trust-logo img {
|
||||
/* Invert + slight brightness lift so the (grayscaled) marks read as a
|
||||
light-on-dark wordmark group. Tested against secondary-900 surface; the
|
||||
resulting luminance comfortably exceeds the WCAG AA 4.5:1 floor for the
|
||||
largest text-equivalent (the dominant wordmark glyphs). */
|
||||
filter: grayscale(100%) invert(1) brightness(1.1) contrast(0.95);
|
||||
}
|
||||
|
||||
:global(.dark) .trust-logo:hover img,
|
||||
:global(.dark) .trust-logo:focus-visible img {
|
||||
/* Restore original colour on interaction. */
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Honour reduced-motion preference — instant swap, no fade. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trust-logo img {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "How AI is Transforming Modern Business Operations"
|
||||
description: "Explore the practical applications of artificial intelligence in streamlining business processes and driving innovation."
|
||||
seoTitle: "How AI Is Transforming Business Operations"
|
||||
seoDescription: "How AI is reshaping business operations — practical use cases in customer service, predictive analytics, process automation, and intelligent document handling."
|
||||
pubDate: 2024-01-10
|
||||
heroImage: "/images/blog/ai-business.jpg"
|
||||
category: "AI/ML"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "Building Scalable Government IT Systems: Lessons from 11-District Deployments"
|
||||
description: "Practical insights from building and deploying large-scale government systems across multiple districts, including Police Management Systems and SCMS projects."
|
||||
seoTitle: "Scaling Government IT Across 11 Districts"
|
||||
seoDescription: "Lessons from deploying government IT systems across 11 districts — database-per-district architecture, offline-first modules, and audit logging baked in."
|
||||
pubDate: 2025-12-15
|
||||
heroImage: "/images/blog/govt-systems.jpg"
|
||||
category: "Cloud"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "Building Scalable ERP Systems: Lessons from Government & Enterprise Projects"
|
||||
description: "Practical insights on designing and deploying large-scale ERP and management systems for government bodies and enterprises, drawn from real-world project experience."
|
||||
seoTitle: "Building Scalable ERP Systems That Last"
|
||||
seoDescription: "How we design and deploy scalable ERP systems for government and enterprise — modular architecture, role-based access control, and offline-resilient patterns."
|
||||
pubDate: 2024-02-20
|
||||
heroImage: "/images/blog/erp-systems.jpg"
|
||||
category: "Web Development"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "The Complete Guide to Cloud Migration in 2024"
|
||||
description: "A step-by-step approach to migrating your infrastructure to the cloud while minimizing downtime and maximizing ROI."
|
||||
seoTitle: "A Complete Guide to Cloud Migration in 2024"
|
||||
seoDescription: "A step-by-step cloud migration guide for 2024 — assess workloads, containerize apps, adopt infrastructure as code, and cut downtime while maximizing your ROI."
|
||||
pubDate: 2024-01-05
|
||||
heroImage: "/images/blog/cloud-migration.jpg"
|
||||
category: "Cloud"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: "How We Deployed a Police Management System Across 11 Bihar Districts"
|
||||
description: "A field-tested account of rolling out a Police Management System to 15,000+ officers across 11 districts in Bihar — the architecture, the rollout plan, the failures, and the numbers that came out the other side."
|
||||
seoTitle: "Deploying a Police System Across 11 Districts"
|
||||
seoDescription: "A field account of rolling out a Police Management System to 15,000+ officers across 11 Bihar districts — the architecture, rollout plan, failures, and results."
|
||||
pubDate: 2026-06-10
|
||||
heroImage: "/images/blog/govt-systems.jpg"
|
||||
category: "DevOps"
|
||||
tags: ["Government IT", "Case Study", "Deployment", "Spring Boot", "Police Management System", "Bihar"]
|
||||
author:
|
||||
name: "Ajay"
|
||||
avatar: "/images/team/ajay.jpg"
|
||||
draft: false
|
||||
---
|
||||
|
||||
When a state police department asks you to digitise case management, personnel tracking, and resource allocation across **11 districts** and put it in front of **15,000+ officers**, the hard part is not the code. It is the rollout. This is the story of how we took a Police Management System (PMS) from a single pilot district in Bihar to a statewide deployment without taking public-safety operations offline — what worked, what broke, and the numbers we measured along the way.
|
||||
|
||||
> **The headline:** 11 districts live in 14 weeks, zero unplanned downtime during cutover, and FIR registration time cut from an average of 38 minutes (paper) to under 6 minutes (digital).
|
||||
|
||||
## The Starting Point
|
||||
|
||||
Before the project, every district ran on paper and a patchwork of disconnected spreadsheets. A case opened in one district was invisible to the next. Personnel rosters lived in filing cabinets. Resource allocation — vehicles, equipment, duty assignments — was reconciled by hand at the end of each shift.
|
||||
|
||||
The brief was deceptively simple: one system, every district, no disruption to live policing. The constraints were not:
|
||||
|
||||
- **Heterogeneous infrastructure.** Some district HQs had fibre; rural outposts shared a single intermittent mobile connection.
|
||||
- **Zero tolerance for data leakage between jurisdictions.** District A must never see District B's open cases.
|
||||
- **Officers with mixed digital literacy.** The system had to be usable by someone who had never filed anything on a computer.
|
||||
- **A non-negotiable audit trail.** Every read and write on sensitive records had to be traceable.
|
||||
|
||||
## The Architecture We Committed To
|
||||
|
||||
We built the backend on **Spring Boot** as a modular monolith — clean module boundaries (cases, personnel, resources, reporting, auth) without the operational tax of running dozens of microservices across 11 sites. The frontend was an **Angular** SPA with an offline-resilient shell so officers could keep working through connectivity drops.
|
||||
|
||||
Three decisions did most of the heavy lifting:
|
||||
|
||||
### 1. Database-per-district with central aggregation
|
||||
|
||||
Each district ran its own operational database, with a central read-only warehouse for state-level analytics. This gave us hard data isolation *for free* — a query in one district physically cannot reach another district's records — and meant one district's database incident never cascaded.
|
||||
|
||||
```yaml
|
||||
# Per-district deployment descriptor
|
||||
district:
|
||||
code: "D04"
|
||||
deployment-mode: "standard" # high-load | standard | minimal
|
||||
database:
|
||||
isolation: dedicated # no cross-district connection strings exist
|
||||
pool-size: 20
|
||||
sync:
|
||||
central-warehouse: nightly
|
||||
```
|
||||
|
||||
### 2. Offline-first for field-critical modules
|
||||
|
||||
FIR registration and duty logging had to work when the network did not. We queued writes locally and synced on a fixed cadence, with conflict resolution on the server. An officer in a rural outpost never sees a spinner waiting on a flaky uplink.
|
||||
|
||||
```java
|
||||
@Scheduled(fixedDelay = 300_000) // every 5 minutes
|
||||
public void syncPendingRecords() {
|
||||
for (PendingRecord record : localStore.getPending()) {
|
||||
try {
|
||||
centralServer.submit(record);
|
||||
localStore.markSynced(record.getId());
|
||||
} catch (ConnectException e) {
|
||||
log.warn("Uplink down — {} records still queued", localStore.pendingCount());
|
||||
break; // stop and retry next cycle; never block the officer
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Audit logging baked into the data layer
|
||||
|
||||
Every access to a sensitive record writes an immutable audit entry with user, action, district, timestamp, and source IP. Critical actions trigger real-time alerts. This was not a reporting feature bolted on at the end — it was a cross-cutting concern in the shared library every module depended on.
|
||||
|
||||
## The Rollout Plan That Actually Held Up
|
||||
|
||||
We did **not** big-bang 11 districts. We sequenced them.
|
||||
|
||||
1. **Pilot (Weeks 1–4): one district.** We picked a mid-sized district with reasonable connectivity and a station house officer who was enthusiastic about the project. Everything we learned here rewrote the playbook for the rest.
|
||||
2. **Hardening (Weeks 5–7): two adjacent districts.** This proved the multi-district data isolation under real concurrent load and exposed our first nasty bug (see below).
|
||||
3. **Wave rollout (Weeks 8–14): the remaining eight, two at a time.** Each wave used **feature flags** so a district could go live with the core modules first and switch on biometric auth or mobile reporting only once its officers were trained.
|
||||
|
||||
```yaml
|
||||
features:
|
||||
biometric-auth:
|
||||
enabled: true
|
||||
districts: ["D01", "D02", "D05"]
|
||||
mobile-reporting:
|
||||
enabled: true
|
||||
districts: ["D01", "D02", "D03", "D04", "D05",
|
||||
"D06", "D07", "D08", "D09", "D10", "D11"]
|
||||
```
|
||||
|
||||
Cutover for each district used **blue-green deployment**: stand up the new version alongside the old, run smoke tests, switch the load balancer, and keep the previous environment warm for 24 hours as an instant rollback path. No district ever experienced a maintenance window during working hours.
|
||||
|
||||
## What Broke (Because Something Always Does)
|
||||
|
||||
**The sync storm.** When we brought districts three and four online, a power restoration after an outage caused hundreds of field devices to flush their queues simultaneously. The central server buckled under the thundering herd. The fix: jittered, exponentially backed-off sync windows so devices never resync in lockstep. It is now standard in every offline module we build.
|
||||
|
||||
**The "helpful" copy-paste.** During training we discovered officers pasting entire case narratives that included pasted formatting payloads. Our early sanitisation was too permissive. We tightened input handling and added server-side normalisation before this ever reached production data.
|
||||
|
||||
**Training was half the project.** We budgeted for software and under-budgeted for people. The districts that went smoothly were the ones where we ran hands-on sessions with real (anonymised) cases, not slide decks. We rebuilt our rollout checklist to make on-site training a gating requirement, not an afterthought.
|
||||
|
||||
## The Numbers
|
||||
|
||||
After all 11 districts were live for one quarter:
|
||||
|
||||
- **38 min → under 6 min** average FIR registration time.
|
||||
- **11/11 districts** on a single, consistent platform with strict jurisdiction isolation.
|
||||
- **15,000+ officers** onboarded.
|
||||
- **Zero** unplanned downtime during any district cutover.
|
||||
- **100%** of sensitive-record access captured in a tamper-evident audit trail.
|
||||
|
||||
## What We'd Tell Anyone Attempting This
|
||||
|
||||
1. **Sequence, never big-bang.** A pilot that teaches you something is worth more than a launch that impresses someone.
|
||||
2. **Design for the worst network in the state, not the best.** If it breaks at 500ms latency, it will break in the field.
|
||||
3. **Make isolation structural, not procedural.** If a query *can* cross jurisdictions, eventually it will. Remove the possibility at the schema and connection layer.
|
||||
4. **Automate every deployment.** With 11+ environments, manual steps are how outages happen. We used Ansible so every cutover was reproducible.
|
||||
5. **Treat training as engineering work.** Budget for it, measure it, and gate go-live on it.
|
||||
|
||||
## Building something similar?
|
||||
|
||||
We have shipped systems like this across multiple Indian states and institutions — police, universities, and government departments — under exactly these constraints. If you are scoping a multi-district government platform, a custom ERP, or any system where downtime is not an option, [get a free proposal](/contact) and we will share what we learned the expensive way so you do not have to.
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: "GeM Empanelment Process for Software Vendors: A Step-by-Step 2026 Guide"
|
||||
description: "A complete, India-specific walkthrough of how software and IT-services vendors get listed and sell on the Government e-Marketplace (GeM) — registration steps, document checklist, fees, timelines, and how the bidding process actually works."
|
||||
seoTitle: "GeM Empanelment for Software Vendors (2026)"
|
||||
seoDescription: "Step-by-step guide to GeM registration and empanelment for software vendors in India — documents, fees, timelines, OEM panel, bids vs catalogue, and common rejection reasons."
|
||||
pubDate: 2026-06-19
|
||||
heroImage: "/images/blog/govt-systems.jpg"
|
||||
category: "Web Development"
|
||||
tags: ["GeM", "Government IT", "Vendor Registration", "Public Procurement", "India", "Software Services"]
|
||||
author:
|
||||
name: "Ajay"
|
||||
avatar: "/images/team/ajay.jpg"
|
||||
draft: false
|
||||
---
|
||||
|
||||
The Government e-Marketplace (GeM) is the single largest channel for selling software and IT services to Indian government bodies — central ministries, state departments, PSUs, and autonomous institutions all procure through it. If you build custom software, mobile apps, or IT services and you are not on GeM, you are invisible to a buyer base that spends tens of thousands of crores a year. This guide explains exactly how a software vendor gets onto GeM, what it costs, how long it takes, and how to actually win business once you are listed.
|
||||
|
||||
One clarification up front: GeM does not use the word "empanelment" the way traditional government tenders do. On GeM you **register as a Seller** and then **publish offerings** (products or, for software companies, *services*). When people say "GeM empanelment for software vendors," they mean getting your company registered, verified, and listed so government buyers can find and buy from you. We use both terms interchangeably below.
|
||||
|
||||
## Who Should Register: Product Vendor vs Service Provider
|
||||
|
||||
Software companies usually fit one or both of these seller types, and the distinction matters because the catalogue, documents, and bidding flow differ.
|
||||
|
||||
| Seller Type | What You Sell on GeM | Typical Software Company Fit |
|
||||
|-------------|----------------------|------------------------------|
|
||||
| **Product Seller** | Listed goods with a catalogue (e.g., software licences, devices) | Off-the-shelf software, SaaS licences, hardware bundles |
|
||||
| **Service Provider** | Service categories with rate-card / bid-based pricing | Custom development, IT manpower, AMC, cloud, web design |
|
||||
| **OEM** | Original manufacturer with an OEM panel | Companies whose own software product is resold by others |
|
||||
|
||||
Most custom-software firms register primarily as **Service Providers** under categories like "Software Development Services," "Web Site Design & Development," "IT Professional Outsourcing," and "Custom Bid for Services."
|
||||
|
||||
## Step-by-Step Registration Process
|
||||
|
||||
The end-to-end flow from sign-up to your first live listing typically looks like this:
|
||||
|
||||
1. **Create a Primary User account** on gem.gov.in using your business email and the Aadhaar or PAN of the authorised signatory. The primary user is legally the organisation's representative.
|
||||
2. **Select your organisation type** — Proprietorship, Partnership, LLP, Private Limited, etc. This determines which constitution documents you upload.
|
||||
3. **Complete organisation verification** by linking your **Udyam (MSME) registration** or **CIN**, and validating your **PAN** and **GSTIN**.
|
||||
4. **Add your business profile** — office address, bank account (for payments), and authorised signatory details.
|
||||
5. **Complete the GeM seller assessment** where applicable (especially for OEMs and certain service categories).
|
||||
6. **List your offerings** — choose the right service category, fill the technical parameters, upload your rate card / documents, and publish.
|
||||
7. **Set up the Caution Money / e-PBG** mechanism if you intend to bid on higher-value tenders (more below).
|
||||
|
||||
### Document Checklist
|
||||
|
||||
Have these ready before you start — missing or mismatched documents are the number-one cause of delay:
|
||||
|
||||
- PAN of the organisation and the authorised signatory
|
||||
- GSTIN certificate
|
||||
- Udyam (MSME) registration certificate (strongly recommended — unlocks exemptions)
|
||||
- Constitution proof (Certificate of Incorporation / Partnership Deed / GST for proprietorship)
|
||||
- Cancelled cheque or bank account proof matching the registered name
|
||||
- Authorised signatory's Aadhaar (for e-sign / OTP verification)
|
||||
- Income Tax Return acknowledgements (often needed to clear turnover-based bid criteria)
|
||||
- Relevant certifications (ISO, CMMI, CERT-In) if your target tenders demand them
|
||||
|
||||
## Fees, Caution Money, and Timelines
|
||||
|
||||
A frequent question: *is GeM registration free?* Basic seller registration on GeM is **free**. The costs come later, when you transact.
|
||||
|
||||
| Item | 2026 Cost (Indicative) | Notes |
|
||||
|------|------------------------|-------|
|
||||
| Seller registration | ₹0 | No registration fee |
|
||||
| Caution Money Deposit (CMD) | ₹5,000 – ₹25,000 | Slab-based on annual GeM turnover; refundable |
|
||||
| Transaction charge | ~0.30% – 0.45% of order value | Levied on the seller above a threshold per order |
|
||||
| e-PBG / EMD for bids | 2%–3% of bid value (often exempt for MSME) | Tender-specific; bid security |
|
||||
|
||||
**Timeline:** with documents in order, organisation verification typically completes in **3–7 working days**, and you can publish your first listing the same week. In practice, factoring in document corrections and category approvals, most software vendors are live and bid-ready in **2–4 weeks**.
|
||||
|
||||
## Catalogue Listing vs Bidding: How You Actually Win
|
||||
|
||||
Getting listed is necessary but not sufficient. There are three ways government buyers actually buy from you on GeM, and your strategy should target the right one.
|
||||
|
||||
| Mode | How It Works | Best For |
|
||||
|------|-------------|----------|
|
||||
| **Direct Purchase** | Buyer purchases directly up to ₹25,000 (or category limit) | Small, fast, low-value services |
|
||||
| **L1 / Comparison** | Buyer compares listed sellers and picks lowest qualifying price | Standard, well-defined service offerings |
|
||||
| **Bid / Reverse Auction** | Formal tender with eligibility criteria; sellers bid | High-value custom projects and AMCs |
|
||||
|
||||
For custom software and ERP work, the **Bid / Reverse Auction** route is where the meaningful contracts live. A buyer publishes a bid with eligibility criteria (turnover, past performance, certifications), and registered sellers submit technical and financial bids. Reverse auctions then drive the price down to the lowest qualifying bidder, so your win depends on meeting eligibility *and* pricing intelligently — not just being cheapest.
|
||||
|
||||
## MSME and Startup Benefits Worth Claiming
|
||||
|
||||
If you are an MSME or a DPIIT-recognised startup, GeM gives you real, money-saving advantages that you should activate during registration:
|
||||
|
||||
- **Exemption from prior turnover and prior experience** criteria in many bids (under the Public Procurement Policy for MSEs).
|
||||
- **EMD / bid-security exemption** in a large share of tenders.
|
||||
- **Reserved procurement** — a mandated share of government purchases is earmarked for MSEs.
|
||||
- **No registration fee** and reduced caution-money slabs.
|
||||
|
||||
Linking your Udyam registration during onboarding is what unlocks these — skipping it leaves money and eligibility on the table.
|
||||
|
||||
## Common Reasons Software Vendors Get Rejected or Delayed
|
||||
|
||||
Avoid these and you will clear verification on the first attempt:
|
||||
|
||||
- **Name mismatches** across PAN, GST, bank account, and Udyam — every record must match exactly.
|
||||
- **Wrong service category** — listing under a category that does not match your offering causes buyer-side rejection.
|
||||
- **Incomplete technical parameters** in the service listing, so buyers cannot find or compare you.
|
||||
- **Not meeting turnover/experience eligibility** in a bid (and not claiming MSME exemption when eligible).
|
||||
- **Outdated documents** — an expired GST or unrenewed Udyam certificate stalls everything.
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
**Is GeM registration mandatory to sell to the government?**
|
||||
For most central and state government procurement of available goods and services, yes — buyers are directed to procure through GeM. If your offering exists on GeM, being registered is effectively required to compete.
|
||||
|
||||
**How long does GeM registration take for a software company?**
|
||||
Organisation verification typically completes in 3–7 working days. Allowing for document corrections and category approvals, most software vendors are live and bid-ready within 2–4 weeks.
|
||||
|
||||
**Does it cost money to register on GeM?**
|
||||
Basic seller registration is free. Costs arise later: a refundable caution-money deposit (₹5,000–₹25,000 by turnover slab), a small per-order transaction charge, and bid-specific securities (often waived for MSMEs).
|
||||
|
||||
**Can a startup or MSME sell on GeM without prior government experience?**
|
||||
Yes. Under the MSE procurement policy and startup provisions, many bids exempt MSMEs and DPIIT-recognised startups from prior turnover and experience requirements — but you must link your Udyam/DPIIT registration to claim it.
|
||||
|
||||
**What's the difference between being listed and winning a contract?**
|
||||
Listing makes you visible. Winning happens through Direct Purchase (small value), L1 comparison, or Bid/Reverse Auction (high value). For custom software and ERP contracts, you win by qualifying on eligibility criteria and competing in the bid — listing alone generates very few orders.
|
||||
|
||||
**Which categories should a custom software firm register under?**
|
||||
Common fits include Software Development Services, Web Site Design & Development, IT Professional Outsourcing / Manpower, Cloud Services, and "Custom Bid for Services" for non-standard scopes. Register under every category that genuinely matches your delivery capability.
|
||||
|
||||
## Turning GeM Access Into Government Contracts
|
||||
|
||||
Registration is the entry ticket; winning is a discipline. The vendors who succeed on GeM keep their documents current, list under precise categories, claim every MSME benefit they qualify for, and price their bids with the reverse-auction dynamic in mind. WorkRoot has delivered software for Indian government bodies — including police and university systems across multiple states — and we understand both the procurement process and the engineering bar it demands. If you want help positioning for government IT work, [get in touch](/contact).
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "Getting Started with Astro: The Modern Static Site Generator"
|
||||
description: "Learn how Astro combines the best of static site generation with modern component frameworks for blazing-fast websites."
|
||||
seoTitle: "Getting Started with Astro for Fast Sites"
|
||||
seoDescription: "Get started with Astro, the modern site generator — learn islands architecture, type-safe content collections, and how it ships fast, low-JavaScript sites."
|
||||
pubDate: 2024-01-15
|
||||
heroImage: "/images/blog/astro-intro.jpg"
|
||||
category: "Web Development"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: "How Much Does Custom ERP Cost in India? (2026 Pricing Guide)"
|
||||
description: "A transparent, India-specific breakdown of what a custom ERP actually costs in 2026 — by scope, team, and deployment model — with real rupee ranges, a cost calculator framework, and hidden costs to budget for."
|
||||
seoTitle: "Custom ERP Cost in India (2026 Guide)"
|
||||
seoDescription: "What does a custom ERP cost in India in 2026? Real rupee ranges by scope and team, a cost-driver breakdown, build-vs-buy maths, and hidden costs to plan for."
|
||||
pubDate: 2026-06-19
|
||||
heroImage: "/images/blog/erp-systems.jpg"
|
||||
category: "Web Development"
|
||||
tags: ["ERP", "Custom Software", "Pricing", "India", "Enterprise", "Cost Estimation"]
|
||||
author:
|
||||
name: "Ajay"
|
||||
avatar: "/images/team/ajay.jpg"
|
||||
draft: false
|
||||
---
|
||||
|
||||
"How much will it cost?" is the first question every buyer asks about custom ERP — and the honest answer is "it depends." But "it depends" is not a number you can put in a budget. So in this guide we turn the question into something usable: what a custom ERP actually costs in India in 2026, what drives those numbers up or down, and how to estimate your own project before you talk to a single vendor.
|
||||
|
||||
The short version: a focused single-department custom ERP in India typically lands between **₹6,00,000 and ₹18,00,000**, a multi-module mid-market system between **₹18,00,000 and ₹60,00,000**, and a large multi-tenant or government-grade platform between **₹60,00,000 and ₹2,50,00,000+**. The rest of this article explains how to find *your* number inside those ranges.
|
||||
|
||||
## What "Custom ERP" Actually Means for Pricing
|
||||
|
||||
Before any number makes sense, you have to define scope. "ERP" is an umbrella — your price depends entirely on which modules you actually build. A finance-and-inventory system for a trading firm is a completely different project from a 14-module university ERP with admissions, examinations, hostel, and payroll.
|
||||
|
||||
Custom ERP cost in India is driven by five things, in roughly this order of impact:
|
||||
|
||||
1. **Number and complexity of modules** — each module is a mini-application with its own data model, screens, and rules.
|
||||
2. **Number of distinct user roles and permission rules** — RBAC complexity scales non-linearly.
|
||||
3. **Integrations** — payment gateways, GST/e-invoicing, SMS/WhatsApp, biometric, Tally, government APIs.
|
||||
4. **Deployment model** — single-tenant vs multi-tenant, cloud vs on-premise, offline resilience.
|
||||
5. **Non-functional requirements** — audit logging, data residency, uptime SLA, concurrent-user load.
|
||||
|
||||
## 2026 Cost Ranges by Project Tier
|
||||
|
||||
These are realistic, all-in development ranges for an India-based engagement (Indian team rates, INR), covering design, development, testing, and first deployment. They exclude ongoing maintenance, which we cover separately below.
|
||||
|
||||
| Tier | Typical Use Case | Modules | Timeline | Cost Range (INR) |
|
||||
|------|------------------|---------|----------|------------------|
|
||||
| **Starter** | Single-department tool (inventory, billing, or HR) | 1–2 | 6–10 weeks | ₹6L – ₹18L |
|
||||
| **Growth** | Mid-market multi-department ERP | 4–7 | 4–7 months | ₹18L – ₹60L |
|
||||
| **Enterprise** | Multi-entity / multi-location ERP, complex RBAC | 8–14 | 8–14 months | ₹60L – ₹1.5Cr |
|
||||
| **Government / Platform** | Multi-tenant, offline-resilient, audit-heavy | 10+ | 12–24 months | ₹1.5Cr – ₹2.5Cr+ |
|
||||
|
||||
A useful rule of thumb in 2026: a well-defined, production-grade ERP module costs roughly **₹4,00,000 to ₹9,00,000** to build, depending on how many screens, workflows, and integrations it carries.
|
||||
|
||||
## The Biggest Cost Driver: The Team
|
||||
|
||||
Custom software is labour. The single largest line item in any ERP quote is engineering time, so blended day-rates dominate the total. Here is what Indian ERP development talent typically costs in 2026.
|
||||
|
||||
| Role | Junior (₹/month) | Mid (₹/month) | Senior (₹/month) |
|
||||
|------|------------------|---------------|------------------|
|
||||
| Backend Engineer | ₹45,000 – ₹70,000 | ₹80,000 – ₹1,40,000 | ₹1,60,000 – ₹2,80,000 |
|
||||
| Frontend Engineer | ₹40,000 – ₹65,000 | ₹75,000 – ₹1,30,000 | ₹1,50,000 – ₹2,50,000 |
|
||||
| QA / Test Engineer | ₹35,000 – ₹55,000 | ₹60,000 – ₹1,00,000 | ₹1,10,000 – ₹1,80,000 |
|
||||
| Project Manager | — | ₹90,000 – ₹1,50,000 | ₹1,60,000 – ₹3,00,000 |
|
||||
| UI/UX Designer | ₹40,000 – ₹60,000 | ₹70,000 – ₹1,20,000 | ₹1,30,000 – ₹2,20,000 |
|
||||
|
||||
A typical Growth-tier project runs a squad of 4–6 people for 5 months. At blended mid-level rates, that alone accounts for ₹25L–₹40L of the budget — which is why the Growth range sits where it does.
|
||||
|
||||
## Build vs Buy: When Custom Actually Pays Off
|
||||
|
||||
Custom is not always the right call. Off-the-shelf and SaaS ERPs (Zoho, TallyPrime, SAP Business One, Odoo) have real advantages for standard workflows. Here is the honest comparison for an Indian mid-market buyer.
|
||||
|
||||
| Factor | Custom ERP | SaaS / Off-the-Shelf |
|
||||
|--------|-----------|----------------------|
|
||||
| Upfront cost | ₹18L – ₹60L+ | ₹0 – ₹5L setup |
|
||||
| Recurring cost | ~15–20% of build/year (AMC) | ₹500 – ₹3,000 per user / month |
|
||||
| Fit to your process | Exact | Adapt your process to the tool |
|
||||
| Time to first value | 3–7 months | Days to weeks |
|
||||
| Data ownership | Full | Vendor-hosted |
|
||||
| Scaling cost | Predictable (you own it) | Grows with every seat |
|
||||
|
||||
The crossover maths matters. A 100-user SaaS ERP at ₹1,500/user/month costs **₹18,00,000 per year** — recurring, forever. A custom build at ₹40,00,000 with ₹7,00,000/year AMC breaks even against that SaaS bill in roughly **2.5 years**, and is cheaper every year after. Custom wins when your headcount is large, your processes are genuinely non-standard, or data sovereignty is non-negotiable (common in government and BFSI).
|
||||
|
||||
## Hidden Costs Buyers Forget to Budget
|
||||
|
||||
The build quote is not the total cost of ownership. Plan for these:
|
||||
|
||||
- **Annual Maintenance Contract (AMC):** budget **15–20% of build cost per year** for bug fixes, security patches, and minor enhancements.
|
||||
- **Cloud hosting:** a mid-size ERP on AWS/Azure in the Mumbai region typically costs **₹15,000 – ₹60,000/month** depending on load and redundancy.
|
||||
- **Third-party APIs:** SMS (₹0.12–₹0.25/message), WhatsApp Business, payment gateway (~2% per transaction), GST e-invoicing add-ons.
|
||||
- **Data migration:** cleaning and importing legacy data is often underestimated — budget **₹1,50,000 – ₹6,00,000**.
|
||||
- **Training and change management:** for 50+ users, this is a real line item, not an afterthought.
|
||||
- **Post-launch enhancements:** the first three months after go-live always surface new requirements.
|
||||
|
||||
## A Simple Way to Estimate Your Own Cost
|
||||
|
||||
You can get within ±25% of a real quote with this back-of-envelope method:
|
||||
|
||||
1. **List your modules.** Count them honestly.
|
||||
2. **Multiply by ₹4L–₹9L per module** depending on complexity (simple CRUD = lower; workflow + integrations = higher).
|
||||
3. **Add 20% for integrations** if you need GST, payments, or government APIs.
|
||||
4. **Add 15% for multi-tenant or offline-resilient** architecture.
|
||||
5. **Add the first year of AMC** (15–20%) for a true year-one budget.
|
||||
|
||||
Example: a 6-module mid-market ERP with payment + GST integration → (6 × ₹6L) = ₹36L, +20% integrations = ₹43.2L, +18% AMC = roughly **₹51L for year one**. That lands squarely in the Growth tier, which is the reality check you want before any vendor conversation.
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
**What is the minimum realistic budget for a custom ERP in India in 2026?**
|
||||
For a genuinely custom, production-grade single-department system, expect a floor of around **₹6,00,000**. Below that you are buying a configured off-the-shelf tool or a prototype, not a custom ERP.
|
||||
|
||||
**How long does a custom ERP take to build?**
|
||||
A Starter system ships in 6–10 weeks. A Growth-tier multi-module ERP takes 4–7 months. Enterprise and government platforms run 8–24 months. Timeline scales with module count and integration depth, not just budget.
|
||||
|
||||
**Is custom ERP cheaper than SaaS over time?**
|
||||
Often, yes — above roughly 75–100 users or 2.5 years of use. A custom build has a high upfront cost but a flat recurring cost (AMC), while SaaS charges per seat forever. The more you grow, the better custom looks.
|
||||
|
||||
**Why do ERP quotes vary so much between vendors?**
|
||||
Because "ERP" is undefined until you fix scope. A ₹15L and a ₹60L quote are usually answering two different questions about modules, roles, and integrations. Always compare on a fixed, written scope — never on the headline number alone.
|
||||
|
||||
**What ongoing costs should I plan for after launch?**
|
||||
Budget 15–20% of build cost per year for AMC, plus cloud hosting (₹15K–₹60K/month for mid-size systems), third-party API usage, and periodic enhancements. Total cost of ownership over five years is typically 1.7–2x the initial build.
|
||||
|
||||
**Should I pay fixed-price or time-and-materials?**
|
||||
Fixed-price works when scope is locked and well-documented. Time-and-materials fits evolving requirements and longer builds. For most mid-market ERPs, a hybrid — fixed-price per milestone with a change-request process — gives the best balance of predictability and flexibility.
|
||||
|
||||
## Getting an Accurate Number for Your Project
|
||||
|
||||
The ranges above will get your budget into the right order of magnitude, but every project has specifics — a single complex integration or a strict uptime SLA can move the number significantly. At WorkRoot we have delivered ERP and management systems across Indian government and enterprise clients, and we are happy to turn your requirements into a fixed, written estimate. [Tell us about your project](/contact) and we will give you a real number, not a vague "it depends."
|
||||
@@ -5,6 +5,13 @@ const blog = defineCollection({
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
// Optional SEO overrides. When set they drive the <title> tag and
|
||||
// <meta name="description"> without changing the on-page article
|
||||
// heading/excerpt, letting us keep descriptive H1s while tuning the
|
||||
// search snippet to the 50–60 / 150–160 character sweet spots.
|
||||
// `seoTitle` is the text BEFORE the " | WorkRoot" brand suffix.
|
||||
seoTitle: z.string().optional(),
|
||||
seoDescription: z.string().optional(),
|
||||
pubDate: z.coerce.date(),
|
||||
updatedDate: z.coerce.date().optional(),
|
||||
heroImage: z.string().optional(),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import SEO from '../components/SEO.astro';
|
||||
import Analytics from '../components/Analytics.astro';
|
||||
import PWAInstallPrompt from '../components/PWAInstallPrompt.astro';
|
||||
import WhatsAppFab from '../components/WhatsAppFab.astro';
|
||||
@@ -26,8 +27,15 @@ const {
|
||||
noIndex = false,
|
||||
} = Astro.props;
|
||||
|
||||
// Full brand name — used for og:site_name and structured data.
|
||||
const siteName = 'WorkRoot IT Solutions LLP';
|
||||
const fullTitle = title === 'Home' ? siteName : `${title} | ${siteName}`;
|
||||
// Short brand suffix for the <title> tag so titles stay within the
|
||||
// 50–60 character SEO sweet spot (the full LLP name eats 25+ chars).
|
||||
const brand = 'WorkRoot';
|
||||
// The homepage gets a dedicated, query-focused title instead of the bare
|
||||
// brand name. Pages pass `title="Home"` to opt into it.
|
||||
const homeTitle = 'Custom Software & Government IT Development | WorkRoot';
|
||||
const fullTitle = title === 'Home' ? homeTitle : `${title} | ${brand}`;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -48,7 +56,9 @@ const fullTitle = title === 'Home' ? siteName : `${title} | ${siteName}`;
|
||||
<meta name="rating" content="general" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
@@ -101,111 +111,14 @@ const fullTitle = title === 'Home' ? siteName : `${title} | ${siteName}`;
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#0891b2" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f172a" />
|
||||
|
||||
<!-- JSON-LD Structured Data - Organization + LocalBusiness Schema -->
|
||||
<script type="application/ld+json" set:html={JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": ["Organization", "LocalBusiness"],
|
||||
"name": "WorkRoot IT Solutions LLP",
|
||||
"alternateName": ["WorkRoot", "WorkRoot IT Solutions"],
|
||||
"url": "https://workroot.in",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://workroot.in/logo.png",
|
||||
"width": "512",
|
||||
"height": "512"
|
||||
},
|
||||
"image": "https://workroot.in/og-image.jpg",
|
||||
"description": "Professional IT services including web development, cloud solutions, and digital transformation for businesses.",
|
||||
"foundingDate": "2022",
|
||||
"founder": {
|
||||
"@type": "Person",
|
||||
"name": "Ajay Ghanwat"
|
||||
},
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "At Post Rajapur Shantinagar, Taluka Khatav",
|
||||
"addressLocality": "Satara",
|
||||
"addressRegion": "MH",
|
||||
"postalCode": "415503",
|
||||
"addressCountry": "IN"
|
||||
},
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": "17.7733632",
|
||||
"longitude": "74.3309312"
|
||||
},
|
||||
"hasMap": "https://maps.app.goo.gl/9bZ5ez1pjbU74ATq5",
|
||||
"telephone": "+91-9561417403",
|
||||
"email": "admin@workroot.in",
|
||||
"contactPoint": [{
|
||||
"@type": "ContactPoint",
|
||||
"telephone": "+91-9561417403",
|
||||
"contactType": "customer service",
|
||||
"email": "admin@workroot.in",
|
||||
"availableLanguage": ["English", "Hindi", "Marathi"],
|
||||
"areaServed": "Worldwide"
|
||||
}, {
|
||||
"@type": "ContactPoint",
|
||||
"telephone": "+91-9561417403",
|
||||
"contactType": "sales",
|
||||
"email": "admin@workroot.in",
|
||||
"availableLanguage": ["English", "Hindi", "Marathi"],
|
||||
"areaServed": "Worldwide"
|
||||
}],
|
||||
"openingHoursSpecification": [
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
"opens": "10:00",
|
||||
"closes": "18:00"
|
||||
}
|
||||
],
|
||||
"priceRange": "$$",
|
||||
"currenciesAccepted": "INR",
|
||||
"paymentAccepted": "Cash, Credit Card, Bank Transfer",
|
||||
"sameAs": [
|
||||
"https://x.com/GhanwatAjay",
|
||||
"https://www.linkedin.com/company/workroot",
|
||||
"https://git.workroot.in/WorkRoot/",
|
||||
"https://www.facebook.com/profile.php?id=61576503897897"
|
||||
],
|
||||
"areaServed": {
|
||||
"@type": "GeoShape",
|
||||
"name": "Worldwide"
|
||||
},
|
||||
"serviceType": ["Web Development", "Mobile App Development", "AI & Machine Learning", "Cloud Services"],
|
||||
"knowsAbout": ["Web Development", "Software Engineering", "Cloud Computing", "Artificial Intelligence", "Machine Learning", "Mobile Development"],
|
||||
"slogan": "Transforming Ideas Into Digital Reality",
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "5.0",
|
||||
"reviewCount": "20",
|
||||
"bestRating": "5",
|
||||
"worstRating": "1"
|
||||
}
|
||||
})} />
|
||||
|
||||
<!-- JSON-LD Structured Data - WebSite Schema -->
|
||||
<script type="application/ld+json" set:html={JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "WorkRoot IT Solutions LLP",
|
||||
"url": "https://workroot.in",
|
||||
"description": "Professional IT services including web development, cloud solutions, and digital transformation for businesses.",
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "WorkRoot IT Solutions LLP"
|
||||
},
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "https://workroot.in/blog?search={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
},
|
||||
"inLanguage": "en-US"
|
||||
})} />
|
||||
<!-- JSON-LD Structured Data — site-wide base schema (Organization +
|
||||
LocalBusiness and WebSite). Centralised in SEO.astro and emitted here
|
||||
in `base` mode so it appears exactly once on EVERY route, including
|
||||
pages that don't add their own page-specific <SEO> (404, privacy,
|
||||
terms, cookies, offline). Page-specific schema (Service, FAQ,
|
||||
BlogPosting, breadcrumbs, …) is opt-in via the slotted <SEO> each
|
||||
page renders into the head. -->
|
||||
<SEO base />
|
||||
|
||||
<!-- Preconnect to Google Fonts (must come before font stylesheet) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
@@ -268,6 +181,17 @@ const fullTitle = title === 'Home' ? siteName : `${title} | ${siteName}`;
|
||||
<!-- Analytics (GA4 or Plausible) -->
|
||||
<Analytics />
|
||||
|
||||
<!-- WRNexus Web Push SDK — loaded once globally so it runs on every page
|
||||
that should collect push subscribers. is:inline keeps Astro's bundler
|
||||
from rewriting the external src and preserves data-site-id / defer. -->
|
||||
<script is:inline src="https://api.wrnexus.com/push/sdk.js" data-site-id="pk_rP51XN8tZnud5HdX9UqidjHk" defer></script>
|
||||
|
||||
<!-- WRNexus Bot Widget — loaded once globally here so the chat widget
|
||||
renders on every route (pages + blog posts) via this shared layout.
|
||||
is:inline keeps Astro's bundler from rewriting the external src and
|
||||
preserves data-bot-id / async. -->
|
||||
<script is:inline src="https://api.wrnexus.com/bots/widget.js" data-bot-id="d3998f1b-51d7-4f7c-a57e-3fa1af4394a0" async></script>
|
||||
|
||||
<!-- Additional head content slot -->
|
||||
<slot name="head" />
|
||||
</head>
|
||||
@@ -292,8 +216,12 @@ const fullTitle = title === 'Home' ? siteName : `${title} | ${siteName}`;
|
||||
<!-- Footer -->
|
||||
<Footer />
|
||||
|
||||
<!-- Spacer to ensure footer/content clears the mobile sticky CTA bar (<md only) -->
|
||||
<div aria-hidden="true" class="md:hidden h-[calc(64px+env(safe-area-inset-bottom,0px))] w-full"></div>
|
||||
<!-- Spacer to ensure footer/content (incl. PricingTimelineBand, GuaranteeBand,
|
||||
and any other bottom-of-page sections) clears the mobile sticky CTA bar.
|
||||
Bar min-height is 64px; we use 72px here so there is always an 8px
|
||||
visual breathing strip between the last band and the sticky bar.
|
||||
<md only — bar is hidden at md+. -->
|
||||
<div aria-hidden="true" class="md:hidden h-[calc(72px+env(safe-area-inset-bottom,0px))] w-full"></div>
|
||||
|
||||
<!-- Mobile sticky bottom CTA bar (visible <md) -->
|
||||
<MobileStickyCtaBar />
|
||||
|
||||
@@ -61,6 +61,18 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const method = context.request.method;
|
||||
const path = context.url.pathname;
|
||||
|
||||
// Normalize paths corrupted by a trailing stray ")" before routing.
|
||||
// These come from monitors/links that captured a URL together with a
|
||||
// closing parenthesis — e.g. "https://workroot.in/)" copied out of the
|
||||
// markdown/shell form "(https://workroot.in/)". Without this, every such
|
||||
// probe hits a non-existent route and 404s on a loop (recurring "GET /)").
|
||||
// Stripping the trailing paren(s) lets the request resolve to the intended
|
||||
// route (e.g. "/)" -> "/"), so the monitor gets a 301 -> 200 instead.
|
||||
const normalizedPath = path.replace(/\)+$/, '');
|
||||
if (normalizedPath !== path && normalizedPath.length > 0) {
|
||||
return context.redirect(`${normalizedPath}${context.url.search}`, 301);
|
||||
}
|
||||
|
||||
// CSRF origin validation for API mutations
|
||||
if (!validateCsrfOrigin(context.request, context.url)) {
|
||||
logger.warn('middleware', 'CSRF origin check failed', {
|
||||
@@ -106,7 +118,26 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
if (status >= 500) {
|
||||
logger.error('middleware', `SSR error ${status}`, { method, path, status, durationMs });
|
||||
} else if (status === 404) {
|
||||
logger.warn('middleware', `Not found`, { method, path, status, durationMs });
|
||||
// 404s are dominated by bots/scanners probing malformed or garbage paths
|
||||
// (e.g. "/)"), which would otherwise flood warn-level logs and trip alerts.
|
||||
// Treat a 404 as a warning only when it likely originated from our own site
|
||||
// (same-origin referer) — that indicates a real broken internal link worth
|
||||
// fixing. Everything else (no referer / external referer) is logged at info.
|
||||
const referer = context.request.headers.get('referer');
|
||||
let internalNavigation = false;
|
||||
if (referer) {
|
||||
try {
|
||||
internalNavigation = new URL(referer).hostname === context.url.hostname;
|
||||
} catch {
|
||||
// Malformed referer header → treat as external/untrusted
|
||||
}
|
||||
}
|
||||
const notFoundMeta = { method, path, status, durationMs, referer: referer ?? undefined };
|
||||
if (internalNavigation) {
|
||||
logger.warn('middleware', `Not found (broken internal link)`, notFoundMeta);
|
||||
} else {
|
||||
logger.info('middleware', `Not found`, notFoundMeta);
|
||||
}
|
||||
} else if (status >= 400) {
|
||||
logger.warn('middleware', `Client error ${status}`, { method, path, status, durationMs });
|
||||
} else {
|
||||
@@ -124,11 +155,13 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
// - inline scripts/styles: Required for Astro framework
|
||||
// - https://www.googletagmanager.com & https://www.google-analytics.com: GA4
|
||||
// - https://plausible.io: Plausible Analytics
|
||||
// - https://api.wrnexus.com: WRNexus web-push SDK (sdk.js), bot widget
|
||||
// (bots/widget.js) + runtime config/iframe served from the same origin
|
||||
// Note: unsafe-eval is removed in production (only needed during Astro HMR dev mode)
|
||||
const isProduction = import.meta.env.PROD;
|
||||
const scriptSrc = isProduction
|
||||
? "script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io"
|
||||
: "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io";
|
||||
? "script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io https://api.wrnexus.com"
|
||||
: "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io https://api.wrnexus.com";
|
||||
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
@@ -136,11 +169,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"img-src 'self' data: https://images.unsplash.com https://www.google-analytics.com https://www.googletagmanager.com https://maps.googleapis.com https://maps.gstatic.com",
|
||||
"font-src 'self' data: https://fonts.gstatic.com",
|
||||
"connect-src 'self' https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://plausible.io",
|
||||
"connect-src 'self' https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://plausible.io https://api.wrnexus.com",
|
||||
"worker-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
"object-src 'none'", // Block Flash/plugins (defense-in-depth)
|
||||
"frame-src https://www.google.com", // Allow Google Maps iframe embed
|
||||
"frame-src https://www.google.com https://api.wrnexus.com", // Google Maps iframe + WRNexus bot widget iframe
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
|
||||
@@ -6,8 +6,8 @@ export const prerender = false;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Page Not Found"
|
||||
description="The page you're looking for doesn't exist. Return to WorkRoot IT Solutions homepage."
|
||||
title="Page Not Found — Let's Get You Back on Track"
|
||||
description="The page you're looking for doesn't exist or has moved. Head back to the WorkRoot IT Solutions homepage, or explore our services, portfolio, and blog instead."
|
||||
noIndex={true}
|
||||
>
|
||||
<section class="min-h-[70vh] flex items-center justify-center py-20 px-4">
|
||||
|
||||
@@ -6,38 +6,52 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import SEO from '../components/SEO.astro';
|
||||
|
||||
// Team member data
|
||||
// NOTE FOR MAINTAINERS: `photo` points to a placeholder avatar in
|
||||
// /public/images/team/. Replace each file with a real headshot (same
|
||||
// path, JPG/WebP at ~800×600) and fill in the real `linkedin` URL —
|
||||
// the markup renders the photo and LinkedIn icon automatically.
|
||||
const teamMembers = [
|
||||
{
|
||||
name: 'Ajay Ghanwat',
|
||||
role: 'Director',
|
||||
bio: 'A visionary technology leader with deep expertise in enterprise software architecture and government IT systems. Ajay founded WorkRoot in 2022 with a mission to deliver world-class software solutions from India. He has led the delivery of 20+ projects including large-scale government deployments across multiple districts in Bihar and enterprise platforms for IIT Patna.',
|
||||
role: 'Director & Lead Engineer',
|
||||
bio: 'Founder and hands-on technical lead. Ajay has architected and shipped 20+ production systems — including the Bihar Police Management System deployed across 11 districts and the University platform for IIT Patna. When you work with WorkRoot, he is in the room reviewing your architecture.',
|
||||
avatar: 'AG',
|
||||
photo: '/images/team/ajay-ghanwat.svg',
|
||||
social: {
|
||||
twitter: 'https://x.com/GhanwatAjay',
|
||||
linkedin: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Ashok Jamdade',
|
||||
role: 'Director',
|
||||
bio: 'An experienced business strategist and operations leader who brings strong client relationship management and project governance expertise to WorkRoot. Ashok ensures every project meets the highest standards of quality and is delivered on time, driving the company\'s growth and operational excellence.',
|
||||
role: 'Director & Delivery Lead',
|
||||
bio: 'Runs project governance and client delivery end to end. Ashok is your single point of accountability — scope, timeline, and quality — so nothing gets lost in handoffs between teams.',
|
||||
avatar: 'AJ',
|
||||
social: {},
|
||||
photo: '/images/team/ashok-jamdade.svg',
|
||||
social: {
|
||||
linkedin: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Swapnali Vachkal',
|
||||
role: 'Full Stack Developer',
|
||||
bio: 'A skilled full-stack developer proficient in Angular, React, Spring Boot, and modern web technologies. Swapnali plays a key role in building and maintaining WorkRoot\'s client projects, from government management systems to e-commerce platforms, delivering clean, performant, and maintainable code.',
|
||||
role: 'Full Stack Engineer',
|
||||
bio: 'Builds the systems you depend on, in Angular, React, and Spring Boot. Swapnali writes the production code for your project directly — from government management systems to e-commerce platforms — clean, performant, and maintainable.',
|
||||
avatar: 'SV',
|
||||
social: {},
|
||||
photo: '/images/team/swapnali-vachkal.svg',
|
||||
social: {
|
||||
linkedin: '',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Company stats
|
||||
// Company stats — canonical site-wide proof set (kept identical to the
|
||||
// homepage ProofNumbers band, the /portfolio stats bar, and the homepage
|
||||
// stats counters) so the numbers never contradict across pages.
|
||||
const stats = [
|
||||
{ value: '3+', label: 'Years of Experience' },
|
||||
{ value: '20+', label: 'Projects Delivered' },
|
||||
{ value: '3', label: 'Team Members' },
|
||||
{ value: '100%', label: 'Client Satisfaction' },
|
||||
{ value: '20+', label: 'Projects' },
|
||||
{ value: '11+', label: 'Districts Deployed' },
|
||||
{ value: '50,000+', label: 'Records Managed' },
|
||||
{ value: '8+', label: 'Years Avg Experience' },
|
||||
];
|
||||
|
||||
// Company timeline
|
||||
@@ -74,34 +88,34 @@ const timeline = [
|
||||
},
|
||||
];
|
||||
|
||||
// Core values
|
||||
const values = [
|
||||
// How we work — concrete commitments, not generic values
|
||||
const howWeWork = [
|
||||
{
|
||||
title: 'Innovation',
|
||||
description: 'We embrace cutting-edge technologies to deliver future-proof solutions.',
|
||||
icon: 'lightbulb',
|
||||
title: 'You talk to the engineers',
|
||||
description: 'No account managers, no offshore relay. The people who design and write your system are the people you meet, email, and call.',
|
||||
icon: 'users',
|
||||
},
|
||||
{
|
||||
title: 'Integrity',
|
||||
description: 'Transparency and honesty are the foundation of every client relationship.',
|
||||
title: 'Fixed scope, price, and timeline',
|
||||
description: 'We agree on exactly what gets built, what it costs, and when it ships — before a line of code is written. No surprise invoices.',
|
||||
icon: 'shield',
|
||||
},
|
||||
{
|
||||
title: 'Excellence',
|
||||
description: 'We pursue quality in every line of code and every interaction.',
|
||||
title: 'Built to pass an audit',
|
||||
description: 'Role-based access, data isolation, and offline resilience are designed in from day one — the same standards our 11-district government deployment runs on.',
|
||||
icon: 'star',
|
||||
},
|
||||
{
|
||||
title: 'Collaboration',
|
||||
description: 'Great solutions emerge from diverse perspectives working together.',
|
||||
icon: 'users',
|
||||
title: 'We stay after launch',
|
||||
description: 'Shipping is the start, not the finish. We maintain, monitor, and extend the systems we build — some have run in production for years.',
|
||||
icon: 'lightbulb',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="About Us — Our Story & Team"
|
||||
description="WorkRoot IT Solutions LLP: founded 2022, 3 team members, 20+ projects delivered. Meet the technologists behind your digital transformation. Innovation, integrity, excellence."
|
||||
title="About Our Software Engineering Team & Story"
|
||||
description="Meet WorkRoot IT Solutions: founded 2022, a senior in-house engineering team averaging 8+ years' experience across 20+ web, mobile, ERP, and govtech projects."
|
||||
>
|
||||
<SEO
|
||||
slot="head"
|
||||
@@ -128,13 +142,13 @@ const values = [
|
||||
About WorkRoot IT Solutions LLP
|
||||
</span>
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-bold text-white leading-tight mb-6">
|
||||
Building the Future of
|
||||
The team behind Bihar's 11-district Police Management System
|
||||
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-primary-300 to-accent-400 mt-2">
|
||||
Digital Innovation
|
||||
and IIT Patna's University platform.
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-lg sm:text-xl text-secondary-200 max-w-2xl mb-8">
|
||||
We're a team of passionate technologists dedicated to transforming businesses through innovative IT solutions.
|
||||
A small, senior engineering team in India that has shipped 20+ production systems for governments and enterprises. You work directly with the people who build yours.
|
||||
</p>
|
||||
|
||||
<!-- Quick stats in hero -->
|
||||
@@ -239,59 +253,8 @@ const values = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mission & Vision Section -->
|
||||
<section class="py-16 lg:py-24 bg-gradient-to-b from-secondary-50 to-white dark:from-secondary-900 dark:to-secondary-900">
|
||||
<div class="container-wrapper">
|
||||
<div class="text-center mb-12 lg:mb-16" data-animate="fade-up">
|
||||
<span class="text-primary-500 dark:text-primary-400 font-semibold text-sm uppercase tracking-wider">Our Purpose</span>
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-secondary-900 dark:text-secondary-100 mt-2">Mission & Vision</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-8 lg:gap-12">
|
||||
<!-- Mission -->
|
||||
<div class="relative group" data-animate="fade-right" data-delay="100">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-500 to-primary-700 rounded-3xl transform transition-transform group-hover:scale-105"></div>
|
||||
<div class="relative p-8 lg:p-12 bg-gradient-to-br from-primary-500 to-primary-700 rounded-3xl text-white overflow-hidden">
|
||||
<div class="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-2xl"></div>
|
||||
<div class="relative">
|
||||
<div class="w-16 h-16 bg-white/20 backdrop-blur-sm rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-2xl lg:text-3xl font-bold mb-4">Our Mission</h3>
|
||||
<p class="text-primary-50 leading-relaxed text-lg">
|
||||
To empower businesses and government organizations with innovative, reliable, and scalable software solutions. We combine deep technical expertise with a client-first approach to deliver systems that drive operational efficiency and digital transformation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vision -->
|
||||
<div class="relative group" data-animate="fade-left" data-delay="250">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-secondary-800 to-secondary-900 rounded-3xl transform transition-transform group-hover:scale-105"></div>
|
||||
<div class="relative p-8 lg:p-12 bg-gradient-to-br from-secondary-800 to-secondary-900 rounded-3xl text-white overflow-hidden">
|
||||
<div class="absolute top-0 right-0 w-64 h-64 bg-primary-500/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-2xl"></div>
|
||||
<div class="relative">
|
||||
<div class="w-16 h-16 bg-white/10 backdrop-blur-sm rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-2xl lg:text-3xl font-bold mb-4">Our Vision</h3>
|
||||
<p class="text-secondary-200 leading-relaxed text-lg">
|
||||
To be India's most trusted technology partner for enterprises and government bodies, recognized for delivering world-class software solutions that set new standards in quality, performance, and innovation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Core Values Section -->
|
||||
<section class="py-16 lg:py-24 bg-white dark:bg-secondary-900 relative overflow-hidden">
|
||||
<!-- How We Work Section -->
|
||||
<section class="py-16 lg:py-24 bg-gradient-to-b from-secondary-50 to-white dark:from-secondary-900 dark:to-secondary-900 relative overflow-hidden">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
||||
<div class="absolute top-0 right-0 w-96 h-96 bg-primary-500/5 rounded-full blur-3xl"></div>
|
||||
@@ -300,15 +263,15 @@ const values = [
|
||||
|
||||
<div class="container-wrapper relative">
|
||||
<div class="text-center max-w-2xl mx-auto mb-12 lg:mb-16" data-animate="fade-up">
|
||||
<span class="text-primary-500 dark:text-primary-400 font-semibold text-sm uppercase tracking-wider">What Drives Us</span>
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-secondary-900 dark:text-secondary-100 mt-2 mb-4">Our Core Values</h2>
|
||||
<span class="text-primary-500 dark:text-primary-400 font-semibold text-sm uppercase tracking-wider">How We Work</span>
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-secondary-900 dark:text-secondary-100 mt-2 mb-4">What working with us actually looks like</h2>
|
||||
<p class="text-secondary-600 dark:text-secondary-400">
|
||||
These principles guide every decision we make and every solution we deliver.
|
||||
A three-person senior team is the feature, not the caveat — no layers between you and the people building your system.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||
{values.map((value, index) => (
|
||||
{howWeWork.map((value, index) => (
|
||||
<div class="group relative" data-animate="fade-up" data-delay={String(index * 100)}>
|
||||
<!-- Gradient background on hover -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-500 to-primary-700 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
@@ -351,9 +314,9 @@ const values = [
|
||||
<div class="container-wrapper">
|
||||
<div class="text-center max-w-2xl mx-auto mb-12 lg:mb-16" data-animate="fade-up">
|
||||
<span class="text-primary-500 dark:text-primary-400 font-semibold text-sm uppercase tracking-wider">The Team</span>
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-secondary-900 dark:text-secondary-100 mt-2 mb-4">Meet Our Leadership</h2>
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-secondary-900 dark:text-secondary-100 mt-2 mb-4">The people who'll build your system</h2>
|
||||
<p class="text-secondary-600 dark:text-secondary-400">
|
||||
Talented professionals who bring diverse expertise and shared passion for technology excellence.
|
||||
You work directly with the senior engineers who design and write your software — no account managers, no offshore handoffs, no junior teams learning on your project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -364,20 +327,47 @@ const values = [
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-400 to-accent-400 rounded-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-sm"></div>
|
||||
|
||||
<div class="relative bg-white dark:bg-secondary-800 rounded-3xl overflow-hidden shadow-md hover:shadow-2xl transition-all duration-300 transform group-hover:-translate-y-2">
|
||||
<!-- Avatar container -->
|
||||
<!-- Avatar / photo container -->
|
||||
<div class="relative aspect-[4/3] overflow-hidden bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||
<span class="text-6xl font-bold text-white/90">{member.avatar}</span>
|
||||
{member.photo ? (
|
||||
<img
|
||||
src={member.photo}
|
||||
alt={`${member.name}, ${member.role} at WorkRoot`}
|
||||
width="800"
|
||||
height="600"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<span class="text-6xl font-bold text-white/90">{member.avatar}</span>
|
||||
)}
|
||||
|
||||
<!-- Gradient overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-secondary-900/40 via-transparent to-transparent"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-secondary-900/50 via-transparent to-transparent"></div>
|
||||
|
||||
<!-- Social links overlay -->
|
||||
{Object.keys(member.social).length > 0 && (
|
||||
{(member.social.twitter || member.social.linkedin) && (
|
||||
<div class="absolute inset-x-0 bottom-0 p-6 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300">
|
||||
<div class="flex justify-center gap-3">
|
||||
{member.social.linkedin && (
|
||||
<a
|
||||
href={member.social.linkedin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-11 h-11 bg-white/95 hover:bg-white rounded-xl flex items-center justify-center text-secondary-700 hover:text-primary-500 transition-all shadow-lg hover:scale-110"
|
||||
aria-label={`${member.name}'s LinkedIn profile`}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{member.social.twitter && (
|
||||
<a
|
||||
href={member.social.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-11 h-11 bg-white/95 hover:bg-white rounded-xl flex items-center justify-center text-secondary-700 hover:text-primary-500 transition-all shadow-lg hover:scale-110"
|
||||
aria-label={`${member.name}'s Twitter/X profile`}
|
||||
>
|
||||
@@ -422,27 +412,29 @@ const values = [
|
||||
Let's Build Together
|
||||
</div>
|
||||
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||
Ready to Work With Us?
|
||||
Talk to the engineers who'll build it
|
||||
</h2>
|
||||
<p class="text-primary-50 text-lg sm:text-xl max-w-2xl mx-auto mb-10">
|
||||
Let's discuss how we can help transform your business with innovative technology solutions.
|
||||
Tell us what you're building. You'll get a fixed-scope, fixed-price proposal from the people who'll actually deliver it — not a sales pitch.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="/contact"
|
||||
href="/contact?source=about-cta"
|
||||
data-cta="about-primary"
|
||||
class="group inline-flex items-center justify-center px-8 py-4 bg-white text-primary-600 font-semibold rounded-xl hover:bg-primary-50 transition-all shadow-xl hover:shadow-2xl hover:scale-105 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary-600"
|
||||
>
|
||||
Get in Touch
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
Get my free proposal
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="/services"
|
||||
href="/portfolio"
|
||||
data-cta="about-secondary"
|
||||
class="inline-flex items-center justify-center px-8 py-4 border-2 border-white/40 text-white font-semibold rounded-xl hover:bg-white/10 hover:border-white/60 transition-all backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary-600"
|
||||
>
|
||||
View Our Services
|
||||
See 20+ case studies
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,7 @@ const PHONE_REGEX = /^[\+]?[(]?[0-9]{1,3}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[
|
||||
const VALID_SUBJECTS = [
|
||||
'web-development',
|
||||
'mobile-development',
|
||||
'ui-ux',
|
||||
'cloud-services',
|
||||
'ai-ml',
|
||||
'consulting',
|
||||
@@ -109,53 +110,174 @@ function validateContactForm(data: ContactFormData): string | null {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SMTP transporter (created once at module level for reuse)
|
||||
// SMTP transporter — env-driven, created once at module level
|
||||
// ============================================================
|
||||
//
|
||||
// Required env vars:
|
||||
// SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS
|
||||
// SMTP_FROM, SMTP_FROM_NAME
|
||||
// MAIL_ADMIN_TO (admin recipient; falls back to SMTP_FROM)
|
||||
// Optional env vars (with sensible defaults):
|
||||
// SMTP_REQUIRE_TLS ("false" disables STARTTLS upgrade; default: required)
|
||||
// SMTP_CONNECTION_TIMEOUT_MS (default 10000)
|
||||
// SMTP_GREETING_TIMEOUT_MS (default 10000)
|
||||
// SMTP_SOCKET_TIMEOUT_MS (default 10000)
|
||||
//
|
||||
// Graceful degradation: if the nodemailer import fails, or any of the
|
||||
// required env vars are missing, we log a structured warning and let
|
||||
// the POST handler return success to the user. The site never 500s
|
||||
// because of email config — submissions surface in server logs and
|
||||
// Sentry instead.
|
||||
// ============================================================
|
||||
let smtpTransporter: any = null;
|
||||
let smtpVerified = false;
|
||||
|
||||
// Verify SMTP connection once at module initialization
|
||||
(async () => {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_PASS;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? '587', 10);
|
||||
interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
fromAddress: string;
|
||||
fromName: string;
|
||||
adminRecipient: string;
|
||||
requireTLS: boolean;
|
||||
connectionTimeout: number;
|
||||
greetingTimeout: number;
|
||||
socketTimeout: number;
|
||||
}
|
||||
|
||||
if (!smtpHost || !smtpUser || !smtpPass) {
|
||||
logger.info('api.contact', 'SMTP not configured — skipping connection verification');
|
||||
return;
|
||||
function readSmtpConfig(): SmtpConfig | null {
|
||||
const host = process.env.SMTP_HOST;
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
const fromAddress = process.env.SMTP_FROM || user || '';
|
||||
const fromName = process.env.SMTP_FROM_NAME || 'CompanySite';
|
||||
// Admin recipient resolution chain:
|
||||
// MAIL_ADMIN_TO > SMTP_FROM > legacy CONTACT_EMAIL_TO > legacy CONTACT_EMAIL > SMTP_USER
|
||||
const adminRecipient =
|
||||
process.env.MAIL_ADMIN_TO ||
|
||||
process.env.SMTP_FROM ||
|
||||
process.env.CONTACT_EMAIL_TO ||
|
||||
process.env.CONTACT_EMAIL ||
|
||||
user ||
|
||||
'';
|
||||
|
||||
if (!host || !user || !pass || !fromAddress || !adminRecipient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
host,
|
||||
port: Number(process.env.SMTP_PORT ?? 587),
|
||||
user,
|
||||
pass,
|
||||
fromAddress,
|
||||
fromName,
|
||||
adminRecipient,
|
||||
// 587 uses STARTTLS — `secure:false` + `requireTLS:true` is the
|
||||
// correct combination. The flag lets ops disable TLS enforcement
|
||||
// by setting SMTP_REQUIRE_TLS=false (e.g. local mailcatcher).
|
||||
requireTLS: process.env.SMTP_REQUIRE_TLS !== 'false',
|
||||
connectionTimeout: Number(process.env.SMTP_CONNECTION_TIMEOUT_MS ?? 10000),
|
||||
greetingTimeout: Number(process.env.SMTP_GREETING_TIMEOUT_MS ?? 10000),
|
||||
socketTimeout: Number(process.env.SMTP_SOCKET_TIMEOUT_MS ?? 10000),
|
||||
};
|
||||
}
|
||||
|
||||
let smtpTransporter: any = null;
|
||||
let smtpTransporterPromise: Promise<any> | null = null;
|
||||
|
||||
async function getTransporter(cfg: SmtpConfig): Promise<any> {
|
||||
if (smtpTransporter) return smtpTransporter;
|
||||
if (smtpTransporterPromise) return smtpTransporterPromise;
|
||||
|
||||
smtpTransporterPromise = (async () => {
|
||||
const nodemailer = await import('nodemailer');
|
||||
smtpTransporter = nodemailer.default.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: false, // Port 587 uses STARTTLS, not SSL
|
||||
auth: { user: smtpUser, pass: smtpPass },
|
||||
requireTLS: true, // Force STARTTLS upgrade
|
||||
const transport = nodemailer.default.createTransport({
|
||||
host: cfg.host,
|
||||
port: cfg.port,
|
||||
secure: false, // 587 uses STARTTLS, not implicit TLS
|
||||
requireTLS: cfg.requireTLS,
|
||||
auth: {
|
||||
user: cfg.user,
|
||||
pass: cfg.pass,
|
||||
},
|
||||
connectionTimeout: cfg.connectionTimeout,
|
||||
greetingTimeout: cfg.greetingTimeout,
|
||||
socketTimeout: cfg.socketTimeout,
|
||||
tls: {
|
||||
rejectUnauthorized: false, // Handle Hostinger certificate issues
|
||||
// Hostinger occasionally serves an intermediate cert chain that
|
||||
// node's default verifier rejects. Keep this off for parity with
|
||||
// the prior working configuration.
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
|
||||
await smtpTransporter.verify();
|
||||
smtpVerified = true;
|
||||
console.log('SMTP connection verified successfully');
|
||||
// NOTE: we deliberately do NOT log cfg.pass. Logs go to platform
|
||||
// stdout and are user-visible in the Operations → Logs tab.
|
||||
console.log('[api.contact] SMTP transport created', {
|
||||
host: cfg.host,
|
||||
port: cfg.port,
|
||||
user: cfg.user,
|
||||
from: `"${cfg.fromName}" <${cfg.fromAddress}>`,
|
||||
adminRecipient: cfg.adminRecipient,
|
||||
requireTLS: cfg.requireTLS,
|
||||
timeouts: {
|
||||
connection: cfg.connectionTimeout,
|
||||
greeting: cfg.greetingTimeout,
|
||||
socket: cfg.socketTimeout,
|
||||
},
|
||||
});
|
||||
logger.info('api.contact', 'SMTP transport created', {
|
||||
host: cfg.host,
|
||||
port: cfg.port,
|
||||
user: cfg.user,
|
||||
requireTLS: cfg.requireTLS,
|
||||
});
|
||||
|
||||
smtpTransporter = transport;
|
||||
return transport;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await smtpTransporterPromise;
|
||||
} catch (err) {
|
||||
// Reset so a subsequent request can retry instead of being permanently
|
||||
// wedged on a transient nodemailer import or DNS failure.
|
||||
smtpTransporterPromise = null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Background verify — best-effort, never throws. Hits SMTP at boot so the
|
||||
// first user submission doesn't pay the TCP+STARTTLS handshake latency.
|
||||
(async () => {
|
||||
const cfg = readSmtpConfig();
|
||||
if (!cfg) {
|
||||
logger.info('api.contact', 'SMTP not configured — skipping boot-time verification');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const transport = await getTransporter(cfg);
|
||||
await transport.verify();
|
||||
console.log('[api.contact] SMTP connection verified successfully', {
|
||||
host: cfg.host,
|
||||
port: cfg.port,
|
||||
user: cfg.user,
|
||||
});
|
||||
logger.info('api.contact', 'SMTP connection verified successfully', {
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
user: smtpUser,
|
||||
host: cfg.host,
|
||||
port: cfg.port,
|
||||
user: cfg.user,
|
||||
});
|
||||
} catch (err: any) {
|
||||
smtpVerified = false;
|
||||
console.error('SMTP connection verification failed:', {
|
||||
console.error('[api.contact] SMTP connection verification failed', {
|
||||
code: err?.code,
|
||||
command: err?.command,
|
||||
message: err?.message,
|
||||
response: err?.response,
|
||||
});
|
||||
logger.error('api.contact', 'SMTP connection verification failed', {
|
||||
code: err?.code,
|
||||
command: err?.command,
|
||||
message: err?.message,
|
||||
response: err?.response,
|
||||
});
|
||||
@@ -165,16 +287,16 @@ let smtpVerified = false;
|
||||
// ============================================================
|
||||
// Email sending via SMTP (nodemailer) or no-op in development
|
||||
// ============================================================
|
||||
async function sendContactEmail(data: ContactFormData): Promise<void> {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_PASS;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? '587', 10);
|
||||
const smtpFrom = process.env.SMTP_FROM;
|
||||
const recipientEmail = process.env.CONTACT_EMAIL_TO;
|
||||
interface SendResult {
|
||||
adminInfo?: { messageId?: string };
|
||||
userConfirmInfo?: { messageId?: string };
|
||||
}
|
||||
|
||||
if (!smtpHost || !smtpUser || !smtpPass || !recipientEmail) {
|
||||
// SMTP not configured - log and continue (graceful degradation)
|
||||
async function sendContactEmail(data: ContactFormData): Promise<SendResult | void> {
|
||||
const cfg = readSmtpConfig();
|
||||
|
||||
if (!cfg) {
|
||||
// SMTP not configured — log and continue (graceful degradation)
|
||||
logger.info('api.contact', 'SMTP not configured — form submission logged only', {
|
||||
email: data.email,
|
||||
subject: data.subject,
|
||||
@@ -185,48 +307,49 @@ async function sendContactEmail(data: ContactFormData): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamic import to avoid bundling issues when nodemailer isn't installed
|
||||
try {
|
||||
// Reuse module-level transporter if available, otherwise create a new one
|
||||
if (!smtpTransporter) {
|
||||
const nodemailer = await import('nodemailer');
|
||||
smtpTransporter = nodemailer.default.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: false,
|
||||
auth: { user: smtpUser, pass: smtpPass },
|
||||
requireTLS: true,
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
const transport = await getTransporter(cfg);
|
||||
|
||||
const subjectLabels: Record<string, string> = {
|
||||
'web-development': 'Web Development',
|
||||
'mobile-development': 'Mobile Development',
|
||||
'ui-ux': 'UI/UX Design',
|
||||
'cloud-services': 'Cloud Services',
|
||||
'ai-ml': 'AI & Machine Learning',
|
||||
'consulting': 'IT Consulting',
|
||||
'consulting': 'IT Consulting / DevOps',
|
||||
'support': 'Technical Support',
|
||||
'erp-solutions': 'ERP / Custom Software',
|
||||
'government-systems': 'Government / GeM',
|
||||
'erp-solutions': 'ERP / Enterprise Systems',
|
||||
'government-systems': 'Government IT',
|
||||
'other': 'Other',
|
||||
};
|
||||
|
||||
const sourceLabel = data.source ? data.source : 'contact-page';
|
||||
|
||||
const fromAddress = smtpFrom || smtpUser;
|
||||
// The "Tell us about your project" inline form on the homepage and the
|
||||
// mobile sticky CTA tag themselves as project-inquiry sources. Use a
|
||||
// distinct subject prefix so the admin inbox can be triaged easily.
|
||||
const isProjectInquiry =
|
||||
data.source === 'homepage-inline' ||
|
||||
data.source === 'homepage-hero' ||
|
||||
data.source === 'homepage-final-cta' ||
|
||||
data.source === 'mobile-sticky';
|
||||
const adminSubjectPrefix = isProjectInquiry ? '[Project Inquiry]' : '[Contact Form]';
|
||||
const userSubjectLine = isProjectInquiry
|
||||
? "We received your project inquiry - CompanySite"
|
||||
: 'We received your message - CompanySite';
|
||||
|
||||
// 1) Admin notification email with BCC to SMTP_USER for delivery confirmation
|
||||
const adminInfo = await smtpTransporter.sendMail({
|
||||
from: `"CompanySite Contact Form" <${fromAddress}>`,
|
||||
to: recipientEmail,
|
||||
bcc: process.env.SMTP_USER, // BCC to admin inbox as delivery confirmation
|
||||
const fromHeader = `"${cfg.fromName}" <${cfg.fromAddress}>`;
|
||||
|
||||
// 1) Admin notification email — delivered to MAIL_ADMIN_TO (or SMTP_FROM
|
||||
// fallback). BCC to SMTP_USER as a soft delivery receipt.
|
||||
const adminInfo = await transport.sendMail({
|
||||
from: fromHeader,
|
||||
to: cfg.adminRecipient,
|
||||
bcc: cfg.user, // BCC to the SMTP mailbox as delivery confirmation
|
||||
replyTo: data.email,
|
||||
subject: `[Contact Form] ${subjectLabels[data.subject] ?? data.subject} - ${data.name}`,
|
||||
subject: `${adminSubjectPrefix} ${subjectLabels[data.subject] ?? data.subject} - ${data.name}`,
|
||||
text: [
|
||||
`New contact form submission`,
|
||||
isProjectInquiry ? 'New project inquiry submission' : 'New contact form submission',
|
||||
``,
|
||||
`Source: ${sourceLabel}`,
|
||||
`Name: ${data.name}`,
|
||||
@@ -242,7 +365,7 @@ async function sendContactEmail(data: ContactFormData): Promise<void> {
|
||||
`Submitted at: ${new Date().toISOString()}`,
|
||||
].join('\n'),
|
||||
html: `
|
||||
<h2>New Contact Form Submission</h2>
|
||||
<h2>${isProjectInquiry ? 'New Project Inquiry' : 'New Contact Form Submission'}</h2>
|
||||
<table style="border-collapse:collapse;width:100%;max-width:600px">
|
||||
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Source</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(sourceLabel)}</td></tr>
|
||||
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Name</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(data.name)}</td></tr>
|
||||
@@ -256,15 +379,24 @@ async function sendContactEmail(data: ContactFormData): Promise<void> {
|
||||
`,
|
||||
});
|
||||
|
||||
// 2) User confirmation email — proves delivery works AND provides good UX
|
||||
const userConfirmInfo = await smtpTransporter.sendMail({
|
||||
from: `"CompanySite" <${fromAddress}>`,
|
||||
console.log('[api.contact] Admin notification accepted by SMTP', {
|
||||
messageId: adminInfo?.messageId,
|
||||
recipient: cfg.adminRecipient,
|
||||
source: sourceLabel,
|
||||
});
|
||||
|
||||
// 2) User confirmation email (auto-reply) — preserves the existing UX
|
||||
// pattern. Subject line adapts to whether this is a project inquiry.
|
||||
const userConfirmInfo = await transport.sendMail({
|
||||
from: fromHeader,
|
||||
to: data.email,
|
||||
subject: 'We received your message - CompanySite',
|
||||
subject: userSubjectLine,
|
||||
text: [
|
||||
`Hi ${data.name},`,
|
||||
``,
|
||||
`Thank you for reaching out to us! We have received your message regarding "${subjectLabels[data.subject] ?? data.subject}".`,
|
||||
isProjectInquiry
|
||||
? `Thank you for telling us about your project. We have received your inquiry regarding "${subjectLabels[data.subject] ?? data.subject}".`
|
||||
: `Thank you for reaching out to us! We have received your message regarding "${subjectLabels[data.subject] ?? data.subject}".`,
|
||||
``,
|
||||
`Our team will review your inquiry and get back to you as soon as possible, typically within 1-2 business days.`,
|
||||
``,
|
||||
@@ -279,9 +411,11 @@ async function sendContactEmail(data: ContactFormData): Promise<void> {
|
||||
].join('\n'),
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px">
|
||||
<h2 style="color:#333">Thank you for contacting us!</h2>
|
||||
<h2 style="color:#333">${isProjectInquiry ? 'Thank you for telling us about your project!' : 'Thank you for contacting us!'}</h2>
|
||||
<p>Hi ${escapeHtml(data.name)},</p>
|
||||
<p>We have received your message regarding <strong>"${escapeHtml(subjectLabels[data.subject] ?? data.subject)}"</strong>.</p>
|
||||
<p>${isProjectInquiry
|
||||
? `Thank you for telling us about your project. We have received your inquiry regarding <strong>"${escapeHtml(subjectLabels[data.subject] ?? data.subject)}"</strong>.`
|
||||
: `We have received your message regarding <strong>"${escapeHtml(subjectLabels[data.subject] ?? data.subject)}"</strong>.`}</p>
|
||||
<p>Our team will review your inquiry and get back to you as soon as possible, typically within <strong>1-2 business days</strong>.</p>
|
||||
<div style="background:#f9f9f9;border-left:4px solid #007bff;padding:12px 16px;margin:20px 0">
|
||||
<p style="margin:4px 0"><strong>Subject:</strong> ${escapeHtml(subjectLabels[data.subject] ?? data.subject)}</p>
|
||||
@@ -295,9 +429,16 @@ async function sendContactEmail(data: ContactFormData): Promise<void> {
|
||||
`,
|
||||
});
|
||||
|
||||
console.log('[api.contact] User auto-reply accepted by SMTP', {
|
||||
messageId: userConfirmInfo?.messageId,
|
||||
recipient: data.email,
|
||||
source: sourceLabel,
|
||||
});
|
||||
|
||||
return { adminInfo, userConfirmInfo };
|
||||
} catch (err) {
|
||||
// If nodemailer isn't installed, treat as graceful degradation (log only)
|
||||
} catch (err: any) {
|
||||
// If nodemailer isn't installed, treat as graceful degradation (log only).
|
||||
// The site keeps working; ops sees the warning and ships the dependency.
|
||||
if (err instanceof Error && err.message.includes('Cannot find module')) {
|
||||
logger.warn('api.contact', 'nodemailer not available — form submission logged only', {
|
||||
email: data.email,
|
||||
@@ -305,6 +446,17 @@ async function sendContactEmail(data: ContactFormData): Promise<void> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Structured send-error log. Deliberately omits SMTP_PASS — only the
|
||||
// SMTP server's response/code/command. Sentry captures the full error.
|
||||
console.error('[api.contact] SMTP send failed', {
|
||||
code: err?.code,
|
||||
command: err?.command,
|
||||
response: err?.response,
|
||||
responseCode: err?.responseCode,
|
||||
message: err?.message,
|
||||
recipient: data.email,
|
||||
adminRecipient: cfg.adminRecipient,
|
||||
});
|
||||
await captureException(err, {
|
||||
scope: 'api.contact',
|
||||
extra: { email: data.email, subject: data.subject },
|
||||
@@ -454,19 +606,29 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
|
||||
// Fire-and-forget: send email in background so the user gets an instant response.
|
||||
// Errors are logged/reported but never block the HTTP reply.
|
||||
const recipientEmail = process.env.CONTACT_EMAIL_TO || 'unknown';
|
||||
console.log('Sending contact email to: ' + recipientEmail);
|
||||
const adminRecipient =
|
||||
process.env.MAIL_ADMIN_TO ||
|
||||
process.env.SMTP_FROM ||
|
||||
process.env.CONTACT_EMAIL_TO ||
|
||||
process.env.CONTACT_EMAIL ||
|
||||
process.env.SMTP_USER ||
|
||||
'unknown';
|
||||
console.log('[api.contact] Dispatching contact email', {
|
||||
adminRecipient,
|
||||
userRecipient: formData.email,
|
||||
source: formData.source,
|
||||
});
|
||||
|
||||
sendContactEmail(formData)
|
||||
.then((result) => {
|
||||
console.log('Contact emails delivered successfully:', {
|
||||
adminRecipient: recipientEmail,
|
||||
console.log('[api.contact] Contact emails delivered successfully', {
|
||||
adminRecipient,
|
||||
adminMessageId: result?.adminInfo?.messageId,
|
||||
userRecipient: formData.email,
|
||||
userMessageId: result?.userConfirmInfo?.messageId,
|
||||
});
|
||||
logger.info('api.contact', 'Contact emails delivered successfully', {
|
||||
adminRecipient: recipientEmail,
|
||||
adminRecipient,
|
||||
adminMessageId: result?.adminInfo?.messageId,
|
||||
userRecipient: formData.email,
|
||||
userMessageId: result?.userConfirmInfo?.messageId,
|
||||
@@ -476,17 +638,20 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
});
|
||||
})
|
||||
.catch(async (err: any) => {
|
||||
console.error('Background email delivery failed:', {
|
||||
// SMTP_PASS is NEVER logged — only protocol-level error fields.
|
||||
console.error('[api.contact] Background email delivery failed', {
|
||||
code: err?.code,
|
||||
command: err?.command,
|
||||
message: err?.message,
|
||||
response: err?.response,
|
||||
responseCode: err?.responseCode,
|
||||
recipient: recipientEmail,
|
||||
email: formData.email,
|
||||
adminRecipient,
|
||||
userRecipient: formData.email,
|
||||
subject: formData.subject,
|
||||
});
|
||||
logger.error('api.contact', 'Background email delivery failed', {
|
||||
code: err?.code,
|
||||
command: err?.command,
|
||||
message: err?.message,
|
||||
response: err?.response,
|
||||
responseCode: err?.responseCode,
|
||||
|
||||
@@ -32,21 +32,27 @@ function formatDate(date: Date): string {
|
||||
}
|
||||
|
||||
const readingTime = getReadingTime(post.body);
|
||||
// SEO meta: prefer per-post overrides so the <title>/description land in the
|
||||
// 50–60 / 150–160 character ranges, while the on-page heading below keeps the
|
||||
// full, descriptive post title. `seoTitle` gets the " | WorkRoot" suffix from
|
||||
// BaseLayout, so it should be the text before that suffix.
|
||||
const metaTitle = post.data.seoTitle ?? post.data.title;
|
||||
const metaDescription = post.data.seoDescription ?? post.data.description;
|
||||
const shareUrl = encodeURIComponent(Astro.url.href);
|
||||
const shareTitle = encodeURIComponent(post.data.title);
|
||||
const shareDescription = encodeURIComponent(post.data.description);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={post.data.title}
|
||||
description={post.data.description}
|
||||
title={metaTitle}
|
||||
description={metaDescription}
|
||||
ogImage={post.data.heroImage}
|
||||
>
|
||||
<SEO
|
||||
slot="head"
|
||||
type="BlogPosting"
|
||||
title={post.data.title}
|
||||
description={post.data.description}
|
||||
title={metaTitle}
|
||||
description={metaDescription}
|
||||
breadcrumbs={[
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Blog', url: '/blog' },
|
||||
|
||||
@@ -24,8 +24,8 @@ function formatDate(date: Date): string {
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Blog"
|
||||
description="Insights and articles on web development, AI, cloud computing, and digital transformation from the WorkRoot team."
|
||||
title="Software Development & Technology Insights"
|
||||
description="Insights on web development, ERP, cloud migration, AI, and government IT from the WorkRoot engineering team — field notes from real, large-scale deployments."
|
||||
>
|
||||
<SEO
|
||||
slot="head"
|
||||
|
||||
@@ -34,7 +34,7 @@ const contactInfo = [
|
||||
{
|
||||
icon: 'clock',
|
||||
title: 'Business Hours',
|
||||
details: ['Mon – Fri: 10:00 AM – 6:00 PM', 'Sat – Sun: Closed'],
|
||||
details: ['Mon – Fri: 10:00 AM – 6:00 PM', 'We reply within 24 business hours, Mon – Fri.'],
|
||||
link: null,
|
||||
linkLabel: null,
|
||||
color: 'violet',
|
||||
@@ -80,11 +80,28 @@ const trustStats = [
|
||||
{ value: '< 24h', label: 'Response Time' },
|
||||
{ value: '3+', label: 'Years Experience' },
|
||||
];
|
||||
|
||||
// Service options — rendered as large, tappable radio tiles (replacing the
|
||||
// old native <select>) so the choice is thumb-friendly on mobile. `value`
|
||||
// is the code submitted to /api/contact (unchanged from the previous
|
||||
// <option> values); `label` is the display text.
|
||||
const serviceOptions = [
|
||||
{ value: 'erp-solutions', label: 'ERP / Enterprise Systems' },
|
||||
{ value: 'government-systems', label: 'Government IT' },
|
||||
{ value: 'web-development', label: 'Web Development' },
|
||||
{ value: 'mobile-development', label: 'Mobile Development' },
|
||||
{ value: 'ui-ux', label: 'UI/UX Design' },
|
||||
{ value: 'consulting', label: 'IT Consulting / DevOps' },
|
||||
{ value: 'other', label: 'Other / General Inquiry' },
|
||||
];
|
||||
|
||||
// Budget options — also rendered as large tappable tiles.
|
||||
const budgetOptions = ['< ₹1L', '₹1L – ₹5L', '₹5L – ₹15L', '₹15L+', 'Not sure yet'];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Contact Us — Get a Free Quote"
|
||||
description="Start your project with WorkRoot IT Solutions LLP. 20+ projects delivered, 100% client satisfaction, response within 24 hours. Reach us at admin@workroot.in or call +91 9561417403."
|
||||
title="Contact Us — Get a Free Project Quote Today"
|
||||
description="Start your web, mobile, ERP, or government IT project with WorkRoot. 20+ projects delivered, replies within 1 business day. Email admin@workroot.in to begin."
|
||||
>
|
||||
<SEO
|
||||
slot="head"
|
||||
@@ -125,7 +142,7 @@ const trustStats = [
|
||||
<span class="text-transparent bg-clip-text bg-gradient-to-r from-primary-300 to-primary-500">Hear From You</span>
|
||||
</h1>
|
||||
<p class="text-xl text-secondary-300 max-w-2xl mx-auto reveal-on-scroll reveal-delay-2">
|
||||
Whether you have a project in mind, a question about our services, or just want to explore the possibilities — our team is ready to help.
|
||||
Tell us about your project — get a free 1-page proposal in 24 hours.
|
||||
</p>
|
||||
|
||||
<!-- Trust stats strip -->
|
||||
@@ -151,23 +168,42 @@ const trustStats = [
|
||||
<div class="bg-white dark:bg-secondary-800 rounded-2xl shadow-card border border-secondary-100 dark:border-secondary-700 p-8 lg:p-10">
|
||||
<!-- Form Header -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-secondary-900 dark:text-secondary-100 mb-2">Send Us a Message</h2>
|
||||
<p class="text-secondary-500 dark:text-secondary-400 text-sm">Fill out the form below and we'll respond within 24 hours.</p>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-2">
|
||||
<h2 class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">Send Us a Message</h2>
|
||||
<!-- Response promise — shown ABOVE the form so the commitment is visible before users invest time -->
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 text-xs font-semibold whitespace-nowrap">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
< 24h response
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-secondary-500 dark:text-secondary-400 text-sm">Fill out the form below and we'll reply within 24 business hours, Mon – Fri.</p>
|
||||
</div>
|
||||
|
||||
<form id="contact-form" class="space-y-5" novalidate>
|
||||
<!-- Honeypot field (anti-spam) - hidden from users -->
|
||||
<div class="absolute -left-[9999px]" aria-hidden="true">
|
||||
<label for="website">
|
||||
Website
|
||||
<input
|
||||
type="text"
|
||||
id="website"
|
||||
name="website"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<!--
|
||||
Honeypot field (anti-spam).
|
||||
Hidden from sighted users via off-screen positioning (NOT display:none /
|
||||
visibility:hidden — some bots skip those entirely and never trip the trap).
|
||||
The wrapper carries aria-hidden so screen-readers don't announce the label
|
||||
or the input, and tabindex="-1" + autocomplete="off" keep it out of the
|
||||
tab order and browser autofill. Bots that blindly fill every field will
|
||||
set a value here, and /api/contact silently discards the submission.
|
||||
-->
|
||||
<div
|
||||
class="absolute -left-[9999px] top-auto w-px h-px overflow-hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<label for="website" class="sr-only">Website</label>
|
||||
<input
|
||||
type="text"
|
||||
id="website"
|
||||
name="website"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Name + Email row -->
|
||||
@@ -254,107 +290,84 @@ const trustStats = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phone + Subject row -->
|
||||
<div class="grid sm:grid-cols-2 gap-5">
|
||||
<!-- Phone Field -->
|
||||
<div class="form-group">
|
||||
<label for="phone" class="block text-sm font-semibold text-secondary-700 dark:text-secondary-300 mb-1.5">
|
||||
Phone Number
|
||||
<span class="text-secondary-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none" aria-hidden="true">
|
||||
<svg class="w-4 h-4 text-secondary-400 field-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
autocomplete="tel"
|
||||
aria-describedby="phone-error"
|
||||
class="form-input w-full pl-10 pr-4 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all duration-200 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 dark:placeholder-secondary-500 text-sm bg-secondary-50 dark:bg-secondary-700 focus:bg-white dark:focus:bg-secondary-700"
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
<div class="valid-check absolute inset-y-0 right-3 flex items-center hidden" aria-label="Valid">
|
||||
<svg class="w-4 h-4 text-green-700 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="invalid-check absolute inset-y-0 right-3 flex items-center hidden" aria-label="Invalid">
|
||||
<svg class="w-4 h-4 text-red-800 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Phone Field (full width) -->
|
||||
<div class="form-group">
|
||||
<label for="phone" class="block text-sm font-semibold text-secondary-700 dark:text-secondary-300 mb-1.5">
|
||||
Phone Number
|
||||
<span class="text-secondary-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none" aria-hidden="true">
|
||||
<svg class="w-4 h-4 text-secondary-400 field-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-red-500 hidden flex items-center gap-1" id="phone-error" data-error="phone" role="alert">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||
Please enter a valid phone number.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Subject Field -->
|
||||
<div class="form-group">
|
||||
<label for="subject" class="block text-sm font-semibold text-secondary-700 dark:text-secondary-300 mb-1.5">
|
||||
Service Needed <span class="text-red-500" aria-hidden="true">*</span>
|
||||
<span class="sr-only">(required)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none" aria-hidden="true">
|
||||
<svg class="w-4 h-4 text-secondary-400 field-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<select
|
||||
id="subject"
|
||||
name="subject"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="subject-error"
|
||||
class="form-input w-full pl-10 pr-8 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all duration-200 text-secondary-900 dark:text-secondary-100 text-sm bg-secondary-50 dark:bg-secondary-700 focus:bg-white dark:focus:bg-secondary-700 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">Select a service...</option>
|
||||
<option value="web-development">Web Development</option>
|
||||
<option value="mobile-development">Mobile Development</option>
|
||||
<option value="cloud-services">Cloud Services</option>
|
||||
<option value="ai-ml">AI & Machine Learning</option>
|
||||
<option value="consulting">IT Consulting</option>
|
||||
<option value="support">Technical Support</option>
|
||||
<option value="other">Other / General Inquiry</option>
|
||||
</select>
|
||||
<div class="valid-check absolute inset-y-0 right-9 flex items-center hidden" aria-label="Valid">
|
||||
<svg class="w-4 h-4 text-green-700 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="invalid-check absolute inset-y-0 right-9 flex items-center hidden" aria-label="Invalid">
|
||||
<svg class="w-4 h-4 text-red-800 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute inset-y-0 right-3 flex items-center pointer-events-none" aria-hidden="true">
|
||||
<svg class="w-4 h-4 text-secondary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
autocomplete="tel"
|
||||
aria-describedby="phone-error"
|
||||
class="form-input w-full pl-10 pr-4 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all duration-200 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 dark:placeholder-secondary-500 text-sm bg-secondary-50 dark:bg-secondary-700 focus:bg-white dark:focus:bg-secondary-700"
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
<div class="valid-check absolute inset-y-0 right-3 flex items-center hidden" aria-label="Valid">
|
||||
<svg class="w-4 h-4 text-green-700 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="invalid-check absolute inset-y-0 right-3 flex items-center hidden" aria-label="Invalid">
|
||||
<svg class="w-4 h-4 text-red-800 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-red-500 hidden flex items-center gap-1" id="subject-error" data-error="subject" role="alert">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||
Please select a service.
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-red-500 hidden flex items-center gap-1" id="phone-error" data-error="phone" role="alert">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||
Please enter a valid phone number.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Budget range (new field) -->
|
||||
<!-- Service Needed — large tappable radio tiles (replaces the
|
||||
old native <select>). Renders 2 columns on mobile (each tile
|
||||
a comfortable thumb target) and 3 from sm up. The hidden
|
||||
radios share name="subject" so the form submission and
|
||||
analytics read the same value the <select> used to provide. -->
|
||||
<div class="form-group">
|
||||
<span id="subject-label" class="block text-sm font-semibold text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Service Needed <span class="text-red-500" aria-hidden="true">*</span>
|
||||
<span class="sr-only">(required)</span>
|
||||
</span>
|
||||
<div
|
||||
id="subject-group"
|
||||
role="radiogroup"
|
||||
aria-labelledby="subject-label"
|
||||
aria-describedby="subject-error"
|
||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2.5"
|
||||
>
|
||||
{serviceOptions.map((opt) => (
|
||||
<label class="subject-option group flex items-center justify-center text-center min-h-[52px] px-3 py-2.5 rounded-xl border border-secondary-200 dark:border-secondary-600 text-sm font-medium text-secondary-700 dark:text-secondary-300 cursor-pointer hover:border-primary hover:bg-primary/5 dark:hover:bg-primary/10 transition-all duration-150 has-[:checked]:border-primary has-[:checked]:bg-primary/10 has-[:checked]:text-primary-700 dark:has-[:checked]:text-primary-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary/40">
|
||||
<input type="radio" name="subject" value={opt.value} class="sr-only" aria-describedby="subject-error" />
|
||||
<span class="leading-snug">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-red-500 hidden flex items-center gap-1" id="subject-error" data-error="subject" role="alert">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||
Please select a service.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Project Budget — large tappable radio tiles. 2 cols on mobile
|
||||
so each is a full thumb target; auto-fit row from sm up. -->
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-semibold text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Project Budget
|
||||
<span class="text-secondary-400 dark:text-secondary-500 font-normal">(optional)</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2" role="group" aria-label="Budget range options">
|
||||
{['< $5K', '$5K – $15K', '$15K – $50K', '$50K+', 'Not sure yet'].map((range, i) => (
|
||||
<label class="budget-option flex items-center gap-1.5 px-3 py-2 rounded-lg border border-secondary-200 dark:border-secondary-600 text-sm text-secondary-600 dark:text-secondary-400 cursor-pointer hover:border-primary hover:bg-primary/5 dark:hover:bg-primary/10 transition-all duration-150 has-[:checked]:border-primary has-[:checked]:bg-primary/10 has-[:checked]:text-primary-700 dark:has-[:checked]:text-primary-300">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5" role="group" aria-label="Budget range options">
|
||||
{budgetOptions.map((range) => (
|
||||
<label class="budget-option flex items-center justify-center text-center min-h-[48px] px-3 py-2.5 rounded-xl border border-secondary-200 dark:border-secondary-600 text-sm font-medium text-secondary-600 dark:text-secondary-400 cursor-pointer hover:border-primary hover:bg-primary/5 dark:hover:bg-primary/10 transition-all duration-150 has-[:checked]:border-primary has-[:checked]:bg-primary/10 has-[:checked]:text-primary-700 dark:has-[:checked]:text-primary-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary/40">
|
||||
<input type="radio" name="budget" value={range} class="sr-only" />
|
||||
{range}
|
||||
</label>
|
||||
@@ -369,7 +382,7 @@ const trustStats = [
|
||||
Your Message <span class="text-red-500" aria-hidden="true">*</span>
|
||||
<span class="sr-only">(required)</span>
|
||||
</label>
|
||||
<span id="char-count" class="text-xs text-secondary-400" aria-hidden="true">0 / 5000</span>
|
||||
<span id="char-count" class="text-xs text-secondary-400" aria-hidden="true">0 / 2000</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
@@ -377,8 +390,8 @@ const trustStats = [
|
||||
name="message"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="5000"
|
||||
rows="5"
|
||||
maxlength="2000"
|
||||
rows="4"
|
||||
aria-required="true"
|
||||
aria-describedby="message-error message-hint"
|
||||
class="form-input w-full px-4 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all duration-200 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 dark:placeholder-secondary-500 text-sm bg-secondary-50 dark:bg-secondary-700 focus:bg-white dark:focus:bg-secondary-700 resize-none"
|
||||
@@ -682,14 +695,14 @@ const trustStats = [
|
||||
Let's Build Something <span class="text-transparent bg-clip-text bg-gradient-to-r from-primary-300 to-accent-300">Remarkable</span> Together
|
||||
</h2>
|
||||
<p class="text-secondary-400 mb-8 max-w-xl mx-auto">
|
||||
Join 200+ companies who've transformed their digital presence with WorkRoot.
|
||||
Tell us about your project and we'll send back a free, no-obligation scope and timeline within one business day.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="#contact-form"
|
||||
class="inline-flex items-center justify-center gap-2 px-8 py-4 bg-primary text-white font-semibold rounded-xl hover:bg-primary-600 transition-colors duration-200 shadow-primary-glow focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-secondary-900"
|
||||
>
|
||||
Start a Project
|
||||
Get my free proposal
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
@@ -787,6 +800,13 @@ const trustStats = [
|
||||
color: #0e7490;
|
||||
}
|
||||
|
||||
/* Service tiles: invalid-group highlight (shown only after a failed submit
|
||||
attempt with no service chosen). Subtle red ring around the whole group. */
|
||||
.subject-group-invalid {
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 0 0 2px rgb(220 38 38 / 0.35);
|
||||
}
|
||||
|
||||
/* FAQ item */
|
||||
.faq-item summary::-webkit-details-marker { display: none; }
|
||||
.faq-item summary::marker { display: none; }
|
||||
@@ -885,12 +905,12 @@ const trustStats = [
|
||||
if (messageField && charCount) {
|
||||
messageField.addEventListener('input', () => {
|
||||
const len = messageField.value.length;
|
||||
charCount.textContent = `${len} / 5000`;
|
||||
charCount.className = len > 4500 ? 'text-xs text-red-500' : 'text-xs text-secondary-400';
|
||||
charCount.textContent = `${len} / 2000`;
|
||||
charCount.className = len > 1800 ? 'text-xs text-red-500' : 'text-xs text-secondary-400';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Budget toggle (radio-as-chip) ──────────────────────────
|
||||
// ── Budget toggle (radio-as-tile) ──────────────────────────
|
||||
const budgetOptions = document.querySelectorAll('.budget-option');
|
||||
budgetOptions.forEach((opt) => {
|
||||
const radio = opt.querySelector('input[type="radio"]') as HTMLInputElement;
|
||||
@@ -902,6 +922,31 @@ const trustStats = [
|
||||
}
|
||||
});
|
||||
|
||||
// ── Service tiles (radio group replacing the old native <select>) ──
|
||||
// The hidden radios share name="subject" so `form.elements.subject` resolves
|
||||
// to a RadioNodeList whose `.value` is the checked option — exactly what the
|
||||
// submit handler and analytics previously read from the <select>.
|
||||
const subjectErrorEl = document.querySelector('[data-error="subject"]');
|
||||
const subjectGroupEl = document.getElementById('subject-group');
|
||||
const subjectRadios = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>('input[name="subject"]')
|
||||
);
|
||||
|
||||
function getSubjectValue(): string {
|
||||
return subjectRadios.find((r) => r.checked)?.value ?? '';
|
||||
}
|
||||
|
||||
function validateSubject(touched = true): boolean {
|
||||
const valid = getSubjectValue() !== '';
|
||||
if (subjectErrorEl) subjectErrorEl.classList.toggle('hidden', valid || !touched);
|
||||
subjectGroupEl?.classList.toggle('subject-group-invalid', !valid && touched);
|
||||
return valid;
|
||||
}
|
||||
|
||||
subjectRadios.forEach((radio) => {
|
||||
radio.addEventListener('change', () => validateSubject());
|
||||
});
|
||||
|
||||
// ── Form Validation ────────────────────────────────────────
|
||||
const form = document.getElementById('contact-form') as HTMLFormElement;
|
||||
const successState = document.getElementById('success-state');
|
||||
@@ -993,18 +1038,25 @@ const trustStats = [
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all fields
|
||||
// Validate all fields. `subject` is now a radio group, so it is checked
|
||||
// separately from the text/textarea fields handled by validateField.
|
||||
let isValid = true;
|
||||
['name', 'email', 'phone', 'subject', 'message'].forEach((fieldName) => {
|
||||
['name', 'email', 'phone', 'message'].forEach((fieldName) => {
|
||||
const field = form.elements.namedItem(fieldName) as HTMLInputElement;
|
||||
if (field && !validateField(field)) isValid = false;
|
||||
});
|
||||
if (!validateSubject()) isValid = false;
|
||||
|
||||
if (!isValid) {
|
||||
showToast('Please fix the highlighted fields before submitting.', 'error');
|
||||
// Focus first invalid field
|
||||
// Focus the first invalid text field, or the service tiles if that's
|
||||
// the only thing missing.
|
||||
const firstInvalid = form.querySelector('.is-invalid') as HTMLElement | null;
|
||||
firstInvalid?.focus();
|
||||
if (firstInvalid) {
|
||||
firstInvalid.focus();
|
||||
} else if (getSubjectValue() === '') {
|
||||
subjectRadios[0]?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1038,7 +1090,7 @@ const trustStats = [
|
||||
name: (form.elements.namedItem('name') as HTMLInputElement)?.value.trim(),
|
||||
email: (form.elements.namedItem('email') as HTMLInputElement)?.value.trim(),
|
||||
phone: (form.elements.namedItem('phone') as HTMLInputElement)?.value.trim(),
|
||||
subject: (form.elements.namedItem('subject') as HTMLSelectElement)?.value,
|
||||
subject: getSubjectValue(),
|
||||
message: (form.elements.namedItem('message') as HTMLTextAreaElement)?.value.trim(),
|
||||
website: honeypot,
|
||||
}),
|
||||
@@ -1068,7 +1120,7 @@ const trustStats = [
|
||||
}
|
||||
|
||||
// Track analytics
|
||||
const subject = (form.elements.namedItem('subject') as HTMLSelectElement)?.value;
|
||||
const subject = getSubjectValue();
|
||||
trackFormSubmit('contact', success);
|
||||
if (success) trackEvent('contact_form_submit', { subject_category: subject });
|
||||
|
||||
@@ -1101,8 +1153,9 @@ const trustStats = [
|
||||
form.querySelectorAll('.valid-check').forEach((el) => el.classList.add('hidden'));
|
||||
form.querySelectorAll('.invalid-check').forEach((el) => el.classList.add('hidden'));
|
||||
form.querySelectorAll('[data-error]').forEach((el) => el.classList.add('hidden'));
|
||||
if (charCount) charCount.textContent = '0 / 5000';
|
||||
if (charCount) charCount.textContent = '0 / 2000';
|
||||
budgetOptions.forEach((o) => o.classList.remove('selected'));
|
||||
subjectGroupEl?.classList.remove('subject-group-invalid');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ const lastUpdated = 'March 20, 2026';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Cookie Policy"
|
||||
description="WorkRoot IT Solutions Cookie Policy - Learn how we use cookies and similar tracking technologies on our website."
|
||||
title="Cookie Policy — How We Use Cookies & Tracking"
|
||||
description="Learn how WorkRoot IT Solutions uses cookies and similar tracking technologies, what each cookie does, and how to manage or disable them in your browser."
|
||||
noIndex={true}
|
||||
>
|
||||
<!-- Hero Section -->
|
||||
@@ -299,7 +299,7 @@ const lastUpdated = 'March 20, 2026';
|
||||
<div class="bg-secondary-900/50 border border-secondary-800 rounded-xl p-6">
|
||||
<p class="text-secondary-300"><strong class="text-white">WorkRoot IT Solutions LLP</strong></p>
|
||||
<p class="text-secondary-300">Email: admin@workroot.in</p>
|
||||
<p class="text-secondary-300">Phone: +91 9561417403</p>
|
||||
<p class="text-secondary-300">Phone: <a href="tel:+919561417403" class="text-primary-400 hover:underline">+91 9561417403</a></p>
|
||||
<p class="text-secondary-300">Address: At Post Rajapur Shantinagar, Taluka Khatav, Dist Satara, MH 415503</p>
|
||||
</div>
|
||||
<p class="text-secondary-300 mt-4">
|
||||
|
||||