# Pilot 2 — Field Visit Data Collection System

**Build spec — Laravel 10 + MySQL, no authentication (v1)**

---

## 1. Purpose

A minimal web application to collect responses from three parallel field-visit streams of the Pilot 2 study:

1. **Visit with DSR** — a live delivery-round observation form (Siam & Nushan).
2. **DSR Interview** — a structured post-round conversation with the DSR (Siam & Nushan).
3. **Retailer Visit** — a shop-scoring walk form (Mishu & Rayem).

The v1 goal is only **capture**. No login, no dashboards, no analytics, no exports (yet). Data is written to MySQL; scaling and access control come in v2.

---

## 2. Tech stack

| Layer | Choice |
|---|---|
| Framework | Laravel 10.x (PHP 8.1+) |
| Database | MySQL 8.x |
| Frontend | Blade views + Tailwind CSS (mobile-first) + tiny vanilla JS for conditional show/hide |
| Session / auth | None in v1 |
| File storage | `storage/app/public/uploads` (symlinked via `php artisan storage:link`) for the one image upload field on Form 3 |

Bangla input works out of the box in Blade — no special server-side handling required as long as the DB is `utf8mb4`.

---

## 3. High-level UX flow

```
  /                  →  Landing page: 3 large tappable cards
  /forms/{slug}      →  Renders that form's sections + fields
  POST /submissions  →  Validates, persists, redirects
  /thank-you         →  Simple confirmation page with a "Submit another" link
```

There is intentionally **no back-navigation between forms** and **no draft-save** in v1. Each visit to `/forms/{slug}` starts fresh.

---

## 4. Directory & file layout

Only files that need to be added or edited are listed.

```
app/
├── Http/
│   └── Controllers/
│       ├── LandingController.php
│       ├── FormController.php
│       └── SubmissionController.php
├── Models/
│   └── Submission.php
└── Support/
    └── FormRegistry.php          # loads config, returns the schema for a slug

config/
└── forms/
    ├── visit_with_dsr.php        # Form 1 config
    ├── dsr_interview.php         # Form 2 config
    └── retailer_visit.php        # Form 3 config

database/
└── migrations/
    └── 2026_08_23_000001_create_submissions_table.php

resources/
└── views/
    ├── landing.blade.php
    ├── thank_you.blade.php
    ├── forms/
    │   └── show.blade.php
    └── partials/
        └── fields/
            ├── short_text.blade.php
            ├── long_text.blade.php
            ├── number.blade.php
            ├── radio.blade.php
            ├── dropdown.blade.php
            ├── multi_select.blade.php
            ├── date.blade.php
            ├── file_image.blade.php
            └── section_header.blade.php

routes/
└── web.php
```

---

## 5. Database schema

One table only. Because the three forms have different shapes and v1 is capture-only, storing the whole payload as JSON keeps the app small and lets you add or reorder fields without a migration. Every named field also gets a dedicated top-level column when it is common across all three forms.

### 5.1 Migration

```php
Schema::create('submissions', function (Blueprint $table) {
    $table->id();
    $table->string('form_slug', 40)->index();      // visit_with_dsr | dsr_interview | retailer_visit
    $table->string('submitted_by', 120);           // the mandatory footer field
    $table->json('data');                          // { "A-027": "...", "A-028": [...], ... }
    $table->string('client_ip', 45)->nullable();
    $table->string('user_agent', 255)->nullable();
    $table->timestamps();
});
```

Notes:

- **Character set:** the connection and table must be `utf8mb4` / `utf8mb4_unicode_ci` — Bangla verbatim quotes will not round-trip on `utf8`.
- **Keys stored as Q_IDs.** The `data` JSON uses `A-027`, `E-036`, etc. as keys. This makes the JSON self-describing and lets you rename a question label without breaking existing rows.
- **No FK to a users table.** `submitted_by` is a free-text field per the requirement ("no login").
- **Image path,** when the one image field (`E-052`) is used, is stored inside `data` as `data["E-052"] = "uploads/2026/08/23/xxxxx.jpg"`.

### 5.2 Why JSON now, normalized tables later

In v2, when you need SQL-side reporting, migrate to one table per form (`visit_with_dsr_submissions`, `dsr_interview_submissions`, `retailer_visit_submissions`) with typed columns. A one-off script can read the `data` JSON out of `submissions` and split into the new tables — nothing is lost. See §14.

---

## 6. Routes (`routes/web.php`)

```php
use App\Http\Controllers\LandingController;
use App\Http\Controllers\FormController;
use App\Http\Controllers\SubmissionController;

Route::get('/',                       [LandingController::class, 'index'])->name('landing');
Route::get('/forms/{slug}',           [FormController::class, 'show'])->name('forms.show');
Route::post('/forms/{slug}/submit',   [SubmissionController::class, 'store'])->name('submissions.store');
Route::get('/thank-you',              fn () => view('thank_you'))->name('thank_you');
```

