본문으로 건너뛰기

CRM 관리 메뉴에 CRM 레시피 기능 추가

PHP 8 · GODO26
변경 파일 수: 27개
변경 파일 목록
27
변경 파일 코드
추가삭제
Admin/crm/crm_recipe_frame.php+102
변경 전crm_recipe_frame.php
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
변경 후crm_recipe_frame.php
<link type="text/css" href="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/css/crm/crm-recipe.css') ?>" rel="stylesheet"/>
<script src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/aggregator-message-router.js') ?>"></script>
<iframe name="crmRecipe" src="<?= gd_htmlspecialchars($pageUrl) ?>" frameborder="0" marginwidth="0" marginheight="0" width="100%" style="height: 100vh;"></iframe>
<script>
(() => {
'use strict';
 
const DRAWER_ID = 'crm-recipe-drawer';
 
const PENDING_TOAST_KEY = 'crmRecipePendingToast';
(() => {
try {
const raw = sessionStorage.getItem(PENDING_TOAST_KEY);
if (!raw) return;
 
sessionStorage.removeItem(PENDING_TOAST_KEY);
const saved = JSON.parse(raw);
if (saved?.toast && window.NCDSToast && Date.now() - saved.at < 10000) {
window.NCDSToast(saved.toast);
}
} catch (e) {/* sessionStorage 접근 불가 환경 무시 */}
})();
 
const onKeydown = (event) => {
if (event.key === 'Escape') closeDrawer();
};
 
const closeDrawer = () => {
document.getElementById(DRAWER_ID)?.remove();
// 어드민은 window 스크롤이라 html/body 둘 다 해제
document.documentElement.classList.remove('crm-recipe-drawer-open');
document.body.classList.remove('crm-recipe-drawer-open');
document.removeEventListener('keydown', onKeydown);
};
 
const openDrawer = (url) => {
closeDrawer(); // 중복 오픈 방지
 
const iframe = Object.assign(document.createElement('iframe'), {
className: 'crm-recipe-drawer__iframe',
src: url,
});
 
const panel = document.createElement('div');
panel.className = 'crm-recipe-drawer__panel';
panel.append(iframe);
 
const backdrop = document.createElement('div');
backdrop.id = DRAWER_ID;
backdrop.className = 'crm-recipe-drawer';
backdrop.append(panel);
 
document.body.append(backdrop);
// 부모 스크롤 잠금 + 스크롤바 제거
document.documentElement.classList.add('crm-recipe-drawer-open');
document.body.classList.add('crm-recipe-drawer-open');
document.addEventListener('keydown', onKeydown);
};
 
window.addEventListener('message', ({ data }) => {
if (data?.type !== 'CLOSE_CRM_RECIPE_DRAWER') return;
 
closeDrawer();
 
if (data.toast) {
try {
sessionStorage.setItem(PENDING_TOAST_KEY, JSON.stringify({ toast: data.toast, at: Date.now() }));
} catch (e) {/* sessionStorage 접근 불가 환경 무시 */}
}
 
if (data.reloadList) {
const listFrame = document.querySelector('iframe[name="crmRecipe"]');
if (listFrame) listFrame.src = listFrame.src;
}
});
 
// 공통 상품선택 팝업의 콜백을 드로어 iframe 으로 전달
window.setAddGoods = function (resultJson) {
document.querySelector('.crm-recipe-drawer__iframe')?.contentWindow?.setAddGoods?.(resultJson);
};
 
// PERMISSION_CHECK 응답용 action 별 거부 사유 (null = 허용)
const PERMISSION_DENIAL_REASONS = <?= json_encode($permissionDenialReasons, JSON_HEX_TAG) ?>;
 
IframeMessageRouter.init({
onLayerOpen: openDrawer,
onLayerClose: closeDrawer,
onPermissionCheck: (event) => {
if (event.data?.category !== 'CRM_RECIPE') return;
 
const { action, requestId } = event.data.payload || {};
const known = Object.prototype.hasOwnProperty.call(PERMISSION_DENIAL_REASONS, action);
const reason = known ? PERMISSION_DENIAL_REASONS[action] : null;
 
const payload = { requestId, granted: known && !reason };
if (reason) payload.reason = reason;
 
event.source?.postMessage({ category: 'CRM_RECIPE', type: 'PERMISSION_CHECK', payload }, event.origin || '*');
},
});
})();
</script>
 
Admin/crm/crm_recipe_send.php+795-2
변경 전crm_recipe_send.php
});
}
 
 
 
 
 
 
 
 
 
function initializeEvents() {
document.querySelector('#closeCrmRecipe').addEventListener('click', function () {
NCDSConfirm({
message: '레시피 등록을 중단하시겠습니까?',
subMessage: '저장하지 않은 내용이 있습니다. 현재 설정한 내용이 모두 사라집니다.',
callback: function (result) {
if (result && window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'CLOSE_CRM_RECIPE_DRAWER' }, '*');
}
 
 
 
 
 
 
},
});
});
window.alternativePreview?.setContent(alternativeContents);
}
 
 
 
// 인원 0명이어도 컨펌 없이 바로 발송전 미리보기
openPreviewLayerPopup($button);
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
변경 후crm_recipe_send.php
});
}
 
function sendClickLog(action) {
return $.post('./crm_recipe_click_log_ps.php', {
action: action,
recipeType: document.querySelector('input[name="recipeType"]')?.value ?? '',
currentPage: location.href.split('?')[0],
});
}
 
function initializeEvents() {
document.querySelector('#closeCrmRecipe').addEventListener('click', function () {
NCDSConfirm({
message: '레시피 등록을 중단하시겠습니까?',
subMessage: '저장하지 않은 내용이 있습니다. 현재 설정한 내용이 모두 사라집니다.',
callback: function (result) {
if (!result) {
return;
}
// 로그 요청 완료 후 드로어를 닫아야 요청이 취소되지 않음
sendClickLog('close').always(function () {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'CLOSE_CRM_RECIPE_DRAWER' }, '*');
}
});
},
});
});
window.alternativePreview?.setContent(alternativeContents);
}
 
sendClickLog('send');
 
// 인원 0명이어도 컨펌 없이 바로 발송전 미리보기
openPreviewLayerPopup($button);
}
<link type="text/css" href="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/css/crm/mobile-send.css') ?>" rel="stylesheet"/>
<link type="text/css" href="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/css/crm/crm-common.css') ?>" rel="stylesheet"/>
<link type="text/css" href="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/css/crm/crm-recipe-send.css') ?>" rel="stylesheet"/>
<link type="text/css" href="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'script/slider/slick/slick.css') ?>" rel="stylesheet"/>
<script src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'script/slider/slick/slick.min.js') ?>"></script>
<script defer src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/charcount.js') ?>"></script>
<script type="text/javascript" src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'script/jquery/jquery.multi_select_box.js') ?>"></script>
<script type="text/javascript" src="<?= PATH_ADMIN_GD_SHARE ?>script/crm/mobile_message_replace_code.js"></script>
<script type="text/javascript" src="<?= PATH_ADMIN_GD_SHARE ?>script/crm/message_send_common.js"></script>
<script type="text/javascript" src="<?= PATH_ADMIN_GD_SHARE ?>script/crm/crm_group_status.js"></script>
<script type="text/javascript" src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/ncds-checkbox-group/ncds-checkbox-group.js') ?>"></script>
<script type="text/javascript" src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/ncds-datepicker-factory/ncds-datepicker-factory.js') ?>"></script>
<script defer src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/scroll-sticky.js') ?>"></script>
<script defer src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/ncds-jquery-validator.js') ?>"></script>
<script type="text/javascript" src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/godo-ui-module/chip-selector/chip-selector.js') ?>"></script>
<script type="text/javascript" src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/godo-ui-module/message-input/message-input.js') ?>"></script>
<script type="text/javascript" src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/godo-ui-module/crm-preview/crm-preview-loader.js') ?>"></script>
<script type="text/javascript" src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/godo-ui-module/godo-ui-module.js') ?>"></script>
<script src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/aggregator-message-router.js') ?>"></script>
 
<article class="ncua-content content-wrap-initial mobile-send">
<form id="frmCrmRecipe">
<input type="hidden" name="recipeType" value="<?= gd_htmlspecialchars($recipeType ?? '') ?>"/>
<input type="hidden" name="recipeName" value="<?= gd_htmlspecialchars($recipeName ?? '레시피') ?>"/>
 
<header class="ncua-page-header page-header js-affix">
<h3 class="ncua-help-manual"><?= gd_htmlspecialchars($recipeName ?? '레시피') ?></h3>
<div class="ncua-page-header__actions">
<button id="closeCrmRecipe" type="button" class="ncua-btn ncua-btn--sm ncua-btn--secondary-gray">닫기</button>
<button id="previewCrmRecipe" type="button" class="ncua-btn ncua-btn--sm ncua-btn--primary">발송</button>
</div>
</header>
 
<div class="ncua-split-layout">
<div class="section-container">
<div id="layerRecipientSetting" class="ncua-card ncua-card--no-border">
<section class="js-recipe-recipient-section js-recipient-setting-section">
<header class="ncua-card__header">
<div>
<h4 class="ncua-card__title">수신 대상</h4>
<?php if (!empty($recipeDefault['guideMessages']['targetConditionGuide'])): ?>
<p class="ncua-notice-info js-recipient-guide"><?= gd_htmlspecialchars($recipeDefault['guideMessages']['targetConditionGuide']) ?></p>
<?php endif; ?>
</div>
<button type="button" id="openRecipientDetail" class="ncua-btn ncua-btn--xs ncua-btn--secondary-gray">상세 설정</button>
</header>
<section class="ncua-card__body">
<input type="hidden" name="selectedCrmGroup" value="" />
<div class="ncua-border-group-box recipient-condition-group">
<?php if (!empty($conditionGuideView['hasConditionGuide'])): ?>
<div class="condition-guide-box js-condition-guide-box">
<div class="condition-guide-row js-condition-selection-guide">
<?= gd_htmlspecialchars($conditionGuideView['guideParts'][0] ?? '') ?>
<span class="ncua-select ncua-select--xs">
<span class="ncua-select__content">
<select name="conditionValue" class="ncua-select__tag js-condition-value-select">
<?php foreach ($conditionGuideView['selectableValues'] as $v): ?>
<option value="<?= gd_htmlspecialchars((string) $v) ?>" <?= $v === $conditionGuideView['currentValue'] ? 'selected' : '' ?>><?= gd_htmlspecialchars((string) $v) ?></option>
<?php endforeach; ?>
</select>
</span>
</span>
<?= gd_htmlspecialchars($conditionGuideView['guideParts'][1] ?? '') ?>
</div>
<p class="ncua-notice-info js-condition-selected-guide"></p>
<?php if (!empty($conditionGuideView['productSelectableCount'])): ?>
<div class="ncua-border-group-box condition-product-select-box js-condition-product-select">
<button type="button" class="ncua-btn ncua-btn--sm ncua-btn--secondary-gray js-open-product-select">상품 선택</button>
<input type="hidden" name="selectedProductNos" class="js-selected-product-nos" value="" data-max="<?= (int) $conditionGuideView['productSelectableCount'] ?>" />
<div class="condition-product-select__list js-selected-product-list"></div>
<p class="ncua-notice-info">특정 상품은 최대 <?= (int) $conditionGuideView['productSelectableCount'] ?>개까지 선택 가능합니다.</p>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
 
<div class="crm-group-selected-row js-crm-group-selected-row display-none">
<span id="crmGroupTagContainer" class="crm-group-tag-container"></span>
 
<span class="crm-group-extraction js-crm-group-extraction display-none">
<span class="crm-group-extraction-text">고객 추출 중 입니다.</span>
<button type="button" class="ncua-btn ncua-btn--xs ncua-btn--text-gray has-underline refresh-crm-group">
<img src="<?= PATH_ADMIN_GD_SHARE ?>ncds/image/refresh-cw-02.svg" alt="새로고침" />
새로고침
</button>
</span>
 
<span class="recipient-count-text js-recipient-count-text display-none">발송 인원 총 <strong class="recipient-count-total">0</strong> 명 <span class="point-text">(수신거부대상자 <strong class="recipient-count-reject">0</strong> 명 포함)</span></span>
</div>
</div>
 
<div class="recipient-info">
<label class="ncua-checkbox-field ncua-checkbox-field--xs has-text">
<span class="ncua-checkbox-input ncua-checkbox-input--xs ncua-checkbox-field__input">
<input type="checkbox" name="isOnlyAgreed" checked />
</span>
<span class="ncua-checkbox-field__text">SMS 수신동의한 회원에게 발송</span>
</label>
<span class="ncua-flex" data-tooltip-code="260604001" data-tooltip-seq="001"></span>
</div>
</section>
</section>
</div>
<div id="layerSendMethodSetting" class="ncua-card ncua-card--no-border"></div>
<div id="layerSendTimeSetting" class="ncua-card ncua-card--no-border"></div>
</div>
 
<aside class="ncua-panel">
<div id="layerPreviewMessage"></div>
</aside>
</div>
</form>
</article>
 
<div id="crmRecipeData"
data-sms-point-each="<?= gd_htmlspecialchars((string) $smsPointEach) ?>"
data-lms-point-each="<?= gd_htmlspecialchars((string) $lmsPointEach) ?>"
data-kakao-alrimtalk-point-each="<?= gd_htmlspecialchars((string) $kakaoAlrimTalkPointEach) ?>"
data-kakao-friendtalk-point-each="<?= gd_htmlspecialchars((string) $kakaoFriendTalkPointEach) ?>"
data-message-config="<?= gd_htmlspecialchars(json_encode($messageConfig, JSON_UNESCAPED_UNICODE)) ?>"
data-recipe-default="<?= gd_htmlspecialchars(json_encode($recipeDefault ?? [], JSON_UNESCAPED_UNICODE)) ?>"
<?php if (!empty($crmGroup)): ?>data-crm-group="<?= gd_htmlspecialchars(json_encode($crmGroup, JSON_UNESCAPED_UNICODE)) ?>"<?php endif; ?>
hidden></div>
 
<script type="text/javascript">
const crmRecipeDataEl = document.getElementById('crmRecipeData');
const smsPointEach = Number(crmRecipeDataEl.dataset.smsPointEach);
const lmsPointEach = Number(crmRecipeDataEl.dataset.lmsPointEach);
const kakaoAlrimtalkPointEach = Number(crmRecipeDataEl.dataset.kakaoAlrimtalkPointEach);
const kakaoFriendtalkPointEach = Number(crmRecipeDataEl.dataset.kakaoFriendtalkPointEach);
const messagePoint = Number(<?= (float) ($smsPoint ?? 0) ?>);
const messageConfig = JSON.parse(crmRecipeDataEl.dataset.messageConfig);
const recipeDefault = JSON.parse(crmRecipeDataEl.dataset.recipeDefault);
const crmGroup = JSON.parse(crmRecipeDataEl.dataset.crmGroup || '{}');
 
$(document).ready(function () {
// 툴팁 init — layer init 보다 먼저 끝내 seq 충돌 회피
const tooltipCodeEl = document.querySelector('[data-tooltip-code]');
const cosGuideReady = (window.GodoCosGuide && tooltipCodeEl)
? window.GodoCosGuide.init({
apiUrl: <?= json_encode($cosGuideApiUrl) ?>,
guideCode: Number(tooltipCodeEl.getAttribute('data-tooltip-code')),
})
: Promise.resolve();
 
cosGuideReady.finally(() => {
loadPreviewMessageLayer().then(() => {
loadSendMethodSettingLayer();
});
loadSendTimeSettingLayer();
});
initializeEvents();
bindConditionGuideSelectChange();
initRecipientCrmGroup();
initProductSelect();
});
 
function bindConditionGuideSelectChange() {
const select = document.querySelector('select.js-condition-value-select');
if (!select) return;
select.addEventListener('change', updateConditionPreview);
updateConditionPreview(); // 초기 미리보기도 JS가 렌더 (서버 initialPreview 제거)
}
 
function computeRelativeDateStr(offset, unit) {
const date = new Date();
if (unit === 'MONTH') {
const targetDay = date.getDate();
date.setDate(1);
date.setMonth(date.getMonth() + offset);
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
date.setDate(Math.min(targetDay, lastDay));
} else {
date.setDate(date.getDate() + offset);
}
// toISOString(UTC)은 KST에서 하루 어긋날 수 있어 로컬 일자로 포맷
const pad = n => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
 
function updateConditionPreview() {
const cg = recipeDefault.conditionGuide || {};
const previewEl = document.querySelector('.js-condition-selected-guide');
const select = document.querySelector('select.js-condition-value-select');
if (!previewEl || !select || !cg.selectedValueGuide) return;
 
const value = parseInt(select.value, 10) || 0;
const unit = cg.valueUnit || 'DAY';
const recipeType = MobileMessageReplaceCode.normalizeRecipeType(document.querySelector('input[name="recipeType"]')?.value);
const isPeriod = (cg.dateDisplayType || 'DAY') === 'PERIOD';
 
let startDay, endDay;
if (recipeType === 'repurchase-reminder') {
startDay = computeRelativeDateStr(-(value + 1), unit);
endDay = computeRelativeDateStr(-value, unit);
} else if (['dormant-coupon-wakeup', 'dormant-mileage-wakeup'].includes(recipeType)) {
startDay = endDay = computeRelativeDateStr(value, unit);
} else {
startDay = computeRelativeDateStr(-value, unit);
endDay = isPeriod ? computeRelativeDateStr(0, unit) : startDay;
}
 
previewEl.textContent = cg.selectedValueGuide
.replaceAll('{no}', String(value))
.replaceAll('{selected_day}', startDay)
.replaceAll('{start_day}', startDay)
.replaceAll('{end_day}', endDay);
}
 
function setRecipientTypeAvailability(enable) {
document.querySelectorAll('input[name="recipientType"]').forEach(input => {
if (['EXCEL_UPLOAD', 'DIRECT'].includes(input.value)) {
input.disabled = !enable;
}
});
}
 
function setSendMethodAvailability(enable) {
document.querySelectorAll('input[name="sendMethod"]').forEach(input => {
if (['ALIMTALK', 'MYAPP'].includes(input.value)) {
input.disabled = !enable;
}
});
 
const sendMethod = document.querySelector('input[name="sendMethod"]:checked')?.value;
 
if (sendMethod === 'FRIENDTALK') {
const activeSendTabType = document.querySelector('button[data-send-tab-type].is-active')?.dataset.sendTabType;
const campaignMessageType = document.querySelector('input[name="campaignMessageType"]:checked')?.value;
if (!enable) clearComponent?.(campaignMessageType);
updateTabComponent?.(document, activeSendTabType);
if (activeSendTabType === 'MAIN') updateCampaignTypeComponent?.(campaignMessageType);
} else if (sendMethod === 'SMS') {
document.querySelector('.js-replace-code')?.classList.toggle('display-none', !enable);
if (!enable) clearSmsComponent?.();
}
}
 
function setMyappSendConditionAvailability(isRecipientAllType, isOnlyAgreed) {
// 레시피는 항상 CRM 그룹 대상 → 발송조건 행 미노출, 회원 대상 고정
isRecipientAllType = false;
const myappPushSendConditionRow = document.querySelector('.js-myapp-push-send-condition-row');
const appInstalledRadio = document.querySelector('input[name="myappSendCondition"][value="APP_INSTALLED"]');
const appInstalledField = appInstalledRadio?.closest('.ncua-radio-field');
 
myappPushSendConditionRow?.classList.toggle('display-none', !isRecipientAllType);
appInstalledField?.classList.toggle('display-none', !isRecipientAllType || isOnlyAgreed);
 
if (!isRecipientAllType || isOnlyAgreed) {
const memberOnlyRadio = document.querySelector('input[name="myappSendCondition"][value="MEMBER_ONLY"]');
if (memberOnlyRadio) memberOnlyRadio.checked = true;
}
}
 
function readCrmGroupCounts() {
const total = parseInt((document.querySelector('.recipient-count-total')?.textContent ?? '0').replace(/[^0-9]/g, ''), 10) || 0;
const reject = parseInt((document.querySelector('.recipient-count-reject')?.textContent ?? '0').replace(/[^0-9]/g, ''), 10) || 0;
return { total, reject };
}
 
function getExpectTargetCount() {
if (!document.querySelector('input[name="selectedCrmGroup"]')?.value) return 0;
const { total, reject } = readCrmGroupCounts();
const isOnlyAgreed = document.querySelector('input[name="isOnlyAgreed"]')?.checked;
return isOnlyAgreed ? total - reject : total;
}
 
function getExpectTargetCountSeparated() {
if (!document.querySelector('input[name="selectedCrmGroup"]')?.value) {
return { agreedCount: 0, rejectedCount: 0, totalCount: 0 };
}
const { total, reject } = readCrmGroupCounts();
return { agreedCount: total - reject, rejectedCount: reject, totalCount: total };
}
 
function loadSendMethodSettingLayer() {
$.post('./mobile_send/layer_send_method_setting.php', { recipeContext: JSON.stringify(recipeDefault) }, function (data) {
$('#layerSendMethodSetting').html(data);
filterSendMethodForRecipe();
});
}
 
function loadSendTimeSettingLayer() {
$.post('./mobile_send/layer_recipe_send_time_setting.php', { recipeContext: JSON.stringify(recipeDefault) }, function (data) {
$('#layerSendTimeSetting').html(data);
});
}
 
function filterSendMethodForRecipe() {
const input = document.querySelector('input[name="sendMethod"][value="ALIMTALK"]');
if (!input) return;
input.disabled = true;
input.closest('.ncua-radio-field')?.classList.add('display-none');
}
 
function loadPreviewMessageLayer() {
return $.post('./mobile_send/layer_preview_message.php', function (data) {
$('#layerPreviewMessage').html(data);
});
}
 
function initializeEvents() {
document.querySelector('#closeCrmRecipe').addEventListener('click', function () {
NCDSConfirm({
message: '레시피 등록을 중단하시겠습니까?',
subMessage: '저장하지 않은 내용이 있습니다. 현재 설정한 내용이 모두 사라집니다.',
callback: function (result) {
if (result && window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'CLOSE_CRM_RECIPE_DRAWER' }, '*');
}
},
});
});
 
document.querySelector('#previewCrmRecipe').addEventListener('click', handleRecipeSendClick);
}
 
const recipeRecipientSection = document.querySelector('.js-recipe-recipient-section');
 
function initProductSelect() {
const wrap = recipeRecipientSection?.querySelector('.js-condition-product-select');
if (!wrap) return; // 상품선택 미사용 레시피면 미렌더
 
const hidden = wrap.querySelector('.js-selected-product-nos');
const listEl = wrap.querySelector('.js-selected-product-list');
const max = parseInt(hidden?.dataset.max ?? '0', 10) || 0;
let selectedProducts = []; // [{ no, name }]
 
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
 
function renderProducts() {
hidden.value = selectedProducts.map(function (p) { return p.no; }).join(',');
if (selectedProducts.length === 0) {
listEl.innerHTML = '';
return;
}
const chips = selectedProducts.map(function (p) {
return '<span class="ncua-tag ncua-tag--md condition-product-select__chip">'
+ '<span class="ncua-tag__text">' + escapeHtml(p.name) + '</span>'
+ '<button type="button" class="ncua-tag__close js-remove-product" data-no="' + escapeHtml(p.no) + '" aria-label="삭제"></button>'
+ '</span>';
}).join('');
listEl.innerHTML = chips
+ '<button type="button" class="ncua-btn ncua-btn--xs ncua-btn--text-gray js-remove-all-product">전체 삭제</button>';
}
 
wrap.querySelector('.js-open-product-select')?.addEventListener('click', function () {
layer_add_info('goods', { layerTitle: '상품 선택', mode: 'simple', callFunc: 'setAddGoods' });
});
 
listEl.addEventListener('click', function (e) {
const removeBtn = e.target.closest('.js-remove-product');
if (removeBtn) {
selectedProducts = selectedProducts.filter(function (p) { return p.no !== removeBtn.dataset.no; });
renderProducts();
return;
}
if (e.target.closest('.js-remove-all-product')) {
selectedProducts = [];
renderProducts();
}
});
 
// NCDS 상품선택 모달(layer_goods) 콜백 — 모달은 매번 빈 상태로 열리므로 기존 선택에 병합한다(no 기준 중복 제거).
window.setAddGoods = function (resultJson) {
const info = (resultJson && resultJson.info) ? resultJson.info : [];
const incoming = info.map(function (v) {
return { no: String(v.goodsNo), name: (v.goodsNm || v.goodsName || '') };
});
 
const merged = selectedProducts.slice();
incoming.forEach(function (p) {
if (!merged.some(function (e) { return e.no === p.no; })) {
merged.push(p);
}
});
 
if (max > 0 && merged.length > max) {
NCDSAlert({ message: max + '개까지 선택 가능합니다.', iconType: 'error' });
selectedProducts = merged.slice(0, max);
} else {
selectedProducts = merged;
}
renderProducts();
};
}
 
function initRecipientCrmGroup() {
// self 로 MODAL_OPEN 을 보내 이 iframe 이 팝업의 opener 가 되게 한다
document.getElementById('openRecipientDetail')?.addEventListener('click', async function () {
try {
if (await CrmGroupStatus.getCount() >= CrmGroupStatus.MAX_COUNT) {
NCDSAlert({ message: 'CRM 그룹은 최대 ' + CrmGroupStatus.MAX_COUNT + '개까지 등록할 수 있습니다.', iconType: 'error' });
return;
}
window.postMessage({
type: 'MODAL_OPEN',
payload: {
target: 'CRM_GROUP_CREATE_POPUP',
query: {
recipeType: document.querySelector('input[name="recipeType"]')?.value ?? '',
},
width: 1000,
height: 570,
},
}, '*');
} catch (e) {
NCDSAlert({ message: '일시적인 오류가 발생하였습니다.<br>다시 시도해 주세요.', iconType: 'error' });
}
});
 
recipeRecipientSection.querySelector('button.refresh-crm-group')?.addEventListener('click', async function () {
try {
const crmGroupNo = recipeRecipientSection.querySelector('input[name="selectedCrmGroup"]').value;
const response = await CrmGroupStatus.getInfo(crmGroupNo);
setSelectedMemberCount(response.data.count, response.data.smsOptOutCount);
updateCrmGroupData(response.data.status);
NCDSToast(CrmGroupStatus.toast(response.data.status));
} catch (e) {
NCDSToast(CrmGroupStatus.errorToast());
}
});
 
if (crmGroup?.no) {
renderSelectedCrmGroup(crmGroup);
}
}
 
