feat: Added the game's supported languages. Added the game's lowest historical price and a link to see the price in various stores (keyshop and official store)

This commit is contained in:
Daniel Saraiva
2025-09-07 12:37:49 -03:00
parent 7e5cef6e44
commit 302ed92018
6 changed files with 1768 additions and 1732 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,66 @@
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context/game-details/game-details.context";
import { SidebarSection } from "../sidebar-section/sidebar-section";
export function GameLanguageSection() {
const { t } = useTranslation("game_details");
const { shopDetails, objectId } = useContext(gameDetailsContext);
const getLanguages = () => {
let languages = shopDetails?.supported_languages;
if (!languages) return [];
languages = languages?.split('<br>')[0];
let arrayIdiomas = languages?.split(',')
let listLanguages: { language: string; caption: string; audio: string }[] = [];
arrayIdiomas?.forEach((lang) => {
const objectLanguage = {
language: lang.replace("<strong>*</strong>", ""),
caption: "✔",
audio: lang.includes("*") ? "✔" : "" };
listLanguages.push(objectLanguage);
})
return listLanguages;
}
return (
<SidebarSection title={t("language")}>
<div>
<h4>{t("supported_languages")}</h4>
<table className="table-languages">
<thead>
<tr>
<th>{t("language")}</th>
<th>{t("caption")}</th>
<th>{t("audio")}</th>
</tr>
</thead>
<tbody>
{getLanguages().map((lang) => (
<tr key={lang.language}>
<td>{lang.language}</td>
<td>{lang.caption}</td>
<td>{lang.audio}</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<a target="_blank" rel="noopener noreferrer" className="list__item" href={`https://store.steampowered.com/app/${objectId}`}>Link Steam</a>
</div>
</SidebarSection>
);
}
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context/game-details/game-details.context";
import { SidebarSection } from "../sidebar-section/sidebar-section";
export function GameLanguageSection() {
const { t } = useTranslation("game_details");
const { shopDetails, objectId } = useContext(gameDetailsContext);
const getLanguages = () => {
let languages = shopDetails?.supported_languages;
if (!languages) return [];
languages = languages?.split("<br>")[0];
const arrayIdiomas = languages?.split(",");
const listLanguages: {
language: string;
caption: string;
audio: string;
}[] = [];
arrayIdiomas?.forEach((lang) => {
const objectLanguage = {
language: lang.replace("<strong>*</strong>", ""),
caption: "✔",
audio: lang.includes("*") ? "✔" : "",
};
listLanguages.push(objectLanguage);
});
return listLanguages;
};
return (
<SidebarSection title={t("language")}>
<div>
<h4>{t("supported_languages")}</h4>
<table className="table-languages">
<thead>
<tr>
<th>{t("language")}</th>
<th>{t("caption")}</th>
<th>{t("audio")}</th>
</tr>
</thead>
<tbody>
{getLanguages().map((lang) => (
<tr key={lang.language}>
<td>{lang.language}</td>
<td>{lang.caption}</td>
<td>{lang.audio}</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<a
target="_blank"
rel="noopener noreferrer"
className="list__item"
href={`https://store.steampowered.com/app/${objectId}`}
>
Link Steam
</a>
</div>
</SidebarSection>
);
}

View File

@@ -1,62 +1,86 @@
import { useCallback, useContext, useEffect, useState } from "react";
import { SidebarSection } from "../sidebar-section/sidebar-section";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context/game-details/game-details.context";
import { useAppSelector } from "@renderer/hooks";
export function GamePricesSection() {
const userPreferences = useAppSelector((state) => state.userPreferences.value);
const { t } = useTranslation("game_details");
const [priceData, setPriceData] = useState<any>(null);
const [isLoadingPrices, setIsLoadingPrices] = useState(false);
const { objectId } = useContext(gameDetailsContext);
const fetchGamePrices = useCallback(async (steamAppId: string) => {
setIsLoadingPrices(true);
try {
const apiKey = userPreferences?.ggDealsApiKey || import.meta.env.VITE_GG_DEALS_API_KEY;
if (!apiKey) {
setPriceData(null);
setIsLoadingPrices(false);
return;
}
const url = `${import.meta.env.VITE_GG_DEALS_API_URL}/?ids=${steamAppId}&key=${apiKey}&region=br`;
const response = await fetch(url);
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
setPriceData(data.data?.[steamAppId] ?? null);
} catch (error) {
setPriceData(null);
} finally {
setIsLoadingPrices(false);
}
}, []);
useEffect(() => {
if (objectId) {
fetchGamePrices(objectId.toString());
}
}, [objectId, fetchGamePrices]);
return (
<SidebarSection title={t("prices")}>
{isLoadingPrices ? (
<div>{t("loading")}</div>
) : priceData ? (
<div>
<ul className="">
<li><b>{t("retail_price")}</b>: {t("currency_symbol")}{priceData.prices.currentRetail}</li>
<li><b>{t("keyshop_price")}</b>: {t("currency_symbol")}{priceData.prices.currentKeyshops}</li>
<li><b>{t("historical_retail")}</b>: {t("currency_symbol")}{priceData.prices.historicalRetail}</li>
<li><b>{t("historical_keyshop")}</b>: {t("currency_symbol")}{priceData.prices.historicalKeyshops}</li>
<li><a href={priceData.url} target="_blank" rel="noopener noreferrer" className="list__item">clique para ver todos os preços</a></li>
</ul>
</div>
) : (
<div>{t("no_prices_found")}</div>
)}
</SidebarSection>
);
}
import { useCallback, useContext, useEffect, useState } from "react";
import { SidebarSection } from "../sidebar-section/sidebar-section";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context/game-details/game-details.context";
import { useAppSelector } from "@renderer/hooks";
export function GamePricesSection() {
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const { t } = useTranslation("game_details");
const [priceData, setPriceData] = useState<any>(null);
const [isLoadingPrices, setIsLoadingPrices] = useState(false);
const { objectId } = useContext(gameDetailsContext);
const fetchGamePrices = useCallback(async (steamAppId: string) => {
setIsLoadingPrices(true);
try {
const apiKey =
userPreferences?.ggDealsApiKey || import.meta.env.VITE_GG_DEALS_API_KEY;
if (!apiKey) {
setPriceData(null);
setIsLoadingPrices(false);
return;
}
const url = `${import.meta.env.VITE_GG_DEALS_API_URL}/?ids=${steamAppId}&key=${apiKey}&region=br`;
const response = await fetch(url);
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
setPriceData(data.data?.[steamAppId] ?? null);
} catch (error) {
setPriceData(null);
} finally {
setIsLoadingPrices(false);
}
}, []);
useEffect(() => {
if (objectId) {
fetchGamePrices(objectId.toString());
}
}, [objectId, fetchGamePrices]);
return (
<SidebarSection title={t("prices")}>
{isLoadingPrices ? (
<div>{t("loading")}</div>
) : priceData ? (
<div>
<ul className="">
<li>
<b>{t("retail_price")}</b>: {t("currency_symbol")}
{priceData.prices.currentRetail}
</li>
<li>
<b>{t("keyshop_price")}</b>: {t("currency_symbol")}
{priceData.prices.currentKeyshops}
</li>
<li>
<b>{t("historical_retail")}</b>: {t("currency_symbol")}
{priceData.prices.historicalRetail}
</li>
<li>
<b>{t("historical_keyshop")}</b>: {t("currency_symbol")}
{priceData.prices.historicalKeyshops}
</li>
<li>
<a
href={priceData.url}
target="_blank"
rel="noopener noreferrer"
className="list__item"
>
clique para ver todos os preços
</a>
</li>
</ul>
</div>
) : (
<div>{t("no_prices_found")}</div>
)}
</SidebarSection>
);
}

View File

@@ -1,217 +1,218 @@
@use "../../../scss/globals.scss";
.content-sidebar {
border-left: solid 1px globals.$border-color;
background-color: globals.$dark-background-color;
width: 100%;
height: 100%;
@media (min-width: 1024px) {
max-width: 300px;
width: 100%;
}
@media (min-width: 1280px) {
width: 100%;
max-width: 400px;
}
}
.requirement {
&__button-container {
width: 100%;
display: flex;
}
&__button {
border: solid 1px globals.$border-color;
border-left: none;
border-right: none;
border-radius: 0;
width: 100%;
}
&__details {
padding: calc(globals.$spacing-unit * 2);
line-height: 22px;
font-size: globals.$body-font-size;
a {
display: flex;
color: globals.$body-color;
}
}
&__details-skeleton {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
padding: calc(globals.$spacing-unit * 2);
font-size: globals.$body-font-size;
}
}
.how-long-to-beat {
&__categories-list {
margin: 0;
padding: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__category {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
background: linear-gradient(
90deg,
transparent 20%,
rgb(255 255 255 / 2%) 100%
);
border-radius: 4px;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
border: solid 1px globals.$border-color;
}
&__category-label {
color: globals.$muted-color;
}
&__category-label--bold {
font-weight: bold;
}
&__category-skeleton {
border: solid 1px globals.$border-color;
border-radius: 4px;
height: 76px;
}
}
.stats {
&__section {
display: flex;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
justify-content: space-between;
transition: max-height ease 0.5s;
overflow: hidden;
@media (min-width: 1024px) {
flex-direction: column;
}
@media (min-width: 1280px) {
flex-direction: row;
}
}
&__category-title {
font-size: globals.$small-font-size;
font-weight: bold;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__category {
display: flex;
flex-direction: row;
gap: calc(globals.$spacing-unit / 2);
justify-content: space-between;
align-items: center;
}
}
.list {
list-style: none;
margin: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
&__item {
display: flex;
cursor: pointer;
transition: all ease 0.1s;
color: globals.$muted-color;
width: 100%;
overflow: hidden;
border-radius: 4px;
padding: globals.$spacing-unit;
gap: calc(globals.$spacing-unit * 2);
align-items: center;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
text-decoration: none;
}
}
&__item-image {
width: 54px;
height: 54px;
border-radius: 4px;
object-fit: cover;
&--locked {
filter: grayscale(100%);
}
}
}
.subscription-required-button {
text-decoration: none;
display: flex;
justify-content: center;
width: 100%;
gap: calc(globals.$spacing-unit / 2);
color: globals.$warning-color;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.achievements-placeholder {
position: absolute;
z-index: 1;
inset: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 8px;
}
.achievements-placeholder__blur {
filter: blur(4px);
}
.table-languages {
width: 100%;
border-collapse: collapse;
text-align: left;
th, td {
padding: globals.$spacing-unit;
border-bottom: solid 1px globals.$border-color;
}
th {
font-size: globals.$small-font-size;
color: globals.$muted-color;
font-weight: normal;
}
td {
font-size: globals.$body-font-size;
}
}
@use "../../../scss/globals.scss";
.content-sidebar {
border-left: solid 1px globals.$border-color;
background-color: globals.$dark-background-color;
width: 100%;
height: 100%;
@media (min-width: 1024px) {
max-width: 300px;
width: 100%;
}
@media (min-width: 1280px) {
width: 100%;
max-width: 400px;
}
}
.requirement {
&__button-container {
width: 100%;
display: flex;
}
&__button {
border: solid 1px globals.$border-color;
border-left: none;
border-right: none;
border-radius: 0;
width: 100%;
}
&__details {
padding: calc(globals.$spacing-unit * 2);
line-height: 22px;
font-size: globals.$body-font-size;
a {
display: flex;
color: globals.$body-color;
}
}
&__details-skeleton {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
padding: calc(globals.$spacing-unit * 2);
font-size: globals.$body-font-size;
}
}
.how-long-to-beat {
&__categories-list {
margin: 0;
padding: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__category {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
background: linear-gradient(
90deg,
transparent 20%,
rgb(255 255 255 / 2%) 100%
);
border-radius: 4px;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
border: solid 1px globals.$border-color;
}
&__category-label {
color: globals.$muted-color;
}
&__category-label--bold {
font-weight: bold;
}
&__category-skeleton {
border: solid 1px globals.$border-color;
border-radius: 4px;
height: 76px;
}
}
.stats {
&__section {
display: flex;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
justify-content: space-between;
transition: max-height ease 0.5s;
overflow: hidden;
@media (min-width: 1024px) {
flex-direction: column;
}
@media (min-width: 1280px) {
flex-direction: row;
}
}
&__category-title {
font-size: globals.$small-font-size;
font-weight: bold;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__category {
display: flex;
flex-direction: row;
gap: calc(globals.$spacing-unit / 2);
justify-content: space-between;
align-items: center;
}
}
.list {
list-style: none;
margin: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
&__item {
display: flex;
cursor: pointer;
transition: all ease 0.1s;
color: globals.$muted-color;
width: 100%;
overflow: hidden;
border-radius: 4px;
padding: globals.$spacing-unit;
gap: calc(globals.$spacing-unit * 2);
align-items: center;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
text-decoration: none;
}
}
&__item-image {
width: 54px;
height: 54px;
border-radius: 4px;
object-fit: cover;
&--locked {
filter: grayscale(100%);
}
}
}
.subscription-required-button {
text-decoration: none;
display: flex;
justify-content: center;
width: 100%;
gap: calc(globals.$spacing-unit / 2);
color: globals.$warning-color;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.achievements-placeholder {
position: absolute;
z-index: 1;
inset: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 8px;
}
.achievements-placeholder__blur {
filter: blur(4px);
}
.table-languages {
width: 100%;
border-collapse: collapse;
text-align: left;
th,
td {
padding: globals.$spacing-unit;
border-bottom: solid 1px globals.$border-color;
}
th {
font-size: globals.$small-font-size;
color: globals.$muted-color;
font-weight: normal;
}
td {
font-size: globals.$body-font-size;
}
}

View File

@@ -1,273 +1,273 @@
import { useContext, useEffect, useState } from "react";
import type {
HowLongToBeatCategory,
SteamAppDetails,
UserAchievement,
} from "@types";
import { useTranslation } from "react-i18next";
import { Button, Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context";
import { useDate, useFormat, useUserDetails } from "@renderer/hooks";
import {
CloudOfflineIcon,
DownloadIcon,
LockIcon,
PeopleIcon,
} from "@primer/octicons-react";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import { howLongToBeatEntriesTable } from "@renderer/dexie";
import { SidebarSection } from "../sidebar-section/sidebar-section";
import { buildGameAchievementPath } from "@renderer/helpers";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./sidebar.scss";
import { GamePricesSection } from "./game-prices-section";
import { GameLanguageSection } from "./game-language-section";
const achievementsPlaceholder: UserAchievement[] = [
{
displayName: "Timber!!",
name: "1",
hidden: false,
description: "Chop down your first tree.",
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0fbb33098c9da39d1d4771d8209afface9c46e81.jpg",
icongray:
"https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0fbb33098c9da39d1d4771d8209afface9c46e81.jpg",
unlocked: true,
unlockTime: Date.now(),
},
{
displayName: "Supreme Helper Minion!",
name: "2",
hidden: false,
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0a6ff6a36670c96ceb4d30cf6fd69d2fdf55f38e.jpg",
icongray:
"https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0a6ff6a36670c96ceb4d30cf6fd69d2fdf55f38e.jpg",
unlocked: false,
unlockTime: null,
},
{
displayName: "Feast of Midas",
name: "3",
hidden: false,
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/2d10311274fe7c92ab25cc29afdca86b019ad472.jpg",
icongray:
"https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/2d10311274fe7c92ab25cc29afdca86b019ad472.jpg",
unlocked: false,
unlockTime: null,
},
];
export function Sidebar() {
const [howLongToBeat, setHowLongToBeat] = useState<{
isLoading: boolean;
data: HowLongToBeatCategory[] | null;
}>({ isLoading: true, data: null });
const { userDetails, hasActiveSubscription } = useUserDetails();
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const { gameTitle, shopDetails, objectId, shop, stats, achievements } =
useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
const { t } = useTranslation("game_details");
const { formatDateTime } = useDate();
const { numberFormatter } = useFormat();
useEffect(() => {
if (objectId) {
setHowLongToBeat({ isLoading: true, data: null });
howLongToBeatEntriesTable
.where({ shop, objectId })
.first()
.then(async (cachedHowLongToBeat) => {
if (cachedHowLongToBeat) {
setHowLongToBeat({
isLoading: false,
data: cachedHowLongToBeat.categories,
});
} else {
try {
const howLongToBeat = await window.electron.getHowLongToBeat(
objectId,
shop
);
if (howLongToBeat) {
howLongToBeatEntriesTable.add({
objectId,
shop: "steam",
createdAt: new Date(),
updatedAt: new Date(),
categories: howLongToBeat,
});
}
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
} catch (err) {
setHowLongToBeat({ isLoading: false, data: null });
}
}
});
}
}, [objectId, shop, gameTitle]);
return (
<aside className="content-sidebar">
<GameLanguageSection />
<GamePricesSection />
{userDetails === null && (
<SidebarSection title={t("achievements")}>
<div className="achievements-placeholder">
<LockIcon size={36} />
<h3>{t("sign_in_to_see_achievements")}</h3>
</div>
<ul className="list achievements-placeholder__blur">
{achievementsPlaceholder.map((achievement) => (
<li key={achievement.name}>
<div className="list__item">
<img
className={`list__item-image achievements-placeholder__blur ${
achievement.unlocked ? "" : "list__item-image--locked"
}`}
src={achievement.icon}
alt={achievement.displayName}
/>
<div>
<p>{achievement.displayName}</p>
<small>
{achievement.unlockTime != null &&
formatDateTime(achievement.unlockTime)}
</small>
</div>
</div>
</li>
))}
</ul>
</SidebarSection>
)}
{userDetails && achievements && achievements.length > 0 && (
<SidebarSection
title={t("achievements_count", {
unlockedCount: achievements.filter((a) => a.unlocked).length,
achievementsCount: achievements.length,
})}
>
<ul className="list">
{!hasActiveSubscription && (
<button
className="subscription-required-button"
onClick={() => showHydraCloudModal("achievements")}
>
<CloudOfflineIcon size={16} />
<span>{t("achievements_not_sync")}</span>
</button>
)}
{achievements.slice(0, 4).map((achievement) => (
<li key={achievement.displayName}>
<Link
to={buildGameAchievementPath({
shop: shop,
objectId: objectId!,
title: gameTitle,
})}
className="list__item"
title={achievement.description}
>
<img
className={`list__item-image ${
achievement.unlocked ? "" : "list__item-image--locked"
}`}
src={achievement.icon}
alt={achievement.displayName}
/>
<div>
<p>{achievement.displayName}</p>
<small>
{achievement.unlockTime != null &&
formatDateTime(achievement.unlockTime)}
</small>
</div>
</Link>
</li>
))}
<Link
to={buildGameAchievementPath({
shop: shop,
objectId: objectId!,
title: gameTitle,
})}
>
{t("see_all_achievements")}
</Link>
</ul>
</SidebarSection>
)}
{stats && (
<SidebarSection title={t("stats")}>
<div className="stats__section">
<div className="stats__category">
<p className="stats__category-title">
<DownloadIcon size={18} />
{t("download_count")}
</p>
<p>{numberFormatter.format(stats?.downloadCount)}</p>
</div>
<div className="stats__category">
<p className="stats__category-title">
<PeopleIcon size={18} />
{t("player_count")}
</p>
<p>{numberFormatter.format(stats?.playerCount)}</p>
</div>
</div>
</SidebarSection>
)}
<HowLongToBeatSection
howLongToBeatData={howLongToBeat.data}
isLoading={howLongToBeat.isLoading}
/>
<SidebarSection title={t("requirements")}>
<div className="requirement__button-container">
<Button
className="requirement__button"
onClick={() => setActiveRequirement("minimum")}
theme={activeRequirement === "minimum" ? "primary" : "outline"}
>
{t("minimum")}
</Button>
<Button
className="requirement__button"
onClick={() => setActiveRequirement("recommended")}
theme={activeRequirement === "recommended" ? "primary" : "outline"}
>
{t("recommended")}
</Button>
</div>
<div
className="requirement__details"
dangerouslySetInnerHTML={{
__html:
shopDetails?.pc_requirements?.[activeRequirement] ??
t(`no_${activeRequirement}_requirements`, {
gameTitle,
}),
}}
/>
</SidebarSection>
</aside>
);
}
import { useContext, useEffect, useState } from "react";
import type {
HowLongToBeatCategory,
SteamAppDetails,
UserAchievement,
} from "@types";
import { useTranslation } from "react-i18next";
import { Button, Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context";
import { useDate, useFormat, useUserDetails } from "@renderer/hooks";
import {
CloudOfflineIcon,
DownloadIcon,
LockIcon,
PeopleIcon,
} from "@primer/octicons-react";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import { howLongToBeatEntriesTable } from "@renderer/dexie";
import { SidebarSection } from "../sidebar-section/sidebar-section";
import { buildGameAchievementPath } from "@renderer/helpers";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./sidebar.scss";
import { GamePricesSection } from "./game-prices-section";
import { GameLanguageSection } from "./game-language-section";
const achievementsPlaceholder: UserAchievement[] = [
{
displayName: "Timber!!",
name: "1",
hidden: false,
description: "Chop down your first tree.",
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0fbb33098c9da39d1d4771d8209afface9c46e81.jpg",
icongray:
"https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0fbb33098c9da39d1d4771d8209afface9c46e81.jpg",
unlocked: true,
unlockTime: Date.now(),
},
{
displayName: "Supreme Helper Minion!",
name: "2",
hidden: false,
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0a6ff6a36670c96ceb4d30cf6fd69d2fdf55f38e.jpg",
icongray:
"https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0a6ff6a36670c96ceb4d30cf6fd69d2fdf55f38e.jpg",
unlocked: false,
unlockTime: null,
},
{
displayName: "Feast of Midas",
name: "3",
hidden: false,
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/2d10311274fe7c92ab25cc29afdca86b019ad472.jpg",
icongray:
"https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/2d10311274fe7c92ab25cc29afdca86b019ad472.jpg",
unlocked: false,
unlockTime: null,
},
];
export function Sidebar() {
const [howLongToBeat, setHowLongToBeat] = useState<{
isLoading: boolean;
data: HowLongToBeatCategory[] | null;
}>({ isLoading: true, data: null });
const { userDetails, hasActiveSubscription } = useUserDetails();
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const { gameTitle, shopDetails, objectId, shop, stats, achievements } =
useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
const { t } = useTranslation("game_details");
const { formatDateTime } = useDate();
const { numberFormatter } = useFormat();
useEffect(() => {
if (objectId) {
setHowLongToBeat({ isLoading: true, data: null });
howLongToBeatEntriesTable
.where({ shop, objectId })
.first()
.then(async (cachedHowLongToBeat) => {
if (cachedHowLongToBeat) {
setHowLongToBeat({
isLoading: false,
data: cachedHowLongToBeat.categories,
});
} else {
try {
const howLongToBeat = await window.electron.getHowLongToBeat(
objectId,
shop
);
if (howLongToBeat) {
howLongToBeatEntriesTable.add({
objectId,
shop: "steam",
createdAt: new Date(),
updatedAt: new Date(),
categories: howLongToBeat,
});
}
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
} catch (err) {
setHowLongToBeat({ isLoading: false, data: null });
}
}
});
}
}, [objectId, shop, gameTitle]);
return (
<aside className="content-sidebar">
<GameLanguageSection />
<GamePricesSection />
{userDetails === null && (
<SidebarSection title={t("achievements")}>
<div className="achievements-placeholder">
<LockIcon size={36} />
<h3>{t("sign_in_to_see_achievements")}</h3>
</div>
<ul className="list achievements-placeholder__blur">
{achievementsPlaceholder.map((achievement) => (
<li key={achievement.name}>
<div className="list__item">
<img
className={`list__item-image achievements-placeholder__blur ${
achievement.unlocked ? "" : "list__item-image--locked"
}`}
src={achievement.icon}
alt={achievement.displayName}
/>
<div>
<p>{achievement.displayName}</p>
<small>
{achievement.unlockTime != null &&
formatDateTime(achievement.unlockTime)}
</small>
</div>
</div>
</li>
))}
</ul>
</SidebarSection>
)}
{userDetails && achievements && achievements.length > 0 && (
<SidebarSection
title={t("achievements_count", {
unlockedCount: achievements.filter((a) => a.unlocked).length,
achievementsCount: achievements.length,
})}
>
<ul className="list">
{!hasActiveSubscription && (
<button
className="subscription-required-button"
onClick={() => showHydraCloudModal("achievements")}
>
<CloudOfflineIcon size={16} />
<span>{t("achievements_not_sync")}</span>
</button>
)}
{achievements.slice(0, 4).map((achievement) => (
<li key={achievement.displayName}>
<Link
to={buildGameAchievementPath({
shop: shop,
objectId: objectId!,
title: gameTitle,
})}
className="list__item"
title={achievement.description}
>
<img
className={`list__item-image ${
achievement.unlocked ? "" : "list__item-image--locked"
}`}
src={achievement.icon}
alt={achievement.displayName}
/>
<div>
<p>{achievement.displayName}</p>
<small>
{achievement.unlockTime != null &&
formatDateTime(achievement.unlockTime)}
</small>
</div>
</Link>
</li>
))}
<Link
to={buildGameAchievementPath({
shop: shop,
objectId: objectId!,
title: gameTitle,
})}
>
{t("see_all_achievements")}
</Link>
</ul>
</SidebarSection>
)}
{stats && (
<SidebarSection title={t("stats")}>
<div className="stats__section">
<div className="stats__category">
<p className="stats__category-title">
<DownloadIcon size={18} />
{t("download_count")}
</p>
<p>{numberFormatter.format(stats?.downloadCount)}</p>
</div>
<div className="stats__category">
<p className="stats__category-title">
<PeopleIcon size={18} />
{t("player_count")}
</p>
<p>{numberFormatter.format(stats?.playerCount)}</p>
</div>
</div>
</SidebarSection>
)}
<HowLongToBeatSection
howLongToBeatData={howLongToBeat.data}
isLoading={howLongToBeat.isLoading}
/>
<SidebarSection title={t("requirements")}>
<div className="requirement__button-container">
<Button
className="requirement__button"
onClick={() => setActiveRequirement("minimum")}
theme={activeRequirement === "minimum" ? "primary" : "outline"}
>
{t("minimum")}
</Button>
<Button
className="requirement__button"
onClick={() => setActiveRequirement("recommended")}
theme={activeRequirement === "recommended" ? "primary" : "outline"}
>
{t("recommended")}
</Button>
</div>
<div
className="requirement__details"
dangerouslySetInnerHTML={{
__html:
shopDetails?.pc_requirements?.[activeRequirement] ??
t(`no_${activeRequirement}_requirements`, {
gameTitle,
}),
}}
/>
</SidebarSection>
</aside>
);
}