# Creator Gap Closure — Phase 2 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Harden the content-submission pipeline — put the Phase 0 upload trio where uploads actually happen, put a confirmation in front of every destructive action, and fill the three real holes in campaign detail.

**Architecture:** All work lands in `creator/main.dc.html`. Screens are `<sc-if value="{{ isX }}">` blocks inside `<x-dc>`; flags and handlers are properties on the object returned from `Component`'s render method. The review-detail screen (`route:'review'`) is the main surface this phase touches — its render block is a large nested object built around line 7740-7960.

**Tech Stack:** Plain HTML + CSS custom properties + the `x-dc` template runtime. Tokens in `creator/_ds/startview-design-system-*/tokens/*.css`. Local server: `python3 server.py`, port 8123.

**Spec:** [`docs/specs/2026-08-19-creator-prototype-gap-closure-design.md`](../specs/2026-08-19-creator-prototype-gap-closure-design.md) §5 Phase 2 — **but see "Scope correction" below, which supersedes it.**

## Scope correction (read before starting)

The spec's Phase 2 list was written from the coverage audit. Verifying each item against the code found that **four of the seven were already implemented**. They are removed from this plan. This is the same class of error that caused a Phase 1 task to be built and then deleted; do not re-add them.

| Spec item | Reality | Verdict |
|---|---|---|
| Raw-footage URL registration | `stepUrl` exists (line ~7755) with copy "네이버 블로그는 자동 연결이 안 돼 주소로 받아요" and a `URL 등록하기` CTA | **Already done — dropped** |
| Apply form: itemized consent expansion | `조건 4개 자세히 보기` + `applyDetailOpen` + `agreements` list (lines ~2172-2180) | **Already done — dropped** |
| Apply form: raw-footage toggle | `영상 원본 제출` row (line ~7727) plus `showRawUpload`/`toggleRaw` (lines ~7759-7766) | **Already done — dropped** |
| Campaign detail: image expand/collapse | `detailArtOpen` / `detailArtLabel: '상세 이미지 더보기'` (lines ~8006-8008) | **Already done — dropped** |
| My-campaigns three-way tab restructure | The prototype deliberately solves this differently — a "내 차례" panel plus `myFilter` chips plus a separate 지난 캠페인 screen | **Deferred pending a product decision — see below** |
| Review submission with the upload trio | A label-toggle picker exists; there is no progress, no filename, no size/format error | **Real gap — Task 1** |
| Courier tracking link-out | 택배사 / 송장번호 render with copy-to-clipboard; no link-out exists | **Real gap — Task 4** |

Three further real gaps were found that the spec did not list, and they are included: campaign detail has no store contact, no recommendations, and no closed-campaign state.

**The my-campaigns tab item is deliberately NOT in this plan.** The existing product uses three tabs (`제작 필요` / `전체` / `미선정·취소`); the prototype answers the same question with a my-turn panel and filter chips. Converting one into the other is a design decision, not a gap to close, and doing it would discard a deliberate prototype choice. It needs a human ruling before anyone implements it.

## Global Constraints

- **All edits in `creator/main.dc.html`.** No new files. Nothing under `startview-platform`.
- **Branch:** `creator-phase2`.
- **No test runner exists.** "Verify" means serve the prototype and observe it. Never write pytest/jest steps.
- **Commit messages Korean** (`feat(creator): …` / `fix(creator): …`). No AI attribution, no `Co-Authored-By`.
- **Colors/spacing/radius from CSS custom properties only.** Hardcoded hex is a defect except `#fff` on dark button fills.
- **Korean copy**, `class="sv-kr"` on Korean text that wraps.
- **Reuse Phase 0 primitives.** The canonical instances live in the dev gallery (`route:'devStates'`). A near-copy that drifts in spacing or structure is a defect — that specific failure was caught twice in Phase 1.

### Verified codebase idioms — do not deviate

1. Back handler is `back`. There is no `goBack`.
2. No `disabled` attribute anywhere. Guard inside the handler (`if (blocked) return;`) and swap the button background, as `submitPayout` does.
3. `<sc-for list="{{ rows }}" as="row" hint-placeholder-count="N">`. There is no `each=`.
4. Event handlers take the event: `(e) => … e.target.value`.
5. Partial interpolation inside a style attribute works (`style="…;background:{{ row.bg }}"`).
6. Destructive actions go through `askConfirm({title, body, reversible, confirmLabel, onConfirm})`. The confirm button invokes `onConfirm` then closes.
7. Row-state changes use a patch helper into component state (`patchApp` for applications, `patchProp` for proposals). Never mutate a module-level seed array in place.

