# COURSE_TEMPLATE_CONVERSION.md

**`node--course.tpl.php` (347 lines) → `node--course--full.html.twig`.**
Written 2026-08-25. **Template written; RUNTIME UNVERIFIED — D10 has 0 nodes, so no course page
exists to render yet.** Nothing here is claimed as working until it renders against migrated content.

---

## 1. Why this template matters

It is the course detail page — the centre of the purchase and learning flow: progress stepper →
teaser video → price / add-to-cart → referral link → PDF download → Google review → evaluation gate
→ quiz gate. Nearly every business rule in `BUSINESS_LOGIC.md` surfaces somewhere on this page.

---

## 2. 🛑 THREE PRESERVED DEFECTS — a build that "fixes" any of these has FAILED

### D-1 · Pass mark 75 with inconsistent operators — **documented, approved**

```php
line 174:  if (!empty($best_result_id) && $best_result_id['score'] >= 75)  // step complete
line 326:  if (!empty($best_result_id) && $best_result_id['score'] >  75)  // "already passed"
```

A score of **exactly 75** marks the quiz step complete but does **not** show the passed message.

- `PHASE_2_MIGRATION_DECISIONS.md` decision **13**: *"Pass mark → **PRESERVE** the hard-coded 75 and
  its `>` / `>=` inconsistency — Approved"*
- **B19**: *"Templates hard-code pass mark 75 vs real `pass_rate` — **Measured: 0 users affected,
  ever** — Yes, Twig reproduces 75"*

The Quiz module's real per-quiz `pass_rate` (100 on 35 quizzes, 75 on 28, 0 on 5) is **ignored by
the template** in D7. It is ignored in D10 too. Both operators reproduced exactly.

### D-2 · The "Watch Video" step can NEVER show as complete — **UNDOCUMENTED, found in conversion**

```php
line 168:  if (!empty($videoCompletedFlag)) { $video_class = "step-complete"; }
```

`$videoCompletedFlag` is **never assigned** — not in this template, not anywhere in `cetc_new`
(grepped the whole theme, 2026-08-25). It is an undefined variable, so the condition is **always
false** and the first step never gets `step-complete`.

**This is not in any project document.** Learners see "Watch Video" as incomplete permanently, even
after finishing every video. Reproduced exactly: `video_step_class` is always empty.

> ⚠️ Do **not** repair this by wiring in the real `completed` video flag. That would change what
> every learner sees on every course page — a UX change requiring approval, not a migration fix.
> Flagged here for a **post-migration** decision.

### D-3 · `$bought` reflects only the LAST video

```php
foreach ($node->field_videos['und'] as $key => $value) {
  $video_nid = $value['target_id'];
  $bought = $boughtFlag->is_flagged($video_nid);   // reassigned every iteration
}
```

After the loop `$bought` holds the state of the **final** video only — not "all videos bought",
which is what the surrounding copy implies (*"you must purchase all lesson for this course"*).
Price/add-to-cart, PDF download and the quiz gate all hang off this single value. Reproduced as-is.

### D-4 · Malformed class on the certificate step (cosmetic)

```php
print "<li class='course-step-item".$quiz_class."'>Download Certificate</li>";
```

No space before `$quiz_class`, so when the quiz is complete it renders
`class="course-step-itemstep-complete"` — matching **neither** `.course-step-item` nor
`.step-complete`. Kept byte-identical; the line is marked in the Twig.

---

## 3. Business logic moved out of the theme layer

D7 ran all of this **inside the template**. D10 computes it in `cetc_d10_preprocess_node()`; output
is unchanged (`D10_ARCHITECTURE.md`).

| D7 in-template operation | D10 equivalent |
|---|---|
| `flag_get_flag('submitted'/'completed'/'enrolled')->is_flagged()` | flag service lookups |
| `flag_get_flag('bought')->is_flagged($video_nid)` in a loop | same loop, same last-wins result (D-3) |
| raw `db_select('quiz_node_results')` ordered by score DESC, time_end DESC | query prepared in preprocess |
| `_ce_referral_uid2ref($user->uid)` + `url(..., absolute)` | `ce_referral` service + `Url::fromUri()` |
| `drupal_add_js` / `drupal_add_css` for `ce_referral` | `#attached` library |
| `field_state` → taxonomy term → `field_pdf` → `file_create_url()` | preprocess resolves the URI |
| `field_hide_pdf == 1` suppresses the PDF | same gate |

**The quiz query must keep both sort keys.** `ORDER BY score DESC, time_end DESC` selects the
best attempt, then the most recent among ties. Dropping the second key would change which
attempt is shown for learners with equal scores.

---

## 4. Hard-coded values preserved verbatim

| Value | Where |
|---|---|
| `https://g.page/r/CWNhZJBkfyAZEAE/review` | Google review button |
| `info@ceonlinetraining.com` | evaluation help text |
| `/cart`, `add-all-to-cart/{nid}`, `buy-course-checkout/{nid}` | cart + checkout routes |
| `/node/{form_id}?nid={course_nid}` | evaluation link — the `nid` query param is load-bearing |
| `75` | pass mark, twice (D-1) |

