diff --git a/apps/www/package.json b/apps/www/package.json index 6d5c76e4..e4236d9e 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -34,11 +34,14 @@ "@builder.io/qwik": "^1.8.0", "@builder.io/qwik-city": "^1.8.0", "@builder.io/qwik-react": "0.5.0", - "@modular-forms/qwik": "^0.27.0", + "@modular-forms/qwik": "^0.29.0", "@nestri/input": "*", "@nestri/libmoq": "*", + "@nestri/sdk": "0.1.0-alpha.11", "@nestri/ui": "*", "@openauthjs/openauth": "^0.2.6", + "@polar-sh/checkout": "^0.1.8", + "@polar-sh/sdk": "^0.21.1", "@qwik-ui/headless": "^0.6.4", "@types/eslint": "8.56.10", "@types/howler": "^2.2.12", diff --git a/apps/www/public/images/steam.png b/apps/www/public/images/steam.png new file mode 100644 index 00000000..357c96a0 Binary files /dev/null and b/apps/www/public/images/steam.png differ diff --git a/apps/www/src/routes/(auth)/device/index.tsx b/apps/www/src/routes/(auth)/device/index.tsx deleted file mode 100644 index 88c75a05..00000000 --- a/apps/www/src/routes/(auth)/device/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { component$ } from "@builder.io/qwik" - -export default component$(() => { - return ( -
- Device -
- ) -}) \ No newline at end of file diff --git a/apps/www/src/routes/(auth)/login-test/index.tsx b/apps/www/src/routes/(auth)/login-test/index.tsx deleted file mode 100644 index 1b606cc1..00000000 --- a/apps/www/src/routes/(auth)/login-test/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { $, component$, useVisibleTask$ } from "@builder.io/qwik"; -import { createClient } from "@openauthjs/openauth/client"; - -function getHashParams(url: URL) { - const urlString = url.toString() - const hash = urlString.substring(urlString.indexOf('#') + 1); // Extract the part after the # - console.log("url", hash) - const params = new URLSearchParams(hash); - const paramsObj = {} as any; - for (const [key, value] of params.entries()) { - paramsObj[key] = decodeURIComponent(value); - } - console.log(paramsObj) - return paramsObj; -} - -function removeURLParams() { - const newURL = window.location.origin + window.location.pathname; // Just origin and path - window.location.replace(newURL); -} - -export default component$(() => { - - const login = $(async () => { - const client = createClient({ - clientID: "www", - issuer: "https://auth.lauryn.dev.nestri.io" - }) - - const { url } = await client.authorize("http://localhost:5173/login-test", "token", { pkce: true }) - window.location.href = url - }) - - // eslint-disable-next-line qwik/no-use-visible-task - useVisibleTask$(async () => { - const urlObj = new URL(window.location.href); - const params = getHashParams(urlObj) - if (params.access_token && params.refresh_token) { - - localStorage.setItem("access_token", params.access_token) - localStorage.setItem("refresh_token", params.refresh_token) - removeURLParams() - } - - - }) - return ( -
- -
- ) -}) \ No newline at end of file diff --git a/apps/www/src/routes/(onboarding)/new/deploy/index.tsx b/apps/www/src/routes/(onboarding)/new/deploy/index.tsx deleted file mode 100644 index f37a8fa6..00000000 --- a/apps/www/src/routes/(onboarding)/new/deploy/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { component$ } from "@builder.io/qwik"; - -export default component$(() => { - return ( - <> -
-
- Step 2 of 2 -

- You're almost done -

-

- Please follow the steps to configure your game and install it -

-
- -
- - ) -}) \ No newline at end of file diff --git a/apps/www/src/routes/(onboarding)/new/game/index.tsx b/apps/www/src/routes/(onboarding)/new/game/index.tsx deleted file mode 100644 index c2b06112..00000000 --- a/apps/www/src/routes/(onboarding)/new/game/index.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { component$ } from "@builder.io/qwik"; - -const games = [ - { - cover: 'https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/444200/library_600x900_2x.jpg', - release_date: 1478710740000, - compatibility: 'playable', - name: 'World of Tanks Blitz', - appid: 444200 - }, - { - cover: 'https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1085660/library_600x900_2x.jpg', - release_date: 1569949200000, - compatibility: 'unsupported', - name: 'Destiny 2', - appid: 1085660 - }, - { - cover: 'https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1172470/library_600x900_2x.jpg', - release_date: 1604548800000, - compatibility: 'playable', - name: 'Apex Legends', - appid: 1172470 - }, - { - cover: 'https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1229380/library_600x900_2x.jpg', - release_date: 1614870001000, - compatibility: 'perfect', - name: 'Everhood', - appid: 1229380 - }, - { - cover: 'https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1637320/library_600x900_2x.jpg', - release_date: 1664296270000, - compatibility: 'perfect', - name: 'Dome Keeper', - appid: 1637320 - }, - { - cover: 'https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2581970/library_600x900_2x.jpg', - release_date: 1698246127000, - compatibility: 'playable', - name: 'Shell Runner - Prelude', - appid: 2581970 - } -] - -export default component$(() => { - return ( - <> -
-
- Step 1 of 2 -

- Let's play something cool -

-

- Choose a game to play from your Steam library -

-
- -
- - ) -}) \ No newline at end of file diff --git a/apps/www/src/routes/(onboarding)/new/index.tsx b/apps/www/src/routes/(onboarding)/new/index.tsx deleted file mode 100644 index 98b6ed00..00000000 --- a/apps/www/src/routes/(onboarding)/new/index.tsx +++ /dev/null @@ -1,217 +0,0 @@ -// import { auth } from "@nestri/ui"; -import { Button } from "@nestri/ui/react" -import { buttonVariants } from "@nestri/ui/design"; -// import { type Provider } from "@supabase/supabase-js"; -import { Link } from "@builder.io/qwik-city"; -import { $, component$, useSignal } from "@builder.io/qwik"; - -// type AuthFlowProps = { -// flow: string -// } - -const flow: any = "join" - -export default component$(() => { - const isLoading = useSignal(false); - const isGHLoading = useSignal(false); - const setIsLoading = $((v: boolean) => isLoading.value = v) - const setIsGHLoading = $((v: boolean) => isGHLoading.value = v) - // const location = useLocation(); - - // const authenticateUser = $(async (provider: any) => { - // await auth.openWindow(`${location.url.origin}/api/auth/${provider}`) - // }) - - return ( - <> -
-
- {/* Nestri Logo */} -

- {flow == "login" ? "Login" : "Join"} -

