2026-02-11 10:43:37 +09:00

1061 lines
51 KiB
PHP

@extends('admin.layouts.app')
@section('title', '관리자 메일 발송')
@section('page_title', '관리자 메일 발송')
@section('page_desc', '단건 / 여러건 / 템플릿(CSV) / DB검색 발송')
@push('head')
<style>
/* mail send page only */
.mwrap{display:grid; grid-template-columns: 1fr 1.15fr; gap:16px;}
@media (max-width: 1100px){ .mwrap{grid-template-columns:1fr;} }
.mtop{display:flex; gap:12px; flex-wrap:wrap; align-items:end;}
.mtop__grow{flex:1; min-width:240px;}
.mtop__right{margin-left:auto; display:flex; gap:10px; align-items:center;}
.mbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.mbtn:hover{background:rgba(255,255,255,.10);}
.mbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.mbtn--primary:hover{background:rgba(59,130,246,.98);}
.mbtn--danger{background:rgba(244,63,94,.88);border-color:rgba(244,63,94,.95);color:#fff;}
.mbtn--ghost{background:transparent;}
.mbtn.is-active{outline:2px solid rgba(59,130,246,.45);}
/* green button */
.mbtn--success{background:rgba(34,197,94,.90); border-color:rgba(34,197,94,.95); color:#fff;}
.mbtn--success:hover{background:rgba(34,197,94,.98);}
.mtabs{display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;}
.mrow{display:flex; gap:10px; flex-wrap:wrap; align-items:center;}
.mhelp{font-size:12px; line-height:1.6; opacity:.9;}
.mmono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}
.previewBox{border:1px solid rgba(255,255,255,.10); background:rgba(0,0,0,.10); border-radius:14px; overflow:hidden;}
.previewHead{display:flex; justify-content:space-between; align-items:center; padding:10px 12px; border-bottom:1px solid rgba(255,255,255,.08);}
.previewBody{padding:12px; min-height:180px;}
.previewBody .emailFrame{background:#fff; color:#111; border-radius:12px; padding:14px;}
.previewBody .emailFrame.dark{background:#0b1220; color:#e5e7eb;}
/* disabled look */
.is-disabled{
opacity:.55;
pointer-events:none;
filter:grayscale(.2);
}
/* ===== modal preview ===== */
.mailModal{display:none; position:fixed; inset:0; z-index:9999;}
.mailModal__back{position:absolute; inset:0; background:rgba(0,0,0,.55);}
.mailModal__box{
position:relative;
width:800px; max-width:calc(100vw - 24px);
height:600px; max-height:calc(100vh - 24px);
margin:12px auto;
top:50%; transform:translateY(-50%);
border:1px solid rgba(255,255,255,.12);
background:rgba(20,20,20,.94);
border-radius:16px;
overflow:hidden;
box-shadow:0 18px 60px rgba(0,0,0,.45);
}
.mailModal__head{
display:flex; justify-content:space-between; align-items:center;
padding:10px 12px;
border-bottom:1px solid rgba(255,255,255,.10);
}
.mailModal__body{
height:calc(100% - 52px);
overflow:auto;
padding:12px;
}
.mailModal__frameWrap{
background:#fff;
border-radius:12px;
overflow:hidden;
}
.mailModal__frame{
width:100%;
height:1100px;
border:0;
display:block;
background:#fff;
}
.mailModal__hint{font-size:12px; opacity:.75; margin-top:10px}
</style>
@endpush
@section('content')
<form method="POST" action="{{ route('admin.mail.send.store') }}" enctype="multipart/form-data" id="mailSendForm">
@csrf
{{-- mode --}}
<input type="hidden" name="send_mode" id="sendMode" value="one">
{{-- 여러건 파싱 결과(JSON) 서버 전달용 --}}
<input type="hidden" name="many_rows_json" id="manyRowsJson" value="[]">
{{-- 토큰 시작 컬럼: 2열부터 {_text_02_} (서버에서 참고용) --}}
<input type="hidden" name="token_base" value="2">
{{-- subject/body 미러 (백엔드 불일치 대비) --}}
<input type="hidden" name="subject_tpl" id="subjectTplMirror" value="">
<input type="hidden" name="body_tpl" id="bodyTplMirror" value="">
<input type="hidden" name="mail_subject" id="mailSubjectMirror" value="">
<input type="hidden" name="mail_body" id="mailBodyMirror" value="">
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div class="mtop">
<div style="min-width:260px;">
<div class="a-muted" style="margin-bottom:6px;">발신자</div>
<div class="mrow">
<input class="a-input" name="from_email" value="{{ config('mail.from.address') }}" readonly style="width:240px;">
<input class="a-input" name="from_name" value="{{ config('mail.from.name') }}" readonly style="width:180px;">
</div>
</div>
<div class="mtop__grow" style="min-width:320px;">
<div class="mrow">
<label class="a-pill"><input type="radio" name="schedule_type" value="now" checked> 즉시발송</label>
<label class="a-pill"><input type="radio" name="schedule_type" value="schedule"> 예약발송</label>
{{-- 직접 입력 불가(실수 방지): readonly + 선택 버튼 --}}
<input class="a-input"
type="text"
name="scheduled_at"
id="scheduledAt"
placeholder="YYYY-MM-DD HH:mm"
style="width:180px"
disabled
readonly>
<button type="button" class="mbtn" id="openSchedulePicker" disabled>선택</button>
</div>
</div>
<div class="mtop__right">
<span class="mmono" id="toCountBadge">0</span>
<span class="mmono" id="approxBadge">예상: 0</span>
</div>
</div>
</div>
<div class="mwrap">
{{-- LEFT: recipients --}}
<div class="a-card" style="padding:16px;">
<div class="mtabs">
<button type="button" class="mbtn is-active" data-tab="one">단건</button>
<button type="button" class="mbtn" data-tab="many">여러건</button>
<button type="button" class="mbtn" data-tab="template">템플릿(CSV)</button>
</div>
<section data-panel="one">
<div class="a-muted" style="margin-bottom:6px;">수신자 (1)</div>
<div class="mrow">
<input class="a-input" name="to_email" id="toEmail" placeholder="user@example.com" style="width:260px;">
<input class="a-input" name="to_name" id="toName" placeholder="수신자명(선택)" style="width:180px;">
</div>
</section>
<section data-panel="many" style="display:none;">
<div class="a-muted" style="margin-bottom:6px;">수신자 (여러명)</div>
<textarea class="a-input" name="to_emails_text" id="toEmailsText" rows="9"
placeholder="✅ 1줄 = 1명 (줄바꿈 기준)&#10;✅ 1열: 이메일, 2열~: 토큰(콤마로 구분)&#10;예) sungro81@gmail.com, 이상도, 10000, 쿠폰"></textarea>
<div class="a-muted mhelp" style="margin-top:8px;">
- : <span class="mmono">sungro81@gmail.com, 홍길동, 10000, 쿠폰</span><br>
- 매칭(공통 규칙): <span class="mmono">{_text_02_}</span> 2(홍길동), <span class="mmono">{_text_03_}</span> 3(10000), <span class="mmono">{_text_04_}</span> 4(쿠폰)
</div>
<div class="mrow" style="margin-top:10px;">
<button type="button" class="mbtn mbtn--ghost" id="clearMany">비우기</button>
<span class="a-muted" id="manyStats" style="font-size:12px;"></span>
</div>
{{-- 파싱 미리보기 --}}
<div class="previewBox" style="margin-top:10px;">
<div class="previewHead">
<div class="a-muted">파싱 미리보기 (상위 5)</div>
<div class="a-muted" style="font-size:12px;">줄바꿈= / 콤마=</div>
</div>
<div class="previewBody" style="min-height:auto;">
<div id="manyPreview" class="a-muted" style="font-size:12px; white-space:pre-wrap;">(입력 )</div>
</div>
</div>
</section>
<section data-panel="template" style="display:none;">
<div class="a-muted" style="margin-bottom:6px;">템플릿 CSV (개인화 발송)</div>
<label class="mbtn">
CSV 업로드
<input type="file" name="to_emails_csv" id="csvFile" accept=".csv,.txt" style="display:none;">
</label>
<div class="previewBox" style="margin-top:10px;">
<div class="previewHead">
<div class="a-muted">CSV 미리보기 ( 5)</div>
<div class="a-muted" style="font-size:12px;">업로드 실수 방지</div>
</div>
<div class="previewBody" style="min-height:auto;">
<div id="csvPreviewTpl" class="a-muted" style="font-size:12px; white-space:pre-wrap;">(업로드 )</div>
</div>
</div>
<div class="a-muted mhelp" style="margin-top:10px;">
<b>- CSV 파일 작성 요령</b><br>
<div class="mmono" style="display:inline-block; margin-top:6px; white-space:pre;">
sungro1@naver.com,홍길동,10000,쿠폰
sungro1@google.com,이순신,20000,상품권
sungro1@nate.com,김개똥,30000,쿠폰
</div>
<div style="margin-top:8px;">
1열은 <b>수신자 이메일</b>, 2열부터는 메시지에 끼워 넣을 <b>사용자 데이터</b> 콤마로 구분해 작성합니다.
</div>
<div style="margin-top:10px;"><b>- 발송 문구와 매칭 (공통)</b></div>
<div class="mmono" style="display:inline-block; margin-top:6px; white-space:pre;">
안녕하세요 {_text_02_} 고객님
결제 금액은 {_text_03_} 입니다.
상품 유형: {_text_04_}
</div>
<div style="margin-top:8px;">
<span class="mmono">{_text_02_}</span> 2(홍길동)<br>
<span class="mmono">{_text_03_}</span> 3(10000)<br>
<span class="mmono">{_text_04_}</span> 4(쿠폰)
</div>
<div style="margin-top:10px;">
사용자 문구는 <span class="mmono">{_text_02_}</span>부터 최대 8개까지 사용 가능:
<span class="mmono">{_text_02_} ~ {_text_09_}</span>
</div>
</div>
</section>
</div>
{{-- RIGHT: message/template/preview --}}
<div class="a-card" style="padding:16px;">
<div class="mrow" style="justify-content:space-between; align-items:end;">
<div style="flex:1; min-width:260px;">
<div class="a-muted" style="margin-bottom:6px;">스킨 (템플릿을 선택하면 스킨은 템플릿에 고정됩니다)</div>
<select class="a-input" name="skin_key" id="skinKey" style="max-width:260px;">
@foreach(($skins ?? []) as $sk)
<option value="{{ data_get($sk,'key') }}">{{ data_get($sk,'label') }}</option>
@endforeach
</select>
<div class="a-muted" id="skinTplHint" style="font-size:12px; margin-top:6px; opacity:.8; display:none;">
템플릿이 선택되어 스킨 변경이 잠겨있습니다. (템플릿 선택 해제하면 다시 변경 가능)
</div>
</div>
<div style="display:flex; gap:8px; align-items:center; flex-wrap:nowrap; min-width:0;">
{{-- 템플릿 선택값 서버로도 보내기 (선택사항이지만 디버깅/로그에 도움됨) --}}
<select class="a-input" id="tplSelect" name="template_id" style="width:240px; max-width:240px; flex:0 0 240px;">
<option value="">템플릿 선택</option>
@foreach(($templates ?? []) as $t)
<option value="{{ data_get($t,'id') }}">
{{ data_get($t,'title') }} ({{ data_get($t,'code') }})
</option>
@endforeach
</select>
<button type="button" class="mbtn" id="tplApply" style="white-space:nowrap;">적용</button>
</div>
</div>
<div style="height:12px;"></div>
<div class="mrow">
<div style="flex:1;">
<div class="a-muted" style="margin-bottom:6px;">제목</div>
<input class="a-input" name="subject" id="subjectText" value="{{ old('subject') }}" placeholder="메일 제목을 입력하세요">
</div>
</div>
<div style="height:10px;"></div>
<div>
<div class="a-muted" style="margin-bottom:6px;">내용</div>
<textarea class="a-input" name="body" id="bodyText" rows="10" placeholder="메일 내용을 입력하세요">{{ old('body') }}</textarea>
<div class="mrow" style="margin-top:10px; justify-content:space-between;">
<div class="mrow">
<button type="button" class="mbtn mbtn--ghost" data-insert="{_text_02_}">{_text_02_}</button>
<button type="button" class="mbtn mbtn--ghost" data-insert="{_text_03_}">{_text_03_}</button>
<button type="button" class="mbtn mbtn--ghost" data-insert="{_text_04_}">{_text_04_}</button>
<span class="a-muted" style="font-size:12px;">토큰 빠른 삽입</span>
</div>
<div class="mrow">
<button type="button" class="mbtn mbtn--success" id="openMailPreview">전체 미리보기</button>
<button type="submit" class="mbtn mbtn--primary" id="sendBtn">발송</button>
<a class="mbtn" href="{{ route('admin.mail.logs') }}">발송 이력</a>
</div>
</div>
</div>
<div style="height:14px;"></div>
<div class="previewBox">
<div class="previewHead">
<div class="a-muted">미리보기</div>
<div class="a-muted" style="font-size:12px;">선택한 스킨 + 현재 입력값(간이)</div>
</div>
<div class="previewBody">
<div id="previewFrame" class="emailFrame">
<div style="font-weight:900; font-size:16px; margin-bottom:10px;" id="pvSubject">(제목)</div>
<div id="pvBody" style="line-height:1.6; white-space:normal;"></div>
</div>
</div>
</div>
<script>
window.__MAIL_TEMPLATES__ = @json($templates ?? []);
window.__MAIL_SKINS__ = @json($skins ?? []);
</script>
</div>
</div>
</form>
{{-- ===== 예약발송 날짜/시간/(5분단위) 선택 모달 ===== --}}
<div id="schedulePickerModal" class="mailModal" aria-hidden="true">
<div id="schedulePickerBackdrop" class="mailModal__back"></div>
<div class="mailModal__box" role="dialog" aria-modal="true" aria-label="예약 발송 시간 선택"
style="width:520px; height:auto; max-height:calc(100vh - 24px);">
<div class="mailModal__head">
<div>
<div style="font-weight:900;">예약 발송 시간 선택</div>
<div class="mailModal__hint">날짜 · 시간 · (5 단위)</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<button type="button" class="mbtn" id="closeSchedulePicker">닫기</button>
</div>
</div>
<div class="mailModal__body" style="height:auto;">
<div class="a-card" style="padding:14px;">
<div class="mrow" style="align-items:end;">
<div>
<div class="a-muted" style="margin-bottom:6px;">날짜</div>
<input class="a-input" type="date" id="schDate" style="width:180px;">
</div>
<div>
<div class="a-muted" style="margin-bottom:6px;">시간</div>
<select class="a-input" id="schHour" style="width:120px;"></select>
</div>
<div>
<div class="a-muted" style="margin-bottom:6px;"></div>
<select class="a-input" id="schMin" style="width:120px;"></select>
</div>
</div>
<div class="mrow" style="margin-top:12px; justify-content:flex-end;">
<button type="button" class="mbtn mbtn--ghost" id="resetSchedulePicker">초기화</button>
<button type="button" class="mbtn mbtn--primary" id="applySchedulePicker">적용</button>
</div>
</div>
</div>
</div>
</div>
{{-- ===== 전체 메일 형태 미리보기: 레이어 팝업 + 내부 스크롤 + iframe ===== --}}
<div id="mailPreviewModal" class="mailModal" aria-hidden="true">
<div id="mailPreviewBackdrop" class="mailModal__back"></div>
<div class="mailModal__box" role="dialog" aria-modal="true" aria-label="전체 메일 미리보기">
<div class="mailModal__head">
<div>
<div style="font-weight:900;">전체 메일 형태 미리보기</div>
<div class="mailModal__hint">Esc로 닫기 · 필요하면 “새로고침”</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<button type="button" class="mbtn mbtn--ghost" id="refreshMailPreview">새로고침</button>
<button type="button" class="mbtn" id="closeMailPreview">닫기</button>
</div>
</div>
<div class="mailModal__body">
<div class="mailModal__frameWrap">
<iframe id="mailPreviewFrame" class="mailModal__frame"></iframe>
</div>
</div>
</div>
</div>
@push('scripts')
@php
$previewUrl = \Illuminate\Support\Facades\Route::has('admin.mail.preview')
? route('admin.mail.preview')
: '';
@endphp
<script>
(() => {
const form = document.getElementById('mailSendForm');
if (!form) return;
// ===== elements
const tabBtns = Array.from(form.querySelectorAll('[data-tab]'));
const panels = Array.from(form.querySelectorAll('[data-panel]'));
const sendModeEl = document.getElementById('sendMode');
const scheduledAtEl = document.getElementById('scheduledAt');
const openSchBtn = document.getElementById('openSchedulePicker'); // ✅ 추가
const toEmailOneEl = document.getElementById('toEmail');
const toEmailsTextEl = document.getElementById('toEmailsText');
const manyPreviewEl = document.getElementById('manyPreview');
const manyRowsJsonEl = document.getElementById('manyRowsJson');
// template csv file
const csvFileEl = document.getElementById('csvFile');
const toCountBadgeEl = document.getElementById('toCountBadge');
const approxBadgeEl = document.getElementById('approxBadge');
const skinEl = document.getElementById('skinKey');
const skinTplHintEl = document.getElementById('skinTplHint');
const subjectEl = document.getElementById('subjectText');
const bodyEl = document.getElementById('bodyText');
// subject/body mirrors
const subjectTplMirrorEl = document.getElementById('subjectTplMirror');
const bodyTplMirrorEl = document.getElementById('bodyTplMirror');
const mailSubjectMirrorEl= document.getElementById('mailSubjectMirror');
const mailBodyMirrorEl = document.getElementById('mailBodyMirror');
// inline preview box
const pvSubjectEl = document.getElementById('pvSubject');
const pvBodyEl = document.getElementById('pvBody');
const inlineFrameEl = document.getElementById('previewFrame');
// modal preview
const modalEl = document.getElementById('mailPreviewModal');
const backEl = document.getElementById('mailPreviewBackdrop');
const openBtn = document.getElementById('openMailPreview');
const closeBtn = document.getElementById('closeMailPreview');
const refreshBtn = document.getElementById('refreshMailPreview');
const previewIframeEl = document.getElementById('mailPreviewFrame');
// templates
const templates = window.__MAIL_TEMPLATES__ || [];
const tplSelect = document.getElementById('tplSelect');
const tplApplyBtn = document.getElementById('tplApply');
// ✅ 예약발송 Picker modal elements (추가)
const schModalEl = document.getElementById('schedulePickerModal');
const schBackEl = document.getElementById('schedulePickerBackdrop');
const schCloseBtn = document.getElementById('closeSchedulePicker');
const schDateEl = document.getElementById('schDate');
const schHourEl = document.getElementById('schHour');
const schMinEl = document.getElementById('schMin');
const schApplyBtn = document.getElementById('applySchedulePicker');
const schResetBtn = document.getElementById('resetSchedulePicker');
// ===== helpers
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, m => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[m]));
}
function pad2(n){ return String(n).padStart(2,'0'); }
function formatScheduledAt(dateStr, hh, mm){
return `${dateStr} ${pad2(hh)}:${pad2(mm)}`;
}
function parseScheduledAt(v){
const s = String(v || '').trim();
const m = s.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}):(\d{2})$/);
if(!m) return null;
return { date: m[1], hh: Number(m[2]), mm: Number(m[3]) };
}
function next5min(){
const d = new Date();
d.setSeconds(0,0);
const m = d.getMinutes();
const add = (5 - (m % 5)) % 5;
d.setMinutes(m + add);
return d;
}
function toDateInputValue(d){
const y = d.getFullYear();
const m = pad2(d.getMonth()+1);
const day = pad2(d.getDate());
return `${y}-${m}-${day}`;
}
// ✅ scheduled_at 직접 입력 차단(실수 방지)
if (scheduledAtEl){
scheduledAtEl.readOnly = true;
scheduledAtEl.addEventListener('keydown', (e) => {
if (e.key === 'Tab') return;
e.preventDefault();
});
scheduledAtEl.addEventListener('paste', (e) => e.preventDefault());
scheduledAtEl.addEventListener('drop', (e) => e.preventDefault());
}
// ✅ subject/body mirror sync (백엔드 키 불일치 대비)
function syncMirrors(){
const s = subjectEl?.value ?? '';
const b = bodyEl?.value ?? '';
if(subjectTplMirrorEl) subjectTplMirrorEl.value = s;
if(mailSubjectMirrorEl) mailSubjectMirrorEl.value = s;
if(bodyTplMirrorEl) bodyTplMirrorEl.value = b;
if(mailBodyMirrorEl) mailBodyMirrorEl.value = b;
}
// ✅ 템플릿/스킨 동시 선택 금지 상태관리
function setTemplateLock(isTemplateChosen){
if (!skinEl) return;
if (isTemplateChosen){
skinEl.classList.add('is-disabled'); // CSS로 클릭 막기(pointer-events: none)
skinEl.setAttribute('aria-disabled','true');
// 키보드 포커스도 막기(선택사항)
if (!skinEl.dataset.prevTabIndex) {
skinEl.dataset.prevTabIndex = skinEl.getAttribute('tabindex') ?? '';
}
skinEl.setAttribute('tabindex', '-1');
if (skinTplHintEl) skinTplHintEl.style.display = '';
} else {
skinEl.classList.remove('is-disabled');
skinEl.removeAttribute('aria-disabled');
// 포커스 복구
const prev = skinEl.dataset.prevTabIndex ?? '';
if (prev === '') skinEl.removeAttribute('tabindex');
else skinEl.setAttribute('tabindex', prev);
delete skinEl.dataset.prevTabIndex;
if (skinTplHintEl) skinTplHintEl.style.display = 'none';
}
}
function isTemplateSelected(){
return !!(tplSelect && String(tplSelect.value || '').trim());
}
// ===== tabs
function setTab(tab){
tabBtns.forEach(b => b.classList.toggle('is-active', b.dataset.tab === tab));
panels.forEach(p => p.style.display = (p.dataset.panel === tab) ? '' : 'none');
// 기존 규칙 유지: template 탭은 send_mode=csv
const sendVal = (tab === 'template') ? 'csv' : tab;
sendModeEl.value = sendVal;
refreshCounts();
}
tabBtns.forEach(b => b.addEventListener('click', () => setTab(b.dataset.tab)));
// ===== schedule (기존 로직 유지 + 버튼 토글만 추가)
form.querySelectorAll('input[name="schedule_type"]').forEach(r => {
r.addEventListener('change', () => {
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked;
if (!scheduledAtEl) return;
scheduledAtEl.disabled = !isSch;
// ✅ 추가: 선택 버튼도 같이 토글
if (openSchBtn) openSchBtn.disabled = !isSch;
if (!isSch) scheduledAtEl.value = '';
});
});
// ✅ 여러건 파싱: "줄바꿈=", "콤마="
// 규칙: 1열=email, 2열부터 {_text_02_} 시작
function parseManyLines(rawText){
const text = String(rawText || '');
const lines = text
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.split('\n')
.map(s => s.trim())
.filter(Boolean);
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const rows = [];
const errors = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
const lineNo = i + 1;
const line = lines[i];
// 한 줄에서만 콤마로 분리 (빈 값 제거)
const cols = line.split(',').map(s => s.trim()).filter(s => s !== '');
const email = String(cols[0] || '').toLowerCase();
if (!email || !emailRe.test(email)) {
errors.push({ line: lineNo, reason: 'invalid_email', raw: line });
continue;
}
if (seen.has(email)) {
errors.push({ line: lineNo, reason: 'duplicate_email', raw: line });
continue;
}
seen.add(email);
const tokenCols = cols.slice(1); // 2열~ 실제 토큰값들
// ✅ 핵심: tokens[0]은 더미로 비워두고
// tokens[1]이 2열(= {_text_02_})이 되게 맞춤
const tokens = [''].concat(tokenCols);
// ✅ 안전용: placeholder 그대로 key map도 함께 전송
const token_map = {};
for (let j = 0; j < tokenCols.length && j < 8; j++) {
const n = j + 2; // 2..9
const pad = String(n).padStart(2,'0');
const key = `{_text_${pad}_}`; // 예: {_text_02_}
token_map[key] = tokenCols[j];
}
rows.push({
email,
tokens, // ['', col2, col3...]
token_map, // {'{_text_02_}': col2, ...}
line: lineNo,
raw: line,
});
}
return {
totalLines: lines.length,
validLines: rows.length,
errors,
rows,
};
}
// ===== counts
function refreshCounts(){
const mode = sendModeEl.value;
let cnt = 0;
if(mode === 'one'){
const e = (toEmailOneEl?.value||'').trim();
cnt = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e) ? 1 : 0;
} else if(mode === 'many'){
const r = parseManyLines(toEmailsTextEl?.value || '');
cnt = r.rows.length;
// 서버 전달용 JSON 갱신
if (manyRowsJsonEl) {
manyRowsJsonEl.value = JSON.stringify(r.rows);
}
const manyStats = document.getElementById('manyStats');
if(manyStats) {
const errN = r.errors.length;
manyStats.textContent = `입력 ${r.totalLines}줄 / 유효 ${r.rows.length}명 / 오류 ${errN}건`;
}
// 미리보기(상위 5개)
if (manyPreviewEl){
if (!r.totalLines) {
manyPreviewEl.textContent = '(입력 전)';
} else if (!r.rows.length && r.errors.length) {
const first = r.errors.slice(0, 5)
.map(e => `라인 ${e.line}: 이메일 형식 오류/중복 → ${e.raw}`)
.join('\n');
manyPreviewEl.textContent = first;
} else {
const top = r.rows.slice(0,5).map(x => {
// 표시: email | col2 | col3 ...
const shown = x.tokens.slice(1); // 더미 제외
const tok = shown.length ? (' | ' + shown.join(' | ')) : '';
return `${x.email}${tok}`;
}).join('\n');
// 매핑 예시 한 줄만 보여주기
const sample = r.rows[0];
let mapHint = '';
if (sample && sample.token_map){
const m2 = sample.token_map['{_text_02_}'] ?? '';
const m3 = sample.token_map['{_text_03_}'] ?? '';
const m4 = sample.token_map['{_text_04_}'] ?? '';
mapHint = `\n\n매핑 예시:\n{_text_02_}=${m2}\n{_text_03_}=${m3}\n{_text_04_}=${m4}`;
}
const errHint = r.errors.length
? `\n\n(오류 ${r.errors.length}건: 예) ` + r.errors.slice(0,2).map(e => `라인 ${e.line}`).join(', ')
: '';
manyPreviewEl.textContent = top + mapHint + errHint;
}
}
} else if (mode === 'template' || mode === 'csv') {
cnt = ((csvFileEl?.files||[]).length > 0) ? 1 : 0;
} else {
cnt = 0;
}
if (toCountBadgeEl){
toCountBadgeEl.textContent = (mode === 'template' || mode === 'csv') ? 'CSV' : (cnt + '명');
}
const approx = ((subjectEl?.value||'') + (bodyEl?.value||'')).length;
if (approxBadgeEl) approxBadgeEl.textContent = `예상: ${approx}자`;
}
[toEmailsTextEl, toEmailOneEl].forEach(el => el?.addEventListener('input', refreshCounts));
refreshCounts();
// clear many
document.getElementById('clearMany')?.addEventListener('click', () => {
if(toEmailsTextEl) toEmailsTextEl.value='';
refreshCounts();
});
// ===== token insert
form.querySelectorAll('[data-insert]').forEach(btn => {
btn.addEventListener('click', () => {
const token = btn.getAttribute('data-insert') || '';
if(!bodyEl) return;
const start = bodyEl.selectionStart ?? bodyEl.value.length;
const end = bodyEl.selectionEnd ?? bodyEl.value.length;
bodyEl.value = bodyEl.value.slice(0,start) + token + bodyEl.value.slice(end);
bodyEl.focus();
bodyEl.selectionStart = bodyEl.selectionEnd = start + token.length;
syncMirrors();
refreshCounts();
refreshInlinePreview();
debouncedIframePreview();
});
});
// ===== CSV preview (first 5 lines)
function previewCsv(file, targetEl){
if(!file || !targetEl) return;
const reader = new FileReader();
reader.onload = () => {
const txt = String(reader.result || '');
const lines = txt.split(/\r\n|\n/).slice(0, 5);
targetEl.textContent = lines.length ? lines.join('\n') : '(비어있음)';
};
reader.onerror = () => targetEl.textContent = '(읽기 실패)';
reader.readAsText(file);
}
csvFileEl?.addEventListener('change', (e) => {
previewCsv(e.target.files?.[0], document.getElementById('csvPreviewTpl'));
refreshCounts();
});
// ===== template apply
tplApplyBtn?.addEventListener('click', () => {
const id = Number(tplSelect?.value || 0);
if(!id){ alert('템플릿을 선택하세요.'); return; }
const t = templates.find(x => Number(x.id) === id);
if(!t){ alert('템플릿을 찾을 수 없습니다.'); return; }
if(subjectEl) subjectEl.value = String(t.subject_tpl ?? t.title ?? '');
if(bodyEl) bodyEl.value = String(t.body_tpl ?? '');
// 템플릿이 가진 스킨 적용 + 스킨 잠금
if(skinEl && (t.skin_key ?? t.skinKey)) skinEl.value = String(t.skin_key ?? t.skinKey);
// 템플릿 선택 상태면 스킨 변경 금지
setTemplateLock(true);
syncMirrors();
refreshCounts();
refreshInlinePreview();
debouncedIframePreview();
});
// 템플릿 선택/해제에 따라 스킨 lock/unlock
tplSelect?.addEventListener('change', () => {
const chosen = isTemplateSelected();
setTemplateLock(chosen);
});
// 스킨을 바꾸려고 하면, 템플릿이 선택된 상태라면 템플릿을 해제하고 스킨 모드로 전환
skinEl?.addEventListener('change', () => {
if (isTemplateSelected()){
if (tplSelect) tplSelect.value = '';
setTemplateLock(false);
}
syncMirrors();
refreshInlinePreview();
debouncedIframePreview();
});
// ===== inline preview box
function refreshInlinePreview(){
const sub = (subjectEl?.value || '').trim() || '(제목)';
const body = (bodyEl?.value || '').trim();
if(pvSubjectEl) pvSubjectEl.textContent = sub;
if(pvBodyEl) pvBodyEl.innerHTML = body
? escapeHtml(body).replace(/\n/g,'<br>')
: '<span style="opacity:.7;">(내용)</span>';
const k = (skinEl?.value || 'clean');
if(inlineFrameEl){
inlineFrameEl.classList.toggle('dark', k === 'dark');
}
}
subjectEl?.addEventListener('input', () => { syncMirrors(); refreshCounts(); refreshInlinePreview(); debouncedIframePreview(); });
bodyEl?.addEventListener('input', () => { syncMirrors(); refreshCounts(); refreshInlinePreview(); debouncedIframePreview(); });
// 초기 동기화
syncMirrors();
refreshInlinePreview();
// 초기 lock 상태 반영
setTemplateLock(isTemplateSelected());
// ===== iframe preview (server render) - only when modal open
const PREVIEW_URL = @json($previewUrl);
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
let isModalOpen = false;
let previewDirty = true;
async function updateIframePreview(){
if(!previewIframeEl) return;
if(!PREVIEW_URL){
previewIframeEl.srcdoc = `<div style="padding:16px;font-family:system-ui;">
<b>미리보기 라우트가 없습니다.</b><br>
<span style="opacity:.7;">Route::post('admin.mail.preview') 를 추가해 주세요.</span>
</div>`;
previewDirty = false;
return;
}
try {
const fd = new FormData();
fd.append('skin_key', skinEl?.value || '');
fd.append('subject', subjectEl?.value || '');
fd.append('body', bodyEl?.value || '');
const res = await fetch(PREVIEW_URL, {
method: 'POST',
headers: csrf ? { 'X-CSRF-TOKEN': csrf } : {},
body: fd,
});
const html = await res.text();
previewIframeEl.srcdoc = html;
previewDirty = false;
} catch (e) {
previewIframeEl.srcdoc = `<div style="padding:16px;font-family:system-ui;">
<b>미리보기 실패</b><br><span style="opacity:.7;">${escapeHtml(String(e))}</span>
</div>`;
previewDirty = false;
}
}
let iframeTimer = null;
function debouncedIframePreview(){
previewDirty = true;
if(!isModalOpen) return;
clearTimeout(iframeTimer);
iframeTimer = setTimeout(updateIframePreview, 200);
}
// ===== modal open/close (메일 미리보기)
function openModal(){
if(!modalEl) return;
modalEl.style.display = 'block';
document.documentElement.style.overflow = 'hidden';
isModalOpen = true;
if(previewDirty) updateIframePreview();
}
function closeModal(){
if(!modalEl) return;
modalEl.style.display = 'none';
document.documentElement.style.overflow = '';
isModalOpen = false;
}
openBtn?.addEventListener('click', openModal);
closeBtn?.addEventListener('click', closeModal);
backEl?.addEventListener('click', closeModal);
refreshBtn?.addEventListener('click', () => { previewDirty = true; updateIframePreview(); });
// ===== 예약발송 Picker modal (추가)
function buildSelectOptions(){
if (schHourEl && schHourEl.options.length === 0){
for(let h=0; h<24; h++){
const opt = document.createElement('option');
opt.value = String(h);
opt.textContent = pad2(h);
schHourEl.appendChild(opt);
}
}
if (schMinEl && schMinEl.options.length === 0){
for(let m=0; m<60; m+=5){
const opt = document.createElement('option');
opt.value = String(m);
opt.textContent = pad2(m);
schMinEl.appendChild(opt);
}
}
}
function fillPickerFromCurrent(){
buildSelectOptions();
const cur = parseScheduledAt(scheduledAtEl?.value || '');
if (cur){
if (schDateEl) schDateEl.value = cur.date;
if (schHourEl) schHourEl.value = String(cur.hh);
if (schMinEl) schMinEl.value = String(cur.mm - (cur.mm % 5));
return;
}
const d = next5min();
if (schDateEl) schDateEl.value = toDateInputValue(d);
if (schHourEl) schHourEl.value = String(d.getHours());
if (schMinEl) schMinEl.value = String(d.getMinutes() - (d.getMinutes()%5));
}
let schModalOpen = false;
function openScheduleModal(){
if(!schModalEl) return;
fillPickerFromCurrent();
schModalEl.style.display = 'block';
document.documentElement.style.overflow = 'hidden';
schModalOpen = true;
}
function closeScheduleModal(){
if(!schModalEl) return;
schModalEl.style.display = 'none';
document.documentElement.style.overflow = '';
schModalOpen = false;
}
openSchBtn?.addEventListener('click', () => {
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked;
if (!isSch) return;
openScheduleModal();
});
schCloseBtn?.addEventListener('click', closeScheduleModal);
schBackEl?.addEventListener('click', closeScheduleModal);
schResetBtn?.addEventListener('click', () => {
if (scheduledAtEl) scheduledAtEl.value = '';
fillPickerFromCurrent();
refreshCounts();
syncMirrors();
refreshInlinePreview();
debouncedIframePreview();
});
schApplyBtn?.addEventListener('click', () => {
const dateStr = (schDateEl?.value || '').trim();
const hh = Number(schHourEl?.value || 0);
const mm = Number(schMinEl?.value || 0);
if (!dateStr){
alert('날짜를 선택하세요.');
return;
}
if (scheduledAtEl){
scheduledAtEl.value = formatScheduledAt(dateStr, hh, mm); // "YYYY-MM-DD HH:mm"
}
refreshCounts();
syncMirrors();
refreshInlinePreview();
debouncedIframePreview();
closeScheduleModal();
});
// Esc 처리: 두 모달 모두 닫기
document.addEventListener('keydown', (e) => {
if(e.key !== 'Escape') return;
if (modalEl?.style.display === 'block') closeModal();
if (schModalOpen) closeScheduleModal();
});
// 초기 schedule 상태 반영(버튼 disable 동기화)
(() => {
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked;
if (openSchBtn) openSchBtn.disabled = !isSch;
})();
// ===== submit guard
form.addEventListener('submit', (e) => {
// ✅ submit 직전 한번 더 동기화 (주제/내용 누락 방어)
syncMirrors();
const mode = sendModeEl.value;
const sub = (subjectEl?.value||'').trim();
const body = (bodyEl?.value||'').trim();
if(!sub){ e.preventDefault(); alert('제목을 입력하세요.'); return; }
if(!body){ e.preventDefault(); alert('내용을 입력하세요.'); return; }
if(mode === 'one'){
const em = (toEmailOneEl?.value||'').trim();
if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(em)){ e.preventDefault(); alert('수신자 이메일(단건)을 확인하세요.'); return; }
}
if(mode === 'many'){
const r = parseManyLines(toEmailsTextEl?.value || '');
if(r.rows.length < 1){
e.preventDefault();
alert('여러건 수신자를 입력하세요. (1줄=1명, 1열=email, 2열~ 토큰)');
return;
}
// 오류가 있으면 발송 막기
if(r.errors.length){
e.preventDefault();
const lines = r.errors.slice(0, 8).map(x => x.line).join(', ');
alert(`여러건 입력에 오류가 있습니다.\n- 오류 ${r.errors.length}\n- 확인 라인: ${lines}${r.errors.length > 8 ? ' ...' : ''}`);
return;
}
// 서버 전달값 갱신 보장
if (manyRowsJsonEl) manyRowsJsonEl.value = JSON.stringify(r.rows);
}
if(mode === 'template' || mode === 'csv'){
const hasT = (csvFileEl?.files||[]).length > 0;
if(!hasT){ e.preventDefault(); alert('템플릿 CSV를 업로드하세요.'); return; }
}
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked;
if(isSch && !(scheduledAtEl?.value||'').trim()){ e.preventDefault(); alert('예약 시간을 선택하세요.'); return; }
document.getElementById('sendBtn')?.setAttribute('disabled','disabled');
});
})();
</script>
@endpush
@endsection