Add initial Neptune Frontend setup

Initial setup of the Neptune frontend project, including Dockerfile, environment files, TypeScript configuration, and essential components. Added basic page structures for dashboard and wallet, and configured Tailwind CSS and postcss.
This commit is contained in:
Mathis H (Avnyr) 2024-11-14 11:10:32 +01:00
parent 442bebd022
commit a75a87f683
Signed by: Mathis
GPG Key ID: DD9E0666A747D126
54 changed files with 8218 additions and 0 deletions

1
.env.development.example Normal file
View File

@ -0,0 +1 @@
NEXT_PUBLIC_API_BASE_URL: ""

1
.env.production.example Normal file
View File

@ -0,0 +1 @@
NEXT_PUBLIC_API_BASE_URL: "localhost:3333"

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env.production
.env.development
.env*.local
/.env

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

7
.idea/discord.xml generated Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

View File

@ -0,0 +1,10 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<Languages>
<language minSize="86" name="TypeScript" />
</Languages>
</inspection_tool>
</profile>
</component>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/brief-07-front.iml" filepath="$PROJECT_DIR$/.idea/brief-07-front.iml" />
</modules>
</component>
</project>

12
.idea/neptune-frontend.iml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# Étape 1 : Build
FROM node:18-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm
# Copier package.json et installer les dépendances
COPY pnpm-lock.yaml ./
COPY package.json ./
RUN pnpm install
# Copier le reste du code et construire l'application
COPY . .
RUN pnpm run build
# Étape 2 : Serveur de production
FROM node:18-alpine
WORKDIR /app
# Copier uniquement les fichiers nécessaires
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
# Exposer le port de l'application
EXPOSE 3000
# Commande de démarrage
CMD ["npm", "start"]

37
biome.json Normal file
View File

@ -0,0 +1,37 @@
{
"$schema": "https://biomejs.dev/schemas/1.6.4/schema.json",
"organizeImports": {
"enabled": true
},
"files": {
"include": [
"./src/**/*.ts",
"./src/**/*.tsx"
]
},
"vcs": {
"enabled": true,
"clientKind": "git"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"recommended": true,
"noDelete": "off"
},
"suspicious": {
"noExplicitAny": "warn"
},
"complexity": {
"useLiteralKeys": "off"
}
}
},
"formatter": {
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 90
}
}

17
components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

4
next.config.mjs Normal file
View File

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

79
package.json Normal file
View File

@ -0,0 +1,79 @@
{
"name": "neptune-back",
"version": "0.1.0",
"author": "Mathis HERRIOT",
"license": "MIT",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"check": "biome check --skip-errors --write src"
},
"dependencies": {
"@fontsource-variable/kode-mono": "^5.1.0",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-context-menu": "^2.2.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.2",
"@radix-ui/react-navigation-menu": "^1.2.1",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-table": "^8.20.5",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.1",
"framer-motion": "^11.11.15",
"input-otp": "^1.4.1",
"lightweight-charts": "^4.2.1",
"lucide-react": "^0.395.0",
"next": "14.2.10",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.2",
"react-resizable-panels": "^2.1.6",
"sonner": "^1.7.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "1.8.1",
"@types/jest": "^29.5.14",
"@types/node": "^20.17.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"jest": "^29.7.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
}
}

5364
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/neptune.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

54
public/neptune.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

10
src/app/auth/page.tsx Normal file
View File

@ -0,0 +1,10 @@
import { AuthForms } from "@/components/auth-form";
import Image from "next/image";
export default function AuthPage() {
return (
<main className="flex flex-col items-center justify-start h-full w-full">
<AuthForms />
</main>
);
}

View File

@ -0,0 +1,9 @@
export default function AdminPage() {
return (
<>
<section>
<h1>Welcome to the Dashboard</h1>
</section>
</>
);
}

View File

@ -0,0 +1,59 @@
"use client";
import { CryptosTable } from "@/components/data-tables/cryptos-table";
import { Skeleton } from "@/components/ui/skeleton";
import type { IApiAllOffersRes } from "@/interfaces/api.interface";
import type { ICryptoInWalletInfo } from "@/interfaces/crypto.interface";
import ApiRequest from "@/services/apiRequest";
import { useEffect, useState } from "react";
export default function DashboardPage() {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [cryptosList, setCryptosList] = useState<ICryptoInWalletInfo[]>([]);
//FIX the loop
useEffect(() => {
ApiRequest.authenticated.get
.json<ICryptoInWalletInfo[]>("crypto/all")
.then((response) => {
if (response.data.length <= 0) {
setCryptosList([]);
setIsLoading(false);
return;
}
const resp = response.data;
setCryptosList(resp);
setIsLoading(false);
});
}, []);
if (isLoading) {
return (
<div
className={
"container flex flex-col justify-center items-center min-h-64 my-6 gap-4"
}
>
<h1 className={"text-2xl font-bold"}>Available cryptos</h1>
<Skeleton
className={
"container flex flex-col justify-center items-center min-h-64 my-6 gap-4"
}
/>
</div>
);
}
return (
<>
<section
className={
"container flex flex-col justify-center items-center min-h-64 my-6 gap-4"
}
>
<h1 className={"text-2xl font-bold"}>Available cryptos</h1>
<CryptosTable cryptosArray={cryptosList} />
</section>
</>
);
}

65
src/app/globals.css Normal file
View File

