변경 파일 목록
총 27개
Admin (15)
crm_recipe_frame.phpcrm/crm_recipe_send.phpcrm/layer_preview_mobile_send.phpcrm/layer_kakao_alimtalk_content_setting.phpcrm/mobile_send/layer_kakao_friendtalk_content_setting.phpcrm/mobile_send/layer_myapp_push_content_setting.phpcrm/mobile_send/layer_preview_message.phpcrm/mobile_send/layer_recipe_send_time_setting.phpcrm/mobile_send/layer_recipient_setting.phpcrm/mobile_send/layer_select_crm_groups.phpcrm/mobile_send/layer_send_method_setting.phpcrm/mobile_send/layer_send_time_setting.phpcrm/mobile_send/layer_sms_content_setting.phpcrm/mobile_send/mobile_send.phpcrm/popup_create_crm_group_frame.phpcrm/Controller (9)
CrmRecipeClickLogPsController.phpAdmin/Crm/CrmRecipeSendController.phpAdmin/Crm/LayerKakaoFriendtalkContentSettingController.phpAdmin/Crm/MobileSend/LayerMyappPushContentSettingController.phpAdmin/Crm/MobileSend/LayerRecipeSendTimeSettingController.phpAdmin/Crm/MobileSend/LayerSendMethodSettingController.phpAdmin/Crm/MobileSend/LayerSendTimeSettingController.phpAdmin/Crm/MobileSend/LayerSmsContentSettingController.phpAdmin/Crm/MobileSend/MobileSendPsController.phpAdmin/Crm/변경 파일 코드
추가삭제
변경 전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> | |
변경 전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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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> | |
변경 전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) { | |
변경 전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']); | |
변경 전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'); | |
변경 전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(); | |
| } | |
변경 전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); | |
| }); | |
| } | |
변경 전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> | |
변경 전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); | |
변경 전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' }); | |
| } | |
| }); | |
| /** | |
변경 전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> | |
변경 전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> | |
변경 전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; | |
변경 전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 | |
변경 전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> | |
변경 전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' | |
변경 전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; | |
| } | |
| } | |
변경 전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]); | |
| } | |
| } | |
변경 전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), | |
| ]; | |
| } | |
| } | |
변경 전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'); | |
| } | |
| } | |
변경 전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'); | |
| } | |
| } | |
변경 전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'] ?? [], | |
| ]; | |
| } | |
| } | |
변경 전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'); | |
| } | |
| } | |
변경 전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'); | |
| } | |
| } | |
변경 전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'); | |
| } | |
| } | |
변경 전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); | |
변경 전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', '') !== ''; | |
| } | |
| } | |