`{slug}` is one of: `visit_with_dsr`, `dsr_interview`, `retailer_visit`. Any other slug should return 404.

---

## 7. Model (`app/Models/Submission.php`)

```php
class Submission extends Model
{
    protected $fillable = ['form_slug', 'submitted_by', 'data', 'client_ip', 'user_agent'];

    protected $casts = [
        'data' => 'array',
    ];
}
```

---

## 8. Controllers

### 8.1 `LandingController`

Just renders `landing.blade.php`, which shows three cards linking to `/forms/visit_with_dsr`, `/forms/dsr_interview`, `/forms/retailer_visit`.

### 8.2 `FormController::show($slug)`

```
- abort 404 if slug is not one of the three
- $schema = FormRegistry::get($slug);
- return view('forms.show', compact('slug', 'schema'));
```

### 8.3 `SubmissionController::store(Request, $slug)`

```
- abort 404 if slug is not one of the three
- $schema = FormRegistry::get($slug);
- $rules  = FormRegistry::validationRules($schema);
- $data   = $request->validate($rules);
- pull out 'submitted_by' from $data
- if any file upload field exists, move it to storage/app/public/uploads/YYYY/MM/DD/
  and replace the value in $data with the stored relative path
- Submission::create([
      'form_slug'    => $slug,
      'submitted_by' => $submittedBy,
      'data'         => $data,          // still keyed by Q_ID
      'client_ip'    => $request->ip(),
      'user_agent'   => substr($request->userAgent() ?? '', 0, 255),
  ]);
- redirect()->route('thank_you');
```

### 8.4 `FormRegistry` (helper)

- `FormRegistry::get(string $slug): array` — returns the config for that slug (loads `config/forms/{slug}.php`).
- `FormRegistry::validationRules(array $schema): array` — walks the schema and builds a Laravel rules array. See §11.

---

## 9. Form config schema

Every form config is a PHP array with the shape below. `show.blade.php` iterates it; the same view renders all three forms.

```php
return [
    'slug'  => 'visit_with_dsr',
    'title' => 'Visit with DSR (Siam & Nushan)',
    'sections' => [
        [
            'title' => 'Route Payment Tally (Test 2)',
            'fields' => [
                [
                    'id'       => 'A-027',
                    'label_en' => 'Payment mode at this shop',
                    'label_bn' => null,
                    'asked_to' => 'Observation',
                    'type'     => 'radio',
                    'options'  => [
                        'Full cash',
                        'Full digital',
                        'Part cash + part digital',
                        'No payment today (goods on credit)',
                        'Cheque',
                        'Adjusted against return',
                    ],
                    'required' => true,
                ],
                // ... more fields
            ],
        ],
        // ... more sections
    ],
];
```

**Recognized `type` values** (map to the Blade partials in §4): `short_text`, `long_text`, `number`, `radio`, `dropdown`, `multi_select`, `date`, `file_image`.

**Compound types** (from the Excel — a radio + free-text, a multi-select + long-text, a dropdown + date, etc.) are handled by emitting **two fields** with a shared prefix. Convention:

- Parent field keeps the Q_ID as the key.
- The follow-up long-text or date box gets `id => 'A-036__note'` (double underscore + suffix). It renders directly under its parent, indented.

This keeps the JSON flat and searchable while still capturing every input the spec asks for.

**Conditional fields** (Excel column: `Required = Conditional`) are marked `'required' => 'conditional'` and always rendered; server-side validation only enforces them when a specific triggering answer is present (see §11.4). The rules are captured per field in §12 as a plain-English precondition — treat this as the source of truth when wiring up JavaScript show/hide.

---

## 10. Common footer — "Submitted By"

Every form ends with the same field, appended by the Blade template (not repeated in each config):

| Attribute | Value |
|---|---|
| Field key | `submitted_by` |
| Label (EN) | Submitted by |
| Label (BN) | কে জমা দিচ্ছেন |
| Type | `short_text` |
| Max length | 120 |
| Required | Yes |

The controller stores it in its own column (`submissions.submitted_by`), not inside the JSON blob.

---

## 11. Input types — rendering & validation

Only the eight canonical types listed in §9 are used. The mapping from the Excel column `Input Type` to these eight is below.

### 11.1 Type map

