diff --git a/app/(app)/settings/index.tsx b/app/(app)/settings/index.tsx
index e7e2404..b8f40d5 100644
--- a/app/(app)/settings/index.tsx
+++ b/app/(app)/settings/index.tsx
@@ -125,7 +125,7 @@ export default function Settings(): JSX.Element {
{
onPress: () => {
if (Platform.OS !== 'web') {
- push('/settings/sponsors');
+ push('/settings/points');
return;
}
@@ -133,7 +133,7 @@ export default function Settings(): JSX.Element {
},
startElement: (
),
- title: t('settings.sponsors'),
+ title: t('settings.points'),
},
{
onPress: () => push('/settings/block-users'),
diff --git a/app/(app)/settings/points.tsx b/app/(app)/settings/points.tsx
new file mode 100644
index 0000000..bdc08e2
--- /dev/null
+++ b/app/(app)/settings/points.tsx
@@ -0,0 +1,248 @@
+import {
+ endConnection,
+ finishTransaction,
+ getProducts,
+ initConnection,
+ isProductAndroid,
+ isProductIos,
+ purchaseErrorListener,
+ purchaseUpdatedListener,
+ requestPurchase,
+} from 'expo-iap';
+import type {
+ Product,
+ ProductPurchase,
+ PurchaseError,
+} from 'expo-iap/build/ExpoIap.types';
+import type {} from 'expo-iap/build/types/ExpoIapAndroid.types';
+import {Stack} from 'expo-router';
+import {useEffect, useState} from 'react';
+import {InteractionManager, View} from 'react-native';
+import {t} from '../../../src/STRINGS';
+import styled, {css} from '@emotion/native';
+import {
+ fetchCreatePurchase,
+ fetchUserPoints,
+} from '../../../src/apis/purchaseQueries';
+import {useRecoilValue} from 'recoil';
+import {authRecoilState} from '../../../src/recoil/atoms';
+import {Button, Icon, Typography, useDooboo} from 'dooboo-ui';
+import {showAlert} from '../../../src/utils/alert';
+
+const productSkus = [
+ 'cpk.points.200',
+ 'cpk.points.500',
+ 'cpk.points.1000',
+ 'cpk.points.5000',
+ 'cpk.points.10000',
+ 'cpk.points.30000',
+];
+
+const Container = styled.View`
+ background-color: ${({theme}) => theme.bg.basic};
+
+ flex: 1;
+ align-self: stretch;
+`;
+
+const Content = styled.ScrollView`
+ padding: 16px;
+`;
+
+export default function App() {
+ const [isConnected, setIsConnected] = useState(false);
+ const [products, setProducts] = useState([]);
+ const [userPoints, setUserPoints] = useState(0);
+ const {authId} = useRecoilValue(authRecoilState);
+ const {theme} = useDooboo();
+
+ useEffect(() => {
+ const getUserPoints = async () => {
+ const data = await fetchUserPoints(authId!);
+ setUserPoints(data || 0);
+ };
+
+ authId && getUserPoints();
+ }, [authId]);
+
+ useEffect(() => {
+ const initIAP = async () => {
+ if (await initConnection()) {
+ setIsConnected(true);
+ }
+
+ const products = await getProducts(productSkus);
+ products.sort((a, b) => {
+ if (isProductAndroid(a) && isProductAndroid(b)) {
+ return (
+ parseInt(a?.oneTimePurchaseOfferDetails?.priceAmountMicros || '0') -
+ parseInt(b?.oneTimePurchaseOfferDetails?.priceAmountMicros || '0')
+ );
+ }
+
+ if (isProductIos(a) && isProductIos(b)) {
+ return a.price - b.price;
+ }
+
+ return 0;
+ });
+ setProducts(products);
+ };
+
+ initIAP();
+
+ return () => {
+ endConnection();
+ };
+ }, []);
+
+ useEffect(() => {
+ const purchaseUpdatedSubs = purchaseUpdatedListener(
+ (purchase: ProductPurchase) => {
+ const ackPurchase = async (purchase: ProductPurchase) => {
+ await finishTransaction({
+ purchase,
+ isConsumable: true,
+ });
+ };
+
+ InteractionManager.runAfterInteractions(async () => {
+ const receipt = purchase && purchase.transactionReceipt;
+
+ if (receipt) {
+ const result = await fetchCreatePurchase({
+ authId: authId!,
+ points: parseInt(purchase?.productId.split('.').pop() || '0'),
+ productId: purchase?.productId || '',
+ receipt,
+ });
+
+ if (result) {
+ ackPurchase(purchase);
+ }
+ }
+ });
+ },
+ );
+
+ const purchaseErrorSubs = purchaseErrorListener((error: PurchaseError) => {
+ InteractionManager.runAfterInteractions(() => {
+ showAlert(error?.message);
+ });
+ });
+
+ return () => {
+ purchaseUpdatedSubs.remove();
+ purchaseErrorSubs.remove();
+ endConnection();
+ };
+ }, [authId]);
+
+ return (
+
+
+
+
+ {t('points.myPoints')}
+
+
+
+
+ {userPoints}
+
+
+
+
+
+ {isConnected
+ ? products.map((item) => {
+ if (isProductAndroid(item)) {
+ return (
+
+
+ {item.title}
+
+
+ );
+ }
+
+ if (isProductIos(item)) {
+ return (
+
+
+ {item.displayName}
+
+
+ );
+ }
+ })
+ : null}
+
+
+ );
+}
diff --git a/app/(app)/settings/sponsors.tsx b/app/(app)/settings/sponsors.tsx
deleted file mode 100644
index 073ab47..0000000
--- a/app/(app)/settings/sponsors.tsx
+++ /dev/null
@@ -1,270 +0,0 @@
-import {
- endConnection,
- getProducts,
- getSubscriptions,
- initConnection,
- isProductAndroid,
- isProductIos,
- isSubscriptionProductAndroid,
- isSubscriptionProductIos,
- purchaseErrorListener,
- purchaseUpdatedListener,
- requestPurchase,
- requestSubscription,
-} from 'expo-iap';
-import type {
- Product,
- ProductPurchase,
- PurchaseError,
- SubscriptionProduct,
-} from 'expo-iap/build/ExpoIap.types';
-import type {RequestSubscriptionAndroidProps} from 'expo-iap/build/types/ExpoIapAndroid.types';
-import {Stack} from 'expo-router';
-import {useEffect, useState} from 'react';
-import {
- Alert,
- Button,
- InteractionManager,
- Pressable,
- SafeAreaView,
- ScrollView,
- StyleSheet,
- Text,
- View,
-} from 'react-native';
-import {t} from '../../../src/STRINGS';
-
-const productSkus = [
- 'cpk.points.200',
- 'cpk.points.500',
- 'cpk.points.1000',
- 'cpk.points.5000',
- 'cpk.points.10000',
- 'cpk.points.30000',
-];
-
-const subscriptionSkus = [
- 'cpk.membership.monthly.bronze',
- 'cpk.membership.monthly.silver',
-];
-
-const operations = [
- 'initConnection',
- 'getProducts',
- 'getSubscriptions',
- 'endConnection',
-];
-type Operation = (typeof operations)[number];
-
-export default function App() {
- const [isConnected, setIsConnected] = useState(false);
- const [products, setProducts] = useState([]);
- const [subscriptions, setSubscriptions] = useState([]);
-
- const handleOperation = async (operation: Operation) => {
- switch (operation) {
- case 'initConnection':
- if (await initConnection()) setIsConnected(true);
- return;
-
- case 'endConnection':
- if (await endConnection()) {
- setProducts([]);
- setIsConnected(false);
- }
- break;
-
- case 'getProducts':
- try {
- const products = await getProducts(productSkus);
- setProducts(products);
- } catch (error) {
- console.error(error);
- }
- break;
-
- case 'getSubscriptions':
- try {
- const subscriptions = await getSubscriptions(subscriptionSkus);
- setSubscriptions(subscriptions);
- } catch (error) {
- console.error(error);
- }
- break;
-
- default:
- console.log('Unknown operation');
- }
- };
-
- useEffect(() => {
- const purchaseUpdatedSubs = purchaseUpdatedListener(
- (purchase: ProductPurchase) => {
- InteractionManager.runAfterInteractions(() => {
- Alert.alert('Purchase updated', JSON.stringify(purchase));
- });
- },
- );
-
- const purchaseErrorSubs = purchaseErrorListener((error: PurchaseError) => {
- InteractionManager.runAfterInteractions(() => {
- Alert.alert('Purchase error', JSON.stringify(error));
- });
- });
-
- return () => {
- purchaseUpdatedSubs.remove();
- purchaseErrorSubs.remove();
- endConnection();
- };
- }, []);
-
- return (
-
-
- Expo IAP Example
-
-
- {operations.map((operation) => (
- handleOperation(operation)}
- >
-
- {operation}
-
-
- ))}
-
-
-
- {!isConnected ? (
- Not connected
- ) : (
-
- Products
- {products.map((item) => {
- if (isProductAndroid(item)) {
- return (
-
-
- {item.title} -{' '}
- {item.oneTimePurchaseOfferDetails?.formattedPrice}
-
-
- );
- }
-
- if (isProductIos(item)) {
- return (
-
-
- {item.displayName} - {item.displayPrice}
-
-
- );
- }
- })}
-
- Subscriptions
- {subscriptions.map((item) => {
- if (isSubscriptionProductAndroid(item)) {
- return item.subscriptionOfferDetails?.map((offer) => (
-
-
- {item.title} -{' '}
- {offer.pricingPhases.pricingPhaseList
- .map((ppl) => ppl.billingPeriod)
- .join(',')}
-
-
- ));
- }
-
- if (isSubscriptionProductIos(item)) {
- return (
-
-
- {item.displayName} - {item.displayPrice}
-
-
- );
- }
- })}
-
- )}
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: '#fff',
- alignItems: 'center',
- },
- title: {
- marginTop: 24,
- fontSize: 20,
- fontWeight: 'bold',
- },
- buttons: {
- height: 90,
- },
- buttonsWrapper: {
- padding: 24,
-
- gap: 8,
- },
- buttonView: {
- borderRadius: 8,
- borderWidth: 1,
- borderColor: '#000',
- padding: 8,
- },
- content: {
- flex: 1,
- alignSelf: 'stretch',
- padding: 24,
- gap: 12,
- },
-});
diff --git a/assets/langs/en.json b/assets/langs/en.json
index 657e38b..d508b6a 100644
--- a/assets/langs/en.json
+++ b/assets/langs/en.json
@@ -55,6 +55,7 @@
"retry": "Retry",
"score": "Score",
"selectFromGallery": "Select from gallery",
+ "sponsors": "Sponsors",
"takeAPhoto": "Take a photo",
"unblock": "Unblock",
"unhandledError": "An unhandled error occurred.",
@@ -116,6 +117,10 @@
"yourTags": "Your Tags",
"yourTagsPlaceholder": "Please enter tags related to you"
},
+ "points": {
+ "title": "Points",
+ "myPoints": "My Points"
+ },
"post": {
"update": {
"updateFailed": "Update failed!",
@@ -145,7 +150,7 @@
"darkMode": "Dark Mode",
"loginInfo": "Login Information",
"notificationSettings": "Notification Settings",
- "sponsors": "Sponsors",
+ "points": "Points",
"termsOfService": "Terms of Service",
"title": "Settings",
"updateProfile": "Update Profile"
diff --git a/assets/langs/ko.json b/assets/langs/ko.json
index cabe3e8..b6ccd5f 100644
--- a/assets/langs/ko.json
+++ b/assets/langs/ko.json
@@ -55,6 +55,7 @@
"retry": "재시도",
"score": "점수",
"selectFromGallery": "갤러리에서 선택",
+ "sponsors": "후원사",
"takeAPhoto": "사진 찍기",
"unblock": "차단 해제",
"unhandledError": "처리되지 않은 오류가 발생했습니다.",
@@ -116,6 +117,10 @@
"yourTags": "나의 태그",
"yourTagsPlaceholder": "나와 관련된 태그들을 입력하세요"
},
+ "points": {
+ "title": "포인트",
+ "myPoints": "나의 포인트"
+ },
"post": {
"update": {
"updateFailed": "업데이트에 실패했습니다!",
@@ -145,7 +150,7 @@
"darkMode": "다크 모드",
"loginInfo": "로그인 정보",
"notificationSettings": "알림 설정",
- "sponsors": "후원사",
+ "points": "포인트",
"termsOfService": "서비스 약관",
"title": "설정",
"updateProfile": "프로필 수정"
diff --git a/bun.lockb b/bun.lockb
index 7da0459..89beabc 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/src/apis/purchaseQueries.ts b/src/apis/purchaseQueries.ts
new file mode 100644
index 0000000..f464d83
--- /dev/null
+++ b/src/apis/purchaseQueries.ts
@@ -0,0 +1,65 @@
+import {getDeviceTypeSync} from 'react-native-device-info';
+import {t} from '../STRINGS';
+import {supabase} from '../supabase';
+
+export const fetchUserPoints = async (authId: string) => {
+ const {data, error: pointsError} = await supabase
+ .from('purchases')
+ .select('points')
+ .eq('user_id', authId);
+
+ if (pointsError) {
+ if (__DEV__) {
+ console.error(pointsError);
+ }
+ throw new Error(t('error.failedToFetchData'));
+ }
+
+ const totalPoints =
+ data?.reduce((acc, purchase) => acc + (purchase.points || 0), 0) || 0;
+
+ return totalPoints;
+};
+
+export const fetchUserPurchases = async (authId: string) => {
+ const {data, error: profileError} = await supabase
+ .from('purchases')
+ .select('*')
+ .eq('user_id', authId)
+ .single();
+
+ if (profileError) {
+ if (__DEV__) {
+ console.error(profileError);
+ }
+ throw new Error(t('error.failedToFetchData'));
+ }
+
+ return data;
+};
+
+export const fetchCreatePurchase = async ({
+ authId,
+ points,
+ productId,
+ receipt,
+}: {
+ authId: string;
+ points: number;
+ productId: string;
+ receipt: string;
+}): Promise => {
+ const {error} = await supabase.from('purchases').insert({
+ user_id: authId,
+ points,
+ product_id: productId,
+ receipt,
+ device: getDeviceTypeSync(),
+ });
+
+ if (error) {
+ throw new Error(t('error.failedToFetchData'));
+ }
+
+ return true;
+};
diff --git a/src/utils/alert.ts b/src/utils/alert.ts
index 62ac1b6..cd474a7 100644
--- a/src/utils/alert.ts
+++ b/src/utils/alert.ts
@@ -1,4 +1,5 @@
import {Alert, Platform} from 'react-native';
+import {t} from '../STRINGS';
export const showAlert = (
message: string,
@@ -22,7 +23,7 @@ export const showAlert = (
Alert.alert('', parsedMessage, [
{
- text: '확인',
+ text: t('common.ok'),
onPress: options?.onPress,
},
]);
@@ -46,13 +47,13 @@ export const showConfirm = async ({
return await new Promise((resolve) => {
Alert.alert(title, description, [
{
- text: '취소',
+ text: t('common.cancel'),
onPress: () => {
resolve(false);
},
},
{
- text: '확인',
+ text: t('common.ok'),
onPress: () => {
resolve(true);
},