create-ait-app 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +38 -38
  2. package/package.json +5 -8
  3. package/src/cli.js +52 -0
  4. package/src/main.js +95 -444
  5. package/src/sample-configs.js +140 -0
  6. package/src/sample-inject.js +65 -0
  7. package/src/scaffold.js +111 -0
  8. package/src/skills.js +66 -0
  9. package/src/templates.js +88 -0
  10. package/src/utils/copy-dir.js +19 -0
  11. package/src/utils/fetch-text.js +47 -0
  12. package/src/utils/package-name.js +26 -0
  13. package/templates/js/eslint.config.js +14 -0
  14. package/templates/js/index.html +12 -0
  15. package/templates/js/package.json +20 -0
  16. package/templates/js/samples/iaa/src/lib/inAppAds.js +119 -0
  17. package/templates/js/samples/iaa/src/pages/InAppAdsPage.js +83 -0
  18. package/templates/js/samples/iap/src/lib/inAppPurchase.js +105 -0
  19. package/templates/js/samples/iap/src/pages/InAppPurchasePage.js +102 -0
  20. package/templates/js/src/app.js +58 -0
  21. package/templates/js/src/main.js +2 -0
  22. package/templates/js/vite.config.js +3 -0
  23. package/templates/react/README.md +26 -0
  24. package/templates/react/eslint.config.js +30 -0
  25. package/templates/react/granite.config.ts +20 -0
  26. package/templates/react/index.html +12 -0
  27. package/templates/react/package.json +27 -0
  28. package/templates/react/public/appsintoss-logo.png +0 -0
  29. package/templates/react/samples/iaa/src/hooks/useInAppAds.js +102 -0
  30. package/templates/react/samples/iaa/src/pages/InAppAdsPage.css +72 -0
  31. package/templates/react/samples/iaa/src/pages/InAppAdsPage.jsx +75 -0
  32. package/templates/react/samples/iap/public/icon-document.png +0 -0
  33. package/templates/react/samples/iap/src/hooks/useInAppPurchase.js +95 -0
  34. package/templates/react/samples/iap/src/pages/InAppPurchasePage.css +115 -0
  35. package/templates/react/samples/iap/src/pages/InAppPurchasePage.jsx +115 -0
  36. package/templates/react/src/App.css +104 -0
  37. package/templates/react/src/App.jsx +45 -0
  38. package/templates/react/src/index.css +27 -0
  39. package/templates/react/src/main.jsx +10 -0
  40. package/templates/react/vite.config.js +6 -0
  41. package/templates/react-ts/README.md +26 -0
  42. package/templates/react-ts/eslint.config.js +28 -0
  43. package/templates/react-ts/granite.config.ts +20 -0
  44. package/templates/react-ts/index.html +12 -0
  45. package/templates/react-ts/public/appsintoss-logo.png +0 -0
  46. package/templates/react-ts/samples/iaa/src/pages/InAppAdsPage.css +72 -0
  47. package/templates/react-ts/samples/iap/public/icon-document.png +0 -0
  48. package/templates/react-ts/samples/iap/src/pages/InAppPurchasePage.css +115 -0
  49. package/templates/react-ts/src/App.css +104 -0
  50. package/templates/react-ts/src/index.css +27 -0
  51. package/templates/react-ts/src/vite-env.d.ts +6 -0
  52. package/templates/react-ts/tsconfig.app.json +22 -0
  53. package/templates/react-ts/tsconfig.json +7 -0
  54. package/templates/react-ts/tsconfig.node.json +20 -0
  55. package/templates/react-ts/vite.config.ts +6 -0
  56. package/templates/react-ts-tds/README.md +26 -0
  57. package/templates/react-ts-tds/granite.config.ts +20 -0
  58. package/templates/react-ts-tds/package.json +35 -0
  59. package/templates/react-ts-tds/public/appsintoss-logo.png +0 -0
  60. package/templates/ts/README.md +26 -0
  61. package/templates/ts/eslint.config.js +15 -0
  62. package/templates/ts/granite.config.ts +20 -0
  63. package/templates/ts/index.html +12 -0
  64. package/templates/ts/package.json +22 -0
  65. package/templates/ts/public/appsintoss-logo.png +0 -0
  66. package/templates/ts/samples/iaa/src/lib/inAppAds.ts +132 -0
  67. package/templates/ts/samples/iaa/src/pages/InAppAdsPage.css +72 -0
  68. package/templates/ts/samples/iaa/src/pages/InAppAdsPage.ts +85 -0
  69. package/templates/ts/samples/iap/public/icon-document.png +0 -0
  70. package/templates/ts/samples/iap/src/lib/inAppPurchase.ts +114 -0
  71. package/templates/ts/samples/iap/src/pages/InAppPurchasePage.css +115 -0
  72. package/templates/ts/samples/iap/src/pages/InAppPurchasePage.ts +105 -0
  73. package/templates/ts/src/App.css +104 -0
  74. package/templates/ts/src/app.ts +60 -0
  75. package/templates/ts/src/index.css +27 -0
  76. package/templates/ts/src/main.ts +2 -0
  77. package/templates/ts/src/vite-env.d.ts +1 -0
  78. package/templates/ts/tsconfig.app.json +22 -0
  79. package/templates/ts/tsconfig.json +7 -0
  80. package/templates/ts/tsconfig.node.json +20 -0
  81. package/templates/ts/vite.config.ts +3 -0
  82. /package/{template → templates/js}/README.md +0 -0
  83. /package/{template → templates/js}/granite.config.ts +0 -0
  84. /package/{template → templates/js}/public/appsintoss-logo.png +0 -0
  85. /package/{template/__default/__samples → templates/js/samples}/iaa/src/pages/InAppAdsPage.css +0 -0
  86. /package/{template/__default/__samples → templates/js/samples}/iap/public/icon-document.png +0 -0
  87. /package/{template/__default/__samples → templates/js/samples}/iap/src/pages/InAppPurchasePage.css +0 -0
  88. /package/{template/__default → templates/js}/src/App.css +0 -0
  89. /package/{template/__default → templates/js}/src/index.css +0 -0
  90. /package/{template → templates/react-ts}/package.json +0 -0
  91. /package/{template/__default/__samples → templates/react-ts/samples}/iaa/src/hooks/useInAppAds.tsx +0 -0
  92. /package/{template/__default/__samples → templates/react-ts/samples}/iaa/src/pages/InAppAdsPage.tsx +0 -0
  93. /package/{template/__default/__samples → templates/react-ts/samples}/iap/src/hooks/useInAppPurchase.ts +0 -0
  94. /package/{template/__default/__samples → templates/react-ts/samples}/iap/src/pages/InAppPurchasePage.tsx +0 -0
  95. /package/{template/__default → templates/react-ts}/src/App.tsx +0 -0
  96. /package/{template/__default → templates/react-ts}/src/main.tsx +0 -0
  97. /package/{template → templates/react-ts-tds}/eslint.config.js +0 -0
  98. /package/{template → templates/react-ts-tds}/index.html +0 -0
  99. /package/{template/__tds/__samples → templates/react-ts-tds/samples}/iaa/src/hooks/useInAppAds.tsx +0 -0
  100. /package/{template/__tds/__samples → templates/react-ts-tds/samples}/iaa/src/pages/InAppAdsPage.tsx +0 -0
  101. /package/{template/__tds/__samples → templates/react-ts-tds/samples}/iap/public/icon-document.png +0 -0
  102. /package/{template/__tds/__samples → templates/react-ts-tds/samples}/iap/src/hooks/useInAppPurchase.ts +0 -0
  103. /package/{template/__tds/__samples → templates/react-ts-tds/samples}/iap/src/pages/InAppPurchasePage.tsx +0 -0
  104. /package/{template/__tds → templates/react-ts-tds}/src/App.css +0 -0
  105. /package/{template/__tds → templates/react-ts-tds}/src/App.tsx +0 -0
  106. /package/{template/__tds → templates/react-ts-tds}/src/index.css +0 -0
  107. /package/{template/__tds → templates/react-ts-tds}/src/main.tsx +0 -0
  108. /package/{template → templates/react-ts-tds}/src/vite-env.d.ts +0 -0
  109. /package/{template → templates/react-ts-tds}/tsconfig.app.json +0 -0
  110. /package/{template → templates/react-ts-tds}/tsconfig.json +0 -0
  111. /package/{template → templates/react-ts-tds}/tsconfig.node.json +0 -0
  112. /package/{template → templates/react-ts-tds}/vite.config.ts +0 -0