@ -0,0 +1,65 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: 'Kode Mono Variable', monospace;
}
@layer base {
:root {
--background: 289 20.6% 90.87%;
--foreground: 289 71% 3%;
--muted: 291.15 0% 100%;
--muted-foreground: 109 0% 39%;
--popover: 289 62% 97%;
--popover-foreground: 289 71% 2%;
--card: 286.67 60% 97.06%;
--card-foreground: 289 71% 2%;
--border: 289 0% 78.21%;
--input: 289 14.91% 46.64%;
--primary: 271.89 62.45% 55.1%;
--primary-foreground: 289 39% 96%;
--secondary: 271.83 81.56% 27.65%;
--secondary-foreground: 0 0% 100%;
--accent: 36.14 100% 67.45%;
--accent-foreground: 0 0% 12.94%;
--destructive: 8 89% 28%;
--destructive-foreground: 8 89% 88%;
--ring: 289 39% 36%;
--radius: 0.5rem;
}
.dark {
--background: 289 47.53% 5.21%;
--foreground: 289 6.69% 90.11%;
--muted: 271.83 26.29% 22.62%;
--muted-foreground: 0 0% 73.85%;
--popover: 289 17.57% 17.45%;
--popover-foreground: 0 0% 89.23%;
--card: 289 50.44% 6.99%;
--card-foreground: 300 5.88% 90%;
--border: 289 5.38% 31.15%;
--input: 289 12.37% 29.89%;
--primary: 271.89 62.45% 55.1%;
--primary-foreground: 289 39% 96%;
--secondary: 271.83 81.56% 27.65%;
--secondary-foreground: 300 5.88% 90%;
--accent: 36.14 100% 67.45%;
--accent-foreground: 0 0% 12.94%;
--destructive: 350.05 93.43% 41.76%;
--destructive-foreground: 0 0% 92.55%;
--ring: 289 39% 36%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

46
src/app/layout.tsx Normal file
View File

@ -0,0 +1,46 @@
import type { Metadata } from "next";
import "@fontsource-variable/kode-mono";
import "./globals.css";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
import { PrimaryNavigationMenu } from "@/components/primary-nav";
import { Providers } from "@/components/providers/providers";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { Button } from "@/components/ui/button";
import { Toaster } from "@/components/ui/toaster";
import Link from "next/link";
import type React from "react";
export const metadata: Metadata = {
title: "Neptune Crypto",
description: "A fictive app",
icons: "neptune.svg",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={"w-full min-h-screen flex flex-col items-center justify-between"}>
<Providers>
<Header>
<div className={"flex flex-row flex-wrap md:flex-nowrap gap-2"}>
<Button asChild variant={"light"}>
<Link href={"/wallet"}><p className={"lg:text-lg"}>Wallet</p></Link>
</Button>
<Button asChild variant={"light"}>
<Link href={"/dashboard"}><p className={"lg:text-lg"}>Explore cryptos</p></Link>
</Button>
</div>
</Header>
{children}
<Toaster />
<Footer />
</Providers>
</body>
</html>
);
}

84
src/app/page.tsx Normal file
View File

@ -0,0 +1,84 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Bitcoin, DollarSign, LineChart, Lock, Zap } from "lucide-react"
import Link from "next/link"
export default function HomePage() {
return (
<div className="flex flex-col min-h-screen">
<main className="flex-1">
<section className="w-full py-12 md:py-16 lg:py-24xl:py-32">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-6xl/none">
Welcome to Neptune Crypto
</h1>
<p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400">
Your gateway to the world of cryptocurrencies. Trade, invest, and grow your digital assets with ease.
</p>
</div>
<div className="space-x-4">
<Button>Get Started</Button>
<Button variant="outline">Learn More</Button>
</div>
</div>
</div>
</section>
<section className="w-full py-12 md:py-16 lg:py-20 bg-card rounded">
<div className="container px-4 md:px-6 rounded">
<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl text-center mb-12">Our Features</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<Bitcoin className="h-10 w-10 mb-2" />
<CardTitle>Multiple Cryptocurrencies</CardTitle>
<CardDescription>Trade a wide variety of popular cryptocurrencies.</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<Lock className="h-10 w-10 mb-2" />
<CardTitle>Secure Storage</CardTitle>
<CardDescription>Your assets are protected with state-of-the-art security measures.</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<LineChart className="h-10 w-10 mb-2" />
<CardTitle>Advanced Trading Tools</CardTitle>
<CardDescription>Access powerful analytics and trading features.</CardDescription>
</CardHeader>
</Card>
</div>
</div>
</section>
<section className="w-full py-12 md:py-24 lg:py-32">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl">Start Trading Today</h2>
<p className="mx-auto max-w-[600px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
Join thousands of traders and investors on our platform. Get started with as little as $10.
</p>
</div>
<div className="w-full max-w-sm space-y-2">
<Button>
Sign Up
<DollarSign className="ml-2 h-4 w-4" />
</Button>
<p className="text-xs text-gray-500 dark:text-gray-400">
By signing up, you agree to our{" "}
<Link className="underline underline-offset-2" href="#">
Terms & Conditions
</Link>
</p>
</div>
</div>
</div>
</section>
</main>
</div>
)
}

57
src/app/wallet/page.tsx Normal file
View File

@ -0,0 +1,57 @@
"use client";
import { CryptosTable } from "@/components/data-tables/cryptos-table";
import { WalletTable } from "@/components/data-tables/wallet-table";
import { UserDataContext } from "@/components/providers/userdata-provider";
import { Skeleton } from "@/components/ui/skeleton";
import type { IApiAllOffersRes } from "@/interfaces/api.interface";
import type { ICryptoInWalletInfo } from "@/interfaces/crypto.interface";
import type { IUserWallet } from "@/interfaces/userdata.interface";
import ApiRequest from "@/services/apiRequest";
import { useContext, useEffect, useState } from "react";
export default function WalletPage() {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [cryptosList, setCryptosList] = useState<ICryptoInWalletInfo[]>([]);
const userContext = useContext(UserDataContext);
//FIX the loop
useEffect(() => {
console.log(userContext?.userData);
if (userContext?.userData) {
setIsLoading(false);
}
}, [userContext]);
if (isLoading || !userContext?.userData) {
return (
<div
className={
"container flex flex-col justify-center items-center min-h-64 my-6 gap-4"
}
>
<h1 className={"text-2xl font-bold"}>Loading in progress ...</h1>
<Skeleton
className={
"container flex flex-col justify-center items-center min-h-64 my-6 gap-4"
}
/>
</div>
);
}
return (
<>
<section
className={
"container flex flex-col justify-center items-center min-h-64 my-6 gap-4 rounded sm:p-2 sm:bg-card"
}
>
<h1 className={"text-xl sm:text-2xl lg:text-3xl font-bold"}>Cryptos in your wallet</h1>
<WalletTable
walletArray={userContext.userData.wallet as unknown as IUserWallet}
/>
</section>
</>
);
}

View File

@ -0,0 +1,53 @@
"use client";
import { AccountInfo } from "@/components/account-info";
import { UserDataContext } from "@/components/providers/userdata-provider";
import { Skeleton } from "@/components/ui/skeleton";
import type { IUserData } from "@/interfaces/userdata.interface";
import {
type Dispatch,
type SetStateAction,
useContext,
useEffect,
useState,
} from "react";
const localStorage = typeof window !== "undefined" ? window.localStorage : null;
export function AccountDialog() {
const userContext = useContext(UserDataContext);
const token = localStorage?.getItem("sub") || "";
const haveToken = token.length >= 16 || false;
console.log(haveToken);
const [isLoaded, setIsLoaded] = useState<boolean>(false);
if (!userContext) {
return (
<div>
<p>No account</p>
</div>
);
}
useEffect(() => {
if (userContext?.userData) {
setIsLoaded(true);
}
}, [userContext?.userData]);
if (!isLoaded) {
return <Skeleton className="w-14 h-10 rounded" />;
}
return (
<div>
<AccountInfo
userData={userContext?.userData as IUserData}
setUserData={
userContext?.setUserData as Dispatch<SetStateAction<IUserData | undefined>>
}
isDisconnected={!haveToken}
/>
</div>
);
}

