create-ait-app 0.0.1

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 (35) hide show
  1. package/README.md +79 -0
  2. package/bin/index.js +4 -0
  3. package/package.json +33 -0
  4. package/src/main.js +534 -0
  5. package/template/README.md +26 -0
  6. package/template/__default/__samples/iaa/src/hooks/useInAppAds.tsx +122 -0
  7. package/template/__default/__samples/iaa/src/pages/InAppAdsPage.css +72 -0
  8. package/template/__default/__samples/iaa/src/pages/InAppAdsPage.tsx +79 -0
  9. package/template/__default/__samples/iap/public/icon-document.png +0 -0
  10. package/template/__default/__samples/iap/src/hooks/useInAppPurchase.ts +117 -0
  11. package/template/__default/__samples/iap/src/pages/InAppPurchasePage.css +115 -0
  12. package/template/__default/__samples/iap/src/pages/InAppPurchasePage.tsx +119 -0
  13. package/template/__default/src/App.css +104 -0
  14. package/template/__default/src/App.tsx +45 -0
  15. package/template/__default/src/index.css +27 -0
  16. package/template/__default/src/main.tsx +10 -0
  17. package/template/__tds/__samples/iaa/src/hooks/useInAppAds.tsx +132 -0
  18. package/template/__tds/__samples/iaa/src/pages/InAppAdsPage.tsx +92 -0
  19. package/template/__tds/__samples/iap/public/icon-document.png +0 -0
  20. package/template/__tds/__samples/iap/src/hooks/useInAppPurchase.ts +124 -0
  21. package/template/__tds/__samples/iap/src/pages/InAppPurchasePage.tsx +122 -0
  22. package/template/__tds/src/App.css +13 -0
  23. package/template/__tds/src/App.tsx +66 -0
  24. package/template/__tds/src/index.css +22 -0
  25. package/template/__tds/src/main.tsx +15 -0
  26. package/template/eslint.config.js +28 -0
  27. package/template/granite.config.ts +20 -0
  28. package/template/index.html +12 -0
  29. package/template/package.json +31 -0
  30. package/template/public/appsintoss-logo.png +0 -0
  31. package/template/src/vite-env.d.ts +6 -0
  32. package/template/tsconfig.app.json +22 -0
  33. package/template/tsconfig.json +7 -0
  34. package/template/tsconfig.node.json +20 -0
  35. package/template/vite.config.ts +6 -0
