diff --git a/scripts/cohort-apps-script/Code.gs b/scripts/cohort-apps-script/Code.gs new file mode 100644 index 000000000..26efacdaf --- /dev/null +++ b/scripts/cohort-apps-script/Code.gs @@ -0,0 +1,109 @@ +// Cohort application form backend. Container-bound to the responses Sheet. +// See README.md in this folder for setup and deployment. + +// The long ID from the Sheet's URL: docs.google.com/spreadsheets/d//edit +// Needed when the script was created at script.google.com rather than from the Sheet. +var SHEET_ID = ''; +var NOTIFY_EMAIL = ''; // e.g. 'team@civicdatalab.in' to get an email per submission; '' = off +var TABS = { short: 'Short', long: 'Long' }; +var SKIP = { form: true, website: true, _order: true }; +var MAX_LEN = 5000; + +function json(obj) { + return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(ContentService.MimeType.JSON); +} + +function clean(v) { + v = String(v == null ? '' : v).slice(0, MAX_LEN); + return /^[=+\-@]/.test(v) ? "'" + v : v; // block formula injection +} + +// The form sends the question order in `_order`, because Apps Script does not keep the order +// fields arrive in. Keys it does not list go last, in arrival order. +function sortByOrder(keys, p) { + var order = []; + try { order = JSON.parse(p._order || '[]'); } catch (err) { order = []; } + if (!Array.isArray(order)) order = []; + function rank(k) { var i = order.indexOf(k); return i === -1 ? order.length : i; } + return keys.map(function (k, i) { return { k: k, r: rank(k), i: i }; }) + .sort(function (a, b) { return a.r - b.r || a.i - b.i; }) + .map(function (x) { return x.k; }); +} + +function doGet() { + return json({ ok: true, status: 'running' }); +} + +function doPost(e) { + try { + var p = (e && e.parameter) || {}; + if (p.website) return json({ ok: true }); // honeypot: pretend success + var tabName = TABS[p.form]; + if (!tabName) return json({ ok: false, error: 'Invalid form' }); + // Column names are the question texts, so find the email by name. + var emailKey = Object.keys(p).filter(function (k) { return /email/i.test(k); })[0]; + var email = String(p[emailKey] || '').trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return json({ ok: false, error: 'Invalid email' }); + } + + var keys = sortByOrder(Object.keys(p).filter(function (k) { return !SKIP[k]; }), p); + var lock = LockService.getScriptLock(); + lock.waitLock(10000); + try { + var ss = SHEET_ID ? SpreadsheetApp.openById(SHEET_ID) : SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName(tabName) || ss.insertSheet(tabName); + // A tab with no answers yet is rebuilt in question order; once it has rows, columns are never moved. + var headers = sheet.getLastRow() > 1 && sheet.getLastColumn() > 0 + ? sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String) + : []; + if (!headers.length) { + if (sheet.getLastColumn() > 0) sheet.getRange(1, 1, 1, sheet.getLastColumn()).clearContent(); + headers = ['Timestamp']; + } + keys.forEach(function (k) { if (headers.indexOf(k) === -1) headers.push(k); }); // append-only + sheet.getRange(1, 1, 1, headers.length).setValues([headers]); + + var row = headers.map(function (h) { + if (h === 'Timestamp') return new Date(); + return keys.indexOf(h) === -1 ? '' : clean(p[h]); + }); + sheet.appendRow(row); + } finally { + lock.releaseLock(); + } + + if (NOTIFY_EMAIL) { + MailApp.sendEmail(NOTIFY_EMAIL, 'New cohort application (' + tabName + ')', + 'From: ' + email + '\n\nOpen the sheet: ' + ss.getUrl()); + } + return json({ ok: true }); + } catch (err) { + console.error(err); + return json({ ok: false, error: 'Server error' }); + } +} + +// Optional, run by hand from the Apps Script editor: puts a tab that already has answers into +// question order (Timestamp, then Q1, Q2, ...). Moves whole columns, data included. Make a copy of +// the Sheet first. Stops if a header is blank. +function sortColumns(tabName) { + var ss = SHEET_ID ? SpreadsheetApp.openById(SHEET_ID) : SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName(tabName); + if (!sheet || sheet.getLastRow() < 1) return; + var values = sheet.getDataRange().getValues(); + var headers = values[0].map(String); + if (headers.some(function (h) { return h === ''; })) throw new Error('Row 1 has a blank header. Name it first.'); + function rank(h) { + if (h === 'Timestamp') return -1; + var m = /^Q(\d+)\b/.exec(h); + return m ? Number(m[1]) : 1000000; + } + var idx = headers.map(function (h, i) { return i; }).sort(function (a, b) { + return rank(headers[a]) - rank(headers[b]) || a - b; + }); + var out = values.map(function (row) { return idx.map(function (i) { return row[i]; }); }); + sheet.getRange(1, 1, out.length, out[0].length).setValues(out); +} +function sortLongColumns() { sortColumns(TABS.long); } +function sortShortColumns() { sortColumns(TABS.short); } diff --git a/scripts/cohort-apps-script/README.md b/scripts/cohort-apps-script/README.md new file mode 100644 index 000000000..5a78fd6ee --- /dev/null +++ b/scripts/cohort-apps-script/README.md @@ -0,0 +1,58 @@ +# Cohort form backend (Google Apps Script) + +Receives the cohort page's form submissions and adds them as rows in a Google Sheet. +Short-form answers go to the `Short` tab, long-form answers to the `Long` tab (created automatically). + +## Setup (one time, on the CivicDataLab Google account) + +1. Create a new Google Sheet, e.g. "Cohort applications". +2. In the Sheet: **Extensions → Apps Script**. (If you made the script at script.google.com instead, set `SHEET_ID` at the top of Code.gs to the ID in the Sheet's URL.) +3. Delete the sample code, paste the whole of `Code.gs` from this folder, and click **Save**. +4. Click **Deploy → New deployment**. Click the gear icon next to "Select type" and choose **Web app**. + - Execute as: **Me** + - Who has access: **Anyone** +5. Click **Deploy**, then **Authorise access**. Pick the CivicDataLab account. If you see + "Google hasn't verified this app", click **Advanced → Go to (project name)** and **Allow**. +6. Copy the **Web app URL** (it ends in `/exec`). +7. Paste it into `SCRIPT_URL` in `src/components/cohort/config.js` and deploy the site. + +## Test it + +Open the URL in a browser: you should see `{"ok":true,"status":"running"}`. + +Send a test submission (replace the URL): + +```sh +URL="https://script.google.com/macros/s/XXXX/exec" +curl -L -d 'form=short&email=test@example.com&website=&q=a' "$URL" +``` + +Expected reply: `{"ok":true}`, and a new row in the `Short` tab. Delete the test row afterwards. + +## Changing the code later + +After editing `Code.gs`, you **must** publish a new version, or the URL keeps running the old code: +**Deploy → Manage deployments →** pencil (edit) icon **→ Version: New version → Deploy**. +The URL stays the same, so the website needs no change. + +## Email on each new submission + +At the top of `Code.gs`, set for example: + +```js +var NOTIFY_EMAIL = 'team@civicdatalab.in'; +``` + +Save, then publish a new version (see above). Google asks you to authorise once more (to send mail). +Set it back to `''` to switch emails off. + +## Notes + +- Columns: `Timestamp`, then one column per question, in question order (the form sends the order, + because Apps Script does not keep the order fields arrive in). A tab with no answers yet is rebuilt in + that order on the next submission. Once it has rows, new questions are added as new columns at the + right and existing columns are never moved, so sorting and filtering the sheet is safe. + To put a tab that already has answers into question order, make a copy of the Sheet, then run + `sortLongColumns` or `sortShortColumns` once from the Apps Script editor. + Don't rename row 1 headers — the script matches answers to columns by those names. +- **Privacy:** the sheet holds applicants' personal data. Share it only with the programme team. diff --git a/src/components/cohort/ApplyForm.js b/src/components/cohort/ApplyForm.js new file mode 100644 index 000000000..c73cc3123 --- /dev/null +++ b/src/components/cohort/ApplyForm.js @@ -0,0 +1,411 @@ +import React, { useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { + EMAIL_RE, + BLUE, + CORAL_DARK, + MUTED, + NAVY, + Card, + Heading, + Muted, + Fieldset, + Legend, + Option, + TextInput, + FieldLabel, + ErrorText, + Button, + TextButton, + Honeypot, + Result +} from './ui'; + +const PHONE_RE = /^\+?[\d\s()-]{8,}$/; + +const countWords = (text) => text.trim().split(/\s+/).filter(Boolean).length; + +// An option ending in ": please specify" opens a text box; the answer is saved +// as e.g. "Other: " or "Another language: ". +const SPECIFY = ': please specify'; +const isSpecify = (opt) => opt.endsWith(SPECIFY); +const NUMBER_WORDS = ['', 'one', 'two', 'three', 'four', 'five']; + +// A blank required question is marked in red and summed up once above the buttons, +// instead of repeating a message under every question. +export const REQUIRED = 'required'; + +// Id of the element to focus when `name` has an error. +const focusId = (fields, name) => { + const field = fields.find((f) => f.name === name); + if (field && field.options) return `apply-${name}-0`; + if (field && field.checks) return `apply-${field.checks[0][0]}`; + return `apply-${name}`; +}; + +// `sections` come from ./questions; answers are saved to the Sheet behind `scriptUrl`. +export default function ApplyForm({ sections: SECTIONS, scriptUrl, heading, intro, thanks }) { + const FIELDS = SECTIONS.flatMap((s) => s.fields); + const [answers, setAnswers] = useState({ website: '' }); + const [errors, setErrors] = useState({}); + const [status, setStatus] = useState('idle'); // idle | saving | done + const [saveError, setSaveError] = useState(''); + const [step, setStep] = useState(0); + const doneRef = useRef(null); + const stepRef = useRef(null); + const progressRef = useRef(null); + const moved = useRef(false); + + useEffect(() => { + if (status === 'done' && doneRef.current) doneRef.current.focus(); + }, [status]); + + // On each step change, bring the progress bar into view and move focus to the step title. + useEffect(() => { + if (!moved.current || !stepRef.current) return; + progressRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + stepRef.current.focus({ preventScroll: true }); + }, [step]); + + const last = step === SECTIONS.length - 1; + const go = (to) => { + moved.current = true; + setStep(to); + }; + + const value = (name) => answers[name] || ''; + // Selected options of a field, as a list for both single and multi choice. + const chosen = (f) => (f.multi ? answers[f.name] || [] : [value(f.name)].filter(Boolean)); + const resolve = (f, opt) => + isSpecify(opt) ? `${opt.slice(0, -SPECIFY.length)}: ${value(`${f.name}_other`).trim()}` : opt; + + // Multi choice: an `exclusive` option ("No major barriers") clears the rest, and vice versa. + const toggle = (f, opt) => { + const cur = chosen(f); + const solo = f.exclusive || []; + let next; + if (cur.includes(opt)) next = cur.filter((o) => o !== opt); + else if (solo.includes(opt)) next = [opt]; + else next = [...cur.filter((o) => !solo.includes(o)), opt]; + set(f.name, next); + }; + + const set = (name, v) => { + setAnswers((a) => ({ ...a, [name]: v })); + setErrors((e) => ({ ...e, [name]: undefined })); + }; + + const validate = (fields) => { + const e = {}; + fields.forEach((f) => { + const v = f.options ? '' : value(f.name); + if (f.checks) { + if (f.checks.some(([n]) => !answers[n])) e[f.name] = 'Please tick every box to continue.'; + } else if (f.options) { + const picked = chosen(f); + if (!picked.length) { + if (!f.optional) e[f.name] = REQUIRED; + } else if (picked.some(isSpecify) && !value(`${f.name}_other`).trim()) { + e[f.name] = 'Please specify.'; + } else if (f.mustAgree && picked[0] !== f.options[0]) { + e[f.name] = f.mustAgree; + } + } else if (!v.trim()) { + if (!f.optional) e[f.name] = REQUIRED; + } else if (f.type === 'email' && !EMAIL_RE.test(v.trim())) { + e[f.name] = 'Please enter a valid email address.'; + } else if (f.type === 'tel' && !PHONE_RE.test(v.trim())) { + e[f.name] = 'Please enter a phone number with country code, e.g. +91 98765 43210.'; + } + }); + return e; + }; + + const save = async () => { + setStatus('saving'); + const data = { form: 'long', website: answers.website }; + FIELDS.forEach((f, i) => { + const column = `Q${i + 1}. ${f.label}`; + if (f.checks) { + f.checks.forEach(([n, , short]) => { + data[`${column}: ${short}`] = answers[n] ? 'Yes' : ''; + }); + return; + } + data[column] = f.options + ? chosen(f) + .map((opt) => resolve(f, opt)) + .join('; ') + : value(f.name).trim(); + }); + // Apps Script does not keep the order fields arrive in, so say what the column order should be. + data._order = JSON.stringify(Object.keys(data).filter((k) => k !== 'form' && k !== 'website')); + let error = ''; + try { + if (!scriptUrl) throw new Error('Form not connected yet'); + const res = await fetch(scriptUrl, { method: 'POST', body: new URLSearchParams(data) }); + const json = await res.json(); + if (!json.ok) error = json.error || 'Unknown error'; + } catch (err) { + error = err.message || 'Network error'; + } + setSaveError(error); + // On failure the form stays filled in so nothing typed is lost. + setStatus(error ? 'idle' : 'done'); + }; + + const handleSubmit = (event) => { + event.preventDefault(); + const fields = SECTIONS[step].fields; + const e = validate(fields); + setErrors(e); + const first = fields.find((f) => e[f.name]); + if (first) { + const el = document.getElementById(focusId(FIELDS, first.name)); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.focus({ preventScroll: true }); + } + return; + } + if (last) save(); + else go(step + 1); + }; + + if (status === 'done') { + return ( + + + {thanks} + + + ); + } + + const errorFor = (name) => + errors[name] && errors[name] !== REQUIRED && {errors[name]}; + const blanks = SECTIONS[step].fields.filter((f) => errors[f.name] === REQUIRED).length; + + const renderField = (f, n) => { + const err = !!errors[f.name]; + const describedBy = !err ? undefined : errors[f.name] === REQUIRED ? 'apply-blanks' : `apply-${f.name}-error`; + const label = `Q${n}. ${f.label}`; + + if (f.options || f.checks) { + return ( +
+ + {label} + + {f.multi && {f.max ? `Choose up to ${NUMBER_WORDS[f.max]}.` : 'Select all that apply.'}} + {f.hint && {f.hint}} + {f.options && + f.options.map((opt, i) => { + const picked = chosen(f); + const on = picked.includes(opt); + const full = f.max && picked.length >= f.max && !on && !(f.exclusive || []).includes(opt); + return ( + + ); + })} + {f.checks && + f.checks.map(([name, text]) => ( + + ))} + {f.options && chosen(f).some(isSpecify) && ( + { + set(`${f.name}_other`, ev.target.value); + setErrors((e) => ({ ...e, [f.name]: undefined })); + }} + /> + )} + {errorFor(f.name)} +
+ ); + } + + const words = f.words && countWords(value(f.name)); + return ( + + + {label} + + {f.hint && {f.hint}} + set(f.name, ev.target.value)} + /> + {f.words && ( + f.words}> + {words} / {f.words} words suggested + + )} + {errorFor(f.name)} + + ); + }; + + const section = SECTIONS[step]; + const firstQ = SECTIONS.slice(0, step).reduce((sum, sec) => sum + sec.fields.length, 1); + return ( + + {heading} + {intro} + + + Step {step + 1} of {SECTIONS.length} + + + + + +
+
+ + {section.title} + + {section.intro && {section.intro}} + {section.fields.map((f, i) => renderField(f, firstQ + i))} +
+ +
+ ); +} + +const Req = ({ optional }) => + optional ? ( + (optional) + ) : ( + + ); + +const Intro = styled.div` + margin: 0 0 24px; + color: ${MUTED}; + p { margin: 0 0 12px; } +`; + +const SectionTitle = styled.h3` + margin: 0 0 16px; + font-size: 1.3rem; + color: ${NAVY}; + &:focus { outline: none; } +`; + +const Progress = styled.div` + margin: 0 0 24px; + scroll-margin-top: 96px; + span { display: block; margin-bottom: 8px; font-size: 0.9rem; font-weight: 600; color: ${MUTED}; } +`; + +const Bar = styled.div` + height: 6px; + border-radius: 999px; + background: #e3e8ee; + overflow: hidden; + i { display: block; height: 100%; background: ${BLUE}; transition: width 0.3s; } +`; + +const Nav = styled.div` + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 8px; + button:last-child { margin-left: auto; } +`; + +const BackButton = styled(Button)` + background: transparent; + color: ${NAVY}; + box-shadow: inset 0 0 0 2px ${NAVY}; + &:hover { background: #eef2f6; } +`; + +const Field = styled.div` + margin-bottom: 28px; + label { font-size: 1.05rem; line-height: 1.4; } + textarea { resize: vertical; } +`; + +const Hint = styled.p` + margin: -4px 0 10px; + color: ${MUTED}; + font-size: 0.9rem; +`; + +const WordCount = styled.p` + margin: 6px 0 0; + font-size: 0.85rem; + color: ${(p) => (p.over ? CORAL_DARK : MUTED)}; +`; diff --git a/src/components/cohort/FitCheck.js b/src/components/cohort/FitCheck.js new file mode 100644 index 000000000..6d7dcdaef --- /dev/null +++ b/src/components/cohort/FitCheck.js @@ -0,0 +1,242 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + EMAIL_RE, + Card, + Heading, + Muted, + Fieldset, + Legend, + Option, + TextInput, + FieldLabel, + ErrorText, + Button, + TextButton, + Honeypot, + Result +} from './ui'; + +import { OTHER } from './questions'; +import { REQUIRED } from './ApplyForm'; + +const QUESTIONS = [ + { + name: 'solution_stage', + legend: 'Where is your AI solution today?', + options: [ + 'Deployed and regularly used by intended users', + 'Being piloted with intended users', + 'Working prototype, not yet tested with intended users', + 'Under active development, with no working prototype yet', + 'Idea or planning stage only', + 'We do not currently have an AI solution' + ] + }, + { + name: 'domain', + legend: 'Which domain does your proposed AI use case address?', + options: ['Health', 'Agriculture', 'Public services', 'More than one', OTHER] + }, + { + name: 'testing', + legend: 'Can you organise testing with beneficiaries or frontline teams during the cohort?', + note: 'The programme aims to build readiness for a pilot with 50–100 users.', + options: [ + 'Yes, through our existing field or community network', + 'Yes, through a partner organisation', + 'Likely, but arrangements need confirmation', + 'We would need support to organise this', + 'No, not currently' + ] + }, + { + name: 'tech_lead', + legend: 'Can you assign a Tech Lead or Product Owner with approximately 50% availability over six months?', + options: [ + 'Yes, a lead and backup are identified, and availability is confirmed', + 'A lead is identified; backup or availability needs confirmation', + 'We can assign a lead before the cohort begins', + 'We cannot commit this staff time' + ] + } +]; + +export default function FitCheck({ scriptUrl }) { + const [answers, setAnswers] = useState({ email: '', domain_other: '', website: '' }); + const [errors, setErrors] = useState({}); + const [status, setStatus] = useState('idle'); // idle | saving | done + const [saveError, setSaveError] = useState(''); + const resultRef = useRef(null); + + useEffect(() => { + if (status === 'done' && resultRef.current) resultRef.current.focus(); + }, [status]); + + const set = (name, value) => { + setAnswers((a) => ({ ...a, [name]: value })); + setErrors((e) => ({ ...e, [name]: undefined })); + }; + + const validate = () => { + const e = {}; + QUESTIONS.forEach((q) => { + if (!answers[q.name]) e[q.name] = REQUIRED; + }); + if (answers.domain === OTHER && !answers.domain_other.trim()) { + e.domain_other = 'Please specify the domain.'; + } + if (!answers.email.trim()) e.email = REQUIRED; + else if (!EMAIL_RE.test(answers.email.trim())) e.email = 'Please enter a valid email address.'; + return e; + }; + + const save = async () => { + setStatus('saving'); + // The Sheet's column headers are the question texts. + const data = { form: 'short', website: answers.website }; + QUESTIONS.forEach((q) => { + // "Other" is saved as "Other: " in the same column. + data[q.legend] = + q.name === 'domain' && answers.domain === OTHER ? `Other: ${answers.domain_other.trim()}` : answers[q.name]; + }); + data['Your email'] = answers.email.trim(); + // Apps Script does not keep the order fields arrive in, so say what the column order should be. + data._order = JSON.stringify(Object.keys(data).filter((k) => k !== 'form' && k !== 'website')); + let error = ''; + try { + if (!scriptUrl) throw new Error('Form not connected yet'); + const res = await fetch(scriptUrl, { method: 'POST', body: new URLSearchParams(data) }); + const json = await res.json(); + if (!json.ok) error = json.error || 'Unknown error'; + } catch (err) { + error = err.message || 'Network error'; + } + setSaveError(error); + setStatus('done'); + }; + + const handleSubmit = (event) => { + event.preventDefault(); + const e = validate(); + setErrors(e); + const order = [...QUESTIONS.map((q) => q.name), 'domain_other', 'email']; + const first = order.find((name) => e[name]); + if (first) { + const id = first === 'domain_other' || first === 'email' ? `fit-${first}` : `fit-${first}-0`; + const el = document.getElementById(id); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.focus({ preventScroll: true }); + } + return; + } + save(); + }; + + return ( + + Are we a fit? Check in a minute + We'll save your answers and your email so we can follow up. +
+ {QUESTIONS.map((q) => ( +
+ {q.legend} + {q.options.map((opt, i) => ( + + ))} + {q.name === 'domain' && answers.domain === OTHER && ( + <> + Please specify + set('domain_other', ev.target.value)} + /> + {errors.domain_other && {errors.domain_other}} + + )} + {q.note && ( + + {q.note} + + )} +
+ ))} + + Your email + set('email', ev.target.value)} + /> + {errors.email && errors.email !== REQUIRED && {errors.email}} + +
+ ); +} diff --git a/src/components/cohort/config.js b/src/components/cohort/config.js new file mode 100644 index 000000000..d28fd6cd6 --- /dev/null +++ b/src/components/cohort/config.js @@ -0,0 +1,15 @@ +// Per-cohort settings. scriptUrl is the Google Apps Script web app that appends +// submissions to that cohort's Sheet; setup: scripts/cohort-apps-script/README.md. +// An empty scriptUrl makes the forms show "Form not connected yet". + +export const TECH4DEV = { + // Applications close 25 Nov, 11:59 PM IST. + deadline: new Date('2026-11-25T23:59:59+05:30'), + scriptUrl: 'https://script.google.com/macros/s/AKfycbw3dA1cYIHNbALkQ4oyFARyF1ro7clobXMl6ywXF_SRatJoT96wd1wC8Rq9hJAr7X2X/exec' +}; + +export const UNDP = { + // Applications close 10 Dec, 11:59 PM IST. + deadline: new Date('2026-12-10T23:59:59+05:30'), + scriptUrl: 'https://script.google.com/macros/s/AKfycbyUKn1v-K69KEfbLhGeBRJ92HjzRVZXSltjtKGxbN19n7uPu7UsSHyqHBARUyxkj3C4/exec' +}; diff --git a/src/components/cohort/layout.js b/src/components/cohort/layout.js new file mode 100644 index 000000000..85e1ea3a7 --- /dev/null +++ b/src/components/cohort/layout.js @@ -0,0 +1,718 @@ +import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; +import styled, { createGlobalStyle } from 'styled-components'; +import Seo from '../Seo/Seo'; +import background from '../../images/cohort/background.webp'; +import brushBody from '../../images/cohort/bubble-body.webp'; +import heroBubble from '../../images/cohort/hero-bubble.webp'; +import heroBubbleFlat from '../../images/cohort/hero-bubble-flat.webp'; +import heroBubbleOrange from '../../images/cohort/hero-bubble-orange.webp'; +import footerLogo from '../../images/cohort/cdl-logo.webp'; +import { FaLinkedinIn, FaInstagram, FaGithubAlt, FaTwitter } from 'react-icons/fa'; +import bandTexture from '../../images/cohort/band-texture.webp'; + +// Shared layout for the cohort landing pages under /cohorts/. + +export const C = { + blue: '#4A74A0', + block: '#47729C', + coral: '#E8705F', + coralCard: '#E57568', + coralDark: '#D85C4B', + navy: '#0B3865', + muted: '#4B5B6E', + bg: '#EEF0F3', + border: '#D5DCE4' +}; + +// Computed in the browser so the static build never shows a stale count. +export const useDeadlineLabel = (deadline) => { + const [label, setLabel] = useState(''); + useEffect(() => { + if (!deadline) return; + const ms = deadline - Date.now(); + const days = Math.ceil(ms / 86400000); + if (ms <= 0) setLabel('Applications closed'); + else if (days <= 1) setLabel('Last day to apply'); + else setLabel(`${days} days left to apply`); + }, [deadline]); + return label; +}; + +// e.g. "30 November, 11:59 pm IST", for showing a deadline in the page text. +export const deadlineText = (deadline) => + deadline + ? deadline.toLocaleString('en-IN', { + day: 'numeric', + month: 'long', + hour: 'numeric', + minute: '2-digit', + timeZone: 'Asia/Kolkata' + }) + ' IST' + : ''; + +// Reveals journey steps as they scroll into view. Kept in state, not classList, +// because React re-renders would wipe classes added by hand. +export const useReveal = () => { + const [shown, setShown] = useState([]); + useEffect(() => { + const els = document.querySelectorAll('[data-reveal]'); + const show = (el) => setShown((s) => [...s, el.dataset.reveal]); + if (!('IntersectionObserver' in window)) { + els.forEach(show); + return undefined; + } + const io = new IntersectionObserver( + (entries) => + entries.forEach((e) => { + if (e.isIntersecting) { + show(e.target); + io.unobserve(e.target); + } + }), + { threshold: 0.3 } + ); + els.forEach((el) => io.observe(el)); + return () => io.disconnect(); + }, []); + return shown; +}; + +// Page-level styles, SEO tags and the Inter font. +// `background` overrides the page background (CSS `background` value). +export const CohortHead = ({ title, description, background: bg }) => ( + <> + + + + + + + +); + +export const PageStyle = createGlobalStyle` + html { scroll-behavior: smooth; } + body { margin: 0; background: ${(p) => p.bg || `#F4F5F7 url(${background}) top center / 100% auto repeat-y`}; } + @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } +`; + +export const Page = styled.div` + font-family: 'Inter', system-ui, sans-serif; + color: ${C.navy}; + font-size: 17px; + line-height: 1.6; + @media (min-width: 768px) { font-size: 18px; } + * { box-sizing: border-box; } + h1, h2, h3 { line-height: 1.2; margin: 0 0 0.75rem; } + /* one heading size per level across the page, forms included */ + h2 { font-size: 2rem; font-weight: 700; margin-top: 1.75rem; } + h3 { font-size: 1.3rem; font-weight: 600; margin-top: 1.5rem; } + p { margin: 0 0 1rem; color: ${C.muted}; } + a { color: ${C.blue}; } + section { scroll-margin-top: 80px; } +`; + +export const TopBar = styled.header` + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + background: rgba(238, 240, 243, 0.95); + backdrop-filter: blur(6px); + font-size: 0.9rem; + @media (min-width: 768px) { padding: 12px 32px; } +`; + +export const TopBarCredit = styled.span` + @media (max-width: 640px) { display: none; } +`; + +export const Button = styled.a` + display: inline-block; + padding: 10px 22px; + border-radius: 999px; + font-weight: 700; + text-decoration: none; + white-space: nowrap; + font: inherit; + font-weight: 700; + cursor: pointer; + /* ghost: white outline for blue backgrounds; outline: navy outline for light ones */ + background: ${(p) => (p.ghost || p.outline ? 'transparent' : p.navy ? C.navy : C.coral)}; + color: ${(p) => (p.outline ? C.navy : '#fff')} !important; + border: 2px solid ${(p) => (p.ghost ? '#fff' : p.outline || p.navy ? C.navy : C.coral)}; + transition: background 0.15s; + &:hover { + background: ${(p) => (p.ghost ? 'rgba(255,255,255,0.15)' : p.outline ? '#e6ebf1' : p.navy ? C.blue : C.coralDark)}; + } + &:focus-visible { outline: 3px solid ${C.navy}; outline-offset: 2px; } +`; + +export const Hero = styled.div` + max-width: 1100px; + margin: 0 auto; + padding: 0 16px; +`; + +// The designer's speech bubbles, 9-sliced so their edges survive any box size. +// The bottom-left slice holds the whole tail, so the tail is never stretched. +// `slice` (top right bottom left) and `tail` (how far it hangs below the body) +// are in pixels of the image; `s` scales them on screen. +const BUBBLES = { + brush: { src: heroBubble, slice: [170, 170, 370, 430], tail: 250 }, // 1200 × 907 + flat: { src: heroBubbleFlat, slice: [130, 130, 320, 460], tail: 200 }, // 996 × 897 + orange: { src: heroBubbleOrange, slice: [200, 200, 450, 430], tail: 245 } // 1200 × 905 +}; + +const heroSlice = ({ slice, tail }, s) => ` + border-image-width: ${slice.map((v) => `${v * s}px`).join(' ')}; + padding-bottom: ${tail * s + 36}px; +`; + +// `art`: 'brush' (default), 'flat' or 'orange'. The orange bubble is too light for white text, so it uses navy. +export const HeroBubble = styled.div` + border: 0 solid transparent; + border-image-source: url(${(p) => BUBBLES[p.art || 'brush'].src}); + border-image-slice: ${(p) => BUBBLES[p.art || 'brush'].slice.join(' ')} fill; + ${(p) => heroSlice(BUBBLES[p.art || 'brush'], 0.3)} + color: ${(p) => (p.art === 'orange' ? C.navy : '#fff')}; + text-align: center; + padding-top: 32px; + padding-left: 20px; + padding-right: 20px; + @media (min-width: 768px) { ${(p) => heroSlice(BUBBLES[p.art || 'brush'], 0.5)} } + h1 { font-size: clamp(2rem, 6vw, 3.5rem); font-weight: 500; letter-spacing: -0.02em; margin: 0.5rem 0 1rem; } + em { color: ${(p) => (p.art === 'orange' ? C.navy : C.coral)}; font-weight: 800; font-style: italic; } + ${(p) => p.art === 'orange' && `p { color: ${C.navy} !important; }`} +`; + +// A brush-edged box with no tail. +export const brushBox = (corner) => ` + border: 0 solid transparent; + border-image: url(${brushBody}) 130 fill / ${corner} stretch; +`; + +export const Logos = styled.div` + display: inline-flex; + align-items: center; + gap: 20px; + flex-wrap: wrap; + justify-content: center; + background: #fff; + padding: 14px 24px; + border-radius: 14px; + margin-bottom: 16px; + img { height: 48px; width: auto; } + .knowledge { display: flex; flex-direction: column; align-items: center; gap: 2px; padding-left: 20px; border-left: 1px solid ${C.border}; } + .knowledge small { font-size: 0.7rem; letter-spacing: 0.04em; text-transform: uppercase; color: ${C.muted}; } + .knowledge img { height: 60px; } +`; + +export const Eyebrow = styled.p` + color: #fff !important; + font-size: 1.1rem; +`; + +export const Sub = styled.p` + color: #fff !important; + font-weight: 600; + font-size: 1.15rem; + max-width: 560px; + margin: 0 auto 1.5rem !important; +`; + +export const HeroActions = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; +`; + +export const People = styled.img` + display: block; + width: 100%; + max-width: 640px; + height: auto; + margin: 0 auto 24px; +`; + +// Hero illustration tucked under the bubble's tail, with two small chat bubbles beside it. +export const PeopleArt = ({ src, left, right }) => ( + + + + + +); + +const PeopleWrap = styled.div` + position: relative; + max-width: 720px; + margin: -24px auto 24px; + .people { + display: block; + width: 100%; + height: auto; + /* the line art is a pale blue; deepen it so it holds up next to the bubble */ + filter: brightness(0.62) saturate(1.4) contrast(1.15); + } + .chat { position: absolute; width: 11%; max-width: 84px; top: 2%; } + .left { left: -2%; top: 14%; } + .right { right: 2%; top: -6%; } +`; + +// Full-width dark textured band of key numbers. +export const StatsBand = styled.ul` + list-style: none; + margin: 0 0 32px; + padding: 40px max(16px, calc((100% - 1100px) / 2 + 16px)); + background: ${C.navy} url(${bandTexture}) center / cover; + color: #fff; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 32px 20px; + text-align: center; + @media (min-width: 900px) { + grid-template-columns: repeat(${(p) => p.cols || 3}, 1fr); + padding-top: 56px; + padding-bottom: 56px; + } + strong { display: block; font-size: clamp(1.6rem, 4vw, 2.4rem); font-weight: 800; line-height: 1.1; } + span { display: block; margin-top: 6px; font-size: 0.9rem; color: #d5e0ec; } +`; + +// Large pull-quote on a full-width blue block. +export const QuoteBand = styled.blockquote` + margin: 20px 0; + padding: 40px max(16px, calc((100% - 860px) / 2)); + background: ${C.block}; + color: #fff; + font-size: clamp(1.5rem, 3.6vw, 2.25rem); + font-weight: 700; + line-height: 1.3; + letter-spacing: -0.01em; +`; + +// Closing call to action: a brush-edged blue block with an illustration on its right end. +export const CtaBand = styled.div` + position: relative; + margin-top: 48px; + padding: 0 16px; + @media (min-width: 900px) { padding-bottom: 64px; } + > div { + ${brushBox('48px')} + max-width: 1100px; + margin: 0 auto; + color: #fff; + text-align: center; + padding: 48px 24px; + @media (min-width: 900px) { padding: 64px 96px; } + } + h2 { margin-top: 0; } + p { color: #fff; } + a:not([href^='#']) { color: #fff; } + /* the art is cut flat on its right and bottom edges, so it sits on the page edge and the footer */ + > img { + display: block; + width: 60%; + max-width: 280px; + height: auto; + margin: -16px -16px 0 auto; + @media (min-width: 900px) { position: absolute; right: 0; bottom: 0; width: 320px; max-width: none; margin: 0; } + } +`; + +// Data-protection consent line shown above each form. +export const Consent = styled.p` + max-width: 728px; + margin: 0 auto 16px !important; + font-size: 0.9rem; +`; + +export const JourneyHead = styled.div` + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + h2 { margin-bottom: 0; } + img { width: 140px; height: auto; flex: none; display: block; } +`; + +export const Section = styled.section` + max-width: ${(p) => (p.narrow ? '760px' : '1100px')}; + margin: 0 auto; + padding: 20px 16px; +`; + +export const Lead = styled.p` + font-size: 1.4rem; + font-weight: 600; + color: ${C.navy} !important; +`; + +export const Ticks = styled.ul` + list-style: none; + padding: 0; + margin: 0 0 1.5rem; + li { position: relative; padding-left: 28px; margin-bottom: 10px; } + li::before { + content: ''; + position: absolute; + left: 4px; + top: 0.55em; + width: 10px; + height: 10px; + border-radius: 50%; + background: ${C.coral}; + } +`; + +export const Chips = styled.ul` + list-style: none; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 10px; + li { + background: ${C.coralCard}; + color: #fff; + border-radius: 999px; + padding: 8px 16px; + font-weight: 600; + } +`; + +// Small cards in a grid that lift when hovered. +export const CardGrid = styled.ul` + list-style: none; + padding: 0; + margin: 16px 0 24px; + display: grid; + gap: 12px; + li { + background: #fff; + border-radius: 14px; + padding: 16px 20px; + line-height: 1.45; + box-shadow: 0 2px 10px rgba(11, 56, 101, 0.06); + border-left: 5px solid ${C.coral}; + transition: transform 0.2s, box-shadow 0.2s; + } + li:hover { transform: translateX(8px) scale(1.01); box-shadow: 0 12px 28px rgba(11, 56, 101, 0.16); } + @media (prefers-reduced-motion: reduce) { li { transition: none; } li:hover { transform: none; } } + /* check: full-width outcome rows with a tick badge; they lift instead of sliding past the page edge */ + ${(p) => + p.check && + `li { + display: flex; + align-items: flex-start; + gap: 14px; + border-left: 0; + text-wrap: pretty; + } + li::before { + content: '\\2713'; + flex: none; + display: grid; + place-items: center; + width: 26px; + height: 26px; + margin-top: -1px; + border-radius: 50%; + background: ${C.coral}; + color: #fff; + font-size: 0.85rem; + font-weight: 800; + } + li:hover { transform: translateY(-3px); }`} +`; + +export const Note = styled.p` + font-size: 0.9rem; + border-left: 3px solid ${C.blue}; + padding-left: 12px; +`; + +export const Journey = styled.ol` + list-style: none; + padding: 0; + margin: 1.5rem 0 2rem; + position: relative; + &::before { + content: ''; + position: absolute; + left: 21px; + top: 10px; + bottom: 10px; + border-left: 4px dashed #5bc0be; + } + /* across: a horizontal path on desktop, ending at the pilot-ready flag */ + ${(p) => + p.across && + `@media (min-width: 900px) { + display: grid; + grid-template-columns: repeat(${p.across}, 1fr); + gap: 24px; + padding-right: 120px; + &::before { left: 0; right: 40px; top: 21px; bottom: auto; border-left: 0; border-top: 4px dashed #5BC0BE; } + gap: 20px; + > li { flex-direction: column; gap: 12px; margin-bottom: 0; } + h3 { font-size: 1.1rem; min-height: 2.6em; } + p { font-size: 0.9rem; line-height: 1.6; } + }`} +`; + +export const Step = styled.li` + position: relative; + display: flex; + gap: 20px; + margin-bottom: 28px; + opacity: 0; + transform: translateY(24px); + transition: opacity 0.5s, transform 0.5s; + &.in { opacity: 1; transform: none; } + h3 { margin-top: 6px; } + @media (prefers-reduced-motion: reduce) { opacity: 1; transform: none; transition: none; } +`; + +// Start and end dates above the horizontal path; one line on mobile. +export const JourneyAxis = styled.p` + display: flex; + gap: 8px; + font-weight: 700; + color: ${C.navy} !important; + margin: 1rem 0 0 !important; +`; + +// Holds the path and its end flag; leaves room above for the flag on desktop. +export const JourneyTrack = styled.div` + position: relative; + @media (min-width: 900px) { margin-top: 24px; } +`; + +// The flags illustration at the end of the desktop path. +export const JourneyEnd = styled.div` + display: none; + @media (min-width: 900px) { + display: block; + position: absolute; + right: 0; + top: -96px; + width: 120px; + text-align: center; + font-size: 0.85rem; + font-weight: 700; + img { width: 100%; height: auto; display: block; } + } +`; + +// Hides the small flags next to the heading where the desktop path shows its own. +export const MobileOnly = styled.span` + @media (min-width: 900px) { display: none; } +`; + +export const Flag = styled.span` + flex: none; + display: grid; + place-items: center; + width: 46px; + height: 46px; + border-radius: 50%; + background: ${C.coral}; + color: #fff; + font-weight: 800; + font-size: 1.1rem; + position: relative; +`; + +export const Band = styled.div` + background: ${C.blue}; + color: #fff; + margin: 16px 0; + h2 { margin-top: 0; } + > div > p { color: #e6edf5; } +`; + +export const BandInner = styled.div` + max-width: 1100px; + margin: 0 auto; + padding: 40px 16px; +`; + +export const Cards = styled.div` + display: grid; + gap: 24px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + margin-top: 24px; +`; + +export const Card = styled.div` + display: flex; + flex-direction: column; + background: #fff; + color: ${C.navy}; + border-radius: 24px; + padding: 28px; + h3 { margin-top: 0; } + ${Button} { margin-top: auto; align-self: center; } +`; + +export const Price = styled.p` + font-size: 2.5rem; + font-weight: 800; + color: ${C.navy} !important; + line-height: 1.1; + span { display: block; font-size: 0.95rem; font-weight: 500; color: ${C.muted}; } +`; + +const FooterBar = styled.footer` + background: ${C.navy}; + color: #fff; + padding: 40px 16px 28px; + > div { max-width: 1068px; margin: 0 auto; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 24px; } + a { color: #fff; } + .brands { display: flex; align-items: center; gap: 28px; flex-wrap: wrap; } + .brands { background: #fff; border-radius: 14px; padding: 14px 24px; gap: 24px; } + .brands img { display: block; height: 52px; width: auto; } + .brands > a:first-child img { height: 72px; } + .brands .partner img { height: 38px; } + .brands a + a, .brands .knowledge { padding-left: 24px; border-left: 1px solid ${C.border}; } + .brands .knowledge { display: flex; flex-direction: column; align-items: center; gap: 2px; } + .brands .knowledge small { font-size: 0.7rem; letter-spacing: 0.04em; text-transform: uppercase; color: ${C.muted}; } + .brands .knowledge img { height: 60px; } + .social { display: flex; gap: 12px; } + .social a { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid rgba(255, 255, 255, 0.55); font-size: 18px; transition: background 0.15s, border-color 0.15s; } + .social a:hover { background: ${C.coral}; border-color: ${C.coral}; } + .social a:focus-visible, .brands a:focus-visible { outline: 3px solid #fff; outline-offset: 3px; } + .legal { flex-basis: 100%; margin: 0; padding-top: 20px; border-top: 1px solid rgba(255, 255, 255, 0.2); font-size: 0.85em; color: rgba(255, 255, 255, 0.8); text-align: center; } + @media (max-width: 600px) { + > div { flex-direction: column; align-items: flex-start; } + .legal { align-self: stretch; text-align: left; } + .brands { padding: 12px 16px; gap: 16px; flex-wrap: nowrap; } + .brands img { height: 40px; } + .brands > a:first-child img { height: 54px; } + .brands .partner img { height: 28px; } + .brands a + a, .brands .knowledge { padding-left: 16px; } + .brands .knowledge img { height: 48px; } + } +`; + +const SOCIAL = [ + ['LinkedIn', 'https://in.linkedin.com/company/civicdatalab', FaLinkedinIn], + ['Instagram', 'https://www.instagram.com/civicdatalab/', FaInstagram], + ['GitHub', 'https://github.com/CivicDataLab', FaGithubAlt], + ['X (Twitter)', 'https://twitter.com/civicdatalab', FaTwitter] +]; + +// partner = { name, logo, url } for a cohort run with another organisation (omit for none). +export const Footer = ({ partner, knowledge }) => ( + +
+
+ + CivicDataLab + + {partner && ( + + {partner.name} + + )} + {knowledge && ( + + Knowledge partner + + {knowledge.name} + + + )} +
+
+ {SOCIAL.map(([name, href, Icon]) => ( + + + ))} +
+