## How to verify anything in this plan

Start the server once from the repo root:

```bash
python3 server.py
```

Then, for every check:

1. **Byte parity** — `curl -s http://127.0.0.1:8123/creator/main.dc.html | wc -c` must equal `wc -c < creator/main.dc.html`. Use `127.0.0.1`, never `localhost`: a stale IPv6 listener on this port has previously served a different worktree's file and produced false passes.
2. Navigate to `http://127.0.0.1:8123/creator/main.dc.html?cb=<number>` — the cache-buster is required.
3. First `javascript_tool` call after navigating, paste the accessor:

```js
(function(){
  window.svLogic=function(){const host=document.querySelector('[data-sc-name="main"]')||document.body;
    const key=Object.keys(host).find(k=>k.startsWith('__reactFiber$'));let f=host[key];
    while(f){if(f.stateNode&&f.stateNode.logic&&f.stateNode.logic.setState)return f.stateNode.logic;f=f.return;}return null;};
  var l=window.svLogic(); l.setState({auth:null, tab:'campaigns'}); return {ok:!!l};
})()
```

4. **Read the DOM in a SEPARATE call.** `setState` re-renders asynchronously; a same-call read returns the stale tree. This is the most common false negative here.
5. **Assert on rendered element text, never `innerHTML.indexOf`** — the page's own `<script>` source sits inside `document.body`, so any Korean string that is also a JS literal false-positives:

```js
Array.from(document.querySelectorAll('span,h1,h2,p,button,label')).some(n=>n.textContent.includes('<korean>'))
```

6. `read_console_messages {"onlyErrors":true}` — ignore `/api/comments` 404s (pre-existing polling). Anything else is a broken binding.

**Known pre-existing, not yours to fix:** a react-dom crash if `channels` is seeded with an incomplete shape (missing `platform`/`handle`).

---

## File Structure

One file. Five regions, same as Phase 1:

| Region | Approx. lines | Contents |
|---|---|---|
| `<style>` | 1-75 | Global CSS, keyframes, `.sv-*` utilities |
| Markup inside `<x-dc>` | 76-4620 | Every `<sc-if>` screen, including the review screen (~3100-3320) and the dev gallery (~4350-4460) |
| Seed data | 4700-5300 | `OFFERS`, `SEED`, `PAST`, `PROPOSAL_ROWS`, status maps |
| `class Component` | 5380-5700 | `state`, navigation, `askConfirm`, `patchApp`, `patchProp` |
| Render object | 5700-end | Every flag and handler; the review block is ~7740-7960 |

---

## Task 1: Upload trio on review submission

The largest task, and the reason Phase 0 built the trio. Today the review screen's uploader is a label toggle: `uploads: [['검수 영상','브랜드 사전 확인용','shield-checkmark-outline']]` with an `onPick` that flips a string in an array (lines ~7767-7776). There is no progress, no filename, no size or format failure. A creator cannot tell a successful upload from a tapped label.

**Files:**
- Modify: `creator/main.dc.html` — `state` (~5386), review render block (~7767), review markup (~3100-3160)

**Interfaces:**
- Consumes: `sv-up-idle` / `sv-up-busy` / `sv-up-fail` markup from the dev gallery (~4390-4425)
- Produces: state `uploadState` (one of `null | 'busy' | 'done' | 'tooBig' | 'badType'`), `uploadName`, `uploadPct`; render flags `upIdle`, `upBusy`, `upDone`, `upTooBig`, `upBadType`, `upName`, `upPct`; handlers `simulateUpload(kind)`, `cancelUpload()`, `resetUpload()`

- [ ] **Step 1: State the observable check**

The review screen's upload area shows the constraints (`MP4·MOV · 500MB 이하`) before any attempt, and can be driven through progress, success, over-size, and wrong-format states.

- [ ] **Step 2: Confirm it fails**

```bash
grep -c "uploadState" creator/main.dc.html
```

Expected: `0`.

- [ ] **Step 3: Add state**

In `state = {`, beside `reviewFiles:[], reviewPassed:false,` add:

```js
    uploadState:null, uploadName:'', uploadPct:0,
```

- [ ] **Step 4: Add the handlers**

In `class Component`, after `askConfirm`/`closeConfirm`, add. This is a prototype, so the upload is simulated on a timer — that is deliberate, not a shortcut:

