Commit cebef8e7 by asranov0003

feat: initial commit

parents
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
{
"name": "thecybernanny-webapp",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"axios": "^1.10.0",
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.2.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.3",
"react-icons": "^5.5.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.6.2"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
\ No newline at end of file
import React from "react";
import Router from "./routes/Router";
import useTelegramExpand from "./hooks/useTelegramExpand";
const App: React.FC = () => {
useTelegramExpand();
return <Router />;
};
export default App;
import { Telegram } from "@twa-dev/types";
declare global {
interface Window {
Telegram: Telegram;
}
}
window.Telegram.WebApp.HapticFeedback.notificationOccurred("success");
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 400;
src: local('Space Mono'), url('https://fonts.cdnfonts.com/s/15317/SpaceMono-Regular.woff') format('woff');
}
@font-face {
font-family: 'Space Mono';
font-style: italic;
font-weight: 400;
src: local('Space Mono'), url('https://fonts.cdnfonts.com/s/15317/SpaceMono-Italic.woff') format('woff');
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 700;
src: local('Space Mono'), url('https://fonts.cdnfonts.com/s/15317/SpaceMono-Bold.woff') format('woff');
}
@font-face {
font-family: 'Space Mono';
font-style: italic;
font-weight: 700;
src: local('Space Mono'), url('https://fonts.cdnfonts.com/s/15317/SpaceMono-BoldItalic.woff') format('woff');
}
\ No newline at end of file
.cbtn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--on-background-color);
color: var(--on-text-color);
padding: 1rem;
text-align: center;
border: none;
border-radius: 10px;
cursor: pointer;
transition: 0.3s;
}
.cbtn-primary {
background: var(--primary-color);
color: var(--on-text-color);
}
.cbtn-success {
background: var(--success-color);
color: var(--on-text-color);
}
.cbtn-danger {
background: var(--danger-color);
color: var(--on-text-color);
}
.cbtn:hover {
opacity: 0.8;
}
.cbtn:disabled {
background: var(--gray-color);
cursor: not-allowed;
}
.cbtn:disabled:hover {
opacity: 1;
}
.cbtn__spinner {
border: 2px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--on-text-color);
border-radius: 50%;
width: 14px;
height: 14px;
animation: spin 1s linear infinite;
margin-left: 8px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
\ No newline at end of file
import React from "react";
import "./CButton.css";
import type { CButtonProps } from "./CButton.types";
const CButton: React.FC<CButtonProps> = ({
title,
onClick,
disabled,
variant,
isLoading,
style,
className,
type,
}) => {
return (
<button
onClick={onClick}
disabled={disabled || isLoading}
style={style}
className={`cbtn ${variant ? `cbtn-${variant}` : ""} ${className || ""}`}
type={type}
>
<span>{title}</span>
{isLoading && <div className="cbtn__spinner"></div>}
</button>
);
};
export default CButton;
export interface CButtonProps {
title?: string;
onClick?: () => void;
disabled?: boolean;
variant?: "primary" | "secondary" | "success" | "danger";
isLoading?: boolean;
style?: React.CSSProperties;
className?: string;
type?: "button" | "submit" | "reset";
}
export { default } from "./CButton";
.cinput-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.cinput-label {
font-size: 0.9rem;
font-weight: bold;
color: var(--on-color);
}
.cinput-inner {
position: relative;
display: flex;
align-items: center;
}
.cinput {
width: 100%;
padding: 0.75rem 1rem;
padding-right: 2.5rem;
border: 1px solid var(--gray-color);
border-radius: 10px;
outline: none;
font-size: 1rem;
transition: 0.2s;
}
.cinput-icon {
position: absolute;
right: 1rem;
cursor: pointer;
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
}
.cinput-error {
border-color: var(--danger-color);
}
.cinput-error-msg {
color: var(--danger-color);
font-size: 0.85rem;
margin-top: 1px;
}
\ No newline at end of file
import { useState, useId, forwardRef } from "react";
import "./CInput.css";
import { FiEye, FiEyeOff } from "react-icons/fi";
import type { CInputProps } from "./CInput.types";
const CInput = forwardRef<HTMLInputElement, CInputProps>(
(
{
label,
placeholder,
type = "text",
disabled,
error,
style,
className,
...rest
},
ref
) => {
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === "password";
const inputId = useId();
return (
<div className={`cinput-wrapper ${className || ""}`} style={style}>
{label && (
<label className="cinput-label" htmlFor={inputId}>
{label}
</label>
)}
<div className="cinput-inner">
<input
id={inputId}
className={`cinput ${error ? "cinput-error" : ""}`}
type={isPassword && showPassword ? "text" : type}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
ref={ref}
{...rest}
/>
{isPassword && (
<span
className="cinput-icon"
onClick={() => setShowPassword((prev) => !prev)}
>
{showPassword ? <FiEyeOff /> : <FiEye />}
</span>
)}
</div>
{error && <span className="cinput-error-msg">{error}</span>}
</div>
);
}
);
export default CInput;
import type { InputHTMLAttributes } from "react";
export interface CInputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
style?: React.CSSProperties;
className?: string;
}
export { default } from "./CInput";
.cselect-container {
width: 100%;
border-radius: 10px;
padding: 0.75rem 1rem;
background: var(--background-color);
color: var(--text-color);
border: 1px solid var(--gray-color);
cursor: pointer;
user-select: none;
position: relative;
transition: 0.3s;
}
.cselect-disabled {
background: var(--light-gray-color);
cursor: not-allowed;
opacity: 0.6;
}
.cselect-selected {
display: flex;
align-items: center;
justify-content: space-between;
}
.cselect-img {
width: 20px;
height: 20px;
margin-right: 8px;
border-radius: 50%;
object-fit: cover;
}
.cselect-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--background-color);
border-radius: 10px;
margin-top: 4px;
z-index: 100;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
max-height: 200px;
overflow-y: auto;
}
.cselect-option {
padding: 0.75rem 1rem;
display: flex;
align-items: center;
cursor: pointer;
transition: 0.2s;
}
.cselect-option:hover {
background: rgba(0, 0, 0, 0.05);
}
.cselect-option.selected {
background: var(--primary-color);
color: var(--on-text-color);
}
.cselect-arrow {
margin-left: auto;
font-size: 0.8rem;
}
.cselect-img {
width: 20px;
height: 20px;
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
}
\ No newline at end of file
import React, { useState, useRef, useEffect } from "react";
import "./CSelect.css";
import type { CSelectProps } from "./CSelect.types";
import { FaChevronDown, FaChevronUp } from "react-icons/fa";
const CSelect: React.FC<CSelectProps> = ({
options,
value,
onChange,
disabled,
variant,
style,
className,
placeholder = "Select...",
}) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const selected = options.find((opt) => opt.value === value);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div
ref={ref}
className={`cselect-container ${variant ? `cselect-${variant}` : ""} ${
className || ""
} ${disabled ? "cselect-disabled" : ""}`}
style={style}
onClick={() => !disabled && setOpen(!open)}
>
<div className="cselect-selected">
{selected?.image && <div className="cselect-img">{selected.image}</div>}
<span>{selected?.label || placeholder}</span>
<span className="cselect-arrow">
{open ? <FaChevronUp /> : <FaChevronDown />}
</span>
</div>
{open && (
<div className="cselect-dropdown">
{options.map((opt) => (
<div
key={opt.value}
className={`cselect-option ${
opt.value === value ? "selected" : ""
}`}
onClick={(e) => {
e.stopPropagation();
onChange?.(opt.value);
setOpen(false);
}}
>
{opt.image && <div className="cselect-img">{opt.image}</div>}
<span>{opt.label}</span>
</div>
))}
</div>
)}
</div>
);
};
export default CSelect;
export interface Option {
label: string;
value: string;
image?: React.ReactNode;
}
export interface CSelectProps {
options: Option[];
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
variant?: "primary" | "secondary" | "success" | "danger";
style?: React.CSSProperties;
className?: string;
placeholder?: string;
}
export { default } from "./CSelect";
// export const API_URL = "https://cabinet.thecybernanny.com/nanny/backend/rpc"; // PROD
export const API_URL = "https://cabinet.dev.thecybernanny.com/nanny/backend/rpc"; // DEV
export const TG_USER_ID = window.Telegram.WebApp.initDataUnsafe?.user?.id || 0;
export const TOKEN = localStorage.getItem(`token-${TG_USER_ID}`) || "";
import React, { createContext, useContext, useState } from "react";
import { TG_USER_ID } from "../../constants/constants";
interface AuthContextType {
isAuthenticated: boolean;
setAuthenticated: (auth: boolean) => void;
}
const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
setAuthenticated: () => {},
});
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [isAuthenticated, setAuthenticated] = useState<boolean>(() => {
return !!localStorage.getItem(`token-${TG_USER_ID}`);
});
return (
<AuthContext.Provider value={{ isAuthenticated, setAuthenticated }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
import { createContext } from "react";
import type { LanguageContextType } from "./LanguageContext.types";
export const LanguageContext = createContext<LanguageContextType | undefined>(
undefined
);
export type Language = "en" | "ru";
export interface LanguageContextType {
language: Language;
changeLanguage: (language: Language) => void;
}
import { useEffect, useState } from "react";
import type { Language } from "./LanguageContext.types";
import i18next from "i18next";
import { LanguageContext } from "./LanguageContext";
export const LanguageProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [language, setLanguage] = useState<Language>("ru");
const changeLanguage = (newLanguage: Language) => {
setLanguage(newLanguage);
i18next.changeLanguage(newLanguage);
localStorage.setItem("i18nextLng", newLanguage);
};
useEffect(() => {
const savedLanguage =
(localStorage.getItem("i18nextLng") as Language) || "ru";
setLanguage(savedLanguage);
i18next.changeLanguage(savedLanguage);
}, []);
return (
<LanguageContext.Provider value={{ language, changeLanguage }}>
{children}
</LanguageContext.Provider>
);
};
import { useContext } from "react";
import { LanguageContext } from "./LanguageContext";
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (!context) {
throw new Error("useLanguage must be used within a LanguageProvider");
}
return context;
};
import { createContext } from "react";
import type { ThemeContextType } from "./ThemeContext.types";
export const ThemeContext = createContext<ThemeContextType | undefined>(
undefined
);
export type Theme = "light" | "dark";
export interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
import { useEffect, useState } from "react";
import type { Theme } from "./ThemeContext.types";
import { ThemeContext } from "./ThemeContext";
import { TG_USER_ID } from "../../constants/constants";
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
const [theme, setTheme] = useState<Theme>("light");
useEffect(() => {
const savedTheme =
(localStorage.getItem(`theme-${TG_USER_ID}`) as Theme) || "light";
setTheme(savedTheme);
document.documentElement.setAttribute("data-theme", savedTheme);
}, []);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(`theme-${TG_USER_ID}`, theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
import { useContext } from "react";
import { ThemeContext } from "./ThemeContext";
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};
import { useEffect } from "react";
const useTelegramExpand = () => {
useEffect(() => {
window.Telegram.WebApp.expand();
}, []);
};
export default useTelegramExpand;
import React from "react";
import "./Footer.css";
const Footer: React.FC = () => {
return <div>Footer</div>;
};
export default Footer;
export { default } from "./Footer";
import React from "react";
import "./Header.css";
const Header: React.FC = () => {
return <div>Header</div>;
};
export default Header;
export { default } from "./Header";
{
"auth": {
"entrance": "Sign In",
"login": "Login",
"loginPlaceholder": "example@mail.com",
"password": "Password",
"passwordPlaceholder": "Enter your password",
"noAccount": "Don't have an account?",
"register": "Register",
"forgetPassword": "Forgot password?",
"recover": "Recover",
"passwordRecovery": "Password recovery",
"newPassword": "New password",
"newPasswordPlaceholder": "Enter new password",
"repeatPassword": "Repeat password",
"repeatPasswordPlaceholder": "Repeat new password",
"passwordsDoNotMatch": "Passwords do not match",
"iaccept": "I accept the",
"terms": "terms of service",
"privacyPolicy": "privacy policy"
},
"button": {
"login": "Login",
"register": "Register",
"recover": "Recover",
"continue": "Continue",
"resetPassword": "Reset Password"
},
"notFound": {
"code": "404",
"message": "This page could not be found."
},
"error": {
"requiredField": "This field is required",
"requiredCheckbox": "You must accept this"
}
}
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import enTranslation from "./en/en.json";
import ruTranslation from "./ru/ru.json";
if (!localStorage.getItem("i18nextLng")) {
localStorage.setItem("i18nextLng", "en");
}
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
debug: false,
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
resources: {
en: {
translation: enTranslation,
},
ru: {
translation: ruTranslation,
},
},
});
export default i18n;
{
"auth": {
"entrance": "Вход",
"login": "Логин",
"loginPlaceholder": "example@mail.com",
"password": "Пароль",
"passwordPlaceholder": "Введите ваш пароль",
"noAccount": "Нет аккаунта?",
"register": "Зарегистрироваться",
"forgetPassword": "Забыли пароль?",
"recover": "Восстановить",
"passwordRecovery": "Восстановление пароля",
"newPassword": "Новый пароль",
"newPasswordPlaceholder": "Введите новый пароль",
"repeatPassword": "Повторите пароль",
"repeatPasswordPlaceholder": "Повторите новый пароль",
"passwordsDoNotMatch": "Пароли не совпадают",
"iaccept": "Я принимаю",
"terms": "условия использования",
"privacyPolicy": "политику конфиденциальности"
},
"button": {
"login": "Войти",
"register": "Зарегистрироваться",
"recover": "Восстановить",
"continue": "Продолжить",
"resetPassword": "Сбросить пароль"
},
"notFound": {
"code": "404",
"message": "Эта страница не найдена."
},
"error": {
"requiredField": "Это поле обязательное",
"requiredCheckbox": "Вы должны принять это"
}
}
import { createRoot } from "react-dom/client";
import "./styles/global.css";
import App from "./App.tsx";
import { Provider } from "react-redux";
import { store } from "./stores/store.ts";
import "./locales/i18n.ts";
import { ThemeProvider } from "./contexts/ThemeContext/ThemeProvider.tsx";
import { LanguageProvider } from "./contexts/LanguageContext/LanguageProvider.tsx";
import { AuthProvider } from "./contexts/AuthContext/AuthContext.tsx";
createRoot(document.getElementById("root")!).render(
<Provider store={store}>
<AuthProvider>
<ThemeProvider>
<LanguageProvider>
<App />
</LanguageProvider>
</ThemeProvider>
</AuthProvider>
</Provider>
);
.auth__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.auth__form__text__danger {
color: var(--danger-color);
font-size: 0.85rem;
margin-top: 1px;
}
.auth__form__links p {
font-size: 0.875rem;
color: var(--gray-color);
margin-bottom: 5px;
}
.auth__form__links p a {
color: var(--text-color);
}
.auth__agreement {
display: flex;
align-items: center;
gap: 5px;
}
.auth__agreement label {
cursor: pointer;
font-size: 0.875rem;
color: var(--gray-color);
}
.auth__agreement a {
color: var(--text-color);
text-decoration: underline;
}
\ No newline at end of file
.authheader {
display: flex;
align-items: center;
}
.header__back {
font-size: 1.5rem;
color: var(--text-color);
cursor: pointer;
transition: 0.3s;
}
.header__back:hover {
opacity: 0.8;
}
.authheader .cselect-container {
width: 180px;
margin-left: auto;
}
\ No newline at end of file
import React from "react";
import "./AuthHeader.css";
import CSelect from "../../../components/CSelect";
import en from "../../../assets/images/flag-en.png";
import ru from "../../../assets/images/flag-ru.png";
import { useLanguage } from "../../../contexts/LanguageContext/useLanguage";
import type { AuthHeaderProps } from "./AuthHeader.types";
import { IoArrowBack } from "react-icons/io5";
const AuthHeader: React.FC<AuthHeaderProps> = ({ back, onBack }) => {
const { language, changeLanguage } = useLanguage();
return (
<div className="authheader">
{back && <IoArrowBack onClick={onBack} className="header__back"/>}
<CSelect
value={language}
onChange={(flag: string) => changeLanguage(flag as "en" | "ru")}
options={[
{
label: "English",
value: "en",
image: (
<img
src={en}
alt="en"
style={{ width: 20, height: 20, objectFit: "cover" }}
/>
),
},
{
label: "Русский",
value: "ru",
image: (
<img
src={ru}
alt="ru"
style={{ width: 20, height: 20, objectFit: "cover" }}
/>
),
},
]}
/>
</div>
);
};
export default AuthHeader;
export interface AuthHeaderProps {
back?: boolean;
onBack?: () => void;
}
export { default } from "./AuthHeader";
import React from "react";
import "../Auth.css";
import CInput from "../../../components/CInput";
import { Link, useNavigate } from "react-router-dom";
import CButton from "../../../components/CButton";
import AuthHeader from "../AuthHeader";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { authLogin } from "../../../stores/slices/authSlice";
import {
useAppDispatch,
useAppSelector,
type RootState,
} from "../../../stores/store";
import { useAuth } from "../../../contexts/AuthContext/AuthContext";
import { TG_USER_ID } from "../../../constants/constants";
interface ILoginFormData {
login: string;
password: string;
}
const Login: React.FC = () => {
const { t } = useTranslation();
const { setAuthenticated } = useAuth();
const navigate = useNavigate();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ILoginFormData>();
const dispatch = useAppDispatch();
const { loading, error } = useAppSelector((state: RootState) => state.auth);
const onSubmit = async (data: ILoginFormData) => {
try {
const result = await dispatch(authLogin(data));
if (authLogin.fulfilled.match(result)) {
const token = result.payload.token;
localStorage.setItem(`token-${TG_USER_ID}`, token);
setAuthenticated(true);
navigate("/home");
}
} catch (e) {
console.error("Unexpected error in login form", e);
}
};
return (
<div className="wrapper login">
<AuthHeader />
<h2>{t("auth.entrance")}</h2>
<form className="auth__form" onSubmit={handleSubmit(onSubmit)}>
<CInput
label={`${t("auth.login")}`}
placeholder={`${t("auth.loginPlaceholder")}`}
{...register("login", { required: t("error.requiredField") })}
error={errors.login?.message as string}
/>
<CInput
label={`${t("auth.password")}`}
placeholder={`${t("auth.passwordPlaceholder")}`}
type="password"
{...register("password", { required: t("error.requiredField") })}
error={errors.password?.message as string}
/>
<div className="auth__form__links">
<p>
{t("auth.noAccount")}{" "}
<Link to={"/auth/register"}>{t("auth.register")}</Link>
</p>
<p>
{t("auth.forgetPassword")}{" "}
<Link to={"/auth/recover"}>{t("auth.recover")}</Link>
</p>
</div>
{error && <p className="text-danger text-center">{error}</p>}
<CButton title={`${t("button.continue")}`} isLoading={loading} />
</form>
</div>
);
};
export default Login;
export { default } from "./Login";
import React from "react";
import "../Auth.css";
import AuthHeader from "../AuthHeader";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import CInput from "../../../components/CInput";
import CButton from "../../../components/CButton";
import { useForm } from "react-hook-form";
import {
useAppDispatch,
useAppSelector,
type RootState,
} from "../../../stores/store";
import { authRecover } from "../../../stores/slices/authSlice";
interface IRecoverFormData {
login: string;
newPassword: string;
repeatPassword: string;
}
const Recover: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { loading, error, success } = useAppSelector(
(state: RootState) => state.auth
);
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<IRecoverFormData>();
const newPasswordValue = watch("newPassword");
const onSubmit = async (data: IRecoverFormData) => {
dispatch(authRecover(data));
};
return (
<div className="wrapper recover">
<AuthHeader back onBack={() => navigate("/auth/login")} />
<h2>{t("auth.passwordRecovery")}</h2>
<form className="auth__form" onSubmit={handleSubmit(onSubmit)}>
<CInput
label={`${t("auth.login")}`}
placeholder={`${t("auth.loginPlaceholder")}`}
{...register("login", { required: t("error.requiredField") })}
error={errors.login?.message as string}
/>
<CInput
label={`${t("auth.newPassword")}`}
placeholder={`${t("auth.newPasswordPlaceholder")}`}
type="password"
{...register("newPassword", { required: t("error.requiredField") })}
error={errors.newPassword?.message as string}
/>
<CInput
label={`${t("auth.repeatPassword")}`}
placeholder={`${t("auth.repeatPasswordPlaceholder")}`}
type="password"
{...register("repeatPassword", {
required: t("error.requiredField"),
validate: (value) =>
value === newPasswordValue || t("auth.passwordsDoNotMatch"),
})}
error={errors.repeatPassword?.message as string}
/>
{success && <p className="text-success text-center">{success}</p>}
{error && <p className="text-danger text-center">{error}</p>}
<CButton title={`${t("button.continue")}`} isLoading={loading} />
</form>
</div>
);
};
export default Recover;
export { default } from "./Recover";
import React from "react";
import "../Auth.css";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import AuthHeader from "../AuthHeader";
import CInput from "../../../components/CInput";
import CButton from "../../../components/CButton";
import { useForm } from "react-hook-form";
import { useSelector } from "react-redux";
import { useAppDispatch, type RootState } from "../../../stores/store";
import { authRegister } from "../../../stores/slices/authSlice";
import { TG_USER_ID } from "../../../constants/constants";
import { useAuth } from "../../../contexts/AuthContext/AuthContext";
interface IRegisterFormData {
login: string;
password: string;
termsAccepted: boolean;
privacyAccepted: boolean;
}
const Register: React.FC = () => {
const { t } = useTranslation();
const { setAuthenticated } = useAuth();
const navigate = useNavigate();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<IRegisterFormData>();
const dispatch = useAppDispatch();
const { loading, error } = useSelector((state: RootState) => state.auth);
const onSubmit = async (data: IRegisterFormData) => {
try {
const result = await dispatch(authRegister(data));
if (authRegister.fulfilled.match(result)) {
const token = result.payload.token;
localStorage.setItem(`token-${TG_USER_ID}`, token);
setAuthenticated(true);
navigate("/home");
}
} catch (e) {
console.error("Unexpected error in login form", e);
}
};
return (
<div className="wrapper">
<AuthHeader back onBack={() => navigate("/auth/login")} />
<h2>{t("auth.register")}</h2>
<form className="auth__form" onSubmit={handleSubmit(onSubmit)}>
<CInput
label={`${t("auth.login")}`}
placeholder={`${t("auth.loginPlaceholder")}`}
{...register("login", { required: t("error.requiredField") })}
error={errors.login?.message as string}
/>
<CInput
label={`${t("auth.newPassword")}`}
placeholder={`${t("auth.newPasswordPlaceholder")}`}
type="password"
{...register("password", { required: t("error.requiredField") })}
error={errors.password?.message as string}
/>
<div>
<div className="auth__agreement">
<input
type="checkbox"
id="terms"
{...register("termsAccepted", {
required: t("error.requiredCheckbox") || "Required",
})}
/>
<label htmlFor="terms">
<span>{t("auth.iaccept")} </span>
<Link to="/auth/terms">{t("auth.terms")}</Link>
</label>
</div>
{errors.termsAccepted && (
<p className="auth__form__text__danger">
{errors.termsAccepted.message}
</p>
)}
</div>
<div>
<div className="auth__agreement">
<input
type="checkbox"
id="privacy"
{...register("privacyAccepted", {
required: t("error.requiredCheckbox") || "Required",
})}
/>
<label htmlFor="privacy">
<span>{t("auth.iaccept")} </span>
<Link to="/auth/privacy">{t("auth.privacyPolicy")}</Link>
</label>
</div>
{errors.privacyAccepted && (
<p className="auth__form__text__danger">
{errors.privacyAccepted.message}
</p>
)}
</div>
{error && <p className="text-danger text-center">{error}</p>}
<CButton title={`${t("button.continue")}`} isLoading={loading} />
</form>
</div>
);
};
export default Register;
export { default } from "./Register";
import React from "react";
import "./Home.css";
const Home: React.FC = () => {
return <div>Home</div>;
};
export default Home;
export { default } from "./Home";
.notfound {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.notfound__code {
margin: 0 20px 0 0;
padding-right: 23px;
font-size: 24px;
font-weight: 500;
line-height: 49px;
color: var(--text-color);
border-right: 1px solid var(--text-color);
}
.notfound__message {
font-size: 14px;
font-weight: 400;
line-height: 49px;
margin: 0;
color: var(--text-color);
}
\ No newline at end of file
import React from "react";
import "./NotFound.css";
import { useTranslation } from "react-i18next";
const NotFound: React.FC = () => {
const { t } = useTranslation();
return (
<div className="notfound">
<h1 className="notfound__code">{t("notFound.code")}</h1>
<p className="notfound__message">{t("notFound.message")}</p>
</div>
);
};
export default NotFound;
export { default } from "./NotFound";
import React from "react";
import "./Notifications.css";
const Notifications: React.FC = () => {
return <div>Notifications</div>;
};
export default Notifications;
export { default } from "./Notifications";
import React from "react";
import "./Settings.css";
const Settings: React.FC = () => {
return <div>Settings</div>;
};
export default Settings;
export { default } from "./Settings";
import React from "react";
import "./Subscription.css";
const Subscription: React.FC = () => {
return <div>Subscription</div>;
};
export default Subscription;
export { default } from "./Subscription";
import { type JSX } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext/AuthContext";
const AuthGuardReverse = ({ component }: { component: JSX.Element }) => {
const { isAuthenticated } = useAuth();
return isAuthenticated ? <Navigate to="/home" replace /> : component;
};
export default AuthGuardReverse;
import React from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext/AuthContext";
const AuthRedirect: React.FC = () => {
const { isAuthenticated } = useAuth();
return <Navigate to={isAuthenticated ? "/home" : "/auth/login"} replace />;
};
export default AuthRedirect;
import React from "react";
import Header from "../layouts/Header";
import { Outlet } from "react-router-dom";
import Footer from "../layouts/Footer";
const Layout: React.FC = () => {
return (
<>
<Header />
<Outlet />
<Footer />
</>
);
};
export default Layout;
import React from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext/AuthContext";
interface ProtectedRouteProps {
element: React.ReactElement;
redirectTo: string;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
element,
redirectTo,
}) => {
const { isAuthenticated } = useAuth();
return isAuthenticated ? element : <Navigate to={redirectTo} replace />;
};
export default ProtectedRoute;
import React from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import NotFound from "../pages/NotFound";
import Login from "../pages/Auth/Login";
import Register from "../pages/Auth/Register";
import Recover from "../pages/Auth/Recover";
import Layout from "./Layout";
import ProtectedRoute from "./ProtectedRoute";
import Home from "../pages/Home";
import Notifications from "../pages/Notifications";
import Subscription from "../pages/Subscription";
import Settings from "../pages/Settings";
import AuthRedirect from "./AuthRedirect";
import AuthGuardReverse from "./AuthGuardReverse";
const router = createBrowserRouter([
{
path: "/",
element: <AuthRedirect />,
},
{
path: "/auth",
children: [
{
path: "login",
element: <AuthGuardReverse component={<Login />} />,
},
{
path: "register",
element: <AuthGuardReverse component={<Register />} />,
},
{
path: "recover",
element: <AuthGuardReverse component={<Recover />} />,
},
],
},
{
path: "/",
element: <ProtectedRoute element={<Layout />} redirectTo="/auth/login" />,
children: [
{
path: "home",
element: <Home />,
},
{
path: "notifications",
element: <Notifications />,
},
{
path: "subscription",
element: <Subscription />,
},
{
path: "settings",
element: <Settings />,
},
],
},
{
path: "*",
element: <NotFound />,
},
]);
const Router: React.FC = () => <RouterProvider router={router} />;
export default Router;
import axios, { AxiosError } from "axios";
import { generateDeviceId } from "../utils/utils";
import { API_URL, TG_USER_ID, TOKEN } from "../constants/constants";
interface RpcRequest<TParams = unknown> {
jsonrpc: string;
method: string;
params?: TParams;
id?: string | number;
}
interface RpcResponse<TResult> {
jsonrpc: string;
result?: TResult;
error?: { code: number; message: string };
id?: string | number;
}
const apiClient = axios.create({
baseURL: API_URL,
timeout: 10000,
headers: {
"Content-Type": "application/json",
"X-Auth-DeviceID": generateDeviceId(),
},
});
apiClient.interceptors.request.use((config) => {
config.headers["X-Auth-SessionToken"] = TOKEN;
return config;
});
export async function sendRpcRequest<TResponse, TParams = unknown>(
method: string,
params?: TParams,
id: string | number = Date.now()
): Promise<TResponse> {
const requestPayload: RpcRequest<TParams> = {
jsonrpc: "2.0",
method,
params,
id,
};
try {
const { data } = await apiClient.post<RpcResponse<TResponse>>(
"/",
requestPayload
);
if (data.error) {
if (data.error.code === 401 && data.error.message === "Invalid session") {
localStorage.removeItem(`token-${TG_USER_ID}`);
window.location.href = "/";
return Promise.reject(new Error("Session expired. Redirecting..."));
}
throw new Error(data.error.message);
}
if (data.result === undefined) {
throw new Error("No result returned from RPC response.");
}
return data.result;
} catch (error) {
const axiosError = error as AxiosError;
if (axios.isAxiosError(axiosError)) {
console.error(
"Axios error:",
axiosError.response?.data || axiosError.message
);
} else {
console.error("Unexpected error:", error);
}
throw error;
}
}
export default apiClient;
import {
createAsyncThunk,
createSlice,
type PayloadAction,
} from "@reduxjs/toolkit";
import { sendRpcRequest } from "../../services/apiClient";
interface IAuthUser {
token: string;
}
interface IAuthState {
user: IAuthUser | null;
loading: boolean;
error: string | null;
success: boolean | string;
}
interface IResponseError {
code: number;
message: string;
}
const initialState: IAuthState = {
user: null,
loading: false,
error: null,
success: false,
};
export const authLogin = createAsyncThunk<
IAuthUser,
{ login: string; password: string },
{ rejectValue: string }
>("auth/login", async ({ login, password }, { rejectWithValue }) => {
try {
const response = await sendRpcRequest("auth.loginPass", {
login,
password,
fcmKey: null,
});
return response as IAuthUser;
} catch (error: unknown) {
const err = error as IResponseError;
return rejectWithValue(err.message);
}
});
export const authRegister = createAsyncThunk<
IAuthUser,
{ login: string; password: string },
{ rejectValue: string }
>("auth/register", async ({ login, password }, { rejectWithValue }) => {
try {
const response = await sendRpcRequest("auth.registerloginpass", {
login,
password,
});
return response as IAuthUser;
} catch (error: unknown) {
const err = error as IResponseError;
return rejectWithValue(err.message);
}
});
export const authRecover = createAsyncThunk<
IAuthUser,
{ login: string; newPassword: string },
{ rejectValue: string }
>("auth/recover", async ({ login, newPassword }, { rejectWithValue }) => {
try {
const response = await sendRpcRequest("auth.resetpassword", {
login,
newPassword,
});
return response as IAuthUser;
} catch (error: unknown) {
const err = error as IResponseError;
return rejectWithValue(err.message);
}
});
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
logout(state) {
state.user = null;
},
clearStatus(state) {
state.error = null;
state.success = false;
},
},
extraReducers: (builder) => {
builder
.addCase(authLogin.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(
authLogin.fulfilled,
(state, action: PayloadAction<IAuthUser>) => {
state.loading = false;
state.user = action.payload;
state.success = true;
}
)
.addCase(authLogin.rejected, (state, action) => {
state.loading = false;
state.error = action.payload ?? "Login failed";
})
.addCase(authRegister.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(
authRegister.fulfilled,
(state, action: PayloadAction<IAuthUser>) => {
state.loading = false;
state.user = action.payload;
state.success = true;
}
)
.addCase(authRegister.rejected, (state, action) => {
state.loading = false;
state.error = action.payload ?? "Registration failed";
})
.addCase(authRecover.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(
authRecover.fulfilled,
(state, action: PayloadAction<IAuthUser>) => {
state.loading = false;
state.user = action.payload;
state.success = "Confirmation code sent to email";
}
)
.addCase(authRecover.rejected, (state, action) => {
state.loading = false;
state.error = action.payload ?? "Recovery failed";
});
},
});
export const { logout, clearStatus } = authSlice.actions;
export default authSlice.reducer;
import { configureStore } from "@reduxjs/toolkit";
import authSlice from "./slices/authSlice";
import {
useDispatch,
useSelector,
type TypedUseSelectorHook,
} from "react-redux";
export const store = configureStore({
reducer: {
auth: authSlice,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
@import "../assets/fonts/space-mono.css";
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Space Mono', sans-serif;
}
body {
background: var(--background-color);
color: var(--text-color);
}
.wrapper {
padding: 1rem;
}
.text-success {
color: var(--success-color);
}
.text-danger {
color: var(--danger-color);
}
.text-center {
text-align: center;
}
:root {
--primary-color: #448AFF;
--primary-muted-color: #BBDEFB;
--success-color: #4CAF50;
--danger-color: #F44336;
--gray-color: #626D77;
--light-gray-color: #D8DCE2;
}
html[data-theme='light'] {
--background-color: #F5F5F5;
--on-background-color: #000000;
--text-color: #000000;
--on-text-color: #ffffff
}
html[data-theme='dark'] {
--background-color: #000000;
--on-background-color: #ffffff;
--text-color: #ffffff;
--on-text-color: #000000;
}
\ No newline at end of file
import { TG_USER_ID } from "../constants/constants";
export const generateDeviceId = (): string => {
let id = localStorage.getItem(`device_id-${TG_USER_ID}`);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(`device_id-${TG_USER_ID}`, id);
}
return id;
};
/// <reference types="vite/client" />
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
This diff is collapsed. Click to expand it.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment