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:
HeuJung
2026-07-15 17:50:39 +09:00
parent e896a47d4f
commit 8b9bfd0c91
58 changed files with 1582 additions and 2013 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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>
+4 -1
View File
@@ -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
View File
@@ -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_* 유지
@@ -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']);
}
}
@@ -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 셸 요청에서 실행된다.'
);
}
}
@@ -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
@@ -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();
@@ -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;
}
}
@@ -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
@@ -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();
@@ -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;
}
}
@@ -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
@@ -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();
@@ -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;
}
}
@@ -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
- 네이버페이·카카오페이 등 간편결제로 주문을 완료했을 때 주문완료 페이지의 "결제 완료" 안내가 표시되지 않던 문제를 수정했습니다. 이제 입금 대기(무통장·가상계좌)를 제외한 모든 결제수단에서 결제 완료 안내가 정상적으로 표시됩니다.
@@ -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",
File diff suppressed because one or more lines are too long
@@ -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;
@@ -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);
}
/**