```js
  /** 업로드는 프로토타입이라 타이머로 흉내 낸다 — 화면이 보여줘야 하는 건
   *  진행률·파일명·실패 복구지, 실제 전송이 아니다. */
  simulateUpload(kind) {
    if (kind === 'tooBig') return this.setState({ uploadState:'tooBig', uploadName:'리뷰_최종본_0819.mp4' });
    if (kind === 'badType') return this.setState({ uploadState:'badType', uploadName:'리뷰_썸네일.png' });
    this.setState({ uploadState:'busy', uploadName:'리뷰_최종본_0819.mp4', uploadPct:0 });
    const tick = () => this.setState((st) => {
      if (st.uploadState !== 'busy') return null;
      const pct = Math.min(100, st.uploadPct + 20);
      if (pct >= 100) return { uploadPct:100, uploadState:'done' };
      this.after(320, tick);
      return { uploadPct: pct };
    });
    this.after(320, tick);
  }
  cancelUpload() { this.setState({ uploadState:null, uploadName:'', uploadPct:0 }); }
  resetUpload() { this.setState({ uploadState:null, uploadName:'', uploadPct:0 }); }
```

- [ ] **Step 5: Add the render flags**

Inside the review render block, beside `uploads:`, add:

```js
          upIdle: !s.uploadState,
          upBusy: s.uploadState === 'busy',
          upDone: s.uploadState === 'done',
          upTooBig: s.uploadState === 'tooBig',
          upBadType: s.uploadState === 'badType',
          upName: s.uploadName,
          upPct: s.uploadPct + '%',
          upBarStyle: { display:'block', width: s.uploadPct + '%', height:'100%', background:'var(--ink)' },
          onPickFile: () => this.simulateUpload('ok'),
          onPickTooBig: () => this.simulateUpload('tooBig'),
          onPickBadType: () => this.simulateUpload('badType'),
          onCancelUpload: () => this.cancelUpload(),
          onResetUpload: () => this.resetUpload(),
```

- [ ] **Step 6: Replace the label toggle with the trio markup**

In the review markup, replace the existing uploads block with the four states. Copy each block from the dev gallery verbatim and bind it — do not restyle. The idle block keeps the two extra trigger buttons so a reviewer can reach the failure states; label them plainly as prototype affordances:

```html
                    <sc-if value="{{ review.upIdle }}" hint-placeholder-val="{{ true }}">
                    <div style="display:flex;flex-direction:column;align-items:center;text-align:center;padding:28px 20px;border-radius:var(--radius-xl);border:1px dashed var(--hairline);background:var(--surface-1)">
                      <x-import component-from-global-scope="StartViewDesignSystem_7416ce.Icon" name="upload" variant="line" size="24px" color="var(--text-secondary)" hint-size="24px,24px"></x-import>
                      <span class="sv-kr" style="margin-top:10px;font:600 14px/19px var(--font-body-semibold),var(--font-fallback-kr);color:var(--ink)">검수 영상을 올려주세요</span>
                      <span class="sv-kr" style="margin-top:5px;font:400 12px/17px var(--font-body),var(--font-fallback-kr);color:var(--text-tertiary)">MP4·MOV · 500MB 이하</span>
                      <button onClick="{{ review.onPickFile }}" style="margin-top:14px;height:40px;padding:0 18px;border-radius:var(--radius-lg);background:var(--ink);font:600 13px/1.2 var(--font-body-semibold),var(--font-fallback-kr);color:#fff">파일 선택</button>
                      <div style="display:flex;gap:8px;margin-top:10px">
                        <button onClick="{{ review.onPickTooBig }}" style="font:400 11px/1.2 var(--font-body),var(--font-fallback-kr);color:var(--text-tertiary);text-decoration:underline">용량 초과 예시</button>
                        <button onClick="{{ review.onPickBadType }}" style="font:400 11px/1.2 var(--font-body),var(--font-fallback-kr);color:var(--text-tertiary);text-decoration:underline">형식 오류 예시</button>
                      </div>
                    </div>
                    </sc-if>
                    <sc-if value="{{ review.upBusy }}" hint-placeholder-val="{{ true }}">
                    <div style="padding:14px;border-radius:var(--radius-xl);background:var(--surface-1)">
                      <div style="display:flex;align-items:center;gap:10px">
                        <span class="sv-kr" style="flex:1;min-width:0;font:500 13px/18px var(--font-body-medium),var(--font-fallback-kr);color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ review.upName }}</span>
                        <span style="flex:none;font:600 12px/1.2 var(--font-body-semibold);color:var(--text-secondary)">{{ review.upPct }}</span>
                        <button onClick="{{ review.onCancelUpload }}" style="flex:none;display:flex"><x-import component-from-global-scope="StartViewDesignSystem_7416ce.Icon" name="close" variant="line" size="16px" color="var(--text-tertiary)" hint-size="16px,16px"></x-import></button>
                      </div>
                      <span style="display:block;margin-top:10px;height:4px;border-radius:var(--radius-pill);background:var(--surface-2);overflow:hidden"><span style="{{ review.upBarStyle }}"></span></span>
                    </div>
                    </sc-if>
                    <sc-if value="{{ review.upDone }}" hint-placeholder-val="{{ true }}">
                    <div style="display:flex;align-items:center;gap:10px;padding:14px;border-radius:var(--radius-xl);background:var(--status-passed-bg)">
                      <x-import component-from-global-scope="StartViewDesignSystem_7416ce.Icon" set="ionicon" name="checkmark-circle" size="18px" color="var(--success)" hint-size="18px,18px"></x-import>
                      <span class="sv-kr" style="flex:1;min-width:0;font:600 13px/18px var(--font-body-semibold),var(--font-fallback-kr);color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ review.upName }}</span>
                      <button onClick="{{ review.onResetUpload }}" style="flex:none;padding:7px 12px;border-radius:var(--radius-sm);background:var(--canvas);font:600 12px/1.2 var(--font-body-semibold),var(--font-fallback-kr);color:var(--ink)">다시 선택</button>
                    </div>
                    </sc-if>
                    <sc-if value="{{ review.upTooBig }}" hint-placeholder-val="{{ true }}">
                    <div style="display:flex;align-items:center;gap:10px;padding:14px;border-radius:var(--radius-xl);background:var(--status-cancel-bg)">
                      <x-import component-from-global-scope="StartViewDesignSystem_7416ce.Icon" name="alert" variant="line" size="18px" color="var(--error)" hint-size="18px,18px"></x-import>
                      <span style="flex:1;min-width:0">
                        <span class="sv-kr" style="display:block;font:600 13px/18px var(--font-body-semibold),var(--font-fallback-kr);color:var(--ink)">파일이 너무 커요 — 620MB</span>
                        <span class="sv-kr" style="display:block;margin-top:2px;font:400 12px/17px var(--font-body),var(--font-fallback-kr);color:var(--text-secondary)">500MB 이하로 줄여서 다시 올려주세요.</span>
                      </span>
                      <button onClick="{{ review.onResetUpload }}" style="flex:none;padding:7px 12px;border-radius:var(--radius-sm);background:var(--canvas);font:600 12px/1.2 var(--font-body-semibold),var(--font-fallback-kr);color:var(--ink)">다시 선택</button>
                    </div>
                    </sc-if>
                    <sc-if value="{{ review.upBadType }}" hint-placeholder-val="{{ true }}">
                    <div style="display:flex;align-items:center;gap:10px;padding:14px;border-radius:var(--radius-xl);background:var(--status-cancel-bg)">
                      <x-import component-from-global-scope="StartViewDesignSystem_7416ce.Icon" name="alert" variant="line" size="18px" color="var(--error)" hint-size="18px,18px"></x-import>
                      <span style="flex:1;min-width:0">
                        <span class="sv-kr" style="display:block;font:600 13px/18px var(--font-body-semibold),var(--font-fallback-kr);color:var(--ink)">영상 파일만 올릴 수 있어요</span>
                        <span class="sv-kr" style="display:block;margin-top:2px;font:400 12px/17px var(--font-body),var(--font-fallback-kr);color:var(--text-secondary)">MP4 또는 MOV 파일을 선택해주세요.</span>
                      </span>
                      <button onClick="{{ review.onResetUpload }}" style="flex:none;padding:7px 12px;border-radius:var(--radius-sm);background:var(--canvas);font:600 12px/1.2 var(--font-body-semibold),var(--font-fallback-kr);color:var(--ink)">다시 선택</button>
                    </div>
                    </sc-if>
```

- [ ] **Step 7: Keep the submit CTA honest**

`reviewBtnLabel` (~7777) currently reads `(s.reviewFiles || []).length ? … : '파일을 올려주세요'`. Change its condition to `s.uploadState === 'done'` so the CTA unlocks on a completed upload rather than a toggled label. Leave the label strings themselves unchanged.

- [ ] **Step 8: Confirm it passes**

Reach the review screen by clicking from 내 협업. Confirm: constraints visible before any tap; 파일 선택 drives progress with filename and percentage to 100% then success; cancel during progress returns to idle; both failure examples show a recovery action; the submit CTA is inert until the upload completes.

- [ ] **Step 9: Commit**

```bash
git add creator/main.dc.html
git commit -m "feat(creator): 검수 영상 업로드에 진행률·실패 복구 상태 적용"
```

---

## Task 2: Confirm before cancelling participation

`footer: '리뷰 참여 취소'` (~7937) renders a destructive action. Phase 0 built `askConfirm` for exactly this, and the dev gallery's demo already carries the right copy — but the real action does not use it.

**Files:**
- Modify: `creator/main.dc.html` — review render block (~7937), review markup footer (~3315)

**Interfaces:**
- Consumes: `askConfirm({title, body, reversible, confirmLabel, onConfirm})`; `patchApp`
- Produces: handler `onCancelParticipation`

- [ ] **Step 1: State the observable check**

Tapping 리뷰 참여 취소 opens the shared confirm sheet; confirming changes the collaboration's status; dismissing changes nothing.

- [ ] **Step 2: Confirm it fails**

```bash
grep -c "onCancelParticipation" creator/main.dc.html
```

Expected: `0`. Also confirm the footer control currently has no confirm step by reading the markup near line 3315.

- [ ] **Step 3: Add the handler**

In the review render block:

```js
          onCancelParticipation: () => this.askConfirm({
            title:'리뷰 참여를 취소할까요?',
            body:'지금 취소하면 이 캠페인의 선정이 사라지고, 다시 신청해야 해요.',
            reversible:false, confirmLabel:'참여 취소',
            onConfirm: () => this.patchApp(a.id, { status:'취소', detail:'내가 참여 취소' }) }),
```

Use whatever field names `patchApp` already writes for a cancelled application — read an existing `patchApp` call before writing this one, and match it.

- [ ] **Step 4: Bind it in the markup**

Bind the footer control at ~3315 to `{{ review.onCancelParticipation }}`.

- [ ] **Step 5: Confirm it passes**

From 내 협업 open a `제작 필요` collaboration, tap 리뷰 참여 취소, confirm the sheet appears with the irreversible note, dismiss it and confirm the status is unchanged, then repeat and confirm — the status must change and the change must survive navigating away and back.

- [ ] **Step 6: Commit**

```bash
git add creator/main.dc.html
git commit -m "fix(creator): 리뷰 참여 취소에 확인 시트 연결"
```

---

## Task 3: Confirm before cancelling a reservation

`onResCancel: () => this.patchApp(a.id, …)` (~3250 binding) cancels immediately with no confirmation.

**Files:**
- Modify: `creator/main.dc.html` — review render block (`onResCancel`)

**Interfaces:**
- Consumes: `askConfirm`, `patchApp`
- Produces: `onResCancel` rewritten to route through the sheet

- [ ] **Step 1: State the observable check**

Tapping 예약 취소 opens the confirm sheet rather than cancelling outright.

- [ ] **Step 2: Confirm it fails**

Read the current `onResCancel` definition and confirm it calls `patchApp` directly with no `askConfirm`.

- [ ] **Step 3: Wrap it**

Keep the existing `patchApp` call exactly as it is — move it inside `onConfirm`:

```js
          onResCancel: () => this.askConfirm({
            title:'예약을 취소할까요?',
            body:'취소하면 잡아둔 방문 시간이 사라져요. 다시 예약할 수 있어요.',
            reversible:true, confirmLabel:'예약 취소',
            onConfirm: () => { /* the existing patchApp(...) call, unchanged */ } }),
```

`reversible:true` here is correct and differs from Task 2 deliberately — a reservation can be rebooked, a participation cannot be un-cancelled. Do not copy Task 2's flag.

- [ ] **Step 4: Confirm it passes**

Open a visit-type collaboration with a reservation, tap 예약 취소, confirm the sheet says the reservation can be made again, dismiss and confirm nothing changed, then confirm and check the reservation is gone.

- [ ] **Step 5: Commit**

```bash
git add creator/main.dc.html
git commit -m "fix(creator): 예약 취소에 확인 시트 연결"
```

---

## Task 4: Courier tracking link-out

The tracking number renders with copy-to-clipboard (markup ~3170-3185) because, per the code's own comment, it is "a value you carry over to the courier's tracking page". Carrying it by hand is the friction this removes.

**Files:**
- Modify: `creator/main.dc.html` — review render block, delivery markup (~3179)

**Interfaces:**
- Consumes: seed fields `courier`, `tracking` (~4861)
- Produces: render flag `trackHref`, handler `onOpenTracking`

- [ ] **Step 1: State the observable check**

Next to the tracking number sits a control that opens the courier's tracking page in a new tab, with the number already in the URL.