function renderSelectedCrmGroup({ no, title, count, smsOptOutCount, status }) {
const tagContainer = recipeRecipientSection.querySelector('#crmGroupTagContainer');
const selectedCrmGroupInput = recipeRecipientSection.querySelector('input[name="selectedCrmGroup"]');
 
markCrmGroupSelected();
 
tagContainer.innerHTML = '';
tagContainer.appendChild(generateCrmGroupTag(title));
 
selectedCrmGroupInput.value = no;
selectedCrmGroupInput.dataset.crmGroupName = title;
 
setSelectedMemberCount(count, smsOptOutCount);
updateCrmGroupData(status);
}
 
window.selectCreatedCrmGroup = function (crmNo, groupName) {
setSelectedCrmGroup(crmNo, groupName);
};
 
window.getSelectedCrmGroup = function () {
const input = recipeRecipientSection.querySelector('input[name="selectedCrmGroup"]');
return { crmNo: input.value, crmGroupName: input.dataset.crmGroupName };
};
 
window.setSelectedCrmGroup = async function (crmNo, groupName) {
try {
const { data } = await CrmGroupStatus.getInfo(crmNo);
// status 응답 title 로 보강, 없으면 전달받은 그룹명
renderSelectedCrmGroup({
no: crmNo,
title: data.title || groupName,
count: data.count,
smsOptOutCount: data.smsOptOutCount,
status: data.status,
});
} catch (e) {
NCDSAlert({ message: '일시적인 오류가 발생하였습니다.<br>다시 시도해 주세요.', iconType: 'error' });
}
};
 
function updateCrmGroupData(status) {
CrmGroupStatus.toggleView(recipeRecipientSection, status);
}
 
function generateCrmGroupTag(groupName) {
const tag = document.createElement('span');
tag.className = 'ncua-tag ncua-tag--md';
tag.innerHTML = `
<span class="ncua-tag__text"></span>
<button type="button" class="ncua-tag__close"></button>
`;
tag.querySelector('.ncua-tag__text').textContent = groupName;
 
tag.querySelector('.ncua-tag__close').addEventListener('click', function () {
NCDSConfirm({
message: '이미 생성된 CRM 그룹입니다.',
subMessage: '그룹 선택 해제하면 레시피에서 다시 선택하기 어렵습니다.<br>선택 해제하시겠습니까?<br>해당 그룹은 "CRM 그룹 관리"에서 조회할 수 있습니다.',
callback: function (result) {
if (!result) return;
 
resetCrmGroupSelection();
tag.remove();
},
});
});
 
return tag;
}
 
function setSelectedMemberCount(totalCount, rejectCount) {
recipeRecipientSection.querySelector('.recipient-count-total').innerText = totalCount;
recipeRecipientSection.querySelector('.recipient-count-reject').innerText = rejectCount;
}
 
// 그룹 선택 상태
function markCrmGroupSelected() {
recipeRecipientSection.querySelector('.js-condition-guide-box')?.classList.add('display-none');
recipeRecipientSection.querySelector('.js-crm-group-selected-row')?.classList.remove('display-none');
}
 
// 그룹 미선택(초기화) 상태
function resetCrmGroupSelection() {
const input = recipeRecipientSection.querySelector('input[name="selectedCrmGroup"]');
input.value = '';
input.dataset.crmGroupName = '';
 
setSelectedMemberCount(0, 0);
 
recipeRecipientSection.querySelector('.js-condition-guide-box')?.classList.remove('display-none');
recipeRecipientSection.querySelector('.js-crm-group-selected-row')?.classList.add('display-none');
recipeRecipientSection.querySelector('.js-crm-group-extraction')?.classList.add('display-none');
recipeRecipientSection.querySelector('.js-recipient-count-text')?.classList.add('display-none');
}
 
function generateTargetInfoPayload() {
const input = document.querySelector('input[name="selectedCrmGroup"]');
return {
targetType: 'CRM',
targetCondition: {
crm: {
no: input?.value ? input.value : null,
name: input?.dataset.crmGroupName || ''
}
}
};
}
 
function validateHasMessagePoint() {
return messagePoint > 0;
}
 
function validateMessagePoint() {
const sendMethod = document.querySelector('input[name="sendMethod"]:checked')?.value ?? 'SMS';
const expectCount = getExpectTargetCount();
let requiredPoint;
switch (sendMethod) {
case 'SMS':
const messageLength = window.messageInput?.calculateLength(window.messageInput.getValue()) ?? 0;
requiredPoint = messageLength > 90 ? lmsPointEach * expectCount : smsPointEach * expectCount;
break;
case 'ALIMTALK':
requiredPoint = kakaoAlrimtalkPointEach * expectCount;
break;
case 'FRIENDTALK':
requiredPoint = kakaoFriendtalkPointEach * expectCount;
break;
default:
requiredPoint = 1;
}
return messagePoint >= requiredPoint;
}
 
function generateSendSchedulePayload() {
const sendCycle = document.querySelector('#sendCycle').value;
const [repeatStartDate, repeatEndDate] = [...document.querySelectorAll('input[name="repeatDate[]"]')].map(el => el.value);
const repeatHour = document.querySelector('select[name="repeatHour"]').value;
const repeatMinutes = document.querySelector('select[name="repeatMinutes"]').value;
const sendTime = `${repeatHour}:${repeatMinutes}:00`;
const hasNoEndDate = document.querySelector('input[name="hasNoEndDate"]').checked;
const repeatConfig = {
startDate: repeatStartDate,
endDate: hasNoEndDate ? null : repeatEndDate,
sendTime: sendTime,
};
if (sendCycle === 'WEEKLY') {
repeatConfig.repeatDays = [...document.querySelectorAll('input[name="repeatDays[]"]:checked')].map(el => parseInt(el.value));
} else if (sendCycle === 'MONTHLY') {
const repeatDatesContainer = document.querySelector('#repeatDatesContainer');
repeatConfig.repeatDays = [...repeatDatesContainer.querySelectorAll('.ncua-tag')].map(el => parseInt(el.dataset.day));
}
return { repeatType: sendCycle, repeatConfig: repeatConfig };
}
 
function validateRepeatTime() {
const [repeatStartDate, repeatEndDate] = [...document.querySelectorAll('input[name="repeatDate[]"]')].map(el => el.value);
const repeatHour = document.querySelector('select[name="repeatHour"]').value;
const repeatMinutes = document.querySelector('select[name="repeatMinutes"]').value;
const hasNoEndDate = document.querySelector('input[name="hasNoEndDate"]').checked;
 
if (!repeatStartDate || !repeatHour || !repeatMinutes || (!hasNoEndDate && !repeatEndDate)) {
return { result: false, message: '처리 중에 오류가 발생하여 실패되었습니다.', subMessage: '반복 발송 주기 입력을 확인해주세요.' };
}
 
const now = new Date();
const startDateTime = new Date(`${repeatStartDate} ${repeatHour}:${repeatMinutes}:00`);
const diffMinutes = (startDateTime - now) / (1000 * 60);
 
if (diffMinutes < 10) {
return { result: false, message: '처리 중에 오류가 발생하여 실패되었습니다.', subMessage: '반복 발송은 현재 시간으로부터 10분 이후로만 설정이 가능합니다.' };
}
 
const firstSchedule = getFirstScheduleDate(repeatStartDate, repeatHour, repeatMinutes);
const endDate = hasNoEndDate ? null : new Date(`${repeatEndDate} ${repeatHour}:${repeatMinutes}:00`);
if (firstSchedule === null || (endDate && firstSchedule > endDate)) {
return { result: false, message: '선택한 기간 내 발송 일정이 없습니다.<br>발송 설정을 변경해주세요.' };
}
 
return { result: true };
}
 
function getFirstScheduleDate(startDateStr, hour, minutes) {
const sendCycle = document.querySelector('#sendCycle').value;
const baseDate = new Date(`${startDateStr} ${hour}:${minutes}:00`);
 
if (sendCycle === 'WEEKLY') {
const repeatDays = [...document.querySelectorAll('input[name="repeatDays[]"]:checked')].map(el => parseInt(el.value));
if (repeatDays.length === 0) return null;
 
for (let i = 0; i < 7; i++) {
const candidate = new Date(baseDate);
candidate.setDate(candidate.getDate() + i);
const dayOfWeek = candidate.getDay() === 0 ? 7 : candidate.getDay();
if (repeatDays.includes(dayOfWeek)) {
return candidate;
}
}
} else if (sendCycle === 'MONTHLY') {
const repeatDatesContainer = document.querySelector('#repeatDatesContainer');
const repeatDays = [...repeatDatesContainer.querySelectorAll('.ncua-tag')].map(el => parseInt(el.dataset.day)).sort((a, b) => a - b);
if (repeatDays.length === 0) return null;
 
const startDay = baseDate.getDate();
for (const day of repeatDays) {
if (day >= startDay) {
const candidate = new Date(baseDate);
candidate.setDate(day);
if (candidate.getMonth() === baseDate.getMonth()) {
return candidate;
}
}
}
const nextMonth = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 1, parseInt(hour), parseInt(minutes), 0);
for (const day of repeatDays) {
const candidate = new Date(nextMonth);
candidate.setDate(day);
if (candidate.getMonth() === nextMonth.getMonth()) {
return candidate;
}
}
}
return baseDate;
}
 
function getSendData() {
const sendMethod = document.querySelector('input[name="sendMethod"]:checked').value;
const selectedGroupNo = document.querySelector('input[name="selectedCrmGroup"]')?.value || '';
const recipeName = document.querySelector('input[name="recipeName"]').value;
 
const sendData = {
recipeType: document.querySelector('input[name="recipeType"]').value,
campaignTitle: recipeName, // 필수, 기본값=레시피명
targetInfo: generateTargetInfoPayload(),
notificationServiceType: sendMethod,
...generateSendMethodRequest(),
sendType: 'REPEAT',
sendSchedule: generateSendSchedulePayload(),
enableAlternative: enableAlternative(sendMethod),
isOnlyAgreeSMSMember: document.querySelector('input[name="isOnlyAgreed"]').checked,
accessRouteType: 'MESSAGE',
targetExpectCount: getExpectTargetCount(),
};
 
// 친구톡은 캠페인명을 제목으로 사용
const friendtalkCampaignName = sendData.kakaoFriendtalkRequest?.campaignName?.trim();
if (friendtalkCampaignName) {
sendData.campaignTitle = friendtalkCampaignName;
}
 
// 재사용: 그룹번호 참조 / 신규: 조건+그룹명
if (selectedGroupNo) {
sendData.notificationTargetRepeatNo = parseInt(selectedGroupNo, 10);
} else {
sendData.repeatRuleTitle = recipeName; // 신규 모드 필수, 기본값=레시피명
const conditionSelect = document.querySelector('select.js-condition-value-select');
const productNos = (document.querySelector('.js-selected-product-nos')?.value ?? '')
.split(',').map(no => no.trim()).filter(Boolean);
sendData.recipeCondition = {
value: parseInt(conditionSelect?.value ?? '0', 10) || 0,
products: productNos.map(no => ({ no: parseInt(no, 10) })), // name 은 선택값(N)
};
}
 
return sendData;
}
 
function getPreviewData() {
const sendData = getSendData();
return {
sendMethod: sendData.notificationServiceType,
viewType: 'layer',
payload: JSON.stringify(sendData),
};
}
 
function openPreviewLayerPopup(openButton) {
$.ajax({
url: './layer_preview_mobile_send.php',
type: 'POST',
data: getPreviewData(),
success: function (data) {
ncds_layer_popup({ message: data, title: '발송전 미리보기', size: 'wide' });
},
complete: function () {
openButton.prop('disabled', false);
}
});
}
 
async function handleRecipeSendClick() {
const $button = $(this);
if ($button.prop('disabled')) return;
$button.prop('disabled', true);
 
document.querySelectorAll('.destructive').forEach(el => {
el.classList.remove('destructive');
el.querySelector('.ncua-input__destructive-icon-wrap')?.remove();
});
 
const fail = (message, subMessage) => {
NCDSAlert({ message, subMessage, iconType: 'error' });
$button.prop('disabled', false);
};
 
if (!messageConfig.hasCallNumber) {
return fail('발신번호가 없어 발송할 수 없습니다.', '설정에서 발신번호 등록 후 발송 시도를 해주세요.');
}
if (!validateHasMessagePoint()) {
return fail('메시지 포인트를 충전해 주세요');
}
if (!validateMessagePoint()) {
return fail('포인트가 부족하여 발송할 수 없습니다.', '발송 대상 인원보다 포인트가 적어 발송이 불가합니다. <br /> 충전 후 다시 시도해주세요.');
}
const result = validateSendMethodContent();
if (!result.success) {
return fail(result.message, result.subMessage);
}
const repeatResult = validateRepeatTime();
if (!repeatResult.result) {
document.querySelectorAll('input[name="repeatDate[]"]').forEach(item => NCDSValidator.highlight(item));
NCDSValidator.highlight(document.querySelector('select[name="repeatHour"]'));
NCDSValidator.highlight(document.querySelector('select[name="repeatMinutes"]'));
return fail(repeatResult.message, repeatResult.subMessage);
}
 
// 대체 메시지 미작성 시 본문 치환코드 변환해 자동 세팅
const sendMethod = document.querySelector('input[name="sendMethod"]:checked').value;
const mainContents = window.messageInput?.getValue() ?? '';
if (enableAlternative(sendMethod) && (window.alternativeMessageInput?.getValue() ?? '') === '') {
const allKeys = [
MobileMessageReplaceCode.CRM_RECIPE,
MobileMessageReplaceCode.MEMBER, MobileMessageReplaceCode.GOODS, MobileMessageReplaceCode.ORDER,
MobileMessageReplaceCode.PROMOTION, MobileMessageReplaceCode.BOARD, MobileMessageReplaceCode.REGULAR,
MobileMessageReplaceCode.PRESENT,
].flatMap(category => Object.keys(category));
const alternativeContents = mainContents.replace(/#\{([a-zA-Z_0-9]+)}/g, (match, key) =>
allKeys.includes(MobileMessageReplaceCode.normalizeReplaceKey(key)) ? `{${key}}` : match);
window.alternativeMessageInput.insertText(alternativeContents);
window.alternativePreview?.setContent(alternativeContents);
}
 
// 인원 0명이어도 컨펌 없이 바로 발송전 미리보기
openPreviewLayerPopup($button);
}
</script>
 
Admin/crm/layer_preview_mobile_send.php+31-32
변경 전layer_preview_mobile_send.php
<script src="<?=gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'script/slider/slick/slick.min.js')?>"></script>
<script>
function resolveReplaceCodeForPreview(content, isMain = true) {
const forceDefaultKeys = ['shopUrl'];
const replaceMap = {};
[
MobileMessageReplaceCode.MEMBER,
MobileMessageReplaceCode.GOODS,
MobileMessageReplaceCode.ORDER,
MobileMessageReplaceCode.PROMOTION,
MobileMessageReplaceCode.BOARD,
MobileMessageReplaceCode.REGULAR,
MobileMessageReplaceCode.PRESENT,
].forEach(category => {
Object.entries(category).forEach(([key, obj]) => {
if (replaceMap[key] !== undefined) return;
replaceMap[key] = (obj.sendMethod !== MobileMessageReplaceCode.SendMethod.AUTO || forceDefaultKeys.includes(key)) ? obj.default : '';
});
});
 
const shortLinks = isMain ? window.shortLinkKeys : window.shortLinkAlternativeKeys;
shortLinks.forEach((_, key) => {
replaceMap[key] = `쇼핑몰도메인/_s/a1B2c3D`;
});
 
return content.replace(/[#$]?\{([a-zA-Z0-9_]+)}/g, (match, key) => {
return replaceMap[key] !== undefined ? replaceMap[key] : match;
});
}
</script>
return;
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
const isOnlyAgreed = previewPayload.isOnlyAgreeSMSMember ?? true;
 
