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

View File

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

View File

@ -15,14 +15,14 @@
overflow-y: hidden; overflow-y: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
width: 100%;
height: 100%; height: 100%;
padding-right: 0.5rem;
padding-left: 0.5rem;
white-space: nowrap; white-space: nowrap;
@include mixins.gradient-border-horizontal(0.5rem, 0.5rem); @include mixins.gradient-border-horizontal(0.25rem, 0.25rem);
&::-webkit-scrollbar { &::-webkit-scrollbar {
height: 0; 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; 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 { .sticker {
margin-inline-start: 0.375rem; margin-inline-start: 0.375rem;
} }
@ -55,6 +114,11 @@
margin-inline-start: 3rem; margin-inline-start: 3rem;
} }
.menuItemCount {
margin-inline-start: 0.5rem;
color: var(--color-text-secondary);
}
.backdrop { .backdrop {
position: absolute; position: absolute;
left: 0.625rem; left: 0.625rem;
@ -69,6 +133,11 @@
margin-inline-start: 0.5 !important; margin-inline-start: 0.5 !important;
} }
.itemIcon {
margin-inline-end: 0.125rem;
font-size: 1.125rem !important;
}
.item { .item {
display: flex; display: flex;
align-items: center; align-items: center;
@ -76,7 +145,7 @@
width: auto; width: auto;
margin-inline: 0.25rem; margin-inline: 0.25rem;
padding: 0.125rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 1rem; border-radius: 1rem;
font-size: 0.875rem; font-size: 0.875rem;

View File

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

View File

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

View File

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

View File

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

View File

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