- [ ] **Step 2: Confirm it fails**

```bash
grep -c "trackHref" creator/main.dc.html
```

Expected: `0`.

- [ ] **Step 3: Add the flag and handler**

In the review render block, near the existing delivery fields:

```js
          // 택배사별 조회 URL. 프로토타입이라 대표 3사만 둔다 — 없으면 링크를 감춘다.
          trackHref: ({
            'CJ대한통운':'https://trace.cjlogistics.com/next/tracking.html?wblNo=',
            '한진택배':'https://www.hanjin.co.kr/kor/CMS/DeliveryMgr/WaybillResult.do?mCode=MN038&schLang=KR&wblnumText2=',
            '롯데택배':'https://www.lotteglogis.com/home/reservation/tracking/linkView?InvNo=',
          }[a.courier] || '') + (a.tracking || ''),
          canTrack: !!a.courier && !!a.tracking,
```

- [ ] **Step 4: Add the control**

Beside the existing copy control on the 송장번호 row, add a link-out. Keep the existing copy control — both are useful, and copy is the fallback when the courier is not one of the three:

```html
                      <sc-if value="{{ review.canTrack }}" hint-placeholder-val="{{ true }}">
                      <a href="{{ review.trackHref }}" target="_blank" rel="noopener" style="flex:none;padding:6px 10px;border-radius:var(--radius-sm);background:var(--surface-2);font:600 12px/1.2 var(--font-body-semibold),var(--font-fallback-kr);color:var(--ink)">배송 조회</a>
                      </sc-if>
```

- [ ] **Step 5: Confirm it passes**

Open a delivery-type collaboration with a tracking number. Confirm the 배송 조회 control renders, and that its `href` contains both the courier's base URL and the tracking number — read the attribute, do not just look at it. Confirm it does not render when the seed has no courier.

- [ ] **Step 6: Commit**

```bash
git add creator/main.dc.html
git commit -m "feat(creator): 송장번호에서 택배사 배송조회로 바로 이동"
```

---

## Task 5: Campaign detail — store contact

Visit-type campaigns send a creator to a physical place; the detail screen currently gives them no way to phone ahead or check the store. The existing product surfaces `매장 연락처/홈페이지`.

**Files:**
- Modify: `creator/main.dc.html` — `OFFERS` seed (~4790), detail render block (~8006), detail markup

**Interfaces:**
- Produces: seed fields `storePhone`, `storeSite`, `storeAddr` on visit-type offers; render flags `hasStore`, `storePhone`, `storeSite`, `storeAddr`

- [ ] **Step 1: State the observable check**

A visit-type campaign's detail screen shows the store's address, a tappable phone number, and a link to its page. A delivery-type campaign shows none of it.

- [ ] **Step 2: Confirm it fails**

```bash
grep -c "storePhone" creator/main.dc.html
```

Expected: `0`.

- [ ] **Step 3: Extend the seed**

Find the visit-type offers in `OFFERS` (they are the ones the review screen treats as `isVisit`) and add to each:

```js
    storeAddr:'서울 성동구 왕십리로 000', storePhone:'02-000-0000', storeSite:'https://example.com',
```

Add these to visit-type offers only. A delivery campaign with a store address is a data error that will show up as a rendered section that should not exist.

- [ ] **Step 4: Add the render flags**

```js
      hasStore: !!(this.offer(s.offerId) || {}).storePhone,
      storeAddr: (this.offer(s.offerId) || {}).storeAddr || '',
      storePhone: (this.offer(s.offerId) || {}).storePhone || '',
      storeSite: (this.offer(s.offerId) || {}).storeSite || '',
```

- [ ] **Step 5: Add the markup**

In the campaign detail screen, after the existing schedule/provides sections:

```html
      <sc-if value="{{ hasStore }}" hint-placeholder-val="{{ true }}">
      <div style="margin-top:24px;padding:16px;border-radius:var(--radius-xl);background:var(--surface-1)">
        <span class="sv-kr" style="display:block;font:600 12px/1.2 var(--font-body-semibold),var(--font-fallback-kr);color:var(--text-tertiary)">매장 정보</span>
        <span class="sv-kr" style="display:block;margin-top:8px;font:400 14px/21px var(--font-body),var(--font-fallback-kr);color:var(--ink)">{{ storeAddr }}</span>
        <div style="display:flex;gap:8px;margin-top:12px">
          <a href="tel:{{ storePhone }}" style="flex:1;height:42px;border-radius:var(--radius-lg);background:var(--canvas);display:flex;align-items:center;justify-content:center;font:600 13px/1.2 var(--font-body-semibold),var(--font-fallback-kr);color:var(--ink)">전화하기</a>
          <a href="{{ storeSite }}" target="_blank" rel="noopener" style="flex:1;height:42px;border-radius:var(--radius-lg);background:var(--canvas);display:flex;align-items:center;justify-content:center;font:600 13px/1.2 var(--font-body-semibold),var(--font-fallback-kr);color:var(--ink)">매장 페이지</a>
        </div>
      </div>
      </sc-if>
```