@@ -0,0 +1,122 @@
1
+ import {
2
+ loadFullScreenAd,
3
+ showFullScreenAd,
4
+ } from "@apps-in-toss/web-framework";
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
+
7
+ interface Reward {
8
+ unitType: string;
9
+ unitAmount: number;
10
+ }
11
+
12
+ interface UseInAppAdsReturn {
13
+ isAdLoaded: boolean;
14
+ isSupported: boolean;
15
+ showAd: () => void;
16
+ lastReward: Reward | null;
17
+ }
18
+
19
+ // 참고문서: https://developers-apps-in-toss.toss.im/ads/intro.html
20
+ export function useInAppAds(adGroupId: string): UseInAppAdsReturn {
21
+ const [isAdLoaded, setIsAdLoaded] = useState(false);
22
+ const [lastReward, setLastReward] = useState<Reward | null>(null);
23
+ const [isSupported, setIsSupported] = useState(false);
24
+ const unregisterRef = useRef<(() => void) | null>(null);
25
+
26
+ /**
27
+ * 광고를 로드합니다.
28
+ */
29
+ const load = useCallback(() => {
30
+ setIsAdLoaded(false);
31
+
32
+ try {
33
+ unregisterRef.current = loadFullScreenAd({
34
+ options: { adGroupId },
35
+ onEvent: (event) => {
36
+ if (event.type === "loaded") {
37
+ setIsAdLoaded(true);
38
+ }
39
+ },
40
+ onError: (error) => {
41
+ console.error("광고 로드 실패:", error);
42
+ },
43
+ });
44
+ } catch (error) {
45
+ alert(
46
+ "광고 로드 실패: \n\n- 인앱광고 기능은 브라우저가 아닌 샌드박스앱/토스앱에서 실행해주세요\n\n" +
47
+ error,
48
+ );
49
+ }
50
+ }, [adGroupId]);
51
+
52
+ useEffect(() => {
53
+ try {
54
+ setIsSupported(loadFullScreenAd.isSupported());
55
+
56
+ if (loadFullScreenAd.isSupported()) {
57
+ load();
58
+ }
59
+ } catch (error) {
60
+ console.error("광고 지원 여부 확인 실패:", error);
61
+ setIsSupported(false);
62
+ }
63
+
64
+ return () => {
65
+ try {
66
+ unregisterRef.current?.();
67
+ } catch (error) {
68
+ console.error("광고 정리(cleanup) 중 에러:", error);
69
+ }
70
+ };
71
+ }, [load]);
72
+
73
+ /**
74
+ * 광고를 실제로 화면에 표시합니다.
75
+ * - 지원되지 않는 환경이거나, 아직 로드되지 않은 경우에는 아무 동작도 하지 않습니다.
76
+ */
77
+ const showAd = useCallback(() => {
78
+ if (!isSupported) {
79
+ console.info("현재 환경에서는 인앱 광고가 지원되지 않습니다.");
80
+ return;
81
+ }
82
+
83
+ if (!isAdLoaded) {
84
+ console.info("아직 광고가 로드되지 않았습니다.");
85
+ return;
86
+ }
87
+
88
+ try {
89
+ showFullScreenAd({
90
+ options: { adGroupId },
91
+ onEvent: (event) => {
92
+ switch (event.type) {
93
+ case "userEarnedReward":
94
+ setLastReward(event.data);
95
+ break;
96
+ case "dismissed":
97
+ setIsAdLoaded(false);
98
+ load();
99
+ break;
100
+ case "failedToShow":
101
+ console.error("광고 표시 실패");
102
+ setIsAdLoaded(false);
103
+ // 실패한 경우에도 다시 로드를 시도해 다음 기회를 준비합니다.
104
+ load();
105
+ break;
106
+ }
107
+ },
108
+ onError: (error) => {
109
+ console.error("광고 표시 실패:", error);
110
+ setIsAdLoaded(false);
111
+ load();
112
+ },
113
+ });
114
+ } catch (error) {
115
+ console.error("광고 표시 실패:", error);
116
+ setIsAdLoaded(false);
117
+ load();
118
+ }
119
+ }, [adGroupId, isAdLoaded, isSupported, load]);
120
+
121
+ return { isAdLoaded, isSupported, showAd, lastReward };
122
+ }
@@ -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,79 @@
1
+ import { useInAppAds } from "../hooks/useInAppAds";
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
+ interface InAppAdsPageProps {
9
+ onBack: () => void;
10
+ }
11
+
12
+ export function InAppAdsPage({ onBack }: InAppAdsPageProps) {
13
+ const interstitial = useInAppAds(TEST_INTERSTITIAL_ID);
14
+ const rewarded = useInAppAds(TEST_REWARDED_ID);
15
+
16
+ return (
17
+ <>
18
+ <div className="app-header">
19
+ <h1 className="page-title">인앱광고</h1>
20
+ {!interstitial.isSupported && (
21
+ <p className="page-subtitle">
22
+ 이 환경에서는 인앱 광고를 사용할 수 없어요.
23
+ </p>
24
+ )}
25
+ </div>
26
+
27
+ <div className="iaa-section-list">
28
+ <div className="iaa-section">
29
+ <div className="iaa-section-row">
30
+ <div className="iaa-section-info">
31
+ <h2 className="iaa-section-title">전면형 광고</h2>
32
+ <p className="iaa-section-desc">화면 전체에 표시되는 광고</p>
33
+ </div>
34
+ <button
35
+ type="button"
36
+ className="iaa-section-button"
37
+ onClick={interstitial.showAd}
38
+ disabled={!interstitial.isAdLoaded}
39
+ >
40
+ {interstitial.isAdLoaded ? "보기" : "로딩 중"}
41
+ </button>
42
+ </div>
43
+ </div>
44
+
45
+ <div className="iaa-section">
46
+ <div className="iaa-section-row">
47
+ <div className="iaa-section-info">
48
+ <h2 className="iaa-section-title">보상형 광고</h2>
49
+ <p className="iaa-section-desc">시청 완료 시 보상을 받는 광고</p>
50
+ </div>
51
+ <button
52
+ type="button"
53
+ className="iaa-section-button"
54
+ onClick={rewarded.showAd}
55
+ disabled={!rewarded.isAdLoaded}
56
+ >
57
+ {rewarded.isAdLoaded ? "보기" : "로딩 중"}
58
+ </button>
59
+ </div>
60
+
61
+ {rewarded.lastReward && (
62
+ <p className="iaa-reward-message">
63
+ 보상 획득: {rewarded.lastReward.unitType}{" "}
64
+ {rewarded.lastReward.unitAmount}개
65
+ </p>
66
+ )}
67
+ </div>
68
+ </div>
69
+
70
+ <button
71
+ type="button"
72
+ className="text-button iaa-back-btn"
73
+ onClick={onBack}
74
+ >
75
+ ← 홈으로
76
+ </button>
77
+ </>
78
+ );
79
+ }
@@ -0,0 +1,117 @@
1
+ import type { IapProductListItem } from "@apps-in-toss/web-framework";
2
+ import { IAP } from "@apps-in-toss/web-framework";
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ interface UseInAppPurchaseReturn {
6
+ products: IapProductListItem[];
7
+ purchaseProduct: (sku: string) => void;
8
+ restorePendingOrders: () => Promise<void>;
9
+ /** 상품 목록 조회 중일 때 true */
10
+ productsLoading: boolean;
11
+ /** 결제 진행 중인 상품의 sku. 없으면 null */
12
+ purchasingSku: string | null;
13
+ }
14
+
15
+ // 참고문서: https://developers-apps-in-toss.toss.im/iap/intro.html
16
+ export function useInAppPurchase(): UseInAppPurchaseReturn {
17
+ const [products, setProducts] = useState<IapProductListItem[]>([]);
18
+ const [productsLoading, setProductsLoading] = useState(false);
19
+ const [purchasingSku, setPurchasingSku] = useState<string | null>(null);
20
+
21
+ useEffect(() => {
22
+ async function fetchProducts() {
23
+ setProductsLoading(true);
24
+
25
+ try {
26
+ const response = await IAP.getProductItemList();
27
+ const fetchedProducts = response?.products ?? [];
28
+
29
+ setProducts(fetchedProducts);
30
+ } catch (error) {
31
+ alert("상품 목록 조회 실패: \n\n-앱인토스 콘솔에서 미니앱을 생성후 인앱상품을 등록해주세요\n- 인앱결제 기능은 브라우저가 아닌 샌드박스앱/토스앱에서 실행해주세요\n\n" + error);
32
+ } finally {
33
+ setProductsLoading(false);
34
+ }
35
+ }
36
+
37
+ fetchProducts();
38
+ }, []);
39
+
40
+ const grantProduct = useCallback((orderId: string) => {
41
+ // TODO: 여기에 상품 지급 로직을 작성해주세요.
42
+
43
+ console.info(`상품 지급 처리: ${orderId}`);
44
+ }, []);
45
+
46
+ /**
47
+ * 인앱상품을 결제합니다.
48
+ * 앱에서 호출해주세요.
49
+ * @param sku - 결제할 상품의 고유 식별자
50
+ * @returns void
51
+ */
52
+ const purchaseProduct = useCallback(
53
+ (sku: string) => {
54
+ setPurchasingSku(sku);
55
+
56
+ try {
57
+ const cleanup = IAP.createOneTimePurchaseOrder({
58
+ options: {
59
+ sku,
60
+ processProductGrant: ({ orderId }) => {
61
+ grantProduct(orderId);
62
+
63
+ console.info(`상품 지급 처리: ${orderId}`);
64
+ return true;
65
+ },
66
+ },
67
+ onEvent: (event) => {
68
+ if (event.type === "success") {
69
+ alert(`${event.data.displayName}이 결제되었어요.`);
70
+ }
71
+
72
+ setPurchasingSku(null);
73
+ cleanup();
74
+ },
75
+ onError: (error) => {
76
+ console.error("인앱결제 실패:", error);
77
+ setPurchasingSku(null);
78
+ cleanup();
79
+ },
80
+ });
81
+ } catch (error) {
82
+ console.error("인앱결제 실패:", error);
83
+ setPurchasingSku(null);
84
+ }
85
+ },
86
+ [grantProduct],
87
+ );
88
+
89
+ /**
90
+ * 결제 완료되었지만 상품 지급이 이뤄지지 않은 미결 주문을 처리합니다.
91
+ * 앱에서 호출해주세요.
92
+ * @returns void
93
+ */
94
+ const restorePendingOrders = useCallback(async () => {
95
+ try {
96
+ const pending = await IAP.getPendingOrders();
97
+ const orders = pending?.orders ?? [];
98
+
99
+ for (const order of orders) {
100
+ grantProduct(order.orderId);
101
+
102
+ await IAP.completeProductGrant({ params: { orderId: order.orderId } });
103
+ console.info("미결 주문 복원 완료:", order.orderId);
104
+ }
105
+ } catch (error) {
106
+ console.error("주문 복원 실패:", error);
107
+ }
108
+ }, [grantProduct]);
109
+
110
+ return {
111
+ products,
112
+ purchaseProduct,
113
+ restorePendingOrders,
114
+ productsLoading,
115
+ purchasingSku,
116
+ };
117
+ }
@@ -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,119 @@
1
+ import { useEffect } from "react";
2
+
3
+ import { useInAppPurchase } from "../hooks/useInAppPurchase";
4
+ import "./InAppPurchasePage.css";
5
+
6
+ interface InAppPurchasePageProps {
7
+ onBack: () => void;
8
+ }
9
+
10
+ export function InAppPurchasePage({ onBack }: InAppPurchasePageProps) {
11
+ const {
12
+ products,
13
+ purchaseProduct,
14
+ restorePendingOrders,
15
+ productsLoading,
16
+ purchasingSku,
17
+ } = useInAppPurchase();
18
+
19
+ useEffect(() => {
20
+ restorePendingOrders();
21
+ }, [restorePendingOrders]);
22
+
23
+ if (productsLoading) {
24
+ return (
25
+ <>
26
+ <div className="app-header">
27
+ <h1 className="page-title">인앱결제</h1>
28
+ </div>
29
+
30
+ <p>상품을 불러오는 중...</p>
31
+ </>
32
+ );
33
+ }
34
+
35
+ if (products.length === 0) {
36
+ return (
37
+ <>
38
+ <div className="app-header">
39
+ <h1 className="page-title">인앱결제</h1>
40
+ </div>
41
+
42
+ <div className="iap-empty-state">
43
+ <img
44
+ className="iap-empty-state-icon"
45
+ src="icon-document.png"
46
+ aria-hidden
47
+ alt=""
48
+ />
49
+ <div className="iap-empty-state-content">
50
+ <h2 className="iap-empty-state-title">인앱 상품이 없어요</h2>
51
+ <p className="iap-empty-state-desc">
52
+ 콘솔 '인앱 결제' 메뉴에서 상품을 등록해 주세요.
53
+ </p>
54
+ </div>
55
+
56
+ <button
57
+ type="button"
58
+ className="iap-empty-state-back-btn"
59
+ onClick={onBack}
60
+ >
61
+ 홈으로
62
+ </button>
63
+ </div>
64
+ </>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <>
70
+ <div className="app-header">
71
+ <h1 className="page-title">인앱결제</h1>
72
+ </div>
73
+
74
+ <div className="iap-product-list">
75
+ {products.map((product) => (
76
+ <div key={product.sku} className="iap-product-item">
77
+ <div className="iap-product-info">
78
+ {product.iconUrl && (
79
+ <img
80
+ className="iap-product-icon"
81
+ src={product.iconUrl}
82
+ alt={product.displayName}
83
+ />
84
+ )}
85
+ <div className="iap-product-details">
86
+ <div className="iap-product-name">{product.displayName}</div>
87
+ {product.description && (
88
+ <div className="iap-product-description">
89
+ {product.description}
90
+ </div>
91
+ )}
92
+ <div className="iap-product-amount">
93
+ {product.displayAmount}
94
+ </div>
95
+ </div>
96
+ </div>
97
+
98
+ <button
99
+ type="button"
100
+ className="iap-product-buy-btn"
101
+ onClick={() => purchaseProduct(product.sku)}
102
+ disabled={purchasingSku !== null}
103
+ >
104
+ {purchasingSku === product.sku ? "결제 처리 중" : "구매하기"}
105
+ </button>
106
+ </div>
107
+ ))}
108
+
109
+ <button
110
+ type="button"
111
+ className="text-button iap-back-btn"
112
+ onClick={onBack}
113
+ >
114
+ ← 홈으로
115
+ </button>
116
+ </div>
117
+ </>
118
+ );
119
+ }