diff --git a/.eslintrc.json b/.eslintrc.json index ec49ba18..2f941a48 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,6 +14,13 @@ "rules": { "no-undef": "off" } + }, + { + "files": ["components/lands/*.tsx"], + "rules": { + "react/prop-types": "off", + "react/no-unknown-property": "off" + } } ], "root": true diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index beec57ea..66b43a22 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,5 +15,5 @@ jobs: - uses: actions/checkout@v1 - name: Run Tests run: | - npm install + npm install --legacy-peer-deps npm test diff --git a/.gitignore b/.gitignore index a3538d60..f1b424ac 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /node_modules /.pnp .pnp.js +bun.lockb # testing /coverage diff --git a/components/UI/actions/clickable/clickableGithubIcon.tsx b/components/UI/actions/clickable/clickableGithubIcon.tsx index 713a4d97..37fc56ae 100644 --- a/components/UI/actions/clickable/clickableGithubIcon.tsx +++ b/components/UI/actions/clickable/clickableGithubIcon.tsx @@ -39,7 +39,7 @@ const ClickableGithubIcon: FunctionComponent = ({
- + ) : null; diff --git a/components/UI/actions/socialmediaActions.tsx b/components/UI/actions/socialmediaActions.tsx index 3de23518..5fb36df4 100644 --- a/components/UI/actions/socialmediaActions.tsx +++ b/components/UI/actions/socialmediaActions.tsx @@ -35,21 +35,21 @@ const SocialMediaActions: FunctionComponent = ({ }, [identity]); return ( -
+
); diff --git a/components/UI/backButton.tsx b/components/UI/backButton.tsx new file mode 100644 index 00000000..05b5a618 --- /dev/null +++ b/components/UI/backButton.tsx @@ -0,0 +1,26 @@ +import React, { FunctionComponent } from "react"; +import styles from "../../styles/components/backButton.module.css"; + +type BackButtonProps = { + onClick: () => void; +}; + +const BackButton: FunctionComponent = ({ onClick }) => ( + +); + +export default BackButton; diff --git a/components/UI/footer.tsx b/components/UI/footer.tsx index 5b81d799..25a7d85e 100644 --- a/components/UI/footer.tsx +++ b/components/UI/footer.tsx @@ -1,11 +1,39 @@ import React, { FunctionComponent } from "react"; -import styles from "../styles/components/footer.module.css"; +import styles from "../../styles/components/footer.module.css"; +import Link from "next/link"; +import TwitterIcon from "./iconsComponents/icons/twitterIcon"; +import DiscordIcon from "./iconsComponents/icons/discordIcon"; +import { useRouter } from "next/router"; const Footer: FunctionComponent = () => { + const route = useRouter().route; + if (route.includes("addressOrDomain")) return null; return ( -
+
- Powered by Starknet +
+ Partnership +
+
+
window.open("https://twitter.com/starknet_quest")} + > + + + + Twitter +
+
window.open("http://discord.gg/Td4a5wS5")} + > + + + + Discord +
+
); diff --git a/components/UI/iconsComponents/icons/arrowRightIcon.tsx b/components/UI/iconsComponents/icons/arrowRightIcon.tsx new file mode 100644 index 00000000..d8d2ee67 --- /dev/null +++ b/components/UI/iconsComponents/icons/arrowRightIcon.tsx @@ -0,0 +1,23 @@ +import React, { FunctionComponent } from "react"; + +const ArrowRightIcon: FunctionComponent = ({ + width = 24, + color, +}) => { + return ( + + + + ); +}; + +export default ArrowRightIcon; diff --git a/components/UI/iconsComponents/icons/closeIcon.tsx b/components/UI/iconsComponents/icons/closeIcon.tsx index 51854389..1193d49b 100644 --- a/components/UI/iconsComponents/icons/closeIcon.tsx +++ b/components/UI/iconsComponents/icons/closeIcon.tsx @@ -1,8 +1,8 @@ import React, { FunctionComponent } from "react"; -const CloseIcon: FunctionComponent = () => { +const CloseIcon: FunctionComponent = ({ width }) => { return ( - + = ({ width, color }) => { + return ( + + + + + ); +}; + +export default CopyIcon; diff --git a/components/UI/iconsComponents/icons/profilFilledIcon.tsx b/components/UI/iconsComponents/icons/profilFilledIcon.tsx new file mode 100644 index 00000000..9c010402 --- /dev/null +++ b/components/UI/iconsComponents/icons/profilFilledIcon.tsx @@ -0,0 +1,21 @@ +import React, { FunctionComponent } from "react"; + +const ProfilFilledIcon: FunctionComponent = ({ color, width }) => { + return ( + + + + ); +}; + +export default ProfilFilledIcon; diff --git a/components/UI/iconsComponents/icons/trophyIcon.tsx b/components/UI/iconsComponents/icons/trophyIcon.tsx new file mode 100644 index 00000000..02be4c5a --- /dev/null +++ b/components/UI/iconsComponents/icons/trophyIcon.tsx @@ -0,0 +1,20 @@ +import React, { FunctionComponent } from "react"; + +const TrophyIcon: FunctionComponent = ({ width, color }) => { + return ( + + + + ); +}; + +export default TrophyIcon; diff --git a/components/UI/iconsComponents/icons/verifiedIcon.tsx b/components/UI/iconsComponents/icons/verifiedIcon.tsx index 3f8d8415..b9268dca 100644 --- a/components/UI/iconsComponents/icons/verifiedIcon.tsx +++ b/components/UI/iconsComponents/icons/verifiedIcon.tsx @@ -3,35 +3,16 @@ import React, { FunctionComponent } from "react"; const verifiedIcon: FunctionComponent = ({ width, color }) => { return ( - - - - - - - - + ); diff --git a/components/UI/menus/popup.tsx b/components/UI/menus/popup.tsx new file mode 100644 index 00000000..d2f73af0 --- /dev/null +++ b/components/UI/menus/popup.tsx @@ -0,0 +1,46 @@ +import React, { FunctionComponent } from "react"; +import styles from "../../../styles/components/popup.module.css"; +import Button from "../button"; +import CloseIcon from "../iconsComponents/icons/closeIcon"; + +type PopupProps = { + title: string; + banner: string; + description: string; + buttonName: string; + onClick: () => void; + onClose?: () => void; +}; + +const Popup: FunctionComponent = ({ + title, + banner, + description, + buttonName, + onClick, + onClose, +}) => { + return ( +
+
+
+

{title}

+ banner +
+
+

{description}

+
+ +
+
+ {onClose && ( + + )} +
+
+ ); +}; + +export default Popup; diff --git a/components/UI/modalWallet.tsx b/components/UI/modalWallet.tsx index 0a007d7d..707cb5af 100644 --- a/components/UI/modalWallet.tsx +++ b/components/UI/modalWallet.tsx @@ -74,7 +74,7 @@ const ModalWallet: FunctionComponent = ({ >
diff --git a/components/UI/navbar.tsx b/components/UI/navbar.tsx index f7ade014..4625be9a 100644 --- a/components/UI/navbar.tsx +++ b/components/UI/navbar.tsx @@ -24,6 +24,8 @@ import ModalWallet from "./modalWallet"; import { CircularProgress } from "@mui/material"; import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import { useRouter } from "next/router"; +import theme from "../../styles/theme"; +import { FaDiscord, FaTwitter } from "react-icons/fa"; const Navbar: FunctionComponent = () => { const [nav, setNav] = useState(false); @@ -38,7 +40,6 @@ const Navbar: FunctionComponent = () => { const domain = useDomainFromAddress(address ?? "").domain; const addressOrDomain = domain && domain.endsWith(".stark") ? domain : address; - const secondary = "#f4faff"; const network = process.env.NEXT_PUBLIC_IS_TESTNET === "true" ? "testnet" : "mainnet"; const [navbarBg, setNavbarBg] = useState(false); @@ -113,14 +114,18 @@ const Navbar: FunctionComponent = () => { } function onTopButtonClick(): void { - if (available.length > 0) { - if (available.length === 1) { - connect(available[0]); + if (!isConnected) { + if (available.length > 0) { + if (available.length === 1) { + connect(available[0]); + } else { + setHasWallet(true); + } } else { setHasWallet(true); } } else { - setHasWallet(true); + setShowWallet(true); } } @@ -147,7 +152,7 @@ const Navbar: FunctionComponent = () => { return ( <> -
+
@@ -228,13 +237,13 @@ const Navbar: FunctionComponent = () => {
-
+
-
+
{
- +
-
-

- Grow your starknet profile -

-
-
+
    - +
  • setNav(false)} className={styles.menuItemSmall} > - Partnership + Quests
  • - +
  • setNav(false)} className={styles.menuItemSmall} > - Quests + Achievements
  • { onClick={() => setNav(false)} className={styles.menuItemSmall} > - My profile + My land
- -
-

- Grow you starknet profile -

-
-
- +
+
+ +
+
+
+ + + +
+
diff --git a/components/UI/nftCard.tsx b/components/UI/nftCard.tsx deleted file mode 100644 index 73f6831a..00000000 --- a/components/UI/nftCard.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { FunctionComponent, useEffect, useState } from "react"; -import styles from "../../styles/profile.module.css"; - -const NftCard: FunctionComponent = ({ title, image, url }) => { - const [imageUri, setImageUri] = useState(""); - - useEffect(() => { - if (image && image.startsWith("ipfs://")) { - setImageUri( - image.replace("ipfs://", "https://gateway.pinata.cloud/ipfs/") - ); - } else { - setImageUri(image); - } - }, [image]); - - return image ? ( -
-
- {`Image window.open(url, "_blank")} - /> -
-

{title}

-
- ) : null; -}; - -export default NftCard; diff --git a/components/UI/pieChart.tsx b/components/UI/pieChart.tsx deleted file mode 100644 index f3dcc0f0..00000000 --- a/components/UI/pieChart.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Doughnut } from "react-chartjs-2"; -import { Chart, ArcElement } from "chart.js"; -import { useAccount } from "@starknet-react/core"; -import React, { FunctionComponent, useEffect, useState } from "react"; -import styles from "../../styles/profile.module.css"; -import { useTheme } from "@mui/material/styles"; - -// initialize chart.js to create a custom pie chart -Chart.register(ArcElement); - -const PieChart: FunctionComponent = () => { - const { connector } = useAccount(); - const [braavosWallet, setBraavosWallet] = useState(null); - const [pieData, setPieData] = useState([0, 100]); - const theme = useTheme(); - - const data = { - datasets: [ - { - data: pieData, - backgroundColor: [ - theme.palette.primary.main, - theme.palette.background.default, - ], - display: true, - borderColor: theme.palette.background.default, - }, - ], - }; - - useEffect(() => { - // connector is of type Connector in starknet-react - // but _wallet which is supposed to be of type IStarknetWindowObject is set as private - if (connector && connector.id === "braavos") { - setBraavosWallet((connector as any)._wallet); - } - }, [connector]); - - useEffect(() => { - if (!braavosWallet) return; - braavosWallet - .request({ type: "wallet_getStarknetProScore" }) - .then((score: BraavosScoreProps) => { - setPieData([score.score, 100 - score.score]); - }); - }, [braavosWallet]); - - return ( - <> -
- -
-
{pieData[0]}%
-
-
-
- Your Starknet Pro Score from Braavos -
- - ); -}; - -export default PieChart; diff --git a/components/UI/profileCard.tsx b/components/UI/profileCard.tsx new file mode 100644 index 00000000..2e03aded --- /dev/null +++ b/components/UI/profileCard.tsx @@ -0,0 +1,20 @@ +import React, { FunctionComponent } from "react"; +import styles from "../../styles/profile.module.css"; + +const ProfileCard: FunctionComponent = ({ + title, + content, + isUppercase = false, +}) => { + return ( +
+

+ {title} +

+
+ {content} +
+ ); +}; + +export default ProfileCard; diff --git a/components/UI/tooltip.tsx b/components/UI/tooltip.tsx new file mode 100644 index 00000000..a9f6f757 --- /dev/null +++ b/components/UI/tooltip.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { Tooltip, TooltipProps, tooltipClasses } from "@mui/material"; + +export const CustomTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(() => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: "#191527", + borderRadius: "12px", + color: "#E1DCEA", + maxWidth: 206, + padding: 12, + }, +})); diff --git a/components/achievements/achievement.tsx b/components/achievements/achievement.tsx new file mode 100644 index 00000000..43d3b6c5 --- /dev/null +++ b/components/achievements/achievement.tsx @@ -0,0 +1,44 @@ +import React, { FunctionComponent, useMemo } from "react"; +import styles from "../../styles/achievements.module.css"; +import { + AchievementDocument, + AchievementsDocument, +} from "../../types/backTypes"; +import Level from "./level"; + +type AchievementProps = { + achievements: AchievementsDocument; + index: number; +}; + +const Achievement: FunctionComponent = ({ + achievements, + index, +}) => { + const backgroundStyle = useMemo(() => { + return { + backgroundImage: `url('/${achievements.category_img_url}')`, + backgroundPosition: `${index % 2 === 0 ? "right center" : "left center"}`, + backgroundSize: "30%", + }; + }, [index]); + return ( +
+
+
+
+
+

{achievements.category_name}

+

/{achievements.category_desc}

+
+
+ {achievements.achievements.map((achievement: AchievementDocument) => { + return ; + })} +
+
+
+ ); +}; + +export default Achievement; diff --git a/components/achievements/level.tsx b/components/achievements/level.tsx new file mode 100644 index 00000000..d5b04926 --- /dev/null +++ b/components/achievements/level.tsx @@ -0,0 +1,42 @@ +import React, { FunctionComponent } from "react"; +import styles from "../../styles/achievements.module.css"; +import { AchievementDocument } from "../../types/backTypes"; +import { CustomTooltip } from "../UI/tooltip"; + +type AchievementLevelProps = { + achievement: AchievementDocument; +}; + +const AchievementLevel: FunctionComponent = ({ + achievement, +}) => { + return ( + +
+
{achievement.title}
+
{achievement.desc}
+
+ + } + placement="bottom-end" + > +
+
+

{achievement.short_desc}

+

{achievement.name}

+
+
+ achievement level image +
+
+
+ ); +}; + +export default AchievementLevel; diff --git a/components/lands/buildingItem.tsx b/components/lands/buildingItem.tsx new file mode 100644 index 00000000..9073d3a4 --- /dev/null +++ b/components/lands/buildingItem.tsx @@ -0,0 +1,71 @@ +import { TileRect, Tileset } from "../../types/ldtk"; +import React, { ReactElement } from "react"; +import { memo, useMemo } from "react"; +import { PlaneGeometry, Texture } from "three"; +import { Coord } from "../../types/land"; + +type BuildingItemProps = { + tileset: Tileset; + pos: Coord; + tileData: TileRect; + textureLoader: Texture; + isNFT: boolean; +}; + +const BuildingItem = memo( + ({ tileset, tileData, pos, textureLoader, isNFT }): ReactElement => { + const elemTexture = useMemo(() => { + if (tileset && textureLoader) { + const localT = textureLoader.clone(); + localT.needsUpdate = true; + + const spritesPerRow = tileset.pxWid / tileset.tileGridSize; // 80 sprites per row : 1280/16 + const spritesPerColumn = tileset.pxHei / tileset.tileGridSize; // 80 sprites per column: 1280/16 + const xIndex = tileData.x / tileset.tileGridSize; + const yIndex = tileData.y / tileset.tileGridSize; + const xOffset = xIndex / spritesPerRow; + const yOffset = 1 - (yIndex + tileData.h / 16) / spritesPerColumn; // Add 1 to yIndex because the y-axis starts from the bottom, not from the top + + localT.offset.set(xOffset, yOffset); + localT.repeat.set( + 1 / (spritesPerRow / (tileData.w / tileset.tileGridSize)), + 1 / (spritesPerColumn / (tileData.h / tileset.tileGridSize)) + ); + return localT; + } + }, [textureLoader, tileset, tileData]); + + const plane = useMemo(() => { + return new PlaneGeometry(tileData.w / 16, tileData.h / 16, 1, 1); + }, [tileData.w, tileData.h]); + + return ( + <> + + + + + ); + } +); + +BuildingItem.displayName = "BuildingItem"; +export default BuildingItem; diff --git a/components/lands/buildingTooltip.tsx b/components/lands/buildingTooltip.tsx new file mode 100644 index 00000000..fb03f371 --- /dev/null +++ b/components/lands/buildingTooltip.tsx @@ -0,0 +1,24 @@ +import React, { FunctionComponent } from "react"; +import styles from "../../styles/components/tooltip.module.css"; +import { Coord } from "../../types/land"; + +type BuildingTooltipProps = { + building: BuildingsInfo | null; + pos: Coord; +}; + +const BuildingTooltip: FunctionComponent = ({ + building, + pos, +}) => { + if (!building) return null; + return ( +
+
Level {building.level}
+
{building.name}
+
{building.description}
+
+ ); +}; + +export default BuildingTooltip; diff --git a/components/lands/buildings.tsx b/components/lands/buildings.tsx new file mode 100644 index 00000000..876866c2 --- /dev/null +++ b/components/lands/buildings.tsx @@ -0,0 +1,61 @@ +import React, { FunctionComponent, ReactElement, useMemo } from "react"; +import { TextureLoader, RepeatWrapping, NearestFilter, Vector2 } from "three"; +import { BuildingTileProps } from "../../types/land"; +import BuildingItem from "./buildingItem"; +import { Tileset } from "../../types/ldtk"; +import { useLoader } from "@react-three/fiber"; + +type BuildingsProps = { + tilesets: Tileset[]; + buildingData: (BuildingTileProps | null)[][]; +}; + +const Buildings: FunctionComponent = ({ + tilesets, + buildingData, +}) => { + const buildingTexture = useMemo(() => { + const texture = useLoader( + TextureLoader, + "/land/textures/SID_BuildingSheet.png" + ); + const tileset = tilesets[2]; + texture.repeat = new Vector2(1 / tileset.__cHei, 1 / tileset.__cWid); + texture.magFilter = NearestFilter; + texture.wrapS = texture.wrapT = RepeatWrapping; + return texture; + }, [tilesets]); + + return ( + <> + {buildingTexture && + buildingData.map((tileX: (BuildingTileProps | null)[], iY: number) => { + return tileX.map((tileData: BuildingTileProps | null, iX: number) => { + if ( + tileData === null || + tileData.tile === undefined || + tileData.tile === null + ) { + return null; + } + return ( + tileset.uid === tileData.tile.tilesetUid + )[0] + } + textureLoader={buildingTexture} + tileData={tileData.tile} + pos={{ x: iX, y: iY }} + isNFT={tileData.isNFT} + /> + ); + }); + })} + + ); +}; + +export default Buildings; diff --git a/components/lands/camera.tsx b/components/lands/camera.tsx new file mode 100644 index 00000000..224f609e --- /dev/null +++ b/components/lands/camera.tsx @@ -0,0 +1,119 @@ +import React, { useRef, FunctionComponent, useEffect } from "react"; +import { useThree, useFrame } from "@react-three/fiber"; +import { PerspectiveCamera } from "@react-three/drei"; +import { Vector2, Vector3 } from "three"; +import { CityCenterProps } from "../../types/land"; + +type CameraProps = { + aspect: number; + mouseRightPressed?: number; + mouseWheelProp?: number; + index: number; + isFirstTouch: boolean; + cityCenter: CityCenterProps; + isMobile: boolean; +}; + +export const Camera: FunctionComponent = ({ + aspect, + mouseRightPressed, + index, + isFirstTouch, + cityCenter, + isMobile, +}) => { + const set = useThree(({ set }) => set); + const size = useThree(({ size }) => size); + const cameraRef = useRef(null); + const tempMouseRef = useRef(new Vector2(0, 0)); + const mouseMoveRef = useRef(new Vector2(0, 0)); + const cameraPosRef = useRef( + new Vector3( + isMobile ? cityCenter.center.x : cityCenter.boundaries.maxX - 8, + cityCenter.center.y, + cityCenter.center.y + ) + ); + + useEffect(() => { + cameraPosRef.current.setY(15 * index); + }, [index]); + + useFrame(({ mouse }) => { + if ( + cameraRef.current != null && + tempMouseRef.current != null && + mouseMoveRef.current != null && + cameraPosRef.current != null + ) { + if (mouseRightPressed == 1) { + mouseMoveRef.current.set(0, 0); + let difX = isFirstTouch ? 0 : (tempMouseRef.current.x - mouse.x) * 100; + let difY = isFirstTouch ? 0 : (tempMouseRef.current.y - mouse.y) * 100; + + if (difX < 0) difX = difX * -1; + if (difY < 0) difY = difY * -1; + + if (tempMouseRef.current.x < mouse.x) { + if (cameraPosRef.current.x > cityCenter.boundaries.minX) { + mouseMoveRef.current.setX(0.1 * difX); + cameraPosRef.current.setX( + cameraPosRef.current.x - mouseMoveRef.current.x + ); + } + } else if (tempMouseRef.current.x > mouse.x) { + if (cameraPosRef.current.x < cityCenter.boundaries.maxX) { + mouseMoveRef.current.setX(0.1 * difX); + cameraPosRef.current.setX( + cameraPosRef.current.x + mouseMoveRef.current.x + ); + } + } else if (tempMouseRef.current.x == mouse.x) { + mouseMoveRef.current.x = 0; + } + if (tempMouseRef.current.y < mouse.y) { + if (cameraPosRef.current.z < cityCenter.boundaries.maxY) { + mouseMoveRef.current.setY(0.1 * difY); + cameraPosRef.current.setZ( + cameraPosRef.current.z + mouseMoveRef.current.y + ); + } + } else if (tempMouseRef.current.y > mouse.y) { + if (cameraPosRef.current.z > cityCenter.boundaries.minY) { + mouseMoveRef.current.setY(0.1 * difY); + cameraPosRef.current.setZ( + cameraPosRef.current.z - mouseMoveRef.current.y + ); + } + } else if (tempMouseRef.current.y == mouse.y) { + mouseMoveRef.current.setY(0); + } + } + tempMouseRef.current.set(mouse.x, mouse.y); + + cameraRef.current.aspect = size.width / size.height; + cameraRef.current.position.set( + cameraPosRef.current.x, + cameraPosRef.current.y, + cameraPosRef.current.z + ); + cameraRef.current.updateProjectionMatrix(); + } + }); + + useEffect(() => { + set({ camera: cameraRef.current as THREE.PerspectiveCamera }); + }, []); + + return ( + + ); +}; diff --git a/components/lands/ground.tsx b/components/lands/ground.tsx new file mode 100644 index 00000000..12e91b4b --- /dev/null +++ b/components/lands/ground.tsx @@ -0,0 +1,59 @@ +import React, { FunctionComponent, ReactElement, useMemo } from "react"; +import { + TextureLoader, + RepeatWrapping, + NearestFilter, + Vector2, + PlaneGeometry, +} from "three"; +import GroundItem from "./groundItem"; +import { GroundTileProps } from "../../types/land"; +import { useLoader } from "@react-three/fiber"; +import { Tileset } from "../../types/ldtk"; + +type GroundProps = { + tileset: Tileset; + cityData: (GroundTileProps | null)[][]; +}; + +const Ground: FunctionComponent = ({ tileset, cityData }) => { + const groundTexture = useMemo(() => { + const textObj = useLoader( + TextureLoader, + "/land/textures/SIDCity_TilesetSheet.png" + ); + textObj.repeat = new Vector2(1 / tileset.__cHei, 1 / tileset.__cWid); + textObj.magFilter = NearestFilter; + textObj.wrapS = textObj.wrapT = RepeatWrapping; + return textObj; + }, [tileset]); + + const plane = useMemo(() => { + return new PlaneGeometry(1, 1, 1, 1); + }, []); + + return ( + <> + {groundTexture && + cityData.map((tileX: (GroundTileProps | null)[], iY: number) => { + return tileX.map((tileData: GroundTileProps | null, iX: number) => { + if (tileData === null || tileData.tileId === undefined) { + return null; + } + return ( + + ); + }); + })} + + ); +}; + +export default Ground; diff --git a/components/lands/groundItem.tsx b/components/lands/groundItem.tsx new file mode 100644 index 00000000..cd797465 --- /dev/null +++ b/components/lands/groundItem.tsx @@ -0,0 +1,55 @@ +import { GroundTileProps, Coord } from "../../types/land"; +import React, { ReactElement, memo, useMemo } from "react"; +import { MeshPhongMaterial, PlaneGeometry, Texture } from "three"; +import { Tileset } from "../../types/ldtk"; + +type GroundItemsProps = { + tileset: Tileset; + pos: Coord; + tileData: GroundTileProps; + groundTexture: Texture; + plane: PlaneGeometry; +}; + +const GroundItem = memo( + ({ tileset, tileData, pos, groundTexture, plane }): ReactElement => { + const itemTexture = useMemo(() => { + if (tileset && groundTexture) { + const localT = groundTexture.clone(); + localT.needsUpdate = true; + + const spritesPerRow = tileset.pxWid / tileset.tileGridSize; // 40 sprites per row : 640/16 + const spritesPerColumn = tileset.pxHei / tileset.tileGridSize; // 40 sprites per column: 640/16 + const xIndex = tileData.tileId % spritesPerRow; + const yIndex = Math.floor(tileData.tileId / spritesPerColumn); + // Texture coordinates are normalized between 0 and 1. We divide by the number of sprites per row or column to get the offset. + const xOffset = xIndex / spritesPerRow; + const yOffset = 1 - (yIndex / spritesPerColumn + 1 / spritesPerColumn); + localT.offset.set(xOffset, yOffset); + return localT; + } + }, [groundTexture, tileset, tileData]); + + const material = useMemo(() => { + return new MeshPhongMaterial({ + map: itemTexture, + transparent: true, + }); + }, [itemTexture]); + + return ( + + ); + } +); + +GroundItem.displayName = "GroundItem"; +export default GroundItem; diff --git a/components/lands/land.tsx b/components/lands/land.tsx new file mode 100644 index 00000000..f2d35be1 --- /dev/null +++ b/components/lands/land.tsx @@ -0,0 +1,187 @@ +import React, { useEffect, useState } from "react"; +import { Scene } from "./scene"; +import { memberSince } from "../../utils/profile"; +import styles from "../../styles/profile.module.css"; +import landStyles from "../../styles/components/land.module.css"; +import Button from "../UI/button"; +import { SoloBuildings, StarkFighterBuildings } from "../../constants/nft"; +import { AchievementsDocument } from "../../types/backTypes"; + +type LandProps = { + address: string; + isOwner: boolean; + isMobile: boolean; + setSinceDate: (s: string | null) => void; + setTotalNfts: (nb: number) => void; + setAchievementCount: (n: number) => void; +}; + +export const Land = ({ + address, + isOwner, + isMobile, + setSinceDate, + setTotalNfts, + setAchievementCount, +}: LandProps) => { + const [userNft, setUserNft] = useState(); + const [hasNFTs, setHasNFTs] = useState(false); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + if (address) { + retrieveAssets( + `https://${ + process.env.NEXT_PUBLIC_IS_TESTNET === "true" ? "api-testnet" : "api" + }.starkscan.co/api/v0/nfts?owner_address=${address}` + ).then((data) => { + filterAssets(data.data); + }); + } + }, [address]); + + // Retrieve assets from Starkscan API + const retrieveAssets = async ( + url: string, + accumulatedAssets: StarkscanNftProps[] = [] + ): Promise => { + return fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-api-key": `${process.env.NEXT_PUBLIC_STARKSCAN}`, + }, + }) + .then((res) => res.json()) + .then((data) => { + const assets = [...accumulatedAssets, ...data.data]; + if (data.next_url) { + return retrieveAssets(data.next_url, assets); + } else { + return { + data: assets, + }; + } + }); + }; + + // Fetch achievements from database and add building id from highest achievement level + const getBuildingsFromAchievements = async (filteredAssets: number[]) => { + let count = 0; + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_LINK}/achievements/fetch?addr=${address}` + ); + const results: AchievementsDocument[] = await response.json(); + if (results) { + results.forEach((result: AchievementsDocument) => { + for (let i = result.achievements.length - 1; i >= 0; i--) { + if (result.achievements[i].completed) { + filteredAssets.push(result.achievements[i].id); + if (i === result.achievements.length - 1) count++; + if (result.category_type === "levels") break; + } + } + }); + } + setAchievementCount(count); + } catch (error) { + console.error("An error occurred while fetching achievements", error); + } + }; + + // Fetch buildings info (name, desc, img) from database + const getBuildingsInfo = async (filteredAssets: number[]) => { + try { + const response = await fetch( + `${ + process.env.NEXT_PUBLIC_API_LINK + }/achievements/fetch_buildings?ids=${filteredAssets.join(",")}` + ); + const results: BuildingsInfo[] = await response.json(); + if (results && results.length > 0) { + setUserNft(results); + setHasNFTs(true); + } else setHasNFTs(false); + } catch (error) { + console.error("An error occurred while fetching buildings info", error); + } + }; + + // Filter assets received from Starkscan API & filter solo buildings represented on the land + const filterAssets = async (assets: StarkscanNftProps[]) => { + const filteredAssets: number[] = []; + const starkFighter: number[] = []; + let sinceDate = 0; + let nftCounter = 0; + + assets.forEach((asset: StarkscanNftProps) => { + if (asset.minted_at_timestamp < sinceDate || sinceDate === 0) + sinceDate = asset.minted_at_timestamp; + + if ( + asset.contract_address === process.env.NEXT_PUBLIC_QUEST_NFT_CONTRACT + ) { + nftCounter++; + + if (asset.name && Object.values(SoloBuildings).includes(asset.name)) { + filteredAssets.push( + SoloBuildings[asset.name as keyof typeof SoloBuildings] + ); + } + if ( + asset.name && + Object.values(StarkFighterBuildings).includes( + asset.name.toLowerCase() + ) + ) { + starkFighter.push( + StarkFighterBuildings[ + asset.name.toLowerCase() as keyof typeof StarkFighterBuildings + ] + ); + } + } + }); + // get starkfighter highest level + if (starkFighter.length > 0) { + const highestValue = Math.max( + ...starkFighter.filter((x) => x >= 64005 && x <= 64007) + ); + filteredAssets.push(highestValue); + } + + await getBuildingsFromAchievements(filteredAssets); + await getBuildingsInfo(filteredAssets); + + setIsReady(true); + setTotalNfts(nftCounter); + setSinceDate(memberSince(sinceDate)); + }; + + return ( +
+ {isReady ? ( + userNft && hasNFTs ? ( + + ) : ( +
+

+ {isOwner ? "You have" : "User has"} not fulfilled any achievement + yet +

+ {isOwner ? ( +
+ +
+ ) : null} +
+ ) + ) : ( +

Loading

+ )} +
+ ); +}; diff --git a/components/lands/map.tsx b/components/lands/map.tsx new file mode 100644 index 00000000..f0276865 --- /dev/null +++ b/components/lands/map.tsx @@ -0,0 +1,85 @@ +import React, { FunctionComponent } from "react"; +import { LdtkReader } from "../../utils/parser"; +import Ground from "./ground"; +import RoadProps from "./roadProps"; +import Buildings from "./buildings"; +import { tileTypes } from "../../constants/tiles"; +import { useFrame, useThree } from "@react-three/fiber"; +import { iLDtk } from "../../types/ldtk"; + +type MapProps = { + mapReader: LdtkReader; + data: iLDtk; + updateBuildingRef: (newBuilding: BuildingsInfo | null) => void; +}; + +export const Map: FunctionComponent = ({ + mapReader, + data, + updateBuildingRef, +}) => { + const { scene } = useThree(); + + // Raycaster to detect if we're hovering a building + useFrame(({ mouse, raycaster }) => { + const intersects = raycaster.intersectObjects(scene.children, true); + + let buildingData = null; + let tooltipPosition = null; + + // Loop through all intersected objects + for (let i = 0; i < intersects.length; i++) { + const object = intersects[i].object; + + // If the object's name includes "isNFT", process it + if (object.name.includes("isNFT")) { + const point = intersects[i].point; + if (point.y > -1 && point.x > 1 && point.z > 1) { + const rayX = parseInt(point.x.toFixed(2)); + const rayY = parseInt(point.z.toFixed(2)); + // Find the building data based on the intersected object + buildingData = mapReader.userNft.find( + (nft) => nft.entity === mapReader.buildingTiles[rayY + 1][rayX]?.ref + ); + + if (buildingData) { + tooltipPosition = mouse; // send mouse position to place tooltip + break; // Break the loop as we've found our object and 2 buildings cannot be on top of one another + } + } + } + } + if (buildingData && tooltipPosition) { + updateBuildingRef({ + ...buildingData, + pos: tooltipPosition, + }); + } else { + updateBuildingRef(null); + } + }); + + return ( + <> + {mapReader.groundTiles ? ( + + ) : null} + {mapReader.roadProps ? ( + + ) : null} + {mapReader.buildingTiles ? ( + + ) : null} + + ); +}; diff --git a/components/lands/roadItem.tsx b/components/lands/roadItem.tsx new file mode 100644 index 00000000..9bca6414 --- /dev/null +++ b/components/lands/roadItem.tsx @@ -0,0 +1,75 @@ +import { propsOffset, OffsetKey } from "../../constants/tiles"; +import { Coord, RoadObjects, TileData } from "../../types/land"; +import React, { ReactElement, memo, useMemo, useState } from "react"; +import { PlaneGeometry, Texture } from "three"; +import { Tileset } from "../../types/ldtk"; + +type RoadItemProps = { + tileset: Tileset; + pos: Coord; + buildingTexture: Texture; + propData: RoadObjects; + tileData: TileData; + plane: PlaneGeometry; +}; + +const RoadItem = memo( + ({ + tileset, + pos, + buildingTexture, + propData, + tileData, + plane, + }): ReactElement => { + const [offset, setOffset] = useState<{ x: number; y: number; z: number }>({ + x: 0, + y: 0, + z: 0, + }); + + const elemTexture = useMemo(() => { + if (tileset && buildingTexture) { + const localT = buildingTexture.clone(); + localT.needsUpdate = true; + localT.offset.set(tileData.textureOffset.x, tileData.textureOffset.y); + localT.repeat.set(tileData.textureRepeat.x, tileData.textureRepeat.y); + + const entityOffsets = propsOffset[tileData.entity.identifier]; + const offset = entityOffsets + ? entityOffsets[propData.corner as OffsetKey] + : undefined; + if (offset) setOffset(offset); + + return localT; + } + }, [buildingTexture, tileset]); + + return ( + + + + ); + } +); + +RoadItem.displayName = "RoadItem"; +export default RoadItem; diff --git a/components/lands/roadProps.tsx b/components/lands/roadProps.tsx new file mode 100644 index 00000000..935c9f62 --- /dev/null +++ b/components/lands/roadProps.tsx @@ -0,0 +1,83 @@ +import React, { FunctionComponent, ReactElement, useMemo } from "react"; +import { + TextureLoader, + RepeatWrapping, + NearestFilter, + Vector2, + PlaneGeometry, +} from "three"; +import { RoadObjects, TileData } from "../../types/land"; +import { Tileset } from "../../types/ldtk"; +import { useLoader } from "@react-three/fiber"; +import RoadItem from "./roadItem"; + +type RoadItemsProps = { + tileset: Tileset; + cityData: (RoadObjects | null)[][]; + tileData: TileData[]; +}; + +const RoadProps: FunctionComponent = ({ + tileset, + cityData, + tileData, +}) => { + const buildingTexture = useMemo(() => { + const texture = useLoader( + TextureLoader, + "/land/textures/SID_BuildingSheet.png" + ); + texture.repeat = new Vector2(1 / tileset.__cHei, 1 / tileset.__cWid); + texture.magFilter = NearestFilter; + texture.wrapS = texture.wrapT = RepeatWrapping; + + return texture; + }, []); + + // Reuse same planes for props + const simplePlane = useMemo(() => { + // used for bench, sewerPlate & firehydrant props + return new PlaneGeometry(1, 1, 1, 1); + }, []); + + const streetLightPlane = useMemo(() => { + return new PlaneGeometry(1, 2, 1, 1); + }, []); + + const treePlane = useMemo(() => { + return new PlaneGeometry(2, 2, 1, 1); + }, []); + + return ( + <> + {buildingTexture && + tileset && + cityData.map((tileX: (RoadObjects | null)[], iY: number) => { + return tileX.map((elem: RoadObjects | null, iX: number) => { + if (elem === null) { + return null; + } + return ( + + ); + }); + })} + + ); +}; + +export default RoadProps; diff --git a/components/lands/scene.tsx b/components/lands/scene.tsx new file mode 100644 index 00000000..bc05939e --- /dev/null +++ b/components/lands/scene.tsx @@ -0,0 +1,141 @@ +import { Canvas } from "@react-three/fiber"; +import React, { FunctionComponent, useEffect, useRef, useState } from "react"; +import { NoToneMapping } from "three"; +import { Camera } from "./camera"; +import { LdtkReader } from "../../utils/parser"; +import { iLDtk } from "../../types/ldtk"; +import { useGesture } from "@use-gesture/react"; +import ZoomSlider from "./zoomSlider"; +import { Map } from "./map"; +import BuildingTooltip from "./buildingTooltip"; + +type SceneProps = { + address: string; + userNft: BuildingsInfo[]; + isMobile: boolean; +}; + +export const Scene: FunctionComponent = ({ + address, + userNft, + isMobile, +}) => { + const maxZoom = isMobile ? 50 : 30; + const indexRef = useRef(); + const [index, setIndex] = useState(maxZoom); + indexRef.current = index; + const [mouseRightPressed, setMouseRightPressed] = useState(0); + const [isFirstTouch, setIsFirstTouch] = useState(false); + const [data, setData] = useState(); + const citySize = 100; + const [mapReader, setMapReader] = useState(null); + const [showTooltip, setShowTooltip] = useState(false); + const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); + const [activeBuilding, setActiveBuilding] = useState( + null + ); + + useEffect(() => { + if (data) { + const mapReader = new LdtkReader(data, address, citySize, userNft); + mapReader.CreateMap(); // init land data + setMapReader(mapReader); + } + }, [data]); + + useEffect(() => { + fetch("/land/data/SIDCity_Base.json") + .then((response) => { + if (!response.ok) { + throw new Error(response.statusText); + } + return response.json(); + }) + .then((jsonData) => { + setData(jsonData); + }) + .catch((error) => console.error("Error:", error)); + }, []); + + const bind = useGesture({ + onDrag: ({ first, down, pinching, cancel }) => { + if (first) setIsFirstTouch(true); + else setIsFirstTouch(false); + if (pinching) return cancel(); + if (down) { + setMouseRightPressed(1); + } else { + setMouseRightPressed(0); + } + }, + }); + + const updateZoomIndex = (newValue: number) => { + setIndex(() => newValue); + }; + + const updateBuildingRef = (newBuilding: BuildingsInfo | null) => { + if ( + (!newBuilding && activeBuilding) || + (newBuilding && !activeBuilding) || + newBuilding?.name !== activeBuilding?.name + ) { + setActiveBuilding(newBuilding); + if (newBuilding) { + setShowTooltip(true); + if (newBuilding.pos) { + let posX = ((newBuilding.pos.x + 1) / 2) * window.innerWidth; + const posY = (-(newBuilding.pos.y - 1) / 2) * window.innerHeight; + // If the building is too close to the profile menu, move it to the left + if (posX > window.innerWidth - 475) { + posX = posX - 285; + } + setMousePosition({ x: posX, y: posY }); + } + } else setShowTooltip(false); + } + }; + + return ( + <> + { + event.preventDefault(); + }} + style={{ touchAction: "none" }} + > + {mapReader ? ( + <> + + + {mapReader.cityCenter ? ( + + ) : null} + {data && mapReader ? ( + + ) : null} + + ) : null} + + + {showTooltip ? ( + + ) : null} + + ); +}; diff --git a/components/lands/zoomSlider.tsx b/components/lands/zoomSlider.tsx new file mode 100644 index 00000000..6407094d --- /dev/null +++ b/components/lands/zoomSlider.tsx @@ -0,0 +1,50 @@ +import { Slider } from "@mui/material"; +import React, { FunctionComponent, useMemo, useState } from "react"; + +type ZoomSliderProps = { + updateZoomIndex: (newValue: number) => void; + maxValue: number; +}; + +const ZoomSlider: FunctionComponent = ({ + updateZoomIndex, + maxValue, +}) => { + const minValue = 8; + const [value, setValue] = useState(minValue); + const zoomStyles = useMemo(() => { + return { + color: "#E1DCEA", + height: 2, + width: "80%", + maxWidth: "850px", + margin: "auto", + position: "absolute", + left: "50%", + transform: "translate(-50%, 0)", + bottom: "40px", + }; + }, []); + + const handleChange = (_event: Event, newValue: number | number[]) => { + if (typeof newValue === "number" && newValue !== value) { + updateZoomIndex(maxValue + minValue - newValue); + setValue(newValue); + } + }; + + return ( + + ); +}; + +export default ZoomSlider; diff --git a/components/pages/home/homeControls.tsx b/components/pages/home/homeControls.tsx new file mode 100644 index 00000000..de9686e8 --- /dev/null +++ b/components/pages/home/homeControls.tsx @@ -0,0 +1,13 @@ +import React, { FunctionComponent } from "react"; +import styles from "../../../styles/Home.module.css"; + +const HomeControls: FunctionComponent = () => { + return ( +
+ + +
+ ); +}; + +export default HomeControls; diff --git a/components/pages/home/howToParticipate.tsx b/components/pages/home/howToParticipate.tsx index d623b51a..ee0993d3 100644 --- a/components/pages/home/howToParticipate.tsx +++ b/components/pages/home/howToParticipate.tsx @@ -1,16 +1,14 @@ -import React from "react"; +import React, { FunctionComponent } from "react"; import Steps from "../../UI/steps/steps"; import CategoryTitle from "../../UI/titles/categoryTitle"; -import Crosses from "../../shapes/crosses"; import styles from "../../../styles/components/pages/home/howToParticipate.module.css"; -const HowToParticipate = () => { +const HowToParticipate: FunctionComponent = () => { return (
{ }, ]} /> -
); diff --git a/components/pages/home/questCategories.tsx b/components/pages/home/questCategories.tsx new file mode 100644 index 00000000..fde0b2f7 --- /dev/null +++ b/components/pages/home/questCategories.tsx @@ -0,0 +1,31 @@ +import React, { FunctionComponent } from "react"; +import styles from "../../../styles/Home.module.css"; +import QuestCategory from "../../quests/questCategory"; +import QuestsSkeleton from "../../skeletons/questsSkeleton"; + +type QuestCategoriesProps = { + categories: QuestCategory[]; +}; + +const QuestCategories: FunctionComponent = ({ + categories, +}) => { + return ( + <> +

Accomplish your Starknet Quests

+
+
+ {categories ? ( + categories.map((category) => { + return ; + }) + ) : ( + + )} +
+
+ + ); +}; + +export default QuestCategories; diff --git a/components/pages/home/trending.tsx b/components/pages/home/trending.tsx new file mode 100644 index 00000000..8dabf26c --- /dev/null +++ b/components/pages/home/trending.tsx @@ -0,0 +1,44 @@ +import React, { FunctionComponent } from "react"; +import styles from "../../../styles/Home.module.css"; +import Quest from "../../quests/quest"; +import QuestsSkeleton from "../../skeletons/questsSkeleton"; +import { useRouter } from "next/router"; +import { QuestDocument } from "../../../types/backTypes"; + +type TrendingQuestsProps = { + trendingQuests: QuestDocument[]; +}; + +const TrendingQuests: FunctionComponent = ({ + trendingQuests, +}) => { + const router = useRouter(); + return ( + <> +

Trending quests

+
+ {trendingQuests ? ( + trendingQuests.map((quest) => { + return ( + router.push(`/quest/${quest.id}`)} + imgSrc={quest.img_card} + issuer={{ + name: quest.issuer, + logoFavicon: quest.logo, + }} + reward={quest.rewards_title} + /> + ); + }) + ) : ( + + )} +
+ + ); +}; + +export default TrendingQuests; diff --git a/components/quests/featuredQuest.tsx b/components/quests/featuredQuest.tsx index e8695ebd..a0f69e3c 100644 --- a/components/quests/featuredQuest.tsx +++ b/components/quests/featuredQuest.tsx @@ -26,17 +26,17 @@ const FeaturedQuest: FunctionComponent = ({ }) => { const isSmallScreen = useMediaQuery("(max-width: 1024px)"); - return onClick && !isSmallScreen ? ( + return onClick ? (
-

Featured

+

Featured

{title}

{desc}

{reward}

-
+
diff --git a/components/quests/nftDisplay.tsx b/components/quests/nftDisplay.tsx index 73128654..878701bf 100644 --- a/components/quests/nftDisplay.tsx +++ b/components/quests/nftDisplay.tsx @@ -1,6 +1,6 @@ import React, { FunctionComponent } from "react"; -import styles from "../../styles/quests.module.css"; import NftIssuer from "./nftIssuer"; +import NftImage from "./nftImage"; type NftDisplayProps = { nfts: Nft[]; @@ -11,19 +11,7 @@ const NftDisplay: FunctionComponent = ({ nfts, issuer }) => { return (
-
- {nfts.map((nft, index) => ( -
- - {nft.level && nfts.length > 1 ? ( -

Level {nft.level}

- ) : null} -
- ))} -
+
); }; diff --git a/components/quests/nftImage.tsx b/components/quests/nftImage.tsx new file mode 100644 index 00000000..c4006b4d --- /dev/null +++ b/components/quests/nftImage.tsx @@ -0,0 +1,23 @@ +import React, { FunctionComponent } from "react"; +import styles from "../../styles/quests.module.css"; + +type NftImageProps = { + nfts: Nft[]; +}; + +const NftImage: FunctionComponent = ({ nfts }) => { + return ( +
+ {nfts.map((nft, index) => ( +
+ + {nft.level && nfts.length > 1 ? ( +

Level {nft.level}

+ ) : null} +
+ ))} +
+ ); +}; + +export default NftImage; diff --git a/components/quests/quest.tsx b/components/quests/quest.tsx index 0328dcbf..be988033 100644 --- a/components/quests/quest.tsx +++ b/components/quests/quest.tsx @@ -17,19 +17,21 @@ const Quest: FunctionComponent = ({ reward, }) => { return ( -
- -
-

{title}

-
-

{issuer.name}

-
-
- -

{reward}

+ <> +
+ +
+

{title}

+
+

{issuer.name}

+
+
+ +

{reward}

+
-
+ ); }; diff --git a/components/quests/questCategory.tsx b/components/quests/questCategory.tsx new file mode 100644 index 00000000..1fdfc35f --- /dev/null +++ b/components/quests/questCategory.tsx @@ -0,0 +1,25 @@ +import React, { FunctionComponent } from "react"; +import styles from "../../styles/Home.module.css"; +import Link from "next/link"; + +type QuestCategoryProps = { + category: QuestCategory; +}; + +const QuestCategory: FunctionComponent = ({ category }) => { + return ( + +
+
+

{category.name} Quest

+

+ {category.questNumber} quest{category.questNumber > 1 ? "s" : null} +

+
+ +
+ + ); +}; + +export default QuestCategory; diff --git a/components/quests/questDetails.tsx b/components/quests/questDetails.tsx new file mode 100644 index 00000000..e84f28d3 --- /dev/null +++ b/components/quests/questDetails.tsx @@ -0,0 +1,323 @@ +import React, { + ReactNode, + useEffect, + useState, + FunctionComponent, +} from "react"; +import styles from "../../styles/quests.module.css"; +import Task from "./task"; +import Reward from "./reward"; +import quests_nft_abi from "../../abi/quests_nft_abi.json"; +import { useAccount, useProvider } from "@starknet-react/core"; +import { hexToDecimal } from "../../utils/feltService"; +import { + NFTItem, + QueryError, + QuestDocument, + UserTask, +} from "../../types/backTypes"; +import { Call, Contract } from "starknet"; +import { Skeleton } from "@mui/material"; +import TasksSkeleton from "../skeletons/tasksSkeleton"; +import { generateCodeChallenge } from "../../utils/codeChallenge"; +import Timer from "./timer"; +import NftImage from "./nftImage"; +import { splitByNftContract } from "../../utils/rewards"; + +type QuestDetailsProps = { + quest: QuestDocument; + taskId?: string; + res?: string; + errorMsg?: string; +}; + +const QuestDetails: FunctionComponent = ({ + quest, + taskId, + res, + errorMsg, +}) => { + const { address } = useAccount(); + const { provider } = useProvider(); + const [tasks, setTasks] = useState([]); + const [rewardsEnabled, setRewardsEnabled] = useState(false); + const [eligibleRewards, setEligibleRewards] = useState< + Record + >({}); + const [unclaimedRewards, setUnclaimedRewards] = useState< + EligibleReward[] | undefined + >(); + const [mintCalldata, setMintCalldata] = useState(); + const [taskError, setTaskError] = useState(); + const [showQuiz, setShowQuiz] = useState(); + const questId = quest.id.toString(); + + // this fetches all tasks of this quest from db + useEffect(() => { + if (questId) { + // If a call was made with an address in the first second, the call with 0 address should be cancelled + let shouldFetchWithZeroAddress = true; + + // Set a 1-second timer to allow time for address loading + const timer = setTimeout(() => { + // If address isn't loaded after 1 second, make the API call with the zero address + if (shouldFetchWithZeroAddress) { + fetch( + `${process.env.NEXT_PUBLIC_API_LINK}/get_tasks?quest_id=${questId}&addr=0` + ) + .then((response) => response.json()) + .then((data: UserTask[] | QueryError) => { + if ((data as UserTask[]).length) setTasks(data as UserTask[]); + }); + } + }, 1000); + + // If the address is loaded before the 1-second timer, make the API call with the loaded address + if (address) { + shouldFetchWithZeroAddress = false; + clearTimeout(timer); + fetch( + `${ + process.env.NEXT_PUBLIC_API_LINK + }/get_tasks?quest_id=${questId}&addr=${hexToDecimal(address)}` + ) + .then((response) => response.json()) + .then((data: UserTask[] | QueryError) => { + if ((data as UserTask[]).length) setTasks(data as UserTask[]); + }); + } + + // Clear the timer when component unmounts or dependencies change to prevent memory leaks + return () => { + clearTimeout(timer); + }; + } + }, [questId, address]); + + const refreshRewards = ( + quest: QuestDocument, + address: string | undefined + ) => { + if (address && quest.rewards_endpoint) { + fetch( + `${process.env.NEXT_PUBLIC_API_LINK}/${ + quest.rewards_endpoint + }?addr=${hexToDecimal(address)}` + ) + .then((response) => response.json()) + .then((data) => { + if (data.rewards) { + setEligibleRewards(splitByNftContract(data.rewards)); + } + }); + } + }; + + // this fetches all rewards claimable by the user + useEffect(() => { + refreshRewards(quest, address); + }, [quest, address]); + + // this filters the claimable rewards to find only the unclaimed ones (on chain) + useEffect(() => { + (async () => { + let unclaimed: EligibleReward[] = []; + for (const contractAddr in eligibleRewards) { + const perContractRewards = eligibleRewards[contractAddr]; + const calldata = []; + for (const reward of perContractRewards) { + calldata.push({ + quest_id: questId as string, + task_id: reward.task_id.toString(), + user_addr: address as string, + }); + } + const contract = new Contract(quests_nft_abi, contractAddr, provider); + + const response = await contract.call("get_tasks_status", [calldata]); + if ( + typeof response === "object" && + response !== null && + !Array.isArray(response) + ) { + const status = response["status"]; + + if (Array.isArray(status)) { + const result = status.map((x: any) => { + if (typeof x === "bigint") { + return Number(x); + } + }); + const unclaimedPerContractRewards = perContractRewards.filter( + (_, index) => result[index] === 0 + ); + unclaimed = unclaimed.concat(unclaimedPerContractRewards); + } + } + } + setUnclaimedRewards(unclaimed); + })(); + }, [questId, eligibleRewards]); + + // this builds multicall for minting rewards + useEffect(() => { + const calldata: Call[] = []; + + // if the sequencer query failed, let's consider the eligible as unclaimed + const to_claim = + unclaimedRewards === undefined + ? ([] as EligibleReward[]).concat(...Object.values(eligibleRewards)) + : unclaimedRewards; + + to_claim.forEach((reward) => { + calldata.push({ + contractAddress: reward.nft_contract, + entrypoint: "mint", + calldata: [ + reward.token_id, + 0, + questId as string, + reward.task_id, + reward.sig[0], + reward.sig[1], + ], + }); + }); + + if (to_claim?.length > 0) { + setRewardsEnabled(true); + } else setRewardsEnabled(false); + setMintCalldata(calldata); + }, [questId, unclaimedRewards, eligibleRewards]); + + useEffect(() => { + if (!taskId || res === "true") return; + if (taskId && res === "false") { + setTaskError({ + taskId: parseInt(taskId.toString()), + res: false, + error: errorMsg?.toString(), + }); + } + }, [taskId, res, errorMsg]); + + const generateOAuthUrl = (task: UserTask): string => { + if (task.verify_endpoint_type === "oauth_discord") { + const rootUrl = "https://discord.com/api/oauth2/authorize"; + const options = { + redirect_uri: `${process.env.NEXT_PUBLIC_API_LINK}/${task.verify_endpoint}`, + client_id: process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID as string, + response_type: "code", + scope: ["identify", "guilds"].join(" "), + state: hexToDecimal(address), + }; + const qs = new URLSearchParams(options).toString(); + return `${rootUrl}?${qs}`; + } else { + const codeChallenge = generateCodeChallenge( + process.env.NEXT_PUBLIC_TWITTER_CODE_VERIFIER as string + ); + const rootUrl = "https://twitter.com/i/oauth2/authorize"; + const options = { + redirect_uri: `${task.verify_endpoint}?addr=${hexToDecimal(address)}`, + client_id: process.env.NEXT_PUBLIC_TWITTER_CLIENT_ID as string, + state: "state", + response_type: "code", + code_challenge: codeChallenge, + code_challenge_method: "S256", + scope: ["follows.read", "tweet.read", "users.read"].join(" "), + }; + const qs = new URLSearchParams(options).toString(); + return `${rootUrl}?${qs}`; + } + }; + + return ( + <> + { + return { imgSrc: nft.img, level: nft.level }; + })} + /> + +
+ {quest.name === "loading" ? ( + + ) : ( +

{quest.name}

+ )} + {quest.desc === "loading" ? ( + + ) : ( +

{quest.desc}

+ )} +
+ {quest?.expiry_timestamp && quest?.expiry_timestamp !== "loading" ? ( + + ) : null} +
+ {tasks.length === 0 || quest.rewards_title === "loading" ? ( + + ) : ( + <> + {tasks.map((task) => { + return ( + refreshRewards(quest, address)} + wasVerified={task.completed} + hasError={Boolean(taskError && taskError.taskId === task.id)} + verifyError={ + taskError && taskError.taskId === task.id + ? taskError.error + : "" + } + setShowQuiz={setShowQuiz} + quizName={task.quiz_name || undefined} + issuer={{ + name: quest.issuer, + logoFavicon: quest.logo, + }} + /> + ); + })} + { + setRewardsEnabled(false); + }} + disabled={!rewardsEnabled} + mintCalldata={mintCalldata} + /> + + )} +
+ {showQuiz} + + ); +}; + +export default QuestDetails; diff --git a/components/quests/task.tsx b/components/quests/task.tsx index 09c7e368..2d98eeef 100644 --- a/components/quests/task.tsx +++ b/components/quests/task.tsx @@ -4,12 +4,11 @@ import { CheckCircle as CheckCircleIcon, ErrorRounded as ErrorRoundedIcon, } from "@mui/icons-material"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; import Button from "../UI/button"; import { CircularProgress } from "@mui/material"; import { useAccount } from "@starknet-react/core"; import Quiz from "../quiz/quiz"; +import ArrowRightIcon from "../UI/iconsComponents/icons/arrowRightIcon"; const Task: FunctionComponent = ({ name, @@ -58,7 +57,9 @@ const Task: FunctionComponent = ({ setIsLoading(false); } else { try { - const response = await fetch(verifyEndpoint); + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_LINK}/${verifyEndpoint}` + ); if (!response.ok) { throw new Error(await response.text()); @@ -131,12 +132,10 @@ const Task: FunctionComponent = ({ className={styles.taskTitle} onClick={() => setIsClicked(!isClicked)} > -
- {isClicked ? ( - - ) : ( - - )} +
+
+ +

{name}

{isVerified ? ( diff --git a/components/quiz/quiz.tsx b/components/quiz/quiz.tsx index a85b1ae5..4a2cf6c1 100644 --- a/components/quiz/quiz.tsx +++ b/components/quiz/quiz.tsx @@ -37,12 +37,17 @@ const Quiz: FunctionComponent = ({ useEffect(() => { const documentBody = document.querySelector("body"); - if (!documentBody) return; + const footer = document.querySelector("footer"); + const navbar = document.getElementById("nav"); // Mount - documentBody.style.overflow = "hidden"; + if (documentBody) documentBody.style.overflow = "hidden"; + if (footer) footer.style.display = "none"; + if (navbar) navbar.style.display = "none"; // Unmount return () => { - documentBody.style.removeProperty("overflow"); + if (documentBody) documentBody.style.removeProperty("overflow"); + if (footer) footer.style.removeProperty("display"); + if (navbar) navbar.style.removeProperty("display"); }; }, []); @@ -77,7 +82,7 @@ const Quiz: FunctionComponent = ({ if (answers.length !== quiz.questions.length) return; const load = () => { setPassed("loading"); - fetch(verifyEndpoint, { + fetch(`${process.env.NEXT_PUBLIC_API_LINK}/${verifyEndpoint}`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/components/quiz/quizControls.tsx b/components/quiz/quizControls.tsx index fa7293ee..f4214dc9 100644 --- a/components/quiz/quizControls.tsx +++ b/components/quiz/quizControls.tsx @@ -1,5 +1,6 @@ import React, { FunctionComponent } from "react"; import styles from "../../styles/components/quests/quiz.module.css"; +import BackButton from "../UI/backButton"; type QuizControlsProps = { setStep: (s: number) => void; @@ -12,21 +13,7 @@ const QuizControls: FunctionComponent = ({ }) => { return (
- + setStep(step - 1)} />
); diff --git a/components/shapes/blur.tsx b/components/shapes/blur.tsx new file mode 100644 index 00000000..a3268e66 --- /dev/null +++ b/components/shapes/blur.tsx @@ -0,0 +1,12 @@ +import React, { FunctionComponent } from "react"; +import styles from "../../styles/components/shapes.module.css"; + +type BlurProps = { + green?: boolean; +}; + +const Blur: FunctionComponent = ({ green = false }) => { + return
; +}; + +export default Blur; diff --git a/components/skeletons/achievementSkeleton.tsx b/components/skeletons/achievementSkeleton.tsx new file mode 100644 index 00000000..0f6a94af --- /dev/null +++ b/components/skeletons/achievementSkeleton.tsx @@ -0,0 +1,36 @@ +import React, { FunctionComponent } from "react"; +import { Skeleton } from "@mui/material"; +import styles from "../../styles/achievements.module.css"; + +const AchievementSkeleton: FunctionComponent = () => { + return ( + <> +
+ +
+
+ +
+ + ); +}; + +export default AchievementSkeleton; diff --git a/constants/nft.ts b/constants/nft.ts new file mode 100644 index 00000000..4d7d3742 --- /dev/null +++ b/constants/nft.ts @@ -0,0 +1,18 @@ +// NFT names & buildings id from db +export enum SoloBuildings { + "Zklend Artemis" = 64001, + "AVNU Astronaut" = 64002, + "JediSwap Light Saber" = 64003, + "Starknet ID Tribe Totem" = 64004, + "Sithswap Helmet" = 64008, + "MySwap" = 64009, + "Morphine" = 64010, + "Carmine" = 64011, + "Ekubo" = 64012, +} + +export enum StarkFighterBuildings { + "starkfighter bronze arcade" = 64005, + "starkfighter silver arcade" = 64006, + "starkfighter gold arcade" = 64007, +} diff --git a/constants/tiles.ts b/constants/tiles.ts new file mode 100644 index 00000000..4b87b76d --- /dev/null +++ b/constants/tiles.ts @@ -0,0 +1,147 @@ +import { Offset } from "../types/land"; + +export enum BLOCK { + MIN_WIDTH = 7, + MAX_WIDTH = 16, + MIN_HEIGHT = 6, + MAX_HEIGHT = 7, +} + +export enum PropsTypes { + LIGHT = 0, + TREE = 1, + SEWER_PLATE = 2, + FIRE_HYDRANT = 3, + BENCH = 4, +} + +export const PropsTypesNames = { + [PropsTypes.LIGHT]: "Props_StreetLight", + [PropsTypes.TREE]: "Props_Tree_1x1", + [PropsTypes.SEWER_PLATE]: "Props_SewerPlate", + [PropsTypes.FIRE_HYDRANT]: "Props_FireHydrant", + [PropsTypes.BENCH]: "Props_BenchGrey", +}; + +export enum tileTypes { + PROPS = 0, + LIGHTS = 1, +} + +type OffsetPosition = "top" | "left" | "bottom" | "right"; +type CornerOffsetPosition = + | "topLeft" + | "topRight" + | "bottomLeft" + | "bottomRight"; +type OffsetProps = Partial> & + Partial>; +export type OffsetKey = OffsetPosition | CornerOffsetPosition; + +export const propsOffset: Record = { + Props_StreetLight: { + topLeft: { x: 0.5, y: -0.3, z: 0 }, + topRight: { x: 0.4, y: -0.3, z: 0 }, + bottomLeft: { x: 0.5, y: -0.3, z: 0 }, + bottomRight: { x: 0.4, y: -0.3, z: 0 }, + }, + Props_Tree_1x1: { + top: { x: 0.6, y: -0.2, z: 0 }, + left: { x: 0.6, y: -0.2, z: 0 }, + bottom: { x: 0.6, y: -0.4, z: 0 }, + right: { x: 0.5, y: -0.2, z: 0 }, + }, + Props_SewerPlate: { + top: { x: 0.6, y: 0.5, z: 0 }, + left: { x: 0.6, y: 0.5, z: 0 }, + bottom: { x: 0.5, y: 0.4, z: 0 }, + right: { x: 0.5, y: 0.4, z: 0 }, + }, + Props_FireHydrant: { + top: { x: 0.5, y: 0.5, z: 0 }, + left: { x: 0.5, y: 0.4, z: 0 }, + bottom: { x: 0.5, y: 0.3, z: 0 }, + right: { x: 0.5, y: 0.3, z: 0 }, + }, + Props_BenchGrey: { + bottom: { x: 0.5, y: -0.3, z: 0 }, + }, +}; + +export const buildingsOrdered = { + 5: [[2, 3]], + 6: [[2, 4], [6]], + 7: [ + [2, 2, 3], + [2, 5], + [3, 4], + ], + 8: [ + [2, 2, 4], + [2, 3, 3], + [2, 6], + [3, 5], + ], + 9: [ + [2, 2, 2, 3], + [2, 2, 5], + [2, 3, 4], + [3, 6], + [4, 5], + ], + 10: [ + [2, 2, 2, 4], + [2, 2, 3, 3], + [2, 2, 6], + [2, 3, 5], + [2, 4, 4], + [3, 3, 4], + [4, 6], + ], + 11: [ + [2, 2, 2, 5], + [2, 2, 3, 4], + [2, 3, 3, 3], + [2, 3, 6], + [2, 4, 5], + [3, 3, 5], + [3, 4, 4], + [5, 6], + ], + 12: [ + [2, 2, 3, 5], + [2, 2, 4, 4], + [2, 3, 3, 4], + [2, 4, 6], + [2, 5, 5], + [3, 3, 6], + [3, 4, 5], + ], + 13: [ + [2, 2, 3, 3, 3], + [2, 2, 3, 6], + [2, 2, 4, 5], + [2, 3, 3, 5], + [2, 3, 4, 4], + [2, 5, 6], + [3, 3, 3, 4], + [3, 4, 6], + [3, 5, 5], + [4, 4, 5], + ], + 14: [ + [2, 2, 2, 3, 5], + [2, 2, 2, 4, 4], + [2, 2, 3, 3, 4], + [2, 2, 4, 6], + [2, 2, 5, 5], + [2, 3, 3, 6], + [2, 3, 4, 5], + [2, 4, 4, 4], + [3, 3, 3, 5], + [3, 3, 4, 4], + [4, 5, 5], + [3, 5, 6], + [4, 4, 6], + ], +}; diff --git a/context/QuestsProvider.tsx b/context/QuestsProvider.tsx new file mode 100644 index 00000000..a759a5fd --- /dev/null +++ b/context/QuestsProvider.tsx @@ -0,0 +1,78 @@ +import { ReactNode, createContext, useMemo, useState } from "react"; +import { QueryError, QuestDocument } from "../types/backTypes"; + +interface QuestsConfig { + quests: QuestDocument[]; + featuredQuest?: QuestDocument; + categories: QuestCategory[]; + trendingQuests: QuestDocument[]; +} + +type GetQuestsRes = + | { + [key: string]: QuestDocument[]; + } + | QueryError; + +export const QuestsContext = createContext({ + quests: [], + featuredQuest: undefined, + categories: [], + trendingQuests: [], +}); + +export const QuestsContextProvider = ({ + children, +}: { + children: ReactNode; +}) => { + const [quests, setQuests] = useState([]); + const [featuredQuest, setFeaturedQuest] = useState< + QuestDocument | undefined + >(); + const [categories, setCategories] = useState([]); + const [trendingQuests, setTrendingQuests] = useState([]); + + useMemo(() => { + fetch(`${process.env.NEXT_PUBLIC_API_LINK}/get_quests`) + .then((response) => response.json()) + .then((data: GetQuestsRes) => { + if ((data as QueryError).error) return; + const q = Object.values(data).flat(); + setCategories( + Object.keys(data).map((key) => ({ + name: key, + img: q.filter((quest) => quest.category === key)[0].img_card, + questNumber: q.filter((quest) => quest.category === key).length, + quests: q.filter((quest) => quest.category === key), + })) + ); + setQuests(q); + setFeaturedQuest(q.length >= 1 ? q[q.length - 1] : undefined); + }); + }, []); + + useMemo(() => { + fetch(`${process.env.NEXT_PUBLIC_API_LINK}/get_trending_quests`) + .then((response) => response.json()) + .then((data: QuestDocument[] | QueryError) => { + if ((data as QueryError).error) return; + setTrendingQuests(data as QuestDocument[]); + }); + }, []); + + const contextValues = useMemo(() => { + return { + quests, + featuredQuest, + categories, + trendingQuests: trendingQuests, + }; + }, [quests, featuredQuest, categories, trendingQuests]); + + return ( + + {children} + + ); +}; diff --git a/hooks/useHasRootDomain.ts b/hooks/useHasRootDomain.ts new file mode 100644 index 00000000..6a9abfa2 --- /dev/null +++ b/hooks/useHasRootDomain.ts @@ -0,0 +1,19 @@ +import BN from "bn.js"; +import { useContext, useEffect, useState } from "react"; +import { StarknetIdJsContext } from "../context/StarknetIdJsProvider"; +import { hexToDecimal } from "../utils/feltService"; + +export default function useHasRootDomain(address: string | BN | undefined) { + const [hasRootDomain, setHasRootDomain] = useState(false); + const { starknetIdNavigator } = useContext(StarknetIdJsContext); + useEffect(() => { + if (!address) return; + fetch( + `${ + process.env.NEXT_PUBLIC_STARKNET_ID_API_LINK + }/addr_to_domain?addr=${hexToDecimal(address.toString())}` + ).then((res) => res.status === 200 && setHasRootDomain(true)); + }, [starknetIdNavigator, address]); + + return hasRootDomain; +} diff --git a/package.json b/package.json index 0a508212..4684ce8e 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,17 @@ "@mui/icons-material": "^5.8.4", "@mui/material": "^5.10.1", "@nimiq/style": "^0.8.5", + "@react-three/drei": "^9.80.3", + "@react-three/fiber": "^8.13.6", "@starknet-react/core": "^1.0.3", + "@use-gesture/react": "^10.2.27", "@vercel/analytics": "^0.1.5", "big.js": "^6.2.1", "bn.js": "^5.2.1", "chart.js": "^4.3.0", "get-starknet-core": "^3.1.0", "lottie-react": "^2.4.0", + "maath": "^0.7.0", "mongodb": "^4.12.1", "next": "12.2.5", "nextjs-cors": "^2.1.2", @@ -33,8 +37,9 @@ "react-intersection-observer": "^9.5.2", "react-loader-spinner": "^5.2.0", "react-use": "^17.4.0", - "starknet": "^5.14.1", + "starknet": "5.14.1", "starknetid.js": "^1.5.2", + "three": "^0.155.0", "twitter-api-sdk": "^1.2.1" }, "devDependencies": { @@ -45,6 +50,7 @@ "@types/node": "18.7.8", "@types/react": "18.0.17", "@types/react-dom": "18.0.6", + "@types/three": "^0.155.0", "@typescript-eslint/eslint-plugin": "^5.47.0", "@typescript-eslint/parser": "^5.47.0", "autoprefixer": "^10.4.8", diff --git a/pages/[addressOrDomain].tsx b/pages/[addressOrDomain].tsx index aef6de5f..62ac6a78 100644 --- a/pages/[addressOrDomain].tsx +++ b/pages/[addressOrDomain].tsx @@ -2,21 +2,22 @@ import React, { useContext, useEffect, useLayoutEffect, useState } from "react"; import type { NextPage } from "next"; import styles from "../styles/profile.module.css"; import { useRouter } from "next/router"; -import { isHexString } from "../utils/stringService"; +import { isHexString, minifyAddressWithChars } from "../utils/stringService"; import { Connector, useAccount, useConnectors } from "@starknet-react/core"; import SocialMediaActions from "../components/UI/actions/socialmediaActions"; import { StarknetIdJsContext } from "../context/StarknetIdJsProvider"; -import CopiedIcon from "../components/UI/iconsComponents/icons/copiedIcon"; -import { Divider, Tooltip } from "@mui/material"; -import { ContentCopy } from "@mui/icons-material"; +import { Tooltip } from "@mui/material"; import { hexToDecimal } from "../utils/feltService"; -import StarknetIcon from "../components/UI/iconsComponents/icons/starknetIcon"; -import NftCard from "../components/UI/nftCard"; import { minifyAddress } from "../utils/stringService"; -import Button from "../components/UI/button"; -import PieChart from "../components/UI/pieChart"; import { utils } from "starknetid.js"; import ErrorScreen from "../components/UI/screens/errorScreen"; +import ProfileCard from "../components/UI/profileCard"; +import TrophyIcon from "../components/UI/iconsComponents/icons/trophyIcon"; +import { Land } from "../components/lands/land"; +import { hasVerifiedSocials } from "../utils/profile"; +import { useMediaQuery } from "@mui/material"; +import VerifiedIcon from "../components/UI/iconsComponents/icons/verifiedIcon"; +import CopyIcon from "../components/UI/iconsComponents/icons/copyIcon"; const AddressOrDomain: NextPage = () => { const router = useRouter(); @@ -29,20 +30,13 @@ const AddressOrDomain: NextPage = () => { const [notFound, setNotFound] = useState(false); const [copied, setCopied] = useState(false); const [isOwner, setIsOwner] = useState(false); - const [active, setActive] = useState(0); const dynamicRoute = useRouter().asPath; - const [userNft, setUserNft] = useState([]); - const [nextUrl, setNextUrl] = useState(null); - const [unusedAssets, setUnusedAssets] = useState([]); const isBraavosWallet = connector && connector.id === "braavos"; - - // Filtered NFTs - const NFTContracts = [ - hexToDecimal(process.env.NEXT_PUBLIC_QUEST_NFT_CONTRACT), - hexToDecimal(process.env.NEXT_PUBLIC_XPLORER_NFT_CONTRACT), - hexToDecimal(process.env.NEXT_PUBLIC_BRAAVOSSHIELD_NFT_CONTRACT), - hexToDecimal(process.env.NEXT_PUBLIC_BRAAVOS_JOURNEY_NFT_CONTRACT), - ]; + const [braavosScore, setBraavosScore] = useState(0); + const isMobile = useMediaQuery("(max-width:768px)"); + const [sinceDate, setSinceDate] = useState(null); + const [totalNFTs, setTotalNfts] = useState(0); + const [achievementCount, setAchievementCount] = useState(0); useEffect(() => setNotFound(false), [dynamicRoute]); @@ -121,7 +115,7 @@ const AddressOrDomain: NextPage = () => { .then((addr) => { setIdentity({ starknet_id: "0", - addr: hexToDecimal(addr), + addr: addr, domain: addressOrDomain, is_owner_main: false, }); @@ -153,7 +147,8 @@ const AddressOrDomain: NextPage = () => { ...data, starknet_id: id.toString(), }); - if (hexToDecimal(address) === data.addr) setIsOwner(true); + if (hexToDecimal(address) === hexToDecimal(data.addr)) + setIsOwner(true); setInitProfile(true); }); }) @@ -163,7 +158,7 @@ const AddressOrDomain: NextPage = () => { } else { setIdentity({ starknet_id: "0", - addr: hexToDecimal(addressOrDomain), + addr: addressOrDomain, domain: name, is_owner_main: false, }); @@ -174,7 +169,7 @@ const AddressOrDomain: NextPage = () => { } else { setIdentity({ starknet_id: "0", - addr: hexToDecimal(addressOrDomain), + addr: addressOrDomain, domain: minifyAddress(addressOrDomain), is_owner_main: false, }); @@ -188,25 +183,18 @@ const AddressOrDomain: NextPage = () => { }, [addressOrDomain, address, dynamicRoute]); useEffect(() => { - if (identity) { - retrieveAssets( - `https://${ - process.env.NEXT_PUBLIC_IS_TESTNET === "true" ? "api-testnet" : "api" - }.starkscan.co/api/v0/nfts?owner_address=${identity.addr}` - ).then((data) => { - setUserNft(data.data); - setNextUrl(data.next_url as string); - setUnusedAssets(data.remainder ?? []); - }); + // connector is of type Connector in starknet-react + // but _wallet which is supposed to be of type IStarknetWindowObject is set as private + if (connector && connector.id === "braavos") { + (connector as any)._wallet + .request({ type: "wallet_getStarknetProScore" }) + .then((score: BraavosScoreProps) => { + setBraavosScore(score.score); + }); + } else { + setBraavosScore(0); } - }, [identity, addressOrDomain, address]); - - const getIdentityData = async (id: number) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_STARKNET_ID_API_LINK}/id_to_data?id=${id}` - ); - return response.json(); - }; + }, [connector, addressOrDomain, address]); const copyToClipboard = () => { setCopied(true); @@ -216,83 +204,6 @@ const AddressOrDomain: NextPage = () => { }, 1500); }; - const retrieveAssets = async ( - url: string, - accumulatedAssets: StarkscanNftProps[] = [] - ): Promise => { - return fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - "x-api-key": `${process.env.NEXT_PUBLIC_STARKSCAN}`, - }, - }) - .then((res) => res.json()) - .then((data) => { - const filteredAssets = filterAssets(data.data); - const assets = [...accumulatedAssets, ...filteredAssets]; - - if (assets.length < 8 && data.next_url) { - return retrieveAssets(data.next_url, assets); - } else if (assets.length > 8) { - // Split and save results - const { res, remainder } = splitAssets(assets); - return { - data: res, - next_url: data.next_url ?? null, - remainder, - }; - } else { - return { - data: assets, - next_url: data.next_url, - }; - } - }); - }; - - const filterAssets = (assets: StarkscanNftProps[]) => { - return assets.filter((obj) => - NFTContracts.includes(hexToDecimal(obj.contract_address)) - ); - }; - - const splitAssets = ( - assets: StarkscanNftProps[] - ): { res: StarkscanNftProps[]; remainder: StarkscanNftProps[] } => { - const modulo = assets.length % 8; - const res = assets.slice(0, assets.length - modulo); - const remainder = assets.slice(assets.length - modulo); - return { res, remainder }; - }; - - const loadMore = () => { - if (unusedAssets.length > 0 && unusedAssets.length < 8) { - if (nextUrl) { - // fetch more assets from API - retrieveAssets(nextUrl, unusedAssets).then((data) => { - setUserNft((prev) => [ - ...(prev as StarkscanNftProps[]), - ...data.data, - ]); - setNextUrl(data.next_url as string); - setUnusedAssets(data.remainder ?? []); - }); - } else { - // show unused assets - setUserNft((prev) => [ - ...(prev as StarkscanNftProps[]), - ...unusedAssets, - ]); - setUnusedAssets([]); - } - } else { - const { res, remainder } = splitAssets(unusedAssets); - setUserNft((prev) => [...(prev as StarkscanNftProps[]), ...res]); - setUnusedAssets(remainder); - } - }; - if (notFound) { return ( { ); } - return initProfile && identity ? ( - <> -
-
-
-
- starknet.id avatar -
- {/* We do not enable the profile pic change atm */} - {/* + const getIdentityData = async (id: number) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_STARKNET_ID_API_LINK}/id_to_data?id=${id}` + ); + return response.json(); + }; + + return ( +
+ {initProfile && identity ? ( + <> + +
+ +
+ starknet.id avatar + {/* We do not enable the profile pic change atm */} + {/*
{ />
*/} -
- -
-
{identity?.domain}
-
- -
- {typeof addressOrDomain === "string" && - isHexString(addressOrDomain) - ? minifyAddress(addressOrDomain) - : minifyAddress(identity?.addr)} -
- -
- {!copied ? ( - - copyToClipboard()} - /> - - ) : ( - - )} -
-
-
- -
-
-
-
-
-
- {/*

setActive(1)} - > - My analytics -

- ) */} -

setActive(0)} - > - Starknet Achievements -

-
- {!active ? ( - <> - {isOwner && isBraavosWallet ? ( -
-
- ) : null} -
- {userNft && userNft.length ? ( - userNft.map((nft, index) => { - return ( - +
+
+ {!copied ? ( + +
copyToClipboard()}> + +
+
+ ) : ( + + )} +
+
+ {typeof addressOrDomain === "string" && + isHexString(addressOrDomain) + ? minifyAddressWithChars(addressOrDomain, 8) + : minifyAddressWithChars(identity?.addr, 8)} +
+
+ {sinceDate ? ( +
+

{sinceDate}

+
+ ) : null} +
+
+ } + /> + + {/* We do not have xp yet */} + {/*
+
+ polygon icon + XP +
+ 12 +
*/} + +
+ + + + {totalNFTs} +
+
+ +
+ verfy badge icon + {achievementCount} +
+
+ {isOwner && isBraavosWallet ? ( + +
+ braavos icon - ); - }) - ) : ( -

No Starknet achievements yet, start some quests !

- )} + {braavosScore} +
+
+ ) : null}
- {nextUrl || unusedAssets.length > 0 ? ( -
- -
- ) : null} - + } + /> + {hasVerifiedSocials(identity) ? ( + } + /> ) : null}
+ + ) : ( +
+

Loading

-
- - ) : ( -
-

Loading

+ )}
); }; diff --git a/pages/_app.tsx b/pages/_app.tsx index b60dbc5a..ae4f7d88 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React from "react"; import "../styles/globals.css"; import type { AppProps } from "next/app"; import Navbar from "../components/UI/navbar"; @@ -8,6 +8,8 @@ import { InjectedConnector, StarknetConfig } from "@starknet-react/core"; import { Analytics } from "@vercel/analytics/react"; import { StarknetIdJsProvider } from "../context/StarknetIdJsProvider"; import { createTheme } from "@mui/material/styles"; +import Footer from "../components/UI/footer"; +import { QuestsContextProvider } from "../context/QuestsProvider"; function MyApp({ Component, pageProps }: AppProps) { const connectors = [ @@ -32,24 +34,25 @@ function MyApp({ Component, pageProps }: AppProps) { }); return ( - <> - - - - - Starknet Quest - - + + + + + Starknet Quest + + + - - - - - +