- [ ] **Step 6: Confirm it passes**

Open a visit-type campaign detail and confirm the section renders with a `tel:` href carrying the number. Open a delivery-type campaign and confirm the section is absent.

- [ ] **Step 7: Commit**

```bash
git add creator/main.dc.html
git commit -m "feat(creator): 방문형 캠페인 상세에 매장 정보 · 전화 · 페이지"
```

---

## Task 6: Campaign detail — closed-campaign state

A campaign whose recruitment has ended currently renders identically to an open one, with a live apply CTA.

**Files:**
- Modify: `creator/main.dc.html` — `OFFERS` seed, detail render block, detail CTA markup

**Interfaces:**
- Produces: seed field `closed:true` on one offer; render flags `offerClosed`, `applyBtnStyle`, `applyBtnLabel`

- [ ] **Step 1: State the observable check**

A closed campaign shows a closed notice and its apply CTA is visibly inert and does nothing when tapped.

- [ ] **Step 2: Confirm it fails**

```bash
grep -c "offerClosed" creator/main.dc.html
```

Expected: `0`.

- [ ] **Step 3: Seed one closed offer**

Add `closed:true,` to exactly one offer in `OFFERS` so the state is reachable. Note which id you chose in your report.

- [ ] **Step 4: Add the flags**

```js
      offerClosed: !!(this.offer(s.offerId) || {}).closed,
      applyBtnLabel: (this.offer(s.offerId) || {}).closed ? '모집이 끝났어요' : '캠페인 신청하기',
      applyBtnStyle: { width:'100%', height:52, borderRadius:'var(--radius-lg)',
        display:'flex', alignItems:'center', justifyContent:'center',
        background: (this.offer(s.offerId) || {}).closed ? 'var(--surface-2)' : 'var(--ink)',
        color: (this.offer(s.offerId) || {}).closed ? 'var(--text-tertiary)' : '#fff',
        font:'var(--type-button)' },
```

- [ ] **Step 5: Guard the handler and bind the style**

Find the detail screen's apply handler and add `if (this.offer(this.state.offerId) || {}).closed) return;` as its first line — per idiom 2, blocked controls guard in-handler rather than using `disabled`. Bind the CTA to `style="{{ applyBtnStyle }}"` and `{{ applyBtnLabel }}`.

- [ ] **Step 6: Add the closed notice**

Above the CTA:

```html
      <sc-if value="{{ offerClosed }}" hint-placeholder-val="{{ true }}">
      <p class="sv-kr" style="padding-bottom:10px;text-align:center;font:500 12px/17px var(--font-body-medium),var(--font-fallback-kr);color:var(--text-tertiary)">이 캠페인은 모집이 끝났어요. 비슷한 캠페인을 찾아보세요.</p>
      </sc-if>
```

- [ ] **Step 7: Confirm it passes**

Open the closed offer: notice renders, CTA is grey and reads 모집이 끝났어요, and tapping it does nothing (confirm the route did not change). Open any other offer and confirm the CTA still works normally.

- [ ] **Step 8: Commit**

```bash
git add creator/main.dc.html
git commit -m "feat(creator): 종료된 캠페인 상태 — 안내와 비활성 CTA"
```

---

## Task 7: Campaign detail — recommendations

The existing product ends campaign detail with `이런 캠페인 어때요?`. It matters most on the closed state built in Task 6, where the creator has nowhere to go.

**Files:**
- Modify: `creator/main.dc.html` — detail render block, detail markup

**Interfaces:**
- Consumes: `OFFERS`, `openOffer(id)`, `offerClosed` (Task 6)
- Produces: render flag `recoOffers` (array of `{id, brand, title, thumb, point, onOpen}`)

- [ ] **Step 1: State the observable check**

Campaign detail ends with a horizontal row of other campaigns, each tappable into its own detail.

- [ ] **Step 2: Confirm it fails**

```bash
grep -c "recoOffers" creator/main.dc.html
```

Expected: `0`.

- [ ] **Step 3: Add the flag**