| Excel "Input Type" | Rendered as | Notes |
|---|---|---|
| Short text | `short_text` | max 80 chars |
| Long text | `long_text` | `<textarea rows=4>`, no maxlength |
| Number | `number` | `<input type="number" inputmode="numeric">` with `min` / `max` from the Excel range — `inputmode` forces the numeric keypad on mobile |
| Number (BDT / days / % / months) | `number` | same, unit shown as suffix label |
| Radio | `radio` | vertical group, no default selection |
| Dropdown | `dropdown` | native `<select>` with a `-- select --` placeholder |
| Multi-select | `multi_select` | checkbox group |
| Multi-select + Long text | `multi_select` + follow-up `long_text` (suffix `__note`) | render the textarea underneath |
| Radio + Long text | `radio` + follow-up `long_text` (suffix `__note`) | same |
| Dropdown + free date | `dropdown` + follow-up `date` (suffix `__date`) | same |
| Dropdown + Long text | `dropdown` + follow-up `long_text` (suffix `__note`) | same |
| Radio + Number | `radio` + follow-up `number` (suffix `__number`) | Form 2 field A-054 uses this |
| Radio + photo | `radio` (image upload not used in v1) | drop the photo half for now — not part of the three forms in scope |
| 3 x Long text | three `long_text` fields with keys `A-063__q1`, `A-063__q2`, `A-063__q3` | three verbatim quotes |
| File upload (image) | `file_image` | Form 3 field E-052 only |
| Read-only text | `short_text` with the `readonly` attribute | Form 3 fields E-011, E-012 |

Types the Excel mentions but **not required by the three forms in scope** (skip in v1, revisit later): `Number (tap counter)`, `Multi-count`, `Time picker`, `Auto geo-capture`, `Auto-calculated`, `Auto timestamp`, `Repeat text`, `Repeat long text`. If a section header says the field is used by another visit, ignore it.

### 11.2 Validation rules per type

| Type | Laravel rules (base) |
|---|---|
| `short_text` | `string`, `max:80` (add `required` when the field is required) |
| `long_text` | `string`, `max:5000` |
| `number` | `numeric`, `min:{from schema}`, `max:{from schema}` |
| `radio` | `string`, `in:{options}` |
| `dropdown` | `string`, `in:{options}` |
| `multi_select` | `array`, plus `data.{id}.*` → `string`, `in:{options}` |
| `date` | `date`, `before_or_equal:today` |
| `file_image` | `nullable`, `image`, `mimes:jpg,jpeg,png`, `max:5120` (5 MB) |
| `submitted_by` | `required`, `string`, `max:120` |

### 11.3 "Required" vs "Conditional"

- `'required' => true` → prepend `required` to the base rules.
- `'required' => 'conditional'` → do **not** prepend `required`. Instead, the schema field carries a `depends_on` sub-array; the validator adds a `required_if` rule at build time. Example:

```php
[
  'id' => 'A-030',
  'type' => 'number',
  'required' => 'conditional',
  'depends_on' => ['field' => 'A-029', 'values' => ['Yes — part unpaid', 'Yes — fully unpaid']],
  // ...
]
```

Rule produced: `required_if:data.A-029,Yes — part unpaid,Yes — fully unpaid|numeric|min:0|max:500000`.

### 11.4 Client-side show/hide

For every conditional field, `show.blade.php` renders it inside a `<div data-depends-on="A-029" data-depends-values="Yes — part unpaid;Yes — fully unpaid" hidden>` wrapper. A ~20-line vanilla-JS snippet at the bottom of the view listens on `change` on all inputs and toggles the wrappers. No framework needed. This is UX only — the real gate is the server-side rule above.

### 11.5 File uploads

Only `E-052` (shopfront photo) in v1. Rules: JPG/PNG, up to 5 MB, single file, optional. Store under `storage/app/public/uploads/YYYY/MM/DD/{ulid}.{ext}` and put the relative path into `data["E-052"]`. Run `php artisan storage:link` once during deploy.

Render on mobile with the rear camera as default:

```html
<input type="file" name="E-052" accept="image/*" capture="environment">
```

`capture="environment"` opens the back camera directly on iOS Safari and Android Chrome. On desktop it degrades to a normal file picker with no extra work.

---

## 12. Form 1 — Visit with DSR (Siam & Nushan)

**Slug:** `visit_with_dsr` · **Sections:** 3 · **Fields:** 30 (+ `submitted_by`)

### Section 1 — Route Payment Tally (Test 2)

