feat: add settings menu
All checks were successful
Deploy to VPS (dist) / deploy (push) Successful in 1m40s

This commit is contained in:
Hewston Fox
2026-03-22 04:08:56 +02:00
parent 2a1115b66f
commit 5e9acffa09
89 changed files with 3412 additions and 216 deletions

View File

@@ -10,7 +10,7 @@
top: 0;
right: 0;
left: 0;
z-index: 10;
z-index: 20;
padding-top: var(--header-padding);
padding-left: var(--safe-left);

View File

@@ -8,12 +8,51 @@
background-repeat: no-repeat;
background-size: cover;
display: flex;
align-items: center;
gap: 2px;
}
.left {
}
.avatar {
position: relative;
display: flex;
flex-direction: column;
}
.avatarBorder {
width: 68px;
height: 68px;
border-radius: 50%;
background: linear-gradient(180deg, #eed074 0%, #a5602c 100%);
border: 1px solid #401e08;
box-shadow:
4px 0px 1px -2px #00000040,
0px 2px 0px 0px #ffefd1 inset,
0px -2px 0px 0px #7a451c inset;
padding: 5px;
}
.avatarInner {
width: 100%;
height: 100%;
border-radius: 50%;
background: linear-gradient(180deg, #9e6025 0%, #feeea1 100%);
box-shadow:
0px -1px 0px 0px #ffffffa6 inset,
0px 1px 0px 0px #00000040 inset;
padding: 3px;
overflow: hidden;
}
.avatarImage {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.right {
display: flex;
flex-direction: column;

View File

@@ -1,19 +1,30 @@
import classes from "./Profile.module.css";
import { motion } from "motion/react";
import { Liftable } from "@components/lift";
import { useTelegramUser } from "@/tg";
export default function Profile() {
const user = useTelegramUser();
return (
<motion.div
initial={{ scale: 0, x: "-100vw" }}
animate={{ scale: 1, x: 0 }}
transition={{ duration: 0.3, type: "spring", bounce: 0.2 }}
className={classes.profile}
>
<div className={classes.left} />
<div className={classes.right}>
<div className={classes.rightTop} />
<div className={classes.rightBottom} />
</div>
</motion.div>
<Liftable always>
<motion.div
initial={{ scale: 0, x: "-100vw" }}
animate={{ scale: 1, x: 0 }}
transition={{ duration: 0.3, type: "spring", bounce: 0.2 }}
className={classes.profile}
>
<div className={classes.avatar}>
<div className={classes.avatarBorder}>
<div className={classes.avatarInner}>
{user?.photoUrl && <img className={classes.avatarImage} src={user.photoUrl} alt="" />}
</div>
</div>
</div>
<div className={classes.right}>
<div className={classes.rightTop} />
<div className={classes.rightBottom} />
</div>
</motion.div>
</Liftable>
);
}

View File

@@ -1,49 +1,114 @@
import { useState } from "react";
import { lazy, Suspense } from "react";
import { useMachine } from "@xstate/react";
import { motion } from "motion/react";
import tg from "@/tg";
import { settingsMachine } from "./settingsMachine";
import SettingsModal, { type SettingsModalId } from "./components/SettingsModal";
const FAQModal = lazy(() => import("./components/FAQModal"));
const AccountModal = lazy(() => import("./components/AccountModal"));
const SupportModal = lazy(() => import("./components/SupportModal"));
const TransactionsHistoryModal = lazy(() => import("./components/TransactionsHistoryModal"));
const LanguageModal = lazy(() => import("./components/LanguageModal"));
import classes from "./Settings.module.css";
import { usePlaySound } from "@/audio";
export default function Settings() {
const [isOpen, setIsOpen] = useState(false);
const play = usePlaySound();
const [state, send] = useMachine(settingsMachine);
const isOpen = state.value !== "closed";
const toggle = () => {
play("click");
tg.hapticFeedback.click();
setIsOpen((prev) => !prev);
if (isOpen) {
send({ type: "CLOSE" });
} else {
send({ type: "OPEN_SETTINGS" });
}
};
const handleClose = () => {
send({ type: "CLOSE" });
};
const handleBack = () => {
send({ type: "BACK" });
};
const handleNavigate = (modal: SettingsModalId) => {
const eventMap = {
faq: "OPEN_FAQ",
account: "OPEN_ACCOUNT",
support: "OPEN_SUPPORT",
transactionsHistory: "OPEN_TRANSACTIONS_HISTORY",
language: "OPEN_LANGUAGE",
} satisfies Record<SettingsModalId, string>;
send({ type: eventMap[modal] as Parameters<typeof send>[0]["type"] });
};
return (
<motion.button
initial={{ scale: 0, x: "50%", y: "-50%" }}
animate={{ scale: 1, x: 0, y: 0 }}
whileTap={{ scale: 0.95 }}
transition={{ duration: 0.1, type: "spring" }}
className={classes.settings}
onClick={toggle}
>
<div className={classes.bars}>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className={classes.bar}
initial={{ scale: 0 }}
animate={
isOpen
? i === 0
? { scale: 1, rotate: 45, y: 12 }
: i === 1
? { scale: 1, opacity: 0, scaleX: 0 }
: { scale: 1, rotate: -45, y: -12 }
: { scale: 1, rotate: 0, y: 0, opacity: 1, scaleX: 1 }
}
transition={{
scale: { delay: 0.05 * i + 0.1, duration: 0.15, type: "spring" },
default: { duration: 0.1 },
}}
/>
))}
</div>
</motion.button>
<>
<motion.button
initial={{ scale: 0, x: "50%", y: "-50%" }}
animate={{ scale: 1, x: 0, y: 0 }}
whileTap={{ scale: 0.95 }}
transition={{ duration: 0.1, type: "spring" }}
className={classes.settings}
onClick={toggle}
>
<div className={classes.bars}>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className={classes.bar}
initial={{ scale: 0 }}
animate={
isOpen
? i === 0
? { scale: 1, rotate: 45, y: 12 }
: i === 1
? { scale: 1, opacity: 0, scaleX: 0 }
: { scale: 1, rotate: -45, y: -12 }
: { scale: 1, rotate: 0, y: 0, opacity: 1, scaleX: 1 }
}
transition={{
scale: { delay: 0.05 * i + 0.1, duration: 0.15, type: "spring" },
default: { duration: 0.1 },
}}
/>
))}
</div>
</motion.button>
<SettingsModal
open={state.value === "settings"}
onClose={handleClose}
onNavigate={handleNavigate}
/>
<Suspense>
<FAQModal open={state.value === "faq"} onClose={handleClose} />
<AccountModal open={state.value === "account"} onClose={handleClose} />
<SupportModal open={state.value === "support"} onClose={handleClose} />
<TransactionsHistoryModal
open={state.value === "transactionsHistory"}
onClose={handleClose}
/>
<LanguageModal
open={state.value === "language"}
onClose={handleClose}
onBack={handleBack}
/>
</Suspense>
</>
);
}

