{
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.
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$(() => {
💖 Thank you for trusting Nestri with your data and gaming experience.💖
+
We are committed to safeguarding your personal information and ensuring your privacy.
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 (
- {/**Gradient to hide the ending of the checkered bg at the bottom*/}
- {/* */}
-
-
+
Nestri's Terms of Service
@@ -30,7 +19,7 @@ export default component$(() => {
- 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.
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$(() => {
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 (
+ )
+})
\ 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