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
-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": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
-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
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
-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
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
-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
File diff suppressed because one or more lines are too long
+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