View File

@@ -0,0 +1,46 @@
import { useTranslation } from "react-i18next";
import Modal from "@components/modals/Modal";
import ContentSurface from "@components/surface/ContentSurface";
import LightSurface from "@components/surface/LightSurface";
type Props = {
open: boolean;
onClose: () => void;
};
const USER_DATA_MOCK = {
id: 123456789,
registrationDate: "2023-01-01",
paymentBalance: 1000,
withdrawalBalance: 500,
};
export default function AccountModal({ open, onClose }: Props) {
const { t } = useTranslation();
return (
<Modal className="w-full max-w-67.5" open={open} onClose={onClose}>
<ContentSurface className="w-full rounded-full">
<LightSurface className="w-full rounded-full p-2.5 font-bold text-lg">
{t("accountInfo.yourId")} - {USER_DATA_MOCK.id}
</LightSurface>
</ContentSurface>
<ContentSurface className="w-full rounded-full">
<LightSurface className="w-full rounded-full p-2.5 font-bold text-lg">
{t("accountInfo.registrationDate")} - {USER_DATA_MOCK.registrationDate}
</LightSurface>
</ContentSurface>
<ContentSurface className="w-full rounded-full">
<LightSurface className="w-full rounded-full p-2.5 font-bold text-lg">
{t("accountInfo.paymentBalance")} - {USER_DATA_MOCK.paymentBalance}
</LightSurface>
</ContentSurface>
<ContentSurface className="w-full rounded-full">
<LightSurface className="w-full rounded-full p-2.5 font-bold text-lg">
{t("accountInfo.withdrawalBalance")} - {USER_DATA_MOCK.withdrawalBalance}
</LightSurface>
</ContentSurface>
</Modal>
);
}

View File

@@ -0,0 +1 @@
export { default } from "./AccountModal";

View File

@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import Modal from "@components/modals/Modal";
import ContentSurface from "@components/surface/ContentSurface";
import LightSurface from "@components/surface/LightSurface";
type Props = {
open: boolean;
onClose: () => void;
};
export default function FAQModal({ open, onClose }: Props) {
const { t } = useTranslation();
return (
<Modal className="w-full max-w-67.5" open={open} onClose={onClose} title={t("settings.faq")}>
<ContentSurface className="w-full rounded-4xl">
<LightSurface className="w-full rounded-4xl p-2.5 font-bold text-lg text-center">
<p>{t("faq.1q")}</p>
<p className="text-[#83552E]">{t("faq.1a")}</p>
</LightSurface>
</ContentSurface>
<ContentSurface className="w-full rounded-4xl">
<LightSurface className="w-full rounded-4xl p-2.5 font-bold text-lg text-center">
<p>{t("faq.1q")}</p>
<p className="text-[#83552E]">{t("faq.1a")}</p>
</LightSurface>
</ContentSurface>
<ContentSurface className="w-full rounded-4xl">
<LightSurface className="w-full rounded-4xl p-2.5 font-bold text-lg text-center">
<p>{t("faq.1q")}</p>
<p className="text-[#83552E]">{t("faq.1a")}</p>
</LightSurface>
</ContentSurface>
<ContentSurface className="w-full rounded-4xl">
<LightSurface className="w-full rounded-4xl p-2.5 font-bold text-lg text-center">
<p>{t("faq.1q")}</p>
<p className="text-[#83552E]">{t("faq.1a")}</p>
</LightSurface>
</ContentSurface>
</Modal>
);
}

View File

@@ -0,0 +1 @@
export { default } from "./FAQModal";

View File

@@ -0,0 +1,48 @@
@layer base {
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
}
.contentSurface {
border-radius: 9999px;
width: 100%;
&.active > * {
background-color: #f8eb86;
}
}
.item {
all: unset;
box-sizing: border-box;
width: 100%;
height: 100%;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 8px 12px;
font-size: 15px;
font-weight: 700;
}
.langIcon {
width: 27px;
height: 27px;
border-radius: 50%;
background: #ccc;
flex-shrink: 0;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.itemActive {
color: #4b2c13;
}
}

View File

@@ -0,0 +1,72 @@
import { useTranslation } from "react-i18next";
import { motion } from "motion/react";
import Modal from "@components/modals/Modal";
import ContentSurface from "@components/surface/ContentSurface";
import DarkSurface from "@components/surface/DarkSurface";
import { useLanguages } from "@/i18n/useLanguages";
import { usePlaySound } from "@/audio";
import tg from "@/tg";
import classes from "./LanguageModal.module.css";
import LightSurface from "@components/surface/LightSurface";
import clsx from "clsx";
type Props = {
open: boolean;
onClose: () => void;
onBack: () => void;
};
export default function LanguageModal({ open, onClose, onBack }: Props) {
const { t } = useTranslation();
const { languages, current, setLanguage } = useLanguages();
const play = usePlaySound();
const handleSelect = (key: string) => {
play("click");
tg.hapticFeedback.click();
setLanguage(key);
onClose();
};
const handleBack = () => {
play("click");
tg.hapticFeedback.click();
onBack();
};
return (
<Modal className="w-full" open={open} onClose={onClose}>
<div className={classes.grid}>
{languages.map((lang) => (
<ContentSurface
key={lang.key}
className={clsx(classes.contentSurface, lang.key === current.key && classes.active)}
>
<LightSurface className="rounded-full">
<motion.button
className={classes.item}
onClick={() => handleSelect(lang.key)}
whileTap={{ scale: 0.95 }}
>
<div className={classes.langIcon}>
<img src={lang.image} alt={lang.label} />
</div>
<span>{lang.label}</span>
</motion.button>
</LightSurface>
</ContentSurface>
))}
<ContentSurface className={classes.contentSurface}>
<DarkSurface className="rounded-full h-full">
<motion.button className={classes.item} onClick={handleBack} whileTap={{ scale: 0.95 }}>
<span>{t("settings.back")}</span>
</motion.button>
</DarkSurface>
</ContentSurface>
</div>
</Modal>
);
}