const targetCounts = getExpectTargetCountSeparated()
data: { mode: 'send', payload: JSON.stringify(previewPayload) },
dataType: 'json',
success: () => {
NCDSToast({
message: '메시지 발송이 정상적으로 완료되었습니다.',
color: 'success',
});
 
setTimeout(() => {
window.location.href = window.location.pathname;
}, 3000);
 
 
 
 
 
 
 
 
},
error: (xhr) => {
if (xhr.status === 400) {
 
변경 후layer_preview_mobile_send.php
<script src="<?=gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'script/slider/slick/slick.min.js')?>"></script>
<script>
function resolveReplaceCodeForPreview(content, isMain = true) {
return MobileMessageReplaceCode.resolvePreviewContent(content, {
shortLinkKeys: isMain ? window.shortLinkKeys : window.shortLinkAlternativeKeys,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
});
}
</script>
return;
}
 
// 레시피 신규 모드(기존 CRM 그룹 미선택)는 발송 시 그룹이 새로 생성되므로 20개 초과 차단
if (previewPayload.recipeType && !previewPayload.notificationTargetRepeatNo) {
try {
if (await CrmGroupStatus.getCount() >= CrmGroupStatus.MAX_COUNT) {
NCDSAlert({ message: 'CRM 그룹은 최대 ' + CrmGroupStatus.MAX_COUNT + '개까지 등록할 수 있습니다.', iconType: 'error' });
return;
}
} catch (e) {
NCDSAlert({ message: '일시적인 오류가 발생하였습니다.<br>다시 시도해 주세요.', iconType: 'error' });
return;
}
}
 
const isOnlyAgreed = previewPayload.isOnlyAgreeSMSMember ?? true;
 
const targetCounts = getExpectTargetCountSeparated()
data: { mode: 'send', payload: JSON.stringify(previewPayload) },
dataType: 'json',
success: () => {
const recipeType = document.querySelector('input[name="recipeType"]')?.value || '';
if (recipeType) {
window.parent.postMessage({
type: 'CLOSE_CRM_RECIPE_DRAWER',
reloadList: true,
toast: { message: '메시지 발송이 정상적으로 완료되었습니다.', color: 'success', autoClose: 2000 },
}, '*');
} else {
NCDSAlert({
message: '메시지 발송이 정상적으로 완료되었습니다.',
iconType: 'success',
callback: () => {
window.location.href = window.location.pathname;
},
});
}
},
error: (xhr) => {
if (xhr.status === 400) {
 
Admin/crm/mobile_send/layer_kakao_alimtalk_content_setting.php+3-1
변경 전layer_kakao_alimtalk_content_setting.php
kakaoAlimtalkTabSection.querySelector('button.load-main-contents').addEventListener('click', function() {
const message = kakaoAlimtalkTabSection.querySelector('textarea[name="messageContent"]').value;
const allKeys = [
 
MobileMessageReplaceCode.MEMBER,
MobileMessageReplaceCode.GOODS,
MobileMessageReplaceCode.ORDER,
].flatMap(category => Object.keys(category));
 
const convertedMessage = message.replace(/#\{([a-zA-Z_0-9]+)}/g, (match, key) => {
return allKeys.includes(key) ? `{${key}}` : '';
});
 
alternativeMessageInput.setValue('');
allowEmoji: false,
onInput: function(input) {
window.alternativePreview.setContent(resolveReplaceCode(input, false));
 
if (input === '') {
window.alternativeMessageInput.setHint(null);
setDefaultPreview(['ALTERNATIVE_TEXT']);
 
변경 후layer_kakao_alimtalk_content_setting.php
kakaoAlimtalkTabSection.querySelector('button.load-main-contents').addEventListener('click', function() {
const message = kakaoAlimtalkTabSection.querySelector('textarea[name="messageContent"]').value;
const allKeys = [
MobileMessageReplaceCode.CRM_RECIPE,
MobileMessageReplaceCode.MEMBER,
MobileMessageReplaceCode.GOODS,
MobileMessageReplaceCode.ORDER,
].flatMap(category => Object.keys(category));
 
const convertedMessage = message.replace(/#\{([a-zA-Z_0-9]+)}/g, (match, key) => {
return allKeys.includes(MobileMessageReplaceCode.normalizeReplaceKey(key)) ? `{${key}}` : '';
});
 
alternativeMessageInput.setValue('');
allowEmoji: false,
onInput: function(input) {
window.alternativePreview.setContent(resolveReplaceCode(input, false));
MobileMessageReplaceCode.updateExpireCodeGuide('#replaceCodeAlternativeContainer', input);
if (input === '') {
window.alternativeMessageInput.setHint(null);
setDefaultPreview(['ALTERNATIVE_TEXT']);
 
Admin/crm/mobile_send/layer_kakao_friendtalk_content_setting.php+303-32
변경 전layer_kakao_friendtalk_content_setting.php
<div class="ncua-input ncua-input--sm">
<div class="ncua-input__content">
<div class="ncua-input__field ncua-input__field--sm">
<input name="friendtalkCampaignName" type="text" value="" maxlength="20" placeholder="캠페인명을 입력해주세요 (최대 20자)" />
</div>
</div>
</div>
</div>
</div>
</div>
 
 
 
</div>
 
<!-- 캠페인 이미지 -->
</ul>
</div>
<!-- 친구톡 안내 -->
<details class="ncua-accordion ncua-accordion--gray send-method-component" data-target="MAIN" open>
<summary>친구톡 안내</summary>
<div class="ncua-accordion__content has-dot">
<ul>
<li>예약 및 반복 발송 시 선택한 CRM 그룹의 예상 발송 인원은 실제 발송 시점의 인원과 상이할 수 있습니다.</li>
<li>수신 대상 중 카카오톡 채널 친구가 아닌 회원에게는 캠페인 발송에 실패하며 발송 실패 시 차감된 포인트는 반환됩니다.</li>
<li>수신 번호가 중복된 경우 가장 먼저 조회된 회원에게만 발송됩니다. 중복 발송 실패 시 차감된 포인트는 반환됩니다.</li>
<li>대체 발송의 경우 <a href="../crm/message_config.php">메시지 설정</a>에서 변경이 가능합니다.</li>
</ul>
</div>
</details>
<!-- SMS/LMS 대체 발송 안내 -->
<details class="ncua-accordion ncua-accordion--gray send-method-component" data-target="ALTERNATIVE" open>
<summary>SMS/LMS 발송 안내</summary>
<div class="ncua-accordion__content has-dot">
<ul>
const kakaoFriendtalkCarouselSection = document.querySelector('.js-kakao-friendtalk-carousel');
const chipContainer = document.querySelector('[data-role="chip-container"]');
 
 
 
 
 
 
 
const linkPlatformType = '<?= $linkPlatformType ?>';
const campaignTypeUnitPoint = <?= json_encode($campaignMessageTypeUnitPoints, JSON_UNESCAPED_UNICODE) ?>;
 
 
 
 
 
const MAX_CAMPAIGN_IMAGE_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const MIN_CAMPAIGN_IMAGE_DIMENSION = {minWidth: 500, minHeight: 250};
const MIN_ITEM_FIRST_IMAGE_DIMENSION = {minWidth: 500, minHeight: 250}; // 첫번째 아이템 2:1 비율
if (!carousel) return;
 
// 타이틀 (header)
kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignTitle"]').value = carousel.header;
 
 
 
 
// 이미지 URL (hidden input)
kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignImageUrl"]').value = carousel.imageUrl;
 
// 메시지 (message)
window.messageInput.setValue(carousel.message);
 
 
 
 
 
 
// 버튼/쿠폰 영역 렌더링
kakaoFriendtalkButtonSection.querySelectorAll('.ncua-table').forEach(t => t.remove());
}
};
}),
replaceKeys: extractReplaceKeys(this.carousels.filter(c => !c.isDeleted).map(c => c.message).join(' '), true),
trackingLink: this.carousels.filter(c => !c.isDeleted).flatMap(carousel => {
const shortLinks = [];
carousel.shortLinkKeys.forEach((url, key) => {
kakaoFriendtalkTabSection.querySelectorAll('input[name="campaignMessageType"]').forEach(radio => {
radio.addEventListener('change', function () {
clearComponent(radio.value);
 
 
updateTabComponent(kakaoFriendtalkTabSection, 'MAIN');
renderMessageInput(radio.value);
updateCampaignUnitPoint(radio.value);
updateCampaignTypeComponent(radio.value);
updateDefaultPreview(radio.value);
 
});
});
 
if (e.target.value === '') {
setDefaultPreview(['FRIENDTALK_TITLE']);
}
 
 
 
});
 
kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignTitle"]').addEventListener('input', function(e) {
kakaoFriendtalkTabSection.querySelector('button.load-main-contents').addEventListener('click', function() {
const message = messageInput.getValue();
const allKeys = [
 
MobileMessageReplaceCode.MEMBER,
MobileMessageReplaceCode.GOODS,
MobileMessageReplaceCode.ORDER,
].flatMap(category => Object.keys(category));
 
const convertedMessage = message.replace(/#\{([a-zA-Z_0-9]+)}/g, (match, key) => {
return allKeys.includes(key) ? `{${key}}` : '';
});
 
alternativeMessageInput.setValue('');
 
function renderMessageInput(campaignMessageType) {
window.messageInput.clear();
 
window.preview.reset();
window.preview.setMessageType(campaignMessageType)
 
}
 
function clearComponent(campaignMessageType) {
// 캠페인 명, 타이틀 초기화
kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignName"]').value = '';
kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignHeader"]').value = '';
kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignTitle"]').value = '';
// 캠페인 이미지 초기화
 
// 아이템 리스트 초기화
kakaoFriendtalkItemListSection.querySelector('tbody').innerHTML = '';
 
 
// 성과분석 숏링크 초기화
kakaoFriendtalkTabSection.querySelector('input[name="shortLink"]').value = '';
const itemListTableBody = kakaoFriendtalkItemListSection.querySelector('tbody');
 
let itemListDatas = [];
itemListTableBody.querySelectorAll('tr').forEach(row => {
const title = row.querySelector('input[name="itemTitle"]').value;
const imageUrl = row.querySelector('input[name="itemImageUrl"]')?.value ?? '';
 
 
 
 
itemListDatas.push({title: title, desc: '', image: imageUrl});
});
 
newRow.querySelector('[data-action="remove-friendtalk-item"]')?.addEventListener('click', function() {
if (itemListTableBody.querySelectorAll('tr').length > 1) {
newRow.remove();
 
updateItemListPreview();
}
});
 
initItemImageFileInput(`campaign-item-image-file-input-container-${itemKey}`, itemKey, isFirst);
}
 
function appendCouponRow() {
<div class="ncua-table ncua-table--horizontal add-coupon-button-table" >
<table id="kakaoFriendTalkcouponTable">
<colgroup>
<col width="80px"/>
<col/>
<col width="88px"/>
</colgroup>
<thead>
<tr>
<th><div class="ncua-align-center">타입</div></th>
<th class="ncua-required"><div>쿠폰 선택</div></th>
<th><div class="ncua-align-center">삭제</div></th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="ncua-align-center">쿠폰</div>
</td>
<td>
<div class="ncua-flex-column ncua-gap-8">
<button id="selectCoupon" type="button" class="ncua-btn ncua-btn--xs ncua-btn--secondary-gray">쿠폰 선택</button>
<div class="ncua-table ncua-table--horizontal add-link-button-table">
<table id="linkButtonTable">
<colgroup>
<col width="80px"/>
<col/>
<col/>
<col width="88px"/>
</colgroup>
<thead>
<tr>
<th><div class="ncua-align-center">타입</div></th>
<th class="ncua-required"><div>버튼명</div></th>
${isAppLink() ? `<th class="ncua-required"><div>APP 링크</div></th>` : `<th class="ncua-required"><div data-tooltip-seq="008">WEB 링크</div></th>`}
<th><div class="ncua-align-center">삭제</div></th>
const uniqueKey = `link-button-name-${Date.now()}`;
return `
<tr>
<td><div class="ncua-align-center">기본</div></td>
<td>
<div>
<div class="ncua-input ncua-input--xs ncua-input-full-width button-name-input">
</div>
<div class="ncua-input__content">
<div class="ncua-input__field ncua-input__field--xs">
<input name="linkButtonIOSUrl" type="text" value="" maxlength="800" placeholder="https://commerce.com/page/event01" />
</div>
</div>
</div>
</div>
<div class="ncua-input__content">
<div class="ncua-input__field ncua-input__field--xs">
<input name="linkButtonAOSUrl" type="text" value="" maxlength="800" placeholder="https://commerce.com/page/event01" />
</div>
</div>
</div>
<span class="ncua-hint-text ncua-input__hint-text">외부 링크 입력 시 캠페인 통계가 집계되지 않습니다.</span>
</div>
</div>
</td>`
<input name="linkButtonWebUrl" type="text" value="" maxlength="800" placeholder="" />
</div>
</div>
<span class="ncua-hint-text ncua-input__hint-text">외부 링크 입력 시 캠페인 통계가 집계되지 않습니다.</span>
</div>
</div>
</td>`;
}
 
function getItemRowTemplate(isFirst = true) {
const uniqueKey = Date.now().toString();
 
return `
<tr data-item-key="${uniqueKey}">
<input name="itemWebLink" type="text" value="" placeholder="" />
</div>
</div>
<span class="ncua-hint-text ncua-input__hint-text">외부 링크 입력 시 캠페인 통계가 집계되지 않습니다.</span>
</div>
</div>
</td>
// 캐러셀 피드 초기화
carouselState.init();
 
 
 
 
 
 
 
 
 
// 본문 메시지
window.messageInput = GodoUIModule.render({
type: 'MessageInput',
title: '내용 입력',
maxLength: 1300,
useBytes: false,
 
onInput: function(input) {
window.preview.setContent(resolveReplaceCode(input));
 
if (input === '') {
 
setDefaultPreview(['TEXT']);
 
 
}
}
});
maxLength: 2000,
useBytes: true,
allowEmoji: false,
 
onInput: function(input) {
window.alternativePreview.setContent(resolveReplaceCode(input, false));
 
if (input === '') {
window.alternativeMessageInput.setHint(null);
setDefaultPreview(['ALTERNATIVE_TEXT']);
window.chipSelector = GodoUIModule.render({
type: 'ChipSelector',
target: '#replaceCodeContainer',
title: '사용 가능한 변수',
tooltipSeq: '003',
categories: buildReplaceCodeCategories('#'),
 
onSelect: (variable) => {
 
if (getCampaignMessageType() === 'WIDE_ITEM_LIST') {
const headerInput = kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignHeader"]');
headerInput.value = headerInput.value + variable;
headerInput.dispatchEvent(new Event('input', { bubbles: true }));
} else {
window.messageInput.insertText(variable);
}
},
onCategoryChange: (category) => {
window.alternativeChipSelector = GodoUIModule.render({
type: 'ChipSelector',
target: '#replaceCodeAlternativeContainer',
title: '사용 가능한 변수',
tooltipSeq: '003',
categories: buildReplaceCodeCategories(),
 
onSelect: (variable) => {
window.alternativeMessageInput.insertText(variable);
},
onCategoryChange: (category) => {
if (category === 'MEMBER') {
});
 
setDefaultPreview(['ALTERNATIVE_TEXT', 'TEXT']);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
}
 
// 친구톡 아이템 이미지 업로드 초기화
onChange: async (newFiles) => {
const file = newFiles[0];
 
 
 
 
// 하이라이트 제거
container.querySelector('.ncua-image-file-input').classList.remove('destructive');
 
 
변경 후layer_kakao_friendtalk_content_setting.php
<div class="ncua-input ncua-input--sm">
<div class="ncua-input__content">
<div class="ncua-input__field ncua-input__field--sm">
<input name="friendtalkCampaignName" type="text" value="<?= gd_htmlspecialchars($campaignName ?? '') ?>" maxlength="20" placeholder="캠페인명을 입력해주세요 (최대 20자)" />
</div>
</div>
</div>
</div>
</div>
</div>
<?php if ($isRecipeContext): ?>
<div class="ncua-hint-text js-wide-item-point-hint display-none"></div>
<?php endif; ?>
</div>
 
<!-- 캠페인 이미지 -->
</ul>
</div>
<!-- 친구톡 안내 -->
<details class="ncua-accordion ncua-accordion--gray send-method-component" data-target="MAIN" <?= !$isRecipeContext ? 'open' : '' ?>>
<summary>친구톡 안내</summary>
<div class="ncua-accordion__content has-dot">
<ul>
<li>예약 및 반복 발송 시 선택한 CRM 그룹의 예상 발송 인원은 실제 발송 시점의 인원과 상이할 수 있습니다.</li>
<li>수신 대상 중 카카오톡 채널 친구가 아닌 회원에게는 캠페인 발송에 실패하며 발송 실패 시 차감된 포인트는 반환됩니다.</li>
<li>수신 번호가 중복된 경우 가장 먼저 조회된 회원에게만 발송됩니다. 중복 발송 실패 시 차감된 포인트는 반환됩니다.</li>
<li>대체 발송의 경우 <a href="../crm/message_config.php" target="_top">메시지 설정</a>에서 변경이 가능합니다.</li>
</ul>
</div>
</details>
<!-- SMS/LMS 대체 발송 안내 -->
<details class="ncua-accordion ncua-accordion--gray send-method-component" data-target="ALTERNATIVE" <?= !$isRecipeContext ? 'open' : '' ?>>
<summary>SMS/LMS 발송 안내</summary>
<div class="ncua-accordion__content has-dot">
<ul>
const kakaoFriendtalkCarouselSection = document.querySelector('.js-kakao-friendtalk-carousel');
const chipContainer = document.querySelector('[data-role="chip-container"]');
 
// 아이템 리스트 행별 이미지 업로더 (itemKey → ImageFileInput)
const itemImageFileInputs = new Map();
// 동기 루프에서 itemKey 충돌 방지용 시퀀스
let itemRowKeySeq = 0;
 
const isRecipeContext = <?= $isRecipeContext ? 'true' : 'false' ?>;
const linkPlatformType = '<?= $linkPlatformType ?>';
const campaignTypeUnitPoint = <?= json_encode($campaignMessageTypeUnitPoints, JSON_UNESCAPED_UNICODE) ?>;
 
function friendtalkUnitPointHint(campaignMessageType) {
return `친구톡 건당 ${parseFloat(campaignTypeUnitPoint[campaignMessageType]).toFixed(1)}포인트 차감`;
}
const friendtalkRequest = <?= json_encode($friendtalkRequest ?? [], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>;
const MAX_CAMPAIGN_IMAGE_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const MIN_CAMPAIGN_IMAGE_DIMENSION = {minWidth: 500, minHeight: 250};
const MIN_ITEM_FIRST_IMAGE_DIMENSION = {minWidth: 500, minHeight: 250}; // 첫번째 아이템 2:1 비율
if (!carousel) return;
 
// 타이틀 (header)
const campaignTitleInput = kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignTitle"]');
campaignTitleInput.value = carousel.header;
const titleCountEl = kakaoFriendtalkTabSection.querySelector('.ncua-input__field-text-count[data-charcount-text="title"] .ncua-input__field-text-count-current');
if (titleCountEl) titleCountEl.textContent = campaignTitleInput.value.length;
 
// 이미지 URL (hidden input)
kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignImageUrl"]').value = carousel.imageUrl;
 
// 메시지 (message)
window.messageInput.setValue(carousel.message);
<?php if ($isRecipeContext): ?>
// 레시피: 슬라이드 전환은 setValue 라 onInput 미발생 → 포인트 힌트·안내문구 직접 반영
window.messageInput.setHint(carousel.message ? friendtalkUnitPointHint(getCampaignMessageType()) : null);
MobileMessageReplaceCode.updateExpireCodeGuide('#replaceCodeContainer', carousel.message);
<?php endif; ?>
 
// 버튼/쿠폰 영역 렌더링
kakaoFriendtalkButtonSection.querySelectorAll('.ncua-table').forEach(t => t.remove());
}
};
}),
replaceKeys: extractReplaceKeys(this.carousels.filter(c => !c.isDeleted).map(c => `${c.header || ''} ${c.message || ''}`).join(' '), true),
trackingLink: this.carousels.filter(c => !c.isDeleted).flatMap(carousel => {
const shortLinks = [];
carousel.shortLinkKeys.forEach((url, key) => {
kakaoFriendtalkTabSection.querySelectorAll('input[name="campaignMessageType"]').forEach(radio => {
radio.addEventListener('change', function () {
clearComponent(radio.value);
// 슬라이드 재구성 전에 레시피 캐러셀 기본값 선반영
preloadRecipeCarouselsIfNeeded(radio.value);
updateTabComponent(kakaoFriendtalkTabSection, 'MAIN');
renderMessageInput(radio.value);
updateCampaignUnitPoint(radio.value);
updateCampaignTypeComponent(radio.value);
updateDefaultPreview(radio.value);
applyFriendtalkRecipeDefaults(radio.value);
});
});
 
if (e.target.value === '') {
setDefaultPreview(['FRIENDTALK_TITLE']);
}
<?php if ($isRecipeContext): ?>
kakaoFriendtalkTabSection.querySelector('.js-wide-item-point-hint')?.classList.toggle('display-none', e.target.value === '');
<?php endif; ?>
});
 
kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignTitle"]').addEventListener('input', function(e) {
kakaoFriendtalkTabSection.querySelector('button.load-main-contents').addEventListener('click', function() {
const message = messageInput.getValue();
const allKeys = [
MobileMessageReplaceCode.CRM_RECIPE,
MobileMessageReplaceCode.MEMBER,
MobileMessageReplaceCode.GOODS,
MobileMessageReplaceCode.ORDER,
].flatMap(category => Object.keys(category));
 
const convertedMessage = message.replace(/#\{([a-zA-Z_0-9]+)}/g, (match, key) => {
return allKeys.includes(MobileMessageReplaceCode.normalizeReplaceKey(key)) ? `{${key}}` : '';
});
 
alternativeMessageInput.setValue('');
 
function renderMessageInput(campaignMessageType) {
window.messageInput.clear();
window.messageInput.setHint(null);
window.preview.reset();
window.preview.setMessageType(campaignMessageType)
 
}
 
function clearComponent(campaignMessageType) {
// 타이틀 초기화 (캠페인 명은 캠페인 레벨 값이라 타입 전환 시 유지)
 
kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignHeader"]').value = '';
kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignTitle"]').value = '';
// 캠페인 이미지 초기화
 
// 아이템 리스트 초기화
kakaoFriendtalkItemListSection.querySelector('tbody').innerHTML = '';
itemImageFileInputs.clear();
 
// 성과분석 숏링크 초기화
kakaoFriendtalkTabSection.querySelector('input[name="shortLink"]').value = '';
const itemListTableBody = kakaoFriendtalkItemListSection.querySelector('tbody');
 
let itemListDatas = [];
itemListTableBody.querySelectorAll('tr').forEach((row, idx) => {
const title = row.querySelector('input[name="itemTitle"]').value;
let imageUrl = row.querySelector('input[name="itemImageUrl"]')?.value ?? '';
// 첫 아이템은 이미지가 비면 기본 placeholder 표시
if (idx === 0 && !imageUrl) {
imageUrl = '<?= PATH_ADMIN_GD_SHARE ?>ncds/image/temp-preview02.png';
}
itemListDatas.push({title: title, desc: '', image: imageUrl});
});
 
newRow.querySelector('[data-action="remove-friendtalk-item"]')?.addEventListener('click', function() {
if (itemListTableBody.querySelectorAll('tr').length > 1) {
newRow.remove();
itemImageFileInputs.delete(itemKey);
updateItemListPreview();
}
});
 
itemImageFileInputs.set(itemKey, initItemImageFileInput(`campaign-item-image-file-input-container-${itemKey}`, itemKey, isFirst));
}
 
function appendCouponRow() {
<div class="ncua-table ncua-table--horizontal add-coupon-button-table" >
<table id="kakaoFriendTalkcouponTable">
<colgroup>
${isRecipeContext ? '' : '<col width="80px"/>'}
<col/>
<col width="88px"/>
</colgroup>
<thead>
<tr>
${isRecipeContext ? '' : '<th><div class="ncua-align-center">타입</div></th>'}
<th class="ncua-required"><div>쿠폰 선택</div></th>
<th><div class="ncua-align-center">삭제</div></th>
</tr>
</thead>
<tbody>
<tr>
${isRecipeContext ? '' : `<td>
<div class="ncua-align-center">쿠폰</div>
</td>`}
<td>
<div class="ncua-flex-column ncua-gap-8">
<button id="selectCoupon" type="button" class="ncua-btn ncua-btn--xs ncua-btn--secondary-gray">쿠폰 선택</button>
<div class="ncua-table ncua-table--horizontal add-link-button-table">
<table id="linkButtonTable">
<colgroup>
${isRecipeContext ? '' : '<col width="80px"/>'}
<col/>
<col/>
<col width="88px"/>
</colgroup>
<thead>
<tr>
${isRecipeContext ? '' : '<th><div class="ncua-align-center">타입</div></th>'}
<th class="ncua-required"><div>버튼명</div></th>
${isAppLink() ? `<th class="ncua-required"><div>APP 링크</div></th>` : `<th class="ncua-required"><div data-tooltip-seq="008">WEB 링크</div></th>`}
<th><div class="ncua-align-center">삭제</div></th>
const uniqueKey = `link-button-name-${Date.now()}`;
return `
<tr>
${isRecipeContext ? '' : '<td><div class="ncua-align-center">기본</div></td>'}
<td>
<div>
<div class="ncua-input ncua-input--xs ncua-input-full-width button-name-input">
</div>
<div class="ncua-input__content">
<div class="ncua-input__field ncua-input__field--xs">
<input name="linkButtonIOSUrl" type="text" value="" maxlength="800" placeholder="${isRecipeContext ? '' : 'https://commerce.com/page/event01'}" />
</div>
</div>
</div>
</div>
<div class="ncua-input__content">
<div class="ncua-input__field ncua-input__field--xs">
<input name="linkButtonAOSUrl" type="text" value="" maxlength="800" placeholder="${isRecipeContext ? '' : 'https://commerce.com/page/event01'}" />
</div>
</div>
</div>
${isRecipeContext ? '' : '<span class="ncua-hint-text ncua-input__hint-text">외부 링크 입력 시 캠페인 통계가 집계되지 않습니다.</span>'}
</div>
</div>
</td>`
<input name="linkButtonWebUrl" type="text" value="" maxlength="800" placeholder="" />
</div>
</div>
${isRecipeContext ? '' : '<span class="ncua-hint-text ncua-input__hint-text">외부 링크 입력 시 캠페인 통계가 집계되지 않습니다.</span>'}
</div>
</div>
</td>`;
}
 
function getItemRowTemplate(isFirst = true) {
const uniqueKey = `${Date.now()}_${itemRowKeySeq++}`;
 
return `
<tr data-item-key="${uniqueKey}">
<input name="itemWebLink" type="text" value="" placeholder="" />
</div>
</div>
${isRecipeContext ? '' : '<span class="ncua-hint-text ncua-input__hint-text">외부 링크 입력 시 캠페인 통계가 집계되지 않습니다.</span>'}
</div>
</div>
</td>
// 캐러셀 피드 초기화
carouselState.init();
 
const initialTextContent = <?= json_encode($initialTextContent ?? '', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>;
const initialAlternativeMessage = <?= json_encode($initialAlternativeMessage ?? '', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>;
 
if (isRecipeContext) {
const wideItemPointHint = kakaoFriendtalkTabSection.querySelector('.js-wide-item-point-hint');
if (wideItemPointHint) wideItemPointHint.textContent = friendtalkUnitPointHint('WIDE_ITEM_LIST');
}
 
// 본문 메시지
window.messageInput = GodoUIModule.render({
type: 'MessageInput',
title: '내용 입력',
maxLength: 1300,
useBytes: false,
initialValue: initialTextContent,
onInput: function(input) {
window.preview.setContent(resolveReplaceCode(input));
MobileMessageReplaceCode.updateExpireCodeGuide('#replaceCodeContainer', input);
if (input === '') {
window.messageInput.setHint(null);
setDefaultPreview(['TEXT']);
} else if (isRecipeContext) {
window.messageInput.setHint(friendtalkUnitPointHint(getCampaignMessageType()));
}
}
});
maxLength: 2000,
useBytes: true,
allowEmoji: false,
initialValue: initialAlternativeMessage,
onInput: function(input) {
window.alternativePreview.setContent(resolveReplaceCode(input, false));
MobileMessageReplaceCode.updateExpireCodeGuide('#replaceCodeAlternativeContainer', input);
if (input === '') {
window.alternativeMessageInput.setHint(null);
setDefaultPreview(['ALTERNATIVE_TEXT']);
window.chipSelector = GodoUIModule.render({
type: 'ChipSelector',
target: '#replaceCodeContainer',
title: isRecipeContext ? '추천 변수' : '사용 가능한 변수',
tooltipSeq: '003',
hideCategorySelector: isRecipeContext,
categories: isRecipeContext ? buildRecipeReplaceCodeCategories('#') : buildReplaceCodeCategories('#'),
onSelect: (variable) => {
const resolved = MobileMessageReplaceCode.resolveRecipeExpireInsertKey(variable, isRecipeContext);
if (getCampaignMessageType() === 'WIDE_ITEM_LIST') {
const headerInput = kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignHeader"]');
headerInput.value = headerInput.value + resolved;
headerInput.dispatchEvent(new Event('input', { bubbles: true }));
} else {
window.messageInput.insertText(resolved);
}
},
onCategoryChange: (category) => {
window.alternativeChipSelector = GodoUIModule.render({
type: 'ChipSelector',
target: '#replaceCodeAlternativeContainer',
title: isRecipeContext ? '추천 변수' : '사용 가능한 변수',
tooltipSeq: '003',
hideCategorySelector: isRecipeContext,
categories: isRecipeContext ? buildRecipeReplaceCodeCategories() : buildReplaceCodeCategories(),
onSelect: (variable) => {
window.alternativeMessageInput.insertText(MobileMessageReplaceCode.resolveRecipeExpireInsertKey(variable, isRecipeContext));
},
onCategoryChange: (category) => {
if (category === 'MEMBER') {
});
 
setDefaultPreview(['ALTERNATIVE_TEXT', 'TEXT']);
 
if (initialAlternativeMessage) {
window.alternativePreview.setContent(resolveReplaceCode(initialAlternativeMessage, false));
MobileMessageReplaceCode.updateExpireCodeGuide('#replaceCodeAlternativeContainer', initialAlternativeMessage);
}
 
applyFriendtalkRecipeDefaults(getCampaignMessageType());
}
 
function applyFriendtalkRecipeDefaults(campaignMessageType) {
if (!friendtalkRequest || Object.keys(friendtalkRequest).length === 0) return;
if (!window.messageInput) return;
 
const typeKeyMap = {
TEXT: 'text',
IMAGE: 'image',
WIDE_IMAGE: 'wideImage',
WIDE_ITEM_LIST: 'wideItemList',
CAROUSEL_FEED: 'carousel',
};
const data = friendtalkRequest[typeKeyMap[campaignMessageType]];
if (!data) return;
 
if (data.content) {
window.messageInput.setValue('');
window.messageInput.insertText(data.content);
}
 
if (data.header) {
const headerInput = kakaoFriendtalkTabSection.querySelector('input[name="friendtalkCampaignHeader"]');
if (headerInput) {
headerInput.value = data.header;
headerInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
 
if (Array.isArray(data.buttons) && data.buttons.length > 0) {
applyFriendtalkRecipeButtons(data.buttons);
}
 
if (Array.isArray(data.items) && data.items.length > 0) {
applyFriendtalkRecipeItems(data.items);
}
 
if (Array.isArray(data.carousels) && data.carousels.length > 0) {
applyFriendtalkRecipeCarousels(data.carousels);
}
}
 
// 레시피 기본 캐러셀 응답 → carouselState.carousels 형태로 변환
function mapRecipeCarousels(carousels) {
if (!Array.isArray(carousels)) {
carousels = [];
}
const mapped = carousels.map(function (carousel) {
const base = carouselState.createEmptyCarousel();
const attachment = carousel.attachment || {};
const image = attachment.image || {};
const buttons = Array.isArray(attachment.buttons) && attachment.buttons.length > 0
? attachment.buttons.map(function (btn) {
// url 은 nullable 이라 객체로 보정
let url = btn?.url;
if (!url || typeof url !== 'object') {
url = { mobileUrl: '', iosUrl: '', aosUrl: '' };
}
return {
name: btn?.name ?? '',
url: url
};
})
: base.buttons;
 
return {
...base,
header: carousel.header ?? '',
message: carousel.message ?? '',
imageUrl: image.imageUrl ?? '',
imageLink: image.imageLink ?? '',
buttons: buttons,
// 응답 쿠폰엔 표시 메타가 없어 운영자 선택으로 남김
coupon: null
};
});
 
// 최소 캐러셀 수 보장
while (mapped.length < carouselState.MIN_CAROUSELS) {
mapped.push(carouselState.createEmptyCarousel());
}
return mapped;
}
 
// 슬라이드 재구성보다 먼저 carouselState 를 채워 빈 데이터로 덮어쓰는 경합 차단
function preloadRecipeCarouselsIfNeeded(campaignMessageType) {
if (campaignMessageType !== 'CAROUSEL_FEED' || !window.carouselState) return;
const carousels = friendtalkRequest && friendtalkRequest.carousel && friendtalkRequest.carousel.carousels;
if (!Array.isArray(carousels) || carousels.length === 0) return;
carouselState.carousels = mapRecipeCarousels(carousels);
carouselState.activeIndex = 0;
}
 
function applyFriendtalkRecipeCarousels(carousels) {
if (!window.carouselState) return;
carouselState.carousels = mapRecipeCarousels(carousels);
carouselState.activeIndex = 0;
carouselState.renderTabs();
carouselState.renderCurrentCarouselForm();
 
// 미리보기는 input 이벤트로만 동기화되므로 레시피 기본값은 모든 슬라이드에 직접 push 한다
carouselState.carousels.forEach(function (carousel, index) {
if (carousel.isDeleted) return;
window.preview.setSlideIndex(index);
 
// 제목·본문은 치환코드 치환 후 반영, 비면 기본 placeholder
if (carousel.header) {
window.preview.setContentTitle(resolveReplaceCode(carousel.header));
} else {
setDefaultPreview(['FRIENDTALK_CAROUSEL_TITLE']);
}
 
if (carousel.message) {
window.preview.setContent(resolveReplaceCode(carousel.message));
} else {
setDefaultPreview(['TEXT']);
}
 
// 이미지가 없으면 기본 placeholder 표시
if (carousel.imageUrl) {
window.preview.setImage(carousel.imageUrl);
} else {
setDefaultPreview(['FRIENDTALK_IMAGE']);
}
 
const previewButtons = (carousel.buttons || [])
.filter(function (button) { return button && button.name; })
.map(function (button) { return { text: button.name }; });
if (previewButtons.length > 0) {
window.preview.setButtons(previewButtons);
}
});
window.preview.setSlideIndex(carouselState.activeIndex);
}
 
function applyFriendtalkRecipeButtons(buttons) {
const isMobileApp = linkPlatformType === 'MOBILE_APP';
 
buttons.forEach(function (btn) {
appendLinkRow();
const row = kakaoFriendtalkButtonSection.querySelector('#linkButtonTableBody tr:last-child');
if (!row) return;
 
const nameInput = row.querySelector('.ncua-link-button-name');
if (nameInput && btn?.name) {
nameInput.value = btn.name;
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
}
 
const url = btn?.url;
if (!url || typeof url !== 'object') return;
 
const webUrlInput = row.querySelector('input[name="linkButtonWebUrl"]');
const iosUrlInput = row.querySelector('input[name="linkButtonIOSUrl"]');
const aosUrlInput = row.querySelector('input[name="linkButtonAOSUrl"]');
if (webUrlInput) webUrlInput.value = isMobileApp ? '' : (url.mobileUrl ?? '');
if (iosUrlInput) iosUrlInput.value = !isMobileApp ? '' : (url.iosUrl ?? '');
if (aosUrlInput) aosUrlInput.value = !isMobileApp ? '' : (url.aosUrl ?? '');
});
 
updateLinkPreview();
}
 
// 레시피 기본 이미지를 해당 행 썸네일로 복원
function restoreItemImagePreview(itemKey, imageUrl) {
if (!imageUrl) return;
const instance = itemImageFileInputs.get(itemKey);
if (!instance) return;
instance.setFiles([{ fileName: imageUrl.split('/').pop(), fileImageUrl: imageUrl }]);
instance.renderImagePreviews();
}
 
function applyFriendtalkRecipeItems(items) {
const itemListTableBody = kakaoFriendtalkItemListSection.querySelector('tbody');
 
// 아이템 수만큼 행 확보 (첫 행은 기존 생성, 최대 4개)
items.forEach(function (_, idx) {
if (idx === 0) return;
if (itemListTableBody.querySelectorAll('tr').length >= 4) return;
appendItemListRow(false);
});
 
const rows = itemListTableBody.querySelectorAll('tr');
items.forEach(function (item, idx) {
const row = rows[idx];
if (!row) return;
 
const titleInput = row.querySelector('input[name="itemTitle"]');
if (titleInput && item?.title) {
titleInput.value = item.title;
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
}
 
const imageUrlInput = row.querySelector('input[name="itemImageUrl"]');
if (imageUrlInput && item?.imageUrl) {
imageUrlInput.value = item.imageUrl;
restoreItemImagePreview(row.dataset.itemKey, item.imageUrl);
}
 
const webLinkInput = row.querySelector('input[name="itemWebLink"]');
if (webLinkInput && item?.url) {
webLinkInput.value = item.url;
webLinkInput.dispatchEvent(new Event('input', { bubbles: true }));
}
});
 
// 타이틀 이벤트가 먼저 미리보기를 갱신하므로 이미지 반영 후 한 번 더 갱신
updateItemListPreview();
}
 
// 친구톡 아이템 이미지 업로드 초기화
onChange: async (newFiles) => {
const file = newFiles[0];
 
// setFiles() 복원 호출이면 건너뛰기
if (file && !file.size) return;
 
// 하이라이트 제거
container.querySelector('.ncua-image-file-input').classList.remove('destructive');
 
 
Admin/crm/mobile_send/layer_myapp_push_content_setting.php+67-30
변경 전layer_myapp_push_content_setting.php
<div class="myapp-condition-setting send-method-component" data-target="MAIN">
<p class="ncua-card__body-title--xs">조건 설정</p>
<ul class="myapp-condition-setting-notice">
<li class="ncua-notice-info">앱 설치자로 발송 시 로그인 상태와 상관없이 앱을 설치한 모든 유저에게 푸시가 발송됩니다.</li>
<li class="ncua-notice-info">가입 회원에게 발송 시 선택된 회원의 계정이 로그인 되어 있는 모든 앱에 푸시가 발송됩니다.<br />다만, 발송 OS, 앱 설치 여부, 앱 알림 수신 동의 여부에 따라 수신 대상에서 제외될 수 있습니다.</li>
 
 
 
 
</ul>
<div class="ncua-table ncua-table--vertical">
<table>
</div>
 
<div class="message-content send-method-component" data-target="MAIN">
 
 
 
<div class="myapp-message-title">
<div class="ncua-input ncua-input--xs ncua-input-full-width">
<div class="ncua-input__content-wrap">
<div class="ncua-input__content">
<div class="ncua-input__field ncua-input__field--xs">
<input class="ncua-myapp-message-title" data-charcount-key="myapp-title" maxlength="40" name="myappTitle" type="text" value="" placeholder="맛집 BEST 3" />
</div>
</div>
<div class="ncua-input__field-text-count" data-charcount-text="myapp-title">
</div>
</div>
<!-- 템플릿 버튼 -->
 
<div class="template-control-wrap send-method-component" data-target="MAIN">
<button data-click-target="loadTemplate" type='button' class="ncua-btn ncua-btn--sm ncua-btn--secondary">템플릿 불러오기</button>
<button data-click-target="saveAsTemplate" type='button' class="ncua-btn ncua-btn--sm ncua-btn--secondary-gray file-btn">
템플릿으로 저장하기
</button>
</div>
 
<!-- 메시지 성과 추적 -->
<div class="message-performance-analysis send-method-component" data-target="ALTERNATIVE">
<p class="ncua-card__body-title--xs tooltip-align" data-tooltip-seq="004">메시지 성과 추적</p>
</div>
</div>
<!-- 마이앱 안내 -->
<details class="ncua-accordion ncua-accordion--gray send-method-component" data-target="MAIN" open>
<summary>마이앱 안내</summary>
<div class="ncua-accordion__content has-dot">
<ul>
</div>
</details>
<!-- SMS/LMS 대체 발송 안내 -->
<details class="ncua-accordion ncua-accordion--gray send-method-component" data-target="ALTERNATIVE" open>
<summary>SMS/LMS 발송 안내</summary>
<div class="ncua-accordion__content has-dot">
<ul>
const myappPushTabSection = document.querySelector('.js-myapp-push-tab-section');
const chipContainer = document.querySelector('[data-role="chip-container"]');
const isMyappInstalled = <?= json_encode($isMyappInstalled) ?>;
 
 
let myappImageFileInput;
let isLoadingTemplate = false;
})
});
 
myappPushTabSection.querySelector('button[data-click-target="loadTemplate"]').addEventListener('click', function() {
$.post('./mobile_send/layer_mobile_send_myapp_push_template.php', null, function (data) {
ncds_layer_popup({message: data, title: '템플릿 불러오기', size: 'wide-sm'});
});
});
 
myappPushTabSection.querySelector('button[data-click-target="saveAsTemplate"]').addEventListener('click', function() {
const message = messageInput.getValue();
if (message === '') {
NCDSAlert({
});
 
myappPushTabSection.querySelector('input[name="myappTitle"]').addEventListener('input', function(e) {
window.preview.setTitle(e.target.value);
if (this.value === '') {
setDefaultPreview(['MYAPP_TITLE']);
}
});
 
myappPushTabSection.querySelector('input[name="myappUnsubscribeGuide"]').addEventListener('input', function(e) {
myappPushTabSection.querySelector('button.load-main-contents').addEventListener('click', function() {
const message = messageInput.getValue();
const allKeys = [
 
MobileMessageReplaceCode.MEMBER,
MobileMessageReplaceCode.GOODS,
MobileMessageReplaceCode.ORDER,
].flatMap(category => Object.keys(category));
 
const convertedMessage = message.replace(/#\{([a-zA-Z_0-9]+)}/g, (match, key) => {
return allKeys.includes(key) ? `{${key}}` : '';
});
 
alternativeMessageInput.setValue('');
myappPushTabSection.querySelector('button[data-send-tab-type="MAIN"]')?.dispatchEvent(new Event('click'));
 
// 전체회원 + SMS 수신동의한 회원 발송 여부에 따른 layout 변경
const isRecipientAllType = document.querySelector('input[name="recipientType"]:checked').value === 'ALL';
const isOnlyAgreed = document.querySelector('input[name="isOnlyAgreed"]').checked;
setMyappSendConditionAvailability(isRecipientAllType, isOnlyAgreed);
 
const myappImageFileTagContainer = myappPushTabSection.querySelector('#myappImageFileTagContainer');
});
charCountManager.init();
 
 
 
 
 
// 본문 메시지
window.messageInput = GodoUIModule.render({
type: 'MessageInput',
name: 'messageContent',
maxLength: 300,
useBytes: false,
onInput: function(input) {
window.preview.setContent(resolveReplaceCode(input));
if (input === '') {
myappPushTabSection.querySelector('#useTemplateCaution').hidden = true;
setDefaultPreview(['MYAPP_TEXT']);
}
}
});
 
// 대체 메시지
maxLength: 2000,
useBytes: true,
allowEmoji: false,
 
onInput: function(input) {
window.alternativePreview.setContent(resolveReplaceCode(input, false));
 
if (input === '') {
window.alternativeMessageInput.setHint(null);
setDefaultPreview(['ALTERNATIVE_TEXT']);
window.chipSelector = GodoUIModule.render({
type: 'ChipSelector',
target: '#replaceCodeContainer',
title: '사용 가능한 변수',
tooltipSeq: '003',
 
cautionHTML: `
<ul class="replace-code-caution">
<li id="nonMemberReplaceCodeCaution" class="ncua-caution-text" hidden>회원 외 치환코드는 정상 적용되지 않으니 저장된 메시지 수정 시에만 이용바랍니다.</li>
<li id="useTemplateCaution" class="ncua-caution-text" hidden>템플릿 사용시에 미지원 치환코드가 포함된 경우, 해당 값은 공란으로 발송되므로 미리보기를 확인해주시기 바랍니다.</li>
</ul>
`,
categories: buildReplaceCodeCategories(),
onSelect: (variable) => {
messageInput.insertText(variable);
},
onCategoryChange: (category) => {
myappPushTabSection.querySelector('#nonMemberReplaceCodeCaution').hidden = category === 'MEMBER';
window.alternativeChipSelector = GodoUIModule.render({
type: 'ChipSelector',
target: '#replaceCodeAlternativeContainer',
title: '사용 가능한 변수',
tooltipSeq: '003',
categories: buildReplaceCodeCategories(),
 
onSelect: (variable) => {
window.alternativeMessageInput.insertText(variable);
},
onCategoryChange: (category) => {
if (category === 'MEMBER') {
 
const notificationType = myappPushTabSection.querySelector('input[type="radio"][name="myappNotificationType"]:checked').value ?? '';
updateAdMessage(notificationType);
setDefaultPreview(['ALTERNATIVE_TEXT', 'MYAPP_TITLE', 'MYAPP_TEXT', 'MYAPP_UNSUBSCRIBE_GUIDE']);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
}
 
function createHiddenFileInput(fileName, file, options) {
if (container) container.innerHTML = '';
const myappImageFileTagContainer = myappPushTabSection.querySelector('#myappImageFileTagContainer');
 
myappImageFileInput = new ncua.ImageFileInput({
 
 
container: 'myappImageFileInput',
fileInputName: 'myappImageFile',
hintItems: [
}
});
 
if (existingImageUrl) {
 
myappImageFileInput.setFiles([{ fileName: existingImageUrl.split('/').pop(), fileImageUrl: existingImageUrl }]);
myappImageFileInput.renderImagePreviews();
}
 
변경 후layer_myapp_push_content_setting.php
<div class="myapp-condition-setting send-method-component" data-target="MAIN">
<p class="ncua-card__body-title--xs">조건 설정</p>
<ul class="myapp-condition-setting-notice">
<?php if ($isRecipeContext): ?>
<li class="ncua-notice-info">선택된 회원의 계정이 로그인 되어있는 모든 앱에 푸시가 발송됩니다.<br />다만, 발송 OS, 앱 설치, 알림 수신 동의 여부에 따라 수신 대상에서 제외될 수 있습니다.</li>
<?php else: ?>
<li class="ncua-notice-info">앱 설치자로 발송 시 로그인 상태와 상관없이 앱을 설치한 모든 유저에게 푸시가 발송됩니다.</li>
<li class="ncua-notice-info">가입 회원에게 발송 시 선택된 회원의 계정이 로그인 되어 있는 모든 앱에 푸시가 발송됩니다.<br />다만, 발송 OS, 앱 설치 여부, 앱 알림 수신 동의 여부에 따라 수신 대상에서 제외될 수 있습니다.</li>
<?php endif; ?>
</ul>
<div class="ncua-table ncua-table--vertical">
<table>
</div>
 
<div class="message-content send-method-component" data-target="MAIN">
<div class="ncua-card__body-title-wrap">
<p class="ncua-card__body-title--xs">내용 입력</p>
</div>
<div class="myapp-message-title">
<div class="ncua-input ncua-input--xs ncua-input-full-width">
<div class="ncua-input__content-wrap">
<div class="ncua-input__content">
<div class="ncua-input__field ncua-input__field--xs">
<input class="ncua-myapp-message-title" data-charcount-key="myapp-title" maxlength="40" name="myappTitle" type="text" value="<?= gd_htmlspecialchars($initialTitle ?? '') ?>" placeholder="맛집 BEST 3" />
</div>
</div>
<div class="ncua-input__field-text-count" data-charcount-text="myapp-title">
</div>
</div>
<!-- 템플릿 버튼 -->
<?php if (!$isRecipeContext): ?>
<div class="template-control-wrap send-method-component" data-target="MAIN">
<button data-click-target="loadTemplate" type='button' class="ncua-btn ncua-btn--sm ncua-btn--secondary">템플릿 불러오기</button>
<button data-click-target="saveAsTemplate" type='button' class="ncua-btn ncua-btn--sm ncua-btn--secondary-gray file-btn">
템플릿으로 저장하기
</button>
</div>
<?php endif; ?>
<!-- 메시지 성과 추적 -->
<div class="message-performance-analysis send-method-component" data-target="ALTERNATIVE">
<p class="ncua-card__body-title--xs tooltip-align" data-tooltip-seq="004">메시지 성과 추적</p>
</div>
</div>
<!-- 마이앱 안내 -->
<details class="ncua-accordion ncua-accordion--gray send-method-component" data-target="MAIN" <?= !$isRecipeContext ? 'open' : '' ?>>
<summary>마이앱 안내</summary>
<div class="ncua-accordion__content has-dot">
<ul>
</div>
</details>
<!-- SMS/LMS 대체 발송 안내 -->
<details class="ncua-accordion ncua-accordion--gray send-method-component" data-target="ALTERNATIVE" <?= !$isRecipeContext ? 'open' : '' ?>>
<summary>SMS/LMS 발송 안내</summary>
<div class="ncua-accordion__content has-dot">
<ul>
const myappPushTabSection = document.querySelector('.js-myapp-push-tab-section');
const chipContainer = document.querySelector('[data-role="chip-container"]');
const isMyappInstalled = <?= json_encode($isMyappInstalled) ?>;
const isRecipeContext = <?= $isRecipeContext ? 'true' : 'false' ?>;
 
let myappImageFileInput;
let isLoadingTemplate = false;
})
});
 
myappPushTabSection.querySelector('button[data-click-target="loadTemplate"]')?.addEventListener('click', function() {
$.post('./mobile_send/layer_mobile_send_myapp_push_template.php', null, function (data) {
ncds_layer_popup({message: data, title: '템플릿 불러오기', size: 'wide-sm'});
});
});
 
myappPushTabSection.querySelector('button[data-click-target="saveAsTemplate"]')?.addEventListener('click', function() {
const message = messageInput.getValue();
if (message === '') {
NCDSAlert({
});
 
myappPushTabSection.querySelector('input[name="myappTitle"]').addEventListener('input', function(e) {
syncMyappTitlePreview(e.target.value);
 
 
 
});
 
myappPushTabSection.querySelector('input[name="myappUnsubscribeGuide"]').addEventListener('input', function(e) {
myappPushTabSection.querySelector('button.load-main-contents').addEventListener('click', function() {
const message = messageInput.getValue();
const allKeys = [
MobileMessageReplaceCode.CRM_RECIPE,
MobileMessageReplaceCode.MEMBER,
MobileMessageReplaceCode.GOODS,
MobileMessageReplaceCode.ORDER,
].flatMap(category => Object.keys(category));
 
const convertedMessage = message.replace(/#\{([a-zA-Z_0-9]+)}/g, (match, key) => {
return allKeys.includes(MobileMessageReplaceCode.normalizeReplaceKey(key)) ? `{${key}}` : '';
});
 
alternativeMessageInput.setValue('');
myappPushTabSection.querySelector('button[data-send-tab-type="MAIN"]')?.dispatchEvent(new Event('click'));
 
// 전체회원 + SMS 수신동의한 회원 발송 여부에 따른 layout 변경
const isRecipientAllType = (document.querySelector('input[name="recipientType"]:checked')?.value ?? 'ALL') === 'ALL';
const isOnlyAgreed = document.querySelector('input[name="isOnlyAgreed"]')?.checked ?? false;
setMyappSendConditionAvailability(isRecipientAllType, isOnlyAgreed);
 
const myappImageFileTagContainer = myappPushTabSection.querySelector('#myappImageFileTagContainer');
});
charCountManager.init();
 
const initialMyappMessage = <?= json_encode($initialMessage ?? '', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>;
const initialMyappTitle = <?= json_encode($initialTitle ?? '', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>;
const initialAlternativeMessage = <?= json_encode($initialAlternativeMessage ?? '', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>;
 
// 본문 메시지
window.messageInput = GodoUIModule.render({
type: 'MessageInput',
name: 'messageContent',
maxLength: 300,
useBytes: false,
initialValue: initialMyappMessage,
onInput: syncMyappMessagePreview,
 
 
 
 
 
});
 
// 대체 메시지
maxLength: 2000,
useBytes: true,
allowEmoji: false,
initialValue: initialAlternativeMessage,
onInput: function(input) {
window.alternativePreview.setContent(resolveReplaceCode(input, false));
MobileMessageReplaceCode.updateExpireCodeGuide('#replaceCodeAlternativeContainer', input);
if (input === '') {
window.alternativeMessageInput.setHint(null);
setDefaultPreview(['ALTERNATIVE_TEXT']);
window.chipSelector = GodoUIModule.render({
type: 'ChipSelector',
target: '#replaceCodeContainer',
title: isRecipeContext ? '추천 변수' : '사용 가능한 변수',
tooltipSeq: '003',
hideCategorySelector: isRecipeContext,
cautionHTML: `
<ul class="replace-code-caution">
<li id="nonMemberReplaceCodeCaution" class="ncua-caution-text" hidden>회원 외 치환코드는 정상 적용되지 않으니 저장된 메시지 수정 시에만 이용바랍니다.</li>
<li id="useTemplateCaution" class="ncua-caution-text" hidden>템플릿 사용시에 미지원 치환코드가 포함된 경우, 해당 값은 공란으로 발송되므로 미리보기를 확인해주시기 바랍니다.</li>
</ul>
`,
categories: isRecipeContext ? buildRecipeReplaceCodeCategories() : buildReplaceCodeCategories(),
onSelect: (variable) => {
messageInput.insertText(MobileMessageReplaceCode.resolveRecipeExpireInsertKey(variable, isRecipeContext));
},
onCategoryChange: (category) => {
myappPushTabSection.querySelector('#nonMemberReplaceCodeCaution').hidden = category === 'MEMBER';
window.alternativeChipSelector = GodoUIModule.render({
type: 'ChipSelector',
target: '#replaceCodeAlternativeContainer',
title: isRecipeContext ? '추천 변수' : '사용 가능한 변수',
tooltipSeq: '003',
hideCategorySelector: isRecipeContext,
categories: isRecipeContext ? buildRecipeReplaceCodeCategories() : buildReplaceCodeCategories(),
onSelect: (variable) => {
window.alternativeMessageInput.insertText(MobileMessageReplaceCode.resolveRecipeExpireInsertKey(variable, isRecipeContext));
},
onCategoryChange: (category) => {
if (category === 'MEMBER') {
 
const notificationType = myappPushTabSection.querySelector('input[type="radio"][name="myappNotificationType"]:checked').value ?? '';
updateAdMessage(notificationType);
 
setDefaultPreview(['ALTERNATIVE_TEXT', 'MYAPP_UNSUBSCRIBE_GUIDE']);
if (initialAlternativeMessage) {
window.alternativePreview.setContent(resolveReplaceCode(initialAlternativeMessage, false));
MobileMessageReplaceCode.updateExpireCodeGuide('#replaceCodeAlternativeContainer', initialAlternativeMessage);
}
syncMyappTitlePreview(initialMyappTitle);
syncMyappMessagePreview(initialMyappMessage);
}
 
function syncMyappTitlePreview(title) {
window.preview.setTitle(title);
if (title === '') {
setDefaultPreview(['MYAPP_TITLE']);
}
}
 
function syncMyappMessagePreview(input) {
window.preview.setContent(resolveReplaceCode(input));
MobileMessageReplaceCode.updateExpireCodeGuide('#replaceCodeContainer', input);
if (input === '') {
myappPushTabSection.querySelector('#useTemplateCaution').hidden = true;
setDefaultPreview(['MYAPP_TEXT']);
}
}
 
function createHiddenFileInput(fileName, file, options) {
if (container) container.innerHTML = '';
const myappImageFileTagContainer = myappPushTabSection.querySelector('#myappImageFileTagContainer');
 
// 레시피 모달에서는 이미지 미리보기 슬롯(+) 없이 파일 찾기 버튼만 노출 (board/article_write와 동일한 FileInput 사용)
const FileInputComponent = isRecipeContext ? ncua.FileInput : ncua.ImageFileInput;
myappImageFileInput = new FileInputComponent({
container: 'myappImageFileInput',
fileInputName: 'myappImageFile',
hintItems: [
}
});
 
// FileInput(레시피)에는 프리뷰 API가 없으므로 기존 이미지 썸네일 표시는 ImageFileInput에서만
if (existingImageUrl && !isRecipeContext) {
myappImageFileInput.setFiles([{ fileName: existingImageUrl.split('/').pop(), fileImageUrl: existingImageUrl }]);
myappImageFileInput.renderImagePreviews();
}
 
Admin/crm/mobile_send/layer_preview_message.php+1-1
변경 전layer_preview_message.php
}
 
function updatePreviewActiveTab(tabType) {
container.querySelectorAll('button[data-preview-message-type]').forEach(btn => {
btn.classList.toggle('is-active', btn.dataset.previewMessageType === tabType);
});
}
 
변경 후layer_preview_message.php
}
 
function updatePreviewActiveTab(tabType) {
previewMessageSection.querySelectorAll('button[data-preview-message-type]').forEach(btn => {
btn.classList.toggle('is-active', btn.dataset.previewMessageType === tabType);
});
}
 
Admin/crm/mobile_send/layer_recipe_send_time_setting.php+287
변경 전layer_recipe_send_time_setting.php
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
변경 후layer_recipe_send_time_setting.php
<section class="js-send-time-setting-section js-recipe-send-time-setting">
<header class="ncua-card__header">
<h4 class="ncua-card__title">
발송 설정
</h4>
</header>
<section class="ncua-card__body">
<div class="send-time-setting-content">
<div class="send-setting-repeat js-send-time-component" data-target="REPEAT">
<div class="send-time-setting-content-container">
<p class="ncua-card__body-title--sm">반복 발송 기간</p>
<div class="send-setting-repeat-date">
<div id="datepicker-repeat-send-container"></div>
<label class="ncua-checkbox-field ncua-checkbox-field--xs has-text">
<span class="ncua-checkbox-input ncua-checkbox-input--xs ncua-checkbox-field__input">
<input name="hasNoEndDate" type="checkbox" />
</span>
<span class="ncua-checkbox-field__text">종료일 없음</span>
</label>
</div>
<ul class="send-setting-repeat-notice">
<li class="ncua-notice-info">반복 발송은 현재 시간으로부터 10분 이후로만 설정이 가능합니다.</li>
</ul>
</div>
<div class="send-time-setting-content-container">
<p class="ncua-card__body-title--sm tooltip-align" data-tooltip-seq="005">발송 주기</p>
<div class="send-setting-repeat-cycle">
<div class="send-setting-repeat-cycle-select">
<span class="ncua-select ncua-select--xs">
<span class="ncua-select__content">
<select id="sendCycle" class="ncua-select__tag">
<?php foreach ($messageRepeatCycles as $messageRepeatCycle): ?>
<option value="<?= $messageRepeatCycle->name ?>" <?= $messageRepeatCycle->name === $sendTimeView['selectedRepeatType'] ? 'selected' : '' ?>><?= $messageRepeatCycle->value ?></option>
<?php endforeach; ?>
</select>
</span>
</span>
<span class="ncua-select ncua-select--xs">
<span class="ncua-select__content">
<select name="repeatHour" class="ncua-select__tag">
<?php for ($i = 8; $i <= 20; $i++): ?>
<option value="<?= sprintf('%02d', $i) ?>" <?= sprintf('%02d', $i) === $sendTimeView['selectedRepeatHour'] ? 'selected' : '' ?>>
<?= sprintf('%02d', $i) ?>
</option>
<?php endfor; ?>
</select>
</span>
</span>
<span>시</span>
<span class="ncua-select ncua-select--xs">
<span class="ncua-select__content">
<select name="repeatMinutes" class="ncua-select__tag">
<?php for ($i = 0; $i <= 50; $i += 10): ?>
<option value="<?= sprintf('%02d', $i) ?>" <?= sprintf('%02d', $i) === $sendTimeView['selectedRepeatMinutes'] ? 'selected' : '' ?>>
<?= sprintf('%02d', $i) ?>
</option>
<?php endfor; ?>
</select>
</span>
</span>
<span>분에 반복 발송</span>
</div>
 
<!-- 매주 발송 설정 -->
<div class="send-cycle-weekly js-repeat-cycle-component js-weekly-day <?= $sendTimeView['selectedRepeatType'] === 'WEEKLY' ? '' : 'display-none' ?>" data-target="WEEKLY">
<label class="ncua-checkbox-field ncua-checkbox-field--xs has-text">
<span class="ncua-checkbox-input ncua-checkbox-input--xs ncua-checkbox-field__input">
<input type="checkbox" value="all" />
</span>
<span class="ncua-checkbox-field__text">전체</span>
</label>
<?php foreach ($weekDays as $weekDay): ?>
<label class="ncua-checkbox-field ncua-checkbox-field--xs has-text">
<span class="ncua-checkbox-input ncua-checkbox-input--xs ncua-checkbox-field__input">
<input type="checkbox" value="<?= $weekDay->value ?>" name="repeatDays[]" <?= in_array($weekDay->value, $sendTimeView['selectedRepeatDays'], true) ? 'checked' : '' ?> />
</span>
<span class="ncua-checkbox-field__text"><?= $weekDay->label() ?></span>
</label>
<?php endforeach; ?>
</div>
 
<!-- 매월 발송 설정 -->
<div class="send-cycle-monthly js-repeat-cycle-component <?= $sendTimeView['selectedRepeatType'] === 'MONTHLY' ? '' : 'display-none' ?>" data-target="MONTHLY">
<div class="ncua-input ncua-input--xs ncua-input-width-320">
<div class="ncua-input__content">
<div class="ncua-input__field ncua-input__field--xs ">
<input name="monthly-repeat-date" type="text" inputmode="numeric" pattern="[0-9]*" value="" placeholder="일자(1~31) 숫자 입력" />
</div>
</div>
</div>
<span class="send-cycle-monthly-notice">5개까지 생성 가능합니다 (Enter로 구분)</span>
</div>
</div>
<div class="send-tag-wrap <?= $sendTimeView['selectedRepeatType'] === 'MONTHLY' ? '' : 'display-none' ?>" data-component="send-cycle-monthly">
<div id="repeatDatesContainer" class="send-tag-content" data-role="tag-container">
<?php if ($sendTimeView['selectedRepeatType'] === 'MONTHLY'): ?>
<?php foreach ($sendTimeView['selectedRepeatDays'] as $day): ?>
<span class="ncua-tag ncua-tag--sm" data-day="<?= (int) $day ?>">
<span class="ncua-tag__text"><?= (int) $day ?>일</span>
<button type="button" class="ncua-tag__close" data-role="tag-close"></button>
</span>
<?php endforeach; ?>
<?php endif; ?>
</div>
<button type="button" data-click-target="deleteMonthlyDate" class="ncua-btn ncua-btn--xxs ncua-btn--text-gray has-underline">전체삭제</button>
</div>
</div>
</div>
</div>
</section>
</section>
 
<script type="text/javascript">
const sendTimeSettingSection = document.querySelector('.js-send-time-setting-section');
 
$(document).ready(function () {
initializeSendTimeSettingSectionEvents();
initializeSendTimeSettingSectionElement();
});
 
function initializeSendTimeSettingSectionEvents() {
const repeatHourSelect = document.querySelector('select[name="repeatHour"]');
const repeatMinutesSelect = document.querySelector('select[name="repeatMinutes"]');
const monthlyInput = sendTimeSettingSection.querySelector('input[name="monthly-repeat-date"]');
const tagContainer = sendTimeSettingSection.querySelector('#repeatDatesContainer');
 
repeatHourSelect.addEventListener('change', function() {
updateMinutesOptions(repeatHourSelect, repeatMinutesSelect)
});
 
sendTimeSettingSection.querySelector('#sendCycle').addEventListener('change', function() {
toggleComponentBySendCycle(this.value);
sendTimeSettingSection.querySelector('#repeatDatesContainer').innerHTML = '';
 
switch (this.value) {
case 'WEEKLY':
sendTimeSettingSection.querySelectorAll('.js-weekly-day input[type="checkbox"]').forEach(el => el.checked = false);
break;
case 'MONTHLY':
monthlyInput.value = '';
break;
}
});
 
monthlyInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
 
const date = parseInt(this.value);
 
if (!date || date < 1 || date > 31) {
NCDSAlert({message: '1~31 사이의 숫자를 입력해주세요.', iconType: 'error'});
return;
}
 
const currentTags = tagContainer.querySelectorAll('.ncua-tag');
if (currentTags.length >= 5) {
NCDSAlert({message: `최대 5개까지만 생성 가능합니다.`, iconType: 'error'});
return;
}
 
const existingTag = tagContainer.querySelector(`[data-day="${date}"]`);
if (existingTag) {
NCDSAlert({message: '이미 등록된 날짜입니다.', iconType: 'error'});
return;
}
 
const tag = generateMonthlytag(date);
tagContainer.appendChild(tag);
this.value = '';
}
});
 
sendTimeSettingSection.querySelector('button[data-click-target="deleteMonthlyDate"]').addEventListener('click', function() {
const tags = tagContainer.querySelectorAll('.ncua-tag');
tags.forEach(tag => tag.remove());
});
 
sendTimeSettingSection.querySelector('input[name="hasNoEndDate"]').addEventListener('change', function() {
const endDateInput = document.querySelectorAll('input[name="repeatDate[]"]')[1];
 
if (this.checked) {
if (endDateInput) {
endDateInput.disabled = true;
endDateInput.value = '';
}
} else {
if (endDateInput) {
endDateInput.disabled = false;
}
}
});
 
tagContainer.querySelectorAll('[data-role="tag-close"]').forEach(closeBtn => {
closeBtn.addEventListener('click', function() {
closeBtn.closest('.ncua-tag').remove();
});
});
}
 
function generateMonthlytag(date) {
const tag = document.createElement('span');
tag.className = 'ncua-tag ncua-tag--sm';
tag.setAttribute('data-day', date);
tag.innerHTML = `
<span class="ncua-tag__text">${date}일</span>
<button type="button" class="ncua-tag__close" data-role="tag-close"></button>
`;
 
const closeBtn = tag.querySelector('[data-role="tag-close"]');
closeBtn.addEventListener('click', function() {
tag.remove();
});
return tag;
}
 
function toggleComponentBySendCycle(sendCycle) {
sendTimeSettingSection.querySelectorAll('.js-repeat-cycle-component').forEach(el => {
el.classList.add('display-none');
const targets = el.dataset.target?.split(',') ?? [];
if (targets.includes(sendCycle)) {
el.classList.remove('display-none');
}
});
 
sendTimeSettingSection.querySelector('.send-tag-wrap')
.classList.toggle('display-none', sendCycle !== 'MONTHLY');
}
 
function updateMinutesOptions(hourSelect, minutesSelect) {
const isHour20 = hourSelect.value === '20';
minutesSelect.querySelectorAll('option').forEach(option => {
const value = parseInt(option.value, 10);
option.hidden = isHour20 && value >= 31;
});
 
if (isHour20 && parseInt(minutesSelect.value, 10) >= 31) {
minutesSelect.value = '00';
}
}
 
function initializeSendTimeSettingSectionElement() {
document.querySelector('input[name="sendMethod"]:checked')?.dispatchEvent(new Event('change'));
 
initRepeatSendDatePicker(false);
 
['.js-weekly-day'].forEach(selector => new CheckboxGroup(selector));
}
 
function initRepeatSendDatePicker(isRestricted) {
const startDateOptions = {
defaultDate: moment().add(1, 'days').format(),
minDate: moment().format(),
maxDate: isRestricted ? moment().add(364, 'days').format() : null,
};
const endDateOptions = {
defaultDate: moment().add(3, 'months').format(),
minDate: moment().format(),
maxDate: isRestricted ? moment().add(364, 'days').format() : null,
};
 
const repeatSendDatePicker = createCRMDatePicker({
containerId: 'datepicker-repeat-send-container',
attrName: 'repeatDate[]',
buttons: [],
startDateOptions,
endDateOptions,
});
attachDateValidation(repeatSendDatePicker, {maxMonthRange: null, containerId: 'datepicker-repeat-send-container'});
 
const noEndDateInput = sendTimeSettingSection.querySelector('input[name="hasNoEndDate"]');
if (isRestricted) noEndDateInput.checked = false;
noEndDateInput.disabled = isRestricted;
}
</script>
<!-- 툴팁 스크립트 -->
<script defer type="text/javascript">
const code = 251219003;
if (window.GodoCosGuide) {
window.GodoCosGuide.init({
apiUrl: <?= json_encode($cosGuideApiUrl) ?>,
guideCode: code,
}).then((data) => {
window.cosData = data;
});
}
</script>
 
Admin/crm/mobile_send/layer_recipient_setting.php+18-40
변경 전layer_recipient_setting.php
});
});
 
recipientSettingSection.querySelector('button[data-click-target="createCrmGroup"]').addEventListener('click', () => {
$.ajax({
url: './mobile_send/layer_select_crm_groups_ps.php',
type: 'GET',
data: {mode: 'getCrmGroups'},
success: function (response) {
if (response.data.totalCount >= 20) {
NCDSAlert({ message: '등록 가능한 개수를 초과하여 그룹 생성이 불가합니다.', iconType: 'error' });
return;
}
parent.postMessage({ type: 'MODAL_OPEN', payload: { target: 'CRM_GROUP_CREATE_POPUP', width: 1000, height: 570 } }, '*');
},
error: function () {
NCDSAlert({ message: '일시적인 오류가 발생하였습니다.<br>다시 시도해 주세요.', iconType: 'error' });
}
});
 
 
 
});
 
recipientSettingSection.querySelector('button[data-click-target="addDirectPhoneBtn"]').addEventListener('click', function() {
recipientSettingSection.querySelector('button.refresh-crm-group').addEventListener('click', async function(e) {
try {
const crmGroupNo = recipientSettingSection.querySelector('input[name="selectedCrmGroup"]').value;
const response = await getCrmGroupInfo(crmGroupNo);
setSelectedMemberCount(response.data.count, response.data.smsOptOutCount);
updateCrmGroupData(response.data.memberCount, response.data.status);
 
if (response.data.status === 'DONE') {
NCDSToast({message: '데이터 추출이 완료되었습니다.', color: 'success'});
} else {
NCDSToast({message: '데이터 추출이 진행 중입니다. 잠시만 기다려주세요.', color: 'error'});
}
} catch (e) {
NCDSToast({message: '일시적인 오류가 발생하였습니다. 다시 시도해 주세요.', color: 'error'});
}
});
}
 
function getCrmGroupInfo(crmGroupNo) {
return $.ajax({
url: './mobile_send/layer_recipient_setting_ps.php',
type: 'GET',
data: {mode: 'getCrmGroupStatus', crmGroupNo: crmGroupNo},
});
}
 
function clearRecipients() {
recipientSettingSection.querySelector('input[name="selectedMembers"]').value = "";
recipientSettingSection.querySelector('input[name="selectedMemberGroups"]').value = "";
 
window.getSelectedCrmGroup = function () {
const input = recipientSettingSection.querySelector('input[name="selectedCrmGroup"]');
return {crmNo: input.value, crmGroupName: input.dataset.crmGroupName, memberCount: input.dataset.memberCount};
}
 
window.setSelectedCrmGroup = async function (crmNo, groupName) {
selectedCrmGroupInput.dataset.crmGroupName = groupName;
 
try {
const response = await getCrmGroupInfo(crmNo);
setSelectedMemberCount(response.data.count, response.data.smsOptOutCount);
updateCrmGroupData(response.data.memberCount, response.data.status);
} catch (e) {
NCDSAlert({message: '일시적인 오류가 발생하였습니다.<br>다시 시도해 주세요.', iconType: 'error'});
}
}
 
function updateCrmGroupData(memberCount, status) {
recipientSettingSection.querySelector('input[name="selectedCrmGroup"]').dataset.memberCount = memberCount;
recipientSettingSection.querySelector('.js-crm-group-extraction').classList.toggle('display-none', status === 'DONE');
recipientSettingSection.querySelector('.js-recipient-count-text').classList.toggle('display-none', status !== 'DONE');
}
 
window.getExcelUploadData = function () {
const selectedCrmGroupInput = recipientSettingSection.querySelector('input[name="selectedCrmGroup"]');
selectedCrmGroupInput.value = '';
selectedCrmGroupInput.dataset.crmGroupName = '';
selectedCrmGroupInput.dataset.memberCount = '';
 
setSelectedMemberCount(0, 0);
 
 
변경 후layer_recipient_setting.php
});
});
 
recipientSettingSection.querySelector('button[data-click-target="createCrmGroup"]').addEventListener('click', async () => {
try {
if (await CrmGroupStatus.getCount() >= CrmGroupStatus.MAX_COUNT) {
NCDSAlert({ message: 'CRM 그룹은 최대 ' + CrmGroupStatus.MAX_COUNT + '개까지 등록할 수 있습니다.', iconType: 'error' });
return;
 
 
 
 
 
 
 
 
 
}
parent.postMessage({ type: 'MODAL_OPEN', payload: { target: 'CRM_GROUP_CREATE_POPUP', width: 1000, height: 570 } }, '*');
} catch (e) {
NCDSAlert({ message: '일시적인 오류가 발생하였습니다.<br>다시 시도해 주세요.', iconType: 'error' });
}
});
 
recipientSettingSection.querySelector('button[data-click-target="addDirectPhoneBtn"]').addEventListener('click', function() {
recipientSettingSection.querySelector('button.refresh-crm-group').addEventListener('click', async function(e) {
try {
const crmGroupNo = recipientSettingSection.querySelector('input[name="selectedCrmGroup"]').value;
const response = await CrmGroupStatus.getInfo(crmGroupNo);
setSelectedMemberCount(response.data.count, response.data.smsOptOutCount);
updateCrmGroupData(response.data.status);
NCDSToast(CrmGroupStatus.toast(response.data.status));
 
 
 
 
 
} catch (e) {
NCDSToast(CrmGroupStatus.errorToast());
}
});
}
 
 
 
 
 
 
 
 
 
function clearRecipients() {
recipientSettingSection.querySelector('input[name="selectedMembers"]').value = "";
recipientSettingSection.querySelector('input[name="selectedMemberGroups"]').value = "";
 
window.getSelectedCrmGroup = function () {
const input = recipientSettingSection.querySelector('input[name="selectedCrmGroup"]');
return {crmNo: input.value, crmGroupName: input.dataset.crmGroupName};
}
 
window.setSelectedCrmGroup = async function (crmNo, groupName) {
selectedCrmGroupInput.dataset.crmGroupName = groupName;
 
try {
const response = await CrmGroupStatus.getInfo(crmNo);
setSelectedMemberCount(response.data.count, response.data.smsOptOutCount);
updateCrmGroupData(response.data.status);
} catch (e) {
NCDSAlert({message: '일시적인 오류가 발생하였습니다.<br>다시 시도해 주세요.', iconType: 'error'});
}
}
 
function updateCrmGroupData(status) {
CrmGroupStatus.toggleView(recipientSettingSection, status);
 
 
}
 
window.getExcelUploadData = function () {
const selectedCrmGroupInput = recipientSettingSection.querySelector('input[name="selectedCrmGroup"]');
selectedCrmGroupInput.value = '';
selectedCrmGroupInput.dataset.crmGroupName = '';
 
 
setSelectedMemberCount(0, 0);
 
 
Admin/crm/mobile_send/layer_select_crm_groups.php+9-15
변경 전layer_select_crm_groups.php
/**
* CRM 그룹 생성 버튼
*/
document.querySelector('#createCrmGroupPopup')?.addEventListener('click', function () {
$.ajax({
url: '/crm/mobile_send/layer_select_crm_groups_ps.php',
type: 'GET',
data: {mode: 'getCrmGroups'},
success: function (response) {
if (response.data.totalCount >= 20) {
NCDSAlert({ message: '등록 가능한 개수를 초과하여 그룹 생성이 불가합니다.', iconType: 'error' });
return;
}
parent.postMessage({ type: 'MODAL_OPEN', payload: { target: 'CRM_GROUP_CREATE_POPUP', width: 1000, height: 570 } }, '*');
},
error: function () {
NCDSAlert({ message: '일시적인 오류가 발생하였습니다.<br>다시 시도해 주세요.', iconType: 'error' });
}
});
 
 
 
});
 
/**
 
변경 후layer_select_crm_groups.php
/**
* CRM 그룹 생성 버튼
*/
document.querySelector('#createCrmGroupPopup')?.addEventListener('click', async function () {
try {
if (await CrmGroupStatus.getCount() >= CrmGroupStatus.MAX_COUNT) {
NCDSAlert({ message: 'CRM 그룹은 최대 ' + CrmGroupStatus.MAX_COUNT + '개까지 등록할 수 있습니다.', iconType: 'error' });
return;
 
 
 
 
 
 
 
 
 
}
parent.postMessage({ type: 'MODAL_OPEN', payload: { target: 'CRM_GROUP_CREATE_POPUP', width: 1000, height: 570 } }, '*');
} catch (e) {
NCDSAlert({ message: '일시적인 오류가 발생하였습니다.<br>다시 시도해 주세요.', iconType: 'error' });
}
});
 
/**
 
Admin/crm/mobile_send/layer_send_method_setting.php+35-36
변경 전layer_send_method_setting.php
<section class="js-send-method-setting-section">
<header class="ncua-card__header">
<h4 class="ncua-card__title">
발송수단 설정
</h4>
 
 
 
</header>
<section class="ncua-card__body">
<!-- 발송수단 라디오 버튼 -->
MYAPP: './mobile_send/layer_myapp_push_content_setting.php',
};
 
$.post(urlMap[sendMethod], function (data) {
 
 
 
 
$('#layerSendMethodContent').html(data);
if (cosData && window.GodoCosGuide) {
window.GodoCosGuide.apply(cosData);
if (myappTitle && myappTitle.value !== '') return true;
}
 
function buildReplaceCodeCategories(prefix = '') {
return [
{ value: 'MEMBER', label: '회원' },
{ value: 'GOODS', label: '상품' },
{ value: 'ORDER', label: '주문' },
{ value: 'PROMOTION', label: '프로모션' },
{ value: 'BOARD', label: '게시판' },
{ value: 'REGULAR', label: '정기결제(배송)' },
{ value: 'PRESENT', label: '선물하기' },
].map(category => ({
...category,
variables: MobileMessageReplaceCode.getItemsByMethod(
MobileMessageReplaceCode[category.value],
MobileMessageReplaceCode.SendMethod.MANUAL
).map(item => ({
key: `${prefix}{${item.key}}`,
 
label: `${item.name}`
}))
}));
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
function resolveReplaceCode(content, isMain = true) {
if (content === null) return content;
const previewObject = isMain ? window.preview : window.alternativePreview;
 
if (previewObject.isCheckboxChecked()) return content;
 
const forceDefaultKeys = ['shopUrl'];
const replaceMap = {};
[
MobileMessageReplaceCode.MEMBER,
MobileMessageReplaceCode.GOODS,
MobileMessageReplaceCode.ORDER,
MobileMessageReplaceCode.PROMOTION,
MobileMessageReplaceCode.BOARD,
MobileMessageReplaceCode.REGULAR,
MobileMessageReplaceCode.PRESENT,
].forEach(category => {
Object.entries(category).forEach(([key, obj]) => {
if (replaceMap[key] !== undefined) return;
replaceMap[key] = (obj.sendMethod !== MobileMessageReplaceCode.SendMethod.AUTO || forceDefaultKeys.includes(key)) ? obj.default : '';
});
});
 
const shortLinks = isMain ? window.shortLinkKeys : window.shortLinkAlternativeKeys;
shortLinks.forEach((_, key) => {
replaceMap[key] = `쇼핑몰도메인/_s/a1B2c3D`;
});
 
return content.replace(/[#$]?\{([a-zA-Z0-9_]+)}/g, (match, key) => {
return replaceMap[key] !== undefined ? replaceMap[key] : match;
});
}
</script>
 
변경 후layer_send_method_setting.php
<section class="js-send-method-setting-section">
<header class="ncua-card__header">
<h4 class="ncua-card__title">
<?= $isRecipeContext ? '메시지 발송' : '발송수단 설정' ?>
</h4>
<?php if ($isRecipeContext && $messageSendGuide !== ''): ?>
<p class="ncua-notice-info js-message-send-guide"><?= nl2br(gd_htmlspecialchars($messageSendGuide)) ?></p>
<?php endif; ?>
</header>
<section class="ncua-card__body">
<!-- 발송수단 라디오 버튼 -->
MYAPP: './mobile_send/layer_myapp_push_content_setting.php',
};
 
const postData = (typeof recipeDefault !== 'undefined' && recipeDefault)
? { recipeContext: JSON.stringify(recipeDefault) }
: null;
 
$.post(urlMap[sendMethod], postData, function (data) {
$('#layerSendMethodContent').html(data);
if (cosData && window.GodoCosGuide) {
window.GodoCosGuide.apply(cosData);
if (myappTitle && myappTitle.value !== '') return true;
}
 
// 치환코드 카테고리 목록을 칩 드로워용 구조로 변환한다. (일반/레시피 공용)
// insertKey 는 _Nd 평탄화(defaultDay)용 — 일반 코드는 defaultDay 가 없어 key 와 동일해지므로 항상 포함해도 안전.
function buildReplaceCodeCategoryList(categories, prefix = '') {
return categories.map(category => ({
 
 
 
 
 
 
...category,
variables: MobileMessageReplaceCode.getItemsByMethod(
MobileMessageReplaceCode[category.value],
MobileMessageReplaceCode.SendMethod.MANUAL
).map(item => ({
key: `${prefix}{${item.key}}`,
insertKey: `${prefix}{${item.defaultDay ? item.key.replace(/_Nd$/, `_${item.defaultDay}d`) : item.key}}`,
label: `${item.name}`
}))
}));
}
 
function buildReplaceCodeCategories(prefix = '') {
return buildReplaceCodeCategoryList([
{ value: 'MEMBER', label: '회원' },
{ value: 'GOODS', label: '상품' },
{ value: 'ORDER', label: '주문' },
{ value: 'PROMOTION', label: '프로모션' },
{ value: 'BOARD', label: '게시판' },
{ value: 'REGULAR', label: '정기결제(배송)' },
{ value: 'PRESENT', label: '선물하기' },
], prefix);
}
 
function buildRecipeReplaceCodeCategories(prefix = '') {
return buildReplaceCodeCategoryList([
{ value: 'CRM_RECIPE', label: '레시피' },
], prefix);
}
 
function resolveReplaceCode(content, isMain = true) {
if (content === null) return content;
const previewObject = isMain ? window.preview : window.alternativePreview;
 
if (previewObject.isCheckboxChecked()) return content;
 
return MobileMessageReplaceCode.resolvePreviewContent(content, {
includeRecipe: true,
shortLinkKeys: isMain ? window.shortLinkKeys : window.shortLinkAlternativeKeys,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
});
}
</script>
 
Admin/crm/mobile_send/layer_send_time_setting.php+2-13
변경 전layer_send_time_setting.php
</span>
<span class="ncua-checkbox-field__text">전체</span>
</label>
<?php
$weekDays = [
['value' => 1, 'label' => '월'],
['value' => 2, 'label' => '화'],
['value' => 3, 'label' => '수'],
['value' => 4, 'label' => '목'],
['value' => 5, 'label' => '금'],
['value' => 6, 'label' => '토'],
['value' => 7, 'label' => '일'],
];
?>
<?php foreach ($weekDays as $weekDay): ?>
<label class="ncua-checkbox-field ncua-checkbox-field--xs has-text">
<span class="ncua-checkbox-input ncua-checkbox-input--xs ncua-checkbox-field__input">
<input type="checkbox" value="<?= $weekDay['value'] ?>" name="repeatDays[]" />
</span>
<span class="ncua-checkbox-field__text"><?= $weekDay['label'] ?></span>
</label>
<?php endforeach; ?>
</div>
 
변경 후layer_send_time_setting.php
</span>
<span class="ncua-checkbox-field__text">전체</span>
</label>
 
 
 
 
 
 
 
 
 
 
 
<?php foreach ($weekDays as $weekDay): ?>
<label class="ncua-checkbox-field ncua-checkbox-field--xs has-text">
<span class="ncua-checkbox-input ncua-checkbox-input--xs ncua-checkbox-field__input">
<input type="checkbox" value="<?= $weekDay->value ?>" name="repeatDays[]" />
</span>
<span class="ncua-checkbox-field__text"><?= $weekDay->label() ?></span>
</label>
<?php endforeach; ?>
</div>
 
Admin/crm/mobile_send/layer_sms_content_setting.php+27-17
변경 전layer_sms_content_setting.php
</div>
 
<!-- 템플릿 버튼 -->
 
<div class="template-control-wrap">
<button type="button" data-click-target="loadTemplate" class="ncua-btn ncua-btn--sm ncua-btn--secondary">템플릿 불러오기</button>
<button type="button" data-click-target="saveAsTemplate" class="ncua-btn ncua-btn--sm ncua-btn--secondary-gray file-btn">
템플릿으로 저장하기
</button>
</div>
 
</div>
<!-- SMS/LMS 대체 발송 안내 -->
<details class="ncua-accordion ncua-accordion--gray" open>
<summary>SMS/LMS 발송 안내</summary>
<div class="ncua-accordion__content has-dot">
<ul>
addShortLinkBtn.disabled = isImpossibleAddShortLink;
});
 
smsSettingSection.querySelector('button[data-click-target="loadTemplate"]').addEventListener('click', function() {
$.post('./mobile_send/layer_mobile_send_sms_template.php', null, function (data) {
ncds_layer_popup({message: data, title: '템플릿 불러오기', size: 'wide-sm'});
});
});
 
smsSettingSection.querySelector('button[data-click-target="saveAsTemplate"]').addEventListener('click', function() {
const message = messageInput.getValue();
if (message === '') {
NCDSAlert({
}
 
function initializeSmsContentSettingSectionElement() {
 
 
// MessageInput 생성
window.messageInput = GodoUIModule.render({
type: 'MessageInput',
maxLength: 2000,
useBytes: true,
allowEmoji: false,
onInput: function(input) {
window.preview.setContent(resolveReplaceCode(input));
if (input === '') {
window.messageInput.setHint(null);
smsSettingSection.querySelector('#useTemplateCaution').hidden = true;
setDefaultPreview(['TEXT']);
} else {
window.messageInput.setHint(window.messageInput.calculateLength(input) > 90 ? 'LMS 건당 3포인트 차감' : 'SMS 건당 1포인트 차감');
}
}
});
 
 
window.chipSelector = GodoUIModule.render({
type: 'ChipSelector',
target: '#replaceCodeContainer',
title: '사용 가능한 변수',
tooltipSeq: '003',
 
cautionHTML: `
<ul class="replace-code-caution">
<li id="nonMemberReplaceCodeCaution" class="ncua-caution-text" hidden>회원 외 치환코드는 정상 적용되지 않으니 저장된 메시지 수정 시에만 이용바랍니다.</li>
<li id="useTemplateCaution" class="ncua-caution-text" hidden>템플릿 사용시에 미지원 치환코드가 포함된 경우, 해당 값은 공란으로 발송되므로 미리보기를 확인해주시기 바랍니다.</li>
</ul>
`,
categories: buildReplaceCodeCategories(),
onSelect: (variable) => {
messageInput.insertText(variable);
},
onCategoryChange: (category) => {
smsSettingSection.querySelector('#nonMemberReplaceCodeCaution').hidden = category === 'MEMBER';
window.preview.setContent(resolveReplaceCode(window.messageInput.getValue()));
});
 
setDefaultPreview(['TEXT']);
 
const recipientType = document.querySelector('input[name="recipientType"]:checked')?.value;
if (['EXCEL_UPLOAD', 'DIRECT'].includes(recipientType)) {
}
}
 
 
 
 
 
 
 
 
 
 
 
 
 
function setTemplate(message) {
smsSettingSection.querySelector('input[name="use080Reject"]').checked = false;
 
 
변경 후layer_sms_content_setting.php
</div>
 
<!-- 템플릿 버튼 -->
<?php if (!$isRecipeContext): ?>
<div class="template-control-wrap">
<button type="button" data-click-target="loadTemplate" class="ncua-btn ncua-btn--sm ncua-btn--secondary">템플릿 불러오기</button>
<button type="button" data-click-target="saveAsTemplate" class="ncua-btn ncua-btn--sm ncua-btn--secondary-gray file-btn">
템플릿으로 저장하기
</button>
</div>
<?php endif; ?>
</div>
<!-- SMS/LMS 대체 발송 안내 -->
<details class="ncua-accordion ncua-accordion--gray" <?= !$isRecipeContext ? 'open' : '' ?>>
<summary>SMS/LMS 발송 안내</summary>
<div class="ncua-accordion__content has-dot">
<ul>
addShortLinkBtn.disabled = isImpossibleAddShortLink;
});
 
smsSettingSection.querySelector('button[data-click-target="loadTemplate"]')?.addEventListener('click', function() {
$.post('./mobile_send/layer_mobile_send_sms_template.php', null, function (data) {
ncds_layer_popup({message: data, title: '템플릿 불러오기', size: 'wide-sm'});
});
});
 
smsSettingSection.querySelector('button[data-click-target="saveAsTemplate"]')?.addEventListener('click', function() {
const message = messageInput.getValue();
if (message === '') {
NCDSAlert({
}
 
function initializeSmsContentSettingSectionElement() {
const initialMessage = <?= json_encode($initialMessage ?? '', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>;
 
// MessageInput 생성
window.messageInput = GodoUIModule.render({
type: 'MessageInput',
maxLength: 2000,
useBytes: true,
allowEmoji: false,
initialValue: initialMessage,
onInput: syncSmsPreviewAndHint,
 
 
 
 
 
 
 
 
});
 
const isRecipeContext = <?= $isRecipeContext ? 'true' : 'false' ?>;
window.chipSelector = GodoUIModule.render({
type: 'ChipSelector',
target: '#replaceCodeContainer',
title: isRecipeContext ? '추천 변수' : '사용 가능한 변수',
tooltipSeq: '003',
hideCategorySelector: isRecipeContext,
cautionHTML: `
<ul class="replace-code-caution">
<li id="nonMemberReplaceCodeCaution" class="ncua-caution-text" hidden>회원 외 치환코드는 정상 적용되지 않으니 저장된 메시지 수정 시에만 이용바랍니다.</li>
<li id="useTemplateCaution" class="ncua-caution-text" hidden>템플릿 사용시에 미지원 치환코드가 포함된 경우, 해당 값은 공란으로 발송되므로 미리보기를 확인해주시기 바랍니다.</li>
</ul>
`,
categories: isRecipeContext ? buildRecipeReplaceCodeCategories() : buildReplaceCodeCategories(),
onSelect: (variable) => {
messageInput.insertText(MobileMessageReplaceCode.resolveRecipeExpireInsertKey(variable, isRecipeContext));
},
onCategoryChange: (category) => {
smsSettingSection.querySelector('#nonMemberReplaceCodeCaution').hidden = category === 'MEMBER';
window.preview.setContent(resolveReplaceCode(window.messageInput.getValue()));
});
 
syncSmsPreviewAndHint(initialMessage);
 
const recipientType = document.querySelector('input[name="recipientType"]:checked')?.value;
if (['EXCEL_UPLOAD', 'DIRECT'].includes(recipientType)) {
}
}
 
function syncSmsPreviewAndHint(input) {
window.preview.setContent(resolveReplaceCode(input));
MobileMessageReplaceCode.updateExpireCodeGuide('#replaceCodeContainer', input);
if (input === '') {
window.messageInput.setHint(null);
smsSettingSection.querySelector('#useTemplateCaution').hidden = true;
setDefaultPreview(['TEXT']);
} else {
window.messageInput.setHint(window.messageInput.calculateLength(input) > 90 ? 'LMS 건당 3포인트 차감' : 'SMS 건당 1포인트 차감');
}
}
 
function setTemplate(message) {
smsSettingSection.querySelector('input[name="use080Reject"]').checked = false;
 
 
Admin/crm/mobile_send.php+3-643
변경 전mobile_send.php
<script defer src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/charcount.js')?> "></script>
<script type="text/javascript" src="<?= PATH_ADMIN_GD_SHARE ?>script/crm/crm_group_description.js"></script>
<script type="text/javascript" src="<?= PATH_ADMIN_GD_SHARE ?>script/crm/mobile_message_replace_code.js"></script>
 
 
<script type="text/javascript" src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/ncds-checkbox-group/ncds-checkbox-group.js') ?>"></script>
<script type="text/javascript" src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/ncds-datepicker-factory/ncds-datepicker-factory.js') ?>"></script>
<script defer src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/scroll-sticky.js') ?>"></script>
].flatMap(category => Object.keys(category));
 
const alternativeContents = mainContents.replace(/#\{([a-zA-Z_0-9]+)}/g, (match, key) => {
return allKeys.includes(key) ? `{${key}}` : match;
});
window.alternativeMessageInput.insertText(alternativeContents);
window.alternativePreview.setContent(alternativeContents);
}
}
 
function generateSmsLmsRequest() {
const use080Reject = document.querySelector('input[name="use080Reject"]').checked;
const mainContents = window.messageInput.getValue() ?? '';
const trackingLink = extractShortLinks(mainContents);
const replaceKeys = extractReplaceKeys(mainContents).filter(key => !trackingLink.some(link => link.key === key));
const convertedContents = convertReplaceKeysToSmsReplaceKeys(mainContents, [...replaceKeys, ...trackingLink.map(link => link.key)]);
return {
isAdText: use080Reject,
trackingLink: trackingLink,
replaceKeys: replaceKeys,
title: null,
content: convertedContents
}
}
 
function generateKakaoFriendtalkRequest() {
// 캐러셀 데이터
const campaignMessageType = document.querySelector('input[name="campaignMessageType"]:checked')?.value ?? 'TEXT';
const isCarouselFeed = campaignMessageType === 'CAROUSEL_FEED';
const carouselData = isCarouselFeed ? carouselState.getCarouselsData() : null;
 
// 본문 메시지
const mainContents = window.messageInput.getValue() ?? '';
let trackingLink = isCarouselFeed ? carouselData.trackingLink : extractShortLinks(mainContents);
let replaceKeys = (isCarouselFeed ? carouselData.replaceKeys : extractReplaceKeys(mainContents, true)).filter(key => !trackingLink.some(link => link.key === key));
 
// 와이드 아이템 리스트일 경우 타이틀도 치환코드 추출
if (campaignMessageType === 'WIDE_ITEM_LIST') {
const campaignHeader = document.querySelector('input[name="friendtalkCampaignHeader"]')?.value || null;
const headerReplaceKeys = extractReplaceKeys(campaignHeader, true).filter(key => !trackingLink.some(link => link.key === key));
replaceKeys = [...replaceKeys, ...headerReplaceKeys];
}
 
// 대체 메시지
const alternativeContents = window.alternativeMessageInput.getValue() ?? '';
const alternativeTrackingLink = extractShortLinks(alternativeContents, false);
const alternativeReplaceKeys = extractReplaceKeys(alternativeContents).filter(key => !alternativeTrackingLink.some(link => link.key === key));
const convertedAlternativeContents = convertReplaceKeysToSmsReplaceKeys(alternativeContents, [...alternativeReplaceKeys, ...alternativeTrackingLink.map(link => link.key)]);
 
const use080Reject = document.querySelector('input[name="use080Reject"]').checked;
 
return {
campaignName: document.querySelector('input[name="friendtalkCampaignName"]')?.value ?? '',
type: campaignMessageType,
content: isCarouselFeed ? null : mainContents,
replaceKeys: replaceKeys,
buttons: isCarouselFeed ? null : collectFriendtalkLinkButtons(),
coupon: isCarouselFeed ? null : collectFriendtalkCouponButton(),
carousels: carouselData?.carousels,
header: isCarouselFeed ? null : (document.querySelector('input[name="friendtalkCampaignHeader"]')?.value || null),
items: collectFriendtalkItems(),
imageLink: isCarouselFeed ? null : (document.querySelector('input[name="friendtalkCampaignImageLink"]')?.value.replace(/^\s+|\s+$/g, '') || null),
imageUrl: isCarouselFeed ? null : (document.querySelector('input[name="friendtalkCampaignImageUrl"]')?.value.replace(/^\s+|\s+$/g, '') || null),
linkPlatformType: messageConfig.kakaoFriendTalk.linkPlatformType,
alternativeInfo: isCarouselFeed ? null : {
title: null,
content: convertedAlternativeContents,
replaceKeys: alternativeReplaceKeys,
isAdText: use080Reject,
trackingLink: alternativeTrackingLink
},
trackingLink: trackingLink
}
}
 
function collectFriendtalkLinkButtons() {
const linkButtonTableBody = document.querySelector('#linkButtonTableBody');
const buttons = [];
if (linkButtonTableBody) {
linkButtonTableBody.querySelectorAll('tr').forEach(row => {
const buttonName = row.querySelector('.ncua-link-button-name').value;
const webUrlInput = row.querySelector('input[name="linkButtonWebUrl"]')?.value;
const iosUrlInput = row.querySelector('input[name="linkButtonIOSUrl"]')?.value;
const aosUrlInput = row.querySelector('input[name="linkButtonAOSUrl"]')?.value;
 
const linkPlatformType = messageConfig.kakaoFriendTalk.linkPlatformType;
const isMobileApp = linkPlatformType === 'MOBILE_APP';
 
buttons.push({
name: buttonName.replace(/^\s+|\s+$/g, ''),
url: {
mobileUrl: isMobileApp ? null : (webUrlInput?.replace(/^\s+|\s+$/g, '') || ''),
iosUrl: !isMobileApp ? null : (iosUrlInput?.replace(/^\s+|\s+$/g, '') || ''),
aosUrl: !isMobileApp ? null : (aosUrlInput?.replace(/^\s+|\s+$/g, '') || '')
}
});
});
}
 
return buttons.length > 0 ? buttons : null;
}
 
function collectFriendtalkCouponButton() {
const couponTable = document.querySelector('#kakaoFriendTalkcouponTable');
if (!couponTable) return null;
 
const friendtalkCouponNo = document.querySelector('input[name="friendtalkCouponNo"]')?.value;
if (!friendtalkCouponNo) return null;
 
const titleInput = couponTable.querySelector('input[name="couponTitle"]');
const descriptionInput = couponTable.querySelector('input[name="couponDescription"]');
const webLinkInput = couponTable.querySelector('input[name="couponUrl"]');
 
return {
couponNo: parseInt(friendtalkCouponNo) || null,
title: titleInput?.value?.replace(/^\s+|\s+$/g, '') || '',
description: descriptionInput?.value?.replace(/^\s+|\s+$/g, '') || '',
url: webLinkInput?.value?.replace(/^\s+|\s+$/g, '') || ''
};
}
 
function collectFriendtalkItems() {
const items = [];
document.querySelectorAll('.js-kakao-friendtalk-itemlist-section tbody tr').forEach(row => {
const itemKey = row.dataset.itemKey;
const titleInput = row.querySelector('input[name="itemTitle"]');
const urlInput = row.querySelector('input[name="itemWebLink"]');
const imageUrlInput = row.querySelector('input[name="itemImageUrl"]');
 
if (titleInput && titleInput.value.replace(/^\s+|\s+$/g, '')) {
items.push({
key: itemKey, // 서버에서 파일 매칭용
title: titleInput.value.replace(/^\s+|\s+$/g, ''),
imageUrl: imageUrlInput?.value || null, // 업로드된 imageUrl 사용
url: urlInput?.value?.replace(/^\s+|\s+$/g, '') || ''
});
}
});
 
return items.length > 0 ? items : null;
}
 
function generateKakaoAlimtalkRequest() {
// 본문 메시지
const mainContents = document.querySelector('textarea[name="messageContent"]').value ?? '';
 
// 대체 메시지
const alternativeContents = window.alternativeMessageInput.getValue() ?? '';
const alternativeTrackingLink = extractShortLinks(alternativeContents, false);
const alternativeReplaceKeys = extractReplaceKeys(alternativeContents).filter(key => !alternativeTrackingLink.some(link => link.key === key));
const convertedAlternativeContents = convertReplaceKeysToSmsReplaceKeys(alternativeContents, [...alternativeReplaceKeys, ...alternativeTrackingLink.map(link => link.key)]);
 
const use080Reject = document.querySelector('input[name="use080Reject"]').checked;
return {
templateCode: document.querySelector('select[name="selectKakaoAlimtalkTemplate"]').value,
title: null,
content: mainContents,
replaceKeys: extractReplaceKeys(mainContents, true),
alternativeInfo: {
title: null,
isAdText: use080Reject,
content: convertedAlternativeContents,
replaceKeys: alternativeReplaceKeys,
trackingLink: alternativeTrackingLink
}
}
}
 
function generateMyappRequest() {
// 본문 메시지
const mainContents = window.messageInput.getValue() ?? '';
 
// 대체 메시지
const alternativeContents = window.alternativeMessageInput.getValue() ?? '';
const alternativeTrackingLink = extractShortLinks(alternativeContents, false);
const alternativeReplaceKeys = extractReplaceKeys(alternativeContents).filter(key => !alternativeTrackingLink.some(link => link.key === key));
const convertedAlternativeContents = convertReplaceKeysToSmsReplaceKeys(alternativeContents, [...alternativeReplaceKeys, ...alternativeTrackingLink.map(link => link.key)]);
 
const use080Reject = document.querySelector('input[name="use080Reject"]').checked;
 
const myappSendCondition = document.querySelector('input[type="radio"][name="myappSendCondition"]:checked').value ?? '';
const myappNotificationType = document.querySelector('input[type="radio"][name="myappNotificationType"]:checked').value ?? '';
const myappTitle = document.querySelector('input[name="myappTitle"]').value ?? '';
const myappUnsubscribeGuide = document.querySelector('input[name="myappUnsubscribeGuide"]').value ?? '';
const pushUrl = document.querySelector('#mobileUrl').innerText + document.querySelector('input[name="myappPushPath"]')?.value;
const platforms = { ALL: ['AOS', 'IOS'], ANDROID: ['AOS'], IOS: ['IOS'] }[document.querySelector('input[name="myappPlatform"]:checked')?.value] ?? null;
return {
sendCondition: myappSendCondition,
notificationType: myappNotificationType,
platforms: platforms,
title: myappTitle,
content: mainContents,
unsubscribeGuide: myappUnsubscribeGuide,
pushUrl: pushUrl,
imageUrl: document.querySelector('input[name="myappImageUrl"]')?.value || null,
replaceKeys: extractReplaceKeys(mainContents),
alternativeInfo: {
isAdText: use080Reject,
content: convertedAlternativeContents,
replaceKeys: alternativeReplaceKeys,
trackingLink: alternativeTrackingLink
}
}
}
 
function generateSendMethodRequest() {
// 발송 수단별 데이터 (smsLmsRequest, kakaoAlimtalkRequest, kakaoFriendtalkRequest, myappRequest)
const sendMethod = document.querySelector('input[name="sendMethod"]:checked').value;
let sendMethodRequest = {};
switch (sendMethod) {
case 'SMS':
sendMethodRequest.smsLmsRequest = generateSmsLmsRequest();
break;
case 'FRIENDTALK':
sendMethodRequest.kakaoFriendtalkRequest = generateKakaoFriendtalkRequest();
break;
case 'ALIMTALK':
sendMethodRequest.kakaoAlimtalkRequest = generateKakaoAlimtalkRequest();
break;
case 'MYAPP':
sendMethodRequest.myappRequest = generateMyappRequest();
break;
default:
break;
}
 
return sendMethodRequest;
}
 
function getSendData() {
const sendMethod = document.querySelector('input[name="sendMethod"]:checked').value;
};
}
 
function extractShortLinks(contents, isMain = true) {
const shortLinkKeys = isMain ? window.shortLinkKeys : window.shortLinkAlternativeKeys;
const shortLinks = [];
shortLinkKeys.forEach((url, key) => {
if (contents.includes(`{${key}}`)) {
shortLinks.push({ key, url });
}
});
return shortLinks;
}
 
function extractReplaceKeys(str, isKakao = false) {
if (!str) return [];
 
const regex = isKakao ? /#\{([^}]+)\}/g : /\{([^}]+)\}/g;
const matchedKeys = [...str.matchAll(regex)].map(m => m[1]);
 
const allReplaceCodeKeys = [
MobileMessageReplaceCode.MEMBER,
MobileMessageReplaceCode.GOODS,
MobileMessageReplaceCode.ORDER,
MobileMessageReplaceCode.PROMOTION,
MobileMessageReplaceCode.BOARD,
MobileMessageReplaceCode.REGULAR,
MobileMessageReplaceCode.PRESENT,
].flatMap(category => MobileMessageReplaceCode.getAllKeys(category));
 
return matchedKeys.filter(key =>
allReplaceCodeKeys.includes(key) || key.startsWith('short_link_')
);
}
 
function convertReplaceKeysToSmsReplaceKeys(content, replaceKeys) {
if (!content || !replaceKeys.length) return content;
return replaceKeys.reduce((result, key) => {
const pattern = new RegExp('(?<!\\$)\\{' + key + '\\}', 'g');
return result.replace(pattern, '${' + key + '}');
}, content);
}
 
function getExpectTargetCountSeparated() {
const recipientType = document.querySelector('input[name="recipientType"]:checked')?.value;
return defaultResult;
}
 
function validateSendPassword(sendPassword) {
return new Promise((resolve) => {
$.ajax({
url: './mobile_send_ps.php',
type: 'POST',
data: { mode: 'validateSendPassword', sendPassword },
dataType: 'json',
success: (response) => {
if (response.success) return resolve({ success: true });
resolve({ success: false, message: response.message, subMessage: response.data?.subMessage });
},
error: () => {
resolve({ success: false, message: '일시적인 오류가 발생하였습니다.<br>다시 시도해 주세요.' });
}
});
});
}
 
function validateCallNumber() {
return new Promise((resolve) => {
$.ajax({
return messagePoint >= requiredPoint;
}
 
function validateSendMethodContent() {
const sendMethod = document.querySelector('input[name="sendMethod"]:checked').value;
switch (sendMethod) {
case 'SMS':
const r = validateMessageInput();
if (!r.success) return r;
break;
case 'FRIENDTALK':
const campaignMessageType = document.querySelector('input[name="campaignMessageType"]:checked')?.value;
switch (campaignMessageType) {
case 'TEXT':
for (const r of [validateMessageInput(), validateCampaignName(), validateLinkButtons(), validateCoupon()]) {
if (!r.success) return r;
}
break;
case 'IMAGE':
for (const r of [validateMessageInput(), validateCampaignName(), validateCampaignImage(), validateCampaignImageLink(), validateLinkButtons(), validateCoupon()]) {
if (!r.success) return r;
}
break;
case 'WIDE_IMAGE':
for (const r of [validateMessageInput(), validateMessageContainsLink(campaignMessageType), validateCampaignName(), validateCampaignImage(), validateCampaignImageLink(), validateMessageContainsUrl(), validateLinkButtons(), validateCoupon()]) {
if (!r.success) return r;
}
break;
case 'WIDE_ITEM_LIST':
for (const r of [validateCampaignName(), validateLinkButtons(), validateCoupon(), validateItemList()]) {
if (!r.success) return r;
}
break;
case 'CAROUSEL_FEED':
// 캐러셀인경우 현재 상태 저장
window.carouselState.saveCurrentCarouselData();
for (const r of [validateCampaignName(), validateCarousel()]) {
if (!r.success) return r;
}
break;
}
break;
case 'MYAPP':
for (const r of [validateMessageInput(), validateMyappPushTitle(), validateUnsubscribeGuide(), validateMyappPushUrlCheck()]) {
if (!r.success) return r;
}
break;
}
 
return {success: true};
}
 
function validateCarousel() {
let slideIndex = 1;
for (let index = 0; index < window.carouselState.carousels.length; index++) {
const carousel = window.carouselState.carousels[index];
if (carousel.isDeleted) continue;
 
// 캐러셀 이미지 검증
if (!carousel.imageUrl) {
window.carouselState.setActiveIndex(index);
const response = validateCampaignImage(slideIndex);
if (!response.success) return response;
}
 
// 캐러셀 이미지 링크 검증
if (!carousel.imageLink || !(/^https?:\/\//.test(carousel.imageLink))) {
window.carouselState.setActiveIndex(index);
const response = validateCampaignImageLink(slideIndex);
if (!response.success) return response;
}
 
// 캐러셀 메시지 검증
if (!carousel.message.replace(/^\s+|\s+$/g, '')) {
window.carouselState.setActiveIndex(index);
const response = validateMessageInput(index, slideIndex);
if (!response.success) return response;
} else {
const response = validateMessageContainsLink('CAROUSEL_FEED', index, slideIndex);
if (!response.success) return response;
}
 
// 캐러셀 링크버튼 검증
if (carousel.buttons.length > 0) {
const urlPattern = /^https?:\/\//;
let shouldValidate = false;
for (const button of carousel.buttons) {
if (!button.name) shouldValidate = true;
const { iosUrl, aosUrl, mobileUrl } = button.url;
if (!shouldValidate) {
if (iosUrl || aosUrl) {
shouldValidate = !iosUrl || !aosUrl || !urlPattern.test(iosUrl) || !urlPattern.test(aosUrl);
} else {
shouldValidate = !mobileUrl || !urlPattern.test(mobileUrl);
}
}
}
 
if (shouldValidate) {
window.carouselState.setActiveIndex(index);
const response = validateLinkButtons(slideIndex);
if (!response.success) return response;
}
}
 
// 캐러셀 쿠폰 검증
if (carousel.coupon) {
if (!carousel.coupon.title || !carousel.coupon.description || !carousel.coupon.url || !(/^https?:\/\//.test(carousel.coupon.url))) {
const response = validateCoupon(slideIndex);
if (!response.success) return response;
}
}
slideIndex++;
}
return {success: true};
}
 
function validateMessageInput(carouselIndex = null, carouselSlideIndex = null) {
const messageContent = carouselIndex !== null ? window.carouselState.carousels[carouselIndex].message.replace(/^\s+|\s+$/g, '') : window.messageInput.getValue().replace(/^\s+|\s+$/g, '');
if (!messageContent) {
if (carouselIndex !== null) {
window.carouselState.setActiveIndex(carouselIndex);
}
NCDSValidator.highlight(window.messageInput.getTextareaContainer());
const message = carouselIndex ? `캐러셀 ${carouselSlideIndex}번 내용을 입력해주세요.` : '발송할 내역이 없습니다.';
return {success: false, message, subMessage: '메시지 내용을 입력 해주세요.'};
}
 
return {success: true};
}
 
function validateMessageContainsLink(campaignMessageType, carouselIndex = null, carouselSlideIndex = null) {
const urlPattern = /https?:\/\//;
const content = carouselIndex !== null ? window.carouselState.carousels[carouselIndex].message.replace(/^\s+|\s+$/g, '') : window.messageInput.getValue().replace(/^\s+|\s+$/g, '');
if (urlPattern.test(content)) {
if (carouselIndex !== null) {
window.carouselState.setActiveIndex(carouselIndex);
}
NCDSValidator.highlight(window.messageInput.getTextareaContainer());
const label = campaignMessageType === 'CAROUSEL_FEED' ? '캐러셀' : '와이드 이미지';
const message = carouselIndex
? `${label} ${carouselSlideIndex}번 메시지 타입은 메시지 내용에 링크(URL)를 포함할 수 없습니다.`
: `${label} 메시지 타입은 메시지 내용에 링크(URL)를 포함할 수 없습니다.`;
return {success: false, message};
}
return {success: true};
}
 
function validateCampaignName() {
const campaignNameContainer = document.querySelector('input[name="friendtalkCampaignName"]');
const campaignNameText = campaignNameContainer?.value?.replace(/^\s+|\s+$/g, '') ?? '';
if (!campaignNameText) {
NCDSValidator.highlight(campaignNameContainer);
return {success: false, message: '캠페인명을 입력해주세요.'};
}
return {success: true};
}
 
function validateCampaignImage(carouselIndex = null) {
const campaignImageUrl = document.querySelector('input[name="friendtalkCampaignImageUrl"]');
if (!campaignImageUrl?.value) {
const message = carouselIndex ? `캐러셀 ${carouselIndex}번 이미지를 등록해주세요.` : '이미지를 등록해주세요.';
const imageButton = document.querySelector('#campaign-image-file-input-container .ncua-image-file-input');
if (imageButton) {
NCDSValidator.highlight(imageButton);
}
return {success: false, message};
}
return {success: true};
}
 
function validateCampaignImageLink(carouselIndex = null) {
const campaignImageLink = document.querySelector('input[name="friendtalkCampaignImageLink"]');
if (!campaignImageLink?.value) {
const message = carouselIndex ? `캐러셀 ${carouselIndex}번 WEB 링크를 입력해주세요.` : 'WEB 링크를 입력해주세요.';
NCDSValidator.highlight(campaignImageLink);
return {success: false, message};
}
 
const urlPattern = /^https?:\/\//;
const urlFormatMessage = carouselIndex
? `캐러셀 ${carouselIndex}번 http:// 또는 https:// 로 시작하는 URL을 입력해주세요.`
: 'http:// 또는 https:// 로 시작하는 URL을 입력해주세요.';
 
if (!urlPattern.test(campaignImageLink.value)) {
NCDSValidator.highlight(campaignImageLink);
return {success: false, message: urlFormatMessage};
}
 
return {success: true};
}
 
function validateMessageContainsUrl() {
const messageContent = window.messageInput.getValue();
const isContainUrl = /https?:\/\/[^\s]+/.test(messageContent);
 
if (isContainUrl) {
NCDSValidator.highlight(window.messageInput.getTextareaContainer());
return {success: false, message: '와이드 이미지 메시지 타입은 메시지 내용에 링크(URL)을 포함할 수 없습니다.'};
}
return {success: true};
}
 
function validateLinkButtons(carouselIndex = null) {
const rows = document.querySelectorAll('#linkButtonTableBody tr');
for (const row of rows) {
const buttonName = row.querySelector('.ncua-link-button-name');
if (!buttonName?.value) {
NCDSValidator.highlight(buttonName);
const message = carouselIndex ? `캐러셀 ${carouselIndex}번 버튼명을 입력해주세요.` : '버튼명을 입력해주세요.';
return {success: false, message};
}
 
const webUrl = row.querySelector('input[name="linkButtonWebUrl"]');
if (webUrl && !webUrl.value) {
NCDSValidator.highlight(webUrl);
const message = carouselIndex ? `캐러셀 ${carouselIndex}번 WEB 링크를 입력해주세요.` : 'WEB 링크를 입력해주세요.';
return {success: false, message};
}
 
const urlPattern = /^https?:\/\//;
const urlFormatMessage = carouselIndex
? `캐러셀 ${carouselIndex}번 http:// 또는 https:// 로 시작하는 URL을 입력해주세요.`
: 'http:// 또는 https:// 로 시작하는 URL을 입력해주세요.';
if (webUrl?.value && !urlPattern.test(webUrl.value)) {
NCDSValidator.highlight(webUrl);
return {success: false, message: urlFormatMessage};
}
 
const iosUrl = row.querySelector('input[name="linkButtonIOSUrl"]');
const aosUrl = row.querySelector('input[name="linkButtonAOSUrl"]');
if (iosUrl && (!iosUrl.value || !aosUrl?.value)) {
if (!iosUrl.value) NCDSValidator.highlight(iosUrl);
if (!aosUrl?.value) NCDSValidator.highlight(aosUrl);
const message = carouselIndex ? `캐러셀 ${carouselIndex}번 iOS, AOS 링크 모두 입력해주세요.` : 'iOS, AOS 링크 모두 입력해주세요.';
return {success: false, message};
}
if (iosUrl?.value && !urlPattern.test(iosUrl.value)) {
NCDSValidator.highlight(iosUrl);
return {success: false, message: urlFormatMessage};
}
if (aosUrl?.value && !urlPattern.test(aosUrl.value)) {
NCDSValidator.highlight(aosUrl);
return {success: false, message: urlFormatMessage};
}
}
return {success: true};
}
 
function validateCoupon(carouselIndex = null) {
const couponTable = document.querySelector('#kakaoFriendTalkcouponTable');
if (couponTable) {
const selectCoupon = couponTable.querySelector('#selectCoupon');
if (selectCoupon) {
NCDSValidator.highlight(selectCoupon);
const message = carouselIndex ? `캐러셀 ${carouselIndex}번 쿠폰을 선택해주세요.` : '쿠폰을 선택해주세요.';
return {success: false, message};
}
 
const couponTitle = couponTable.querySelector('input[name="couponTitle"]');
const couponDescription = couponTable.querySelector('input[name="couponDescription"]');
const couponUrl = couponTable.querySelector('input[name="couponUrl"]');
 
if (couponTitle) {
const validCouponUrl = /^https?:\/\//.test(couponUrl?.value ?? '');
if (!couponTitle.value || !couponDescription.value || !couponUrl.value || !validCouponUrl) {
if (!couponTitle.value) NCDSValidator.highlight(couponTitle);
if (!couponDescription.value) NCDSValidator.highlight(couponDescription);
if (!couponUrl.value || !validCouponUrl) NCDSValidator.highlight(couponUrl);
 
if (!couponTitle.value) {
const message = carouselIndex ? `캐러셀 ${carouselIndex}번 쿠폰 타이틀을 입력해주세요.` : '쿠폰 타이틀을 입력해주세요.';
return {success: false, message};
}
 
if (!couponDescription.value) {
const message = carouselIndex ? `캐러셀 ${carouselIndex}번 쿠폰 설명을 입력해주세요.` : '쿠폰 설명을 입력해주세요.';
return {success: false, message};
}
 
if (!couponUrl.value) {
const message = carouselIndex ? `캐러셀 ${carouselIndex}번 WEB 링크를 입력해주세요.` : 'WEB 링크를 입력해주세요.';
return {success: false, message};
}
 
if (!validCouponUrl) {
const message = carouselIndex ? `캐러셀 ${carouselIndex}번 http:// 또는 https:// 로 시작하는 URL을 입력해주세요.` : 'http:// 또는 https:// 로 시작하는 URL을 입력해주세요.';
return {success: false, message};
}
}
}
}
return {success: true};
}
 
function validateItemList() {
const kakaoFriendtalkItemListSection = document.querySelectorAll('.js-kakao-friendtalk-itemlist-section tbody tr');
 
if (kakaoFriendtalkItemListSection.length < 3 || kakaoFriendtalkItemListSection.length > 4) {
return {success: false, message: '와이드 아이템 리스트는 최소 3개 , 최대 4개까지 가능합니다.'};
}
 
for (const item of kakaoFriendtalkItemListSection) {
const itemImageUrl = item.querySelector('input[name="itemImageUrl"]');
const itemTitleContainer = item.querySelector('input[name="itemTitle"]');
const itemLinkContainer = item.querySelector('input[name="itemWebLink"]');
 
if (!itemImageUrl?.value) {
const imageButton = item.querySelector('.campaign-item-image-file-input-container .ncua-image-file-input');
if (imageButton) {
NCDSValidator.highlight(imageButton);
}
return {success: false, message: '이미지를 등록해주세요.'};
}
 
if (!itemTitleContainer?.value?.replace(/^\s+|\s+$/g, '')) {
NCDSValidator.highlight(itemTitleContainer);
return {success: false, message: '타이틀을 입력해주세요.'};
}
 
if (!itemLinkContainer?.value?.replace(/^\s+|\s+$/g, '')) {
NCDSValidator.highlight(itemLinkContainer);
return {success: false, message: 'WEB 링크를 입력해주세요.'};
}
 
const urlPattern = /^https?:\/\//;
const message = 'http:// 또는 https:// 로 시작하는 URL을 입력해주세요.';
if (!urlPattern.test(itemLinkContainer.value)) {
NCDSValidator.highlight(itemLinkContainer);
return {success: false, message};
}
}
 
return {success: true};
}
 
function validateMyappPushTitle() {
const myappPushTitleContainer = document.querySelector('input[name="myappTitle"]');
if (!myappPushTitleContainer.value) {
NCDSValidator.highlight(myappPushTitleContainer);
return {success: false, message: '푸시 제목을 입력해주세요.'};
}
 
return {success: true};
}
 
function validateUnsubscribeGuide() {
const myappNotificationType = document.querySelector('input[type="radio"][name="myappNotificationType"]:checked').value;
if (myappNotificationType === 'AD') {
const unsubscribeGuideContainer = document.querySelector('input[name="myappUnsubscribeGuide"]');
if (!unsubscribeGuideContainer.value) {
NCDSValidator.highlight(unsubscribeGuideContainer);
return {success: false, message: '수신동의 철회 방법을 입력해주세요.'};
}
}
return {success: true};
}
 
function validateMyappPushUrlCheck(){
const pushUrlPathContainer = document.querySelector('input[name="myappPushPath"]');
// URL 경로가 비어있으면 검증 불필요 (메인페이지 이동)
if (!pushUrlPathContainer.value) {
return {success: true};
}
if (document.querySelector('input[name="validatePushUrl"]').value !== 'y') {
NCDSValidator.highlight(pushUrlPathContainer);
return {success: false, message: '입력한 푸시 URL을 검증해주세요.'};
}
return {success: true};
}
 
function getExpectTargetCount() {
const recipientType = document.querySelector('[name="recipientType"]:checked')?.value
 
변경 후mobile_send.php
<script defer src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/charcount.js')?> "></script>
<script type="text/javascript" src="<?= PATH_ADMIN_GD_SHARE ?>script/crm/crm_group_description.js"></script>
<script type="text/javascript" src="<?= PATH_ADMIN_GD_SHARE ?>script/crm/mobile_message_replace_code.js"></script>
<script type="text/javascript" src="<?= PATH_ADMIN_GD_SHARE ?>script/crm/message_send_common.js"></script>
<script type="text/javascript" src="<?= PATH_ADMIN_GD_SHARE ?>script/crm/crm_group_status.js"></script>
<script type="text/javascript" src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/ncds-checkbox-group/ncds-checkbox-group.js') ?>"></script>
<script type="text/javascript" src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/ncds-datepicker-factory/ncds-datepicker-factory.js') ?>"></script>
<script defer src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/scroll-sticky.js') ?>"></script>
].flatMap(category => Object.keys(category));
 
const alternativeContents = mainContents.replace(/#\{([a-zA-Z_0-9]+)}/g, (match, key) => {
return allKeys.includes(MobileMessageReplaceCode.normalizeReplaceKey(key)) ? `{${key}}` : match;
});
window.alternativeMessageInput.insertText(alternativeContents);
window.alternativePreview.setContent(alternativeContents);
}
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
function getSendData() {
const sendMethod = document.querySelector('input[name="sendMethod"]:checked').value;
};
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
function getExpectTargetCountSeparated() {
const recipientType = document.querySelector('input[name="recipientType"]:checked')?.value;
return defaultResult;
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
function validateCallNumber() {
return new Promise((resolve) => {
$.ajax({
return messagePoint >= requiredPoint;
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
function getExpectTargetCount() {
const recipientType = document.querySelector('[name="recipientType"]:checked')?.value
 
Admin/crm/popup_create_crm_group_frame.php+9-5
변경 전popup_create_crm_group_frame.php
<script src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/aggregator-message-router.js') ?>"></script>
<iframe name="popupCreateCrmGroup" src="<?= htmlspecialchars($pageUrl, ENT_QUOTES, 'UTF-8') ?>" frameborder="0" marginwidth="0" marginheight="0" width="100%" style="height: 100vh;"></iframe>
<script>
window.addEventListener('message', function (event) {
if (event.data.type === 'MODAL_CLOSE') {
const { no, title } = event.data?.payload ?? {};
opener.selectCreatedCrmGroup(no, title);
}
 
 
 
 
});
</script>
 
변경 후popup_create_crm_group_frame.php
<script src="<?= gd_set_browser_cache(PATH_ADMIN_GD_SHARE . 'ncds/script/common/aggregator-message-router.js') ?>"></script>
<iframe name="popupCreateCrmGroup" src="<?= htmlspecialchars($pageUrl, ENT_QUOTES, 'UTF-8') ?>" frameborder="0" marginwidth="0" marginheight="0" width="100%" style="height: 100vh;"></iframe>
<script>
// aggregator가 그룹 생성 성공 시 MODAL_CLOSE 전송 — iframe은 창을 못 닫으므로 여기서 opener 반영 후 닫는다.
IframeMessageRouter.init({
onModalClose: function (payload) {
const { no, title } = payload ?? {};
if (no) {
opener?.selectCreatedCrmGroup?.(no, title);
}
window.close();
},
});
</script>
 
Component/Member/MemberAdmin.php+64-37
변경 전MemberAdmin.php
use Origin\Service\WebHook\MemberWebHookService;
use Request;
use Session;
use Origin\Model\Member\Member as MemberModel;
 
/**
* Class 관리자에서 사용하는 회원 관리
/**
* 회원 알림 치환코드 조회
*
* @param array $memberNos
 
*
* @return array
*/
public function getNotificationReplaceCodes(array $memberNos): array
{
$this->db = \App::load('DB');
$members = MemberModel::from('es_member as m')
->leftJoin('es_memberHistory as mh', function ($join){
$join->on('mh.memNo', '=', 'm.memNo')
->where('mh.updateColumn', '=', '\'SMS수신동의\'');
})
->leftJoin('es_memberHistory as mh2', function ($join){
$join->on('mh2.memNo', '=', 'm.memNo')
->where('mh2.updateColumn', '=', '\'메일수신동의\'');
})
->leftJoin('es_memberGroup as mg', 'm.groupSno', '=', 'mg.sno')
->whereIn("m.memNo", $memberNos)
->selectRaw('m.memNo, m.memId, m.memNm, m.groupSno, m.mileage, m.deposit, m.smsFl, m.maillingFl, m.cellPhone, m.email, m.entryDt, m.sleepWakeDt, m.expirationFl, mg.groupNm')
->selectRaw('IF(m.smsFl = \'y\', IF(mh.updateColumn IS NULL, IF(m.entryDt < \'2014-11-29\', \'2014-11-28 00:00:00\', m.entryDt), MAX(mh.regDt)), null) AS smsAgreementDt')
->selectRaw('IF(m.maillingFl = \'y\', IF(mh2.updateColumn IS NULL, IF(m.entryDt < \'2014-11-29\', \'2014-11-28 00:00:00\', m.entryDt), MAX(mh2.regDt)), null) AS mailAgreementDt')
->groupBy('m.memNo')
->get();
$members = $members ? $members->toArray() : [];
 
$basicInfoConfigs = gd_policy('basic.info');
$replaceCodes = [];
$members = $sleepService->getSleepScheduleList($members, $memberSleepConfig['useFl']);
 
foreach ($members as $member) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
$replaceCodes[] = [
'memNo' => (int) $member['memNo'],
'codes' => [
'rc_mallNm' => $basicInfoConfigs['mallNm'],
'memId' => $member['memId'],
'memNm' => $member['memNm'],
'sleepScheduleDt' => $member['sleepScheduleDt'],
'rc_scheduleDt' => DateTimeUtils::dateFormat('Y년 m월 d일', $member['sleepWakeDt']),
'smsAgreementDt' => DateTimeUtils::dateFormat('Y-m-d', $member['smsAgreementDt']),
'mailAgreementDt' => DateTimeUtils::dateFormat('Y-m-d', $member['mailAgreementDt']),
'smsAgreementFl' => $member['smsFl'],
'mailAgreementFl' => $member['maillingFl'],
'groupNm' => $member['groupNm'],
'mileage' => number_format($member['mileage']),
'deposit' => number_format($member['deposit']),
'passJoin' => $this->calculatePassJoinDays($member['entryDt']),
'shopUrl' => URI_HOME
]
];
}
 
return $replaceCodes;
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
protected function calculatePassJoinDays(?string $entryDt): string
{
$start = $entryDt && $entryDt != '0000-00-00 00:00:00'
 
변경 후MemberAdmin.php
use Origin\Service\WebHook\MemberWebHookService;
use Request;
use Session;
 
 
/**
* Class 관리자에서 사용하는 회원 관리
/**
* 회원 알림 치환코드 조회
*
* @param array $memberNos
* @param array|null $replaceKeys 평탄화된 _Nd 키 포함 가능 (예: ['expireCoupon_7d', 'memId'])
*
* @return array
*/
public function getNotificationReplaceCodes(array $memberNos, ?array $replaceKeys = null): array
{
$expireDays = $this->extractExpireDays($replaceKeys);
$couponDays = $expireDays['expireCoupon'];
$mileageDays = $expireDays['expireMileage'];
$needCouponJoin = !empty($couponDays) || in_array('useCouponCnt', $replaceKeys ?? [], true);
 
$members = $this->memberDAO->selectNotificationReplaceData($memberNos, $couponDays, $mileageDays, $needCouponJoin);
 
 
 
 
 
 
 
 
 
 
 
 
 
$basicInfoConfigs = gd_policy('basic.info');
$replaceCodes = [];
$members = $sleepService->getSleepScheduleList($members, $memberSleepConfig['useFl']);
 
foreach ($members as $member) {
$codes = [
'rc_mallNm' => $basicInfoConfigs['mallNm'],
'memId' => $member['memId'],
'memNm' => $member['memNm'],
'sleepScheduleDt' => $member['sleepScheduleDt'],
'rc_scheduleDt' => DateTimeUtils::dateFormat('Y년 m월 d일', $member['sleepWakeDt']),
'smsAgreementDt' => DateTimeUtils::dateFormat('Y-m-d', $member['smsAgreementDt']),
'mailAgreementDt' => DateTimeUtils::dateFormat('Y-m-d', $member['mailAgreementDt']),
'smsAgreementFl' => $member['smsFl'],
'mailAgreementFl' => $member['maillingFl'],
'groupNm' => $member['groupNm'],
'mileage' => number_format($member['mileage']),
'deposit' => number_format($member['deposit']),
'passJoin' => $this->calculatePassJoinDays($member['entryDt']),
'shopUrl' => URI_HOME,
];
if ($needCouponJoin) {
$codes['useCouponCnt'] = (string) $member['useCouponCnt'];
foreach ($couponDays as $n) {
$key = "expireCoupon_{$n}d";
$codes[$key] = (string) ($member[$key] ?? '');
}
}
foreach ($mileageDays as $n) {
$key = "expireMileage_{$n}d";
$codes[$key] = number_format((int) ($member[$key] ?? 0));
}
$filteredCodes = empty($replaceKeys)
? $codes
: array_intersect_key($codes, array_flip($replaceKeys));
// 매칭되는 키가 하나도 없으면 기본 전체 코드로 폴백 (replaceKeys 미전달 시와 동일)
$replaceCodes[] = [
'memNo' => (int) $member['memNo'],
'codes' => empty($filteredCodes) ? $codes : $filteredCodes,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
];
}
 
return $replaceCodes;
}
 
/**
* replaceKeys에서 _Nd 평탄화 키의 N값 목록 추출 → {expireCoupon: [int], expireMileage: [int]}
*/
protected function extractExpireDays(?array $replaceKeys): array
{
$result = ['expireCoupon' => [], 'expireMileage' => []];
if (empty($replaceKeys)) {
return $result;
}
foreach ($replaceKeys as $key) {
if (!is_string($key)) {
continue;
}
if (preg_match('/^expire(Coupon|Mileage)_(\d+)d$/', $key, $m)) {
$type = $m[1] === 'Coupon' ? 'expireCoupon' : 'expireMileage';
$result[$type][] = (int) $m[2];
}
}
$result['expireCoupon'] = array_values(array_unique($result['expireCoupon']));
$result['expireMileage'] = array_values(array_unique($result['expireMileage']));
return $result;
}
 
protected function calculatePassJoinDays(?string $entryDt): string
{
$start = $entryDt && $entryDt != '0000-00-00 00:00:00'
 
Component/Member/MemberDAO.php+101-1
변경 전MemberDAO.php
$query->selectRaw('COUNT(DISTINCT mc.memberCouponNo) AS useCouponCnt');
foreach ($couponDays as $n) {
// couponNo 높은 1개만 노출
$query->selectRaw("SUBSTRING_INDEX(GROUP_CONCAT(IF(mc.memberCouponEndDate BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL {$n} DAY), cp.couponNm, NULL) ORDER BY mc.couponNo DESC SEPARATOR 0x1D), 0x1D, 1) AS expireCoupon_{$n}d");
}
}
foreach ($mileageDays as $n) {
use Framework\Utility\StringUtils;
use Framework\Security\Digester;
use Framework\Utility\GodoUtils;
 
use Exception;
 
/**
$this->db->strOrder = StringUtils::strIsSet($params['sort'], 'm.memNo desc');
$this->db->strJoin .= ' LEFT JOIN ' . DB_MEMBER_SNS . ' AS ms ON m.memNo = ms.memNo';
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
}
 
변경 후MemberDAO.php
$query->selectRaw('COUNT(DISTINCT mc.memberCouponNo) AS useCouponCnt');
foreach ($couponDays as $n) {
// couponNo 높은 1개만 노출
$query->selectRaw("SUBSTRING_INDEX(GROUP_CONCAT(IF(DATE(mc.memberCouponEndDate) = DATE(DATE_ADD(NOW(), INTERVAL {$n} DAY)), cp.couponNm, NULL) ORDER BY mc.couponNo DESC SEPARATOR 0x1D), 0x1D, 1) AS expireCoupon_{$n}d");
}
}
foreach ($mileageDays as $n) {
use Framework\Utility\StringUtils;
use Framework\Security\Digester;
use Framework\Utility\GodoUtils;
use Origin\Model\Member\Member as MemberModel;
use Exception;
 
/**
$this->db->strOrder = StringUtils::strIsSet($params['sort'], 'm.memNo desc');
$this->db->strJoin .= ' LEFT JOIN ' . DB_MEMBER_SNS . ' AS ms ON m.memNo = ms.memNo';
}
 
/**
* 알림 치환코드 산출에 필요한 회원 원본 데이터 조회
*
* 회원 기본정보 + 수신동의 이력 + (선택)쿠폰/마일리지 만료 집계.
* 비즈니스 조립/포맷팅은 호출측(MemberAdmin::getNotificationReplaceCodes)이 담당한다.
*
* @param int[] $memberNos 대상 회원 번호 목록
* @param int[] $couponDays 쿠폰 만료 집계 일수 목록 (expireCoupon_Nd 의 N)
* @param int[] $mileageDays 마일리지 만료 집계 일수 목록 (expireMileage_Nd 의 N)
* @param bool $needCouponJoin 쿠폰 조인 필요 여부 (만료 쿠폰 또는 useCouponCnt 요청 시)
* @return array 회원별 원본 행 배열
*/
public function selectNotificationReplaceData(array $memberNos, array $couponDays, array $mileageDays, bool $needCouponJoin): array
{
$couponDays = array_map('intval', $couponDays);
$mileageDays = array_map('intval', $mileageDays);
 
$query = MemberModel::from('es_member as m')
->leftJoin('es_memberHistory as mh', function ($join) {
$join->on('mh.memNo', '=', 'm.memNo')
->where('mh.updateColumn', 'SMS수신동의');
})
->leftJoin('es_memberHistory as mh2', function ($join) {
$join->on('mh2.memNo', '=', 'm.memNo')
->where('mh2.updateColumn', '메일수신동의');
})
->leftJoin('es_memberGroup as mg', 'm.groupSno', '=', 'mg.sno');
 
if ($needCouponJoin) {
$query->leftJoin('es_memberCoupon as mc', function ($join) {
$join->on('mc.memNo', '=', 'm.memNo')
->where(function ($join) {
$join->where('mc.memberCouponState', 'y')
->orWhere(function ($join) {
$join->where('mc.memberCouponState', 'cart')
->where('mc.memberCouponCartDate', '>', '0000-00-00 00:00:00');
});
})
->whereRaw('mc.memberCouponStartDate <= NOW()')
->whereRaw('mc.memberCouponEndDate >= NOW()');
});
if (!empty($couponDays)) {
$query->leftJoin('es_coupon as cp', 'cp.couponNo', '=', 'mc.couponNo');
}
}
 
if (!empty($mileageDays)) {
// 마일리지 합계는 mh/mh2·쿠폰 조인의 행 곱셈에 오염되지 않도록 회원별로 미리 집계한 서브쿼리로 분리한다.
$mileageSubQuery = $this->buildExpiringMileageSubQuery($mileageDays, $memberNos);
$query->leftJoinSub($mileageSubQuery, 'mm', 'mm.memNo', '=', 'm.memNo');
}
 
$query->whereIn('m.memNo', $memberNos)
->selectRaw('m.memNo, m.memId, m.memNm, m.groupSno, m.mileage, m.deposit, m.smsFl, m.maillingFl, m.cellPhone, m.email, m.entryDt, m.sleepWakeDt, m.expirationFl, mg.groupNm')
->selectRaw('IF(m.smsFl = \'y\', IF(mh.updateColumn IS NULL, IF(m.entryDt < \'2014-11-29\', \'2014-11-28 00:00:00\', m.entryDt), MAX(mh.regDt)), null) AS smsAgreementDt')
->selectRaw('IF(m.maillingFl = \'y\', IF(mh2.updateColumn IS NULL, IF(m.entryDt < \'2014-11-29\', \'2014-11-28 00:00:00\', m.entryDt), MAX(mh2.regDt)), null) AS mailAgreementDt');
 
if ($needCouponJoin) {
$query->selectRaw('COUNT(DISTINCT mc.memberCouponNo) AS useCouponCnt');
foreach ($couponDays as $n) {
// couponNo 높은 1개만 노출
$query->selectRaw("SUBSTRING_INDEX(GROUP_CONCAT(IF(mc.memberCouponEndDate BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL {$n} DAY), cp.couponNm, NULL) ORDER BY mc.couponNo DESC SEPARATOR 0x1D), 0x1D, 1) AS expireCoupon_{$n}d");
}
}
foreach ($mileageDays as $n) {
// 서브쿼리에서 회원별로 이미 합산된 값을 그대로 가져온다 (조인 곱셈 영향 없음).
$query->selectRaw("mm.expireMileage_{$n}d");
}
 
$members = $query->groupBy('m.memNo')->get();
 
return $members ? $members->toArray() : [];
}
 
/**
* 회원별 만료 예정 마일리지 합계 서브쿼리를 만든다.
*
* 메인 쿼리에서 직접 SUM 하면 mh/mh2(수신동의 이력)·쿠폰 LEFT JOIN 의 행 곱셈만큼 합계가 부풀려진다.
* 이를 막기 위해 es_memberMileage 를 회원별(memNo)로 GROUP BY 하여 1행으로 미리 집계한 뒤 조인한다.
*
* @param int[] $mileageDays 만료 임박 기준 일수 목록 (검증된 정수)
* @param array $memberNos
*
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function buildExpiringMileageSubQuery(array $mileageDays, array $memberNos)
{
$subQuery = MemberModel::from('es_memberMileage')
->whereIn('deleteFl', ['n', 'use'])
->where('mileage', '>', 0)
->whereIn('memNo', $memberNos)
->groupBy('memNo')
->select('memNo');
foreach ($mileageDays as $n) {
$subQuery->selectRaw("COALESCE(SUM(IF(DATE(deleteScheduleDt) = DATE(DATE_ADD(NOW(), INTERVAL {$n} DAY)), mileage, 0)), 0) AS expireMileage_{$n}d");
}
return $subQuery;
}
}
 
Controller/Admin/Crm/CrmRecipeClickLogPsController.php+46
변경 전CrmRecipeClickLogPsController.php
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
변경 후CrmRecipeClickLogPsController.php
<?php
/*
* Copyright (C) 2026 NHN COMMERCE. - All Rights Reserved
*
* Unauthorized copying or redistribution of this file in source and binary forms via any medium
* is strictly prohibited.
*/
 
namespace Bundle\Controller\Admin\Crm;
 
use Framework\Http\Request;
use Origin\Service\Crm\CrmRecipeClickLogCollectorService;
 
class CrmRecipeClickLogPsController extends \Controller\Admin\Controller
{
private const ALLOWED_ACTIONS = [
CrmRecipeClickLogCollectorService::ACTION_SEND,
CrmRecipeClickLogCollectorService::ACTION_CLOSE,
];
 
public function index()
{
$this->callMenu('crm', 'crm', 'crmRecipe');
$this->setMenuCode('crm', 'crm', 'crmRecipe');
 
/** @var Request $request */
$request = \App::getInstance('request');
$postData = $request->post()->toArray();
 
$action = $postData['action'] ?? '';
if (!in_array($action, self::ALLOWED_ACTIONS, true)) {
$this->json(['success' => false]);
return;
}
 
/** @var CrmRecipeClickLogCollectorService $clickLogCollector */
$clickLogCollector = \App::getInstance(CrmRecipeClickLogCollectorService::class);
$clickLogCollector->collect([
'action' => $action,
'recipeType' => $postData['recipeType'] ?? '',
'currentPage' => $postData['currentPage'] ?? '',
]);
 
$this->json(['success' => true]);
}
}
 
Controller/Admin/Crm/CrmRecipeSendController.php+154
변경 전CrmRecipeSendController.php
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
변경 후CrmRecipeSendController.php
<?php
/*
* Copyright (C) 2026 NHN COMMERCE. - All Rights Reserved
*
* Unauthorized copying or redistribution of this file in source and binary forms via any medium
* is strictly prohibited.
*/
 
namespace Bundle\Controller\Admin\Crm;
 
use Bundle\Component\Godo\GodoSmsServerApi;
use Bundle\Component\Sms\SmsAdmin;
use Component\Sms\Sms;
use Component\Validator\Validator;
use Framework\Debug\Exception\AlertBackException;
use Origin\DTO\ApiClient\Commerce\Crm\TargetsRepeatRuleStatusRequestDTO;
use Origin\Enum\ApiClient\Crm\RecipeType;
use Origin\Service\ApiClient\Commerce\Crm\CrmAdminApiClientService;
use Origin\Service\Crm\Message\MessageConfigService;
 
class CrmRecipeSendController extends \Controller\Admin\Controller
{
protected $messageLogger;
 
public function index()
{
$this->callMenu('crm', 'crm', 'crmRecipe');
$this->setMenuCode('crm', 'crm', 'crmRecipe');
 
$this->messageLogger = \Logger::channel('mobileMessage');
 
$recipeType = (string) \Request::get()->get('recipeType', '');
 
if (!empty($recipeType) && RecipeType::tryFromSlug($recipeType) === null) {
throw new AlertBackException(__('잘못된 접근입니다.'));
}
 
$crmGroupNoRaw = \Request::get()->get('no', '');
 
if (Validator::number($crmGroupNoRaw, 1) === false) {
throw new AlertBackException(__('잘못된 접근입니다.'));
}
 
$crmGroupNo = (int) $crmGroupNoRaw;
 
/** @var CrmAdminApiClientService $crmAdminApiClientService */
$crmAdminApiClientService = \App::getInstance(CrmAdminApiClientService::class);
 
$crmGroup = $crmGroupNo > 0
? $this->fetchSelectedCrmGroup($crmAdminApiClientService, $crmGroupNo)
: [];
 
$recipeDefault = [];
if (!empty($recipeType)) {
try {
$recipeDefault = $crmAdminApiClientService->getRecipeDefault($recipeType)->toRecursiveArray();
} catch (\Throwable $e) {
$this->messageLogger->error('CRM 레시피 기본값 처리 실패', [$e->getMessage(), $recipeType, __METHOD__]);
$recipeDefault = [];
}
}
 
$recipeName = $recipeDefault['recipeTitle'] ?? '레시피';
$conditionGuideView = $this->buildConditionGuideView($recipeDefault['conditionGuide'] ?? []);
 
Sms::saveSmsPoint();
 
/** @var SmsAdmin $smsAdmin */
$smsAdmin = \App::load('Component\\Sms\\SmsAdmin');
$smsAutoData = $smsAdmin->getSmsAutoData();
 
/** @var GodoSmsServerApi $godoSms */
$godoSms = \App::load('Component\\Godo\\GodoSmsServerApi');
$smsPreRegister = $godoSms->checkSmsCallNumber($smsAutoData['smsCallNum']);
 
$hasCallNumber = $smsPreRegister && !empty($smsAutoData['smsCallNum']);
 
/** @var MessageConfigService $messageConfigService */
$messageConfigService = \App::getInstance(MessageConfigService::class);
$messageConfig = $messageConfigService->buildSendableMessageConfig($hasCallNumber);
 
$this->setData('messageConfig', $messageConfig);
$this->setData('smsPointEach', Sms::SMS_POINT);
$this->setData('lmsPointEach', Sms::LMS_POINT);
$this->setData('kakaoAlrimTalkPointEach', Sms::KAKAO_POINT);
$this->setData('kakaoFriendTalkPointEach', Sms::KAKAO_FRIEND_TALK_POINT);
$this->setData('recipeType', $recipeType);
$this->setData('crmGroup', $crmGroup);
$this->setData('recipeName', $recipeName);
$this->setData('recipeDefault', $recipeDefault);
$this->setData('conditionGuideView', $conditionGuideView);
 
// 어드민 헤더·네비 없는 blank 레이아웃 (드로어용)
$this->getView()->setDefine('layout', 'layout_blank_noiframe.php');
$this->getView()->setDefine('layoutHeader', 'header_no_breadcrumb.php');
$this->getView()->setPageName('crm/crm_recipe_send.php');
 
$this->setData('adminBodyClass', trim($this->getData('adminBodyClass') . ' ncds'));
}
 
protected function fetchSelectedCrmGroup(CrmAdminApiClientService $crmAdminApiClientService, int $crmGroupNo): array
{
try {
$crmGroup = $crmAdminApiClientService
->getNotificationTargetsRepeatRuleStatus(new TargetsRepeatRuleStatusRequestDTO(), $crmGroupNo)
->toRecursiveArray();
} catch (\Throwable $e) {
$this->messageLogger->error('CRM 그룹 상태 조회 실패', [$e->getMessage(), $crmGroupNo, __METHOD__]);
return [];
}
 
if (empty($crmGroup)) {
return [];
}
 
// status API 응답의 no 는 내부 레코드 PK라 crmGroupNo 로 덮어씀
$crmGroup['no'] = $crmGroupNo;
 
// status 응답엔 title 이 없어 규칙 단건 조회로 보강
try {
$crmGroup['title'] = $crmAdminApiClientService
->getNotificationTargetsRepeatRule($crmGroupNo)
->getTitle();
} catch (\Throwable $e) {
$this->messageLogger->error('CRM 그룹명 조회 실패', [$e->getMessage(), $crmGroupNo, __METHOD__]);
}
 
return $crmGroup;
}
 
protected function buildConditionGuideView(array $conditionGuide): array
{
$hasConditionGuide = !empty($conditionGuide['selectionGuide'])
&& !empty($conditionGuide['selectableValues'])
&& is_array($conditionGuide['selectableValues']);
 
if (!$hasConditionGuide) {
return ['hasConditionGuide' => false];
}
 
$selectableValues = $conditionGuide['selectableValues'];
$currentValue = $conditionGuide['value'] ?? $selectableValues[0];
$guideParts = explode('{no}', $conditionGuide['selectionGuide'], 2);
 
// 미리보기 문구는 JS(updateConditionPreview)가 단독 계산·렌더 — 날짜 규칙 단일화
return [
'hasConditionGuide' => true,
'selectableValues' => $selectableValues,
'currentValue' => $currentValue,
'guideParts' => $guideParts,
'productSelectableCount' => (int) ($conditionGuide['productSelectableCount'] ?? 0),
];
}
}
 
Controller/Admin/Crm/MobileSend/LayerKakaoFriendtalkContentSettingController.php+16-1
변경 전LayerKakaoFriendtalkContentSettingController.php
 
namespace Bundle\Controller\Admin\Crm\MobileSend;
 
use Framework\Utility\StringUtils;
use Origin\Enum\Crm\Message\CampaignMessageType;
use Origin\Service\Crm\Message\KakaoFriendTalkSettingService;
use Origin\Service\Crm\Message\Sms080RejectService;
 
class LayerKakaoFriendtalkContentSettingController extends \Controller\Admin\Controller
{
 
 
public function index()
{
$this->callMenu('crm', 'messageSend', 'mobileSend');
$campaignMessageTypeUnitPoint[$type->name] = $type->unitPoint();
}
 
 
 
 
 
 
 
 
 
$this->setData('is080RejectAvailable', $is080RejectAvailable);
$this->setData('campaignMessageTypes', CampaignMessageType::cases());
$this->setData('campaignMessageTypeUnitPoints', $campaignMessageTypeUnitPoint);
$this->setData('useSmsAlternativeSendFlag', $kakaoFriendTalkConfig->getSmsAlternativeSendFlag());
$this->setData('linkPlatformType', $kakaoFriendTalkConfig->getLinkPlatformType()->name);
 
 
 
 
 
$this->getView()->setDefine('layout', 'layout_layer.php');
}
}
 
변경 후LayerKakaoFriendtalkContentSettingController.php
 
namespace Bundle\Controller\Admin\Crm\MobileSend;
 
use Bundle\Util\Crm\RecipeContextResolverTrait;
use Origin\Enum\Crm\Message\CampaignMessageType;
use Origin\Service\Crm\Message\KakaoFriendTalkSettingService;
use Origin\Service\Crm\Message\Sms080RejectService;
 
class LayerKakaoFriendtalkContentSettingController extends \Controller\Admin\Controller
{
use RecipeContextResolverTrait;
 
public function index()
{
$this->callMenu('crm', 'messageSend', 'mobileSend');
$campaignMessageTypeUnitPoint[$type->name] = $type->unitPoint();
}
 
$recipeContext = $this->resolveRecipeContext();
$isRecipeContext = $this->hasRecipeContext();
$defaultCampaignRequest = $recipeContext['defaultCampaignRequest'] ?? [];
$campaignName = $recipeContext['recipeTitle'] ?? '';
$friendtalkRequest = $defaultCampaignRequest['kakaoFriendtalkRequest'] ?? [];
$initialTextContent = $friendtalkRequest['text']['content'] ?? '';
$initialAlternativeMessage = $defaultCampaignRequest['smsLmsRequest']['content'] ?? '';
 
$this->setData('is080RejectAvailable', $is080RejectAvailable);
$this->setData('campaignMessageTypes', CampaignMessageType::cases());
$this->setData('campaignMessageTypeUnitPoints', $campaignMessageTypeUnitPoint);
$this->setData('useSmsAlternativeSendFlag', $kakaoFriendTalkConfig->getSmsAlternativeSendFlag());
$this->setData('linkPlatformType', $kakaoFriendTalkConfig->getLinkPlatformType()->name);
$this->setData('isRecipeContext', $isRecipeContext);
$this->setData('campaignName', $campaignName);
$this->setData('friendtalkRequest', $friendtalkRequest);
$this->setData('initialTextContent', $initialTextContent);
$this->setData('initialAlternativeMessage', $initialAlternativeMessage);
$this->getView()->setDefine('layout', 'layout_layer.php');
}
}
 
Controller/Admin/Crm/MobileSend/LayerMyappPushContentSettingController.php+14
변경 전LayerMyappPushContentSettingController.php
 
namespace Bundle\Controller\Admin\Crm\MobileSend;
 
 
use Framework\Utility\StringUtils;
use Origin\Enum\Crm\Message\MyappPushNotificationType;
use Origin\Enum\Crm\Message\MyappPushSendCondition;
 
class LayerMyappPushContentSettingController extends \Controller\Admin\Controller
{
 
 
public function index()
{
$this->callMenu('crm', 'messageSend', 'mobileSend');
$myappPushTemplateService = \App::getInstance(MyappPushTemplateService::class);
$myappPushUrl = $myappPushTemplateService->getMyappDomainUrl();
 
 
 
 
 
 
 
 
$this->setData('is080RejectAvailable', $is080RejectAvailable);
$this->setData('myappPushSendConditions', MyappPushSendCondition::cases());
$this->setData('myappPushNotificationTypes', MyappPushNotificationType::cases());
$this->setData('myappPushSendPlatforms', MyappPushSendPlatform::cases());
$this->setData('myappPushUrl', $myappPushUrl);
 
 
 
 
$this->getView()->setDefine('layout', 'layout_layer.php');
}
}
 
변경 후LayerMyappPushContentSettingController.php
 
namespace Bundle\Controller\Admin\Crm\MobileSend;
 
use Bundle\Util\Crm\RecipeContextResolverTrait;
use Framework\Utility\StringUtils;
use Origin\Enum\Crm\Message\MyappPushNotificationType;
use Origin\Enum\Crm\Message\MyappPushSendCondition;
 
class LayerMyappPushContentSettingController extends \Controller\Admin\Controller
{
use RecipeContextResolverTrait;
 
public function index()
{
$this->callMenu('crm', 'messageSend', 'mobileSend');
$myappPushTemplateService = \App::getInstance(MyappPushTemplateService::class);
$myappPushUrl = $myappPushTemplateService->getMyappDomainUrl();
 
$recipeContext = $this->resolveRecipeContext();
$isRecipeContext = $this->hasRecipeContext();
$myappRequest = $recipeContext['defaultCampaignRequest']['myappRequest'] ?? [];
$initialTitle = $myappRequest['title'] ?? '';
$initialMessage = $myappRequest['content'] ?? '';
$initialAlternativeMessage = $recipeContext['defaultCampaignRequest']['smsLmsRequest']['content'] ?? '';
 
$this->setData('is080RejectAvailable', $is080RejectAvailable);
$this->setData('myappPushSendConditions', MyappPushSendCondition::cases());
$this->setData('myappPushNotificationTypes', MyappPushNotificationType::cases());
$this->setData('myappPushSendPlatforms', MyappPushSendPlatform::cases());
$this->setData('myappPushUrl', $myappPushUrl);
$this->setData('isRecipeContext', $isRecipeContext);
$this->setData('initialTitle', $initialTitle);
$this->setData('initialMessage', $initialMessage);
$this->setData('initialAlternativeMessage', $initialAlternativeMessage);
$this->getView()->setDefine('layout', 'layout_layer.php');
}
}
 
Controller/Admin/Crm/MobileSend/LayerRecipeSendTimeSettingController.php+45
변경 전LayerRecipeSendTimeSettingController.php
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
변경 후LayerRecipeSendTimeSettingController.php
<?php
/*
* Copyright (C) 2026 NHN COMMERCE. - All Rights Reserved
*
* Unauthorized copying or redistribution of this file in source and binary forms via any medium
* is strictly prohibited.
*/
 
namespace Bundle\Controller\Admin\Crm\MobileSend;
 
use Bundle\Util\Crm\RecipeContextResolverTrait;
use Origin\Enum\Common\WeekDay;
use Origin\Enum\Crm\Message\MessageRepeatCycle;
 
class LayerRecipeSendTimeSettingController extends \Controller\Admin\Controller
{
use RecipeContextResolverTrait;
 
public function index()
{
$this->callMenu('crm', 'messageSend', 'mobileSend');
 
$sendSchedule = $this->resolveRecipeContext()['sendSchedule'] ?? [];
$sendTimeView = $this->buildSendTimeView($sendSchedule);
 
$this->setData('messageRepeatCycles', MessageRepeatCycle::cases());
$this->setData('sendTimeView', $sendTimeView);
$this->setData('weekDays', WeekDay::cases());
$this->getView()->setDefine('layout', 'layout_layer.php');
}
 
protected function buildSendTimeView(array $sendSchedule): array
{
$selectedRepeatType = $sendSchedule['repeatType'] ?? 'WEEKLY';
$repeatConfig = $sendSchedule['repeatConfig'] ?? [];
$sendTime = $repeatConfig['sendTime'] ?? '';
 
return [
'selectedRepeatType' => $selectedRepeatType,
'selectedRepeatHour' => $sendTime !== '' ? substr($sendTime, 0, 2) : '',
'selectedRepeatMinutes' => $sendTime !== '' ? substr($sendTime, 3, 2) : '',
'selectedRepeatDays' => $repeatConfig['repeatDays'] ?? [],
];
}
}
 
Controller/Admin/Crm/MobileSend/LayerSendMethodSettingController.php+9
변경 전LayerSendMethodSettingController.php
 
namespace Bundle\Controller\Admin\Crm\MobileSend;
 
 
use Origin\Enum\Crm\Message\SendMethod;
use Origin\Service\Crm\Message\Sms080RejectService;
 
class LayerSendMethodSettingController extends \Controller\Admin\Controller
{
 
 
public function index()
{
$this->callMenu('crm', 'messageSend', 'mobileSend');
$smsRejectPolicy = $sms080RejectService->getConfig();
$smsRejectText = ($smsRejectPolicy['rejectNumber'] != '' && $smsRejectPolicy['status'] == 'O' && $smsRejectPolicy['use'] == 'y') ? "수신거부 {$smsRejectPolicy['rejectNumber']}" : '';
 
 
 
 
 
$this->setData('sendMethods', SendMethod::cases());
$this->setData('smsRejectText', $smsRejectText);
 
 
$this->getView()->setDefine('layout', 'layout_layer.php');
}
}
 
변경 후LayerSendMethodSettingController.php
 
namespace Bundle\Controller\Admin\Crm\MobileSend;
 
use Bundle\Util\Crm\RecipeContextResolverTrait;
use Origin\Enum\Crm\Message\SendMethod;
use Origin\Service\Crm\Message\Sms080RejectService;
 
class LayerSendMethodSettingController extends \Controller\Admin\Controller
{
use RecipeContextResolverTrait;
 
public function index()
{
$this->callMenu('crm', 'messageSend', 'mobileSend');
$smsRejectPolicy = $sms080RejectService->getConfig();
$smsRejectText = ($smsRejectPolicy['rejectNumber'] != '' && $smsRejectPolicy['status'] == 'O' && $smsRejectPolicy['use'] == 'y') ? "수신거부 {$smsRejectPolicy['rejectNumber']}" : '';
 
$recipeContext = $this->resolveRecipeContext();
$isRecipeContext = $this->hasRecipeContext();
$messageSendGuide = $recipeContext['guideMessages']['messageSendGuide'] ?? '';
 
$this->setData('sendMethods', SendMethod::cases());
$this->setData('smsRejectText', $smsRejectText);
$this->setData('isRecipeContext', $isRecipeContext);
$this->setData('messageSendGuide', $messageSendGuide);
$this->getView()->setDefine('layout', 'layout_layer.php');
}
}
 
Controller/Admin/Crm/MobileSend/LayerSendTimeSettingController.php+2
변경 전LayerSendTimeSettingController.php
 
namespace Bundle\Controller\Admin\Crm\MobileSend;
 
 
use Origin\Enum\Crm\Message\MessageRepeatCycle;
use Origin\Enum\Crm\Message\MessageSendType;
 
 
$this->setData('messageSendTypes', MessageSendType::cases());
$this->setData('messageRepeatCycles', MessageRepeatCycle::cases());
 
$this->getView()->setDefine('layout', 'layout_layer.php');
}
}
 
변경 후LayerSendTimeSettingController.php
 
namespace Bundle\Controller\Admin\Crm\MobileSend;
 
use Origin\Enum\Common\WeekDay;
use Origin\Enum\Crm\Message\MessageRepeatCycle;
use Origin\Enum\Crm\Message\MessageSendType;
 
 
$this->setData('messageSendTypes', MessageSendType::cases());
$this->setData('messageRepeatCycles', MessageRepeatCycle::cases());
$this->setData('weekDays', WeekDay::cases());
$this->getView()->setDefine('layout', 'layout_layer.php');
}
}
 
Controller/Admin/Crm/MobileSend/LayerSmsContentSettingController.php+9
변경 전LayerSmsContentSettingController.php
 
namespace Bundle\Controller\Admin\Crm\MobileSend;
 
 
use Origin\Service\Crm\Message\Sms080RejectService;
 
class LayerSmsContentSettingController extends \Controller\Admin\Controller
{
 
 
public function index()
{
$this->callMenu('crm', 'messageSend', 'mobileSend');
$sms080RejectService = \App::getInstance(Sms080RejectService::class);
$is080RejectAvailable = $sms080RejectService->isAvailable();
 
 
 
 
 
$this->setData('is080RejectAvailable', $is080RejectAvailable);
 
 
$this->getView()->setDefine('layout', 'layout_layer.php');
}
}
 
변경 후LayerSmsContentSettingController.php
 
namespace Bundle\Controller\Admin\Crm\MobileSend;
 
use Bundle\Util\Crm\RecipeContextResolverTrait;
use Origin\Service\Crm\Message\Sms080RejectService;
 
class LayerSmsContentSettingController extends \Controller\Admin\Controller
{
use RecipeContextResolverTrait;
 
public function index()
{
$this->callMenu('crm', 'messageSend', 'mobileSend');
$sms080RejectService = \App::getInstance(Sms080RejectService::class);
$is080RejectAvailable = $sms080RejectService->isAvailable();
 
$recipeContext = $this->resolveRecipeContext();
$isRecipeContext = $this->hasRecipeContext();
$initialMessage = $recipeContext['defaultCampaignRequest']['smsLmsRequest']['content'] ?? '';
 
$this->setData('is080RejectAvailable', $is080RejectAvailable);
$this->setData('isRecipeContext', $isRecipeContext);
$this->setData('initialMessage', $initialMessage);
$this->getView()->setDefine('layout', 'layout_layer.php');
}
}
 
Controller/Admin/Crm/MobileSendPsController.php+10-1
변경 전MobileSendPsController.php
use Framework\Debug\Exception\AlertCloseException;
use Framework\Http\Request;
use GuzzleHttp\Exception\RequestException;
 
use Origin\Service\Crm\Message\MessageSendService;
use Origin\Service\Crm\Message\SmsPasswordService;
use Component\Crm\MobileMessage;
try {
$requestData = json_decode($postData['payload'], true);
 
 
 
 
 
 
 
 
 
// 수신대상 - 엑셀업로드, 업로드된 정보로 수신자 설정
if ($requestData['targetInfo']['targetType'] === 'EXCEL') {
/* @var \Bundle\Component\Crm\MobileMessage $mobileMessage*/
$mobileMessage = \App::getInstance(MobileMessage::class);
 
 
변경 후MobileSendPsController.php
use Framework\Debug\Exception\AlertCloseException;
use Framework\Http\Request;
use GuzzleHttp\Exception\RequestException;
use Origin\Enum\ApiClient\Crm\RecipeType;
use Origin\Service\Crm\Message\MessageSendService;
use Origin\Service\Crm\Message\SmsPasswordService;
use Component\Crm\MobileMessage;
try {
$requestData = json_decode($postData['payload'], true);
 
// CRM 레시피 발송은 레시피 메뉴 권한으로 인가
if (!empty($requestData['recipeType'])) {
if (RecipeType::tryFromSlug($requestData['recipeType']) === null) {
throw new AlertCloseException(__('잘못된 접근입니다.'));
}
$this->setMenuCode('crm', 'crm', 'crmRecipe');
}
 
// 수신대상 - 엑셀업로드, 업로드된 정보로 수신자 설정
if (($requestData['targetInfo']['targetType'] ?? null) === 'EXCEL') {
/* @var \Bundle\Component\Crm\MobileMessage $mobileMessage*/
$mobileMessage = \App::getInstance(MobileMessage::class);
 
 
Util/Crm/RecipeContextResolverTrait.php+30
변경 전RecipeContextResolverTrait.php
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
변경 후RecipeContextResolverTrait.php
<?php
/*
* Copyright (C) 2026 NHN COMMERCE. - All Rights Reserved
*
* Unauthorized copying or redistribution of this file in source and binary forms via any medium
* is strictly prohibited.
*/
 
namespace Bundle\Util\Crm;
 
trait RecipeContextResolverTrait
{
protected function resolveRecipeContext(): array
{
$raw = (string) \Request::post()->get('recipeContext', '');
if ($raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
 
/**
* 레시피 컨텍스트에서 호출되었는지 여부 (데이터가 비어 있어도 파라미터가 전달되면 레시피 화면)
*/
protected function hasRecipeContext(): bool
{
return (string) \Request::post()->get('recipeContext', '') !== '';
}
}