View File

@ -0,0 +1,174 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { IUserData, IUserWallet } from "@/interfaces/userdata.interface";
import { CopyButton } from "@/components/ui/copy-button";
import {
type ICryptoInUserWalletInfo,
ICryptoInWalletInfo,
} from "@/interfaces/crypto.interface";
import { doDisconnect, getWallet } from "@/services/account.handler";
import { Bitcoin, Fingerprint, Key, Landmark, Unplug, User, Wallet } from "lucide-react";
import Link from "next/link";
import type React from "react";
import { useEffect, useState } from "react";
export function AccountInfo({
userData,
setUserData,
isDisconnected,
}: {
userData: IUserData;
setUserData: React.Dispatch<React.SetStateAction<IUserData | undefined>>;
isDisconnected: boolean;
}) {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
if (!isLoaded) {
getWallet().then((res) => {
const wallet: IUserWallet = {
uat: Date.now(),
update_interval: 30_000,
owned_cryptos:
res.resolved?.UserHasCrypto?.map((el): ICryptoInUserWalletInfo => {
return {
id: el.Crypto.id,
name: el.Crypto.name,
value: el.Crypto.value,
image: el.Crypto.image,
quantity: el.Crypto.quantity,
owned_amount: el.amount,
created_at: el.Crypto.created_at,
updated_at: el.Crypto.updated_at,
};
}) || [],
};
delete res.resolved?.UserHasCrypto;
//@ts-ignore
setUserData({
...userData,
...res.resolved,
wallet: wallet,
});
console.log(userData);
setIsLoaded(true);
});
}
}, [isLoaded, userData, setUserData]);
if (isDisconnected) {
return (
<div className={"flex flex-col justify-center items-center h-10 p-2 text-xs mt-2"}>
<div
className={
"flex flex-row justify-center items-center gap-1 text-destructive to-red-900 animate-pulse"
}
>
<Unplug className={"w-4"} />
<p>Disconnected</p>
</div>
<div>
<Link
href={"/auth"}
className={
"hover:text-primary flex justify-evenly items-center gap-1 p-1 text-nowrap"
}
>
<Key className={"w-3"} /> Link account
</Link>
</div>
</div>
);
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className={"gap-2 px-2"}>
<p>{userData.firstName}</p>
<User />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] md:max-w-[720px]">
<DialogHeader>
<DialogTitle>{`Your account - ${userData.firstName} ${userData.lastName}`}</DialogTitle>
<DialogDescription>{userData.pseudo}</DialogDescription>
</DialogHeader>
<div className={"flex flex-col items-center justify-center w-full"}>
<div className={"flex flex-col justify-evenly items-center gap-2"}>
<div
className={
"flex flex-col md:flex-row gap-2 justify-center md:justify-evenly items-start md:items-center w-full"
}
>
<div
className={
"flex gap-1 justify-start md:justify-center items-center mx-auto w-full md:w-fit"
}
>
<Landmark />
<p className={"rounded bg-accent text-accent-foreground p-1"}>
{userData.dollarAvailables} $
</p>
</div>
<div
className={
"flex gap-1 justify-start md:justify-center items-center mx-auto w-full md:w-fit"
}
>
<Bitcoin />
<p className={"rounded bg-accent text-accent-foreground p-1"}>
{`You currently have ${userData.wallet.owned_cryptos.length} crypto(s)`}
</p>
</div>
</div>
<div
className={"flex flex-col gap-3 justify-center items-start mx-auto mt-4"}
>
<div className={"flex flex-row text-nowrap flex-nowrap gap-1 text-primary"}>
<Fingerprint />
<h2>Your identity</h2>
</div>
<div
className={
"font-light text-xs md:text-sm flex flex-row items-center justify-start gap-1 bg-accent p-2 rounded"
}
>
<p>{userData.id}</p>
<CopyButton value={userData.id} />
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant={"secondary"} className={"gap-2 px-2"} asChild>
<Link href={"/wallet"}>
<Wallet />
<p>My wallet</p>
</Link>
</Button>
<Button
variant={"destructive"}
className={"gap-2 px-2 mb-2"}
onClick={() => doDisconnect()}
>
<Unplug />
<p>Disconnect</p>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,259 @@
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import AutoForm, { AutoFormSubmit } from "@/components/auto-form";
import { UserDataContext } from "@/components/providers/userdata-provider";
import { ToastBox, toastType } from "@/components/ui/toast-box";
import { useToast } from "@/components/ui/use-toast";
import type {
IApiLoginReq,
IApiLoginRes,
IApiRegisterReq,
IApiRegisterRes,
} from "@/interfaces/api.interface";
import { EReturnState, type IStandardisedReturn } from "@/interfaces/general.interface";
import type { IUserData } from "@/interfaces/userdata.interface";
import ApiRequest from "@/services/apiRequest";
import { useLocalStorage } from "@/services/localStorage";
import { Bug, RefreshCw } from "lucide-react";
import Link from "next/link";
import React, { type Dispatch, type SetStateAction, useContext, useState } from "react";
import * as z from "zod";
import Image from "next/image";
const loginSchema = z.object({
email: z
.string({
required_error: "Email is needed.",
})
.email({
message: "Should be a valid email.",
})
.describe("Your account email."),
password: z
.string({
required_error: "Password is needed.",
})
.describe("Your account password."),
});
const registerSchema = z.object({
firstName: z.string({
required_error: "First name is required.",
}).describe("First name"),
lastName: z.string({
required_error: "Last name is required.",
}).describe("Last name"),
pseudo: z.string({
required_error: "Nickname is required.",
}).describe("Nickname"),
email: z
.string({
required_error: "Email is required.",
})
.email("Email must be valid."),
password: z
.string({
required_error: "Password is required.",
})
.min(8, "Password must be at least 8 characters.")
.max(32, "Password must be less than 32 characters.")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter.")
.regex(/[a-z]/, "Password must contain at least one lowercase letter.")
.regex(/[0-9]/, "Password must contain at least one number.")
.regex(/[^a-zA-Z0-9]/, "Password must contain at least one special character.")
.describe("Your account password."),
});
export function AuthForms() {
const [isLoading, setIsLoading] = useState(false);
const [sub, setSub] = useLocalStorage<string | undefined>("sub", "");
const userContext = useContext(UserDataContext);
const { toast } = useToast();
async function doRegister(
registerData: IApiRegisterReq,
userDataSetter: Dispatch<SetStateAction<IUserData | null>>,
): Promise<IStandardisedReturn<IApiRegisterRes>> {
console.trace(registerData);
try {
const ReqRes = await ApiRequest.standard.post.json<
IApiRegisterReq,
IApiRegisterRes
>("auth/sign-up", registerData);
console.trace(ReqRes.data);
if (ReqRes.data.user) {
userDataSetter({
...ReqRes.data.user,
wallet: {
uat: Date.now(),
update_interval: 30_000,
owned_cryptos: [],
},
});
setSub(ReqRes.data.access_token);
}
console.debug(ReqRes.data.message || "Not additional message from request");
return {
state: EReturnState.done,
resolved: ReqRes.data,
};
} catch (error) {
console.error("Error during registration:", error);
return {
state: EReturnState.serverError,
message: error as string,
};
}
}
async function doLogin(
loginData: IApiLoginReq,
): Promise<IStandardisedReturn<IApiLoginRes>> {
try {
const ReqRes = await ApiRequest.standard.post.json<IApiLoginReq, IApiLoginRes>(
"auth/sign-in",
loginData,
);
console.trace(ReqRes.data);
if (ReqRes.data.access_token) {
setSub(ReqRes.data.access_token);
}
return {
state: EReturnState.done,
};
} catch (err) {
console.error("Error during login:", err);
return {
state: EReturnState.serverError,
message: err as string,
};
}
}
if (!userContext || !userContext.setUserData) {
return (
<div
className={
"bg-destructive text-destructive-foreground p-3 gap-2 border rounded flex flex-row justify-center items-center"
}
>
<Bug />
<p>It seems that the context is missing..</p>
</div>
);
}
return (
<section className={"flex flex-col gap-6 items-center justify-center"}>
<Link href={"/"} className={"hidden sm:visible sm:flex flex-row justify-center md:justify-start items-center w-fit gap-2"}>
<Image src={"neptune.svg"} alt={"Logo of Neptune"} className={"w-24 h-24"} width={128} height={128} />
<h1 className={"font-bold text-xl lg:text-2xl align-middle text-center text-wrap"}>
Neptune Crypto
</h1>
</Link>
<Tabs defaultValue="login" className="w-full p-2 gap-4 flex flex-col justify-center sm:p-4 rounded bg-card text-card-foreground md:w-[400px] my-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="login">Login</TabsTrigger>
<TabsTrigger value="register">Register</TabsTrigger>
</TabsList>
<TabsContent value="login">
<AutoForm
// I pass the Zod schema to the form
formSchema={loginSchema}
onSubmit={(data: IApiLoginReq) => {
setIsLoading(true);
doLogin(data).then((res) => {
if (res.state !== EReturnState.done) {
toast({
description: res.message || "An unexpected error occurred..",
variant: "destructive",
});
setIsLoading(false);
return;
}
//toast.custom(<ToastBox message={"Login successful ! \n You will be redirected."} type={toastType.success}/>)
toast({
description: "Login successful ! \n You will be redirected.",
});
setTimeout(() => {
setIsLoading(false);
location.href = "/";
console.log("Moving to home.");
}, 3_000);
});
}}
fieldConfig={{
password: {
inputProps: {
type: "password",
placeholder: "••••••••",
},
},
}}
>
<AutoFormSubmit
disabled={!!isLoading}
className={"gap-2 disabled:bg-secondary"}
>
{/* biome-ignore lint/style/useTemplate: <explanation> */}
<RefreshCw className={"animate-spin" + isLoading && "hidden"} />
<p>Login</p>
</AutoFormSubmit>
</AutoForm>
</TabsContent>
<TabsContent value="register">
<AutoForm
// Pass the schema to the form
formSchema={registerSchema}
onSubmit={(data: IApiRegisterReq) => {
setIsLoading(true);
doRegister(
data,
userContext.setUserData as Dispatch<SetStateAction<IUserData | null>>,
).then((res) => {
if (res.state !== EReturnState.done) {
//toast.custom(<ToastBox message={res.message || "An unexpected error occurred.."} type={toastType.error}/>)
setIsLoading(false);
return;
}
//toast.custom(<ToastBox message={"Register successful ! \n You will be redirected."} type={toastType.success}/>)
setTimeout(() => {
setIsLoading(false);
//location.href = "/"
console.log("Moving to home.");
}, 5_000);
});
}}
fieldConfig={{
password: {
inputProps: {
type: "password",
placeholder: "••••••••",
},
},
}}
>
<AutoFormSubmit
disabled={!!isLoading}
className={"gap-2 disabled:bg-secondary"}
>
{/* biome-ignore lint/style/useTemplate: <explanation> */}
<RefreshCw className={"animate-spin" + !isLoading && "hidden"} />
<p>Register</p>
</AutoFormSubmit>
<p className="text-gray-500 text-sm">
By submitting this form, you agree to our{" "}
<Link href="#" className="text-primary underline">
terms and conditions
</Link>
.
</p>
</AutoForm>
</TabsContent>
</Tabs>
</section>
);
}

