FeaturesStore (매장)Store Broadcast Now (/store/broadcast/now)

Store Broadcast Now — /store/broadcast/now (점장 즉시방송 작성)

즉시방송 슬라이스. 시안: TTS 탭 = design_handoff_linkmusic/design/screens/broadcast-now.jsx · 녹음/템플릿 탭 = design_handoff_linkmusic_8/.../store-broadcast-{recording,templates}.jsx(§8E-②·③, 시안 정합 완료). 전제: BE previewStoreBroadcast·sendStoreBroadcast(V24 tts_announcement.source·created_by_store_id) 라이브. player 변경 0.

Overview

점장(STORE_MANAGER)이 apps/space /store/broadcast/now 에서 방송을 만들어 미리듣기한 뒤 자기 매장 스피커로 송출하는 화면. 입력 방식이 탭 3종이다 — (1) TTS 탭(텍스트→합성), (2) 녹음 탭(마이크 녹음→업로드), (3) 자주 쓰는 방송 탭(템플릿 CRUD·로드). (1)·(2) 두 탭은 같은 미리듣기 게이트·[송출하기]·“송출 후 취소 불가” 경고를 공유하고, (3) 템플릿 탭은 저장해둔 안내를 TTS 탭으로 불러와(로드) 재사용한다. 베타 게이트(“본사 세팅→점장 재생 end-to-end”)에 더해 점장이 직접 매장 안내를 내보내는 첫 화면이다.