⚠️ The evaluation link passes the course nid as `?nid=`. This is the counterpart of the
**`cid = 30`** webform mapping (`RISK_REGISTER.md` R15) that `course_actions.module` reads back.
Both halves must survive the migration or course completion stops recording.

---

## 4b. `node--video.tpl.php` → `node--video--full.html.twig`

Small template (~20 lines of real markup): Wistia player, title, body, "Return to course".

### 🛑 The `?course_nid` query parameter is load-bearing

```php
$params = drupal_get_query_parameters();
if (isset($params['course_nid']) && !empty($params['course_nid'])) {
  print l(t('Return to course'), 'node/' . $params['course_nid'], ...);
}
```

The link renders **only** when `?course_nid=` is present and non-empty. **There is no fallback** — a
learner arriving at a video without it gets no way back to the course. Reproduced exactly; adding a
parent-course lookup would be new functionality.

This is also the channel behind the preserved **RB-12b** defect: 18 videos belong to more than one
course (`DECISIONS_PHASE_3.md` N-3), and the session course target is overwritten by whichever
course was visited last. The parameter is how course context travels, so it must keep working as-is.

⚠️ **Cache context added:** `url.query_args:course_nid`. Without it Drupal would serve the
first-cached variant to everyone and the link would show the wrong course — a D10-only failure
mode that does not exist in D7's uncached rendering.

### Preserved: the disabled popup

D7 carries a commented-out `download-pdf-popup` fragment with `style="display: none"`. It renders
nothing. Kept as an inert Twig comment (§30 — theme code is not dropped for scope). **Do not
re-enable it**: that would add a popup the live site does not show.

---

## 4c. `node--course--teaser` — the CE label is hard-coded, not the field

D7 does **not** render `field_ce_or_non_ce`. The line that would is commented out:

```php
//print render($content['field_ce_or_non_ce']);
```

Instead it prints one of two fixed strings inside the markup the field formatter *would* have
produced (so existing `.field-name-field-ce-or-non-ce` CSS still matches):

| `field_ce_or_non_ce` | Rendered text |
|---|---|
| `ce` | `Approved CE Course` |
| anything else **including empty** | `Advanced Training Course (Not CE)` |

⚠️ The fallback is `else`, not `non-ce`. A course with an **empty** field is labelled
*"Advanced Training Course (Not CE)"*, not left blank. Preserved exactly, parentheses included.

---

## 4d. `node--video--teaser` — the entitlement gate learners actually see

### The four-way state machine — **order is the contract**

D7 tests flags in this priority, first match wins:

| # | Condition | Shows |
|---|---|---|
| 1 | `in_progress` | "Watch Video" button **+** `In Progress` |
| 2 | `completed` | `Video Completed` with check icon — **no button** |
| 3 | `bought` | "Watch Video" button |
| 4 | else | add-to-cart **+** price |

> A video flagged **both** `in_progress` and `completed` shows **In Progress**, because
> `in_progress` is tested first. Reordering these branches changes what learners see.

In branches 1–3 D7 calls `hide()` on `add_to_cart_video_field_entity_view_1` and `field_price`, so
neither appears once a lesson is owned.

### 🛑 `arg(1)` is the course context — this IS the RB-12b mechanism

```php
if (arg(0) == 'node' && is_numeric(arg(1))) {
  print '<a href="'.$node_url.'?course_nid='.arg(1).'" ...>';
} else {
  print '<a href="'.$node_url.'" ...>';        // no course_nid at all
}
```

The course nid comes from **the URL currently being viewed**, never from the video's own field
relationships. So "which course does this video belong to" is answered by *whichever course page
the learner happens to be on*.

That is precisely why the **18 videos belonging to multiple courses** (`DECISIONS_PHASE_3.md` N-3)
show the preserved "last course wins" defect — and why `node--video--full` shows "Return to course"
only when `?course_nid` is present: when this template takes the `else` branch the parameter is
never set, and the learner lands on a video with **no way back**.

> **Do not resolve the course from the video's field relationships.** It would change which course
> a learner returns to and would silently repair RB-12b.

**One further D7 subtlety preserved:** the *title* link emits `?course_nid=` + `arg(1)`
**unconditionally** when the video is bought — even with `arg(1)` absent, producing a trailing
`?course_nid=` with an empty value. That empty parameter then fails the `!empty()` test in
`node--video--full`, so no return link appears. Reproduced by leaving `course_arg` empty.

---

## 5. Template naming

D7 `node--course.tpl.php` applied to the course node in **all** view modes. D10 splits by view
mode, so this is `node--course--full.html.twig`. `node--course--teaser.tpl.php` (113 lines) is a
separate file and converts to `node--course--teaser.html.twig` — **not yet done**.

---

## 6. Runtime verification still required

None of this is confirmed. D10 has **0 nodes**. Before it can be called done:

1. Content migration creates course nodes.
2. Render a course page and diff the DOM against the same page on D7.
3. Confirm the stepper classes, including that **Watch Video never shows complete** (D-2) and the
   malformed certificate class (D-4) — a "clean" result there means the defects were wrongly fixed.
4. Verify with a learner whose best score is **exactly 75**: step complete, message absent (D-1).
5. Confirm the referral URL is absolute and matches D7's.