| Q_ID | Question (English) | Question (Bangla) | Asked to | Type | Options / Range | Required |
|---|---|---|---|---|---|---|
| A-027 | Payment mode at this shop | — | Observation | Radio | Full cash; Full digital; Part cash + part digital; No payment today (goods on credit); Cheque; Adjusted against return | Yes |
| A-028 | If any digital, which instrument? | — | Observation | Multi-select | bKash personal; bKash merchant; Nagad; Rocket; Upay; Bank transfer; QR scan; Other | Conditional on A-027 ∈ {Full digital, Part cash + part digital} |
| A-029 | Was any amount left unpaid today? | — | Observation | Radio | No — fully settled; Yes — part unpaid; Yes — fully unpaid | Yes |
| A-030 | If unpaid, amount left (BDT) | — | Observation | Number | 0 – 500,000 | Conditional on A-029 ∈ {Yes — part unpaid, Yes — fully unpaid} |
| A-031 | If unpaid, was the stock still delivered? | — | Observation | Radio | Yes — full stock left; Yes — reduced quantity left; No — delivery refused; Manager called for approval | Conditional on A-029 ∈ {Yes — part unpaid, Yes — fully unpaid} |
| A-032 | Days the DSR said this shop can pay later | — | DSR | Number | 0 – 60 | Conditional on A-029 ∈ {Yes — part unpaid, Yes — fully unpaid} |
| A-033 | Any extra charge mentioned for the delay? | — | DSR / retailer | Radio | No charge at all; Yes — fixed amount; Yes — percentage; Only tea/snacks; Not asked | Conditional on A-029 ∈ {Yes — part unpaid, Yes — fully unpaid} |
| A-034 | Were previous dues cleared today? | — | Observation | Radio | Yes; Partly; No; No previous dues | Yes |
| A-035 | Who handed over the money? | — | Observation | Radio | Owner; Staff; Family member; Nobody present | Yes |
| A-036 | Anything said at the counter, verbatim (Bangla) | — | Retailer | Long text | — | No |

### Section 2 — Test 1 — 10 Retailers

| Q_ID | Question (English) | Question (Bangla) | Asked to | Type | Options / Range | Required |
|---|---|---|---|---|---|---|
| A-070 | Shop name | — | Retailer | Short text | — | Yes |
| A-071 | Shop strength (interviewer judgement) | — | Self | Radio | Strong; Average; Weak | Yes |
| A-072 | When did you last take goods without paying in full? | শেষ কবে পুরো টাকা না দিয়ে মাল নিয়েছিলেন? | Retailer | Dropdown + free date | This week; Last week; Within last month; 2–3 months ago; Longer ago; Never *(follow-up: date picker)* | Yes |
| A-073 | How many days later did you pay? | কত দিন পরে শোধ করেছিলেন? | Retailer | Number | 0 – 90 (days) | Yes |
| A-074 | How much was taken on credit that time (BDT)? | কত টাকার মাল বাকিতে নিয়েছিলেন? | Retailer | Number | 0 – 500,000 | Yes |
| A-075 | Did you pay anything extra for those days? | ওই দিনগুলোর জন্য বাড়তি কিছু দিতে হয়েছিল? | Retailer | Radio | No; Yes — amount; Yes — percentage; Only tea / snacks | Yes |
| A-076 | How many times did this happen last month? | গত মাসে কতবার এমন হয়েছে? | Retailer | Number | 0 – 31 | Yes |
| A-077 | Who allowed it? | কে অনুমতি দিয়েছিল? | Retailer | Radio | DSR; Distributor manager; Distributor owner; Nobody — I was refused | Yes |
| A-078 | Was any paper or document signed? | কোনো কাগজে সই করতে হয়েছিল? | Retailer | Radio | No; Just noted in a register; Signed a slip; Post-dated cheque | Yes |
| A-079 | Verbatim quote (Bangla) | — | Retailer | Long text | — | No |

### Section 3 — Test 2 — Wallet Check

| Q_ID | Question (English) | Question (Bangla) | Asked to | Type | Options / Range | Required |
|---|---|---|---|---|---|---|
| A-090 | Shop name | — | Retailer | Short text | — | Yes |
| A-091 | How much is in your mobile wallet right now? | আপনার মোবাইল ওয়ালেটে এখন কত টাকা? | Retailer | Number (BDT) | 0 – 500,000 | Yes |
| A-093 | Which wallet(s) do you hold? | কোন কোন ওয়ালেট আছে? | Retailer | Multi-select | bKash; Nagad; Rocket; Upay; Bank app; None | Yes |
| A-094 | Is it a personal or a merchant account? | এটা কি পার্সোনাল না মার্চেন্ট একাউন্ট? | Retailer | Radio | Personal; Merchant; Agent; Don't know | Yes |
| A-095 | When did you last put money into it? | শেষ কবে ওয়ালেটে টাকা ঢুকিয়েছিলেন? | Retailer | Dropdown | Today; This week; This month; More than a month ago; Can't remember; Never | Yes |
| A-096 | What do you use it for? | ওয়ালেট দিয়ে কী কী করেন? | Retailer | Multi-select + Long text | Send money to family; Mobile top-up; Pay supplier; Receive customer payment; Utility bill; Cash out the full amount immediately; Keep savings; Loan repayment *(follow-up: long text)* | Yes |
| A-097 | If you had to keep BDT 4,000 in it for a week, could you? | এক সপ্তাহ ৪,০০০ টাকা ওয়ালেটে রাখতে হলে পারতেন? | Retailer | Radio | Yes — easily; Yes — but difficult; No; Only if business is good that week | Yes |
| A-098 | Reason, in his own words | — | Retailer | Long text | — | Yes |
| A-099 | Who operates the wallet / knows the PIN? | ওয়ালেট কে চালায়? | Retailer | Radio | Owner himself; Staff; Son or relative; Shared | Yes |
| A-100 | Hypothetically, would a fixed-date automatic deduction be acceptable? | নির্দিষ্ট তারিখে অটোমেটিক টাকা কেটে নিলে আপনার আপত্তি আছে? | Retailer | Radio + Long text | Acceptable; Not acceptable; Need to think; Only if I get an SMS before *(follow-up: long text)* | No |