© CivicDataLab · info@civicdatalab.in

+
+
+); + +// Sets --p (0 to 1) on the element as it scrolls through the viewport; drives the filling path. +export const useScrollProgress = (ref) => { + useEffect(() => { + const el = ref.current; + if (!el) return undefined; + const still = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const update = () => { + const r = el.getBoundingClientRect(); + const p = still ? 1 : Math.min(1, Math.max(0, (window.innerHeight * 0.6 - r.top) / r.height)); + el.style.setProperty('--p', p.toFixed(3)); + }; + update(); + window.addEventListener('scroll', update, { passive: true }); + window.addEventListener('resize', update); + return () => { + window.removeEventListener('scroll', update); + window.removeEventListener('resize', update); + }; + }, [ref]); +}; + +// The step list with a winding path behind the numbers that fills in coral as you scroll. +export const Trail = styled.ol` + --p: 0; + list-style: none; + padding: 0; + margin: 8px 0 0; + position: relative; + .trail { + position: absolute; + left: 0; + top: 0; + width: 46px; + height: 100%; + overflow: visible; + pointer-events: none; + } + .trail path { fill: none; stroke-width: 4; stroke-linecap: round; vector-effect: non-scaling-stroke; } + .base path { stroke: #5bc0be; stroke-dasharray: 2 9; } + .fill { clip-path: inset(0 0 calc((1 - var(--p)) * 100%) 0); } + .fill path { stroke: ${C.coral}; } +`; + +export const TrailStep = styled(Step)` + gap: 16px; + margin-bottom: 14px; + scroll-margin-top: 80px; + ${Flag} { background: #c3cfdc; transition: background 0.4s; z-index: 1; } + &.in ${Flag} { background: ${C.coral}; } + > div { + flex: 1; + background: #fff; + border-radius: 16px; + padding: 14px 20px; + box-shadow: 0 2px 10px rgba(11, 56, 101, 0.06); + } + h3 { margin: 0 0 4px; font-size: 1.15rem; } + p { margin: 0; font-size: 0.98rem; line-height: 1.55; } +`; + +// Closing marker under the last step. +export const PilotEnd = styled.div` + display: flex; + align-items: center; + gap: 12px; + margin: 4px 0 0; + font-weight: 700; + color: ${C.navy}; + img { width: 56px; height: auto; display: block; } +`; diff --git a/src/components/cohort/questions.js b/src/components/cohort/questions.js new file mode 100644 index 000000000..533322406 --- /dev/null +++ b/src/components/cohort/questions.js @@ -0,0 +1,372 @@ +export const OTHER = 'Other: please specify'; + +// Field kinds: `options` = single choice, `checks` = required checkboxes +// ([name, text, short column label]), `words` = paragraph with a suggested +// word limit, otherwise a one-line input. The Sheet's column headers are the +// numbered questions ("Q1. Your full name"), in this order. +export const TECH4DEV_SECTIONS = [ + { + title: 'Your details & organisation', + fields: [ + { name: 'full_name', label: 'Your full name', autoComplete: 'name' }, + { name: 'role', label: 'Your role in the organisation', autoComplete: 'organization-title' }, + { name: 'email', label: 'Email address', type: 'email', autoComplete: 'email' }, + { name: 'phone', label: 'Phone number with country code', type: 'tel', autoComplete: 'tel' }, + { name: 'organisation', label: 'Organisation name', autoComplete: 'organization' }, + { name: 'registration_type', label: 'Registration type', options: ['Trust', 'Society', 'Section 8 company', OTHER] }, + { name: 'geography', label: 'Which states or districts do you currently work in?' }, + { + name: 'annual_budget', + label: 'What was your annual operating budget in the latest audited financial year?', + options: [ + 'Below ₹50 lakh', + '₹50 lakh to below ₹1 crore', + '₹1 crore to below ₹5 crore', + '₹5 crore or above', + 'Audited figures are not yet available' + ] + }, + { + name: 'domain', + label: 'Which domain does your proposed AI use case address?', + options: ['Health', 'Agriculture', 'Public services', 'More than one', OTHER] + }, + { + name: 'programmes', + label: 'Briefly describe your programmes and whom they serve.', + hint: 'Include your approximate annual reach among beneficiaries or frontline teams.', + words: 50 + } + ] + }, + { + title: 'Your AI / tech solution & language need', + intro: + 'The cohort supports existing AI solutions and active development or pilots. It does not fund new solutions from the idea stage.', + fields: [ + { + name: 'solution_stage', + label: 'What stage is your AI solution at?', + options: [ + 'Deployed and regularly used by intended users', + 'Being piloted with intended users', + 'Working prototype, not yet tested with intended users', + 'Under active development, with no working prototype yet', + 'Idea or planning stage only', + 'We do not currently have an AI solution' + ] + }, + { + name: 'solution_description', + label: 'Briefly describe your AI solution.', + hint: 'What does it do, who uses it, and which platform and languages does it support? Add a demo link if available.', + words: 75 + }, + { name: 'languages', label: 'Which languages or dialects do your users primarily speak?' }, + { + name: 'language_gaps', + label: 'What language or interface gaps have you identified?', + hint: 'Consider local languages, dialects, voice access and ease of use.', + options: [ + 'Clear gaps, supported by user feedback or usage data', + 'Gaps observed by our team, but not yet tested with users', + 'Unsure—we need help assessing this', + 'No significant gaps identified' + ] + }, + { + name: 'main_improvement', + label: 'What is the main improvement you want to make through this cohort?', + hint: 'Consider language or voice access, accuracy, trust, adoption, workflow fit or feedback. Share one example of the challenge.', + words: 50 + } + ] + }, + { + title: 'Field access, team & sustainability', + fields: [ + { + name: 'testing', + label: 'Can you organise testing with beneficiaries or frontline teams during the cohort?', + hint: 'The programme aims to build readiness for a pilot with 50–100 users.', + options: [ + 'Yes, through our existing field or community network', + 'Yes, through a partner organisation', + 'Likely, but arrangements need confirmation', + 'We would need support to organise this', + 'No, not currently' + ] + }, + { + name: 'tech_lead', + label: 'Can you assign a Tech Lead or Product Owner with approximately 50% availability over six months?', + options: [ + 'Yes, a lead and backup are identified, and availability is confirmed', + 'A lead is identified; backup or availability needs confirmation', + 'We can assign a lead before the cohort begins', + 'We cannot commit this staff time' + ] + }, + { + name: 'leadership_approval', + label: + 'Has leadership approved participation, including staff time, the ₹1 lakh programme fee and travel to Delhi for the kick-off?', + options: [ + 'Yes, approval is confirmed', + 'Leadership is supportive; formal approval is pending', + 'Not yet discussed internally', + 'We cannot commit at this stage' + ] + }, + { + name: 'maintenance', + label: 'What arrangements exist for maintaining the solution after the cohort?', + options: [ + 'An owner and maintenance budget or plan are in place', + 'An owner is identified; the maintenance plan needs work', + 'We have an initial plan but no confirmed owner', + 'We need support to develop these arrangements' + ] + } + ] + }, + { + title: 'Consent', + fields: [ + { + name: 'declaration', + label: 'Declaration and acknowledgment', + checks: [ + [ + 'declaration_accurate', + 'I confirm that the information provided is accurate to the best of my knowledge and that I am authorised to submit it on behalf of my organisation.', + 'Information is accurate and I am authorised' + ], + [ + 'declaration_no_guarantee', + 'I understand that submitting this form does not guarantee selection, funding or any other commitment from the programme partners.', + 'Submission does not guarantee selection' + ] + ] + }, + { + name: 'consent', + label: 'Consent to review your application', + checks: [ + [ + 'consent_review', + 'I consent to CivicDataLab, Gram Vaani and Tech4Dev accessing, reviewing and using the information submitted in this form to assess our application and contact us about the cohort.', + 'I consent' + ] + ] + } + ] + } +]; + +// UNDP baseline assessment questionnaire (Inception Report, Annex A). +// Extra kinds used here: `multi` = tick boxes (`max` caps how many; `exclusive` +// options must be chosen on their own), `optional`, and `mustAgree` = the error +// shown unless the first option is chosen. +export const UNDP_SECTIONS = [ + { + title: 'About your organisation', + fields: [ + { name: 'full_name', label: 'Your full name', autoComplete: 'name' }, + { name: 'role', label: 'Your role in the organisation', autoComplete: 'organization-title' }, + { name: 'email', label: 'Email address', type: 'email', autoComplete: 'email' }, + { name: 'phone', label: 'Phone number with country code', type: 'tel', autoComplete: 'tel' }, + { name: 'organisation', label: 'Organisation name', autoComplete: 'organization' }, + { + name: 'registration_type', + label: 'Registration type', + options: ['Trust', 'Society', 'Section 8 company', 'Unregistered collective or community group', OTHER] + }, + { name: 'geography', label: 'Which states or districts does your organisation work in?' }, + { + name: 'areas_of_work', + label: 'What are your organisation’s main areas of work?', + multi: true, + max: 2, + options: [ + 'Education', + 'Skill development', + 'Women’s economic empowerment', + 'Livelihoods', + 'Health', + 'Agriculture', + 'Disability inclusion', + 'Governance and rights', + 'Climate and environment', + OTHER + ] + }, + { + name: 'community_led', + label: 'Is your organisation led by members of the communities it primarily serves?', + optional: true, + options: ['Yes', 'No', 'Unsure', 'Prefer not to say'] + } + ] + }, + { + title: 'Current digital and AI use', + fields: [ + { + name: 'ai_uses', + label: 'What does your organisation use AI tools for?', + multi: true, + hint: 'Select either of the last two options on its own.', + exclusive: ['We do not currently use AI tools', 'Not sure whether the tools we use include AI'], + options: [ + 'Drafting proposals, reports or other documents', + 'Researching topics and summarising information', + 'Analysing data or creating charts', + 'Creating social media posts, presentations or outreach materials', + 'Translating content or working in local languages', + 'Managing routine tasks and team workflows', + 'Supporting communities through chatbots, advisory tools or other services', + OTHER, + 'We do not currently use AI tools', + 'Not sure whether the tools we use include AI' + ] + }, + { + name: 'barriers', + label: 'What are the main barriers to using AI effectively?', + multi: true, + max: 3, + hint: 'Select “No major barriers” or “Unsure” on its own.', + exclusive: ['No major barriers', 'Unsure'], + options: [ + 'Limited skills or training', + 'Limited staff time', + 'Tool or subscription costs', + 'Unreliable internet or limited device access', + 'Limited local-language support', + 'Difficulty checking accuracy', + 'Privacy or consent concerns', + 'Bias or safety concerns', + 'Unsure where AI could help our work', + 'No major barriers', + OTHER, + 'Unsure' + ] + }, + { + name: 'checking_confidence', + label: 'How confident are staff in checking AI output before using it?', + options: ['Not at all', 'Slightly', 'Moderately', 'Very', 'Have not tried'] + }, + { + name: 'useful_skills', + label: 'Which skills would be most useful to your organisation?', + multi: true, + options: [ + 'Writing proposals, reports and other documents', + 'Analysing data and creating charts', + 'Communication and storytelling', + 'Managing workflows and collaborating', + 'Using AI responsibly, including checking outputs and protecting data', + OTHER + ] + }, + { + name: 'learning_methods', + label: 'Which learning methods would help you most?', + multi: true, + max: 2, + options: [ + 'Live demonstrations', + 'Guided hands-on practice', + 'Short recorded lessons', + 'Step-by-step written guides', + 'Troubleshooting clinics', + 'Learning with other CSOs' + ] + } + ] + }, + { + title: 'Access and participation', + fields: [ + { + name: 'device_access', + label: 'What device and internet access will the nominated learner have?', + options: [ + 'Regular access to a computer and reliable internet', + 'Regular access to a smartphone and reliable internet, but no computer', + 'Access to a shared device and internet at agreed times', + 'Limited or unreliable device or internet access', + 'Not yet confirmed' + ] + }, + { + name: 'language', + label: 'Which language would be most comfortable for learning?', + options: ['English', 'Hindi', 'Another language: please specify'] + }, + { + name: 'support', + label: 'What support would help the learner participate?', + multi: true, + hint: 'Select “No additional support needed” on its own.', + exclusive: ['No additional support needed'], + options: [ + 'Captions or transcripts', + 'Screen reader accessible materials', + 'Downloadable materials for low-bandwidth use', + 'Flexible session timings', + 'Translation or language support', + 'No additional support needed', + OTHER + ] + }, + { + name: 'same_person', + label: 'Can the same person attend throughout the programme and practise between sessions?', + options: ['Yes', 'Yes, with scheduling support', 'Not currently', 'Not yet confirmed'] + }, + { + name: 'delhi_workshop', + label: + 'If invited to the two-day Delhi/NCR workshop, could the learner attend with travel paid by their organisation or themselves?', + hint: 'Your response will help us plan participation. The ability to pay for travel is not a measure of merit.', + options: [ + 'Yes', + 'Possibly, depending on dates and travel costs', + 'No, travel costs would prevent attendance', + 'Not yet confirmed', + 'We would prefer virtual participation' + ] + } + ] + }, + { + title: 'Consent', + fields: [ + { + name: 'consent', + label: 'Do you consent to the use of your responses for this assessment?', + mustAgree: 'We can only use your responses with your consent. Please choose “Yes” to submit.', + options: [ + 'Yes, I consent to CivicDataLab and UNDP India accessing, reviewing and using the information submitted in this form to assess training needs, plan the programme and contact us about participation.', + 'No, I do not consent.' + ] + }, + { + name: 'case_study', + label: 'Would your organisation consider sharing a use case, blog or learning story from the programme?', + hint: 'Case studies may appear in programme reports, websites and communication materials. Consent is voluntary and does not affect selection. Personal details, photographs and confidential information are not covered by this consent.', + options: ['Yes, we would be interested', 'Maybe, after discussing the details', 'No, we would prefer not to'] + }, + { + name: 'anything_else', + label: 'Is there anything else we should know about your learning or participation needs?', + optional: true, + hint: 'Mention any need or barrier not covered above. Please do not include confidential information or personal details of beneficiaries.', + paragraph: true + } + ] + } +]; diff --git a/src/components/cohort/ui.js b/src/components/cohort/ui.js new file mode 100644 index 000000000..5df86ddd7 --- /dev/null +++ b/src/components/cohort/ui.js @@ -0,0 +1,168 @@ +import styled from 'styled-components'; + +// Shared look for the cohort forms (eligibility check and application). +export const NAVY = '#0B3865'; +export const MUTED = '#4B5B6E'; +export const BLUE = '#4A74A0'; +export const CORAL = '#E8705F'; +export const CORAL_DARK = '#D85C4B'; +export const BORDER = '#D5DCE4'; +export const LIGHT_BLUE = '#EAF0F7'; +export const ERROR = '#B3261E'; + +export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const focusRing = ` + outline: 3px solid ${BLUE}; + outline-offset: 2px; +`; + +export const Card = styled.div` + max-width: 760px; + margin: 0 auto; + padding: 32px; + background: #fff; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(11, 56, 101, 0.08); + color: ${NAVY}; + font-family: inherit; + @media (max-width: 600px) { + padding: 20px; + } +`; + +export const Heading = styled.h2` + /* !important: the page's h2 spacing would otherwise push the card's title down */ + margin: 0 0 8px !important; + font-size: 2rem; + line-height: 1.25; + color: ${NAVY}; +`; + +export const Muted = styled.p` + margin: 0 0 24px; + color: ${MUTED}; + font-size: ${(p) => (p.small ? '0.875em' : '1em')}; +`; + +export const Fieldset = styled.fieldset` + border: 0; + padding: 0; + margin: 0 0 28px; + min-width: 0; + /* a required question left blank is marked by a red bar rather than a message under it */ + ${(p) => p.invalid && `border-left: 3px solid ${ERROR}; padding-left: 12px;`} +`; + +export const Legend = styled.legend` + padding: 0; + margin-bottom: 12px; + font-weight: 700; + font-size: 1.05em; + line-height: 1.4; +`; + +export const Option = styled.label` + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 8px; + padding: 12px 14px; + border: 1px solid ${(p) => (p.selected ? BLUE : BORDER)}; + border-radius: 10px; + background: ${(p) => (p.selected ? LIGHT_BLUE : '#fff')}; + cursor: pointer; + line-height: 1.4; + &:focus-within { + ${focusRing} + } + input { + margin: 3px 0 0; + flex-shrink: 0; + accent-color: ${BLUE}; + } +`; + +export const TextInput = styled.input` + display: block; + width: 100%; + box-sizing: border-box; + padding: 12px 14px; + border: 1px solid ${(p) => (p.invalid ? ERROR : BORDER)}; + border-radius: 10px; + font: inherit; + color: ${NAVY}; + &:focus { + ${focusRing} + } +`; + +export const FieldLabel = styled.label` + display: block; + margin: 8px 0 6px; + font-weight: 700; +`; + +export const ErrorText = styled.p` + margin: 6px 0 0; + /* !important: the page's paragraph colour would otherwise turn errors grey */ + color: ${ERROR} !important; + font-size: 0.9em; +`; + +export const Button = styled.button` + display: inline-block; + padding: 12px 28px; + border: 0; + border-radius: 999px; + background: ${CORAL}; + color: #fff; + font: inherit; + font-weight: 700; + text-decoration: none; + cursor: pointer; + &:hover { + background: ${CORAL_DARK}; + } + &:focus-visible { + ${focusRing} + } + &:disabled { + opacity: 0.7; + cursor: wait; + } +`; + +export const TextButton = styled.button` + padding: 0; + border: 0; + background: none; + color: ${BLUE}; + font: inherit; + font-size: 0.875em; + text-decoration: underline; + cursor: pointer; + &:focus-visible { + ${focusRing} + } +`; + +export const Honeypot = styled.input` + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + opacity: 0; +`; + +export const Result = styled.div` + padding: 20px; + border-radius: 10px; + background: ${LIGHT_BLUE}; + &:focus { + ${focusRing} + } + p { + margin: 0 0 16px; + } +`; diff --git a/src/images/cohort/background.webp b/src/images/cohort/background.webp new file mode 100644 index 000000000..8d0fcf492 Binary files /dev/null and b/src/images/cohort/background.webp differ diff --git a/src/images/cohort/band-texture.webp b/src/images/cohort/band-texture.webp new file mode 100644 index 000000000..7a865029e Binary files /dev/null and b/src/images/cohort/band-texture.webp differ diff --git a/src/images/cohort/bubble-body.webp b/src/images/cohort/bubble-body.webp new file mode 100644 index 000000000..e3b067e80 Binary files /dev/null and b/src/images/cohort/bubble-body.webp differ diff --git a/src/images/cohort/cdl-logo.webp b/src/images/cohort/cdl-logo.webp new file mode 100644 index 000000000..16e26c1c4 Binary files /dev/null and b/src/images/cohort/cdl-logo.webp differ diff --git a/src/images/cohort/chat-left.webp b/src/images/cohort/chat-left.webp new file mode 100644 index 000000000..47cb0eb53 Binary files /dev/null and b/src/images/cohort/chat-left.webp differ diff --git a/src/images/cohort/chat-right.webp b/src/images/cohort/chat-right.webp new file mode 100644 index 000000000..1c16d47cb Binary files /dev/null and b/src/images/cohort/chat-right.webp differ diff --git a/src/images/cohort/coral-card.webp b/src/images/cohort/coral-card.webp new file mode 100644 index 000000000..cc9d17c14 Binary files /dev/null and b/src/images/cohort/coral-card.webp differ diff --git a/src/images/cohort/flags.webp b/src/images/cohort/flags.webp new file mode 100644 index 000000000..c94653d71 Binary files /dev/null and b/src/images/cohort/flags.webp differ diff --git a/src/images/cohort/gram-vaani-logo.png b/src/images/cohort/gram-vaani-logo.png new file mode 100644 index 000000000..3d2a722bc Binary files /dev/null and b/src/images/cohort/gram-vaani-logo.png differ diff --git a/src/images/cohort/hero-bubble-flat.webp b/src/images/cohort/hero-bubble-flat.webp new file mode 100644 index 000000000..ebd8c6cea Binary files /dev/null and b/src/images/cohort/hero-bubble-flat.webp differ diff --git a/src/images/cohort/hero-bubble-orange.webp b/src/images/cohort/hero-bubble-orange.webp new file mode 100644 index 000000000..e4c1e1ba3 Binary files /dev/null and b/src/images/cohort/hero-bubble-orange.webp differ diff --git a/src/images/cohort/hero-bubble.webp b/src/images/cohort/hero-bubble.webp new file mode 100644 index 000000000..6033e1c77 Binary files /dev/null and b/src/images/cohort/hero-bubble.webp differ diff --git a/src/images/cohort/hills.webp b/src/images/cohort/hills.webp new file mode 100644 index 000000000..35c9241e2 Binary files /dev/null and b/src/images/cohort/hills.webp differ diff --git a/src/images/cohort/people.webp b/src/images/cohort/people.webp new file mode 100644 index 000000000..1b1a5dedd Binary files /dev/null and b/src/images/cohort/people.webp differ diff --git a/src/images/cohort/tech4dev-logo.webp b/src/images/cohort/tech4dev-logo.webp new file mode 100644 index 000000000..b5521d168 Binary files /dev/null and b/src/images/cohort/tech4dev-logo.webp differ diff --git a/src/images/cohort/undp-hero.webp b/src/images/cohort/undp-hero.webp new file mode 100644 index 000000000..fb16977dc Binary files /dev/null and b/src/images/cohort/undp-hero.webp differ diff --git a/src/images/cohort/undp-logo.webp b/src/images/cohort/undp-logo.webp new file mode 100644 index 000000000..807d9f239 Binary files /dev/null and b/src/images/cohort/undp-logo.webp differ diff --git a/src/pages/cohorts/cdl_tech4dev.js b/src/pages/cohorts/cdl_tech4dev.js new file mode 100644 index 000000000..c2b2a0da6 --- /dev/null +++ b/src/pages/cohorts/cdl_tech4dev.js @@ -0,0 +1,422 @@ +import React, { useRef } from 'react'; +import FitCheck from '../../components/cohort/FitCheck'; +import ApplyForm from '../../components/cohort/ApplyForm'; +import { TECH4DEV } from '../../components/cohort/config'; +import { TECH4DEV_SECTIONS } from '../../components/cohort/questions'; +import { + deadlineText, + useDeadlineLabel, + useReveal, + CohortHead, + Page, + TopBar, + TopBarCredit, + Button, + Hero, + HeroBubble, + Logos, + Eyebrow, + Sub, + HeroActions, + PeopleArt, + JourneyHead, + Section, + StatsBand, + QuoteBand, + Ticks, + Chips, + CardGrid, + Note, + Trail, + TrailStep, + PilotEnd, + useScrollProgress, + Flag, + Band, + BandInner, + Cards, + Card, + Price, + CtaBand, + Consent, + Footer +} from '../../components/cohort/layout'; +import cdlLogo from '../../images/cohort/cdl-logo.webp'; +import gramVaaniLogo from '../../images/cohort/gram-vaani-logo.png'; +import tech4devLogo from '../../images/cohort/tech4dev-logo.webp'; +import peopleArt from '../../images/cohort/people.webp'; +import flagsArt from '../../images/cohort/flags.webp'; +import hillsArt from '../../images/cohort/hills.webp'; +import chatLeft from '../../images/cohort/chat-left.webp'; +import chatRight from '../../images/cohort/chat-right.webp'; + +const stats = [ + ['6–8', 'NGOs in the cohort'], + ['5 months', 'core programme, then light-touch support'], + ['3 focus sectors', 'health, agriculture, public services'], + ['Hybrid', 'two-day in-person kick-off, virtual mentorship and clinics, in-person closing showcase'], + ['₹1 lakh', 'subsidised investment, inclusive of taxes'], + ['25 Nov', 'applications close, 11:59 PM IST'] +]; + +const helps = [ + 'Adapt or integrate your solution to local languages and community needs', + 'Strengthen human oversight and feedback loops', + 'Test its effectiveness with intended users, building a practical pathway to sustained adoption and scale' +]; + +const focusAreas = [ + 'Localisation', + 'Community Testing & Evaluation', + 'Human Oversight & Ground Truthing', + 'Feedback & Continuous Improvement', + 'Adoption & Scale' +]; + +const journey = [ + [ + 'Assess and define', + 'Begin by assessing your existing solution, data, language resources, technical infrastructure and organisational readiness. During the two-day kick-off, identify priority users and workflows, pinpoint a focused localisation opportunity, and establish clear metrics to measure success.' + ], + [ + 'Design, build and integrate', + 'Develop an implementation roadmap and shortlist 2–3 language models suited to your use case, and use human evaluation to select the best fit based on cost, performance and deployment needs. During months 2–4, advance your localisation journey through tailored language modules and AI evaluation support, complemented by expert mentorship, technical clinics, and peer learning.' + ], + [ + 'Test, learn, and improve', + 'Learn to test your solution with beneficiaries and frontline teams, using voice-based interactions where relevant to enable community participation in feedback and evaluation. Assess language and dialect fit, accuracy, usability, safety and workflow fit, then use community feedback, human review and documented edge cases to improve the solution.' + ], + [ + 'Showcase and plan adoption', + 'Demonstrate the adapted solution and document what you have learned. Develop a plan for piloting with 50–100 end users, alongside a five-month roadmap for ownership, maintenance, monitoring, costs and expansion.' + ], + [ + 'Continue implementation', + 'Three light-touch group check-ins will help participants sustain implementation progress, address emerging challenges, and explore opportunities to scale their solutions.' + ] +]; + +const leaveWith = [ + 'A baseline readiness assessment and a focused localisation roadmap', + 'A local-language prototype integrated into your existing solution', + 'A testing and benchmarking plan, with evidence on language performance, accuracy, usability, safety and workflow fit', + 'A clear pathway to pilot with 50–100 end users, informed by initial user testing', + 'An internal owner and a five-month technical, maintenance, cost and sustainability roadmap', + 'Reusable documentation, implementation patterns and appropriate open-source resources' +]; + +const invest = [ + 'Your travel (flights or trains) to Delhi for the in-person workshop', + 'A Tech Lead or Product Owner to lead implementation' +]; + +const receive = [ + 'Technical mentorship and implementation support', + 'Technical clinics, tailored language modules and AI evaluation support', + 'Peer learning across the cohort', + 'Accommodation and food at the Delhi workshop', + 'An in-person closing showcase', + 'Three light-touch check-ins after the programme' +]; + +const criteria = [ + 'An AI application in production or active development, or a credible AI pilot, with a clear opportunity to improve localisation', + 'An active programme and access to beneficiaries or frontline teams for user research, testing and evaluation', + 'A meaningful localisation need involving language, dialect, contextual content, voice interaction or human escalation', + 'A Tech Lead or Product Owner who can lead implementation, supported by a backup counterpart and organisational leadership', + 'The capacity to allocate staff time, maintain the adapted solution and act on user feedback', + 'Willingness to share appropriate code, documentation, benchmarks and learning as open-source resources' +]; + +const consent = ( + + By submitting this form, you consent to CivicDataLab,{' '} + + Gram Vaani + {' '} + and{' '} + + Tech4Dev + {' '} + collecting and using the information + you provide to assess your application for the Local & Inclusive AI Cohort and to contact you about it, + including if you are selected. To withdraw your consent at any time, to exercise your rights under the Digital + Personal Data Protection Act, 2023, or to make a complaint, please email us at{' '} + dpg@civicdatalab.in. + +); + +const CohortPage = () => { + const deadlineLabel = useDeadlineLabel(TECH4DEV.deadline); + const closed = deadlineLabel === 'Applications closed'; + const shown = useReveal(); + const trailRef = useRef(null); + useScrollProgress(trailRef); + + return ( + + + + + + CivicDataLab with Gram Vaani, for Tech4Dev · + {deadlineLabel} + + {!closed && } + + + + + + CivicDataLab + Project Tech4Dev + + Knowledge partner + Gram Vaani + + + Call For Applications: Local & Inclusive AI Cohort +

