Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01cafba6c1 | ||
|
|
19a0720fe8 | ||
|
|
5c05779541 | ||
|
|
ce67f90f06 | ||
|
|
fb5e1ea281 | ||
|
|
03c37a623f |
@@ -1,11 +1,11 @@
|
||||
# Graph Report - 2e709500-79ab-403f-9ca0-3f027ac9f36e (2026-06-17)
|
||||
|
||||
## Corpus Check
|
||||
- 57 files · ~323,606 words
|
||||
- 58 files · ~324,818 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 522 nodes · 567 edges · 93 communities (86 shown, 7 thin omitted)
|
||||
- 536 nodes · 580 edges · 94 communities (87 shown, 7 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,8 @@
|
||||
- [[_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 41|Community 41]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `Performance Testing Scripts` - 12 edges
|
||||
@@ -73,7 +74,7 @@
|
||||
- `POST()` --calls--> `logApiRequest()` [INFERRED]
|
||||
src/pages/api/newsletter.ts → src/utils/logger.ts
|
||||
|
||||
## Communities (93 total, 7 thin omitted)
|
||||
## Communities (94 total, 7 thin omitted)
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.05
|
||||
@@ -105,90 +106,94 @@ 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 18 - "Community 18"
|
||||
Cohesion: 0.43
|
||||
Nodes (6): trackCaseStudyView(), trackEvent(), trackExternalLink(), trackFormSubmit(), trackNewsletterSignup(), trackPortfolioFilter()
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
### Community 19 - "Community 19"
|
||||
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 20 - "Community 20"
|
||||
Cohesion: 0.46
|
||||
Nodes (7): captureScreenshot(), ensureDir(), generateIndex(), main(), screenshotPath(), setTheme(), waitForPageReady()
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.36
|
||||
Nodes (4): deleteFromDB(), getAllFromDB(), openDB(), processSyncQueue()
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 0.46
|
||||
Nodes (7): checkRateLimit(), corsHeaders(), escapeHtml(), OPTIONS(), POST(), sendContactEmail(), validateContactForm()
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
### Community 23 - "Community 23"
|
||||
Cohesion: 0.43
|
||||
Nodes (6): trackCaseStudyView(), trackEvent(), trackExternalLink(), trackFormSubmit(), trackNewsletterSignup(), trackPortfolioFilter()
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
### Community 24 - "Community 24"
|
||||
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 24 - "Community 24"
|
||||
### Community 25 - "Community 25"
|
||||
Cohesion: 0.46
|
||||
Nodes (7): captureScreenshot(), ensureDir(), generateIndex(), main(), screenshotPath(), setTheme(), waitForPageReady()
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
### Community 26 - "Community 26"
|
||||
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()
|
||||
Nodes (5): initAllAnimations(), initButtonRipple(), initCounters(), initProgressBars(), initScrollReveal()
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 0.48
|
||||
Nodes (5): initAllAnimations(), initButtonRipple(), initCounters(), initProgressBars(), initScrollReveal()
|
||||
Nodes (5): extractJsonLd(), main(), validateOpenGraph(), validatePage(), validateTwitterCard()
|
||||
|
||||
### 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.48
|
||||
Nodes (5): extractJsonLd(), main(), validateOpenGraph(), validatePage(), validateTwitterCard()
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
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 32 - "Community 32"
|
||||
### 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"
|
||||
Cohesion: 0.6
|
||||
Nodes (3): formatUptime(), GET(), percentile()
|
||||
|
||||
### Community 35 - "Community 35"
|
||||
Cohesion: 0.6
|
||||
Nodes (3): formatUptime(), GET(), percentile()
|
||||
|
||||
### Community 36 - "Community 36"
|
||||
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)
|
||||
- **120 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` (+115 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.
|
||||
|
||||
@@ -198,7 +203,7 @@ _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._
|
||||
_120 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?**
|
||||
|
||||
|
Before Width: | Height: | Size: 495 B After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 410 B After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 31 KiB |
@@ -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">
|
||||
|
||||
@@ -33,9 +33,13 @@ const currentPath = Astro.url.pathname;
|
||||
<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>
|
||||
@@ -85,23 +89,40 @@ const currentPath = Astro.url.pathname;
|
||||
</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>
|
||||
|
||||
@@ -124,9 +145,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
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
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."
|
||||
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.
|
||||
@@ -49,6 +49,7 @@ const fullTitle = title === 'Home' ? siteName : `${title} | ${siteName}`;
|
||||
|
||||
<!-- Favicon -->
|
||||
<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 -->
|
||||
|
||||
@@ -80,6 +80,23 @@ 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
|
||||
@@ -273,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="erp-solutions">ERP / Enterprise Systems</option>
|
||||
<option value="government-systems">Government IT</option>
|
||||
<option value="web-development">Web Development</option>
|
||||
<option value="mobile-development">Mobile Development</option>
|
||||
<option value="ui-ux">UI/UX Design</option>
|
||||
<option value="consulting">IT Consulting / DevOps</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">
|
||||
{['< ₹1L', '₹1L – ₹5L', '₹5L – ₹15L', '₹15L+', '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>
|
||||
@@ -806,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; }
|
||||
@@ -909,7 +910,7 @@ const trustStats = [
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
@@ -921,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');
|
||||
@@ -1012,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;
|
||||
}
|
||||
|
||||
@@ -1057,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,
|
||||
}),
|
||||
@@ -1087,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 });
|
||||
|
||||
@@ -1122,6 +1155,7 @@ const trustStats = [
|
||||
form.querySelectorAll('[data-error]').forEach((el) => el.classList.add('hidden'));
|
||||
if (charCount) charCount.textContent = '0 / 2000';
|
||||
budgetOptions.forEach((o) => o.classList.remove('selected'));
|
||||
subjectGroupEl?.classList.remove('subject-group-invalid');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +400,52 @@ const techStack = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mid-page Conversion CTA — sits directly after the Services section so
|
||||
mobile visitors hit the funnel early instead of having to scroll past
|
||||
How-It-Works, testimonials, the tech stack and the FAQ to reach the
|
||||
inline lead form. Primary action jumps straight to the on-page inline
|
||||
lead form (#inline-lead-form, smooth-scrolled by the anchor handler at
|
||||
the bottom of this page); secondary action is a one-tap phone call. -->
|
||||
<section
|
||||
aria-labelledby="midpage-cta-heading"
|
||||
class="bg-gradient-to-r from-primary-600 to-primary-700 dark:from-primary-700 dark:to-primary-800"
|
||||
>
|
||||
<div class="container-wrapper py-10 sm:py-12">
|
||||
<div class="flex flex-col items-center gap-5 text-center md:flex-row md:justify-between md:text-left md:gap-8 max-w-5xl mx-auto">
|
||||
<div class="md:flex-1">
|
||||
<h2 id="midpage-cta-heading" class="text-2xl sm:text-3xl font-bold text-white leading-tight">
|
||||
Have a project in mind? Get a free proposal in 24 hours.
|
||||
</h2>
|
||||
<p class="mt-2 text-primary-100 text-sm sm:text-base">
|
||||
No obligation · Free 30-min discovery call · NDA on request.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex w-full flex-col sm:flex-row md:w-auto md:flex-shrink-0 gap-3">
|
||||
<a
|
||||
href="#inline-lead-form"
|
||||
data-cta="midpage-primary"
|
||||
class="inline-flex items-center justify-center rounded-xl bg-white px-7 py-4 text-base font-bold text-primary-700 shadow-lg transition-all hover:bg-primary-50 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary-700"
|
||||
>
|
||||
Get my free proposal
|
||||
<svg class="ml-2 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.5" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="tel:+919561417403"
|
||||
data-cta="midpage-call"
|
||||
class="inline-flex items-center justify-center rounded-xl border-2 border-white/40 bg-white/5 px-7 py-4 text-base font-bold text-white transition-all hover:bg-white/15 hover:border-white/60 focus:outline-none focus:ring-2 focus:ring-white/60 focus:ring-offset-2 focus:ring-offset-primary-700"
|
||||
>
|
||||
<svg class="mr-2 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="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>
|
||||
Call now
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works Section - NEW -->
|
||||
<section class="py-24 bg-gradient-to-br from-secondary-900 via-secondary-800 to-primary-900 relative overflow-hidden">
|
||||
<!-- Background pattern -->
|
||||
|
||||
@@ -348,8 +348,11 @@ const featuredProjects = projects.filter(p => p.featured);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter Tabs -->
|
||||
<div class="flex flex-wrap justify-center gap-3" role="tablist" aria-label="Filter projects by category">
|
||||
<!-- Category Filter Tabs
|
||||
Mobile (<sm): a single horizontally-scrollable row so the five
|
||||
chips never wrap into a tall stack that pushes the project grid
|
||||
below the fold. ≥sm: centered wrapping row as before. -->
|
||||
<div class="filter-scroll flex flex-nowrap gap-3 overflow-x-auto pb-1 snap-x sm:flex-wrap sm:justify-center sm:overflow-visible sm:pb-0" role="tablist" aria-label="Filter projects by category">
|
||||
<button
|
||||
data-filter="all"
|
||||
class="filter-btn active rounded-full bg-primary px-6 py-2.5 font-semibold text-white transition-all duration-300 hover:shadow-lg hover:shadow-primary/25 focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2"
|
||||
@@ -1190,4 +1193,28 @@ const featuredProjects = projects.filter(p => p.featured);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Horizontally-scrollable category filter row on mobile.
|
||||
Chips keep their intrinsic width (no shrinking) and snap as the user
|
||||
swipes; the scrollbar is hidden for a clean native-app feel. At >=sm the
|
||||
row wraps normally so the scroll affordance is mobile-only. */
|
||||
.filter-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
.filter-scroll > .filter-btn {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
/* Hide the scrollbar (still scrollable) only while in the mobile scroll mode */
|
||||
@media (max-width: 639px) {
|
||||
.filter-scroll {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge legacy */
|
||||
}
|
||||
.filter-scroll::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||