View File

@@ -0,0 +1 @@
export { default } from "./LanguageModal";

View File

@@ -0,0 +1,51 @@
@layer base {
.items {
display: flex;
flex-direction: column;
gap: 5px;
}
.contentSurface {
border-radius: 9999px;
width: 100%;
}
.lightSurface {
border-radius: 9999px;
}
.item {
all: unset;
box-sizing: border-box;
width: 100%;
text-align: center;
cursor: pointer;
padding: 10px 12px;
font-size: 20px;
font-weight: 700;
}
.itemRow {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 20px;
font-weight: 700;
}
.langIcon {
width: 27px;
height: 27px;
border-radius: 50%;
background: #ccc;
flex-shrink: 0;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}

View File

@@ -0,0 +1,118 @@
import { useTranslation } from "react-i18next";
import { motion } from "motion/react";
import Modal from "@components/modals/Modal";
import ContentSurface from "@components/surface/ContentSurface";
import LightSurface from "@components/surface/LightSurface";
import SwitchInput from "@components/form/SwitchInput";
import { useAudioSettings, usePlaySound } from "@/audio";
import tg from "@/tg";
import classes from "./SettingsModal.module.css";
import { useLanguages } from "@/i18n/useLanguages";
export type SettingsModalId = "faq" | "account" | "support" | "transactionsHistory" | "language";
type Props = {
open: boolean;
onClose: () => void;
onNavigate: (modal: SettingsModalId) => void;
liftIds?: string[];
};
export default function SettingsModal({ open, onClose, onNavigate, liftIds }: Props) {
const { t } = useTranslation();
const { current } = useLanguages();
const { isEnabled, setIsEnabled } = useAudioSettings();
const play = usePlaySound();
const handleNavigate = (modal: SettingsModalId) => {
play("click");
tg.hapticFeedback.click();
onNavigate(modal);
};
return (
<Modal className="w-full max-w-67.5" open={open} onClose={onClose} liftIds={liftIds}>
<div className={classes.items}>
<ContentSurface className={classes.contentSurface}>
<LightSurface className={classes.lightSurface}>
<motion.button
className={classes.item}
onClick={() => handleNavigate("faq")}
whileTap={{ scale: 0.95 }}
>
{t("settings.faq")}
</motion.button>
</LightSurface>
</ContentSurface>
<ContentSurface className={classes.contentSurface}>
<LightSurface className={classes.lightSurface}>
<motion.button
className={classes.item}
onClick={() => handleNavigate("account")}
whileTap={{ scale: 0.95 }}
>
{t("settings.accountInfo")}
</motion.button>
</LightSurface>
</ContentSurface>
<ContentSurface className={classes.contentSurface}>
<LightSurface className={classes.lightSurface}>
<motion.button
className={classes.item}
onClick={() => handleNavigate("support")}
whileTap={{ scale: 0.95 }}
>
{t("settings.support")}
</motion.button>
</LightSurface>
</ContentSurface>
<ContentSurface className={classes.contentSurface}>
<LightSurface className={classes.lightSurface}>
<motion.button
className={classes.item}
onClick={() => handleNavigate("transactionsHistory")}
whileTap={{ scale: 0.95 }}
>
{t("settings.transactionHistory")}
</motion.button>
</LightSurface>
</ContentSurface>
<ContentSurface className={classes.contentSurface}>
<LightSurface className={classes.lightSurface}>
<div className={classes.itemRow}>
<span>{t("settings.sound")}</span>
<SwitchInput
value={isEnabled}
onChange={(value) => {
setIsEnabled(value);
if (value) play("click", { force: true });
}}
/>
</div>
</LightSurface>
</ContentSurface>
<ContentSurface className={classes.contentSurface}>
<LightSurface className={classes.lightSurface}>
<motion.button
className={classes.itemRow}
onClick={() => handleNavigate("language")}
whileTap={{ scale: 0.95 }}
>
<span>{t("settings.language")}</span>
<div className={classes.langIcon}>
<img src={current.image} alt="lang" />
</div>
</motion.button>
</LightSurface>
</ContentSurface>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,2 @@
export { default } from "./SettingsModal";
export type { SettingsModalId } from "./SettingsModal";

View File

@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import Modal from "@components/modals/Modal";
import ContentSurface from "@components/surface/ContentSurface";
import LightSurface from "@components/surface/LightSurface";
import Button from "@components/atoms/Button";
type Props = {
open: boolean;
onClose: () => void;
};
export default function SupportModal({ open, onClose }: Props) {
const { t } = useTranslation();
return (
<Modal
className="w-full max-w-67.5 flex flex-col items-center gap-2"
open={open}
onClose={onClose}
title={t("support.title")}
>
<ContentSurface className="w-full rounded-4xl">
<LightSurface className="rounded-4xl py-1 px-2 font-bold text-lg text-center">
{t("support.text")}
</LightSurface>
</ContentSurface>
<Button variant="blue" className="w-11/12">
{t("support.action")}
</Button>
</Modal>
);
}

View File

@@ -0,0 +1 @@
export { default } from "./SupportModal";

View File

@@ -0,0 +1,6 @@
@layer base {
.placeholder {
padding: 12px;
text-align: center;
}
}

View File

@@ -0,0 +1,83 @@
import { useTranslation } from "react-i18next";
import Modal from "@components/modals/Modal";
import ContentSurface from "@components/surface/ContentSurface";
import LightSurface from "@components/surface/LightSurface";
import DarkSurface from "@components/surface/DarkSurface";
import HoneyIcon from "@components/icons/HoneyIcon";
import MoneyIcon from "@components/icons/MoneyIcon";
import Pagination from "@components/atoms/Pagination";
import { useState } from "react";
const MOCK_TRANSACTIONS: {
id: number;
amount: number;
date: string;
currency: "honey" | "money";
kind: "withdrawal" | "deposit" | "greeting" | "referral";
}[] = [
{ id: 1, amount: 500, date: "2026-03-20", currency: "honey", kind: "deposit" },
{ id: 2, amount: -150, date: "2026-03-19", currency: "money", kind: "withdrawal" },
{ id: 3, amount: 100, date: "2026-03-18", currency: "honey", kind: "greeting" },
{ id: 4, amount: 250, date: "2026-03-17", currency: "honey", kind: "referral" },
{ id: 7, amount: 75, date: "2026-03-14", currency: "money", kind: "referral" },
{ id: 8, amount: -200, date: "2026-03-13", currency: "honey", kind: "withdrawal" },
];
type Props = {
open: boolean;
onClose: () => void;
};
const numberFormat = new Intl.NumberFormat("en-US", { signDisplay: "exceptZero" });
const dateFormat = new Intl.DateTimeFormat("fi-FI");
export default function TransactionsHistoryModal({ open, onClose }: Props) {
const { t } = useTranslation();
const totalPages = 6;
const [page, setPage] = useState(1);
return (
<Modal
className="w-full flex flex-col items-center gap-2"
open={open}
onClose={onClose}
title={t("transactionHistory.title")}
>
<span className="text-center font-bold text-xl text-[#FBE6BE]">
{t("transactionHistory.yourTransactions")}
</span>
<div className="w-full flex justify-between items-center text-center font-bold text-lg">
<ContentSurface className="rounded-full w-25">
<DarkSurface className="rounded-full p-1.5 text-white">
{t("transactionHistory.sum")}
</DarkSurface>
</ContentSurface>
<ContentSurface className="rounded-full w-25">
<DarkSurface className="rounded-full p-1.5 text-white">
{t("transactionHistory.date")}
</DarkSurface>
</ContentSurface>
</div>
{MOCK_TRANSACTIONS.map(({ id, kind, date, currency, amount }) => (
<ContentSurface key={id} className="rounded-2xl w-full">
<LightSurface className="rounded-t-2xl rounded-b-sm flex justify-between p-2">
<div className="flex gap-1">
<span>{numberFormat.format(amount)}</span>
{currency === "honey" ? <HoneyIcon /> : <MoneyIcon />}
</div>
<div>{dateFormat.format(new Date(date))}</div>
</LightSurface>
<DarkSurface className="rounded-b-2xl rounded-t-sm p-2">
{t("transactionHistory.operationType")}: {t(`operationType.${kind}`)}
</DarkSurface>
</ContentSurface>
))}
<Pagination value={page} total={totalPages} onChange={setPage} />
</Modal>
);
}

View File

@@ -0,0 +1 @@
export { default } from "./TransactionsHistoryModal";

View File

@@ -0,0 +1,65 @@
import { setup } from "xstate";
export const settingsMachine = setup({
types: {
events: {} as
| { type: "OPEN_SETTINGS" }
| { type: "OPEN_FAQ" }
| { type: "OPEN_ACCOUNT" }
| { type: "OPEN_SUPPORT" }
| { type: "OPEN_TRANSACTIONS_HISTORY" }
| { type: "OPEN_LANGUAGE" }
| { type: "CLOSE" }
| { type: "BACK" },
},
}).createMachine({
id: "settings",
initial: "closed",
states: {
closed: {
on: {
OPEN_SETTINGS: { target: "settings" },
},
},
settings: {
on: {
CLOSE: { target: "closed" },
OPEN_FAQ: { target: "faq" },
OPEN_ACCOUNT: { target: "account" },
OPEN_SUPPORT: { target: "support" },
OPEN_TRANSACTIONS_HISTORY: { target: "transactionsHistory" },
OPEN_LANGUAGE: { target: "language" },
},
},
faq: {
on: {
CLOSE: { target: "closed" },
BACK: { target: "settings" },
},
},
account: {
on: {
CLOSE: { target: "closed" },
BACK: { target: "settings" },
},
},
support: {
on: {
CLOSE: { target: "closed" },
BACK: { target: "settings" },
},
},
transactionsHistory: {
on: {
CLOSE: { target: "closed" },
BACK: { target: "settings" },
},
},
language: {
on: {
CLOSE: { target: "closed" },
BACK: { target: "settings" },
},
},
},
});

View File

@@ -23,6 +23,7 @@ import rouletteIcon from "./assets/roulette.svg";
import tasksIcon from "./assets/tasks.svg";
import earningsIcon from "./assets/earnings.svg";
import tg, { useTelegramViewportValue } from "@/tg";
import { usePlaySound } from "@/audio";
const ANIMATION_DURATION = 0.2;
const SPRING_ANIMATION = {
@@ -34,7 +35,6 @@ const SPRING_ANIMATION = {
const BAR_HEIGHT = 64;
const ACTIVE_BAR_HEIGHT = 74;
const OFFSCREEN_BAR_OFFSET = 20;
const NAV_ITEMS = [
{ key: "nav.shop", route: ShopRoute, icon: shopIcon },
@@ -162,6 +162,7 @@ function MenuBar({ labelKey, icon, delay, active, onClick }: MenuBarProps) {
}
export default function Navigation() {
const play = usePlaySound();
const matchRoute = useMatchRoute();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState<number>(0);
@@ -181,6 +182,7 @@ export default function Navigation() {
}, [menuOpen, handleOutsideClick]);
const navigateRoute = async (route: AnyRoute, wait = false) => {
play("click");
tg.hapticFeedback.click();
const redirection = navigate({ to: route.to });
if (wait) {
@@ -220,6 +222,7 @@ export default function Navigation() {
onClick={
item.isMenu
? () => {
play("click");
tg.hapticFeedback.click();
setMenuOpen((v) => (v ? 0 : Math.random()));
}