Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ceccb1a
Add Local & Inclusive AI cohort landing page at /cohorts/local-inclus…
saqibmanan Oct 29, 2026
c5a03f3
Point cohort forms at the updated Apps Script deployment
saqibmanan Oct 29, 2026
76c325d
Let the cohort Apps Script open its Sheet by ID
saqibmanan Oct 29, 2026
1cfbf4d
Point cohort forms at the working Apps Script deployment
saqibmanan Oct 29, 2026
0ac1d03
Cohort page: add artwork, logos and background; use the latest Apps S…
saqibmanan Oct 29, 2026
e051c61
Cohort page: add the Gram Vaani logo
saqibmanan Oct 29, 2026
e077a5d
Cohort page: remove the Gram Vaani logo until the official file arrives
saqibmanan Oct 29, 2026
d3ceaff
Cohort page: add the NGO screening questionnaire as the application form
saqibmanan Oct 29, 2026
e73e8ae
Cohort application form: split into four steps
saqibmanan Oct 29, 2026
3eba07f
Cohort forms: save Other answers as 'Other: <text>' in the same column
saqibmanan Oct 29, 2026
43f6a1f
Cohort forms: use the question text as each Sheet column's name
saqibmanan Oct 29, 2026
62eab38
Point cohort forms at the latest Apps Script deployment
saqibmanan Oct 29, 2026
13cdda0
Cohort pages: share layout and forms across cohorts
saqibmanan Oct 29, 2026
263913f
Add the UNDP AI & Digital Tools for CSOs page at /cohorts/ai-digital-…
saqibmanan Oct 29, 2026
fc97f0d
Connect the UNDP page to its Apps Script deployment
saqibmanan Oct 29, 2026
532870a
UNDP page: add the baseline assessment questionnaire as the applicati…
saqibmanan Oct 29, 2026
656034c
Cohort pages: use partner names in the URLs (/cohorts/tech4dev, /coho…
saqibmanan Oct 29, 2026
a089a71
UNDP page: applications close 30 Nov, 11:59 PM IST
saqibmanan Oct 29, 2026
8949476
UNDP page: use the grain texture (4.jpg) as the page background
saqibmanan Oct 29, 2026
2f53260
UNDP page: lighten the grain background; soft shadow on the white bub…
saqibmanan Oct 29, 2026
d065826
UNDP page: flat #F6F3EE background instead of the grain texture
saqibmanan Oct 29, 2026
b0b8ddd
Cohort pages: apply the designer's feedback on both landing pages
saqibmanan Oct 30, 2026
3e94688
Tech4Dev hero: use the designer's flat speech bubble (7.png)
saqibmanan Oct 30, 2026
c028703
Tech4Dev: designer's chat bubbles beside the people; textured band fr…
saqibmanan Oct 30, 2026
3f8e94f
Tech4Dev: 'What you will leave with' as full-width single-line row cards
saqibmanan Oct 30, 2026
b326714
Cohort pages: text-width sections, winding programme timeline, collap…
saqibmanan Oct 30, 2026
f332db5
Cohort forms: keep Sheet columns in question order
saqibmanan Nov 2, 2026
f49778d
UNDP page: 'in partnership with UNDP India' in the intro
saqibmanan Nov 2, 2026
668e14b
UNDP page: orange speech bubble in the hero
saqibmanan Nov 2, 2026
0602d77
Cohort pages: footer with CDL logo, social links, and the Tech4Dev lo…
saqibmanan Nov 2, 2026
c5f3447
Cohort pages: footer logos in containers, Gram Vaani knowledge partne…
saqibmanan Nov 2, 2026
6674090
Cohort pages: reviewer feedback on headlines, links, copy, footer log…
saqibmanan Nov 2, 2026
7c85a07
Tech4Dev: Gram Vaani knowledge partner in footer and larger in hero; …
saqibmanan Nov 2, 2026
50620e4
Cohort footer: larger CDL logo, smaller Tech4Dev logo
saqibmanan Nov 2, 2026
c701026
Cohort pages: URLs /cohorts/cdl_tech4dev and /cohorts/cdl_undp
saqibmanan Nov 2, 2026
84321ff
Cohort pages: UNDP logo in UNDP footer; new Apps Script URLs for both…
saqibmanan Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions scripts/cohort-apps-script/Code.gs
Original file line number Diff line number Diff line change
@@ -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/<SHEET_ID>/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); }
58 changes: 58 additions & 0 deletions scripts/cohort-apps-script/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading