diff --git a/.prettierrc.cjs b/.prettierrc.cjs index e643a35..77c651e 100644 --- a/.prettierrc.cjs +++ b/.prettierrc.cjs @@ -1,7 +1,7 @@ module.exports = { singleQuote: true, - trailingComma: "all", + trailingComma: 'all', printWidth: 120, semi: true, - plugins: ["prettier-plugin-tailwindcss"], + plugins: ['prettier-plugin-tailwindcss'], }; diff --git a/src/components/common/About/index.tsx b/src/components/common/About/index.tsx index 78a890b..dc7491f 100644 --- a/src/components/common/About/index.tsx +++ b/src/components/common/About/index.tsx @@ -36,8 +36,8 @@ const INSTRUCTIONS = [ subtitle: 'Ask another question:', description: (

- To start a new session, click "Make a New Forecast" or refresh the - page + To start a new session, click " + Make a New Forecast" or refresh the page

), }, diff --git a/src/components/common/AdminBtn/index.tsx b/src/components/common/AdminBtn/index.tsx new file mode 100644 index 0000000..3d34920 --- /dev/null +++ b/src/components/common/AdminBtn/index.tsx @@ -0,0 +1,17 @@ +import { Link } from 'react-router'; + +import { Button } from '@/components/ui/button'; +import { routes } from '@/lib/router'; + +export const AdminBtn = () => { + return ( + + + + ); +}; diff --git a/src/components/pages/game/game.tsx b/src/components/pages/game/game.tsx index d759302..4c3a603 100644 --- a/src/components/pages/game/game.tsx +++ b/src/components/pages/game/game.tsx @@ -20,7 +20,9 @@ const TarotRequestSchema = z.object({ .min(3, 'Min 3 symbols') .max(1000, 'Max 1000 symbols') // .regex(/^[a-zA-Z0-9.,!?-\s]+$/, 'Only English letters and numbers are allowed') - .refine((value) => value.trim() !== '', { message: 'String cannot consist of only spaces' }), + .refine((value) => value.trim() !== '', { + message: 'String cannot consist of only spaces', + }), }); const DEFAULT_IMAGE = 'images/tarot-game/bord.png'; diff --git a/src/components/pages/home/faq.tsx b/src/components/pages/home/faq.tsx index 44f20f8..456bafd 100644 --- a/src/components/pages/home/faq.tsx +++ b/src/components/pages/home/faq.tsx @@ -28,7 +28,10 @@ const FAQ_MOCK = [ title: 'Which wallets are supported?', description: 'We currently support two Solana-compatible wallets: Phantom and Solflare', }, - { title: 'What payment tokens can I use?', description: 'You can choose SOL for your payment.' }, + { + title: 'What payment tokens can I use?', + description: 'You can choose SOL for your payment.', + }, { title: 'Can I use this service anonymously?', description: diff --git a/src/components/pages/home/hero.tsx b/src/components/pages/home/hero.tsx index d8b0bd3..d0aac47 100644 --- a/src/components/pages/home/hero.tsx +++ b/src/components/pages/home/hero.tsx @@ -1,3 +1,4 @@ +import { AdminBtn } from '@/components/common/AdminBtn'; import { GoToTwitterBtn } from '@/components/common/GoToTwitterBtn'; import { PredictFutureBtn } from '@/components/common/PredictFutureBtn'; @@ -8,6 +9,7 @@ export const Hero = () => {
+
{ + const { publicKey, signMessage } = useWallet(); + + const client = useQueryClient(); + + return useMutation({ + async mutationFn({ status }: { status: boolean }) { + if (!publicKey || !signMessage) { + return; + } + + const client = authClient(); + + const uuid = crypto.randomUUID(); + + const message = `I am the admin of Tarot ${uuid}`; + + const encodedMessage = new TextEncoder().encode(message); + const signature = bs58.encode(await signMessage(encodedMessage)); + + client.setHeader('x-admin-signature', signature); + client.setHeader('x-admin-uuid', uuid); + + return await post<{ + isShutDown: boolean; + id: number; + }>(client, status ? `shutdown/enable` : 'shutdown/shutdown'); + }, + async onSuccess() { + await client.invalidateQueries({ + queryKey: ['status'], + }); + }, + onError(error) { + console.trace(error); + }, + }); +}; + +export default useControlService; diff --git a/src/hooks/api/use-status.ts b/src/hooks/api/use-status.ts new file mode 100644 index 0000000..2922f44 --- /dev/null +++ b/src/hooks/api/use-status.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; + +import { get } from './utils'; + +import { authClient } from '@/lib/fetcher'; + +const useStatus = () => { + return useQuery({ + queryKey: ['status'], + queryFn: async () => { + const client = authClient(); + + return await get<{ + isShutDown: boolean; + id: number; + } | null>(client, 'shutdown/status'); + }, + refetchInterval: 10000, + }); +}; + +export default useStatus; diff --git a/src/hooks/contracts/write/use-make-prediction.ts b/src/hooks/contracts/write/use-make-prediction.ts index fd0df8a..bd754f9 100644 --- a/src/hooks/contracts/write/use-make-prediction.ts +++ b/src/hooks/contracts/write/use-make-prediction.ts @@ -1,13 +1,48 @@ import { useWallet } from '@solana/wallet-adapter-react'; import { SystemProgram, Transaction } from '@solana/web3.js'; import { useMutation } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; import { OwnerAddress } from '@/constants/addresses'; import { env } from '@/env'; import useSubmitTarotCards from '@/hooks/api/use-submit-cards'; import { network } from '@/lib/solana'; import { sendAndConfirmTransaction } from '@/lib/solana/utils'; -import { getRandomTarotCards, showTxToast } from '@/lib/utils'; +import { getRandomTarotCards } from '@/lib/utils'; + +let toastId: string | number | null = null; + +const notify = () => { + toastId = toast('Making prediction...', { + autoClose: false, + closeOnClick: false, + draggable: false, + isLoading: true, + type: 'default', + }); +}; + +const updateToast = (toastId: string | number | null) => { + if (toastId !== null) { + toast.update(toastId, { + render: 'Done!', + type: 'success', + autoClose: 3000, + isLoading: false, + }); + } +}; + +const handleErrorToast = (toastId: string | number | null) => { + if (toastId !== null) { + toast.update(toastId, { + render: 'Error occurred!', + type: 'error', + autoClose: 3000, + isLoading: false, + }); + } +}; const useMakePrediction = () => { const { publicKey, sendTransaction } = useWallet(); @@ -19,30 +54,35 @@ const useMakePrediction = () => { return; } - return await showTxToast('Making prediction', async () => { - const rawTx = new Transaction(); - - rawTx.add( - SystemProgram.transfer({ - fromPubkey: publicKey, - toPubkey: OwnerAddress[network], - lamports: Number(env.VITE_DEPOSIT_AMOUNT_SOL) * 1e9, - }), - ); - - const txHash = await sendAndConfirmTransaction(publicKey, rawTx, sendTransaction); - const tarots = getRandomTarotCards(txHash + publicKey.toBase58()); - const result = await submitCards({ tarots, hash: txHash, question }); - - return { - tarots, - answer: result?.response ?? '', - }; - }); + notify(); + + const rawTx = new Transaction(); + + rawTx.add( + SystemProgram.transfer({ + fromPubkey: publicKey, + toPubkey: OwnerAddress[network], + lamports: Number(env.VITE_DEPOSIT_AMOUNT_SOL) * 1e9, + }), + ); + + const txHash = await sendAndConfirmTransaction(publicKey, rawTx, sendTransaction); + + const tarots = getRandomTarotCards(txHash + publicKey.toBase58()); + + const result = await submitCards({ tarots, hash: txHash, question }); + + updateToast(toastId); + + return { + tarots, + answer: result?.response ?? '', + }; }, onError(error) { console.trace(error); + handleErrorToast(toastId); }, }); }; diff --git a/src/lib/fetcher.ts b/src/lib/fetcher.ts index 51d85f4..7b2027d 100644 --- a/src/lib/fetcher.ts +++ b/src/lib/fetcher.ts @@ -16,6 +16,10 @@ export class Fetcher { this._headers = headers; } + public setHeader(key: string, value: string) { + this._headers[key] = value; + } + public async get(url: string) { return this._processResponse( fetch(new URL(url, this._baseURL), { diff --git a/src/lib/router.ts b/src/lib/router.ts index e156e36..1f4cc66 100644 --- a/src/lib/router.ts +++ b/src/lib/router.ts @@ -7,6 +7,7 @@ import { MainLayout } from '@/layouts/main-layout'; export const routes = { HOME: '/', GAME: '/game', + ADMIN: '/admin', } as const; export const router = createBrowserRouter([ @@ -21,6 +22,10 @@ export const router = createBrowserRouter([ path: routes.GAME, Component: lazy(() => import('@/pages/game-page')), }, + { + path: routes.ADMIN, + Component: lazy(() => import('@/pages/admin-page')), + }, ], }, { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c6a745e..dee146f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -43,7 +43,10 @@ export const getRandomTarotCards = (hash: string): TarotCard[] => { .map((sum) => sum % 50 === 0) .slice(0, 3); - return tarotIds.map((tarot, idx) => ({ id: tarot, reverted: isReverted[idx] })); + return tarotIds.map((tarot, idx) => ({ + id: tarot, + reverted: isReverted[idx], + })); }; const calculateByteSum = (str: string): number => stringToBytes(str).reduce((sum, byte) => sum + byte, 0); diff --git a/src/pages/admin-page.tsx b/src/pages/admin-page.tsx new file mode 100644 index 0000000..7a14822 --- /dev/null +++ b/src/pages/admin-page.tsx @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import { useWallet } from '@solana/wallet-adapter-react'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router'; + +import { Header } from '@/components/common/Header'; +import { Button } from '@/components/ui/button'; +import { OwnerAddress } from '@/constants/addresses'; +import useControlService from '@/hooks/api/use-control-service'; +import useStatus from '@/hooks/api/use-status'; +import { network } from '@/lib/solana'; + +export default function AdminPage() { + const { publicKey } = useWallet(); + const navigate = useNavigate(); + + const { data: status } = useStatus(); + + const { mutateAsync } = useControlService(); + + useEffect(() => { + if (!publicKey?.equals(OwnerAddress[network])) { + console.log(publicKey?.toBase58(), OwnerAddress[network].toBase58()); + navigate('/'); + } + }, [navigate, publicKey]); + + const handleControl = async (status: boolean) => { + await mutateAsync({ + status: status, + }); + }; + + return ( + <> +
+ +
+

Status: {status?.isShutDown === true ? 'Disabled' : 'Enabled'}

+ +
+ + +
+
+ + ); +}