- {flow == "join" ? ( -

- Already have an account? - Login - -

- ) : ( -

- Don't have an account yet? - Join - -

- )} -
- {/* await authenticateUser("discord")} - size="md" - class="w-full gap-4 from-[#5865F2] to-[#4445e7] [--btn-border-color:#3836cc] dark:border-[#8093f9]/75"> - - - - - - - - Continue with Discord - - */} - - - - - - - - - - Link your Steam account - -
- - - - - - - - - - Link your Epic Games account - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Link your GOG.com account - -
- - - {flow == "join" ? ( -

- By creating an account, you agree to our
- Terms of Service - and Privacy Policy - -

- ) : ( -
- )} -
-
- - ) -}) \ No newline at end of file diff --git a/apps/www/src/routes/home/index.tsx b/apps/www/src/routes/(play)/[user]/index.tsx similarity index 59% rename from apps/www/src/routes/home/index.tsx rename to apps/www/src/routes/(play)/[user]/index.tsx index 1993ed6e..217927a8 100644 --- a/apps/www/src/routes/home/index.tsx +++ b/apps/www/src/routes/(play)/[user]/index.tsx @@ -1,8 +1,10 @@ -import { Avatar } from "@nestri/ui"; -// import { } from "@qwik-ui/headless"; -import { component$ } from "@builder.io/qwik"; -import { HomeNavBar, Modal, SimpleFooter } from "@nestri/ui"; import { cn } from "@nestri/ui/design"; +import type Nestri from "@nestri/sdk"; +import { Modal } from "@qwik-ui/headless"; +import { Avatar, Icons } from "@nestri/ui"; +import { Link, routeLoader$ } from "@builder.io/qwik-city"; +import { HomeNavBar, SimpleFooter, GameStoreButton } from "@nestri/ui"; +import { component$, useSignal, useStore, useVisibleTask$ } from "@builder.io/qwik"; const games = [ { @@ -52,60 +54,96 @@ const games = [ ] +export const useCurrentProfile = routeLoader$(async ({ sharedMap }) => { + const res = sharedMap.get("profile") as Nestri.Users.UserRetrieveResponse.Data | null + + return res + // return { + // avatarUrl: undefined, + // discriminator: 47, + // username: "WanjohiRyan" + // } +}) +//bg-blue-100 rounded-lg p-4 min-w-16 text-center +const TimeUnit = ({ value, label }: { value: number, label: string }) => ( +
+
+ {new Array(2).fill(0).map((_, key) => { + const [digitOne, digitTwo] = value.toString().padStart(2, '0') + return ( +
+
+
9
+
0
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
0
+
+
+ ) + })} +
+
{label}
+
+); + +const random = Math.floor(100 * Math.random()) + export default component$(() => { + const profile = useCurrentProfile() + const isNewPerson = useSignal(false) + const targetDate = new Date('2025-01-29T23:59:00Z'); + + const timeLeft = useStore({ + days: 0, + hours: 0, + minutes: 0, + seconds: 0 + }) + + // eslint-disable-next-line qwik/no-use-visible-task + useVisibleTask$(() => { + isNewPerson.value = true + + const calculateTimeLeft = () => { + const difference = targetDate.getTime() - new Date().getTime(); + + if (difference > 0) { + const days = Math.floor(difference / (1000 * 60 * 60 * 24)); + const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((difference % (1000 * 60)) / 1000); + + timeLeft.days = days + timeLeft.hours = hours + timeLeft.minutes = minutes + timeLeft.seconds = seconds + } + }; + + calculateTimeLeft(); + + const timer = setInterval(calculateTimeLeft, 1000); + + return () => clearInterval(timer); + }) + return ( -
- +
+ {profile.value && }
- - - + - + Add another Linux machine @@ -137,7 +175,7 @@ export default component$(() => {
-
+

@@ -176,15 +214,15 @@ export default component$(() => { maskImage: `url('data:image/svg+xml,'),url('data:image/svg+xml,')` }} > - +
))}
- +
-

{`${Math.floor(Math.random() * 100)} people are currently playing this game`}

+

{`${(key + 1) * random} people are currently playing this game`}

@@ -210,7 +248,7 @@ export default component$(() => { -
+
@@ -287,13 +325,13 @@ export default component$(() => { {game.name}
-
-
+
{ Delta Force is a first-person shooter which offers players both a single player campaign based on the movie Black Hawk Down, but also large-scale PvP multiplayer action. The game was formerly known as Delta Force: Hawk Ops.

@@ -341,8 +379,112 @@ export default component$(() => { ))}
- + + + +
+
+ +
+
+
+
+ + + + + + +
+
Nestri needs your help
+
+ {profile.value && profile.value.avatarUrl ? (Avatar) : ()} + + {profile.value && profile.value.username} + +
+
+
+
+
What's wrong?
+
+
+
+
+
1
+
We're almost ready to launch Nestri, but server costs are our biggest hurdle right now.
+
+
+
2
+
As a bootstrapped startup (yeah, just a few passionate developers!), we're reaching out to our early believers.
+
+
+
3
+
Your early access subscription will directly fund our initial server infrastructure, helping us bring self-hosted cloud gaming to life.
+
+
+
+
+
+ What you get +
+
+
+
+ +
+
+ Schedule 1-on-1 calls with the Founders +
+
+
+
+ +
+
+ Keep your special early supporter pricing forever +
+
+
+
+ +
+
+ Priority feature requests +
+
+
+
+
+
Full access in
+
+ + + + +
+
+
+
+ {/**https://sandbox-api.polar.sh/v1/checkout-links/polar_cl_3Kf9mOEl8We2ZnmYr0tolrFPfHiPvlC71XgZy4Jd2ni/redirect */} + Get early supporter price +
+ +
+
+
+
+
+
+
+
+ + + {/*
*/} ) }) \ No newline at end of file diff --git a/apps/www/src/routes/(play)/layout.tsx b/apps/www/src/routes/(play)/layout.tsx new file mode 100644 index 00000000..ac31b8b1 --- /dev/null +++ b/apps/www/src/routes/(play)/layout.tsx @@ -0,0 +1,17 @@ +import type Nestri from "@nestri/sdk" +import { component$, Slot } from "@builder.io/qwik"; +import { type RequestHandler } from "@builder.io/qwik-city"; + +export const onRequest: RequestHandler = async ({ url, redirect, sharedMap }) => { + const currentProfile = sharedMap.get("profile") as Nestri.Users.UserRetrieveResponse.Data | null + + if (!currentProfile) { + throw redirect(308, `${url.origin}`) + } +} + +export default component$(() => { + return ( + + ) +}) \ No newline at end of file diff --git a/apps/www/src/routes/play/[id]/index.tsx b/apps/www/src/routes/(play)/play/[id]/index.tsx similarity index 100% rename from apps/www/src/routes/play/[id]/index.tsx rename to apps/www/src/routes/(play)/play/[id]/index.tsx diff --git a/apps/www/src/routes/(legal)/privacy/index.tsx b/apps/www/src/routes/(public)/(legal)/privacy/index.tsx similarity index 85% rename from apps/www/src/routes/(legal)/privacy/index.tsx rename to apps/www/src/routes/(public)/(legal)/privacy/index.tsx index e7443dca..5b83f9ee 100644 --- a/apps/www/src/routes/(legal)/privacy/index.tsx +++ b/apps/www/src/routes/(public)/(legal)/privacy/index.tsx @@ -1,26 +1,14 @@ /* eslint-disable qwik/no-react-props */ -import { Title, Text} from "@nestri/ui/react"; -import { buttonVariants, cn } from "@nestri/ui/design"; -import { component$ } from "@builder.io/qwik"; import { Link } from "@builder.io/qwik-city"; +import { Title, Text } from "@nestri/ui/react"; +import { component$ } from "@builder.io/qwik"; +import { buttonVariants } from "@nestri/ui/design"; export default component$(() => { return (
- {/**Gradient to hide the ending of the checkered bg at the bottom*/} - {/*
*/} - -
- +
Nestri's Privacy Policy @@ -177,6 +165,7 @@ export default component$(() => { <Text align="center" className="pt-3"> 💖 Thank you for trusting Nestri with your data and gaming experience.💖 <br /> + <br /> We are committed to safeguarding your personal information and ensuring your privacy. </Text> </div> </section> diff --git a/apps/www/src/routes/(legal)/terms/index.tsx b/apps/www/src/routes/(public)/(legal)/terms/index.tsx similarity index 85% rename from apps/www/src/routes/(legal)/terms/index.tsx rename to apps/www/src/routes/(public)/(legal)/terms/index.tsx index 988067b5..4dc2923b 100644 --- a/apps/www/src/routes/(legal)/terms/index.tsx +++ b/apps/www/src/routes/(public)/(legal)/terms/index.tsx @@ -1,24 +1,13 @@ /* eslint-disable qwik/no-react-props */ -import { Title, Text } from "@nestri/ui/react"; -import { buttonVariants, cn } from "@nestri/ui/design"; -import { component$ } from "@builder.io/qwik"; import { Link } from "@builder.io/qwik-city"; +import { Title, Text } from "@nestri/ui/react"; +import { component$ } from "@builder.io/qwik"; +import { buttonVariants } from "@nestri/ui/design"; export default component$(() => { return ( <div class="w-screen relative" > - {/**Gradient to hide the ending of the checkered bg at the bottom*/} - {/* <div class="absolute inset-0 dark:[background:radial-gradient(60.1852%_65%_at_50%_52%,rgba(255,255,255,0)_41.4414%,theme(colors.gray.950,0.7)_102%)] [background:radial-gradient(60.1852%_65%_at_50%_52%,rgba(255,255,255,0)_41.4414%,theme(colors.gray.50,0.7)_102%)] h-screen w-screen overflow-hidden max-w-[100vw] top-0 left-0 right-0 select-none" /> */} - <nav class="w-full h-[70px] lg:flex hidden sticky top-0 z-50 py-4 justify-center items-center" > - <div class="w-full left-1/2 relative -translate-x-[40%]"> - <Link href="/" class={cn(buttonVariants.outlined({ intent: "neutral", size: "md" }), "w-max")}> - <svg xmlns="http://www.w3.org/2000/svg" class="size-[20px] -rotate-90" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"><path stroke-linejoin="round" d="m17 9.5l-5-5l-5 5" /><path d="M12 4.5v10c0 1.667-1 5-5 5" opacity=".5" /></g></svg> - {/* <svg xmlns="http://www.w3.org/2000/svg" class="size-[20px]" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10" opacity=".5" /><path stroke-linecap="round" stroke-linejoin="round" d="m15.5 9l-3 3l3 3m-4-6l-3 3l3 3" /></g></svg> */} - Go Back - </Link> - </div> - </nav> - <section class="px-4 relative lg:-top-[70px]" > + <section class="px-4 relative" > <div class="mx-auto select-text max-w-xl overflow-x-hidden py-8 [&_h1]:text-3xl flex relative gap-4 w-full flex-col" > <Title className="py-4 text-4xl" > Nestri's Terms of Service @@ -30,7 +19,7 @@ export default component$(() => { </Text> <Text> - Welcome to Nestri and thank you for using our services. We are an innovative cloud gaming platform that offers both self-hosted and hosted versions for gamers without GPUs. + Welcome to Nestri and thank you for using our services. We are an innovative cloud gaming platform that offers both self-hosted and hosted versions for gamers to play online with friends and family. <br /> <br /> By using Nestri, you agree to these Terms of Service ("Terms"). If you have any questions, feel free to contact us. @@ -38,11 +27,11 @@ export default component$(() => { <Title>Who We Are - Nestri is an open-source cloud gaming platform that lets you play games on your own terms — invite friends to join your gaming sessions, share your game library, and take even more control by hosting your own gaming server. Our hosted version is perfect for those who need GPU support, providing seamless gaming experiences for everyone. + Nestri is an open-source cloud gaming platform that lets you play games on your own terms — invite friends to join your gaming sessions, share your game library, and take even more control by hosting your own gaming server. Our hosted version is perfect for those who need GPU support. Privacy and Security - We take your privacy and security very seriously. We adhere to stringent data protection laws and ensure that all data, including games downloaded from Steam on behalf of the user, is encrypted. We also collect and log IP addresses to avoid abuse and ensure the security of our services. For more details, please review our + We take your privacy and security very seriously. We adhere to stringent data protection laws and ensure that all data, is secured. We also collect and log IP addresses to avoid abuse and ensure the security of our services. For more details, please review our @@ -59,7 +48,7 @@ export default component$(() => {

Check the list here.
@@ -176,7 +165,7 @@ export default component$(() => { .
- 💖 Thank you for choosing Nestri for your cloud gaming needs! 💖 + 💖 Thank you for choosing Nestri for your cloud gaming needs! 💖
diff --git a/apps/www/src/routes/blog/blog.css b/apps/www/src/routes/(public)/blog/blog.css similarity index 100% rename from apps/www/src/routes/blog/blog.css rename to apps/www/src/routes/(public)/blog/blog.css diff --git a/apps/www/src/routes/blog/gpu-passthru/index.mdx b/apps/www/src/routes/(public)/blog/gpu-passthru/index.mdx similarity index 100% rename from apps/www/src/routes/blog/gpu-passthru/index.mdx rename to apps/www/src/routes/(public)/blog/gpu-passthru/index.mdx diff --git a/apps/www/src/routes/blog/how-nestri-works/index.mdx b/apps/www/src/routes/(public)/blog/how-nestri-works/index.mdx similarity index 100% rename from apps/www/src/routes/blog/how-nestri-works/index.mdx rename to apps/www/src/routes/(public)/blog/how-nestri-works/index.mdx diff --git a/apps/www/src/routes/blog/index.tsx b/apps/www/src/routes/(public)/blog/index.tsx similarity index 97% rename from apps/www/src/routes/blog/index.tsx rename to apps/www/src/routes/(public)/blog/index.tsx index 4b3bec86..0e0b18ba 100644 --- a/apps/www/src/routes/blog/index.tsx +++ b/apps/www/src/routes/(public)/blog/index.tsx @@ -1,6 +1,5 @@ -import { component$ } from "@builder.io/qwik" import { Link } from "@builder.io/qwik-city" -import { NavBar } from "@nestri/ui" +import { component$ } from "@builder.io/qwik" import { MotionComponent, TitleSection, transition } from "@nestri/ui/react" const blogs = [ @@ -15,7 +14,6 @@ const blogs = [ export default component$(() => { return (
- { return ( <> - { return ( <> - { return ( <> - {
-
+
) }) diff --git a/apps/www/src/routes/index.tsx b/apps/www/src/routes/(public)/index.tsx similarity index 83% rename from apps/www/src/routes/index.tsx rename to apps/www/src/routes/(public)/index.tsx index 7b8aefb7..2c01f495 100644 --- a/apps/www/src/routes/index.tsx +++ b/apps/www/src/routes/(public)/index.tsx @@ -1,8 +1,9 @@ -import { component$ } from "@builder.io/qwik"; -import { type DocumentHead } from "@builder.io/qwik-city"; -import { HeroSection, MotionComponent, transition } from "@nestri/ui/react" -import { NavBar, Footer } from "@nestri/ui" +import { Footer } from "@nestri/ui" import { cn } from "@nestri/ui/design"; +import { component$ } from "@builder.io/qwik"; +import { HeroSection, MotionComponent, transition } from "@nestri/ui/react" +import { type RequestHandler, type DocumentHead } from "@builder.io/qwik-city"; + const tags = [ { @@ -40,25 +41,49 @@ const games = [ "https://assets-prd.ignimgs.com/2023/03/22/keyart-wide-1-1679503853654-1679505306655.jpeg", "https://assets-prd.ignimgs.com/2022/11/09/coffee-talk-episode-1-button-fin-1668033710468.jpg", "https://assets-prd.ignimgs.com/2022/06/15/stalker2chornobyl-1655253282275.jpg", - "https://assets-prd.ignimgs.com/2022/05/24/call-of-duty-modern-warfare-2-button-02-1653417394041.jpg", + "https://assets-prd.ignimgs.com/2023/05/27/alanwake2-1685200534966.jpg", "https://assets-prd.ignimgs.com/2023/02/16/apexrevelry-1676588335122.jpg" ] +export const onGet: RequestHandler = async ({ request, send }) => { + const userAgent = request.headers.get('user-agent') || '' + const isCurl = userAgent.toLowerCase().includes('curl'); + + //TODO: + if (isCurl) { + const response = new Response( + `#!/bin/bash + +echo "Not yet ready 😅\n" + +echo "Consider joining our Discord channel (https://discord.com/invite/Y6etn3qKZ3) for the latest updates \n" + `, { + status: 200, + headers: { + 'Content-Type': 'text/plain', + 'Content-Disposition': 'attachment; filename="nestri.sh"' + } + }) + send(response) + } +}; + // FIXME: Change up the copy -//TODO: Use a db to query all this -//TODO: Add the search modal -// TODO: Add the game modal +//TODO: Add the demo video/gif export default component$(() => { + return (
-
- +
@@ -254,20 +274,20 @@ export default component$(() => {
- +
-

Family

+

Pro

@@ -329,7 +349,7 @@ export default component$(() => { class="overflow-hidden absolute cursor-pointer z-30 top-0 left-0 opacity-0 h-full w-full" />
-
+

{convertToTitle(priceValue.value)}

@@ -455,14 +475,14 @@ export default component$(() => {
- +
1 Feature is in development @@ -483,15 +503,15 @@ export default component$(() => {

Looking for something else? Use Nestri as your own on our servers or yours. Flexible licensing and white-glove onboarding included.

- +
- Organization Account · Security Restrictions · Custom Events · Single Sign On · Advanced Integrations · Additional APIs · Custom-Built Features · - Organization Account · Security Restrictions · Custom Events · Single Sign On · Advanced Integrations · Additional APIs · Custom-Built Features · + Organization Account · Security Restrictions · Custom Parties · Single Sign On · Advanced Integrations · Additional APIs · Custom-Built Features · + Organization Account · Security Restrictions · Custom Parties · Single Sign On · Advanced Integrations · Additional APIs · Custom-Built Features ·
diff --git a/apps/www/src/routes/(public)/thanks/index.tsx b/apps/www/src/routes/(public)/thanks/index.tsx new file mode 100644 index 00000000..d7e2f89d --- /dev/null +++ b/apps/www/src/routes/(public)/thanks/index.tsx @@ -0,0 +1,85 @@ +import Nestri from '@nestri/sdk'; +import { routeLoader$ } from '@builder.io/qwik-city'; +import { component$, useVisibleTask$ } from '@builder.io/qwik'; + +// function getCookie(cname: string) { +// const name = cname + "="; +// const ca = document.cookie.split(';'); +// for (let i = 0; i < ca.length; i++) { +// let c = ca[i]; +// while (c.charAt(0) == ' ') { +// c = c.substring(1); +// } +// if (c.indexOf(name) == 0) { +// return c.substring(name.length, c.length); +// } +// } +// return ""; +// } + +// function getParams(url: URL) { +// const urlString = url.toString() +// const hash = urlString.substring(urlString.indexOf('?') + 1); // Extract the part after the # +// const params = new URLSearchParams(hash); +// const paramsObj = {} as any; +// for (const [key, value] of params.entries()) { +// paramsObj[key] = decodeURIComponent(value); +// } +// console.log(paramsObj) +// return paramsObj; +// } + +//FIXME: There is an issue where the cookie cannot be found, tbh, i dunno what drugs Qwik is on +export const useSubscribe = routeLoader$(async ({ url, cookie }) => { + const access = cookie.get("access_token") + if (access) { + const bearerToken = access.value + console.log("bearerToken", bearerToken) + + const nestriClient = new Nestri({ + bearerToken, + baseURL: "https://api.nestri.io" + }) + + const checkout_id = url.searchParams.get("checkout") + + if (checkout_id) { + console.log("checkout", checkout_id) + + await nestriClient.subscriptions.create({ + checkoutID: checkout_id + }) + + return "okey" + } + } +}) + +export default component$(() => { + const subscribe = useSubscribe() + // eslint-disable-next-line qwik/no-use-visible-task + useVisibleTask$(async () => { + console.log("subscribe", subscribe) + }) + // const bearerToken = getCookie("access_token") + // // console.log("bearerToken", bearerToken) + // const nestriClient = new Nestri({ + // bearerToken, + // baseURL: "https://api.lauryn.dev.nestri.io" + // }) + // const urlObj = new URL(window.location.href) + // const checkout_id = getParams(urlObj).checkout + + // if (checkout_id) { + // await nestriClient.subscriptions.create({ + // checkoutID: checkout_id + // }) + // } + // }) + + return ( +
+

Thank you, now check your email for more details

+
+ ) +}) \ No newline at end of file diff --git a/apps/www/src/routes/callback/index.tsx b/apps/www/src/routes/callback/index.tsx new file mode 100644 index 00000000..fdea0732 --- /dev/null +++ b/apps/www/src/routes/callback/index.tsx @@ -0,0 +1,73 @@ +import Nestri from "@nestri/sdk"; +import { component$, useVisibleTask$ } from "@builder.io/qwik"; +import { createClient } from "@openauthjs/openauth/client"; +import { routeLoader$, useNavigate, type CookieOptions } from "@builder.io/qwik-city"; + +export const useLoggedIn = routeLoader$(async ({ query, url, cookie }) => { + const code = query.get("code") + if (code) { + const redirect_uri = url.origin + "/callback" + const cookieOptions: CookieOptions = { + path: "/", + sameSite: "lax", + secure: false, // Only send cookies over HTTPS + //FIXME: This causes weird issues in Qwik + httpOnly: true, // Prevent JavaScript access to cookies + expires: new Date(Date.now() + 24 * 10 * 60 * 60 * 1000), // expires in like 10 days + } + + const client = createClient({ + clientID: "www", + issuer: "https://auth.nestri.io" + }) + + const tokens = await client.exchange(code, redirect_uri) + if (!tokens.err) { + const access_token = tokens.tokens.access + const refresh_token = tokens.tokens.refresh + + cookie.set("access_token", access_token, cookieOptions) + cookie.set("refresh_token", refresh_token, cookieOptions) + + const bearerToken = access_token + + const nestriClient = new Nestri({ + bearerToken, + baseURL: "https://api.nestri.io" + }) + + //TODO: Use subjects instead + const currentProfile = await nestriClient.users.retrieve() + const username = currentProfile.data.username + return username + } + } +}) + +export default component$(() => { + const username = useLoggedIn() + const navigate = useNavigate(); + + // eslint-disable-next-line qwik/no-use-visible-task + useVisibleTask$(() => { + if (username.value) { + setTimeout(async () => { + await navigate(`${window.location.origin}/${username.value}`) + }, 500); + } + }) + + return ( +
+ +
+
+ {new Array(12).fill(0).map((i, k) => ( +
+ ))} +
+
+ We are confirming your identity... +
+ ) +}) \ No newline at end of file diff --git a/apps/www/src/routes/layout.tsx b/apps/www/src/routes/layout.tsx index 38176ed0..59b6e6e3 100644 --- a/apps/www/src/routes/layout.tsx +++ b/apps/www/src/routes/layout.tsx @@ -1,6 +1,7 @@ -import { component$, Slot } from "@builder.io/qwik"; +import Nestri from "@nestri/sdk"; import { NavProgress } from "@nestri/ui"; -import type { DocumentHead, RequestHandler } from "@builder.io/qwik-city"; +import { component$, Slot } from "@builder.io/qwik"; +import { type DocumentHead, type RequestHandler } from "@builder.io/qwik-city"; export const onGet: RequestHandler = async ({ cacheControl }) => { // Control caching for this request for best performance and to reduce hosting costs: @@ -13,6 +14,21 @@ export const onGet: RequestHandler = async ({ cacheControl }) => { }); }; +export const onRequest: RequestHandler = async ({ cookie, sharedMap }) => { + const access = cookie.get("access_token") + if (access) { + const bearerToken = access.value + + const nestriClient = new Nestri({ + bearerToken, + baseURL: "https://api.nestri.io" + }) + + const currentProfile = await nestriClient.users.retrieve() + sharedMap.set("profile", currentProfile.data) + } +} + export default component$(() => { return ( <> diff --git a/bun.lockb b/bun.lockb index f07d1f0a..87c2a0ab 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/infra/cli.ts b/infra/cli.ts deleted file mode 100644 index 0b5f2728..00000000 --- a/infra/cli.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { auth, urls } from "./api" - - -// export const cmd = new sst.x.DevCommand("Cmd", { -// link: [urls, auth], -// dev: { -// autostart: true, -// command: "cd packages/cmd && go run main.go" -// } -// }) \ No newline at end of file diff --git a/infra/dns.ts b/infra/dns.ts index 2dd0569e..e23d3a8e 100644 --- a/infra/dns.ts +++ b/infra/dns.ts @@ -1,6 +1,6 @@ export const domain = { - production: "prod.nestri.io", //temporary use until we go into the real production + production: "nestri.io", dev: "dev.nestri.io", }[$app.stage] || $app.stage + ".dev.nestri.io"; diff --git a/packages/core/instant.schema.ts b/packages/core/instant.schema.ts index 3b28c460..a841847c 100644 --- a/packages/core/instant.schema.ts +++ b/packages/core/instant.schema.ts @@ -14,11 +14,17 @@ const _schema = i.schema({ profiles: i.entity({ avatarUrl: i.string().optional(), username: i.string().indexed(), - ownerID: i.string().unique().indexed(), updatedAt: i.date(), createdAt: i.date(), discriminator: i.string().indexed() }), + teams: i.entity({ + name: i.string(), + slug: i.string().unique().indexed(), + deletedAt: i.date().optional().indexed(), + updatedAt: i.date(), + createdAt: i.date(), + }), games: i.entity({ name: i.string(), steamID: i.number().unique().indexed(), @@ -29,12 +35,31 @@ const _schema = i.schema({ endedAt: i.date().optional().indexed(), public: i.boolean().indexed(), }), + subscriptions: i.entity({ + checkoutID: i.string(), + // quantity: i.number(), + // frequency: i.string(), + canceledAt: i.date(), + // next: i.date() + }) }, links: { + UserSubscriptions: { + forward: { on: "subscriptions", has: "one", label: "owner" }, + reverse: { on: "$users", has: "many", label: "subscriptions" } + }, UserProfiles: { forward: { on: "profiles", has: "one", label: "owner" }, reverse: { on: "$users", has: "one", label: "profile" } }, + TeamsOwned: { + forward: { on: "teams", has: "one", label: "owner" }, + reverse: { on: "$users", has: "many", label: "teamsOwned" }, + }, + TeamsJoined: { + forward: { on: "teams", has: "many", label: "members" }, + reverse: { on: "$users", has: "many", label: "teamsJoined" }, + }, UserMachines: { forward: { on: "machines", has: "one", label: "owner" }, reverse: { on: "$users", has: "many", label: "machines" } diff --git a/packages/core/src/email/index.ts b/packages/core/src/email/index.ts index b552aa33..769f1b31 100644 --- a/packages/core/src/email/index.ts +++ b/packages/core/src/email/index.ts @@ -22,4 +22,24 @@ export namespace Email { console.log("error sending email", error) } } + + export async function sendWelcome( + to: string, + name: string, + ) { + + try { + await Client().sendTransactionalEmail( + { + transactionalId: "cm61jrbbx02twlstfwfcywt5u", + email: to, + dataVariables: { + name + } + } + ); + } catch (error) { + console.log("error sending email", error) + } + } } \ No newline at end of file diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 17b2c130..5a99b1f3 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -14,6 +14,25 @@ export module Examples { updatedAt: '2025-01-09T01:56:23.902Z' } + export const Subscription = { + id: "0bfcb712-df13-4454-81a8-fbee66eddca4", + checkoutID: "0bfcb712-df43-4454-81a8-fbee66eddca4", + // productID: "0bfcb712-df43-4454-81a8-fbee66eddca4", + // quantity: 1, + // frequency: "monthly" as const, + // next: '2025-01-09T01:56:23.902Z', + canceledAt: '2025-02-09T01:56:23.902Z' + } + + export const Team = { + id: "0bfcb712-df13-4454-81a8-fbee66eddca4", + owner: true, + name: "Jane Doe's Games", + slug: "jane-does-games", + createdAt: '2025-01-04T11:56:23.902Z', + updatedAt: '2025-01-09T01:56:23.902Z' + } + export const Machine = { id: "0bfcb712-df13-4454-81a8-fbee66eddca4", hostname: "DESKTOP-EUO8VSF", diff --git a/packages/core/src/profile/index.ts b/packages/core/src/profile/index.ts index f28bdea3..1d20fb99 100644 --- a/packages/core/src/profile/index.ts +++ b/packages/core/src/profile/index.ts @@ -5,6 +5,7 @@ import { Examples } from "../examples"; import databaseClient from "../database"; import { groupBy, map, pipe, values } from "remeda" import { id as createID } from "@instantdb/admin"; +import { useCurrentUser } from "../actor"; export module Profiles { const MAX_ATTEMPTS = 50; @@ -124,10 +125,6 @@ export module Profiles { export const create = fn(z.object({ username: z.string(), customDiscriminator: z.string().optional(), avatarUrl: z.string().optional(), owner: z.string() }), async (input) => { const username = sanitizeUsername(input.username); - // if (!username || username.length < 2 || username.length > 32) { - // // throw new Error('Invalid username length'); - // } - const db = databaseClient() const id = createID() const now = new Date().toISOString() @@ -150,6 +147,7 @@ export module Profiles { } } } + const res = await db.query(query) const profiles = res.profiles if (profiles.length != 0) { @@ -176,7 +174,6 @@ export module Profiles { avatarUrl: input.avatarUrl, createdAt: now, updatedAt: now, - ownerID: input.owner, discriminator, }).link({ owner: input.owner }) ) @@ -214,7 +211,7 @@ export module Profiles { profiles: { $: { where: { - ownerID + owner: ownerID } }, } @@ -227,6 +224,27 @@ export module Profiles { return null } - return profiles + const profile = pipe( + profiles, + groupBy(x => x.id), + values(), + map((group): Info => ({ + id: group[0].id, + username: group[0].username, + createdAt: group[0].createdAt, + updatedAt: group[0].updatedAt, + avatarUrl: group[0].avatarUrl, + discriminator: group[0].discriminator + })) + ) + + return profile[0] + } + + export const getCurrentProfile = async () => { + const user = useCurrentUser() + const currentProfile = await getProfile(user.id); + + return currentProfile } }; \ No newline at end of file diff --git a/packages/core/src/subscription/index.ts b/packages/core/src/subscription/index.ts new file mode 100644 index 00000000..44b567c4 --- /dev/null +++ b/packages/core/src/subscription/index.ts @@ -0,0 +1,205 @@ +import { z } from "zod"; +import databaseClient from "../database" +import { fn } from "../utils"; +import { groupBy, map, pipe, values } from "remeda" +import { Common } from "../common"; +import { Examples } from "../examples"; +import { useCurrentUser } from "../actor"; +import { id as createID } from "@instantdb/admin"; +import { Email } from "../email"; +import { Profiles } from "../profile"; + +export const SubscriptionFrequency = z.enum([ + "fixed", + "daily", + "weekly", + "monthly", + "yearly", +]); + +export type SubscriptionFrequency = z.infer; + +export namespace Subscriptions { + export const Info = z + .object({ + id: z.string().openapi({ + description: Common.IdDescription, + example: Examples.Subscription.id, + }), + checkoutID: z.string().openapi({ + description: "The polar.sh checkout id", + example: Examples.Subscription.checkoutID, + }), + // productID: z.string().openapi({ + // description: "ID of the product being subscribed to.", + // example: Examples.Subscription.productID, + // }), + // quantity: z.number().int().openapi({ + // description: "Quantity of the subscription.", + // example: Examples.Subscription.quantity, + // }), + // frequency: SubscriptionFrequency.openapi({ + // description: "Frequency of the subscription.", + // example: Examples.Subscription.frequency, + // }), + // next: z.string().or(z.number()).openapi({ + // description: "Next billing date for the subscription.", + // example: Examples.Subscription.next, + // }), + canceledAt: z.string().or(z.number()).optional().openapi({ + description: "Cancelled date for the subscription.", + example: Examples.Subscription.canceledAt, + }), + }) + .openapi({ + ref: "Subscription", + description: "Subscription to a Nestri product.", + example: Examples.Subscription, + }); + + export type Info = z.infer; + + export const list = async () => { + const db = databaseClient() + const user = useCurrentUser() + + const query = { + subscriptions: { + $: { + where: { + owner: user.id, + canceledAt: { $isNull: true } + } + }, + } + } + + const res = await db.query(query) + + const response = res.subscriptions + if (!response || response.length === 0) { + return null + } + + const result = pipe( + response, + groupBy(x => x.id), + values(), + map((group): Info => ({ + id: group[0].id, + // next: group[0].next, + // frequency: group[0].frequency as any, + // quantity: group[0].quantity, + // productID: group[0].productID, + checkoutID: group[0].checkoutID, + })) + ) + + return result + } + + export const create = fn(Info.omit({ id: true, canceledAt: true }), async (input) => { + // const id = createID() + const id = createID() + const db = databaseClient() + const user = useCurrentUser() + + //Use the polar.sh ID + await db.transact(db.tx.subscriptions[id]!.update({ + // next: input.next, + // frequency: input.frequency, + // quantity: input.quantity, + checkoutID: input.checkoutID, + }).link({ owner: user.id })) + const res = await db.auth.getUser({ id: user.id }) + const profile = await Profiles.getProfile(user.id) + if (profile) { + await Email.sendWelcome(res.email, profile.username) + } + + }) + + export const remove = fn(z.string(), async (id) => { + const db = databaseClient() + + await db.transact(db.tx.subscriptions[id]!.update({ + canceledAt: new Date().toString() + })) + }) + + export const fromID = fn(z.string(), async (id) => { + const db = databaseClient() + const user = useCurrentUser() + const query = { + subscriptions: { + $: { + where: { + id, + //Make sure they can only get subscriptions they own + owner: user.id, + canceledAt: { $isNull: true } + } + }, + } + } + + const res = await db.query(query) + + const response = res.subscriptions + if (!response || response.length === 0) { + return null + } + + const result = pipe( + response, + groupBy(x => x.id), + values(), + map((group): Info => ({ + id: group[0].id, + checkoutID: group[0].checkoutID, + // next: group[0].next, + // frequency: group[0].frequency as any, + // quantity: group[0].quantity, + // productID: group[0].productID, + })) + ) + + return result[0] + }) + + export const fromCheckoutID = fn(z.string(), async (id) => { + const db = databaseClient() + const user = useCurrentUser() + const query = { + subscriptions: { + $: { + where: { + id, + //Make sure they can only get subscriptions they own + checkoutID: id, + canceledAt: { $isNull: true } + } + }, + } + } + + const res = await db.query(query) + + const response = res.subscriptions + if (!response || response.length === 0) { + return null + } + + const result = pipe( + response, + groupBy(x => x.id), + values(), + map((group): Info => ({ + id: group[0].id, + checkoutID: group[0].checkoutID, + })) + ) + + return result[0] + }) +} \ No newline at end of file diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts new file mode 100644 index 00000000..e3a7df3f --- /dev/null +++ b/packages/core/src/team/index.ts @@ -0,0 +1,165 @@ +import { z } from "zod"; +import databaseClient from "../database" +import { fn } from "../utils"; +import { groupBy, map, pipe, values } from "remeda" +import { Common } from "../common"; +import { Examples } from "../examples"; +import { useCurrentUser } from "../actor"; +import { id as createID } from "@instantdb/admin"; + +export namespace Teams { + export const Info = z + .object({ + id: z.string().openapi({ + description: Common.IdDescription, + example: Examples.Team.id, + }), + name: z.string().openapi({ + description: "Name of the team", + example: Examples.Team.name, + }), + createdAt: z.string().or(z.number()).openapi({ + description: "The time when this team was first created", + example: Examples.Team.createdAt, + }), + updatedAt: z.string().or(z.number()).openapi({ + description: "The time when this team was last edited", + example: Examples.Team.updatedAt, + }), + owner: z.boolean().openapi({ + description: "Whether this team is owned by this user", + example: Examples.Team.owner, + }), + slug: z.string().openapi({ + description: "This is the unique name identifier for the team", + example: Examples.Team.slug + }) + }) + .openapi({ + ref: "Team", + description: "A group of users sharing the same machines for gaming.", + example: Examples.Team, + }); + + export type Info = z.infer; + + export const list = async () => { + const db = databaseClient() + const user = useCurrentUser() + + const query = { + teams: { + $: { + where: { + members: user.id, + deletedAt: { $isNull: true } + } + }, + } + } + + const res = await db.query(query) + + const teams = res.teams + if (!teams || teams.length === 0) { + return null + } + + const result = pipe( + teams, + groupBy(x => x.id), + values(), + map((group): Info => ({ + id: group[0].id, + name: group[0].name, + createdAt: group[0].createdAt, + updatedAt: group[0].updatedAt, + slug: group[0].slug, + //@ts-expect-error + owner: group[0].owner === user.id + })) + ) + + return result + } + + + export const fromSlug = fn(z.string(), async (slug) => { + const db = databaseClient() + + const query = { + teams: { + $: { + where: { + slug, + deletedAt: { $isNull: true } + } + }, + } + } + + const res = await db.query(query) + + const teams = res.teams + if (!teams || teams.length === 0) { + return null + } + + const result = pipe( + teams, + groupBy(x => x.id), + values(), + map((group): Info => ({ + id: group[0].id, + name: group[0].name, + createdAt: group[0].createdAt, + slug: group[0].slug, + updatedAt: group[0].updatedAt, + //@ts-expect-error + owner: group[0].owner === user.id + })) + ) + + return result[0] + }) + + export const create = fn(Info.pick({ name: true, slug: true }), async (input) => { + const id = createID() + const db = databaseClient() + const user = useCurrentUser() + const now = new Date().toISOString() + + await db.transact(db.tx.teams[id]!.update({ + name: input.name, + slug: input.slug, + createdAt: now, + updatedAt: now, + }).link({ owner: user.id, members: user.id })) + + return id + }) + + export const remove = fn(z.string(), async (id) => { + const db = databaseClient() + const now = new Date().toISOString() + + await db.transact(db.tx.teams[id]!.update({ + deletedAt: now + })) + + return "ok" + }) + + export const invite = fn(z.object({email:z.string(), id: z.string()}), async (input) => { + //TODO: + // const db = databaseClient() + // const now = new Date().toISOString() + + // await db.transact(db.tx.teams[id]!.update({ + // deletedAt: now + // })) + + return "ok" + }) + +} \ No newline at end of file diff --git a/packages/core/src/user/index.ts b/packages/core/src/user/index.ts index 12c4babd..278fbbcc 100644 --- a/packages/core/src/user/index.ts +++ b/packages/core/src/user/index.ts @@ -4,7 +4,7 @@ import { fn } from "../utils"; import { Common } from "../common"; import { Examples } from "../examples"; -export module User { +export module Users { export const Info = z .object({ id: z.string().openapi({ diff --git a/packages/functions/src/api/index.ts b/packages/functions/src/api/index.ts index f48e20ea..5a709fb0 100644 --- a/packages/functions/src/api/index.ts +++ b/packages/functions/src/api/index.ts @@ -1,12 +1,15 @@ import "zod-openapi/extend"; import { Resource } from "sst"; import { ZodError } from "zod"; +import { UserApi } from "./user"; import { GameApi } from "./game"; +import { TeamApi } from "./team"; import { logger } from "hono/logger"; import { subjects } from "../subjects"; import { SessionApi } from "./session"; import { MachineApi } from "./machine"; import { openAPISpecs } from "hono-openapi"; +import { SubscriptionApi } from "./subscription"; import { VisibleError } from "@nestri/core/error"; import { ActorContext } from '@nestri/core/actor'; import { Hono, type MiddlewareHandler } from "hono"; @@ -81,9 +84,12 @@ app .use(auth); const routes = app + .route("/users", UserApi.route) + .route("/teams", TeamApi.route) .route("/games", GameApi.route) - .route("/machines", MachineApi.route) .route("/sessions", SessionApi.route) + .route("/machines", MachineApi.route) + .route("/subscriptions", SubscriptionApi.route) .onError((error, c) => { console.warn(error); if (error instanceof VisibleError) { diff --git a/packages/functions/src/api/session.ts b/packages/functions/src/api/session.ts index 976db274..ff2d87af 100644 --- a/packages/functions/src/api/session.ts +++ b/packages/functions/src/api/session.ts @@ -166,11 +166,11 @@ export module SessionApi { }, ) .post( - "/:id", + "/", describeRoute({ tags: ["Session"], summary: "Create a new gaming session for this user", - description: "Creates a new gaming session for the currently authenticated user, enabling them to play a game", + description: "Create a new gaming session for the currently authenticated user, enabling them to play a game", responses: { 200: { content: { diff --git a/packages/functions/src/api/subscription.ts b/packages/functions/src/api/subscription.ts new file mode 100644 index 00000000..ca23d500 --- /dev/null +++ b/packages/functions/src/api/subscription.ts @@ -0,0 +1,132 @@ +import { z } from "zod"; +import { Hono } from "hono"; +import { Result } from "../common"; +import { describeRoute } from "hono-openapi"; +import { Examples } from "@nestri/core/examples"; +import { validator, resolver } from "hono-openapi/zod"; +import { Subscriptions } from "@nestri/core/subscription/index"; +import { Email } from "@nestri/core/email/index"; + +export module SubscriptionApi { + export const route = new Hono() + .get( + "/", + describeRoute({ + tags: ["Subscription"], + summary: "List subscriptions", + description: "List the subscriptions associated with the current user.", + responses: { + 200: { + content: { + "application/json": { + schema: Result( + Subscriptions.Info.array().openapi({ + description: "List of subscriptions.", + example: [Examples.Subscription], + }), + ), + }, + }, + description: "List of subscriptions.", + }, + 404: { + content: { + "application/json": { + schema: resolver(z.object({ error: z.string() })), + }, + }, + description: "No subscriptions found for this user", + }, + }, + }), + async (c) => { + const data = await Subscriptions.list(); + if (!data) return c.json({ error: "No subscriptions found for this user" }, 404); + return c.json({ data }, 200); + }, + ) + .post( + "/", + describeRoute({ + tags: ["Subscription"], + summary: "Subscribe", + description: "Create a subscription for the current user.", + responses: { + 200: { + content: { + "application/json": { + schema: Result(z.literal("ok")), + }, + }, + description: "Subscription was created successfully.", + }, + 400: { + content: { + "application/json": { + schema: resolver(z.object({ error: z.string() })), + }, + }, + description: "Subscription already exists.", + }, + }, + }), + validator( + "json", + z.object({ + checkoutID: Subscriptions.Info.shape.id.openapi({ + description: "The checkout id information.", + example: Examples.Subscription.id, + }) + }), + ), + async (c) => { + const body = c.req.valid("json"); + const data = await Subscriptions.fromCheckoutID(body.checkoutID) + if (data) return c.json({ error: "Subscription already exists" }) + await Subscriptions.create(body); + return c.json({ data: "ok" as const }, 200); + }, + ) + .delete( + "/:id", + describeRoute({ + tags: ["Subscription"], + summary: "Cancel", + description: "Cancel a subscription for the current user.", + responses: { + 200: { + content: { + "application/json": { + schema: Result(z.literal("ok")), + }, + }, + description: "Subscription was cancelled successfully.", + }, + 404: { + content: { + "application/json": { + schema: resolver(z.object({ error: z.string() })), + }, + }, + description: "Subscription not found.", + }, + }, + }), + validator( + "param", + z.object({ + id: Subscriptions.Info.shape.id.openapi({ + description: "ID of the subscription to cancel.", + example: Examples.Subscription.id, + }), + }), + ), + async (c) => { + const param = c.req.valid("param"); + const subscription = await Subscriptions.fromID(param.id); + if (!subscription) return c.json({ error: "Subscription not found" }, 404); + await Subscriptions.remove(param.id); + return c.json({ data: "ok" as const }, 200); + }, + ); +} \ No newline at end of file diff --git a/packages/functions/src/api/team.ts b/packages/functions/src/api/team.ts new file mode 100644 index 00000000..d8f9a5ad --- /dev/null +++ b/packages/functions/src/api/team.ts @@ -0,0 +1,238 @@ +import { z } from "zod"; +import { Hono } from "hono"; +import { Result } from "../common"; +import { describeRoute } from "hono-openapi"; +import { Teams } from "@nestri/core/team/index"; +import { Users } from "@nestri/core/user/index"; +import { Examples } from "@nestri/core/examples"; +import { validator, resolver } from "hono-openapi/zod"; + +export module TeamApi { + export const route = new Hono() + .get( + "/", + //FIXME: Add a way to filter through query params + describeRoute({ + tags: ["Team"], + summary: "Retrieve all teams", + description: "Returns a list of all teams which the authenticated user is part of", + responses: { + 200: { + content: { + "application/json": { + schema: Result( + Teams.Info.array().openapi({ + description: "A list of teams associated with the user", + example: [Examples.Team], + }), + ), + }, + }, + description: "Successfully retrieved the list teams", + }, + 404: { + content: { + "application/json": { + schema: resolver(z.object({ error: z.string() })), + }, + }, + description: "No teams found for the authenticated user", + }, + }, + }), + async (c) => { + const teams = await Teams.list(); + if (!teams) return c.json({ error: "No teams found for this user" }, 404); + return c.json({ data: teams }, 200); + }, + ) + .get( + "/:slug", + describeRoute({ + tags: ["Team"], + summary: "Retrieve a team by slug", + description: "Fetch detailed information about a specific team using its unique slug", + responses: { + 404: { + content: { + "application/json": { + schema: resolver(z.object({ error: z.string() })), + }, + }, + description: "No team found matching the provided slug", + }, + 200: { + content: { + "application/json": { + schema: Result( + Teams.Info.openapi({ + description: "Detailed information about the requested team", + example: Examples.Team, + }), + ), + }, + }, + description: "Successfully retrieved the team information", + }, + }, + }), + validator( + "param", + z.object({ + slug: Teams.Info.shape.slug.openapi({ + description: "The unique slug used to identify the team", + example: Examples.Team.slug, + }), + }), + ), + async (c) => { + const params = c.req.valid("param"); + const team = await Teams.fromSlug(params.slug); + if (!team) return c.json({ error: "Team not found" }, 404); + return c.json({ data: team }, 200); + }, + ) + .post( + "/", + describeRoute({ + tags: ["Team"], + summary: "Create a team", + description: "Create a new team for the currently authenticated user, enabling them to invite and play a game together with friends", + responses: { + 200: { + content: { + "application/json": { + schema: Result(z.literal("ok")) + }, + }, + description: "Team successfully created", + }, + 404: { + content: { + "application/json": { + schema: resolver(z.object({ error: z.string() })), + }, + }, + description: "A team with this slug already exists", + }, + }, + }), + validator( + "json", + z.object({ + slug: Teams.Info.shape.slug.openapi({ + description: "The unique name to be used with this team", + example: Examples.Team.slug + }), + name: Teams.Info.shape.name.openapi({ + description: "The human readable name to give this team", + example: Examples.Team.name + }) + }) + ), + async (c) => { + const params = c.req.valid("json") + const team = await Teams.fromSlug(params.slug) + if (team) return c.json({ error: "A team with this slug already exists" }, 404); + const res = await Teams.create(params) + return c.json({ data: res }, 200); + }, + ) + .delete( + "/:slug", + describeRoute({ + tags: ["Team"], + summary: "Delete a team", + description: "This endpoint allows a user to delete a team, by providing it's unique slug", + responses: { + 200: { + content: { + "application/json": { + schema: Result(z.literal("ok")), + }, + }, + description: "The team was successfully deleted.", + }, + 404: { + content: { + "application/json": { + schema: resolver(z.object({ error: z.string() })), + }, + }, + description: "A team with this slug does not exist", + }, + 401: { + content: { + "application/json": { + schema: resolver(z.object({ error: z.string() })), + }, + }, + description: "Your are not authorized to delete this team", + }, + } + }), + validator( + "param", + z.object({ + slug: Teams.Info.shape.slug.openapi({ + description: "The unique slug of the team to be deleted. ", + example: Examples.Team.slug, + }), + }), + ), + async (c) => { + const params = c.req.valid("param"); + const team = await Teams.fromSlug(params.slug) + if (!team) return c.json({ error: "Team not found" }, 404); + if (!team.owner) return c.json({ error: "Your are not authorised to delete this team" }, 401) + const res = await Teams.remove(team.id); + return c.json({ data: res }, 200); + }, + ) + .post( + "/:slug/invite/:email", + describeRoute({ + tags: ["Team"], + summary: "Invite a user to a team", + description: "Invite a user to a team owned by the current user", + responses: { + 200: { + content: { + "application/json": { + schema: Result(z.literal("ok")), + }, + }, + description: "User successfully invited", + }, + 404: { + content: { + "application/json": { + schema: resolver(z.object({ error: z.string() })), + }, + }, + description: "The game with the specified Steam ID was not found", + }, + } + }), + validator( + "param", + z.object({ + slug: Teams.Info.shape.slug.openapi({ + description: "The unique slug of the team the user wants to invite ", + example: Examples.Team.slug, + }), + email: Users.Info.shape.email.openapi({ + description: "The email of the user to invite", + example: Examples.User.email + }) + }), + ), + async (c) => { + const params = c.req.valid("param"); + const team = await Teams.fromSlug(params.slug) + if (!team) return c.json({ error: "Team not found" }, 404); + if (!team.owner) return c.json({ error: "Your are not authorized to delete this team" }, 401) + return c.json({ data: "ok" }, 200); + }, + ) +} \ No newline at end of file diff --git a/packages/functions/src/api/user.ts b/packages/functions/src/api/user.ts new file mode 100644 index 00000000..d89f55af --- /dev/null +++ b/packages/functions/src/api/user.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { Hono } from "hono"; +import { Result } from "../common"; +import { describeRoute } from "hono-openapi"; +import { Examples } from "@nestri/core/examples"; +import { Profiles } from "@nestri/core/profile/index"; +import { validator, resolver } from "hono-openapi/zod"; + +export module UserApi { + export const route = new Hono() + .get( + "/@me", + describeRoute({ + tags: ["User"], + summary: "Retrieve current user profile", + description: "Returns the current authenticate user's profile", + responses: { + 200: { + content: { + "application/json": { + schema: Result( + Profiles.Info.openapi({ + description: "The profile for this user", + example: Examples.Profile, + }), + ), + }, + }, + description: "Successfully retrieved the user's profile", + }, + 404: { + content: { + "application/json": { + schema: resolver(z.object({ error: z.string() })), + }, + }, + description: "No user profile found", + }, + }, + }), async (c) => { + const profile = await Profiles.getCurrentProfile(); + if (!profile) return c.json({ error: "No profile found for this user" }, 404); + return c.json({ data: profile }, 200); + }, + ) +} \ No newline at end of file diff --git a/packages/functions/src/auth.ts b/packages/functions/src/auth.ts index c4b68c2b..27629a23 100644 --- a/packages/functions/src/auth.ts +++ b/packages/functions/src/auth.ts @@ -6,7 +6,11 @@ import { import { Select } from "./ui/select"; import { subjects } from "./subjects" import { PasswordUI } from "./ui/password" +import { Email } from "@nestri/core/email/index" +import { Users } from "@nestri/core/user/index" import { authorizer } from "@openauthjs/openauth" +import { Profiles } from "@nestri/core/profile/index" +import { handleDiscord, handleGithub } from "./utils"; import { type CFRequest } from "@nestri/core/types" import { GithubAdapter } from "./ui/adapters/github"; import { DiscordAdapter } from "./ui/adapters/discord"; @@ -14,9 +18,6 @@ import { Machines } from "@nestri/core/machine/index" import { PasswordAdapter } from "./ui/adapters/password" import { type Adapter } from "@openauthjs/openauth/adapter/adapter" import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare" -import { handleDiscord, handleGithub } from "./utils"; -import { User } from "@nestri/core/user/index" -import { Profiles } from "@nestri/core/profile/index" interface Env { CloudflareAuthKV: KVNamespace } @@ -89,7 +90,7 @@ export default { PasswordUI({ sendCode: async (email, code) => { console.log("email & code:", email, code) - // await Email.send(email, code) + await Email.send(email, code) }, }), ), @@ -149,8 +150,8 @@ export default { if (value.provider === "password") { const email = value.email const username = value.username - const token = await User.create(email) - const usr = await User.fromEmail(email); + const token = await Users.create(email) + const usr = await Users.fromEmail(email); const exists = await Profiles.getProfile(usr.id) if(username && !exists){ await Profiles.create({ owner: usr.id, username }) @@ -168,19 +169,17 @@ export default { if (value.provider === "github") { const access = value.tokenset.access; user = await handleGithub(access) - // console.log("user", user) } if (value.provider === "discord") { const access = value.tokenset.access user = await handleDiscord(access) - // console.log("user", user) } if (user) { try { - const token = await User.create(user.primary.email) - const usr = await User.fromEmail(user.primary.email); + const token = await Users.create(user.primary.email) + const usr = await Users.fromEmail(user.primary.email); const exists = await Profiles.getProfile(usr.id) console.log("exists",exists) if (!exists) { @@ -198,23 +197,7 @@ export default { } - // if (email) { - // console.log("email", email) - // // value.username && console.log("username", value.username) - - // } - - // if (email) { - // const token = await User.create(email); - // const user = await User.fromEmail(email); - - // return await ctx.subject("user", { - // accessToken: token, - // userID: user.id - // }); - // } - - throw new Error("This is not implemented yet"); + throw new Error("Something went seriously wrong"); }, }).fetch(request, env, ctx) } diff --git a/packages/ui/globals.css b/packages/ui/globals.css index 11c0f5b2..71d300b5 100644 --- a/packages/ui/globals.css +++ b/packages/ui/globals.css @@ -260,6 +260,7 @@ transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1); animation-name: slideFromRight; } + .modal::backdrop, .modal-sheet::backdrop { animation-duration: 0.5s; @@ -280,7 +281,7 @@ } .modal[data-closing]::backdrop, -.modal-sheet[data-closing]::backdrop{ +.modal-sheet[data-closing]::backdrop { animation-duration: 0.5s; animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); touch-action: none; @@ -298,7 +299,7 @@ animation-name: modalIn; } -.modal[data-closing]{ +.modal[data-closing] { animation-duration: 0.5s; animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); touch-action: none; @@ -325,37 +326,138 @@ @keyframes fadeIn { from { - opacity: 0; + opacity: 0; } - to { - opacity: 1; - } - } - - @keyframes fadeOut { - to { - opacity: 0; - } - } - - @keyframes modalIn { - from { - opacity: 0; - scale: 0.9; - } - to { - opacity: 1; - scale: 1; - } - } - - @keyframes modalOut { - to { - opacity: 0; - scale: 0.9; - } - } - /* button, a { - @apply outline-none hover:[box-shadow:0_0_0_2px_#fcfcfc,0_0_0_4px_#8f8f8f] dark:hover:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070] focus:[box-shadow:0_0_0_2px_#fcfcfc,0_0_0_4px_#8f8f8f] dark:focus:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070] [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] - } */ \ No newline at end of file + to { + opacity: 1; + } +} + +@keyframes fadeOut { + to { + opacity: 0; + } +} + +@keyframes modalIn { + from { + opacity: 0; + scale: 0.9; + } + + to { + opacity: 1; + scale: 1; + } +} + +@keyframes modalOut { + to { + opacity: 0; + scale: 0.9; + } +} + +[data-component="spinner"] { + --spinner-size: 20px; + --spinner-color: #000; + + @media (prefers-color-scheme: dark) { + --spinner-color: #FFF; + } + + height: var(--spinner-size, 20px); + width: var(--spinner-size, 20px); + margin-left: calc(var(--spinner-size, 20px)*-1px); + /* display: none; */ + + &>div { + position: relative; + top: 50%; + left: 50%; + height: var(--spinner-size, 20px); + width: var(--spinner-size, 20px); + } + + &>div>div { + animation: spin 1.2s linear infinite; + background: var(--spinner-color); + border-radius: 9999px; + height: 8%; + left: -10%; + position: absolute; + top: -3.9%; + width: 24%; + } + + &>div>div:first-child { + animation-delay: -1.2s; + transform: rotate(.0001deg) translate(146%); + } + + &>div>div:nth-child(2) { + animation-delay: -1.1s; + transform: rotate(30deg) translate(146%); + } + + &>div>div:nth-child(3) { + animation-delay: -1s; + transform: rotate(60deg) translate(146%); + } + + &>div>div:nth-child(4) { + animation-delay: -.9s; + transform: rotate(90deg) translate(146%); + } + + &>div>div:nth-child(5) { + animation-delay: -.8s; + transform: rotate(120deg) translate(146%); + } + + &>div>div:nth-child(6) { + animation-delay: -.7s; + transform: rotate(150deg) translate(146%); + } + + &>div>div:nth-child(7) { + animation-delay: -.6s; + transform: rotate(180deg) translate(146%); + } + + &>div>div:nth-child(8) { + animation-delay: -.5s; + transform: rotate(210deg) translate(146%); + } + + &>div>div:nth-child(9) { + animation-delay: -.4s; + transform: rotate(240deg) translate(146%); + } + + &>div>div:nth-child(10) { + animation-delay: -.3s; + transform: rotate(270deg) translate(146%); + } + + &>div>div:nth-child(11) { + animation-delay: -.2s; + transform: rotate(300deg) translate(146%); + } + + &>div>div:nth-child(12) { + animation-delay: -.1s; + transform: rotate(330deg) translate(146%); + } +} + +@keyframes spin { + 0% { + opacity: 1; + } + + 100% { + opacity: .15; + } +} \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json index 9ba953d9..1167b37d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -28,7 +28,7 @@ "@fontsource/bricolage-grotesque": "^5.0.7", "@fontsource/geist-mono": "^5.1.0", "@fontsource/geist-sans": "^5.1.0", - "@modular-forms/qwik": "0.26.1", + "@modular-forms/qwik": "^0.29.0", "@nestri/core": "*", "@qwik-ui/headless": "^0.6.4", "@types/eslint": "^8.56.5", diff --git a/packages/ui/src/constants.ts b/packages/ui/src/constants.ts new file mode 100644 index 00000000..43ec81f7 --- /dev/null +++ b/packages/ui/src/constants.ts @@ -0,0 +1,4 @@ +export const CONSTANTS = { + githubLink: "https://github.com/nestrilabs/nestri#start", + enterpriseContact: "mailto:enterprise@nestri.io" +} \ No newline at end of file diff --git a/packages/ui/src/github-banner.tsx b/packages/ui/src/footer-banner.tsx similarity index 76% rename from packages/ui/src/github-banner.tsx rename to packages/ui/src/footer-banner.tsx index d23486cf..42736223 100644 --- a/packages/ui/src/github-banner.tsx +++ b/packages/ui/src/footer-banner.tsx @@ -1,23 +1,26 @@ -import { component$, useSignal, useVisibleTask$ } from "@builder.io/qwik"; -import { MotionComponent, transition } from "@nestri/ui/react"; import Book from "./book"; +import { CONSTANTS } from "./constants"; +import { Link } from "@builder.io/qwik-city"; +import { MotionComponent, transition } from "@nestri/ui/react"; +import { component$, useSignal, useVisibleTask$ } from "@builder.io/qwik"; -export const GithubBanner = component$(() => { - const buttonRef = useSignal() - const bookRef = useSignal() +export const FooterBanner = component$(() => { + const docsLinkRef = useSignal() + const bookRef = useSignal() + // eslint-disable-next-line qwik/no-use-visible-task useVisibleTask$(() => { - buttonRef.value?.addEventListener("mouseenter", () => { + docsLinkRef.value?.addEventListener("mouseenter", () => { bookRef.value?.classList.add('flip') }) - buttonRef.value?.addEventListener("mouseleave", () => { + docsLinkRef.value?.addEventListener("mouseleave", () => { bookRef.value?.classList.remove('flip') }) return () => { - buttonRef.value?.removeEventListener("mouseenter", () => { + docsLinkRef.value?.removeEventListener("mouseenter", () => { bookRef.value?.classList.add('flip') }) - buttonRef.value?.removeEventListener("mouseleave", () => { + docsLinkRef.value?.removeEventListener("mouseleave", () => { bookRef.value?.classList.remove('flip') }) } @@ -41,36 +44,36 @@ export const GithubBanner = component$(() => {

Ready to start playing?
- Dive into the documentation or unlock premium features with Nestri Family + Dive into the documentation or unlock premium features with Nestri Pro

- - +
- +
diff --git a/packages/ui/src/footer.tsx b/packages/ui/src/footer.tsx index c3d4bfaa..a31fe384 100644 --- a/packages/ui/src/footer.tsx +++ b/packages/ui/src/footer.tsx @@ -2,7 +2,7 @@ import { component$ } from "@builder.io/qwik"; import { Link } from "@builder.io/qwik-city"; import { MotionComponent, transition } from "@nestri/ui/react" -import { GithubBanner } from "./github-banner"; +import { FooterBanner } from "./footer-banner"; const socialMedia = [ { @@ -30,13 +30,13 @@ const socialMedia = [ ] type Props = { - showGH?: boolean + showBanner?: boolean } -export const Footer = component$(({ showGH = true }: Props) => { +export const Footer = component$(({ showBanner = true }: Props) => { return ( <> - {showGH && } + {showBanner && }
{

Docs

Pricing - Changelog + About Us

Company

- Blog - Contact Us + {/* Blog */} + {/* Contact Us */} +

Blog

+

Contact Us

Open Startup

diff --git a/packages/ui/src/game-store/default.tsx b/packages/ui/src/game-store/default.tsx new file mode 100644 index 00000000..10879cc2 --- /dev/null +++ b/packages/ui/src/game-store/default.tsx @@ -0,0 +1,89 @@ +import { Modal } from "@qwik-ui/headless"; +import { MotionComponent } from "../react"; +import { component$, type QRL } from "@builder.io/qwik"; + +// const FadeScale = { +// initial: { +// opacity: 0, +// scale: 0.85, +// }, +// animate: { +// opacity: 1, +// scale: 1, +// }, +// exit: { +// opacity: 0, +// scale: 0.85, +// }, +// transition: { +// type: "spring", +// duration: 0.35, +// bounce: 0.1 +// } +// }; + +type StoreSelectProps = { + onSteamPress$: QRL<() => void> +} + +export const StoreSelect = component$(({ onSteamPress$ }: StoreSelectProps) => { + return ( + // + <> +
+ + + +
+
+
+

+
+ +
+ Connect Game Store +

+
+
+ +
+ Epic Games + +
+
+ GOG.com + + + +
+
+ Amazon Games + + + +
+
+
+ +
+
+ + //
+ ) +}) + +export const SteamLoad = component$(() => { + return ( + +
+ +
+
+ ) +}) \ No newline at end of file diff --git a/packages/ui/src/game-store/index.tsx b/packages/ui/src/game-store/index.tsx new file mode 100644 index 00000000..2ed3fa2d --- /dev/null +++ b/packages/ui/src/game-store/index.tsx @@ -0,0 +1,34 @@ +import { SteamLoad, StoreSelect } from "./default"; +import { Modal } from "@qwik-ui/headless"; +import { $, component$, useSignal } from "@builder.io/qwik"; + +export default component$(() => { + const storeSelect = useSignal(true) + return ( + + +
+

DESKTOP-EUO8VSF

+
+
+ game +
+
+ +
+ {storeSelect.value ? { console.log("clicked") })} /> : } +
+
+
+ ) +}) \ No newline at end of file diff --git a/packages/ui/src/home-nav-bar.tsx b/packages/ui/src/home-nav-bar.tsx index cee4bac8..ae30c859 100644 --- a/packages/ui/src/home-nav-bar.tsx +++ b/packages/ui/src/home-nav-bar.tsx @@ -1,20 +1,31 @@ import { cn } from "./design"; import Avatar from "./avatar" -import { Dropdown } from '@qwik-ui/headless'; +import { MotionComponent } from "./react"; +import { Dropdown, Modal } from '@qwik-ui/headless'; import { disablePageScroll, enablePageScroll } from '@fluejs/noscroll'; import { $, component$, useOnDocument, useSignal } from "@builder.io/qwik"; +type Props = { + avatarUrl?: string; + discriminator: string | number; + username: string; +} -export const HomeNavBar = component$(() => { +export const HomeNavBar = component$(({ avatarUrl, username, discriminator }: Props) => { const hasScrolled = useSignal(false); + const defaultTeam = `${username}'s Games` + const selectedTeam = useSignal(defaultTeam) + const isNewTeam = useSignal(false); + const isNewMember = useSignal(false); + const isHolding = useSignal(false); + const showInviteSuccess = useSignal(false); + const newTeamName = useSignal(''); + const inviteName = useSignal(''); + const inviteEmail = useSignal(''); + const teams = useSignal([ + { name: defaultTeam } + ]); - const actions = [ - { label: "Hell Diver's Europe", disabled: false }, - { label: "WanjohiRyan's Games", disabled: false }, - { label: "CyberPunk Marathon", disabled: false }, - { label: "Emulation Hackers", disabled: true }, - { label: "testing-123", disabled: false }, - ]; const onDialogOpen = $((open: boolean) => { if (open) { @@ -24,111 +35,213 @@ export const HomeNavBar = component$(() => { } }) + const handlePointerDown = $(() => { + isHolding.value = true + }); + + const handlePointerUp = $(() => { + isHolding.value = false + }); + + const handleAddTeam = $((e: any) => { + e.preventDefault(); + if (newTeamName.value.trim()) { + teams.value = [...teams.value, { name: newTeamName.value.trim() }]; + // selectedTeam.value = newTeamName.value.trim() + newTeamName.value = ''; + isNewTeam.value = false; + } + }); + + const handleInvite = $((e: any) => { + e.preventDefault(); + if (inviteName.value && inviteEmail.value) { + // Here you would typically make an API call to send the invitation + console.log('Sending invite to:', { name: inviteName.value, email: inviteEmail.value }); + inviteName.value = ''; + inviteEmail.value = ''; + isNewMember.value = false; + showInviteSuccess.value = true; + setTimeout(() => { + showInviteSuccess.value = false; + }, 3000); + } + }); + useOnDocument( 'scroll', $(() => { hasScrolled.value = window.scrollY > 0; }) ); - // + + const handleDeleteTeam = $(() => { + // Only delete if it's not the default team + if (selectedTeam.value !== defaultTeam) { + teams.value = teams.value.filter(team => team.name !== selectedTeam.value); + selectedTeam.value = defaultTeam; + } + }); + + const handleDeleteAnimationComplete = $(() => { + if (isHolding.value) { + // isDeleting.value = true; + // Reset the holding state + isHolding.value = false; + handleDeleteTeam(); + } + }); + return ( - + + +
+
+
+

Create a team

+
+ Continue to start playing with on Pro with increased usage, additional security features, and support +
+
+
+
+ + newTeamName.value = e.target!.value} + required value={newTeamName.value} id="name" type="text" placeholder="Enter team name" class="[transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] w-full bg-transparent px-2 py-3 h-10 border text-black dark:text-white dark:border-gray-700/70 border-gray-300/70 rounded-md text-sm outline-none leading-none focus:ring-gray-300 dark:focus:ring-gray-700 focus:ring-2" /> +
+
+
+
+ + Cancel + + +
+
+
+
+ + +
+ +
+
+

Send an invite

+
+ Friends will receive an email allowing them to join this team +
+
+
+
+ + inviteName.value = e.target!.value} + id="name" type="text" placeholder="Jane Doe" class="[transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] w-full bg-transparent px-2 py-3 h-10 border text-black dark:text-white dark:border-gray-700/70 border-gray-300/70 rounded-md text-sm outline-none leading-none focus:ring-gray-300 dark:focus:ring-gray-700 focus:ring-2" /> +
+
+ + inviteEmail.value = e.target!.value} + id="email" type="email" placeholder="jane@doe.com" class="[transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] w-full px-2 bg-transparent py-3 h-10 border text-black dark:text-white dark:border-gray-700/70 border-gray-300/70 rounded-md text-sm outline-none leading-none focus:ring-gray-300 dark:focus:ring-gray-700 focus:ring-2" /> +
+
+
+
+ + Cancel + + +
+
+
+
+ ) }) \ No newline at end of file diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 1d930d73..18097385 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -9,9 +9,12 @@ export * from "./footer" export * from "./card" export * from "./router-head" export * from "./team-counter" +export * from "./constants" +export * from "./svg" export * as auth from "./popup" export * as Modal from "./modal" export { default as Book } from "./book" export { default as Portal } from "./portal" export { default as Avatar } from "./avatar" -export { default as SimpleFooter } from "./simple-footer" \ No newline at end of file +export { default as SimpleFooter } from "./simple-footer" +export { default as GameStoreButton } from "./game-store" \ No newline at end of file diff --git a/packages/ui/src/nav-bar.tsx b/packages/ui/src/nav-bar.tsx index 8d43a3bf..7332f9df 100644 --- a/packages/ui/src/nav-bar.tsx +++ b/packages/ui/src/nav-bar.tsx @@ -4,8 +4,8 @@ import { buttonVariants, cn } from "./design"; const navLinks = [ { - name: "Changelog", - href: "/changelog" + name: "About Us", + href: "/about" }, { name: "Pricing", @@ -13,11 +13,15 @@ const navLinks = [ }, { name: "Login", - href: "/login" } ] -export const NavBar = component$(() => { +type Props = { + link?: string +} + + +export const NavBar = component$(({ link }: Props) => { const location = useLocation() const hasScrolled = useSignal(false); @@ -30,8 +34,16 @@ export const NavBar = component$(() => { ); return ( -