**Note on A-092:** the Excel intentionally skips this Q_ID (goes A-091 → A-093). Preserve that gap in the config so IDs stay stable if a future field is inserted.

---

## 13. Form 2 — DSR Interview (Siam & Nushan)

**Slug:** `dsr_interview` · **Sections:** 1 · **Fields:** 14 (+ `submitted_by`)

### Section 1 — Test 1 — DSR Interview

| Q_ID | Question (English) | Question (Bangla) | Asked to | Type | Options / Range | Required |
|---|---|---|---|---|---|---|
| A-050 | DSR name / code | — | DSR | Short text | — | Yes |
| A-051 | How long have you been doing this route? (months) | — | DSR | Number | 0 – 480 | Yes |
| A-052 | How many shops do you cover in a day? | আপনি দিনে কতটা দোকান কভার করেন? | DSR | Number | 0 – 200 | Yes |
| A-053 | What happens when a shop cannot pay the full amount today? | কোনো দোকান আজ পুরো টাকা দিতে না পারলে কী হয়? | DSR | Multi-select + Long text | I leave the goods and collect next visit; I reduce the quantity; I refuse delivery; I pay from my own pocket; I call the manager for approval; Shop pays part, rest next day; Other *(follow-up: verbatim long text)* | Yes |
| A-054 | How often did that happen last week? | গত সপ্তাহে এমন কতবার হয়েছে? | DSR | Radio + Number | Bands: 0; 1–2; 3–5; 6–10; More than 10 *(follow-up: exact number)* | Yes |
| A-055 | Do you leave the goods anyway? | মাল কি তবুও রেখে আসেন? | DSR | Radio | Always; Usually; Only for old / regular shops; Only with manager approval; Never | Yes |
| A-056 | Does the shop pay anything extra for the delay? | দেরির জন্য দোকান কি বাড়তি কিছু দেয়? | DSR | Radio | No — nothing extra; Yes — fixed amount; Yes — percentage; Sometimes tea / snacks only; Don't know | Yes |
| A-057 | If yes, how much? (BDT or %) | — | DSR | Short text | — | Conditional on A-056 ∈ {Yes — fixed amount, Yes — percentage} |
| A-058 | If a shop does not pay, does it come out of your own money or salary? | দোকান টাকা না দিলে সেটা কি আপনার বেতন থেকে কাটে? | DSR | Radio | Yes — fully my responsibility; Partly; No — company bears it; Written against my name until recovered | Yes |
| A-059 | Who decides which shop is allowed to pay late? | কোন দোকান পরে টাকা দিতে পারবে, সেটা কে ঠিক করে? | DSR | Radio | I decide; Manager decides; Owner decides; There is a written policy; Case by case | Yes |
| A-060 | Is the late payment recorded anywhere? | বাকি টাকার হিসাব কোথায় লেখা থাকে? | DSR | Radio | Distributor register; Software / app; My own notebook; Nowhere — verbal only | Yes |
| A-061 | What is the maximum number of days a shop is allowed? | সবচেয়ে বেশি কত দিন সময় দেওয়া হয়? | DSR | Number | 0 – 90 | Yes |
| A-062 | How much money are shops holding from you right now (BDT)? | এখন আপনার কাছে দোকানের কত টাকা বাকি আছে? | DSR | Number | 0 – 2,000,000 | No |
| A-063 | Three things the DSR said, verbatim in Bangla | — | DSR | 3 × Long text | render as `A-063__q1`, `A-063__q2`, `A-063__q3` | Yes (all three) |

---

## 14. Form 3 — Retailer Visit (Mishu & Rayem)

**Slug:** `retailer_visit` · **Sections:** 7 · **Fields:** 17 (+ `submitted_by`)

### Section 1 — Shop Identification

| Q_ID | Question (English) | Question (Bangla) | Asked to | Type | Options / Range | Required |
|---|---|---|---|---|---|---|
| E-011 | Shop name | — | Pre-loaded | Read-only text | — | Yes |
| E-012 | Address / landmark | — | Pre-loaded | Read-only text | — | Yes |
| E-013 | Shop status today | — | Observation | Radio | Open — scored; Closed today; Shifted to a new address; Not found; Permanently closed | Yes |

**v1 note on read-only fields:** since there is no login and no pre-population source yet, render `E-011` and `E-012` as ordinary editable `short_text` inputs in v1 (drop the `readonly` attribute). Flip them back to `readonly` in v2 when a shop list is loaded from an admin panel or a CSV import.

### Section 2 — Criterion 1 — Visibility

| Q_ID | Question (English) | Question (Bangla) | Asked to | Type | Options / Range | Required |
|---|---|---|---|---|---|---|
| E-020 | Is the shop visible from the main road or the corner? | — | Observation | Radio | Yes = 1; No = 0 | Yes |
| E-021 | Exact position | — | Observation | Dropdown | Main road frontage; Corner plot; Inside lane; Inside a market building; Inside a residential block | Yes |

### Section 3 — Criterion 2 — Footfall

| Q_ID | Question (English) | Question (Bangla) | Asked to | Type | Options / Range | Required |
|---|---|---|---|---|---|---|
| E-025 | Customers counted in a full 10 minutes | — | Observation | Number | 0 – 300 | Yes |
| E-026 | Was the full 10 minutes actually timed? | — | Self | Radio | Yes — full 10 minutes; No — shorter (state how long) | Yes |
| E-027 | Footfall score — is this high for this strip? | — | Self | Radio | High for the strip = 1; Not high = 0 | Yes |

### Section 4 — Criterion 3 — Daily Necessities

| Q_ID | Question (English) | Question (Bangla) | Asked to | Type | Options / Range | Required |
|---|---|---|---|---|---|---|
| E-030 | Does it stock daily necessities (biscuits, chips, cigarettes, cold drinks, sundries)? | — | Observation | Radio | Yes = 1; No = 0 | Yes |
| E-031 | Which categories are actually present? | — | Observation | Multi-select | Biscuits; Chips / snacks; Cigarettes; Cold drinks; Sundries; Rice & pulses; Dairy; Bread; Personal care | Yes |

### Section 5 — Criterion 4 — Anchor

| Q_ID | Question (English) | Question (Bangla) | Asked to | Type | Options / Range | Required |
|---|---|---|---|---|---|---|
| E-035 | Is there at least one repeat-visit anchor? | — | Observation | Radio | Yes = 1; No = 0 | Yes |
| E-036 | Which anchor(s)? (note the type, not just yes/no) | — | Observation | Multi-select | Ice-cream freezer; Mobile top-up counter; Bakery display; Cold drinks chiller; bKash / Nagad agent point; Photocopy or printing; Recharge / bill pay; None | Conditional on E-035 = "Yes = 1" |

### Section 6 — Criterion 5 — Owner Presence

| Q_ID | Question (English) | Question (Bangla) | Asked to | Type | Options / Range | Required |
|---|---|---|---|---|---|---|
| E-040 | Is the owner himself at the counter (not junior staff)? | — | Observation | Radio | Yes = 1; No = 0 | Yes |
| E-041 | Only if unclear — do you sit at the shop yourself, or does staff run it? | দোকানে কি আপনি নিজেই বসেন, নাকি স্টাফ থাকে? | Retailer | Radio | Owner sits himself; Staff runs it; Both; Family member runs it | Conditional on E-040 = "No = 0" |

### Section 7 — Score & Notes

| Q_ID | Question (English) | Question (Bangla) | Asked to | Type | Options / Range | Required |
|---|---|---|---|---|---|---|
| E-052 | Shopfront photo | — | Observation | File upload (image) | JPG / PNG, max 5 MB | No |
| E-053 | Digital payment stickers visible at the counter | — | Observation | Multi-select | bKash; Nagad; Rocket; Upay; QR code; None visible | Yes |
| E-054 | Approximate shop size | — | Observation | Radio | Small (under 100 sq ft); Medium (100–300); Large (over 300) | Yes |

---

## 15. Landing page

Three cards, one per form. Show the title, a one-line description, and a "Start" button. In Bangla and English so field users can pick fast.

Layout: **one column on mobile (cards stack full-width), three columns on ≥`md` breakpoint (768 px+).** In Tailwind: `grid grid-cols-1 md:grid-cols-3 gap-4`.

```
┌─────────────────────────────┐   ┌─────────────────────────────┐   ┌─────────────────────────────┐
│  Visit with DSR             │   │  DSR Interview              │   │  Retailer Visit             │
│  ডিএসআর-এর সাথে ভিজিট        │   │  ডিএসআর ইন্টারভিউ           │   │  রিটেইলার ভিজিট             │
│                             │   │                             │   │                             │
│  Siam & Nushan              │   │  Siam & Nushan              │   │  Mishu & Rayem              │
│  Route observation, 30 Qs   │   │  Structured interview, 14 Qs│   │  Shop scoring walk, 17 Qs   │
│                             │   │                             │   │                             │
│  [ Start / শুরু করুন ]      │   │  [ Start / শুরু করুন ]      │   │  [ Start / শুরু করুন ]      │
└─────────────────────────────┘   └─────────────────────────────┘   └─────────────────────────────┘
```

---

## 16. Form page (`forms/show.blade.php`) — rendering rules

- Show the form title at the top.
- Iterate `$schema['sections']` — each renders as an `<h2>` section header followed by its fields in order.
- For each field, dispatch to the matching `partials/fields/{type}.blade.php`.
- Bangla label shows underneath the English label in a smaller grey font when `label_bn` is set.
- Every required field shows a red asterisk. Conditional fields get "(if applicable)" in grey.
- The `submitted_by` footer field is appended by the template, not the config.
- The submit button is disabled until either (a) all required fields are filled, or (b) the user tries to submit — do whichever is easier. Server-side validation is authoritative either way.
- After a validation failure, redisplay with `old()` values and per-field error messages.

---

## 17. Mobile responsiveness

The primary user is a field enumerator holding a phone at a shop counter, often one-handed, sometimes in bright sun, on a flaky network. Mobile is the default; desktop is the fallback. This section is the source of truth when a design choice has to trade one against the other.

### 17.1 Baseline

- **Viewport tag** in the layout: `<meta name="viewport" content="width=device-width, initial-scale=1.0">`.
- **Tailwind mobile-first breakpoints.** Write the mobile styles first, then override at `sm` (640 px), `md` (768 px), `lg` (1024 px). Do not use `max-w-*` classes without a mobile counterpart — the form should always fill the phone width with sensible side padding (`px-4`).
- **Target devices:** Android Chrome and iOS Safari, current-year and two versions back. No IE, no legacy Edge, no in-app browsers of exotic apps.
- **Test at 360 × 640** (a common budget Android) as the narrowest supported viewport. If it works there, it works everywhere.

### 17.2 Layout rules

- **Container.** Wrap every page in `max-w-2xl mx-auto px-4 py-6` — full-bleed with padding on mobile, comfortably centred on desktop.
- **Single-column forms always.** Do not put two fields side by side even on desktop. It complicates conditional show/hide and offers no readability benefit for this length of form.
- **Section headers.** `text-lg font-semibold mt-6 mb-3`. Not sticky — sticky headers eat vertical space on short phone screens.
- **Field spacing.** `space-y-5` between fields inside a section. Enough breathing room that adjacent radios don't get mis-tapped.
- **No horizontal scroll, ever.** If content forces it, the layout is broken — fix the offending element, don't hide overflow.

### 17.3 Touch targets

- **Minimum 44 × 44 px** for every interactive element (Apple HIG; Android Material recommends 48 × 48 dp — either is fine, be generous).
- **Radio and checkbox rows** should be the full width of the container and tappable anywhere on the row, not just on the input dot. Wrap each in a `<label class="flex items-center gap-3 py-3 px-2 rounded active:bg-gray-100">` — the padding makes the whole row a tap target.
- **Submit button:** full-width on mobile (`w-full`), auto-width on `md+` (`md:w-auto`). Minimum height `min-h-[48px]`. Do **not** put it in the footer bar sticky-fixed — the mobile keyboard covers it when a text field is focused. A normal in-flow button at the end of the form is safer.

### 17.4 Text inputs — the iOS zoom trap

**iOS Safari zooms the viewport when it focuses any input with `font-size < 16px`.** Once zoomed, the layout is broken until the user pinch-resets. Every input, textarea, and select must render at **`text-base` (16 px) or larger** on mobile. In Tailwind: `text-base` is the default — do not override it with `text-sm` on form controls, ever.

Other input rules:

- **Text fields:** `w-full rounded-md border-gray-300 px-3 py-3 text-base`.
- **Textareas** (long text, verbatim quotes): `rows="4"` minimum, `w-full text-base`. Do not fix a pixel height — let the browser reflow around the keyboard.
- **Numeric inputs:** `type="number" inputmode="numeric"` — `inputmode` is what actually forces the numeric keypad on iOS.
- **Dropdown:** native `<select>` — do not build a custom one. The native one gets the OS-level bottom-sheet picker on iOS and Android, which is the right UX and free.
- **Bangla input** works with any keyboard the user has installed (Ridmik, Google Bangla, etc.). Do not attach an `input event` listener that transliterates or auto-corrects — that fights the user's keyboard.

### 17.5 Camera and file upload

Covered in §11.5. The one thing worth repeating here: **do not add a custom "Take Photo" button.** The native `<input type="file" accept="image/*" capture="environment">` is universally understood, works offline, and hands the file straight to the form. A custom flow costs code and breaks on some Android skins.

### 17.6 Network reality

- **Assume 3G-class latency** for form submits. The POST handler should return fast — no synchronous image processing beyond the move-to-storage.
- **Disable the submit button on click** and swap the label to "Submitting…" so the user does not double-tap and create a duplicate row. A single line of vanilla JS on the `<form>`'s `submit` handler.
- **Server-side idempotency isn't needed in v1** (each submission is a new row by design), but the disable-on-click prevents most of the accidental duplication.
- **No offline mode in v1.** If the user loses signal mid-submit, they lose that submission — flag this to the field team in training.

