Gift Modal: Fix UI (#6642)

This commit is contained in:
Alexander Zinchuk 2026-02-22 23:42:48 +01:00
parent 49013cdf7b
commit b40fe83338
9 changed files with 241 additions and 70 deletions

View File

@ -133,7 +133,6 @@
.resaleHeaderContentContainer {
align-items: center;
justify-items: center;
width: 100%;
}
@ -166,6 +165,12 @@
.resaleHeaderText {
margin: 0;
margin-bottom: 0.0625rem !important;
font-size: 1rem;
line-height: 1.3125rem;
}
.resaleHeaderTextBlock {
margin-left: 4.5rem;
}
.resaleHeaderDescription {

View File

@ -40,7 +40,6 @@ import Button from '../../ui/Button';
import InfiniteScroll from '../../ui/InfiniteScroll';
import Modal from '../../ui/Modal';
import Transition from '../../ui/Transition';
import BalanceBlock from '../stars/BalanceBlock';
import GiftComposer from './GiftComposer';
import GiftItemPremium from './GiftItemPremium';
import GiftItemStar from './GiftItemStar';
@ -518,23 +517,25 @@ const GiftModal: FC<OwnProps & StateProps> = ({
const isFirstLoading = areResaleGiftsLoading && !resaleGiftsCount;
return (
<div className={styles.resaleHeaderContentContainer}>
<h2 className={styles.resaleHeaderText}>
{selectedResaleGift.title}
</h2>
{isFirstLoading
&& (
<div className={styles.resaleHeaderDescription}>
{lang('Loading')}
</div>
)}
{!isFirstLoading && resaleGiftsCount !== undefined
&& (
<div className={styles.resaleHeaderDescription}>
{lang('HeaderDescriptionResaleGifts', {
count: resaleGiftsCount,
}, { withNodes: true, withMarkdown: true, pluralValue: resaleGiftsCount })}
</div>
)}
<div className={styles.resaleHeaderTextBlock}>
<h2 className={styles.resaleHeaderText}>
{selectedResaleGift.title}
</h2>
{isFirstLoading
&& (
<div className={styles.resaleHeaderDescription}>
{lang('Loading')}
</div>
)}
{!isFirstLoading && resaleGiftsCount !== undefined
&& (
<div className={styles.resaleHeaderDescription}>
{lang('HeaderDescriptionResaleGifts', {
count: resaleGiftsCount,
}, { withNodes: true, withMarkdown: true, pluralValue: resaleGiftsCount })}
</div>
)}
</div>
<GiftResaleFilters dialogRef={dialogRef} />
</div>
);
@ -555,6 +556,7 @@ const GiftModal: FC<OwnProps & StateProps> = ({
contentClassName={styles.content}
className={buildClassName(styles.modalDialog, styles.root)}
isLowStackPriority
withBalanceBar
>
<Button
className={styles.closeButton}
@ -566,12 +568,11 @@ const GiftModal: FC<OwnProps & StateProps> = ({
>
<div className={buttonClassName} />
</Button>
<BalanceBlock className={styles.balance} balance={starBalance} withAddButton />
<div className={buildClassName(
styles.header,
isResaleScreen && styles.resaleHeader,
!shouldShowHeader && styles.hiddenHeader,
isCategoryListPinned && styles.noBorder)}
isCategoryListPinned && !isResaleScreen && styles.noBorder)}
>
<Transition
name="slideVerticalFade"

View File

@ -15,14 +15,14 @@
overflow-y: hidden;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
padding-right: 0.5rem;
padding-left: 0.5rem;
white-space: nowrap;
@include mixins.gradient-border-horizontal(0.5rem, 0.5rem);
@include mixins.gradient-border-horizontal(0.25rem, 0.25rem);
&::-webkit-scrollbar {
height: 0;
@ -33,10 +33,69 @@
}
}
.itemIcon {
.dropdownArrows {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 0.625rem;
height: 1rem;
margin-inline-start: 0.25rem;
}
.arrowLine {
/* stylelint-disable-next-line plugin/whole-pixel */
--x: 1.5px;
--y: 0.25rem;
--a: 45deg;
position: absolute;
/* stylelint-disable-next-line plugin/whole-pixel */
width: 1.5px;
height: 0.3125rem;
border-radius: 1px;
background-color: currentcolor;
transition: transform 0.2s ease-in-out;
&.topLeft {
transform: translate(calc(-1 * var(--x)), calc(-1 * var(--y))) rotate(var(--a));
}
&.topRight {
transform: translate(var(--x), calc(-1 * var(--y))) rotate(calc(-1 * var(--a)));
}
&.bottomLeft {
transform: translate(calc(-1 * var(--x)), var(--y)) rotate(calc(-1 * var(--a)));
}
&.bottomRight {
transform: translate(var(--x), var(--y)) rotate(var(--a));
}
&.open.topLeft {
transform: translate(calc(-1 * var(--x)), calc(-1 * var(--y))) rotate(calc(-1 * var(--a)));
}
&.open.topRight {
transform: translate(var(--x), calc(-1 * var(--y))) rotate(var(--a));
}
&.open.bottomLeft {
transform: translate(calc(-1 * var(--x)), var(--y)) rotate(var(--a));
}
&.open.bottomRight {
transform: translate(var(--x), var(--y)) rotate(calc(-1 * var(--a)));
}
}
.sticker {
margin-inline-start: 0.375rem;
}
@ -55,6 +114,11 @@
margin-inline-start: 3rem;
}
.menuItemCount {
margin-inline-start: 0.5rem;
color: var(--color-text-secondary);
}
.backdrop {
position: absolute;
left: 0.625rem;
@ -69,6 +133,11 @@
margin-inline-start: 0.5 !important;
}
.itemIcon {
margin-inline-end: 0.125rem;
font-size: 1.125rem !important;
}
.item {
display: flex;
align-items: center;
@ -76,7 +145,7 @@
width: auto;
margin-inline: 0.25rem;
padding: 0.125rem 0.75rem;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;

View File

@ -126,6 +126,27 @@ const GiftResaleFilters: FC<StateProps & OwnProps> = ({
};
}, [filteredAttributes, searchModelQuery, searchBackdropQuery, searchPatternQuery]);
const countersMap = useMemo(() => {
const map = {
model: new Map<string, number>(),
pattern: new Map<string, number>(),
backdrop: new Map<number, number>(),
};
for (const counter of counters ?? []) {
const { attribute, count } = counter;
if (attribute.type === 'model') {
map.model.set(attribute.documentId, count);
} else if (attribute.type === 'pattern') {
map.pattern.set(attribute.documentId, count);
} else if (attribute.type === 'backdrop') {
map.backdrop.set(attribute.backdropId, count);
}
}
return map;
}, [counters]);
// Sort Menu
const sortMenuRef = useRef<HTMLDivElement>();
const {
@ -175,18 +196,21 @@ const GiftResaleFilters: FC<StateProps & OwnProps> = ({
const SortMenuButton: FC<{ onTrigger: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => void; isOpen?: boolean }>
= useMemo(() => {
const sortType = filter.sortType;
const iconName = sortType === 'byDate' ? 'sort-by-date'
: sortType === 'byNumber' ? 'sort-by-number'
: 'sort-by-price';
return ({ onTrigger, isOpen: isMenuOpen }) => (
<div
className={styles.item}
onClick={onTrigger}
>
<Icon
name={iconName}
className={styles.itemIcon}
/>
{sortType === 'byDate' && lang('ValueGiftSortByDate')}
{sortType === 'byNumber' && lang('ValueGiftSortByNumber')}
{sortType === 'byPrice' && lang('ValueGiftSortByPrice')}
<Icon
name="dropdown-arrows"
className={styles.itemIcon}
/>
</div>
);
}, [lang, filter]);
@ -203,10 +227,7 @@ const GiftResaleFilters: FC<StateProps & OwnProps> = ({
{attributesCount === 0 && lang('GiftAttributeModel')}
{attributesCount > 0
&& lang('GiftAttributeModelPlural', { count: attributesCount }, { pluralValue: attributesCount })}
<Icon
name="dropdown-arrows"
className={styles.itemIcon}
/>
{renderDropdownArrows(isMenuOpen)}
</div>
);
}, [lang, filter]);
@ -222,10 +243,7 @@ const GiftResaleFilters: FC<StateProps & OwnProps> = ({
{attributesCount === 0 && lang('GiftAttributeBackdrop')}
{attributesCount > 0
&& lang('GiftAttributeBackdropPlural', { count: attributesCount }, { pluralValue: attributesCount })}
<Icon
name="dropdown-arrows"
className={styles.itemIcon}
/>
{renderDropdownArrows(isMenuOpen)}
</div>
);
}, [lang, filter]);
@ -240,10 +258,7 @@ const GiftResaleFilters: FC<StateProps & OwnProps> = ({
{attributesCount === 0 && lang('GiftAttributeSymbol')}
{attributesCount > 0
&& lang('GiftAttributeSymbolPlural', { count: attributesCount }, { pluralValue: attributesCount })}
<Icon
name="dropdown-arrows"
className={styles.itemIcon}
/>
{renderDropdownArrows(isMenuOpen)}
</div>
);
}, [lang, filter]);
@ -334,6 +349,17 @@ const GiftResaleFilters: FC<StateProps & OwnProps> = ({
} });
});
function renderDropdownArrows(isOpen?: boolean) {
return (
<div className={styles.dropdownArrows}>
<div className={buildClassName(styles.arrowLine, styles.topLeft, isOpen && styles.open)} />
<div className={buildClassName(styles.arrowLine, styles.topRight, isOpen && styles.open)} />
<div className={buildClassName(styles.arrowLine, styles.bottomLeft, isOpen && styles.open)} />
<div className={buildClassName(styles.arrowLine, styles.bottomRight, isOpen && styles.open)} />
</div>
);
}
function renderSortMenuItems() {
return (
<>
@ -425,7 +451,7 @@ const GiftResaleFilters: FC<StateProps & OwnProps> = ({
onReset={handleSearchModelInputReset}
placeholder={lang('Search')}
/>
<MenuItem icon="select" onClick={handleSelectedAllModelsClick} disabled={isSelectedAll}>
<MenuItem icon="select" onClick={handleSelectedAllModelsClick}>
{lang('ContextMenuItemSelectAll')}
</MenuItem>
{models.map((model) => {
@ -447,6 +473,9 @@ const GiftResaleFilters: FC<StateProps & OwnProps> = ({
/>
<div className={styles.menuItemStickerText}>
{model.name}
<span className={styles.menuItemCount}>
{lang.number(countersMap.model.get(model.sticker.id) || 0)}
</span>
</div>
<Icon
className={styles.menuItemIcon}
@ -495,7 +524,7 @@ const GiftResaleFilters: FC<StateProps & OwnProps> = ({
onReset={handleSearchBackdropInputReset}
placeholder={lang('Search')}
/>
<MenuItem icon="select" onClick={handleSelectedAllBackdropsClick} disabled={isSelectedAll}>
<MenuItem icon="select" onClick={handleSelectedAllBackdropsClick}>
{lang('ContextMenuItemSelectAll')}
</MenuItem>
{backdrops.map((backdrop) => {
@ -515,6 +544,9 @@ const GiftResaleFilters: FC<StateProps & OwnProps> = ({
/>
<div className={styles.backdropAttributeMenuItemText}>
{backdrop.name}
<span className={styles.menuItemCount}>
{lang.number(countersMap.backdrop.get(backdrop.backdropId) || 0)}
</span>
</div>
<Icon
className={styles.menuItemIcon}
@ -560,7 +592,7 @@ const GiftResaleFilters: FC<StateProps & OwnProps> = ({
onReset={handleSearchPatternInputReset}
placeholder={lang('Search')}
/>
<MenuItem icon="select" onClick={handleSelectedAllPatternsClick} disabled={isSelectedAll}>
<MenuItem icon="select" onClick={handleSelectedAllPatternsClick}>
{lang('ContextMenuItemSelectAll')}
</MenuItem>
{patterns.map((pattern) => {
@ -583,6 +615,9 @@ const GiftResaleFilters: FC<StateProps & OwnProps> = ({
<div className={styles.menuItemStickerText}>
{pattern.name}
<span className={styles.menuItemCount}>
{lang.number(countersMap.pattern.get(pattern.sticker.id) || 0)}
</span>
</div>
<Icon
className={styles.menuItemIcon}

View File

@ -58,10 +58,10 @@
&.with-balance-bar {
.modal-container {
top: 5.5rem;
flex-direction: column;
}
.modal-dialog {
max-height: calc(100vh - 7.5rem);
margin-top: 0;
}
}

View File

@ -208,14 +208,14 @@ const Modal: FC<OwnProps> = ({
tabIndex={-1}
role="dialog"
>
{withBalanceBar && (
<ModalStarBalanceBar
isModalOpen={isOpen}
currency={currencyInBalanceBar}
/>
)}
<div className="modal-container">
<div className="modal-backdrop" onClick={!noBackdropClose ? onClose : undefined} />
{withBalanceBar && (
<ModalStarBalanceBar
isModalOpen={isOpen}
currency={currencyInBalanceBar}
/>
)}
<div className={modalDialogClassName} ref={actualDialogRef} style={dialogStyle}>
{renderHeader()}
{Boolean(moreMenuItems) && (

View File

@ -1,14 +1,12 @@
.root {
position: absolute;
z-index: var(--z-modal);
top: 0rem;
left: 50%;
transform: translate(-50%, -1rem);
display: flex;
flex-direction: column;
align-items: center;
margin-top: 2rem;
margin-bottom: 1rem;
padding: 0.5rem 1.25rem;
border-radius: 2rem;
@ -20,6 +18,12 @@
transition: transform 0.2s ease, opacity 0.2s ease;
&.hidden {
pointer-events: none;
transform: scale(0);
opacity: 0;
}
:global(.confirm) & {
z-index: var(--z-modal-confirm);
}
@ -32,14 +36,6 @@
transform: none !important;
transition: none;
}
&:not(:global(.open)) {
transform: translate(-50%, 0);
}
&:not(:global(.closing)) {
transform: translate(-50%, 1rem);
}
}
.starIcon {
@ -51,3 +47,8 @@
.tonInUsdDescription {
color: var(--color-text-secondary);
}
.tonInUsdDescription,
.getMoreStarsLink {
font-weight: var(--font-weight-semibold);
}

View File

@ -1,6 +1,4 @@
import {
memo,
} from '../../lib/teact/teact';
import { memo } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiStarsAmount, ApiTonAmount } from '../../api/types';
@ -10,6 +8,7 @@ import buildClassName from '../../util/buildClassName';
import { convertTonFromNanos, convertTonToUsd, formatCurrencyAsString } from '../../util/formatCurrency';
import { formatStarsAsIcon, formatTonAsIcon } from '../../util/localization/format';
import useIsTopmostBalanceBarModal from '../../hooks/element/useIsTopmostBalanceBarModal';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useShowTransition from '../../hooks/useShowTransition';
@ -56,6 +55,8 @@ function ModalStarBalanceBar({
withShouldRender: true,
});
const isTopmost = useIsTopmostBalanceBarModal(ref, Boolean(shouldRender && currentBalance));
const handleGetMoreStars = useLastCallback(() => {
openStarsBalanceModal(isTonMode ? { currency: 'TON' } : {});
});
@ -66,7 +67,7 @@ function ModalStarBalanceBar({
return (
<div
className={buildClassName(styles.root)}
className={buildClassName(styles.root, !isTopmost && styles.hidden)}
ref={ref}
>
<div>
@ -101,7 +102,7 @@ function ModalStarBalanceBar({
</div>
)}
{!isTonMode && (
<Link isPrimary onClick={handleGetMoreStars}>
<Link className={styles.getMoreStarsLink} isPrimary onClick={handleGetMoreStars}>
{lang('GetMoreStarsLinkText')}
</Link>
)}

View File

@ -0,0 +1,59 @@
import { useEffect, useState } from '../../lib/teact/teact';
import { createCallbackManager } from '../../util/callbacks';
const BALANCE_BAR_MODAL_SELECTOR = '.Modal.with-balance-bar';
const balanceBarCallbacks = createCallbackManager();
export default function useIsTopmostBalanceBarModal(
ref: { current?: HTMLElement },
isActive?: boolean,
) {
const [isTopmost, setIsTopmost] = useState(true);
useEffect(() => {
if (!isActive) return undefined;
const updateIsTopmost = () => {
setIsTopmost(checkIsTopmostBalanceBarModal(ref.current));
};
updateIsTopmost();
const unsubscribe = balanceBarCallbacks.addCallback(updateIsTopmost);
balanceBarCallbacks.runCallbacks();
return () => {
unsubscribe();
balanceBarCallbacks.runCallbacks();
};
}, [isActive, ref]);
return isTopmost;
}
function checkIsTopmostBalanceBarModal(element?: HTMLElement) {
if (!element) return true;
const parentModal = element.closest(BALANCE_BAR_MODAL_SELECTOR);
if (!parentModal) return true;
const allBalanceBarModals = document.querySelectorAll(BALANCE_BAR_MODAL_SELECTOR);
if (allBalanceBarModals.length <= 1) {
return true;
}
let topmostModal: Element | undefined;
let highestZIndex = -Infinity;
allBalanceBarModals.forEach((modal) => {
const zIndex = parseInt(getComputedStyle(modal).zIndex, 10) || 0;
if (zIndex >= highestZIndex) {
highestZIndex = zIndex;
topmostModal = modal;
}
});
return parentModal === topmostModal;
}