+ You’ve built the AI solution. +
+ Now make it work in the real world +

+ Enabling NGOs to Extend Local Language AI Solutions to Last-Mile Users + + {!closed && } + + +
+ +
+ + + {stats.map(([big, small]) => ( +
  • + {big} + {small} +
  • + ))} +
    + + + Does your NGO already have an AI solution or is building one, but face challenges in making it useful, trusted + and widely adopted by communities and frontline teams? + + +
    +

    The Local & Inclusive AI Cohort will help you

    + + {helps.map((t) => ( +
  • {t}
  • + ))} +
    + +

    About the cohort

    +

    + Through the Local & Inclusive AI cohort, CivicDataLab, along with{' '} + + Gram Vaani + {' '} + as knowledge partner, will + support 6-8 NGOs working in domains like health, agriculture and public services. The programme combines + technical mentorship, exploration of the open-source multilingual ecosystem, community testing, and peer + learning across five months, followed by light-touch implementation support post completion. +

    +

    + The focus is on improving an existing AI application or a credible pilot. Participating NGOs will own + implementation, supported by mentors who help them make practical design, integration and evaluation choices. +

    +

    Cohort focus areas

    + + {focusAreas.map((f) => ( +
  • {f}
  • + ))} +
    +
    + +
    + +

    Programme structure

    + +
    + + {['base', 'fill'].map((layer) => ( + + ))} + {journey.map(([title, body], i) => ( + + +
    +

    {title}

    +

    {body}

    +
    +
    + ))} +
    + + + Pilot-ready + +
    + +
    +

    Programme approach

    +

    + The programme follows a learn, build, test, iterate and share approach. CivicDataLab will lead cohort + management, readiness assessment, technical mentorship and participatory evaluation exercises. Gram Vaani will + contribute expertise in voice-first design, community engagement, contextualisation, human oversight and + feedback processes. +

    +

    + Participants will work on their own products throughout the cohort. Support will include co-design sessions, + implementation rubrics, technical troubleshooting, demonstrations and peer learning, tailored to each NGO’s + use case and starting point. +

    +
    + + {/* horizontal cards (tick beside text) at text width */} +
    +

    What you will leave with

    + + {leaveWith.map((t) => ( +
  • {t}
  • + ))} +
    +
    + +
    + + Success will be assessed against each NGO’s agreed use case, including user value, technical performance, + field adoption, internal capacity and sustainability. The end-of-cohort goal is pilot readiness; reaching + 50–100 users is not a guaranteed programme outcome. + +
    + + + +

    The investment

    +

    + This subsidised fee covers technical mentorship, implementation support and peer learning at substantially + below the full programme cost. +

    + + +

    What you invest

    + + ₹1 lakh per organisation, inclusive of taxes + + + {invest.map((t) => ( +
  • {t}
  • + ))} +
    + +
    + +

    What you receive

    + + {receive.map((t) => ( +
  • {t}
  • + ))} +
    + {!closed && } +
    +
    +
    +
    + +
    +

    Who should apply

    +

    + We invite applications from NGOs working in health, agriculture, and public services that can demonstrate the + following: +

    + + {criteria.map((t) => ( +
  • {t}
  • + ))} +
    + + Applications will be assessed for AI maturity, the value and feasibility of the proposed localisation, data + and language readiness, team bandwidth, access to users and capacity to sustain implementation. Operational + budget and technology investment will also inform the assessment. + +
    + +
    + {consent} + +
    + +
    +

    Apply

    + {closed ? ( +

    Applications for this cohort have closed. Thank you for your interest.

    + ) : ( + <> + {consent} + + This screening form takes approximately 5–7 minutes. All questions are required. Rough answers are + fine where exact figures aren't available. Responses are used for screening only and do not guarantee + a cohort seat. For queries, write to dpg@civicdatalab.in. + + } + thanks={ + <> +

    + Thank you for your interest in the Local and Inclusive AI Cohort. +

    +

    + We will review your responses and contact you if further information is needed or to discuss next + steps. +

    + + } + /> + + )} +
    + + +
    +

    From AI pilot to implementation. What’s your next move?

    +

    + If your organisation is ready to make its existing AI solution more accessible and useful in the field, we + invite you to apply. +

    + {!closed && ( +

    + +

    + )} +

    + Applications close: {deadlineText(TECH4DEV.deadline)} +
    + For queries, reach out to: dpg@civicdatalab.in +

    +
    + +
    +