View File

@ -0,0 +1,243 @@
import AutoForm, { AutoFormSubmit } from "@/components/auto-form";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "@/components/ui/use-toast";
import type { IApiAllOffersRes, IApiDoTradeReq } from "@/interfaces/api.interface";
import type { ICryptoInWalletInfo } from "@/interfaces/crypto.interface";
import ApiRequest from "@/services/apiRequest";
import { zodResolver } from "@hookform/resolvers/zod";
import { Ban, DollarSign, RefreshCw } from "lucide-react";
import Link from "next/link";
import * as React from "react";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
type Props = {
cryptoData: ICryptoInWalletInfo;
};
export function BuyModal(props: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [offersList, setOffersList] = useState<IApiAllOffersRes[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
if (!isLoaded) {
ApiRequest.authenticated.get
.json<IApiAllOffersRes[]>(`offer/crypto/${props.cryptoData.id}`)
.then((response) => {
if (response.data) {setOffersList(response.data)}
console.log(`Crypto ${props.cryptoData.name} -> ${response.data.length}`);
setIsLoaded(true);
return;
});
}
}, [isLoaded]);
const buyFromServerSchema = z.object({
amount: z
.number({
required_error: "An amount is needed.",
})
.min(1)
.max(
props.cryptoData.quantity,
"You cant buy more that what is available on the server.",
)
.describe("The amount you want to buy."),
});
const buyFromUserSchema = z.object({
offerId: z
.string({
required_error: "You should select an offer.",
})
.uuid(),
});
function onBuyFromServerSubmit(data: z.infer<typeof buyFromServerSchema>) {
ApiRequest.authenticated.post
.json("crypto/buy", { id_crypto: props.cryptoData.id, amount: data.amount })
.then((res) => {
if (res.status !== 201) {
toast({
title: "An error occurred !",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white text-wrap">
{JSON.stringify(res.statusText, null, 2)}
</code>
</pre>
),
});
return;
}
toast({
title: "Transaction accepted.",
description: <p>You will be redirected.</p>,
});
setTimeout(() => location.reload(), 1_500);
});
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
});
}
function onBuyFromUserSubmit(data: z.infer<typeof buyFromUserSchema>) {
ApiRequest.authenticated.post
.json("trade/create", { id_offer: data.offerId })
.then((res) => {
if (res.status !== 201) {
toast({
title: "An error occurred !",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white text-wrap">
{JSON.stringify(res.statusText, null, 2)}
</code>
</pre>
),
});
return;
}
toast({
title: "Transaction accepted.",
description: <p>You will be redirected.</p>,
});
setTimeout(() => location.reload(), 1_500);
});
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
});
}
const buyFromUserForm = useForm<z.infer<typeof buyFromUserSchema>>({
resolver: zodResolver(buyFromUserSchema),
});
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="light">
<DollarSign />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] md:max-w-[800px] flex flex-col justify-start items-center">
<Tabs defaultValue="server" className="w-full p-2 my-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="user">
Buy from user{" "}
<p className={" ml-1 px-1 bg-primary text-primary-foreground rounded"}>
{offersList.length}
</p>
</TabsTrigger>
<TabsTrigger value="server">Buy from server</TabsTrigger>
</TabsList>
<TabsContent
value="user"
className={"flex flex-col justify-start items-center"}
>
<Form {...buyFromUserForm}>
<form
onSubmit={buyFromUserForm.handleSubmit(onBuyFromUserSubmit)}
className="w-full space-y-6"
>
<FormField
control={buyFromUserForm.control}
name="offerId"
render={({ field }) => (
<FormItem>
<FormLabel>Select an offer</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an offer to purchase." />
</SelectTrigger>
</FormControl>
<SelectContent>
{offersList.map((offer) => {
if (!offer) return;
return (
<SelectItem
value={offer.id}
key={offer.id}
>{`${offer.amount}x ${offer.Crypto.name} - ${offer.User.pseudo}`}</SelectItem>
);
})}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
</TabsContent>
<TabsContent
value="server"
className={"flex flex-col justify-start items-center"}
>
{!props.cryptoData.quantity && (
<div
className={
"bg-destructive text-destructive-foreground border-destructive rounded p-2 flex justify-start items-center gap-2"
}
>
<Ban />
<p>The server dont have stock for the designated cryptos.</p>
</div>
)}
<div className={"w-full flex justify-center gap-2 items-center p-2"}>
<p>Available quantity on the server :</p>
<p className={"p-1 bg-accent text-accent-foreground rounded m-1"}>
{props.cryptoData.quantity}
</p>
</div>
{props.cryptoData.quantity && (
<AutoForm
// Pass the schema to the form
formSchema={buyFromServerSchema}
onSubmit={onBuyFromServerSubmit}
>
<AutoFormSubmit
disabled={isLoading}
className={"gap-2 disabled:bg-secondary-foreground"}
>
<RefreshCw className={`animate-spin ${!isLoading && "hidden"}`} />
<p>Buy from the server</p>
</AutoFormSubmit>
</AutoForm>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,32 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LineChart } from "lucide-react";
// @flow
import * as React from "react";
type Props = {
targetedCryptoId: string;
};
export function ViewModal(props: Props) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost">
<LineChart />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] md:max-w-[800px]">
Test 1,2 ! //From here
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,85 @@
"use client";
import type { ICryptoInWalletInfo } from "@/interfaces/crypto.interface";
import * as React from "react";
import { BuyModal } from "@/components/cryptos/buy-modal";
import { ViewModal } from "@/components/cryptos/view-modal";
import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/ui/data-table";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
type Props = {
cryptosArray: ICryptoInWalletInfo[];
};
export function CryptosTable(props: Props) {
const router = useRouter();
const cryptos = props.cryptosArray;
const columns: ColumnDef<ICryptoInWalletInfo, any>[] = [
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "value",
header: ({ column }) => {
return (
<Button
variant="light"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Value - USD
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
accessorKey: "quantity",
header: ({ column }) => {
return (
<Button
variant="light"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Available from server
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
id: "actions",
cell: ({ row }) => {
const payment = row.original;
return (
<div className={"flex gap-2"}>
<BuyModal cryptoData={row.original} />
<ViewModal targetedCryptoId={row.original.id} />
</div>
);
},
},
];
return (
<>
<DataTable columns={columns} data={cryptos} fieldToFilter={"name"} />
</>
);
}

View File

@ -0,0 +1,88 @@
"use client";
import type {
ICryptoInUserWalletInfo,
ICryptoInWalletInfo,
} from "@/interfaces/crypto.interface";
import * as React from "react";
import { BuyModal } from "@/components/cryptos/buy-modal";
import { ViewModal } from "@/components/cryptos/view-modal";
import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/ui/data-table";
import type { IUserWallet } from "@/interfaces/userdata.interface";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
type Props = {
walletArray: IUserWallet;
};
export function WalletTable(props: Props) {
const router = useRouter();
const wallet = props.walletArray.owned_cryptos;
const columns: ColumnDef<ICryptoInUserWalletInfo, any>[] = [
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "value",
header: ({ column }) => {
return (
<Button
variant="light"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Value - USD
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
accessorKey: "owned_amount",
header: ({ column }) => {
return (
<Button
variant="light"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Amount owned
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
id: "actions",
cell: ({ row }) => {
const payment = row.original;
return (
<div className={"flex gap-2"}>
<p className={"font-light italic text-xs"}>Soon here : Sell, History</p>
</div>
);
},
},
];
return (
<>
<DataTable columns={columns} data={wallet} fieldToFilter={"name"} />
</>
);
}

55
src/components/footer.tsx Normal file
View File

@ -0,0 +1,55 @@
import { Copyright } from "lucide-react";
import Link from "next/link";
export function Footer() {
return (
<footer
className={
"flex flex-col-reverse md:flex-row justify-between gap-2 md:gap-1 self-end order-6 items-center p-2 border-t-2 w-full"
}
>
<div
className={
"flex flex-row gap-1 items-center justify-center md:justify-start md:w-1/3 opacity-50"
}
>
<Copyright className={"w-4"} />
<h4 className={"pr-2"}>Yidhra Studio</h4>
<p>
MIT <em>2024</em>
</p>
</div>
<div
className={
"flex flex-col flex-wrap md:flex-row max-h-24 md:flex-nowrap gap-1 md:gap-2 items-center justify-evenly w-full md:w-1/3"
}
>
<Link
href={"#"}
className={"p-1 hover:-translate-y-1.5 hover:text-primary w-1/2"}
>
<h3 className={"text-nowrap text-center"}>Data privacy</h3>
</Link>
<Link
href={"#"}
className={"p-1 hover:-translate-y-1.5 hover:text-primary w-1/2"}
>
<h3 className={"text-nowrap text-center"}>Terms and conditions</h3>
</Link>
<Link
href={"#"}
className={"p-1 hover:-translate-y-1.5 hover:text-primary w-1/2"}
>
<h3 className={"text-nowrap text-center"}>Legal notice</h3>
</Link>
<Link
href={"#"}
className={"p-1 hover:-translate-y-1.5 hover:text-primary w-1/2"}
>
<h3 className={"text-nowrap text-center"}>Support Center</h3>
</Link>
</div>
<div />
</footer>
);
}

38
src/components/header.tsx Normal file
View File

@ -0,0 +1,38 @@
import { AccountDialog } from "@/components/account-dialog";
import { ThemeBtnSelector } from "@/components/theme-btn-selector";
import Image from "next/image";
import type React from "react";
import Link from "next/link";
export function Header({
title,
children,
}: { title?: string; children?: React.ReactNode }) {
return (
<header
className={
"flex flex-col md:flex-row justify-between items-center w-full p-1 md:px-3 md:py-2 pb-2 border-b-2"
}
>
<Link href={"/"} className={"flex flex-row justify-center md:justify-start items-center w-fit gap-2"}>
<Image src={"neptune.svg"} alt={"Logo of Neptune"} width={42} height={42} />
<h1 className={"font-bold text-xl align-middle text-center text-wrap"}>
{title || "Neptune"}
</h1>
</Link>
<div
className={"flex flex-col md:flex-row w-fit justify-center items-center"}
>
{children}
</div>
<div
className={
"w-1/3 flex flex-row justify-center md:justify-end md:w-fit gap-2 items-center"
}
>
<AccountDialog />
<ThemeBtnSelector />
</div>
</header>
);
}

View File

@ -0,0 +1,154 @@
"use client";
import Link from "next/link";
import * as React from "react";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import { cn } from "@/lib/utils";
import { Boxes, Info } from "lucide-react";
import Image from "next/image";
const components: { title: string; href: string; description: string }[] = [
{
title: "Alert Dialog",
href: "/docs/primitives/alert-dialog",
description:
"A modal dialog that interrupts the user with important content and expects a response.",
},
{
title: "Hover Card",
href: "/docs/primitives/hover-card",
description: "For sighted users to preview content available behind a link.",
},
{
title: "Progress",
href: "/docs/primitives/progress",
description:
"Displays an indicator showing the completion progress of a task, typically displayed as a progress bar.",
},
{
title: "Scroll-area",
href: "/docs/primitives/scroll-area",
description: "Visually or semantically separates content.",
},
{
title: "Tabs",
href: "/docs/primitives/tabs",
description:
"A set of layered sections of content—known as tab panels—that are displayed one at a time.",
},
{
title: "Tooltip",
href: "/docs/primitives/tooltip",
description:
"A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.",
},
];
export function PrimaryNavigationMenu() {
return (
<NavigationMenu>
<NavigationMenuList className={"flex flex-row flex-wrap md:flex-nowrap"}>
<NavigationMenuItem className={"relative"}>
<NavigationMenuTrigger className={"gap-1"}>
<Info className={"w-4"} />
Getting started
</NavigationMenuTrigger>
<NavigationMenuContent className={""}>
<ul className="grid gap-3 p-6 md:w-[400px] lg:w-[500px] lg:grid-cols-[.75fr_1fr]">
<li className="row-span-3">
<NavigationMenuLink asChild>
<a
className="flex h-full w-full select-none flex-col justify-end rounded-md bg-gradient-to-b from-muted/50 to-muted p-6 no-underline outline-none focus:shadow-md"
href="/"
>
<Image
src={"logo-red.svg"}
alt={"Logo of Yidhra Studio"}
width={64}
height={64}
/>
<div className="mb-2 mt-4 text-lg font-medium">shadcn/ui</div>
<p className="text-sm leading-tight text-muted-foreground">
Beautifully designed components that you can copy and paste into
your apps. Accessible. Customizable. Open Source.
</p>
</a>
</NavigationMenuLink>
</li>
<ListItem href="/docs" title="Introduction">
Re-usable components built using Radix UI and Tailwind CSS.
</ListItem>
<ListItem href="/docs/installation" title="Installation">
How to install dependencies and structure your app.
</ListItem>
<ListItem href="/docs/primitives/typography" title="Typography">
Styles for headings, paragraphs, lists...etc
</ListItem>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger className={"gap-1"}>
<Boxes className={"w-4"} />
Features
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] ">
{components.map((component) => (
<ListItem
key={component.title}
title={component.title}
href={component.href}
>
{component.description}
</ListItem>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/docs" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Documentation
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
);
}
const ListItem = React.forwardRef<
React.ElementRef<"a">,
React.ComponentPropsWithoutRef<"a">
>(({ className, title, children, ...props }, ref) => {
return (
<li>
<NavigationMenuLink asChild>
<a
ref={ref}
className={cn(
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<div className="text-sm font-medium leading-none">{title}</div>
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
{children}
</p>
</a>
</NavigationMenuLink>
</li>
);
});
ListItem.displayName = "ListItem";

View File

@ -0,0 +1,14 @@
"use client";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { UserDataProvider } from "@/components/providers/userdata-provider";
import type React from "react";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<UserDataProvider>{children}</UserDataProvider>
</ThemeProvider>
);
}

View File

@ -0,0 +1,9 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes/dist/types";
import * as React from "react";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,25 @@
import type { IUserData } from "@/interfaces/userdata.interface";
import { useEncodedLocalStorage } from "@/services/localStorage";
import React from "react";
export interface IUserDataProvider {
userData: IUserData | undefined;
setUserData: React.Dispatch<React.SetStateAction<IUserData | undefined>>;
}
export const UserDataContext = React.createContext<IUserDataProvider | undefined>(
undefined,
);
export const UserDataProvider = ({ children }: { children: React.ReactNode }) => {
const [userData, setUserData] = useEncodedLocalStorage<IUserData | undefined>(
"user_data",
undefined,
);
return (
<UserDataContext.Provider value={{ userData, setUserData }}>
{children}
</UserDataContext.Provider>
);
};

View File

@ -0,0 +1,34 @@
"use client";
import { MoonStar, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ThemeBtnSelector() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<MoonStar className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,92 @@
import {
type ICryptoInWalletInfo,
ITrade,
type IUserWalletCryptos,
} from "@/interfaces/crypto.interface";
import type { IUserData } from "@/interfaces/userdata.interface";
// ----- Request -----
export interface IApiRegisterReq {
firstName: string;
lastName: string;
pseudo: string;
email: string;
password: string;
}
export interface IApiLoginReq {
email: string;
password: string;
}
export interface IApiTradeCreateRq {
id_offer: string;
}
export interface IApiOfferCreateReq {
id_crypto: string;
amount: number;
}
export interface IApiCreateReferralCodeReq {
name: string;
value: number;
}
export interface IApiDoTradeReq {
id_offer: string;
}
export interface IApiDoOfferReq {
id_crypto: string;
amount: number;
}
// ----- Response -----
export interface IAbstractApiResponse {
message?: Array<string> | string;
error?: string;
statusCode?: number;
}
export interface IApiRegisterRes extends IAbstractApiResponse {
access_token?: string;
user?: IUserData;
}
export interface IApiLoginRes extends IAbstractApiResponse {
access_token?: string;
}
export interface IApiUserAssetsRes extends IAbstractApiResponse {
firstName?: string;
lastName?: string;
dollarAvailables?: number;
pseudo?: string;
UserHasCrypto?: IUserWalletCryptos[];
}
export interface IApiAllTradesRes extends IAbstractApiResponse {}
export interface IAllRankRes extends IAbstractApiResponse {}
export interface IAllReferralCodeRes extends IAbstractApiResponse {}
export interface ICreateReferralCodeRes extends IAbstractApiResponse {}
export interface IReferralCodeUpdateRes extends IAbstractApiResponse {}
export interface IReferralCodeDeleteRes extends IAbstractApiResponse {}
export interface IApiAllOffersRes extends IAbstractApiResponse {
id: string;
User: {
pseudo: string;
};
amount: number;
created_at: string;
id_user: string;
Crypto: ICryptoInWalletInfo;
}

View File

@ -0,0 +1,50 @@
export interface IUserWalletCryptos {
Crypto: ICryptoInWalletInfo;
amount: number;
}
export interface ICryptoInWalletInfo {
id: string;
name: string;
value: number;
image: string;
quantity: number;
created_at: string;
updated_at: string;
}
export interface ICryptoInUserWalletInfo extends ICryptoInWalletInfo {
owned_amount: number;
}
export type IAllTrades = ITrade[];
export interface ITrade {
Giver: ISellerIdentity;
Receiver: IBuyerIdentity;
Crypto: ITradedCrypto;
}
export interface ISellerIdentity {
firstName: string;
lastName: string;
pseudo: string;
dollarAvailables: number;
}
export interface IBuyerIdentity {
firstName: string;
lastName: string;
pseudo: string;
dollarAvailables: number;
}
export interface ITradedCrypto {
id: string;
name: string;
value: number;
image: string;
quantity: number;
created_at: string;
updated_at: string;
}

View File

@ -0,0 +1,13 @@
export interface IStandardisedReturn<T> {
state: EReturnState;
message?: string;
resolved?: T;
}
export enum EReturnState {
unauthorized = 0,
clientError = 1,
serverError = 2,
done = 3,
queued = 4,
}

View File

@ -0,0 +1,26 @@
import {
type ICryptoInUserWalletInfo,
type ICryptoInWalletInfo,
IUserWalletCryptos,
} from "@/interfaces/crypto.interface";
export interface IUserData {
id: string;
firstName: string;
lastName: string;
pseudo: string;
email: string;
roleId: string;
isActive: boolean;
dollarAvailables: number;
created_at: string;
updated_at: string;
//TODO get on register
wallet: IUserWallet;
}
export interface IUserWallet {
uat: number;
update_interval: number;
owned_cryptos: ICryptoInUserWalletInfo[];
}

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,54 @@
"use client";
import type {
IAbstractApiResponse,
IAllReferralCodeRes,
IApiAllTradesRes,
IApiDoTradeReq,
IApiUserAssetsRes,
ICreateReferralCodeRes,
} from "@/interfaces/api.interface";
import { ICryptoInWalletInfo, IUserWalletCryptos } from "@/interfaces/crypto.interface";
import { EReturnState, type IStandardisedReturn } from "@/interfaces/general.interface";
import type { IUserData, IUserWallet } from "@/interfaces/userdata.interface";
import ApiRequest from "@/services/apiRequest";
import { AxiosResponse } from "axios";
import type { Dispatch, SetStateAction } from "react";
//TODO Run disconnect task
export function doDisconnect() {
if (typeof window !== "undefined") {
window.localStorage.removeItem("sub");
//Redirect to homepage
window.location.href = "/";
return true;
}
console.log(
"Whut ? Why trying to remove an item from the localStorage when running in SSR ?",
);
return false;
}
export async function getWallet(): Promise<IStandardisedReturn<IApiUserAssetsRes>> {
try {
const ReqRes =
await ApiRequest.authenticated.get.json<IStandardisedReturn<IApiUserAssetsRes>>(
"user/my-assets",
);
console.log(ReqRes.data);
if (ReqRes.status !== 200) {
return {
state: EReturnState.clientError,
};
}
return {
state: EReturnState.done,
resolved: ReqRes.data,
};
} catch (err) {
return {
state: EReturnState.serverError,
};
}
}

View File

@ -0,0 +1,83 @@
"use client";
import axios, { type AxiosResponse } from "axios";
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3333/";
const AxiosConfigs = {
authenticated: {
json: () => {
return {
headers: {
"content-type": "application/json",
Authorization: `Bearer ${typeof window !== "undefined" ? JSON.parse(window.localStorage.getItem("sub") || "not-ssr") : "not-ssr"}`,
},
validateStatus: (status: number) => {
return status < 500; // Resolve only if the status code is less than 500
},
};
},
},
standard: {
json: () => {
return {
headers: {
"content-type": "application/json",
},
validateStatus: (status: number) => {
return status < 500; // Resolve only if the status code is less than 500
},
};
},
},
};
async function doAuthenticatedJsonPostReq<ReqT, ResT>(
route: string,
body: ReqT,
): Promise<AxiosResponse<ResT>> {
return await axios.post(baseUrl + route, body, AxiosConfigs.authenticated.json());
}
async function doAuthenticatedGetReq<ResT>(route: string): Promise<AxiosResponse<ResT>> {
return await axios.get(baseUrl + route, AxiosConfigs.authenticated.json());
}
async function doAuthenticatedPatchReq<ReqT, ResT>(
route: string,
body: ReqT,
): Promise<AxiosResponse<ResT>> {
return await axios.patch(baseUrl + route, body, AxiosConfigs.authenticated.json());
}
async function doAuthenticatedDelReq<ResT>(route: string): Promise<AxiosResponse<ResT>> {
return await axios.delete(baseUrl + route, AxiosConfigs.authenticated.json());
}
//TODO form/multipart req
async function doJsonPostReq<ReqT, ResT>(
route: string,
body: ReqT,
): Promise<AxiosResponse<ResT>> {
return await axios.post(baseUrl + route, body, AxiosConfigs.standard.json());
}
async function doJsonGetReq<ResT>(route: string): Promise<AxiosResponse<ResT>> {
return await axios.get(baseUrl + route, AxiosConfigs.standard.json());
}
const ApiRequest = {
authenticated: {
post: { json: doAuthenticatedJsonPostReq },
patch: { json: doAuthenticatedPatchReq },
delete: { json: doAuthenticatedDelReq },
get: { json: doAuthenticatedGetReq },
},
standard: {
post: { json: doJsonPostReq },
get: { json: doJsonGetReq },
},
};
export default ApiRequest;

View File

View File

@ -0,0 +1,92 @@
"use client";
import type React from "react";
import { useEffect, useRef, useState } from "react";
const localStorage = typeof window !== "undefined" ? window.localStorage : null;
/**
* A custom React hook that allows you to store and retrieve data in the browser's localStorage.
*
* @param {string} key - The key under which the data should be stored in localStorage.
* @param {T} initial - The initial value for the stored data.
* @returns {[T, React.Dispatch<React.SetStateAction<T>>]} - An array containing the stored value and a function to update the stored value.
*/
export function useLocalStorage<T>(
key: string,
initial: T,
): [T, React.Dispatch<React.SetStateAction<T>>] {
const readValue = () => {
const item = localStorage?.getItem(key);
if (item) {
try {
return JSON.parse(item);
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error);
}
}
return initial;
};
const [storedValue, setStoredValue] = useState<T>(readValue);
useEffect(() => {
try {
localStorage?.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.warn(`Error setting localStorage key “${key}”:`, error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
/**
* Custom hook that provides a way to store and retrieve encoded values in local storage.
*
* @template T - The type of the value to be stored.
*
* @param {string} key - The key to be used for storing the encoded value in local storage.
* @param {T} fallbackValue - The fallback value to be used if no value is found in local storage.
*
* @return {readonly [T, React.Dispatch<React.SetStateAction<T>>]} - An array containing the encoded value and a function to update the encoded value.
*/
export function useEncodedLocalStorage<T>(
key: string,
fallbackValue: T,
): readonly [T, React.Dispatch<React.SetStateAction<T>>] {
console.log("Pong !");
const [encodedValue, setEncodedValue] = useState<T>(() => {
const stored = localStorage?.getItem(key);
return stored ? safelyParse(stored, fallbackValue) : fallbackValue;
});
const prevValue = useRef(encodedValue);
useEffect(() => {
console.log({ encodedValue });
if (!b64ValEqual(prevValue.current, encodedValue)) {
localStorage?.setItem(key, safelyStringify(encodedValue));
}
prevValue.current = encodedValue; // Set ref to current value
}, [key, encodedValue]);
return [encodedValue, setEncodedValue] as const;
function safelyParse(stored: string, fallback: T): T {
try {
return JSON.parse(atob(stored));
} catch {
return fallback;
}
}
function b64ValEqual<T>(v1: T, v2: T): boolean {
return btoa(JSON.stringify(v1)) === btoa(JSON.stringify(v2));
}
function safelyStringify(value: T): string {
try {
return btoa(JSON.stringify(value));
} catch {
return btoa(JSON.stringify(fallbackValue));
}
}
}

95
tailwind.config.ts Normal file
View File

@ -0,0 +1,95 @@
import type { Config } from "tailwindcss"
// @ts-ignore
import {default as flattenColorPalette} from "tailwindcss/lib/util/flattenColorPalette";
const config = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate"), addVariablesForColors],
} satisfies Config
// This plugin adds each Tailwind color as a global CSS variable, e.g. var(--gray-200).
function addVariablesForColors({ addBase, theme }: any) {
let allColors = flattenColorPalette(theme("colors"));
let newVars = Object.fromEntries(
Object.entries(allColors).map(([key, val]) => [`--${key}`, val])
);
addBase({
":root": newVars,
});
}
export default config

400
temp.ts Normal file
View File

@ -0,0 +1,400 @@
////////apiTypes.ts
export interface ResponseSuccess {
data: any
status: number
statusText: string
}
export interface ResponseFailed {
code: string
message: string
name: string
response: {
data: {
error: string
message: string
statusCode: number
}
status: number
statusText: string
}
}
///////////////cryptoTypes.ts
export enum RoleName {
user = 'user',
admin = 'admin',
}
export type Role = {
id: string
name: RoleName
created_at?: string
updated_at?: string
}
export type PromoCode = {
id: string
name: string
value: number
}
export type CryptoHistory = {
id: string
id_crypto: string
value: number
created_at: string
updated_at: string
}
export interface Offer {
id: string
User: {
pseudo: string
}
amount: number
created_at: string
id_user: string
Crypto: CryptoData
}
export interface UserAssets {
firstName: string
lastName: string
dollarAvailables: number
pseudo: string
age: number
UserHasCrypto: CryptoData[]
}
export interface Signin {
access_token: string
user: UserExtended
Role: Role
}
export interface CryptoData {
id: string
name: string
value: number
image: string
quantity: number
created_at: string
updated_at: string
}
export interface MyCryptoData {
Crypto: CryptoData
amount: number
id: string
}
export interface Trade {
Giver: User
Receiver: User
Crypto: CryptoData
id: string
}
export interface User {
firstName: string
lastName: string
pseudo: string
dollarAvailables: number
}
export interface UserHasCrypto {
id: string
id_user: string
id_crypto: string
amount: number
createdAt: string
updated_at: string
Crypto: CryptoData
}
export interface UserExtended extends User {
id: string
hash: string
email: string
roleId: string
isActive: boolean
city: string
age: number
created_at: string
updated_at: string
UserHasCrypto?: UserHasCrypto
}
export interface MyTrade {
id: string
id_giver: string
id_receiver: string
id_crypto: string
amount_traded: number
created_at: string
updated_at: string
Crypto: CryptoData
Giver: UserExtended
Receiver: UserExtended
}
export interface AuthData {
access_token: string
user: {
id: string
firstName: string
lastName: string
pseudo: string
hash: null | any
email: string
roleId: string
isActive: boolean
city: string
dollarAvailables: number
age: number
created_at: string
updated_at: string
UserHasCrypto: UserHasCrypto[]
Role: Role
}
}
////////////formTypes.ts
export type RegisterInput = {
firstName: string
lastName: string
pseudo: string
city: string
email: string
password: string
confirmPassword: string
promoCode: string
age: number
}
export type LoginInput = {
email: string
password: string
}
export type RoleInput = {
name: string
}
export type PromoCodeInput = {
name: string
value: number
}
export type TradeInput = {
id_offer: string
}
export type OfferInput = {
id_crypto: string
amount: number
}

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}