관리자 메일 템플릿 발송내역 작업
This commit is contained in:
parent
7e04708d79
commit
e1b7951f4c
285
app/Http/Controllers/Admin/Mail/AdminMailController.php
Normal file
285
app/Http/Controllers/Admin/Mail/AdminMailController.php
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Mail;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Mail\AdminMailService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
final class AdminMailController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminMailService $service
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$templates = $this->service->getActiveTemplates(); // service가 repo 호출
|
||||||
|
$skins = $this->service->getSkinOptions();
|
||||||
|
|
||||||
|
return view('admin.mail.send', [
|
||||||
|
'templates' => $templates,
|
||||||
|
'skins' => $skins,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
// ✅ 템플릿 선택값(옵션): Blade에서 <select name="template_id"> 로 보내면 여기서 받음
|
||||||
|
$hasTemplate = $request->filled('template_id');
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'send_mode' => ['required','in:one,many,csv,db'],
|
||||||
|
|
||||||
|
// ✅ schedule_type 추가 + 예약이면 scheduled_at 필수
|
||||||
|
'schedule_type' => ['required','in:now,schedule'],
|
||||||
|
'scheduled_at' => ['nullable','date_format:Y-m-d H:i','required_if:schedule_type,schedule'],
|
||||||
|
|
||||||
|
'from_email' => ['required','email','max:190'],
|
||||||
|
'from_name' => ['nullable','string','max:120'],
|
||||||
|
'reply_to' => ['nullable','email','max:190'],
|
||||||
|
|
||||||
|
// ✅ 템플릿 선택이면(스킨 select disabled 등) skin_key가 누락될 수 있으니 nullable 허용 + 서버에서 기본값 세팅
|
||||||
|
'skin_key' => $hasTemplate
|
||||||
|
? ['nullable','in:hero,newsletter,minimal,clean,dark']
|
||||||
|
: ['required','in:hero,newsletter,minimal,clean,dark'],
|
||||||
|
|
||||||
|
// 템플릿 선택용(옵션)
|
||||||
|
'template_id' => ['nullable','integer','min:1'],
|
||||||
|
|
||||||
|
// ✅ subject/body는 무조건 들어가야 함(템플릿이면 적용 버튼으로 채워지게)
|
||||||
|
'subject' => ['required','string','max:190'],
|
||||||
|
'body' => ['required','string','max:20000'],
|
||||||
|
|
||||||
|
'hero_image_url' => ['nullable','string','max:500'],
|
||||||
|
'cta_label' => ['nullable','string','max:80'],
|
||||||
|
'cta_url' => ['nullable','string','max:500'],
|
||||||
|
|
||||||
|
// one
|
||||||
|
'to_email' => ['nullable','email','max:190'],
|
||||||
|
'to_name' => ['nullable','string','max:120'],
|
||||||
|
|
||||||
|
// many(text)
|
||||||
|
'to_emails_text' => ['nullable','string','max:500000'],
|
||||||
|
|
||||||
|
// csv upload
|
||||||
|
'to_emails_csv' => ['nullable','file','mimes:csv,txt','max:5120'],
|
||||||
|
|
||||||
|
// db mode(최소 v1)
|
||||||
|
'db_q' => ['nullable','string','max:120'],
|
||||||
|
'db_limit' => ['nullable','integer','min:1','max:20000'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$v = Validator::make($request->all(), $rules);
|
||||||
|
|
||||||
|
// ✅ 모드별 추가 검증(필수값 체크)
|
||||||
|
$v->after(function ($validator) use ($request) {
|
||||||
|
$mode = (string)$request->input('send_mode');
|
||||||
|
|
||||||
|
if ($mode === 'one') {
|
||||||
|
$em = trim((string)$request->input('to_email'));
|
||||||
|
if ($em === '') {
|
||||||
|
$validator->errors()->add('to_email', '단건 발송은 수신자 이메일이 필요합니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mode === 'many') {
|
||||||
|
$raw = (string)$request->input('to_emails_text');
|
||||||
|
if (trim($raw) === '') {
|
||||||
|
$validator->errors()->add('to_emails_text', '여러건 발송은 수신자 목록을 입력하세요.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mode === 'csv') {
|
||||||
|
if (!$request->file('to_emails_csv')) {
|
||||||
|
$validator->errors()->add('to_emails_csv', 'CSV 발송은 CSV 파일 업로드가 필요합니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$data = $v->validate();
|
||||||
|
|
||||||
|
// ✅ skin_key가 폼에서 누락되면(템플릿 선택 시 disabled 등) 기본값 채움
|
||||||
|
// (템플릿이면 서비스가 템플릿의 skin_key로 덮어쓰도록 하는 게 정석)
|
||||||
|
$data['skin_key'] = (string)($data['skin_key'] ?? '');
|
||||||
|
if ($data['skin_key'] === '') {
|
||||||
|
$data['skin_key'] = 'clean';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 컨트롤러에서 “정상 규칙”으로 파싱 결과를 만들어 request에 심어둠
|
||||||
|
// → 서비스에서 이 parsed_rows를 우선 사용하면 토큰 밀림(덮어쓰기) 문제를 확실히 잡을 수 있음.
|
||||||
|
if ($data['send_mode'] === 'many') {
|
||||||
|
$rows = $this->parseManyText((string)($data['to_emails_text'] ?? ''));
|
||||||
|
if (count($rows) < 1) {
|
||||||
|
return back()->with('toast', [
|
||||||
|
'type'=>'danger','title'=>'실패','message'=>'여러건 수신자 파싱 결과가 없습니다. (이메일 형식/중복/입력 형식 확인)'
|
||||||
|
])->withInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
// request에 전달(서비스에서 우선 사용)
|
||||||
|
$request->request->set('parsed_rows', $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data['send_mode'] === 'csv') {
|
||||||
|
/** @var UploadedFile|null $file */
|
||||||
|
$file = $request->file('to_emails_csv');
|
||||||
|
$rows = $file ? $this->parseCsvFile($file) : [];
|
||||||
|
if (count($rows) < 1) {
|
||||||
|
return back()->with('toast', [
|
||||||
|
'type'=>'danger','title'=>'실패','message'=>'CSV 파싱 결과가 없습니다. (1열 이메일 형식/중복/파일 내용 확인)'
|
||||||
|
])->withInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->request->set('parsed_rows', $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminId = (int)auth('admin')->id();
|
||||||
|
$ctx = [
|
||||||
|
'admin_name' => (string)(auth('admin')->user()->name ?? ''),
|
||||||
|
'ip' => (string)$request->ip(),
|
||||||
|
'ua' => (string)$request->userAgent(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$res = $this->service->createBatch($adminId, $data, $request, $ctx);
|
||||||
|
|
||||||
|
if (!$res['ok']) {
|
||||||
|
return back()->with('toast', [
|
||||||
|
'type'=>'danger','title'=>'실패','message'=>$res['message'] ?? '처리에 실패했습니다.'
|
||||||
|
])->withInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('admin.mail.logs.show', ['batchId'=>$res['batch_id']])
|
||||||
|
->with('toast', [
|
||||||
|
'type'=>'success','title'=>'접수 완료',
|
||||||
|
'message'=>"총 {$res['total']}건 / 유효 {$res['valid']}건 등록. " . ($res['scheduled'] ? '예약 발송입니다.' : '발송을 시작합니다.')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preview(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'skin_key' => ['required','string','max:40'],
|
||||||
|
'subject' => ['nullable','string','max:200'],
|
||||||
|
'body' => ['nullable','string','max:20000'],
|
||||||
|
'heroUrl' => ['nullable','string','max:500'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ✅ 미리보기 샘플도 실제 규칙과 동일하게
|
||||||
|
// {_text_02_}=이름, {_text_03_}=금액, {_text_04_}=상품유형
|
||||||
|
$sample = [
|
||||||
|
'{_text_02_}' => '홍길동',
|
||||||
|
'{_text_03_}' => '10000',
|
||||||
|
'{_text_04_}' => '쿠폰',
|
||||||
|
];
|
||||||
|
|
||||||
|
$subject = strtr((string)($data['subject'] ?? ''), $sample);
|
||||||
|
$body = strtr((string)($data['body'] ?? ''), $sample);
|
||||||
|
|
||||||
|
return response()->view('mail.preview', [
|
||||||
|
'skin_key' => $data['skin_key'],
|
||||||
|
'brand' => 'PIN FOR YOU',
|
||||||
|
'subtitle' => '공지/이벤트/안내 메일',
|
||||||
|
'logoUrl' => config('services.brand.logo_url', ''),
|
||||||
|
'heroUrl' => (string)($data['heroUrl'] ?? ''),
|
||||||
|
'subject' => $subject,
|
||||||
|
'body_html' => nl2br(e($body)),
|
||||||
|
'siteUrl' => config('app.url'),
|
||||||
|
'year' => date('Y'),
|
||||||
|
'sns' => [
|
||||||
|
['label'=>'Instagram','url'=>'https://example.com'],
|
||||||
|
['label'=>'YouTube','url'=>'https://example.com'],
|
||||||
|
['label'=>'Kakao','url'=>'https://example.com'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Token helpers (컨트롤러 공통)
|
||||||
|
// =========================
|
||||||
|
private function buildVarsFromTokens(array $tokens): array
|
||||||
|
{
|
||||||
|
$vars = [];
|
||||||
|
$tokens = array_values($tokens);
|
||||||
|
|
||||||
|
foreach ($tokens as $i => $v) {
|
||||||
|
$key = sprintf('{_text_%02d_}', $i + 2); // ✅ 무조건 02부터
|
||||||
|
$vars[$key] = trim((string)$v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyVars(string $tpl, array $vars): string
|
||||||
|
{
|
||||||
|
return strtr($tpl, $vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bodyToHtml(string $text): string
|
||||||
|
{
|
||||||
|
// ✅ 줄바꿈 유지 + XSS 방지
|
||||||
|
return nl2br(e($text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Parsers
|
||||||
|
// =========================
|
||||||
|
private function parseManyText(string $raw): array
|
||||||
|
{
|
||||||
|
$lines = preg_split("/\r\n|\r|\n/", $raw) ?: [];
|
||||||
|
$rows = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($lines as $idx => $line) {
|
||||||
|
$line = trim((string)$line);
|
||||||
|
if ($line === '') continue;
|
||||||
|
|
||||||
|
// ✅ 빈열 유지(열 밀림 방지)
|
||||||
|
$cols = array_map('trim', explode(',', $line));
|
||||||
|
$email = strtolower(trim($cols[0] ?? ''));
|
||||||
|
|
||||||
|
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) continue;
|
||||||
|
if (isset($seen[$email])) continue;
|
||||||
|
$seen[$email] = true;
|
||||||
|
|
||||||
|
$tokens = array_slice($cols, 1); // 2열부터
|
||||||
|
$vars = $this->buildVarsFromTokens($tokens);
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'email' => $email,
|
||||||
|
'vars' => $vars,
|
||||||
|
'raw' => $line,
|
||||||
|
'line' => $idx + 1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseCsvFile(UploadedFile $file): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
$handle = fopen($file->getRealPath(), 'rb');
|
||||||
|
if (!$handle) return [];
|
||||||
|
|
||||||
|
while (($cols = fgetcsv($handle)) !== false) {
|
||||||
|
$cols = array_map(fn($v) => trim((string)$v), $cols);
|
||||||
|
|
||||||
|
$email = strtolower(trim($cols[0] ?? ''));
|
||||||
|
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) continue;
|
||||||
|
if (isset($seen[$email])) continue;
|
||||||
|
$seen[$email] = true;
|
||||||
|
|
||||||
|
$tokens = array_slice($cols, 1); // 2열부터
|
||||||
|
$vars = $this->buildVarsFromTokens($tokens);
|
||||||
|
|
||||||
|
$rows[] = ['email' => $email, 'vars' => $vars];
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Http/Controllers/Admin/Mail/AdminMailLogController.php
Normal file
75
app/Http/Controllers/Admin/Mail/AdminMailLogController.php
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Http\Controllers\Admin\Mail;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Mail\AdminMailService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class AdminMailLogController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AdminMailService $service) {}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$filters = $request->validate([
|
||||||
|
'status' => ['nullable','in:scheduled,queued,sending,sent,partial,failed,canceled'],
|
||||||
|
'send_mode' => ['nullable','in:one,many,csv,template,db'],
|
||||||
|
'date_from' => ['nullable','date'],
|
||||||
|
'date_to' => ['nullable','date'],
|
||||||
|
'q' => ['nullable','string','max:120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$page = $this->service->listBatches($filters, perPage: 20);
|
||||||
|
|
||||||
|
return view('admin.mail.logs.index', [
|
||||||
|
'batches' => $page,
|
||||||
|
'filters' => $filters,
|
||||||
|
'labels' => $this->service->getStatusLabels(),
|
||||||
|
'modeLabels' => $this->service->getModeLabels(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, int $batchId)
|
||||||
|
{
|
||||||
|
$filters = $request->validate([
|
||||||
|
'status' => ['nullable','in:queued,sent,failed,canceled,skipped'],
|
||||||
|
'to' => ['nullable','string','max:190'],
|
||||||
|
'q' => ['nullable','string','max:120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$res = $this->service->getBatchDetail($batchId, $filters, perPage: 50);
|
||||||
|
if (!$res['ok']) abort(404);
|
||||||
|
|
||||||
|
return view('admin.mail.logs.show', [
|
||||||
|
'batch' => $res['batch'],
|
||||||
|
'items' => $res['items'],
|
||||||
|
'labels' => $this->service->getStatusLabels(),
|
||||||
|
'itemLabels' => $this->service->getItemStatusLabels(),
|
||||||
|
'modeLabels' => $this->service->getModeLabels(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(int $batchId)
|
||||||
|
{
|
||||||
|
$adminId = (int)auth('admin')->id();
|
||||||
|
$res = $this->service->cancelBatch($adminId, $batchId);
|
||||||
|
|
||||||
|
return back()->with('toast', [
|
||||||
|
'type' => $res['ok'] ? 'success' : 'danger',
|
||||||
|
'title'=> $res['ok'] ? '처리 완료' : '실패',
|
||||||
|
'message' => $res['message'] ?? ($res['ok'] ? '취소 처리했습니다.' : '취소 실패'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retryFailed(int $batchId)
|
||||||
|
{
|
||||||
|
$adminId = (int)auth('admin')->id();
|
||||||
|
$res = $this->service->retryFailed($adminId, $batchId);
|
||||||
|
|
||||||
|
return back()->with('toast', [
|
||||||
|
'type' => $res['ok'] ? 'success' : 'danger',
|
||||||
|
'title'=> $res['ok'] ? '재시도 시작' : '실패',
|
||||||
|
'message' => $res['message'] ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Http\Controllers\Admin\Mail;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Mail\AdminMailService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class AdminMailTemplateController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AdminMailService $service) {}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$filters = $request->validate([
|
||||||
|
'active' => ['nullable','in:0,1'],
|
||||||
|
'q' => ['nullable','string','max:120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$page = $this->service->listTemplates($filters, perPage: 20);
|
||||||
|
|
||||||
|
return view('admin.mail.templates.index', [
|
||||||
|
'templates' => $page,
|
||||||
|
'filters' => $filters,
|
||||||
|
'skins' => $this->service->getSkinOptions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
return view('admin.mail.templates.form', [
|
||||||
|
'mode' => 'create',
|
||||||
|
'tpl' => (object)[
|
||||||
|
'id'=>null,'code'=>'','title'=>'','description'=>'',
|
||||||
|
'skin_key'=>'hero','subject'=>'','body'=>'',
|
||||||
|
'hero_image_url'=>'','cta_label'=>'','cta_url'=>'','is_active'=>1,
|
||||||
|
],
|
||||||
|
'skins' => $this->service->getSkinOptions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$data = $this->validateTemplate($request, isCreate: true);
|
||||||
|
$adminId = (int)auth('admin')->id();
|
||||||
|
|
||||||
|
$res = $this->service->createTemplate($adminId, $data);
|
||||||
|
if (!$res['ok']) return back()->with('toast',['type'=>'danger','title'=>'실패','message'=>$res['message']])->withInput();
|
||||||
|
|
||||||
|
return redirect()->route('admin.mail.templates.index')->with('toast',['type'=>'success','title'=>'완료','message'=>'템플릿을 생성했습니다.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(int $id)
|
||||||
|
{
|
||||||
|
$tpl = $this->service->getTemplate($id);
|
||||||
|
if (!$tpl) abort(404);
|
||||||
|
|
||||||
|
return view('admin.mail.templates.form', [
|
||||||
|
'mode' => 'edit',
|
||||||
|
'tpl' => $tpl,
|
||||||
|
'skins' => $this->service->getSkinOptions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$data = $this->validateTemplate($request, isCreate: false);
|
||||||
|
$adminId = (int)auth('admin')->id();
|
||||||
|
|
||||||
|
$res = $this->service->updateTemplate($adminId, $id, $data);
|
||||||
|
if (!$res['ok']) return back()->with('toast',['type'=>'danger','title'=>'실패','message'=>$res['message']])->withInput();
|
||||||
|
|
||||||
|
return redirect()->route('admin.mail.templates.index')->with('toast',['type'=>'success','title'=>'완료','message'=>'템플릿을 수정했습니다.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateTemplate(Request $request, bool $isCreate): array
|
||||||
|
{
|
||||||
|
$rules = [
|
||||||
|
'skin_key' => ['required','in:hero,newsletter,minimal,clean,dark'],
|
||||||
|
'title' => ['required','string','max:120'],
|
||||||
|
'description' => ['nullable','string','max:255'],
|
||||||
|
'subject' => ['required','string','max:190'],
|
||||||
|
'body' => ['required','string','max:20000'],
|
||||||
|
'hero_image_url' => ['nullable','string','max:500'],
|
||||||
|
'cta_label' => ['nullable','string','max:80'],
|
||||||
|
'cta_url' => ['nullable','string','max:500'],
|
||||||
|
'is_active' => ['nullable','in:1'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($isCreate) {
|
||||||
|
$rules['code'] = ['required','string','min:3','max:60','regex:/^[a-zA-Z0-9_-]+$/'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request->validate($rules);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Jobs/Admin/Mail/DispatchMailBatchJob.php
Normal file
33
app/Jobs/Admin/Mail/DispatchMailBatchJob.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Jobs\Admin\Mail;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Mail\AdminMailRepository;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
|
||||||
|
final class DispatchMailBatchJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, Queueable;
|
||||||
|
|
||||||
|
public $tries = 1;
|
||||||
|
|
||||||
|
public function __construct(public int $batchId) {}
|
||||||
|
|
||||||
|
public function handle(AdminMailRepository $repo): void
|
||||||
|
{
|
||||||
|
$b = $repo->findBatch($this->batchId);
|
||||||
|
if (!$b) return;
|
||||||
|
|
||||||
|
if (in_array((string)$b->status, ['canceled','sent','failed','partial'], true)) return;
|
||||||
|
|
||||||
|
// sending 상태로 전환
|
||||||
|
$repo->markBatchSending($this->batchId);
|
||||||
|
|
||||||
|
// queued 아이템을 일정량씩 job으로 뿌림
|
||||||
|
$items = $repo->nextQueuedItems($this->batchId, 500);
|
||||||
|
foreach ($items as $it) {
|
||||||
|
SendMailItemJob::dispatch($this->batchId, (int)$it->id)->onQueue('mail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Jobs/Admin/Mail/SendMailItemJob.php
Normal file
44
app/Jobs/Admin/Mail/SendMailItemJob.php
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Jobs\Admin\Mail;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Mail\AdminMailRepository;
|
||||||
|
use App\Services\Admin\Mail\AdminMailService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\Middleware\RateLimited;
|
||||||
|
|
||||||
|
final class SendMailItemJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, Queueable;
|
||||||
|
|
||||||
|
public $tries = 3;
|
||||||
|
public $backoff = [10, 60, 180]; // 재시도 간격
|
||||||
|
|
||||||
|
public function __construct(public int $batchId, public int $itemId) {}
|
||||||
|
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new RateLimited('admin-mail-smtp')];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(AdminMailRepository $repo, AdminMailService $svc): void
|
||||||
|
{
|
||||||
|
$batch = $repo->findBatch($this->batchId);
|
||||||
|
if (!$batch) return;
|
||||||
|
|
||||||
|
if (in_array((string)$batch->status, ['canceled','sent','failed','partial'], true)) return;
|
||||||
|
|
||||||
|
$item = \DB::table('admin_mail_batch_items')->where('id',$this->itemId)->first();
|
||||||
|
if (!$item) return;
|
||||||
|
if ((string)$item->status !== 'queued') return;
|
||||||
|
|
||||||
|
$svc->sendItem($batch, $item);
|
||||||
|
$svc->refreshBatchProgress($this->batchId);
|
||||||
|
|
||||||
|
// 아직 queued가 남아있으면 계속 뿌리기(가벼운 자기재호출)
|
||||||
|
if (\DB::table('admin_mail_batch_items')->where('batch_id',$this->batchId)->where('status','queued')->exists()) {
|
||||||
|
DispatchMailBatchJob::dispatch($this->batchId)->onQueue('mail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -46,5 +46,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
Limit::perMinute(5)->by('admin-otp:'.$request->session()->getId()),
|
Limit::perMinute(5)->by('admin-otp:'.$request->session()->getId()),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('admin-mail-smtp', function () {
|
||||||
|
return Limit::perMinute(30)->by('admin-mail-smtp');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
161
app/Repositories/Admin/Mail/AdminMailRepository.php
Normal file
161
app/Repositories/Admin/Mail/AdminMailRepository.php
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Repositories\Admin\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AdminMailRepository
|
||||||
|
{
|
||||||
|
public function createBatch(array $row): int
|
||||||
|
{
|
||||||
|
DB::table('admin_mail_batches')->insert($row);
|
||||||
|
return (int)DB::getPdo()->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateBatch(int $batchId, array $row): int
|
||||||
|
{
|
||||||
|
return DB::table('admin_mail_batches')->where('id', $batchId)->update($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findBatch(int $batchId): ?object
|
||||||
|
{
|
||||||
|
$b = DB::table('admin_mail_batches')->where('id',$batchId)->first();
|
||||||
|
return $b ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listBatches(array $filters, int $perPage)
|
||||||
|
{
|
||||||
|
$q = DB::table('admin_mail_batches');
|
||||||
|
|
||||||
|
if (!empty($filters['status'])) $q->where('status', $filters['status']);
|
||||||
|
if (!empty($filters['send_mode'])) $q->where('send_mode', $filters['send_mode']);
|
||||||
|
|
||||||
|
if (!empty($filters['date_from'])) $q->whereDate('created_at','>=',$filters['date_from']);
|
||||||
|
if (!empty($filters['date_to'])) $q->whereDate('created_at','<=',$filters['date_to']);
|
||||||
|
|
||||||
|
if (!empty($filters['q'])) {
|
||||||
|
$kw = '%'.$filters['q'].'%';
|
||||||
|
$q->where(function($w) use ($kw){
|
||||||
|
$w->where('subject_raw','like',$kw)
|
||||||
|
->orWhere('body_raw','like',$kw)
|
||||||
|
->orWhere('from_email','like',$kw)
|
||||||
|
->orWhere('admin_name','like',$kw)
|
||||||
|
->orWhere('last_error','like',$kw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q->orderByDesc('id')->paginate($perPage)->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkInsertItems(int $batchId, array $rows): void
|
||||||
|
{
|
||||||
|
// rows: [ [batch_id, seq, to_email, ...], ... ]
|
||||||
|
DB::table('admin_mail_batch_items')->insert($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listItems(int $batchId, array $filters, int $perPage)
|
||||||
|
{
|
||||||
|
$q = DB::table('admin_mail_batch_items')->where('batch_id',$batchId);
|
||||||
|
|
||||||
|
if (!empty($filters['status'])) $q->where('status',$filters['status']);
|
||||||
|
if (!empty($filters['to'])) $q->where('to_email','like','%'.$filters['to'].'%');
|
||||||
|
if (!empty($filters['q'])) {
|
||||||
|
$kw = '%'.$filters['q'].'%';
|
||||||
|
$q->where(function($w) use ($kw){
|
||||||
|
$w->where('subject_final','like',$kw)->orWhere('body_final','like',$kw)->orWhere('last_error','like',$kw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q->orderBy('seq')->paginate($perPage)->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markBatchSending(int $batchId): void
|
||||||
|
{
|
||||||
|
DB::table('admin_mail_batches')->where('id',$batchId)->update([
|
||||||
|
'status' => 'sending',
|
||||||
|
'started_at' => now()->format('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pickDueScheduledBatches(int $limit=20): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('admin_mail_batches')
|
||||||
|
->where('status','scheduled')
|
||||||
|
->whereNotNull('scheduled_at')
|
||||||
|
->where('scheduled_at','<=', now()->format('Y-m-d H:i:s'))
|
||||||
|
->orderBy('scheduled_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $rows ? $rows->all() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBatchQueued(int $batchId): void
|
||||||
|
{
|
||||||
|
DB::table('admin_mail_batches')->where('id',$batchId)->update(['status'=>'queued']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countItemsByStatus(int $batchId): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('admin_mail_batch_items')
|
||||||
|
->select('status', DB::raw('COUNT(*) as c'))
|
||||||
|
->where('batch_id',$batchId)
|
||||||
|
->groupBy('status')->get();
|
||||||
|
|
||||||
|
$map = ['sent'=>0,'failed'=>0,'canceled'=>0,'skipped'=>0,'queued'=>0];
|
||||||
|
foreach ($rows as $r) $map[$r->status] = (int)$r->c;
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateBatchCounts(int $batchId, array $counts, ?string $status=null, ?string $lastError=null): void
|
||||||
|
{
|
||||||
|
$row = [
|
||||||
|
'sent_count' => (int)($counts['sent'] ?? 0),
|
||||||
|
'failed_count' => (int)($counts['failed'] ?? 0),
|
||||||
|
'canceled_count' => (int)(($counts['canceled'] ?? 0) + ($counts['skipped'] ?? 0)),
|
||||||
|
];
|
||||||
|
if ($status) $row['status'] = $status;
|
||||||
|
if ($lastError !== null) $row['last_error'] = $lastError;
|
||||||
|
if (in_array($status, ['sent','partial','failed','canceled'], true)) {
|
||||||
|
$row['finished_at'] = now()->format('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
DB::table('admin_mail_batches')->where('id',$batchId)->update($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelBatch(int $batchId): void
|
||||||
|
{
|
||||||
|
DB::table('admin_mail_batches')->where('id',$batchId)->update([
|
||||||
|
'status'=>'canceled',
|
||||||
|
'finished_at'=>now()->format('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('admin_mail_batch_items')
|
||||||
|
->where('batch_id',$batchId)
|
||||||
|
->whereIn('status',['queued'])
|
||||||
|
->update(['status'=>'canceled','last_error'=>'batch_canceled']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetFailedToQueued(int $batchId): int
|
||||||
|
{
|
||||||
|
return DB::table('admin_mail_batch_items')
|
||||||
|
->where('batch_id',$batchId)
|
||||||
|
->where('status','failed')
|
||||||
|
->update(['status'=>'queued','last_error'=>'','attempts'=>0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextQueuedItems(int $batchId, int $limit=200): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('admin_mail_batch_items')
|
||||||
|
->where('batch_id',$batchId)
|
||||||
|
->where('status','queued')
|
||||||
|
->orderBy('seq')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $rows ? $rows->all() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateItem(int $id, array $row): void
|
||||||
|
{
|
||||||
|
DB::table('admin_mail_batch_items')->where('id',$id)->update($row);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Repositories/Admin/Mail/AdminMailTemplateRepository.php
Normal file
58
app/Repositories/Admin/Mail/AdminMailTemplateRepository.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Repositories\Admin\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AdminMailTemplateRepository
|
||||||
|
{
|
||||||
|
public function list(array $filters, int $perPage)
|
||||||
|
{
|
||||||
|
$q = DB::table('admin_mail_templates');
|
||||||
|
|
||||||
|
if (($filters['active'] ?? '') !== '') {
|
||||||
|
$q->where('is_active', (int)$filters['active']);
|
||||||
|
}
|
||||||
|
if (!empty($filters['q'])) {
|
||||||
|
$kw = '%'.$filters['q'].'%';
|
||||||
|
$q->where(function($w) use ($kw){
|
||||||
|
$w->where('code','like',$kw)
|
||||||
|
->orWhere('title','like',$kw)
|
||||||
|
->orWhere('subject_tpl','like',$kw)
|
||||||
|
->orWhere('body_tpl','like',$kw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q->orderByDesc('id')->paginate($perPage)->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activeAll(): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('admin_mail_templates')
|
||||||
|
->where('is_active',1)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $rows ? $rows->all() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(int $id): ?object
|
||||||
|
{
|
||||||
|
$r = DB::table('admin_mail_templates')->where('id',$id)->first();
|
||||||
|
return $r ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $row): int
|
||||||
|
{
|
||||||
|
return (int) DB::table('admin_mail_templates')->insertGetId($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(int $id, array $row): int
|
||||||
|
{
|
||||||
|
return DB::table('admin_mail_templates')->where('id',$id)->update($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function existsCode(string $code): bool
|
||||||
|
{
|
||||||
|
return DB::table('admin_mail_templates')->where('code',$code)->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
498
app/Services/Admin/Mail/AdminMailService.php
Normal file
498
app/Services/Admin/Mail/AdminMailService.php
Normal file
@ -0,0 +1,498 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Services\Admin\Mail;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Mail\AdminMailRepository;
|
||||||
|
use App\Repositories\Admin\Mail\AdminMailTemplateRepository;
|
||||||
|
use App\Services\MailService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
final class AdminMailService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminMailRepository $repo,
|
||||||
|
private readonly AdminMailTemplateRepository $tplRepo,
|
||||||
|
private readonly MailService $mail
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getSkinOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['key'=>'hero','label'=>'Hero Classic (이벤트/마케팅)'],
|
||||||
|
['key'=>'newsletter','label'=>'Newsletter Card (여러 항목)'],
|
||||||
|
['key'=>'minimal','label'=>'Minimal Transactional (알림/인증)'],
|
||||||
|
['key'=>'clean','label'=>'Clean Transactional'],
|
||||||
|
['key'=>'dark','label'=>'Dark Transactional'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'scheduled'=>'예약대기',
|
||||||
|
'queued'=>'대기',
|
||||||
|
'sending'=>'발송중',
|
||||||
|
'sent'=>'완료',
|
||||||
|
'partial'=>'부분실패',
|
||||||
|
'failed'=>'실패',
|
||||||
|
'canceled'=>'취소',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getItemStatusLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'queued'=>'대기',
|
||||||
|
'sent'=>'성공',
|
||||||
|
'failed'=>'실패',
|
||||||
|
'canceled'=>'취소',
|
||||||
|
'skipped'=>'스킵',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getModeLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'one'=>'단건',
|
||||||
|
'many'=>'직접입력(여러건)',
|
||||||
|
'csv'=>'CSV 업로드',
|
||||||
|
'db'=>'DB 검색',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActiveTemplates(): array
|
||||||
|
{
|
||||||
|
return $this->tplRepo->activeAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listTemplates(array $filters, int $perPage=20)
|
||||||
|
{
|
||||||
|
return $this->tplRepo->list($filters, $perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTemplate(int $id): ?object
|
||||||
|
{
|
||||||
|
return $this->tplRepo->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createTemplate(int $adminId, array $data): array
|
||||||
|
{
|
||||||
|
$code = (string)$data['code'];
|
||||||
|
if ($this->tplRepo->existsCode($code)) {
|
||||||
|
return ['ok'=>false,'message'=>'이미 존재하는 code 입니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->tplRepo->create([
|
||||||
|
'code' => $code,
|
||||||
|
'title' => $data['title'],
|
||||||
|
'description' => $data['description'] ?? '',
|
||||||
|
'skin_key' => $data['skin_key'],
|
||||||
|
'subject_tpl' => $data['subject'],
|
||||||
|
'body_tpl' => $data['body'],
|
||||||
|
'hero_image_url'=> $data['hero_image_url'] ?? '',
|
||||||
|
'cta_label' => $data['cta_label'] ?? '',
|
||||||
|
'cta_url' => $data['cta_url'] ?? '',
|
||||||
|
'is_active' => (int)(!empty($data['is_active']) ? 1 : 0),
|
||||||
|
'created_by' => $adminId,
|
||||||
|
'updated_by' => $adminId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['ok'=>true,'id'=>$id];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateTemplate(int $adminId, int $id, array $data): array
|
||||||
|
{
|
||||||
|
$affected = $this->tplRepo->update($id, [
|
||||||
|
'title'=>$data['title'],
|
||||||
|
'description'=>$data['description'] ?? '',
|
||||||
|
'skin_key'=>$data['skin_key'],
|
||||||
|
'subject_tpl'=>$data['subject'],
|
||||||
|
'body_tpl'=>$data['body'],
|
||||||
|
'hero_image_url'=>$data['hero_image_url'] ?? '',
|
||||||
|
'cta_label'=>$data['cta_label'] ?? '',
|
||||||
|
'cta_url'=>$data['cta_url'] ?? '',
|
||||||
|
'is_active'=> (int)(($data['is_active'] ?? 0) ? 1 : 0),
|
||||||
|
'updated_by'=>$adminId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['ok'=>$affected>=0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listBatches(array $filters, int $perPage=20)
|
||||||
|
{
|
||||||
|
return $this->repo->listBatches($filters, $perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBatchDetail(int $batchId, array $filters, int $perPage=50): array
|
||||||
|
{
|
||||||
|
$batch = $this->repo->findBatch($batchId);
|
||||||
|
if (!$batch) return ['ok'=>false];
|
||||||
|
|
||||||
|
$items = $this->repo->listItems($batchId, $filters, $perPage);
|
||||||
|
return ['ok'=>true,'batch'=>$batch,'items'=>$items];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelBatch(int $adminId, int $batchId): array
|
||||||
|
{
|
||||||
|
$b = $this->repo->findBatch($batchId);
|
||||||
|
if (!$b) return ['ok'=>false,'message'=>'배치를 찾을 수 없습니다.'];
|
||||||
|
|
||||||
|
if (in_array((string)$b->status, ['sent','failed','partial','canceled'], true)) {
|
||||||
|
return ['ok'=>false,'message'=>'이미 종료된 배치입니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->repo->cancelBatch($batchId);
|
||||||
|
return ['ok'=>true,'message'=>'취소 처리했습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retryFailed(int $adminId, int $batchId): array
|
||||||
|
{
|
||||||
|
$b = $this->repo->findBatch($batchId);
|
||||||
|
if (!$b) return ['ok'=>false,'message'=>'배치를 찾을 수 없습니다.'];
|
||||||
|
|
||||||
|
$n = $this->repo->resetFailedToQueued($batchId);
|
||||||
|
$this->repo->updateBatch($batchId, ['status'=>'queued','last_error'=>'']);
|
||||||
|
|
||||||
|
\App\Jobs\Admin\Mail\DispatchMailBatchJob::dispatch($batchId)->onQueue('mail');
|
||||||
|
|
||||||
|
return ['ok'=>true,'message'=>"실패 {$n}건을 재시도 대기열로 전환했습니다."];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createBatch(int $adminId, array $data, Request $request, array $ctx): array
|
||||||
|
{
|
||||||
|
$mode = (string)$data['send_mode'];
|
||||||
|
|
||||||
|
// ✅ (선택) 템플릿을 서버에서 강제 적용하고 싶으면 template_id가 넘어왔을 때 덮어쓰기
|
||||||
|
// Blade에서 tplSelect에 name="template_id" 꼭 넣어야 들어옴
|
||||||
|
$tplId = (int)($data['template_id'] ?? 0);
|
||||||
|
if ($tplId > 0) {
|
||||||
|
$tpl = $this->tplRepo->find($tplId);
|
||||||
|
if (!$tpl) return ['ok'=>false,'message'=>'템플릿을 찾을 수 없습니다.'];
|
||||||
|
|
||||||
|
$data['skin_key'] = (string)($tpl->skin_key ?? $data['skin_key']);
|
||||||
|
$data['subject'] = (string)($tpl->subject_tpl ?? $data['subject']);
|
||||||
|
$data['body'] = (string)($tpl->body_tpl ?? $data['body']);
|
||||||
|
$data['hero_image_url'] = (string)($tpl->hero_image_url ?? ($data['hero_image_url'] ?? ''));
|
||||||
|
$data['cta_label'] = (string)($tpl->cta_label ?? ($data['cta_label'] ?? ''));
|
||||||
|
$data['cta_url'] = (string)($tpl->cta_url ?? ($data['cta_url'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 수신자 수집
|
||||||
|
$recipients = $this->collectRecipients($mode, $data, $request);
|
||||||
|
if (!$recipients['ok']) return $recipients;
|
||||||
|
|
||||||
|
$list = $recipients['list']; // [ ['email'=>..., 'name'=>..., 'tokens'=>[...]], ... ]
|
||||||
|
$total = $recipients['total'];
|
||||||
|
$valid = count($list);
|
||||||
|
|
||||||
|
if ($valid < 1) {
|
||||||
|
return ['ok'=>false,'message'=>'유효한 수신자가 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 배치 생성
|
||||||
|
$scheduledAt = !empty($data['scheduled_at']) ? (string)$data['scheduled_at'].':00' : null;
|
||||||
|
$status = $scheduledAt ? 'scheduled' : 'queued';
|
||||||
|
|
||||||
|
$ipBin = null;
|
||||||
|
try { $ipBin = @inet_pton($ctx['ip'] ?? '') ?: null; } catch (\Throwable $e) {}
|
||||||
|
|
||||||
|
$batchId = $this->repo->createBatch([
|
||||||
|
'admin_user_id'=>$adminId,
|
||||||
|
'admin_name'=>$ctx['admin_name'] ?? '',
|
||||||
|
'created_ip'=>$ipBin,
|
||||||
|
'created_ua'=>mb_substr((string)($ctx['ua'] ?? ''),0,255),
|
||||||
|
|
||||||
|
'send_mode'=>$mode,
|
||||||
|
'from_email'=>$data['from_email'],
|
||||||
|
'from_name'=>$data['from_name'] ?? '',
|
||||||
|
'reply_to'=>$data['reply_to'] ?? '',
|
||||||
|
|
||||||
|
'skin_key'=>$data['skin_key'],
|
||||||
|
'subject_raw'=>$data['subject'],
|
||||||
|
'body_raw'=>$data['body'],
|
||||||
|
'hero_image_url'=>$data['hero_image_url'] ?? '',
|
||||||
|
'cta_label'=>$data['cta_label'] ?? '',
|
||||||
|
'cta_url'=>$data['cta_url'] ?? '',
|
||||||
|
|
||||||
|
'scheduled_at'=>$scheduledAt,
|
||||||
|
'status'=>$status,
|
||||||
|
|
||||||
|
'total_count'=>$total,
|
||||||
|
'valid_count'=>$valid,
|
||||||
|
'sent_count'=>0,
|
||||||
|
'failed_count'=>0,
|
||||||
|
'canceled_count'=>0,
|
||||||
|
'last_error'=>'',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3) 아이템 insert (토큰 치환)
|
||||||
|
$rows = [];
|
||||||
|
$seq = 1;
|
||||||
|
foreach ($list as $r) {
|
||||||
|
$tokens = $r['tokens'] ?? [];
|
||||||
|
|
||||||
|
// ✅ strtr로 치환(키-값 배열)
|
||||||
|
$subjectFinal = $this->applyTokens((string)$data['subject'], $tokens);
|
||||||
|
$bodyFinal = $this->applyTokens((string)$data['body'], $tokens);
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'batch_id'=>$batchId,
|
||||||
|
'seq'=>$seq++,
|
||||||
|
'to_email'=>$r['email'],
|
||||||
|
'to_name'=>$r['name'] ?? '',
|
||||||
|
'token_json'=>json_encode($tokens, JSON_UNESCAPED_UNICODE),
|
||||||
|
'subject_final'=>$subjectFinal,
|
||||||
|
'body_final'=>$bodyFinal,
|
||||||
|
'status'=>'queued',
|
||||||
|
'attempts'=>0,
|
||||||
|
'last_error'=>'',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_chunk($rows, 500) as $chunk) {
|
||||||
|
$this->repo->bulkInsertItems($batchId, $chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$scheduledAt) {
|
||||||
|
\App\Jobs\Admin\Mail\DispatchMailBatchJob::dispatch($batchId)->onQueue('mail');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok'=>true,
|
||||||
|
'batch_id'=>$batchId,
|
||||||
|
'total'=>$total,
|
||||||
|
'valid'=>$valid,
|
||||||
|
'scheduled'=>(bool)$scheduledAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectRecipients(string $mode, array $data, Request $request): array
|
||||||
|
{
|
||||||
|
$raw = [];
|
||||||
|
if ($mode === 'one') {
|
||||||
|
if (empty($data['to_email'])) return ['ok'=>false,'message'=>'수신자 이메일을 입력하세요.'];
|
||||||
|
$raw[] = ['email'=>$data['to_email'], 'name'=>$data['to_name'] ?? '', 'tokens'=>[]];
|
||||||
|
}
|
||||||
|
elseif ($mode === 'many') {
|
||||||
|
$text = (string)($data['to_emails_text'] ?? '');
|
||||||
|
$raw = array_merge($raw, $this->parseEmailsText($text));
|
||||||
|
}
|
||||||
|
elseif ($mode === 'csv') {
|
||||||
|
$file = $request->file('to_emails_csv');
|
||||||
|
if (!$file) return ['ok'=>false,'message'=>'CSV 파일을 업로드하세요.'];
|
||||||
|
$raw = array_merge($raw, $this->parseEmailsCsv($file->getRealPath()));
|
||||||
|
}
|
||||||
|
else { // db
|
||||||
|
$q = trim((string)($data['db_q'] ?? ''));
|
||||||
|
$limit = (int)($data['db_limit'] ?? 3000);
|
||||||
|
if ($q === '') return ['ok'=>false,'message'=>'DB 검색어를 입력하세요.'];
|
||||||
|
|
||||||
|
$members = []; // TODO
|
||||||
|
|
||||||
|
foreach ($members as $m) {
|
||||||
|
$raw[] = ['email'=>$m['email'], 'name'=>$m['name'] ?? '', 'tokens'=>[]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = count($raw);
|
||||||
|
$seen = [];
|
||||||
|
$list = [];
|
||||||
|
|
||||||
|
foreach ($raw as $r) {
|
||||||
|
$email = mb_strtolower(trim((string)($r['email'] ?? '')));
|
||||||
|
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) continue;
|
||||||
|
|
||||||
|
if (isset($seen[$email])) continue;
|
||||||
|
$seen[$email] = true;
|
||||||
|
|
||||||
|
$list[] = [
|
||||||
|
'email'=>$email,
|
||||||
|
'name'=>trim((string)($r['name'] ?? '')),
|
||||||
|
'tokens'=>is_array($r['tokens'] ?? null) ? $r['tokens'] : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok'=>true,'total'=>$total,'list'=>$list];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseEmailsText(string $text): array
|
||||||
|
{
|
||||||
|
// 형식:
|
||||||
|
// email,token1,token2,...
|
||||||
|
// ✅ 1열=email, 2열={_text_02_}, 3열={_text_03_} ...
|
||||||
|
$lines = preg_split('/\r\n|\r|\n/', $text) ?: [];
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim((string)$line);
|
||||||
|
if ($line === '') continue;
|
||||||
|
|
||||||
|
// ✅ 빈열 유지(열 밀림 방지) => filter 하지 않음
|
||||||
|
$cols = array_map('trim', explode(',', $line));
|
||||||
|
|
||||||
|
$email = $cols[0] ?? '';
|
||||||
|
$name = $cols[1] ?? ''; // 로그용으로만(원하면 비워도 됨)
|
||||||
|
|
||||||
|
$tokens = $this->colsToTokens($cols);
|
||||||
|
$out[] = ['email'=>$email,'name'=>$name,'tokens'=>$tokens];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseEmailsCsv(string $path): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
$fh = fopen($path, 'r');
|
||||||
|
if (!$fh) return $out;
|
||||||
|
|
||||||
|
while (($row = fgetcsv($fh)) !== false) {
|
||||||
|
if (!$row) continue;
|
||||||
|
|
||||||
|
// fgetcsv는 빈값도 배열에 포함됨(열 밀림 방지에 유리)
|
||||||
|
$cols = array_map(fn($v)=>trim((string)$v), $row);
|
||||||
|
|
||||||
|
$email = $cols[0] ?? '';
|
||||||
|
$name = $cols[1] ?? ''; // 로그용
|
||||||
|
|
||||||
|
$tokens = $this->colsToTokens($cols);
|
||||||
|
$out[] = ['email'=>$email,'name'=>$name,'tokens'=>$tokens];
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($fh);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function colsToTokens(array $cols): array
|
||||||
|
{
|
||||||
|
$tokens = [];
|
||||||
|
if (!empty($cols[1])) $tokens['{_name_}'] = $cols[1];
|
||||||
|
for ($i = 1; $i < count($cols) && $i <= 8; $i++) { // 최대 {_text_09_} 까지
|
||||||
|
$tokenNo = str_pad((string)($i + 1), 2, '0', STR_PAD_LEFT); // 02..09
|
||||||
|
$tokens['{_text_'.$tokenNo.'_}'] = (string)($cols[$i] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyTokens(string $text, array $tokens): string
|
||||||
|
{
|
||||||
|
if ($text === '' || empty($tokens)) return $text;
|
||||||
|
return strtr($text, $tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatchDueScheduledBatches(int $limit=20): int
|
||||||
|
{
|
||||||
|
$rows = $this->repo->pickDueScheduledBatches($limit);
|
||||||
|
$n = 0;
|
||||||
|
foreach ($rows as $b) {
|
||||||
|
$this->repo->setBatchQueued((int)$b->id);
|
||||||
|
\App\Jobs\Admin\Mail\DispatchMailBatchJob::dispatch((int)$b->id)->onQueue('mail');
|
||||||
|
$n++;
|
||||||
|
}
|
||||||
|
return $n;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendItem(object $batch, object $item): array
|
||||||
|
{
|
||||||
|
if (in_array((string)$batch->status, ['canceled'], true)) {
|
||||||
|
$this->repo->updateItem((int)$item->id, [
|
||||||
|
'status'=>'skipped','last_error'=>'batch_canceled'
|
||||||
|
]);
|
||||||
|
return ['ok'=>false,'skipped'=>true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$view = match ((string)$batch->skin_key) {
|
||||||
|
'newsletter' => 'mail.skins.newsletter',
|
||||||
|
'minimal' => 'mail.skins.minimal',
|
||||||
|
'clean' => 'mail.skins.clean',
|
||||||
|
'dark' => 'mail.skins.dark',
|
||||||
|
default => 'mail.skins.hero',
|
||||||
|
};
|
||||||
|
|
||||||
|
$subject = (string)($item->subject_final ?? '');
|
||||||
|
$bodyText = (string)($item->body_final ?? '');
|
||||||
|
|
||||||
|
// 핵심: preview와 동일한 변수명으로 통일
|
||||||
|
$payload = [
|
||||||
|
'brand' => config('app.name'),
|
||||||
|
'subtitle' => '공지/이벤트/안내 메일', // 스킨에서 쓰면 보이고, 안 쓰면 무시됨
|
||||||
|
'logoUrl' => config('services.brand.logo_url', ''),
|
||||||
|
'subject' => $subject,
|
||||||
|
'body_html' => nl2br(e($bodyText)),
|
||||||
|
'body_text' => $bodyText,
|
||||||
|
'heroUrl' => (string)($batch->hero_image_url ?? ''),
|
||||||
|
'siteUrl' => config('app.url'),
|
||||||
|
'year' => date('Y'),
|
||||||
|
'sns' => [
|
||||||
|
['label'=>'Instagram', 'url'=>config('services.brand.sns.instagram','')],
|
||||||
|
['label'=>'YouTube', 'url'=>config('services.brand.sns.youtube','')],
|
||||||
|
['label'=>'Kakao', 'url'=>config('services.brand.sns.kakao','')],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->mail->sendTemplate(
|
||||||
|
$item->to_email,
|
||||||
|
$subject,
|
||||||
|
$view,
|
||||||
|
$payload,
|
||||||
|
queue: false
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->repo->updateItem((int)$item->id, [
|
||||||
|
'status'=>'sent',
|
||||||
|
'attempts'=> (int)$item->attempts + 1,
|
||||||
|
'last_error'=>'',
|
||||||
|
'sent_at'=> now()->format('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['ok'=>true];
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$msg = mb_substr($e->getMessage(), 0, 500);
|
||||||
|
|
||||||
|
$this->repo->updateItem((int)$item->id, [
|
||||||
|
'status'=>'failed',
|
||||||
|
'attempts'=> (int)$item->attempts + 1,
|
||||||
|
'last_error'=>$msg,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::warning('[admin-mail] send failed', [
|
||||||
|
'batch_id'=>$batch->id,
|
||||||
|
'item_id'=>$item->id,
|
||||||
|
'to'=>$item->to_email,
|
||||||
|
'err'=>$msg,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['ok'=>false,'error'=>$msg];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshBatchProgress(int $batchId): void
|
||||||
|
{
|
||||||
|
$b = $this->repo->findBatch($batchId);
|
||||||
|
if (!$b) return;
|
||||||
|
|
||||||
|
$cnt = $this->repo->countItemsByStatus($batchId);
|
||||||
|
|
||||||
|
$valid = (int)$b->valid_count;
|
||||||
|
$sent = (int)($cnt['sent'] ?? 0);
|
||||||
|
$failed= (int)($cnt['failed'] ?? 0);
|
||||||
|
$queued= (int)($cnt['queued'] ?? 0);
|
||||||
|
|
||||||
|
$status = (string)$b->status;
|
||||||
|
|
||||||
|
if ($status !== 'canceled') {
|
||||||
|
if ($queued > 0) $status = 'sending';
|
||||||
|
else {
|
||||||
|
if ($failed === 0 && $sent >= $valid) $status = 'sent';
|
||||||
|
elseif ($sent > 0 && $failed > 0) $status = 'partial';
|
||||||
|
else $status = 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->repo->updateBatchCounts($batchId, $cnt, $status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,7 +9,8 @@
|
|||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"laravel/fortify": "^1.34",
|
"laravel/fortify": "^1.34",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^2.10.1"
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"mews/purifier": "^3.4"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
141
composer.lock
generated
141
composer.lock
generated
@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "7dd899a9877c179228369caa0361b2cc",
|
"content-hash": "5489ef2cd1c47137632ceb06cdbab30b",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@ -613,6 +613,67 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"time": "2025-03-06T22:45:56+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ezyang/htmlpurifier",
|
||||||
|
"version": "v4.19.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/ezyang/htmlpurifier.git",
|
||||||
|
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
|
||||||
|
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"cerdic/css-tidy": "^1.7 || ^2.0",
|
||||||
|
"simpletest/simpletest": "dev-master"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
|
||||||
|
"ext-bcmath": "Used for unit conversion and imagecrash protection",
|
||||||
|
"ext-iconv": "Converts text to and from non-UTF-8 encodings",
|
||||||
|
"ext-tidy": "Used for pretty-printing HTML"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"library/HTMLPurifier.composer.php"
|
||||||
|
],
|
||||||
|
"psr-0": {
|
||||||
|
"HTMLPurifier": "library/"
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/library/HTMLPurifier/Language/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"LGPL-2.1-or-later"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Edward Z. Yang",
|
||||||
|
"email": "admin@htmlpurifier.org",
|
||||||
|
"homepage": "http://ezyang.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Standards compliant HTML filter written in PHP",
|
||||||
|
"homepage": "http://htmlpurifier.org/",
|
||||||
|
"keywords": [
|
||||||
|
"html"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/ezyang/htmlpurifier/issues",
|
||||||
|
"source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
|
||||||
|
},
|
||||||
|
"time": "2025-10-17T16:34:55+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "fruitcake/php-cors",
|
"name": "fruitcake/php-cors",
|
||||||
"version": "v1.4.0",
|
"version": "v1.4.0",
|
||||||
@ -2187,6 +2248,84 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-07T16:03:21+00:00"
|
"time": "2025-12-07T16:03:21+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "mews/purifier",
|
||||||
|
"version": "3.4.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/mewebstudio/Purifier.git",
|
||||||
|
"reference": "acc71bc512dcf9b87144546d0e3055fc76d244ff"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/mewebstudio/Purifier/zipball/acc71bc512dcf9b87144546d0e3055fc76d244ff",
|
||||||
|
"reference": "acc71bc512dcf9b87144546d0e3055fc76d244ff",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ezyang/htmlpurifier": "^4.16.0",
|
||||||
|
"illuminate/config": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/filesystem": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/support": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"php": "^7.2|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"graham-campbell/testbench": "^3.2|^5.5.1|^6.1",
|
||||||
|
"mockery/mockery": "^1.3.3",
|
||||||
|
"phpunit/phpunit": "^8.0|^9.0|^10.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"laravel/framework": "To test the Laravel bindings",
|
||||||
|
"laravel/lumen-framework": "To test the Lumen bindings"
|
||||||
|
},
|
||||||
|
"type": "package",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Purifier": "Mews\\Purifier\\Facades\\Purifier"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Mews\\Purifier\\PurifierServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Mews\\Purifier\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Muharrem ERİN",
|
||||||
|
"email": "me@mewebstudio.com",
|
||||||
|
"homepage": "https://github.com/mewebstudio",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Laravel 5/6/7/8/9/10 HtmlPurifier Package",
|
||||||
|
"homepage": "https://github.com/mewebstudio/purifier",
|
||||||
|
"keywords": [
|
||||||
|
"Laravel Purifier",
|
||||||
|
"Laravel Security",
|
||||||
|
"Purifier",
|
||||||
|
"htmlpurifier",
|
||||||
|
"laravel HtmlPurifier",
|
||||||
|
"security",
|
||||||
|
"xss"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/mewebstudio/Purifier/issues",
|
||||||
|
"source": "https://github.com/mewebstudio/Purifier/tree/3.4.3"
|
||||||
|
},
|
||||||
|
"time": "2025-02-24T16:00:29+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
|
|||||||
@ -134,6 +134,12 @@ return [
|
|||||||
'replace_placeholders' => true,
|
'replace_placeholders' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'mail_debug' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/mail_debug.log'),
|
||||||
|
'level' => 'debug',
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
71
package-lock.json
generated
71
package-lock.json
generated
@ -4,6 +4,9 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"quill": "^2.0.2"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
@ -1453,6 +1456,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fast-diff": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@ -1956,6 +1971,25 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.17.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||||
|
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.clonedeep": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isequal": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@ -2018,6 +2052,12 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parchment": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -2031,7 +2071,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -2075,6 +2114,35 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quill": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-QfazNrhMakEdRG57IoYFwffUIr04LWJxbS/ZkidRFXYCQt63c1gK6Z7IHUXMx/Vh25WgPBU42oBaNzQ0K1R/xw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"parchment": "^3.0.0",
|
||||||
|
"quill-delta": "^5.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=8.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quill-delta": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-diff": "^1.3.0",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@ -2268,7 +2336,6 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
@ -13,5 +13,8 @@
|
|||||||
"laravel-vite-plugin": "^2.0.0",
|
"laravel-vite-plugin": "^2.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"vite": "^7.0.7"
|
"vite": "^7.0.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"quill": "^2.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1073,3 +1073,226 @@ html,body{ height:100%; }
|
|||||||
}
|
}
|
||||||
.a-btn--dangerSolid:hover{ filter: brightness(1.03); }
|
.a-btn--dangerSolid:hover{ filter: brightness(1.03); }
|
||||||
.a-td--status.is-bad{ color:#ff4d4f; }
|
.a-td--status.is-bad{ color:#ff4d4f; }
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
Sidebar Accordion (override)
|
||||||
|
- append at end of admin.css
|
||||||
|
================================ */
|
||||||
|
|
||||||
|
.a-nav{
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* dashboard single link */
|
||||||
|
.a-nav__item--root{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
background: rgba(43,127,255,.10);
|
||||||
|
border-color: rgba(43,127,255,.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* group wrapper */
|
||||||
|
.a-nav__group{
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* title button (more visible but not loud) */
|
||||||
|
.a-nav__titlebtn{
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
|
||||||
|
border: 1px solid rgba(255,255,255,.12);
|
||||||
|
background: rgba(255,255,255,.045);
|
||||||
|
|
||||||
|
color: rgba(255,255,255,.88);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-nav__titlebtn:hover{
|
||||||
|
background: rgba(255,255,255,.06);
|
||||||
|
border-color: rgba(255,255,255,.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-nav__group.is-open .a-nav__titlebtn{
|
||||||
|
background: rgba(43,127,255,.10);
|
||||||
|
border-color: rgba(43,127,255,.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* chevron */
|
||||||
|
.a-nav__chev{
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
|
||||||
|
background: rgba(0,0,0,.18);
|
||||||
|
border: 1px solid rgba(255,255,255,.10);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-nav__chev::before{
|
||||||
|
content: "";
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-right: 2px solid rgba(255,255,255,.75);
|
||||||
|
border-bottom: 2px solid rgba(255,255,255,.75);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
transition: transform .18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-nav__group.is-open .a-nav__chev::before{
|
||||||
|
transform: rotate(-135deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* items container (collapsed by default) */
|
||||||
|
.a-nav__items{
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
transition: max-height .22s ease, opacity .18s ease, transform .18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-nav__group.is-open .a-nav__items{
|
||||||
|
max-height: 1200px; /* 충분히 크게 */
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* remove old margin-top inside accordion list */
|
||||||
|
.a-nav__items .a-nav__item{
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
Sidebar Accordion - readability tweaks (append)
|
||||||
|
================================ */
|
||||||
|
|
||||||
|
/* --- Title: group header (NO chip, highlight full button when open) --- */
|
||||||
|
.a-nav__titlebtn{
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
|
||||||
|
/* 기본: 아이템보다 살짝만 존재감 */
|
||||||
|
background: rgba(255,255,255,.035);
|
||||||
|
border: 1px solid rgba(255,255,255,.10);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ 칩/원형/캡슐 제거: 텍스트는 그냥 텍스트 */
|
||||||
|
.a-nav__titletext{
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
color: rgba(255,255,255,.78);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hover: 살짝만 또렷 */
|
||||||
|
.a-nav__titlebtn:hover{
|
||||||
|
background: rgba(255,255,255,.055);
|
||||||
|
border-color: rgba(255,255,255,.16);
|
||||||
|
}
|
||||||
|
.a-nav__titlebtn:hover .a-nav__titletext{
|
||||||
|
color: rgba(255,255,255,.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ selected(open): 버튼 "전체"를 아이템보다 더 강하게 강조 */
|
||||||
|
.a-nav__group.is-open .a-nav__titlebtn{
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(43,127,255,.22),
|
||||||
|
rgba(124,92,255,.16)
|
||||||
|
);
|
||||||
|
border-color: rgba(43,127,255,.32);
|
||||||
|
box-shadow: 0 10px 24px rgba(43,127,255,.10); /* 과하지 않게 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-nav__group.is-open .a-nav__titletext{
|
||||||
|
color: rgba(255,255,255,.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Items: smaller + indent + guide line --- */
|
||||||
|
.a-nav__items{
|
||||||
|
margin-left: 8px; /* 그룹 자체 살짝 들여쓰기 */
|
||||||
|
padding-left: 14px; /* ✅ 아이템 들여쓰기 */
|
||||||
|
border-left: 1px solid rgba(255,255,255,.08); /* ✅ 가이드 라인 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* item size + spacing */
|
||||||
|
.a-nav__items .a-nav__item{
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
font-size: 12.5px; /* ✅ 글씨 크기 줄임 */
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
color: rgba(255,255,255,.82);
|
||||||
|
background: rgba(0,0,0,.12);
|
||||||
|
border: 1px solid rgba(255,255,255,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* dot position a bit nicer with indent */
|
||||||
|
.a-nav__items .a-nav__dot{
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background: rgba(255,255,255,.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hover */
|
||||||
|
.a-nav__items .a-nav__item:hover{
|
||||||
|
background: rgba(255,255,255,.05);
|
||||||
|
border-color: rgba(255,255,255,.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* active item: clearer but not loud */
|
||||||
|
.a-nav__items .a-nav__item.is-active{
|
||||||
|
background: rgba(43,127,255,.12);
|
||||||
|
border-color: rgba(43,127,255,.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-nav__items .a-nav__item.is-active .a-nav__label{
|
||||||
|
color: rgba(255,255,255,.95);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* disabled item inside accordion */
|
||||||
|
.a-nav__items .a-nav__item.is-disabled{
|
||||||
|
opacity: .45;
|
||||||
|
background: rgba(0,0,0,.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모바일에서 들여쓰기 과하면 줄이기 */
|
||||||
|
@media (max-width: 980px){
|
||||||
|
.a-nav__items{
|
||||||
|
margin-left: 4px;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -116,6 +116,86 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------
|
||||||
|
// Sidebar accordion
|
||||||
|
// - title click: toggle, others close
|
||||||
|
// - item click: keep its group open, others close
|
||||||
|
// - on load: open group containing .is-active
|
||||||
|
// -----------------------
|
||||||
|
function __closeAllGroups(navEl, exceptGroup = null) {
|
||||||
|
navEl.querySelectorAll('.a-nav__group.is-open').forEach(g => {
|
||||||
|
if (exceptGroup && g === exceptGroup) return;
|
||||||
|
g.classList.remove('is-open');
|
||||||
|
const b = g.querySelector('.a-nav__titlebtn');
|
||||||
|
if (b) b.setAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.a-nav__titlebtn[data-nav-toggle]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const group = btn.closest('.a-nav__group');
|
||||||
|
const nav = btn.closest('.a-nav');
|
||||||
|
if (!group || !nav) return;
|
||||||
|
|
||||||
|
const wasOpen = group.classList.contains('is-open');
|
||||||
|
|
||||||
|
// accordion: always close others
|
||||||
|
__closeAllGroups(nav);
|
||||||
|
|
||||||
|
// toggle current
|
||||||
|
if (!wasOpen) {
|
||||||
|
group.classList.add('is-open');
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
} else {
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
// submenu item click
|
||||||
|
const link = e.target.closest('.a-nav__items a.a-nav__item[href]');
|
||||||
|
if (!link) return;
|
||||||
|
|
||||||
|
const group = link.closest('.a-nav__group');
|
||||||
|
const nav = link.closest('.a-nav');
|
||||||
|
if (!group || !nav) return;
|
||||||
|
|
||||||
|
__closeAllGroups(nav, group);
|
||||||
|
|
||||||
|
group.classList.add('is-open');
|
||||||
|
const btn = group.querySelector('.a-nav__titlebtn');
|
||||||
|
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const nav = document.querySelector('.a-nav');
|
||||||
|
if (!nav) return;
|
||||||
|
|
||||||
|
// aria sync
|
||||||
|
nav.querySelectorAll('.a-nav__group').forEach(g => {
|
||||||
|
const btn = g.querySelector('.a-nav__titlebtn');
|
||||||
|
if (btn) btn.setAttribute('aria-expanded', g.classList.contains('is-open') ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
// if server already opened (groupActive) keep it
|
||||||
|
if (nav.querySelector('.a-nav__group.is-open')) return;
|
||||||
|
|
||||||
|
// else open group containing active item
|
||||||
|
const active = nav.querySelector('.a-nav__items .a-nav__item.is-active');
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
const group = active.closest('.a-nav__group');
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
__closeAllGroups(nav, group);
|
||||||
|
group.classList.add('is-open');
|
||||||
|
const btn = group.querySelector('.a-nav__titlebtn');
|
||||||
|
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// flash 자동 표시
|
// flash 자동 표시
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const list = window.__adminFlash;
|
const list = window.__adminFlash;
|
||||||
|
|||||||
178
resources/views/admin/mail/logs/index.blade.php
Normal file
178
resources/views/admin/mail/logs/index.blade.php
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '메일 발송 이력')
|
||||||
|
@section('page_title', '메일 발송 이력')
|
||||||
|
@section('page_desc', '배치 단위로 조회합니다.')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
.logbar{display:flex; gap:10px; flex-wrap:wrap; align-items:end;}
|
||||||
|
.logbar__grow{flex:1; min-width:220px;}
|
||||||
|
.logbar__actions{display:flex; gap:8px; align-items:center;}
|
||||||
|
|
||||||
|
.lbtn{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;}
|
||||||
|
.lbtn:hover{background:rgba(255,255,255,.10);}
|
||||||
|
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
|
||||||
|
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
|
||||||
|
.lbtn--ghost{background:transparent;}
|
||||||
|
|
||||||
|
.pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
|
||||||
|
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
|
||||||
|
.pill--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.12);}
|
||||||
|
.pill--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.12);}
|
||||||
|
.pill--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
|
||||||
|
.pill--muted{opacity:.9}
|
||||||
|
|
||||||
|
.mono{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;}
|
||||||
|
|
||||||
|
.msg-clip{max-width:520px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; opacity:.95;}
|
||||||
|
.table td{vertical-align:top;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php
|
||||||
|
$statusLabel = [
|
||||||
|
'scheduled' => '예약',
|
||||||
|
'queued' => '대기',
|
||||||
|
'submitting' => '발송중',
|
||||||
|
'submitted' => '완료',
|
||||||
|
'partial' => '부분성공',
|
||||||
|
'failed' => '실패',
|
||||||
|
'canceled' => '취소',
|
||||||
|
];
|
||||||
|
$modeLabel = [
|
||||||
|
'one' => '단건',
|
||||||
|
'many' => '여러건',
|
||||||
|
'template' => '템플릿CSV',
|
||||||
|
'db' => 'DB검색',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<form method="GET" action="{{ route('admin.mail.logs') }}" class="logbar" id="logFilterForm">
|
||||||
|
|
||||||
|
<div style="min-width:150px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">상태</div>
|
||||||
|
<select class="a-input" name="status" style="width:150px;">
|
||||||
|
<option value="">전체</option>
|
||||||
|
@foreach(array_keys($statusLabel) as $st)
|
||||||
|
<option value="{{ $st }}" @selected(request('status')===$st)>{{ $statusLabel[$st] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="min-width:140px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">모드</div>
|
||||||
|
<select class="a-input" name="send_mode" style="width:140px;">
|
||||||
|
<option value="">전체</option>
|
||||||
|
@foreach(array_keys($modeLabel) as $m)
|
||||||
|
<option value="{{ $m }}" @selected(request('send_mode')===$m)>{{ $modeLabel[$m] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="min-width:230px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">기간</div>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||||
|
<input class="a-input" type="date" name="date_from" id="dateFrom" value="{{ request('date_from') }}" style="width:140px;">
|
||||||
|
<span class="a-muted">~</span>
|
||||||
|
<input class="a-input" type="date" name="date_to" id="dateTo" value="{{ request('date_to') }}" style="width:140px;">
|
||||||
|
<button type="button" class="lbtn lbtn--ghost" id="dateClear">초기화</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="logbar__grow">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">검색</div>
|
||||||
|
<input class="a-input" name="q" value="{{ request('q') }}" placeholder="제목/내용/발신자/IP/작성자 등" style="max-width:360px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="logbar__actions">
|
||||||
|
<button class="lbtn lbtn--primary" type="submit">조회</button>
|
||||||
|
<a class="lbtn" href="{{ route('admin.mail.send') }}">발송</a>
|
||||||
|
<a class="lbtn lbtn--ghost" href="{{ route('admin.mail.logs') }}">필터 초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px;">
|
||||||
|
<div class="a-muted">총 <b>{{ $batches->total() }}</b>건</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="overflow:auto;">
|
||||||
|
<table class="a-table table" style="width:100%; min-width:1120px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:90px;">Batch</th>
|
||||||
|
<th style="width:160px;">생성일시</th>
|
||||||
|
<th style="width:160px;">작성자</th>
|
||||||
|
<th style="width:110px;">모드</th>
|
||||||
|
<th style="width:170px;">예약</th>
|
||||||
|
<th style="width:130px;">진행률</th>
|
||||||
|
<th style="width:140px;">상태</th>
|
||||||
|
<th>제목</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($batches as $b)
|
||||||
|
@php
|
||||||
|
$st = (string)$b->status;
|
||||||
|
$pillClass = match ($st) {
|
||||||
|
'submitted' => 'pill--ok',
|
||||||
|
'partial','submitting','queued','scheduled' => 'pill--warn',
|
||||||
|
'failed','canceled' => 'pill--bad',
|
||||||
|
default => 'pill--muted',
|
||||||
|
};
|
||||||
|
$stK = $statusLabel[$st] ?? $st;
|
||||||
|
$modeK = $modeLabel[(string)$b->send_mode] ?? $b->send_mode;
|
||||||
|
$sent = (int)($b->sent_count ?? 0);
|
||||||
|
$total = (int)($b->valid_count ?? $b->total_count ?? 0);
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="mono" style="text-decoration:none;"
|
||||||
|
href="{{ route('admin.mail.logs.show', ['batchId'=>$b->id]) }}">#{{ $b->id }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="a-muted">{{ $b->created_at }}</td>
|
||||||
|
<td style="font-weight:700;">{{ $b->admin_name ?? ('#'.$b->admin_user_id) }}</td>
|
||||||
|
<td><span class="mono">{{ $modeK }}</span></td>
|
||||||
|
<td class="a-muted">{{ $b->scheduled_at ?? '-' }}</td>
|
||||||
|
<td><b>{{ $sent }}</b>/{{ $total }}</td>
|
||||||
|
<td><span class="pill {{ $pillClass }}">● {{ $stK }}</span></td>
|
||||||
|
<td><div class="msg-clip">{{ $b->subject_raw ?? '-' }}</div></td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="8" class="a-muted">데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $batches->links() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const from = document.getElementById('dateFrom');
|
||||||
|
const to = document.getElementById('dateTo');
|
||||||
|
const clear= document.getElementById('dateClear');
|
||||||
|
if (!from || !to) return;
|
||||||
|
|
||||||
|
from.addEventListener('change', () => {
|
||||||
|
if (from.value && !to.value) to.value = from.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
clear?.addEventListener('click', () => {
|
||||||
|
from.value = '';
|
||||||
|
to.value = '';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
@endsection
|
||||||
157
resources/views/admin/mail/logs/show.blade.php
Normal file
157
resources/views/admin/mail/logs/show.blade.php
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '메일 이력 상세')
|
||||||
|
@section('page_title', '메일 이력 상세')
|
||||||
|
@section('page_desc', '배치 및 수신자별 상세')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
.hgrid{display:flex; gap:16px; flex-wrap:wrap;}
|
||||||
|
.hbox{min-width:160px;}
|
||||||
|
.hbox .k{font-size:12px; opacity:.8;}
|
||||||
|
.hbox .v{font-weight:800; margin-top:4px;}
|
||||||
|
.mono{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;}
|
||||||
|
.btn{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;}
|
||||||
|
.btn:hover{background:rgba(255,255,255,.10);}
|
||||||
|
.btn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
|
||||||
|
.btn--danger{background:rgba(244,63,94,.88);border-color:rgba(244,63,94,.95);color:#fff;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php
|
||||||
|
$statusLabel = [
|
||||||
|
'scheduled' => '예약',
|
||||||
|
'queued' => '대기',
|
||||||
|
'submitting' => '발송중',
|
||||||
|
'submitted' => '완료',
|
||||||
|
'partial' => '부분성공',
|
||||||
|
'failed' => '실패',
|
||||||
|
'canceled' => '취소',
|
||||||
|
];
|
||||||
|
$modeLabel = [
|
||||||
|
'one' => '단건',
|
||||||
|
'many' => '여러건',
|
||||||
|
'template' => '템플릿CSV',
|
||||||
|
'db' => 'DB검색',
|
||||||
|
];
|
||||||
|
$sent = (int)($batch->sent_count ?? 0);
|
||||||
|
$total = (int)($batch->valid_count ?? $batch->total_count ?? 0);
|
||||||
|
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<div class="hgrid">
|
||||||
|
<div class="hbox">
|
||||||
|
<div class="k a-muted">Batch ID</div>
|
||||||
|
<div class="v">#{{ $batch->id }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hbox">
|
||||||
|
<div class="k a-muted">상태</div>
|
||||||
|
<div class="v">{{ $statusLabel[(string)$batch->status] ?? $batch->status }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hbox">
|
||||||
|
<div class="k a-muted">모드</div>
|
||||||
|
<div class="v">{{ $modeLabel[(string)$batch->send_mode] ?? $batch->send_mode }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hbox">
|
||||||
|
<div class="k a-muted">예약</div>
|
||||||
|
<div class="v">{{ $batch->scheduled_at ?? '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hbox">
|
||||||
|
<div class="k a-muted">진행률</div>
|
||||||
|
<div class="v"><b>{{ $sent }}</b>/{{ $total }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin:14px 0; opacity:.15;">
|
||||||
|
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">제목</div>
|
||||||
|
<div class="mono" style="display:inline-block;">{{ $batch->subject_raw ?? '-' }}</div>
|
||||||
|
|
||||||
|
<div style="height:10px;"></div>
|
||||||
|
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">내용</div>
|
||||||
|
<div style="white-space:pre-wrap; background:rgba(255,255,255,.03); padding:12px; border-radius:12px;">
|
||||||
|
{{ $batch->body_raw ?? $batch->body ?? '' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
|
<a class="btn" href="{{ route('admin.mail.logs') }}">목록</a>
|
||||||
|
<a class="btn" href="{{ route('admin.mail.send') }}">발송</a>
|
||||||
|
|
||||||
|
@if(in_array((string)$batch->status, ['queued','submitting','scheduled'], true))
|
||||||
|
<form method="POST" action="{{ route('admin.mail.logs.cancel', ['batchId'=>$batch->id]) }}" data-confirm="이 배치를 취소할까요?" style="display:inline;">
|
||||||
|
@csrf
|
||||||
|
<button class="btn btn--danger" type="submit">취소/중지</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(in_array((string)$batch->status, ['failed','partial'], true))
|
||||||
|
<form method="POST" action="{{ route('admin.mail.logs.retry_failed', ['batchId'=>$batch->id]) }}" data-confirm="실패 건만 재시도할까요?" style="display:inline;">
|
||||||
|
@csrf
|
||||||
|
<button class="btn btn--primary" type="submit">실패 재시도</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<form method="GET" action="{{ route('admin.mail.logs.show', ['batchId'=>$batch->id]) }}"
|
||||||
|
style="display:flex; gap:10px; flex-wrap:wrap; align-items:end; margin-bottom:12px;">
|
||||||
|
<div style="min-width:140px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">상태</div>
|
||||||
|
<select class="a-input" name="status" style="width:140px;">
|
||||||
|
<option value="">전체</option>
|
||||||
|
@foreach(['queued'=>'대기','submitted'=>'완료','failed'=>'실패'] as $k=>$v)
|
||||||
|
<option value="{{ $k }}" @selected(request('status')===$k)>{{ $v }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="min-width:240px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">수신자</div>
|
||||||
|
<input class="a-input" name="to" value="{{ request('to') }}" placeholder="user@example.com" style="width:240px;">
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; min-width:240px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">검색(제목/내용)</div>
|
||||||
|
<input class="a-input" name="q" value="{{ request('q') }}" placeholder="치환된 내용 검색" style="max-width:360px;">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary" type="submit">필터</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="overflow:auto;">
|
||||||
|
<table class="a-table" style="width:100%; min-width:1100px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:80px;">Seq</th>
|
||||||
|
<th style="width:260px;">수신자</th>
|
||||||
|
<th style="width:120px;">상태</th>
|
||||||
|
<th style="width:170px;">제출시간</th>
|
||||||
|
<th>제목</th>
|
||||||
|
<th>요약</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($items as $it)
|
||||||
|
<tr>
|
||||||
|
<td class="a-muted">{{ $it->seq }}</td>
|
||||||
|
<td><span class="mono">{{ $it->to_email }}</span></td>
|
||||||
|
<td>{{ $statusLabel[(string)$it->status] ?? $it->status }}</td>
|
||||||
|
<td class="a-muted">{{ $it->sent_at ?? '-' }}</td>
|
||||||
|
<td style="max-width:420px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ $it->subject_final ?? $it->subject ?? '-' }}</td>
|
||||||
|
<td style="max-width:520px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ $it->body_final ?? '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="6" class="a-muted">상세 데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $items->links() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
875
resources/views/admin/mail/send.blade.php
Normal file
875
resources/views/admin/mail/send.blade.php
Normal file
@ -0,0 +1,875 @@
|
|||||||
|
@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
|
||||||
201
resources/views/admin/mail/templates/form.blade.php
Normal file
201
resources/views/admin/mail/templates/form.blade.php
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', $mode === 'create' ? '메일 템플릿 생성' : '메일 템플릿 수정')
|
||||||
|
@section('page_title', $mode === 'create' ? '메일 템플릿 생성' : '메일 템플릿 수정')
|
||||||
|
@section('page_desc', '스킨을 선택하고 제목/본문을 작성한 뒤, 발송 화면에서 바로 적용할 수 있습니다.')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
.grid{display:grid; grid-template-columns: 1fr 1fr; gap:16px;}
|
||||||
|
@media (max-width: 1100px){ .grid{grid-template-columns:1fr;} }
|
||||||
|
|
||||||
|
.btn{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;}
|
||||||
|
.btn:hover{background:rgba(255,255,255,.10);}
|
||||||
|
.btn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
|
||||||
|
.btn--ghost{background:transparent;}
|
||||||
|
|
||||||
|
.mono{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;}
|
||||||
|
.emailFrame{background:#fff; color:#111; border-radius:12px; padding:14px;}
|
||||||
|
.emailFrame.dark{background:#0b1220; color:#e5e7eb;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<form method="POST" action="{{ $mode === 'create' ? route('admin.mail.templates.store') : route('admin.mail.templates.update', ['id'=>$tpl->id]) }}">
|
||||||
|
@csrf
|
||||||
|
@if($mode !== 'create')
|
||||||
|
@method('PUT')
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
@if($mode === 'create')
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">Code (unique)</div>
|
||||||
|
<input class="a-input" name="code" value="{{ old('code') }}" placeholder="ex) event_seol_2026">
|
||||||
|
<div class="a-muted" style="margin-top:6px;">영문/숫자/대시/언더바 3~60</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">Code</div>
|
||||||
|
<div><span class="mono">{{ $tpl->code }}</span></div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">제목(관리용)</div>
|
||||||
|
<input class="a-input" name="title" id="titleText" value="{{ old('title', $tpl->title ?? '') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">메일 제목(Subject)</div>
|
||||||
|
<input class="a-input" name="subject" id="subjectText" value="{{ old('subject', $tpl->subject_tpl ?? '') }}" placeholder="예) [PIN FOR YOU] 설맞이 혜택 안내">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">스킨</div>
|
||||||
|
<select class="a-input" name="skin_key" id="skinKey">
|
||||||
|
@foreach(($skins ?? []) as $sk)
|
||||||
|
<option value="{{ $sk['key'] }}" @selected(old('skin_key', $tpl->skin_key ?? 'clean') === $sk['key'])>
|
||||||
|
{{ $sk['label'] }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<div class="a-muted" style="font-size:12px; margin-top:6px;">발송 시 선택된 스킨으로 렌더링됩니다.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">본문</div>
|
||||||
|
<textarea class="a-input" name="body" id="bodyText" rows="10">{{ old('body', $tpl->body_tpl ?? '') }}</textarea>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:10px;">
|
||||||
|
<button type="button" class="btn btn--ghost" data-insert="{_text_02_}">{_text_02_}</button>
|
||||||
|
<button type="button" class="btn btn--ghost" data-insert="{_text_03_}">{_text_03_}</button>
|
||||||
|
<button type="button" class="btn btn--ghost" data-insert="{_text_04_}">{_text_04_}</button>
|
||||||
|
<span class="a-muted" style="font-size:12px;">토큰 빠른 삽입</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">설명</div>
|
||||||
|
<input class="a-input" name="description" value="{{ old('description', $tpl->description ?? '') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label class="a-pill">
|
||||||
|
<input type="checkbox" name="is_active" value="1" @checked((int)old('is_active', $tpl->is_active ?? 1) === 1)>
|
||||||
|
활성
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
|
<button class="btn btn--primary" type="submit">저장</button>
|
||||||
|
<a class="btn" href="{{ route('admin.mail.templates.index') }}">목록</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div class="previewBox">
|
||||||
|
<div class="previewHead">
|
||||||
|
<div class="a-muted">미리보기</div>
|
||||||
|
<div class="a-muted" style="font-size:12px;">스킨 + 현재 입력값</div>
|
||||||
|
</div>
|
||||||
|
<div class="previewBody">
|
||||||
|
<iframe id="tplPreviewIframe"
|
||||||
|
style="width:100%; height:720px; border:0; border-radius:12px; background:#fff;"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-muted" style="font-size:12px; margin-top:10px; line-height:1.6;">
|
||||||
|
* 1차 버전은 SMTP “접수 성공” 기준으로 완료 처리합니다.<br>
|
||||||
|
* 안정화 후 SES 이벤트(Delivery/Bounce) 연동해 정확도 올리면 됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const subjectText = document.getElementById('subjectText');
|
||||||
|
const bodyText = document.getElementById('bodyText');
|
||||||
|
const skinKeyEl = document.getElementById('skinKey');
|
||||||
|
const pvSubject = document.getElementById('pvSubject');
|
||||||
|
const pvBody = document.getElementById('pvBody');
|
||||||
|
const frame = document.getElementById('previewFrame');
|
||||||
|
const PREVIEW_URL = @json(\Illuminate\Support\Facades\Route::has('admin.mail.preview') ? route('admin.mail.preview') : '');
|
||||||
|
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||||
|
const iframe = document.getElementById('tplPreviewIframe');
|
||||||
|
|
||||||
|
let tmr = null;
|
||||||
|
async function renderPreview(){
|
||||||
|
if(!PREVIEW_URL || !iframe) return;
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('skin_key', skinKeyEl?.value || 'clean');
|
||||||
|
fd.append('subject', subjectText?.value || '');
|
||||||
|
fd.append('body', bodyText?.value || '');
|
||||||
|
|
||||||
|
try{
|
||||||
|
const res = await fetch(PREVIEW_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: csrf ? {'X-CSRF-TOKEN': csrf} : {},
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
iframe.srcdoc = await res.text();
|
||||||
|
}catch(e){
|
||||||
|
iframe.srcdoc = `<div style="padding:16px;font-family:system-ui">preview fail: ${String(e)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function debounce(){
|
||||||
|
clearTimeout(tmr);
|
||||||
|
tmr = setTimeout(renderPreview, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
[subjectText, bodyText].forEach(el => el?.addEventListener('input', debounce));
|
||||||
|
skinKeyEl?.addEventListener('change', debounce);
|
||||||
|
renderPreview();
|
||||||
|
|
||||||
|
function escapeHtml(s){
|
||||||
|
return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPreview(){
|
||||||
|
const sub = (subjectText?.value || '').trim() || '(제목)';
|
||||||
|
const body = (bodyText?.value || '').trim();
|
||||||
|
if(pvSubject) pvSubject.textContent = sub;
|
||||||
|
if(pvBody) pvBody.innerHTML = body ? escapeHtml(body).replace(/\n/g,'<br>') : '<span style="opacity:.7;">(본문)</span>';
|
||||||
|
|
||||||
|
const k = (skinKeyEl?.value || 'clean');
|
||||||
|
frame?.classList.toggle('dark', k === 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
[subjectText, bodyText].forEach(el => el?.addEventListener('input', refreshPreview));
|
||||||
|
skinKeyEl?.addEventListener('change', refreshPreview);
|
||||||
|
refreshPreview();
|
||||||
|
|
||||||
|
// token insert
|
||||||
|
document.querySelectorAll('[data-insert]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const token = btn.getAttribute('data-insert') || '';
|
||||||
|
const ta = bodyText;
|
||||||
|
if(!ta) return;
|
||||||
|
const start = ta.selectionStart ?? ta.value.length;
|
||||||
|
const end = ta.selectionEnd ?? ta.value.length;
|
||||||
|
ta.value = ta.value.slice(0,start) + token + ta.value.slice(end);
|
||||||
|
ta.focus();
|
||||||
|
ta.selectionStart = ta.selectionEnd = start + token.length;
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
@endsection
|
||||||
87
resources/views/admin/mail/templates/index.blade.php
Normal file
87
resources/views/admin/mail/templates/index.blade.php
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '메일 템플릿')
|
||||||
|
@section('page_title', '메일 템플릿')
|
||||||
|
@section('page_desc', '스킨 + 제목/본문 템플릿 관리')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
.bar{display:flex; gap:10px; flex-wrap:wrap; align-items:end;}
|
||||||
|
.btn{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;}
|
||||||
|
.btn:hover{background:rgba(255,255,255,.10);}
|
||||||
|
.btn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
|
||||||
|
.mono{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;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<form method="GET" action="{{ route('admin.mail.templates.index') }}" class="bar">
|
||||||
|
<div style="min-width:140px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">활성</div>
|
||||||
|
<select class="a-input" name="active" style="width:140px;">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="1" @selected(request('active')==='1')>활성</option>
|
||||||
|
<option value="0" @selected(request('active')==='0')>비활성</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="min-width:200px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">검색</div>
|
||||||
|
<input class="a-input" name="q" value="{{ request('q') }}" placeholder="code/title/subject/body" style="width:320px; max-width:100%;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
|
<button class="btn btn--primary" type="submit">조회</button>
|
||||||
|
<a class="btn" href="{{ route('admin.mail.templates.create') }}">새 템플릿</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:10px;">총 {{ $templates->total() }}건</div>
|
||||||
|
|
||||||
|
<div style="overflow:auto;">
|
||||||
|
<table class="a-table" style="width:100%; min-width:1050px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:80px;">ID</th>
|
||||||
|
<th style="width:70px;">활성</th>
|
||||||
|
<th style="width:160px;">Code</th>
|
||||||
|
<th style="width:180px;">Title</th>
|
||||||
|
<th style="width:220px;">Subject</th>
|
||||||
|
<th style="width:140px;">Skin</th>
|
||||||
|
<th>Body</th>
|
||||||
|
<th style="width:90px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($templates as $t)
|
||||||
|
<tr>
|
||||||
|
<td class="a-muted">{{ $t->id }}</td>
|
||||||
|
<td>{{ (int)$t->is_active === 1 ? 'Y' : 'N' }}</td>
|
||||||
|
<td><span class="mono">{{ $t->code }}</span></td>
|
||||||
|
<td>{{ $t->title }}</td>
|
||||||
|
<td style="max-width:220px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
|
||||||
|
{{ $t->subject_tpl ?? '-' }}
|
||||||
|
</td>
|
||||||
|
<td><span class="mono">{{ $t->skin_key ?? '-' }}</span></td>
|
||||||
|
<td style="max-width:420px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ $t->body_tpl }}</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<a class="btn" href="{{ route('admin.mail.templates.edit', ['id'=>$t->id]) }}">수정</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="8" class="a-muted">데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $templates->links() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@ -8,20 +8,28 @@
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => '콘솔 관리',
|
'title' => '관리자/MY 관리',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '내 정보', 'route' => 'admin.me'],
|
['label' => '내 정보', 'route' => 'admin.me'],
|
||||||
['label' => '관리자 계정 관리', 'route' => 'admin.admins.index' ,'roles' => ['super_admin']],
|
['label' => '관리자 계정 관리', 'route' => 'admin.admins.index' ,'roles' => ['super_admin']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => '알림/메시지',
|
'title' => 'SMS 관리',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => 'SMS 발송', 'route' => 'admin.sms.send','roles' => ['super_admin','finance','product','support']],
|
['label' => 'SMS 발송', 'route' => 'admin.sms.send','roles' => ['super_admin','finance','product','support']],
|
||||||
['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs','roles' => ['super_admin','finance','product','support']],
|
['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs','roles' => ['super_admin','finance','product','support']],
|
||||||
['label' => 'SMS 템플릿', 'route' => 'admin.templates.index','roles' => ['super_admin','finance','product','support']],
|
['label' => 'SMS 템플릿', 'route' => 'admin.templates.index','roles' => ['super_admin','finance','product','support']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'title' => 'MAIL 관리',
|
||||||
|
'items' => [
|
||||||
|
['label' => 'MAIL 발송', 'route' => 'admin.mail.send', 'roles' => ['super_admin','finance','product','support']],
|
||||||
|
['label' => 'MAIL 발송 이력', 'route' => 'admin.mail.logs', 'roles' => ['super_admin','finance','product','support']],
|
||||||
|
['label' => 'MAIL 템플릿', 'route' => 'admin.mail.templates.index', 'roles' => ['super_admin','finance','product','support']],
|
||||||
|
],
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'title' => '고객지원',
|
'title' => '고객지원',
|
||||||
'items' => [
|
'items' => [
|
||||||
@ -83,45 +91,79 @@
|
|||||||
$isSuper = in_array('super_admin', $roleNames, true);
|
$isSuper = in_array('super_admin', $roleNames, true);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<nav class="a-nav">
|
<nav class="a-nav" data-nav="accordion">
|
||||||
|
{{-- ✅ 대시보드는 메뉴 배열에서 빼고 "단일 링크"로만 상단 고정 --}}
|
||||||
|
@php
|
||||||
|
$dashHas = \Illuminate\Support\Facades\Route::has('admin.home');
|
||||||
|
$dashActive = request()->routeIs('admin.home');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if($dashHas)
|
||||||
|
<a class="a-nav__item a-nav__item--root {{ $dashActive ? 'is-active' : '' }}" href="{{ route('admin.home') }}">
|
||||||
|
<span class="a-nav__dot" aria-hidden="true"></span>
|
||||||
|
<span class="a-nav__label">대시보드</span>
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<span class="a-nav__item a-nav__item--root is-disabled" title="준비중">
|
||||||
|
<span class="a-nav__dot" aria-hidden="true"></span>
|
||||||
|
<span class="a-nav__label">대시보드</span>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
@foreach($menu as $group)
|
@foreach($menu as $group)
|
||||||
@php
|
@php
|
||||||
// 그룹 아이템 필터링 (roles 체크 + route 존재 여부는 아래에서 disabled로 처리)
|
// ✅ "대시보드" 그룹은 렌더링에서만 제외 (배열 수정 X)
|
||||||
|
if (($group['title'] ?? '') === '대시보드') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 아이템 필터링 (roles 체크)
|
||||||
$visibleItems = [];
|
$visibleItems = [];
|
||||||
foreach (($group['items'] ?? []) as $it) {
|
foreach (($group['items'] ?? []) as $it) {
|
||||||
$need = $it['roles'] ?? null;
|
$need = $it['roles'] ?? null;
|
||||||
|
|
||||||
// roles 미지정: 전체 노출
|
if ($need === null) { $visibleItems[] = $it; continue; }
|
||||||
if ($need === null) {
|
if ($isSuper) { $visibleItems[] = $it; continue; }
|
||||||
$visibleItems[] = $it;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// super_admin: 무조건 통과
|
|
||||||
if ($isSuper) {
|
|
||||||
$visibleItems[] = $it;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// roles 배열과 교집합이 있으면 노출
|
|
||||||
if (is_array($need) && !empty(array_intersect($need, $roleNames))) {
|
if (is_array($need) && !empty(array_intersect($need, $roleNames))) {
|
||||||
$visibleItems[] = $it;
|
$visibleItems[] = $it;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ 현재 라우트 기준으로 "해당 그룹이 열려야 하는지" 선계산
|
||||||
|
$groupActive = false;
|
||||||
|
foreach ($visibleItems as $it) {
|
||||||
|
$routeName = (string)($it['route'] ?? '');
|
||||||
|
if ($routeName === '' || !\Illuminate\Support\Facades\Route::has($routeName)) continue;
|
||||||
|
|
||||||
|
$base = preg_replace('/\.index$/', '', $routeName);
|
||||||
|
if ($base && (request()->routeIs($base.'.*') || request()->routeIs($base))) {
|
||||||
|
$groupActive = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if(!empty($visibleItems))
|
@if(!empty($visibleItems))
|
||||||
<div class="a-nav__group">
|
<div class="a-nav__group {{ $groupActive ? 'is-open' : '' }}" data-nav-group>
|
||||||
<div class="a-nav__title">{{ $group['title'] }}</div>
|
{{-- ✅ 타이틀만 기본 노출 + 클릭 시 하위 펼침 --}}
|
||||||
|
<button type="button"
|
||||||
|
class="a-nav__titlebtn"
|
||||||
|
data-nav-toggle
|
||||||
|
aria-expanded="{{ $groupActive ? 'true' : 'false' }}">
|
||||||
|
<span class="a-nav__titletext">{{ $group['title'] }}</span>
|
||||||
|
<span class="a-nav__chev" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{-- ✅ 하위 메뉴(기본 닫힘 / is-open일 때만 펼침) --}}
|
||||||
|
<div class="a-nav__items" data-nav-items>
|
||||||
@foreach($visibleItems as $it)
|
@foreach($visibleItems as $it)
|
||||||
@php
|
@php
|
||||||
$routeName = (string)($it['route'] ?? '');
|
$routeName = (string)($it['route'] ?? '');
|
||||||
$has = $routeName !== '' ? \Illuminate\Support\Facades\Route::has($routeName) : false;
|
$has = $routeName !== '' ? \Illuminate\Support\Facades\Route::has($routeName) : false;
|
||||||
|
|
||||||
// index면 admin.admins.* 전체를 active로 잡아줌
|
|
||||||
$base = $routeName !== '' ? preg_replace('/\.index$/', '', $routeName) : '';
|
$base = $routeName !== '' ? preg_replace('/\.index$/', '', $routeName) : '';
|
||||||
$isActive = $has && $base !== '' ? request()->routeIs($base . '.*') || request()->routeIs($base) : false;
|
$isActive = $has && $base !== '' ? (request()->routeIs($base . '.*') || request()->routeIs($base)) : false;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if($has)
|
@if($has)
|
||||||
@ -137,6 +179,7 @@
|
|||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -104,7 +104,7 @@
|
|||||||
<div class="a-muted" style="margin-top:8px;">* 서버에서 최종 정규화/중복/오류 제거합니다.</div>
|
<div class="a-muted" style="margin-top:8px;">* 서버에서 최종 정규화/중복/오류 제거합니다.</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section data-panel="template" style="display:none;">
|
<section data-panel="csv" style="display:none;">
|
||||||
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
|
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
|
||||||
<label class="sms-btn">
|
<label class="sms-btn">
|
||||||
CSV 업로드
|
CSV 업로드
|
||||||
|
|||||||
18
resources/views/mail/preview.blade.php
Normal file
18
resources/views/mail/preview.blade.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
|
<title>{{ ($subject ?? '') !== '' ? $subject : 'preview' }}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background:#0b1220;">
|
||||||
|
{{-- Preheader (메일 리스트에서 보이는 짧은 요약 텍스트) --}}
|
||||||
|
<div style="display:none; max-height:0; overflow:hidden; opacity:0; color:transparent; mso-hide:all;">
|
||||||
|
{{ \Illuminate\Support\Str::limit(strip_tags(($body_text ?? $body ?? $subject ?? '')), 80) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@includeIf("mail.skins.$skin_key", get_defined_vars())
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
83
resources/views/mail/skins/clean.blade.php
Normal file
83
resources/views/mail/skins/clean.blade.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
@php
|
||||||
|
$brandTxt = $brand ?? config('app.name', 'Brand');
|
||||||
|
$subj = $subject ?? '';
|
||||||
|
$copy = $copyright ?? ('© 2018 '.$brandTxt.'.');
|
||||||
|
$bodyOut = $body_html ?? nl2br(e($body_text ?? $body ?? ''));
|
||||||
|
|
||||||
|
$snsList = [];
|
||||||
|
if (!empty($sns) && is_array($sns)) {
|
||||||
|
if (isset($sns[0]) && is_array($sns[0])) $snsList = $sns;
|
||||||
|
else foreach ($sns as $k=>$u) if ($u) $snsList[] = ['label'=>ucfirst((string)$k), 'url'=>$u];
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6; padding:26px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="660" cellpadding="0" cellspacing="0"
|
||||||
|
style="width:660px; max-width:94%; background:#ffffff; border-radius:18px; overflow:hidden; border:1px solid #e5e7eb;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 20px; background:linear-gradient(90deg,#0ea5e9,#6366f1); color:#fff;">
|
||||||
|
<div style="font-weight:900; font-size:15px;">
|
||||||
|
@if(!empty($logoUrl))
|
||||||
|
<img src="{{ $logoUrl }}" alt="{{ $brandTxt }}" style="height:20px; vertical-align:middle;">
|
||||||
|
@else
|
||||||
|
{{ $brandTxt }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px; opacity:.9; margin-top:6px;">안내 메일</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
@if(!empty($heroUrl))
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ $heroUrl }}" alt="" style="width:100%; display:block;"></td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:22px 20px;">
|
||||||
|
<div style="font-weight:900; font-size:18px; color:#0f172a; margin-bottom:12px;">
|
||||||
|
{{ $subj }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:#f8fafc; border:1px solid #e5e7eb; border-radius:14px; padding:14px;">
|
||||||
|
<div style="font-size:14px; line-height:1.85; color:#111; height:300px">
|
||||||
|
{!! $bodyOut !!}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height:16px;"></div>
|
||||||
|
|
||||||
|
{{-- Highlight tip --}}
|
||||||
|
<div style="border-left:4px solid #6b7280; padding:10px 12px; background:#f3f4f6; border-radius:12px;">
|
||||||
|
<div style="font-size:12px; color:#111827; font-weight:800; margin-bottom:4px;">안내</div>
|
||||||
|
<div style="font-size:12px; color:#374151; line-height:1.6;">
|
||||||
|
이 메일은 발신 전용입니다. 문의가 필요하면 관리자 채널을 통해 연락해 주세요.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:18px; font-size:12px; color:#6b7280;">
|
||||||
|
<div style="border-top:1px solid #eef2f7; padding-top:14px;">
|
||||||
|
<div style="margin-bottom:10px;">{{ $copy }}</div>
|
||||||
|
|
||||||
|
@if(!empty($snsList))
|
||||||
|
<div>
|
||||||
|
@foreach($snsList as $s)
|
||||||
|
@php $u=$s['url']??''; $l=$s['label']??''; @endphp
|
||||||
|
@if($u && $l)
|
||||||
|
<a href="{{ $u }}" style="color:#2563eb; text-decoration:none; margin-right:12px;">
|
||||||
|
{{ $l }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
78
resources/views/mail/skins/dark.blade.php
Normal file
78
resources/views/mail/skins/dark.blade.php
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
@php
|
||||||
|
$brandTxt = $brand ?? config('app.name', 'Brand');
|
||||||
|
$subj = $subject ?? '';
|
||||||
|
$copy = $copyright ?? ('© 2018 '.$brandTxt.'.');
|
||||||
|
$bodyOut = $body_html ?? nl2br(e($body_text ?? $body ?? ''));
|
||||||
|
|
||||||
|
$snsList = [];
|
||||||
|
if (!empty($sns) && is_array($sns)) {
|
||||||
|
if (isset($sns[0]) && is_array($sns[0])) $snsList = $sns;
|
||||||
|
else foreach ($sns as $k=>$u) if ($u) $snsList[] = ['label'=>ucfirst((string)$k), 'url'=>$u];
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#050914; padding:26px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="660" cellpadding="0" cellspacing="0"
|
||||||
|
style="width:660px; max-width:94%; background:#0b1220; border-radius:18px; overflow:hidden; border:1px solid rgba(255,255,255,.10);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 20px; background:linear-gradient(90deg,#111827,#0b1220); color:#e5e7eb; border-bottom:1px solid rgba(255,255,255,.08);">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between;">
|
||||||
|
<div style="font-weight:900; font-size:15px;">
|
||||||
|
@if(!empty($logoUrl))
|
||||||
|
<img src="{{ $logoUrl }}" alt="{{ $brandTxt }}" style="height:20px; vertical-align:middle;">
|
||||||
|
@else
|
||||||
|
{{ $brandTxt }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:22px 20px;">
|
||||||
|
<div style="font-weight:900; font-size:18px; color:#e5e7eb; margin-bottom:12px;">
|
||||||
|
{{ $subj }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.10); border-radius:14px; padding:14px;">
|
||||||
|
<div style="font-size:14px; line-height:1.85; color:#e5e7eb; height:300px">
|
||||||
|
{!! $bodyOut !!}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Highlight tip --}}
|
||||||
|
<div style="border-left:4px solid #6b7280; padding:10px 12px; background:#19202d; border-radius:12px; margin-top:20px">
|
||||||
|
<div style="font-size:12px; color:#f8f8f8; font-weight:800; margin-bottom:4px;">안내</div>
|
||||||
|
<div style="font-size:12px; color:#eef0f3; line-height:1.6;">
|
||||||
|
이 메일은 발신 전용입니다. 문의가 필요하면 관리자 채널을 통해 연락해 주세요.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:18px; font-size:12px; color:rgba(229,231,235,.70);">
|
||||||
|
<div style="border-top:1px solid rgba(255,255,255,.08); padding-top:14px;">
|
||||||
|
<div style="margin-bottom:10px;">{{ $copy }}</div>
|
||||||
|
|
||||||
|
@if(!empty($snsList))
|
||||||
|
<div>
|
||||||
|
@foreach($snsList as $s)
|
||||||
|
@php $u=$s['url']??''; $l=$s['label']??''; @endphp
|
||||||
|
@if($u && $l)
|
||||||
|
<a href="{{ $u }}" style="color:#93c5fd; text-decoration:none; margin-right:12px;">
|
||||||
|
{{ $l }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
112
resources/views/mail/skins/hero.blade.php
Normal file
112
resources/views/mail/skins/hero.blade.php
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
@php
|
||||||
|
$brandTxt = $brand ?? config('app.name', 'Brand');
|
||||||
|
$subTxt = $subtitle ?? '공지/이벤트/안내 메일';
|
||||||
|
$subj = $subject ?? '';
|
||||||
|
$copy = $copyright ?? ('© 2018 '.$brandTxt.'.');
|
||||||
|
$bodyOut = $body_html ?? nl2br(e($body_text ?? $body ?? ''));
|
||||||
|
|
||||||
|
// sns: (1) [['label'=>'','url'=>''], ...] 또는 (2) ['instagram'=>'url', 'youtube'=>'url']
|
||||||
|
$snsList = [];
|
||||||
|
if (!empty($sns) && is_array($sns)) {
|
||||||
|
if (isset($sns[0]) && is_array($sns[0])) {
|
||||||
|
$snsList = $sns;
|
||||||
|
} else {
|
||||||
|
foreach ($sns as $k=>$u) {
|
||||||
|
if ($u) $snsList[] = ['label'=>ucfirst((string)$k), 'url'=>$u];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0b1220; padding:28px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
|
||||||
|
<table role="presentation" width="720" cellpadding="0" cellspacing="0"
|
||||||
|
style="width:720px; max-width:94%; border-radius:22px; overflow:hidden; background:#0b1220;">
|
||||||
|
|
||||||
|
{{-- outer glow frame --}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:1px; background:linear-gradient(135deg, rgba(37,99,235,.95), rgba(124,58,237,.95), rgba(34,197,94,.75)); border-radius:22px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||||
|
style="background:#ffffff; border-radius:21px; overflow:hidden;">
|
||||||
|
|
||||||
|
{{-- Header --}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 22px; background:linear-gradient(90deg,#2563eb,#7c3aed); color:#fff;">
|
||||||
|
<div style="font-weight:900; font-size:16px; letter-spacing:.2px;">
|
||||||
|
@if(!empty($logoUrl))
|
||||||
|
<img src="{{ $logoUrl }}" alt="{{ $brandTxt }}" style="height:22px; vertical-align:middle;">
|
||||||
|
@else
|
||||||
|
{{ $brandTxt }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{{-- Hero Image --}}
|
||||||
|
@if(!empty($heroUrl))
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img src="{{ $heroUrl }}" alt="" style="width:100%; display:block;">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Content --}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:26px 22px;">
|
||||||
|
<div style="font-weight:900; font-size:22px; line-height:1.25; color:#0f172a; margin-bottom:12px;">
|
||||||
|
{{ $subj }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height:10px;"></div>
|
||||||
|
|
||||||
|
<div style="background:linear-gradient(180deg,#f8fafc,#ffffff); border:1px solid #eef2f7; border-radius:16px; padding:16px 16px;">
|
||||||
|
<div style="font-size:14px; line-height:1.85; color:#111; height:300px">
|
||||||
|
{!! $bodyOut !!}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height:16px;"></div>
|
||||||
|
|
||||||
|
{{-- Highlight tip --}}
|
||||||
|
<div style="border-left:4px solid #6b7280; padding:10px 12px; background:#f3f4f6; border-radius:12px;">
|
||||||
|
<div style="font-size:12px; color:#111827; font-weight:800; margin-bottom:4px;">안내</div>
|
||||||
|
<div style="font-size:12px; color:#374151; line-height:1.6;">
|
||||||
|
이 메일은 발신 전용입니다. 문의가 필요하면 관리자 채널을 통해 연락해 주세요.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Footer --}}
|
||||||
|
<div style="margin-top:22px; font-size:12px; color:#64748b; line-height:1.7;">
|
||||||
|
<div style="border-top:1px solid #eef2f7; padding-top:14px;">
|
||||||
|
<div style="margin-bottom:10px;">{{ $copy }}</div>
|
||||||
|
|
||||||
|
@if(!empty($snsList))
|
||||||
|
<div>
|
||||||
|
@foreach($snsList as $s)
|
||||||
|
@php $u=$s['url']??''; $l=$s['label']??''; @endphp
|
||||||
|
@if($u && $l)
|
||||||
|
<a href="{{ $u }}" style="color:#2563eb; text-decoration:none; margin-right:12px;">
|
||||||
|
{{ $l }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
75
resources/views/mail/skins/minimal.blade.php
Normal file
75
resources/views/mail/skins/minimal.blade.php
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
@php
|
||||||
|
$brandTxt = $brand ?? config('app.name', 'Brand');
|
||||||
|
$subj = $subject ?? '';
|
||||||
|
$copy = $copyright ?? ('© 2018 '.$brandTxt.'.');
|
||||||
|
$bodyOut = $body_html ?? nl2br(e($body_text ?? $body ?? ''));
|
||||||
|
|
||||||
|
$snsList = [];
|
||||||
|
if (!empty($sns) && is_array($sns)) {
|
||||||
|
if (isset($sns[0]) && is_array($sns[0])) $snsList = $sns;
|
||||||
|
else foreach ($sns as $k=>$u) if ($u) $snsList[] = ['label'=>ucfirst((string)$k), 'url'=>$u];
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6; padding:22px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="620" cellpadding="0" cellspacing="0"
|
||||||
|
style="width:620px; max-width:94%; background:#ffffff; border-radius:16px; overflow:hidden; border:1px solid #e5e7eb;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 18px; border-bottom:1px solid #e5e7eb; background:#ffffff;">
|
||||||
|
<div style="font-weight:900; color:#111827; font-size:14px;">
|
||||||
|
@if(!empty($logoUrl))
|
||||||
|
<img src="{{ $logoUrl }}" alt="{{ $brandTxt }}" style="height:18px; vertical-align:middle;">
|
||||||
|
@else
|
||||||
|
{{ $brandTxt }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px;">
|
||||||
|
<div style="font-weight:900; font-size:16px; color:#111827;">
|
||||||
|
{{ $subj }}
|
||||||
|
</div>
|
||||||
|
<div style="height:10px;"></div>
|
||||||
|
|
||||||
|
<div style="background:#0f172a; border-radius:14px; padding:14px; border:1px solid #111827;">
|
||||||
|
<div style="font-size:13px; color:#e5e7eb; line-height:1.8; height:300px">
|
||||||
|
{!! $bodyOut !!}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Highlight tip --}}
|
||||||
|
<div style="border-left:4px solid #6b7280; padding:10px 12px; background:#f3f4f6; border-radius:12px; margin-top:20px ">
|
||||||
|
<div style="font-size:12px; color:#111827; font-weight:800; margin-bottom:4px;">안내</div>
|
||||||
|
<div style="font-size:12px; color:#374151; line-height:1.6;">
|
||||||
|
이 메일은 발신 전용입니다. 문의가 필요하면 관리자 채널을 통해 연락해 주세요.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:16px; font-size:12px; color:#6b7280; line-height:1.6;">
|
||||||
|
<div style="border-top:1px solid #eef2f7; padding-top:12px;">
|
||||||
|
<div style="margin-bottom:10px;">{{ $copy }}</div>
|
||||||
|
|
||||||
|
@if(!empty($snsList))
|
||||||
|
<div>
|
||||||
|
@foreach($snsList as $s)
|
||||||
|
@php $u=$s['url']??''; $l=$s['label']??''; @endphp
|
||||||
|
@if($u && $l)
|
||||||
|
<a href="{{ $u }}" style="color:#2563eb; text-decoration:none; margin-right:12px;">
|
||||||
|
{{ $l }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
85
resources/views/mail/skins/newsletter.blade.php
Normal file
85
resources/views/mail/skins/newsletter.blade.php
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
@php
|
||||||
|
$brandTxt = $brand ?? config('app.name', 'Brand');
|
||||||
|
$subTxt = $subtitle ?? '업데이트/공지/소식';
|
||||||
|
$subj = $subject ?? '';
|
||||||
|
$copy = $copyright ?? ('© 2018 '.$brandTxt.'.');
|
||||||
|
$bodyOut = $body_html ?? nl2br(e($body_text ?? $body ?? ''));
|
||||||
|
|
||||||
|
$snsList = [];
|
||||||
|
if (!empty($sns) && is_array($sns)) {
|
||||||
|
if (isset($sns[0]) && is_array($sns[0])) $snsList = $sns;
|
||||||
|
else foreach ($sns as $k=>$u) if ($u) $snsList[] = ['label'=>ucfirst((string)$k), 'url'=>$u];
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#070a12; padding:26px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="640" cellpadding="0" cellspacing="0"
|
||||||
|
style="width:640px; max-width:94%; background:#0b1220; border-radius:20px; overflow:hidden; border:1px solid rgba(255,255,255,.10);">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 20px; background:linear-gradient(90deg,#22c55e,#16a34a,#0ea5e9); color:#052e16;">
|
||||||
|
<div style="font-weight:1000; font-size:16px; color:#fbfdfc;">
|
||||||
|
@if(!empty($logoUrl))
|
||||||
|
<img src="{{ $logoUrl }}" alt="{{ $brandTxt }}" style="height:20px; vertical-align:middle;">
|
||||||
|
@else
|
||||||
|
{{ $brandTxt }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
@if(!empty($heroUrl))
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ $heroUrl }}" alt="" style="width:100%; display:block;"></td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 20px;">
|
||||||
|
<div style="color:#e5e7eb; font-weight:900; font-size:18px;">
|
||||||
|
{{ $subj }}
|
||||||
|
</div>
|
||||||
|
<div style="height:12px;"></div>
|
||||||
|
|
||||||
|
<div style="background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.12); border-radius:18px; padding:16px;">
|
||||||
|
<div style="color:rgba(255,255,255,.90); font-size:14px; line-height:1.85; height:300px">
|
||||||
|
{!! $bodyOut !!}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Highlight tip --}}
|
||||||
|
<div style="border-left:4px solid #6b7280; padding:10px 12px; background:#19202d; border-radius:12px; margin-top:20px ">
|
||||||
|
<div style="font-size:12px; color:#ffffff; font-weight:800; margin-bottom:4px;">안내</div>
|
||||||
|
<div style="font-size:12px; color:#ffffff; line-height:1.6;">
|
||||||
|
이 메일은 발신 전용입니다. 문의가 필요하면 관리자 채널을 통해 연락해 주세요.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:18px; font-size:12px; color:rgba(255,255,255,.60); line-height:1.6;">
|
||||||
|
<div style="border-top:1px solid rgba(255,255,255,.08); padding-top:14px;">
|
||||||
|
<div style="margin-bottom:10px;">{{ $copy }}</div>
|
||||||
|
|
||||||
|
@if(!empty($snsList))
|
||||||
|
<div>
|
||||||
|
@foreach($snsList as $s)
|
||||||
|
@php $u=$s['url']??''; $l=$s['label']??''; @endphp
|
||||||
|
@if($u && $l)
|
||||||
|
<a href="{{ $u }}" style="color:rgba(255,255,255,.75); text-decoration:none; margin-right:12px;">
|
||||||
|
{{ $l }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
@ -6,6 +6,9 @@ use App\Http\Controllers\Admin\Auth\AdminAuthController;
|
|||||||
use App\Http\Controllers\Admin\Sms\AdminSmsController;
|
use App\Http\Controllers\Admin\Sms\AdminSmsController;
|
||||||
use App\Http\Controllers\Admin\Sms\AdminSmsLogController;
|
use App\Http\Controllers\Admin\Sms\AdminSmsLogController;
|
||||||
use App\Http\Controllers\Admin\Sms\AdminSmsTemplateController;
|
use App\Http\Controllers\Admin\Sms\AdminSmsTemplateController;
|
||||||
|
use App\Http\Controllers\Admin\Mail\AdminMailController;
|
||||||
|
use App\Http\Controllers\Admin\Mail\AdminMailLogController;
|
||||||
|
use App\Http\Controllers\Admin\Mail\AdminMailTemplateController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware(['web'])->group(function () {
|
Route::middleware(['web'])->group(function () {
|
||||||
@ -47,17 +50,17 @@ Route::middleware(['web'])->group(function () {
|
|||||||
// 로그인 이후
|
// 로그인 이후
|
||||||
Route::middleware(['auth:admin', \App\Http\Middleware\NoStore::class])->group(function () {
|
Route::middleware(['auth:admin', \App\Http\Middleware\NoStore::class])->group(function () {
|
||||||
|
|
||||||
// ✅ 대시보드: 전체 허용
|
// 대시보드: 전체 허용
|
||||||
Route::get('/', fn() => view('admin.home'))->name('admin.home');
|
Route::get('/', fn() => view('admin.home'))->name('admin.home');
|
||||||
|
|
||||||
// ✅ 내 정보: 전체 허용
|
// 내 정보: 전체 허용
|
||||||
Route::get('/me', [MeController::class, 'show'])->name('admin.me');
|
Route::get('/me', [MeController::class, 'show'])->name('admin.me');
|
||||||
Route::post('/me', [MeController::class, 'update'])->name('admin.me.update');
|
Route::post('/me', [MeController::class, 'update'])->name('admin.me.update');
|
||||||
|
|
||||||
Route::get('/me/password', [MeController::class, 'showPassword'])->name('admin.me.password.form');
|
Route::get('/me/password', [MeController::class, 'showPassword'])->name('admin.me.password.form');
|
||||||
Route::post('/me/password', [MeController::class, 'updatePassword'])->name('admin.me.password.update');
|
Route::post('/me/password', [MeController::class, 'updatePassword'])->name('admin.me.password.update');
|
||||||
|
|
||||||
// ✅ 보안/OTP 등록(자기계정 설정): 전체 허용
|
// 보안/OTP 등록(자기계정 설정): 전체 허용
|
||||||
Route::get('/security', [AdminAuthController::class, 'security'])->name('admin.security');
|
Route::get('/security', [AdminAuthController::class, 'security'])->name('admin.security');
|
||||||
|
|
||||||
Route::post('/totp/start', [AdminAuthController::class, 'totpStart'])->name('admin.totp.start');
|
Route::post('/totp/start', [AdminAuthController::class, 'totpStart'])->name('admin.totp.start');
|
||||||
@ -68,7 +71,7 @@ Route::middleware(['web'])->group(function () {
|
|||||||
|
|
||||||
Route::post('/logout', [AdminAuthController::class, 'logout'])->name('admin.logout');
|
Route::post('/logout', [AdminAuthController::class, 'logout'])->name('admin.logout');
|
||||||
|
|
||||||
// ✅ 관리자 계정 관리: super_admin 전용
|
// 관리자 계정 관리: super_admin 전용
|
||||||
Route::prefix('/admins')
|
Route::prefix('/admins')
|
||||||
->name('admin.admins.')
|
->name('admin.admins.')
|
||||||
->middleware('admin.role:super_admin')
|
->middleware('admin.role:super_admin')
|
||||||
@ -93,7 +96,7 @@ Route::middleware(['web'])->group(function () {
|
|||||||
Route::get('/logs/{batchId}', [AdminSmsLogController::class, 'show'])->name('admin.sms.logs.show');
|
Route::get('/logs/{batchId}', [AdminSmsLogController::class, 'show'])->name('admin.sms.logs.show');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('templates')->name('admin.templates.')->group(function () {
|
Route::prefix('/templates')->name('admin.templates.')->group(function () {
|
||||||
Route::get('/', [AdminSmsTemplateController::class, 'index'])
|
Route::get('/', [AdminSmsTemplateController::class, 'index'])
|
||||||
->name('index');
|
->name('index');
|
||||||
|
|
||||||
@ -112,6 +115,27 @@ Route::middleware(['web'])->group(function () {
|
|||||||
->name('update');
|
->name('update');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
Route::prefix('/mail')->group(function () {
|
||||||
|
// 발송
|
||||||
|
Route::get('/send', [AdminMailController::class, 'create'])->name('admin.mail.send');
|
||||||
|
Route::post('/send', [AdminMailController::class, 'store'])->name('admin.mail.send.store');
|
||||||
|
|
||||||
|
// 로그
|
||||||
|
Route::get('/logs', [AdminMailLogController::class, 'index'])->name('admin.mail.logs');
|
||||||
|
Route::get('/logs/{batchId}', [AdminMailLogController::class, 'show'])->name('admin.mail.logs.show');
|
||||||
|
Route::post('/logs/{batchId}/cancel', [AdminMailLogController::class, 'cancel'])->name('admin.mail.logs.cancel');
|
||||||
|
Route::post('/logs/{batchId}/retry-failed', [AdminMailLogController::class, 'retryFailed'])->name('admin.mail.logs.retry_failed');
|
||||||
|
|
||||||
|
// 템플릿
|
||||||
|
Route::get('/templates', [AdminMailTemplateController::class, 'index'])->name('admin.mail.templates.index');
|
||||||
|
Route::get('/templates/create', [AdminMailTemplateController::class, 'create'])->name('admin.mail.templates.create');
|
||||||
|
Route::post('/templates', [AdminMailTemplateController::class, 'store'])->name('admin.mail.templates.store');
|
||||||
|
Route::get('/templates/{id}/edit', [AdminMailTemplateController::class, 'edit'])->name('admin.mail.templates.edit');
|
||||||
|
Route::put('/templates/{id}', [AdminMailTemplateController::class, 'update'])->name('admin.mail.templates.update');
|
||||||
|
Route::post('/preview', [AdminMailController::class, 'preview'])->name('admin.mail.preview');
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 아래는 메뉴는 있지만 실제 라우트/컨트롤러가 아직 없으니,
|
* 아래는 메뉴는 있지만 실제 라우트/컨트롤러가 아직 없으니,
|
||||||
* 구현 시점에만 같은 패턴으로 그룹에 admin.role 을 붙이면 됨.
|
* 구현 시점에만 같은 패턴으로 그룹에 admin.role 을 붙이면 됨.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user