범위: TTS 탭 + 녹음 탭 + 자주 쓰는 방송(템플릿) 탭 + 긴급방송 옵션(SPEC #082) + 예약방송 옵션(SPEC #083 — 즉시/예약 모드 토글, 본사 #078 스케줄 인프라 재사용). 긴급은 플래그 적재 + 시안 UI + 응답 노출까지(player 인터럽트 재생은 F1, 본사 audit 누적은 F2 후속). 예약은 점장 등록 + 본사 디스패처 자동 픽업 + 본사 점유 정확-시각 충돌 차단(#109) + 5분 슬롯 그리드(ReservationSlotGrid — 빈 시간대 1탭 선택, 점유/지난 슬롯 비활성)까지(반복 차단·전용 점유 조회는 슬롯 모델 후속 · 취소·목록 view 는 #091·#092 라이브). 송출되면 기존 점장 player(/store)가 pending 폴링으로 자동 재생하므로 player 는 변경하지 않는다.

시안 출처: TTS 탭 = workspace parent dir design_handoff_linkmusic/design/screens/broadcast-now.jsx §tab==='tts'(안내 내용 textarea + 카운터·목소리 select·미리듣기 게이트 footer·“송출 후 취소 불가” 카피). 녹음 탭·자주 쓰는 방송 탭은 design_8 핸드오프로 시안 정합 완료design_handoff_linkmusic_8/design/screens/ store-broadcast-recording.jsx(§8E-②)·store-broadcast-templates.jsx(§8E-③). 인터랙션은 §8E-① 확정대로(시작/정지 = 탭 토글, 행 탭 = 로드, 삭제 = 인라인 2-step). 시각만 시안 정합 — 동작·API· 검증·에러매핑·접근성 로직은 보존. [[feedback_design_only_from_handoff]].

2-step 미리듣기 게이트 (Critical state)

[텍스트 1~200자 + 목소리 5종]
        │  ① [미리듣기]

usePreviewStoreBroadcast  →  POST /api/v1/store/broadcasts/preview
  body BroadcastPreviewRequest{ text, voice }
        │  201 BroadcastPreviewResponse{ announcementId, audioUrl, durationSeconds? }

화면 내 <audio src=audioUrl> 재생(들어보기) + [송출하기] 활성화
        │  ② [송출하기]  (previewedAnnouncementId 보유 시에만)

useSendStoreBroadcast  →  POST /api/v1/store/broadcasts/{announcementId}/send
        │  201 BroadcastSendResponse{ dispatchId }

"송출됐습니다. 곧 매장 스피커에서 재생됩니다." + 입력 리셋
        ▼  (별도 흐름 — player 무변경)
점장 player(/store) pending 폴링이 잡아 곡 끝에 삽입 재생 → ack
  • 미리듣기 전 [송출하기] 비활성previewedAnnouncementId === null 이면 disabled. 잘못된 방송이 매장에 그대로 나가지 않게 미리듣기를 강제한다(시안 PreviewGate).
  • 재미리듣기 무효화 — 미리듣기 후 텍스트/목소리를 바꾸면 이전 미리듣기를 폐기한다 (announcementId·audioUrl null·송출 버튼 재비활성·<audio> 정지). 바뀐 내용이 송출되는 혼선을 차단한다. [다시 미리듣기]로 새 draft 를 만든 뒤에야 송출할 수 있다.
  • 송출 후 취소 불가 — footer 상단에 경고 배너(시안 명시). 송출하면 바로 매장에 재생되며 되돌릴 수 없다.

입력·검증

필드제약비고
안내 내용(textarea)@minLength 1 @maxLength 200(BE 계약 일치)카운터 n / 200자. 빈/초과는 클라이언트에서 미리 막아 불필요한 합성 호출 절약(frontend.md §8)
목소리(select)BroadcastPreviewRequestVoice 5종(SHEAN·WOOSUNG·CYRUS·AERAN·SEUNGA)라벨은 본사 안내방송 화면(tts-voice-meta)의 TTS_VOICE_LABELS 미러(broadcast-voice-meta.ts)

톤은 미노출(BE NORMAL 고정). storeId·hqId 는 토큰 주체에서 도출하므로 요청 파라미터 없음.

녹음 탭 (MediaRecorder · 미리듣기 게이트 공유)

녹음 탭은 TTS 의 “합성→미리듣기” 자리를 “녹음→업로드” 로 대체하되, 업로드 성공 이후의 게이트는 TTS 와 동일하다(같은 previewedAnnouncementId·[송출하기]·“송출 후 취소 불가” 경고·send·리셋).

[녹음 시작] → getUserMedia({audio:true}) → MediaRecorder(webm/opus 협상, isTypeSupported)
        │  경과 타이머 + 30초 도달 시 자동 정지

[정지] → 녹음 Blob → <audio controls> 미리듣기 + [다시 녹음] / [이 녹음 사용하기]
        │  [이 녹음 사용하기]

useRecordStoreBroadcast → POST /api/v1/store/broadcasts/recording
  multipart file=Blob + ?durationSeconds=초(query)
        │  201 BroadcastRecordingResponse{ announcementId, audioUrl, durationSeconds? }

onPreviewed(announcementId, audioUrl) → 공유 게이트 세팅 → [송출하기] 활성(TTS 와 동일)
  • 30초 자동 정지 — 경과 카운터가 30초에 도달하면 MediaRecorder.stop() 을 호출한다. 정지 시점에 녹음 길이(초)를 고정해 업로드 durationSeconds 로 보낸다(1~30 클램프).
  • MIME 협상MediaRecorder.isTypeSupportedaudio/webm;codecs=opusaudio/webmaudio/mp4audio/mpeg 순으로 첫 지원 타입을 채택(BE 허용 3종에 매핑).
  • 다시 녹음 — 미리듣기·게이트를 폐기하고 idle 로 돌아가 새 녹음을 받는다.
  • 마이크 권한 거부 UXgetUserMedia reject 를 코드별로 안내한다:
    • NotAllowedError/SecurityError → “마이크가 차단됐어요 — 브라우저 설정에서 허용한 뒤 다시 시도”
    • NotFoundError → “마이크를 찾을 수 없어요” · NotReadableError → “다른 앱이 마이크를 쓰는지 확인”
      • [다시 시도] 버튼.
  • 정리 — 언마운트/탭 전환 시 MediaRecorder.stop()·stream track stop·objectURL revoke·타이머 clear. 녹음 상태·권한·업로드 결과는 aria-live 로 announce.
  • 탭 전환 무효화 — 탭을 바꾸면 공유 미리듣기 게이트를 무효화한다(바뀐 입력 송출 차단).

계약/제약(BE): MIME audio/webm·audio/mp4·audio/mpeg, ≤10MB, duration 1~30초. 위반 시 400 RECORDING_UNSUPPORTED_FORMAT(형식)·RECORDING_INVALID_FIELD(빈 파일·10MB 초과·범위 밖). 빈/ 초과/범위 밖은 클라이언트에서 미리 막아 불필요한 업로드를 절약한다(frontend.md §8).

디자인 시안 정합 완료: 녹음 탭은 design_8 핸드오프 (design_handoff_linkmusic_8/design/screens/store-broadcast-recording.jsx, §8E-②)로 정식 시각을 흡수했다 — 상태별 카드(대기 72px / 녹음 중 96px danger·진행바 / 녹음 완료 60px·미리듣기 audio / 올리는 중 스피너 / 마이크 권한 거부 3종)·80px 큰 버튼·40~50대 가독성·aria-live 동적 announce. 인터랙션은 §8E-① 확정대로 시작/정지 = 탭 토글(“꾹 누르지 않아도 됩니다”). 시각만 교체 — 녹음 흐름·업로드 API·에러매핑은 보존.

자주 쓰는 방송 탭 (템플릿 CRUD · 로드)

자주 쓰는 안내(주차·시음·영업 종료 등)를 이름·텍스트·목소리로 저장해두고 다음부터 한 번에 불러 쓰는 탭. 합성/송출 없이 TTS 탭 입력을 채우는 로드까지만 담당한다 — 미리듣기·송출은 TTS 탭 게이트가 맡는다.

[목록] useListBroadcastTemplates → GET /api/v1/store/broadcast-templates
  200 BroadcastTemplateListResponse{ items: BroadcastTemplateResponse[], total }   (updated_at DESC, 매장당 최대 20)

        ├─ [+ 새 템플릿] → 인라인 폼(name 1~60·text 1~200·voice 5종)
        │     useCreateBroadcastTemplate → POST  201 / 상한 도달 409 BROADCAST_TEMPLATE_LIMIT_EXCEEDED
        ├─ 행 [편집] → 기존값 prefilled 인라인 폼
        │     useUpdateBroadcastTemplate → PUT /{id}  200 / 소유 아님 404 BROADCAST_TEMPLATE_NOT_FOUND
        ├─ 행 [삭제] → 2-step 확인 → useDeleteBroadcastTemplate → DELETE /{id}  204(소프트삭제)
        └─ 행 탭(로드) → onLoad(text, voice) → TTS 탭으로 전환 + text/voice 채움 (※ 자동 미리듣기 없음)
  • 상한 20개total >= 20 이면 [+ 새 템플릿] 비활성 + 안내 배너. 저장 시 서버 409 도 안내로 매핑.
  • 로드 게이트 유지 — 행을 탭하면 TTS 탭으로 전환하고 입력만 채운다. 자동 미리듣기·송출은 하지 않는다 — 점장이 직접 [미리듣기]로 게이트를 통과해야 송출할 수 있다(잘못된 방송 송출 차단). 로드 시 이전 미리듣기 게이트는 무효화한다.
  • 저장/편집/삭제 성공 시 목록 query 를 invalidate 해 최신 상태로 재조회한다. 검증은 BE 제약과 동일 (name 160·text 1200, frontend.md §8) — 빈/초과는 클라이언트에서 미리 막는다.

디자인 시안 정합 완료: 템플릿 탭은 design_8 핸드오프 (design_handoff_linkmusic_8/design/screens/store-broadcast-templates.jsx, §8E-③)로 정식 시각을 흡수했다 — 행 레이아웃(Star 박스·name·text 요약·voice pill·상대시각·편집/삭제 아이콘 버튼)·인라인 저장/편집 폼(카운터 병기)·인라인 2-step 삭제(행 자리 danger 확인 카드)·빈 상태(점선 카드 + 큰 [새 템플릿 만들기] CTA)·상한 warn 배너. 인터랙션은 §8E-① 확정대로 행 본문 탭 = 로드(자동 미리듣기 X)·[+ 새 템플릿] = 인라인 폼·삭제 = 인라인 2-step. 시각만 교체 — CRUD API·검증·로드 동작은 보존. [[feedback_design_only_from_handoff]].

긴급방송 옵션 (SPEC #082)

TTS / 녹음 탭 양쪽에 긴급(isEmergency) 체크박스를 추가했다. 시안 broadcast-now.jsx §EmergencyToggle 인라인 미러(새 컴포넌트 추출 X) — 활성 시 빨간 톤(border: var(--danger) + background: color-mix(in oklch, var(--danger) 10%, transparent)) + 경고 텍스트(“긴급 옵션은 미아·화재·정전·분실물·응급 안전 안내에만 사용하세요. 마케팅·이벤트 안내에 사용하면 감사 로그에 누적되어 본사에 보고됩니다.”). 송출 버튼 라벨·톤도 분기한다.

일반 (off)긴급 (on)
송출 버튼 라벨송출하기 / 송출 중…긴급 송출하기 / 긴급 송출 중…
송출 버튼 톤variant="primary"variant="danger"
preview payload{ text, voice }{ text, voice, isEmergency: true }
send payload{}{ isEmergency: true }
recording upload?durationSeconds=N (query)?durationSeconds=N&isEmergency=true
  • mutation 페이로드(D11): true 만 명시 전송한다(false 는 생략 — BE default false). preview/send 는 JSON body, 녹음 업로드는 query param(BE @RequestParam)이다.
  • 게이트 무효화: 토글 변경 시 이전 미리듣기 게이트를 무효화한다(audioUrl·announcementId null·송출 재비활성). 송출 의도가 바뀌면 BE response·DB row 의 isEmergency 도 재합성에서 다시 확정되어야 정확하다.
  • 송출 후 리셋: 송출 성공 시 emergency=false 로 자동 리셋(다음 방송은 일반으로 시작).
  • 자주 쓰는 방송(템플릿) 탭(D12): 토글 없음. 템플릿 load 시 emergency=false 로 고정(긴급은 즉시 결정 사항이라 템플릿화하지 않는다 — 부주의한 재사용 차단). 템플릿 자체에는 emergency 필드 없음.
  • DB·BE 반영: tts_announcement.is_emergency BOOLEAN NOT NULL DEFAULT FALSE(V30). preview 단계에 draft row 에 set 되고 send 단계 body 가 있으면 마지막 확정(텍스트·voice 등 합성 파라미터는 preview 단계에서 확정 — send 는 isEmergency 만 받는 단일 옵션 DTO BroadcastSendRequest). 점장 pending 조회 응답(PendingAnnouncementItem.isEmergency)에도 노출되어 후속 F1(player 인터럽트)·F2(audit 누적)에서 분기 근거가 된다.

예약방송 옵션 (SPEC #083)

TTS / 녹음 탭의 공유 송출 게이트에 즉시(IMMEDIATE) / 예약(SCHEDULED) 모드 라디오 토글을 추가했다. 시안 부재라 본사 안내방송 송출 다이얼로그(DispatchAnnouncementDialog SPEC #078) 의 라디오 + date/time 인풋 패턴을 그대로 inline 미러(새 컴포넌트 추출 X · atom-grounded). 본사 #078 의 스케줄 인프라 (announcement_dispatch.scheduled_at V29 + HqDispatchScheduler 1분 cron)를 그대로 재사용 — 점장 row 도 디스패처가 도래 시 SCHEDULED → PENDING 전이시키므로 player 변경 0(기존 PENDING 폴링이 자동 픽업).

즉시 (IMMEDIATE, 기본)예약 (SCHEDULED)
송출 버튼 라벨송출하기 / 송출 중…예약 등록하기 / 예약 등록 중…
긴급 조합 라벨긴급 송출하기 / 긴급 송출 중…긴급 예약 등록하기 / 긴급 예약 등록 중…
send body{} (또는 { isEmergency: true }){ scheduledAt: ISO-8601 UTC } (긴급이면 isEmergency: true 함께)
송출 후 카피”송출하면 바로 매장 스피커에서 재생되며 취소할 수 없습니다.""예약을 등록하면 등록 후 취소할 수 없어요.”
결과 배너”송출됐습니다. 곧 매장 스피커에서 재생됩니다.”"{YYYY-MM-DD HH:mm} 예약 등록 완료" (KST)
  • 입력 = 날짜 선택 + 5분 슬롯 그리드: <input type="date" min={today-KST}> (날짜 선택) + ReservationSlotGrid(시각 선택 — 종전 <input type="time"> 자유 입력 대체). 시안 정합: workspace parent dir design_handoff_linkmusic_15/design/screens/store-broadcast-reservation-grid.jsx. 하루(00:00~23:55)를 5분 단위 288칸(시간 축 + 12칸 매트릭스, 한 행=1시간)으로 보여주고 빈 슬롯 1탭 으로 예약 시각을 5분 경계(KST)에 스냅 선택한다. 진입 시 KST 현재 + 1h 로 자동 채움. KST→UTC ISO 정규화는 종전과 동일(composeScheduledAtIso·defaultScheduledFields·todayKstDateString 보존) — 그리드는 scheduledTime(HH:mm)을 스냅 세팅하고 scheduledDate 와 결합해 +09:00toISOString() (...Z) 으로 송신. 5분 스냅이 안전한 이유: 디스패처가 매분 cron(#078)이라 5분 경계도 정확히 그 분에 전이(누락 없음).
  • 점유 슬롯 비활성(회색): (a) 본인 매장 예약 = useListStoreScheduledBroadcasts(본인 매장 SCHEDULED) 의 scheduledAt 을 선택 날짜(KST)로 매칭해 사전 비활성(“예약됨(내 매장)”). (b) 본사 점유 = 전용 조회 endpoint 부재 → send 시 409 DISPATCH_SLOT_OCCUPIED 로만 차단되며, 거부된 슬롯을 날짜별 occupiedHqByDate 에 사후 누적해 그리드에서 비활성(“본사 점유”). 과거 가드(오늘 지난 슬롯)도 비활성. 각 슬롯 button aria-label(시각 + 가용/점유/지난)·aria-pressed(선택됨), 색만 아니라 테두리·라벨로 상태 구분(WCAG 2.1 AA).
  • mutation 페이로드(D8): non-null/SCHEDULED 만 명시 전송. IMMEDIATE 면 scheduledAt 키 자체를 생략(BE default null=즉시 송출 보존, 기존 트레이스와 동일). 긴급(isEmergency) 과 자유롭게 조합 가능 (D14 — 긴급 예약 = 정당한 use case).
  • 클라이언트 검증(D9):
    • SCHEDULED + 슬롯 미선택 → 송출 disabled + 보조 텍스트 “예약 시각을 슬롯에서 선택해 주세요”
    • SCHEDULED + 시각 ≤ 현재 → 송출 disabled + 보조 텍스트 “현재 이후 시각을 선택해 주세요”(과거 슬롯 자체도 그리드에서 비활성)
    • 슬롯 선택 시 하단 요약에 선택 시각(KST) 표기(store-broadcast-schedule-selected).
    • 매 렌더마다 Date.now() 비교 → disabled 가 1분 단위로 자연 갱신(타이머 없이도 안전).
  • 녹음 탭: 녹음 흐름은 recordStoreBroadcast(업로드) → 공유 게이트 통과 → sendStoreBroadcast (송출) 2단계라, 공유 게이트 영역의 schedule UI 가 그대로 동작한다(녹음 업로드 단계엔 scheduledAt 파라미터 없음 — 송출 단계 body 에만 실린다).
  • 자주 쓰는 방송(템플릿) 탭(D13): 송출 게이트 자체가 숨겨지므로 schedule UI 도 자동 숨김 (템플릿 탭에선 로드만 함). 점장이 템플릿을 TTS 탭으로 불러온 뒤 즉시/예약을 선택한다 — #082 의 긴급 토글과 동일 idiom(템플릿화하지 않는 즉시 결정 사항).
  • 모드 리셋: 송출/예약 등록 성공 시 scheduleMode=IMMEDIATE 로 리셋(다음 방송은 즉시가 자연 디폴트). 날짜/시각도 다시 현재 + 1h 로 재초기화.

본사 점유 시각 차단 (F1 — #109, 옵션 A 구현됨): 같은 매장에 본사가 예약한 동일 시각 (store_id + status=SCHEDULED + scheduledAt 일치 + announcement.source='HQ')이면 BE 가 송출 단계에서 409 DISPATCH_SLOT_OCCUPIED 를 반환하고, FE 는 “본사 방송이 이미 예약된 시각입니다. 다른 시각을 선택해 주세요.” 로 매핑하고 그 슬롯을 그리드에서 사후 비활성(“본사 점유”) 처리한다. 정확한 시각(Instant) 충돌만 차단한다 — 20-policy §3-3반복(hourly/even/odd) 예약 차단은 본사 예약을 슬롯 모델로 재설계하는 별도 대형 SPEC 후속(현 본사 예약 #078 은 1회성 single Instant 라 반복 모델이 코드에 없음). 점장발 SCHEDULED(자기 예약)는 충돌로 보지 않으며, 즉시 송출(scheduledAt 없음)은 검사 대상이 아니다. ✅ 정책의 “회색 비활성 슬롯” 그리드 UI 는 5분 슬롯 그리드(ReservationSlotGrid) 로 구현됨 — 본인 매장 예약은 사전 비활성, 본사 점유는 시안 정합대로 send 409 사후 비활성(전용 점유 조회 endpoint 는 슬롯 모델 후속). 반복 예약·전용 점유 조회만 잔여 후속.

후속 (F2·F3): F2 점장 예약 취소 ✅ (#092) · F3 점장 예약 목록 view ✅ (#091 — 본인 매장 예약 송출 조회 라이브). 본 슬라이스는 점장 등록 + 디스패처 자동 픽업까지.

예약 송출 목록 view (SPEC #091 · /store/broadcast/scheduled)

예약방송 #083 을 보낸 뒤 본인 매장에 도래하지 않은 SCHEDULED row 가 몇 개 쌓였는지 점장이 확인할 수 있는 read-only 페이지. 즉시방송 페이지 우측 상단 [예약 목록] 링크에서 진입(아이콘 CalendarClock). 사이드바 없는 점장 모드 셸이라 페이지 안에서 ← 뒤로 로 복귀한다.

시안 출처: design_14 핸드오프(Phase 8 §8G-③ store-scheduled-broadcasts.jsx)로 시안 정합 완료 — max-w-760 카드 리스트, inline [취소]→warn confirm strip 2-step, 긴급 배지 병기. 서브페이지 헤더는 신규 공용 atom StoreSubHeader. 시각만 교체 — 목록 fetch·취소 mutation·에러 매핑·격리 동작은 보존. ※ 이 정합은 예약 목록/취소·즉시 방송 화면 한정이며, 즉시방송 §의 5분 슬롯 그리드· 즉시방송 schedule UI 는 슬롯 모델 후속(아래 참조)으로 별개다.

  • 데이터: useListStoreScheduledBroadcastsGET /api/v1/store/scheduled-broadcasts (STORE_MANAGER-only). 토큰 claim → BE 가 본인 매장으로 스코프(store_id + status = 'SCHEDULED' + 상한 50).
  • 정렬: scheduled_at ASC (가장 가까운 예약이 위) — 본사 디스패처(#078)가 도래 시 PENDING 으로 전이하기 직전 row 만 노출. 즉시 송출/PENDING·종착/PLAYED·CANCELED 자동 제외.
  • 3탭 (SPEC #142 + 캘린더): 목록 상단에 ARIA tablist(role=tablist·tab·aria-selected·aria-controls) 3탭 — 오늘 남은 방송(scope="today" 필터) / 전체 방송 목록(scope="all") / 캘린더(scope="calendar"). 기본 선택 탭은 “오늘 남은 방송”(모달·페이지 공통). 탭 전환 시 page·열린 strip·결과 Banner 를 초기화한다. “캘린더” 탭은 목록(today/all)과 별개 데이터 소스라 목록 fetch·필터·페이지네이션 대신 월 그리드(아래)만 렌더한다. 패턴은 admin/announcements/announcement-tabs.tsx(점장 셸 톤 — surface/hairline 토큰 + 활성 밑줄)를 참고.
  • 표 컬럼: 제목(announcementTitle) · 예약 시각(scheduledAt → KST 24시간제) · 긴급 배지 (isEmergency=true 면 빨간 “긴급” pill — 즉시방송 페이지 긴급 톤과 동일) · 등록 시각(createdAt → KST) · 즉시 방송(SPEC #142, 아래) · 취소(SPEC #092).
  • 즉시 방송 (SPEC #142 — 미리듣기 완전 대체): 각 행에 (구 [미리듣기] 자리) [즉시 방송] 토글 버튼 (Radio 아이콘 + aria-expanded). 클릭 → 그 행 아래 1-step 확인 strip(“지금 송출할까요? 예약은 예정대로 유지됩니다” · [지금 송출]/[닫기]) 펼침(점장 실수 방지·큰 터치타깃 h-12). [지금 송출] → useBroadcastStoreScheduledNow({ dispatchId }) (POST /api/v1/store/scheduled-broadcasts/{dispatchId}/broadcast-now) → 201. 원래 SCHEDULED row 는 무변경(원래 시각에 정상 발화) — 별도 IMMEDIATE dispatch 1건만 추가 송출돼 점장 player 가 폴링으로 받아 재생한다(더킹, SPEC #141). pending 중 [지금 송출] 은 aria-busy + spinner + “송출 중…”(연타 방지). 성공 시 상단 success Banner(“지금 송출했습니다 — 곧 매장에 재생됩니다. 예약은 예정대로 유지됩니다.”) + list query invalidate. 404 TTS_ANNOUNCEMENT_NOT_FOUND(그 사이 도래· 취소돼 더는 SCHEDULED 아님) → danger Banner “이미 도래했거나 취소된 예약입니다. 목록을 새로고침했습니다.”
    • invalidate · 그 외 → “지금 송출에 실패했습니다. 잠시 후 다시 시도해주세요.” audioUrl 이 빈 문자열 (트림 후 길이 0)인 행은 [즉시 방송] 버튼 자체를 미노출(방어). 이전 SPEC #131 의 네이티브 <audio> 미리듣기는 #142 에서 [즉시 방송] 으로 완전 대체(previewId state·펼침 audio·관련 테스트 제거). 성공 시 store_audit_log STORE_DISPATCH_BROADCAST_NOW 1건(target DISPATCH·info 톤, Store 감사).
  • 상태: 로딩 시 행 스켈레톤 3줄 · 에러 Banner danger(401/403/5xx code→메시지 매핑은 mapListError)· 빈 상태 “예약된 송출이 없습니다.” 단문(추가 진입은 헤더 [← 뒤로]가 담당).
  • 격리: STORE_MANAGER 본인 매장 외엔 보이지 않음(BE verifyStoreScope). 다른 매장 예약은 응답에 없다.
  • 취소 (SPEC #092): 각 행 우측 마지막 셀에 inline [취소] 버튼. 클릭 → 헤더 아래 confirm strip (**{제목}** 예약을 취소하시겠습니까? [취소 확정] [닫기]) 노출 — 본사 #077 cancel strip 패턴 그대로 미러. [취소 확정] → useCancelStoreDispatch({ id }) (PATCH /api/v1/store/dispatches/{id}/cancel) → 204 → list query invalidate → 해당 row 제거. mutating 중 strip 의 [취소 확정] 은 aria-busy + spinner + “취소 중…” 라벨. 실패는 같은 자리를 Banner danger 로 교체: 404 DISPATCH_NOT_FOUND → “이미 처리됐거나 취소된 예약입니다.” (BE 가 미존재·이미 종착·타 매장·PENDING 을 한 코드로 은닉 하므로 가장 흔한 race 메시지로 평탄화) · 그 외 → “예약 취소에 실패했습니다.” 본사 #077 차이: 본사는 PENDING 도 취소 가능했지만 점장은 SCHEDULED 만 취소 — 본사가 즉시 보낸 PENDING 을 점장이 무효화 하는 건 정책 위반(SPEC #092 §D5). PLAYED·CANCELED 는 종착 — BE 에서 자동 제외됐으므로 표에 노출되지 않는다. 취소 성공 시 store_audit_log(V36) STORE_DISPATCH_CANCELED 1건 기록(SPEC #114, #092 F2 마감) — target DISPATCH·target_id=dispatch.id·detail 없음. 원자 UPDATE 후 affected=1 이 확정될 때에만 같은 트랜잭션에서 기록(race window 0). 운영자가 점장 모드로 위장해 취소한 경우 actor_role= OPERATOR_IMPERSONATING 으로 원본 운영자도 함께 추적. v1 은 기록만(조회 view 후속). 상세는 Store 감사.
  • 후속(F1·F2): F1 페이지네이션(50건 초과 매장)SPEC #102 도착 · F2 점장 취소 audit 누적SPEC #114 도착 · 미리듣기 audio(#091 F2)SPEC #131 도착즉시 방송(#142)으로 대체SPEC #142 도착.

라우트·파일: apps/space/src/app/store/broadcast/scheduled/page.tsx (server 셸, force-dynamic) + store-scheduled-list-client.tsx ('use client', 목록 fetch·2탭·표·빈 상태·에러 매핑·**inline cancel strip

  • mutation**·행 [즉시 방송] 토글 + 1-step 확인 strip + useBroadcastStoreScheduledNow). 새 컴포넌트 추출 X — 카드/표는 본사 안내방송 목록(#061) atom 미러, cancel strip 은 본사 #077 cancel strip atom 미러, 탭은 announcement-tabs.tsx 패턴 참고.

defaultScope prop — 이 client 는 두 진입점에서 재사용된다. SPEC #142 에서 scope 를 내부 탭 토글 state 로 일반화했다(이전엔 고정 prop). 별도 페이지(/store/broadcast/scheduled)와 점장 player 의 [다음 방송] 모달(embedded defaultScope="today") 둘 다 2탭(오늘 남은 / 전체)을 노출하며 기본 탭은 “오늘 남은 방송”(defaultScope="today"). “오늘 남은 방송” 탭은 지금부터 오늘 영업종료(KST 자정)까지 남은 예약만 클라이언트에서 필터한다(now <= scheduledAt <= 오늘 KST 23:59:59.999). 매장 도메인에 영업시간 필드가 없어 영업종료를 “오늘 KST 자정”으로 정의한다. “오늘 남은 방송” 탭에선 페이지네이션 미노출 + 빈 상태가 “이번 영업시간 내 남은 예약이 없습니다.”로 바뀐다. “전체 방송 목록” 탭은 BE SCHEDULED 전체(상위 50건·페이지네이션)를 그대로 보여준다.

송출 캘린더 (월 그리드)

예약 송출 목록의 “캘린더” 탭(StoreDispatchCalendar → 본사와 공용 presentational DispatchCalendar). 본인 매장의 송출 예약/이력을 월 그리드(7열×주행, 일요일 시작)로 본다. 전용 시안 부재 — atom-grounded 최소 (기존 토큰·atom 재사용, 새 시각 창작 X).

  • 데이터: useGetStoreDispatchCalendar({ from, to })(generated) → GET /api/v1/store/dispatch-calendar. 보는 달의 그리드 범위(앞뒤 패딩 ≤42칸)만 fetch → BE 의 62일 cap 안에서 단일 호출(@/lib/calendar monthGridRange). 이전/다음 월 네비 시 from/to 변경 → 자동 refetch. storeId 토큰 주체 도출(본인 매장 고정).
  • 본사 캘린더와 동일 컴포넌트: @/lib/calendar(KST day 변환·월 그리드)·dispatch-calendar-meta(kind/status 색)·DispatchCalendar(월 그리드 표현)를 그대로 재사용한다. 점장은 본인 매장 고정이라 매장명을 숨긴다(showStoreName=false, 응답 storeName 도 null). 색·셀·+N건 요약·범례·오늘 강조·로딩/빈/에러 처리는 본사 캘린더와 동일.
  • KST 변환: 이벤트 scheduledAt(UTC ISO) → kstDayOf 로 KST 날짜로 변환해 셀 배치(목록 탭의 todayKstCloseEpochMs 와 동일 UTC+9 offset 기법).

에러 분기 (code → 메시지)

단계status/code메시지
미리듣기(TTS)502 TTS_SYNTHESIS_FAILED합성에 실패했습니다. 잠시 후 다시 시도해주세요.
미리듣기(TTS)503 TTS_TOKEN_NOT_CONFIGURED방송 기능이 아직 설정되지 않았습니다. 본사에 문의해주세요.
업로드(녹음)400 RECORDING_UNSUPPORTED_FORMAT지원하지 않는 녹음 형식이에요. 브라우저를 최신으로 업데이트한 뒤 다시 녹음해주세요.
업로드(녹음)400 RECORDING_INVALID_FIELD녹음이 올바르지 않아요. (빈 파일·10MB 초과·길이 1~30초 범위 밖) 다시 녹음해주세요.
송출404 TTS_ANNOUNCEMENT_NOT_FOUND미리듣기가 만료됐습니다. 다시 미리듣기 후 송출해주세요.
송출(예약)400 BROADCAST_SCHEDULED_AT_PAST과거 시각으로 예약할 수 없습니다.
송출(예약)400 BROADCAST_SCHEDULED_AT_TOO_FAR1년을 초과한 예약은 허용되지 않습니다.
송출(예약)409 DISPATCH_SLOT_OCCUPIED본사 방송이 이미 예약된 시각입니다. 다른 시각을 선택해 주세요. (#109 — 본사 점유 정확-시각 충돌)
템플릿 저장409 BROADCAST_TEMPLATE_LIMIT_EXCEEDED템플릿은 최대 20개까지 저장할 수 있어요. 쓰지 않는 템플릿을 지운 뒤 다시 시도해주세요.
템플릿 수정/삭제404 BROADCAST_TEMPLATE_NOT_FOUND템플릿을 찾을 수 없어요. 이미 삭제됐을 수 있어요.
공통403(권한)·5xx·네트워크안전 기본 메시지(권한 없음·일시 장애·연결 실패)

합성은 수 초 걸릴 수 있어 [미리듣기]/[송출하기] 버튼을 “합성 중…”/“송출 중…” 로딩으로 표시한다.

접근성

  • 미리듣기 상태·송출 결과를 aria-live="polite" 영역으로 전달한다.
  • 미리듣기 <audio controls> 로 들어볼 수 있게 한다. 무효화·송출 성공 시 pause() 로 정리.
  • 닫기 버튼은 aria-label="닫기"(홈 /store 복귀).

보안 불변식 (격리)

점장 즉시방송이 만든 안내방송은 tts_announcement.source='STORE_BROADCAST'(V24)로 본사 안내방송 (HQ)과 격리된다. 본사-facing 쿼리는 모두 source='HQ' 로 필터하므로 점장이 만든 row 는 본사 화면·계약에 노출되지 않는다. 점장 송출은 본인 매장에만 dispatch 된다. 상세는 TtsAnnouncementSource.

라우트·구성

  • apps/space/src/app/store/broadcast/now/page.tsx — server 셸(force-dynamic). /store layout (server)이 STORE_MANAGER role 가드를 담당하므로 본 page 는 셸 역할만 한다.
  • broadcast-now-client.tsx'use client' 본체(탭 전환·공유 미리듣기 게이트·<audio>·TTS 검증·에러 매핑).
  • recording-tab.tsx'use client' 녹음 탭(MediaRecorder·30초 자동 정지·미리듣기·업로드→공유 게이트 콜백·마이크 권한/업로드 에러 매핑·정리). 업로드는 useRecordStoreBroadcast(multipart) — 음원 업로드 (useUploadMusic)와 동일하게 BFF catch-all 프록시로 통과(신규 BFF route 불필요).
  • templates-tab.tsx'use client' 자주 쓰는 방송 탭(목록·인라인 저장/편집 폼·2-step 삭제·로드 콜백·상한 20·에러 매핑). CRUD 는 useListBroadcastTemplates·useCreateBroadcastTemplate· useUpdateBroadcastTemplate·useDeleteBroadcastTemplate — 모두 BFF catch-all 프록시로 통과 (신규 BFF route 불필요). 로드는 부모 onLoad(text, voice) 콜백 → TTS 탭 전환·입력 채움.
  • broadcast-voice-meta.ts — voice value→한글 라벨/옵션(본사 tts-voice-meta 미러).
  • reservation-slot-grid.tsx'use client' 5분 슬롯 그리드(예약 시각 선택, SPEC #083). 288칸 (시간 축 + 12칸/시) · 점유(본인/본사)·지난 슬롯 비활성 · value/onSelect 제어 · 점유 집합· pastBeforeMinutes props. 부모(broadcast-now-client)가 useListStoreScheduledBroadcasts(본인 매장 점유)·409 사후(occupiedHqByDate)·KST 날짜/분 파생(kstDateTimeParts·minutesOfDay·snapToFiveMin) 을 소유. BFF catch-all 프록시 경유라 신규 route·middleware 등록 불필요.

후속

녹음 탭·템플릿 탭 정식 시안 정합(handoff)·Safari 트랜스코딩(webm 미지원 폴백)·긴급방송 F1 (player 인터럽트 — 현재 곡 즉시 더킹·삽입 재생)·F2(본사 audit 누적 — 마케팅 오용 보고)· 예약방송 F1 본사 점유 시각 차단 ✅ (#109 — 정확-시각 충돌 409 DISPATCH_SLOT_OCCUPIED5분 슬롯 그리드(ReservationSlotGrid) ✅ (빈 시간대 1탭 선택·점유/지난 비활성·KST 5분 스냅 — 반복 차단(20-policy §3-3)·전용 점유 조회 endpoint 는 본사 예약 슬롯 모델 도입 후속 대형 SPECF2 점장 예약 취소 ✅ (#092 — SCHEDULED row [취소] inline strip + cancelStoreDispatch 라이브)·예약 목록 페이지네이션(#091 F1) ✅ (#102)· 예약 목록 미리듣기 audio(#091 F2) ✅ (#131 — 행 펼침 네이티브 <audio controls>·단일 재생)· 점장 취소 audit 누적(#092 F2) ✅ (#114 — store_audit_log STORE_DISPATCH_CANCELED)·송출 rate limit·송출 이력/audit. 계약·DTO 는 API 카탈로그 · DTOs.