876 lines
43 KiB
PHP
876 lines
43 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>
|
|
<input class="a-input" type="text" name="scheduled_at" id="scheduledAt" placeholder="YYYY-MM-DD HH:mm" style="width:180px" disabled>
|
|
<span class="a-muted" style="font-size:12px;">(5분 단위 권장)</span>
|
|
</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>
|
|
<button type="button" class="mbtn" data-tab="db">DB 검색</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명 (줄바꿈 기준) ✅ 1열: 이메일, 2열~: 토큰(콤마로 구분) 예) 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>
|
|
|
|
<section data-panel="db" style="display:none;">
|
|
<div class="a-muted" style="margin-bottom:6px;">회원 DB 검색 발송</div>
|
|
<div class="a-muted mhelp" style="margin-bottom:10px;">
|
|
* 서버에서 조건에 맞는 회원을 찾아 임시 리스트를 만든 뒤, 큐로 천천히 발송합니다.
|
|
</div>
|
|
|
|
<div class="mrow">
|
|
<input class="a-input" name="db_q" id="memberQ" placeholder="이메일/성명/회원번호 등" style="width:320px;">
|
|
<input class="a-input" name="db_limit" id="memberLimit" placeholder="최대 발송수(예: 1000)" style="width:160px;">
|
|
</div>
|
|
|
|
<div class="a-muted mhelp" style="margin-top:8px;">
|
|
예) <span class="mmono">gmail.com</span> / <span class="mmono">홍길동</span> / <span class="mmono">mem_no:123</span>
|
|
</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>
|
|
|
|
{{-- ===== 전체 메일 형태 미리보기: 레이어 팝업 + 내부 스크롤 + 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 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');
|
|
|
|
// ===== helpers
|
|
function escapeHtml(s){
|
|
return String(s).replace(/[&<>"']/g, m => ({
|
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
}[m]));
|
|
}
|
|
|
|
// ✅ 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.disabled = false;
|
|
|
|
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 (!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_})이 되게 맞춤
|
|
// (백엔드가 보통 N-1 인덱스로 접근하는 케이스 방어)
|
|
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);
|
|
|
|
// 템플릿을 비웠으면(선택 해제) 스킨을 다시 변경 가능
|
|
if (!chosen){
|
|
// 아무 것도 안 함 (skin 선택은 자유)
|
|
}
|
|
});
|
|
|
|
// 스킨을 바꾸려고 하면, 템플릿이 선택된 상태라면 템플릿을 해제하고 스킨 모드로 전환
|
|
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(); });
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if(e.key === 'Escape' && modalEl?.style.display === 'block') closeModal();
|
|
});
|
|
|
|
// ===== 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
|