```js
      // 같은 카테고리를 먼저, 모자라면 아무거나. 지금 보고 있는 건 빼고 최대 6개.
      recoOffers: (() => {
        const cur = this.offer(s.offerId) || {};
        const rest = OFFERS.filter((o) => o.id !== cur.id && !o.closed);
        const same = rest.filter((o) => o.cat === cur.cat);
        return same.concat(rest.filter((o) => o.cat !== cur.cat)).slice(0, 6).map((o) => ({
          ...o, pointLine: (o.point || 0).toLocaleString() + 'P', onOpen: () => this.openOffer(o.id) }));
      })(),
```

- [ ] **Step 4: Add the markup**

Use the existing horizontal row idiom (`class="sv-row"`), which already handles drag-scroll:

```html
      <div style="margin-top:28px">
        <h2 class="sv-kr" style="padding:0 16px;font:600 15px/20px var(--font-body-semibold),var(--font-fallback-kr);color:var(--ink)">이런 캠페인은 어때요?</h2>
        <div class="sv-row" style="display:flex;gap:10px;padding:12px 16px 0">
          <sc-for list="{{ recoOffers }}" as="ro" hint-placeholder-count="3">
          <button onClick="{{ ro.onOpen }}" style="flex:none;width:132px;text-align:left">
            <img src="{{ ro.thumb }}" alt="" style="width:132px;height:132px;object-fit:cover;border-radius:var(--radius-xl);display:block">
            <span class="sv-kr" style="display:block;margin-top:8px;font:600 13px/17px var(--font-body-semibold),var(--font-fallback-kr);color:var(--ink)">{{ ro.brand }}</span>
            <span class="sv-kr" style="display:block;margin-top:2px;font:400 12px/16px var(--font-body),var(--font-fallback-kr);color:var(--text-tertiary)">{{ ro.pointLine }}</span>
          </button>
          </sc-for>
        </div>
      </div>
```

- [ ] **Step 5: Confirm it passes**

Open a campaign detail, scroll to the bottom, confirm the row renders with other campaigns and excludes the current one. Tap one and confirm the detail switches to it. Then open the closed offer from Task 6 and confirm the row is present there too — that is the case it exists for.

- [ ] **Step 6: Commit**

```bash
git add creator/main.dc.html
git commit -m "feat(creator): 캠페인 상세에 추천 캠페인 줄 추가"
```

---

## Phase 2 gate

Walk these by clicking, not by setting state:

- **Submission** — 내 협업 → a `제작 필요` collaboration → upload through progress to success → submit CTA unlocks only then.
- **Upload failures** — both failure examples show a recovery action and return to idle.
- **Destructive actions** — 리뷰 참여 취소 and 예약 취소 both open the confirm sheet; dismissing changes nothing; confirming changes the row and the change persists across navigation.
- **Delivery** — 배송 조회 opens with the tracking number in the URL.
- **Campaign detail** — a visit-type offer shows store info; the closed offer shows its notice with an inert CTA; the recommendation row renders on both and navigates.

Then `read_console_messages {"onlyErrors":true}` on each surface. Only `/api/comments` 404s are acceptable.

---

## Self-review notes

**Spec coverage.** Spec §5 Phase 2 lists seven items. Four are already implemented and are documented as dropped in "Scope correction" with the evidence. One (my-campaigns tabs) is deferred pending a product decision and is deliberately absent. The remaining two map to Tasks 1 and 4. Tasks 2, 3, 5, 6, 7 are real gaps the spec did not list, found while verifying it.

**Deferred to a later plan.** Spec §5 Phases 3-5. Spec §8's three open questions all touch Phase 5 and block nothing here.

**Naming consistency.** `askConfirm` (Phase 0) is consumed unchanged by Tasks 2 and 3, both supplying `onConfirm`, which exists as of commit `4765ad4`. `patchApp` is the established row-patch helper and is used by Tasks 2 and 3 rather than mutating seeds. `openOffer(id)` (existing) is reused by Task 7. The upload markup in Task 1 is copied from the dev gallery's canonical blocks rather than restyled.

**Known soft spots.**
- Task 2 tells the implementer to read an existing `patchApp` call and match its field names rather than inventing them; the exact cancelled-state fields were not pinned down while writing this plan.
- Task 4's courier URL map covers three carriers. Any other courier renders no link and falls back to the existing copy control, which is why that control is kept.
- Task 6 requires seeding `closed:true` on one offer; whichever offer is chosen will stop appearing in Task 7's recommendation row, since that filter excludes closed offers. That is intended, not a conflict.
