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,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
+ }
@@ -0,0 +1,45 @@
1
+ import "./App.css";
2
+ {{SAMPLE_IMPORTS}}
3
+
4
+
5
+ function App() {
6
+ {{PAGE_STATE_AND_ROUTES}}
7
+ return (
8
+ <div className="app">
9
+ <header className="app-header">
10
+ <h1 className="page-title">반가워요</h1>
11
+ <p className="page-subtitle">앱인토스 개발을 시작해 보세요.</p>
12
+ </header>
13
+
14
+ <div className="app-actions">
15
+ <a
16
+ className="app-button app-button-primary"
17
+ href="https://developers-apps-in-toss.toss.im"
18
+ target="_blank"
19
+ rel="noopener noreferrer"
20
+ >
21
+ 개발자센터
22
+ </a>
23
+ <a
24
+ className="app-button app-button-primary"
25
+ href="https://techchat-apps-in-toss.toss.im"
26
+ target="_blank"
27
+ rel="noopener noreferrer"
28
+ >
29
+ 개발자 커뮤니티
30
+ </a>
31
+ {{SAMPLE_BUTTONS}}
32
+ </div>
33
+
34
+ <div className="app-logo-wrap">
35
+ <img
36
+ className="logo"
37
+ src={`${import.meta.env.BASE_URL}appsintoss-logo.png`}
38
+ alt="apps in toss"
39
+ />
40
+ </div>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ export default App;
@@ -0,0 +1,27 @@
1
+ :root {
2
+ font-family: sans-serif;
3
+ }
4
+
5
+ * {
6
+ -webkit-tap-highlight-color: transparent;
7
+ }
8
+
9
+ body {
10
+ margin: 0;
11
+ min-width: 320px;
12
+ min-height: 100vh;
13
+ }
14
+
15
+ @media (min-width: 481px) {
16
+ body {
17
+ display: flex;
18
+ justify-content: center;
19
+ align-items: flex-start;
20
+ background-color: #f5f5f5;
21
+ }
22
+ }
23
+
24
+ h1 {
25
+ font-size: 3.2em;
26
+ line-height: 1.1;
27
+ }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
@@ -0,0 +1,132 @@
1
+ import {
2
+ loadFullScreenAd,
3
+ showFullScreenAd,
4
+ } from "@apps-in-toss/web-framework";
5
+ import { useDialog, useToast } from "@toss/tds-mobile";
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+
8
+ interface Reward {
9
+ unitType: string;
10
+ unitAmount: number;
11
+ }
12
+
13
+ interface UseInAppAdsReturn {
14
+ isAdLoaded: boolean;
15
+ isSupported: boolean;
16
+ showAd: () => void;
17
+ lastReward: Reward | null;
18
+ }
19
+
20
+ // 참고문서: https://developers-apps-in-toss.toss.im/ads/intro.html
21
+ export function useInAppAds(adGroupId: string): UseInAppAdsReturn {
22
+ const dialog = useDialog();
23
+ const toast = useToast();
24
+
25
+ const [isAdLoaded, setIsAdLoaded] = useState(false);
26
+ const [lastReward, setLastReward] = useState<Reward | null>(null);
27
+ const [isSupported, setIsSupported] = useState(false);
28
+ const unregisterRef = useRef<(() => void) | null>(null);
29
+
30
+ /**
31
+ * 광고를 로드합니다.
32
+ */
33
+ const load = useCallback(() => {
34
+ setIsAdLoaded(false);
35
+
36
+ try {
37
+ unregisterRef.current = loadFullScreenAd({
38
+ options: { adGroupId },
39
+ onEvent: (event) => {
40
+ if (event.type === "loaded") {
41
+ setIsAdLoaded(true);
42
+ }
43
+ },
44
+ onError: (error) => {
45
+ console.error("광고 로드 실패:", error);
46
+ },
47
+ });
48
+ } catch (error) {
49
+ console.error("광고 로드 실패:", error);
50
+ setIsAdLoaded(false);
51
+ }
52
+ }, [adGroupId]);
53
+
54
+ useEffect(() => {
55
+ try {
56
+ setIsSupported(loadFullScreenAd.isSupported());
57
+
58
+ if (loadFullScreenAd.isSupported()) {
59
+ load();
60
+ }
61
+ } catch (error) {
62
+ dialog.openAlert({
63
+ title: "광고 지원 여부 확인 실패",
64
+ description:
65
+ "광고 지원 여부 확인 실패: \n\n- 인앱광고 기능은 브라우저가 아닌 샌드박스앱/토스앱에서 실행해주세요.\n\n" +
66
+ error,
67
+ });
68
+ setIsSupported(false);
69
+ }
70
+
71
+ return () => {
72
+ try {
73
+ unregisterRef.current?.();
74
+ } catch (error) {
75
+ console.error("광고 정리(cleanup) 중 에러:", error);
76
+ }
77
+ };
78
+ }, []);
79
+
80
+ /**
81
+ * 광고를 실제로 화면에 표시합니다.
82
+ * - 지원되지 않는 환경이거나, 아직 로드되지 않은 경우에는 아무 동작도 하지 않습니다.
83
+ */
84
+ const showAd = useCallback(() => {
85
+ if (!isSupported) {
86
+ console.info("현재 환경에서는 인앱 광고가 지원되지 않습니다.");
87
+ return;
88
+ }
89
+
90
+ if (!isAdLoaded) {
91
+ console.info("아직 광고가 로드되지 않았습니다.");
92
+ return;
93
+ }
94
+
95
+ try {
96
+ showFullScreenAd({
97
+ options: { adGroupId },
98
+ onEvent: (event) => {
99
+ switch (event.type) {
100
+ case "userEarnedReward":
101
+ toast.openToast(
102
+ `보상 획득: ${event.data.unitType} ${event.data.unitAmount}개`,
103
+ );
104
+ setLastReward(event.data);
105
+ break;
106
+ case "dismissed":
107
+ setIsAdLoaded(false);
108
+ load();
109
+ break;
110
+ case "failedToShow":
111
+ console.error("광고 표시 실패");
112
+ setIsAdLoaded(false);
113
+ // 실패한 경우에도 다시 로드를 시도해 다음 기회를 준비합니다.
114
+ load();
115
+ break;
116
+ }
117
+ },
118
+ onError: (error) => {
119
+ console.error("광고 표시 실패:", error);
120
+ setIsAdLoaded(false);
121
+ load();
122
+ },
123
+ });
124
+ } catch (error) {
125
+ console.error("광고 표시 실패:", error);
126
+ setIsAdLoaded(false);
127
+ load();
128
+ }
129
+ }, [adGroupId, isAdLoaded, isSupported, load]);
130
+
131
+ return { isAdLoaded, isSupported, showAd, lastReward };
132
+ }
@@ -0,0 +1,92 @@
1
+ import { colors } from "@toss/tds-colors";
2
+ import { Button, List, ListRow, TextButton, Top } from "@toss/tds-mobile";
3
+
4
+ import { useInAppAds } from "../hooks/useInAppAds";
5
+
6
+ // TODO: 서비스를 출시하기 전에 앱인토스 콘솔에서 발급한 광고그룹ID로 변경해주세요.
7
+ const TEST_INTERSTITIAL_ID = "ait-ad-test-interstitial-id";
8
+ const TEST_REWARDED_ID = "ait-ad-test-rewarded-id";
9
+
10
+ interface InAppAdsPageProps {
11
+ onBack: () => void;
12
+ }
13
+
14
+ export function InAppAdsPage({ onBack }: InAppAdsPageProps) {
15
+ const interstitial = useInAppAds(TEST_INTERSTITIAL_ID);
16
+ const rewarded = useInAppAds(TEST_REWARDED_ID);
17
+
18
+ return (
19
+ <>
20
+ <Top
21
+ title={<Top.TitleParagraph size={22}>인앱광고</Top.TitleParagraph>}
22
+ subtitleBottom={
23
+ !interstitial.isSupported && (
24
+ <Top.SubtitleParagraph
25
+ size={17}
26
+ style={{ overflow: `visible`, display: `block` }}
27
+ >
28
+ 이 환경에서는 인앱 광고를 사용할 수 없어요.
29
+ </Top.SubtitleParagraph>
30
+ )
31
+ }
32
+ />
33
+
34
+ <List>
35
+ <ListRow
36
+ verticalPadding="large"
37
+ contents={
38
+ <ListRow.Texts
39
+ type="2RowTypeA"
40
+ top="전면형 광고"
41
+ topProps={{ color: colors.grey800, fontWeight: "bold" }}
42
+ bottom="화면 전체에 표시되는 광고"
43
+ bottomProps={{ color: colors.grey600 }}
44
+ />
45
+ }
46
+ right={
47
+ <Button
48
+ size="small"
49
+ variant="weak"
50
+ loading={!interstitial.isAdLoaded}
51
+ onClick={interstitial.showAd}
52
+ >
53
+ 보기
54
+ </Button>
55
+ }
56
+ />
57
+
58
+ <ListRow
59
+ verticalPadding="large"
60
+ contents={
61
+ <ListRow.Texts
62
+ type="2RowTypeA"
63
+ top="보상형 광고"
64
+ topProps={{ color: colors.grey800, fontWeight: "bold" }}
65
+ bottom="시청 완료 시 보상을 받는 광고"
66
+ bottomProps={{ color: colors.grey600 }}
67
+ />
68
+ }
69
+ right={
70
+ <Button
71
+ size="small"
72
+ variant="weak"
73
+ loading={!rewarded.isAdLoaded}
74
+ onClick={rewarded.showAd}
75
+ >
76
+ 보기
77
+ </Button>
78
+ }
79
+ />
80
+ </List>
81
+
82
+ <TextButton
83
+ style={{ padding: "16px 24px" }}
84
+ size="medium"
85
+ color={colors.blue500}
86
+ onClick={onBack}
87
+ >
88
+ ← 홈으로
89
+ </TextButton>
90
+ </>
91
+ );
92
+ }
@@ -0,0 +1,124 @@
1
+ import type { IapProductListItem } from "@apps-in-toss/web-framework";
2
+ import { IAP } from "@apps-in-toss/web-framework";
3
+ import { useDialog } from "@toss/tds-mobile";
4
+ import { useCallback, useEffect, useState } from "react";
5
+
6
+ interface UseInAppPurchaseReturn {
7
+ products: IapProductListItem[];
8
+ purchaseProduct: (sku: string) => void;
9
+ restorePendingOrders: () => Promise<void>;
10
+ /** 상품 목록 조회 중일 때 true */
11
+ productsLoading: boolean;
12
+ /** 결제 진행 중인 상품의 sku. 없으면 null */
13
+ purchasingSku: string | null;
14
+ }
15
+
16
+ // 참고문서: https://developers-apps-in-toss.toss.im/iap/intro.html
17
+ export function useInAppPurchase(): UseInAppPurchaseReturn {
18
+ const dialog = useDialog();
19
+
20
+ const [products, setProducts] = useState<IapProductListItem[]>([]);
21
+ const [productsLoading, setProductsLoading] = useState(false);
22
+ const [purchasingSku, setPurchasingSku] = useState<string | null>(null);
23
+
24
+ useEffect(() => {
25
+ async function fetchProducts() {
26
+ setProductsLoading(true);
27
+
28
+ try {
29
+ const response = await IAP.getProductItemList();
30
+ const fetchedProducts = response?.products ?? [];
31
+
32
+ setProducts(fetchedProducts);
33
+ } catch (error) {
34
+ dialog.openAlert({
35
+ title: "상품 목록 조회 실패",
36
+ description: `상품 목록 조회 실패: \n\n- 앱인토스 콘솔에서 미니앱을 생성후 인앱상품을 등록해주세요.\n- 인앱결제 기능은 브라우저가 아닌 샌드박스앱/토스앱에서 실행해주세요.\n\n${error}`,
37
+ });
38
+ } finally {
39
+ setProductsLoading(false);
40
+ }
41
+ }
42
+
43
+ fetchProducts();
44
+ }, []);
45
+
46
+ const grantProduct = useCallback((orderId: string) => {
47
+ // TODO: 여기에 상품 지급 로직을 작성해주세요.
48
+
49
+ console.info(`상품 지급 처리: ${orderId}`);
50
+ }, []);
51
+
52
+ /**
53
+ * 인앱상품을 결제합니다.
54
+ * 앱에서 호출해주세요.
55
+ * @param sku - 결제할 상품의 고유 식별자
56
+ * @returns void
57
+ */
58
+ const purchaseProduct = useCallback(
59
+ (sku: string) => {
60
+ setPurchasingSku(sku);
61
+
62
+ try {
63
+ const cleanup = IAP.createOneTimePurchaseOrder({
64
+ options: {
65
+ sku,
66
+ processProductGrant: ({ orderId }) => {
67
+ grantProduct(orderId);
68
+
69
+ console.info(`상품 지급 처리: ${orderId}`);
70
+ return true;
71
+ },
72
+ },
73
+ onEvent: (event) => {
74
+ if (event.type === "success") {
75
+ alert(`${event.data.displayName}이 결제되었어요.`);
76
+ }
77
+
78
+ setPurchasingSku(null);
79
+ cleanup();
80
+ },
81
+ onError: (error) => {
82
+ console.error("인앱결제 실패:", error);
83
+ setPurchasingSku(null);
84
+ cleanup();
85
+ },
86
+ });
87
+ } catch (error) {
88
+ console.error("인앱결제 실패:", error);
89
+ setPurchasingSku(null);
90
+ }
91
+ },
92
+ [grantProduct],
93
+ );
94
+
95
+ /**
96
+ * 결제 완료되었지만 상품 지급이 이뤄지지 않은 미결 주문을 처리합니다.
97
+ * 앱에서 호출해주세요.
98
+ * @returns void
99
+ */
100
+ const restorePendingOrders = useCallback(async () => {
101
+ try {
102
+ const pending = await IAP.getPendingOrders();
103
+ const orders = pending?.orders ?? [];
104
+
105
+ for (const order of orders) {
106
+ grantProduct(order.orderId);
107
+
108
+ await IAP.completeProductGrant({ params: { orderId: order.orderId } });
109
+ console.info("미결 주문 복원 완료:", order.orderId);
110
+ }
111
+ } catch (error) {
112
+ console.error("주문 복원 실패:", error);
113
+ }
114
+ }, [grantProduct]);
115
+
116
+ return {
117
+ products,
118
+ purchaseProduct,
119
+ restorePendingOrders,
120
+ productsLoading,
121
+ purchasingSku,
122
+ };
123
+ }
124
+
@@ -0,0 +1,122 @@
1
+ import { colors } from "@toss/tds-colors";
2
+ import {
3
+ Asset,
4
+ Button,
5
+ List,
6
+ ListRow,
7
+ Result,
8
+ Text,
9
+ TextButton,
10
+ Top,
11
+ } from "@toss/tds-mobile";
12
+ import { useEffect } from "react";
13
+
14
+ import { useInAppPurchase } from "../hooks/useInAppPurchase";
15
+
16
+ interface InAppPurchasePageProps {
17
+ onBack: () => void;
18
+ }
19
+
20
+ export function InAppPurchasePage({ onBack }: InAppPurchasePageProps) {
21
+ const {
22
+ products,
23
+ purchaseProduct,
24
+ restorePendingOrders,
25
+ productsLoading,
26
+ purchasingSku,
27
+ } = useInAppPurchase();
28
+
29
+ useEffect(() => {
30
+ restorePendingOrders();
31
+ }, [restorePendingOrders]);
32
+
33
+ if (productsLoading) {
34
+ return (
35
+ <>
36
+ <Top
37
+ title={<Top.TitleParagraph size={22}>인앱결제</Top.TitleParagraph>}
38
+ />
39
+
40
+ <Text style={{ padding: 24 }}>상품을 불러오는 중...</Text>
41
+ </>
42
+ );
43
+ }
44
+
45
+ if (products.length === 0) {
46
+ return (
47
+ <>
48
+ <Top
49
+ title={<Top.TitleParagraph size={22}>인앱결제</Top.TitleParagraph>}
50
+ />
51
+
52
+ <Result
53
+ style={{ height: 300 }}
54
+ title="인앱 상품이 없어요"
55
+ description="콘솔 '인앱 결제' 메뉴에서 상품을 등록해 주세요."
56
+ figure={
57
+ <Asset.Image
58
+ frameShape={Asset.frameShape.CleanW60}
59
+ src={`${import.meta.env.BASE_URL}icon-document.png`}
60
+ aria-hidden={true}
61
+ />
62
+ }
63
+ button={<Result.Button onClick={onBack}>홈으로</Result.Button>}
64
+ />
65
+ </>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <>
71
+ <Top
72
+ title={<Top.TitleParagraph size={22}>인앱결제</Top.TitleParagraph>}
73
+ />
74
+
75
+ <List>
76
+ {products.map((product) => (
77
+ <ListRow
78
+ key={product.sku}
79
+ verticalPadding="large"
80
+ left={
81
+ <ListRow.AssetImage
82
+ src={product.iconUrl}
83
+ shape="squircle"
84
+ size="xsmall"
85
+ />
86
+ }
87
+ contents={
88
+ <ListRow.Texts
89
+ type="3RowTypeA"
90
+ top={product.displayName}
91
+ topProps={{ color: colors.grey800, fontWeight: "bold" }}
92
+ middle={product.description}
93
+ middleProps={{ color: colors.grey600 }}
94
+ bottom={product.displayAmount}
95
+ bottomProps={{ color: colors.grey600 }}
96
+ />
97
+ }
98
+ right={
99
+ <Button
100
+ size="small"
101
+ variant="weak"
102
+ loading={purchasingSku !== null}
103
+ onClick={() => purchaseProduct(product.sku)}
104
+ >
105
+ 구매하기
106
+ </Button>
107
+ }
108
+ />
109
+ ))}
110
+ </List>
111
+
112
+ <TextButton
113
+ style={{ padding: "16px 24px" }}
114
+ size="medium"
115
+ color={colors.blue500}
116
+ onClick={onBack}
117
+ >
118
+ ← 홈으로
119
+ </TextButton>
120
+ </>
121
+ );
122
+ }
@@ -0,0 +1,13 @@
1
+ #root {
2
+ width: 100%;
3
+ min-height: 100vh;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ @media (min-width: 481px) {
8
+ #root {
9
+ background-color: #ffffff !important;
10
+ max-width: 480px;
11
+ margin: 0 auto;
12
+ }
13
+ }