Initial commit: Tapalka monorepo (bot, front, strapi)

This commit is contained in:
2026-05-21 11:54:44 +07:00
commit 9ec9485940
208 changed files with 58314 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
'use client';
import { FC } from 'react';
import styles from '@/components/home/Home.module.css';
import Image from 'next/image';
interface IButtonMining {
isLoading: boolean;
typeButton: 'pending' | 'farming' | 'claim_reward';
remainingTime: string | null;
farm_amount: number | undefined;
handleStartFarming: () => void;
handleClaimRewards: () => void;
}
const ButtonMining: FC<IButtonMining> = ({
isLoading,
typeButton,
remainingTime,
farm_amount,
handleStartFarming,
handleClaimRewards,
}) => {
if (typeButton === 'pending') {
return (
<>
<button
className={
isLoading
? `${styles.disabledButton} ${styles.miningButton}`
: `${styles.miningButton}`
}
disabled={isLoading}
onClick={handleStartFarming}
>
Начать добычу
</button>
</>
);
}
if (typeButton === 'farming' && remainingTime) {
return (
<>
<button
className={
isLoading
? `${styles.disabledButton} ${styles.farmingButton}`
: `${styles.farmingButton}`
}
>{`Собрать добычу через ${remainingTime}`}</button>
</>
);
}
if (typeButton === 'claim_reward') {
return (
<>
<button
className={
isLoading
? `${styles.disabledButton} ${styles.claimReward}`
: `${styles.claimReward}`
}
onClick={handleClaimRewards}
>
<div className={styles.reward}>
<span>Собрать добычу </span>
<div className={styles.claimAmount}>
<span>+</span>
<Image
src={'/icons/small-robucks-icon.svg'}
alt={'small robucks'}
width={18}
height={18}
/>
<span>{farm_amount}</span>
</div>
</div>
</button>
</>
);
}
};
export default ButtonMining;

View File

@@ -0,0 +1,56 @@
.container {
position: relative;
display: flex;
align-items: center;
}
.container button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
.toast {
position: fixed;
right: -500px;
bottom: 90px;
height: 89px;
border-radius: 20px;
padding: 12px 16px;
background: #000000;
border: 1px solid #143916;
transition: right 0.3s ease;
font-size: 16px;
font-weight: 600;
line-height: 21px;
font-family: var(--font-manrope);
}
.content {
display: flex;
align-items: flex-start;
gap: 7px;
}
.closeIcon {
display: flex;
justify-content: flex-end;
width: 100%;
height: auto;
cursor: pointer;
}
.info {
display: flex;
flex-direction: column;
}
.info span {
font-size: 14px;
font-weight: 400;
line-height: 19px;
}
.visible {
right: 20px;
}

View File

@@ -0,0 +1,42 @@
'use client';
import { FC } from 'react';
import styles from '@/components/ui/copy-link-toast/CopyLinkToast.module.css';
import Image from 'next/image';
interface ICopyLinkToast {
isVisible: boolean;
handleCloseToastLink: () => void;
}
const CopyLinkToast: FC<ICopyLinkToast> = ({ isVisible, handleCloseToastLink }) => {
return (
<div className={styles.container}>
<div className={`${styles.toast} ${isVisible ? styles.visible : ''}`}>
<div className={styles.closeIcon} onClick={handleCloseToastLink}>
<Image
src={'/icons/close-icon.svg'}
alt={'close icon'}
width={12}
height={12}
/>
</div>
<div className={styles.content}>
<div className={styles.img}>
<Image
src={'/icons/info-icon.svg'}
alt={'info icon'}
height={24}
width={24}
/>
</div>
<div className={styles.info}>
<p>Приглашение скопировано 🔗</p>
<span>Отправьте эту ссылку своим друзьям 📨</span>
</div>
</div>
</div>
</div>
);
};
export default CopyLinkToast;

View File

@@ -0,0 +1,238 @@
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
transition: opacity 0.5s ease;
opacity: 0;
pointer-events: none;
}
.modal.isOpen {
opacity: 1;
pointer-events: auto;
}
.modalContent {
border-radius: 16px 16px 0 0;
background: #1A1A1C;
padding: 24px 16px 0 16px;
width: 100%;
height: 85%;
position: fixed;
bottom: -85%;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
transition: transform 0.5s ease;
}
.modalContent.isOpen {
transform: translateY(-100%);
}
.modalContent.isClosing {
transform: translateY(0);
}
.closeButton {
position: fixed;
top: 20px;
right: 20px;
width: 18px;
height: 18px;
cursor: pointer;
}
.closeButton::before,
.closeButton::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 18px;
height: 2px;
background: #FFFFFF26;
}
.closeButton::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.closeButton::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.exchangeRate {
margin: 10px 0;
}
.submitButton {
background-color: green;
color: white;
border: none;
padding: 10px;
cursor: pointer;
}
.modalTitle {
margin-top: 11px;
font-weight: 600;
font-size: 19px;
line-height: 20px;
color: white;
}
.content {
display: flex;
flex-direction: column;
width: 100%;
gap: 0.5rem;
}
.selectAmount {
display: flex;
flex-direction: column;
width: 100%;
margin-top: 1.5rem;
}
.selectAmountTitle {
display: flex;
justify-content: flex-start;
padding-bottom: 0.5rem;
color: white;
}
.inputRange {
width: 100%;
position: absolute;
bottom: 0;
left: 0;
}
.inputRange input[type='range'] {
display: flex;
align-items: center;
width: 100%;
-webkit-appearance: none;
appearance: none;
height: 5px;
background: #333;
border-radius: 5px;
outline: none;
transition: opacity .2s;
}
.inputRange input[type='range']::-moz-range-progress {
background-color: #17da17;
}
.inputRange input[type='range']::-moz-range-track {
background: #333;
}
.inputRange input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #42BB47;
border-radius: 50%;
cursor: pointer;
}
.inputRange input[type='range']::-moz-range-thumb {
width: 16px;
height: 16px;
background: #42BB47;
border-radius: 50%;
cursor: pointer;
}
.amount {
position: relative;
display: flex;
flex-direction: column;
border-radius: 16px 16px 0 0;
background: #FFFFFF0D;
padding: 16px;
color: white;
}
.rate {
background: #FFFFFF0D;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
height: 56px;
border-radius: 1rem;
padding: 1rem;
gap: 5px;
}
.rate span {
font-size: 24px;
color: #BBBBBB;
line-height: 28px;
font-weight: 500;
}
.nickname {
display: flex;
flex-direction: column;
width: 100%;
gap: 0.5rem;
margin-top: 1.5rem;
}
.nickname input {
background: #FFFFFF0D;
border-radius: 1rem;
padding: 1rem;
outline: none;
border: none;
height: 56px;
}
.nickname input::placeholder {
font-weight: 500;
color: #BBBBBB;
font-size: 14px;
line-height: 16px;
}
.nicknameTitle {
display: flex;
justify-content: flex-start;
}
.selectedAmount {
display: flex;
align-items: center;
gap: 5px;
justify-content: center;
}
.submitButton {
width: 100%;
background: #42BB47;
color: white;
border-radius: 30px;
padding: 1rem;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1.5rem;
font-weight: 600;
font-size: 16px;
line-height: 21px;
}

View File

@@ -0,0 +1,66 @@
'use client';
import { Dispatch, FC, RefObject, SetStateAction } from 'react';
import styles from '@/components/ui/modal-exchange/ModalExchange.module.css';
import ModalExchangeContent from '@/components/ui/modal-exchange/ModalExchangeContent';
import { useModalStore } from '@/store/modal.state';
interface IModalExchange {
isOpen: boolean;
isClosing: boolean;
amount: number;
balance: number | undefined;
nickname: string;
rangeRef: RefObject<HTMLInputElement>;
setNickname: Dispatch<SetStateAction<string>>;
setAmount: Dispatch<SetStateAction<number>>;
onClose: () => void;
handleCreateExchange: (amount: number, total: number, nickname: string) => Promise<void>;
}
const ModalExchange: FC<IModalExchange> = ({
isOpen,
isClosing,
amount,
balance,
nickname,
rangeRef,
onClose,
setNickname,
setAmount,
handleCreateExchange,
}) => {
const { priceItem, image, isDecimal } = useModalStore();
return (
<div
className={`${styles.modal} ${isOpen ? styles.isOpen : ''} ${isClosing ? styles.isClosing : ''}`}
>
<div
className={`${styles.modalContent} ${isOpen ? styles.isOpen : ''} ${isClosing ? styles.isClosing : ''}`}
>
<div className={styles.closeButton} onClick={onClose} />
<div className={styles.modalTitle}>
<span>Обмен</span>
</div>
<ModalExchangeContent
isDecimal={isDecimal}
balance={balance}
nickname={nickname}
amount={amount}
image={image}
priceItem={priceItem}
rangeRef={rangeRef}
setNickname={setNickname}
setAmount={setAmount}
/>
<button
className={styles.submitButton}
onClick={() => handleCreateExchange(amount, amount / +priceItem, nickname)}
>
Вывод
</button>
</div>
</div>
);
};
export default ModalExchange;

View File

@@ -0,0 +1,116 @@
'use client';
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import ModalExchange from '@/components/ui/modal-exchange/ModalExchange';
import { usePlayerStore } from '@/store/player.state';
import { useConfigStore } from '@/store/config.state';
import { useNotify } from '@/hooks/useNotify';
import { ExchangeRequestApi } from '@/api/exchange-request.api';
import { useModalStore } from '@/store/modal.state';
import { useTokenStore } from '@/store/token.state';
import { toFixed } from '@/utils/FormatNumber';
interface IModalExchangeContainer {
isOpen: boolean;
balance: number | undefined;
onClose: () => void;
}
const ModalExchangeContainer: FC<IModalExchangeContainer> = ({ balance, isOpen, onClose }) => {
const { token } = useTokenStore();
const [amount, setAmount] = useState<number>(1);
const [nickname, setNickname] = useState<string>('');
const [isClosing, setIsClosing] = useState<boolean>(false);
const rangeRef = useRef<HTMLInputElement>(null);
const { player, setPlayer } = usePlayerStore();
const { config } = useConfigStore();
const { shopItemId, isDecimal, image, setShopItemId, setIsDecimal } = useModalStore();
const { getSuccessNotify, getErrorNotifyByCount, getErrorNotifyByNickname } = useNotify();
useEffect(() => {
if (rangeRef.current) {
rangeRef.current.style.setProperty('--value', amount.toString());
}
}, [amount]);
useEffect(() => {
if (isOpen) {
setIsClosing(false);
setAmount(0);
}
}, [isOpen]);
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
onClose();
}, 500);
};
const handleCreateExchange = useCallback(
async (amount: number, total: number, nickname: string) => {
if (!token) {
return;
}
const exchangeRequestApi = ExchangeRequestApi.getInstance(token);
if (player && config) {
if (nickname.length === 0) {
getErrorNotifyByNickname();
} else if (total === 0) {
getErrorNotifyByCount();
} else {
exchangeRequestApi
.createExchange({
amount: amount,
total: total,
playerId: player.id,
nickname: nickname,
balance: player.attributes.balance,
telegram_id: config.attributes.admins.data.map(
user => user.attributes.telegram_id,
),
shop_item: shopItemId || undefined,
archive_request_id: player!.attributes.archive_request!.data?.id || -1,
})
.then(() => {
if (player) {
setPlayer({
...player,
attributes: {
...player.attributes,
balance: toFixed(player?.attributes.balance) - amount,
},
});
setShopItemId(null);
setIsDecimal(true);
}
});
getSuccessNotify();
handleClose();
}
}
},
[player, token, shopItemId],
);
return (
<ModalExchange
isOpen={isOpen}
isClosing={isClosing}
balance={balance}
amount={amount}
nickname={nickname}
rangeRef={rangeRef}
onClose={handleClose}
setNickname={setNickname}
setAmount={setAmount}
handleCreateExchange={handleCreateExchange}
/>
);
};
export default ModalExchangeContainer;

View File

@@ -0,0 +1,76 @@
'use client';
import { Dispatch, FC, RefObject, SetStateAction } from 'react';
import styles from '@/components/ui/modal-exchange/ModalExchange.module.css';
import Image from 'next/image';
import NicknameInput from '@/components/ui/modal-exchange/inputs/NicknameInput';
import RangeInput from '@/components/ui/modal-exchange/inputs/RangeInput';
import { toFixed } from '@/utils/FormatNumber';
interface IModalExchangeContent {
isDecimal: boolean;
balance: number | undefined;
nickname: string;
amount: number;
priceItem: number;
image: string;
rangeRef: RefObject<HTMLInputElement>;
setNickname: Dispatch<SetStateAction<string>>;
setAmount: Dispatch<SetStateAction<number>>;
}
const ModalExchangeContent: FC<IModalExchangeContent> = ({
isDecimal,
balance,
nickname,
amount,
priceItem,
image,
rangeRef,
setNickname,
setAmount,
}) => {
return (
<div className={styles.content}>
<div className={styles.selectAmount}>
<span className={styles.selectAmountTitle}>Выберите количество</span>
<div className={styles.amount}>
<div className={styles.selectedAmount}>
<span>{`${amount.toFixed(isDecimal ? 3 : 0)} - ${toFixed(balance)}`}</span>
<Image
src={'/icons/small-robucks-icon.svg'}
alt={'small robucks icon'}
height={18}
width={18}
/>
</div>
<RangeInput
isDecimal={isDecimal}
balance={balance}
amount={amount}
priceItem={priceItem}
rangeRef={rangeRef}
setAmount={setAmount}
/>
</div>
</div>
<div>
<Image
src={'/icons/exchange-icon.svg'}
alt={'exchange icon'}
width={24}
height={24}
/>
</div>
<div className={styles.rate}>
{priceItem && (
<span>{isDecimal ? (amount / priceItem).toFixed(3) : amount / priceItem}</span>
)}
<Image src={image} alt={'small coin icon'} height={24} width={24} />
</div>
<NicknameInput nickname={nickname} setNickname={setNickname} />
</div>
);
};
export default ModalExchangeContent;

View File

@@ -0,0 +1,28 @@
'use client';
import React, { Dispatch, FC, SetStateAction, useState } from 'react';
import styles from '@/components/ui/modal-exchange/ModalExchange.module.css';
interface INicknameInput {
nickname: string;
setNickname: Dispatch<SetStateAction<string>>;
}
const NicknameInput: FC<INicknameInput> = ({ nickname, setNickname }) => {
const handleChangeNickname = (e: React.ChangeEvent<HTMLInputElement>) => {
setNickname(e.target.value);
};
return (
<div className={styles.nickname}>
<span className={styles.nicknameTitle}>Укажите ваш профиль в Roblox</span>
<input
placeholder={'Ник в Roblox'}
type={'text'}
value={nickname}
onChange={handleChangeNickname}
/>
</div>
);
};
export default NicknameInput;

View File

@@ -0,0 +1,48 @@
'use client';
import { Dispatch, FC, SetStateAction } from 'react';
import styles from '@/components/ui/modal-exchange/ModalExchange.module.css';
interface IRangeInput {
isDecimal: boolean;
balance: number | undefined;
amount: number;
priceItem: number;
rangeRef: React.RefObject<HTMLInputElement>;
setAmount: Dispatch<SetStateAction<number>>;
}
const RangeInput: FC<IRangeInput> = ({
isDecimal,
balance,
priceItem,
amount,
rangeRef,
setAmount,
}) => {
const handleRangeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = parseFloat(e.target.value);
if (!isDecimal) {
value = Math.round(value / priceItem) * priceItem;
} else {
value = parseFloat(value.toFixed(6));
}
setAmount(value);
};
return (
<div className={styles.inputRange}>
<input
type='range'
id='amountRange'
min='0'
max={balance}
step={isDecimal ? '0.000001' : priceItem.toString()}
value={amount}
onChange={handleRangeChange}
ref={rangeRef}
/>
</div>
);
};
export default RangeInput;

View File

@@ -0,0 +1,40 @@
.container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
padding-bottom: 1rem;
padding-top: 1rem;
position: fixed;
height: 82px;
width: 100%;
background: #101010;
bottom: 0;
z-index: 1;
}
.nav_menu, .active_nav_menu {
display: flex;
flex-direction: column;
align-items: center;
font-size: 12px;
font-weight: 600;
line-height: 16.3px;
gap: 3px;
}
.countTasks {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
right: 10%;
top: -10%;
background: #D92000;
}
.active_nav_menu span, .active_nav_menu img {
color: #42BB47;
}

View File

@@ -0,0 +1,126 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import styles from './Navigation.module.css';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { usePlayerStore } from '@/store/player.state';
import { useTasksStore } from '@/store/tasks.state';
import { ProvidersProps } from '@/components/providers/Providers';
const Navigation = ({ id }: Pick<ProvidersProps, 'id'>) => {
const pathname = usePathname();
const { player } = usePlayerStore();
const [isLoading, setIsLoading] = useState<boolean>(true);
const { tasks } = useTasksStore();
const countTasks = tasks.length - (player?.attributes.finished_tasks.data?.length || 0);
useEffect(() => {
if (isLoading) {
setIsLoading(false);
}
}, [isLoading]);
const getNavLink = (endpoint: string) => {
return `/${id}/${endpoint}`;
};
return (
<div className={styles.container}>
<Link href={getNavLink('home')} style={{ textDecoration: 'none' }}>
<div className={pathname === '/' ? styles.active_nav_menu : styles.nav_menu}>
{pathname === '/' ? (
<Image
src={'/icons/pickaxe-icon-selected.svg'}
alt={'pickaxe-icon'}
width={24}
height={24}
/>
) : (
<Image
src={'/icons/pickaxe-icon.svg'}
alt={'pickaxe-icon'}
width={24}
height={24}
/>
)}
<span>Главная</span>
</div>
</Link>
<Link href={getNavLink('shop')} style={{ textDecoration: 'none' }}>
<div className={pathname === '/shop' ? styles.active_nav_menu : styles.nav_menu}>
{pathname === '/shop' ? (
<Image
src={'/icons/shop-icon-selected.svg'}
alt={'shop-icon'}
width={24}
height={24}
/>
) : (
<Image
src={'/icons/shop-icon.svg'}
alt={'shop-icon'}
width={24}
height={24}
/>
)}
<span>Магазин</span>
</div>
</Link>
<Link
href={getNavLink('tasks')}
style={{ textDecoration: 'none', position: 'relative', height: '82px' }}
>
{countTasks !== 0 && <div className={styles.countTasks}>{countTasks}</div>}
<div
className={
pathname === '/tasks'
? `${styles.active_nav_menu} ${styles.task}`
: `${styles.nav_menu} ${styles.task}`
}
>
{pathname === '/tasks' ? (
<Image
src={'/icons/task-list-icon-selected.svg'}
alt={'task-list-icon'}
width={24}
height={24}
/>
) : (
<Image
src={'/icons/task-list-icon.svg'}
alt={'task-list-icon'}
width={24}
height={24}
/>
)}
<span>Задания</span>
</div>
</Link>
<Link href={getNavLink('friends')} style={{ textDecoration: 'none' }}>
<div className={pathname === '/friends' ? styles.active_nav_menu : styles.nav_menu}>
{pathname === '/friends' ? (
<Image
src={'/icons/friends-icon-selected.svg'}
alt={'friends-icon'}
width={24}
height={24}
/>
) : (
<Image
src={'/icons/friends-icon.svg'}
alt={'friends-icon'}
width={24}
height={24}
/>
)}
<span>Друзья</span>
</div>
</Link>
</div>
);
};
export default Navigation;

View File

@@ -0,0 +1,6 @@
.wrapper {
max-width: 100%;
min-height: 100vh;
position: relative;
overflow-y: hidden;
}

View File

@@ -0,0 +1,12 @@
import {PropsWithChildren} from "react";
import styles from './Wrapper.module.css'
const Wrapper = ({ children } : PropsWithChildren) => {
return(
<div className={styles.wrapper}>
{children}
</div>
);
};
export default Wrapper;