diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..9f1e92d --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 4, + "printWidth": 100, + "trailingComma": "es5" + } \ No newline at end of file diff --git a/src/app/router/protected-layout/index.tsx b/src/app/router/protected-layout/index.tsx index 468fcb7..1abd710 100644 --- a/src/app/router/protected-layout/index.tsx +++ b/src/app/router/protected-layout/index.tsx @@ -3,18 +3,18 @@ import { Outlet } from 'react-router-dom'; import { useAuthTwa } from '~/shared/services/authTwa'; export const ProtectedLayout = () => { - const auth = useAuthTwa(); - const [isInitialLoad, setIsInitialLoad] = useState(true); + const auth = useAuthTwa(); + const [isInitialLoad, setIsInitialLoad] = useState(true); - useEffect(() => { - void auth.mutateAsync().finally(() => { - setIsInitialLoad(false); - }); - }, []); + useEffect(() => { + void auth.mutateAsync().finally(() => { + setIsInitialLoad(false); + }); + }, []); - if (isInitialLoad || auth.isLoading) { - return null; - } + if (isInitialLoad || auth.isLoading) { + return null; + } - return ; -}; \ No newline at end of file + return ; +}; diff --git a/src/pages/root/steps/data-step/index.tsx b/src/pages/root/steps/data-step/index.tsx index eacd209..dc42ac7 100644 --- a/src/pages/root/steps/data-step/index.tsx +++ b/src/pages/root/steps/data-step/index.tsx @@ -165,6 +165,16 @@ export const DataStep = ({ nextStep }: DataStepProps) => {
+ rootStore.setAllowDwnld(!rootStore.allowDwnld)} + checked={rootStore.allowDwnld} + /> + } + /> { // Откомментировать при условии того что вы принимаете много авторов // следует отметить что вы должны еще откомментровать AuthorsStep в RootPage // authors: rootStore.authors, - + downloadable: rootStore.allowDwnld, content: fileUploadResult.content_id_v1, image: coverUploadResult.content_id_v1, price: String(rootStore.price * 10 ** 9), diff --git a/src/pages/view-content/index.tsx b/src/pages/view-content/index.tsx index 575f557..cbc79ee 100644 --- a/src/pages/view-content/index.tsx +++ b/src/pages/view-content/index.tsx @@ -1,217 +1,275 @@ -import ReactPlayer from "react-player/lazy"; -import { useTonConnectUI } from "@tonconnect/ui-react"; -import { useWebApp } from "@vkruglikov/react-telegram-web-app"; +import ReactPlayer from 'react-player/lazy'; +import { useTonConnectUI } from '@tonconnect/ui-react'; +import { useWebApp } from '@vkruglikov/react-telegram-web-app'; -import { Button } from "~/shared/ui/button"; -import { usePurchaseContent, useViewContent } from "~/shared/services/content"; -import { fromNanoTON } from "~/shared/utils"; -import {useCallback, useEffect, useMemo, useState} from "react"; -import { AudioPlayer } from "~/shared/ui/audio-player"; -import {useAuth} from "~/shared/services/auth"; -import { CongratsModal } from "./components/congrats-modal"; -import { ErrorModal } from "./components/error-modal"; +import { Button } from '~/shared/ui/button'; +import { usePurchaseContent, useViewContent } from '~/shared/services/content'; +import { fromNanoTON } from '~/shared/utils'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AudioPlayer } from '~/shared/ui/audio-player'; +import { useAuth } from '~/shared/services/auth'; +import { CongratsModal } from './components/congrats-modal'; +import { ErrorModal } from './components/error-modal'; type InvoiceStatus = 'paid' | 'failed' | 'cancelled' | 'pending'; // Add type for invoice event interface InvoiceEvent { - url: string; - status: InvoiceStatus; + url: string; + status: InvoiceStatus; } - export const ViewContentPage = () => { - const WebApp = useWebApp(); + const WebApp = useWebApp(); - const { data: content, refetch: refetchContent } = useViewContent(WebApp.initDataUnsafe?.start_param); + const { data: content, refetch: refetchContent } = useViewContent( + WebApp.initDataUnsafe?.start_param + ); - const { mutateAsync: purchaseContent } = usePurchaseContent(); + const { mutateAsync: purchaseContent } = usePurchaseContent(); - const [tonConnectUI] = useTonConnectUI(); + const [tonConnectUI] = useTonConnectUI(); - const auth = useAuth(); + const auth = useAuth(); + const [isCongratsModal, setIsCongratsModal] = useState(false); + const [isErrorModal, setIsErrorModal] = useState(false); - const [isCongratsModal, setIsCongratsModal] = useState(false); - const [isErrorModal, setIsErrorModal] = useState(false); - const handleBuyContentTON = useCallback(async () => { - try { - if (!tonConnectUI.connected) { - await tonConnectUI.openModal(); - await auth.mutateAsync(); - return - } else { - await auth.mutateAsync() - } + const handleBuyContentTON = useCallback(async () => { + try { + // Helper function to wait for wallet connection + const waitForConnection = async (timeoutMs = 30000, intervalMs = 500) => { + const startTime = Date.now(); - const contentResponse = await purchaseContent({ - content_address: WebApp.initDataUnsafe?.start_param, - license_type: "resale", - }); + while (Date.now() - startTime < timeoutMs) { + if (tonConnectUI.connected) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } - const transactionResponse = await tonConnectUI.sendTransaction({ - validUntil: Math.floor(Date.now() / 1000) + 120, - messages: [ - { - amount: contentResponse.data.amount, - address: contentResponse.data.address, - payload: contentResponse.data.payload, - }, - ], - }); + return false; // Timed out + }; - if (transactionResponse.boc) { - void refetchContent() - setIsCongratsModal(true); - console.log(transactionResponse.boc, "PURCHASED") - } else { - setIsErrorModal(true); - console.error("Transaction failed:", transactionResponse); - } - } catch (error) { - setIsErrorModal(true); - console.error("Error handling Ton Connect subscription:", error); - } - }, [content, tonConnectUI.connected]); + // If not connected, start connection process + if (!tonConnectUI.connected) { + console.log('DEBUG: Wallet not connected, opening modal'); - const handleBuyContentStars = useCallback(async () => { - try { - if (!content?.data?.invoice.url) { - console.error('No invoice URL available'); - return; - } - - // Add event listener for invoice closing with typed event - const handleInvoiceClosed = (event: InvoiceEvent) => { - if (event.url === content.data.invoice.url) { - if (event.status === 'paid') { - void refetchContent(); - setIsCongratsModal(true); - } else if (event.status === 'failed' || event.status === 'cancelled') { - // setIsErrorModal(true); // Turn on if need in error modal. Update text in it to match both way of payment errors + // Open connection modal + await tonConnectUI.openModal(); + + // Wait for connection + const connected = await waitForConnection(); + if (!connected) { + console.log('DEBUG: Connection timed out or was cancelled'); + return; + } + + console.log('DEBUG: Connection successful, authenticating'); + await auth.mutateAsync(); + } else { + // Already connected, just authenticate + await auth.mutateAsync(); } + + // Proceed with purchase + console.log('DEBUG: Proceeding with purchase'); + const contentResponse = await purchaseContent({ + content_address: WebApp.initDataUnsafe?.start_param, + license_type: 'resale', + }); + + const transactionResponse = await tonConnectUI.sendTransaction({ + validUntil: Math.floor(Date.now() / 1000) + 86400, // 24 hours + messages: [ + { + amount: contentResponse.data.amount, + address: contentResponse.data.address, + payload: contentResponse.data.payload, + }, + ], + }); + + if (transactionResponse.boc) { + void refetchContent(); + setIsCongratsModal(true); + console.log(transactionResponse.boc, 'PURCHASED'); + } else { + setIsErrorModal(true); + console.error('Transaction failed:', transactionResponse); + } + } catch (error) { + setIsErrorModal(true); + console.error('Error handling Ton Connect subscription:', error); } - }; - - WebApp.onEvent('invoiceClosed', handleInvoiceClosed); - - await WebApp.openInvoice( - content.data.invoice.url, - (status: InvoiceStatus) => { - console.log('Invoice status:', status); - if (status === 'paid') { + }, [auth, purchaseContent, refetchContent, tonConnectUI, WebApp.initDataUnsafe?.start_param]); + + const handleBuyContentStars = useCallback(async () => { + try { + if (!content?.data?.invoice.url) { + console.error('No invoice URL available'); + return; + } + + // Add event listener for invoice closing with typed event + const handleInvoiceClosed = (event: InvoiceEvent) => { + if (event.url === content.data.invoice.url) { + if (event.status === 'paid') { + void refetchContent(); + setIsCongratsModal(true); + } else if (event.status === 'failed' || event.status === 'cancelled') { + // setIsErrorModal(true); // Turn on if need in error modal. Update text in it to match both way of payment errors + } + } + }; + + WebApp.onEvent('invoiceClosed', handleInvoiceClosed); + + await WebApp.openInvoice(content.data.invoice.url, (status: InvoiceStatus) => { + console.log('Invoice status:', status); + if (status === 'paid') { + void refetchContent(); + setIsCongratsModal(true); + } else if (status === 'failed' || status === 'cancelled') { + // setIsErrorModal(true); // Turn on if need in error modal. Update text in it to match both way of payment errors + } + }); + + return () => { + WebApp.offEvent('invoiceClosed', handleInvoiceClosed); + }; + } catch (error) { + console.error('Payment failed:', error); + // setIsErrorModal(true); // Turn on if need in error modal. Update text in it to match both way of payment errors + } + }, [content, refetchContent]); + + const haveLicense = useMemo(() => { + document.title = content?.data?.display_options?.metadata?.name; + return ( + content?.data?.have_licenses?.includes('listen') || + content?.data?.have_licenses?.includes('resale') + ); + }, [content]); + + useEffect(() => { + const interval = setInterval(() => { void refetchContent(); - setIsCongratsModal(true); - } else if (status === 'failed' || status === 'cancelled') { - // setIsErrorModal(true); // Turn on if need in error modal. Update text in it to match both way of payment errors - } + }, 5000); + + return () => clearInterval(interval); + }, []); + + const handleConfirmCongrats = () => { + setIsCongratsModal(!isCongratsModal); + }; + + const handleErrorModal = () => { + setIsErrorModal(!isErrorModal); + }; + + const handleDwnldContent = async () => { + try { + const fileUrl = content?.data?.display_options?.content_url; + const fileName = content?.data?.display_options?.metadata?.name || 'content'; + const fileFormat = content?.data?.content_type?.contentFormat || ''; + await WebApp.downloadFile({ + url: fileUrl, + file_name: fileName + fileFormat, + }); + } catch (error) { + console.error('Error downloading content:', error); } - ); - - return () => { - WebApp.offEvent('invoiceClosed', handleInvoiceClosed); - }; - } catch (error) { - console.error('Payment failed:', error); - // setIsErrorModal(true); // Turn on if need in error modal. Update text in it to match both way of payment errors - } - }, [content, refetchContent]); + }; - const haveLicense = useMemo(() => { - document.title = content?.data?.display_options?.metadata?.name; - return content?.data?.have_licenses?.includes("listen") || content?.data?.have_licenses?.includes("resale"); - }, [content]) + return ( +
+ {isCongratsModal && } + {isErrorModal && } + {content?.data?.content_type.startsWith('audio') && + content?.data?.display_options?.metadata?.image && ( +
+ {'content_image'} +
+ )} - useEffect(() => { - const interval = setInterval(() => { - void refetchContent() - }, 5000) + {content?.data?.content_type.startsWith('audio') ? ( + + ) : ( + + )} - return () => clearInterval(interval) - }, []); +
+

+ {content?.data?.display_options?.metadata?.name} +

+ {/*

Russian

*/} + {/*

2022

*/} +

+ {content?.data?.display_options?.metadata?.description} +

+
- const handleConfirmCongrats = () => { - setIsCongratsModal(!isCongratsModal); - }; - - const handleErrorModal = () => { - setIsErrorModal(!isErrorModal); - } - return ( -
- {isCongratsModal && } - {isErrorModal && } - {content?.data?.content_type.startsWith("audio") && content?.data?.display_options?.metadata?.image && ( -
- {"content_image"} -
- )} - - {content?.data?.content_type.startsWith("audio") ? ( - - ) : ( - - )} - -
-

- {content?.data?.display_options?.metadata?.name} -

- {/*

Russian

*/} - {/*

2022

*/} -

- {content?.data?.display_options?.metadata?.description} -

-
- -
- {!haveLicense &&
-
- } -
-
- ); +
+ {content?.data?.downloadable && ( +
+ )} +
+ + ); }; diff --git a/src/shared/libs/request/index.ts b/src/shared/libs/request/index.ts index 6b7338e..9eae009 100644 --- a/src/shared/libs/request/index.ts +++ b/src/shared/libs/request/index.ts @@ -1,17 +1,17 @@ -import axios from "axios"; +import axios from 'axios'; export const APP_API_BASE_URL = import.meta.env.VITE_API_BASE_URL; export const request = axios.create({ - baseURL: APP_API_BASE_URL, + baseURL: APP_API_BASE_URL, }); request.interceptors.request.use((config) => { - const auth_v1_token = sessionStorage.getItem("auth_v1_token"); + const auth_v1_token = localStorage.getItem('auth_v1_token'); - if (auth_v1_token) { - config.headers.Authorization = auth_v1_token; - } + if (auth_v1_token) { + config.headers.Authorization = auth_v1_token; + } - return config; + return config; }); diff --git a/src/shared/services/auth/index.ts b/src/shared/services/auth/index.ts index bc63a47..f4197a4 100644 --- a/src/shared/services/auth/index.ts +++ b/src/shared/services/auth/index.ts @@ -1,118 +1,131 @@ -import { useRef } from "react"; -import { useTonConnectUI } from "@tonconnect/ui-react"; -import { useMutation } from "react-query"; -import { request } from "~/shared/libs"; -import { useWebApp } from "@vkruglikov/react-telegram-web-app"; +import { useRef } from 'react'; +import { useTonConnectUI } from '@tonconnect/ui-react'; +import { useMutation } from 'react-query'; +import { request } from '~/shared/libs'; +import { useWebApp } from '@vkruglikov/react-telegram-web-app'; -const sessionStorageKey = "auth_v1_token"; +const sessionStorageKey = 'auth_v1_token'; const payloadTTLMS = 1000 * 60 * 20; export const useAuth = () => { - const WebApp = useWebApp(); - // const wallet = useTonWallet(); - const [tonConnectUI] = useTonConnectUI(); - const interval = useRef | undefined>(); + const WebApp = useWebApp(); + // const wallet = useTonWallet(); + const [tonConnectUI] = useTonConnectUI(); + const interval = useRef | undefined>(); - const waitForWalletProof = async () => { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("Timeout waiting for proof")), 30000); - const checkProof = setInterval(() => { - const currentWallet = tonConnectUI.wallet; - if ( - currentWallet?.connectItems?.tonProof && - !("error" in currentWallet.connectItems.tonProof) - ) { - clearInterval(checkProof); - clearTimeout(timeout); - resolve(currentWallet.connectItems.tonProof.proof); - } - }, 500); - }); - }; - - const makeAuthRequest = async (params: { - twa_data: string; - ton_proof?: { - account: any; - ton_proof: any; - }; - }) => { - const res = await request.post<{ - connected_wallet: null | { - version: string; - address: string; - ton_balance: string; - }; - auth_v1_token: string; - }>("/auth.twa", params); - - if (res?.data?.auth_v1_token) { - localStorage.setItem(sessionStorageKey, res.data.auth_v1_token); - } else { - throw new Error("Failed to get auth token"); - } - return res; - }; - - return useMutation(["auth"], async () => { - clearInterval(interval.current); - console.log("DEBUG: Starting auth flow"); - - // Case 1: Not connected - need to connect and get proof - if (!tonConnectUI.connected) { - console.log("DEBUG: No wallet connection, starting flow"); - localStorage.removeItem(sessionStorageKey); - - const refreshPayload = async () => { - tonConnectUI.setConnectRequestParameters({ state: "loading" }); - const value = await request.post<{ auth_v1_token: string }>("/auth.twa", { - twa_data: WebApp.initData, + const waitForWalletProof = async () => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timeout waiting for proof')), 30000); + const checkProof = setInterval(() => { + const currentWallet = tonConnectUI.wallet; + if ( + currentWallet?.connectItems?.tonProof && + !('error' in currentWallet.connectItems.tonProof) + ) { + clearInterval(checkProof); + clearTimeout(timeout); + resolve(currentWallet.connectItems.tonProof.proof); + } + }, 500); }); - - if (value?.data?.auth_v1_token) { - tonConnectUI.setConnectRequestParameters({ - state: "ready", - value: { tonProof: value.data.auth_v1_token }, - }); + }; + + const makeAuthRequest = async (params: { + twa_data: string; + ton_proof?: { + account: any; + ton_proof: any; + }; + }) => { + const res = await request.post<{ + connected_wallet: null | { + version: string; + address: string; + ton_balance: string; + }; + auth_v1_token: string; + }>('/auth.twa', params); + + if (res?.data?.auth_v1_token) { + localStorage.setItem(sessionStorageKey, res.data.auth_v1_token); } else { - tonConnectUI.setConnectRequestParameters(null); + throw new Error('Failed to get auth token'); } - }; + return res; + }; - await refreshPayload(); - interval.current = setInterval(refreshPayload, payloadTTLMS); + const makeSelectWalletRequest = async (params: { wallet_address: string }) => { + const res = await request.post('/auth.selectWallet', params); + return res; + }; - const tonProof = await waitForWalletProof(); - console.log("DEBUG: Got initial proof", tonProof); + return useMutation(['auth'], async () => { + clearInterval(interval.current); + let authResult; + console.log('DEBUG: Starting auth flow'); - return makeAuthRequest({ - twa_data: WebApp.initData, - ton_proof: { - account: tonConnectUI.wallet!.account, - ton_proof: tonProof, - }, - }); - } + // Case 1: Not connected - need to connect and get proof + if (!tonConnectUI.connected) { + console.log('DEBUG: No wallet connection, starting flow'); + localStorage.removeItem(sessionStorageKey); - // Commented this part for two reasons: - // 1) When we include ton_proof from the wallet it fails the call for a reason of bad ton_proof - // 2) This call could happen only if the first case happened and it means that the ton_proof is already have been stored once before - // Case 2: Connected with proof - use it - // if (wallet?.connectItems?.tonProof && !("error" in wallet.connectItems.tonProof)) { - // console.log("DEBUG: Using existing proof", wallet.connectItems.tonProof.proof); - // return makeAuthRequest({ - // twa_data: WebApp.initData, - // ton_proof: { - // account: wallet.account, - // ton_proof: wallet.connectItems.tonProof.proof, - // }, - // }); - // } + const refreshPayload = async () => { + tonConnectUI.setConnectRequestParameters({ state: 'loading' }); + const value = await request.post<{ auth_v1_token: string }>('/auth.twa', { + twa_data: WebApp.initData, + }); - // Case 3: Connected without proof - already authenticated - console.log("DEBUG: Connected without proof, proceeding without it"); - return makeAuthRequest({ - twa_data: WebApp.initData, + if (value?.data?.auth_v1_token) { + tonConnectUI.setConnectRequestParameters({ + state: 'ready', + value: { tonProof: value.data.auth_v1_token }, + }); + } else { + tonConnectUI.setConnectRequestParameters(null); + } + }; + + await refreshPayload(); + interval.current = setInterval(refreshPayload, payloadTTLMS); + + const tonProof = await waitForWalletProof(); + console.log('DEBUG: Got initial proof', tonProof); + + authResult = await makeAuthRequest({ + twa_data: WebApp.initData, + ton_proof: { + account: tonConnectUI.wallet!.account, + ton_proof: tonProof, + }, + }); + } else { + // Case 3: Connected without proof - already authenticated + console.log('DEBUG: Connected without proof, proceeding without it'); + authResult = await makeAuthRequest({ + twa_data: WebApp.initData, + }); + } + + if (tonConnectUI.wallet?.account?.address) { + console.log('DEBUG: Selecting wallet', tonConnectUI.wallet.account.address); + await makeSelectWalletRequest({ wallet_address: tonConnectUI.wallet.account.address }); + } + + return authResult; + + // Commented this part for two reasons: + // 1) When we include ton_proof from the wallet it fails the call for a reason of bad ton_proof + // 2) This call could happen only if the first case happened and it means that the ton_proof is already have been stored once before + // Case 2: Connected with proof - use it + // if (wallet?.connectItems?.tonProof && !("error" in wallet.connectItems.tonProof)) { + // console.log("DEBUG: Using existing proof", wallet.connectItems.tonProof.proof); + // return makeAuthRequest({ + // twa_data: WebApp.initData, + // ton_proof: { + // account: wallet.account, + // ton_proof: wallet.connectItems.tonProof.proof, + // }, + // }); + // } }); - }); -}; \ No newline at end of file +}; diff --git a/src/shared/services/content/index.ts b/src/shared/services/content/index.ts index bbad153..44e5417 100644 --- a/src/shared/services/content/index.ts +++ b/src/shared/services/content/index.ts @@ -14,6 +14,7 @@ type UseCreateNewContentPayload = { allowResale: boolean; authors: string[]; royaltyParams: Royalty[]; + downloadable: boolean; }; export const useCreateNewContent = () => { diff --git a/src/shared/stores/root/index.ts b/src/shared/stores/root/index.ts index 94a9ab4..6baf63e 100644 --- a/src/shared/stores/root/index.ts +++ b/src/shared/stores/root/index.ts @@ -24,6 +24,9 @@ type RootStore = { allowCover: boolean; setAllowCover: (allowCover: boolean) => void; + allowDwnld: boolean; + setAllowDwnld: (allowDwnld: boolean) => void; + cover: File | null; setCover: (cover: File | null) => void; @@ -68,6 +71,9 @@ export const useRootStore = create((set) => ({ allowCover: false, setAllowCover: (allowCover) => set({ allowCover }), + allowDwnld: false, + setAllowDwnld: (allowDwnld) => set({ allowDwnld }), + cover: null, setCover: (cover) => set({ cover }),