refactor(ecommerce,pay): 간편결제 브랜드 마크 카탈로그 편입 + 애플페이 iOS 게이팅 이관
3사 PG 플러그인의 체크아웃 DOM 인젝터가 렌더 후 브랜드 마크를 주입하고 injector끼리 같은 버튼을 두고 경합하던 구조를 제거한다. 시각정보(색 배지/ SVG 로고)를 결제수단 카탈로그(_cached_brand_mark)로 편입해 코어 체크아웃 레이아웃의 신규 BrandMark 컴포넌트가 데이터 기반으로 직접 렌더한다. reconcileKginicisPatchedDuplicates(중복 방어)도 함께 제거했다. 애플페이 iOS 게이팅도 injector 밖으로 이관: 이커머스 모듈 DetectDevice 미들웨어가 UA로 기기를 판정해 코어 core.frontend.filter_app_config 훅으로 appConfig.isIos를 주입하고(1차), 템플릿 부트스트랩이 iPadOS 데스크탑 UA를 클라 신호로 보정한다(2차). 코어는 훅만 제공하고 감지 로직은 모듈이 소유한다. 미들웨어는 Router::pushMiddlewareToGroup 이 아닌 HTTP Kernel::appendMiddlewareToGroup 으로 web 그룹에 등록한다(전자는 Router 레지스트리만 갱신해 SSR 셸 요청 파이프라인에 미반영 → isIos 항상 false). sirsoft-gdpr 선례와 동일. 코어 최소 요구 버전 7.0.5 상향(appConfig 필터 훅 도입).
This commit is contained in:
1 parent
e896a47d4f
commit
8b9bfd0c91
58 files changed
+1582
-2013
No files matched your search
+1
-1
@@ -3,7 +3,7 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.4
|
||||
APP_VERSION=7.0.5
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -3,7 +3,7 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.4
|
||||
APP_VERSION=7.0.5
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.5] - 2026-07-15
|
||||
|
||||
### Added
|
||||
|
||||
- 확장 기능이 화면을 그릴 때, 방문자의 접속 기기 종류처럼 요청마다 달라지는 정보를 화면 구성 데이터에 함께 실어 보낼 수 있는 통로를 마련했습니다. 쇼핑몰의 애플페이가 iOS 기기에서만 표시되도록 하는 데 이 통로가 쓰입니다.
|
||||
|
||||
## [7.0.4] - 2026-07-14
|
||||
|
||||
### Security
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.4 g7
|
||||
# (필요 시) mv g7-7.0.5 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.4-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.5-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
|
||||
@@ -198,7 +198,10 @@ class SettingsService
|
||||
$result[$frontendKey] = $this->castValue($value, $fieldSchema);
|
||||
}
|
||||
|
||||
return $result;
|
||||
// 확장이 요청 컨텍스트 기반 값(예: 기기 유형)을 appConfig 에 주입할 수 있도록 필터 훅 제공.
|
||||
// config/frontend.php 는 정적 config 값만 담으므로, 요청별로 달라지는 값은 이 훅으로 확장이 채운다.
|
||||
// 프론트는 window.G7Config.appConfig → _global.appConfig 로 그대로 받는다.
|
||||
return HookManager::applyFilters('core.frontend.filter_app_config', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.4'),
|
||||
'version' => env('APP_VERSION', '7.0.5'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
|
||||
- 결제 플러그인이 제공하는 간편결제도 신용카드·가상계좌와 동일하게 정식 결제수단으로 취급됩니다. 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록되어 관리자 주문 상세와 주문 목록에서 확인할 수 있습니다.
|
||||
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 불필요"가 아니라 "PG 고정"으로 표시되고 어느 결제대행사를 통해 처리되는지 함께 보여줍니다. 간편결제는 해당 결제대행사 전용이므로 다른 대행사로 바꿀 수 없습니다.
|
||||
- 주문서의 간편결제 버튼에 표시되는 브랜드 마크(네이버페이·카카오페이·삼성페이 등의 색상 배지와 로고)를 표준화했습니다. 이전에는 결제 플러그인이 화면이 그려진 뒤 버튼 모양을 덧입히는 방식이라 여러 결제사가 함께 켜져 있을 때 드물게 표시가 겹치거나 어긋날 수 있었는데, 이제 결제수단 정보에 담긴 브랜드 마크를 주문서가 처음부터 직접 그리도록 바꿔 표시가 안정적입니다.
|
||||
- 애플페이 간편결제 버튼이 iOS 기기(iPhone·iPad)에서만 주문서에 표시되도록 했습니다. 애플페이는 iOS에서만 결제할 수 있으므로 다른 기기에서는 버튼을 감춰 혼선을 줄입니다.
|
||||
- 위 표시 개선을 위해 코어 최소 요구 버전을 7.0.5로 상향했습니다. 쇼핑몰 모듈을 업데이트하기 전에 코어를 7.0.5 이상으로 먼저 업데이트해 주세요.
|
||||
|
||||
## [1.0.3] - 2026-07-14
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
|
||||
"en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management"
|
||||
},
|
||||
"g7_version": ">=7.0.0",
|
||||
"g7_version": ">=7.0.5",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
@@ -19,6 +19,7 @@ use Modules\Sirsoft\Ecommerce\Listeners\CouponUseListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\EcommerceAdminActivityLogListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\EcommerceNotificationDataListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\EcommerceUserActivityLogListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\InjectAppConfigDeviceListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\MergeCartOnLoginListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\MileageTransactionListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\OrderActivityLogListener;
|
||||
@@ -2007,6 +2008,7 @@ class Module extends AbstractModule
|
||||
UserMileageCleanupListener::class,
|
||||
EcommerceNotificationDataListener::class,
|
||||
OrderStatusNotificationListener::class,
|
||||
InjectAppConfigDeviceListener::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,13 @@ class DeviceDetector
|
||||
*/
|
||||
private const MOBILE_UA_KEYWORDS = ['Mobile', 'Android', 'iPhone', 'iPad', 'iPod'];
|
||||
|
||||
/**
|
||||
* iOS User-Agent 키워드 (iPhone/iPad/iPod)
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const IOS_UA_KEYWORDS = ['iPhone', 'iPad', 'iPod'];
|
||||
|
||||
/**
|
||||
* Request 의 User-Agent 로 결제 디바이스 판별
|
||||
*
|
||||
@@ -51,4 +58,25 @@ class DeviceDetector
|
||||
|
||||
return 'pc';
|
||||
}
|
||||
|
||||
/**
|
||||
* User-Agent 문자열이 iOS 기기(iPhone/iPad/iPod)인지 판별
|
||||
*
|
||||
* 애플페이는 iOS 기기에서만 결제 가능하므로, 체크아웃 결제수단 목록에서
|
||||
* 애플페이 노출 여부를 정하는 데 쓰인다. 데스크탑 UA 를 보내는 iPadOS 는
|
||||
* 이 판정에서 false 가 될 수 있으며, 그 경우 클라이언트(maxTouchPoints)가 보정한다.
|
||||
*
|
||||
* @param string $userAgent User-Agent 헤더 값
|
||||
* @return bool iOS 기기 여부
|
||||
*/
|
||||
public static function isIosFromUserAgent(string $userAgent): bool
|
||||
{
|
||||
foreach (self::IOS_UA_KEYWORDS as $keyword) {
|
||||
if (stripos($userAgent, $keyword) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 요청 User-Agent 로 iOS 기기 여부를 결정해 컨테이너에 보관하는 미들웨어.
|
||||
*
|
||||
* SetTimezone / ResolveShippingCountry 패턴(App::instance + static accessor)을 미러한다.
|
||||
* web 그룹(SSR 셸 렌더 경로)에 적용되며, InjectAppConfigDeviceListener 가 코어의
|
||||
* `core.frontend.filter_app_config` 필터 훅에서 이 값을 읽어 appConfig.isIos 로 주입한다.
|
||||
* 프론트는 window.G7Config.appConfig → _global.appConfig.isIos 로 받는다.
|
||||
*
|
||||
* 서버 UA 판정은 데스크탑 UA 를 보내는 iPadOS 를 놓칠 수 있으므로, 템플릿 부트스트랩이
|
||||
* 클라이언트 신호(maxTouchPoints/userAgentData)로 이를 보정한다.
|
||||
*/
|
||||
class DetectDevice
|
||||
{
|
||||
/**
|
||||
* 애플리케이션 컨테이너에 저장되는 iOS 여부 키
|
||||
*/
|
||||
public const IS_IOS_KEY = 'ecommerce_request_is_ios';
|
||||
|
||||
/**
|
||||
* 들어오는 요청의 iOS 여부를 판별해 컨테이너에 보관합니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @param Closure $next 다음 미들웨어 또는 요청 핸들러
|
||||
* @return Response HTTP 응답
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
App::instance(
|
||||
self::IS_IOS_KEY,
|
||||
DeviceDetector::isIosFromUserAgent($request->userAgent() ?? '')
|
||||
);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 요청이 iOS 기기인지 반환합니다.
|
||||
*
|
||||
* 미들웨어가 실행되지 않은 컨텍스트에서는 false(애플페이 기본 숨김 — fail-safe)를 반환합니다.
|
||||
*
|
||||
* @return bool iOS 기기 여부
|
||||
*/
|
||||
public static function isIos(): bool
|
||||
{
|
||||
if (App::bound(self::IS_IOS_KEY)) {
|
||||
return (bool) App::make(self::IS_IOS_KEY);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice;
|
||||
|
||||
/**
|
||||
* 요청 기기 유형(iOS 여부)을 프론트엔드 appConfig 에 주입하는 리스너.
|
||||
*
|
||||
* 코어 `core.frontend.filter_app_config` 필터 훅을 구독해, DetectDevice 미들웨어가
|
||||
* 컨테이너에 보관한 iOS 판정값을 `appConfig.isIos` 로 주입한다. 프론트는
|
||||
* window.G7Config.appConfig → _global.appConfig.isIos 로 받아 체크아웃 레이아웃의
|
||||
* 애플페이 iOS 게이팅에 활용한다.
|
||||
*
|
||||
* 코어는 config/frontend.php 정적 값만 노출하므로, 요청별로 달라지는 값(기기 유형)은
|
||||
* 이 훅을 통해 이커머스 모듈이 채운다.
|
||||
*/
|
||||
class InjectAppConfigDeviceListener implements HookListenerInterface
|
||||
{
|
||||
/**
|
||||
* 구독할 훅 목록 반환
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.frontend.filter_app_config' => [
|
||||
'method' => 'injectDeviceFlags',
|
||||
'priority' => 20,
|
||||
'type' => 'filter',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 핸들러 (미사용).
|
||||
*
|
||||
* @param mixed ...$args
|
||||
*/
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* appConfig 에 요청 기기 iOS 여부를 주입합니다.
|
||||
*
|
||||
* @param array<string, mixed> $appConfig 코어가 조립한 프론트엔드 appConfig
|
||||
* @return array<string, mixed> isIos 가 추가된 appConfig
|
||||
*/
|
||||
public function injectDeviceFlags(array $appConfig): array
|
||||
{
|
||||
$appConfig['isIos'] = DetectDevice::isIos();
|
||||
|
||||
return $appConfig;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace Modules\Sirsoft\Ecommerce\Providers;
|
||||
|
||||
use App\Extension\BaseModuleServiceProvider;
|
||||
use App\Seo\SitemapGenerator;
|
||||
use Illuminate\Contracts\Http\Kernel as HttpKernelContract;
|
||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||
use Modules\Sirsoft\Ecommerce\Console\Commands\AggregateEcommerceStatsCommand;
|
||||
use Modules\Sirsoft\Ecommerce\Console\Commands\CancelPendingPaymentOrdersCommand;
|
||||
use Modules\Sirsoft\Ecommerce\Console\Commands\EarnMileageCommand;
|
||||
@@ -11,6 +13,7 @@ use Modules\Sirsoft\Ecommerce\Console\Commands\ExpireMileageCommand;
|
||||
use Modules\Sirsoft\Ecommerce\Console\Commands\NotifyExpiringMileageCommand;
|
||||
use Modules\Sirsoft\Ecommerce\Console\Commands\PruneExpiredCartsCommand;
|
||||
use Modules\Sirsoft\Ecommerce\Console\Commands\ReconcileMileageBalanceCommand;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\BrandRepository;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\CartRepository;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\CategoryImageRepository;
|
||||
@@ -210,6 +213,20 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider
|
||||
$this->commands($this->commands);
|
||||
}
|
||||
|
||||
// 요청 기기 유형(iOS) 감지 미들웨어를 web 그룹(SSR 셸 렌더 경로)에 등록.
|
||||
// InjectAppConfigDeviceListener 가 core.frontend.filter_app_config 훅에서 이 값을 읽어
|
||||
// appConfig.isIos 로 주입한다(체크아웃 애플페이 iOS 게이팅). DB/인증 비의존 순수 UA 판정이라
|
||||
// 마이그레이션 이전에도 안전하다.
|
||||
//
|
||||
// Router::pushMiddlewareToGroup 은 Router 자체 레지스트리만 갱신해 실제 요청 파이프라인이
|
||||
// 참조하는 HTTP Kernel 의 web 그룹에는 반영되지 않는다(미들웨어 미실행 → isIos 항상 false).
|
||||
// sirsoft-gdpr 선례와 동일하게 HTTP Kernel::appendMiddlewareToGroup 을 사용한다
|
||||
// (Kernel 은 중복 등록을 자체 방지하므로 매 부팅 호출돼도 1회만 등록).
|
||||
$kernel = $this->app->make(HttpKernelContract::class);
|
||||
if ($kernel instanceof HttpKernel) {
|
||||
$kernel->appendMiddlewareToGroup('web', DetectDevice::class);
|
||||
}
|
||||
|
||||
// Sitemap 기여자 등록
|
||||
$this->app->booted(function () {
|
||||
if ($this->app->bound(SitemapGenerator::class)) {
|
||||
|
||||
@@ -796,6 +796,12 @@ class EcommerceSettingsService implements ModuleSettingsInterface
|
||||
'refund_method' => $refundMethod,
|
||||
];
|
||||
|
||||
// iOS 전용 노출 플래그(애플페이 등) — 정의가 SSoT. 관리자가 편집하는 값이 아니며,
|
||||
// 체크아웃 레이아웃이 비-iOS 기기에서 해당 수단을 렌더하지 않는 데 쓰인다.
|
||||
if (! empty($definition['requires_ios'])) {
|
||||
$capabilities['requires_ios'] = true;
|
||||
}
|
||||
|
||||
if ($savedItem) {
|
||||
$merged[] = array_merge([
|
||||
'id' => $id,
|
||||
@@ -810,6 +816,7 @@ class EcommerceSettingsService implements ModuleSettingsInterface
|
||||
'_cached_name' => $definition['name'],
|
||||
'_cached_description' => $definition['description'] ?? ['ko' => '', 'en' => ''],
|
||||
'_cached_icon' => $definition['icon'] ?? 'circle-question',
|
||||
'_cached_brand_mark' => $definition['brand_mark'] ?? null,
|
||||
'_cached_source' => $definition['source'] ?? 'builtin',
|
||||
], $capabilities);
|
||||
} else {
|
||||
@@ -825,6 +832,7 @@ class EcommerceSettingsService implements ModuleSettingsInterface
|
||||
'_cached_name' => $definition['name'],
|
||||
'_cached_description' => $definition['description'] ?? ['ko' => '', 'en' => ''],
|
||||
'_cached_icon' => $definition['icon'] ?? 'circle-question',
|
||||
'_cached_brand_mark' => $definition['brand_mark'] ?? null,
|
||||
'_cached_source' => $definition['source'] ?? 'builtin',
|
||||
], $capabilities);
|
||||
}
|
||||
@@ -874,6 +882,7 @@ class EcommerceSettingsService implements ModuleSettingsInterface
|
||||
$savedMethods[$index]['_cached_name'] = $def['name'] ?? $method['_cached_name'] ?? null;
|
||||
$savedMethods[$index]['_cached_description'] = $def['description'] ?? $method['_cached_description'] ?? null;
|
||||
$savedMethods[$index]['_cached_icon'] = $def['icon'] ?? $method['_cached_icon'] ?? null;
|
||||
$savedMethods[$index]['_cached_brand_mark'] = $def['brand_mark'] ?? $method['_cached_brand_mark'] ?? null;
|
||||
$savedMethods[$index]['_cached_source'] = $def['source'] ?? $method['_cached_source'] ?? 'builtin';
|
||||
}
|
||||
// 고아 항목은 기존 _cached_* 유지
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 체크아웃 간편결제 브랜드 마크 데이터 기반 렌더 + 애플페이 iOS 게이팅.
|
||||
*
|
||||
* 3사(kginicis/nhnkcp/nicepayments) 동시 활성 체크아웃에서, 각 간편결제 버튼의 브랜드 마크
|
||||
* (색 배지 / 브랜드 SVG 로고)가 카탈로그 _cached_brand_mark 데이터로 렌더되고(injector 후처리
|
||||
* 없이), 애플페이는 iOS 기기에서만 노출되는지 검증. 템플릿 sirsoft-basic (유저 화면).
|
||||
* (skeleton, placeholder)
|
||||
*
|
||||
* @effects badge_renders_text_and_color,
|
||||
* svg_renders_inline_logo,
|
||||
* svg_dangerous_content_sanitized,
|
||||
* none_form_falls_back_to_icon,
|
||||
* applepay_shown_on_ios,
|
||||
* applepay_hidden_on_non_ios,
|
||||
* non_ios_methods_unaffected,
|
||||
* injectors_removed
|
||||
*
|
||||
* e2e:allow 브랜드 마크 렌더/게이팅 — 카탈로그→레이아웃 계약을 단위/렌더/리소스 테스트로 1차 차단하고,
|
||||
* 브라우저 회귀는 본 placeholder(test.describe.skip)가 data-testid 보강 + 3-PG 동시활성 시드 후 활성화될 때 검증한다.
|
||||
* 현재 커버리지:
|
||||
* (1) 카탈로그 보강 — modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsOrderSettingsTest.php
|
||||
* (brand_mark→_cached_brand_mark 병합, requires_ios 전달, 마크 없는 builtin=null) green.
|
||||
* (2) 등록 리스너 — plugins/_bundled/sirsoft-pay_{nhnkcp,nicepayments,kginicis}/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php
|
||||
* (배지 9종 text/class, SVG 6종 markup, 애플페이만 requires_ios) green.
|
||||
* (3) BrandMark 컴포넌트 — templates/_bundled/sirsoft-basic/src/components/composite/__tests__/BrandMark.test.tsx
|
||||
* (배지/SVG 렌더, SVG script/onload/foreignObject sanitize 제거, 마크 없음→null) green.
|
||||
* (4) 레이아웃 렌더 + iOS 필터 — templates/_bundled/sirsoft-basic/__tests__/layouts/checkout-payment-brand-mark.test.tsx
|
||||
* (BrandMark 노드 바인딩 + Icon 폴백, iteration source 필터가 iOS↔비iOS 로 애플페이 포함/제외) green.
|
||||
* (5) 서버/클라 iOS 게이팅 — tests/Unit/Helpers/DeviceDetectorTest.php + tests/Unit/Middleware/DetectDeviceTest.php
|
||||
* + tests/Unit/Services/SettingsServiceAppConfigTest.php + templates/_bundled/sirsoft-basic/src/handlers/__tests__/deviceCorrection.test.ts
|
||||
* (UA→appConfig.isIos, iPadOS 데스크탑 UA 클라 보정) green.
|
||||
* 라이브 검수는 Chrome MCP 실측(3사 브랜드 버튼 공존 + 마크 표시, iOS 에뮬레이션 애플페이 노출/숨김, 스크린샷)으로 기록 예정.
|
||||
*/
|
||||
// @scenario mark_form=svg, requires_ios=false, device=ios
|
||||
// @scenario mark_form=svg, requires_ios=false, device=non_ios
|
||||
// @scenario mark_form=none, requires_ios=false, device=non_ios
|
||||
// @scenario mark_form=badge, requires_ios=false, device=non_ios
|
||||
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
|
||||
|
||||
test.describe.skip('체크아웃 간편결제 브랜드 마크 + 애플페이 iOS 게이팅 (skeleton)', () => {
|
||||
test('3사 간편결제 버튼이 브랜드 마크(배지/SVG)로 렌더된다', async ({ page, userToken }) => {
|
||||
await authenticatePage(page, userToken);
|
||||
await page.goto('/shop/checkout');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
// 활성화 시: 배지 형태(nhnkcp 네이버페이 'N')와 SVG 형태(kginicis 네이버페이 로고)가 함께 렌더
|
||||
await expect(page.getByTestId('payment-method-nhnkcp_naverpay').getByText('N')).toBeVisible();
|
||||
await expect(page.getByTestId('payment-method-kginicis_naverpay').locator('svg')).toBeVisible();
|
||||
// 마크 없는 무통장은 아이콘 폴백
|
||||
await expect(page.getByTestId('payment-method-dbank').locator('i')).toBeVisible();
|
||||
});
|
||||
|
||||
test('비-iOS 기기에서 애플페이 버튼이 노출되지 않는다', async ({ page, userToken }) => {
|
||||
await authenticatePage(page, userToken);
|
||||
await page.goto('/shop/checkout');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
// 활성화 시: 데스크탑(비-iOS)에서는 애플페이 수단 카드가 렌더되지 않음
|
||||
await expect(page.getByTestId('payment-method-nhnkcp_applepay')).toHaveCount(0);
|
||||
// 비-애플페이 수단은 정상 노출
|
||||
await expect(page.getByTestId('payment-method-nhnkcp_naverpay')).toBeVisible();
|
||||
});
|
||||
|
||||
test('iOS 기기 에뮬레이션에서 애플페이 버튼이 노출된다', async ({ browser }) => {
|
||||
// 활성화 시: iPhone UA 컨텍스트에서 애플페이 수단 카드가 렌더됨(서버 UA 게이팅)
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/shop/checkout');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
await expect(page.getByTestId('payment-method-nhnkcp_applepay')).toBeVisible();
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Helpers;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
class DeviceDetectorTest extends ModuleTestCase
|
||||
{
|
||||
#[DataProvider('deviceTypeProvider')]
|
||||
public function test_detect_from_user_agent(string $userAgent, string $expected): void
|
||||
{
|
||||
$this->assertSame($expected, DeviceDetector::detectFromUserAgent($userAgent));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{0: string, 1: string}>
|
||||
*/
|
||||
public static function deviceTypeProvider(): array
|
||||
{
|
||||
return [
|
||||
'iPhone' => ['Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)', 'mobile'],
|
||||
'iPad' => ['Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)', 'mobile'],
|
||||
'Android' => ['Mozilla/5.0 (Linux; Android 14; Pixel 8)', 'mobile'],
|
||||
'desktop Chrome' => ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120', 'pc'],
|
||||
'macOS Safari' => ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17', 'pc'],
|
||||
'empty' => ['', 'pc'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=ios
|
||||
*
|
||||
* @effects server_ua_sets_is_ios, applepay_shown_on_ios
|
||||
*/
|
||||
#[DataProvider('iosProvider')]
|
||||
public function test_is_ios_from_user_agent(string $userAgent, bool $expected): void
|
||||
{
|
||||
$this->assertSame($expected, DeviceDetector::isIosFromUserAgent($userAgent));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{0: string, 1: bool}>
|
||||
*/
|
||||
public static function iosProvider(): array
|
||||
{
|
||||
return [
|
||||
'iPhone' => ['Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)', true],
|
||||
'iPad' => ['Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)', true],
|
||||
'iPod' => ['Mozilla/5.0 (iPod touch; CPU iPhone OS 15_0 like Mac OS X)', true],
|
||||
// Android 모바일은 iOS 가 아님(애플페이 미노출).
|
||||
'Android' => ['Mozilla/5.0 (Linux; Android 14; Pixel 8)', false],
|
||||
// 데스크탑 UA 를 보내는 iPadOS 는 서버 UA 판정으로 놓친다(클라 보정 대상).
|
||||
'macOS desktop UA' => ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17', false],
|
||||
'Windows' => ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120', false],
|
||||
'empty' => ['', false],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Middleware;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\InjectAppConfigDeviceListener;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
class DetectDeviceTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=ios
|
||||
*
|
||||
* @effects server_ua_sets_is_ios, applepay_shown_on_ios
|
||||
*/
|
||||
public function test_ios_user_agent_sets_is_ios_true_in_container(): void
|
||||
{
|
||||
$request = Request::create('/', 'GET', server: [
|
||||
'HTTP_USER_AGENT' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
|
||||
]);
|
||||
|
||||
(new DetectDevice)->handle($request, fn () => new Response);
|
||||
|
||||
$this->assertTrue(DetectDevice::isIos());
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=non_ios
|
||||
*
|
||||
* @effects server_ua_sets_is_ios, applepay_hidden_on_non_ios
|
||||
*/
|
||||
public function test_desktop_user_agent_sets_is_ios_false_in_container(): void
|
||||
{
|
||||
$request = Request::create('/', 'GET', server: [
|
||||
'HTTP_USER_AGENT' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120',
|
||||
]);
|
||||
|
||||
(new DetectDevice)->handle($request, fn () => new Response);
|
||||
|
||||
$this->assertFalse(DetectDevice::isIos());
|
||||
}
|
||||
|
||||
public function test_is_ios_defaults_false_when_middleware_not_run(): void
|
||||
{
|
||||
// 컨테이너 미바인딩(미들웨어 미실행) 시 fail-safe(애플페이 기본 숨김).
|
||||
$this->assertFalse(DetectDevice::isIos());
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=ipados_desktop_ua
|
||||
*
|
||||
* @effects is_ios_flows_to_global_appconfig
|
||||
*/
|
||||
public function test_listener_injects_is_ios_into_app_config_via_hook(): void
|
||||
{
|
||||
// iOS 미들웨어 통과 상태 재현.
|
||||
$request = Request::create('/', 'GET', server: [
|
||||
'HTTP_USER_AGENT' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
|
||||
]);
|
||||
(new DetectDevice)->handle($request, fn () => new Response);
|
||||
|
||||
// 리스너를 훅에 등록(모듈 부팅이 하는 것과 동일).
|
||||
HookManager::addFilter(
|
||||
'core.frontend.filter_app_config',
|
||||
[new InjectAppConfigDeviceListener, 'injectDeviceFlags'],
|
||||
20
|
||||
);
|
||||
|
||||
$appConfig = app(SettingsService::class)->getAppConfigForFrontend();
|
||||
|
||||
$this->assertArrayHasKey('isIos', $appConfig);
|
||||
$this->assertTrue($appConfig['isIos']);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Providers;
|
||||
|
||||
use Illuminate\Contracts\Http\Kernel as HttpKernelContract;
|
||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* DetectDevice 미들웨어가 실제 요청 파이프라인의 web 그룹에 등록되는지 검증.
|
||||
*
|
||||
* 회귀: EcommerceServiceProvider 가 Router::pushMiddlewareToGroup('web', ...) 로 등록하면
|
||||
* HTTP Kernel 이 참조하는 web 그룹에 반영되지 않아(Router 자체 레지스트리만 갱신) SSR 셸
|
||||
* 요청에서 미들웨어가 실행되지 않는다. 그 결과 appConfig.isIos 가 항상 fail-safe false 가 되어
|
||||
* 애플페이 iOS 게이팅 서버층이 동작하지 않는다. Kernel::appendMiddlewareToGroup 을 써야 한다.
|
||||
*/
|
||||
class DetectDeviceMiddlewareRegistrationTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=non_ios
|
||||
*
|
||||
* @effects server_ua_sets_is_ios
|
||||
*/
|
||||
public function test_detect_device_is_registered_in_kernel_web_group(): void
|
||||
{
|
||||
$kernel = $this->app->make(HttpKernelContract::class);
|
||||
$this->assertInstanceOf(HttpKernel::class, $kernel);
|
||||
|
||||
// HTTP Kernel 이 실제 요청 처리 시 참조하는 web 그룹을 조회한다.
|
||||
$webGroup = $kernel->getMiddlewareGroups()['web'] ?? [];
|
||||
|
||||
$this->assertContains(
|
||||
DetectDevice::class,
|
||||
$webGroup,
|
||||
'DetectDevice 미들웨어가 HTTP Kernel 의 web 그룹에 등록되어야 SSR 셸 요청에서 실행된다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
+49
@@ -257,6 +257,55 @@ class EcommerceSettingsOrderSettingsTest extends TestCase
|
||||
$this->assertFalse($toss['is_active']); // 기본값: 비활성
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=svg, requires_ios=false, device=non_ios
|
||||
*
|
||||
* @effects brand_mark_flows_to_cached, requires_ios_flag_carried, none_form_cached_brand_mark_null
|
||||
*/
|
||||
public function test_brand_mark_and_requires_ios_flow_from_definition_to_merged_catalog(): void
|
||||
{
|
||||
// 플러그인이 brand_mark(배지/SVG) + requires_ios 를 등록하면
|
||||
// 병합 카탈로그가 _cached_brand_mark / requires_ios 로 노출해 레이아웃까지 전달한다.
|
||||
$this->addPaymentMethodFilter(function (array $methods) {
|
||||
$methods[] = [
|
||||
'id' => 'pgtest_badge',
|
||||
'name' => ['ko' => '배지결제', 'en' => 'Badge Pay'],
|
||||
'description' => ['ko' => '배지 결제', 'en' => 'Badge Payment'],
|
||||
'icon' => 'wallet',
|
||||
'source' => 'plugin:sirsoft-pgtest',
|
||||
'brand_mark' => ['text' => 'B', 'class' => 'bg-green-500 text-white'],
|
||||
'defaults' => ['is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
|
||||
];
|
||||
$methods[] = [
|
||||
'id' => 'pgtest_svg_ios',
|
||||
'name' => ['ko' => 'SVG결제', 'en' => 'SVG Pay'],
|
||||
'description' => ['ko' => 'SVG 결제', 'en' => 'SVG Payment'],
|
||||
'icon' => 'smartphone',
|
||||
'source' => 'plugin:sirsoft-pgtest',
|
||||
'brand_mark' => ['svg' => '<svg><rect fill="#03C75A"/></svg>'],
|
||||
'requires_ios' => true,
|
||||
'defaults' => ['is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
|
||||
];
|
||||
|
||||
return $methods;
|
||||
});
|
||||
|
||||
$this->service->clearCache();
|
||||
$methods = collect($this->service->getSettings('order_settings')['payment_methods'])->keyBy('id');
|
||||
|
||||
$badge = $methods->get('pgtest_badge');
|
||||
$this->assertSame(['text' => 'B', 'class' => 'bg-green-500 text-white'], $badge['_cached_brand_mark']);
|
||||
$this->assertArrayNotHasKey('requires_ios', $badge);
|
||||
|
||||
$svg = $methods->get('pgtest_svg_ios');
|
||||
$this->assertSame(['svg' => '<svg><rect fill="#03C75A"/></svg>'], $svg['_cached_brand_mark']);
|
||||
$this->assertTrue($svg['requires_ios']);
|
||||
|
||||
// brand_mark 없는 builtin 은 _cached_brand_mark 가 null.
|
||||
$dbank = $methods->get('dbank');
|
||||
$this->assertNull($dbank['_cached_brand_mark']);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 결제수단 병합 (사용자 저장 설정 오버라이드)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
feature: checkout_brand_mark_rendering
|
||||
description: >
|
||||
체크아웃 간편결제 버튼의 브랜드 시각정보(색 배지 / 브랜드 SVG 로고)를 결제수단 카탈로그
|
||||
(`_cached_brand_mark`)로 편입해, 코어 체크아웃 레이아웃의 BrandMark 컴포넌트가 데이터 기반
|
||||
으로 직접 렌더한다. 과거에는 각 PG 플러그인의 체크아웃 DOM 인젝터(checkoutEasyPayInjector /
|
||||
checkoutNaverpayBrandButton)가 렌더 후 DOM 을 후처리해 마크를 주입했고, injector 끼리 같은
|
||||
버튼을 두고 경합하며 reconcileKginicisPatchedDuplicates 로 중복을 방어했다. 시각정보를
|
||||
카탈로그로 옮기면서 세 injector + reconcile 을 제거했다.
|
||||
|
||||
애플페이 iOS 게이팅도 injector 밖으로 이관했다: 이커머스 모듈의 DetectDevice 미들웨어가
|
||||
UA 로 기기를 감지해 컨테이너에 보관하고, InjectAppConfigDeviceListener 가 코어
|
||||
`core.frontend.filter_app_config` 훅에서 이를 appConfig.isIos 로 주입해 내려준다(1차).
|
||||
템플릿 부트스트랩이 iPadOS(데스크탑 UA)를 클라 신호(maxTouchPoints)로 보정한다(2차).
|
||||
체크아웃 레이아웃 iteration source 필터가 requires_ios 수단을 _global.appConfig.isIos 에 따라
|
||||
포함/제외한다. (미들웨어·감지 로직은 코어가 아닌 이커머스 모듈 소유 — 코어는 appConfig 필터 훅만 제공.)
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsOrderSettingsTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Helpers/DeviceDetectorTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Middleware/DetectDeviceTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Providers/DetectDeviceMiddlewareRegistrationTest.php
|
||||
- tests/Unit/Services/SettingsServiceAppConfigTest.php
|
||||
- plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php
|
||||
- plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php
|
||||
- plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php
|
||||
- templates/_bundled/sirsoft-basic/src/components/composite/__tests__/BrandMark.test.tsx
|
||||
- templates/_bundled/sirsoft-basic/src/handlers/__tests__/deviceCorrection.test.ts
|
||||
- templates/_bundled/sirsoft-basic/__tests__/layouts/checkout-payment-brand-mark.test.tsx
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/checkout-brand-mark.spec.ts
|
||||
|
||||
# 입력 axis — 브랜드 마크 렌더 + iOS 게이팅에 영향을 주는 변수
|
||||
axes:
|
||||
# 브랜드 마크 형태: 배지(nhnkcp/nicepay) / SVG 로고(kginicis) / 없음(무통장·카드 등 builtin)
|
||||
mark_form: [badge, svg, none]
|
||||
# iOS 전용 노출 수단 여부 (애플페이만 true)
|
||||
requires_ios: [true, false]
|
||||
# 요청 기기 (서버 UA 판정 + 클라 보정 결과)
|
||||
device: [ios, non_ios, ipados_desktop_ua]
|
||||
|
||||
exclusions:
|
||||
- mark_form: none
|
||||
requires_ios: true
|
||||
reason: "브랜드 마크 없는 builtin(무통장/카드/가상계좌)에는 iOS 전용 수단이 없다"
|
||||
- mark_form: svg
|
||||
requires_ios: true
|
||||
reason: "SVG 마크(kginicis)에는 애플페이 수단이 없다 — kginicis 는 애플페이 미제공"
|
||||
|
||||
# 후속 효과 체인 — 시각정보 이관 + injector 제거가 낳는 관찰 가능 효과
|
||||
effects:
|
||||
# 카탈로그 → 레이아웃 데이터 전달
|
||||
- brand_mark_flows_to_cached # brand_mark 등록값이 _cached_brand_mark 로 병합 카탈로그에 노출
|
||||
- requires_ios_flag_carried # requires_ios 가 method 최상위로 노출(레이아웃 접근 가능)
|
||||
- none_form_cached_brand_mark_null # 마크 없는 수단은 _cached_brand_mark=null (아이콘 폴백)
|
||||
# 렌더 (BrandMark 컴포넌트)
|
||||
- badge_renders_text_and_color # 배지 형태는 text + Tailwind 색상 클래스로 렌더
|
||||
- svg_renders_inline_logo # SVG 형태는 인라인 SVG 로고로 렌더
|
||||
- svg_dangerous_content_sanitized # SVG 내부 script/onload/foreignObject 는 sanitize 제거(XSS 방어)
|
||||
- none_form_falls_back_to_icon # 마크 없는 수단은 Icon 폴백 렌더
|
||||
# iOS 게이팅
|
||||
- server_ua_sets_is_ios # DetectDevice 미들웨어가 UA 로 appConfig.isIos 설정
|
||||
- is_ios_flows_to_global_appconfig # appConfig.isIos 가 _global.appConfig 로 전달
|
||||
- client_corrects_ipados_desktop_ua # 부트스트랩이 iPadOS 데스크탑 UA 를 클라 신호로 보정
|
||||
- applepay_shown_on_ios # iOS 기기에서 애플페이 버튼 노출
|
||||
- applepay_hidden_on_non_ios # 비-iOS 기기에서 애플페이 버튼 미노출(iteration 필터 제외)
|
||||
- non_ios_methods_unaffected # iOS 게이팅이 비-애플페이 수단 노출에 영향 없음
|
||||
# injector 제거 (기술부채 상환)
|
||||
- injectors_removed # 세 injector + reconcile + install 호출 제거
|
||||
- shared_helpers_preserved # applePayDevice(결제 시점 방어) / JPY restrictor / admin injector 잔존
|
||||
|
||||
sub_flows:
|
||||
- id: three_pg_coexist
|
||||
description: 3사(kginicis/nhnkcp/nicepayments) 동시 활성 체크아웃에서 브랜드 버튼 공존
|
||||
effects:
|
||||
- badge_renders_text_and_color
|
||||
- svg_renders_inline_logo
|
||||
- none_form_falls_back_to_icon
|
||||
@@ -17,6 +17,7 @@
|
||||
- 간편결제가 정식 결제수단으로 취급되어 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록됩니다.
|
||||
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 고정 · KG이니시스"로 표시됩니다. 간편결제는 KG 이니시스 전용이므로 다른 결제대행사로 바꿀 수 없습니다.
|
||||
- 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화되어 주문서 입력값이 초기화되는 등의 부작용이 사라집니다.
|
||||
- 주문서의 간편결제 버튼에 표시되는 브랜드 로고(네이버페이·삼성페이·카카오페이·L.pay 등)를 화면이 그려진 뒤 덧입히던 방식에서 결제수단 정보로 처음부터 표시하는 방식으로 바꿨습니다. 여러 결제사가 함께 켜져 있을 때 표시가 안정적입니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-pay_kginicis",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
(function(){"use strict";const L="kginicis_pay_form_",M="__sirsoftKginicisReloadStandardPaySdk",w="__sirsoftKginicisMobilePaymentReturnPending";function de(){if(typeof document>"u")return 0;const e=document.querySelectorAll(`form[id^="${L}"]`);return e.forEach(t=>t.remove()),e.length}function at(){typeof window>"u"||(window[M]=!0)}function st(){if(typeof window>"u")return!1;const e=window,t=e[M]===!0;return delete e[M],t}function ct(e){typeof window>"u"||typeof document>"u"||(window.INIStdPay=void 0,document.querySelectorAll(`script[src="${e}"], script[src*="/INIStdPay_third-party.js"]`).forEach(t=>t.remove()))}function lt(e=Date.now()){if(!(typeof window>"u"))try{window.sessionStorage.setItem(w,String(e))}catch{window[w]=e}}function K(){if(!(typeof window>"u")){try{window.sessionStorage.removeItem(w)}catch{}delete window[w]}}function ue(e=Date.now()){if(typeof window>"u")return!1;let t=null;try{const n=window.sessionStorage.getItem(w);t=n?Number(n):null}catch{t=null}if(!Number.isFinite(t)){const n=window[w];t=typeof n=="number"?n:Number(n)}return!Number.isFinite(t)||e-t>18e5?(K(),!1):!0}const O="sirsoft-pay_kginicis",dt=O,ut="payment-window-closed",fe="__sirsoftKginicisPaymentCloseListenerInstalled",G="__sirsoftKginicisActiveStandardPaymentCloseContext",me=20,ft=100,$={info:(...e)=>console.info(`[${O}]`,...e),warn:(...e)=>console.warn(`[${O}]`,...e)};function mt(e){if(!e||typeof e!="object")return!1;const t=e;return t.source===dt&&t.type===ut}function D(){return/\/shop\/checkout\/?$/.test(window.location.pathname)}function E(){return window}function pt(){const e=E()[G];return!e||typeof e!="object"?null:e}function yt(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function ht(e){e.closeReportUrl&&(E()[G]={...e,reported:!1})}function pe(){delete E()[G]}function ye(){pe()}async function gt(e="payment-window-closed"){const t=pt();if(!t||t.reported||!D())return;t.reported=!0;const n={oid:t.oid,price:t.price,buyer_email:t.buyer_email??"",buyer_phone:t.buyer_phone??"",payment_method:t.payment_method??"",reason:e};try{const i=window.G7Core?.api;typeof i?.post=="function"?await i.post(t.closeReportUrl,n):await fetch(yt(t.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(i){$.warn("failed to report KG payment window close",{reason:e,error:i})}finally{pe()}}function he(e="payment-window-closed",t=!0){if(!D())return!1;const i=window.G7Core?.state?.setLocal;return typeof i!="function"?(t&&$.warn("G7Core.state.setLocal not available while resetting payment submit state"),!1):(de(),at(),i({isSubmittingOrder:!1}),$.info("checkout submit state reset after KG payment close",{reason:e}),!0)}function _t(e,t=!1){let n=0;const i=()=>{if(n++,!(t&&!ue())){if(he(e,n>=me)){t&&K();return}!D()||n>=me||window.setTimeout(i,ft)}};i()}function v(e){ue()&&_t(e,!0)}function wt(){if(typeof window>"u")return;const e=E();e[fe]||(window.addEventListener("message",t=>{t.origin===window.location.origin&&mt(t.data)&&(gt(t.data.reason),he(t.data.reason))}),window.addEventListener("pagehide",()=>{ye()}),window.addEventListener("beforeunload",()=>{ye()}),window.addEventListener("pageshow",t=>{v(t.persisted?"mobile-payment-bfcache-return":"mobile-payment-page-show")}),window.addEventListener("focus",()=>{v("mobile-payment-window-focus")}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="visible"&&v("mobile-payment-visibility-return")}),e[fe]=!0,v("mobile-payment-listener-installed"))}function ge(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(e.userAgent||"").toLowerCase(),n=((e.userAgentData?.platform??e.platform)||"").toLowerCase();if(/android|iphone|ipad|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||/iphone|ipad|ipod|ios/.test(n))return!0;const i=e.maxTouchPoints??0;return/macintosh|mac os x/.test(t)&&i>1}function bt(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const i=document.createElement("script");i.src=e,i.async=!0,i.onload=()=>t(),i.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(i)})}function _e(e,t){if(!t.startsWith("kginicis_"))return e;const n=new URL(e,window.location.origin);return n.searchParams.set("selectedPaymentMethod",t),n.toString()}function kt(e){const t=(e??"").trim().toUpperCase();return t===""||t==="WON"?"KRW":t}function we(e,t,n="utf-8",i){const r=document.createElement("form");i&&(r.id=i),r.method="POST",r.action=e,r.acceptCharset=n,r.style.display="none";for(const[o,a]of Object.entries(t)){const s=document.createElement("input");s.type="hidden",s.name=o,s.value=a,r.appendChild(s)}document.body.appendChild(r),r.submit()}const Pt={card:"Card",vbank:"VBank",bank:"DirectBank",phone:"HPP",kginicis_samsung_pay:"onlyssp",kginicis_naverpay:"onlynaverpay",kginicis_lLine truncated
|
||||
(function(){"use strict";const x="kginicis_pay_form_",R="__sirsoftKginicisReloadStandardPaySdk",w="__sirsoftKginicisMobilePaymentReturnPending";function oe(){if(typeof document>"u")return 0;const e=document.querySelectorAll(`form[id^="${x}"]`);return e.forEach(t=>t.remove()),e.length}function qe(){typeof window>"u"||(window[R]=!0)}function We(){if(typeof window>"u")return!1;const e=window,t=e[R]===!0;return delete e[R],t}function Ve(e){typeof window>"u"||typeof document>"u"||(window.INIStdPay=void 0,document.querySelectorAll(`script[src="${e}"], script[src*="/INIStdPay_third-party.js"]`).forEach(t=>t.remove()))}function ze(e=Date.now()){if(!(typeof window>"u"))try{window.sessionStorage.setItem(w,String(e))}catch{window[w]=e}}function M(){if(!(typeof window>"u")){try{window.sessionStorage.removeItem(w)}catch{}delete window[w]}}function ie(e=Date.now()){if(typeof window>"u")return!1;let t=null;try{const n=window.sessionStorage.getItem(w);t=n?Number(n):null}catch{t=null}if(!Number.isFinite(t)){const n=window[w];t=typeof n=="number"?n:Number(n)}return!Number.isFinite(t)||e-t>18e5?(M(),!1):!0}const N="sirsoft-pay_kginicis",Xe=N,Ze="payment-window-closed",ae="__sirsoftKginicisPaymentCloseListenerInstalled",L="__sirsoftKginicisActiveStandardPaymentCloseContext",se=20,Qe=100,O={info:(...e)=>console.info(`[${N}]`,...e),warn:(...e)=>console.warn(`[${N}]`,...e)};function et(e){if(!e||typeof e!="object")return!1;const t=e;return t.source===Xe&&t.type===Ze}function $(){return/\/shop\/checkout\/?$/.test(window.location.pathname)}function P(){return window}function tt(){const e=P()[L];return!e||typeof e!="object"?null:e}function nt(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function rt(e){e.closeReportUrl&&(P()[L]={...e,reported:!1})}function ce(){delete P()[L]}function de(){ce()}async function ot(e="payment-window-closed"){const t=tt();if(!t||t.reported||!$())return;t.reported=!0;const n={oid:t.oid,price:t.price,buyer_email:t.buyer_email??"",buyer_phone:t.buyer_phone??"",payment_method:t.payment_method??"",reason:e};try{const r=window.G7Core?.api;typeof r?.post=="function"?await r.post(t.closeReportUrl,n):await fetch(nt(t.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(r){O.warn("failed to report KG payment window close",{reason:e,error:r})}finally{ce()}}function le(e="payment-window-closed",t=!0){if(!$())return!1;const r=window.G7Core?.state?.setLocal;return typeof r!="function"?(t&&O.warn("G7Core.state.setLocal not available while resetting payment submit state"),!1):(oe(),qe(),r({isSubmittingOrder:!1}),O.info("checkout submit state reset after KG payment close",{reason:e}),!0)}function it(e,t=!1){let n=0;const r=()=>{if(n++,!(t&&!ie())){if(le(e,n>=se)){t&&M();return}!$()||n>=se||window.setTimeout(r,Qe)}};r()}function I(e){ie()&&it(e,!0)}function at(){if(typeof window>"u")return;const e=P();e[ae]||(window.addEventListener("message",t=>{t.origin===window.location.origin&&et(t.data)&&(ot(t.data.reason),le(t.data.reason))}),window.addEventListener("pagehide",()=>{de()}),window.addEventListener("beforeunload",()=>{de()}),window.addEventListener("pageshow",t=>{I(t.persisted?"mobile-payment-bfcache-return":"mobile-payment-page-show")}),window.addEventListener("focus",()=>{I("mobile-payment-window-focus")}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="visible"&&I("mobile-payment-visibility-return")}),e[ae]=!0,I("mobile-payment-listener-installed"))}function ue(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(e.userAgent||"").toLowerCase(),n=((e.userAgentData?.platform??e.platform)||"").toLowerCase();if(/android|iphone|ipad|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||/iphone|ipad|ipod|ios/.test(n))return!0;const r=e.maxTouchPoints??0;return/macintosh|mac os x/.test(t)&&r>1}function st(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const r=document.createElement("script");r.src=e,r.async=!0,r.onload=()=>t(),r.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(r)})}function me(e,t){if(!t.startsWith("kginicis_"))return e;const n=new URL(e,window.location.origin);return n.searchParams.set("selectedPaymentMethod",t),n.toString()}function ct(e){const t=(e??"").trim().toUpperCase();return t===""||t==="WON"?"KRW":t}function fe(e,t,n="utf-8",r){const o=document.createElement("form");r&&(o.id=r),o.method="POST",o.action=e,o.acceptCharset=n,o.style.display="none";for(const[i,s]of Object.entries(t)){const a=document.createElement("input");a.type="hidden",a.name=i,a.value=s,o.appendChild(a)}document.body.appendChild(o),o.submit()}const dt={card:"Card",vbank:"VBank",bank:"DirectBank",phone:"HPP",kginicis_samsung_pay:"onlyssp",kginicis_naverpay:"onlynaverpay",kginicis_lLine truncated
|
||||
<div class="row">
|
||||
<dt>${x(o.label)}</dt>
|
||||
<dd>${x(o.value)}</dd>
|
||||
<dt>${E(i.label)}</dt>
|
||||
<dd>${E(i.value)}</dd>
|
||||
</div>
|
||||
`).join("");t.document.open(),t.document.write(`<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${x(n)}</title>
|
||||
<title>${E(n)}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -81,11 +81,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${x(n)}</h1>
|
||||
${i?`<p class="notice">${x(i)}</p>`:""}
|
||||
<dl>${r}</dl>
|
||||
<h1>${E(n)}</h1>
|
||||
${r?`<p class="notice">${E(r)}</p>`:""}
|
||||
<dl>${o}</dl>
|
||||
<div class="actions"><button type="button" onclick="window.print()">인쇄</button></div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`),t.document.close()}function x(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}const B="sirsoft-pay_kginicis",Ee="__kginicisOrderShowInjectorInstalled",j="kginicis-mp-receipt-row",ve=/^(?:\/mypage\/orders\/([^/]+)|\/shop\/guest\/orders\/([^/]+))$/,Y=new Map;function Yt(e){try{const n=window.G7Core?.getState,o=n?.()?.currentDataContext?.order?.data;return o&&(o.order_number===e||o.id!==void 0&&String(o.id)===e)?o:null}catch{return null}}function Ut(){const e=document.getElementById("order_payment_info_panel");if(e)return Array.from(e.children).find(i=>i.className?.includes("space-y"))??e;const t=Array.from(document.querySelectorAll("h3")).find(i=>{const r=i.textContent?.trim()??"";return r.includes("결제 정보")||/^Payment Information$/i.test(r)});if(!t)return null;const n=t.parentElement?.parentElement;return n?Array.from(n.children).find(i=>i.className?.includes("space-y"))??n:null}function Ht(e,t){if(!t)return!1;const n=Array.from(e.querySelectorAll("div"));for(const i of n){const r=Array.from(i.children).filter(s=>s instanceof HTMLElement&&s.tagName==="SPAN");if(r.length<2)continue;const o=r[0].textContent?.trim();if(o!=="결제 방법"&&o!=="결제수단"&&o!=="Payment Method")continue;const a=r[r.length-1];return a.textContent?.trim()===t?(i.dataset.kginicisPaymentMethodRow="true",!0):(a.textContent=t,a.dataset.kginicisPaymentMethodPatched="true",i.dataset.kginicisPaymentMethodRow="true",!0)}return!1}function Jt(e,t){const n=document.createElement("div");n.id=j,n.className="flex items-center justify-between",n.dataset.kginicisReceiptRow="mypage-order";const i=document.createElement("span");i.className="text-gray-500 dark:text-gray-400 text-sm",i.textContent=Bt(t);const r=document.createElement("button");return r.type="button",r.dataset.kginicisReceiptButton="mypage-order",r.className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50",r.textContent=I(t),r.addEventListener("click",async()=>{r.disabled=!0,r.textContent="로딩 중...";const o=await S(e);r.disabled=!1,r.textContent=I(o??t),P(o)&&Ce(o)}),n.appendChild(i),n.appendChild(r),n}async function Ft(e,t){const n=Yt(e),i=n?.payment;if(i&&(i.pg_provider!=="kginicis"||!i.transaction_id))return!0;const r=n?.order_number??(t?e:null);if(!r)return!1;if(!t&&n&&!i)return!0;const o=Ut();if(!o)return!1;const{status:a,info:s}=await xe(r);if(!t&&a===404)return!0;const c=i?.payment_status==="paid",u=s?.receipt_type==="cbt_confirmation";if(i&&!c&&!u)return!0;if(!i&&!P(s))return!1;const l=Ht(o,s?.payment_method_display_label);return!document.getElementById(j)&&P(s)&&(o.appendChild(Jt(r,s)),console.info(`[${B}] receipt button injected on mypage order show`)),l&&console.info(`[${B}] payment method display patched on mypage order show`),!!(document.getElementById(j)||l)}function Wt(e,t){if(Y.has(e))return;let n=0;const i=window.setInterval(()=>{n++,Ft(e,t).then(r=>{(r||n>=30)&&(window.clearInterval(i),Y.delete(e))})},400);Y.set(e,i)}function qt(){const e=location.pathname.match(ve);if(e){const t=e[1],n=e[2],i=t??n;i&&Wt(i,n!==void 0)}}function zt(){if(typeof window>"u")return;const e=window;if(e[Ee])return;e[Ee]=!0,console.info(`[${B}] mypage order show injector installed`);let t=null;const n=(s=1500)=>{t!==null&&window.clearTimeout(t),t=window.setTimeout(()=>{t=null,qt()},s)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>n()):n();const i=history.pushState.bind(history);history.pushState=(...s)=>{i(...s),n(600)};const r=history.replaceState.bind(history);history.replaceState=(...s)=>{r(...s),n(600)},window.addEventListener("popstate",()=>n(500));const o=document.getElementById("app")??document.body;new MutationObserver(()=>{ve.test(location.pathname)&&n(250)}).observe(o,{childList:!0,subtree:!0})}const U="sirsoft-pay_kginicis",Se="__kginicisAdminPaymentDisplayInjectorInstalled",Vt=/^\/admin\/ecommerce\/orders\/([^/]+)$/;function Xt(){return localStorage.getItem("auth_token")}async function Zt(e){const t=Xt();if(!t)return null;try{const n=await fetch(`/api/plugins/${U}/admin/orders/${e}/transaction-status`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});if(!n.ok)return null;const i=await n.json();return("data"in i?i.data:i)??null}catch{return null}}function Qt(e){return Array.from(e.children).filter(t=>t instanceof HTMLElement)}function en(e,t){let n=!1;const i=Array.from(e.querySelectorAll("div"));for(const r of i){const o=Qt(r),a=o.findIndex(d=>d.textContent?.trim()==="결제수단");if(a<0)continue;const s=o.slice(a+1).find(d=>d.textContent?.trim()&&d.textContent.trim()!=="결제수단");if(!s)continue;const c=o.slice(a+2).find(d=>/^\(.+\)$/.test(d.textContent?.trim()??"")),u=c?.textContent?.trim().replace(/^\(|\)$/g,"")??"",l=u&&t.endsWith(")")?t.replace(/\)$/,`, ${u})`):t;s.textContent?.trim()!==l&&(s.textContent=l,s.dataset.kginicisPaymentMethodPatched="true"),r.dataLine truncated
|
||||
</html>`),t.document.close()}function E(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}const D="sirsoft-pay_kginicis",we="__kginicisOrderShowInjectorInstalled",K="kginicis-mp-receipt-row",be=/^(?:\/mypage\/orders\/([^/]+)|\/shop\/guest\/orders\/([^/]+))$/,G=new Map;function Tt(e){try{const n=window.G7Core?.getState,i=n?.()?.currentDataContext?.order?.data;return i&&(i.order_number===e||i.id!==void 0&&String(i.id)===e)?i:null}catch{return null}}function xt(){const e=document.getElementById("order_payment_info_panel");if(e)return Array.from(e.children).find(r=>r.className?.includes("space-y"))??e;const t=Array.from(document.querySelectorAll("h3")).find(r=>{const o=r.textContent?.trim()??"";return o.includes("결제 정보")||/^Payment Information$/i.test(o)});if(!t)return null;const n=t.parentElement?.parentElement;return n?Array.from(n.children).find(r=>r.className?.includes("space-y"))??n:null}function Rt(e,t){if(!t)return!1;const n=Array.from(e.querySelectorAll("div"));for(const r of n){const o=Array.from(r.children).filter(a=>a instanceof HTMLElement&&a.tagName==="SPAN");if(o.length<2)continue;const i=o[0].textContent?.trim();if(i!=="결제 방법"&&i!=="결제수단"&&i!=="Payment Method")continue;const s=o[o.length-1];return s.textContent?.trim()===t?(r.dataset.kginicisPaymentMethodRow="true",!0):(s.textContent=t,s.dataset.kginicisPaymentMethodPatched="true",r.dataset.kginicisPaymentMethodRow="true",!0)}return!1}function Mt(e,t){const n=document.createElement("div");n.id=K,n.className="flex items-center justify-between",n.dataset.kginicisReceiptRow="mypage-order";const r=document.createElement("span");r.className="text-gray-500 dark:text-gray-400 text-sm",r.textContent=vt(t);const o=document.createElement("button");return o.type="button",o.dataset.kginicisReceiptButton="mypage-order",o.className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50",o.textContent=A(t),o.addEventListener("click",async()=>{o.disabled=!0,o.textContent="로딩 중...";const i=await v(e);o.disabled=!1,o.textContent=A(i??t),C(i)&&ge(i)}),n.appendChild(r),n.appendChild(o),n}async function Nt(e,t){const n=Tt(e),r=n?.payment;if(r&&(r.pg_provider!=="kginicis"||!r.transaction_id))return!0;const o=n?.order_number??(t?e:null);if(!o)return!1;if(!t&&n&&!r)return!0;const i=xt();if(!i)return!1;const{status:s,info:a}=await he(o);if(!t&&s===404)return!0;const c=r?.payment_status==="paid",u=a?.receipt_type==="cbt_confirmation";if(r&&!c&&!u)return!0;if(!r&&!C(a))return!1;const d=Rt(i,a?.payment_method_display_label);return!document.getElementById(K)&&C(a)&&(i.appendChild(Mt(o,a)),console.info(`[${D}] receipt button injected on mypage order show`)),d&&console.info(`[${D}] payment method display patched on mypage order show`),!!(document.getElementById(K)||d)}function Lt(e,t){if(G.has(e))return;let n=0;const r=window.setInterval(()=>{n++,Nt(e,t).then(o=>{(o||n>=30)&&(window.clearInterval(r),G.delete(e))})},400);G.set(e,r)}function Ot(){const e=location.pathname.match(be);if(e){const t=e[1],n=e[2],r=t??n;r&&Lt(r,n!==void 0)}}function $t(){if(typeof window>"u")return;const e=window;if(e[we])return;e[we]=!0,console.info(`[${D}] mypage order show injector installed`);let t=null;const n=(a=1500)=>{t!==null&&window.clearTimeout(t),t=window.setTimeout(()=>{t=null,Ot()},a)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>n()):n();const r=history.pushState.bind(history);history.pushState=(...a)=>{r(...a),n(600)};const o=history.replaceState.bind(history);history.replaceState=(...a)=>{o(...a),n(600)},window.addEventListener("popstate",()=>n(500));const i=document.getElementById("app")??document.body;new MutationObserver(()=>{be.test(location.pathname)&&n(250)}).observe(i,{childList:!0,subtree:!0})}const j="sirsoft-pay_kginicis",ke="__kginicisAdminPaymentDisplayInjectorInstalled",Dt=/^\/admin\/ecommerce\/orders\/([^/]+)$/;function Kt(){return localStorage.getItem("auth_token")}async function Gt(e){const t=Kt();if(!t)return null;try{const n=await fetch(`/api/plugins/${j}/admin/orders/${e}/transaction-status`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});if(!n.ok)return null;const r=await n.json();return("data"in r?r.data:r)??null}catch{return null}}function jt(e){return Array.from(e.children).filter(t=>t instanceof HTMLElement)}function Yt(e,t){let n=!1;const r=Array.from(e.querySelectorAll("div"));for(const o of r){const i=jt(o),s=i.findIndex(l=>l.textContent?.trim()==="결제수단");if(s<0)continue;const a=i.slice(s+1).find(l=>l.textContent?.trim()&&l.textContent.trim()!=="결제수단");if(!a)continue;const c=i.slice(s+2).find(l=>/^\(.+\)$/.test(l.textContent?.trim()??"")),u=c?.textContent?.trim().replace(/^\(|\)$/g,"")??"",d=u&&t.endsWith(")")?t.replace(/\)$/,`, ${u})`):t;a.textContent?.trim()!==d&&(a.textContent=d,a.dataset.kginicisPaymentMethodPatched="true"),o.dataLine truncated
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
-233
@@ -1,233 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
installCheckoutNaverpayBrandButton,
|
||||
patchRenderedNaverpayBrandButton,
|
||||
resetCheckoutNaverpayBrandButtonForTests,
|
||||
} from '../checkoutNaverpayBrandButton';
|
||||
|
||||
function brandButton(label: string, description: string, icon = 'wallet'): string {
|
||||
return `
|
||||
<button type="button">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-${icon}" data-original-icon="true" role="img"></i>
|
||||
<div>
|
||||
<p>${label}</p>
|
||||
<p>${description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPaymentButtons(): void {
|
||||
document.body.innerHTML = `
|
||||
${brandButton('네이버페이 (KG이니시스)', '네이버페이로 결제')}
|
||||
${brandButton('삼성페이 (KG이니시스)', '삼성페이로 결제 — KG 이니시스를 통해 처리', 'mobile-screen-button')}
|
||||
${brandButton('L.pay (KG이니시스)', 'L.pay 로 결제 — KG 이니시스를 통해 처리', 'mobile-screen-button')}
|
||||
${brandButton('카카오페이 (KG이니시스)', '카카오페이로 결제 — KG 이니시스를 통해 처리', 'mobile-screen-button')}
|
||||
${brandButton('PayPay (일본 KG이니시스)', '일본 엔(JPY) 주문을 KG 이니시스 CBT PayPay로 결제')}
|
||||
${brandButton('일본 편의점결제 (KG이니시스)', '일본 엔(JPY) 주문을 KG 이니시스 CBT 편의점 결제로 접수', 'store')}
|
||||
<button type="button">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg data-original-icon="true"></svg>
|
||||
<div>
|
||||
<p>신용카드</p>
|
||||
<p>카드로 결제</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
describe('checkoutNaverpayBrandButton', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.lang = 'ko';
|
||||
window.history.pushState({}, '', '/shop/checkout');
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetCheckoutNaverpayBrandButtonForTests();
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('네이버페이 버튼에 title 과 브랜드 마크를 적용한다', () => {
|
||||
renderPaymentButtons();
|
||||
|
||||
expect(patchRenderedNaverpayBrandButton()).toBe(true);
|
||||
|
||||
const naverpayButton = document.querySelector<HTMLButtonElement>('button[data-kginicis-brand-payment-method="kginicis_naverpay"]');
|
||||
expect(naverpayButton).not.toBeNull();
|
||||
expect(naverpayButton?.title).toBe('네이버페이로 결제 (kg이니시스)');
|
||||
expect(naverpayButton?.dataset.kginicisNaverpayBrandButton).toBe('true');
|
||||
expect(naverpayButton?.querySelector('[data-kginicis-brand-payment-mark="true"]')).not.toBeNull();
|
||||
expect(naverpayButton?.querySelector('[data-kginicis-naverpay-mark="true"]')).not.toBeNull();
|
||||
expect(naverpayButton?.querySelector('[data-original-icon="true"]')).toBeNull();
|
||||
const heading = naverpayButton?.querySelector<HTMLElement>('p');
|
||||
expect(heading?.getAttribute('aria-label')).toBe('네이버페이');
|
||||
expect(heading?.textContent).toBe('네이버페이');
|
||||
expect(heading?.querySelector('span')).toBeNull();
|
||||
expect(heading?.style.fontSize).toBe('');
|
||||
expect(heading?.style.whiteSpace).toBe('normal');
|
||||
expect(heading?.style.wordBreak).toBe('keep-all');
|
||||
expect(heading?.style.overflowWrap).toBe('anywhere');
|
||||
const description = naverpayButton?.querySelectorAll<HTMLElement>('p')[1];
|
||||
expect(description?.textContent).toBe('네이버페이로 결제 (kg이니시스)');
|
||||
expect(description?.style.fontSize).toBe('12px');
|
||||
expect(description?.style.lineHeight).toBe('1rem');
|
||||
const row = naverpayButton?.querySelector<HTMLElement>('.flex.items-center');
|
||||
expect(row?.style.width).toBe('100%');
|
||||
expect(row?.style.minWidth).toBe('0');
|
||||
expect(row?.style.maxWidth).toBe('100%');
|
||||
const textWrapper = heading?.parentElement;
|
||||
expect(textWrapper?.style.flex).toBe('1 1 0px');
|
||||
expect(textWrapper?.style.minWidth).toBe('0');
|
||||
expect(textWrapper?.style.maxWidth).toBe('100%');
|
||||
expect(naverpayButton?.querySelector<HTMLElement>('[data-kginicis-brand-payment-mark="true"]')?.style.width).toBe('32px');
|
||||
|
||||
const cardButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
|
||||
.find((button) => button.textContent?.includes('신용카드'));
|
||||
expect(cardButton?.querySelector('[data-original-icon="true"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('브랜드 버튼 설정 대상 결제수단을 모두 아이콘과 짧은 문구로 표시한다', () => {
|
||||
renderPaymentButtons();
|
||||
|
||||
expect(patchRenderedNaverpayBrandButton()).toBe(true);
|
||||
|
||||
const expected = [
|
||||
['kginicis_samsung_pay', '삼성페이', '삼성페이로 결제 (kg이니시스)'],
|
||||
['kginicis_naverpay', '네이버페이', '네이버페이로 결제 (kg이니시스)'],
|
||||
['kginicis_lpay', 'L.pay', 'L.pay로 결제 (kg이니시스)'],
|
||||
['kginicis_kakaopay', '카카오페이', '카카오페이로 결제 (kg이니시스)'],
|
||||
['kginicis_japan_paypay', 'PayPay', 'PayPay로 결제 (일본 KG)'],
|
||||
['kginicis_japan_cvs', '일본 편의점결제', '편의점 결제 (일본 KG)'],
|
||||
];
|
||||
|
||||
expected.forEach(([methodId, heading, description]) => {
|
||||
const button = document.querySelector<HTMLButtonElement>(`button[data-kginicis-brand-payment-method="${methodId}"]`);
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.querySelector('[data-kginicis-brand-payment-mark="true"]')).not.toBeNull();
|
||||
expect(button?.querySelector('[data-original-icon="true"]')).toBeNull();
|
||||
expect(button?.querySelectorAll<HTMLElement>('p')[0]?.textContent).toBe(heading);
|
||||
expect(button?.querySelectorAll<HTMLElement>('p')[1]?.textContent).toBe(description);
|
||||
});
|
||||
|
||||
expect(document.querySelectorAll('button[data-kginicis-brand-payment-button="true"]')).toHaveLength(6);
|
||||
expect(document.querySelectorAll('[data-kginicis-brand-payment-mark="true"]')).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('좁은 카드에서는 제목과 설명을 총 2줄 compact layout 으로 정렬한다', () => {
|
||||
renderPaymentButtons();
|
||||
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement): DOMRect {
|
||||
const isNaverpayButton = this instanceof HTMLButtonElement
|
||||
&& (this.textContent ?? '').includes('네이버페이');
|
||||
const width = isNaverpayButton ? 180 : 0;
|
||||
|
||||
return {
|
||||
bottom: 80,
|
||||
height: 80,
|
||||
left: 0,
|
||||
right: width,
|
||||
top: 0,
|
||||
width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
});
|
||||
|
||||
expect(patchRenderedNaverpayBrandButton()).toBe(true);
|
||||
|
||||
const naverpayButton = document.querySelector<HTMLButtonElement>('button[data-kginicis-brand-payment-method="kginicis_naverpay"]');
|
||||
const row = naverpayButton?.querySelector<HTMLElement>('.flex.items-center');
|
||||
const paragraphs = naverpayButton?.querySelectorAll<HTMLElement>('p');
|
||||
const heading = paragraphs?.[0];
|
||||
const description = paragraphs?.[1];
|
||||
const textWrapper = heading?.parentElement;
|
||||
|
||||
expect(naverpayButton?.style.paddingLeft).toBe('10px');
|
||||
expect(naverpayButton?.style.paddingRight).toBe('10px');
|
||||
expect(row?.style.gap).toBe('6px');
|
||||
expect(row?.style.maxWidth).toBe('100%');
|
||||
expect(row?.style.flexWrap).toBe('wrap');
|
||||
expect(textWrapper?.style.display).toBe('contents');
|
||||
expect(heading?.style.order).toBe('2');
|
||||
expect(heading?.style.flex).toBe('1 1 0px');
|
||||
expect(description?.style.order).toBe('3');
|
||||
expect(description?.style.flex).toBe('0 0 100%');
|
||||
expect(description?.style.whiteSpace).toBe('nowrap');
|
||||
expect(description?.style.wordBreak).toBe('normal');
|
||||
expect(description?.style.overflowWrap).toBe('normal');
|
||||
expect(description?.style.fontSize).toBe('11px');
|
||||
});
|
||||
|
||||
it('클라이언트 설정이 비활성화면 버튼을 건드리지 않는다', async () => {
|
||||
renderPaymentButtons();
|
||||
|
||||
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({
|
||||
data: { easy_pay_show_brand_button: false },
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
|
||||
installCheckoutNaverpayBrandButton(fetchSpy as unknown as typeof fetch);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(document.querySelector('[data-kginicis-brand-payment-button="true"]')).toBeNull();
|
||||
expect(document.querySelector('[data-kginicis-brand-payment-mark="true"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('클라이언트 설정이 활성화면 체크아웃에서 자동 적용한다', async () => {
|
||||
renderPaymentButtons();
|
||||
|
||||
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({
|
||||
data: { easy_pay_show_brand_button: true },
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
|
||||
installCheckoutNaverpayBrandButton(fetchSpy as unknown as typeof fetch);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(document.querySelector('[data-kginicis-brand-payment-button="true"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-kginicis-brand-payment-mark="true"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('결제수단 텍스트가 늦게 채워져도 자동 적용한다', async () => {
|
||||
document.body.innerHTML = `
|
||||
<button>
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-wallet" data-original-icon="true" role="img"></i>
|
||||
<div>
|
||||
<p id="late-heading"></p>
|
||||
<p id="late-description"></p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const heading = document.getElementById('late-heading');
|
||||
const description = document.getElementById('late-description');
|
||||
heading?.appendChild(document.createTextNode(''));
|
||||
description?.appendChild(document.createTextNode(''));
|
||||
|
||||
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({
|
||||
data: { easy_pay_show_brand_button: true },
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
|
||||
installCheckoutNaverpayBrandButton(fetchSpy as unknown as typeof fetch);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(document.querySelector('[data-kginicis-brand-payment-button="true"]')).toBeNull();
|
||||
|
||||
if (heading?.firstChild) heading.firstChild.nodeValue = '네이버페이 (KG이니시스)';
|
||||
if (description?.firstChild) description.firstChild.nodeValue = '네이버페이로 결제';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const naverpayButton = document.querySelector<HTMLButtonElement>('button[data-kginicis-brand-payment-method="kginicis_naverpay"]');
|
||||
expect(naverpayButton).not.toBeNull();
|
||||
expect(naverpayButton?.title).toBe('네이버페이로 결제 (kg이니시스)');
|
||||
expect(naverpayButton?.querySelector('[data-kginicis-brand-payment-mark="true"]')).not.toBeNull();
|
||||
expect(naverpayButton?.querySelector<HTMLElement>('p')?.textContent).toBe('네이버페이');
|
||||
expect(naverpayButton?.querySelectorAll<HTMLElement>('p')[1]?.textContent).toBe('네이버페이로 결제 (kg이니시스)');
|
||||
});
|
||||
});
|
||||
@@ -1,504 +0,0 @@
|
||||
const PLUGIN_ID = 'sirsoft-pay_kginicis';
|
||||
const FLAG = '__sirsoftKginicisCheckoutBrandPaymentButtonsInstalled';
|
||||
const CHECKOUT_RE = /^\/shop\/checkout\/?$/;
|
||||
const CLIENT_CONFIG_PATH = '/api/modules/sirsoft-ecommerce/payments/client-config/kginicis';
|
||||
const TWO_LINE_COMPACT_WIDTH = 220;
|
||||
const DEFAULT_PADDING_X = 12;
|
||||
const COMPACT_PADDING_X = 10;
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
let cachedEnabled: Promise<boolean> | null = null;
|
||||
let retryTimer: number | null = null;
|
||||
|
||||
interface ClientConfigBody {
|
||||
data?: {
|
||||
easy_pay_show_brand_button?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface BrandPaymentCopy {
|
||||
heading: string;
|
||||
description: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface BrandPaymentDefinition {
|
||||
id: string;
|
||||
labels: string[];
|
||||
ko: BrandPaymentCopy;
|
||||
en: BrandPaymentCopy;
|
||||
markSvg: string;
|
||||
}
|
||||
|
||||
const BRAND_PAYMENT_DEFINITIONS: BrandPaymentDefinition[] = [
|
||||
{
|
||||
id: 'kginicis_naverpay',
|
||||
labels: ['네이버페이 (KG이니시스)', 'Naver Pay (KG Inicis)'],
|
||||
ko: {
|
||||
heading: '네이버페이',
|
||||
description: '네이버페이로 결제 (kg이니시스)',
|
||||
title: '네이버페이로 결제 (kg이니시스)',
|
||||
},
|
||||
en: {
|
||||
heading: 'Naver Pay',
|
||||
description: 'Pay with Naver Pay (KG Inicis)',
|
||||
title: 'Pay with Naver Pay (KG Inicis)',
|
||||
},
|
||||
markSvg: [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 40 40" role="img" aria-label="Naver Pay">',
|
||||
'<rect width="40" height="40" rx="8" fill="#03C75A"/>',
|
||||
'<text x="20" y="17" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="12" font-weight="700">N</text>',
|
||||
'<text x="20" y="29" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="9" font-weight="700">Pay</text>',
|
||||
'</svg>',
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
id: 'kginicis_samsung_pay',
|
||||
labels: ['삼성페이 (KG이니시스)', 'Samsung Pay (KG Inicis)'],
|
||||
ko: {
|
||||
heading: '삼성페이',
|
||||
description: '삼성페이로 결제 (kg이니시스)',
|
||||
title: '삼성페이로 결제 (kg이니시스)',
|
||||
},
|
||||
en: {
|
||||
heading: 'Samsung Pay',
|
||||
description: 'Pay with Samsung Pay (KG Inicis)',
|
||||
title: 'Pay with Samsung Pay (KG Inicis)',
|
||||
},
|
||||
markSvg: [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 40 40" role="img" aria-label="Samsung Pay">',
|
||||
'<rect width="40" height="40" rx="8" fill="#1428A0"/>',
|
||||
'<text x="20" y="18" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="10" font-weight="700">S</text>',
|
||||
'<text x="20" y="29" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="9" font-weight="700">Pay</text>',
|
||||
'</svg>',
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
id: 'kginicis_lpay',
|
||||
labels: ['L.pay (KG이니시스)', 'L.pay (KG Inicis)'],
|
||||
ko: {
|
||||
heading: 'L.pay',
|
||||
description: 'L.pay로 결제 (kg이니시스)',
|
||||
title: 'L.pay로 결제 (kg이니시스)',
|
||||
},
|
||||
en: {
|
||||
heading: 'L.pay',
|
||||
description: 'Pay with L.pay (KG Inicis)',
|
||||
title: 'Pay with L.pay (KG Inicis)',
|
||||
},
|
||||
markSvg: [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 40 40" role="img" aria-label="L.pay">',
|
||||
'<rect width="40" height="40" rx="8" fill="#D71920"/>',
|
||||
'<text x="20" y="18" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="12" font-weight="700">L</text>',
|
||||
'<text x="20" y="29" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="8" font-weight="700">pay</text>',
|
||||
'</svg>',
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
id: 'kginicis_kakaopay',
|
||||
labels: ['카카오페이 (KG이니시스)', 'Kakao Pay (KG Inicis)'],
|
||||
ko: {
|
||||
heading: '카카오페이',
|
||||
description: '카카오페이로 결제 (kg이니시스)',
|
||||
title: '카카오페이로 결제 (kg이니시스)',
|
||||
},
|
||||
en: {
|
||||
heading: 'Kakao Pay',
|
||||
description: 'Pay with Kakao Pay (KG Inicis)',
|
||||
title: 'Pay with Kakao Pay (KG Inicis)',
|
||||
},
|
||||
markSvg: [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 40 40" role="img" aria-label="Kakao Pay">',
|
||||
'<rect width="40" height="40" rx="8" fill="#FEE500"/>',
|
||||
'<text x="20" y="18" text-anchor="middle" fill="#111111" font-family="Arial, sans-serif" font-size="9" font-weight="700">Kakao</text>',
|
||||
'<text x="20" y="29" text-anchor="middle" fill="#111111" font-family="Arial, sans-serif" font-size="9" font-weight="700">Pay</text>',
|
||||
'</svg>',
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
id: 'kginicis_japan_paypay',
|
||||
labels: ['PayPay (일본 KG이니시스)', 'PayPay (KG Inicis Japan)'],
|
||||
ko: {
|
||||
heading: 'PayPay',
|
||||
description: 'PayPay로 결제 (일본 KG)',
|
||||
title: 'PayPay로 결제 (일본 KG이니시스)',
|
||||
},
|
||||
en: {
|
||||
heading: 'PayPay',
|
||||
description: 'Pay with PayPay (KG Japan)',
|
||||
title: 'Pay with PayPay (KG Inicis Japan)',
|
||||
},
|
||||
markSvg: [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 40 40" role="img" aria-label="PayPay">',
|
||||
'<rect width="40" height="40" rx="8" fill="#E60012"/>',
|
||||
'<text x="20" y="18" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="8" font-weight="700">Pay</text>',
|
||||
'<text x="20" y="29" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="8" font-weight="700">Pay</text>',
|
||||
'</svg>',
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
id: 'kginicis_japan_cvs',
|
||||
labels: ['일본 편의점결제 (KG이니시스)', 'Japan Convenience Store (KG Inicis)'],
|
||||
ko: {
|
||||
heading: '일본 편의점결제',
|
||||
description: '편의점 결제 (일본 KG)',
|
||||
title: '일본 편의점결제 (KG이니시스)',
|
||||
},
|
||||
en: {
|
||||
heading: 'Convenience Store',
|
||||
description: 'Pay at store (KG Japan)',
|
||||
title: 'Japan Convenience Store (KG Inicis)',
|
||||
},
|
||||
markSvg: [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 40 40" role="img" aria-label="Convenience Store">',
|
||||
'<rect width="40" height="40" rx="8" fill="#0072CE"/>',
|
||||
'<text x="20" y="18" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="9" font-weight="700">CVS</text>',
|
||||
'<text x="20" y="29" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="8" font-weight="700">JP</text>',
|
||||
'</svg>',
|
||||
].join(''),
|
||||
},
|
||||
];
|
||||
|
||||
const logger = {
|
||||
info: (...args: unknown[]) => console.info(`[${PLUGIN_ID}]`, ...args),
|
||||
};
|
||||
|
||||
function windowRecord(): Record<string, unknown> {
|
||||
return window as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isCheckoutPage(): boolean {
|
||||
return CHECKOUT_RE.test(window.location.pathname);
|
||||
}
|
||||
|
||||
function isKoreanPage(): boolean {
|
||||
const lang = document.documentElement.lang || navigator.language || '';
|
||||
return lang.toLowerCase().startsWith('ko');
|
||||
}
|
||||
|
||||
function copyFor(definition: BrandPaymentDefinition): BrandPaymentCopy {
|
||||
return isKoreanPage() ? definition.ko : definition.en;
|
||||
}
|
||||
|
||||
function normalizedText(value: string | null | undefined): string {
|
||||
return (value ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getButtonWidth(button: HTMLButtonElement): number {
|
||||
const rectWidth = button.getBoundingClientRect().width;
|
||||
|
||||
return rectWidth > 0 ? rectWidth : button.clientWidth;
|
||||
}
|
||||
|
||||
function shouldUseTwoLineCompactLayout(button: HTMLButtonElement): boolean {
|
||||
const width = getButtonWidth(button);
|
||||
|
||||
return width > 0 && width < TWO_LINE_COMPACT_WIDTH;
|
||||
}
|
||||
|
||||
function compactDescriptionFontSize(button: HTMLButtonElement): string {
|
||||
const availableWidth = getButtonWidth(button) - (COMPACT_PADDING_X * 2);
|
||||
|
||||
if (availableWidth > 0 && availableWidth < 144) {
|
||||
return '9.5px';
|
||||
}
|
||||
|
||||
if (availableWidth > 0 && availableWidth < 160) {
|
||||
return '10px';
|
||||
}
|
||||
|
||||
if (availableWidth > 0 && availableWidth < 176) {
|
||||
return '11px';
|
||||
}
|
||||
|
||||
return '12px';
|
||||
}
|
||||
|
||||
async function fetchEnabled(fetchImpl: typeof fetch): Promise<boolean> {
|
||||
if (cachedEnabled !== null) return cachedEnabled;
|
||||
|
||||
cachedEnabled = (async () => {
|
||||
try {
|
||||
const response = await fetchImpl(CLIENT_CONFIG_PATH, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
|
||||
const body = (await response.json()) as ClientConfigBody;
|
||||
return body.data?.easy_pay_show_brand_button === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
return cachedEnabled;
|
||||
}
|
||||
|
||||
function findBrandPaymentDefinition(button: HTMLButtonElement): BrandPaymentDefinition | null {
|
||||
const methodId = button.dataset.kginicisBrandPaymentMethod;
|
||||
const existing = BRAND_PAYMENT_DEFINITIONS.find((definition) => definition.id === methodId);
|
||||
if (existing) return existing;
|
||||
|
||||
if (button.dataset.kginicisNaverpayBrandButton === 'true') {
|
||||
return BRAND_PAYMENT_DEFINITIONS.find((definition) => definition.id === 'kginicis_naverpay') ?? null;
|
||||
}
|
||||
|
||||
const text = normalizedText(button.textContent);
|
||||
|
||||
return BRAND_PAYMENT_DEFINITIONS.find((definition) => {
|
||||
const copy = copyFor(definition);
|
||||
|
||||
return definition.labels.some((label) => text.includes(label))
|
||||
|| text.includes(definition.ko.heading)
|
||||
|| text.includes(definition.en.heading)
|
||||
|| text.includes(copy.heading);
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
function findHeading(
|
||||
paragraphs: HTMLParagraphElement[] | HTMLElement[],
|
||||
definition: BrandPaymentDefinition,
|
||||
): HTMLElement | null {
|
||||
const copy = copyFor(definition);
|
||||
|
||||
return paragraphs.find((element) => {
|
||||
const text = normalizedText(element.textContent);
|
||||
|
||||
return element.dataset.kginicisBrandPaymentHeading === definition.id
|
||||
|| definition.labels.some((label) => text.includes(label))
|
||||
|| text.includes(definition.ko.heading)
|
||||
|| text.includes(definition.en.heading)
|
||||
|| text.includes(copy.heading);
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
function formatBrandPaymentText(button: HTMLButtonElement, definition: BrandPaymentDefinition): void {
|
||||
const paragraphs = Array.from(button.querySelectorAll<HTMLParagraphElement>('p'));
|
||||
const heading = findHeading(paragraphs, definition);
|
||||
|
||||
if (!heading) return;
|
||||
|
||||
const copy = copyFor(definition);
|
||||
if (heading.textContent !== copy.heading) {
|
||||
heading.textContent = copy.heading;
|
||||
}
|
||||
heading.dataset.kginicisBrandPaymentHeading = definition.id;
|
||||
if (definition.id === 'kginicis_naverpay') {
|
||||
heading.dataset.kginicisNaverpayHeading = copy.heading;
|
||||
}
|
||||
if (heading.getAttribute('aria-label') !== copy.heading) {
|
||||
heading.setAttribute('aria-label', copy.heading);
|
||||
}
|
||||
|
||||
const description = paragraphs[paragraphs.indexOf(heading as HTMLParagraphElement) + 1]
|
||||
?? paragraphs.find((element) => element !== heading);
|
||||
if (!description) return;
|
||||
|
||||
if (description.textContent !== copy.description) {
|
||||
description.textContent = copy.description;
|
||||
}
|
||||
description.dataset.kginicisBrandPaymentDescription = definition.id;
|
||||
if (definition.id === 'kginicis_naverpay') {
|
||||
description.dataset.kginicisNaverpayDescription = copy.description;
|
||||
}
|
||||
}
|
||||
|
||||
function applyCompactBrandPaymentLayout(button: HTMLButtonElement, definition: BrandPaymentDefinition): void {
|
||||
const useTwoLineCompact = shouldUseTwoLineCompactLayout(button);
|
||||
const paddingX = useTwoLineCompact ? COMPACT_PADDING_X : DEFAULT_PADDING_X;
|
||||
|
||||
button.style.paddingLeft = `${paddingX}px`;
|
||||
button.style.paddingRight = `${paddingX}px`;
|
||||
button.style.boxSizing = 'border-box';
|
||||
button.style.minWidth = '0';
|
||||
|
||||
const row = button.querySelector<HTMLElement>('.flex.items-center.gap-2, .flex.items-center.gap-3')
|
||||
?? button.querySelector<HTMLElement>('.flex.items-center');
|
||||
if (row) {
|
||||
row.style.gap = useTwoLineCompact ? '6px' : '8px';
|
||||
row.style.width = '100%';
|
||||
row.style.minWidth = '0';
|
||||
row.style.maxWidth = '100%';
|
||||
row.style.boxSizing = 'border-box';
|
||||
row.style.flexWrap = useTwoLineCompact ? 'wrap' : 'nowrap';
|
||||
}
|
||||
|
||||
const heading = findHeading(
|
||||
Array.from(button.querySelectorAll<HTMLElement>('p')),
|
||||
definition,
|
||||
);
|
||||
|
||||
if (!heading) return;
|
||||
|
||||
heading.style.whiteSpace = 'normal';
|
||||
heading.style.wordBreak = 'keep-all';
|
||||
heading.style.overflowWrap = 'anywhere';
|
||||
heading.style.removeProperty('font-size');
|
||||
heading.style.removeProperty('line-height');
|
||||
heading.style.maxWidth = '100%';
|
||||
|
||||
const textWrapper = heading.parentElement;
|
||||
if (textWrapper instanceof HTMLElement) {
|
||||
textWrapper.style.display = useTwoLineCompact ? 'contents' : '';
|
||||
textWrapper.style.flex = useTwoLineCompact ? '' : '1 1 0px';
|
||||
textWrapper.style.minWidth = '0';
|
||||
textWrapper.style.maxWidth = '100%';
|
||||
|
||||
Array.from(textWrapper.querySelectorAll<HTMLElement>('p')).forEach((paragraph) => {
|
||||
paragraph.style.minWidth = '0';
|
||||
paragraph.style.maxWidth = '100%';
|
||||
paragraph.style.whiteSpace = 'normal';
|
||||
paragraph.style.wordBreak = 'keep-all';
|
||||
paragraph.style.overflowWrap = 'anywhere';
|
||||
paragraph.style.removeProperty('order');
|
||||
paragraph.style.removeProperty('flex');
|
||||
});
|
||||
|
||||
const description = Array.from(textWrapper.querySelectorAll<HTMLElement>('p')).find((paragraph) => (
|
||||
paragraph.dataset.kginicisBrandPaymentDescription === definition.id
|
||||
|| paragraph !== heading
|
||||
));
|
||||
|
||||
if (description) {
|
||||
if (useTwoLineCompact) {
|
||||
heading.style.order = '2';
|
||||
heading.style.flex = '1 1 0px';
|
||||
|
||||
description.style.order = '3';
|
||||
description.style.flex = '0 0 100%';
|
||||
description.style.whiteSpace = 'nowrap';
|
||||
description.style.wordBreak = 'normal';
|
||||
description.style.overflowWrap = 'normal';
|
||||
}
|
||||
|
||||
description.style.fontSize = useTwoLineCompact
|
||||
? compactDescriptionFontSize(button)
|
||||
: '12px';
|
||||
description.style.lineHeight = '1rem';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createBrandPaymentMark(definition: BrandPaymentDefinition): HTMLSpanElement {
|
||||
const mark = document.createElement('span');
|
||||
mark.dataset.kginicisBrandPaymentMark = 'true';
|
||||
mark.dataset.kginicisBrandPaymentMethod = definition.id;
|
||||
if (definition.id === 'kginicis_naverpay') {
|
||||
mark.dataset.kginicisNaverpayMark = 'true';
|
||||
}
|
||||
mark.setAttribute('aria-hidden', 'true');
|
||||
mark.style.display = 'inline-flex';
|
||||
mark.style.width = '32px';
|
||||
mark.style.height = '32px';
|
||||
mark.style.flex = '0 0 32px';
|
||||
mark.style.alignItems = 'center';
|
||||
mark.style.justifyContent = 'center';
|
||||
mark.innerHTML = definition.markSvg;
|
||||
|
||||
return mark;
|
||||
}
|
||||
|
||||
function findPaymentIcon(button: HTMLButtonElement): Element | null {
|
||||
return button.querySelector('svg')
|
||||
?? button.querySelector('i[class*="fa-"], i[role="img"], i');
|
||||
}
|
||||
|
||||
function findPaymentRow(button: HTMLButtonElement): HTMLElement | null {
|
||||
return button.querySelector<HTMLElement>('.flex.items-center.gap-2, .flex.items-center.gap-3')
|
||||
?? button.querySelector<HTMLElement>('.flex.items-center');
|
||||
}
|
||||
|
||||
export function patchRenderedNaverpayBrandButton(root: ParentNode = document): boolean {
|
||||
let patched = false;
|
||||
|
||||
root.querySelectorAll<HTMLButtonElement>('button').forEach((button) => {
|
||||
const definition = findBrandPaymentDefinition(button);
|
||||
if (!definition) return;
|
||||
|
||||
const copy = copyFor(definition);
|
||||
button.title = copy.title;
|
||||
button.dataset.kginicisBrandPaymentButton = 'true';
|
||||
button.dataset.kginicisBrandPaymentMethod = definition.id;
|
||||
if (definition.id === 'kginicis_naverpay') {
|
||||
button.dataset.kginicisNaverpayBrandButton = 'true';
|
||||
}
|
||||
|
||||
formatBrandPaymentText(button, definition);
|
||||
applyCompactBrandPaymentLayout(button, definition);
|
||||
|
||||
if (button.querySelector('[data-kginicis-brand-payment-mark="true"], [data-kginicis-naverpay-mark="true"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mark = createBrandPaymentMark(definition);
|
||||
const icon = findPaymentIcon(button);
|
||||
if (icon && icon.parentElement) {
|
||||
icon.replaceWith(mark);
|
||||
patched = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const row = findPaymentRow(button);
|
||||
if (!row) return;
|
||||
|
||||
row.prepend(mark);
|
||||
patched = true;
|
||||
});
|
||||
|
||||
return patched;
|
||||
}
|
||||
|
||||
function stopPatchRetries(): void {
|
||||
if (retryTimer === null) return;
|
||||
|
||||
window.clearInterval(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
function startPatchRetries(): void {
|
||||
stopPatchRetries();
|
||||
|
||||
let attempts = 0;
|
||||
retryTimer = window.setInterval(() => {
|
||||
attempts += 1;
|
||||
patchRenderedNaverpayBrandButton();
|
||||
|
||||
if (attempts >= 50) {
|
||||
stopPatchRetries();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function startDomPatchLoop(fetchImpl: typeof fetch): Promise<void> {
|
||||
if (!isCheckoutPage()) return;
|
||||
if (!(await fetchEnabled(fetchImpl))) return;
|
||||
|
||||
patchRenderedNaverpayBrandButton();
|
||||
startPatchRetries();
|
||||
|
||||
if (observer === null) {
|
||||
observer = new MutationObserver(() => {
|
||||
patchRenderedNaverpayBrandButton();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function installCheckoutNaverpayBrandButton(fetchImpl: typeof fetch = fetch): void {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
if (windowRecord()[FLAG] === true) return;
|
||||
|
||||
windowRecord()[FLAG] = true;
|
||||
|
||||
void startDomPatchLoop(fetchImpl).then(() => {
|
||||
logger.info('checkout KG Inicis brand payment button patcher installed');
|
||||
});
|
||||
}
|
||||
|
||||
export function resetCheckoutNaverpayBrandButtonForTests(): void {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
stopPatchRetries();
|
||||
cachedEnabled = null;
|
||||
delete windowRecord()[FLAG];
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { installOrderCompleteReceiptInjector } from './orderCompleteReceiptInjec
|
||||
import { installVbankInfoInjector } from './vbankInfoInjector';
|
||||
import { installPaymentCloseMessageListener } from './paymentCloseMessageListener';
|
||||
import { installCheckoutJpyPaymentMethodRestrictor } from './checkoutJpyPaymentMethodRestrictor';
|
||||
import { installCheckoutNaverpayBrandButton } from './checkoutNaverpayBrandButton';
|
||||
import { installAdminPaymentMethodBrandInjector } from './adminPaymentMethodBrandInjector';
|
||||
|
||||
const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';
|
||||
@@ -88,7 +87,6 @@ function initPlugin(): void {
|
||||
// (체크아웃 템플릿이 코어 영역이라 수정 불가하므로 클라이언트 사이드 우회)
|
||||
installOrderResponseInterceptor();
|
||||
installCheckoutJpyPaymentMethodRestrictor();
|
||||
installCheckoutNaverpayBrandButton();
|
||||
|
||||
installMypageOrderShowInjector();
|
||||
installAdminOrderPaymentDisplayInjector();
|
||||
|
||||
+28
-1
@@ -37,6 +37,25 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
'kginicis_kakaopay' => 'easy_pay_kakaopay',
|
||||
];
|
||||
|
||||
/**
|
||||
* 결제수단별 브랜드 마크(인라인 SVG 로고).
|
||||
*
|
||||
* 코어 카탈로그가 `_cached_brand_mark` 로 전달하고, 체크아웃 레이아웃의 BrandMark
|
||||
* 컴포넌트가 화이트리스트 sanitize 후 렌더한다. 과거에는 체크아웃 DOM 인젝터
|
||||
* (checkoutNaverpayBrandButton.ts) 가 렌더 후 이 SVG 를 주입했으나, 시각정보를
|
||||
* 카탈로그로 편입해 인젝터를 제거했다.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const BRAND_MARK_SVG = [
|
||||
'kginicis_naverpay' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 40 40" role="img" aria-label="Naver Pay"><rect width="40" height="40" rx="8" fill="#03C75A"/><text x="20" y="17" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="12" font-weight="700">N</text><text x="20" y="29" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="9" font-weight="700">Pay</text></svg>',
|
||||
'kginicis_samsung_pay' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 40 40" role="img" aria-label="Samsung Pay"><rect width="40" height="40" rx="8" fill="#1428A0"/><text x="20" y="18" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="10" font-weight="700">S</text><text x="20" y="29" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="9" font-weight="700">Pay</text></svg>',
|
||||
'kginicis_lpay' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 40 40" role="img" aria-label="L.pay"><rect width="40" height="40" rx="8" fill="#D71920"/><text x="20" y="18" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="12" font-weight="700">L</text><text x="20" y="29" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="8" font-weight="700">pay</text></svg>',
|
||||
'kginicis_kakaopay' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 40 40" role="img" aria-label="Kakao Pay"><rect width="40" height="40" rx="8" fill="#FEE500"/><text x="20" y="18" text-anchor="middle" fill="#111111" font-family="Arial, sans-serif" font-size="9" font-weight="700">Kakao</text><text x="20" y="29" text-anchor="middle" fill="#111111" font-family="Arial, sans-serif" font-size="9" font-weight="700">Pay</text></svg>',
|
||||
'kginicis_japan_paypay' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 40 40" role="img" aria-label="PayPay"><rect width="40" height="40" rx="8" fill="#E60012"/><text x="20" y="18" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="8" font-weight="700">Pay</text><text x="20" y="29" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="8" font-weight="700">Pay</text></svg>',
|
||||
'kginicis_japan_cvs' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 40 40" role="img" aria-label="Convenience Store"><rect width="40" height="40" rx="8" fill="#0072CE"/><text x="20" y="18" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="9" font-weight="700">CVS</text><text x="20" y="29" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="8" font-weight="700">JP</text></svg>',
|
||||
];
|
||||
|
||||
/**
|
||||
* 구독할 훅 매핑 반환.
|
||||
*
|
||||
@@ -193,7 +212,7 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
*/
|
||||
private function buildEntry(string $id, string $nameKey, string $descriptionKey, string $icon, bool $isActive = false): array
|
||||
{
|
||||
return [
|
||||
$entry = [
|
||||
'id' => $id,
|
||||
'name' => [
|
||||
'ko' => __($nameKey, [], 'ko'),
|
||||
@@ -219,5 +238,13 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
'mileage_deduction_timing' => 'payment_complete',
|
||||
],
|
||||
];
|
||||
|
||||
// 브랜드 마크(SVG 로고)가 정의된 수단만 카탈로그에 실어 내린다.
|
||||
// 레이아웃 BrandMark 컴포넌트가 svg 키를 화이트리스트 sanitize 후 렌더한다.
|
||||
if (isset(self::BRAND_MARK_SVG[$id])) {
|
||||
$entry['brand_mark'] = ['svg' => self::BRAND_MARK_SVG[$id]];
|
||||
}
|
||||
|
||||
return $entry;
|
||||
}
|
||||
}
|
||||
+40
@@ -164,6 +164,46 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=svg, requires_ios=false, device=ipados_desktop_ua
|
||||
*
|
||||
* @effects brand_mark_flows_to_cached, svg_renders_inline_logo, shared_helpers_preserved
|
||||
*/
|
||||
public function test_easy_pay_methods_carry_svg_brand_mark(): void
|
||||
{
|
||||
// 브랜드 SVG 로고를 카탈로그로 편입 — 과거 checkoutNaverpayBrandButton 이
|
||||
// DOM 후처리로 주입하던 markSvg 를 등록 데이터(brand_mark.svg)로 이관.
|
||||
$this->mockSettings([
|
||||
'easy_pay_samsung_pay' => false,
|
||||
'easy_pay_naverpay' => false,
|
||||
'easy_pay_lpay' => false,
|
||||
'easy_pay_kakaopay' => false,
|
||||
'japan_enabled' => true,
|
||||
]);
|
||||
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = collect($listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
['id' => 'point'],
|
||||
]))->keyBy('id');
|
||||
|
||||
$naverpay = $methods->get('kginicis_naverpay');
|
||||
$kakaopay = $methods->get('kginicis_kakaopay');
|
||||
|
||||
$this->assertIsArray($naverpay['brand_mark'] ?? null);
|
||||
$this->assertArrayHasKey('svg', $naverpay['brand_mark']);
|
||||
$this->assertStringContainsString('<svg', $naverpay['brand_mark']['svg']);
|
||||
$this->assertStringContainsString('#03C75A', $naverpay['brand_mark']['svg']);
|
||||
|
||||
$this->assertStringContainsString('#FEE500', $kakaopay['brand_mark']['svg'] ?? '');
|
||||
|
||||
// KG 이니시스는 애플페이 수단이 없으므로 requires_ios 플래그도 없어야 한다.
|
||||
foreach ($methods as $method) {
|
||||
$this->assertArrayNotHasKey('requires_ios', $method);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_legacy_easy_pay_settings_are_used_as_default_active_state(): void
|
||||
{
|
||||
$this->mockSettings([
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
- 간편결제가 정식 결제수단으로 취급되어 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록됩니다.
|
||||
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 고정 · NHN KCP"로 표시됩니다. 간편결제는 NHN KCP 전용이므로 다른 결제대행사로 바꿀 수 없습니다.
|
||||
- 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화됩니다.
|
||||
- 주문서의 간편결제 버튼에 표시되는 브랜드 마크(PAYCO·네이버페이·카카오페이 등의 색상 배지)를 화면이 그려진 뒤 덧입히던 방식에서 결제수단 정보로 처음부터 표시하는 방식으로 바꿨습니다. 여러 결제사가 함께 켜져 있을 때 표시가 안정적입니다.
|
||||
- 애플페이 간편결제 버튼이 iOS 기기(iPhone·iPad)에서만 주문서에 표시되도록 했습니다. 애플페이는 iOS에서만 결제할 수 있으므로 다른 기기에서는 버튼을 감춰 혼선을 줄입니다.
|
||||
|
||||
## [1.0.0-beta.5] - 2026-06-23
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(function(){"use strict";function Je(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function Qe(e){return e.trim().slice(0,160)}function Xe(e){if(e)return e.replace(/\/payment\/close-report$/,"/payment/retry")}async function oe(e,t,n=!1){const r=window.G7Core?.api;if(typeof r?.post=="function"){await r.post(e,t);return}const a=await fetch(Je(e),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),keepalive:n});if(!a.ok){let o=`NHN KCP payment request failed (${a.status})`;try{const i=await a.json(),s=Array.isArray(i?.errors?.message)?i.errors.message[0]:i?.error??i?.message;typeof s=="string"&&s.trim()!==""&&(o=s)}catch{}throw new Error(o)}}async function Ze(e){const t=Xe(e.closeReportUrl);t&&await oe(t,{oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??""})}async function et(e,t="kcp-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:Qe(t)||"kcp-window-closed"};try{await oe(e.closeReportUrl,n,!0)}catch(r){console.warn("[sirsoft-pay_nhnkcp] failed to report payment window close",r)}}const tt="nhnkcp_applepay";function N(e){return e===tt}function P(){if(typeof navigator>"u")return!1;const e=navigator,t=(e.userAgent||"").toLowerCase(),n=((e.userAgentData?.platform??e.platform)||"").toLowerCase(),r=e.maxTouchPoints??0;return/iphone|ipad|ipod/.test(t)||/iphone|ipad|ipod|ios/.test(n)?!0:/macintosh|mac os x/.test(t)&&r>1}function M(){return((typeof document<"u"?document.documentElement.lang:"")||"").toLowerCase().startsWith("ko")?"애플페이는 IOS 기기에 모바일결제만 가능합니다.":"Apple Pay is available only for mobile payments on iOS devices."}function T(e){return(e??"KRW").trim().toUpperCase()||"KRW"}function nt(e){return T(e)==="KRW"}function rt(e){const t=T(e);return((typeof document<"u"?document.documentElement.lang:"")||"").toLowerCase().startsWith("ko")?`NHN KCP는 KRW 결제만 지원합니다. (${t})`:`NHN KCP supports KRW payments only. (${t})`}function at(e){const t=Math.max(0,Math.round(Number(e.amount??0))),n=Math.min(Math.max(0,Math.round(Number(e.tax_free_amount??0))),t);if(n<=0)return{};const r=Math.max(0,Math.round(Number(e.tax_amount??0))),a=Math.max(0,Math.round(Number(e.vat_amount??0))),o=Math.max(0,t-n),i=o>0&&r>0&&a>0?Math.min(o,Math.round(o*(a/r))):0,s=o-i;return{tax_flag:"TG03",comm_tax_mny:String(s),comm_vat_mny:String(i),comm_free_mny:String(n)}}class $ extends Error{constructor(t){super(t??"결제가 취소되었습니다."),this.name="KcpCancelledError"}}const ot={card:"100000000000",bank_transfer:"010000000000",virtual_account:"001000000000",mobile:"000010000000",bank:"010000000000",vbank:"001000000000",phone:"000010000000"},ie={nhnkcp_payco:{payco_direct:"Y"},nhnkcp_naverpay:{naverpay_direct:"Y"},nhnkcp_naverpay_point:{naverpay_direct:"Y",naverpay_point_direct:"Y"},nhnkcp_kakaopay:{kakaopay_direct:"Y"},nhnkcp_applepay:{applepay_direct:"Y"}};function it(e,t){const n=e.toLowerCase();return!t||!Object.prototype.hasOwnProperty.call(ie,n)?{}:{param_opt_1:n,nhnkcp_easy_pay_method:n}}function st(e){if(!e.isConnected)return!0;const t=window.getComputedStyle(e);if(t.display==="none"||t.visibility==="hidden")return!0;const n=e.getBoundingClientRect();return n.width<=0||n.height<=0}function ct(e,t){let n=!1,r,a;const o=()=>{n=!0,a&&(a.disconnect(),a=void 0),r!==void 0&&(window.clearInterval(r),r=void 0)},i=()=>{n||!st(e)||(o(),t())},s=document.body??document.documentElement;return s&&typeof MutationObserver<"u"&&(a=new MutationObserver(i),a.observe(s,{attributes:!0,attributeFilter:["class","hidden","style"],childList:!0,subtree:!0})),r=window.setInterval(i,300),o}function dt(){if(typeof navigator>"u")return!1;if(P())return!0;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function lt(e,t){const{pgPaymentData:n,paymentMethod:r}=e.params||{};if(!n){console.error("[sirsoft-pay_nhnkcp] pgPaymentData is required");return}const a=r??n.payment_method??n.pay_method??"card",o=typeof a=="string"&&a.startsWith("nhnkcp_"),i=window.G7Core;let s=null;try{if(!nt(n.currency)){const u=rt(n.currency);i?.state?.setLocal?.({paymentErrorMessage:u,isSubmittingOrder:!1,paymentMethod:a}),i?.modal?.open?.("nhnkcp_payment_error_modal");return}if(N(a)&&!P()){const u=M();i?.state?.setLocal?.({paymentErrorMessage:u,isSubmittingOrder:!1,paymentMethod:a}),i?.modal?.open?.("nhnkcp_payment_error_modal");return}const c=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nhnkcp");if(!c.data){console.error("[Line truncated
|
||||
(function(){"use strict";function Le(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function Oe(e){return e.trim().slice(0,160)}function Ke(e){if(e)return e.replace(/\/payment\/close-report$/,"/payment/retry")}async function ee(e,t,n=!1){const o=window.G7Core?.api;if(typeof o?.post=="function"){await o.post(e,t);return}const r=await fetch(Le(e),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),keepalive:n});if(!r.ok){let a=`NHN KCP payment request failed (${r.status})`;try{const s=await r.json(),i=Array.isArray(s?.errors?.message)?s.errors.message[0]:s?.error??s?.message;typeof i=="string"&&i.trim()!==""&&(a=i)}catch{}throw new Error(a)}}async function je(e){const t=Ke(e.closeReportUrl);t&&await ee(t,{oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??""})}async function Be(e,t="kcp-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:Oe(t)||"kcp-window-closed"};try{await ee(e.closeReportUrl,n,!0)}catch(o){console.warn("[sirsoft-pay_nhnkcp] failed to report payment window close",o)}}const De="nhnkcp_applepay";function S(e){return e===De}function x(){if(typeof navigator>"u")return!1;const e=navigator,t=(e.userAgent||"").toLowerCase(),n=((e.userAgentData?.platform??e.platform)||"").toLowerCase(),o=e.maxTouchPoints??0;return/iphone|ipad|ipod/.test(t)||/iphone|ipad|ipod|ios/.test(n)?!0:/macintosh|mac os x/.test(t)&&o>1}function M(){return((typeof document<"u"?document.documentElement.lang:"")||"").toLowerCase().startsWith("ko")?"애플페이는 IOS 기기에 모바일결제만 가능합니다.":"Apple Pay is available only for mobile payments on iOS devices."}function T(e){return(e??"KRW").trim().toUpperCase()||"KRW"}function He(e){return T(e)==="KRW"}function Fe(e){const t=T(e);return((typeof document<"u"?document.documentElement.lang:"")||"").toLowerCase().startsWith("ko")?`NHN KCP는 KRW 결제만 지원합니다. (${t})`:`NHN KCP supports KRW payments only. (${t})`}function qe(e){const t=Math.max(0,Math.round(Number(e.amount??0))),n=Math.min(Math.max(0,Math.round(Number(e.tax_free_amount??0))),t);if(n<=0)return{};const o=Math.max(0,Math.round(Number(e.tax_amount??0))),r=Math.max(0,Math.round(Number(e.vat_amount??0))),a=Math.max(0,t-n),s=a>0&&o>0&&r>0?Math.min(a,Math.round(a*(r/o))):0,i=a-s;return{tax_flag:"TG03",comm_tax_mny:String(i),comm_vat_mny:String(s),comm_free_mny:String(n)}}class R extends Error{constructor(t){super(t??"결제가 취소되었습니다."),this.name="KcpCancelledError"}}const Ue={card:"100000000000",bank_transfer:"010000000000",virtual_account:"001000000000",mobile:"000010000000",bank:"010000000000",vbank:"001000000000",phone:"000010000000"},te={nhnkcp_payco:{payco_direct:"Y"},nhnkcp_naverpay:{naverpay_direct:"Y"},nhnkcp_naverpay_point:{naverpay_direct:"Y",naverpay_point_direct:"Y"},nhnkcp_kakaopay:{kakaopay_direct:"Y"},nhnkcp_applepay:{applepay_direct:"Y"}};function Ye(e,t){const n=e.toLowerCase();return!t||!Object.prototype.hasOwnProperty.call(te,n)?{}:{param_opt_1:n,nhnkcp_easy_pay_method:n}}function Ge(e){if(!e.isConnected)return!0;const t=window.getComputedStyle(e);if(t.display==="none"||t.visibility==="hidden")return!0;const n=e.getBoundingClientRect();return n.width<=0||n.height<=0}function We(e,t){let n=!1,o,r;const a=()=>{n=!0,r&&(r.disconnect(),r=void 0),o!==void 0&&(window.clearInterval(o),o=void 0)},s=()=>{n||!Ge(e)||(a(),t())},i=document.body??document.documentElement;return i&&typeof MutationObserver<"u"&&(r=new MutationObserver(s),r.observe(i,{attributes:!0,attributeFilter:["class","hidden","style"],childList:!0,subtree:!0})),o=window.setInterval(s,300),a}function ze(){if(typeof navigator>"u")return!1;if(x())return!0;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function Je(e,t){const{pgPaymentData:n,paymentMethod:o}=e.params||{};if(!n){console.error("[sirsoft-pay_nhnkcp] pgPaymentData is required");return}const r=o??n.payment_method??n.pay_method??"card",a=typeof r=="string"&&r.startsWith("nhnkcp_"),s=window.G7Core;let i=null;try{if(!He(n.currency)){const u=Fe(n.currency);s?.state?.setLocal?.({paymentErrorMessage:u,isSubmittingOrder:!1,paymentMethod:r}),s?.modal?.open?.("nhnkcp_payment_error_modal");return}if(S(r)&&!x()){const u=M();s?.state?.setLocal?.({paymentErrorMessage:u,isSubmittingOrder:!1,paymentMethod:r}),s?.modal?.open?.("nhnkcp_payment_error_modal");return}const c=await s.api.get("/modules/sirsoft-ecommerce/payments/client-config/nhnkcp");if(!c.data){console.error("[Line truncated
|
||||
<script src="${e.sdk_url}"><\/script>
|
||||
</head><body style="margin:0;padding:0;background:transparent;">
|
||||
<form name="order_info">${c}</form>
|
||||
@@ -14,5 +14,5 @@ try {
|
||||
window.__kcpFail && window.__kcpFail(e);
|
||||
}
|
||||
<\/script>
|
||||
</body></html>`),ne.close(),f=window.setTimeout(()=>{S(new Error("KCP SDK load timeout"))},15e3),y.__kcpReady=()=>{f!==void 0&&(clearTimeout(f),f=void 0)}})}const mt={nhnkcp_payco:"PAYCO",nhnkcp_naverpay:"네이버페이",nhnkcp_naverpay_point:"네이버페이 포인트",nhnkcp_kakaopay:"카카오페이",nhnkcp_applepay:"Apple Pay"},yt="0 0 0 2px #ffffff, 0 0 0 5px rgba(0,0,0,0.55)";function se(){return document.querySelector(".nhnkcp-easy-pay-btns")??null}function ce(){const e=se();e&&e.querySelectorAll("button").forEach(t=>{t.style.boxShadow="",t.style.outline=""})}function ft(e){const t=se();if(!t)return;const n=mt[e];t.querySelectorAll("button").forEach(r=>{r.textContent?.trim()===n?(r.style.boxShadow=yt,r.style.outline="none"):(r.style.boxShadow="",r.style.outline="")})}function ht(e){const t=e.params?.paymentMethod;if(!t)return;const n=window.G7Core;if(N(t)&&!P()){const a=M();n?.state?.setLocal?.({paymentErrorMessage:a,isSubmittingOrder:!1}),n?.modal?.open?.("nhnkcp_payment_error_modal"),ce();return}const r=typeof t=="string"&&t.startsWith("nhnkcp_");n?.state?.setLocal?.({paymentMethod:t}),r?ft(t):ce()}function gt(e){const t=document.createElement("textarea");t.value=e,t.setAttribute("readonly","readonly"),t.style.position="fixed",t.style.left="-9999px",t.style.top="0",document.body.appendChild(t),t.select();try{document.execCommand("copy")}finally{t.remove()}}async function _t(e){const t=String(e.params?.text??"");if(t!==""){if(navigator.clipboard&&typeof navigator.clipboard.writeText=="function"){await navigator.clipboard.writeText(t);return}gt(t)}}const de={requestPayment:lt,setPaymentMethod:ht,copyToClipboard:_t},le="/api/modules/sirsoft-ecommerce/user/orders",R="sirsoft-pay_nhnkcp",ue={info:(...e)=>console.info(`[${R}]`,...e),warn:(...e)=>console.warn(`[${R}]`,...e),error:(...e)=>console.error(`[${R}]`,...e)};function wt(e){return typeof e=="string"?e:e instanceof URL?e.toString():typeof Request<"u"&&e instanceof Request?e.url:String(e)}function Pt(e,t){return t?.method?t.method.toUpperCase():typeof Request<"u"&&e instanceof Request?e.method.toUpperCase():"GET"}function kt(e,t){if(t!=="POST")return!1;const n=e.split("?")[0].split("#")[0];return n===le||n.endsWith(le)}function bt(e){try{return JSON.parse(e).payment_method}catch{return}}function Ct(e){return new Response(JSON.stringify({success:!1,message:e,error:e,errors:{payment_method:[e]}}),{status:422,statusText:"Unprocessable Content",headers:{"Content-Type":"application/json"}})}function Nt(){if(typeof window>"u"||typeof window.fetch!="function")return;const e="__sirsoftNhnkcpInterceptorInstalled",t=window;if(t[e])return;t[e]=!0;const n=window.fetch.bind(window);window.fetch=async function(a,o){const i=wt(a),s=Pt(a,o);if(!kt(i,s))return n(a,o);let c;if(o?.body&&typeof o.body=="string"&&(c=bt(o.body)),N(c)&&!P()){const d=M();return ue.warn(d),Ct(d)}return n(a,o)},ue.info("order request interceptor installed (Apple Pay device guard)")}const H="sirsoft-pay_nhnkcp",pe="__kcpOcReceiptInjectorInstalled",me="kcp-oc-receipt-btn",xt=/^\/shop\/orders\/([^/]+)\/complete$/;function ye(){return localStorage.getItem("auth_token")}async function vt(e){const t=ye();if(!t)return null;try{const n=await fetch(`/api/modules/sirsoft-ecommerce/user/orders/${e}`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?(await n.json())?.data?.payment??null:null}catch{return null}}async function L(e){const t=ye();if(!t)return null;try{const n=await fetch(`/api/plugins/${H}/user/orders/${e}/receipt`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?await n.json():null}catch{return null}}function fe(e,t,n){const r=document.createElement("button");return r.type="button",r.className=t,r.textContent=e,r.addEventListener("click",()=>n(r)),r}function Et(e){if(!e)return!1;const t=Array.from(document.querySelectorAll("div"));for(const n of t){const r=Array.from(n.children).filter(i=>i instanceof HTMLElement&&i.tagName==="SPAN");if(r.length<2)continue;const a=r[0].textContent?.trim();if(a!=="결제 방법"&&a!=="결제수단"&&a!=="결제 방식")continue;const o=r[r.length-1];return o.textContent?.trim()===e?(n.dataset.nhnkcpPaymentMethodRow="true",!0):(o.textContent=e,o.dataset.nhnkcpPaymentMethodPatched="true",n.dataset.nhnkcpPaymentMethodRow="true",!0)}return!1}async function At(e){if(document.getElementById(me))return;const t=await vt(e);if(!t||t.pg_provider!=="nhnkcp"||!t.transaction_id||!t.paid_at)return;const n=Array.from(document.querySelectorAll('button[type="button"]')).find(c=>c.className.includes("bg-blue-600"));if(!n?.parentElement)return;const r=await L(e);Et(r?.payment_method_display_label);const a=n.parentElement,o=fe("영수증 조회",n.className.replace(/bg-blue-\d+/g,"bg-green-600").replace(/hover:bg-blue-\d+/g,"hover:bg-green-700"),async c=>{c.disabled=!0,c.textContent="로딩 중...";const d=await L(e);if(c.disabled=!1,c.textContent="영수증 조회",d){const l=window.KcpReceiptPopup;l&&new l({url:d.rLine truncated
|
||||
</body></html>`),Q.close(),y=window.setTimeout(()=>{N(new Error("KCP SDK load timeout"))},15e3),f.__kcpReady=()=>{y!==void 0&&(clearTimeout(y),y=void 0)}})}const Xe={nhnkcp_payco:"PAYCO",nhnkcp_naverpay:"네이버페이",nhnkcp_naverpay_point:"네이버페이 포인트",nhnkcp_kakaopay:"카카오페이",nhnkcp_applepay:"Apple Pay"},Ze="0 0 0 2px #ffffff, 0 0 0 5px rgba(0,0,0,0.55)";function ne(){return document.querySelector(".nhnkcp-easy-pay-btns")??null}function oe(){const e=ne();e&&e.querySelectorAll("button").forEach(t=>{t.style.boxShadow="",t.style.outline=""})}function et(e){const t=ne();if(!t)return;const n=Xe[e];t.querySelectorAll("button").forEach(o=>{o.textContent?.trim()===n?(o.style.boxShadow=Ze,o.style.outline="none"):(o.style.boxShadow="",o.style.outline="")})}function tt(e){const t=e.params?.paymentMethod;if(!t)return;const n=window.G7Core;if(S(t)&&!x()){const r=M();n?.state?.setLocal?.({paymentErrorMessage:r,isSubmittingOrder:!1}),n?.modal?.open?.("nhnkcp_payment_error_modal"),oe();return}const o=typeof t=="string"&&t.startsWith("nhnkcp_");n?.state?.setLocal?.({paymentMethod:t}),o?et(t):oe()}function nt(e){const t=document.createElement("textarea");t.value=e,t.setAttribute("readonly","readonly"),t.style.position="fixed",t.style.left="-9999px",t.style.top="0",document.body.appendChild(t),t.select();try{document.execCommand("copy")}finally{t.remove()}}async function ot(e){const t=String(e.params?.text??"");if(t!==""){if(navigator.clipboard&&typeof navigator.clipboard.writeText=="function"){await navigator.clipboard.writeText(t);return}nt(t)}}const re={requestPayment:Je,setPaymentMethod:tt,copyToClipboard:ot},ae="/api/modules/sirsoft-ecommerce/user/orders",$="sirsoft-pay_nhnkcp",se={info:(...e)=>console.info(`[${$}]`,...e),warn:(...e)=>console.warn(`[${$}]`,...e),error:(...e)=>console.error(`[${$}]`,...e)};function rt(e){return typeof e=="string"?e:e instanceof URL?e.toString():typeof Request<"u"&&e instanceof Request?e.url:String(e)}function at(e,t){return t?.method?t.method.toUpperCase():typeof Request<"u"&&e instanceof Request?e.method.toUpperCase():"GET"}function st(e,t){if(t!=="POST")return!1;const n=e.split("?")[0].split("#")[0];return n===ae||n.endsWith(ae)}function it(e){try{return JSON.parse(e).payment_method}catch{return}}function ct(e){return new Response(JSON.stringify({success:!1,message:e,error:e,errors:{payment_method:[e]}}),{status:422,statusText:"Unprocessable Content",headers:{"Content-Type":"application/json"}})}function dt(){if(typeof window>"u"||typeof window.fetch!="function")return;const e="__sirsoftNhnkcpInterceptorInstalled",t=window;if(t[e])return;t[e]=!0;const n=window.fetch.bind(window);window.fetch=async function(r,a){const s=rt(r),i=at(r,a);if(!st(s,i))return n(r,a);let c;if(a?.body&&typeof a.body=="string"&&(c=it(a.body)),S(c)&&!x()){const d=M();return se.warn(d),ct(d)}return n(r,a)},se.info("order request interceptor installed (Apple Pay device guard)")}const L="sirsoft-pay_nhnkcp",ie="__kcpOcReceiptInjectorInstalled",ce="kcp-oc-receipt-btn",lt=/^\/shop\/orders\/([^/]+)\/complete$/;function de(){return localStorage.getItem("auth_token")}async function ut(e){const t=de();if(!t)return null;try{const n=await fetch(`/api/modules/sirsoft-ecommerce/user/orders/${e}`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?(await n.json())?.data?.payment??null:null}catch{return null}}async function O(e){const t=de();if(!t)return null;try{const n=await fetch(`/api/plugins/${L}/user/orders/${e}/receipt`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?await n.json():null}catch{return null}}function le(e,t,n){const o=document.createElement("button");return o.type="button",o.className=t,o.textContent=e,o.addEventListener("click",()=>n(o)),o}function pt(e){if(!e)return!1;const t=Array.from(document.querySelectorAll("div"));for(const n of t){const o=Array.from(n.children).filter(s=>s instanceof HTMLElement&&s.tagName==="SPAN");if(o.length<2)continue;const r=o[0].textContent?.trim();if(r!=="결제 방법"&&r!=="결제수단"&&r!=="결제 방식")continue;const a=o[o.length-1];return a.textContent?.trim()===e?(n.dataset.nhnkcpPaymentMethodRow="true",!0):(a.textContent=e,a.dataset.nhnkcpPaymentMethodPatched="true",n.dataset.nhnkcpPaymentMethodRow="true",!0)}return!1}async function mt(e){if(document.getElementById(ce))return;const t=await ut(e);if(!t||t.pg_provider!=="nhnkcp"||!t.transaction_id||!t.paid_at)return;const n=Array.from(document.querySelectorAll('button[type="button"]')).find(c=>c.className.includes("bg-blue-600"));if(!n?.parentElement)return;const o=await O(e);pt(o?.payment_method_display_label);const r=n.parentElement,a=le("영수증 조회",n.className.replace(/bg-blue-\d+/g,"bg-green-600").replace(/hover:bg-blue-\d+/g,"hover:bg-green-700"),async c=>{c.disabled=!0,c.textContent="로딩 중...";const d=await O(e);if(c.disabled=!1,c.textContent="영수증 조회",d){const l=window.KcpReceiptPopup;l&&new l({url:d.reLine truncated
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
resetCheckoutEasyPayInjectorForTests,
|
||||
syncRenderedCheckoutEasyPayMethods,
|
||||
} from '../checkoutEasyPayInjector';
|
||||
|
||||
function paymentButton(label: string, description: string): string {
|
||||
return `
|
||||
<button type="button">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-wallet" role="img"></i>
|
||||
<div>
|
||||
<p>${label}</p>
|
||||
<p>${description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPaymentButtons(): void {
|
||||
document.body.innerHTML = `
|
||||
${paymentButton('Naver Pay (KG Inicis)', 'Pay with Naver Pay')}
|
||||
${paymentButton('Kakao Pay (KG Inicis)', 'Pay with Kakao Pay')}
|
||||
${paymentButton('PAYCO (NHN KCP)', 'Pay with PAYCO')}
|
||||
${paymentButton('Naver Pay (NHN KCP)', 'Pay with Naver Pay')}
|
||||
${paymentButton('Naver Pay Point (NHN KCP)', 'Pay with Naver Pay points')}
|
||||
${paymentButton('Kakao Pay (NHN KCP)', 'Pay with Kakao Pay')}
|
||||
${paymentButton('Apple Pay (NHN KCP)', 'Pay with Apple Pay')}
|
||||
${paymentButton('Credit Card', 'Pay securely with credit card')}
|
||||
`;
|
||||
}
|
||||
|
||||
function visibleButtonTexts(): string[] {
|
||||
return Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
|
||||
.filter((button) => !button.hidden && button.style.display !== 'none')
|
||||
.map((button) => plainText(button));
|
||||
}
|
||||
|
||||
function plainText(element: Element | null | undefined): string {
|
||||
return (element?.textContent ?? '').replace(/\u200B/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function mockIosDevice(): void {
|
||||
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
||||
);
|
||||
vi.spyOn(window.navigator, 'platform', 'get').mockReturnValue('iPhone');
|
||||
}
|
||||
|
||||
describe('checkoutEasyPayInjector', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.lang = 'en';
|
||||
window.history.pushState({}, '', '/shop/checkout');
|
||||
renderPaymentButtons();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetCheckoutEasyPayInjectorForTests();
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('주문설정에서 렌더링된 NHN KCP 간편결제는 KCP 플러그인 설정과 무관하게 유지한다', async () => {
|
||||
mockIosDevice();
|
||||
|
||||
await syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
expect(visibleButtonTexts()).toEqual([
|
||||
'Naver Pay (KG Inicis) Pay with Naver Pay',
|
||||
'Kakao Pay (KG Inicis) Pay with Kakao Pay',
|
||||
'P PAYCO Pay with PAYCO (NHN KCP)',
|
||||
'N Naver Pay (Card) Pay by Naver Pay credit card (NHN KCP)',
|
||||
'NP Naver Pay (Point) Pay with Naver Pay Money/Points (NHN KCP)',
|
||||
'K Kakao Pay Pay with Kakao Pay (NHN KCP)',
|
||||
'A Apple Pay Pay with Apple Pay (NHN KCP)',
|
||||
'Credit Card Pay securely with credit card',
|
||||
]);
|
||||
expect(document.querySelectorAll('[data-nhnkcp-easy-pay-hidden="true"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('한국어 체크아웃에서 요청한 NHN KCP 간편결제 문구와 왼쪽 정렬을 적용한다', async () => {
|
||||
mockIosDevice();
|
||||
document.documentElement.lang = 'ko';
|
||||
document.body.innerHTML = `
|
||||
${paymentButton('PAYCO (NHN KCP)', 'PAYCO로 결제')}
|
||||
${paymentButton('네이버페이 (NHN KCP)', '네이버페이로 결제')}
|
||||
${paymentButton('네이버페이 포인트 (NHN KCP)', '네이버페이 포인트로 결제')}
|
||||
${paymentButton('카카오페이 (NHN KCP)', '카카오페이로 결제')}
|
||||
${paymentButton('Apple Pay (NHN KCP)', 'Apple Pay로 결제')}
|
||||
`;
|
||||
|
||||
await syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
expect(visibleButtonTexts()).toEqual([
|
||||
'P PAYCO PAYCO로 결제 (NHN KCP)',
|
||||
'N 네이버페이 (카드) 네이버페이 신용카드로 결제 (NHN KCP)',
|
||||
'NP 네이버페이 (포인트) 네이버페이 머니/포인트로 결제 (NHN KCP)',
|
||||
'K 카카오페이 카카오페이로 결제 (NHN KCP)',
|
||||
'A 애플페이 애플페이로 결제 (NHN KCP)',
|
||||
]);
|
||||
|
||||
const paycoButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_payco"]');
|
||||
const appleButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_applepay"]');
|
||||
const paycoRow = paycoButton?.querySelector<HTMLElement>('.flex.items-center');
|
||||
const appleRow = appleButton?.querySelector<HTMLElement>('.flex.items-center');
|
||||
|
||||
expect(paycoButton?.style.textAlign).toBe('left');
|
||||
expect(appleButton?.style.textAlign).toBe('left');
|
||||
expect(paycoRow?.style.justifyContent).toBe('flex-start');
|
||||
expect(appleRow?.style.justifyContent).toBe('flex-start');
|
||||
expect(paycoButton?.querySelectorAll('p')[0].style.textAlign).toBe('left');
|
||||
expect(appleButton?.querySelectorAll('p')[0].style.textAlign).toBe('left');
|
||||
});
|
||||
|
||||
it('KG 이니시스 브랜드 보정기가 다시 잡지 않도록 충돌되는 원문 매칭을 피한다', async () => {
|
||||
await syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
const naverButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_naverpay"]');
|
||||
const kakaoButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_kakaopay"]');
|
||||
|
||||
expect(naverButton?.textContent).not.toContain('Naver Pay');
|
||||
expect(kakaoButton?.textContent).not.toContain('Kakao Pay');
|
||||
expect((naverButton?.textContent ?? '').replace(/\u200B/g, '')).toContain('Naver Pay (Card)');
|
||||
expect((kakaoButton?.textContent ?? '').replace(/\u200B/g, '')).toContain('Kakao Pay');
|
||||
});
|
||||
|
||||
it('KG 브랜드 문구로 바뀐 버튼을 NHN KCP 문구로 복구한다', async () => {
|
||||
await syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
const naverButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_naverpay"]');
|
||||
const kakaoButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_kakaopay"]');
|
||||
|
||||
expect(naverButton).not.toBeNull();
|
||||
expect(kakaoButton).not.toBeNull();
|
||||
|
||||
if (naverButton && kakaoButton) {
|
||||
naverButton.dataset.kginicisBrandPaymentMethod = 'kginicis_naverpay';
|
||||
naverButton.querySelectorAll('p')[0].textContent = 'Naver Pay';
|
||||
naverButton.querySelectorAll('p')[1].textContent = 'Pay with Naver Pay (KG Inicis)';
|
||||
|
||||
kakaoButton.dataset.kginicisBrandPaymentMethod = 'kginicis_kakaopay';
|
||||
kakaoButton.querySelectorAll('p')[0].textContent = 'Kakao Pay';
|
||||
kakaoButton.querySelectorAll('p')[1].textContent = 'Pay with Kakao Pay (KG Inicis)';
|
||||
const kgMark = document.createElement('span');
|
||||
kgMark.dataset.kginicisBrandPaymentMark = 'true';
|
||||
kgMark.textContent = 'KakaoPay';
|
||||
kakaoButton.querySelector('.flex.items-center')?.prepend(kgMark);
|
||||
}
|
||||
|
||||
await syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
expect(naverButton?.dataset.kginicisBrandPaymentMethod).toBeUndefined();
|
||||
expect(plainText(naverButton)).toContain('Naver Pay (Card)');
|
||||
expect(plainText(naverButton)).toContain('Pay by Naver Pay credit card (NHN KCP)');
|
||||
expect(naverButton?.textContent).not.toContain('KG Inicis');
|
||||
|
||||
expect(kakaoButton?.dataset.kginicisBrandPaymentMethod).toBeUndefined();
|
||||
expect(plainText(kakaoButton)).toContain('Kakao Pay');
|
||||
expect(plainText(kakaoButton)).toContain('Pay with Kakao Pay (NHN KCP)');
|
||||
expect(kakaoButton?.textContent).not.toContain('KG Inicis');
|
||||
expect(kakaoButton?.querySelector('[data-kginicis-brand-payment-mark="true"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('iOS 모바일 기기에서는 Apple Pay도 주문설정에서 렌더링된 경우 숨기지 않는다', async () => {
|
||||
mockIosDevice();
|
||||
|
||||
await syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
const appleButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_applepay"]');
|
||||
|
||||
expect(appleButton).not.toBeNull();
|
||||
expect(appleButton?.hidden).toBe(false);
|
||||
expect(appleButton?.style.display).not.toBe('none');
|
||||
expect(appleButton?.textContent).toContain('Pay with Apple Pay (NHN KCP)');
|
||||
});
|
||||
|
||||
it('iOS 모바일 기기가 아니면 Apple Pay 버튼을 숨긴다', async () => {
|
||||
await syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
const appleButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_applepay"]');
|
||||
|
||||
expect(appleButton).not.toBeNull();
|
||||
expect(appleButton?.hidden).toBe(true);
|
||||
expect(appleButton?.disabled).toBe(true);
|
||||
expect(appleButton?.style.display).toBe('none');
|
||||
expect(visibleButtonTexts()).not.toContain('A Apple Pay Pay with Apple Pay (NHN KCP)');
|
||||
});
|
||||
|
||||
it('KG 보정기가 먼저 NHN KCP Naver/Kakao 버튼을 KG 버튼으로 바꿔도 중복 버튼을 회수한다', async () => {
|
||||
document.body.innerHTML = `
|
||||
${paymentButton('NPay Naver Pay', 'Pay with Naver Pay (KG Inicis)')}
|
||||
${paymentButton('KakaoPay Kakao Pay', 'Pay with Kakao Pay (KG Inicis)')}
|
||||
${paymentButton('NPay Naver Pay', 'Pay with Naver Pay (KG Inicis)')}
|
||||
${paymentButton('NPay Naver Pay', 'Pay with Naver Pay (KG Inicis)')}
|
||||
${paymentButton('KakaoPay Kakao Pay', 'Pay with Kakao Pay (KG Inicis)')}
|
||||
`;
|
||||
|
||||
const buttons = document.querySelectorAll<HTMLButtonElement>('button');
|
||||
buttons[0].dataset.kginicisBrandPaymentMethod = 'kginicis_naverpay';
|
||||
buttons[1].dataset.kginicisBrandPaymentMethod = 'kginicis_kakaopay';
|
||||
buttons[2].dataset.kginicisBrandPaymentMethod = 'kginicis_naverpay';
|
||||
buttons[3].dataset.kginicisBrandPaymentMethod = 'kginicis_naverpay';
|
||||
buttons[4].dataset.kginicisBrandPaymentMethod = 'kginicis_kakaopay';
|
||||
|
||||
await syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
expect(buttons[0].hidden).toBe(false);
|
||||
expect(buttons[1].hidden).toBe(false);
|
||||
expect(buttons[2].dataset.nhnkcpEasyPayMethod).toBe('nhnkcp_naverpay');
|
||||
expect(buttons[2].hidden).toBe(false);
|
||||
expect(plainText(buttons[2])).toContain('Naver Pay (Card)');
|
||||
expect(plainText(buttons[2])).toContain('Pay by Naver Pay credit card (NHN KCP)');
|
||||
expect(buttons[2].textContent).not.toContain('KG Inicis');
|
||||
expect(buttons[3].dataset.nhnkcpEasyPayMethod).toBe('nhnkcp_naverpay_point');
|
||||
expect(buttons[3].hidden).toBe(false);
|
||||
expect(plainText(buttons[3])).toContain('Naver Pay (Point)');
|
||||
expect(plainText(buttons[3])).toContain('Pay with Naver Pay Money/Points (NHN KCP)');
|
||||
expect(buttons[3].textContent).not.toContain('KG Inicis');
|
||||
expect(buttons[4].dataset.nhnkcpEasyPayMethod).toBe('nhnkcp_kakaopay');
|
||||
expect(buttons[4].hidden).toBe(false);
|
||||
expect(plainText(buttons[4])).toContain('Kakao Pay');
|
||||
expect(plainText(buttons[4])).toContain('Pay with Kakao Pay (NHN KCP)');
|
||||
expect(buttons[4].textContent).not.toContain('KG Inicis');
|
||||
});
|
||||
});
|
||||
@@ -1,429 +0,0 @@
|
||||
import {
|
||||
isIosMobileDevice,
|
||||
isNhnKcpApplePayMethod,
|
||||
} from './support/applePayDevice';
|
||||
|
||||
const PLUGIN_ID = 'sirsoft-pay_nhnkcp';
|
||||
const FLAG = '__kcpCheckoutEasyPayInjectorInstalled';
|
||||
const CHECKOUT_RE = /^\/shop\/checkout\/?$/;
|
||||
const LISTENER_FLAG = '__kcpCheckoutEasyPaySyncListenerAttached';
|
||||
const SYNC_RETRY_INTERVAL_MS = 200;
|
||||
const SYNC_RETRY_ATTEMPTS = 120;
|
||||
|
||||
interface EasyPayCopy {
|
||||
heading: string;
|
||||
description: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface EasyPayDefinition {
|
||||
id: string;
|
||||
configKey: string;
|
||||
labels: string[];
|
||||
ko: EasyPayCopy;
|
||||
en: EasyPayCopy;
|
||||
markText: string;
|
||||
markClassName: string;
|
||||
}
|
||||
|
||||
const EASY_PAY_DEFINITIONS: EasyPayDefinition[] = [
|
||||
{
|
||||
id: 'nhnkcp_payco',
|
||||
configKey: 'PAYCO',
|
||||
labels: ['PAYCO (NHN KCP)', 'PAYCO NHN KCP 간편결제', 'PAYCO로 결제 (NHN KCP)', 'Pay with PAYCO (NHN KCP)'],
|
||||
ko: {
|
||||
heading: 'PAYCO',
|
||||
description: 'PAYCO로 결제 (NHN KCP)',
|
||||
title: 'PAYCO로 결제 (NHN KCP)',
|
||||
},
|
||||
en: {
|
||||
heading: 'PAYCO',
|
||||
description: 'Pay with PAYCO (NHN KCP)',
|
||||
title: 'Pay with PAYCO (NHN KCP)',
|
||||
},
|
||||
markText: 'P',
|
||||
markClassName: 'bg-red-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'nhnkcp_naverpay',
|
||||
configKey: 'NAVERPAY',
|
||||
labels: ['네이버페이 (NHN KCP)', '네이버페이 신용카드로 결제 (NHN KCP)', 'NaverPay NHN KCP 간편결제', 'Naver Pay (NHN KCP)', 'NaverPay (NHN KCP)', 'Pay by Naver Pay credit card (NHN KCP)'],
|
||||
ko: {
|
||||
heading: '네이버페이 (카드)',
|
||||
description: '네이버페이 신용카드로 결제 (NHN KCP)',
|
||||
title: '네이버페이 신용카드로 결제 (NHN KCP)',
|
||||
},
|
||||
en: {
|
||||
heading: 'Naver Pay (Card)',
|
||||
description: 'Pay by Naver Pay credit card (NHN KCP)',
|
||||
title: 'Pay by Naver Pay credit card (NHN KCP)',
|
||||
},
|
||||
markText: 'N',
|
||||
markClassName: 'bg-green-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'nhnkcp_naverpay_point',
|
||||
configKey: 'NAVERPAY POINT',
|
||||
labels: ['네이버페이 포인트 (NHN KCP)', '네이버페이 머니/포인트로 결제 (NHN KCP)', 'NaverPay 포인트 NHN KCP 포인트 간편결제', 'Naver Pay Point (NHN KCP)', 'NaverPay Point (NHN KCP)', 'Pay with Naver Pay Money/Points (NHN KCP)'],
|
||||
ko: {
|
||||
heading: '네이버페이 (포인트)',
|
||||
description: '네이버페이 머니/포인트로 결제 (NHN KCP)',
|
||||
title: '네이버페이 머니/포인트로 결제 (NHN KCP)',
|
||||
},
|
||||
en: {
|
||||
heading: 'Naver Pay (Point)',
|
||||
description: 'Pay with Naver Pay Money/Points (NHN KCP)',
|
||||
title: 'Pay with Naver Pay Money/Points (NHN KCP)',
|
||||
},
|
||||
markText: 'NP',
|
||||
markClassName: 'bg-green-600 text-white',
|
||||
},
|
||||
{
|
||||
id: 'nhnkcp_kakaopay',
|
||||
configKey: 'KAKAOPAY',
|
||||
labels: ['카카오페이 (NHN KCP)', '카카오페이로 결제 (NHN KCP)', 'KakaoPay NHN KCP 간편결제', 'Kakao Pay (NHN KCP)', 'KakaoPay (NHN KCP)', 'Pay with Kakao Pay (NHN KCP)'],
|
||||
ko: {
|
||||
heading: '카카오페이',
|
||||
description: '카카오페이로 결제 (NHN KCP)',
|
||||
title: '카카오페이로 결제 (NHN KCP)',
|
||||
},
|
||||
en: {
|
||||
heading: 'Kakao Pay',
|
||||
description: 'Pay with Kakao Pay (NHN KCP)',
|
||||
title: 'Pay with Kakao Pay (NHN KCP)',
|
||||
},
|
||||
markText: 'K',
|
||||
markClassName: 'bg-yellow-400 text-gray-950',
|
||||
},
|
||||
{
|
||||
id: 'nhnkcp_applepay',
|
||||
configKey: 'APPLEPAY',
|
||||
labels: ['애플페이 (NHN KCP)', '애플페이로 결제 (NHN KCP)', 'Apple Pay NHN KCP 간편결제', 'Apple Pay (NHN KCP)', 'Pay with Apple Pay (NHN KCP)'],
|
||||
ko: {
|
||||
heading: '애플페이',
|
||||
description: '애플페이로 결제 (NHN KCP)',
|
||||
title: '애플페이로 결제 (NHN KCP)',
|
||||
},
|
||||
en: {
|
||||
heading: 'Apple Pay',
|
||||
description: 'Pay with Apple Pay (NHN KCP)',
|
||||
title: 'Pay with Apple Pay (NHN KCP)',
|
||||
},
|
||||
markText: 'A',
|
||||
markClassName: 'bg-gray-900 text-white',
|
||||
},
|
||||
];
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
let retryTimer: number | null = null;
|
||||
let syncQueued = false;
|
||||
|
||||
function windowRecord(): Record<string, unknown> {
|
||||
return window as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isCheckoutPage(): boolean {
|
||||
return CHECKOUT_RE.test(window.location.pathname);
|
||||
}
|
||||
|
||||
function normalizedText(value: string | null | undefined): string {
|
||||
return (value ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function isKoreanPage(): boolean {
|
||||
const lang = document.documentElement.lang || navigator.language || '';
|
||||
|
||||
return lang.toLowerCase().startsWith('ko');
|
||||
}
|
||||
|
||||
function copyFor(definition: EasyPayDefinition): EasyPayCopy {
|
||||
return isKoreanPage() ? definition.ko : definition.en;
|
||||
}
|
||||
|
||||
function displayText(value: string): string {
|
||||
return value
|
||||
.replaceAll('네이버페이', '네이\u200B버페이')
|
||||
.replaceAll('카카오페이', '카카\u200B오페이')
|
||||
.replaceAll('Naver Pay', 'Naver\u200B Pay')
|
||||
.replaceAll('Kakao Pay', 'Kakao\u200B Pay');
|
||||
}
|
||||
|
||||
function findDefinitionById(id: string | undefined): EasyPayDefinition | null {
|
||||
return EASY_PAY_DEFINITIONS.find((definition) => definition.id === id) ?? null;
|
||||
}
|
||||
|
||||
function findDefinitionForButton(button: HTMLButtonElement): EasyPayDefinition | null {
|
||||
const markedDefinition = findDefinitionById(button.dataset.nhnkcpEasyPayMethod);
|
||||
if (markedDefinition) return markedDefinition;
|
||||
|
||||
const text = normalizedText(button.textContent);
|
||||
|
||||
return EASY_PAY_DEFINITIONS.find((definition) => (
|
||||
definition.labels.some((label) => text.includes(label))
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
function findPaymentRow(button: HTMLButtonElement): HTMLElement | null {
|
||||
return button.querySelector<HTMLElement>('.flex.items-center.gap-2, .flex.items-center.gap-3')
|
||||
?? button.querySelector<HTMLElement>('.flex.items-center');
|
||||
}
|
||||
|
||||
function alignPaymentTextLeft(button: HTMLButtonElement, heading?: HTMLElement, description?: HTMLElement): void {
|
||||
button.style.textAlign = 'left';
|
||||
|
||||
const row = findPaymentRow(button);
|
||||
if (row) {
|
||||
row.style.justifyContent = 'flex-start';
|
||||
row.style.textAlign = 'left';
|
||||
row.style.width = '100%';
|
||||
}
|
||||
|
||||
const textContainer = heading?.parentElement ?? description?.parentElement;
|
||||
if (textContainer instanceof HTMLElement) {
|
||||
textContainer.style.textAlign = 'left';
|
||||
textContainer.style.minWidth = '0';
|
||||
}
|
||||
}
|
||||
|
||||
function createMark(definition: EasyPayDefinition): HTMLSpanElement {
|
||||
const mark = document.createElement('span');
|
||||
mark.dataset.nhnkcpEasyPayMark = 'true';
|
||||
mark.dataset.nhnkcpEasyPayMethod = definition.id;
|
||||
mark.setAttribute('aria-hidden', 'true');
|
||||
mark.className = `inline-flex items-center justify-center rounded-lg text-xs font-bold ${definition.markClassName}`;
|
||||
mark.style.width = '32px';
|
||||
mark.style.height = '32px';
|
||||
mark.style.flex = '0 0 32px';
|
||||
mark.textContent = definition.markText;
|
||||
|
||||
return mark;
|
||||
}
|
||||
|
||||
function removeKginicisBrandArtifacts(button: HTMLButtonElement): void {
|
||||
delete button.dataset.kginicisBrandPaymentButton;
|
||||
delete button.dataset.kginicisBrandPaymentMethod;
|
||||
delete button.dataset.kginicisNaverpayBrandButton;
|
||||
|
||||
button.querySelectorAll<HTMLElement>('[data-kginicis-brand-payment-mark="true"], [data-kginicis-naverpay-mark="true"]').forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
|
||||
button.querySelectorAll<HTMLElement>('[data-kginicis-brand-payment-heading], [data-kginicis-brand-payment-description], [data-kginicis-naverpay-heading], [data-kginicis-naverpay-description]').forEach((element) => {
|
||||
delete element.dataset.kginicisBrandPaymentHeading;
|
||||
delete element.dataset.kginicisBrandPaymentDescription;
|
||||
delete element.dataset.kginicisNaverpayHeading;
|
||||
delete element.dataset.kginicisNaverpayDescription;
|
||||
});
|
||||
}
|
||||
|
||||
function findPaymentIcon(button: HTMLButtonElement): Element | null {
|
||||
return button.querySelector('[data-kginicis-brand-payment-mark="true"], [data-kginicis-naverpay-mark="true"]')
|
||||
?? button.querySelector('[data-nhnkcp-easy-pay-mark="true"]')
|
||||
?? button.querySelector('svg')
|
||||
?? button.querySelector('i[class*="fa-"], i[role="img"], i');
|
||||
}
|
||||
|
||||
function ensureMark(button: HTMLButtonElement, definition: EasyPayDefinition): void {
|
||||
const existingMark = button.querySelector<HTMLElement>('[data-nhnkcp-easy-pay-mark="true"]');
|
||||
if (existingMark) {
|
||||
existingMark.dataset.nhnkcpEasyPayMethod = definition.id;
|
||||
existingMark.className = `inline-flex items-center justify-center rounded-lg text-xs font-bold ${definition.markClassName}`;
|
||||
existingMark.textContent = definition.markText;
|
||||
return;
|
||||
}
|
||||
|
||||
const mark = createMark(definition);
|
||||
const icon = findPaymentIcon(button);
|
||||
if (icon && icon.parentElement) {
|
||||
icon.replaceWith(mark);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = findPaymentRow(button);
|
||||
row?.prepend(mark);
|
||||
}
|
||||
|
||||
function updatePaymentText(button: HTMLButtonElement, definition: EasyPayDefinition): void {
|
||||
const paragraphs = Array.from(button.querySelectorAll<HTMLParagraphElement>('p'));
|
||||
const copy = copyFor(definition);
|
||||
const headingText = displayText(copy.heading);
|
||||
const descriptionText = displayText(copy.description);
|
||||
|
||||
const heading = paragraphs[0];
|
||||
const description = paragraphs[1];
|
||||
|
||||
if (heading && heading.textContent !== headingText) {
|
||||
heading.textContent = headingText;
|
||||
}
|
||||
if (heading) {
|
||||
heading.setAttribute('aria-label', copy.heading);
|
||||
heading.style.textAlign = 'left';
|
||||
heading.style.whiteSpace = 'normal';
|
||||
heading.style.wordBreak = 'keep-all';
|
||||
heading.style.overflowWrap = 'anywhere';
|
||||
}
|
||||
|
||||
if (description && description.textContent !== descriptionText) {
|
||||
description.textContent = descriptionText;
|
||||
}
|
||||
if (description) {
|
||||
description.style.textAlign = 'left';
|
||||
description.style.fontSize = '12px';
|
||||
description.style.lineHeight = '1rem';
|
||||
description.style.whiteSpace = 'normal';
|
||||
description.style.wordBreak = 'keep-all';
|
||||
description.style.overflowWrap = 'anywhere';
|
||||
}
|
||||
|
||||
alignPaymentTextLeft(button, heading, description);
|
||||
button.title = copy.title;
|
||||
}
|
||||
|
||||
function showButton(button: HTMLButtonElement, definition: EasyPayDefinition): void {
|
||||
if (isNhnKcpApplePayMethod(definition.id) && !isIosMobileDevice()) {
|
||||
button.hidden = true;
|
||||
button.disabled = true;
|
||||
button.style.display = 'none';
|
||||
button.setAttribute('aria-hidden', 'true');
|
||||
button.dataset.nhnkcpEasyPayMethod = definition.id;
|
||||
button.dataset.nhnkcpEasyPayHidden = 'true';
|
||||
delete button.dataset.nhnkcpEasyPayVisible;
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.hidden) button.hidden = false;
|
||||
if (button.disabled) button.disabled = false;
|
||||
if (button.style.display === 'none') button.style.removeProperty('display');
|
||||
button.removeAttribute('aria-hidden');
|
||||
button.dataset.nhnkcpEasyPayMethod = definition.id;
|
||||
button.dataset.nhnkcpEasyPayVisible = 'true';
|
||||
delete button.dataset.nhnkcpEasyPayHidden;
|
||||
|
||||
removeKginicisBrandArtifacts(button);
|
||||
ensureMark(button, definition);
|
||||
updatePaymentText(button, definition);
|
||||
}
|
||||
|
||||
function reconcileKginicisPatchedDuplicates(root: ParentNode): boolean {
|
||||
const duplicateGroups: Record<string, string[]> = {
|
||||
kginicis_naverpay: ['nhnkcp_naverpay', 'nhnkcp_naverpay_point'],
|
||||
kginicis_kakaopay: ['nhnkcp_kakaopay'],
|
||||
};
|
||||
let changed = false;
|
||||
|
||||
Object.entries(duplicateGroups).forEach(([kginicisMethod, nhnkcpMethods]) => {
|
||||
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>(`button[data-kginicis-brand-payment-method="${kginicisMethod}"]`))
|
||||
.filter((button) => !button.dataset.nhnkcpEasyPayMethod);
|
||||
|
||||
buttons.slice(1).forEach((button, index) => {
|
||||
const definition = findDefinitionById(nhnkcpMethods[index]);
|
||||
if (!definition) return;
|
||||
|
||||
showButton(button, definition);
|
||||
changed = true;
|
||||
});
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function syncRenderedCheckoutEasyPayMethods(
|
||||
root: ParentNode = document,
|
||||
): Promise<boolean> {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return false;
|
||||
if (!isCheckoutPage()) return false;
|
||||
|
||||
let changed = false;
|
||||
|
||||
root.querySelectorAll<HTMLButtonElement>('button').forEach((button) => {
|
||||
const definition = findDefinitionForButton(button);
|
||||
if (!definition) return;
|
||||
|
||||
showButton(button, definition);
|
||||
changed = true;
|
||||
});
|
||||
|
||||
if (reconcileKginicisPatchedDuplicates(root)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function queueSync(): void {
|
||||
if (syncQueued) return;
|
||||
syncQueued = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
syncQueued = false;
|
||||
void syncRenderedCheckoutEasyPayMethods();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function stopRetries(): void {
|
||||
if (retryTimer === null) return;
|
||||
|
||||
window.clearInterval(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
function startSync(): void {
|
||||
if (!isCheckoutPage()) return;
|
||||
|
||||
stopRetries();
|
||||
void syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
let attempts = 0;
|
||||
retryTimer = window.setInterval(() => {
|
||||
attempts += 1;
|
||||
void syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
if (attempts >= SYNC_RETRY_ATTEMPTS) {
|
||||
stopRetries();
|
||||
}
|
||||
}, SYNC_RETRY_INTERVAL_MS);
|
||||
|
||||
const body = document.body as HTMLElement & Record<string, unknown>;
|
||||
if (body[LISTENER_FLAG]) return;
|
||||
body[LISTENER_FLAG] = true;
|
||||
|
||||
observer = new MutationObserver(() => queueSync());
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||
}
|
||||
|
||||
function onRouteChange(): void {
|
||||
if (isCheckoutPage()) startSync();
|
||||
}
|
||||
|
||||
export function installCheckoutEasyPayInjector(): void {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
const w = windowRecord();
|
||||
if (w[FLAG]) return;
|
||||
w[FLAG] = true;
|
||||
|
||||
console.info(`[${PLUGIN_ID}] checkout easy pay method sync installed`);
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => startSync());
|
||||
} else {
|
||||
startSync();
|
||||
}
|
||||
|
||||
const origPush = history.pushState.bind(history);
|
||||
history.pushState = (...args: Parameters<typeof history.pushState>) => {
|
||||
origPush(...args);
|
||||
window.setTimeout(() => onRouteChange(), 200);
|
||||
};
|
||||
window.addEventListener('popstate', () => window.setTimeout(() => onRouteChange(), 200));
|
||||
}
|
||||
|
||||
export function resetCheckoutEasyPayInjectorForTests(): void {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
stopRetries();
|
||||
syncQueued = false;
|
||||
delete windowRecord()[FLAG];
|
||||
if (document.body) {
|
||||
delete ((document.body as HTMLElement & Record<string, unknown>)[LISTENER_FLAG]);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { handlerMap } from './handlers';
|
||||
import { installOrderResponseInterceptor } from './orderResponseInterceptor';
|
||||
import { installOrderCompleteReceiptInjector } from './orderCompleteReceiptInjector';
|
||||
import { installMypageOrderShowInjector } from './mypageOrderShowInjector';
|
||||
import { installCheckoutEasyPayInjector } from './checkoutEasyPayInjector';
|
||||
import { installAdminApplePayNoticeInjector } from './adminApplePayNoticeInjector';
|
||||
import { installAdminPaymentMethodBrandInjector } from './adminPaymentMethodBrandInjector';
|
||||
import { installAdminOrderPaymentDisplayInjector } from './adminOrderPaymentDisplayInjector';
|
||||
@@ -98,7 +97,6 @@ function initPlugin(): void {
|
||||
installOrderResponseInterceptor();
|
||||
installOrderCompleteReceiptInjector();
|
||||
installMypageOrderShowInjector();
|
||||
installCheckoutEasyPayInjector();
|
||||
installAdminApplePayNoticeInjector();
|
||||
installAdminPaymentMethodBrandInjector();
|
||||
installAdminOrderPaymentDisplayInjector();
|
||||
|
||||
+36
-3
@@ -26,30 +26,38 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
'name_key' => 'sirsoft-pay_nhnkcp::payment_methods.payco.name',
|
||||
'description_key' => 'sirsoft-pay_nhnkcp::payment_methods.payco.description',
|
||||
'icon' => 'wallet',
|
||||
'brand_mark' => ['text' => 'P', 'class' => 'bg-red-500 text-white'],
|
||||
],
|
||||
[
|
||||
'id' => 'nhnkcp_naverpay',
|
||||
'name_key' => 'sirsoft-pay_nhnkcp::payment_methods.naverpay.name',
|
||||
'description_key' => 'sirsoft-pay_nhnkcp::payment_methods.naverpay.description',
|
||||
'icon' => 'wallet',
|
||||
'brand_mark' => ['text' => 'N', 'class' => 'bg-green-500 text-white'],
|
||||
],
|
||||
[
|
||||
'id' => 'nhnkcp_naverpay_point',
|
||||
'name_key' => 'sirsoft-pay_nhnkcp::payment_methods.naverpay_point.name',
|
||||
'description_key' => 'sirsoft-pay_nhnkcp::payment_methods.naverpay_point.description',
|
||||
'icon' => 'wallet-cards',
|
||||
'brand_mark' => ['text' => 'NP', 'class' => 'bg-green-600 text-white'],
|
||||
],
|
||||
[
|
||||
'id' => 'nhnkcp_kakaopay',
|
||||
'name_key' => 'sirsoft-pay_nhnkcp::payment_methods.kakaopay.name',
|
||||
'description_key' => 'sirsoft-pay_nhnkcp::payment_methods.kakaopay.description',
|
||||
'icon' => 'message-circle',
|
||||
'brand_mark' => ['text' => 'K', 'class' => 'bg-yellow-400 text-gray-950'],
|
||||
],
|
||||
[
|
||||
'id' => 'nhnkcp_applepay',
|
||||
'name_key' => 'sirsoft-pay_nhnkcp::payment_methods.applepay.name',
|
||||
'description_key' => 'sirsoft-pay_nhnkcp::payment_methods.applepay.description',
|
||||
'icon' => 'smartphone',
|
||||
'brand_mark' => ['text' => 'A', 'class' => 'bg-gray-900 text-white'],
|
||||
// 애플페이는 iOS 기기에서만 결제 가능 — 비-iOS 기기에서는 체크아웃 레이아웃이
|
||||
// 이 수단을 렌더하지 않는다(과거 checkoutEasyPayInjector 의 iOS 게이팅 이관).
|
||||
'requires_ios' => true,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -84,6 +92,8 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
nameKey: $method['name_key'],
|
||||
descriptionKey: $method['description_key'],
|
||||
icon: $method['icon'],
|
||||
brandMark: $method['brand_mark'] ?? null,
|
||||
requiresIos: $method['requires_ios'] ?? false,
|
||||
),
|
||||
self::EASY_PAY_METHODS
|
||||
);
|
||||
@@ -135,9 +145,19 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
return $insertAfter;
|
||||
}
|
||||
|
||||
private function buildEntry(string $id, string $nameKey, string $descriptionKey, string $icon): array
|
||||
{
|
||||
return [
|
||||
/**
|
||||
* @param array{text: string, class: string}|null $brandMark 배지 브랜드 마크(텍스트+색상 클래스)
|
||||
* @param bool $requiresIos iOS 기기에서만 노출되는 수단 여부(애플페이)
|
||||
*/
|
||||
private function buildEntry(
|
||||
string $id,
|
||||
string $nameKey,
|
||||
string $descriptionKey,
|
||||
string $icon,
|
||||
?array $brandMark = null,
|
||||
bool $requiresIos = false,
|
||||
): array {
|
||||
$entry = [
|
||||
'id' => $id,
|
||||
'name' => [
|
||||
'ko' => __($nameKey, [], 'ko'),
|
||||
@@ -163,5 +183,18 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
'mileage_deduction_timing' => 'payment_complete',
|
||||
],
|
||||
];
|
||||
|
||||
// 브랜드 마크(색 배지) — 레이아웃 BrandMark 컴포넌트가 text+class 로 렌더한다.
|
||||
// 과거 checkoutEasyPayInjector 가 DOM 후처리로 주입하던 markText/markClassName 이관.
|
||||
if ($brandMark !== null) {
|
||||
$entry['brand_mark'] = $brandMark;
|
||||
}
|
||||
|
||||
// iOS 전용 수단(애플페이)은 비-iOS 기기에서 체크아웃 레이아웃이 렌더하지 않는다.
|
||||
if ($requiresIos) {
|
||||
$entry['requires_ios'] = true;
|
||||
}
|
||||
|
||||
return $entry;
|
||||
}
|
||||
}
|
||||
+69
@@ -114,4 +114,73 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
|
||||
$applepay['description']['ko'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=false, device=ios
|
||||
*
|
||||
* @effects brand_mark_flows_to_cached, badge_renders_text_and_color
|
||||
*/
|
||||
public function test_easy_pay_methods_carry_badge_brand_mark(): void
|
||||
{
|
||||
// 브랜드 마크(색 배지)를 카탈로그로 편입 — 과거 checkoutEasyPayInjector 가
|
||||
// DOM 후처리로 주입하던 markText/markClassName 을 등록 데이터로 이관.
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
['id' => 'point'],
|
||||
]);
|
||||
|
||||
$naverpay = collect($methods)->firstWhere('id', 'nhnkcp_naverpay');
|
||||
$kakaopay = collect($methods)->firstWhere('id', 'nhnkcp_kakaopay');
|
||||
|
||||
$this->assertSame(['text' => 'N', 'class' => 'bg-green-500 text-white'], $naverpay['brand_mark'] ?? null);
|
||||
$this->assertSame(['text' => 'K', 'class' => 'bg-yellow-400 text-gray-950'], $kakaopay['brand_mark'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=ipados_desktop_ua
|
||||
*
|
||||
* @effects requires_ios_flag_carried
|
||||
*/
|
||||
public function test_only_apple_pay_requires_ios(): void
|
||||
{
|
||||
// 애플페이만 iOS 전용 노출 플래그를 가진다(비-iOS 기기에서 체크아웃 레이아웃이 숨김).
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
['id' => 'point'],
|
||||
]);
|
||||
|
||||
$applepay = collect($methods)->firstWhere('id', 'nhnkcp_applepay');
|
||||
$naverpay = collect($methods)->firstWhere('id', 'nhnkcp_naverpay');
|
||||
|
||||
$this->assertTrue($applepay['requires_ios'] ?? false);
|
||||
$this->assertArrayNotHasKey('requires_ios', $naverpay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 브랜드 마크를 카탈로그로 편입하면서 체크아웃 DOM 인젝터를 제거했다.
|
||||
* 인젝터 파일 부재 + index.ts 에 install 호출 부재를 구조로 확인한다.
|
||||
* 결제 시점 방어(applePayDevice) 는 잔존한다.
|
||||
*
|
||||
* @scenario mark_form=badge, requires_ios=true, device=ios
|
||||
*
|
||||
* @effects injectors_removed, shared_helpers_preserved
|
||||
*/
|
||||
public function test_checkout_easy_pay_injector_removed_but_shared_helpers_preserved(): void
|
||||
{
|
||||
$jsDir = base_path('plugins/_bundled/sirsoft-pay_nhnkcp/resources/js');
|
||||
|
||||
// 브랜드 인젝터 제거됨.
|
||||
$this->assertFileDoesNotExist($jsDir.'/checkoutEasyPayInjector.ts');
|
||||
|
||||
// index.ts 에 install 호출 부재.
|
||||
$indexTs = file_get_contents($jsDir.'/index.ts');
|
||||
$this->assertStringNotContainsString('installCheckoutEasyPayInjector', $indexTs);
|
||||
|
||||
// 결제 시점 방어 헬퍼(applePayDevice)는 잔존 — requestPayment/orderResponseInterceptor 가 사용.
|
||||
$this->assertFileExists($jsDir.'/support/applePayDevice.ts');
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
- 간편결제가 정식 결제수단으로 취급되어 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록됩니다.
|
||||
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 고정 · 나이스페이먼츠"로 표시됩니다. 간편결제는 나이스페이먼츠 전용이므로 다른 결제대행사로 바꿀 수 없습니다.
|
||||
- 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화됩니다.
|
||||
- 주문서의 간편결제 버튼에 표시되는 브랜드 마크(네이버페이·카카오페이·삼성페이·11pay·SSG페이·L.pay 등의 색상 배지)를 화면이 그려진 뒤 덧입히던 방식에서 결제수단 정보로 처음부터 표시하는 방식으로 바꿨습니다. 여러 결제사가 함께 켜져 있을 때 표시가 안정적입니다.
|
||||
- 애플페이 간편결제 버튼이 iOS 기기(iPhone·iPad)에서만 주문서에 표시되도록 했습니다. 애플페이는 iOS에서만 결제할 수 있으므로 다른 기기에서는 버튼을 감춰 혼선을 줄입니다.
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
(function(){"use strict";function Ne(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function ke(e){return e.trim().slice(0,160)}async function Se(e,t="nicepay-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:ke(t)||"nicepay-window-closed"};try{const a=window.G7Core?.api;if(typeof a?.post=="function"){await a.post(e.closeReportUrl,n);return}await fetch(Ne(e.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(a){console.warn("[sirsoft-pay_nicepayments] failed to report payment window close",a)}}function Ce(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const a=document.createElement("script");a.src=e,a.onload=()=>t(),a.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(a)})}function xe(e,t){const n=document.createElement("form");n.id="nicepayForm",n.method="post",n.action=e,n.acceptCharset="utf-8";for(const[a,o]of Object.entries(t)){const r=document.createElement("input");r.type="hidden",r.name=a,r.value=o,n.appendChild(r)}return document.body.appendChild(n),n}const Ee="/plugins/sirsoft-pay_nicepayments/payment/callback",Ae="https://web.nicepay.co.kr/v3/v3Payment.jsp";function ve(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function Le(e,t){const{pgPaymentData:n,paymentMethod:a}=e.params??{},o=window.__templateApp?.globalState?._local,r=a??n?.payment_method??o?.paymentMethod??"card";if(!n){console.error("[sirsoft-pay_nicepayments] pgPaymentData is required");return}const i=window.G7Core;try{const s=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nicepayments");if(!s.data){console.error("[sirsoft-pay_nicepayments] Failed to fetch client config",s);return}const l=s.data,d=ve();if(!d&&(await Ce(l.sdk_url),typeof window.goPay!="function")){i?.toast?.error?.("나이스페이먼츠 SDK를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."),i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:r});return}const b=window.location.origin+l.sign_data_url,y=typeof localStorage<"u"?localStorage.getItem("auth_token"):null,me={"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json"};y&&(me.Authorization=`Bearer ${y}`);const fe=await fetch(b,{method:"POST",credentials:"include",headers:me,body:JSON.stringify({amt:n.amount,moid:n.order_number,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""})});if(!fe.ok)throw new Error("SignData 생성에 실패했습니다.");const R=await fe.json(),he=window.location.origin+Ee,D=typeof r=="string"&&r.startsWith("nicepay_"),ge=D?"CARD":{card:"CARD",vbank:"VBANK",bank:"BANK",phone:"CELLPHONE"}[r??"card"]??"CARD",c={PayMethod:ge,GoodsName:n.order_name,Amt:String(n.amount),MID:R.mid,Moid:n.order_number,BuyerName:n.customer_name??"",BuyerEmail:n.customer_email??"",BuyerTel:n.customer_phone??"",ReturnURL:he,EdiDate:R.ediDate,SignData:R.signData,CharSet:"utf-8",GoodsCl:"1",DirectShowOpt:"",DirectEasyPay:"",NicepayReserved:"",EasyPayMethod:"",MallReserved:"",MallReserved1:""};ge==="CELLPHONE"&&(c.GoodsCl=n.goods_cl??"1");const Jt=l.useEscrow&&!D;if(c.TransType=Jt?"1":"0",D)switch(c.DirectShowOpt="CARD",c.MallReserved=`nicepay_easy_pay_method=${encodeURIComponent(r)}`,c.MallReserved1=r,r){case"nicepay_naverpay":c.DirectEasyPay="E020",c.EasyPayMethod="E020=CARD";break;case"nicepay_kakaopay":c.NicepayReserved="DirectKakao=Y";break;case"nicepay_samsungpay":c.DirectEasyPay="E021";break;case"nicepay_applepay":c.DirectEasyPay="E025";break;case"nicepay_payco":c.NicepayReserved="DirectPayco=Y";break;case"nicepay_skpay":c.NicepayReserved="DirectPay11=Y";break;case"nicepay_ssgpay":c.DirectEasyPay="E007";break;case"nicepay_lpay":c.DirectEasyPay="E018";break}try{const p=(await i.api.get(`/modules/sirsoft-ecommerce/user/orders/${n.order_number}`))?.data;if(p){const N=Number(p.total_tax_amount??0),h=Number(p.total_vat_amount??0),be=Number(p.total_tax_free_amount??0);(N>0||h>0||be>0)&&(c.TaxAmt=String(N),c.VatAmt=String(h),c.TaxFreeAmt=String(be))}}catch{}const u=xe(he,c);if(d){u.action=Ae,u.acceptCharset="euc-kr";const f=u.querySelector('input[name="CharSet"]');f&&(f.value="euc-kr"),u.submit();return}window.nicepaySubmit=()=>{u.submit()};let Pe=!1;const Xt=new Set(document.body.children),we=(f,p)=>{if(Pe)return;Pe=!0;const N=[f,p].filter(Boolean).join(": ")||"nicepay-window-closed";Se({closeReportUrl:l.close_report_url,oid:n.order_number,price:Number(n.aLine truncated
|
||||
(function(){"use strict";function le(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function de(e){return e.trim().slice(0,160)}async function ue(e,t="nicepay-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:de(t)||"nicepay-window-closed"};try{const a=window.G7Core?.api;if(typeof a?.post=="function"){await a.post(e.closeReportUrl,n);return}await fetch(le(e.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(a){console.warn("[sirsoft-pay_nicepayments] failed to report payment window close",a)}}function pe(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const a=document.createElement("script");a.src=e,a.onload=()=>t(),a.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(a)})}function me(e,t){const n=document.createElement("form");n.id="nicepayForm",n.method="post",n.action=e,n.acceptCharset="utf-8";for(const[a,r]of Object.entries(t)){const o=document.createElement("input");o.type="hidden",o.name=a,o.value=r,n.appendChild(o)}return document.body.appendChild(n),n}const ye="/plugins/sirsoft-pay_nicepayments/payment/callback",fe="https://web.nicepay.co.kr/v3/v3Payment.jsp";function he(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function be(e,t){const{pgPaymentData:n,paymentMethod:a}=e.params??{},r=window.__templateApp?.globalState?._local,o=a??n?.payment_method??r?.paymentMethod??"card";if(!n){console.error("[sirsoft-pay_nicepayments] pgPaymentData is required");return}const i=window.G7Core;try{const s=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nicepayments");if(!s.data){console.error("[sirsoft-pay_nicepayments] Failed to fetch client config",s);return}const l=s.data,d=he();if(!d&&(await pe(l.sdk_url),typeof window.goPay!="function")){i?.toast?.error?.("나이스페이먼츠 SDK를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."),i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:o});return}const w=window.location.origin+l.sign_data_url,u=typeof localStorage<"u"?localStorage.getItem("auth_token"):null,te={"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json"};u&&(te.Authorization=`Bearer ${u}`);const ne=await fetch(w,{method:"POST",credentials:"include",headers:te,body:JSON.stringify({amt:n.amount,moid:n.order_number,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""})});if(!ne.ok)throw new Error("SignData 생성에 실패했습니다.");const I=await ne.json(),ae=window.location.origin+ye,L=typeof o=="string"&&o.startsWith("nicepay_"),oe=L?"CARD":{card:"CARD",vbank:"VBANK",bank:"BANK",phone:"CELLPHONE"}[o??"card"]??"CARD",c={PayMethod:oe,GoodsName:n.order_name,Amt:String(n.amount),MID:I.mid,Moid:n.order_number,BuyerName:n.customer_name??"",BuyerEmail:n.customer_email??"",BuyerTel:n.customer_phone??"",ReturnURL:ae,EdiDate:I.ediDate,SignData:I.signData,CharSet:"utf-8",GoodsCl:"1",DirectShowOpt:"",DirectEasyPay:"",NicepayReserved:"",EasyPayMethod:"",MallReserved:"",MallReserved1:""};oe==="CELLPHONE"&&(c.GoodsCl=n.goods_cl??"1");const ft=l.useEscrow&&!L;if(c.TransType=ft?"1":"0",L)switch(c.DirectShowOpt="CARD",c.MallReserved=`nicepay_easy_pay_method=${encodeURIComponent(o)}`,c.MallReserved1=o,o){case"nicepay_naverpay":c.DirectEasyPay="E020",c.EasyPayMethod="E020=CARD";break;case"nicepay_kakaopay":c.NicepayReserved="DirectKakao=Y";break;case"nicepay_samsungpay":c.DirectEasyPay="E021";break;case"nicepay_applepay":c.DirectEasyPay="E025";break;case"nicepay_payco":c.NicepayReserved="DirectPayco=Y";break;case"nicepay_skpay":c.NicepayReserved="DirectPay11=Y";break;case"nicepay_ssgpay":c.DirectEasyPay="E007";break;case"nicepay_lpay":c.DirectEasyPay="E018";break}try{const m=(await i.api.get(`/modules/sirsoft-ecommerce/user/orders/${n.order_number}`))?.data;if(m){const P=Number(m.total_tax_amount??0),h=Number(m.total_vat_amount??0),ce=Number(m.total_tax_free_amount??0);(P>0||h>0||ce>0)&&(c.TaxAmt=String(P),c.VatAmt=String(h),c.TaxFreeAmt=String(ce))}}catch{}const p=me(ae,c);if(d){p.action=fe,p.acceptCharset="euc-kr";const f=p.querySelector('input[name="CharSet"]');f&&(f.value="euc-kr"),p.submit();return}window.nicepaySubmit=()=>{p.submit()};let re=!1;const ht=new Set(document.body.children),ie=(f,m)=>{if(re)return;re=!0;const P=[f,m].filter(Boolean).join(": ")||"nicepay-window-closed";ue({closeReportUrl:l.close_report_url,oid:n.order_number,price:Number(n.aLine truncated
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
resetCheckoutEasyPayInjectorForTests,
|
||||
syncRenderedCheckoutEasyPayMethods,
|
||||
} from '../checkoutEasyPayInjector';
|
||||
|
||||
function paymentButton(label: string, description: string): string {
|
||||
return `
|
||||
<button type="button">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-wallet" role="img"></i>
|
||||
<div>
|
||||
<p>${label}</p>
|
||||
<p>${description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function plainText(element: Element | null | undefined): string {
|
||||
return (element?.textContent ?? '').replace(/\u200B/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
describe('checkoutEasyPayInjector', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.lang = 'ko';
|
||||
window.history.pushState({}, '', '/shop/checkout');
|
||||
document.body.innerHTML = `
|
||||
${paymentButton('네이\u200B버페이 (나이스페이먼츠)', '네이\u200B버페이로 결제 (나이스페이먼츠)')}
|
||||
${paymentButton('카카\u200B오페이 (나이스페이먼츠)', '카카\u200B오페이로 결제 (나이스페이먼츠)')}
|
||||
${paymentButton('삼성\u200B페이 (나이스페이먼츠)', '삼성\u200B페이로 결제 (나이스페이먼츠)')}
|
||||
${paymentButton('애플페이 (나이스페이먼츠)', '애플페이로 결제 (나이스페이먼츠)')}
|
||||
${paymentButton('PAYCO (나이스페이먼츠)', 'PAYCO로 결제 (나이스페이먼츠)')}
|
||||
${paymentButton('11pay (나이스페이먼츠)', '11pay로 결제 (나이스페이먼츠)')}
|
||||
${paymentButton('SSG페이 (나이스페이먼츠)', 'SSG페이로 결제 (나이스페이먼츠)')}
|
||||
${paymentButton('L.\u200Bpay (나이스페이먼츠)', 'L.\u200Bpay로 결제 (나이스페이먼츠)')}
|
||||
`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetCheckoutEasyPayInjectorForTests();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('나이스페이먼츠 간편결제를 NHN KCP처럼 아이콘과 텍스트가 있는 버튼으로 보정한다', async () => {
|
||||
await syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>('button[data-nicepay-easy-pay-method]'));
|
||||
|
||||
expect(buttons.map((button) => button.dataset.nicepayEasyPayMethod)).toEqual([
|
||||
'nicepay_naverpay',
|
||||
'nicepay_kakaopay',
|
||||
'nicepay_samsungpay',
|
||||
'nicepay_applepay',
|
||||
'nicepay_payco',
|
||||
'nicepay_skpay',
|
||||
'nicepay_ssgpay',
|
||||
'nicepay_lpay',
|
||||
]);
|
||||
|
||||
expect(buttons.map((button) => plainText(button))).toEqual([
|
||||
'N 네이버페이 (나이스페이먼츠) 네이버페이로 결제 (나이스페이먼츠)',
|
||||
'K 카카오페이 (나이스페이먼츠) 카카오페이로 결제 (나이스페이먼츠)',
|
||||
'S 삼성페이 (나이스페이먼츠) 삼성페이로 결제 (나이스페이먼츠)',
|
||||
'A 애플페이 (나이스페이먼츠) 애플페이로 결제 (나이스페이먼츠)',
|
||||
'P PAYCO (나이스페이먼츠) PAYCO로 결제 (나이스페이먼츠)',
|
||||
'11 11pay (나이스페이먼츠) 11pay로 결제 (나이스페이먼츠)',
|
||||
'SSG SSG페이 (나이스페이먼츠) SSG페이로 결제 (나이스페이먼츠)',
|
||||
'L L.pay (나이스페이먼츠) L.pay로 결제 (나이스페이먼츠)',
|
||||
]);
|
||||
|
||||
for (const button of buttons) {
|
||||
const row = button.querySelector<HTMLElement>('.flex.items-center');
|
||||
|
||||
expect(button.querySelector('[data-nicepay-easy-pay-mark="true"]')).not.toBeNull();
|
||||
expect(button.style.textAlign).toBe('left');
|
||||
expect(row?.style.justifyContent).toBe('flex-start');
|
||||
expect(button.dataset.kginicisBrandPaymentMethod).toBeUndefined();
|
||||
expect(button.dataset.nhnkcpEasyPayMethod).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,480 +0,0 @@
|
||||
const PLUGIN_ID = 'sirsoft-pay_nicepayments';
|
||||
const FLAG = '__nicepayCheckoutEasyPayInjectorInstalled';
|
||||
const CHECKOUT_RE = /^\/shop\/checkout\/?$/;
|
||||
const LISTENER_FLAG = '__nicepayCheckoutEasyPaySyncListenerAttached';
|
||||
const SYNC_RETRY_INTERVAL_MS = 200;
|
||||
const SYNC_RETRY_ATTEMPTS = 120;
|
||||
const LEGACY_CONTAINER_ID = 'nicepay_checkout_payment_section';
|
||||
|
||||
interface EasyPayCopy {
|
||||
heading: string;
|
||||
description: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface EasyPayDefinition {
|
||||
id: string;
|
||||
labels: string[];
|
||||
ko: EasyPayCopy;
|
||||
en: EasyPayCopy;
|
||||
markText: string;
|
||||
markClassName: string;
|
||||
}
|
||||
|
||||
const EASY_PAY_DEFINITIONS: EasyPayDefinition[] = [
|
||||
{
|
||||
id: 'nicepay_naverpay',
|
||||
labels: [
|
||||
'네이버페이 (나이스페이먼츠)',
|
||||
'네이버페이로 결제 (나이스페이먼츠)',
|
||||
'Naver Pay (NicePayments)',
|
||||
'Pay with Naver Pay (NicePayments)',
|
||||
],
|
||||
ko: {
|
||||
heading: '네이버페이 (나이스페이먼츠)',
|
||||
description: '네이버페이로 결제 (나이스페이먼츠)',
|
||||
title: '네이버페이로 결제 (나이스페이먼츠)',
|
||||
},
|
||||
en: {
|
||||
heading: 'Naver Pay (NicePayments)',
|
||||
description: 'Pay with Naver Pay (NicePayments)',
|
||||
title: 'Pay with Naver Pay (NicePayments)',
|
||||
},
|
||||
markText: 'N',
|
||||
markClassName: 'bg-green-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'nicepay_kakaopay',
|
||||
labels: [
|
||||
'카카오페이 (나이스페이먼츠)',
|
||||
'카카오페이로 결제 (나이스페이먼츠)',
|
||||
'Kakao Pay (NicePayments)',
|
||||
'Pay with Kakao Pay (NicePayments)',
|
||||
],
|
||||
ko: {
|
||||
heading: '카카오페이 (나이스페이먼츠)',
|
||||
description: '카카오페이로 결제 (나이스페이먼츠)',
|
||||
title: '카카오페이로 결제 (나이스페이먼츠)',
|
||||
},
|
||||
en: {
|
||||
heading: 'Kakao Pay (NicePayments)',
|
||||
description: 'Pay with Kakao Pay (NicePayments)',
|
||||
title: 'Pay with Kakao Pay (NicePayments)',
|
||||
},
|
||||
markText: 'K',
|
||||
markClassName: 'bg-yellow-400 text-gray-950',
|
||||
},
|
||||
{
|
||||
id: 'nicepay_samsungpay',
|
||||
labels: [
|
||||
'삼성페이 (나이스페이먼츠)',
|
||||
'삼성페이로 결제 (나이스페이먼츠)',
|
||||
'Samsung Pay (NicePayments)',
|
||||
'Pay with Samsung Pay (NicePayments)',
|
||||
],
|
||||
ko: {
|
||||
heading: '삼성페이 (나이스페이먼츠)',
|
||||
description: '삼성페이로 결제 (나이스페이먼츠)',
|
||||
title: '삼성페이로 결제 (나이스페이먼츠)',
|
||||
},
|
||||
en: {
|
||||
heading: 'Samsung Pay (NicePayments)',
|
||||
description: 'Pay with Samsung Pay (NicePayments)',
|
||||
title: 'Pay with Samsung Pay (NicePayments)',
|
||||
},
|
||||
markText: 'S',
|
||||
markClassName: 'bg-blue-600 text-white',
|
||||
},
|
||||
{
|
||||
id: 'nicepay_applepay',
|
||||
labels: [
|
||||
'애플페이 (나이스페이먼츠)',
|
||||
'애플페이로 결제 (나이스페이먼츠)',
|
||||
'Apple Pay (NicePayments)',
|
||||
'Pay with Apple Pay (NicePayments)',
|
||||
],
|
||||
ko: {
|
||||
heading: '애플페이 (나이스페이먼츠)',
|
||||
description: '애플페이로 결제 (나이스페이먼츠)',
|
||||
title: '애플페이로 결제 (나이스페이먼츠)',
|
||||
},
|
||||
en: {
|
||||
heading: 'Apple Pay (NicePayments)',
|
||||
description: 'Pay with Apple Pay (NicePayments)',
|
||||
title: 'Pay with Apple Pay (NicePayments)',
|
||||
},
|
||||
markText: 'A',
|
||||
markClassName: 'bg-gray-900 text-white',
|
||||
},
|
||||
{
|
||||
id: 'nicepay_payco',
|
||||
labels: [
|
||||
'PAYCO (나이스페이먼츠)',
|
||||
'PAYCO로 결제 (나이스페이먼츠)',
|
||||
'PAYCO (NicePayments)',
|
||||
'Pay with PAYCO (NicePayments)',
|
||||
],
|
||||
ko: {
|
||||
heading: 'PAYCO (나이스페이먼츠)',
|
||||
description: 'PAYCO로 결제 (나이스페이먼츠)',
|
||||
title: 'PAYCO로 결제 (나이스페이먼츠)',
|
||||
},
|
||||
en: {
|
||||
heading: 'PAYCO (NicePayments)',
|
||||
description: 'Pay with PAYCO (NicePayments)',
|
||||
title: 'Pay with PAYCO (NicePayments)',
|
||||
},
|
||||
markText: 'P',
|
||||
markClassName: 'bg-red-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'nicepay_skpay',
|
||||
labels: [
|
||||
'11pay (나이스페이먼츠)',
|
||||
'11pay로 결제 (나이스페이먼츠)',
|
||||
'11pay (NicePayments)',
|
||||
'Pay with 11pay (NicePayments)',
|
||||
],
|
||||
ko: {
|
||||
heading: '11pay (나이스페이먼츠)',
|
||||
description: '11pay로 결제 (나이스페이먼츠)',
|
||||
title: '11pay로 결제 (나이스페이먼츠)',
|
||||
},
|
||||
en: {
|
||||
heading: '11pay (NicePayments)',
|
||||
description: 'Pay with 11pay (NicePayments)',
|
||||
title: 'Pay with 11pay (NicePayments)',
|
||||
},
|
||||
markText: '11',
|
||||
markClassName: 'bg-orange-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'nicepay_ssgpay',
|
||||
labels: [
|
||||
'SSG페이 (나이스페이먼츠)',
|
||||
'SSG페이로 결제 (나이스페이먼츠)',
|
||||
'SSG Pay (NicePayments)',
|
||||
'Pay with SSG Pay (NicePayments)',
|
||||
],
|
||||
ko: {
|
||||
heading: 'SSG페이 (나이스페이먼츠)',
|
||||
description: 'SSG페이로 결제 (나이스페이먼츠)',
|
||||
title: 'SSG페이로 결제 (나이스페이먼츠)',
|
||||
},
|
||||
en: {
|
||||
heading: 'SSG Pay (NicePayments)',
|
||||
description: 'Pay with SSG Pay (NicePayments)',
|
||||
title: 'Pay with SSG Pay (NicePayments)',
|
||||
},
|
||||
markText: 'SSG',
|
||||
markClassName: 'bg-red-700 text-white',
|
||||
},
|
||||
{
|
||||
id: 'nicepay_lpay',
|
||||
labels: [
|
||||
'L.pay (나이스페이먼츠)',
|
||||
'L.pay로 결제 (나이스페이먼츠)',
|
||||
'L.pay (NicePayments)',
|
||||
'Pay with L.pay (NicePayments)',
|
||||
],
|
||||
ko: {
|
||||
heading: 'L.pay (나이스페이먼츠)',
|
||||
description: 'L.pay로 결제 (나이스페이먼츠)',
|
||||
title: 'L.pay로 결제 (나이스페이먼츠)',
|
||||
},
|
||||
en: {
|
||||
heading: 'L.pay (NicePayments)',
|
||||
description: 'Pay with L.pay (NicePayments)',
|
||||
title: 'Pay with L.pay (NicePayments)',
|
||||
},
|
||||
markText: 'L',
|
||||
markClassName: 'bg-purple-600 text-white',
|
||||
},
|
||||
];
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
let retryTimer: number | null = null;
|
||||
let syncQueued = false;
|
||||
|
||||
function windowRecord(): Record<string, unknown> {
|
||||
return window as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isCheckoutPage(): boolean {
|
||||
return CHECKOUT_RE.test(window.location.pathname);
|
||||
}
|
||||
|
||||
function normalizedText(value: string | null | undefined): string {
|
||||
return (value ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function comparableText(value: string | null | undefined): string {
|
||||
return normalizedText(value).replace(/\u200B/g, '');
|
||||
}
|
||||
|
||||
function isKoreanPage(): boolean {
|
||||
const lang = document.documentElement.lang || navigator.language || '';
|
||||
|
||||
return lang.toLowerCase().startsWith('ko');
|
||||
}
|
||||
|
||||
function copyFor(definition: EasyPayDefinition): EasyPayCopy {
|
||||
return isKoreanPage() ? definition.ko : definition.en;
|
||||
}
|
||||
|
||||
function displayText(value: string): string {
|
||||
return value
|
||||
.replaceAll('네이버페이', '네이\u200B버페이')
|
||||
.replaceAll('카카오페이', '카카\u200B오페이')
|
||||
.replaceAll('삼성페이', '삼성\u200B페이')
|
||||
.replaceAll('Naver Pay', 'Naver\u200B Pay')
|
||||
.replaceAll('Kakao Pay', 'Kakao\u200B Pay')
|
||||
.replaceAll('Samsung Pay', 'Samsung\u200B Pay')
|
||||
.replaceAll('L.pay', 'L.\u200Bpay');
|
||||
}
|
||||
|
||||
function findDefinitionById(id: string | undefined): EasyPayDefinition | null {
|
||||
return EASY_PAY_DEFINITIONS.find((definition) => definition.id === id) ?? null;
|
||||
}
|
||||
|
||||
function findDefinitionForButton(button: HTMLButtonElement): EasyPayDefinition | null {
|
||||
const markedDefinition = findDefinitionById(button.dataset.nicepayEasyPayMethod);
|
||||
if (markedDefinition) return markedDefinition;
|
||||
|
||||
const text = comparableText(button.textContent);
|
||||
|
||||
return EASY_PAY_DEFINITIONS.find((definition) => (
|
||||
definition.labels.some((label) => text.includes(label))
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
function findPaymentRow(button: HTMLButtonElement): HTMLElement | null {
|
||||
return button.querySelector<HTMLElement>('.flex.items-center.gap-2, .flex.items-center.gap-3')
|
||||
?? button.querySelector<HTMLElement>('.flex.items-center');
|
||||
}
|
||||
|
||||
function alignPaymentTextLeft(button: HTMLButtonElement, heading?: HTMLElement, description?: HTMLElement): void {
|
||||
button.style.textAlign = 'left';
|
||||
|
||||
const row = findPaymentRow(button);
|
||||
if (row) {
|
||||
row.style.justifyContent = 'flex-start';
|
||||
row.style.textAlign = 'left';
|
||||
row.style.width = '100%';
|
||||
}
|
||||
|
||||
const textContainer = heading?.parentElement ?? description?.parentElement;
|
||||
if (textContainer instanceof HTMLElement) {
|
||||
textContainer.style.textAlign = 'left';
|
||||
textContainer.style.minWidth = '0';
|
||||
}
|
||||
}
|
||||
|
||||
function createMark(definition: EasyPayDefinition): HTMLSpanElement {
|
||||
const mark = document.createElement('span');
|
||||
mark.dataset.nicepayEasyPayMark = 'true';
|
||||
mark.dataset.nicepayEasyPayMethod = definition.id;
|
||||
mark.setAttribute('aria-hidden', 'true');
|
||||
mark.className = `inline-flex items-center justify-center rounded-lg text-xs font-bold ${definition.markClassName}`;
|
||||
mark.style.width = '32px';
|
||||
mark.style.height = '32px';
|
||||
mark.style.flex = '0 0 32px';
|
||||
mark.textContent = definition.markText;
|
||||
|
||||
return mark;
|
||||
}
|
||||
|
||||
function removeOtherPgArtifacts(button: HTMLButtonElement): void {
|
||||
delete button.dataset.kginicisBrandPaymentButton;
|
||||
delete button.dataset.kginicisBrandPaymentMethod;
|
||||
delete button.dataset.kginicisNaverpayBrandButton;
|
||||
delete button.dataset.nhnkcpEasyPayMethod;
|
||||
delete button.dataset.nhnkcpEasyPayVisible;
|
||||
delete button.dataset.nhnkcpEasyPayHidden;
|
||||
|
||||
button.querySelectorAll<HTMLElement>('[data-kginicis-brand-payment-mark="true"], [data-kginicis-naverpay-mark="true"], [data-nhnkcp-easy-pay-mark="true"]').forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function findPaymentIcon(button: HTMLButtonElement): Element | null {
|
||||
return button.querySelector('[data-nicepay-easy-pay-mark="true"]')
|
||||
?? button.querySelector('[data-kginicis-brand-payment-mark="true"], [data-kginicis-naverpay-mark="true"], [data-nhnkcp-easy-pay-mark="true"]')
|
||||
?? button.querySelector('svg')
|
||||
?? button.querySelector('i[class*="fa-"], i[role="img"], i');
|
||||
}
|
||||
|
||||
function ensureMark(button: HTMLButtonElement, definition: EasyPayDefinition): void {
|
||||
const existingMark = button.querySelector<HTMLElement>('[data-nicepay-easy-pay-mark="true"]');
|
||||
if (existingMark) {
|
||||
existingMark.dataset.nicepayEasyPayMethod = definition.id;
|
||||
existingMark.className = `inline-flex items-center justify-center rounded-lg text-xs font-bold ${definition.markClassName}`;
|
||||
existingMark.textContent = definition.markText;
|
||||
return;
|
||||
}
|
||||
|
||||
const mark = createMark(definition);
|
||||
const icon = findPaymentIcon(button);
|
||||
if (icon && icon.parentElement) {
|
||||
icon.replaceWith(mark);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = findPaymentRow(button);
|
||||
row?.prepend(mark);
|
||||
}
|
||||
|
||||
function updatePaymentText(button: HTMLButtonElement, definition: EasyPayDefinition): void {
|
||||
const paragraphs = Array.from(button.querySelectorAll<HTMLParagraphElement>('p'));
|
||||
const copy = copyFor(definition);
|
||||
const headingText = displayText(copy.heading);
|
||||
const descriptionText = displayText(copy.description);
|
||||
|
||||
const heading = paragraphs[0];
|
||||
const description = paragraphs[1];
|
||||
|
||||
if (heading && heading.textContent !== headingText) {
|
||||
heading.textContent = headingText;
|
||||
}
|
||||
if (heading) {
|
||||
heading.setAttribute('aria-label', copy.heading);
|
||||
heading.style.textAlign = 'left';
|
||||
heading.style.whiteSpace = 'normal';
|
||||
heading.style.wordBreak = 'keep-all';
|
||||
heading.style.overflowWrap = 'anywhere';
|
||||
}
|
||||
|
||||
if (description && description.textContent !== descriptionText) {
|
||||
description.textContent = descriptionText;
|
||||
}
|
||||
if (description) {
|
||||
description.style.textAlign = 'left';
|
||||
description.style.fontSize = '12px';
|
||||
description.style.lineHeight = '1rem';
|
||||
description.style.whiteSpace = 'normal';
|
||||
description.style.wordBreak = 'keep-all';
|
||||
description.style.overflowWrap = 'anywhere';
|
||||
}
|
||||
|
||||
alignPaymentTextLeft(button, heading, description);
|
||||
button.title = copy.title;
|
||||
}
|
||||
|
||||
function showButton(button: HTMLButtonElement, definition: EasyPayDefinition): void {
|
||||
if (button.hidden) button.hidden = false;
|
||||
if (button.disabled) button.disabled = false;
|
||||
if (button.style.display === 'none') button.style.removeProperty('display');
|
||||
button.removeAttribute('aria-hidden');
|
||||
button.dataset.nicepayEasyPayMethod = definition.id;
|
||||
button.dataset.nicepayEasyPayVisible = 'true';
|
||||
|
||||
removeOtherPgArtifacts(button);
|
||||
ensureMark(button, definition);
|
||||
updatePaymentText(button, definition);
|
||||
}
|
||||
|
||||
function removeLegacyInjectedSection(): boolean {
|
||||
const legacySection = document.getElementById(LEGACY_CONTAINER_ID);
|
||||
if (!legacySection) return false;
|
||||
|
||||
legacySection.remove();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function syncRenderedCheckoutEasyPayMethods(
|
||||
root: ParentNode = document,
|
||||
): Promise<boolean> {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return false;
|
||||
if (!isCheckoutPage()) return false;
|
||||
|
||||
let changed = removeLegacyInjectedSection();
|
||||
|
||||
root.querySelectorAll<HTMLButtonElement>('button').forEach((button) => {
|
||||
const definition = findDefinitionForButton(button);
|
||||
if (!definition) return;
|
||||
|
||||
showButton(button, definition);
|
||||
changed = true;
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function queueSync(): void {
|
||||
if (syncQueued) return;
|
||||
syncQueued = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
syncQueued = false;
|
||||
void syncRenderedCheckoutEasyPayMethods();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function stopRetries(): void {
|
||||
if (retryTimer === null) return;
|
||||
|
||||
window.clearInterval(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
function startSync(): void {
|
||||
if (!isCheckoutPage()) return;
|
||||
|
||||
stopRetries();
|
||||
void syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
let attempts = 0;
|
||||
retryTimer = window.setInterval(() => {
|
||||
attempts += 1;
|
||||
void syncRenderedCheckoutEasyPayMethods();
|
||||
|
||||
if (attempts >= SYNC_RETRY_ATTEMPTS) {
|
||||
stopRetries();
|
||||
}
|
||||
}, SYNC_RETRY_INTERVAL_MS);
|
||||
|
||||
const body = document.body as HTMLElement & Record<string, unknown>;
|
||||
if (body[LISTENER_FLAG]) return;
|
||||
body[LISTENER_FLAG] = true;
|
||||
|
||||
observer = new MutationObserver(() => queueSync());
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||
}
|
||||
|
||||
function onRouteChange(): void {
|
||||
if (isCheckoutPage()) startSync();
|
||||
}
|
||||
|
||||
export function installCheckoutEasyPayInjector(): void {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
const w = windowRecord();
|
||||
if (w[FLAG]) return;
|
||||
w[FLAG] = true;
|
||||
|
||||
console.info(`[${PLUGIN_ID}] checkout easy pay method sync installed`);
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => startSync());
|
||||
} else {
|
||||
startSync();
|
||||
}
|
||||
|
||||
const origPush = history.pushState.bind(history);
|
||||
history.pushState = (...args: Parameters<typeof history.pushState>) => {
|
||||
origPush(...args);
|
||||
window.setTimeout(() => onRouteChange(), 200);
|
||||
};
|
||||
window.addEventListener('popstate', () => window.setTimeout(() => onRouteChange(), 200));
|
||||
}
|
||||
|
||||
export function resetCheckoutEasyPayInjectorForTests(): void {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
stopRetries();
|
||||
syncQueued = false;
|
||||
delete windowRecord()[FLAG];
|
||||
if (document.body) {
|
||||
delete ((document.body as HTMLElement & Record<string, unknown>)[LISTENER_FLAG]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { handlerMap } from './handlers';
|
||||
import { initEasyPayWatcher } from './handlers/setPaymentMethod';
|
||||
import { installOrderResponseInterceptor } from './orderResponseInterceptor';
|
||||
import { installCheckoutEasyPayInjector } from './checkoutEasyPayInjector';
|
||||
import { installOrderCompleteReceiptInjector } from './orderCompleteReceiptInjector';
|
||||
import { installMypageOrderShowInjector } from './mypageOrderShowInjector';
|
||||
import { installAdminPaymentMethodBrandInjector } from './adminPaymentMethodBrandInjector';
|
||||
@@ -50,7 +49,6 @@ function registerHandlers(): number {
|
||||
function initPlugin(): void {
|
||||
// fetch 인터셉터는 G7Core 초기화와 무관하게 즉시 설치
|
||||
installOrderResponseInterceptor();
|
||||
installCheckoutEasyPayInjector();
|
||||
installOrderCompleteReceiptInjector();
|
||||
installMypageOrderShowInjector();
|
||||
installAdminPaymentMethodBrandInjector();
|
||||
|
||||
+38
-3
@@ -26,48 +26,58 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
'name_key' => 'sirsoft-pay_nicepayments::payment_methods.naverpay.name',
|
||||
'description_key' => 'sirsoft-pay_nicepayments::payment_methods.naverpay.description',
|
||||
'icon' => 'wallet',
|
||||
'brand_mark' => ['text' => 'N', 'class' => 'bg-green-500 text-white'],
|
||||
],
|
||||
[
|
||||
'id' => 'nicepay_kakaopay',
|
||||
'name_key' => 'sirsoft-pay_nicepayments::payment_methods.kakaopay.name',
|
||||
'description_key' => 'sirsoft-pay_nicepayments::payment_methods.kakaopay.description',
|
||||
'icon' => 'message-circle',
|
||||
'brand_mark' => ['text' => 'K', 'class' => 'bg-yellow-400 text-gray-950'],
|
||||
],
|
||||
[
|
||||
'id' => 'nicepay_samsungpay',
|
||||
'name_key' => 'sirsoft-pay_nicepayments::payment_methods.samsungpay.name',
|
||||
'description_key' => 'sirsoft-pay_nicepayments::payment_methods.samsungpay.description',
|
||||
'icon' => 'smartphone',
|
||||
'brand_mark' => ['text' => 'S', 'class' => 'bg-blue-600 text-white'],
|
||||
],
|
||||
[
|
||||
'id' => 'nicepay_applepay',
|
||||
'name_key' => 'sirsoft-pay_nicepayments::payment_methods.applepay.name',
|
||||
'description_key' => 'sirsoft-pay_nicepayments::payment_methods.applepay.description',
|
||||
'icon' => 'smartphone',
|
||||
'brand_mark' => ['text' => 'A', 'class' => 'bg-gray-900 text-white'],
|
||||
// 애플페이는 iOS 기기에서만 노출(과거 injector iOS 게이팅 이관).
|
||||
'requires_ios' => true,
|
||||
],
|
||||
[
|
||||
'id' => 'nicepay_payco',
|
||||
'name_key' => 'sirsoft-pay_nicepayments::payment_methods.payco.name',
|
||||
'description_key' => 'sirsoft-pay_nicepayments::payment_methods.payco.description',
|
||||
'icon' => 'wallet',
|
||||
'brand_mark' => ['text' => 'P', 'class' => 'bg-red-500 text-white'],
|
||||
],
|
||||
[
|
||||
'id' => 'nicepay_skpay',
|
||||
'name_key' => 'sirsoft-pay_nicepayments::payment_methods.skpay.name',
|
||||
'description_key' => 'sirsoft-pay_nicepayments::payment_methods.skpay.description',
|
||||
'icon' => 'wallet-cards',
|
||||
'brand_mark' => ['text' => '11', 'class' => 'bg-orange-500 text-white'],
|
||||
],
|
||||
[
|
||||
'id' => 'nicepay_ssgpay',
|
||||
'name_key' => 'sirsoft-pay_nicepayments::payment_methods.ssgpay.name',
|
||||
'description_key' => 'sirsoft-pay_nicepayments::payment_methods.ssgpay.description',
|
||||
'icon' => 'shopping-bag',
|
||||
'brand_mark' => ['text' => 'SSG', 'class' => 'bg-red-700 text-white'],
|
||||
],
|
||||
[
|
||||
'id' => 'nicepay_lpay',
|
||||
'name_key' => 'sirsoft-pay_nicepayments::payment_methods.lpay.name',
|
||||
'description_key' => 'sirsoft-pay_nicepayments::payment_methods.lpay.description',
|
||||
'icon' => 'wallet',
|
||||
'brand_mark' => ['text' => 'L', 'class' => 'bg-purple-600 text-white'],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -102,6 +112,8 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
nameKey: $method['name_key'],
|
||||
descriptionKey: $method['description_key'],
|
||||
icon: $method['icon'],
|
||||
brandMark: $method['brand_mark'] ?? null,
|
||||
requiresIos: $method['requires_ios'] ?? false,
|
||||
),
|
||||
self::EASY_PAY_METHODS
|
||||
);
|
||||
@@ -157,9 +169,19 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
return $insertAfter;
|
||||
}
|
||||
|
||||
private function buildEntry(string $id, string $nameKey, string $descriptionKey, string $icon): array
|
||||
{
|
||||
return [
|
||||
/**
|
||||
* @param array{text: string, class: string}|null $brandMark 배지 브랜드 마크(텍스트+색상 클래스)
|
||||
* @param bool $requiresIos iOS 기기에서만 노출되는 수단 여부(애플페이)
|
||||
*/
|
||||
private function buildEntry(
|
||||
string $id,
|
||||
string $nameKey,
|
||||
string $descriptionKey,
|
||||
string $icon,
|
||||
?array $brandMark = null,
|
||||
bool $requiresIos = false,
|
||||
): array {
|
||||
$entry = [
|
||||
'id' => $id,
|
||||
'name' => [
|
||||
'ko' => __($nameKey, [], 'ko'),
|
||||
@@ -185,5 +207,18 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
'mileage_deduction_timing' => 'payment_complete',
|
||||
],
|
||||
];
|
||||
|
||||
// 브랜드 마크(색 배지) — 레이아웃 BrandMark 컴포넌트가 text+class 로 렌더한다.
|
||||
// 과거 checkoutEasyPayInjector 가 DOM 후처리로 주입하던 markText/markClassName 이관.
|
||||
if ($brandMark !== null) {
|
||||
$entry['brand_mark'] = $brandMark;
|
||||
}
|
||||
|
||||
// iOS 전용 수단(애플페이)은 비-iOS 기기에서 체크아웃 레이아웃이 렌더하지 않는다.
|
||||
if ($requiresIos) {
|
||||
$entry['requires_ios'] = true;
|
||||
}
|
||||
|
||||
return $entry;
|
||||
}
|
||||
}
|
||||
+41
@@ -144,4 +144,45 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
|
||||
$this->assertStringNotContainsString('Samsung Pay', $joined);
|
||||
$this->assertStringNotContainsString('L.pay', $joined);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=false, device=ipados_desktop_ua
|
||||
*
|
||||
* @effects brand_mark_flows_to_cached, badge_renders_text_and_color
|
||||
*/
|
||||
public function test_easy_pay_methods_carry_badge_brand_mark(): void
|
||||
{
|
||||
// 브랜드 마크(색 배지)를 카탈로그로 편입 — 과거 checkoutEasyPayInjector 가
|
||||
// DOM 후처리로 주입하던 markText/markClassName 을 등록 데이터로 이관.
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = collect($listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
['id' => 'point'],
|
||||
]))->keyBy('id');
|
||||
|
||||
$this->assertSame(
|
||||
['text' => 'N', 'class' => 'bg-green-500 text-white'],
|
||||
$methods->get('nicepay_naverpay')['brand_mark'] ?? null,
|
||||
);
|
||||
$this->assertSame(
|
||||
['text' => 'SSG', 'class' => 'bg-red-700 text-white'],
|
||||
$methods->get('nicepay_ssgpay')['brand_mark'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_only_apple_pay_requires_ios(): void
|
||||
{
|
||||
// 애플페이만 iOS 전용 노출 플래그를 가진다.
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = collect($listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
['id' => 'point'],
|
||||
]))->keyBy('id');
|
||||
|
||||
$this->assertTrue($methods->get('nicepay_applepay')['requires_ios'] ?? false);
|
||||
$this->assertArrayNotHasKey('requires_ios', $methods->get('nicepay_naverpay'));
|
||||
$this->assertArrayNotHasKey('requires_ios', $methods->get('nicepay_samsungpay'));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,11 @@
|
||||
|
||||
## [1.0.3] - 2026-07-15
|
||||
|
||||
### Changed
|
||||
|
||||
- 주문서의 간편결제 버튼에 표시되는 브랜드 마크(색상 배지·로고)를 결제수단 정보로 처음부터 그리도록 표준화했습니다. 여러 결제사가 함께 켜져 있어도 표시가 안정적입니다.
|
||||
- 애플페이 간편결제 버튼을 iOS 기기(iPhone·iPad)에서만 표시하도록 했습니다. iPad가 데스크톱 화면으로 접속한 경우에도 정확히 인식합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 네이버페이·카카오페이 등 간편결제로 주문을 완료했을 때 주문완료 페이지의 "결제 완료" 안내가 표시되지 않던 문제를 수정했습니다. 이제 입금 대기(무통장·가상계좌)를 제외한 모든 결제수단에서 결제 완료 안내가 정상적으로 표시됩니다.
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @file checkout-payment-brand-mark.test.tsx
|
||||
* @description 체크아웃 결제수단 목록의 브랜드 마크 데이터 기반 렌더 + 애플페이 iOS 게이팅 검증.
|
||||
*
|
||||
* 과거 각 PG 플러그인의 체크아웃 DOM 인젝터가 렌더 후 브랜드 마크를 주입하던 것을,
|
||||
* 카탈로그 `_cached_brand_mark` 를 코어 레이아웃이 BrandMark 컴포넌트로 직접 렌더하도록 통일했다.
|
||||
* 애플페이 iOS 게이팅도 injector 밖(iteration source 필터 + appConfig.isIos)으로 이관했다.
|
||||
*
|
||||
* 검증 축:
|
||||
* - 구조: BrandMark 노드가 method._cached_brand_mark 에 바인딩 + method._cached_brand_mark 없을 때 Icon 폴백
|
||||
* - 조건 평가: iteration source 필터가 requires_ios 수단을 appConfig.isIos 에 따라 포함/제외
|
||||
*
|
||||
* @effects none_form_falls_back_to_icon, applepay_shown_on_ios, applepay_hidden_on_non_ios, non_ios_methods_unaffected
|
||||
*/
|
||||
// @scenario mark_form=badge, requires_ios=false, device=ios
|
||||
// @scenario mark_form=badge, requires_ios=false, device=non_ios
|
||||
// @scenario mark_form=badge, requires_ios=false, device=ipados_desktop_ua
|
||||
// @scenario mark_form=svg, requires_ios=false, device=ios
|
||||
// @scenario mark_form=svg, requires_ios=false, device=non_ios
|
||||
// @scenario mark_form=none, requires_ios=false, device=ios
|
||||
// @scenario mark_form=none, requires_ios=false, device=non_ios
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DataBindingEngine } from '@core/template-engine/DataBindingEngine';
|
||||
import checkoutPaymentJson from '../../layouts/partials/shop/_checkout_payment.json';
|
||||
|
||||
/** 객체 트리에서 조건을 만족하는 첫 노드를 깊이우선 탐색 */
|
||||
function findNode(node: any, predicate: (n: any) => boolean): any {
|
||||
if (node == null || typeof node !== 'object') return undefined;
|
||||
if (predicate(node)) return node;
|
||||
for (const value of Object.values(node)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = findNode(item, predicate);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
const found = findNode(value, predicate);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const brandMarkNode = findNode(
|
||||
checkoutPaymentJson,
|
||||
(n) => n.type === 'composite' && n.name === 'BrandMark'
|
||||
);
|
||||
|
||||
const iconFallbackNode = findNode(
|
||||
checkoutPaymentJson,
|
||||
(n) => n.type === 'basic' && n.name === 'Icon' && typeof n.if === 'string' && n.if.includes('_cached_brand_mark')
|
||||
);
|
||||
|
||||
const iterationNode = findNode(
|
||||
checkoutPaymentJson,
|
||||
(n) => n.iteration && n.iteration.item_var === 'method'
|
||||
);
|
||||
|
||||
const engine = new DataBindingEngine();
|
||||
|
||||
describe('체크아웃 결제수단 브랜드 마크 렌더', () => {
|
||||
describe('구조', () => {
|
||||
it('BrandMark 노드가 method._cached_brand_mark 에 바인딩되어야 한다', () => {
|
||||
expect(brandMarkNode, 'BrandMark 노드').toBeDefined();
|
||||
expect(brandMarkNode.props.brandMark).toBe('{{method._cached_brand_mark}}');
|
||||
// 브랜드 마크가 있는 수단만 렌더 (if 가드)
|
||||
expect(brandMarkNode.if).toBe('{{method._cached_brand_mark}}');
|
||||
});
|
||||
|
||||
it('브랜드 마크가 없는 수단은 Icon 폴백을 렌더해야 한다', () => {
|
||||
expect(iconFallbackNode, 'Icon 폴백 노드').toBeDefined();
|
||||
expect(iconFallbackNode.if).toBe('{{!method._cached_brand_mark}}');
|
||||
expect(iconFallbackNode.props.name).toBe("{{method._cached_icon ?? 'circle-question'}}");
|
||||
});
|
||||
});
|
||||
|
||||
describe('애플페이 iOS 게이팅 (iteration source 필터)', () => {
|
||||
const source = iterationNode?.iteration?.source as string;
|
||||
|
||||
const methods = [
|
||||
{ id: 'dbank', is_active: true },
|
||||
{ id: 'nhnkcp_naverpay', is_active: true },
|
||||
{ id: 'nhnkcp_applepay', is_active: true, requires_ios: true },
|
||||
{ id: 'inactive', is_active: false },
|
||||
];
|
||||
|
||||
const evalSource = (isIos: boolean): any[] => {
|
||||
const ctx = {
|
||||
paymentSettings: { data: { order_settings: { payment_methods: methods } } },
|
||||
_global: { appConfig: { isIos } },
|
||||
};
|
||||
const stripped = source.replace(/^\{\{/, '').replace(/\}\}$/, '');
|
||||
return engine.evaluateExpression(stripped, ctx) as any[];
|
||||
};
|
||||
|
||||
it('iteration source 필터 노드가 존재해야 한다', () => {
|
||||
expect(iterationNode, 'iteration 노드').toBeDefined();
|
||||
expect(source).toContain('requires_ios');
|
||||
expect(source).toContain('isIos');
|
||||
expect(source).toContain('is_active');
|
||||
});
|
||||
|
||||
it('iOS 기기: 애플페이 포함, 비활성 수단 제외', () => {
|
||||
const ids = evalSource(true).map((m) => m.id);
|
||||
expect(ids).toContain('nhnkcp_applepay');
|
||||
expect(ids).toContain('nhnkcp_naverpay');
|
||||
expect(ids).toContain('dbank');
|
||||
expect(ids).not.toContain('inactive');
|
||||
});
|
||||
|
||||
it('비-iOS 기기: 애플페이 제외 (게이팅), 나머지 활성 수단 유지', () => {
|
||||
const ids = evalSource(false).map((m) => m.id);
|
||||
expect(ids).not.toContain('nhnkcp_applepay');
|
||||
expect(ids).toContain('nhnkcp_naverpay');
|
||||
expect(ids).toContain('dbank');
|
||||
expect(ids).not.toContain('inactive');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1129,6 +1129,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BrandMark",
|
||||
"type": "composite",
|
||||
"description": "결제 브랜드 마크 (색 배지 또는 인라인 SVG 로고, DOMPurify 화이트리스트 sanitize)",
|
||||
"path": "src/components/composite/BrandMark.tsx",
|
||||
"props": {
|
||||
"brandMark": {
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"description": "브랜드 마크 데이터 { text, class } 또는 { svg }"
|
||||
},
|
||||
"size": {
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"default": 32,
|
||||
"description": "마크 컨테이너 크기(px)"
|
||||
},
|
||||
"className": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"description": "사용자 정의 클래스"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "HtmlEditor",
|
||||
"type": "composite",
|
||||
|
||||
+18
-17
@@ -1,23 +1,23 @@
|
||||
var SirsoftBasic=(function(J,d,k,Jt){"use strict";function Mc(e){const t=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(e){for(const r in e)if(r!=="default"){const n=Object.getOwnPropertyDescriptor(e,r);Object.defineProperty(t,r,n.get?n:{enumerable:!0,get:()=>e[r]})}}return t.default=e,Object.freeze(t)}const T=Mc(k),Oc=()=>window.G7Core,Q=k.forwardRef(({children:e,variant:t="primary",size:r="md",className:n="",...o},a)=>{const s="inline-flex items-center justify-center",l=Oc()?.style?.mergeClasses?.(s,n)??`${s} ${n}`;return d.jsx("button",{ref:a,className:l,...o,children:e})});Q.displayName="Button";const W=k.forwardRef(({children:e,className:t="",...r},n)=>d.jsx("div",{ref:n,className:t,...r,children:e})),te=({children:e,className:t="",...r})=>d.jsx("span",{className:t,...r,children:e}),fn=({children:e,className:t="",...r})=>d.jsx("svg",{className:t,...r,children:e}),xt=k.forwardRef(({label:e,error:t,className:r="",onChange:n,onKeyPress:o,value:a,defaultValue:s,type:l="text",checked:g,defaultChecked:h,...c},i)=>{const u=l==="radio"||l==="checkbox",f=k.useRef(!1),p=k.useRef(null);k.useImperativeHandle(i,()=>p.current);const[m,w]=k.useState(a??s??"");k.useEffect(()=>{!u&&!f.current&&a!==void 0&&w(a)},[a,u]);const x=k.useCallback(()=>{f.current=!0},[]),A=k.useCallback(y=>{if(f.current=!1,n){const N=y.target.value;w(N);const M=new Event("change",{bubbles:!0});Object.defineProperty(M,"target",{writable:!1,value:{value:N}}),Object.defineProperty(M,"currentTarget",{writable:!1,value:{value:N}}),n(M)}},[n]),v=k.useCallback(y=>{const N=y.target.value;w(N),!f.current&&n&&n(y)},[n]),E=k.useCallback(y=>{f.current||o&&o(y)},[o]),{loadingactions:O,formdata:_,...I}=c;if(u)return d.jsx("input",{ref:p,type:l,className:r,onChange:n,checked:g,defaultChecked:h,value:a,...I});const C=a!==void 0?{value:m}:{defaultValue:s};return d.jsx("input",{ref:p,type:l,className:r,onChange:v,onKeyPress:E,onCompositionStart:x,onCompositionEnd:A,...C,...I})});xt.displayName="Input";const Ga=k.forwardRef(({accept:e,maxSize:t,onChange:r,onError:n,buttonText:o="Browse",placeholder:a="No file selected",className:s="",disabled:l,...g},h)=>{const c=k.useRef(null),i=h||c,[u,f]=k.useState(""),p={},m={};Object.entries(g).forEach(([v,E])=>{v.startsWith("data-editor-")||v==="id"||v==="onMouseMove"||v==="onMouseLeave"?p[v]=E:m[v]=E});const w=k.useCallback(()=>{!l&&i.current&&i.current.click()},[l,i]),x=k.useCallback(v=>{const E=v.target.files?.[0]||null;if(!E){f(""),r?.(null);return}if(t&&E.size>t*1024*1024){const O=`File size exceeds ${t}MB limit`;n?.(O),i.current&&(i.current.value=""),f("");return}f(E.name),r?.(E)},[t,r,n,i]),A=k.useCallback(v=>{v.stopPropagation(),i.current&&(i.current.value=""),f(""),r?.(null)},[r,i]);return d.jsxs(W,{className:`flex items-center gap-2 ${s}`,...p,children:[d.jsx(xt,{ref:i,type:"file",accept:e,onChange:x,disabled:l,className:"hidden",...m}),d.jsx(Q,{type:"button",onClick:w,disabled:l,className:"px-4 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed",children:o}),d.jsx(te,{className:"flex-1 text-sm text-gray-600 dark:text-gray-400 truncate",children:u||a}),u&&!l&&d.jsx(Q,{type:"button",onClick:A,className:"p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200","aria-label":"Clear file",children:d.jsx(fn,{className:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:d.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M6 18L18 6M6 6l12 12"})})})]})});Ga.displayName="FileInput";var Je=(e=>(e.User="fa-user",e.Users="fa-users",e.UserCircle="fa-user-circle",e.UserPlus="fa-user-plus",e.UserMinus="fa-user-minus",e.UserShield="fa-user-shield",e.Search="fa-search",e.Edit="fa-edit",e.Pencil="fa-pencil-alt",e.Trash="fa-trash",e.Save="fa-save",e.Plus="fa-plus",e.Minus="fa-minus",e.Check="fa-check",e.Times="fa-times",e.Download="fa-download",e.Upload="fa-upload",e.Copy="fa-copy",e.ExternalLink="fa-external-link-alt",e.Home="fa-home",e.ChevronLeft="fa-chevron-left",e.ChevronRight="fa-chevron-right",e.ChevronUp="fa-chevron-up",e.ChevronDown="fa-chevron-down",e.AngleLeft="fa-angle-left",e.AngleRight="fa-angle-right",e.AngleUp="fa-angle-up",e.AngleDown="fa-angle-down",e.ArrowUp="fa-arrow-up",e.ArrowDown="fa-arrow-down",e.ArrowLeft="fa-arrow-left",e.ArrowRight="fa-arrow-right",e.Bars="fa-bars",e.EllipsisVertical="fa-ellipsis-v",e.EllipsisHorizontal="fa-ellipsis-h",e.CheckCircle="fa-check-circle",e.TimesCircle="fa-times-circle",e.ExclamationCircle="fa-exclamation-circle",e.ExclamationTriangle="fa-exclamation-triangle",e.InfoCircle="fa-info-circle",e.QuestionCircle="fa-question-circle",e.XCircle="fa-times-circle",e.ShoppingCart="fa-shopping-cart",e.ShoppingBag="fa-shopping-bag",e.CreditCard="fa-credit-card",e.Tag="fa-tag"Line truncated
|
||||
var SirsoftBasic=(function(J,c,k,Jt){"use strict";function Ic(e){const t=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(e){for(const r in e)if(r!=="default"){const n=Object.getOwnPropertyDescriptor(e,r);Object.defineProperty(t,r,n.get?n:{enumerable:!0,get:()=>e[r]})}}return t.default=e,Object.freeze(t)}const T=Ic(k),Dc=()=>window.G7Core,Q=k.forwardRef(({children:e,variant:t="primary",size:r="md",className:n="",...o},a)=>{const s="inline-flex items-center justify-center",l=Dc()?.style?.mergeClasses?.(s,n)??`${s} ${n}`;return c.jsx("button",{ref:a,className:l,...o,children:e})});Q.displayName="Button";const W=k.forwardRef(({children:e,className:t="",...r},n)=>c.jsx("div",{ref:n,className:t,...r,children:e})),ee=({children:e,className:t="",...r})=>c.jsx("span",{className:t,...r,children:e}),fn=({children:e,className:t="",...r})=>c.jsx("svg",{className:t,...r,children:e}),xt=k.forwardRef(({label:e,error:t,className:r="",onChange:n,onKeyPress:o,value:a,defaultValue:s,type:l="text",checked:f,defaultChecked:h,...d},i)=>{const u=l==="radio"||l==="checkbox",g=k.useRef(!1),p=k.useRef(null);k.useImperativeHandle(i,()=>p.current);const[m,w]=k.useState(a??s??"");k.useEffect(()=>{!u&&!g.current&&a!==void 0&&w(a)},[a,u]);const x=k.useCallback(()=>{g.current=!0},[]),A=k.useCallback(y=>{if(g.current=!1,n){const N=y.target.value;w(N);const M=new Event("change",{bubbles:!0});Object.defineProperty(M,"target",{writable:!1,value:{value:N}}),Object.defineProperty(M,"currentTarget",{writable:!1,value:{value:N}}),n(M)}},[n]),v=k.useCallback(y=>{const N=y.target.value;w(N),!g.current&&n&&n(y)},[n]),E=k.useCallback(y=>{g.current||o&&o(y)},[o]),{loadingactions:O,formdata:_,...I}=d;if(u)return c.jsx("input",{ref:p,type:l,className:r,onChange:n,checked:f,defaultChecked:h,value:a,...I});const C=a!==void 0?{value:m}:{defaultValue:s};return c.jsx("input",{ref:p,type:l,className:r,onChange:v,onKeyPress:E,onCompositionStart:x,onCompositionEnd:A,...C,...I})});xt.displayName="Input";const Ba=k.forwardRef(({accept:e,maxSize:t,onChange:r,onError:n,buttonText:o="Browse",placeholder:a="No file selected",className:s="",disabled:l,...f},h)=>{const d=k.useRef(null),i=h||d,[u,g]=k.useState(""),p={},m={};Object.entries(f).forEach(([v,E])=>{v.startsWith("data-editor-")||v==="id"||v==="onMouseMove"||v==="onMouseLeave"?p[v]=E:m[v]=E});const w=k.useCallback(()=>{!l&&i.current&&i.current.click()},[l,i]),x=k.useCallback(v=>{const E=v.target.files?.[0]||null;if(!E){g(""),r?.(null);return}if(t&&E.size>t*1024*1024){const O=`File size exceeds ${t}MB limit`;n?.(O),i.current&&(i.current.value=""),g("");return}g(E.name),r?.(E)},[t,r,n,i]),A=k.useCallback(v=>{v.stopPropagation(),i.current&&(i.current.value=""),g(""),r?.(null)},[r,i]);return c.jsxs(W,{className:`flex items-center gap-2 ${s}`,...p,children:[c.jsx(xt,{ref:i,type:"file",accept:e,onChange:x,disabled:l,className:"hidden",...m}),c.jsx(Q,{type:"button",onClick:w,disabled:l,className:"px-4 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed",children:o}),c.jsx(ee,{className:"flex-1 text-sm text-gray-600 dark:text-gray-400 truncate",children:u||a}),u&&!l&&c.jsx(Q,{type:"button",onClick:A,className:"p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200","aria-label":"Clear file",children:c.jsx(fn,{className:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:c.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M6 18L18 6M6 6l12 12"})})})]})});Ba.displayName="FileInput";var Je=(e=>(e.User="fa-user",e.Users="fa-users",e.UserCircle="fa-user-circle",e.UserPlus="fa-user-plus",e.UserMinus="fa-user-minus",e.UserShield="fa-user-shield",e.Search="fa-search",e.Edit="fa-edit",e.Pencil="fa-pencil-alt",e.Trash="fa-trash",e.Save="fa-save",e.Plus="fa-plus",e.Minus="fa-minus",e.Check="fa-check",e.Times="fa-times",e.Download="fa-download",e.Upload="fa-upload",e.Copy="fa-copy",e.ExternalLink="fa-external-link-alt",e.Home="fa-home",e.ChevronLeft="fa-chevron-left",e.ChevronRight="fa-chevron-right",e.ChevronUp="fa-chevron-up",e.ChevronDown="fa-chevron-down",e.AngleLeft="fa-angle-left",e.AngleRight="fa-angle-right",e.AngleUp="fa-angle-up",e.AngleDown="fa-angle-down",e.ArrowUp="fa-arrow-up",e.ArrowDown="fa-arrow-down",e.ArrowLeft="fa-arrow-left",e.ArrowRight="fa-arrow-right",e.Bars="fa-bars",e.EllipsisVertical="fa-ellipsis-v",e.EllipsisHorizontal="fa-ellipsis-h",e.CheckCircle="fa-check-circle",e.TimesCircle="fa-times-circle",e.ExclamationCircle="fa-exclamation-circle",e.ExclamationTriangle="fa-exclamation-triangle",e.InfoCircle="fa-info-circle",e.QuestionCircle="fa-question-circle",e.XCircle="fa-times-circle",e.ShoppingCart="fa-shopping-cart",e.ShoppingBag="fa-shopping-bag",e.CreditCard="fa-credit-card",e.Tag="fa-tag"Line truncated
|
||||
w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors
|
||||
${g==="auto"?"bg-gray-50 dark:bg-gray-700":""}
|
||||
`,children:[d.jsx(de,{name:Je.Settings,className:"w-5 h-5 text-gray-600 dark:text-gray-400"}),d.jsx(te,{className:"flex-1 text-left text-gray-900 dark:text-white",children:r}),g==="auto"&&d.jsx(de,{name:Je.Check,className:"w-4 h-4 text-blue-600 dark:text-blue-400 ml-auto"})]}),d.jsxs(Q,{onClick:()=>f("light"),className:`
|
||||
${f==="auto"?"bg-gray-50 dark:bg-gray-700":""}
|
||||
`,children:[c.jsx(de,{name:Je.Settings,className:"w-5 h-5 text-gray-600 dark:text-gray-400"}),c.jsx(ee,{className:"flex-1 text-left text-gray-900 dark:text-white",children:r}),f==="auto"&&c.jsx(de,{name:Je.Check,className:"w-4 h-4 text-blue-600 dark:text-blue-400 ml-auto"})]}),c.jsxs(Q,{onClick:()=>g("light"),className:`
|
||||
w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors
|
||||
${g==="light"?"bg-gray-50 dark:bg-gray-700":""}
|
||||
`,children:[d.jsx(de,{name:Je.Sun,className:"w-5 h-5 text-gray-600 dark:text-gray-400"}),d.jsx(te,{className:"flex-1 text-left text-gray-900 dark:text-white",children:n}),g==="light"&&d.jsx(de,{name:Je.Check,className:"w-4 h-4 text-blue-600 dark:text-blue-400 ml-auto"})]}),d.jsxs(Q,{onClick:()=>f("dark"),className:`
|
||||
${f==="light"?"bg-gray-50 dark:bg-gray-700":""}
|
||||
`,children:[c.jsx(de,{name:Je.Sun,className:"w-5 h-5 text-gray-600 dark:text-gray-400"}),c.jsx(ee,{className:"flex-1 text-left text-gray-900 dark:text-white",children:n}),f==="light"&&c.jsx(de,{name:Je.Check,className:"w-4 h-4 text-blue-600 dark:text-blue-400 ml-auto"})]}),c.jsxs(Q,{onClick:()=>g("dark"),className:`
|
||||
w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors
|
||||
${g==="dark"?"bg-gray-50 dark:bg-gray-700":""}
|
||||
`,children:[d.jsx(de,{name:Je.Moon,className:"w-5 h-5 text-gray-600 dark:text-gray-400"}),d.jsx(te,{className:"flex-1 text-left text-gray-900 dark:text-white",children:o}),g==="dark"&&d.jsx(de,{name:Je.Check,className:"w-4 h-4 text-blue-600 dark:text-blue-400 ml-auto"})]})]})})]})},qc=Object.freeze(Object.defineProperty({__proto__:null,ThemeToggle:hn,default:hn},Symbol.toStringTag,{value:"Module"})),Za={xs:"w-6 h-6 text-xs",sm:"w-8 h-8 text-sm",md:"w-10 h-10 text-base",lg:"w-12 h-12 text-lg",xl:"w-16 h-16 text-xl","2xl":"w-24 h-24 text-2xl","3xl":"w-32 h-32 text-3xl","4xl":"w-40 h-40 text-4xl","5xl":"w-48 h-48 text-5xl"},Zc="rounded-full overflow-hidden flex-shrink-0 border border-gray-200 dark:border-gray-700",Qc="grayscale opacity-50",co={normal:"bg-gradient-to-br from-blue-500 to-purple-600",withdrawn:"bg-gray-300 dark:bg-gray-600",guest:"bg-gray-200 dark:bg-gray-700"},$r=({author:e,name:t,avatar:r,size:n="md",className:o="",text:a,isWithdrawn:s=!1,isGuest:l=!1,id:g,editorAttrs:h})=>{const c=r??e?.avatar,i=a??t??e?.name??"?",u=s||e?.status==="withdrawn",f=l||e?.is_guest||!1,p=k.useMemo(()=>[Za[n]||Za.md,Zc,u?Qc:"",o].filter(Boolean).join(" "),[n,u,o]),m=f?co.guest:u?co.withdrawn:co.normal,w=()=>f?d.jsx(W,{className:`w-full h-full flex items-center justify-center ${m}`,children:d.jsx(de,{name:"user",className:"text-gray-400 dark:text-gray-500"})}):u?d.jsx(W,{className:`w-full h-full flex items-center justify-center ${m}`,children:d.jsx(de,{name:"user",className:"text-gray-500 dark:text-gray-400"})}):d.jsx(W,{className:`w-full h-full flex items-center justify-center text-white font-semibold ${m}`,children:i.charAt(0).toUpperCase()});return d.jsx(W,{className:p,id:g,...h,children:c&&!f&&!u?d.jsx(Lt,{src:c,alt:i,className:"w-full h-full object-cover"}):w()})},Jc=Object.freeze(Object.defineProperty({__proto__:null,Avatar:$r},Symbol.toStringTag,{value:"Module"})),Rc=window.G7Core?.createLogger?.("Comp:SlotContainer")??{log:(...e)=>console.log("[Comp:SlotContainer]",...e),warn:(...e)=>console.warn("[Comp:SlotContainer]",...e),error:(...e)=>console.error("[Comp:SlotContainer]",...e)};function Qa(e,t){return t&&e&&typeof e.id=="string"&&e.id.length>0?{...e,id:`${e.id}__${t}`}:e}const pn=({slotId:e,className:t="",emptyContent:r,style:n,id:o,children:a})=>{const[s,l]=k.useState([]),[,g]=k.useState({}),h=k.useCallback(()=>{const p=window.__slotContextValue;if(p)return p;const m=window.G7Core;return m&&m.getSlotContext?.()||null},[]),c=k.useCallback(()=>{const p=h();p&&p.isEnabled&&l(p.getSlotComponents(e))},[e,h]);k.useEffect(()=>{const p=h();return!p||!p.isEnabled?void 0:(c(),p.subscribeToSlot(e,()=>{c(),g({})}))},[e,h,c]);const i=k.useCallback(()=>{const p=window.G7Core;return p?p.getDynamicRenderer?.():null},[]);if(s.length===0)return r?d.jsx(W,{className:t,style:n,id:o,children:r}):a?d.jsx(W,{className:t,style:n,id:o,children:a}):null;const u=i(),f=window.G7Core;return!u||!f?(Rc.error("G7Core or DynamicRenderer not available"),null):d.jsxs(W,{className:t,style:n,id:o,children:[s.map(p=>{const{componentDef:m,dataContext:w,parentFormContext:x,getParentComponentContext:A,translationContext:v}=p,E=Qa(m,o);return d.jsx(u,{componentDef:E,dataContext:w,translationContext:v,registry:f.getComponentRegistry(),bindingEngine:f.getDataBindingEngine(),translationEngine:f.getTranslationEngine(),actionDispatcher:f.getActionDispatcher(),parentComponentContext:A?.(),parentFormContextProp:x,isRootRenderer:!1},E.id||`slot-${e}-${p.order}`)}),a]})},ed=Object.freeze(Object.defineProperty({__proto__:null,SlotContainer:pn,default:pn,scopeSlotChildDef:Qa},Symbol.toStringTag,{value:"Module"})),mn=({notifications:e=[],unreadCount:t=0,hasMore:r=!1,loading:n=!1,titleText:o="알림",emptyText:a="알림이 없습니다.",markAllReadText:s="모두 읽음",deleteAllText:l="모두 삭제",unreadOnlyText:g="안 읽은 알림만",unreadOnly:h=!1,onNotificationClick:c,onClose:i,onLoadMore:u,onMarkAllRead:f,onDeleteAll:p,onDelete:m,onUnreadOnlyToggle:w,dropdownAlign:x="right",className:A="",id:v,editorAttrs:E})=>{const[O,_]=k.useState(!1),I=k.useRef(null),C=k.useRef(null),y=k.useRef(null),N=k.useRef(null),M=k.useRef(new Set),P=k.useRef(u),D=k.useRef(r),L=k.useRef(n),$=k.useRef(e.length),S=k.useRef(-1);k.useEffect(()=>{P.current=u,D.current=r,L.current=n,$.current=e.length}),k.useEffect(()=>{if(!N.current||!y.current||!O)return;S.current=-1;const G=new IntersectionObserver(([Y])=>{Y.isIntersecting&&(L.current||!D.current||S.current!==$.current&&(S.current=$.current,P.current?.()))},{root:y.current,threshold:.1});return G.observe(N.current),()=>G.disconnect()},[O]),k.useEffect(()=>{if(!y.current||!O)return;const G=new IntersectionObserver(B=>{B.forEach(V=>{const j=V.target.getAttribute("data-notification-id"),H=V.target.getAttribute("data-unread")==="true";j&&H&&V.isIntersecting&&M.current.add(j)})},{root:y.current,threshold:.5});return y.current.querySelectorAll("[data-notification-id]").forEach(B=>G.observe(B)),()=>G.disconnect()},[O,
Line truncated
|
||||
`+wt),Ge&&bn([P,D,L],Qt=>{wt=Hr(wt,Qt," ")}),E&&Le?E.createHTML(wt):wt},t.setConfig=function(){let ge=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};qe(ge),Me=!0},t.clearConfig=function(){Be=null,Me=!1},t.isValidAttribute=function(ge,z,Z){Be||qe({});const ce=Fe(ge),Ye=Fe(z);return _c(ce,Ye,Z)},t.addHook=function(ge,z){typeof z=="function"&&zr(M[ge],z)},t.removeHook=function(ge,z){if(z!==void 0){const Z=ud(M[ge],z);return Z===-1?void 0:fd(M[ge],Z,1)[0]}return os(M[ge])},t.removeHooks=function(ge){M[ge]=[]},t.removeAllHooks=function(){M=fs()},t}var hs=gs();const Ad=()=>{const e=()=>{const n=window.G7Core?.state?.get?.();return n&&n.preferredCurrency||"KRW"},[t,r]=k.useState(e);return k.useEffect(()=>{const n=window.G7Core?.state?.subscribe;if(r(e()),typeof n!="function")return;const o=n(a=>{r(a&&a.preferredCurrency||"KRW")});return typeof o=="function"?o:void 0},[]),t},Md=e=>{window.G7Core?.dispatch?.({handler:"navigate",params:{path:e}})},Od=e=>{const t=Math.round(e*2)/2;return[1,2,3,4,5].map(r=>t>=r?"full":t>=r-.5?"half":"empty")},ps=({product:e,onClick:t,shopBase:r="/shop",className:n="",id:o,editorAttrs:a})=>{const s=Ad(),l=A=>{const v=`multi_currency_${A}`,E=e[v];if(E&&E[s])return E[s].formatted;const O=`${A}_formatted`;return e[O]},g=e.name_localized??(typeof e.name=="string"?e.name:"")??"",h=l("selling_price"),c=l("list_price"),i=e.discount_rate&&e.discount_rate>0,u=e.sales_status&&e.sales_status!=="on_sale",f=e.labels??[],p=A=>hs.sanitize(A,{ALLOWED_TAGS:["mark"],ALLOWED_ATTR:[]}),m=e.name_highlighted,w=e.description_highlighted??e.short_description,x=()=>{if(t)t(e.id);else{const A=r==="/"?"":r,v=e.product_code??e.id;Md(`${A}/products/${v}`)}};return d.jsxs(Q,{onClick:x,className:`block w-full text-left group bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-lg transition-shadow cursor-pointer ${n}`,id:o,...a,children:[d.jsxs(W,{className:"relative aspect-square overflow-hidden bg-gray-100 dark:bg-gray-700",children:[d.jsx(Lt,{src:e.thumbnail_url,alt:g,className:"w-full h-full object-cover group-hover:scale-105 transition-transform duration-200",loading:"lazy"}),u&&d.jsx(W,{className:"absolute inset-0 bg-black/50 flex items-center justify-center",children:d.jsx(te,{className:`text-lg font-bold ${e.sales_status==="sold_out"?"text-red-300":e.sales_status==="suspended"?"text-amber-300":"text-blue-300"}`,style:{textShadow:"0 1px 4px rgba(0,0,0,0.6)"},children:e.sales_status_label??e.sales_status})}),i&&e.sales_status!=="sold_out"&&e.sales_status!=="suspended"&&d.jsxs(te,{className:"absolute top-2 left-2 px-2 py-1 bg-red-500 text-white text-xs font-bold rounded",children:[e.discount_rate,"%"]}),f.length>0&&d.jsx(W,{className:"absolute bottom-2 right-2 flex gap-1 flex-wrap justify-end",children:f.map((A,v)=>d.jsx(te,{className:"text-xs text-white font-medium px-1.5 py-0.5 rounded",style:{backgroundColor:A.color||"#6b7280"},children:A.name},v))})]}),d.jsxs(W,{className:"p-4",children:[d.jsxs(W,{className:"flex items-center gap-1.5 flex-wrap",children:[e.primary_category&&d.jsx(te,{className:"inline-block text-xs font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-2 py-0.5 rounded",children:e.primary_category}),e.brand_name&&d.jsx(te,{className:"text-xs text-gray-500 dark:text-gray-400",children:e.brand_name})]}),m?d.jsx(W,{className:"mt-1 font-medium text-gray-900 dark:text-white line-clamp-1 [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-500/40 [&_mark]:px-0.5 [&_mark]:rounded",dangerouslySetInnerHTML:{__html:p(m)}}):d.jsx(gn,{className:"mt-1 font-medium text-gray-900 dark:text-white line-clamp-1",children:g}),w&&d.jsx(W,{className:"mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2 [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-500/40 [&_mark]:px-0.5 [&_mark]:rounded",dangerouslySetInnerHTML:{__html:p(w)}}),d.jsxs(W,{className:"mt-1.5 flex items-center gap-1",children:[Od(e.rating_avg??0).map((A,v)=>d.jsxs(te,{className:`text-xs ${A==="empty"?"text-gray-300 dark:text-gray-600":"text-yellow-400"}`,children:[A==="full"&&d.jsx("i",{className:"fa-solid fa-star"}),A==="half"&&d.jsx("i",{className:"fa-solid fa-star-half-stroke"}),A==="empty"&&d.jsx("i",{className:"fa-regular fa-star"})]},v)),d.jsx(te,{className:"text-xs text-gray-600 dark:text-gray-400 ml-0.5",children:(e.rating_avg??0).toFixed(1)}),d.jsxs(te,{className:"text-xs text-gray-400 dark:text-gray-500",children:["(",e.review_count??0,")"]})]}),d.jsxs(W,{className:"mt-2 flex items-baseline gap-2",children:[d.jsx(te,{className:"font-bold text-gray-900 dark:text-white",children:h}),c&&i&&d.jsx(te,{className:"text-sm text-gray-500 dark:text-gray-400 line-through",children:c})]})]})]})},Id=Object.freeze(Object.defineProperty({__proto__:null,default:ps},Symbol.toStringTag,{value:"Module"})),ms="carousel",Wr="controller",Dd="navigation",Ld="no-scroll",wo="portal",Pd="root",bs="toolbar",Fd="counter",Kr="fullscreen",ys="slideshow",Vr="thumbnails",vs="zoom"
Line truncated
|
||||
${f==="dark"?"bg-gray-50 dark:bg-gray-700":""}
|
||||
`,children:[c.jsx(de,{name:Je.Moon,className:"w-5 h-5 text-gray-600 dark:text-gray-400"}),c.jsx(ee,{className:"flex-1 text-left text-gray-900 dark:text-white",children:o}),f==="dark"&&c.jsx(de,{name:Je.Check,className:"w-4 h-4 text-blue-600 dark:text-blue-400 ml-auto"})]})]})})]})},Qc=Object.freeze(Object.defineProperty({__proto__:null,ThemeToggle:hn,default:hn},Symbol.toStringTag,{value:"Module"})),Ja={xs:"w-6 h-6 text-xs",sm:"w-8 h-8 text-sm",md:"w-10 h-10 text-base",lg:"w-12 h-12 text-lg",xl:"w-16 h-16 text-xl","2xl":"w-24 h-24 text-2xl","3xl":"w-32 h-32 text-3xl","4xl":"w-40 h-40 text-4xl","5xl":"w-48 h-48 text-5xl"},Jc="rounded-full overflow-hidden flex-shrink-0 border border-gray-200 dark:border-gray-700",Rc="grayscale opacity-50",co={normal:"bg-gradient-to-br from-blue-500 to-purple-600",withdrawn:"bg-gray-300 dark:bg-gray-600",guest:"bg-gray-200 dark:bg-gray-700"},Ur=({author:e,name:t,avatar:r,size:n="md",className:o="",text:a,isWithdrawn:s=!1,isGuest:l=!1,id:f,editorAttrs:h})=>{const d=r??e?.avatar,i=a??t??e?.name??"?",u=s||e?.status==="withdrawn",g=l||e?.is_guest||!1,p=k.useMemo(()=>[Ja[n]||Ja.md,Jc,u?Rc:"",o].filter(Boolean).join(" "),[n,u,o]),m=g?co.guest:u?co.withdrawn:co.normal,w=()=>g?c.jsx(W,{className:`w-full h-full flex items-center justify-center ${m}`,children:c.jsx(de,{name:"user",className:"text-gray-400 dark:text-gray-500"})}):u?c.jsx(W,{className:`w-full h-full flex items-center justify-center ${m}`,children:c.jsx(de,{name:"user",className:"text-gray-500 dark:text-gray-400"})}):c.jsx(W,{className:`w-full h-full flex items-center justify-center text-white font-semibold ${m}`,children:i.charAt(0).toUpperCase()});return c.jsx(W,{className:p,id:f,...h,children:d&&!g&&!u?c.jsx(Lt,{src:d,alt:i,className:"w-full h-full object-cover"}):w()})},ed=Object.freeze(Object.defineProperty({__proto__:null,Avatar:Ur},Symbol.toStringTag,{value:"Module"})),td=window.G7Core?.createLogger?.("Comp:SlotContainer")??{log:(...e)=>console.log("[Comp:SlotContainer]",...e),warn:(...e)=>console.warn("[Comp:SlotContainer]",...e),error:(...e)=>console.error("[Comp:SlotContainer]",...e)};function Ra(e,t){return t&&e&&typeof e.id=="string"&&e.id.length>0?{...e,id:`${e.id}__${t}`}:e}const pn=({slotId:e,className:t="",emptyContent:r,style:n,id:o,children:a})=>{const[s,l]=k.useState([]),[,f]=k.useState({}),h=k.useCallback(()=>{const p=window.__slotContextValue;if(p)return p;const m=window.G7Core;return m&&m.getSlotContext?.()||null},[]),d=k.useCallback(()=>{const p=h();p&&p.isEnabled&&l(p.getSlotComponents(e))},[e,h]);k.useEffect(()=>{const p=h();return!p||!p.isEnabled?void 0:(d(),p.subscribeToSlot(e,()=>{d(),f({})}))},[e,h,d]);const i=k.useCallback(()=>{const p=window.G7Core;return p?p.getDynamicRenderer?.():null},[]);if(s.length===0)return r?c.jsx(W,{className:t,style:n,id:o,children:r}):a?c.jsx(W,{className:t,style:n,id:o,children:a}):null;const u=i(),g=window.G7Core;return!u||!g?(td.error("G7Core or DynamicRenderer not available"),null):c.jsxs(W,{className:t,style:n,id:o,children:[s.map(p=>{const{componentDef:m,dataContext:w,parentFormContext:x,getParentComponentContext:A,translationContext:v}=p,E=Ra(m,o);return c.jsx(u,{componentDef:E,dataContext:w,translationContext:v,registry:g.getComponentRegistry(),bindingEngine:g.getDataBindingEngine(),translationEngine:g.getTranslationEngine(),actionDispatcher:g.getActionDispatcher(),parentComponentContext:A?.(),parentFormContextProp:x,isRootRenderer:!1},E.id||`slot-${e}-${p.order}`)}),a]})},rd=Object.freeze(Object.defineProperty({__proto__:null,SlotContainer:pn,default:pn,scopeSlotChildDef:Ra},Symbol.toStringTag,{value:"Module"})),mn=({notifications:e=[],unreadCount:t=0,hasMore:r=!1,loading:n=!1,titleText:o="알림",emptyText:a="알림이 없습니다.",markAllReadText:s="모두 읽음",deleteAllText:l="모두 삭제",unreadOnlyText:f="안 읽은 알림만",unreadOnly:h=!1,onNotificationClick:d,onClose:i,onLoadMore:u,onMarkAllRead:g,onDeleteAll:p,onDelete:m,onUnreadOnlyToggle:w,dropdownAlign:x="right",className:A="",id:v,editorAttrs:E})=>{const[O,_]=k.useState(!1),I=k.useRef(null),C=k.useRef(null),y=k.useRef(null),N=k.useRef(null),M=k.useRef(new Set),P=k.useRef(u),D=k.useRef(r),L=k.useRef(n),U=k.useRef(e.length),S=k.useRef(-1);k.useEffect(()=>{P.current=u,D.current=r,L.current=n,U.current=e.length}),k.useEffect(()=>{if(!N.current||!y.current||!O)return;S.current=-1;const G=new IntersectionObserver(([Y])=>{Y.isIntersecting&&(L.current||!D.current||S.current!==U.current&&(S.current=U.current,P.current?.()))},{root:y.current,threshold:.1});return G.observe(N.current),()=>G.disconnect()},[O]),k.useEffect(()=>{if(!y.current||!O)return;const G=new IntersectionObserver(B=>{B.forEach(V=>{const j=V.target.getAttribute("data-notification-id"),H=V.target.getAttribute("data-unread")==="true";j&&H&&V.isIntersecting&&M.current.add(j)})},{root:y.current,threshold:.5});return y.current.querySelectorAll("[data-notification-id]").forEach(B=>G.observe(B)),()=>G.disconnect()},[O,
Line truncated
|
||||
`+wt),Ge&&bn([P,D,L],Qt=>{wt=Hr(wt,Qt," ")}),E&&Le?E.createHTML(wt):wt},t.setConfig=function(){let ge=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};Ze(ge),Me=!0},t.clearConfig=function(){Be=null,Me=!1},t.isValidAttribute=function(ge,z,q){Be||Ze({});const ce=Fe(ge),Ye=Fe(z);return Tc(ce,Ye,q)},t.addHook=function(ge,z){typeof z=="function"&&zr(M[ge],z)},t.removeHook=function(ge,z){if(z!==void 0){const q=gd(M[ge],z);return q===-1?void 0:hd(M[ge],q,1)[0]}return ss(M[ge])},t.removeHooks=function(ge){M[ge]=[]},t.removeAllHooks=function(){M=hs()},t}var wo=ps();const Od=()=>{const e=()=>{const n=window.G7Core?.state?.get?.();return n&&n.preferredCurrency||"KRW"},[t,r]=k.useState(e);return k.useEffect(()=>{const n=window.G7Core?.state?.subscribe;if(r(e()),typeof n!="function")return;const o=n(a=>{r(a&&a.preferredCurrency||"KRW")});return typeof o=="function"?o:void 0},[]),t},Id=e=>{window.G7Core?.dispatch?.({handler:"navigate",params:{path:e}})},Dd=e=>{const t=Math.round(e*2)/2;return[1,2,3,4,5].map(r=>t>=r?"full":t>=r-.5?"half":"empty")},ms=({product:e,onClick:t,shopBase:r="/shop",className:n="",id:o,editorAttrs:a})=>{const s=Od(),l=A=>{const v=`multi_currency_${A}`,E=e[v];if(E&&E[s])return E[s].formatted;const O=`${A}_formatted`;return e[O]},f=e.name_localized??(typeof e.name=="string"?e.name:"")??"",h=l("selling_price"),d=l("list_price"),i=e.discount_rate&&e.discount_rate>0,u=e.sales_status&&e.sales_status!=="on_sale",g=e.labels??[],p=A=>wo.sanitize(A,{ALLOWED_TAGS:["mark"],ALLOWED_ATTR:[]}),m=e.name_highlighted,w=e.description_highlighted??e.short_description,x=()=>{if(t)t(e.id);else{const A=r==="/"?"":r,v=e.product_code??e.id;Id(`${A}/products/${v}`)}};return c.jsxs(Q,{onClick:x,className:`block w-full text-left group bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-lg transition-shadow cursor-pointer ${n}`,id:o,...a,children:[c.jsxs(W,{className:"relative aspect-square overflow-hidden bg-gray-100 dark:bg-gray-700",children:[c.jsx(Lt,{src:e.thumbnail_url,alt:f,className:"w-full h-full object-cover group-hover:scale-105 transition-transform duration-200",loading:"lazy"}),u&&c.jsx(W,{className:"absolute inset-0 bg-black/50 flex items-center justify-center",children:c.jsx(ee,{className:`text-lg font-bold ${e.sales_status==="sold_out"?"text-red-300":e.sales_status==="suspended"?"text-amber-300":"text-blue-300"}`,style:{textShadow:"0 1px 4px rgba(0,0,0,0.6)"},children:e.sales_status_label??e.sales_status})}),i&&e.sales_status!=="sold_out"&&e.sales_status!=="suspended"&&c.jsxs(ee,{className:"absolute top-2 left-2 px-2 py-1 bg-red-500 text-white text-xs font-bold rounded",children:[e.discount_rate,"%"]}),g.length>0&&c.jsx(W,{className:"absolute bottom-2 right-2 flex gap-1 flex-wrap justify-end",children:g.map((A,v)=>c.jsx(ee,{className:"text-xs text-white font-medium px-1.5 py-0.5 rounded",style:{backgroundColor:A.color||"#6b7280"},children:A.name},v))})]}),c.jsxs(W,{className:"p-4",children:[c.jsxs(W,{className:"flex items-center gap-1.5 flex-wrap",children:[e.primary_category&&c.jsx(ee,{className:"inline-block text-xs font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-2 py-0.5 rounded",children:e.primary_category}),e.brand_name&&c.jsx(ee,{className:"text-xs text-gray-500 dark:text-gray-400",children:e.brand_name})]}),m?c.jsx(W,{className:"mt-1 font-medium text-gray-900 dark:text-white line-clamp-1 [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-500/40 [&_mark]:px-0.5 [&_mark]:rounded",dangerouslySetInnerHTML:{__html:p(m)}}):c.jsx(gn,{className:"mt-1 font-medium text-gray-900 dark:text-white line-clamp-1",children:f}),w&&c.jsx(W,{className:"mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2 [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-500/40 [&_mark]:px-0.5 [&_mark]:rounded",dangerouslySetInnerHTML:{__html:p(w)}}),c.jsxs(W,{className:"mt-1.5 flex items-center gap-1",children:[Dd(e.rating_avg??0).map((A,v)=>c.jsxs(ee,{className:`text-xs ${A==="empty"?"text-gray-300 dark:text-gray-600":"text-yellow-400"}`,children:[A==="full"&&c.jsx("i",{className:"fa-solid fa-star"}),A==="half"&&c.jsx("i",{className:"fa-solid fa-star-half-stroke"}),A==="empty"&&c.jsx("i",{className:"fa-regular fa-star"})]},v)),c.jsx(ee,{className:"text-xs text-gray-600 dark:text-gray-400 ml-0.5",children:(e.rating_avg??0).toFixed(1)}),c.jsxs(ee,{className:"text-xs text-gray-400 dark:text-gray-500",children:["(",e.review_count??0,")"]})]}),c.jsxs(W,{className:"mt-2 flex items-baseline gap-2",children:[c.jsx(ee,{className:"font-bold text-gray-900 dark:text-white",children:h}),d&&i&&c.jsx(ee,{className:"text-sm text-gray-500 dark:text-gray-400 line-through",children:d})]})]})]})},Ld=Object.freeze(Object.defineProperty({__proto__:null,default:ms},Symbol.toStringTag,{value:"Module"})),Pd={ALLOWED_TAGS:["svg","g","defs","title","desc","rect","circle","ellipse","line","polyline","polygon","path","text","tspan","linearGradient","radialGradient","stop
Line truncated
|
||||
inline-flex items-center rounded-full border transition-colors
|
||||
${g[o]}
|
||||
${f[o]}
|
||||
${p?`${u.activeColor} border-transparent`:"border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"}
|
||||
`,title:Ml(u.labelKey),children:[d.jsx(te,{className:h[o],children:u.emoji}),d.jsx(te,{children:Ml(u.labelKey)}),f>0&&d.jsx(te,{className:"font-medium",children:f})]},u.type)})})},jf=Object.freeze(Object.defineProperty({__proto__:null,default:Ol},Symbol.toStringTag,{value:"Module"})),Mn=window.G7Core?.createLogger?.("Comp:RichTextEditor")??{log:(...e)=>console.log("[Comp:RichTextEditor]",...e),warn:(...e)=>console.warn("[Comp:RichTextEditor]",...e),error:(...e)=>console.error("[Comp:RichTextEditor]",...e)},St=(e,t)=>window.G7Core?.t?.(e,t)??e,Il=({name:e,initialValue:t="",placeholder:r,onChange:n,imageUploadUrl:o="/api/upload/image",minHeight:a="300px",disabled:s=!1,className:l="",id:g,editorAttrs:h})=>{const c=k.useRef(null),[i,u]=k.useState(t),[f,p]=k.useState(!1),[m,w]=k.useState(!1),[x,A]=k.useState("");k.useEffect(()=>{c.current&&!c.current.innerHTML&&(c.current.innerHTML=t)},[t]),k.useEffect(()=>{n&&n(i)},[i,n]);const v=N=>{Mn.log("Apply format:",N)},E=async N=>{const M=new FormData;M.append("file",N);try{const P=await fetch(o,{method:"POST",body:M,credentials:"include"});if(!P.ok)throw new Error("업로드 실패");const D=await P.json();Mn.log("Image uploaded:",D.url)}catch(P){Mn.error("이미지 업로드 오류:",P)}},O=N=>{const M=N.target.files?.[0];M&&E(M)},_=()=>{x&&(Mn.log("Insert link:",x),w(!1),A(""))},I=[{format:"bold",icon:"B",title:St("editor.toolbar.bold")},{format:"italic",icon:"I",title:St("editor.toolbar.italic")},{format:"underline",icon:"U",title:St("editor.toolbar.underline")},{format:"strike",icon:"S",title:St("editor.toolbar.strike")},{format:"code",icon:"</>",title:St("editor.toolbar.code")}],C=[{format:"h1",label:"H1"},{format:"h2",label:"H2"},{format:"h3",label:"H3"}],y=[{format:"bulletList",icon:"•",title:St("editor.toolbar.bullet_list")},{format:"orderedList",icon:"1.",title:St("editor.toolbar.ordered_list")},{format:"blockquote",icon:"❝",title:St("editor.toolbar.blockquote")}];return d.jsxs(W,{className:`border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden ${l}`,id:g,...h,children:[d.jsxs(W,{className:"flex flex-wrap items-center gap-1 p-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700",children:[d.jsx(W,{className:"flex items-center gap-1 pr-2 border-r border-gray-200 dark:border-gray-700",children:I.map(N=>d.jsx(Q,{type:"button",onClick:()=>v(N.format),disabled:s,className:"w-8 h-8 flex items-center justify-center text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded disabled:opacity-50",title:N.title,children:N.icon},N.format))}),d.jsx(W,{className:"flex items-center gap-1 pr-2 border-r border-gray-200 dark:border-gray-700",children:C.map(N=>d.jsx(Q,{type:"button",onClick:()=>v(N.format),disabled:s,className:"px-2 h-8 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded disabled:opacity-50",children:N.label},N.format))}),d.jsx(W,{className:"flex items-center gap-1 pr-2 border-r border-gray-200 dark:border-gray-700",children:y.map(N=>d.jsx(Q,{type:"button",onClick:()=>v(N.format),disabled:s,className:"w-8 h-8 flex items-center justify-center text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded disabled:opacity-50",title:N.title,children:N.icon},N.format))}),d.jsxs(W,{className:"flex items-center gap-1 pr-2 border-r border-gray-200 dark:border-gray-700",children:[d.jsx(Q,{type:"button",onClick:()=>w(!0),disabled:s,className:"w-8 h-8 flex items-center justify-center text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded disabled:opacity-50",title:St("editor.toolbar.link"),children:"🔗"}),d.jsxs(yr,{className:"w-8 h-8 flex items-center justify-center text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer",children:["🖼",d.jsx(xt,{type:"file",accept:"image/*",onChange:O,disabled:s,className:"hidden"})]})]}),d.jsxs(Q,{type:"button",onClick:()=>p(!f),disabled:s,className:`px-2 h-8 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded disabled:opacity-50 ${f?"bg-gray-200 dark:bg-gray-700":""}`,children:["</>",St("editor.toolbar.code_view")]})]}),f?d.jsx(no,{value:i,onChange:N=>u(N.target.value),placeholder:r,disabled:s,className:"w-full p-4 bg-gray-900 text-gray-100 font-mono text-sm focus:outline-none resize-none",style:{minHeight:a}}):d.jsx(W,{ref:c,contentEditable:!s,onInput:N=>u(N.currentTarget.innerHTML),className:"p-4 bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none prose dark:prose-invert max-w-none",style:{minHeight:a},"data-placeholder":r}),d.jsx(xt,{type:"hidden",name:e,value:i}),m&&d.jsx(W,{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/50",children:d.jsxs(W,{className:"bg-white dark:bg-gray-800 rounded-lg p-6 w-96",children:[d.jsx(gn,{className:"text-lg font-medium text-gray-900 dark:text-white mb-4",children:St("edi
Line truncated
|
||||
`,title:Ol(u.labelKey),children:[c.jsx(ee,{className:h[o],children:u.emoji}),c.jsx(ee,{children:Ol(u.labelKey)}),g>0&&c.jsx(ee,{className:"font-medium",children:g})]},u.type)})})},Vf=Object.freeze(Object.defineProperty({__proto__:null,default:Il},Symbol.toStringTag,{value:"Module"})),Mn=window.G7Core?.createLogger?.("Comp:RichTextEditor")??{log:(...e)=>console.log("[Comp:RichTextEditor]",...e),warn:(...e)=>console.warn("[Comp:RichTextEditor]",...e),error:(...e)=>console.error("[Comp:RichTextEditor]",...e)},St=(e,t)=>window.G7Core?.t?.(e,t)??e,Dl=({name:e,initialValue:t="",placeholder:r,onChange:n,imageUploadUrl:o="/api/upload/image",minHeight:a="300px",disabled:s=!1,className:l="",id:f,editorAttrs:h})=>{const d=k.useRef(null),[i,u]=k.useState(t),[g,p]=k.useState(!1),[m,w]=k.useState(!1),[x,A]=k.useState("");k.useEffect(()=>{d.current&&!d.current.innerHTML&&(d.current.innerHTML=t)},[t]),k.useEffect(()=>{n&&n(i)},[i,n]);const v=N=>{Mn.log("Apply format:",N)},E=async N=>{const M=new FormData;M.append("file",N);try{const P=await fetch(o,{method:"POST",body:M,credentials:"include"});if(!P.ok)throw new Error("업로드 실패");const D=await P.json();Mn.log("Image uploaded:",D.url)}catch(P){Mn.error("이미지 업로드 오류:",P)}},O=N=>{const M=N.target.files?.[0];M&&E(M)},_=()=>{x&&(Mn.log("Insert link:",x),w(!1),A(""))},I=[{format:"bold",icon:"B",title:St("editor.toolbar.bold")},{format:"italic",icon:"I",title:St("editor.toolbar.italic")},{format:"underline",icon:"U",title:St("editor.toolbar.underline")},{format:"strike",icon:"S",title:St("editor.toolbar.strike")},{format:"code",icon:"</>",title:St("editor.toolbar.code")}],C=[{format:"h1",label:"H1"},{format:"h2",label:"H2"},{format:"h3",label:"H3"}],y=[{format:"bulletList",icon:"•",title:St("editor.toolbar.bullet_list")},{format:"orderedList",icon:"1.",title:St("editor.toolbar.ordered_list")},{format:"blockquote",icon:"❝",title:St("editor.toolbar.blockquote")}];return c.jsxs(W,{className:`border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden ${l}`,id:f,...h,children:[c.jsxs(W,{className:"flex flex-wrap items-center gap-1 p-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700",children:[c.jsx(W,{className:"flex items-center gap-1 pr-2 border-r border-gray-200 dark:border-gray-700",children:I.map(N=>c.jsx(Q,{type:"button",onClick:()=>v(N.format),disabled:s,className:"w-8 h-8 flex items-center justify-center text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded disabled:opacity-50",title:N.title,children:N.icon},N.format))}),c.jsx(W,{className:"flex items-center gap-1 pr-2 border-r border-gray-200 dark:border-gray-700",children:C.map(N=>c.jsx(Q,{type:"button",onClick:()=>v(N.format),disabled:s,className:"px-2 h-8 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded disabled:opacity-50",children:N.label},N.format))}),c.jsx(W,{className:"flex items-center gap-1 pr-2 border-r border-gray-200 dark:border-gray-700",children:y.map(N=>c.jsx(Q,{type:"button",onClick:()=>v(N.format),disabled:s,className:"w-8 h-8 flex items-center justify-center text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded disabled:opacity-50",title:N.title,children:N.icon},N.format))}),c.jsxs(W,{className:"flex items-center gap-1 pr-2 border-r border-gray-200 dark:border-gray-700",children:[c.jsx(Q,{type:"button",onClick:()=>w(!0),disabled:s,className:"w-8 h-8 flex items-center justify-center text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded disabled:opacity-50",title:St("editor.toolbar.link"),children:"🔗"}),c.jsxs(yr,{className:"w-8 h-8 flex items-center justify-center text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer",children:["🖼",c.jsx(xt,{type:"file",accept:"image/*",onChange:O,disabled:s,className:"hidden"})]})]}),c.jsxs(Q,{type:"button",onClick:()=>p(!g),disabled:s,className:`px-2 h-8 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded disabled:opacity-50 ${g?"bg-gray-200 dark:bg-gray-700":""}`,children:["</>",St("editor.toolbar.code_view")]})]}),g?c.jsx(no,{value:i,onChange:N=>u(N.target.value),placeholder:r,disabled:s,className:"w-full p-4 bg-gray-900 text-gray-100 font-mono text-sm focus:outline-none resize-none",style:{minHeight:a}}):c.jsx(W,{ref:d,contentEditable:!s,onInput:N=>u(N.currentTarget.innerHTML),className:"p-4 bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none prose dark:prose-invert max-w-none",style:{minHeight:a},"data-placeholder":r}),c.jsx(xt,{type:"hidden",name:e,value:i}),m&&c.jsx(W,{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/50",children:c.jsxs(W,{className:"bg-white dark:bg-gray-800 rounded-lg p-6 w-96",children:[c.jsx(gn,{className:"text-lg font-medium text-gray-900 dark:text-white mb-4",children:St("edi
Line truncated
|
||||
whitespace-pre-wrap
|
||||
text-gray-900 dark:text-gray-100
|
||||
font-sans
|
||||
${n}
|
||||
`.trim().replace(/\s+/g," ");return d.jsx(W,{className:i,id:a,...s,children:l})}const g={FORBID_TAGS:["script","noscript","iframe","frame","frameset","object","embed","applet","portal","form","input","textarea","select","button","style","link","meta","base","body","head","html","title","svg","math","audio","video","source","track","canvas","details","dialog","plaintext","xmp","listing","marquee","noframes","noembed","template","slot"],FORBID_ATTR:["onerror","onload","onclick","onmouseover","onfocus","onblur","ontoggle","oncanplay","onloadstart","formaction","xlink:href","action"],ALLOW_DATA_ATTR:!0,ADD_ATTR:["target"]},h=k.useMemo(()=>{const i=o?{...g,...o,FORBID_TAGS:[...g.FORBID_TAGS,...o.FORBID_TAGS||[]],FORBID_ATTR:[...g.FORBID_ATTR,...o.FORBID_ATTR||[]]}:g;return hs.sanitize(l,i).replace(/<a\s+([^>]*?)href=["']([^"']+)["']([^>]*?)>/gi,(f,p,m,w)=>(m.startsWith("http://")||m.startsWith("https://"))&&!f.includes("rel=")?`<a ${p}href="${m}"${w} rel="noopener noreferrer">`:f)},[l,o]),c=`
|
||||
`.trim().replace(/\s+/g," ");return c.jsx(W,{className:i,id:a,...s,children:l})}const f={FORBID_TAGS:["script","noscript","iframe","frame","frameset","object","embed","applet","portal","form","input","textarea","select","button","style","link","meta","base","body","head","html","title","svg","math","audio","video","source","track","canvas","details","dialog","plaintext","xmp","listing","marquee","noframes","noembed","template","slot"],FORBID_ATTR:["onerror","onload","onclick","onmouseover","onfocus","onblur","ontoggle","oncanplay","onloadstart","formaction","xlink:href","action"],ALLOW_DATA_ATTR:!0,ADD_ATTR:["target"]},h=k.useMemo(()=>{const i=o?{...f,...o,FORBID_TAGS:[...f.FORBID_TAGS,...o.FORBID_TAGS||[]],FORBID_ATTR:[...f.FORBID_ATTR,...o.FORBID_ATTR||[]]}:f;return wo.sanitize(l,i).replace(/<a\s+([^>]*?)href=["']([^"']+)["']([^>]*?)>/gi,(g,p,m,w)=>(m.startsWith("http://")||m.startsWith("https://"))&&!g.includes("rel=")?`<a ${p}href="${m}"${w} rel="noopener noreferrer">`:g)},[l,o]),d=`
|
||||
prose dark:prose-invert max-w-none
|
||||
prose-p:my-2
|
||||
prose-headings:font-bold prose-headings:mt-6 prose-headings:mb-4
|
||||
@@ -39,7 +39,7 @@ var SirsoftBasic=(function(J,d,k,Jt){"use strict";function Mc(e){const t=Object.
|
||||
[&_tr:hover]:bg-gray-50 dark:[&_tr:hover]:bg-gray-700/50
|
||||
after:content-[''] after:block after:clear-both
|
||||
${n}
|
||||
`.trim().replace(/\s+/g," ");return d.jsx(W,{id:a,className:c,dangerouslySetInnerHTML:{__html:h},...s})},Wf=Object.freeze(Object.defineProperty({__proto__:null,HtmlContent:Yo},Symbol.toStringTag,{value:"Module"})),Xo=()=>window.G7Core,qo=(e,t)=>Xo()?.t?.(e,t)??e,Dl=({content:e="",onChange:t,isHtml:r=!1,onIsHtmlChange:n,rows:o=15,placeholder:a="",label:s,showHtmlModeToggle:l=!0,contentClassName:g="",purifyConfig:h,className:c="",name:i="content",htmlFieldName:u="content_mode",readOnly:f=!1,id:p,editorAttrs:m})=>{const[w,x]=k.useState(r),[A,v]=k.useState(e),[E,O]=k.useState(!1);k.useEffect(()=>{x(r)},[r]),k.useEffect(()=>{v(e)},[e]);const _=k.useCallback(y=>{const N=y.target.value;if(v(N),t){const M=Xo()?.createChangeEvent?.({value:N,name:i,type:"textarea"})??{target:{name:i,value:N}};t(M)}},[t,i]),I=k.useCallback(y=>{const N=y.target.checked;if(x(N),N||O(!1),n){const M=Xo()?.createChangeEvent?.({checked:N,name:u,type:"checkbox"})??{target:{name:u,checked:N}};n(M)}},[n,u]),C=k.useCallback(()=>{O(y=>!y)},[]);return d.jsxs(W,{className:`space-y-2 ${c}`,id:p,...m,children:[d.jsxs(W,{className:"flex items-center justify-between",children:[s&&d.jsx(yr,{className:"block text-sm font-medium text-gray-500 dark:text-gray-400",children:s}),d.jsxs(W,{className:"flex items-center gap-3",children:[w&&!f&&d.jsx(Q,{type:"button",onClick:C,className:`px-3 py-1.5 text-xs font-bold rounded-lg focus:outline-none focus:ring-2 ${E?"text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-600 border border-gray-300 dark:border-gray-500 hover:bg-gray-300 dark:hover:bg-gray-500 focus:ring-gray-400 dark:focus:ring-gray-500":"text-blue-600 dark:text-blue-400 bg-white dark:bg-gray-700 border border-blue-300 dark:border-blue-600 hover:bg-blue-50 dark:hover:bg-gray-600 focus:ring-blue-500 dark:focus:ring-blue-600"}`,children:qo(E?"common.preview_off":"common.preview")}),l&&d.jsxs(yr,{className:"flex items-center gap-2 p-2 cursor-pointer",children:[d.jsx(xt,{type:"checkbox",name:u,checked:w,onChange:I,disabled:f,className:"w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"}),d.jsx(W,{className:"text-sm font-medium text-gray-700 dark:text-gray-300",children:qo("common.html_mode")})]})]})]}),!E&&d.jsx(no,{name:i,value:A,onChange:_,placeholder:a,rows:o,readOnly:f,className:`block w-full rounded-lg border px-3 py-2 text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-1 disabled:opacity-50 disabled:cursor-not-allowed ${w?"font-mono bg-white dark:bg-gray-800 border-blue-300 dark:border-blue-600 text-gray-800 dark:text-gray-200 focus:border-blue-500 focus:ring-blue-500":"bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white focus:border-blue-500 focus:ring-blue-500"}`}),E&&d.jsx(W,{className:"p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 min-h-[400px]",children:d.jsx(Yo,{content:A,isHtml:!0,className:g||"prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100",purifyConfig:h})})]})},Kf=Object.freeze(Object.defineProperty({__proto__:null,HtmlEditor:Dl},Symbol.toStringTag,{value:"Module"})),Ll=e=>window.G7Core?.t?.(e)??e,Zo=({maxHeight:e=500,expandText:t,collapseText:r,className:n="",children:o,id:a,editorAttrs:s})=>{const[l,g]=k.useState(!1),[h,c]=k.useState(!1),i=k.useRef(null),u=k.useCallback(()=>{if(i.current){const w=i.current.scrollHeight;c(w>e)}},[e]);k.useEffect(()=>{u();const w=i.current;if(!w)return;const x=w.querySelectorAll("img");x.forEach(v=>{v.complete||v.addEventListener("load",u)});let A=null;return typeof ResizeObserver<"u"&&(A=new ResizeObserver(u),A.observe(w)),()=>{x.forEach(v=>{v.removeEventListener("load",u)}),A?.disconnect()}},[u,o]);const f=()=>{g(w=>!w)},p=t||Ll("sirsoft-ecommerce.shop.product.expand_detail"),m=r||Ll("sirsoft-ecommerce.shop.product.collapse_detail");return d.jsxs(W,{className:n,id:a,...s,children:[d.jsxs(W,{className:"relative",children:[d.jsx(W,{ref:i,className:!l&&h?"overflow-hidden transition-[max-height] duration-300 ease-in-out":"transition-[max-height] duration-300 ease-in-out",style:!l&&h?{maxHeight:`${e}px`}:void 0,children:o}),!l&&h&&d.jsx(W,{className:"absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-white dark:from-gray-900 to-transparent pointer-events-none"})]}),h&&d.jsxs(Q,{type:"button",onClick:f,className:"flex items-center justify-center w-full py-3.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer gap-1.5",children:[d.jsx(te,{className:"text-sm font-semibold text-gray-600 dark:text-gray-400",children:l?m:p}),d.jsx(de,{name:l?"chevron-up":"chevron-down",size:"sm"})]})]})},Vf=Object.freeze(Object.defineProperty({__proto__:null,ExpandableContent:Zo,default:Zo},Symbol.toStringTag,{value:"Module"})),Yf=(e,t)=>window.G7Core?.t?.(e,t)??e
Line truncated
|
||||
`.trim().replace(/\s+/g," ");return c.jsx(W,{id:a,className:d,dangerouslySetInnerHTML:{__html:h},...s})},Xf=Object.freeze(Object.defineProperty({__proto__:null,HtmlContent:Zo},Symbol.toStringTag,{value:"Module"})),qo=()=>window.G7Core,Qo=(e,t)=>qo()?.t?.(e,t)??e,Ll=({content:e="",onChange:t,isHtml:r=!1,onIsHtmlChange:n,rows:o=15,placeholder:a="",label:s,showHtmlModeToggle:l=!0,contentClassName:f="",purifyConfig:h,className:d="",name:i="content",htmlFieldName:u="content_mode",readOnly:g=!1,id:p,editorAttrs:m})=>{const[w,x]=k.useState(r),[A,v]=k.useState(e),[E,O]=k.useState(!1);k.useEffect(()=>{x(r)},[r]),k.useEffect(()=>{v(e)},[e]);const _=k.useCallback(y=>{const N=y.target.value;if(v(N),t){const M=qo()?.createChangeEvent?.({value:N,name:i,type:"textarea"})??{target:{name:i,value:N}};t(M)}},[t,i]),I=k.useCallback(y=>{const N=y.target.checked;if(x(N),N||O(!1),n){const M=qo()?.createChangeEvent?.({checked:N,name:u,type:"checkbox"})??{target:{name:u,checked:N}};n(M)}},[n,u]),C=k.useCallback(()=>{O(y=>!y)},[]);return c.jsxs(W,{className:`space-y-2 ${d}`,id:p,...m,children:[c.jsxs(W,{className:"flex items-center justify-between",children:[s&&c.jsx(yr,{className:"block text-sm font-medium text-gray-500 dark:text-gray-400",children:s}),c.jsxs(W,{className:"flex items-center gap-3",children:[w&&!g&&c.jsx(Q,{type:"button",onClick:C,className:`px-3 py-1.5 text-xs font-bold rounded-lg focus:outline-none focus:ring-2 ${E?"text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-600 border border-gray-300 dark:border-gray-500 hover:bg-gray-300 dark:hover:bg-gray-500 focus:ring-gray-400 dark:focus:ring-gray-500":"text-blue-600 dark:text-blue-400 bg-white dark:bg-gray-700 border border-blue-300 dark:border-blue-600 hover:bg-blue-50 dark:hover:bg-gray-600 focus:ring-blue-500 dark:focus:ring-blue-600"}`,children:Qo(E?"common.preview_off":"common.preview")}),l&&c.jsxs(yr,{className:"flex items-center gap-2 p-2 cursor-pointer",children:[c.jsx(xt,{type:"checkbox",name:u,checked:w,onChange:I,disabled:g,className:"w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"}),c.jsx(W,{className:"text-sm font-medium text-gray-700 dark:text-gray-300",children:Qo("common.html_mode")})]})]})]}),!E&&c.jsx(no,{name:i,value:A,onChange:_,placeholder:a,rows:o,readOnly:g,className:`block w-full rounded-lg border px-3 py-2 text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-1 disabled:opacity-50 disabled:cursor-not-allowed ${w?"font-mono bg-white dark:bg-gray-800 border-blue-300 dark:border-blue-600 text-gray-800 dark:text-gray-200 focus:border-blue-500 focus:ring-blue-500":"bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white focus:border-blue-500 focus:ring-blue-500"}`}),E&&c.jsx(W,{className:"p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 min-h-[400px]",children:c.jsx(Zo,{content:A,isHtml:!0,className:f||"prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100",purifyConfig:h})})]})},Zf=Object.freeze(Object.defineProperty({__proto__:null,HtmlEditor:Ll},Symbol.toStringTag,{value:"Module"})),Pl=e=>window.G7Core?.t?.(e)??e,Jo=({maxHeight:e=500,expandText:t,collapseText:r,className:n="",children:o,id:a,editorAttrs:s})=>{const[l,f]=k.useState(!1),[h,d]=k.useState(!1),i=k.useRef(null),u=k.useCallback(()=>{if(i.current){const w=i.current.scrollHeight;d(w>e)}},[e]);k.useEffect(()=>{u();const w=i.current;if(!w)return;const x=w.querySelectorAll("img");x.forEach(v=>{v.complete||v.addEventListener("load",u)});let A=null;return typeof ResizeObserver<"u"&&(A=new ResizeObserver(u),A.observe(w)),()=>{x.forEach(v=>{v.removeEventListener("load",u)}),A?.disconnect()}},[u,o]);const g=()=>{f(w=>!w)},p=t||Pl("sirsoft-ecommerce.shop.product.expand_detail"),m=r||Pl("sirsoft-ecommerce.shop.product.collapse_detail");return c.jsxs(W,{className:n,id:a,...s,children:[c.jsxs(W,{className:"relative",children:[c.jsx(W,{ref:i,className:!l&&h?"overflow-hidden transition-[max-height] duration-300 ease-in-out":"transition-[max-height] duration-300 ease-in-out",style:!l&&h?{maxHeight:`${e}px`}:void 0,children:o}),!l&&h&&c.jsx(W,{className:"absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-white dark:from-gray-900 to-transparent pointer-events-none"})]}),h&&c.jsxs(Q,{type:"button",onClick:g,className:"flex items-center justify-center w-full py-3.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer gap-1.5",children:[c.jsx(ee,{className:"text-sm font-semibold text-gray-600 dark:text-gray-400",children:l?m:p}),c.jsx(de,{name:l?"chevron-up":"chevron-down",size:"sm"})]})]})},qf=Object.freeze(Object.defineProperty({__proto__:null,ExpandableContent:Jo,default:Jo},Symbol.toStringTag,{value:"Module"})),Qf=(e,t)=>window.G7Core?.t?.(e,t)??e
Line truncated
|
||||
let scriptImported = false
|
||||
self.addEventListener('message', async (e) => {
|
||||
const { file, id, imageCompressionLibUrl, options } = e.data
|
||||
@@ -58,20 +58,20 @@ self.addEventListener('message', async (e) => {
|
||||
self.postMessage({ error: e.message + '\\n' + e.stack, id })
|
||||
}
|
||||
})
|
||||
`;let ra;function og(e,t){return new Promise(((r,n)=>{ra||(ra=(function(s){const l=[];return l.push(s),URL.createObjectURL(new Blob(l))})(ng));const o=new Worker(ra);o.addEventListener("message",(function(s){if(t.signal&&t.signal.aborted)o.terminate();else if(s.data.progress===void 0){if(s.data.error)return n(new Error(s.data.error)),void o.terminate();r(s.data.file),o.terminate()}else t.onProgress(s.data.progress)})),o.addEventListener("error",n),t.signal&&t.signal.addEventListener("abort",(()=>{n(t.signal.reason),o.terminate()})),o.postMessage({file:e,imageCompressionLibUrl:t.libURL,options:{...t,onProgress:void 0,signal:void 0}})}))}function ct(e,t){return new Promise((function(r,n){let o,a,s,l,g,h;if(o={...t},s=0,{onProgress:l}=o,o.maxSizeMB=o.maxSizeMB||Number.POSITIVE_INFINITY,g=typeof o.useWebWorker!="boolean"||o.useWebWorker,delete o.useWebWorker,o.onProgress=f=>{s=f,typeof l=="function"&&l(s)},!(e instanceof Blob||e instanceof rg))return n(new Error("The file given is not an instance of Blob or File"));if(!/^image/.test(e.type))return n(new Error("The file given is not an image"));if(h=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope,!g||typeof Worker!="function"||h)return Xl(e,o).then(function(f){try{return a=f,u.call(this)}catch(p){return n(p)}}.bind(this),n);var c=function(){try{return u.call(this)}catch(f){return n(f)}}.bind(this),i=function(f){try{return Xl(e,o).then((function(p){try{return a=p,c()}catch(m){return n(m)}}),n)}catch(p){return n(p)}};try{return o.libURL=o.libURL||"https://cdn.jsdelivr.net/npm/browser-image-compression@2.0.2/dist/browser-image-compression.js",og(e,o).then((function(f){try{return a=f,c()}catch{return i()}}),i)}catch{i()}function u(){try{a.name=e.name,a.lastModified=e.lastModified}catch{}try{o.preserveExif&&e.type==="image/jpeg"&&(!o.fileType||o.fileType&&o.fileType===e.type)&&(a=Ul(e,a))}catch{}return r(a)}}))}ct.getDataUrlFromFile=Gl,ct.getFilefromDataUrl=ta,ct.loadImage=jl,ct.drawImageInCanvas=Wl,ct.drawFileInCanvas=Pn,ct.canvasToFile=Fn,ct.getExifOrientation=Kl,ct.handleMaxWidthOrHeight=Vl,ct.followExifOrientation=Yl,ct.cleanupCanvasMemory=Vt,ct.isAutoOrientationInBrowser=Nr,ct.approximateBelowMaximumCanvasSizeOfBrowser=Bl,ct.copyExifWithoutOrientation=Ul,ct.getBrowserName=fr,ct.version="2.0.2";const ag=window.G7Core,Un=e=>{const t=["B","KB","MB","GB"];let r=0,n=e;for(;n>=1024&&r<t.length-1;)n/=1024,r++;return`${n.toFixed(2)} ${t[r]}`},ql=e=>{if(e&&typeof e=="object"){const t=e;if(t.response?.data?.message)return t.response.data.message;if(t.response?.data?.errors){const r=Object.values(t.response.data.errors)[0];if(Array.isArray(r)&&r[0])return r[0]}if(t.message)return t.message}return e instanceof Error?e.message:"업로드 실패"},Zl=e=>e?e.startsWith("image/")?"fa-file-image":e.startsWith("video/")?"fa-file-video":e.startsWith("audio/")?"fa-file-audio":e==="application/pdf"?"fa-file-pdf":e.includes("word")||e.includes("document")?"fa-file-word":e.includes("excel")||e.includes("spreadsheet")?"fa-file-excel":e.includes("zip")||e.includes("compressed")?"fa-file-zipper":"fa-file":"fa-file",Re=(e,t)=>ag?.t?.(e,t)??e,gr=window.G7Core;function Ql(e){const{attachmentableType:t,attachmentableId:r,collection:n,maxFiles:o,maxSize:a,maxConcurrentUploads:s,accept:l,imageCompression:g,roleIds:h,autoUpload:c,initialFiles:i,endpoints:u,onFilesChange:f,onUploadComplete:p,onUploadError:m,onRemove:w,onReorder:x,uploadTriggerEvent:A,confirmBeforeRemove:v,uploadParams:E}=e,[O,_]=k.useState(i),[I,C]=k.useState([]),[y,N]=k.useState(!1),M=k.useRef(null),P=k.useRef(0),D=k.useRef(new Set),L=k.useRef(new Map),[$,S]=k.useState(!1),[b,U]=k.useState(null),[F,G]=k.useState(!1),Y=k.useRef(!1),[B,V]=k.useState([]),j=k.useRef(i),[H,q]=k.useState(!1),[ue,ee]=k.useState(0),fe=k.useRef(0),[R,ke]=k.useState(new Map),me=k.useRef(new Map);k.useEffect(()=>{f?.(I)},[I,f]);const Ge=k.useMemo(()=>{const X=j.current;return X===i?X:X.length!==i.length?(j.current=i,i):X.length===0&&i.length===0?X:i.some((ie,le)=>ie.id!==X[le]?.id)?(j.current=i,i):X},[i]);k.useEffect(()=>{if(Y.current)return;const X=Ge.filter(le=>!D.current.has(le.id)&&(!le.hash||!D.current.has(le.hash))),se=new Set(X.map(le=>le.id)),ie=Array.from(L.current.values()).filter(le=>!se.has(le.id)&&!D.current.has(le.id));_([...X,...ie])},[Ge]);const De=k.useCallback(async X=>{const se={maxSizeMB:g?.maxSizeMB??1,maxWidthOrHeight:g?.maxWidthOrHeight??1920,useWebWorker:!0};return await ct(X,se)},[g]),ye=k.useCallback(async X=>{const se=O.length+I.length,ie=Math.max(0,o-se),le=Array.from(X).slice(0,ie);if(X.length>ie){const ne=Array.from(X).slice(ie),Te=Re("attachment.upload_limit_exceeded",{max:o});m?.(Te,ne[0]??X[0])}for(const ne of le){if(ne.size>a*1024*1024){m?.(`[${ne.name}] 파일 크기(${Un(ne.size)})가 최대 허용 크기(${a}MB)를 초과합니다.`,ne);continue}if(l){const ve="."+ne.name.split(".").pop()?.toLowerCase();if(!l.split(",").map($e=>$e.trim().toLowerCase()).some($e=>{if($e.includes("/")){if($e.endsWith("/*"
Line truncated
|
||||
`;let oa;function ig(e,t){return new Promise(((r,n)=>{oa||(oa=(function(s){const l=[];return l.push(s),URL.createObjectURL(new Blob(l))})(lg));const o=new Worker(oa);o.addEventListener("message",(function(s){if(t.signal&&t.signal.aborted)o.terminate();else if(s.data.progress===void 0){if(s.data.error)return n(new Error(s.data.error)),void o.terminate();r(s.data.file),o.terminate()}else t.onProgress(s.data.progress)})),o.addEventListener("error",n),t.signal&&t.signal.addEventListener("abort",(()=>{n(t.signal.reason),o.terminate()})),o.postMessage({file:e,imageCompressionLibUrl:t.libURL,options:{...t,onProgress:void 0,signal:void 0}})}))}function ct(e,t){return new Promise((function(r,n){let o,a,s,l,f,h;if(o={...t},s=0,{onProgress:l}=o,o.maxSizeMB=o.maxSizeMB||Number.POSITIVE_INFINITY,f=typeof o.useWebWorker!="boolean"||o.useWebWorker,delete o.useWebWorker,o.onProgress=g=>{s=g,typeof l=="function"&&l(s)},!(e instanceof Blob||e instanceof sg))return n(new Error("The file given is not an instance of Blob or File"));if(!/^image/.test(e.type))return n(new Error("The file given is not an image"));if(h=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope,!f||typeof Worker!="function"||h)return Zl(e,o).then(function(g){try{return a=g,u.call(this)}catch(p){return n(p)}}.bind(this),n);var d=function(){try{return u.call(this)}catch(g){return n(g)}}.bind(this),i=function(g){try{return Zl(e,o).then((function(p){try{return a=p,d()}catch(m){return n(m)}}),n)}catch(p){return n(p)}};try{return o.libURL=o.libURL||"https://cdn.jsdelivr.net/npm/browser-image-compression@2.0.2/dist/browser-image-compression.js",ig(e,o).then((function(g){try{return a=g,d()}catch{return i()}}),i)}catch{i()}function u(){try{a.name=e.name,a.lastModified=e.lastModified}catch{}try{o.preserveExif&&e.type==="image/jpeg"&&(!o.fileType||o.fileType&&o.fileType===e.type)&&(a=Ul(e,a))}catch{}return r(a)}}))}ct.getDataUrlFromFile=jl,ct.getFilefromDataUrl=na,ct.loadImage=Bl,ct.drawImageInCanvas=Kl,ct.drawFileInCanvas=Pn,ct.canvasToFile=Fn,ct.getExifOrientation=Vl,ct.handleMaxWidthOrHeight=Yl,ct.followExifOrientation=Xl,ct.cleanupCanvasMemory=Vt,ct.isAutoOrientationInBrowser=Nr,ct.approximateBelowMaximumCanvasSizeOfBrowser=Wl,ct.copyExifWithoutOrientation=Ul,ct.getBrowserName=fr,ct.version="2.0.2";const cg=window.G7Core,$n=e=>{const t=["B","KB","MB","GB"];let r=0,n=e;for(;n>=1024&&r<t.length-1;)n/=1024,r++;return`${n.toFixed(2)} ${t[r]}`},ql=e=>{if(e&&typeof e=="object"){const t=e;if(t.response?.data?.message)return t.response.data.message;if(t.response?.data?.errors){const r=Object.values(t.response.data.errors)[0];if(Array.isArray(r)&&r[0])return r[0]}if(t.message)return t.message}return e instanceof Error?e.message:"업로드 실패"},Ql=e=>e?e.startsWith("image/")?"fa-file-image":e.startsWith("video/")?"fa-file-video":e.startsWith("audio/")?"fa-file-audio":e==="application/pdf"?"fa-file-pdf":e.includes("word")||e.includes("document")?"fa-file-word":e.includes("excel")||e.includes("spreadsheet")?"fa-file-excel":e.includes("zip")||e.includes("compressed")?"fa-file-zipper":"fa-file":"fa-file",Re=(e,t)=>cg?.t?.(e,t)??e,gr=window.G7Core;function Jl(e){const{attachmentableType:t,attachmentableId:r,collection:n,maxFiles:o,maxSize:a,maxConcurrentUploads:s,accept:l,imageCompression:f,roleIds:h,autoUpload:d,initialFiles:i,endpoints:u,onFilesChange:g,onUploadComplete:p,onUploadError:m,onRemove:w,onReorder:x,uploadTriggerEvent:A,confirmBeforeRemove:v,uploadParams:E}=e,[O,_]=k.useState(i),[I,C]=k.useState([]),[y,N]=k.useState(!1),M=k.useRef(null),P=k.useRef(0),D=k.useRef(new Set),L=k.useRef(new Map),[U,S]=k.useState(!1),[b,$]=k.useState(null),[F,G]=k.useState(!1),Y=k.useRef(!1),[B,V]=k.useState([]),j=k.useRef(i),[H,Z]=k.useState(!1),[ue,te]=k.useState(0),fe=k.useRef(0),[R,ke]=k.useState(new Map),me=k.useRef(new Map);k.useEffect(()=>{g?.(I)},[I,g]);const Ge=k.useMemo(()=>{const X=j.current;return X===i?X:X.length!==i.length?(j.current=i,i):X.length===0&&i.length===0?X:i.some((ie,le)=>ie.id!==X[le]?.id)?(j.current=i,i):X},[i]);k.useEffect(()=>{if(Y.current)return;const X=Ge.filter(le=>!D.current.has(le.id)&&(!le.hash||!D.current.has(le.hash))),se=new Set(X.map(le=>le.id)),ie=Array.from(L.current.values()).filter(le=>!se.has(le.id)&&!D.current.has(le.id));_([...X,...ie])},[Ge]);const De=k.useCallback(async X=>{const se={maxSizeMB:f?.maxSizeMB??1,maxWidthOrHeight:f?.maxWidthOrHeight??1920,useWebWorker:!0};return await ct(X,se)},[f]),ye=k.useCallback(async X=>{const se=O.length+I.length,ie=Math.max(0,o-se),le=Array.from(X).slice(0,ie);if(X.length>ie){const ne=Array.from(X).slice(ie),Te=Re("attachment.upload_limit_exceeded",{max:o});m?.(Te,ne[0]??X[0])}for(const ne of le){if(ne.size>a*1024*1024){m?.(`[${ne.name}] 파일 크기(${$n(ne.size)})가 최대 허용 크기(${a}MB)를 초과합니다.`,ne);continue}if(l){const ve="."+ne.name.split(".").pop()?.toLowerCase();if(!l.split(",").map(Ue=>Ue.trim().toLowerCase()).some(Ue=>{if(Ue.includes("/")){if(Ue.endsWith("/*"
Line truncated
|
||||
relative border-2 border-dashed rounded-lg transition-colors
|
||||
${e?"border-blue-500 bg-blue-50 dark:bg-blue-900/20":"border-gray-300 dark:border-gray-600"}
|
||||
`,onDragOver:h=>{h.preventDefault(),t&&n(!0)},onDragLeave:()=>n(!1),onDrop:h=>{h.preventDefault(),n(!1),t&&r(h.dataTransfer.files)},children:[d.jsx("input",{ref:o,type:"file",multiple:!0,accept:a,onChange:h=>h.target.files&&r(h.target.files),className:"hidden"}),!g&&d.jsxs(W,{className:"p-8 text-center cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors",onClick:()=>o.current?.click(),children:[d.jsx(Ct,{className:"fa-solid fa-cloud-arrow-up text-4xl text-gray-400 dark:text-gray-500 mb-4"}),d.jsx(Dt,{className:"text-sm text-gray-600 dark:text-gray-400",children:Re("attachment.drop_or_click")}),d.jsxs(Dt,{className:"text-xs text-gray-500 dark:text-gray-500 mt-2",children:[Re("attachment.upload_limit",{maxFiles:s,maxSize:l}),a&&` (${a})`]})]}),g]});function sg(){for(var e=arguments.length,t=new Array(e),r=0;r<e;r++)t[r]=arguments[r];return k.useMemo(()=>n=>{t.forEach(o=>o(n))},t)}const $n=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function Ar(e){const t=Object.prototype.toString.call(e);return t==="[object Window]"||t==="[object global]"}function na(e){return"nodeType"in e}function yt(e){var t,r;return e?Ar(e)?e:na(e)&&(t=(r=e.ownerDocument)==null?void 0:r.defaultView)!=null?t:window:window}function oa(e){const{Document:t}=yt(e);return e instanceof t}function Qr(e){return Ar(e)?!1:e instanceof yt(e).HTMLElement}function Rl(e){return e instanceof yt(e).SVGElement}function Mr(e){return e?Ar(e)?e.document:na(e)?oa(e)?e:Qr(e)||Rl(e)?e.ownerDocument:document:document:document}const Yt=$n?k.useLayoutEffect:k.useEffect;function aa(e){const t=k.useRef(e);return Yt(()=>{t.current=e}),k.useCallback(function(){for(var r=arguments.length,n=new Array(r),o=0;o<r;o++)n[o]=arguments[o];return t.current==null?void 0:t.current(...n)},[])}function lg(){const e=k.useRef(null),t=k.useCallback((n,o)=>{e.current=setInterval(n,o)},[]),r=k.useCallback(()=>{e.current!==null&&(clearInterval(e.current),e.current=null)},[]);return[t,r]}function Jr(e,t){t===void 0&&(t=[e]);const r=k.useRef(e);return Yt(()=>{r.current!==e&&(r.current=e)},t),r}function Rr(e,t){const r=k.useRef();return k.useMemo(()=>{const n=e(r.current);return r.current=n,n},[...t])}function zn(e){const t=aa(e),r=k.useRef(null),n=k.useCallback(o=>{o!==r.current&&t?.(o,r.current),r.current=o},[]);return[r,n]}function sa(e){const t=k.useRef();return k.useEffect(()=>{t.current=e},[e]),t.current}let la={};function en(e,t){return k.useMemo(()=>{if(t)return t;const r=la[e]==null?0:la[e]+1;return la[e]=r,e+"-"+r},[e,t])}function ei(e){return function(t){for(var r=arguments.length,n=new Array(r>1?r-1:0),o=1;o<r;o++)n[o-1]=arguments[o];return n.reduce((a,s)=>{const l=Object.entries(s);for(const[g,h]of l){const c=a[g];c!=null&&(a[g]=c+e*h)}return a},{...t})}}const Or=ei(1),tn=ei(-1);function ig(e){return"clientX"in e&&"clientY"in e}function ia(e){if(!e)return!1;const{KeyboardEvent:t}=yt(e.target);return t&&e instanceof t}function cg(e){if(!e)return!1;const{TouchEvent:t}=yt(e.target);return t&&e instanceof t}function ca(e){if(cg(e)){if(e.touches&&e.touches.length){const{clientX:t,clientY:r}=e.touches[0];return{x:t,y:r}}else if(e.changedTouches&&e.changedTouches.length){const{clientX:t,clientY:r}=e.changedTouches[0];return{x:t,y:r}}}return ig(e)?{x:e.clientX,y:e.clientY}:null}const rn=Object.freeze({Translate:{toString(e){if(!e)return;const{x:t,y:r}=e;return"translate3d("+(t?Math.round(t):0)+"px, "+(r?Math.round(r):0)+"px, 0)"}},Scale:{toString(e){if(!e)return;const{scaleX:t,scaleY:r}=e;return"scaleX("+t+") scaleY("+r+")"}},Transform:{toString(e){if(e)return[rn.Translate.toString(e),rn.Scale.toString(e)].join(" ")}},Transition:{toString(e){let{property:t,duration:r,easing:n}=e;return t+" "+r+"ms "+n}}}),ti="a,frame,iframe,input:not([type=hidden]):not(:disabled),select:not(:disabled),textarea:not(:disabled),button:not(:disabled),*[tabindex]";function dg(e){return e.matches(ti)?e:e.querySelector(ti)}const ug={display:"none"};function fg(e){let{id:t,value:r}=e;return k.createElement("div",{id:t,style:ug},r)}function gg(e){let{id:t,announcement:r,ariaLiveType:n="assertive"}=e;const o={position:"fixed",top:0,left:0,width:1,height:1,margin:-1,border:0,padding:0,overflow:"hidden",clip:"rect(0 0 0 0)",clipPath:"inset(100%)",whiteSpace:"nowrap"};return k.createElement("div",{id:t,style:o,role:"status","aria-live":n,"aria-atomic":!0},r)}function hg(){const[e,t]=k.useState("");return{announce:k.useCallback(n=>{n!=null&&t(n)},[]),announcement:e}}const ri=k.createContext(null);function pg(e){const t=k.useContext(ri);k.useEffect(()=>{if(!t)throw new Error("useDndMonitor must be used within a children of <DndContext>");return t(e)},[e,t])}function mg(){const[e]=k.useState(()=>new Set),t=k.useCallback(n=>(e.add(n),()=>e.delete(n)),[e]);return[k.useCallback(n=>{let{type:o,event:a}=n;e.forEach(s=>{var l;return(l=s[o])==null?void 0:l.call(s,a)})},[e]),t]}const bg={draggable:`
|
||||
`,onDragOver:h=>{h.preventDefault(),t&&n(!0)},onDragLeave:()=>n(!1),onDrop:h=>{h.preventDefault(),n(!1),t&&r(h.dataTransfer.files)},children:[c.jsx("input",{ref:o,type:"file",multiple:!0,accept:a,onChange:h=>h.target.files&&r(h.target.files),className:"hidden"}),!f&&c.jsxs(W,{className:"p-8 text-center cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors",onClick:()=>o.current?.click(),children:[c.jsx(Ct,{className:"fa-solid fa-cloud-arrow-up text-4xl text-gray-400 dark:text-gray-500 mb-4"}),c.jsx(Dt,{className:"text-sm text-gray-600 dark:text-gray-400",children:Re("attachment.drop_or_click")}),c.jsxs(Dt,{className:"text-xs text-gray-500 dark:text-gray-500 mt-2",children:[Re("attachment.upload_limit",{maxFiles:s,maxSize:l}),a&&` (${a})`]})]}),f]});function dg(){for(var e=arguments.length,t=new Array(e),r=0;r<e;r++)t[r]=arguments[r];return k.useMemo(()=>n=>{t.forEach(o=>o(n))},t)}const Un=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function Ar(e){const t=Object.prototype.toString.call(e);return t==="[object Window]"||t==="[object global]"}function aa(e){return"nodeType"in e}function yt(e){var t,r;return e?Ar(e)?e:aa(e)&&(t=(r=e.ownerDocument)==null?void 0:r.defaultView)!=null?t:window:window}function sa(e){const{Document:t}=yt(e);return e instanceof t}function Qr(e){return Ar(e)?!1:e instanceof yt(e).HTMLElement}function ei(e){return e instanceof yt(e).SVGElement}function Mr(e){return e?Ar(e)?e.document:aa(e)?sa(e)?e:Qr(e)||ei(e)?e.ownerDocument:document:document:document}const Yt=Un?k.useLayoutEffect:k.useEffect;function la(e){const t=k.useRef(e);return Yt(()=>{t.current=e}),k.useCallback(function(){for(var r=arguments.length,n=new Array(r),o=0;o<r;o++)n[o]=arguments[o];return t.current==null?void 0:t.current(...n)},[])}function ug(){const e=k.useRef(null),t=k.useCallback((n,o)=>{e.current=setInterval(n,o)},[]),r=k.useCallback(()=>{e.current!==null&&(clearInterval(e.current),e.current=null)},[]);return[t,r]}function Jr(e,t){t===void 0&&(t=[e]);const r=k.useRef(e);return Yt(()=>{r.current!==e&&(r.current=e)},t),r}function Rr(e,t){const r=k.useRef();return k.useMemo(()=>{const n=e(r.current);return r.current=n,n},[...t])}function zn(e){const t=la(e),r=k.useRef(null),n=k.useCallback(o=>{o!==r.current&&t?.(o,r.current),r.current=o},[]);return[r,n]}function ia(e){const t=k.useRef();return k.useEffect(()=>{t.current=e},[e]),t.current}let ca={};function en(e,t){return k.useMemo(()=>{if(t)return t;const r=ca[e]==null?0:ca[e]+1;return ca[e]=r,e+"-"+r},[e,t])}function ti(e){return function(t){for(var r=arguments.length,n=new Array(r>1?r-1:0),o=1;o<r;o++)n[o-1]=arguments[o];return n.reduce((a,s)=>{const l=Object.entries(s);for(const[f,h]of l){const d=a[f];d!=null&&(a[f]=d+e*h)}return a},{...t})}}const Or=ti(1),tn=ti(-1);function fg(e){return"clientX"in e&&"clientY"in e}function da(e){if(!e)return!1;const{KeyboardEvent:t}=yt(e.target);return t&&e instanceof t}function gg(e){if(!e)return!1;const{TouchEvent:t}=yt(e.target);return t&&e instanceof t}function ua(e){if(gg(e)){if(e.touches&&e.touches.length){const{clientX:t,clientY:r}=e.touches[0];return{x:t,y:r}}else if(e.changedTouches&&e.changedTouches.length){const{clientX:t,clientY:r}=e.changedTouches[0];return{x:t,y:r}}}return fg(e)?{x:e.clientX,y:e.clientY}:null}const rn=Object.freeze({Translate:{toString(e){if(!e)return;const{x:t,y:r}=e;return"translate3d("+(t?Math.round(t):0)+"px, "+(r?Math.round(r):0)+"px, 0)"}},Scale:{toString(e){if(!e)return;const{scaleX:t,scaleY:r}=e;return"scaleX("+t+") scaleY("+r+")"}},Transform:{toString(e){if(e)return[rn.Translate.toString(e),rn.Scale.toString(e)].join(" ")}},Transition:{toString(e){let{property:t,duration:r,easing:n}=e;return t+" "+r+"ms "+n}}}),ri="a,frame,iframe,input:not([type=hidden]):not(:disabled),select:not(:disabled),textarea:not(:disabled),button:not(:disabled),*[tabindex]";function hg(e){return e.matches(ri)?e:e.querySelector(ri)}const pg={display:"none"};function mg(e){let{id:t,value:r}=e;return k.createElement("div",{id:t,style:pg},r)}function bg(e){let{id:t,announcement:r,ariaLiveType:n="assertive"}=e;const o={position:"fixed",top:0,left:0,width:1,height:1,margin:-1,border:0,padding:0,overflow:"hidden",clip:"rect(0 0 0 0)",clipPath:"inset(100%)",whiteSpace:"nowrap"};return k.createElement("div",{id:t,style:o,role:"status","aria-live":n,"aria-atomic":!0},r)}function yg(){const[e,t]=k.useState("");return{announce:k.useCallback(n=>{n!=null&&t(n)},[]),announcement:e}}const ni=k.createContext(null);function vg(e){const t=k.useContext(ni);k.useEffect(()=>{if(!t)throw new Error("useDndMonitor must be used within a children of <DndContext>");return t(e)},[e,t])}function wg(){const[e]=k.useState(()=>new Set),t=k.useCallback(n=>(e.add(n),()=>e.delete(n)),[e]);return[k.useCallback(n=>{let{type:o,event:a}=n;e.forEach(s=>{var l;return(l=s[o])==null?void 0:l.call(s,a)})},[e]),t]}const xg={draggable:`
|
||||
To pick up a draggable item, press the space bar.
|
||||
While dragging, use the arrow keys to move the item.
|
||||
Press space again to drop the item in its new position, or press escape to cancel.
|
||||
`},yg={onDragStart(e){let{active:t}=e;return"Picked up draggable item "+t.id+"."},onDragOver(e){let{active:t,over:r}=e;return r?"Draggable item "+t.id+" was moved over droppable area "+r.id+".":"Draggable item "+t.id+" is no longer over a droppable area."},onDragEnd(e){let{active:t,over:r}=e;return r?"Draggable item "+t.id+" was dropped over droppable area "+r.id:"Draggable item "+t.id+" was dropped."},onDragCancel(e){let{active:t}=e;return"Dragging was cancelled. Draggable item "+t.id+" was dropped."}};function vg(e){let{announcements:t=yg,container:r,hiddenTextDescribedById:n,screenReaderInstructions:o=bg}=e;const{announce:a,announcement:s}=hg(),l=en("DndLiveRegion"),[g,h]=k.useState(!1);if(k.useEffect(()=>{h(!0)},[]),pg(k.useMemo(()=>({onDragStart(i){let{active:u}=i;a(t.onDragStart({active:u}))},onDragMove(i){let{active:u,over:f}=i;t.onDragMove&&a(t.onDragMove({active:u,over:f}))},onDragOver(i){let{active:u,over:f}=i;a(t.onDragOver({active:u,over:f}))},onDragEnd(i){let{active:u,over:f}=i;a(t.onDragEnd({active:u,over:f}))},onDragCancel(i){let{active:u,over:f}=i;a(t.onDragCancel({active:u,over:f}))}}),[a,t])),!g)return null;const c=k.createElement(k.Fragment,null,k.createElement(fg,{id:n,value:o.draggable}),k.createElement(gg,{id:l,announcement:s}));return r?Jt.createPortal(c,r):c}var et;(function(e){e.DragStart="dragStart",e.DragMove="dragMove",e.DragEnd="dragEnd",e.DragCancel="dragCancel",e.DragOver="dragOver",e.RegisterDroppable="registerDroppable",e.SetDroppableDisabled="setDroppableDisabled",e.UnregisterDroppable="unregisterDroppable"})(et||(et={}));function Hn(){}function ni(e,t){return k.useMemo(()=>({sensor:e,options:t??{}}),[e,t])}function wg(){for(var e=arguments.length,t=new Array(e),r=0;r<e;r++)t[r]=arguments[r];return k.useMemo(()=>[...t].filter(n=>n!=null),[...t])}const Gt=Object.freeze({x:0,y:0});function oi(e,t){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function ai(e,t){let{data:{value:r}}=e,{data:{value:n}}=t;return r-n}function xg(e,t){let{data:{value:r}}=e,{data:{value:n}}=t;return n-r}function si(e){let{left:t,top:r,height:n,width:o}=e;return[{x:t,y:r},{x:t+o,y:r},{x:t,y:r+n},{x:t+o,y:r+n}]}function li(e,t){if(!e||e.length===0)return null;const[r]=e;return r[t]}function ii(e,t,r){return t===void 0&&(t=e.left),r===void 0&&(r=e.top),{x:t+e.width*.5,y:r+e.height*.5}}const kg=e=>{let{collisionRect:t,droppableRects:r,droppableContainers:n}=e;const o=ii(t,t.left,t.top),a=[];for(const s of n){const{id:l}=s,g=r.get(l);if(g){const h=oi(ii(g),o);a.push({id:l,data:{droppableContainer:s,value:h}})}}return a.sort(ai)},Cg=e=>{let{collisionRect:t,droppableRects:r,droppableContainers:n}=e;const o=si(t),a=[];for(const s of n){const{id:l}=s,g=r.get(l);if(g){const h=si(g),c=o.reduce((u,f,p)=>u+oi(h[p],f),0),i=Number((c/4).toFixed(4));a.push({id:l,data:{droppableContainer:s,value:i}})}}return a.sort(ai)};function Sg(e,t){const r=Math.max(t.top,e.top),n=Math.max(t.left,e.left),o=Math.min(t.left+t.width,e.left+e.width),a=Math.min(t.top+t.height,e.top+e.height),s=o-n,l=a-r;if(n<o&&r<a){const g=t.width*t.height,h=e.width*e.height,c=s*l,i=c/(g+h-c);return Number(i.toFixed(4))}return 0}const _g=e=>{let{collisionRect:t,droppableRects:r,droppableContainers:n}=e;const o=[];for(const a of n){const{id:s}=a,l=r.get(s);if(l){const g=Sg(l,t);g>0&&o.push({id:s,data:{droppableContainer:a,value:g}})}}return o.sort(xg)};function Eg(e,t,r){return{...e,scaleX:t&&r?t.width/r.width:1,scaleY:t&&r?t.height/r.height:1}}function ci(e,t){return e&&t?{x:e.left-t.left,y:e.top-t.top}:Gt}function Tg(e){return function(r){for(var n=arguments.length,o=new Array(n>1?n-1:0),a=1;a<n;a++)o[a-1]=arguments[a];return o.reduce((s,l)=>({...s,top:s.top+e*l.y,bottom:s.bottom+e*l.y,left:s.left+e*l.x,right:s.right+e*l.x}),{...r})}}const Ng=Tg(1);function Ag(e){if(e.startsWith("matrix3d(")){const t=e.slice(9,-1).split(/, /);return{x:+t[12],y:+t[13],scaleX:+t[0],scaleY:+t[5]}}else if(e.startsWith("matrix(")){const t=e.slice(7,-1).split(/, /);return{x:+t[4],y:+t[5],scaleX:+t[0],scaleY:+t[3]}}return null}function Mg(e,t,r){const n=Ag(t);if(!n)return e;const{scaleX:o,scaleY:a,x:s,y:l}=n,g=e.left-s-(1-o)*parseFloat(r),h=e.top-l-(1-a)*parseFloat(r.slice(r.indexOf(" ")+1)),c=o?e.width/o:e.width,i=a?e.height/a:e.height;return{width:c,height:i,top:h,right:g+c,bottom:h+i,left:g}}const Og={ignoreTransform:!1};function Ir(e,t){t===void 0&&(t=Og);let r=e.getBoundingClientRect();if(t.ignoreTransform){const{transform:h,transformOrigin:c}=yt(e).getComputedStyle(e);h&&(r=Mg(r,h,c))}const{top:n,left:o,width:a,height:s,bottom:l,right:g}=r;return{top:n,left:o,width:a,height:s,bottom:l,right:g}}function di(e){return Ir(e,{ignoreTransform:!0})}function Ig(e){const t=e.innerWidth,r=e.innerHeight;return{top:0,left:0,right:t,bottom:r,width:t,height:r}}function Dg(e,t){return t===void 0&&(t=yt(e).getComputedStyle(e)),t.position==="fixed"}function Lg(e,t){t===void 0&&(t=yt(e).getComputedStyle(e));const r=/(auto|scroll|overlay)/;return["overflow","overflowX","o
Line truncated
|
||||
`},kg={onDragStart(e){let{active:t}=e;return"Picked up draggable item "+t.id+"."},onDragOver(e){let{active:t,over:r}=e;return r?"Draggable item "+t.id+" was moved over droppable area "+r.id+".":"Draggable item "+t.id+" is no longer over a droppable area."},onDragEnd(e){let{active:t,over:r}=e;return r?"Draggable item "+t.id+" was dropped over droppable area "+r.id:"Draggable item "+t.id+" was dropped."},onDragCancel(e){let{active:t}=e;return"Dragging was cancelled. Draggable item "+t.id+" was dropped."}};function Cg(e){let{announcements:t=kg,container:r,hiddenTextDescribedById:n,screenReaderInstructions:o=xg}=e;const{announce:a,announcement:s}=yg(),l=en("DndLiveRegion"),[f,h]=k.useState(!1);if(k.useEffect(()=>{h(!0)},[]),vg(k.useMemo(()=>({onDragStart(i){let{active:u}=i;a(t.onDragStart({active:u}))},onDragMove(i){let{active:u,over:g}=i;t.onDragMove&&a(t.onDragMove({active:u,over:g}))},onDragOver(i){let{active:u,over:g}=i;a(t.onDragOver({active:u,over:g}))},onDragEnd(i){let{active:u,over:g}=i;a(t.onDragEnd({active:u,over:g}))},onDragCancel(i){let{active:u,over:g}=i;a(t.onDragCancel({active:u,over:g}))}}),[a,t])),!f)return null;const d=k.createElement(k.Fragment,null,k.createElement(mg,{id:n,value:o.draggable}),k.createElement(bg,{id:l,announcement:s}));return r?Jt.createPortal(d,r):d}var et;(function(e){e.DragStart="dragStart",e.DragMove="dragMove",e.DragEnd="dragEnd",e.DragCancel="dragCancel",e.DragOver="dragOver",e.RegisterDroppable="registerDroppable",e.SetDroppableDisabled="setDroppableDisabled",e.UnregisterDroppable="unregisterDroppable"})(et||(et={}));function Hn(){}function oi(e,t){return k.useMemo(()=>({sensor:e,options:t??{}}),[e,t])}function Sg(){for(var e=arguments.length,t=new Array(e),r=0;r<e;r++)t[r]=arguments[r];return k.useMemo(()=>[...t].filter(n=>n!=null),[...t])}const Gt=Object.freeze({x:0,y:0});function ai(e,t){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function si(e,t){let{data:{value:r}}=e,{data:{value:n}}=t;return r-n}function _g(e,t){let{data:{value:r}}=e,{data:{value:n}}=t;return n-r}function li(e){let{left:t,top:r,height:n,width:o}=e;return[{x:t,y:r},{x:t+o,y:r},{x:t,y:r+n},{x:t+o,y:r+n}]}function ii(e,t){if(!e||e.length===0)return null;const[r]=e;return r[t]}function ci(e,t,r){return t===void 0&&(t=e.left),r===void 0&&(r=e.top),{x:t+e.width*.5,y:r+e.height*.5}}const Eg=e=>{let{collisionRect:t,droppableRects:r,droppableContainers:n}=e;const o=ci(t,t.left,t.top),a=[];for(const s of n){const{id:l}=s,f=r.get(l);if(f){const h=ai(ci(f),o);a.push({id:l,data:{droppableContainer:s,value:h}})}}return a.sort(si)},Tg=e=>{let{collisionRect:t,droppableRects:r,droppableContainers:n}=e;const o=li(t),a=[];for(const s of n){const{id:l}=s,f=r.get(l);if(f){const h=li(f),d=o.reduce((u,g,p)=>u+ai(h[p],g),0),i=Number((d/4).toFixed(4));a.push({id:l,data:{droppableContainer:s,value:i}})}}return a.sort(si)};function Ng(e,t){const r=Math.max(t.top,e.top),n=Math.max(t.left,e.left),o=Math.min(t.left+t.width,e.left+e.width),a=Math.min(t.top+t.height,e.top+e.height),s=o-n,l=a-r;if(n<o&&r<a){const f=t.width*t.height,h=e.width*e.height,d=s*l,i=d/(f+h-d);return Number(i.toFixed(4))}return 0}const Ag=e=>{let{collisionRect:t,droppableRects:r,droppableContainers:n}=e;const o=[];for(const a of n){const{id:s}=a,l=r.get(s);if(l){const f=Ng(l,t);f>0&&o.push({id:s,data:{droppableContainer:a,value:f}})}}return o.sort(_g)};function Mg(e,t,r){return{...e,scaleX:t&&r?t.width/r.width:1,scaleY:t&&r?t.height/r.height:1}}function di(e,t){return e&&t?{x:e.left-t.left,y:e.top-t.top}:Gt}function Og(e){return function(r){for(var n=arguments.length,o=new Array(n>1?n-1:0),a=1;a<n;a++)o[a-1]=arguments[a];return o.reduce((s,l)=>({...s,top:s.top+e*l.y,bottom:s.bottom+e*l.y,left:s.left+e*l.x,right:s.right+e*l.x}),{...r})}}const Ig=Og(1);function Dg(e){if(e.startsWith("matrix3d(")){const t=e.slice(9,-1).split(/, /);return{x:+t[12],y:+t[13],scaleX:+t[0],scaleY:+t[5]}}else if(e.startsWith("matrix(")){const t=e.slice(7,-1).split(/, /);return{x:+t[4],y:+t[5],scaleX:+t[0],scaleY:+t[3]}}return null}function Lg(e,t,r){const n=Dg(t);if(!n)return e;const{scaleX:o,scaleY:a,x:s,y:l}=n,f=e.left-s-(1-o)*parseFloat(r),h=e.top-l-(1-a)*parseFloat(r.slice(r.indexOf(" ")+1)),d=o?e.width/o:e.width,i=a?e.height/a:e.height;return{width:d,height:i,top:h,right:f+d,bottom:h+i,left:f}}const Pg={ignoreTransform:!1};function Ir(e,t){t===void 0&&(t=Pg);let r=e.getBoundingClientRect();if(t.ignoreTransform){const{transform:h,transformOrigin:d}=yt(e).getComputedStyle(e);h&&(r=Lg(r,h,d))}const{top:n,left:o,width:a,height:s,bottom:l,right:f}=r;return{top:n,left:o,width:a,height:s,bottom:l,right:f}}function ui(e){return Ir(e,{ignoreTransform:!0})}function Fg(e){const t=e.innerWidth,r=e.innerHeight;return{top:0,left:0,right:t,bottom:r,width:t,height:r}}function $g(e,t){return t===void 0&&(t=yt(e).getComputedStyle(e)),t.position==="fixed"}function Ug(e,t){t===void 0&&(t=yt(e).getComputedStyle(e));const r=/(auto|scroll|overlay)/;return["overflow","overflowX","o
Line truncated
|
||||
flex items-center gap-3 p-4 rounded-lg border shadow-lg
|
||||
min-w-[300px] max-w-md
|
||||
transition-all duration-300
|
||||
${o.bg}
|
||||
${r?"opacity-100 translate-y-0":`opacity-0 ${s}`}
|
||||
`,role:"alert",children:[d.jsx(de,{name:a,className:`w-5 h-5 flex-shrink-0 ${o.text}`}),d.jsx(te,{className:`flex-1 text-sm font-medium ${o.text}`,children:e.message}),d.jsx(Q,{onClick:n,className:`flex-shrink-0 p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 ${o.text} focus:outline-none`,"aria-label":Yh("common.close"),children:d.jsx(de,{name:Je.Times,className:"w-4 h-4"})})]})},Qh=Object.freeze(Object.defineProperty({__proto__:null,Toast:xa,default:xa},Symbol.toStringTag,{value:"Module"})),Jh=window.G7Core?.createLogger?.("Comp:PageTransition")??{log:(...e)=>console.log("[Comp:PageTransition]",...e),warn:(...e)=>console.warn("[Comp:PageTransition]",...e),error:(...e)=>console.error("[Comp:PageTransition]",...e)},ka=({className:e="",style:t})=>{const[r,n]=k.useState(!1);return k.useEffect(()=>{const o=window.G7Core?.TransitionManager;if(!o){Jh.warn("TransitionManager를 찾을 수 없습니다.");return}return n(o.getIsPending()),o.subscribe(s=>{n(s)})},[]),r?d.jsxs(W,{className:`fixed top-0 left-0 right-0 z-50 ${e}`,style:t,role:"progressbar","aria-label":"페이지 로딩 중",children:[d.jsx(W,{className:"h-1 bg-gradient-to-r from-blue-500 via-blue-600 to-blue-500 animate-pulse",children:d.jsx(W,{className:"h-full bg-blue-700 animate-[loading_1.5s_ease-in-out_infinite]",style:{width:"30%",animation:"loading 1.5s ease-in-out infinite"}})}),d.jsx("style",{children:`
|
||||
`,role:"alert",children:[c.jsx(de,{name:a,className:`w-5 h-5 flex-shrink-0 ${o.text}`}),c.jsx(ee,{className:`flex-1 text-sm font-medium ${o.text}`,children:e.message}),c.jsx(Q,{onClick:n,className:`flex-shrink-0 p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 ${o.text} focus:outline-none`,"aria-label":Qh("common.close"),children:c.jsx(de,{name:Je.Times,className:"w-4 h-4"})})]})},t0=Object.freeze(Object.defineProperty({__proto__:null,Toast:Ca,default:Ca},Symbol.toStringTag,{value:"Module"})),r0=window.G7Core?.createLogger?.("Comp:PageTransition")??{log:(...e)=>console.log("[Comp:PageTransition]",...e),warn:(...e)=>console.warn("[Comp:PageTransition]",...e),error:(...e)=>console.error("[Comp:PageTransition]",...e)},Sa=({className:e="",style:t})=>{const[r,n]=k.useState(!1);return k.useEffect(()=>{const o=window.G7Core?.TransitionManager;if(!o){r0.warn("TransitionManager를 찾을 수 없습니다.");return}return n(o.getIsPending()),o.subscribe(s=>{n(s)})},[]),r?c.jsxs(W,{className:`fixed top-0 left-0 right-0 z-50 ${e}`,style:t,role:"progressbar","aria-label":"페이지 로딩 중",children:[c.jsx(W,{className:"h-1 bg-gradient-to-r from-blue-500 via-blue-600 to-blue-500 animate-pulse",children:c.jsx(W,{className:"h-full bg-blue-700 animate-[loading_1.5s_ease-in-out_infinite]",style:{width:"30%",animation:"loading 1.5s ease-in-out infinite"}})}),c.jsx("style",{children:`
|
||||
@keyframes loading {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
@@ -83,4 +83,5 @@ self.addEventListener('message', async (e) => {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
`})]}):null},Rh=Object.freeze(Object.defineProperty({__proto__:null,PageTransitionIndicator:ka,default:ka},Symbol.toStringTag,{value:"Module"})),Vi=window.G7Core?.createLogger?.("Comp:PageTransitionBlur")??{log:(...e)=>console.log("[Comp:PageTransitionBlur]",...e),warn:(...e)=>console.warn("[Comp:PageTransitionBlur]",...e)},Ca=({className:e="",style:t})=>{const[r,n]=k.useState(!1);return k.useEffect(()=>{const o=window.G7Core?.TransitionManager;if(!o){Vi.warn("TransitionManager를 찾을 수 없습니다.");return}return n(o.getIsPending()),o.subscribe(s=>{Vi.log("isPending changed:",s),n(s)})},[]),r?d.jsx(W,{className:`fixed inset-0 z-40 bg-white/30 dark:bg-gray-900/30 backdrop-blur-sm transition-opacity duration-150 pointer-events-none ${e}`,style:t,role:"status","aria-label":"페이지 전환 중","data-testid":"page-transition-blur"}):null},e0=Object.freeze(Object.defineProperty({__proto__:null,PageTransitionBlur:Ca,default:Ca},Symbol.toStringTag,{value:"Module"})),Yi={sm:640,md:768,lg:1024,xl:1280,"2xl":1536},Xi=new Set(["hidden","block","inline-block","inline","flex","inline-flex","grid","inline-grid","table","table-row","table-cell","contents","list-item","flow-root"]),t0=new Set(["Div","Flex","Grid","Section","Article","Nav","Aside","Main","Header","Footer","Fragment","Form","Container"]),r0=new Set(["H1","H2","H3","H4","H5","H6","P","Span","Label","A","Strong","Em","Small","Badge"]),n0=new Set(["Input","Textarea","Select","Checkbox","Radio","Toggle","DatePicker","TagInput","SearchBar"]),o0=new Set(["Img","Image","Avatar","AvatarUploader","ImageGallery","ProductImageViewer","FileUploader"]),qi={DataGrid:(e,t)=>d.jsxs("div",{className:"w-full border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden",children:[d.jsx("div",{className:`h-10 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 ${e}`,children:d.jsxs("div",{className:"flex items-center h-full px-4 gap-8",children:[d.jsx("div",{className:"h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"}),d.jsx("div",{className:"h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded"}),d.jsx("div",{className:"h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded"}),d.jsx("div",{className:"h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"})]})}),Array.from({length:t},(r,n)=>d.jsx("div",{className:`h-12 border-b border-gray-100 dark:border-gray-800 ${e}`,style:{animationDelay:`${n*75}ms`},children:d.jsxs("div",{className:"flex items-center h-full px-4 gap-8",children:[d.jsx("div",{className:"h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded"}),d.jsx("div",{className:"h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded"}),d.jsx("div",{className:"h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded"}),d.jsx("div",{className:"h-3 w-14 bg-gray-100 dark:bg-gray-800 rounded"})]})},n))]}),Pagination:e=>d.jsx("div",{className:"flex justify-center gap-1.5 py-3",children:Array.from({length:5},(t,r)=>d.jsx("div",{className:`w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),Tabs:e=>d.jsxs("div",{children:[d.jsx("div",{className:"flex gap-4 border-b border-gray-200 dark:border-gray-700 pb-2 mb-3",children:Array.from({length:3},(t,r)=>d.jsx("div",{className:`h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),d.jsx("div",{className:`h-32 bg-gray-100 dark:bg-gray-800 rounded ${e}`})]}),TabNavigation:e=>d.jsx("div",{className:"flex gap-4 border-b border-gray-200 dark:border-gray-700 pb-2",children:Array.from({length:3},(t,r)=>d.jsx("div",{className:`h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),ProductCard:e=>d.jsxs("div",{className:"rounded-lg overflow-hidden",children:[d.jsx("div",{className:`h-48 w-full bg-gray-200 dark:bg-gray-700 ${e}`}),d.jsxs("div",{className:"p-3 space-y-2",children:[d.jsx("div",{className:`h-3.5 w-3/4 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),d.jsx("div",{className:`h-4 w-1/3 bg-gray-300 dark:bg-gray-600 rounded ${e}`})]})]}),UserInfo:e=>d.jsx("div",{className:`h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),QuantitySelector:e=>d.jsx("div",{className:`h-9 w-28 bg-gray-100 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 ${e}`}),Header:e=>d.jsx("header",{className:"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700",children:d.jsx("div",{className:"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8",children:d.jsxs("div",{className:"flex items-center justify-between h-16",children:[d.jsxs("div",{className:"flex items-center gap-8",children:[d.jsx("div",{className:`h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),d.jsx("div",{className:"hidden lg:flex items-center gap-6",children:Array.from({length:4},(t,r)=>d.jsx("div",{className:`h-3.5 w-16 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))})]}),d.jsx("div",{className:"flex items-center gap-4",children:Array.from({length:3},(t,r)=>d.jsx("div",{className:`h-8 w-8 bg-gray-200 dark:bg-gray-700 rounded-full ${e}`},r))})]})})}),Footer:e=>d.jsx("footer",{className:"bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-
Line truncated
|
||||
`})]}):null},n0=Object.freeze(Object.defineProperty({__proto__:null,PageTransitionIndicator:Sa,default:Sa},Symbol.toStringTag,{value:"Module"})),Yi=window.G7Core?.createLogger?.("Comp:PageTransitionBlur")??{log:(...e)=>console.log("[Comp:PageTransitionBlur]",...e),warn:(...e)=>console.warn("[Comp:PageTransitionBlur]",...e)},_a=({className:e="",style:t})=>{const[r,n]=k.useState(!1);return k.useEffect(()=>{const o=window.G7Core?.TransitionManager;if(!o){Yi.warn("TransitionManager를 찾을 수 없습니다.");return}return n(o.getIsPending()),o.subscribe(s=>{Yi.log("isPending changed:",s),n(s)})},[]),r?c.jsx(W,{className:`fixed inset-0 z-40 bg-white/30 dark:bg-gray-900/30 backdrop-blur-sm transition-opacity duration-150 pointer-events-none ${e}`,style:t,role:"status","aria-label":"페이지 전환 중","data-testid":"page-transition-blur"}):null},o0=Object.freeze(Object.defineProperty({__proto__:null,PageTransitionBlur:_a,default:_a},Symbol.toStringTag,{value:"Module"})),Xi={sm:640,md:768,lg:1024,xl:1280,"2xl":1536},Zi=new Set(["hidden","block","inline-block","inline","flex","inline-flex","grid","inline-grid","table","table-row","table-cell","contents","list-item","flow-root"]),a0=new Set(["Div","Flex","Grid","Section","Article","Nav","Aside","Main","Header","Footer","Fragment","Form","Container"]),s0=new Set(["H1","H2","H3","H4","H5","H6","P","Span","Label","A","Strong","Em","Small","Badge"]),l0=new Set(["Input","Textarea","Select","Checkbox","Radio","Toggle","DatePicker","TagInput","SearchBar"]),i0=new Set(["Img","Image","Avatar","AvatarUploader","ImageGallery","ProductImageViewer","FileUploader"]),qi={DataGrid:(e,t)=>c.jsxs("div",{className:"w-full border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden",children:[c.jsx("div",{className:`h-10 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 ${e}`,children:c.jsxs("div",{className:"flex items-center h-full px-4 gap-8",children:[c.jsx("div",{className:"h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"}),c.jsx("div",{className:"h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded"}),c.jsx("div",{className:"h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded"}),c.jsx("div",{className:"h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"})]})}),Array.from({length:t},(r,n)=>c.jsx("div",{className:`h-12 border-b border-gray-100 dark:border-gray-800 ${e}`,style:{animationDelay:`${n*75}ms`},children:c.jsxs("div",{className:"flex items-center h-full px-4 gap-8",children:[c.jsx("div",{className:"h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded"}),c.jsx("div",{className:"h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded"}),c.jsx("div",{className:"h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded"}),c.jsx("div",{className:"h-3 w-14 bg-gray-100 dark:bg-gray-800 rounded"})]})},n))]}),Pagination:e=>c.jsx("div",{className:"flex justify-center gap-1.5 py-3",children:Array.from({length:5},(t,r)=>c.jsx("div",{className:`w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),Tabs:e=>c.jsxs("div",{children:[c.jsx("div",{className:"flex gap-4 border-b border-gray-200 dark:border-gray-700 pb-2 mb-3",children:Array.from({length:3},(t,r)=>c.jsx("div",{className:`h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),c.jsx("div",{className:`h-32 bg-gray-100 dark:bg-gray-800 rounded ${e}`})]}),TabNavigation:e=>c.jsx("div",{className:"flex gap-4 border-b border-gray-200 dark:border-gray-700 pb-2",children:Array.from({length:3},(t,r)=>c.jsx("div",{className:`h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),ProductCard:e=>c.jsxs("div",{className:"rounded-lg overflow-hidden",children:[c.jsx("div",{className:`h-48 w-full bg-gray-200 dark:bg-gray-700 ${e}`}),c.jsxs("div",{className:"p-3 space-y-2",children:[c.jsx("div",{className:`h-3.5 w-3/4 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),c.jsx("div",{className:`h-4 w-1/3 bg-gray-300 dark:bg-gray-600 rounded ${e}`})]})]}),UserInfo:e=>c.jsx("div",{className:`h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),QuantitySelector:e=>c.jsx("div",{className:`h-9 w-28 bg-gray-100 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 ${e}`}),Header:e=>c.jsx("header",{className:"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700",children:c.jsx("div",{className:"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8",children:c.jsxs("div",{className:"flex items-center justify-between h-16",children:[c.jsxs("div",{className:"flex items-center gap-8",children:[c.jsx("div",{className:`h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),c.jsx("div",{className:"hidden lg:flex items-center gap-6",children:Array.from({length:4},(t,r)=>c.jsx("div",{className:`h-3.5 w-16 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))})]}),c.jsx("div",{className:"flex items-center gap-4",children:Array.from({length:3},(t,r)=>c.jsx("div",{className:`h-8 w-8 bg-gray-200 dark:bg-gray-700 rounded-full ${e}`},r))})]})})}),Footer:e=>c.jsx("footer",{className:"bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-
Line truncated
|
||||
//# sourceMappingURL=components.iife.js.map
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { default as React } from 'react';
|
||||
import { EditorAttrs } from '../../types';
|
||||
/**
|
||||
* 브랜드 마크 데이터
|
||||
*
|
||||
* 결제수단 카탈로그(`_cached_brand_mark`)가 실어 내리는 브랜드 시각정보.
|
||||
* 두 형태 중 하나:
|
||||
* - 색 배지: `{ text: 'N', class: 'bg-green-500 text-white' }`
|
||||
* - 인라인 SVG 로고: `{ svg: '<svg>...</svg>' }`
|
||||
*/
|
||||
export interface BrandMarkData {
|
||||
/** 배지 텍스트 (예: 'N', 'NP', 'K') */
|
||||
text?: string;
|
||||
/** 배지 배경/텍스트 색상 Tailwind 클래스 (예: 'bg-green-500 text-white') */
|
||||
class?: string;
|
||||
/** 인라인 SVG 로고 마크업 문자열 */
|
||||
svg?: string;
|
||||
}
|
||||
export interface BrandMarkProps {
|
||||
/**
|
||||
* 브랜드 마크 데이터 (카탈로그 `_cached_brand_mark`).
|
||||
* 값이 없으면 아무것도 렌더하지 않음(레이아웃이 아이콘 폴백을 그림).
|
||||
*/
|
||||
brandMark?: BrandMarkData | null;
|
||||
/** 마크 컨테이너 크기(px). 기본 32 (배지·SVG 공통) */
|
||||
size?: number;
|
||||
/** 사용자 정의 클래스 (컨테이너에 병합) */
|
||||
className?: string;
|
||||
/** DOM id 속성 (레이아웃 편집기 코어 일괄 ID) */
|
||||
id?: string;
|
||||
/** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */
|
||||
editorAttrs?: EditorAttrs;
|
||||
}
|
||||
/**
|
||||
* 결제 브랜드 마크 컴포넌트
|
||||
*
|
||||
* 결제수단 카탈로그가 내려준 브랜드 시각정보를 데이터 기반으로 렌더한다.
|
||||
* 과거에는 각 PG 플러그인의 체크아웃 DOM 인젝터가 렌더 후 DOM 을 후처리해 마크를
|
||||
* 주입했으나, 시각정보를 카탈로그(`_cached_brand_mark`)로 편입하고 이 컴포넌트가
|
||||
* 직접 그리도록 통일했다.
|
||||
*
|
||||
* - `brandMark.svg` 가 있으면 화이트리스트 sanitize 후 인라인 SVG 로고를 렌더.
|
||||
* - `brandMark.text` 가 있으면 색 배지(text + Tailwind 색상 클래스)를 렌더.
|
||||
* - 둘 다 없으면 null 을 반환해 레이아웃이 아이콘 폴백을 그리게 한다.
|
||||
*
|
||||
* @example
|
||||
* // 색 배지
|
||||
* <BrandMark brandMark={{ text: 'N', class: 'bg-green-500 text-white' }} />
|
||||
*
|
||||
* // SVG 로고
|
||||
* <BrandMark brandMark={{ svg: '<svg>...</svg>' }} />
|
||||
*/
|
||||
export declare const BrandMark: React.FC<BrandMarkProps>;
|
||||
export default BrandMark;
|
||||
@@ -10,6 +10,8 @@ export { default as MobileNav } from './MobileNav';
|
||||
export { NotificationCenter } from './NotificationCenter';
|
||||
export type { NotificationCenterProps, NotificationItem } from './NotificationCenter';
|
||||
export { default as ProductCard } from './ProductCard';
|
||||
export { BrandMark } from './BrandMark';
|
||||
export type { BrandMarkProps, BrandMarkData } from './BrandMark';
|
||||
export { default as ImageGallery } from './ImageGallery';
|
||||
export { default as ProductImageViewer } from './ProductImageViewer';
|
||||
export { default as QuantitySelector } from './QuantitySelector';
|
||||
@@ -56,6 +58,7 @@ export declare const compositeComponents: {
|
||||
SlotContainer: () => Promise<typeof import("./SlotContainer")>;
|
||||
NotificationCenter: () => Promise<typeof import("./NotificationCenter")>;
|
||||
ProductCard: () => Promise<typeof import("./ProductCard")>;
|
||||
BrandMark: () => Promise<typeof import("./BrandMark")>;
|
||||
ImageGallery: () => Promise<typeof import("./ImageGallery")>;
|
||||
ProductImageViewer: () => Promise<typeof import("./ProductImageViewer")>;
|
||||
QuantitySelector: () => Promise<typeof import("./QuantitySelector")>;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 서버측 기기 감지값(appConfig.isIos)의 클라이언트 보정.
|
||||
*
|
||||
* 서버는 User-Agent 만으로 iOS 를 판정하므로, 데스크탑 UA 를 보내는 iPadOS 를
|
||||
* 놓칠 수 있다(iPad 에서 애플페이 버튼 미표시). 부트스트랩 시점에 클라이언트
|
||||
* 신호(maxTouchPoints/userAgentData.platform)로 iOS 를 재판정해, 서버가 false
|
||||
* 로 내려보낸 경우에만 전역 상태 `appConfig.isIos` 를 true 로 보정한다.
|
||||
*
|
||||
* 서버가 이미 true 로 판정했으면 그대로 두어(다운그레이드 방지) fail-safe 를 유지한다.
|
||||
*/
|
||||
/**
|
||||
* 클라이언트 신호로 iOS 기기 여부를 판정합니다.
|
||||
*
|
||||
* 과거 checkoutEasyPayInjector 의 isIosMobileDevice 판정과 동일한 규칙:
|
||||
* - UA/platform 에 iphone|ipad|ipod(+ios) 매칭
|
||||
* - 데스크탑 UA 를 보내는 iPadOS: macintosh UA + maxTouchPoints > 1
|
||||
*
|
||||
* @returns iOS 기기 여부
|
||||
*/
|
||||
export declare function isIosMobileDeviceClient(): boolean;
|
||||
/**
|
||||
* 서버가 내려준 `appConfig.isIos` 를 클라이언트 판정으로 보정합니다.
|
||||
*
|
||||
* 서버 값이 false 이고 클라이언트가 iOS 로 판정한 경우에만 전역 상태를 갱신한다.
|
||||
* (iPadOS 데스크탑 UA 케이스 복구)
|
||||
*/
|
||||
export declare function correctIosDeviceState(): void;
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "credit-card", "className": "w-5 h-5 text-gray-700 dark:text-gray-300" } },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "credit-card", "className": "text-xl text-gray-700 dark:text-gray-300" } },
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H2",
|
||||
@@ -52,7 +52,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"iteration": {
|
||||
"source": "{{(paymentSettings.data?.order_settings?.payment_methods ?? []).filter(m => m.is_active)}}",
|
||||
"source": "{{(paymentSettings.data?.order_settings?.payment_methods ?? []).filter(m => m.is_active && (!m.requires_ios || _global.appConfig?.isIos))}}",
|
||||
"item_var": "method",
|
||||
"index_var": "methodIdx"
|
||||
},
|
||||
@@ -79,17 +79,34 @@
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "브랜드 마크(색 배지/SVG 로고) — 카탈로그 _cached_brand_mark 가 있는 결제수단(간편결제)만 렌더",
|
||||
"type": "composite",
|
||||
"name": "BrandMark",
|
||||
"if": "{{method._cached_brand_mark}}",
|
||||
"props": {
|
||||
"brandMark": "{{method._cached_brand_mark}}",
|
||||
"size": 40
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": { "size": 32 }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "브랜드 마크가 없는 결제수단(무통장/카드 등) — 아이콘 폴백",
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{!method._cached_brand_mark}}",
|
||||
"props": {
|
||||
"name": "{{method._cached_icon ?? 'circle-question'}}",
|
||||
"className": "w-10 h-10 text-gray-700 dark:text-gray-300"
|
||||
"className": "text-4xl text-gray-700 dark:text-gray-300"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"name": "{{method._cached_icon ?? 'circle-question'}}",
|
||||
"className": "w-8 h-8 text-gray-700 dark:text-gray-300"
|
||||
"className": "text-3xl text-gray-700 dark:text-gray-300"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,7 +321,7 @@
|
||||
"props": {
|
||||
"name": "{{_local.selectedDbank?.bank_code === bank.bank_code ? 'circle-check' : 'circle'}}",
|
||||
"iconStyle": "{{_local.selectedDbank?.bank_code === bank.bank_code ? 'solid' : 'regular'}}",
|
||||
"className": "{{_local.selectedDbank?.bank_code === bank.bank_code ? 'w-5 h-5 text-blue-500 flex-shrink-0' : 'w-5 h-5 text-gray-300 dark:text-gray-600 flex-shrink-0'}}"
|
||||
"className": "{{_local.selectedDbank?.bank_code === bank.bank_code ? 'text-xl text-blue-500 flex-shrink-0' : 'text-xl text-gray-300 dark:text-gray-600 flex-shrink-0'}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { useMemo } from 'react';
|
||||
// @ts-ignore - DOMPurify 타입 정의 없음
|
||||
import DOMPurify from 'dompurify';
|
||||
import { Div } from '../basic/Div';
|
||||
import { Span } from '../basic/Span';
|
||||
import type { EditorAttrs } from '../../types';
|
||||
|
||||
/**
|
||||
* 브랜드 마크 데이터
|
||||
*
|
||||
* 결제수단 카탈로그(`_cached_brand_mark`)가 실어 내리는 브랜드 시각정보.
|
||||
* 두 형태 중 하나:
|
||||
* - 색 배지: `{ text: 'N', class: 'bg-green-500 text-white' }`
|
||||
* - 인라인 SVG 로고: `{ svg: '<svg>...</svg>' }`
|
||||
*/
|
||||
export interface BrandMarkData {
|
||||
/** 배지 텍스트 (예: 'N', 'NP', 'K') */
|
||||
text?: string;
|
||||
/** 배지 배경/텍스트 색상 Tailwind 클래스 (예: 'bg-green-500 text-white') */
|
||||
class?: string;
|
||||
/** 인라인 SVG 로고 마크업 문자열 */
|
||||
svg?: string;
|
||||
}
|
||||
|
||||
export interface BrandMarkProps {
|
||||
/**
|
||||
* 브랜드 마크 데이터 (카탈로그 `_cached_brand_mark`).
|
||||
* 값이 없으면 아무것도 렌더하지 않음(레이아웃이 아이콘 폴백을 그림).
|
||||
*/
|
||||
brandMark?: BrandMarkData | null;
|
||||
|
||||
/** 마크 컨테이너 크기(px). 기본 32 (배지·SVG 공통) */
|
||||
size?: number;
|
||||
|
||||
/** 사용자 정의 클래스 (컨테이너에 병합) */
|
||||
className?: string;
|
||||
|
||||
/** DOM id 속성 (레이아웃 편집기 코어 일괄 ID) */
|
||||
id?: string;
|
||||
|
||||
/** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */
|
||||
editorAttrs?: EditorAttrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG 브랜드 로고 화이트리스트 sanitize 설정.
|
||||
*
|
||||
* 레이아웃 JSON 저장 검증은 SVG 문자열 내부의 위험 요소(onload/script/foreignObject 등)를
|
||||
* 검출하지 않으므로, XSS 방어는 전적으로 이 렌더 시점 sanitize 가 담당한다.
|
||||
* 브랜드 로고에 필요한 프리미티브 태그/속성만 허용하고, 스크립트·이벤트 핸들러·외부
|
||||
* 참조(foreignObject/image/use/href) 는 배제한다.
|
||||
*/
|
||||
const SVG_SANITIZE_CONFIG: any = {
|
||||
ALLOWED_TAGS: [
|
||||
'svg', 'g', 'defs', 'title', 'desc',
|
||||
'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'path',
|
||||
'text', 'tspan',
|
||||
'linearGradient', 'radialGradient', 'stop',
|
||||
'clipPath',
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
'xmlns', 'width', 'height', 'viewBox', 'role', 'aria-label', 'aria-hidden',
|
||||
'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-linecap',
|
||||
'stroke-linejoin', 'opacity', 'transform',
|
||||
'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry',
|
||||
'd', 'points', 'width', 'height',
|
||||
'text-anchor', 'dominant-baseline', 'font-family', 'font-size', 'font-weight',
|
||||
'offset', 'stop-color', 'stop-opacity', 'gradientUnits', 'id', 'clip-path',
|
||||
],
|
||||
// xlink:href / href / use / image / foreignObject 등 외부·스크립트 벡터 강제 차단.
|
||||
FORBID_TAGS: ['script', 'foreignObject', 'image', 'use', 'a', 'style'],
|
||||
FORBID_ATTR: [
|
||||
'onload', 'onerror', 'onclick', 'onmouseover', 'onfocus', 'onblur',
|
||||
'href', 'xlink:href',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 결제 브랜드 마크 컴포넌트
|
||||
*
|
||||
* 결제수단 카탈로그가 내려준 브랜드 시각정보를 데이터 기반으로 렌더한다.
|
||||
* 과거에는 각 PG 플러그인의 체크아웃 DOM 인젝터가 렌더 후 DOM 을 후처리해 마크를
|
||||
* 주입했으나, 시각정보를 카탈로그(`_cached_brand_mark`)로 편입하고 이 컴포넌트가
|
||||
* 직접 그리도록 통일했다.
|
||||
*
|
||||
* - `brandMark.svg` 가 있으면 화이트리스트 sanitize 후 인라인 SVG 로고를 렌더.
|
||||
* - `brandMark.text` 가 있으면 색 배지(text + Tailwind 색상 클래스)를 렌더.
|
||||
* - 둘 다 없으면 null 을 반환해 레이아웃이 아이콘 폴백을 그리게 한다.
|
||||
*
|
||||
* @example
|
||||
* // 색 배지
|
||||
* <BrandMark brandMark={{ text: 'N', class: 'bg-green-500 text-white' }} />
|
||||
*
|
||||
* // SVG 로고
|
||||
* <BrandMark brandMark={{ svg: '<svg>...</svg>' }} />
|
||||
*/
|
||||
export const BrandMark: React.FC<BrandMarkProps> = ({
|
||||
brandMark,
|
||||
size = 32,
|
||||
className = '',
|
||||
id,
|
||||
editorAttrs,
|
||||
}) => {
|
||||
const svg = brandMark?.svg;
|
||||
const text = brandMark?.text;
|
||||
|
||||
// SVG 로고를 화이트리스트 sanitize (렌더 시점 XSS 방어선).
|
||||
const sanitizedSvg = useMemo(() => {
|
||||
if (!svg || svg.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return DOMPurify.sanitize(svg, SVG_SANITIZE_CONFIG) as unknown as string;
|
||||
}, [svg]);
|
||||
|
||||
const dimensionStyle: React.CSSProperties = {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
flex: `0 0 ${size}px`,
|
||||
};
|
||||
|
||||
if (sanitizedSvg) {
|
||||
return (
|
||||
<Div
|
||||
id={id}
|
||||
aria-hidden="true"
|
||||
className={`inline-flex items-center justify-center ${className}`.trim()}
|
||||
style={dimensionStyle}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedSvg }}
|
||||
{...editorAttrs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (text && text.trim() !== '') {
|
||||
const badgeColorClass = brandMark?.class ?? 'bg-gray-500 text-white';
|
||||
|
||||
return (
|
||||
<Span
|
||||
id={id}
|
||||
aria-hidden="true"
|
||||
className={`inline-flex items-center justify-center rounded-lg text-xs font-bold ${badgeColorClass} ${className}`.trim()}
|
||||
style={dimensionStyle}
|
||||
{...editorAttrs}
|
||||
>
|
||||
{text}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
|
||||
// 브랜드 마크가 없는 결제수단(무통장/카드 등) — 레이아웃이 아이콘 폴백을 그린다.
|
||||
return null;
|
||||
};
|
||||
|
||||
export default BrandMark;
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* BrandMark 컴포넌트 테스트
|
||||
*
|
||||
* @description 결제 브랜드 마크(색 배지 / 인라인 SVG) 데이터 기반 렌더 + SVG 화이트리스트 sanitize 검증.
|
||||
* 과거 체크아웃 DOM 인젝터가 후처리로 주입하던 브랜드 마크를 카탈로그 데이터 기반 렌더로 통일한 것을 방어한다.
|
||||
*
|
||||
* @effects badge_renders_text_and_color, svg_renders_inline_logo, svg_dangerous_content_sanitized, none_form_falls_back_to_icon
|
||||
*/
|
||||
// @scenario mark_form=badge, requires_ios=false, device=non_ios
|
||||
// @scenario mark_form=svg, requires_ios=false, device=non_ios
|
||||
// @scenario mark_form=none, requires_ios=false, device=non_ios
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BrandMark } from '../BrandMark';
|
||||
|
||||
describe('BrandMark', () => {
|
||||
describe('배지 렌더 (nhnkcp/nicepay 형태)', () => {
|
||||
it('text + class 로 색 배지를 렌더한다', () => {
|
||||
const { container } = render(
|
||||
<BrandMark brandMark={{ text: 'N', class: 'bg-green-500 text-white' }} />
|
||||
);
|
||||
|
||||
const badge = container.querySelector('span');
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge!.textContent).toBe('N');
|
||||
expect(badge!.className).toContain('bg-green-500');
|
||||
expect(badge!.className).toContain('text-white');
|
||||
// 배지 골격 클래스(safelist 포함)
|
||||
expect(badge!.className).toContain('rounded-lg');
|
||||
expect(badge!.className).toContain('font-bold');
|
||||
});
|
||||
|
||||
it('class 미지정 시 기본 색상으로 폴백한다', () => {
|
||||
const { container } = render(<BrandMark brandMark={{ text: 'X' }} />);
|
||||
const badge = container.querySelector('span');
|
||||
expect(badge!.className).toContain('bg-gray-500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG 렌더 (kginicis 형태)', () => {
|
||||
it('brand_mark.svg 를 인라인 SVG 로 렌더한다', () => {
|
||||
const svg = '<svg viewBox="0 0 40 40"><rect fill="#03C75A"/><text>N</text></svg>';
|
||||
const { container } = render(<BrandMark brandMark={{ svg }} />);
|
||||
|
||||
const svgEl = container.querySelector('svg');
|
||||
expect(svgEl).not.toBeNull();
|
||||
expect(container.querySelector('rect')?.getAttribute('fill')).toBe('#03C75A');
|
||||
expect(container.querySelector('text')?.textContent).toBe('N');
|
||||
});
|
||||
|
||||
it('SVG 내부의 script / onload 등 위험 요소를 sanitize 로 제거한다', () => {
|
||||
const malicious =
|
||||
'<svg viewBox="0 0 40 40"><rect fill="#fff" onload="alert(1)"/><script>alert(2)</script><foreignObject><body/></foreignObject></svg>';
|
||||
const { container } = render(<BrandMark brandMark={{ svg: malicious }} />);
|
||||
|
||||
// svg 자체는 렌더되지만 위험 요소는 제거되어야 한다.
|
||||
expect(container.querySelector('svg')).not.toBeNull();
|
||||
expect(container.querySelector('script')).toBeNull();
|
||||
expect(container.querySelector('foreignObject')).toBeNull();
|
||||
expect(container.querySelector('rect')?.getAttribute('onload')).toBeNull();
|
||||
// innerHTML 에도 script 문자열이 남지 않아야 한다.
|
||||
expect(container.innerHTML).not.toContain('<script');
|
||||
expect(container.innerHTML).not.toContain('onload');
|
||||
});
|
||||
|
||||
it('svg 가 badge(text)보다 우선한다', () => {
|
||||
const { container } = render(
|
||||
<BrandMark brandMark={{ svg: '<svg><rect/></svg>', text: 'N', class: 'bg-green-500' }} />
|
||||
);
|
||||
expect(container.querySelector('svg')).not.toBeNull();
|
||||
// 배지 span 은 렌더되지 않음
|
||||
expect(container.querySelector('span')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('빈 값 처리', () => {
|
||||
it('brandMark 가 null 이면 아무것도 렌더하지 않는다 (아이콘 폴백 위임)', () => {
|
||||
const { container } = render(<BrandMark brandMark={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('brandMark 가 빈 객체이면 아무것도 렌더하지 않는다', () => {
|
||||
const { container } = render(<BrandMark brandMark={{}} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ export type { NotificationCenterProps, NotificationItem } from './NotificationCe
|
||||
|
||||
// 상품 관련 컴포넌트
|
||||
export { default as ProductCard } from './ProductCard';
|
||||
export { BrandMark } from './BrandMark';
|
||||
export type { BrandMarkProps, BrandMarkData } from './BrandMark';
|
||||
export { default as ImageGallery } from './ImageGallery';
|
||||
export { default as ProductImageViewer } from './ProductImageViewer';
|
||||
export { default as QuantitySelector } from './QuantitySelector';
|
||||
@@ -75,6 +77,8 @@ export const compositeComponents = {
|
||||
|
||||
// 상품
|
||||
ProductCard: () => import('./ProductCard'),
|
||||
// 결제 브랜드 마크(배지/SVG) — 카탈로그 _cached_brand_mark 를 데이터 기반 렌더
|
||||
BrandMark: () => import('./BrandMark'),
|
||||
ImageGallery: () => import('./ImageGallery'),
|
||||
ProductImageViewer: () => import('./ProductImageViewer'),
|
||||
QuantitySelector: () => import('./QuantitySelector'),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* deviceCorrection 테스트
|
||||
*
|
||||
* @description 서버 UA 판정(appConfig.isIos)이 놓친 iPadOS(데스크탑 UA)를 클라 신호로 보정하는 로직 검증.
|
||||
*
|
||||
* @effects client_corrects_ipados_desktop_ua, applepay_shown_on_ios
|
||||
*/
|
||||
// @scenario mark_form=badge, requires_ios=true, device=ipados_desktop_ua
|
||||
// @scenario mark_form=svg, requires_ios=false, device=ipados_desktop_ua
|
||||
// @scenario mark_form=none, requires_ios=false, device=ipados_desktop_ua
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { isIosMobileDeviceClient, correctIosDeviceState } from '../deviceCorrection';
|
||||
|
||||
const originalNavigator = globalThis.navigator;
|
||||
|
||||
function mockNavigator(overrides: Partial<Navigator> & Record<string, unknown>): void {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: '', platform: '', maxTouchPoints: 0, ...overrides },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
delete (window as any).G7Core;
|
||||
});
|
||||
|
||||
describe('isIosMobileDeviceClient', () => {
|
||||
it('iPhone UA 를 iOS 로 판정한다', () => {
|
||||
mockNavigator({ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' });
|
||||
expect(isIosMobileDeviceClient()).toBe(true);
|
||||
});
|
||||
|
||||
it('데스크탑 UA + maxTouchPoints>1 (iPadOS) 를 iOS 로 판정한다', () => {
|
||||
mockNavigator({ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17', maxTouchPoints: 5 });
|
||||
expect(isIosMobileDeviceClient()).toBe(true);
|
||||
});
|
||||
|
||||
it('데스크탑 Mac (터치 없음) 은 iOS 가 아니다', () => {
|
||||
mockNavigator({ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17', maxTouchPoints: 0 });
|
||||
expect(isIosMobileDeviceClient()).toBe(false);
|
||||
});
|
||||
|
||||
it('Android 는 iOS 가 아니다', () => {
|
||||
mockNavigator({ userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8)', maxTouchPoints: 5 });
|
||||
expect(isIosMobileDeviceClient()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('correctIosDeviceState', () => {
|
||||
beforeEach(() => {
|
||||
const state: Record<string, any> = { appConfig: { isIos: false } };
|
||||
(window as any).G7Core = {
|
||||
state: {
|
||||
get: vi.fn(() => state),
|
||||
set: vi.fn((updates: Record<string, any>) => Object.assign(state, updates)),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('클라가 iOS 로 판정하고 서버가 false 였으면 appConfig.isIos 를 true 로 보정한다', () => {
|
||||
mockNavigator({ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17', maxTouchPoints: 5 });
|
||||
|
||||
correctIosDeviceState();
|
||||
|
||||
const setMock = (window as any).G7Core.state.set;
|
||||
expect(setMock).toHaveBeenCalledWith({ appConfig: { isIos: true } });
|
||||
});
|
||||
|
||||
it('클라가 iOS 가 아니면 보정하지 않는다', () => {
|
||||
mockNavigator({ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120', maxTouchPoints: 0 });
|
||||
|
||||
correctIosDeviceState();
|
||||
|
||||
expect((window as any).G7Core.state.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('서버가 이미 iOS 로 판정했으면 재보정하지 않는다 (다운그레이드 방지)', () => {
|
||||
const state: Record<string, any> = { appConfig: { isIos: true } };
|
||||
(window as any).G7Core.state.get = vi.fn(() => state);
|
||||
mockNavigator({ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' });
|
||||
|
||||
correctIosDeviceState();
|
||||
|
||||
expect((window as any).G7Core.state.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 서버측 기기 감지값(appConfig.isIos)의 클라이언트 보정.
|
||||
*
|
||||
* 서버는 User-Agent 만으로 iOS 를 판정하므로, 데스크탑 UA 를 보내는 iPadOS 를
|
||||
* 놓칠 수 있다(iPad 에서 애플페이 버튼 미표시). 부트스트랩 시점에 클라이언트
|
||||
* 신호(maxTouchPoints/userAgentData.platform)로 iOS 를 재판정해, 서버가 false
|
||||
* 로 내려보낸 경우에만 전역 상태 `appConfig.isIos` 를 true 로 보정한다.
|
||||
*
|
||||
* 서버가 이미 true 로 판정했으면 그대로 두어(다운그레이드 방지) fail-safe 를 유지한다.
|
||||
*/
|
||||
|
||||
const logger = ((window as any).G7Core?.createLogger?.('Template:sirsoft-basic:device'))
|
||||
?? {
|
||||
log: (...args: unknown[]) => console.log('[Template:sirsoft-basic:device]', ...args),
|
||||
warn: (...args: unknown[]) => console.warn('[Template:sirsoft-basic:device]', ...args),
|
||||
};
|
||||
|
||||
/**
|
||||
* 클라이언트 신호로 iOS 기기 여부를 판정합니다.
|
||||
*
|
||||
* 과거 checkoutEasyPayInjector 의 isIosMobileDevice 판정과 동일한 규칙:
|
||||
* - UA/platform 에 iphone|ipad|ipod(+ios) 매칭
|
||||
* - 데스크탑 UA 를 보내는 iPadOS: macintosh UA + maxTouchPoints > 1
|
||||
*
|
||||
* @returns iOS 기기 여부
|
||||
*/
|
||||
export function isIosMobileDeviceClient(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
|
||||
const nav = navigator as Navigator & {
|
||||
userAgentData?: { platform?: string };
|
||||
maxTouchPoints?: number;
|
||||
};
|
||||
const ua = (nav.userAgent || '').toLowerCase();
|
||||
const platform = ((nav.userAgentData?.platform ?? nav.platform) || '').toLowerCase();
|
||||
const touchPoints = nav.maxTouchPoints ?? 0;
|
||||
|
||||
if (/iphone|ipad|ipod/.test(ua) || /iphone|ipad|ipod|ios/.test(platform)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /macintosh|mac os x/.test(ua) && touchPoints > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 내려준 `appConfig.isIos` 를 클라이언트 판정으로 보정합니다.
|
||||
*
|
||||
* 서버 값이 false 이고 클라이언트가 iOS 로 판정한 경우에만 전역 상태를 갱신한다.
|
||||
* (iPadOS 데스크탑 UA 케이스 복구)
|
||||
*/
|
||||
export function correctIosDeviceState(): void {
|
||||
const G7Core = (window as any).G7Core;
|
||||
if (!G7Core?.state?.get || !G7Core?.state?.set) {
|
||||
logger.warn('G7Core.state not available; skip iOS device correction');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isIosMobileDeviceClient()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const globalState = G7Core.state.get() || {};
|
||||
const appConfig = globalState.appConfig || {};
|
||||
|
||||
// 서버가 이미 iOS 로 판정했으면 보정 불필요.
|
||||
if (appConfig.isIos === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
G7Core.state.set({ appConfig: { ...appConfig, isIos: true } });
|
||||
logger.log('appConfig.isIos corrected to true by client signals (iPadOS desktop-UA)');
|
||||
}
|
||||
@@ -111,6 +111,9 @@ import { handlerMap } from './handlers';
|
||||
// IDV Modal Launcher (engine-v1.46.0+)
|
||||
import { registerSirsoftBasicIdentityLauncher } from './handlers/identityLauncher';
|
||||
|
||||
// 서버 기기 감지값(appConfig.isIos) 클라이언트 보정 (iPadOS 데스크탑 UA 케이스)
|
||||
import { correctIosDeviceState } from './handlers/deviceCorrection';
|
||||
|
||||
// 레이아웃 편집기 커스텀 위젯(icon-picker 등) 등록
|
||||
import { registerSirsoftBasicEditorWidgets } from './layout-editor/registerEditorWidgets';
|
||||
|
||||
@@ -151,6 +154,10 @@ export function initTemplate(): void {
|
||||
|
||||
// IDV Modal Launcher 등록 (window.G7Core.identity.setLauncher 사용)
|
||||
registerSirsoftBasicIdentityLauncher();
|
||||
|
||||
// 서버 UA 판정이 놓친 iPadOS(데스크탑 UA) 를 클라 신호로 보정 → appConfig.isIos.
|
||||
// 애플페이 iOS 게이팅(체크아웃 레이아웃 iteration 필터)이 이 값을 참조한다.
|
||||
correctIosDeviceState();
|
||||
} else {
|
||||
retryCount++;
|
||||
if (retryCount <= maxRetries) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
@@ -106,11 +107,45 @@ class SettingsServiceAppConfigTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* config/frontend.php가 없는 경우 빈 배열을 반환하는지 테스트합니다.
|
||||
* getAppConfigForFrontend()가 core.frontend.filter_app_config 훅으로 확장이 주입한 값을
|
||||
* appConfig 에 반영하는지 테스트합니다. (예: 이커머스 모듈이 요청 기기 유형 isIos 를 주입)
|
||||
*/
|
||||
public function test_get_app_config_returns_empty_when_no_config(): void
|
||||
public function test_get_app_config_applies_frontend_filter_hook(): void
|
||||
{
|
||||
// config를 비움
|
||||
// 우선순위 100 — 이커머스 모듈이 등록한 InjectAppConfigDeviceListener(priority 20)보다
|
||||
// 뒤에 실행되어, 전체 스위트에서 그 리스너가 함께 등록돼 있어도 이 테스트의 주입값이
|
||||
// 최종적으로 반영됨을 검증한다(훅 적용 메커니즘 자체 검증, 스위트 순서 비의존).
|
||||
HookManager::addFilter(
|
||||
'core.frontend.filter_app_config',
|
||||
function (array $appConfig): array {
|
||||
$appConfig['isIos'] = true;
|
||||
$appConfig['__testHookMarker'] = 'applied';
|
||||
|
||||
return $appConfig;
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
$service = app(SettingsService::class);
|
||||
|
||||
$result = $service->getAppConfigForFrontend();
|
||||
|
||||
$this->assertTrue($result['isIos']);
|
||||
// 훅이 실제로 적용됐음을 별도 마커로도 확인(다른 리스너 간섭과 무관).
|
||||
$this->assertSame('applied', $result['__testHookMarker'] ?? null);
|
||||
// 기존 정적 값은 그대로 유지된다.
|
||||
$this->assertArrayHasKey('supportedLocales', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* config/frontend.php의 app_config 스키마가 비면 config 파생 키가 없는지 테스트합니다.
|
||||
*
|
||||
* 참고: core.frontend.filter_app_config 훅으로 확장(예: 이커머스 isIos)이 값을 주입할 수
|
||||
* 있으므로 결과가 완전히 비어 있음을 단정하지 않고, config 스키마 파생 키의 부재만 확인한다.
|
||||
*/
|
||||
public function test_get_app_config_has_no_config_derived_keys_when_schema_empty(): void
|
||||
{
|
||||
// config 스키마를 비움
|
||||
config(['frontend.app_config' => []]);
|
||||
|
||||
$service = app(SettingsService::class);
|
||||
@@ -118,7 +153,10 @@ class SettingsServiceAppConfigTest extends TestCase
|
||||
$result = $service->getAppConfigForFrontend();
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertEmpty($result);
|
||||
// 스키마 파생 키(config_key 기반)는 없어야 한다.
|
||||
$this->assertArrayNotHasKey('supportedTimezones', $result);
|
||||
$this->assertArrayNotHasKey('supportedLocales', $result);
|
||||
$this->assertArrayNotHasKey('version', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user