@@ -0,0 +1,15 @@
1
+ import js from "@eslint/js";
2
+ import globals from "globals";
3
+ import tseslint from "typescript-eslint";
4
+
5
+ export default tseslint.config(
6
+ { ignores: ["dist"] },
7
+ {
8
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
9
+ files: ["**/*.{ts,mts}"],
10
+ languageOptions: {
11
+ ecmaVersion: 2020,
12
+ globals: globals.browser,
13
+ },
14
+ },
15
+ );
@@ -0,0 +1,20 @@
1
+ import { defineConfig } from "@apps-in-toss/web-framework/config";
2
+
3
+ export default defineConfig({
4
+ appName: "{{APP_NAME}}",
5
+ brand: {
6
+ displayName: "앱 이름", // 화면에 노출될 앱의 한글 이름으로 바꿔주세요.
7
+ primaryColor: "{{PRIMARY_COLOR}}", // 화면에 노출될 앱의 기본 색상으로 바꿔주세요.
8
+ icon: "", // 화면에 노출될 앱의 아이콘 이미지 주소로 바꿔주세요.
9
+ },
10
+ web: {
11
+ host: "localhost",
12
+ port: 5173,
13
+ commands: {
14
+ dev: "vite dev",
15
+ build: "vite build",
16
+ },
17
+ },
18
+ permissions: [],
19
+ outdir: "dist",
20
+ });
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>AIT App</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.ts"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "{{APP_NAME}}",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "granite dev",
8
+ "build": "ait build",
9
+ "deploy": "ait deploy",
10
+ "lint": "eslint .",
11
+ "format": "prettier --write ."
12
+ },
13
+ "devDependencies": {
14
+ "@eslint/js": "^9.21.0",
15
+ "eslint": "^9.21.0",
16
+ "globals": "^15.15.0",
17
+ "prettier": "^3.4.2",
18
+ "typescript": "~5.7.2",
19
+ "typescript-eslint": "^8.24.1",
20
+ "vite": "^6.2.0"
21
+ }
22
+ }
@@ -0,0 +1,132 @@
1
+ import {
2
+ loadFullScreenAd,
3
+ showFullScreenAd,
4
+ } from "@apps-in-toss/web-framework";
5
+
6
+ type Listener = () => void;
7
+
8
+ interface Reward {
9
+ unitType: string;
10
+ unitAmount: number;
11
+ }
12
+
13
+ interface InAppAdsState {
14
+ isAdLoaded: boolean;
15
+ isSupported: boolean;
16
+ lastReward: Reward | null;
17
+ }
18
+
19
+ // 참고문서: https://developers-apps-in-toss.toss.im/ads/intro.html
20
+ export function createInAppAds(adGroupId: string) {
21
+ const state: InAppAdsState = {
22
+ isAdLoaded: false,
23
+ isSupported: false,
24
+ lastReward: null,
25
+ };
26
+
27
+ let onUpdate: Listener = () => {};
28
+ let unregister: (() => void) | null = null;
29
+
30
+ function notify() {
31
+ onUpdate();
32
+ }
33
+
34
+ function load() {
35
+ state.isAdLoaded = false;
36
+ notify();
37
+
38
+ try {
39
+ unregister = loadFullScreenAd({
40
+ options: { adGroupId },
41
+ onEvent: (event) => {
42
+ if (event.type === "loaded") {
43
+ state.isAdLoaded = true;
44
+ notify();
45
+ }
46
+ },
47
+ onError: (error) => {
48
+ console.error("광고 로드 실패:", error);
49
+ },
50
+ });
51
+ } catch (error) {
52
+ alert(
53
+ "광고 로드 실패: \n\n- 인앱광고 기능은 브라우저가 아닌 샌드박스앱/토스앱에서 실행해주세요\n\n" +
54
+ error,
55
+ );
56
+ }
57
+ }
58
+
59
+ function showAd() {
60
+ if (!state.isSupported) {
61
+ console.info("현재 환경에서는 인앱 광고가 지원되지 않습니다.");
62
+ return;
63
+ }
64
+
65
+ if (!state.isAdLoaded) {
66
+ console.info("아직 광고가 로드되지 않았습니다.");
67
+ return;
68
+ }
69
+
70
+ try {
71
+ showFullScreenAd({
72
+ options: { adGroupId },
73
+ onEvent: (event) => {
74
+ switch (event.type) {
75
+ case "userEarnedReward":
76
+ state.lastReward = event.data;
77
+ notify();
78
+ break;
79
+ case "dismissed":
80
+ state.isAdLoaded = false;
81
+ notify();
82
+ load();
83
+ break;
84
+ case "failedToShow":
85
+ console.error("광고 표시 실패");
86
+ state.isAdLoaded = false;
87
+ notify();
88
+ load();
89
+ break;
90
+ }
91
+ },
92
+ onError: (error) => {
93
+ console.error("광고 표시 실패:", error);
94
+ state.isAdLoaded = false;
95
+ notify();
96
+ load();
97
+ },
98
+ });
99
+ } catch (error) {
100
+ console.error("광고 표시 실패:", error);
101
+ state.isAdLoaded = false;
102
+ notify();
103
+ load();
104
+ }
105
+ }
106
+
107
+ try {
108
+ state.isSupported = loadFullScreenAd.isSupported();
109
+ if (state.isSupported) {
110
+ load();
111
+ }
112
+ } catch (error) {
113
+ console.error("광고 지원 여부 확인 실패:", error);
114
+ state.isSupported = false;
115
+ }
116
+
117
+ return {
118
+ getState: () => state,
119
+ showAd,
120
+ subscribe: (listener: Listener) => {
121
+ onUpdate = listener;
122
+ return () => {
123
+ onUpdate = () => {};
124
+ try {
125
+ unregister?.();
126
+ } catch (error) {
127
+ console.error("광고 정리(cleanup) 중 에러:", error);
128
+ }
129
+ };
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,72 @@
1
+ .iaa-section-list {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 6px;
5
+ }
6
+
7
+ .iaa-section {
8
+ padding: 20px 0;
9
+ display: flex;
10
+ flex-direction: column;
11
+ align-items: stretch;
12
+ gap: 0;
13
+ }
14
+
15
+ .iaa-section-row {
16
+ display: flex;
17
+ align-items: center;
18
+ gap: 16px;
19
+ }
20
+
21
+ .iaa-section-info {
22
+ flex: 1;
23
+ display: flex;
24
+ flex-direction: column;
25
+ gap: 4px;
26
+ }
27
+
28
+ .iaa-section-title {
29
+ margin: 0;
30
+ font-size: 18px;
31
+ font-weight: 600;
32
+ color: #333d4b;
33
+ }
34
+
35
+ .iaa-section-desc {
36
+ margin: 0;
37
+ font-size: 15px;
38
+ color: #8b95a1;
39
+ }
40
+
41
+ .iaa-section-button {
42
+ flex-shrink: 0;
43
+ height: fit-content;
44
+ padding: 10px 16px;
45
+ color: #1368cc;
46
+ background-color: #ecf3ff;
47
+ border: 0;
48
+ border-radius: 8px;
49
+ font-size: 15px;
50
+ font-weight: 500;
51
+ cursor: pointer;
52
+ }
53
+
54
+ .iaa-section-button:hover:not(:disabled) {
55
+ background-color: #d4e5ff;
56
+ }
57
+
58
+ .iaa-section-button:disabled {
59
+ opacity: 0.5;
60
+ cursor: not-allowed;
61
+ }
62
+
63
+ .iaa-reward-message {
64
+ margin: 8px 0 0;
65
+ font-size: 15px;
66
+ color: #3182f6;
67
+ }
68
+
69
+ /* 인앱결제 페이지 "← 홈으로"와 동일 스타일 */
70
+ .iaa-back-btn {
71
+ margin: 16px 0;
72
+ }
@@ -0,0 +1,85 @@
1
+ import { createInAppAds } from "../lib/inAppAds.ts";
2
+ import "./InAppAdsPage.css";
3
+
4
+ // TODO: 서비스를 출시하기 전에 앱인토스 콘솔에서 발급한 광고그룹ID로 변경해주세요.
5
+ const TEST_INTERSTITIAL_ID = "ait-ad-test-interstitial-id";
6
+ const TEST_REWARDED_ID = "ait-ad-test-rewarded-id";
7
+
8
+ export function mountInAppAdsPage(onBack: () => void) {
9
+ const root = document.getElementById("root");
10
+ if (!root) return;
11
+
12
+ const interstitial = createInAppAds(TEST_INTERSTITIAL_ID);
13
+ const rewarded = createInAppAds(TEST_REWARDED_ID);
14
+
15
+ function render() {
16
+ const interstitialState = interstitial.getState();
17
+ const rewardedState = rewarded.getState();
18
+
19
+ root.innerHTML = `
20
+ <div class="app-header">
21
+ <h1 class="page-title">인앱광고</h1>
22
+ ${
23
+ !interstitialState.isSupported
24
+ ? '<p class="page-subtitle">이 환경에서는 인앱 광고를 사용할 수 없어요.</p>'
25
+ : ""
26
+ }
27
+ </div>
28
+
29
+ <div class="iaa-section-list">
30
+ <div class="iaa-section">
31
+ <div class="iaa-section-row">
32
+ <div class="iaa-section-info">
33
+ <h2 class="iaa-section-title">전면형 광고</h2>
34
+ <p class="iaa-section-desc">화면 전체에 표시되는 광고</p>
35
+ </div>
36
+ <button
37
+ type="button"
38
+ class="iaa-section-button"
39
+ data-action="show-interstitial"
40
+ ${interstitialState.isAdLoaded ? "" : "disabled"}
41
+ >
42
+ ${interstitialState.isAdLoaded ? "보기" : "로딩 중"}
43
+ </button>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="iaa-section">
48
+ <div class="iaa-section-row">
49
+ <div class="iaa-section-info">
50
+ <h2 class="iaa-section-title">보상형 광고</h2>
51
+ <p class="iaa-section-desc">시청 완료 시 보상을 받는 광고</p>
52
+ </div>
53
+ <button
54
+ type="button"
55
+ class="iaa-section-button"
56
+ data-action="show-rewarded"
57
+ ${rewardedState.isAdLoaded ? "" : "disabled"}
58
+ >
59
+ ${rewardedState.isAdLoaded ? "보기" : "로딩 중"}
60
+ </button>
61
+ </div>
62
+ ${
63
+ rewardedState.lastReward
64
+ ? `<p class="iaa-reward-message">보상 획득: ${rewardedState.lastReward.unitType} ${rewardedState.lastReward.unitAmount}개</p>`
65
+ : ""
66
+ }
67
+ </div>
68
+ </div>
69
+
70
+ <button type="button" class="text-button iaa-back-btn" data-action="back">← 홈으로</button>
71
+ `;
72
+
73
+ root
74
+ .querySelector('[data-action="show-interstitial"]')
75
+ ?.addEventListener("click", () => interstitial.showAd());
76
+ root
77
+ .querySelector('[data-action="show-rewarded"]')
78
+ ?.addEventListener("click", () => rewarded.showAd());
79
+ root.querySelector('[data-action="back"]')?.addEventListener("click", onBack);
80
+ }
81
+
82
+ interstitial.subscribe(render);
83
+ rewarded.subscribe(render);
84
+ render();
85
+ }
@@ -0,0 +1,114 @@
1
+ import type { IapProductListItem } from "@apps-in-toss/web-framework";
2
+ import { IAP } from "@apps-in-toss/web-framework";
3
+
4
+ type Listener = () => void;
5
+
6
+ interface InAppPurchaseState {
7
+ products: IapProductListItem[];
8
+ productsLoading: boolean;
9
+ purchasingSku: string | null;
10
+ }
11
+
12
+ // 참고문서: https://developers-apps-in-toss.toss.im/iap/intro.html
13
+ export function createInAppPurchase() {
14
+ const state: InAppPurchaseState = {
15
+ products: [],
16
+ productsLoading: false,
17
+ purchasingSku: null,
18
+ };
19
+
20
+ let onUpdate: Listener = () => {};
21
+
22
+ function notify() {
23
+ onUpdate();
24
+ }
25
+
26
+ function grantProduct(orderId: string) {
27
+ // TODO: 여기에 상품 지급 로직을 작성해주세요.
28
+ console.info(`상품 지급 처리: ${orderId}`);
29
+ }
30
+
31
+ async function fetchProducts() {
32
+ state.productsLoading = true;
33
+ notify();
34
+
35
+ try {
36
+ const response = await IAP.getProductItemList();
37
+ state.products = response?.products ?? [];
38
+ } catch (error) {
39
+ alert(
40
+ "상품 목록 조회 실패: \n\n-앱인토스 콘솔에서 미니앱을 생성후 인앱상품을 등록해주세요\n- 인앱결제 기능은 브라우저가 아닌 샌드박스앱/토스앱에서 실행해주세요\n\n" +
41
+ error,
42
+ );
43
+ } finally {
44
+ state.productsLoading = false;
45
+ notify();
46
+ }
47
+ }
48
+
49
+ function purchaseProduct(sku: string) {
50
+ state.purchasingSku = sku;
51
+ notify();
52
+
53
+ try {
54
+ const cleanup = IAP.createOneTimePurchaseOrder({
55
+ options: {
56
+ sku,
57
+ processProductGrant: ({ orderId }) => {
58
+ grantProduct(orderId);
59
+ console.info(`상품 지급 처리: ${orderId}`);
60
+ return true;
61
+ },
62
+ },
63
+ onEvent: (event) => {
64
+ if (event.type === "success") {
65
+ alert(`${event.data.displayName}이 결제되었어요.`);
66
+ }
67
+
68
+ state.purchasingSku = null;
69
+ notify();
70
+ cleanup();
71
+ },
72
+ onError: (error) => {
73
+ console.error("인앱결제 실패:", error);
74
+ state.purchasingSku = null;
75
+ notify();
76
+ cleanup();
77
+ },
78
+ });
79
+ } catch (error) {
80
+ console.error("인앱결제 실패:", error);
81
+ state.purchasingSku = null;
82
+ notify();
83
+ }
84
+ }
85
+
86
+ async function restorePendingOrders() {
87
+ try {
88
+ const pending = await IAP.getPendingOrders();
89
+ const orders = pending?.orders ?? [];
90
+
91
+ for (const order of orders) {
92
+ grantProduct(order.orderId);
93
+ await IAP.completeProductGrant({ params: { orderId: order.orderId } });
94
+ console.info("미결 주문 복원 완료:", order.orderId);
95
+ }
96
+ } catch (error) {
97
+ console.error("주문 복원 실패:", error);
98
+ }
99
+ }
100
+
101
+ fetchProducts();
102
+
103
+ return {
104
+ getState: () => state,
105
+ purchaseProduct,
106
+ restorePendingOrders,
107
+ subscribe: (listener: Listener) => {
108
+ onUpdate = listener;
109
+ return () => {
110
+ onUpdate = () => {};
111
+ };
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,115 @@
1
+ .iap-empty-state {
2
+ height: 300px;
3
+ display: flex;
4
+ flex-direction: column;
5
+ justify-content: center;
6
+ align-items: center;
7
+ gap: 16px;
8
+ }
9
+
10
+ .iap-empty-state-icon {
11
+ width: 64px;
12
+ }
13
+
14
+ .iap-empty-state-content {
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: center;
18
+ gap: 12px;
19
+ }
20
+
21
+ .iap-empty-state-title {
22
+ margin: 0;
23
+ font-size: 20px;
24
+ font-weight: 600;
25
+ color: #191f28;
26
+ }
27
+
28
+ .iap-empty-state-desc {
29
+ margin: 0;
30
+ font-size: 16px;
31
+ color: #4e5968;
32
+ }
33
+
34
+ .iap-empty-state-back-btn {
35
+ width: fit-content;
36
+ padding: 10px 16px;
37
+ border-radius: 10px;
38
+ background-color: #3e8cf5;
39
+ color: #fff;
40
+ border: none;
41
+ font-size: 14px;
42
+ cursor: pointer;
43
+ }
44
+
45
+ .iap-empty-state-back-btn:hover {
46
+ background-color: #2d7ae0;
47
+ }
48
+
49
+ .iap-product-list {
50
+ /* wrapper for product items */
51
+ }
52
+
53
+ .iap-product-item {
54
+ padding: 20px 0;
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 6px;
58
+ }
59
+
60
+ .iap-product-info {
61
+ flex: 1;
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 16px;
65
+ }
66
+
67
+ .iap-product-icon {
68
+ width: 36px;
69
+ height: 36px;
70
+ border-radius: 6px;
71
+ object-fit: cover;
72
+ }
73
+
74
+ .iap-product-details {
75
+ display: flex;
76
+ flex-direction: column;
77
+ gap: 4px;
78
+ }
79
+
80
+ .iap-product-name {
81
+ font-size: 18px;
82
+ font-weight: 600;
83
+ color: #333d4b;
84
+ }
85
+
86
+ .iap-product-description,
87
+ .iap-product-amount {
88
+ font-size: 15px;
89
+ color: #8b95a1;
90
+ }
91
+
92
+ .iap-product-buy-btn {
93
+ height: fit-content;
94
+ padding: 10px;
95
+ color: #1368cc;
96
+ background-color: #ecf3ff;
97
+ border: 0;
98
+ border-radius: 8px;
99
+ font-size: 15px;
100
+ font-weight: 500;
101
+ cursor: pointer;
102
+ }
103
+
104
+ .iap-product-buy-btn:hover:not(:disabled) {
105
+ background-color: #d4e5ff;
106
+ }
107
+
108
+ .iap-product-buy-btn:disabled {
109
+ opacity: 0.7;
110
+ cursor: not-allowed;
111
+ }
112
+
113
+ .iap-back-btn {
114
+ margin: 16px 0;
115
+ }
@@ -0,0 +1,105 @@
1
+ import { createInAppPurchase } from "../lib/inAppPurchase.ts";
2
+ import "./InAppPurchasePage.css";
3
+
4
+ function escapeHtml(value: unknown) {
5
+ return String(value)
6
+ .replaceAll("&", "&amp;")
7
+ .replaceAll("<", "&lt;")
8
+ .replaceAll(">", "&gt;")
9
+ .replaceAll('"', "&quot;")
10
+ .replaceAll("'", "&#39;");
11
+ }
12
+
13
+ export function mountInAppPurchasePage(onBack: () => void) {
14
+ const root = document.getElementById("root");
15
+ if (!root) return;
16
+
17
+ const iap = createInAppPurchase();
18
+
19
+ function render() {
20
+ const { products, productsLoading, purchasingSku } = iap.getState();
21
+
22
+ if (productsLoading) {
23
+ root.innerHTML = `
24
+ <div class="app-header">
25
+ <h1 class="page-title">인앱결제</h1>
26
+ </div>
27
+ <p>상품을 불러오는 중...</p>
28
+ `;
29
+ return;
30
+ }
31
+
32
+ if (products.length === 0) {
33
+ root.innerHTML = `
34
+ <div class="app-header">
35
+ <h1 class="page-title">인앱결제</h1>
36
+ </div>
37
+ <div class="iap-empty-state">
38
+ <img class="iap-empty-state-icon" src="icon-document.png" aria-hidden alt="" />
39
+ <div class="iap-empty-state-content">
40
+ <h2 class="iap-empty-state-title">인앱 상품이 없어요</h2>
41
+ <p class="iap-empty-state-desc">콘솔 '인앱 결제' 메뉴에서 상품을 등록해 주세요.</p>
42
+ </div>
43
+ <button type="button" class="iap-empty-state-back-btn" data-action="back">홈으로</button>
44
+ </div>
45
+ `;
46
+ root.querySelector('[data-action="back"]')?.addEventListener("click", onBack);
47
+ return;
48
+ }
49
+
50
+ const productItems = products
51
+ .map(
52
+ (product) => `
53
+ <div class="iap-product-item">
54
+ <div class="iap-product-info">
55
+ ${
56
+ product.iconUrl
57
+ ? `<img class="iap-product-icon" src="${escapeHtml(product.iconUrl)}" alt="${escapeHtml(product.displayName)}" />`
58
+ : ""
59
+ }
60
+ <div class="iap-product-details">
61
+ <div class="iap-product-name">${escapeHtml(product.displayName)}</div>
62
+ ${
63
+ product.description
64
+ ? `<div class="iap-product-description">${escapeHtml(product.description)}</div>`
65
+ : ""
66
+ }
67
+ <div class="iap-product-amount">${escapeHtml(product.displayAmount)}</div>
68
+ </div>
69
+ </div>
70
+ <button
71
+ type="button"
72
+ class="iap-product-buy-btn"
73
+ data-sku="${escapeHtml(product.sku)}"
74
+ ${purchasingSku !== null ? "disabled" : ""}
75
+ >
76
+ ${purchasingSku === product.sku ? "결제 처리 중" : "구매하기"}
77
+ </button>
78
+ </div>
79
+ `,
80
+ )
81
+ .join("");
82
+
83
+ root.innerHTML = `
84
+ <div class="app-header">
85
+ <h1 class="page-title">인앱결제</h1>
86
+ </div>
87
+ <div class="iap-product-list">
88
+ ${productItems}
89
+ <button type="button" class="text-button iap-back-btn" data-action="back">← 홈으로</button>
90
+ </div>
91
+ `;
92
+
93
+ root.querySelector('[data-action="back"]')?.addEventListener("click", onBack);
94
+ root.querySelectorAll<HTMLButtonElement>("[data-sku]").forEach((button) => {
95
+ button.addEventListener("click", () => {
96
+ const sku = button.dataset.sku;
97
+ if (sku) iap.purchaseProduct(sku);
98
+ });
99
+ });
100
+ }
101
+
102
+ iap.subscribe(render);
103
+ iap.restorePendingOrders();
104
+ render();
105
+ }