feat(marketing): open EDM live send — double-confirm UI path (live=true)

Server EDM_TEST_MODE flipped to false (env, owner request), so the UI
gains an explicit live-send button: existing button stays as safe test
send, live send requires an audience-count warning confirm plus typing
LIVE. sendMut now carries {id, live}; i18n keys x4 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 07:09:27 +09:00
parent 1e15173fd4
commit 0892bbfb07
5 changed files with 48 additions and 10 deletions

View File

@ -2132,7 +2132,11 @@
"sendFail": "Failed to send.", "sendFail": "Failed to send.",
"confirm": "Send the {{name}} campaign?", "confirm": "Send the {{name}} campaign?",
"sending": "Sending…", "sending": "Sending…",
"sendBtn": "Send" "sendBtn": "Send",
"sendTestBtn": "Test send",
"sendLiveBtn": "Live send",
"confirmLive": "This will send the '{{name}}' campaign to {{audience}} real recipients. This cannot be undone. Continue?",
"confirmLiveType": "Type LIVE to confirm the live send."
}, },
"program": { "program": {
"title": "Program Management", "title": "Program Management",

View File

@ -2132,7 +2132,11 @@
"sendFail": "送信に失敗しました。", "sendFail": "送信に失敗しました。",
"confirm": "{{name}} キャンペーンを送信しますか?", "confirm": "{{name}} キャンペーンを送信しますか?",
"sending": "送信中…", "sending": "送信中…",
"sendBtn": "送信" "sendBtn": "送信",
"sendTestBtn": "テスト送信",
"sendLiveBtn": "本番送信",
"confirmLive": "'{{name}}' キャンペーンを実際の受信者 {{audience}} 名に本番送信します。取り消せません。続行しますか?",
"confirmLiveType": "本番送信を確定するには LIVE と入力してください。"
}, },
"program": { "program": {
"title": "プログラム管理", "title": "プログラム管理",

View File

@ -2132,7 +2132,11 @@
"sendFail": "발송에 실패했습니다.", "sendFail": "발송에 실패했습니다.",
"confirm": "{{name}} 캠페인을 발송하시겠습니까?", "confirm": "{{name}} 캠페인을 발송하시겠습니까?",
"sending": "발송 중…", "sending": "발송 중…",
"sendBtn": "발송" "sendBtn": "발송",
"sendTestBtn": "테스트 발송",
"sendLiveBtn": "운영 실발송",
"confirmLive": "'{{name}}' 캠페인을 실제 수신자 {{audience}}명에게 운영 발송합니다. 되돌릴 수 없습니다. 계속하시겠습니까?",
"confirmLiveType": "운영 발송을 확정하려면 LIVE 를 입력하세요."
}, },
"program": { "program": {
"title": "프로그램 관리", "title": "프로그램 관리",

View File

@ -2132,7 +2132,11 @@
"sendFail": "发送失败。", "sendFail": "发送失败。",
"confirm": "确定发送 {{name}} 活动吗?", "confirm": "确定发送 {{name}} 活动吗?",
"sending": "发送中…", "sending": "发送中…",
"sendBtn": "发送" "sendBtn": "发送",
"sendTestBtn": "测试发送",
"sendLiveBtn": "正式发送",
"confirmLive": "将向 {{audience}} 名真实收件人正式发送'{{name}}'活动,此操作不可撤销。是否继续?",
"confirmLiveType": "输入 LIVE 以确认正式发送。"
}, },
"program": { "program": {
"title": "程序管理", "title": "程序管理",

View File

@ -52,8 +52,9 @@ export function EdmCampaignPage() {
const [sendMsg, setSendMsg] = useState<string | null>(null); const [sendMsg, setSendMsg] = useState<string | null>(null);
const sendMut = useMutation({ const sendMut = useMutation({
// 안전 기본: 테스트 모드(live 미지정). 운영 실발송은 서버 설정(EDM_TEST_MODE=false)에서만 개방. // 안전 기본: 테스트 모드(live=false). 운영 실발송은 ①UI 이중 확인(live=true) ②서버 EDM_TEST_MODE=false 둘 다 필요.
mutationFn: (id: string) => marketingApi.sendCampaign(eventId as string, id), mutationFn: (vars: { id: string; live: boolean }) =>
marketingApi.sendCampaign(eventId as string, vars.id, vars.live),
onSuccess: (res) => { onSuccess: (res) => {
const n = res.sentCount.toLocaleString(); const n = res.sentCount.toLocaleString();
let base: string; let base: string;
@ -88,10 +89,23 @@ export function EdmCampaignPage() {
const ok = window.confirm(t('marketing.confirm', { name: c.name })); const ok = window.confirm(t('marketing.confirm', { name: c.name }));
if (ok) { if (ok) {
setSendMsg(null); setSendMsg(null);
sendMut.mutate(c.id); sendMut.mutate({ id: c.id, live: false });
} }
} }
/** 운영 실발송 — 이중 확인(경고 confirm + 'LIVE' 타이핑). 서버 EDM_TEST_MODE=false 일 때만 실제 발송된다. */
function confirmSendLive(c: CampaignDto) {
if (sendMut.isPending) return;
const ok = window.confirm(
t('marketing.confirmLive', { name: c.name, audience: c.audience.toLocaleString() }),
);
if (!ok) return;
const typed = window.prompt(t('marketing.confirmLiveType'));
if (typed !== 'LIVE') return;
setSendMsg(null);
sendMut.mutate({ id: c.id, live: true });
}
const audience = useMemo( const audience = useMemo(
() => SEGMENTS.filter((s) => segments[s.id]).reduce((sum, s) => sum + s.count, 0), () => SEGMENTS.filter((s) => segments[s.id]).reduce((sum, s) => sum + s.count, 0),
[segments], [segments],
@ -204,7 +218,8 @@ export function EdmCampaignPage() {
key={c.id} key={c.id}
campaign={c} campaign={c}
onSend={() => confirmSend(c)} onSend={() => confirmSend(c)}
sending={sendMut.isPending && sendMut.variables === c.id} onSendLive={() => confirmSendLive(c)}
sending={sendMut.isPending && sendMut.variables?.id === c.id}
/> />
))} ))}
{rows.length === 0 && ( {rows.length === 0 && (
@ -333,10 +348,12 @@ export function EdmCampaignPage() {
function CampaignRow({ function CampaignRow({
campaign: c, campaign: c,
onSend, onSend,
onSendLive,
sending, sending,
}: { }: {
campaign: CampaignDto; campaign: CampaignDto;
onSend?: () => void; onSend?: () => void;
onSendLive?: () => void;
sending?: boolean; sending?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -371,8 +388,13 @@ function CampaignRow({
<strong>{c.clickRate ?? '-'}</strong> <strong>{c.clickRate ?? '-'}</strong>
</div> </div>
{canSend && onSend && ( {canSend && onSend && (
<Button variant="primary" onClick={onSend} disabled={sending}> <Button variant="secondary" onClick={onSend} disabled={sending}>
{sending ? t('marketing.sending') : t('marketing.sendBtn')} {sending ? t('marketing.sending') : t('marketing.sendTestBtn')}
</Button>
)}
{canSend && onSendLive && (
<Button variant="primary" onClick={onSendLive} disabled={sending}>
{t('marketing.sendLiveBtn')}
</Button> </Button>
)} )}
</div> </div>