### 17.7 Rendering conditional fields

The show/hide behaviour in §11.4 must not cause the page to jump. Two rules:

- When a field is hidden, use `hidden` (which sets `display: none`) — do not use `visibility: hidden`, which reserves the space and leaves a gap.
- When a field appears mid-form because a radio was tapped, the browser will not scroll — good. Do not add smooth-scroll logic; it disorients on mobile.

### 17.8 Fonts and readability

- Body text: 16 px minimum. Bangla text is denser than Latin; do not go smaller.
- Line height: `leading-relaxed` (1.625) for Bangla labels — a tight line height clips descenders on some Bangla fonts.
- System font stack is fine (`font-sans` in Tailwind picks it up). Do not load a web font just for Bangla — the system Bangla font on both iOS and Android is fine and saves a network round-trip.

### 17.9 Accessibility floor

Not a full a11y pass in v1, but do these:

- Every input has a `<label for="...">` with a matching `id`.
- Required fields include `aria-required="true"` in addition to the visual asterisk.
- Error messages sit directly under the field, in red, referenced by `aria-describedby`.
- Do not rely on colour alone — the red asterisk is paired with the word "required" in the label tooltip.

### 17.10 Quick-check list before shipping

Load the app on one physical Android and one iOS device (not just Chrome DevTools) and verify:

- No horizontal scroll on any form, any section.
- Numeric keypad appears on all number fields, not the full QWERTY.
- Camera opens directly when tapping the shopfront photo field.
- Bangla text renders correctly in the label, the placeholder, and the entered value.
- Tapping "Submit" once yields exactly one row in `submissions`.
- Focusing a long-text field does not push the submit button off screen when the keyboard opens.

---

## 18. Deployment checklist

1. `composer install --no-dev` and `npm ci && npm run build` (if using Vite for Tailwind).
2. `.env`:
   - `APP_ENV=production`, `APP_DEBUG=false`, `APP_URL=https://...`
   - `DB_CONNECTION=mysql`, `DB_DATABASE=[PLACEHOLDER]`, `DB_USERNAME=[PLACEHOLDER]`, `DB_PASSWORD=[PLACEHOLDER]`
   - `FILESYSTEM_DISK=public`
3. `php artisan key:generate`
4. `php artisan migrate --force`
5. `php artisan storage:link`
6. Force HTTPS at the web server (nginx / Apache) — do not rely on the app to redirect.
7. Set `session.cookie_secure=true` and `session.cookie_samesite=Lax` in `config/session.php` even though there is no login (CSRF tokens still ride in a cookie).
8. Add a basic 5-req/min rate limit to `POST /forms/{slug}/submit` via `throttle:5,1` middleware — cheap defence against someone spamming rubbish rows.

---

## 19. Data-handling notes (flag for later review)

Even though this is a research pilot with no financial transactions, the forms capture:

- **Retailer verbatim quotes** in Bangla — sometimes identifying (shop name + a story about borrowing).
- **DSR names and codes** (A-050) alongside their answers on how they handle unpaid deliveries and money written against them personally.
- **Shopfront photos** (E-052) with EXIF potentially containing GPS if the camera adds it.

Recommended before v2:

1. Restrict DB and `storage/app/public/uploads` access; do not expose the upload directory as a browsable index.
2. Strip EXIF from uploaded images at ingestion time. Laravel's `Intervention/Image` can do this in one call inside the controller before `->save()`.
3. Add a retention policy: submissions older than the pilot's declared retention window get archived or deleted.
4. When authentication lands in v2, log which enumerator submitted each row — do not rely on the free-text `submitted_by` field for audit.
5. If any Bangladesh Bank or internal PCI-DSS review touches this data, the JSON blob is the single artefact to hand over; a normalized schema (see §5.2) will make redaction easier.

---

## 20. What v1 explicitly does not include

- No user accounts, roles, or per-user history.
- No draft-save, no resume-later.
- No admin panel, no CSV export, no reporting UI. (Query directly against `submissions.data` with MySQL JSON functions in the meantime — e.g. `JSON_EXTRACT(data, '$."A-029"')`.)
- No offline / PWA support. If the field team loses connectivity, they lose the in-progress form.
- No pre-loaded shop lists for Form 3 (see §14 note on `E-011` / `E-012`).
- No auto-calculated fields, tap counters, GPS auto-capture, or timers — these appear in other Pilot 2 forms but not the three in scope.
- No image compression on the client (server-side 5 MB cap only).
- No i18n framework — Bangla strings are inlined in the config alongside English.

Each of the above has a clear v2 path and none of them block the pilot from running.
