Описание
Код-ревью приветствуется. Тем кто будет предлагать какие-то свои кейсы. Вообще это должно в моменте закрыть какие-то типовые экраны, бутерброды-компонентов, которые как правило делаются достаточно часто, или их можно встретить в самом VK.
Тут наверное задача больше показать связки компонентов. Как они работают между собой. Если начать собирать и шлифовать какие-то типовые кейсы от комьюнити, можно прийти повышению качества мини-приложений и сайтов на vkui и сроками разработки c использованием по сути best-practices одобреными разработчиками vkui.
Примеры:
Адаптивный UI/UX выбора диапазона дат:
import { useContext, useRef, useState } from "react";
import { AppContext } from "engine/state";
import {
ActionSheet,
ActionSheetItem,
AppRootPortal,
Button,
CalendarRange,
DateInput,
Div,
FormItem,
ModalPage,
ModalPageHeader,
Popper,
Separator,
} from "@vkontakte/vkui";
import { ModalPageHeaderButtonClose } from "engine/components";
import { Icon16DropdownOutline } from "@vkontakte/icons";
import "./ButtonSelectIntervalDate.css";
type AnalyticsIntervalKey =
| "all"
| "today"
| "yesterday"
| "last7"
| "last30"
| "custom";
type DateRangeValue = [Date | null, Date | null];
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const INTERVALS: { key: AnalyticsIntervalKey; label: string }[] = [
{ key: "all", label: "За все время" },
{ key: "today", label: "Сегодня" },
{ key: "yesterday", label: "Вчера" },
{ key: "last7", label: "Последние 7 дней" },
{ key: "last30", label: "Последние 30 дней" },
{ key: "custom", label: "Указать период" },
];
const hasCompleteRange = (range?: DateRangeValue | null) =>
Boolean(range?.[0] && range[1]);
const hasInvalidRangeOrder = (range?: DateRangeValue | null) =>
Boolean(range?.[0] && range[1] && range[0] > range[1]);
const getStartOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate());
const isBeforeDay = (date: Date, compareDate: Date) =>
getStartOfDay(date).getTime() < getStartOfDay(compareDate).getTime();
const isAfterDay = (date: Date, compareDate: Date) =>
getStartOfDay(date).getTime() > getStartOfDay(compareDate).getTime();
const isFutureDay = (date: Date) => isAfterDay(date, new Date());
const shiftDays = (date: Date, amount: number) => {
const nextDate = new Date(date);
nextDate.setDate(nextDate.getDate() + amount);
return getStartOfDay(nextDate);
};
const formatDate = (date: Date, showYear = false) => {
if (showYear) {
const dateParts = new Intl.DateTimeFormat("ru-RU", {
day: "numeric",
month: "short",
year: "numeric",
})
.formatToParts(date)
.reduce<Record<string, string>>((acc, part) => {
if (part.type !== "literal") {
acc[part.type] = part.value;
}
return acc;
}, {});
const day = dateParts.day ?? String(date.getDate());
const month =
dateParts.month?.replace(/\./g, "") ??
new Intl.DateTimeFormat("ru-RU", { month: "short" })
.format(date)
.replace(/\./g, "");
const year = dateParts.year ?? String(date.getFullYear());
return `${day} ${month} ${year}`;
}
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${day}.${month}`;
};
const isSameDay = (startDate: Date, endDate: Date) =>
startDate.getFullYear() === endDate.getFullYear() &&
startDate.getMonth() === endDate.getMonth() &&
startDate.getDate() === endDate.getDate();
const formatRangeLabel = (
range?: DateRangeValue | null,
fallback = "Указать период",
) => {
if (!range?.[0] || !range[1]) {
return fallback;
}
const [startDate, endDate] = range;
if (isSameDay(startDate, endDate)) {
return formatDate(startDate);
}
const diffInDays = Math.floor(
Math.abs(
getStartOfDay(endDate).getTime() - getStartOfDay(startDate).getTime(),
) / DAY_IN_MS,
);
const showYear =
startDate.getFullYear() !== endDate.getFullYear() || diffInDays > 365;
return `${formatDate(startDate, showYear)} — ${formatDate(endDate, showYear)}`;
};
const getPresetRange = (
interval: AnalyticsIntervalKey,
): DateRangeValue | null => {
const today = getStartOfDay(new Date());
switch (interval) {
case "today":
return [today, today];
case "yesterday": {
const yesterday = shiftDays(today, -1);
return [yesterday, yesterday];
}
case "last7":
return [shiftDays(today, -6), today];
case "last30":
return [shiftDays(today, -29), today];
default:
return null;
}
};
const ButtonSelectIntervalDate: React.FC = () => {
const { modeDesktop } = useContext(AppContext);
const [actionSheetShown, setActionSheetShown] = useState(false);
const [selectedInterval, setSelectedInterval] =
useState<AnalyticsIntervalKey>(INTERVALS[0].key);
const [previousInterval, setPreviousInterval] =
useState<AnalyticsIntervalKey>(INTERVALS[0].key);
const [calendarPlacement, setCalendarPlacement] = useState<any>("bottom-end");
const [customRange, setCustomRange] = useState<DateRangeValue | null>(null);
const [desktopCalendarOpen, setDesktopCalendarOpen] = useState(false);
const [mobileDateModalOpen, setMobileDateModalOpen] = useState(false);
const targetRef = useRef<HTMLElement>(null);
const popperRef = useRef<HTMLDivElement>(null);
const handleCloseActionSheet = () => {
setActionSheetShown(false);
};
const handleCloseCustomPicker = () => {
if (selectedInterval === "custom" && !hasCompleteRange(customRange)) {
setSelectedInterval(previousInterval);
setCustomRange(null);
}
setDesktopCalendarOpen(false);
setMobileDateModalOpen(false);
};
const handleOpenActionSheet = () => {
handleCloseCustomPicker();
setActionSheetShown(true);
};
const handleSelectInterval = (interval: AnalyticsIntervalKey) => {
if (interval === "custom" && selectedInterval !== "custom") {
setPreviousInterval(selectedInterval);
}
if (interval !== "custom") {
setCustomRange(null);
}
setSelectedInterval(interval);
handleCloseActionSheet();
if (interval === "custom") {
if (modeDesktop) {
setDesktopCalendarOpen(true);
} else {
setMobileDateModalOpen(true);
}
} else {
setDesktopCalendarOpen(false);
setMobileDateModalOpen(false);
}
};
const handleCustomRangeChange = (value?: DateRangeValue) => {
if (!value) {
setCustomRange(null);
return;
}
setCustomRange([value[0], value[1]]);
if (hasCompleteRange(value)) {
setDesktopCalendarOpen(false);
}
};
const handleStartDateChange = (value?: Date) => {
setCustomRange((prevValue) => [value ?? null, prevValue?.[1] ?? null]);
};
const handleEndDateChange = (value?: Date) => {
setCustomRange((prevValue) => [prevValue?.[0] ?? null, value ?? null]);
};
const handleApplyCustomRange = () => {
if (hasCompleteRange(customRange) && !hasInvalidRangeOrder(customRange)) {
setMobileDateModalOpen(false);
}
};
const shouldDisableStartDate = (value: Date) =>
Boolean(
isFutureDay(value) ||
(customRange?.[1] && isAfterDay(value, customRange[1])),
);
const shouldDisableEndDate = (value: Date) =>
Boolean(
isFutureDay(value) ||
(customRange?.[0] && isBeforeDay(value, customRange[0])),
);
const buttonLabel = (() => {
if (selectedInterval === "all") {
return INTERVALS[0].label;
}
if (selectedInterval === "custom") {
return formatRangeLabel(customRange);
}
return formatRangeLabel(
getPresetRange(selectedInterval),
INTERVALS.find((interval) => interval.key === selectedInterval)?.label,
);
})();
return (
<>
{actionSheetShown ? (
<AppRootPortal usePortal>
<ActionSheet toggleRef={targetRef} onClose={handleCloseActionSheet}>
{INTERVALS.map((interval) => (
<ActionSheetItem
key={interval.key}
selectable
checked={selectedInterval === interval.key}
onClick={() => handleSelectInterval(interval.key)}
>
{interval.label}
</ActionSheetItem>
))}
</ActionSheet>
</AppRootPortal>
) : null}
{!modeDesktop ? (
<AppRootPortal usePortal>
<ModalPage
open={mobileDateModalOpen}
settlingHeight={100}
size="m"
header={
<ModalPageHeader
before={
<ModalPageHeaderButtonClose
onClick={handleCloseCustomPicker}
/>
}
>
Календарь
</ModalPageHeader>
}
footer={
<>
<Separator />
<Div>
<Button
appearance="accent"
disabled={
!hasCompleteRange(customRange) ||
hasInvalidRangeOrder(customRange)
}
mode="primary"
size="l"
stretched
onClick={handleApplyCustomRange}
>
Применить
</Button>
</Div>
</>
}
onClose={handleCloseCustomPicker}
>
<FormItem top="Начало периода">
<DateInput
aria-label="Дата начала"
disableFuture
placeholder="Дата начала"
shouldDisableDate={shouldDisableStartDate}
value={customRange?.[0] ?? undefined}
onChange={handleStartDateChange}
/>
</FormItem>
<FormItem top="Окончание периода">
<DateInput
aria-label="Дата окончания"
disableFuture
placeholder="Дата окончания"
shouldDisableDate={shouldDisableEndDate}
value={customRange?.[1] ?? undefined}
onChange={handleEndDateChange}
/>
</FormItem>
</ModalPage>
</AppRootPortal>
) : null}
{modeDesktop && desktopCalendarOpen ? (
<Popper
getRootRef={popperRef}
targetRef={targetRef}
placement={calendarPlacement}
shown={desktopCalendarOpen}
onShownChange={(shown) => {
if (!shown) {
handleCloseCustomPicker();
}
}}
onPlacementChange={setCalendarPlacement}
offsetByMainAxis={8}
usePortal
zIndex={101}
>
<CalendarRange
role="dialog"
disableFuture
value={customRange ?? undefined}
onChange={handleCustomRangeChange}
/>
</Popper>
) : null}
<Button
after={<Icon16DropdownOutline />}
appearance="neutral"
getRootRef={targetRef as React.Ref<HTMLElement>}
mode="tertiary"
onClick={handleOpenActionSheet}
size="l"
>
{buttonLabel}
</Button>
</>
);
};
export default ButtonSelectIntervalDate;
Карточка видео:
import { Fragment, useContext } from "react";
import { getOptimalImageFromSizes } from "engine/action";
import {
VKVIDEO_LINK,
VK_LINK,
classNames,
decWord,
formatTime,
getDataProfileOrGroup,
toShort,
} from "engine/utils";
import { AppContext } from "engine/state";
import {
AspectRatio,
Card,
Headline,
SimpleCell,
Tappable,
Link,
} from "@vkontakte/vkui";
import { CustomBadge, CustomAvatar } from "engine/components";
import "./VideoCard.css";
interface VideoCardProps extends React.HTMLAttributes<HTMLDivElement> {
data: any;
type: "artists" | "people" | "groups";
}
const VideoCard: React.FC<VideoCardProps> = ({
data,
type,
className,
...restProps
}) => {
const { isDesktop } = useContext(AppContext);
const image = data?.image || [];
const url = getOptimalImageFromSizes({ data: image })?.url;
const isArtists = type === "artists";
const isPeople = type === "people";
const isGroup = type === "groups";
const profileOrGroup = getDataProfileOrGroup({ data, type });
const id = isGroup
? `-${profileOrGroup?.id || 0}`
: `${profileOrGroup?.id || 0}`;
const name = isGroup
? `${profileOrGroup?.name}`
: isPeople
? `${profileOrGroup?.first_name} ${profileOrGroup?.last_name}`
: ``;
const mainLink = isGroup
? `${VKVIDEO_LINK}/@club${profileOrGroup?.id || 0}`
: isPeople
? `${VKVIDEO_LINK}/@id${id}`
: ``;
const views =
toShort(data?.views) +
" " +
decWord(
data?.views > 1000 ? 1000 : data?.views,
["просмотр", "просмотра", "просмотров"],
false
);
const date = new Date(data?.date * 1000)?.toLocaleDateString(undefined, {
day: "numeric",
hour: "2-digit",
minute: "2-digit",
month: "short",
year: "numeric",
});
const subtitle = isArtists ? (
<div className="VideoCard__names">
{data?.main_artists?.map((value: Record<string, any>, key: number) => (
<Fragment key={`Link--${key}`}>
<Link
className="VideoCard__name"
href={`${VK_LINK}/artist/${value?.id}`}
target="_blank"
>
{value?.name}
</Link>
{data?.main_artists?.length - 1 !== key ? ", " : ""}
</Fragment>
))}
</div>
) : (
<Link className="VideoCard__name" href={mainLink} target="_blank">
<div className="VideoCard__name__text">{name}</div>
<CustomBadge className="VideoCard__name__Badge" data={profileOrGroup} tooltip={false} />
</Link>
);
return (
<Card
{...restProps}
className={classNames(
"VideoCard",
isDesktop ? "VideoCard--desktop" : "",
className
)}
>
<Tappable
className="VideoCard__content"
href={data?.video_url}
target="_blank"
>
{data?.live === 1 ? (
<Headline
className={classNames(
"VideoCard__caption",
"VideoCard__caption--live"
)}
level="2"
weight="2"
>
LIVE
</Headline>
) : (
<Headline className="VideoCard__caption" level="2" weight="2">
{(data?.platform ? data?.platform + " · " : "") +
formatTime(data?.duration)}
</Headline>
)}
<AspectRatio className="VideoCard__image" ratio={16 / 9}>
<img alt="" src={url} />
</AspectRatio>
</Tappable>
<SimpleCell
className="VideoCard__cell"
before={<CustomAvatar data={profileOrGroup} size={36} />}
subtitle={subtitle}
extraSubtitle={
<>
{views}
<br />
{date}
</>
}
>
<Link
className="VideoCard__title"
href={data?.video_url}
target="_blank"
>
{data?.title}
</Link>
</SimpleCell>
</Card>
);
};
export default VideoCard;
import { useContext } from "react";
import { VK_LINK } from "engine/utils";
import { AppContext } from "engine/state";
import { Tooltip, Link } from "@vkontakte/vkui";
import { Icon12Verified, Icon12Crown } from "@vkontakte/icons";
import "./CustomBadge.css";
interface CustomBadgeProps extends React.HTMLAttributes<SVGSVGElement> {
data: {
[key: string]: any;
is_verified: boolean;
verified: number;
};
tooltip?: boolean;
}
const CustomBadge: React.FC<CustomBadgeProps> = ({
data,
tooltip = true,
className,
...restProps
}) => {
const { isDesktop } = useContext(AppContext);
const confirmed = data?.is_verified;
const verified = data?.verified === 1;
const isGoldenMarkedBusiness = data?.is_golden_marked_business;
const isConfirmedBusiness = data?.is_confirmed_business;
const iconVerifiedColor = verified
? "var(--vkui--color_icon_accent)"
: "var(--vkui--color_icon_secondary)";
return (
<div className="CustomBadge">
{confirmed || verified ? (
<Tooltip
enableInteractive={true}
title={
verified
? "Верифицированный профиль"
: confirmed
? "Подтверждённый аккаунт"
: ""
}
maxWidth={null}
placement="top"
shown={tooltip ? (isDesktop ? undefined : false) : false}
description={
verified ? (
<span>
Профиль верифицирован <br />
<Link target="_blank" href={`${VK_LINK}/blog/verification`}>
Узнайте больше о новой верификации
</Link>
</span>
) : confirmed ? (
<span>
Данные аккаунта подтверждены <br />
<Link target="_blank" href={`${VK_LINK}/faq20572`}>
Подробнее
</Link>
</span>
) : (
""
)
}
>
<Icon12Verified
color={iconVerifiedColor}
style={{ minWidth: 16, minHeight: 16, cursor: "pointer" }}
/>
</Tooltip>
) : isGoldenMarkedBusiness ? (
<Tooltip
enableInteractive={true}
maxWidth={null}
placement="top"
shown={tooltip ? (isDesktop ? undefined : false) : false}
description={
<span>
Компания подтвердила свою надёжность
<br /> и пользуется сервисами VK для бизнеса
<br />
<Link
target="_blank"
href="https://vk.ru/@business-otmetky-dlya-biznesa?anchor=otmetka-premium-biznes"
>
Подробнее
</Link>
</span>
}
>
<Icon12Crown
color="var(--vkui--color_icon_accent)"
style={{ minWidth: 16, minHeight: 16, cursor: "pointer" }}
/>
</Tooltip>
) : isConfirmedBusiness ? (
<Tooltip
enableInteractive={true}
maxWidth={null}
placement="top"
shown={tooltip ? (isDesktop ? undefined : false) : false}
description={
<span>
Данные компании подтверждены
<br /> через сервис VK Бизнес ID
<br />
<Link
target="_blank"
href="https://vk.ru/@business-otmetky-dlya-biznesa?anchor=otmetka-podtverzhd-nny-biznes"
>
Подробнее
</Link>
</span>
}
>
<Icon12Verified
color="var(--vkui--color_icon_secondary)"
style={{ minWidth: 16, minHeight: 16, cursor: "pointer" }}
/>
</Tooltip>
) : (
<></>
)}
</div>
);
};
export default CustomBadge;
import { useContext } from "react";
import { bridge } from "engine/action";
import { VK_LINK, classNames } from "engine/utils";
import { AppContext } from "engine/state";
import { Avatar, Tappable } from "@vkontakte/vkui";
import {
Icon16BloggerMark10kOutline,
Icon28MagnifierPlus,
} from "@vkontakte/icons";
// import "./CustomAvatar.css";
interface CustomAvatarProps extends React.HTMLAttributes<HTMLDivElement> {
data: {
[key: string]: any;
icon_150: string;
online: number;
online_app: boolean;
online_mobile: number;
photo_100: string;
photo_max_orig: string;
};
size?: number;
}
const CustomAvatar: React.FC<CustomAvatarProps> = ({
data,
size,
className,
...restProps
}) => {
const { isDesktop } = useContext(AppContext);
const photo =
data?.photo_100 || data?.icon_150 || `${VK_LINK}/images/camera_100.png`;
const photoMaxOrig = data?.photo_max_orig;
const photoShowed = isDesktop && photoMaxOrig && !size;
const preset =
data?.online_mobile === 1 || data?.online_app
? "online-mobile"
: data?.online === 1
? "online"
: undefined;
const avatarSize = size ? size : isDesktop ? 80 : 48;
return (
<Tappable
activeMode="none"
hoverMode="none"
onClick={
photoShowed
? () => bridge.send("VKWebAppShowImages", { images: [photoMaxOrig] })
: undefined
}
>
<Avatar
{...restProps}
className={classNames("CustomAvatar", className)}
size={avatarSize}
src={photo}
>
{data?.a_plus_mark ? (
<Avatar.Badge
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
height: 20,
borderRadius: 4,
padding: "0 6px",
backgroundColor: "var(--vkui--color_overlay_primary)",
}}
>
<Icon16BloggerMark10kOutline
color="#fff"
height={12}
width={12}
style={{
margin: 2,
}}
/>
</Avatar.Badge>
) : preset ? (
<Avatar.BadgeWithPreset preset={preset} />
) : (
<></>
)}
{photoShowed && !size ? (
<Avatar.Overlay
aria-label="Кнопка для просмотра изображения"
theme="dark"
>
<Icon28MagnifierPlus />
</Avatar.Overlay>
) : (
<></>
)}
</Avatar>
</Tappable>
);
};
export default CustomAvatar;
Карточка сообщества и пользователя:
import { useMemo, useContext } from "react";
import {
VK_LINK_CLUB,
VK_LINK_ID,
calculateAge,
classNames,
decWord,
toShort,
} from "engine/utils";
import { useGlobalValue } from "elum-state/react";
import { AppContext, USER_INFO } from "engine/state";
import {
AspectRatio,
Caption,
Card,
Footnote,
Headline,
Tappable,
UsersStack,
} from "@vkontakte/vkui";
import { CustomBadge } from "engine/components";
import { Icon16BloggerMark10kOutline } from "@vkontakte/icons";
import { TKeySearchParams } from "engine/types";
import "./UserCard.css";
interface UserCardProps extends React.HTMLAttributes<HTMLDivElement> {
data: any;
type: TKeySearchParams;
}
const UserCard: React.FC<UserCardProps> = ({
data,
type,
className,
...restProps
}) => {
const { isDesktop } = useContext(AppContext);
const user = useGlobalValue(USER_INFO);
const isPeople = type === "people";
const isGroup = type === "groups";
const isCurrentUser = user?.id === data?.id;
const commonFriendsCount = data?.common_friends_count || 0;
const commonFriendsPhotos = data?.common_friends_photos || [];
const hasMutualFriends = commonFriendsCount > 0;
const commonFriendsText = `${decWord(commonFriendsCount, [
"общий",
"общих",
"общих",
])}`;
const isFriend = data?.is_friend;
const isFriendText = "У вас в друзьях";
const id = isGroup ? `-${data?.id || 0}` : `${data?.id || 0}`;
const name = isGroup
? `${data?.name}`
: isPeople
? `${data?.first_name} ${data?.last_name}`
: ``;
const mainLink = isGroup
? `${VK_LINK_CLUB}${data?.id || 0}`
: isPeople
? `${VK_LINK_ID}${id}`
: ``;
const url = data?.photo_max_orig;
const age = useMemo(
() =>
data?.bdate?.split(".")?.[0] &&
data?.bdate?.split(".")?.[1] &&
data?.bdate?.split(".")?.[2]
? calculateAge(
data?.bdate?.split(".")?.[0],
data?.bdate?.split(".")?.[1],
data?.bdate?.split(".")?.[2]
)
: undefined,
[data?.bdate]
);
const city = data?.city?.title;
const company = data?.career?.reverse()?.[0]?.company;
const subtitle = isPeople
? isCurrentUser
? "Это вы. И это прекрасно."
: (age ? decWord(age, ["год", "года", "лет"]) : "") +
(company ? (age ? ", " : "") + company : "") +
(city ? (age || company ? ", " : "") + city : "")
: isGroup
? data?.activity
: "";
return (
<Card
{...restProps}
className={classNames(
"UserCard",
isDesktop ? "UserCard--desktop" : "",
className
)}
>
<Tappable className="UserCard__content" href={mainLink} target="_blank">
<AspectRatio className="UserCard__image" ratio={1 / 1}>
<img alt="" src={url} />
<div className="UserCard__image__in">
{(isGroup && data?.members_count) ||
(isPeople && (isFriend || hasMutualFriends)) ? (
<div
className={classNames("UserCard__badge", {
"UserCard__badge--with_UsersStack": hasMutualFriends,
})}
>
<Caption className="EllipsisText">
{isGroup ? (
data?.members_count ? (
toShort(
data?.members_count,
["", "K", "M", "G", "T", "P"],
false
) +
" " +
decWord(
data?.members_count,
["подписчик", "подписчика", "подписчиков"],
false
)
) : (
""
)
) : isFriend ? (
isFriendText
) : hasMutualFriends ? (
<UsersStack
className="UserCard__UsersStack"
photos={commonFriendsPhotos}
size="s"
>
{commonFriendsText}
</UsersStack>
) : (
""
)}
</Caption>
</div>
) : (
<></>
)}
{data?.a_plus_mark ? (
<div className="UserCard__badge-top">
<Icon16BloggerMark10kOutline
color="#fff"
height={12}
width={12}
style={{
margin: 2,
}}
/>
</div>
) : (
<></>
)}
</div>
</AspectRatio>
<div className="UserCard__caption">
<div className="UserCard__title">
<Headline className="UserCard__headline EllipsisText" level="1">
{name}
</Headline>
<CustomBadge className="UserCard__verify" data={data} />
</div>
<Footnote className="UserCard__subtitle EllipsisText">
{subtitle}
</Footnote>
</div>
</Tappable>
</Card>
);
};
export default UserCard;
и тд.
Описание
Код-ревью приветствуется. Тем кто будет предлагать какие-то свои кейсы. Вообще это должно в моменте закрыть какие-то типовые экраны, бутерброды-компонентов, которые как правило делаются достаточно часто, или их можно встретить в самом VK.
Тут наверное задача больше показать связки компонентов. Как они работают между собой. Если начать собирать и шлифовать какие-то типовые кейсы от комьюнити, можно прийти повышению качества мини-приложений и сайтов на vkui и сроками разработки c использованием по сути best-practices одобреными разработчиками vkui.
Примеры:
Адаптивный UI/UX выбора диапазона дат:
Карточка видео:
Карточка сообщества и пользователя:
и тд.