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,102 @@
1
+ import {
2
+ loadFullScreenAd,
3
+ showFullScreenAd,
4
+ } from "@apps-in-toss/web-framework";
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
+
7
+ // 참고문서: https://developers-apps-in-toss.toss.im/ads/intro.html
8
+ export function useInAppAds(adGroupId) {
9
+ const [isAdLoaded, setIsAdLoaded] = useState(false);
10
+ const [lastReward, setLastReward] = useState(null);
11
+ const [isSupported, setIsSupported] = useState(false);
12
+ const unregisterRef = useRef(null);
13
+
14
+ const load = useCallback(() => {
15
+ setIsAdLoaded(false);
16
+
17
+ try {
18
+ unregisterRef.current = loadFullScreenAd({
19
+ options: { adGroupId },
20
+ onEvent: (event) => {
21
+ if (event.type === "loaded") {
22
+ setIsAdLoaded(true);
23
+ }
24
+ },
25
+ onError: (error) => {
26
+ console.error("광고 로드 실패:", error);
27
+ },
28
+ });
29
+ } catch (error) {
30
+ alert(
31
+ "광고 로드 실패: \n\n- 인앱광고 기능은 브라우저가 아닌 샌드박스앱/토스앱에서 실행해주세요\n\n" +
32
+ error,
33
+ );
34
+ }
35
+ }, [adGroupId]);
36
+
37
+ useEffect(() => {
38
+ try {
39
+ setIsSupported(loadFullScreenAd.isSupported());
40
+
41
+ if (loadFullScreenAd.isSupported()) {
42
+ load();
43
+ }
44
+ } catch (error) {
45
+ console.error("광고 지원 여부 확인 실패:", error);
46
+ setIsSupported(false);
47
+ }
48
+
49
+ return () => {
50
+ try {
51
+ unregisterRef.current?.();
52
+ } catch (error) {
53
+ console.error("광고 정리(cleanup) 중 에러:", error);
54
+ }
55
+ };
56
+ }, [load]);
57
+
58
+ const showAd = useCallback(() => {
59
+ if (!isSupported) {
60
+ console.info("현재 환경에서는 인앱 광고가 지원되지 않습니다.");
61
+ return;
62
+ }
63
+
64
+ if (!isAdLoaded) {
65
+ console.info("아직 광고가 로드되지 않았습니다.");
66
+ return;
67
+ }
68
+
69
+ try {
70
+ showFullScreenAd({
71
+ options: { adGroupId },
72
+ onEvent: (event) => {
73
+ switch (event.type) {
74
+ case "userEarnedReward":
75
+ setLastReward(event.data);
76
+ break;
77
+ case "dismissed":
78
+ setIsAdLoaded(false);
79
+ load();
80
+ break;
81
+ case "failedToShow":
82
+ console.error("광고 표시 실패");
83
+ setIsAdLoaded(false);
84
+ load();
85
+ break;
86
+ }
87
+ },
88
+ onError: (error) => {
89
+ console.error("광고 표시 실패:", error);
90
+ setIsAdLoaded(false);
91
+ load();
92
+ },
93
+ });
94
+ } catch (error) {
95
+ console.error("광고 표시 실패:", error);
96
+ setIsAdLoaded(false);
97
+ load();
98
+ }
99
+ }, [adGroupId, isAdLoaded, isSupported, load]);
100
+
101
+ return { isAdLoaded, isSupported, showAd, lastReward };
102
+ }
@@ -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,75 @@
1
+ import { useInAppAds } from "../hooks/useInAppAds.js";
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 InAppAdsPage({ onBack }) {
9
+ const interstitial = useInAppAds(TEST_INTERSTITIAL_ID);
10
+ const rewarded = useInAppAds(TEST_REWARDED_ID);
11
+
12
+ return (
13
+ <>
14
+ <div className="app-header">
15
+ <h1 className="page-title">인앱광고</h1>
16
+ {!interstitial.isSupported && (
17
+ <p className="page-subtitle">
18
+ 이 환경에서는 인앱 광고를 사용할 수 없어요.
19
+ </p>
20
+ )}
21
+ </div>
22
+
23
+ <div className="iaa-section-list">
24
+ <div className="iaa-section">
25
+ <div className="iaa-section-row">
26
+ <div className="iaa-section-info">
27
+ <h2 className="iaa-section-title">전면형 광고</h2>
28
+ <p className="iaa-section-desc">화면 전체에 표시되는 광고</p>
29
+ </div>
30
+ <button
31
+ type="button"
32
+ className="iaa-section-button"
33
+ onClick={interstitial.showAd}
34
+ disabled={!interstitial.isAdLoaded}
35
+ >
36
+ {interstitial.isAdLoaded ? "보기" : "로딩 중"}
37
+ </button>
38
+ </div>
39
+ </div>
40
+
41
+ <div className="iaa-section">
42
+ <div className="iaa-section-row">
43
+ <div className="iaa-section-info">
44
+ <h2 className="iaa-section-title">보상형 광고</h2>
45
+ <p className="iaa-section-desc">시청 완료 시 보상을 받는 광고</p>
46
+ </div>
47
+ <button
48
+ type="button"
49
+ className="iaa-section-button"
50
+ onClick={rewarded.showAd}
51
+ disabled={!rewarded.isAdLoaded}
52
+ >
53
+ {rewarded.isAdLoaded ? "보기" : "로딩 중"}
54
+ </button>
55
+ </div>
56
+
57
+ {rewarded.lastReward && (
58
+ <p className="iaa-reward-message">
59
+ 보상 획득: {rewarded.lastReward.unitType}{" "}
60
+ {rewarded.lastReward.unitAmount}개
61
+ </p>
62
+ )}
63
+ </div>
64
+ </div>
65
+
66
+ <button
67
+ type="button"
68
+ className="text-button iaa-back-btn"
69
+ onClick={onBack}
70
+ >
71
+ ← 홈으로
72
+ </button>
73
+ </>
74
+ );
75
+ }
@@ -0,0 +1,95 @@
1
+ import { IAP } from "@apps-in-toss/web-framework";
2
+ import { useCallback, useEffect, useState } from "react";
3
+
4
+ // 참고문서: https://developers-apps-in-toss.toss.im/iap/intro.html
5
+ export function useInAppPurchase() {
6
+ const [products, setProducts] = useState([]);
7
+ const [productsLoading, setProductsLoading] = useState(false);
8
+ const [purchasingSku, setPurchasingSku] = useState(null);
9
+
10
+ useEffect(() => {
11
+ async function fetchProducts() {
12
+ setProductsLoading(true);
13
+
14
+ try {
15
+ const response = await IAP.getProductItemList();
16
+ const fetchedProducts = response?.products ?? [];
17
+
18
+ setProducts(fetchedProducts);
19
+ } catch (error) {
20
+ alert(
21
+ "상품 목록 조회 실패: \n\n-앱인토스 콘솔에서 미니앱을 생성후 인앱상품을 등록해주세요\n- 인앱결제 기능은 브라우저가 아닌 샌드박스앱/토스앱에서 실행해주세요\n\n" +
22
+ error,
23
+ );
24
+ } finally {
25
+ setProductsLoading(false);
26
+ }
27
+ }
28
+
29
+ fetchProducts();
30
+ }, []);
31
+
32
+ const grantProduct = useCallback((orderId) => {
33
+ // TODO: 여기에 상품 지급 로직을 작성해주세요.
34
+ console.info(`상품 지급 처리: ${orderId}`);
35
+ }, []);
36
+
37
+ const purchaseProduct = useCallback(
38
+ (sku) => {
39
+ setPurchasingSku(sku);
40
+
41
+ try {
42
+ const cleanup = IAP.createOneTimePurchaseOrder({
43
+ options: {
44
+ sku,
45
+ processProductGrant: ({ orderId }) => {
46
+ grantProduct(orderId);
47
+ console.info(`상품 지급 처리: ${orderId}`);
48
+ return true;
49
+ },
50
+ },
51
+ onEvent: (event) => {
52
+ if (event.type === "success") {
53
+ alert(`${event.data.displayName}이 결제되었어요.`);
54
+ }
55
+
56
+ setPurchasingSku(null);
57
+ cleanup();
58
+ },
59
+ onError: (error) => {
60
+ console.error("인앱결제 실패:", error);
61
+ setPurchasingSku(null);
62
+ cleanup();
63
+ },
64
+ });
65
+ } catch (error) {
66
+ console.error("인앱결제 실패:", error);
67
+ setPurchasingSku(null);
68
+ }
69
+ },
70
+ [grantProduct],
71
+ );
72
+
73
+ const restorePendingOrders = useCallback(async () => {
74
+ try {
75
+ const pending = await IAP.getPendingOrders();
76
+ const orders = pending?.orders ?? [];
77
+
78
+ for (const order of orders) {
79
+ grantProduct(order.orderId);
80
+ await IAP.completeProductGrant({ params: { orderId: order.orderId } });
81
+ console.info("미결 주문 복원 완료:", order.orderId);
82
+ }
83
+ } catch (error) {
84
+ console.error("주문 복원 실패:", error);
85
+ }
86
+ }, [grantProduct]);
87
+
88
+ return {
89
+ products,
90
+ purchaseProduct,
91
+ restorePendingOrders,
92
+ productsLoading,
93
+ purchasingSku,
94
+ };
95
+ }
@@ -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,115 @@
1
+ import { useEffect } from "react";
2
+
3
+ import { useInAppPurchase } from "../hooks/useInAppPurchase.js";
4
+ import "./InAppPurchasePage.css";
5
+
6
+ export function InAppPurchasePage({ onBack }) {
7
+ const {
8
+ products,
9
+ purchaseProduct,
10
+ restorePendingOrders,
11
+ productsLoading,
12
+ purchasingSku,
13
+ } = useInAppPurchase();
14
+
15
+ useEffect(() => {
16
+ restorePendingOrders();
17
+ }, [restorePendingOrders]);
18
+
19
+ if (productsLoading) {
20
+ return (
21
+ <>
22
+ <div className="app-header">
23
+ <h1 className="page-title">인앱결제</h1>
24
+ </div>
25
+
26
+ <p>상품을 불러오는 중...</p>
27
+ </>
28
+ );
29
+ }
30
+
31
+ if (products.length === 0) {
32
+ return (
33
+ <>
34
+ <div className="app-header">
35
+ <h1 className="page-title">인앱결제</h1>
36
+ </div>
37
+
38
+ <div className="iap-empty-state">
39
+ <img
40
+ className="iap-empty-state-icon"
41
+ src="icon-document.png"
42
+ aria-hidden
43
+ alt=""
44
+ />
45
+ <div className="iap-empty-state-content">
46
+ <h2 className="iap-empty-state-title">인앱 상품이 없어요</h2>
47
+ <p className="iap-empty-state-desc">
48
+ 콘솔 '인앱 결제' 메뉴에서 상품을 등록해 주세요.
49
+ </p>
50
+ </div>
51
+
52
+ <button
53
+ type="button"
54
+ className="iap-empty-state-back-btn"
55
+ onClick={onBack}
56
+ >
57
+ 홈으로
58
+ </button>
59
+ </div>
60
+ </>
61
+ );
62
+ }
63
+
64
+ return (
65
+ <>
66
+ <div className="app-header">
67
+ <h1 className="page-title">인앱결제</h1>
68
+ </div>
69
+
70
+ <div className="iap-product-list">
71
+ {products.map((product) => (
72
+ <div key={product.sku} className="iap-product-item">
73
+ <div className="iap-product-info">
74
+ {product.iconUrl && (
75
+ <img
76
+ className="iap-product-icon"
77
+ src={product.iconUrl}
78
+ alt={product.displayName}
79
+ />
80
+ )}
81
+ <div className="iap-product-details">
82
+ <div className="iap-product-name">{product.displayName}</div>
83
+ {product.description && (
84
+ <div className="iap-product-description">
85
+ {product.description}
86
+ </div>
87
+ )}
88
+ <div className="iap-product-amount">
89
+ {product.displayAmount}
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <button
95
+ type="button"
96
+ className="iap-product-buy-btn"
97
+ onClick={() => purchaseProduct(product.sku)}
98
+ disabled={purchasingSku !== null}
99
+ >
100
+ {purchasingSku === product.sku ? "결제 처리 중" : "구매하기"}
101
+ </button>
102
+ </div>
103
+ ))}
104
+
105
+ <button
106
+ type="button"
107
+ className="text-button iap-back-btn"
108
+ onClick={onBack}
109
+ >
110
+ ← 홈으로
111
+ </button>
112
+ </div>
113
+ </>
114
+ );
115
+ }
@@ -0,0 +1,104 @@
1
+ #root {
2
+ width: 100%;
3
+ min-height: 100vh;
4
+ box-sizing: border-box;
5
+ padding: 24px;
6
+ }
7
+
8
+ @media (min-width: 481px) {
9
+ #root {
10
+ background-color: #ffffff;
11
+ max-width: 480px;
12
+ margin: 0 auto;
13
+ }
14
+ }
15
+
16
+ .app {
17
+ display: flex;
18
+ flex-direction: column;
19
+ }
20
+
21
+ .app-header {
22
+ display: flex;
23
+ flex-direction: column;
24
+ gap: 12px;
25
+ padding: 24px 0;
26
+ }
27
+
28
+ .page-title {
29
+ margin: 0;
30
+ font-size: 24px;
31
+ font-weight: 600;
32
+ color: #333d4b;
33
+ }
34
+
35
+ .page-subtitle {
36
+ margin: 0;
37
+ font-size: 18px;
38
+ color: #4e5968;
39
+ }
40
+
41
+ .app-actions {
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: 16px;
45
+ padding: 24px 0;
46
+ }
47
+
48
+ .app-button {
49
+ width: 100%;
50
+ border: none;
51
+ border-radius: 16px;
52
+ padding: 20px;
53
+ font-size: 18px;
54
+ font-weight: 500;
55
+ cursor: pointer;
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ box-sizing: border-box;
60
+ text-decoration: none;
61
+ }
62
+
63
+ .app-button:active {
64
+ transform: scale(0.96);
65
+ transition: transform 0.1s ease;
66
+ }
67
+
68
+ .app-button-primary {
69
+ background-color: #ebf2ff;
70
+ color: #2272eb;
71
+ }
72
+
73
+ .app-button-primary:hover {
74
+ background-color: #c9e2ff;
75
+ }
76
+
77
+ .app-button-ghost {
78
+ background-color: #f2f4f6;
79
+ color: #4b5563;
80
+ }
81
+
82
+ .app-button-ghost:hover {
83
+ background-color: #e5e8eb;
84
+ }
85
+
86
+ .text-button {
87
+ background: none;
88
+ border: none;
89
+ cursor: pointer;
90
+ font-size: 18px;
91
+ color: #3182f6;
92
+ }
93
+
94
+ .app-logo-wrap {
95
+ position: fixed;
96
+ bottom: 24px;
97
+ left: 50%;
98
+ transform: translateX(-50%);
99
+ }
100
+
101
+ .logo {
102
+ width: 160px;
103
+ height: auto;
104
+ }