diff --git a/template/README.md b/template/README.md new file mode 100644 index 00000000..d6621b4b --- /dev/null +++ b/template/README.md @@ -0,0 +1,8 @@ +# + +This project is based on [OpenSaas](https://opensaas.sh) template and consists of three main dirs: +1. `app` - Your web app, built with [Wasp](https://wasp.sh). +2. `e2e-tests` - [Playwright](https://playwright.dev/) tests for your Wasp web app. +3. `blog` - Your blog / docs, built with [Astro](https://docs.astro.build) based on [Starlight](https://starlight.astro.build/) template. + +For more details, check READMEs of each respective directory! diff --git a/template/app/.cursorrules b/template/app/.cursorrules new file mode 100644 index 00000000..eee00945 --- /dev/null +++ b/template/app/.cursorrules @@ -0,0 +1,52 @@ +// Wasp Import Rules +- Path to Wasp functions within .ts files must come from 'wasp', not '@wasp'! + ✓ import { Task } from 'wasp/entities' + ✓ import type { GetTasks } from 'wasp/server/operations' + ✓ import { getTasks, useQuery } from 'wasp/client/operations' + ✗ import { getTasks, useQuery } from '@wasp/...' + ✗ import { getTasks, useQuery } from '@src/feature/operations.ts' + +- Path to external imports within 'main.wasp' must start with "@src/"! + ✓ component: import { LoginPage } from "@src/client/pages/auth/LoginPage.tsx" + ✗ component: import { LoginPage } from "@client/pages/auth/LoginPage.tsx" +- In the client's root component, use the Outlet component rather than children + ✓ import { Outlet } from 'react-router-dom'; + +// Wasp DB Schema Rules +- Add databse models to the 'schema.prisma' file, NOT to 'main.wasp' as "entities" +- Do NOT add a db.system nor a db.prisma property to 'main.wasp'. This is taken care of in 'schema.prisma' +- Keep the 'schema.prisma' within the root of the project + +// Wasp Operations +- Types are generated automatically from the function definition in 'main.wasp', + ✓ import type { GetTimeLogs, CreateTimeLog, UpdateTimeLog } from 'wasp/server/operations' +- Wasp also generates entity types based on the models in 'schema.prisma' + ✓ import type { Project, TimeLog } from 'wasp/entities' +- Make sure that all Entities that should be included in the operations context are defined in its definition in 'main.wasp' + ✓ action createTimeLog { fn: import { createTimeLog } from "@src/server/timeLogs/operations.js", entities: [TimeLog, Project] } + +// Wasp Auth +- When creating Auth pages, use the LoginForm and SignupForm components provided by Wasp + ✓ import { LoginForm } from 'wasp/client/auth' +- Wasp takes care of creating the user's auth model id, username, and password for a user, so a user model DOES NOT need these properties + ✓ model User { id Int @id @default(autoincrement()) } + +// Wasp Dependencies +- Do NOT add dependencies to 'main.wasp' +- Install dependencies via 'npm install' instead + +// Wasp +- Use the latest Wasp version, ^0.16.0 +- Always use typescript for Wasp code. +- When creating Wasp operations (queries and actions) combine them into an operations.ts file within the feature directory rather than into separate queries.ts and actions.ts files + +// React +- Use relative imports for other react components +- If importing a function from an operations file, defer to the wasp import rules + +// CSS +- Use Tailwind CSS for styling. +- Do not use inline styles unless necessary + +// General +- Use single quotes \ No newline at end of file diff --git a/template/app/.env.client.example b/template/app/.env.client.example new file mode 100644 index 00000000..9ae3bef8 --- /dev/null +++ b/template/app/.env.client.example @@ -0,0 +1,4 @@ +# All client-side env vars must start with REACT_APP_ https://wasp.sh/docs/project/env-vars + +# See https://docs.opensaas.sh/guides/analytics/#google-analytics +REACT_APP_GOOGLE_ANALYTICS_ID=G-... diff --git a/template/app/.env.server.example b/template/app/.env.server.example new file mode 100644 index 00000000..65d02b9e --- /dev/null +++ b/template/app/.env.server.example @@ -0,0 +1,56 @@ +# NOTE: you can let Wasp set up your Postgres DB by running `wasp start db` in a separate terminal window. +# then, in a new terminal window, run `wasp db migrate-dev` and finally `wasp start`. +# If you use `wasp start db` then you DO NOT need to add a DATABASE_URL env variable here. +# DATABASE_URL= + +# For testing, go to https://dashboard.stripe.com/test/apikeys and get a test stripe key that starts with "sk_test_..." +STRIPE_API_KEY=sk_test_... +# After downloading starting the stripe cli (https://stripe.com/docs/stripe-cli) with `stripe listen --forward-to localhost:3001/payments-webhook` it will output your signing secret +STRIPE_WEBHOOK_SECRET=whsec_... +# You can find your Stripe customer portal URL in the Stripe Dashboard under the 'Customer Portal' settings. +STRIPE_CUSTOMER_PORTAL_URL=https://billing.stripe.com/... + +# For testing, create a new store in test mode on https://lemonsqueezy.com +LEMONSQUEEZY_API_KEY=eyJ... +# After creating a store, you can find your store id in the store settings https://app.lemonsqueezy.com/settings/stores +LEMONSQUEEZY_STORE_ID=012345 +# define your own webhook secret when creating a new webhook on https://app.lemonsqueezy.com/settings/webhooks +LEMONSQUEEZY_WEBHOOK_SECRET=my-webhook-secret + +# If using Stripe, go to https://dashboard.stripe.com/test/products and click on + Add Product +# If using Lemon Squeezy, go to https://app.lemonsqueezy.com/products and create new products and variants +PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=012345 +PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID=012345 +PAYMENTS_CREDITS_10_PLAN_ID=012345 + +# set this as a comma-separated list of emails you want to give admin privileges to upon registeration +ADMIN_EMAILS=me@example.com,you@example.com,them@example.com + +# see our guide for setting up google auth: https://wasp.sh/docs/auth/social-auth/google +GOOGLE_CLIENT_ID=722... +GOOGLE_CLIENT_SECRET=GOC... + +# get your sendgrid api key at https://app.sendgrid.com/settings/api_keys +SENDGRID_API_KEY=test... + +# (OPTIONAL) get your openai api key at https://platform.openai.com/account +OPENAI_API_KEY=sk-k... + +# (OPTIONAL) get your plausible api key at https://plausible.io/login or https://your-plausible-instance.com/login +PLAUSIBLE_API_KEY=gUTgtB... +# You will find your site id in the Plausible dashboard. It will look like 'opensaas.sh' +PLAUSIBLE_SITE_ID=yoursite.com +PLAUSIBLE_BASE_URL=https://plausible.io/api # if you are self-hosting plausible, change this to your plausible instance's base url + +# (OPTIONAL) get your google service account key at https://console.cloud.google.com/iam-admin/serviceaccounts +GOOGLE_ANALYTICS_CLIENT_EMAIL=email@example.gserviceaccount.com +# Make sure you convert the private key within the JSON file to base64 first with `echo -n "PRIVATE_KEY" | base64`. see the docs for more info. +GOOGLE_ANALYTICS_PRIVATE_KEY=LS02... +# You will find your Property ID in the Google Analytics dashboard. It will look like '987654321' +GOOGLE_ANALYTICS_PROPERTY_ID=123456789 + +# (OPTIONAL) get your aws s3 credentials at https://console.aws.amazon.com and create a new IAM user with S3 access +AWS_S3_IAM_ACCESS_KEY=ACK... +AWS_S3_IAM_SECRET_KEY=t+33a... +AWS_S3_FILES_BUCKET=your-bucket-name +AWS_S3_REGION=your-region \ No newline at end of file diff --git a/template/app/.gitignore b/template/app/.gitignore new file mode 100644 index 00000000..fd453426 --- /dev/null +++ b/template/app/.gitignore @@ -0,0 +1,11 @@ +.wasp/ +node_modules/ + +# Ignore all dotenv files by default to prevent accidentally committing any secrets. +# To include specific dotenv files, use the `!` operator or adjust these rules. +.env +.env.* + +# Don't ignore example dotenv files. +!.env.example +!.env.*.example diff --git a/template/app/.waspignore b/template/app/.waspignore new file mode 100644 index 00000000..1c432f30 --- /dev/null +++ b/template/app/.waspignore @@ -0,0 +1,3 @@ +# Ignore editor tmp files +**/*~ +**/#*# diff --git a/template/app/.wasproot b/template/app/.wasproot new file mode 100644 index 00000000..ca2cfdb4 --- /dev/null +++ b/template/app/.wasproot @@ -0,0 +1 @@ +File marking the root of Wasp project. diff --git a/template/app/README.md b/template/app/README.md new file mode 100644 index 00000000..a81d83b1 --- /dev/null +++ b/template/app/README.md @@ -0,0 +1,12 @@ +# + +Built with [Wasp](https://wasp.sh), based on the [Open Saas](https://opensaas.sh) template. + +## Development + +### Running locally + - Make sure you have the `.env.client` and `.env.server` files with correct dev values in the root of the project. + - Run the database with `wasp start db` and leave it running. + - Run `wasp start` and leave it running. + - [OPTIONAL]: If this is the first time starting the app, or you've just made changes to your entities/prisma schema, also run `wasp db migrate-dev`. + diff --git a/template/app/main.wasp b/template/app/main.wasp new file mode 100644 index 00000000..52e54b1a --- /dev/null +++ b/template/app/main.wasp @@ -0,0 +1,347 @@ +app OpenSaaS { + wasp: { + version: "^0.16.0" + }, + + title: "My Open SaaS App", + + head: [ + "", + "", + "", + "", + + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + // TODO: You can put your Plausible analytics scripts below (https://docs.opensaas.sh/guides/analytics/): + // NOTE: Plausible does not use Cookies, so you can simply add the scripts here. + // Google, on the other hand, does, so you must instead add the script dynamically + // via the Cookie Consent component after the user clicks the "Accept" cookies button. + "", // for production + "", // for development + ], + + // 🔐 Auth out of the box! https://wasp.sh/docs/auth/overview + auth: { + userEntity: User, + methods: { + // NOTE: If you decide to not use email auth, make sure to also delete the related routes and pages below. + // (RequestPasswordReset(Route|Page), PasswordReset(Route|Page), EmailVerification(Route|Page)) + email: { + fromField: { + name: "Open SaaS App", + email: "me@example.com" + }, + emailVerification: { + clientRoute: EmailVerificationRoute, + getEmailContentFn: import { getVerificationEmailContent } from "@src/auth/email-and-pass/emails", + }, + passwordReset: { + clientRoute: PasswordResetRoute, + getEmailContentFn: import { getPasswordResetEmailContent } from "@src/auth/email-and-pass/emails", + }, + userSignupFields: import { getEmailUserFields } from "@src/auth/userSignupFields", + }, + // Uncomment to enable Google Auth (check https://wasp.sh/docs/auth/social-auth/google for setup instructions): + // google: { // Guide for setting up Auth via Google + // userSignupFields: import { getGoogleUserFields } from "@src/auth/userSignupFields", + // configFn: import { getGoogleAuthConfig } from "@src/auth/userSignupFields", + // }, + // Uncomment to enable GitHub Auth (check https://wasp.sh/docs/auth/social-auth/github for setup instructions): + // gitHub: { + // userSignupFields: import { getGitHubUserFields } from "@src/auth/userSignupFields", + // configFn: import { getGitHubAuthConfig } from "@src/auth/userSignupFields", + // }, + // Uncomment to enable Discord Auth (check https://wasp.sh/docs/auth/social-auth/discord for setup instructions): + // discord: { + // userSignupFields: import { getDiscordUserFields } from "@src/auth/userSignupFields", + // configFn: import { getDiscordAuthConfig } from "@src/auth/userSignupFields" + // } + }, + onAfterSignup: import { onAfterSignup } from "@src/auth/hooks", + onAuthFailedRedirectTo: "/login", + onAuthSucceededRedirectTo: "/demo-app", + }, + + db: { + // Run `wasp db seed` to seed the database with the seed functions below: + seeds: [ + // Populates the database with a bunch of fake users to work with during development. + import { seedMockUsers } from "@src/server/scripts/dbSeeds", + ] + }, + + client: { + rootComponent: import App from "@src/client/App", + }, + + emailSender: { + // NOTE: "Dummy" provider is just for local development purposes. + // Make sure to check the server logs for the email confirmation url (it will not be sent to an address)! + // Once you are ready for production, switch to e.g. "SendGrid" or "Mailgun" providers. Check out https://docs.opensaas.sh/guides/email-sending/ . + provider: Dummy, + defaultFrom: { + name: "Open SaaS App", + // When using a real provider, e.g. SendGrid, you must use the same email address that you configured your account to send out emails with! + email: "me@example.com" + }, + }, +} + +route LandingPageRoute { path: "/", to: LandingPage } +page LandingPage { + component: import LandingPage from "@src/landing-page/LandingPage" +} + +//#region Auth Pages +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import Login from "@src/auth/LoginPage" +} + +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { + component: import { Signup } from "@src/auth/SignupPage" +} + +route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordResetPage } +page RequestPasswordResetPage { + component: import { RequestPasswordResetPage } from "@src/auth/email-and-pass/RequestPasswordResetPage", +} + +route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage } +page PasswordResetPage { + component: import { PasswordResetPage } from "@src/auth/email-and-pass/PasswordResetPage", +} + +route EmailVerificationRoute { path: "/email-verification", to: EmailVerificationPage } +page EmailVerificationPage { + component: import { EmailVerificationPage } from "@src/auth/email-and-pass/EmailVerificationPage", +} +//#endregion + +//#region User +route AccountRoute { path: "/account", to: AccountPage } +page AccountPage { + authRequired: true, + component: import Account from "@src/user/AccountPage" +} + +query getPaginatedUsers { + fn: import { getPaginatedUsers } from "@src/user/operations", + entities: [User] +} + +action updateCurrentUserLastActiveTimestamp { + fn: import { updateCurrentUserLastActiveTimestamp } from "@src/user/operations", + entities: [User] +} + +action updateIsUserAdminById { + fn: import { updateIsUserAdminById } from "@src/user/operations", + entities: [User] +} +//#endregion + +//#region Demo AI App +route DemoAppRoute { path: "/demo-app", to: DemoAppPage } +page DemoAppPage { + authRequired: true, + component: import DemoAppPage from "@src/demo-ai-app/DemoAppPage" +} + +action generateGptResponse { + fn: import { generateGptResponse } from "@src/demo-ai-app/operations", + entities: [User, Task, GptResponse] +} + +action createTask { + fn: import { createTask } from "@src/demo-ai-app/operations", + entities: [Task] +} + +action deleteTask { + fn: import { deleteTask } from "@src/demo-ai-app/operations", + entities: [Task] +} + +action updateTask { + fn: import { updateTask } from "@src/demo-ai-app/operations", + entities: [Task] +} + +query getGptResponses { + fn: import { getGptResponses } from "@src/demo-ai-app/operations", + entities: [User, GptResponse] +} + +query getAllTasksByUser { + fn: import { getAllTasksByUser } from "@src/demo-ai-app/operations", + entities: [Task] +} +//#endregion + +//#region Payment +route PricingPageRoute { path: "/pricing", to: PricingPage } +page PricingPage { + component: import PricingPage from "@src/payment/PricingPage" +} + +route CheckoutRoute { path: "/checkout", to: CheckoutPage } +page CheckoutPage { + authRequired: true, + component: import Checkout from "@src/payment/CheckoutPage" +} + +query getCustomerPortalUrl { + fn: import { getCustomerPortalUrl } from "@src/payment/operations", + entities: [User] +} + +action generateCheckoutSession { + fn: import { generateCheckoutSession } from "@src/payment/operations", + entities: [User] +} + +api paymentsWebhook { + fn: import { paymentsWebhook } from "@src/payment/webhook", + entities: [User], + middlewareConfigFn: import { paymentsMiddlewareConfigFn } from "@src/payment/webhook", + httpRoute: (POST, "/payments-webhook") +} +//#endregion + +//#region File Upload +route FileUploadRoute { path: "/file-upload", to: FileUploadPage } +page FileUploadPage { + authRequired: true, + component: import FileUpload from "@src/file-upload/FileUploadPage" +} + +action createFile { + fn: import { createFile } from "@src/file-upload/operations", + entities: [User, File] +} + +query getAllFilesByUser { + fn: import { getAllFilesByUser } from "@src/file-upload/operations", + entities: [User, File] +} + +query getDownloadFileSignedURL { + fn: import { getDownloadFileSignedURL } from "@src/file-upload/operations", + entities: [User, File] +} +//#endregion + +//#region Analytics +query getDailyStats { + fn: import { getDailyStats } from "@src/analytics/operations", + entities: [User, DailyStats] +} + +job dailyStatsJob { + executor: PgBoss, + perform: { + fn: import { calculateDailyStats } from "@src/analytics/stats" + }, + schedule: { + cron: "0 * * * *" // every hour. useful in production + // cron: "* * * * *" // every minute. useful for debugging + }, + entities: [User, DailyStats, Logs, PageViewSource] +} +//#endregion + +//#region Admin Dashboard +route AdminRoute { path: "/admin", to: AnalyticsDashboardPage } +page AnalyticsDashboardPage { + authRequired: true, + component: import AnalyticsDashboardPage from "@src/admin/dashboards/analytics/AnalyticsDashboardPage" +} + +route AdminUsersRoute { path: "/admin/users", to: AdminUsersPage } +page AdminUsersPage { + authRequired: true, + component: import AdminUsers from "@src/admin/dashboards/users/UsersDashboardPage" +} + +route AdminSettingsRoute { path: "/admin/settings", to: AdminSettingsPage } +page AdminSettingsPage { + authRequired: true, + component: import AdminSettings from "@src/admin/elements/settings/SettingsPage" +} + +route AdminChartsRoute { path: "/admin/chart", to: AdminChartsPage } +page AdminChartsPage { + authRequired: true, + component: import AdminCharts from "@src/admin/elements/charts/ChartsPage" +} + +route AdminFormElementsRoute { path: "/admin/forms/form-elements", to: AdminFormElementsPage } +page AdminFormElementsPage { + authRequired: true, + component: import AdminForms from "@src/admin/elements/forms/FormElementsPage" +} + +route AdminFormLayoutsRoute { path: "/admin/forms/form-layouts", to: AdminFormLayoutsPage } +page AdminFormLayoutsPage { + authRequired: true, + component: import AdminForms from "@src/admin/elements/forms/FormLayoutsPage" +} + +route AdminCalendarRoute { path: "/admin/calendar", to: AdminCalendarPage } +page AdminCalendarPage { + authRequired: true, + component: import AdminCalendar from "@src/admin/elements/calendar/CalendarPage" +} + +route AdminUIAlertsRoute { path: "/admin/ui/alerts", to: AdminUIAlertsPage } +page AdminUIAlertsPage { + authRequired: true, + component: import AdminUI from "@src/admin/elements/ui-elements/AlertsPage" +} + +route AdminUIButtonsRoute { path: "/admin/ui/buttons", to: AdminUIButtonsPage } +page AdminUIButtonsPage { + authRequired: true, + component: import AdminUI from "@src/admin/elements/ui-elements/ButtonsPage" +} + +route NotFoundRoute { path: "*", to: NotFoundPage } +page NotFoundPage { + component: import { NotFoundPage } from "@src/client/components/NotFoundPage" +} +//#endregion + +//#region Contact Form Messages +// TODO: +// add functionality to allow users to send messages to admin +// and make them accessible via the admin dashboard +route AdminMessagesRoute { path: "/admin/messages", to: AdminMessagesPage } +page AdminMessagesPage { + authRequired: true, + component: import AdminMessages from "@src/messages/MessagesPage" +} +//#endregion + +//#region Newsletter +job sendNewsletter { + executor: PgBoss, + perform: { + fn: import { checkAndQueueNewsletterEmails } from "@src/newsletter/sendNewsletter" + }, + schedule: { + cron: "0 7 * * 1" // at 7:00 am every Monday + }, + entities: [User] +} +//#endregion \ No newline at end of file diff --git a/template/app/package.json b/template/app/package.json new file mode 100644 index 00000000..140c8fb2 --- /dev/null +++ b/template/app/package.json @@ -0,0 +1,40 @@ +{ + "name": "opensaas", + "type": "module", + "dependencies": { + "@aws-sdk/client-s3": "^3.523.0", + "@aws-sdk/s3-request-presigner": "^3.523.0", + "@faker-js/faker": "8.3.1", + "@google-analytics/data": "4.1.0", + "@headlessui/react": "1.7.13", + "@lemonsqueezy/lemonsqueezy.js": "^3.2.0", + "@tailwindcss/forms": "^0.5.3", + "@tailwindcss/typography": "^0.5.7", + "apexcharts": "3.41.0", + "clsx": "^2.1.0", + "headlessui": "^0.0.0", + "node-fetch": "3.3.0", + "openai": "^4.55.3", + "prettier": "3.1.1", + "prettier-plugin-tailwindcss": "0.5.11", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.26.2", + "react-apexcharts": "1.4.1", + "react-hot-toast": "^2.4.1", + "react-icons": "4.11.0", + "stripe": "11.15.0", + "tailwind-merge": "^2.2.1", + "vanilla-cookieconsent": "^3.0.1", + "wasp": "file:.wasp/out/sdk/wasp", + "zod": "^3.23.8", + "tailwindcss": "^3.2.7" + }, + "devDependencies": { + "@types/express": "^4.17.13", + "@types/react": "^18.0.37", + "prisma": "5.19.1", + "typescript": "^5.1.0", + "vite": "^4.3.9" + } +} diff --git a/template/app/postcss.config.cjs b/template/app/postcss.config.cjs new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/template/app/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/template/app/public/.gitkeep b/template/app/public/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/template/app/public/favicon.ico b/template/app/public/favicon.ico new file mode 100644 index 00000000..4a7d9f04 Binary files /dev/null and b/template/app/public/favicon.ico differ diff --git a/template/app/public/fonts/Satoshi-Black.eot b/template/app/public/fonts/Satoshi-Black.eot new file mode 100644 index 00000000..11747f36 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Black.eot differ diff --git a/template/app/public/fonts/Satoshi-Black.ttf b/template/app/public/fonts/Satoshi-Black.ttf new file mode 100644 index 00000000..62015aca Binary files /dev/null and b/template/app/public/fonts/Satoshi-Black.ttf differ diff --git a/template/app/public/fonts/Satoshi-Black.woff b/template/app/public/fonts/Satoshi-Black.woff new file mode 100644 index 00000000..a6bee36d Binary files /dev/null and b/template/app/public/fonts/Satoshi-Black.woff differ diff --git a/template/app/public/fonts/Satoshi-Black.woff2 b/template/app/public/fonts/Satoshi-Black.woff2 new file mode 100644 index 00000000..64492d52 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Black.woff2 differ diff --git a/template/app/public/fonts/Satoshi-BlackItalic.eot b/template/app/public/fonts/Satoshi-BlackItalic.eot new file mode 100644 index 00000000..de2edbbc Binary files /dev/null and b/template/app/public/fonts/Satoshi-BlackItalic.eot differ diff --git a/template/app/public/fonts/Satoshi-BlackItalic.ttf b/template/app/public/fonts/Satoshi-BlackItalic.ttf new file mode 100644 index 00000000..74410b97 Binary files /dev/null and b/template/app/public/fonts/Satoshi-BlackItalic.ttf differ diff --git a/template/app/public/fonts/Satoshi-BlackItalic.woff b/template/app/public/fonts/Satoshi-BlackItalic.woff new file mode 100644 index 00000000..0e07e1c5 Binary files /dev/null and b/template/app/public/fonts/Satoshi-BlackItalic.woff differ diff --git a/template/app/public/fonts/Satoshi-BlackItalic.woff2 b/template/app/public/fonts/Satoshi-BlackItalic.woff2 new file mode 100644 index 00000000..9d5c911d Binary files /dev/null and b/template/app/public/fonts/Satoshi-BlackItalic.woff2 differ diff --git a/template/app/public/fonts/Satoshi-Bold.eot b/template/app/public/fonts/Satoshi-Bold.eot new file mode 100644 index 00000000..390ae252 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Bold.eot differ diff --git a/template/app/public/fonts/Satoshi-Bold.ttf b/template/app/public/fonts/Satoshi-Bold.ttf new file mode 100644 index 00000000..00bc985b Binary files /dev/null and b/template/app/public/fonts/Satoshi-Bold.ttf differ diff --git a/template/app/public/fonts/Satoshi-Bold.woff b/template/app/public/fonts/Satoshi-Bold.woff new file mode 100644 index 00000000..bba8257f Binary files /dev/null and b/template/app/public/fonts/Satoshi-Bold.woff differ diff --git a/template/app/public/fonts/Satoshi-Bold.woff2 b/template/app/public/fonts/Satoshi-Bold.woff2 new file mode 100644 index 00000000..0a8db7a4 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Bold.woff2 differ diff --git a/template/app/public/fonts/Satoshi-BoldItalic.eot b/template/app/public/fonts/Satoshi-BoldItalic.eot new file mode 100644 index 00000000..426be2ac Binary files /dev/null and b/template/app/public/fonts/Satoshi-BoldItalic.eot differ diff --git a/template/app/public/fonts/Satoshi-BoldItalic.ttf b/template/app/public/fonts/Satoshi-BoldItalic.ttf new file mode 100644 index 00000000..24f012cb Binary files /dev/null and b/template/app/public/fonts/Satoshi-BoldItalic.ttf differ diff --git a/template/app/public/fonts/Satoshi-BoldItalic.woff b/template/app/public/fonts/Satoshi-BoldItalic.woff new file mode 100644 index 00000000..8bcb7a6e Binary files /dev/null and b/template/app/public/fonts/Satoshi-BoldItalic.woff differ diff --git a/template/app/public/fonts/Satoshi-BoldItalic.woff2 b/template/app/public/fonts/Satoshi-BoldItalic.woff2 new file mode 100644 index 00000000..225527f7 Binary files /dev/null and b/template/app/public/fonts/Satoshi-BoldItalic.woff2 differ diff --git a/template/app/public/fonts/Satoshi-Italic.eot b/template/app/public/fonts/Satoshi-Italic.eot new file mode 100644 index 00000000..64039a84 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Italic.eot differ diff --git a/template/app/public/fonts/Satoshi-Italic.ttf b/template/app/public/fonts/Satoshi-Italic.ttf new file mode 100644 index 00000000..c214f4fe Binary files /dev/null and b/template/app/public/fonts/Satoshi-Italic.ttf differ diff --git a/template/app/public/fonts/Satoshi-Italic.woff b/template/app/public/fonts/Satoshi-Italic.woff new file mode 100644 index 00000000..edd4d932 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Italic.woff differ diff --git a/template/app/public/fonts/Satoshi-Italic.woff2 b/template/app/public/fonts/Satoshi-Italic.woff2 new file mode 100644 index 00000000..8b98599d Binary files /dev/null and b/template/app/public/fonts/Satoshi-Italic.woff2 differ diff --git a/template/app/public/fonts/Satoshi-Light.eot b/template/app/public/fonts/Satoshi-Light.eot new file mode 100644 index 00000000..d8fcaccd Binary files /dev/null and b/template/app/public/fonts/Satoshi-Light.eot differ diff --git a/template/app/public/fonts/Satoshi-Light.ttf b/template/app/public/fonts/Satoshi-Light.ttf new file mode 100644 index 00000000..b41a2d4a Binary files /dev/null and b/template/app/public/fonts/Satoshi-Light.ttf differ diff --git a/template/app/public/fonts/Satoshi-Light.woff b/template/app/public/fonts/Satoshi-Light.woff new file mode 100644 index 00000000..8f05e4e9 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Light.woff differ diff --git a/template/app/public/fonts/Satoshi-Light.woff2 b/template/app/public/fonts/Satoshi-Light.woff2 new file mode 100644 index 00000000..cf18cd4c Binary files /dev/null and b/template/app/public/fonts/Satoshi-Light.woff2 differ diff --git a/template/app/public/fonts/Satoshi-LightItalic.eot b/template/app/public/fonts/Satoshi-LightItalic.eot new file mode 100644 index 00000000..e34a0df4 Binary files /dev/null and b/template/app/public/fonts/Satoshi-LightItalic.eot differ diff --git a/template/app/public/fonts/Satoshi-LightItalic.ttf b/template/app/public/fonts/Satoshi-LightItalic.ttf new file mode 100644 index 00000000..08f5db57 Binary files /dev/null and b/template/app/public/fonts/Satoshi-LightItalic.ttf differ diff --git a/template/app/public/fonts/Satoshi-LightItalic.woff b/template/app/public/fonts/Satoshi-LightItalic.woff new file mode 100644 index 00000000..a03a50d7 Binary files /dev/null and b/template/app/public/fonts/Satoshi-LightItalic.woff differ diff --git a/template/app/public/fonts/Satoshi-LightItalic.woff2 b/template/app/public/fonts/Satoshi-LightItalic.woff2 new file mode 100644 index 00000000..6bd15ad5 Binary files /dev/null and b/template/app/public/fonts/Satoshi-LightItalic.woff2 differ diff --git a/template/app/public/fonts/Satoshi-Medium.eot b/template/app/public/fonts/Satoshi-Medium.eot new file mode 100644 index 00000000..83caceca Binary files /dev/null and b/template/app/public/fonts/Satoshi-Medium.eot differ diff --git a/template/app/public/fonts/Satoshi-Medium.ttf b/template/app/public/fonts/Satoshi-Medium.ttf new file mode 100644 index 00000000..ab149b71 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Medium.ttf differ diff --git a/template/app/public/fonts/Satoshi-Medium.woff b/template/app/public/fonts/Satoshi-Medium.woff new file mode 100644 index 00000000..cef3226e Binary files /dev/null and b/template/app/public/fonts/Satoshi-Medium.woff differ diff --git a/template/app/public/fonts/Satoshi-Medium.woff2 b/template/app/public/fonts/Satoshi-Medium.woff2 new file mode 100644 index 00000000..ffd0ac96 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Medium.woff2 differ diff --git a/template/app/public/fonts/Satoshi-MediumItalic.eot b/template/app/public/fonts/Satoshi-MediumItalic.eot new file mode 100644 index 00000000..25d229a5 Binary files /dev/null and b/template/app/public/fonts/Satoshi-MediumItalic.eot differ diff --git a/template/app/public/fonts/Satoshi-MediumItalic.ttf b/template/app/public/fonts/Satoshi-MediumItalic.ttf new file mode 100644 index 00000000..387f278e Binary files /dev/null and b/template/app/public/fonts/Satoshi-MediumItalic.ttf differ diff --git a/template/app/public/fonts/Satoshi-MediumItalic.woff b/template/app/public/fonts/Satoshi-MediumItalic.woff new file mode 100644 index 00000000..46d8995a Binary files /dev/null and b/template/app/public/fonts/Satoshi-MediumItalic.woff differ diff --git a/template/app/public/fonts/Satoshi-MediumItalic.woff2 b/template/app/public/fonts/Satoshi-MediumItalic.woff2 new file mode 100644 index 00000000..212adc92 Binary files /dev/null and b/template/app/public/fonts/Satoshi-MediumItalic.woff2 differ diff --git a/template/app/public/fonts/Satoshi-Regular.eot b/template/app/public/fonts/Satoshi-Regular.eot new file mode 100644 index 00000000..452666f4 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Regular.eot differ diff --git a/template/app/public/fonts/Satoshi-Regular.ttf b/template/app/public/fonts/Satoshi-Regular.ttf new file mode 100644 index 00000000..fe85cd6c Binary files /dev/null and b/template/app/public/fonts/Satoshi-Regular.ttf differ diff --git a/template/app/public/fonts/Satoshi-Regular.woff b/template/app/public/fonts/Satoshi-Regular.woff new file mode 100644 index 00000000..03ac1952 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Regular.woff differ diff --git a/template/app/public/fonts/Satoshi-Regular.woff2 b/template/app/public/fonts/Satoshi-Regular.woff2 new file mode 100644 index 00000000..81c40ab0 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Regular.woff2 differ diff --git a/template/app/public/fonts/Satoshi-Variable.eot b/template/app/public/fonts/Satoshi-Variable.eot new file mode 100644 index 00000000..f42624e1 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Variable.eot differ diff --git a/template/app/public/fonts/Satoshi-Variable.ttf b/template/app/public/fonts/Satoshi-Variable.ttf new file mode 100644 index 00000000..976e85cb Binary files /dev/null and b/template/app/public/fonts/Satoshi-Variable.ttf differ diff --git a/template/app/public/fonts/Satoshi-Variable.woff b/template/app/public/fonts/Satoshi-Variable.woff new file mode 100644 index 00000000..f8dcd1d6 Binary files /dev/null and b/template/app/public/fonts/Satoshi-Variable.woff differ diff --git a/template/app/public/fonts/Satoshi-Variable.woff2 b/template/app/public/fonts/Satoshi-Variable.woff2 new file mode 100644 index 00000000..b00e833e Binary files /dev/null and b/template/app/public/fonts/Satoshi-Variable.woff2 differ diff --git a/template/app/public/fonts/Satoshi-VariableItalic.eot b/template/app/public/fonts/Satoshi-VariableItalic.eot new file mode 100644 index 00000000..5f4554af Binary files /dev/null and b/template/app/public/fonts/Satoshi-VariableItalic.eot differ diff --git a/template/app/public/fonts/Satoshi-VariableItalic.ttf b/template/app/public/fonts/Satoshi-VariableItalic.ttf new file mode 100644 index 00000000..4c2677c6 Binary files /dev/null and b/template/app/public/fonts/Satoshi-VariableItalic.ttf differ diff --git a/template/app/public/fonts/Satoshi-VariableItalic.woff b/template/app/public/fonts/Satoshi-VariableItalic.woff new file mode 100644 index 00000000..3fe029e2 Binary files /dev/null and b/template/app/public/fonts/Satoshi-VariableItalic.woff differ diff --git a/template/app/public/fonts/Satoshi-VariableItalic.woff2 b/template/app/public/fonts/Satoshi-VariableItalic.woff2 new file mode 100644 index 00000000..e7ab3a09 Binary files /dev/null and b/template/app/public/fonts/Satoshi-VariableItalic.woff2 differ diff --git a/template/app/public/public-banner.webp b/template/app/public/public-banner.webp new file mode 100644 index 00000000..ad96e10f Binary files /dev/null and b/template/app/public/public-banner.webp differ diff --git a/template/app/schema.prisma b/template/app/schema.prisma new file mode 100644 index 00000000..fc3f5d39 --- /dev/null +++ b/template/app/schema.prisma @@ -0,0 +1,114 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" +} + +model User { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + + email String? @unique + username String? @unique + lastActiveTimestamp DateTime @default(now()) + isAdmin Boolean @default(false) + + paymentProcessorUserId String? @unique + lemonSqueezyCustomerPortalUrl String? // You can delete this if you're not using Lemon Squeezy as your payments processor. + subscriptionStatus String? // 'active', 'cancel_at_period_end', 'past_due', 'deleted' + subscriptionPlan String? // 'hobby', 'pro' + sendNewsletter Boolean @default(false) + datePaid DateTime? + credits Int @default(3) + + gptResponses GptResponse[] + contactFormMessages ContactFormMessage[] + tasks Task[] + files File[] +} + +model GptResponse { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id]) + userId String + + content String +} + +model Task { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + userId String + + description String + time String @default("1") + isDone Boolean @default(false) +} + +model File { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + userId String + + name String + type String + key String + uploadUrl String +} + +model DailyStats { + id Int @id @default(autoincrement()) + date DateTime @default(now()) @unique + + totalViews Int @default(0) + prevDayViewsChangePercent String @default("0") + userCount Int @default(0) + paidUserCount Int @default(0) + userDelta Int @default(0) + paidUserDelta Int @default(0) + totalRevenue Float @default(0) + totalProfit Float @default(0) + + sources PageViewSource[] +} + +model PageViewSource { + @@id([date, name]) + name String + date DateTime @default(now()) + + dailyStats DailyStats? @relation(fields: [dailyStatsId], references: [id]) + dailyStatsId Int? + + visitors Int +} + +model Logs { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + + message String + level String +} + +model ContactFormMessage { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + userId String + + content String + isRead Boolean @default(false) + repliedAt DateTime? +} \ No newline at end of file diff --git a/template/app/src/admin/dashboards/analytics/AnalyticsDashboardPage.tsx b/template/app/src/admin/dashboards/analytics/AnalyticsDashboardPage.tsx new file mode 100644 index 00000000..70382a45 --- /dev/null +++ b/template/app/src/admin/dashboards/analytics/AnalyticsDashboardPage.tsx @@ -0,0 +1,64 @@ +import { type AuthUser } from 'wasp/auth'; +import { useQuery, getDailyStats } from 'wasp/client/operations'; +import TotalSignupsCard from './TotalSignupsCard'; +import TotalPageViewsCard from './TotalPageViewsCard'; +import TotalPayingUsersCard from './TotalPayingUsersCard'; +import TotalRevenueCard from './TotalRevenueCard'; +import RevenueAndProfitChart from './RevenueAndProfitChart'; +import SourcesTable from './SourcesTable'; +import DefaultLayout from '../../layout/DefaultLayout'; +import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin'; +import { cn } from '../../../client/cn'; + +const Dashboard = ({ user }: { user: AuthUser }) => { + useRedirectHomeUnlessUserIsAdmin({ user }); + + const { data: stats, isLoading, error } = useQuery(getDailyStats); + + return ( + +
+
+
+ + + + +
+ +
+ + +
+ +
+
+
+ + {!stats && ( +
+
+

+ No daily stats generated yet +

+

+ Stats will appear here once the daily stats job has run +

+
+
+ )} +
+
+ ); +}; + +export default Dashboard; diff --git a/template/app/src/admin/dashboards/analytics/RevenueAndProfitChart.tsx b/template/app/src/admin/dashboards/analytics/RevenueAndProfitChart.tsx new file mode 100644 index 00000000..dca3f54e --- /dev/null +++ b/template/app/src/admin/dashboards/analytics/RevenueAndProfitChart.tsx @@ -0,0 +1,242 @@ +import { ApexOptions } from 'apexcharts'; +import React, { useState, useMemo, useEffect } from 'react'; +import ReactApexChart from 'react-apexcharts'; +import { type DailyStatsProps } from '../../../analytics/stats'; + +const options: ApexOptions = { + legend: { + show: false, + position: 'top', + horizontalAlign: 'left', + }, + colors: ['#3C50E0', '#80CAEE'], + chart: { + fontFamily: 'Satoshi, sans-serif', + height: 335, + type: 'area', + dropShadow: { + enabled: true, + color: '#623CEA14', + top: 10, + blur: 4, + left: 0, + opacity: 0.1, + }, + + toolbar: { + show: false, + }, + }, + responsive: [ + { + breakpoint: 1024, + options: { + chart: { + height: 300, + }, + }, + }, + { + breakpoint: 1366, + options: { + chart: { + height: 350, + }, + }, + }, + ], + stroke: { + width: [2, 2], + curve: 'straight', + }, + // labels: { + // show: false, + // position: "top", + // }, + grid: { + xaxis: { + lines: { + show: true, + }, + }, + yaxis: { + lines: { + show: true, + }, + }, + }, + dataLabels: { + enabled: false, + }, + markers: { + size: 4, + colors: '#fff', + strokeColors: ['#3056D3', '#80CAEE'], + strokeWidth: 3, + strokeOpacity: 0.9, + strokeDashArray: 0, + fillOpacity: 1, + discrete: [], + hover: { + size: undefined, + sizeOffset: 5, + }, + }, + xaxis: { + type: 'category', + axisBorder: { + show: false, + }, + axisTicks: { + show: false, + }, + }, + yaxis: { + title: { + style: { + fontSize: '0px', + }, + }, + min: 0, + max: 100, + }, +}; + +interface ChartOneState { + series: { + name: string; + data: number[]; + }[]; +} + +const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => { + const dailyRevenueArray = useMemo(() => { + if (!!weeklyStats && weeklyStats?.length > 0) { + const sortedWeeks = weeklyStats?.sort((a, b) => { + return new Date(a.date).getTime() - new Date(b.date).getTime(); + }); + return sortedWeeks.map((stat) => stat.totalRevenue); + } + }, [weeklyStats]); + + const daysOfWeekArr = useMemo(() => { + if (!!weeklyStats && weeklyStats?.length > 0) { + const datesArr = weeklyStats?.map((stat) => { + // get day of week, month, and day of month + const dateArr = stat.date.toString().split(' '); + return dateArr.slice(0, 3).join(' '); + }); + return datesArr; + } + }, [weeklyStats]); + + const [state, setState] = useState({ + series: [ + { + name: 'Profit', + data: [4, 7, 10, 11, 13, 14, 17], + }, + ], + }); + const [chartOptions, setChartOptions] = useState(options); + + useEffect(() => { + if (dailyRevenueArray && dailyRevenueArray.length > 0) { + setState((prevState) => { + // Check if a "Revenue" series already exists + const existingSeriesIndex = prevState.series.findIndex((series) => series.name === 'Revenue'); + + if (existingSeriesIndex >= 0) { + // Update existing "Revenue" series data + return { + ...prevState, + series: prevState.series.map((serie, index) => { + if (index === existingSeriesIndex) { + return { ...serie, data: dailyRevenueArray }; + } + return serie; + }), + }; + } else { + // Add "Revenue" series as it does not exist yet + return { + ...prevState, + series: [ + ...prevState.series, + { + name: 'Revenue', + data: dailyRevenueArray, + }, + ], + }; + } + }); + } + }, [dailyRevenueArray]); + + useEffect(() => { + if (!!daysOfWeekArr && daysOfWeekArr?.length > 0 && !!dailyRevenueArray && dailyRevenueArray?.length > 0) { + setChartOptions({ + ...options, + xaxis: { + ...options.xaxis, + categories: daysOfWeekArr, + }, + yaxis: { + ...options.yaxis, + // get the min & max values to the neareast hundred + max: Math.ceil(Math.max(...dailyRevenueArray) / 100) * 100, + min: Math.floor(Math.min(...dailyRevenueArray) / 100) * 100, + }, + }); + } + }, [daysOfWeekArr, dailyRevenueArray]); + + return ( +
+
+
+
+ + + +
+

Total Profit

+

Last 7 Days

+
+
+
+ + + +
+

Total Revenue

+

Last 7 Days

+
+
+
+
+
+ + + +
+
+
+ +
+
+ +
+
+
+ ); +}; + +export default RevenueAndProfitChart; diff --git a/template/app/src/admin/dashboards/analytics/SourcesTable.tsx b/template/app/src/admin/dashboards/analytics/SourcesTable.tsx new file mode 100644 index 00000000..c05f5ba3 --- /dev/null +++ b/template/app/src/admin/dashboards/analytics/SourcesTable.tsx @@ -0,0 +1,47 @@ +import { type PageViewSource } from 'wasp/entities'; + +const SourcesTable = ({ sources }: { sources: PageViewSource[] | undefined }) => { + return ( +
+

Top Sources

+ +
+
+
+
Source
+
+
+
Visitors
+
+
+
Sales
+
+
+ + {sources && sources.length > 0 ? ( + sources.map((source) => ( +
+
+

{source.name}

+
+ +
+

{source.visitors}

+
+ +
+

--

+
+
+ )) + ) : ( +
+

No data to display

+
+ )} +
+
+ ); +}; + +export default SourcesTable; diff --git a/template/app/src/admin/dashboards/analytics/TotalPageViewsCard.tsx b/template/app/src/admin/dashboards/analytics/TotalPageViewsCard.tsx new file mode 100644 index 00000000..3322929f --- /dev/null +++ b/template/app/src/admin/dashboards/analytics/TotalPageViewsCard.tsx @@ -0,0 +1,55 @@ +import { cn } from '../../../client/cn'; +import { UpArrow, DownArrow } from '../../../client/icons/icons-arrows'; + +type PageViewsStats = { + totalPageViews: number | undefined; + prevDayViewsChangePercent: string | undefined; +}; + +const TotalPageViewsCard = ({ totalPageViews, prevDayViewsChangePercent }: PageViewsStats) => { + const isDeltaPositive = parseInt(prevDayViewsChangePercent || '') > 0; + + return ( +
+
+ + + + +
+ +
+
+

{totalPageViews}

+ Total page views +
+ + {prevDayViewsChangePercent && parseInt(prevDayViewsChangePercent) !== 0 && ( + + {prevDayViewsChangePercent}%{parseInt(prevDayViewsChangePercent) > 0 ? : } + + )} +
+
+ ); +}; + +export default TotalPageViewsCard; diff --git a/template/app/src/admin/dashboards/analytics/TotalPayingUsersCard.tsx b/template/app/src/admin/dashboards/analytics/TotalPayingUsersCard.tsx new file mode 100644 index 00000000..ecb29840 --- /dev/null +++ b/template/app/src/admin/dashboards/analytics/TotalPayingUsersCard.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import { cn } from '../../../client/cn'; +import { UpArrow, DownArrow } from '../../../client/icons/icons-arrows'; +import { type DailyStatsProps } from '../../../analytics/stats'; + +const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => { + const isDeltaPositive = useMemo(() => { + return !!dailyStats?.paidUserDelta && dailyStats?.paidUserDelta > 0; + }, [dailyStats]); + + return ( +
+
+ + + + +
+ +
+
+

{dailyStats?.paidUserCount}

+ Total Paying Users +
+ + + {isLoading ? '...' : dailyStats?.paidUserDelta !== 0 ? dailyStats?.paidUserDelta : '-'} + {dailyStats?.paidUserDelta !== 0 ? isDeltaPositive ? : : null} + +
+
+ ); +}; + +export default TotalPayingUsersCard; diff --git a/template/app/src/admin/dashboards/analytics/TotalRevenueCard.tsx b/template/app/src/admin/dashboards/analytics/TotalRevenueCard.tsx new file mode 100644 index 00000000..1d3faf05 --- /dev/null +++ b/template/app/src/admin/dashboards/analytics/TotalRevenueCard.tsx @@ -0,0 +1,62 @@ +import { useMemo } from 'react'; +import { UpArrow, DownArrow } from '../../../client/icons/icons-arrows'; +import { type DailyStatsProps } from '../../../analytics/stats'; + +const TotalRevenueCard = ({dailyStats, weeklyStats, isLoading}: DailyStatsProps) => { + const isDeltaPositive = useMemo(() => { + if (!weeklyStats) return false; + return (weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) > 0; + }, [weeklyStats]); + + const deltaPercentage = useMemo(() => { + if ( !weeklyStats || weeklyStats.length < 2 || isLoading) return; + if ( weeklyStats[1]?.totalRevenue === 0 || weeklyStats[0]?.totalRevenue === 0 ) return 0; + + weeklyStats.sort((a, b) => b.id - a.id); + + const percentage = ((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) / weeklyStats[1]?.totalRevenue) * 100; + return Math.floor(percentage); + }, [weeklyStats]); + + return ( +
+
+ + + + + +
+ +
+
+

${dailyStats?.totalRevenue}

+ Total Revenue +
+ + + {isLoading ? '...' : !!deltaPercentage ? deltaPercentage + '%' : '-'} + {!!deltaPercentage ? isDeltaPositive ? : : null} + +
+
+ ); +}; + +export default TotalRevenueCard; diff --git a/template/app/src/admin/dashboards/analytics/TotalSignupsCard.tsx b/template/app/src/admin/dashboards/analytics/TotalSignupsCard.tsx new file mode 100644 index 00000000..f497216a --- /dev/null +++ b/template/app/src/admin/dashboards/analytics/TotalSignupsCard.tsx @@ -0,0 +1,57 @@ +import { useMemo } from 'react'; +import { cn } from '../../../client/cn'; +import { UpArrow } from '../../../client/icons/icons-arrows'; +import { type DailyStatsProps } from '../../../analytics/stats'; + +const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => { + const isDeltaPositive = useMemo(() => { + return !!dailyStats?.userDelta && dailyStats.userDelta > 0; + }, [dailyStats]); + + return ( +
+
+ + + + + +
+ +
+
+

{dailyStats?.userCount}

+ Total Signups +
+ + + {isLoading ? '...' : isDeltaPositive ? dailyStats?.userDelta : '-'} + {!!dailyStats && isDeltaPositive && } + +
+
+ ); +}; + +export default TotalSignupsCard; diff --git a/template/app/src/admin/dashboards/users/DropdownEditDelete.tsx b/template/app/src/admin/dashboards/users/DropdownEditDelete.tsx new file mode 100644 index 00000000..84dab386 --- /dev/null +++ b/template/app/src/admin/dashboards/users/DropdownEditDelete.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from 'react'; +import { cn } from '../../../client/cn'; + +const DropdownDefault = () => { + const [dropdownOpen, setDropdownOpen] = useState(false); + + const trigger = useRef(null); + const dropdown = useRef(null); + + // close on click outside + useEffect(() => { + const clickHandler = ({ target }: MouseEvent) => { + if (!dropdown.current) return; + if (!dropdownOpen || dropdown.current.contains(target) || trigger.current.contains(target)) return; + setDropdownOpen(false); + }; + document.addEventListener('click', clickHandler); + return () => document.removeEventListener('click', clickHandler); + }); + + // close if the esc key is pressed + useEffect(() => { + const keyHandler = ({ keyCode }: KeyboardEvent) => { + if (!dropdownOpen || keyCode !== 27) return; + setDropdownOpen(false); + }; + document.addEventListener('keydown', keyHandler); + return () => document.removeEventListener('keydown', keyHandler); + }); + + return ( +
+ +
setDropdownOpen(true)} + onBlur={() => setDropdownOpen(false)} + className={cn( + 'absolute right-0 top-full z-40 w-40 space-y-1 rounded-sm border border-stroke bg-white p-1.5 shadow-default dark:border-strokedark dark:bg-boxdark', + { + block: dropdownOpen, + hidden: !dropdownOpen, + } + )} + > + + +
+
+ ); +}; + +export default DropdownDefault; diff --git a/template/app/src/admin/dashboards/users/SwitcherOne.tsx b/template/app/src/admin/dashboards/users/SwitcherOne.tsx new file mode 100644 index 00000000..084cabe0 --- /dev/null +++ b/template/app/src/admin/dashboards/users/SwitcherOne.tsx @@ -0,0 +1,33 @@ +import { type User } from 'wasp/entities'; +import { useState } from 'react'; +import { cn } from '../../../client/cn'; + +const SwitcherOne = ({ user, updateIsUserAdminById }: { user?: Partial; updateIsUserAdminById?: any }) => { + const [enabled, setEnabled] = useState(user?.isAdmin || false); + + return ( +
+ +
+ ); +}; + +export default SwitcherOne; diff --git a/template/app/src/admin/dashboards/users/UsersDashboardPage.tsx b/template/app/src/admin/dashboards/users/UsersDashboardPage.tsx new file mode 100644 index 00000000..9ebeb627 --- /dev/null +++ b/template/app/src/admin/dashboards/users/UsersDashboardPage.tsx @@ -0,0 +1,20 @@ +import { type AuthUser } from 'wasp/auth'; +import UsersTable from './UsersTable'; +import Breadcrumb from '../../layout/Breadcrumb'; +import DefaultLayout from '../../layout/DefaultLayout'; +import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin'; + +const Users = ({ user }: { user: AuthUser }) => { + useRedirectHomeUnlessUserIsAdmin({user}) + + return ( + + +
+ +
+
+ ); +}; + +export default Users; diff --git a/template/app/src/admin/dashboards/users/UsersTable.tsx b/template/app/src/admin/dashboards/users/UsersTable.tsx new file mode 100644 index 00000000..6e1dccdd --- /dev/null +++ b/template/app/src/admin/dashboards/users/UsersTable.tsx @@ -0,0 +1,242 @@ +import { type SubscriptionStatus } from '../../../payment/plans'; +import { updateIsUserAdminById, useQuery, getPaginatedUsers } from 'wasp/client/operations'; +import { useState, useEffect } from 'react'; +import SwitcherOne from './SwitcherOne'; +import LoadingSpinner from '../../layout/LoadingSpinner'; +import DropdownEditDelete from './DropdownEditDelete'; + +const UsersTable = () => { + const [skip, setskip] = useState(0); + const [page, setPage] = useState(1); + const [email, setEmail] = useState(undefined); + const [isAdminFilter, setIsAdminFilter] = useState(undefined); + const [statusOptions, setStatusOptions] = useState([]); + const { data, isLoading, error } = useQuery(getPaginatedUsers, { + skip, + emailContains: email, + isAdmin: isAdminFilter, + subscriptionStatus: statusOptions?.length > 0 ? statusOptions : undefined, + }); + + useEffect(() => { + setPage(1); + }, [email, statusOptions]); + + useEffect(() => { + setskip((page - 1) * 10); + }, [page]); + + return ( +
+
+
+ Filters: +
+
+ + { + setEmail(e.currentTarget.value); + }} + className='rounded border border-stroke py-2 px-5 bg-white outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary' + /> + +
+
+ {!!statusOptions && statusOptions.length > 0 ? ( + statusOptions.map((opt, idx) => ( + + {opt ? opt : 'has not subscribed'} + { + e.stopPropagation(); + setStatusOptions((prevValue) => { + return prevValue?.filter((val) => val !== opt); + }); + }} + className='z-30 cursor-pointer pl-2 hover:text-danger' + > + + + + + + )) + ) : ( + + Select Status Filters + + )} +
+ + + + + + + + +
+
+ + +
+
+ {!isLoading && ( +
+ page + { + setPage(parseInt(e.currentTarget.value)); + }} + className='rounded-md border-1 border-stroke bg-transparent px-4 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary' + /> + / {data?.totalPages} +
+ )} +
+
+ +
+
+

Email / Username

+
+
+

Last Active

+
+
+

Subscription Status

+
+
+

Stripe ID

+
+
+

Is Admin

+
+
+

+
+
+ {isLoading && ( +
+ +
+ )} + {!!data?.users && + data?.users?.length > 0 && + data.users.map((user) => ( +
+
+
+

{user.email}

+

{user.username}

+
+
+ +
+

+ {user.lastActiveTimestamp.toLocaleDateString() + + ' ' + + user.lastActiveTimestamp.toLocaleTimeString()} +

+
+
+

{user.subscriptionStatus}

+
+
+

{user.paymentProcessorUserId}

+
+
+
+ +
+
+
+ +
+
+ ))} +
+
+ ); +}; + +export default UsersTable; diff --git a/template/app/src/admin/elements/calendar/CalendarPage.tsx b/template/app/src/admin/elements/calendar/CalendarPage.tsx new file mode 100644 index 00000000..ea7c4ff4 --- /dev/null +++ b/template/app/src/admin/elements/calendar/CalendarPage.tsx @@ -0,0 +1,198 @@ +import { type AuthUser } from 'wasp/auth'; +import Breadcrumb from '../../layout/Breadcrumb'; +import DefaultLayout from '../../layout/DefaultLayout'; +import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin'; + +const Calendar = ({ user }: { user: AuthUser }) => { + useRedirectHomeUnlessUserIsAdmin({ user }); + + return ( + + + + {/* */} +
+ + + + + + + + + + + + + + {/* */} + + + + + + + + + + {/* */} + {/* */} + + + + + + + + + + {/* */} + {/* */} + + + + + + + + + + {/* */} + {/* */} + + + + + + + + + + {/* */} + {/* */} + + + + + + + + + + {/* */} + +
+ Sunday + Sun + + Monday + Mon + + Tuesday + Tue + + Wednesday + Wed + + Thursday + Thur + + Friday + Fri + + Saturday + Sat +
+ 1 +
+ More +
+ + Redesign Website + + 1 Dec - 2 Dec +
+
+
+ 2 + + 3 + + 4 + + 5 + + 6 + + 7 +
+ 8 + + 9 + + 10 + + 11 + + 12 + + 13 + + 14 +
+ 15 + + 16 + + 17 + + 18 + + 19 + + 20 + + 21 +
+ 22 + + 23 + + 24 + + 25 +
+ More +
+ App Design + 25 Dec - 27 Dec +
+
+
+ 26 + + 27 + + 28 +
+ 29 + + 30 + + 31 + + 1 + + 2 + + 3 + + 4 +
+
+ {/* */} +
+ ); +}; + +export default Calendar; diff --git a/template/app/src/admin/elements/charts/BarChart.tsx b/template/app/src/admin/elements/charts/BarChart.tsx new file mode 100644 index 00000000..6c5e067d --- /dev/null +++ b/template/app/src/admin/elements/charts/BarChart.tsx @@ -0,0 +1,138 @@ +import { ApexOptions } from 'apexcharts'; +import React, { useState } from 'react'; +import ReactApexChart from 'react-apexcharts'; + +interface BarChartState { + series: { data: number[] }[]; +} + +const BarChart: React.FC = () => { + const [state, setState] = useState({ + series: [ + { + data: [ + 168, 385, 201, 298, 187, 195, 291, 110, 215, 390, 280, 112, 123, 212, + 270, 190, 310, 115, 90, 380, 112, 223, 292, 170, 290, 110, 115, 290, + 380, 312, + ], + }, + ], + }); + + const options: ApexOptions = { + colors: ['#3C50E0'], + chart: { + fontFamily: 'Satoshi, sans-serif', + type: 'bar', + height: 350, + toolbar: { + show: false, + }, + }, + plotOptions: { + bar: { + horizontal: false, + columnWidth: '55%', + // endingShape: "rounded", + borderRadius: 2, + }, + }, + dataLabels: { + enabled: false, + }, + stroke: { + show: true, + width: 4, + colors: ['transparent'], + }, + xaxis: { + categories: [ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '19', + '20', + '21', + '22', + '23', + '24', + '25', + '26', + '27', + '28', + '29', + '30', + ], + axisBorder: { + show: false, + }, + axisTicks: { + show: false, + }, + }, + legend: { + show: true, + position: 'top', + horizontalAlign: 'left', + fontFamily: 'inter', + }, + yaxis: { + title: { + text: 'Visitors', + } + }, + grid: { + yaxis: { + lines: { + show: false, + }, + }, + }, + fill: { + opacity: 1, + }, + tooltip: { + x: { + show: false, + }, + }, + }; + + return ( +
+
+

+ Visitors Analytics +

+
+ +
+
+ +
+
+
+ ); +}; + +export default BarChart; diff --git a/template/app/src/admin/elements/charts/ChartsPage.tsx b/template/app/src/admin/elements/charts/ChartsPage.tsx new file mode 100644 index 00000000..b865a429 --- /dev/null +++ b/template/app/src/admin/elements/charts/ChartsPage.tsx @@ -0,0 +1,27 @@ +import { type AuthUser } from 'wasp/auth'; +import Breadcrumb from '../../layout/Breadcrumb'; +import DefaultLayout from '../../layout/DefaultLayout'; +import BarChart from './BarChart'; +import PieChart from './PieChart'; +import DataStats from './DataStatsChart'; +import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin'; + +const Chart = ({ user }: { user: AuthUser }) => { + useRedirectHomeUnlessUserIsAdmin({ user }); + + return ( + + + +
+ +
+ +
+ +
+
+ ); +}; + +export default Chart; diff --git a/template/app/src/admin/elements/charts/DataStatsChart.tsx b/template/app/src/admin/elements/charts/DataStatsChart.tsx new file mode 100644 index 00000000..a1b3882e --- /dev/null +++ b/template/app/src/admin/elements/charts/DataStatsChart.tsx @@ -0,0 +1,102 @@ +const DataStatsChart = () => { + return ( +
+
+
+
+

+ 4,350 +

+

Unique Visitors

+
+
+ + + + 18% +
+
+
+
+

+ 55.9K +

+

Total Pageviews

+
+
+ + + + 25% +
+
+
+
+

+ 54% +

+

Bounce Rate

+
+
+ + + + 7% +
+
+
+
+

+ 2m 56s +

+

Visit Duration

+
+
+ + + + 12% +
+
+
+
+ ); +}; + +export default DataStatsChart; diff --git a/template/app/src/admin/elements/charts/PieChart.tsx b/template/app/src/admin/elements/charts/PieChart.tsx new file mode 100644 index 00000000..4a7042e5 --- /dev/null +++ b/template/app/src/admin/elements/charts/PieChart.tsx @@ -0,0 +1,150 @@ +import { ApexOptions } from 'apexcharts'; +import React, { useState } from 'react'; +import ReactApexChart from 'react-apexcharts'; + +interface PieChartState { + series: number[]; +} + +const options: ApexOptions = { + chart: { + type: 'donut', + }, + colors: ['#10B981', '#375E83', '#259AE6', '#FFA70B'], + labels: ['Remote', 'Hybrid', 'Onsite', 'Leave'], + legend: { + show: true, + position: 'bottom', + }, + + plotOptions: { + pie: { + donut: { + size: '65%', + background: 'transparent', + }, + }, + }, + dataLabels: { + enabled: false, + }, + responsive: [ + { + breakpoint: 2600, + options: { + chart: { + width: 380, + }, + }, + }, + { + breakpoint: 640, + options: { + chart: { + width: 200, + }, + }, + }, + ], +}; + +const PieChart: React.FC = () => { + const [state, setState] = useState({ + series: [65, 34, 12, 56], + }); + + return ( +
+
+
+
+ Visitors Analytics +
+
+
+
+ + + + + + + +
+
+
+ +
+
+ +
+
+ +
+
+
+ +

+ Desktop + 65% +

+
+
+
+
+ +

+ Tablet + 34% +

+
+
+
+
+ +

+ Mobile + 45% +

+
+
+
+
+ +

+ Unknown + 12% +

+
+
+
+
+ ); +}; + +export default PieChart; diff --git a/template/app/src/admin/elements/forms/CheckboxOne.tsx b/template/app/src/admin/elements/forms/CheckboxOne.tsx new file mode 100644 index 00000000..3136eb87 --- /dev/null +++ b/template/app/src/admin/elements/forms/CheckboxOne.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { cn } from '../../../client/cn'; + +const CheckboxOne = () => { + const [isChecked, setIsChecked] = useState(false); + + return ( +
+ +
+ ); +}; + +export default CheckboxOne; diff --git a/template/app/src/admin/elements/forms/CheckboxTwo.tsx b/template/app/src/admin/elements/forms/CheckboxTwo.tsx new file mode 100644 index 00000000..f9b4206c --- /dev/null +++ b/template/app/src/admin/elements/forms/CheckboxTwo.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import { cn } from '../../../client/cn'; + +const CheckboxTwo = () => { + const [enabled, setEnabled] = useState(false); + return ( +
+ +
+ ); +}; + +export default CheckboxTwo; diff --git a/template/app/src/admin/elements/forms/FormElementsPage.tsx b/template/app/src/admin/elements/forms/FormElementsPage.tsx new file mode 100644 index 00000000..745e0044 --- /dev/null +++ b/template/app/src/admin/elements/forms/FormElementsPage.tsx @@ -0,0 +1,275 @@ +import { type AuthUser } from 'wasp/auth'; +import Breadcrumb from '../../layout/Breadcrumb'; +import DefaultLayout from '../../layout/DefaultLayout'; +import CheckboxOne from './CheckboxOne'; +import SwitcherOne from '../../dashboards/users/SwitcherOne'; +import SwitcherTwo from './SwitcherTwo'; +import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin'; + +const FormElements = ({ user }: { user: AuthUser }) => { + useRedirectHomeUnlessUserIsAdmin({ user }); + + return ( + + + +
+
+ {/* */} +
+
+

Input Fields

+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + {/* */} +
+
+

Toggle switch input

+
+
+ + +
+
+ + {/* */} +
+
+

Time and date

+
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+ + {/* */} +
+
+

File upload

+
+
+
+ + +
+ +
+ + +
+
+
+
+ +
+ {/* */} +
+
+

Textarea Fields

+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + {/* */} +
+
+

Checkbox and radio

+
+
+ +
+
+ + {/* */} +
+
+

Select input

+
+
+
+ +
+ + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+ + Design + + + + + + + + Development + + + + + + +
+ + + + + + + + +
+
+
+
+
+
+
+ ); +}; + +export default FormElements; diff --git a/template/app/src/admin/elements/forms/FormLayoutsPage.tsx b/template/app/src/admin/elements/forms/FormLayoutsPage.tsx new file mode 100644 index 00000000..cfa98ab0 --- /dev/null +++ b/template/app/src/admin/elements/forms/FormLayoutsPage.tsx @@ -0,0 +1,230 @@ +import { type AuthUser } from 'wasp/auth'; +import Breadcrumb from '../../layout/Breadcrumb'; +import DefaultLayout from '../../layout/DefaultLayout'; +import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin'; + +const FormLayout = ({ user }: { user: AuthUser }) => { + useRedirectHomeUnlessUserIsAdmin({ user }); + + return ( + + + +
+
+ {/* */} +
+
+

Contact Form

+
+
+
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + + + + + + +
+
+ +
+ + +
+ + +
+
+
+
+ +
+ {/* */} +
+
+

Sign In Form

+
+
+
+
+ + +
+ +
+ + +
+ +
+ + + + Forget password? + +
+ + +
+
+
+ + {/* */} +
+
+

Sign Up Form

+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+
+
+ ); +}; + +export default FormLayout; diff --git a/template/app/src/admin/elements/forms/SwitcherTwo.tsx b/template/app/src/admin/elements/forms/SwitcherTwo.tsx new file mode 100644 index 00000000..55fcbd9b --- /dev/null +++ b/template/app/src/admin/elements/forms/SwitcherTwo.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { cn } from '../../../client/cn'; + +const SwitcherTwo = () => { + const [enabled, setEnabled] = useState(false); + + return ( +
+ +
+ ); +}; + +export default SwitcherTwo; diff --git a/template/app/src/admin/elements/settings/SettingsPage.tsx b/template/app/src/admin/elements/settings/SettingsPage.tsx new file mode 100644 index 00000000..9f353aee --- /dev/null +++ b/template/app/src/admin/elements/settings/SettingsPage.tsx @@ -0,0 +1,297 @@ +import { type AuthUser } from 'wasp/auth'; +import { FormEvent } from 'react'; +import toast from 'react-hot-toast'; +import Breadcrumb from '../../layout/Breadcrumb'; +import DefaultLayout from '../../layout/DefaultLayout'; +import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin'; + +const SettingsPage = ({ user }: { user: AuthUser }) => { + useRedirectHomeUnlessUserIsAdmin({ user }); + + const handleSubmit = (event: FormEvent) => { + // TODO add toast provider / wrapper + event.preventDefault(); + const confirmed = confirm('Are you sure you want to save the changes?'); + if (confirmed) { + toast.success('Your changes have been saved successfully!'); + } else { + toast.error('Your changes have not been saved!'); + } + }; + + return ( + +
+ + +
+
+
+
+

Personal Information

+
+
+
+
+
+ +
+ + + + + + + + + +
+
+ +
+ + +
+
+ +
+ +
+ + + + + + + + + +
+
+ +
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + +
+
+ +
+ + +
+
+
+
+
+
+
+
+

Your Photo

+
+
+
+
+
{/* User */}
+
+ Edit your photo + + + + +
+
+ +
+ +
+ + + + + + + +

+ Click to upload or drag and drop +

+

SVG, PNG, JPG or GIF

+

(max, 800 X 800px)

+
+
+ +
+ + +
+
+
+
+
+
+
+
+ ); +}; + +export default SettingsPage; diff --git a/template/app/src/admin/elements/ui-elements/AlertsPage.tsx b/template/app/src/admin/elements/ui-elements/AlertsPage.tsx new file mode 100644 index 00000000..1830b8f0 --- /dev/null +++ b/template/app/src/admin/elements/ui-elements/AlertsPage.tsx @@ -0,0 +1,75 @@ +import { type AuthUser } from 'wasp/auth'; +import Breadcrumb from '../../layout/Breadcrumb'; +import DefaultLayout from '../../layout/DefaultLayout'; +import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin'; + +const Alerts = ({ user }: { user: AuthUser }) => { + useRedirectHomeUnlessUserIsAdmin({ user }); + + return ( + + + +
+
+ {/* */} +
+
+ + + +
+
+
Attention needed
+

+ Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the + industry's standard dummy text ever since the 1500s, when +

+
+
+ {/* */} +
+
+ + + +
+
+
Message Sent Successfully
+

+ Lorem Ipsum is simply dummy text of the printing and typesetting industry. +

+
+
+ {/* */} +
+
+ + + +
+
+
There were 1 errors with your submission
+
    +
  • Lorem Ipsum is simply dummy text of the printing
  • +
+
+
+
+
+
+ ); +}; + +export default Alerts; diff --git a/template/app/src/admin/elements/ui-elements/ButtonsPage.tsx b/template/app/src/admin/elements/ui-elements/ButtonsPage.tsx new file mode 100644 index 00000000..f6ac5dc3 --- /dev/null +++ b/template/app/src/admin/elements/ui-elements/ButtonsPage.tsx @@ -0,0 +1,471 @@ +import { type AuthUser } from 'wasp/auth'; +import { Link } from 'react-router-dom'; +import Breadcrumb from '../../layout/Breadcrumb'; +import DefaultLayout from '../../layout/DefaultLayout'; +import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin'; + +const Buttons = ({ user }: { user: AuthUser }) => { + useRedirectHomeUnlessUserIsAdmin({ user }); + + return ( + + + + {/* */} +
+
+

Normal Button

+
+ +
+
+ + Button + + + + Button + + + + Button + + + + Button + +
+ +
+ + Button + + + + Button + + + + Button + + + + Button + +
+ +
+ + Button + + + + Button + + + + Button + + + + Button + +
+
+
+ + {/* */} +
+
+

Button With Icon

+
+ +
+
+ + + + + + + + Button With Icon + + + + + + + + + + Button With Icon + + + + + + + + + + Button With Icon + + + + + + + + + + Button With Icon + +
+ +
+ + + + + + + + + Button With Icon + + + + + + + + + + + Button With Icon + + + + + + + + + + + Button With Icon + + + + + + + + + + + Button With Icon + +
+ +
+ + + + + + + + + + + + + + Button With Icon + + + + + + + + + + + + + + + + Button With Icon + + + + + + + + + + + + + + + + Button With Icon + + + + + + + + + + + + + + + + Button With Icon + +
+
+
+
+ ); +}; + +export default Buttons; diff --git a/template/app/src/admin/layout/Breadcrumb.tsx b/template/app/src/admin/layout/Breadcrumb.tsx new file mode 100644 index 00000000..f602e17c --- /dev/null +++ b/template/app/src/admin/layout/Breadcrumb.tsx @@ -0,0 +1,22 @@ +import { Link as WaspRouterLink, routes } from 'wasp/client/router'; +interface BreadcrumbProps { + pageName: string; +} +const Breadcrumb = ({ pageName }: BreadcrumbProps) => { + return ( +
+

{pageName}

+ + +
+ ); +}; + +export default Breadcrumb; diff --git a/template/app/src/admin/layout/DefaultLayout.tsx b/template/app/src/admin/layout/DefaultLayout.tsx new file mode 100644 index 00000000..f9c2297d --- /dev/null +++ b/template/app/src/admin/layout/DefaultLayout.tsx @@ -0,0 +1,41 @@ +import { type AuthUser } from 'wasp/auth'; +import { useState, ReactNode, FC } from 'react'; +import Header from './Header'; +import Sidebar from './Sidebar'; + +interface Props { + user: AuthUser; + children?: ReactNode; +} + +const DefaultLayout: FC = ({ children, user }) => { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ {/* */} +
+ {/* */} + + {/* */} + + {/* */} +
+ {/* */} +
+ {/* */} + + {/* */} +
+
{children}
+
+ {/* */} +
+ {/* */} +
+ {/* */} +
+ ); +}; + +export default DefaultLayout; diff --git a/template/app/src/admin/layout/Header.tsx b/template/app/src/admin/layout/Header.tsx new file mode 100644 index 00000000..3ad9503d --- /dev/null +++ b/template/app/src/admin/layout/Header.tsx @@ -0,0 +1,97 @@ +import { type AuthUser } from 'wasp/auth'; +import MessageButton from '../../messages/MessageButton'; +import DropdownUser from '../../user/DropdownUser'; +import { cn } from '../../client/cn'; +import DarkModeSwitcher from '../../client/components/DarkModeSwitcher'; + +const Header = (props: { + sidebarOpen: string | boolean | undefined; + setSidebarOpen: (arg0: boolean) => void; + user: AuthUser; +}) => { + return ( +
+
+
+ {/* */} + + + + {/* */} +
+ +
    + {/* */} + + {/* */} + + {/* */} + + {/* */} +
+ +
+ {/* */} + + {/* */} +
+
+
+ ); +}; + +export default Header; diff --git a/template/app/src/admin/layout/LoadingSpinner.tsx b/template/app/src/admin/layout/LoadingSpinner.tsx new file mode 100644 index 00000000..7990079d --- /dev/null +++ b/template/app/src/admin/layout/LoadingSpinner.tsx @@ -0,0 +1,9 @@ +const LoadingSpinner = () => { + return ( +
+
+
+ ); +}; + +export default LoadingSpinner; diff --git a/template/app/src/admin/layout/Sidebar.tsx b/template/app/src/admin/layout/Sidebar.tsx new file mode 100644 index 00000000..5a0c53fa --- /dev/null +++ b/template/app/src/admin/layout/Sidebar.tsx @@ -0,0 +1,521 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { NavLink, useLocation } from 'react-router-dom'; +import Logo from '../../client/static/logo.webp'; +import SidebarLinkGroup from './SidebarLinkGroup'; +import { cn } from '../../client/cn'; + +interface SidebarProps { + sidebarOpen: boolean; + setSidebarOpen: (arg: boolean) => void; +} + +const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => { + const location = useLocation(); + const { pathname } = location; + + const trigger = useRef(null); + const sidebar = useRef(null); + + const storedSidebarExpanded = localStorage.getItem('sidebar-expanded'); + const [sidebarExpanded, setSidebarExpanded] = useState( + storedSidebarExpanded === null ? false : storedSidebarExpanded === 'true' + ); + + // close on click outside + useEffect(() => { + const clickHandler = ({ target }: MouseEvent) => { + if (!sidebar.current || !trigger.current) return; + if (!sidebarOpen || sidebar.current.contains(target) || trigger.current.contains(target)) return; + setSidebarOpen(false); + }; + document.addEventListener('click', clickHandler); + return () => document.removeEventListener('click', clickHandler); + }); + + // close if the esc key is pressed + useEffect(() => { + const keyHandler = ({ keyCode }: KeyboardEvent) => { + if (!sidebarOpen || keyCode !== 27) return; + setSidebarOpen(false); + }; + document.addEventListener('keydown', keyHandler); + return () => document.removeEventListener('keydown', keyHandler); + }); + + useEffect(() => { + localStorage.setItem('sidebar-expanded', sidebarExpanded.toString()); + if (sidebarExpanded) { + document.querySelector('body')?.classList.add('sidebar-expanded'); + } else { + document.querySelector('body')?.classList.remove('sidebar-expanded'); + } + }, [sidebarExpanded]); + + return ( + + ); +}; + +export default Sidebar; diff --git a/template/app/src/admin/layout/SidebarLinkGroup.tsx b/template/app/src/admin/layout/SidebarLinkGroup.tsx new file mode 100644 index 00000000..5330b12e --- /dev/null +++ b/template/app/src/admin/layout/SidebarLinkGroup.tsx @@ -0,0 +1,21 @@ +import { ReactNode, useState } from 'react'; + +interface SidebarLinkGroupProps { + children: (handleClick: () => void, open: boolean) => ReactNode; + activeCondition: boolean; +} + +const SidebarLinkGroup = ({ + children, + activeCondition, +}: SidebarLinkGroupProps) => { + const [open, setOpen] = useState(activeCondition); + + const handleClick = () => { + setOpen(!open); + }; + + return
  • {children(handleClick, open)}
  • ; +}; + +export default SidebarLinkGroup; diff --git a/template/app/src/admin/useRedirectHomeUnlessUserIsAdmin.ts b/template/app/src/admin/useRedirectHomeUnlessUserIsAdmin.ts new file mode 100644 index 00000000..a17a3f6e --- /dev/null +++ b/template/app/src/admin/useRedirectHomeUnlessUserIsAdmin.ts @@ -0,0 +1,13 @@ +import { type AuthUser } from 'wasp/auth'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +export function useRedirectHomeUnlessUserIsAdmin({ user }: { user: AuthUser }) { + const navigate = useNavigate(); + + useEffect(() => { + if (!user.isAdmin) { + navigate('/'); + } + }, [user, history]); +} diff --git a/template/app/src/analytics/operations.ts b/template/app/src/analytics/operations.ts new file mode 100644 index 00000000..bfe5041c --- /dev/null +++ b/template/app/src/analytics/operations.ts @@ -0,0 +1,42 @@ +import { type DailyStats, type PageViewSource } from 'wasp/entities'; +import { HttpError } from 'wasp/server'; +import { type GetDailyStats } from 'wasp/server/operations'; + +type DailyStatsWithSources = DailyStats & { + sources: PageViewSource[]; +}; + +type DailyStatsValues = { + dailyStats: DailyStatsWithSources; + weeklyStats: DailyStatsWithSources[]; +}; + +export const getDailyStats: GetDailyStats = async (_args, context) => { + if (!context.user?.isAdmin) { + throw new HttpError(401); + } + const dailyStats = await context.entities.DailyStats.findFirst({ + orderBy: { + date: 'desc', + }, + include: { + sources: true, + }, + }); + if (!dailyStats) { + console.log('\x1b[34mNote: No daily stats have been generated by the dailyStatsJob yet. \x1b[0m'); + return undefined; + } + + const weeklyStats = await context.entities.DailyStats.findMany({ + orderBy: { + date: 'desc', + }, + take: 7, + include: { + sources: true, + }, + }); + + return { dailyStats, weeklyStats }; +}; diff --git a/template/app/src/analytics/providers/googleAnalyticsUtils.ts b/template/app/src/analytics/providers/googleAnalyticsUtils.ts new file mode 100644 index 00000000..7c6b4b1e --- /dev/null +++ b/template/app/src/analytics/providers/googleAnalyticsUtils.ts @@ -0,0 +1,141 @@ +import { BetaAnalyticsDataClient } from '@google-analytics/data'; + +const CLIENT_EMAIL = process.env.GOOGLE_ANALYTICS_CLIENT_EMAIL; +const PRIVATE_KEY = Buffer.from(process.env.GOOGLE_ANALYTICS_PRIVATE_KEY!, 'base64').toString('utf-8'); +const PROPERTY_ID = process.env.GOOGLE_ANALYTICS_PROPERTY_ID; + +const analyticsDataClient = new BetaAnalyticsDataClient({ + credentials: { + client_email: CLIENT_EMAIL, + private_key: PRIVATE_KEY, + }, +}); + +export async function getSources() { + const [response] = await analyticsDataClient.runReport({ + property: `properties/${PROPERTY_ID}`, + dateRanges: [ + { + startDate: '2020-01-01', + endDate: 'today', + }, + ], + // for a list of dimensions and metrics see https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema + dimensions: [ + { + name: 'source', + }, + ], + metrics: [ + { + name: 'activeUsers', + }, + ], + }); + + let activeUsersPerReferrer: any[] = []; + if (response?.rows) { + activeUsersPerReferrer = response.rows.map((row) => { + if (row.dimensionValues && row.metricValues) { + return { + source: row.dimensionValues[0].value, + visitors: row.metricValues[0].value, + }; + } + }); + } else { + throw new Error('No response from Google Analytics'); + } + + return activeUsersPerReferrer; +} + +export async function getDailyPageViews() { + const totalViews = await getTotalPageViews(); + const prevDayViewsChangePercent = await getPrevDayViewsChangePercent(); + + return { + totalViews, + prevDayViewsChangePercent, + }; +} + +async function getTotalPageViews() { + const [response] = await analyticsDataClient.runReport({ + property: `properties/${PROPERTY_ID}`, + dateRanges: [ + { + startDate: '2020-01-01', // go back to earliest date of your app + endDate: 'today', + }, + ], + metrics: [ + { + name: 'screenPageViews', + }, + ], + }); + let totalViews = 0; + if (response?.rows) { + // @ts-ignore + totalViews = parseInt(response.rows[0].metricValues[0].value); + } else { + throw new Error('No response from Google Analytics'); + } + return totalViews; +} + +async function getPrevDayViewsChangePercent() { + const [response] = await analyticsDataClient.runReport({ + property: `properties/${PROPERTY_ID}`, + + dateRanges: [ + { + startDate: '2daysAgo', + endDate: 'yesterday', + }, + ], + orderBys: [ + { + dimension: { + dimensionName: 'date', + }, + desc: true, + }, + ], + dimensions: [ + { + name: 'date', + }, + ], + metrics: [ + { + name: 'screenPageViews', + }, + ], + }); + + let viewsFromYesterday; + let viewsFromDayBeforeYesterday; + + if (response?.rows && response.rows.length === 2) { + // @ts-ignore + viewsFromYesterday = response.rows[0].metricValues[0].value; + // @ts-ignore + viewsFromDayBeforeYesterday = response.rows[1].metricValues[0].value; + + if (viewsFromYesterday && viewsFromDayBeforeYesterday) { + viewsFromYesterday = parseInt(viewsFromYesterday); + viewsFromDayBeforeYesterday = parseInt(viewsFromDayBeforeYesterday); + if (viewsFromYesterday === 0 || viewsFromDayBeforeYesterday === 0) { + return '0'; + } + console.table({ viewsFromYesterday, viewsFromDayBeforeYesterday }); + + const change = ((viewsFromYesterday - viewsFromDayBeforeYesterday) / viewsFromDayBeforeYesterday) * 100; + return change.toFixed(0); + } + } else { + return '0'; + } +} diff --git a/template/app/src/analytics/providers/plausibleAnalyticsUtils.ts b/template/app/src/analytics/providers/plausibleAnalyticsUtils.ts new file mode 100644 index 00000000..51da1a57 --- /dev/null +++ b/template/app/src/analytics/providers/plausibleAnalyticsUtils.ts @@ -0,0 +1,106 @@ +const PLAUSIBLE_API_KEY = process.env.PLAUSIBLE_API_KEY!; +const PLAUSIBLE_SITE_ID = process.env.PLAUSIBLE_SITE_ID!; +const PLAUSIBLE_BASE_URL = process.env.PLAUSIBLE_BASE_URL; + +const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${PLAUSIBLE_API_KEY}`, +}; + +type PageViewsResult = { + results: { + [key: string]: { + value: number; + }; + }; +}; + +type PageViewSourcesResult = { + results: [ + { + source: string; + visitors: number; + } + ]; +}; + +export async function getDailyPageViews() { + const totalViews = await getTotalPageViews(); + const prevDayViewsChangePercent = await getPrevDayViewsChangePercent(); + + return { + totalViews, + prevDayViewsChangePercent, + }; +} + +async function getTotalPageViews() { + const response = await fetch( + `${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&metrics=pageviews`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${PLAUSIBLE_API_KEY}`, + }, + } + ); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + const json = (await response.json()) as PageViewsResult; + + return json.results.pageviews.value; +} + +async function getPrevDayViewsChangePercent() { + // Calculate today, yesterday, and the day before yesterday's dates + const today = new Date(); + const yesterday = new Date(today.setDate(today.getDate() - 1)).toISOString().split('T')[0]; + const dayBeforeYesterday = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString().split('T')[0]; + + // Fetch page views for yesterday and the day before yesterday + const pageViewsYesterday = await getPageviewsForDate(yesterday); + const pageViewsDayBeforeYesterday = await getPageviewsForDate(dayBeforeYesterday); + + console.table({ + pageViewsYesterday, + pageViewsDayBeforeYesterday, + typeY: typeof pageViewsYesterday, + typeDBY: typeof pageViewsDayBeforeYesterday, + }); + + let change = 0; + if (pageViewsYesterday === 0 || pageViewsDayBeforeYesterday === 0) { + return '0'; + } else { + change = ((pageViewsYesterday - pageViewsDayBeforeYesterday) / pageViewsDayBeforeYesterday) * 100; + } + return change.toFixed(0); +} + +async function getPageviewsForDate(date: string) { + const url = `${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&period=day&date=${date}&metrics=pageviews`; + const response = await fetch(url, { + method: 'GET', + headers: headers, + }); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + const data = (await response.json()) as PageViewsResult; + return data.results.pageviews.value; +} + +export async function getSources() { + const url = `${PLAUSIBLE_BASE_URL}/v1/stats/breakdown?site_id=${PLAUSIBLE_SITE_ID}&property=visit:source&metrics=visitors`; + const response = await fetch(url, { + method: 'GET', + headers: headers, + }); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + const data = (await response.json()) as PageViewSourcesResult; + return data.results; +} diff --git a/template/app/src/analytics/stats.ts b/template/app/src/analytics/stats.ts new file mode 100644 index 00000000..32d1275c --- /dev/null +++ b/template/app/src/analytics/stats.ts @@ -0,0 +1,199 @@ +import { type DailyStats } from 'wasp/entities'; +import { type DailyStatsJob } from 'wasp/server/jobs'; +import Stripe from 'stripe'; +import { stripe } from '../payment/stripe/stripeClient' +import { listOrders } from '@lemonsqueezy/lemonsqueezy.js'; +import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils'; +// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils'; +import { paymentProcessor } from '../payment/paymentProcessor'; + +export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?: DailyStats[]; isLoading?: boolean }; + +export const calculateDailyStats: DailyStatsJob = async (_args, context) => { + const nowUTC = new Date(Date.now()); + nowUTC.setUTCHours(0, 0, 0, 0); + + const yesterdayUTC = new Date(nowUTC); + yesterdayUTC.setUTCDate(yesterdayUTC.getUTCDate() - 1); + + try { + const yesterdaysStats = await context.entities.DailyStats.findFirst({ + where: { + date: { + equals: yesterdayUTC, + }, + }, + }); + + const userCount = await context.entities.User.count({}); + // users can have paid but canceled subscriptions which terminate at the end of the period + // we don't want to count those users as current paying users + const paidUserCount = await context.entities.User.count({ + where: { + subscriptionStatus: 'active', + }, + }); + + let userDelta = userCount; + let paidUserDelta = paidUserCount; + if (yesterdaysStats) { + userDelta -= yesterdaysStats.userCount; + paidUserDelta -= yesterdaysStats.paidUserCount; + } + + let totalRevenue; + switch (paymentProcessor.id) { + case 'stripe': + totalRevenue = await fetchTotalStripeRevenue(); + break; + case 'lemonsqueezy': + totalRevenue = await fetchTotalLemonSqueezyRevenue(); + break; + default: + throw new Error(`Unsupported payment processor: ${paymentProcessor.id}`); + } + + const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews(); + + let dailyStats = await context.entities.DailyStats.findUnique({ + where: { + date: nowUTC, + }, + }); + + if (!dailyStats) { + console.log('No daily stat found for today, creating one...'); + dailyStats = await context.entities.DailyStats.create({ + data: { + date: nowUTC, + totalViews, + prevDayViewsChangePercent, + userCount, + paidUserCount, + userDelta, + paidUserDelta, + totalRevenue, + }, + }); + } else { + console.log('Daily stat found for today, updating it...'); + dailyStats = await context.entities.DailyStats.update({ + where: { + id: dailyStats.id, + }, + data: { + totalViews, + prevDayViewsChangePercent, + userCount, + paidUserCount, + userDelta, + paidUserDelta, + totalRevenue, + }, + }); + } + const sources = await getSources(); + + for (const source of sources) { + let visitors = source.visitors; + if (typeof source.visitors !== 'number') { + visitors = parseInt(source.visitors); + } + await context.entities.PageViewSource.upsert({ + where: { + date_name: { + date: nowUTC, + name: source.source, + }, + }, + create: { + date: nowUTC, + name: source.source, + visitors, + dailyStatsId: dailyStats.id, + }, + update: { + visitors, + }, + }); + } + + console.table({ dailyStats }); + } catch (error: any) { + console.error('Error calculating daily stats: ', error); + await context.entities.Logs.create({ + data: { + message: `Error calculating daily stats: ${error?.message}`, + level: 'job-error', + }, + }); + } +}; + +async function fetchTotalStripeRevenue() { + let totalRevenue = 0; + let params: Stripe.BalanceTransactionListParams = { + limit: 100, + // created: { + // gte: startTimestamp, + // lt: endTimestamp + // }, + type: 'charge', + }; + + let hasMore = true; + while (hasMore) { + const balanceTransactions = await stripe.balanceTransactions.list(params); + + for (const transaction of balanceTransactions.data) { + if (transaction.type === 'charge') { + totalRevenue += transaction.amount; + } + } + + if (balanceTransactions.has_more) { + // Set the starting point for the next iteration to the last object fetched + params.starting_after = balanceTransactions.data[balanceTransactions.data.length - 1].id; + } else { + hasMore = false; + } + } + + // Revenue is in cents so we convert to dollars (or your main currency unit) + return totalRevenue / 100; +} + +async function fetchTotalLemonSqueezyRevenue() { + try { + let totalRevenue = 0; + let hasNextPage = true; + let currentPage = 1; + + while (hasNextPage) { + const { data: response } = await listOrders({ + filter: { + storeId: process.env.LEMONSQUEEZY_STORE_ID, + }, + page: { + number: currentPage, + size: 100, + }, + }); + + if (response?.data) { + for (const order of response.data) { + totalRevenue += order.attributes.total; + } + } + + hasNextPage = !response?.meta?.page.lastPage; + currentPage++; + } + + // Revenue is in cents so we convert to dollars (or your main currency unit) + return totalRevenue / 100; + } catch (error) { + console.error('Error fetching Lemon Squeezy revenue:', error); + throw error; + } +} \ No newline at end of file diff --git a/template/app/src/auth/AuthPageLayout.tsx b/template/app/src/auth/AuthPageLayout.tsx new file mode 100644 index 00000000..6381aa75 --- /dev/null +++ b/template/app/src/auth/AuthPageLayout.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from 'react'; + +export function AuthPageLayout({children} : {children: ReactNode }) { + return ( +
    +
    +
    +
    + { children } +
    +
    +
    +
    + ); +} diff --git a/template/app/src/auth/LoginPage.tsx b/template/app/src/auth/LoginPage.tsx new file mode 100644 index 00000000..735b87bf --- /dev/null +++ b/template/app/src/auth/LoginPage.tsx @@ -0,0 +1,27 @@ +import { Link as WaspRouterLink, routes } from 'wasp/client/router'; +import { LoginForm } from 'wasp/client/auth'; +import { AuthPageLayout } from './AuthPageLayout'; + +export default function Login() { + return ( + + +
    + + Don't have an account yet?{' '} + + go to signup + + . + +
    + + Forgot your password?{' '} + + reset it + + . + +
    + ); +} diff --git a/template/app/src/auth/SignupPage.tsx b/template/app/src/auth/SignupPage.tsx new file mode 100644 index 00000000..cf205f6e --- /dev/null +++ b/template/app/src/auth/SignupPage.tsx @@ -0,0 +1,20 @@ +import { Link as WaspRouterLink, routes } from 'wasp/client/router'; +import { SignupForm } from 'wasp/client/auth'; +import { AuthPageLayout } from './AuthPageLayout'; + +export function Signup() { + return ( + + +
    + + I already have an account ( + + go to login + + ). + +
    +
    + ); +} diff --git a/template/app/src/auth/email-and-pass/EmailVerificationPage.tsx b/template/app/src/auth/email-and-pass/EmailVerificationPage.tsx new file mode 100644 index 00000000..24d25659 --- /dev/null +++ b/template/app/src/auth/email-and-pass/EmailVerificationPage.tsx @@ -0,0 +1,15 @@ +import { Link as WaspRouterLink, routes } from 'wasp/client/router'; +import { VerifyEmailForm } from 'wasp/client/auth'; +import { AuthPageLayout } from '../AuthPageLayout'; + +export function EmailVerificationPage() { + return ( + + +
    + + If everything is okay, go to login + +
    + ); +} diff --git a/template/app/src/auth/email-and-pass/PasswordResetPage.tsx b/template/app/src/auth/email-and-pass/PasswordResetPage.tsx new file mode 100644 index 00000000..ea5dc100 --- /dev/null +++ b/template/app/src/auth/email-and-pass/PasswordResetPage.tsx @@ -0,0 +1,15 @@ +import { Link as WaspRouterLink, routes } from 'wasp/client/router'; +import { ResetPasswordForm } from 'wasp/client/auth'; +import { AuthPageLayout } from '../AuthPageLayout'; + +export function PasswordResetPage() { + return ( + + +
    + + If everything is okay, go to login + +
    + ); +} diff --git a/template/app/src/auth/email-and-pass/RequestPasswordResetPage.tsx b/template/app/src/auth/email-and-pass/RequestPasswordResetPage.tsx new file mode 100644 index 00000000..ebc74843 --- /dev/null +++ b/template/app/src/auth/email-and-pass/RequestPasswordResetPage.tsx @@ -0,0 +1,10 @@ +import { ForgotPasswordForm } from 'wasp/client/auth'; +import { AuthPageLayout } from '../AuthPageLayout'; + +export function RequestPasswordResetPage() { + return ( + + + + ); +} diff --git a/template/app/src/auth/email-and-pass/emails.ts b/template/app/src/auth/email-and-pass/emails.ts new file mode 100644 index 00000000..6f998286 --- /dev/null +++ b/template/app/src/auth/email-and-pass/emails.ts @@ -0,0 +1,19 @@ +import { type GetVerificationEmailContentFn, type GetPasswordResetEmailContentFn } from 'wasp/server/auth'; + +export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ verificationLink }) => ({ + subject: 'Verify your email', + text: `Click the link below to verify your email: ${verificationLink}`, + html: ` +

    Click the link below to verify your email

    + Verify email + `, +}); + +export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ passwordResetLink }) => ({ + subject: 'Password reset', + text: `Click the link below to reset your password: ${passwordResetLink}`, + html: ` +

    Click the link below to reset your password

    + Reset password + `, +}); diff --git a/template/app/src/auth/hooks.ts b/template/app/src/auth/hooks.ts new file mode 100644 index 00000000..b652294f --- /dev/null +++ b/template/app/src/auth/hooks.ts @@ -0,0 +1,16 @@ +import { HttpError } from 'wasp/server'; +import type { OnAfterSignupHook } from 'wasp/server/auth'; + +export const onAfterSignup: OnAfterSignupHook = async ({ providerId, user, prisma }) => { + // For Stripe to function correctly, we need a valid email associated with the user. + // Discord allows an email address to be optional. If this is the case, we delete the user + // from our DB and throw an error. + if (providerId.providerName === 'discord' && !user.email) { + await prisma.user.delete({ + where: { + id: user.id, + }, + }); + throw new HttpError(403, 'Discord user needs a valid email to sign up'); + } +}; diff --git a/template/app/src/auth/userSignupFields.ts b/template/app/src/auth/userSignupFields.ts new file mode 100644 index 00000000..bea820f5 --- /dev/null +++ b/template/app/src/auth/userSignupFields.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import { defineUserSignupFields } from 'wasp/auth/providers/types'; + +const adminEmails = process.env.ADMIN_EMAILS?.split(',') || []; + +export const getEmailUserFields = defineUserSignupFields({ + username: (data: any) => data.email, + isAdmin: (data: any) => adminEmails.includes(data.email), + email: (data: any) => data.email, +}); + +const githubDataSchema = z.object({ + profile: z.object({ + emails: z.array( + z.object({ + email: z.string(), + }) + ), + login: z.string(), + }), +}); + +export const getGitHubUserFields = defineUserSignupFields({ + email: (data) => { + const githubData = githubDataSchema.parse(data); + return githubData.profile.emails[0].email; + }, + username: (data) => { + const githubData = githubDataSchema.parse(data); + return githubData.profile.login; + }, + isAdmin: (data) => { + const githubData = githubDataSchema.parse(data); + return adminEmails.includes(githubData.profile.emails[0].email); + }, +}); + +// NOTE: if we don't want to access users' emails, we can use scope ["user:read"] +// instead of ["user"] and access args.profile.username instead +export function getGitHubAuthConfig() { + return { + scopes: ['user'], + }; +} + +const googleDataSchema = z.object({ + profile: z.object({ + email: z.string(), + }), +}); + +export const getGoogleUserFields = defineUserSignupFields({ + email: (data) => { + const googleData = googleDataSchema.parse(data); + return googleData.profile.email; + }, + username: (data) => { + const googleData = googleDataSchema.parse(data); + return googleData.profile.email; + }, + isAdmin: (data) => { + const googleData = googleDataSchema.parse(data); + return adminEmails.includes(googleData.profile.email); + }, +}); + +export function getGoogleAuthConfig() { + return { + scopes: ['profile', 'email'], // must include at least 'profile' for Google + }; +} + +const discordDataSchema = z.object({ + profile: z.object({ + username: z.string(), + email: z.string().email().nullable(), + }), +}); + +export const getDiscordUserFields = defineUserSignupFields({ + email: (data) => { + const discordData = discordDataSchema.parse(data); + return discordData.profile.email; + }, + username: (data) => { + const discordData = discordDataSchema.parse(data); + return discordData.profile.username; + }, + isAdmin: (data) => { + const email = discordDataSchema.parse(data).profile.email; + return !!email && adminEmails.includes(email); + }, +}); + +export function getDiscordAuthConfig() { + return { + scopes: ['identify', 'email'], + }; +} diff --git a/template/app/src/client/App.tsx b/template/app/src/client/App.tsx new file mode 100644 index 00000000..65dd8fa1 --- /dev/null +++ b/template/app/src/client/App.tsx @@ -0,0 +1,68 @@ +import './Main.css'; +import NavBar from './components/NavBar/NavBar'; +import CookieConsentBanner from './components/cookie-consent/Banner'; +import { appNavigationItems } from './components/NavBar/contentSections'; +import { landingPageNavigationItems } from '../landing-page/contentSections'; +import { useMemo, useEffect } from 'react'; +import { routes } from 'wasp/client/router'; +import { Outlet, useLocation } from 'react-router-dom'; +import { useAuth } from 'wasp/client/auth'; +import { useIsLandingPage } from './hooks/useIsLandingPage'; +import { updateCurrentUserLastActiveTimestamp } from 'wasp/client/operations'; + +/** + * use this component to wrap all child components + * this is useful for templates, themes, and context + */ +export default function App() { + const location = useLocation(); + const { data: user } = useAuth(); + const isLandingPage = useIsLandingPage(); + const navigationItems = isLandingPage ? landingPageNavigationItems : appNavigationItems; + + const shouldDisplayAppNavBar = useMemo(() => { + return location.pathname !== routes.LoginRoute.build() && location.pathname !== routes.SignupRoute.build(); + }, [location]); + + const isAdminDashboard = useMemo(() => { + return location.pathname.startsWith('/admin'); + }, [location]); + + useEffect(() => { + if (user) { + const lastSeenAt = new Date(user.lastActiveTimestamp); + const today = new Date(); + if (today.getTime() - lastSeenAt.getTime() > 5 * 60 * 1000) { + updateCurrentUserLastActiveTimestamp({ lastActiveTimestamp: today }); + } + } + }, [user]); + + useEffect(() => { + if (location.hash) { + const id = location.hash.replace('#', ''); + const element = document.getElementById(id); + if (element) { + element.scrollIntoView(); + } + } + }, [location]); + + return ( + <> +
    + {isAdminDashboard ? ( + + ) : ( + <> + {shouldDisplayAppNavBar && } +
    + +
    + + )} +
    + + + ); +} diff --git a/template/app/src/client/Main.css b/template/app/src/client/Main.css new file mode 100644 index 00000000..3bfed05e --- /dev/null +++ b/template/app/src/client/Main.css @@ -0,0 +1,167 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer utilities { + /* Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + .chat-height { + @apply h-[calc(100vh_-_8.125rem)] lg:h-[calc(100vh_-_5.625rem)]; + } + .inbox-height { + @apply h-[calc(100vh_-_8.125rem)] lg:h-[calc(100vh_-_5.625rem)]; + } +} + +/* Here is an example of how to add a custom font. +* Fonts are stored in the public/fonts folder. +* They are defined first here, then need to be referenced in the tailwind.config.js file +* under `theme.extend.fontFamily`, and then can be used as a tailwind class, e.g. className='font-satoshi'. +*/ +@font-face { + font-family: 'Satoshi'; + src: url('/fonts/Satoshi-Regular.woff2') format('woff2'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +/* third-party libraries CSS */ + +.tableCheckbox:checked ~ div span { + @apply opacity-100; +} +.tableCheckbox:checked ~ div { + @apply bg-primary border-primary; +} + +.apexcharts-legend-text { + @apply !text-body dark:!text-bodydark; +} +.apexcharts-text { + @apply !fill-body dark:!fill-bodydark; +} +.apexcharts-xcrosshairs { + @apply !fill-stroke dark:!fill-strokedark; +} +.apexcharts-gridline { + @apply !stroke-stroke dark:!stroke-strokedark; +} +.apexcharts-series.apexcharts-pie-series path { + @apply dark:!stroke-transparent; +} +.apexcharts-legend-series { + @apply !inline-flex gap-1.5; +} +.apexcharts-tooltip.apexcharts-theme-light { + @apply dark:!bg-boxdark dark:!border-strokedark; +} +.apexcharts-tooltip.apexcharts-theme-light .apexcharts-tooltip-title { + @apply dark:!bg-meta-4 dark:!border-strokedark; +} +.apexcharts-xaxistooltip, .apexcharts-yaxistooltip { + @apply dark:!bg-meta-4 dark:!border-meta-4 dark:!text-bodydark1; +} +.apexcharts-xaxistooltip-bottom:after { + @apply dark:!border-b-meta-4; +} +.apexcharts-xaxistooltip-bottom:before { + @apply dark:!border-b-meta-4; + +} + +.flatpickr-day.selected { + @apply bg-primary border-primary hover:bg-primary hover:border-primary; +} +.flatpickr-months .flatpickr-prev-month:hover svg, +.flatpickr-months .flatpickr-next-month:hover svg { + @apply fill-primary; +} +.flatpickr-calendar.arrowTop:before { + @apply dark:!border-b-boxdark; +} +.flatpickr-calendar.arrowTop:after { + @apply dark:!border-b-boxdark; +} +.flatpickr-calendar { + @apply dark:!bg-boxdark dark:!text-bodydark dark:!shadow-8 !p-6 2xsm:!w-auto; +} +.flatpickr-day { + @apply dark:!text-bodydark; +} +.flatpickr-months .flatpickr-prev-month, .flatpickr-months .flatpickr-next-month { + @apply !top-7 dark:!text-white dark:!fill-white; +} +.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month, .flatpickr-months .flatpickr-next-month.flatpickr-prev-month { + @apply !left-7 +} +.flatpickr-months .flatpickr-prev-month.flatpickr-next-month, .flatpickr-months .flatpickr-next-month.flatpickr-next-month { + @apply !right-7 +} +span.flatpickr-weekday, +.flatpickr-months .flatpickr-month { + @apply dark:!text-white dark:!fill-white; +} +.flatpickr-day.inRange { + @apply dark:!bg-meta-4 dark:!border-meta-4 dark:!shadow-7; +} +.flatpickr-day.selected, .flatpickr-day.startRange, +.flatpickr-day.selected, .flatpickr-day.endRange { + @apply dark:!text-white; +} + +.map-btn .jvm-zoom-btn { + @apply flex items-center justify-center w-7.5 h-7.5 rounded border border-stroke dark:border-strokedark hover:border-primary dark:hover:border-primary bg-white hover:bg-primary text-body hover:text-white dark:text-bodydark dark:hover:text-white text-2xl leading-none px-0 pt-0 pb-0.5; +} +.mapOne .jvm-zoom-btn { + @apply left-auto top-auto bottom-0; +} +.mapOne .jvm-zoom-btn.jvm-zoomin { + @apply right-10; +} +.mapOne .jvm-zoom-btn.jvm-zoomout { + @apply right-0; +} +.mapTwo .jvm-zoom-btn { + @apply top-auto bottom-0; +} +.mapTwo .jvm-zoom-btn.jvm-zoomin { + @apply left-0; +} +.mapTwo .jvm-zoom-btn.jvm-zoomout { + @apply left-10; +} + +.taskCheckbox:checked ~ .box span { + @apply opacity-100; +} +.taskCheckbox:checked ~ p { + @apply line-through; +} +.taskCheckbox:checked ~ .box { + @apply bg-primary border-primary dark:border-primary; +} + +.custom-input-date::-webkit-calendar-picker-indicator { + background-position: center; + background-repeat: no-repeat; + background-size: 20px; +} +.custom-input-date-1::-webkit-calendar-picker-indicator { + background-image: url(./images/icon/icon-calendar.svg); +} +.custom-input-date-2::-webkit-calendar-picker-indicator { + background-image: url(./images/icon/icon-arrow-down.svg); +} + +[x-cloak] { + display: none !important; +} \ No newline at end of file diff --git a/template/app/src/client/cn.ts b/template/app/src/client/cn.ts new file mode 100644 index 00000000..4cef98f4 --- /dev/null +++ b/template/app/src/client/cn.ts @@ -0,0 +1,6 @@ +import clsx, { ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/template/app/src/client/components/DarkModeSwitcher.tsx b/template/app/src/client/components/DarkModeSwitcher.tsx new file mode 100644 index 00000000..249089bb --- /dev/null +++ b/template/app/src/client/components/DarkModeSwitcher.tsx @@ -0,0 +1,71 @@ +import { cn } from '../cn'; +import useColorMode from '../hooks/useColorMode'; + +const DarkModeSwitcher = () => { + const [colorMode, setColorMode] = useColorMode(); + const isInLightMode = colorMode === 'light'; + + return ( +
    + +
    + ); +}; + +function ModeIcon({ isInLightMode }: { isInLightMode: boolean }) { + const iconStyle = 'absolute inset-0 flex items-center justify-center transition-opacity ease-in-out duration-400'; + return ( + <> + + + + ); +} + +function SunIcon() { + return ( + + + + + ); +} + +function MoonIcon() { + return ( + + + + ); +} + +export default DarkModeSwitcher; diff --git a/template/app/src/client/components/NavBar/NavBar.tsx b/template/app/src/client/components/NavBar/NavBar.tsx new file mode 100644 index 00000000..a4fed880 --- /dev/null +++ b/template/app/src/client/components/NavBar/NavBar.tsx @@ -0,0 +1,140 @@ +import { Link as ReactRouterLink } from 'react-router-dom'; +import { Link as WaspRouterLink, routes } from 'wasp/client/router'; +import { useAuth } from 'wasp/client/auth'; +import { useState, Dispatch, SetStateAction } from 'react'; +import { Dialog } from '@headlessui/react'; +import { BiLogIn } from 'react-icons/bi'; +import { AiFillCloseCircle } from 'react-icons/ai'; +import { HiBars3 } from 'react-icons/hi2'; +import logo from '../../static/logo.webp'; +import DropdownUser from '../../../user/DropdownUser'; +import { UserMenuItems } from '../../../user/UserMenuItems'; +import DarkModeSwitcher from '../DarkModeSwitcher'; +import { useIsLandingPage } from '../../hooks/useIsLandingPage'; +import { cn } from '../../cn'; + +export interface NavigationItem { + name: string; + to: string; +} + +const NavLogo = () => Your SaaS App; + +export default function AppNavBar({ navigationItems }: { navigationItems: NavigationItem[] }) { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const isLandingPage = useIsLandingPage(); + + const { data: user, isLoading: isUserLoading } = useAuth(); + return ( +
    + + +
    + +
    + + Your SaaS + + + +
    +
    +
    +
    {renderNavigationItems(navigationItems, setMobileMenuOpen)}
    +
    + {isUserLoading ? null : !user ? ( + +
    + Log in +
    +
    + ) : ( + + )} +
    +
    + +
    +
    +
    +
    +
    +
    + ); +} + +function renderNavigationItems( + navigationItems: NavigationItem[], + setMobileMenuOpen?: Dispatch> +) { + const menuStyles = cn({ + '-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50 dark:text-white dark:hover:bg-boxdark-2': + !!setMobileMenuOpen, + 'text-sm font-semibold leading-6 text-gray-900 duration-300 ease-in-out hover:text-yellow-500 dark:text-white': + !setMobileMenuOpen, + }); + + return navigationItems.map((item) => { + return ( + setMobileMenuOpen(false))} + > + {item.name} + + ); + }); +} diff --git a/template/app/src/client/components/NavBar/contentSections.ts b/template/app/src/client/components/NavBar/contentSections.ts new file mode 100644 index 00000000..907ed1a3 --- /dev/null +++ b/template/app/src/client/components/NavBar/contentSections.ts @@ -0,0 +1,11 @@ +import type { NavigationItem } from '../NavBar/NavBar'; +import { routes } from 'wasp/client/router'; +import { BlogUrl, DocsUrl } from '../../../shared/common'; + +export const appNavigationItems: NavigationItem[] = [ + { name: 'AI Scheduler (Demo App)', to: routes.DemoAppRoute.to }, + { name: 'File Upload (AWS S3)', to: routes.FileUploadRoute.to }, + { name: 'Pricing', to: routes.PricingPageRoute.to }, + { name: 'Documentation', to: DocsUrl }, + { name: 'Blog', to: BlogUrl }, +]; diff --git a/template/app/src/client/components/NotFoundPage.tsx b/template/app/src/client/components/NotFoundPage.tsx new file mode 100644 index 00000000..6b8bb46a --- /dev/null +++ b/template/app/src/client/components/NotFoundPage.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useAuth } from 'wasp/client/auth'; +import { Link as WaspRouterLink, routes } from 'wasp/client/router'; + +export function NotFoundPage() { + const { data: user } = useAuth(); + + return ( +
    +
    +

    404

    +

    Oops! The page you're looking for doesn't exist.

    + + Go Back Home + +
    +
    + ); +} diff --git a/template/app/src/client/components/cookie-consent/Banner.tsx b/template/app/src/client/components/cookie-consent/Banner.tsx new file mode 100644 index 00000000..7a87fca7 --- /dev/null +++ b/template/app/src/client/components/cookie-consent/Banner.tsx @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import * as CookieConsent from 'vanilla-cookieconsent'; +import 'vanilla-cookieconsent/dist/cookieconsent.css'; +import getConfig from './Config'; + +/** + * NOTE: if you do not want to use the cookie consent banner, you should + * run `npm uninstall vanilla-cookieconsent`, and delete this component, its config file, + * as well as its import in src/client/App.tsx . + */ +const CookieConsentBanner = () => { + useEffect(() => { + CookieConsent.run(getConfig()); + }, []); + + return
    ; +}; + +export default CookieConsentBanner; diff --git a/template/app/src/client/components/cookie-consent/Config.ts b/template/app/src/client/components/cookie-consent/Config.ts new file mode 100644 index 00000000..5d3f7b37 --- /dev/null +++ b/template/app/src/client/components/cookie-consent/Config.ts @@ -0,0 +1,116 @@ +import type { CookieConsentConfig } from 'vanilla-cookieconsent'; + +declare global { + interface Window { + dataLayer: any; + } +} + +const getConfig = () => { + // See https://cookieconsent.orestbida.com/reference/configuration-reference.html for configuration options. + const config: CookieConsentConfig = { + // Default configuration for the modal. + root: 'body', + autoShow: true, + disablePageInteraction: false, + hideFromBots: import.meta.env.PROD ? true : false, // Set this to false for dev/headless tests otherwise the modal will not be visible. + mode: 'opt-in', + revision: 0, + + // Default configuration for the cookie. + cookie: { + name: 'cc_cookie', + domain: location.hostname, + path: '/', + sameSite: 'Lax', + expiresAfterDays: 365, + }, + + guiOptions: { + consentModal: { + layout: 'box', + position: 'bottom right', + equalWeightButtons: true, + flipButtons: false, + }, + }, + + categories: { + necessary: { + enabled: true, // this category is enabled by default + readOnly: true, // this category cannot be disabled + }, + analytics: { + autoClear: { + cookies: [ + { + name: /^_ga/, // regex: match all cookies starting with '_ga' + }, + { + name: '_gid', // string: exact cookie name + }, + ], + }, + + // https://cookieconsent.orestbida.com/reference/configuration-reference.html#category-services + services: { + ga: { + label: 'Google Analytics', + onAccept: () => { + try { + const GA_ANALYTICS_ID = import.meta.env.REACT_APP_GOOGLE_ANALYTICS_ID; + if (!GA_ANALYTICS_ID.length) { + throw new Error('Google Analytics ID is missing'); + } + window.dataLayer = window.dataLayer || []; + function gtag(..._args: unknown[]) { + (window.dataLayer as Array).push(arguments); + } + gtag('js', new Date()); + gtag('config', GA_ANALYTICS_ID); + + // Adding the script tag dynamically to the DOM. + const script = document.createElement('script'); + script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ANALYTICS_ID}`; + script.async = true; + document.body.appendChild(script); + } catch (error) { + console.error(error); + } + }, + onReject: () => {}, + }, + }, + }, + }, + + language: { + default: 'en', + translations: { + en: { + consentModal: { + title: 'We use cookies', + description: + 'We use cookies primarily for analytics to enhance your experience. By accepting, you agree to our use of these cookies. You can manage your preferences or learn more about our cookie policy.', + acceptAllBtn: 'Accept all', + acceptNecessaryBtn: 'Reject all', + // showPreferencesBtn: 'Manage Individual preferences', // (OPTIONAL) Activates the preferences modal + // TODO: Add your own privacy policy and terms and conditions links below. + footer: ` + Privacy Policy + Terms and Conditions + `, + }, + // The showPreferencesBtn activates this modal to manage individual preferences https://cookieconsent.orestbida.com/reference/configuration-reference.html#translation-preferencesmodal + preferencesModal: { + sections: [], + }, + }, + }, + }, + }; + + return config; +}; + +export default getConfig; \ No newline at end of file diff --git a/template/app/src/client/hooks/useColorMode.tsx b/template/app/src/client/hooks/useColorMode.tsx new file mode 100644 index 00000000..250d70dd --- /dev/null +++ b/template/app/src/client/hooks/useColorMode.tsx @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; +import useLocalStorage from './useLocalStorage'; + +export default function useColorMode() { + const [colorMode, setColorMode] = useLocalStorage('color-theme', 'light'); + + useEffect(() => { + const className = 'dark'; + const bodyClass = window.document.body.classList; + + colorMode === 'dark' + ? bodyClass.add(className) + : bodyClass.remove(className); + }, [colorMode]); + + return [colorMode, setColorMode]; +}; + diff --git a/template/app/src/client/hooks/useIsLandingPage.tsx b/template/app/src/client/hooks/useIsLandingPage.tsx new file mode 100644 index 00000000..66d7475f --- /dev/null +++ b/template/app/src/client/hooks/useIsLandingPage.tsx @@ -0,0 +1,9 @@ +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +export const useIsLandingPage = () => { + const location = useLocation(); + return useMemo(() => { + return location.pathname === '/'; + }, [location]); +}; diff --git a/template/app/src/client/hooks/useLocalStorage.tsx b/template/app/src/client/hooks/useLocalStorage.tsx new file mode 100644 index 00000000..68492a84 --- /dev/null +++ b/template/app/src/client/hooks/useLocalStorage.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; + +type SetValue = T | ((val: T) => T); + +function useLocalStorage( + key: string, + initialValue: T +): [T, (value: SetValue) => void] { + // State to store our value + // Pass initial state function to useState so logic is only executed once + const [storedValue, setStoredValue] = useState(() => { + try { + // Get from local storage by key + const item = window.localStorage.getItem(key); + // Parse stored json or if none return initialValue + return item ? JSON.parse(item) : initialValue; + } catch (error) { + // If error also return initialValue + console.log(error); + return initialValue; + } + }); + + // useEffect to update local storage when the state changes + useEffect(() => { + try { + // Allow value to be a function so we have same API as useState + const valueToStore = + typeof storedValue === 'function' + ? storedValue(storedValue) + : storedValue; + // Save state + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } catch (error) { + // A more advanced implementation would handle the error case + console.log(error); + } + }, [key, storedValue]); + + return [storedValue, setStoredValue]; +} + +export default useLocalStorage; diff --git a/template/app/src/client/icons/icon-arrow-down.svg b/template/app/src/client/icons/icon-arrow-down.svg new file mode 100644 index 00000000..1fd6d42a --- /dev/null +++ b/template/app/src/client/icons/icon-arrow-down.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/template/app/src/client/icons/icon-calendar.svg b/template/app/src/client/icons/icon-calendar.svg new file mode 100644 index 00000000..d75d9937 --- /dev/null +++ b/template/app/src/client/icons/icon-calendar.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/template/app/src/client/icons/icon-copy-alt.svg b/template/app/src/client/icons/icon-copy-alt.svg new file mode 100644 index 00000000..1f0c7702 --- /dev/null +++ b/template/app/src/client/icons/icon-copy-alt.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/template/app/src/client/icons/icon-moon.svg b/template/app/src/client/icons/icon-moon.svg new file mode 100644 index 00000000..1ca395d0 --- /dev/null +++ b/template/app/src/client/icons/icon-moon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/template/app/src/client/icons/icon-sun.svg b/template/app/src/client/icons/icon-sun.svg new file mode 100644 index 00000000..4524cde9 --- /dev/null +++ b/template/app/src/client/icons/icon-sun.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/template/app/src/client/icons/icons-arrows.tsx b/template/app/src/client/icons/icons-arrows.tsx new file mode 100644 index 00000000..f2629fa5 --- /dev/null +++ b/template/app/src/client/icons/icons-arrows.tsx @@ -0,0 +1,35 @@ +export function UpArrow() { + return ( + + + + ); +} + +export function DownArrow() { + return ( + + + + ); +} diff --git a/template/app/src/client/static/avatar-placeholder.webp b/template/app/src/client/static/avatar-placeholder.webp new file mode 100644 index 00000000..e985d27d Binary files /dev/null and b/template/app/src/client/static/avatar-placeholder.webp differ diff --git a/template/app/src/client/static/da-boi.webp b/template/app/src/client/static/da-boi.webp new file mode 100644 index 00000000..96280c72 Binary files /dev/null and b/template/app/src/client/static/da-boi.webp differ diff --git a/template/app/src/client/static/logo.webp b/template/app/src/client/static/logo.webp new file mode 100644 index 00000000..0e73a39d Binary files /dev/null and b/template/app/src/client/static/logo.webp differ diff --git a/template/app/src/client/static/open-saas-banner.webp b/template/app/src/client/static/open-saas-banner.webp new file mode 100644 index 00000000..ad96e10f Binary files /dev/null and b/template/app/src/client/static/open-saas-banner.webp differ diff --git a/template/app/src/demo-ai-app/DemoAppPage.tsx b/template/app/src/demo-ai-app/DemoAppPage.tsx new file mode 100644 index 00000000..a14dc546 --- /dev/null +++ b/template/app/src/demo-ai-app/DemoAppPage.tsx @@ -0,0 +1,411 @@ +import { type Task } from 'wasp/entities'; + +import { + generateGptResponse, + deleteTask, + updateTask, + createTask, + useQuery, + getAllTasksByUser, +} from 'wasp/client/operations'; + +import { useState, useMemo } from 'react'; +import { CgSpinner } from 'react-icons/cg'; +import { TiDelete } from 'react-icons/ti'; +import type { GeneratedSchedule, MainTask, SubTask } from './schedule'; +import { cn } from '../client/cn'; + +export default function DemoAppPage() { + return ( +
    +
    +
    +

    + AI Day Scheduler +

    +
    +

    + This example app uses OpenAI's chat completions with function calling to return a structured JSON object. Try + it out, enter your day's tasks, and let AI do the rest! +

    + {/* begin AI-powered Todo List */} +
    +
    + +
    +
    + {/* end AI-powered Todo List */} +
    +
    + ); +} + +function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask }) { + const [description, setDescription] = useState(''); + const [todaysHours, setTodaysHours] = useState('8'); + const [response, setResponse] = useState({ + mainTasks: [ + { + name: 'Respond to emails', + priority: 'high', + }, + { + name: 'Learn WASP', + priority: 'low', + }, + { + name: 'Read a book', + priority: 'medium', + }, + ], + subtasks: [ + { + description: 'Read introduction and chapter 1', + time: 0.5, + mainTaskName: 'Read a book', + }, + { + description: 'Read chapter 2 and take notes', + time: 0.3, + mainTaskName: 'Read a book', + }, + { + description: 'Read chapter 3 and summarize key points', + time: 0.2, + mainTaskName: 'Read a book', + }, + { + description: 'Check and respond to important emails', + time: 1, + mainTaskName: 'Respond to emails', + }, + { + description: 'Organize and prioritize remaining emails', + time: 0.5, + mainTaskName: 'Respond to emails', + }, + { + description: 'Draft responses to urgent emails', + time: 0.5, + mainTaskName: 'Respond to emails', + }, + { + description: 'Watch tutorial video on WASP', + time: 0.5, + mainTaskName: 'Learn WASP', + }, + { + description: 'Complete online quiz on the basics of WASP', + time: 1.5, + mainTaskName: 'Learn WASP', + }, + { + description: 'Review quiz answers and clarify doubts', + time: 1, + mainTaskName: 'Learn WASP', + }, + ], + }); + const [isPlanGenerating, setIsPlanGenerating] = useState(false); + + const { data: tasks, isLoading: isTasksLoading } = useQuery(getAllTasksByUser); + + const handleSubmit = async () => { + try { + await handleCreateTask({ description }); + setDescription(''); + } catch (err: any) { + window.alert('Error: ' + (err.message || 'Something went wrong')); + } + }; + + const handleGeneratePlan = async () => { + try { + setIsPlanGenerating(true); + const response = await generateGptResponse({ + hours: todaysHours, + }); + if (response) { + setResponse(response); + } + } catch (err: any) { + window.alert('Error: ' + (err.message || 'Something went wrong')); + } finally { + setIsPlanGenerating(false); + } + }; + + return ( +
    +
    +
    + setDescription(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSubmit(); + } + }} + /> + +
    +
    + +
    + {isTasksLoading &&
    Loading...
    } + {tasks!! && tasks.length > 0 ? ( +
    + {tasks.map((task: Task) => ( + + ))} +
    +
    + + setTodaysHours(e.currentTarget.value)} + /> +
    +
    +
    + ) : ( +
    Add tasks to begin
    + )} +
    + + + + {!!response && ( +
    +

    Today's Schedule

    + + +
    + )} +
    + ); +} + +type TodoProps = Pick; + +function Todo({ id, isDone, description, time }: TodoProps) { + const handleCheckboxChange = async (e: React.ChangeEvent) => { + await updateTask({ + id, + isDone: e.currentTarget.checked, + }); + }; + + const handleTimeChange = async (e: React.ChangeEvent) => { + await updateTask({ + id, + time: e.currentTarget.value, + }); + }; + + const handleDeleteClick = async () => { + await deleteTask({ id }); + }; + + return ( +
    +
    +
    + + + {description} + +
    +
    + + + hrs + +
    +
    +
    + +
    +
    + ); +} + +function TaskTable({ schedule }: { schedule: GeneratedSchedule }) { + return ( +
    + + {!!schedule.mainTasks ? ( + schedule.mainTasks + .map((mainTask) => ) + .sort((a, b) => { + const priorityOrder = ['low', 'medium', 'high']; + if (a.props.mainTask.priority && b.props.mainTask.priority) { + return ( + priorityOrder.indexOf(b.props.mainTask.priority) - priorityOrder.indexOf(a.props.mainTask.priority) + ); + } else { + return 0; + } + }) + ) : ( +
    OpenAI didn't return any Main Tasks. Try again.
    + )} +
    + + {/* ))} */} +
    + ); +} + +function MainTaskTable({ mainTask, subtasks }: { mainTask: MainTask; subtasks: SubTask[] }) { + return ( + <> + + + + {mainTask.name} + {mainTask.priority} priority + + + + {!!subtasks ? ( + subtasks.map((subtask) => { + if (subtask.mainTaskName === mainTask.name) { + return ( + + + + + + + + ); + } + }) + ) : ( +
    OpenAI didn't return any Subtasks. Try again.
    + )} + + ); +} + +function SubtaskTable({ description, time }: { description: string; time: number }) { + const [isDone, setIsDone] = useState(false); + + const convertHrsToMinutes = (time: number) => { + if (time === 0) return 0; + const hours = Math.floor(time); + const minutes = Math.round((time - hours) * 60); + return `${hours > 0 ? hours + 'hr' : ''} ${minutes > 0 ? minutes + 'min' : ''}`; + }; + + const minutes = useMemo(() => convertHrsToMinutes(time), [time]); + + return ( + <> + setIsDone(e.currentTarget.checked)} + /> + + {description} + + + {minutes} + + + ); +} diff --git a/template/app/src/demo-ai-app/operations.ts b/template/app/src/demo-ai-app/operations.ts new file mode 100644 index 00000000..ca58426c --- /dev/null +++ b/template/app/src/demo-ai-app/operations.ts @@ -0,0 +1,261 @@ +import type { Task, GptResponse } from 'wasp/entities'; +import type { + GenerateGptResponse, + CreateTask, + DeleteTask, + UpdateTask, + GetGptResponses, + GetAllTasksByUser, +} from 'wasp/server/operations'; +import { HttpError } from 'wasp/server'; +import { GeneratedSchedule } from './schedule'; +import OpenAI from 'openai'; + +const openai = setupOpenAI(); +function setupOpenAI() { + if (!process.env.OPENAI_API_KEY) { + return new HttpError(500, 'OpenAI API key is not set'); + } + return new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); +} + +//#region Actions +type GptPayload = { + hours: string; +}; + +export const generateGptResponse: GenerateGptResponse = async ({ hours }, context) => { + if (!context.user) { + throw new HttpError(401); + } + + const tasks = await context.entities.Task.findMany({ + where: { + user: { + id: context.user.id, + }, + }, + }); + + const parsedTasks = tasks.map(({ description, time }) => ({ + description, + time, + })); + + try { + // check if openai is initialized correctly with the API key + if (openai instanceof Error) { + throw openai; + } + + const hasCredits = context.user.credits > 0; + const hasValidSubscription = + !!context.user.subscriptionStatus && + context.user.subscriptionStatus !== 'deleted' && + context.user.subscriptionStatus !== 'past_due'; + const canUserContinue = hasCredits || hasValidSubscription; + + if (!canUserContinue) { + throw new HttpError(402, 'User has not paid or is out of credits'); + } else { + console.log('decrementing credits'); + await context.entities.User.update({ + where: { id: context.user.id }, + data: { + credits: { + decrement: 1, + }, + }, + }); + } + + const completion = await openai.chat.completions.create({ + model: 'gpt-3.5-turbo', // you can use any model here, e.g. 'gpt-3.5-turbo', 'gpt-4', etc. + messages: [ + { + role: 'system', + content: + 'you are an expert daily planner. you will be given a list of main tasks and an estimated time to complete each task. You will also receive the total amount of hours to be worked that day. Your job is to return a detailed plan of how to achieve those tasks by breaking each task down into at least 3 subtasks each. MAKE SURE TO ALWAYS CREATE AT LEAST 3 SUBTASKS FOR EACH MAIN TASK PROVIDED BY THE USER! YOU WILL BE REWARDED IF YOU DO.', + }, + { + role: 'user', + content: `I will work ${hours} hours today. Here are the tasks I have to complete: ${JSON.stringify( + parsedTasks + )}. Please help me plan my day by breaking the tasks down into actionable subtasks with time and priority status.`, + }, + ], + tools: [ + { + type: 'function', + function: { + name: 'parseTodaysSchedule', + description: 'parses the days tasks and returns a schedule', + parameters: { + type: 'object', + properties: { + mainTasks: { + type: 'array', + description: 'Name of main tasks provided by user, ordered by priority', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name of main task provided by user', + }, + priority: { + type: 'string', + enum: ['low', 'medium', 'high'], + description: 'task priority', + }, + }, + }, + }, + subtasks: { + type: 'array', + items: { + type: 'object', + properties: { + description: { + type: 'string', + description: + 'detailed breakdown and description of sub-task related to main task. e.g., "Prepare your learning session by first reading through the documentation"', + }, + time: { + type: 'number', + description: 'time allocated for a given subtask in hours, e.g. 0.5', + }, + mainTaskName: { + type: 'string', + description: 'name of main task related to subtask', + }, + }, + }, + }, + }, + required: ['mainTasks', 'subtasks', 'time', 'priority'], + }, + }, + }, + ], + tool_choice: { + type: 'function', + function: { + name: 'parseTodaysSchedule', + }, + }, + temperature: 1, + }); + + const gptArgs = completion?.choices[0]?.message?.tool_calls?.[0]?.function.arguments; + + if (!gptArgs) { + throw new HttpError(500, 'Bad response from OpenAI'); + } + + console.log('gpt function call arguments: ', gptArgs); + + await context.entities.GptResponse.create({ + data: { + user: { connect: { id: context.user.id } }, + content: JSON.stringify(gptArgs), + }, + }); + + return JSON.parse(gptArgs); + } catch (error: any) { + if (!context.user.subscriptionStatus && error?.statusCode != 402) { + await context.entities.User.update({ + where: { id: context.user.id }, + data: { + credits: { + increment: 1, + }, + }, + }); + } + console.error(error); + const statusCode = error.statusCode || 500; + const errorMessage = error.message || 'Internal server error'; + throw new HttpError(statusCode, errorMessage); + } +}; + +export const createTask: CreateTask, Task> = async ({ description }, context) => { + if (!context.user) { + throw new HttpError(401); + } + + const task = await context.entities.Task.create({ + data: { + description, + user: { connect: { id: context.user.id } }, + }, + }); + + return task; +}; + +export const updateTask: UpdateTask, Task> = async ({ id, isDone, time }, context) => { + if (!context.user) { + throw new HttpError(401); + } + + const task = await context.entities.Task.update({ + where: { + id, + }, + data: { + isDone, + time, + }, + }); + + return task; +}; + +export const deleteTask: DeleteTask, Task> = async ({ id }, context) => { + if (!context.user) { + throw new HttpError(401); + } + + const task = await context.entities.Task.delete({ + where: { + id, + }, + }); + + return task; +}; +//#endregion + +//#region Queries +export const getGptResponses: GetGptResponses = async (_args, context) => { + if (!context.user) { + throw new HttpError(401); + } + return context.entities.GptResponse.findMany({ + where: { + user: { + id: context.user.id, + }, + }, + }); +}; + +export const getAllTasksByUser: GetAllTasksByUser = async (_args, context) => { + if (!context.user) { + throw new HttpError(401); + } + return context.entities.Task.findMany({ + where: { + user: { + id: context.user.id, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); +}; +//#endregion diff --git a/template/app/src/demo-ai-app/schedule.ts b/template/app/src/demo-ai-app/schedule.ts new file mode 100644 index 00000000..452b8e11 --- /dev/null +++ b/template/app/src/demo-ai-app/schedule.ts @@ -0,0 +1,15 @@ +export type GeneratedSchedule = { + mainTasks: MainTask[]; // Main tasks provided by user, ordered by priority + subtasks: SubTask[]; +}; + +export type MainTask = { + name: string; + priority: 'low' | 'medium' | 'high'; +}; + +export type SubTask = { + description: string; + time: number; // total time it takes to complete given main task in hours, e.g. 2.75 + mainTaskName: string; // name of main task related to subtask +}; \ No newline at end of file diff --git a/template/app/src/file-upload/FileUploadPage.tsx b/template/app/src/file-upload/FileUploadPage.tsx new file mode 100644 index 00000000..74c71458 --- /dev/null +++ b/template/app/src/file-upload/FileUploadPage.tsx @@ -0,0 +1,169 @@ +import { cn } from '../client/cn'; +import { useState, useEffect, FormEvent } from 'react'; +import type { File } from 'wasp/entities'; +import { useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations'; +import { type FileUploadError, uploadFileWithProgress, validateFile, ALLOWED_FILE_TYPES } from './fileUploading'; + +export default function FileUploadPage() { + const [fileKeyForS3, setFileKeyForS3] = useState(''); + const [uploadProgressPercent, setUploadProgressPercent] = useState(0); + const [uploadError, setUploadError] = useState(null); + + const allUserFiles = useQuery(getAllFilesByUser, undefined, { + // We disable automatic refetching because otherwise files would be refetched after `createFile` is called and the S3 URL is returned, + // which happens before the file is actually fully uploaded. Instead, we manually (re)fetch on mount and after the upload is complete. + enabled: false, + }); + const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } = useQuery( + getDownloadFileSignedURL, + { key: fileKeyForS3 }, + { enabled: false } + ); + + useEffect(() => { + allUserFiles.refetch(); + }, []); + + useEffect(() => { + if (fileKeyForS3.length > 0) { + refetchDownloadUrl() + .then((urlQuery) => { + switch (urlQuery.status) { + case 'error': + console.error('Error fetching download URL', urlQuery.error); + alert('Error fetching download'); + return; + case 'success': + window.open(urlQuery.data, '_blank'); + return; + } + }) + .finally(() => { + setFileKeyForS3(''); + }); + } + }, [fileKeyForS3]); + + const handleUpload = async (e: FormEvent) => { + try { + e.preventDefault(); + + const formElement = e.target; + if (!(formElement instanceof HTMLFormElement)) { + throw new Error('Event target is not a form element'); + } + + const formData = new FormData(formElement); + const file = formData.get('file-upload'); + + if (!file || !(file instanceof File)) { + setUploadError({ + message: 'Please select a file to upload.', + code: 'NO_FILE', + }); + return; + } + + const validationError = validateFile(file); + if (validationError) { + setUploadError(validationError); + return; + } + + await uploadFileWithProgress({ file, setUploadProgressPercent }); + formElement.reset(); + allUserFiles.refetch(); + } catch (error) { + console.error('Error uploading file:', error); + setUploadError({ + message: + error instanceof Error ? error.message : 'An unexpected error occurred while uploading the file.', + code: 'UPLOAD_FAILED', + }); + } finally { + setUploadProgressPercent(0); + } + }; + + return ( +
    +
    +
    +

    + AWS File Upload +

    +
    +

    + This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a + lot of people asked for this feature, so here you go 🤝 +

    +
    +
    +
    + setUploadError(null)} + /> + + {uploadError &&
    {uploadError.message}
    } +
    +
    +
    +

    Uploaded Files

    + {allUserFiles.isLoading &&

    Loading...

    } + {allUserFiles.error &&

    Error: {allUserFiles.error.message}

    } + {!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? ( + allUserFiles.data.map((file: File) => ( +
    +

    {file.name}

    + +
    + )) + ) : ( +

    No files uploaded yet :(

    + )} +
    +
    +
    +
    +
    + ); +} diff --git a/template/app/src/file-upload/fileUploading.ts b/template/app/src/file-upload/fileUploading.ts new file mode 100644 index 00000000..50ec5d56 --- /dev/null +++ b/template/app/src/file-upload/fileUploading.ts @@ -0,0 +1,54 @@ +import { Dispatch, SetStateAction } from 'react'; +import { createFile } from 'wasp/client/operations'; +import axios from 'axios'; + +interface FileUploadProgress { + file: File; + setUploadProgressPercent: Dispatch>; +} + +export interface FileUploadError { + message: string; + code: 'NO_FILE' | 'INVALID_FILE_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED'; +} + +export const MAX_FILE_SIZE = 5 * 1024 * 1024; // Set this to the max file size you want to allow (currently 5MB). +export const ALLOWED_FILE_TYPES = [ + 'image/jpeg', + 'image/png', + 'application/pdf', + 'text/*', + 'video/quicktime', + 'video/mp4', +]; + +export async function uploadFileWithProgress({ file, setUploadProgressPercent }: FileUploadProgress) { + const { uploadUrl } = await createFile({ fileType: file.type, name: file.name }); + return await axios.put(uploadUrl, file, { + headers: { + 'Content-Type': file.type, + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + const percentage = Math.round((progressEvent.loaded / progressEvent.total) * 100); + setUploadProgressPercent(percentage); + } + }, + }); +} + +export function validateFile(file: File): FileUploadError | null { + if (file.size > MAX_FILE_SIZE) { + return { + message: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`, + code: 'FILE_TOO_LARGE', + }; + } + if (!ALLOWED_FILE_TYPES.includes(file.type)) { + return { + message: `File type '${file.type}' is not supported.`, + code: 'INVALID_FILE_TYPE', + }; + } + return null; +} diff --git a/template/app/src/file-upload/operations.ts b/template/app/src/file-upload/operations.ts new file mode 100644 index 00000000..59ab5135 --- /dev/null +++ b/template/app/src/file-upload/operations.ts @@ -0,0 +1,57 @@ +import { HttpError } from 'wasp/server'; +import { type File } from 'wasp/entities'; +import { + type CreateFile, + type GetAllFilesByUser, + type GetDownloadFileSignedURL, +} from 'wasp/server/operations'; + +import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils'; + +type FileDescription = { + fileType: string; + name: string; +}; + +export const createFile: CreateFile = async ({ fileType, name }, context) => { + if (!context.user) { + throw new HttpError(401); + } + + const userInfo = context.user.id; + + const { uploadUrl, key } = await getUploadFileSignedURLFromS3({ fileType, userInfo }); + + return await context.entities.File.create({ + data: { + name, + key, + uploadUrl, + type: fileType, + user: { connect: { id: context.user.id } }, + }, + }); +}; + +export const getAllFilesByUser: GetAllFilesByUser = async (_args, context) => { + if (!context.user) { + throw new HttpError(401); + } + return context.entities.File.findMany({ + where: { + user: { + id: context.user.id, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); +}; + +export const getDownloadFileSignedURL: GetDownloadFileSignedURL<{ key: string }, string> = async ( + { key }, + _context +) => { + return await getDownloadFileSignedURLFromS3({ key }); +}; diff --git a/template/app/src/file-upload/s3Utils.ts b/template/app/src/file-upload/s3Utils.ts new file mode 100644 index 00000000..709f8052 --- /dev/null +++ b/template/app/src/file-upload/s3Utils.ts @@ -0,0 +1,39 @@ +import { randomUUID } from 'crypto'; +import { S3Client } from '@aws-sdk/client-s3'; +import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +const s3Client = new S3Client({ + region: process.env.AWS_S3_REGION, + credentials: { + accessKeyId: process.env.AWS_S3_IAM_ACCESS_KEY!, + secretAccessKey: process.env.AWS_S3_IAM_SECRET_KEY!, + }, +}); + +type S3Upload = { + fileType: string; + userInfo: string; +} + +export const getUploadFileSignedURLFromS3 = async ({fileType, userInfo}: S3Upload) => { + const ex = fileType.split('/')[1]; + const Key = `${userInfo}/${randomUUID()}.${ex}`; + const s3Params = { + Bucket: process.env.AWS_S3_FILES_BUCKET, + Key, + ContentType: `${fileType}`, + }; + const command = new PutObjectCommand(s3Params); + const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600,}); + return { uploadUrl, key: Key }; +} + +export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) => { + const s3Params = { + Bucket: process.env.AWS_S3_FILES_BUCKET, + Key: key, + }; + const command = new GetObjectCommand(s3Params); + return await getSignedUrl(s3Client, command, { expiresIn: 3600 }); +} diff --git a/template/app/src/landing-page/LandingPage.tsx b/template/app/src/landing-page/LandingPage.tsx new file mode 100644 index 00000000..ac865a8d --- /dev/null +++ b/template/app/src/landing-page/LandingPage.tsx @@ -0,0 +1,22 @@ +import { features, faqs, footerNavigation, testimonials } from './contentSections'; +import Hero from './components/Hero'; +import Clients from './components/Clients'; +import Features from './components/Features'; +import Testimonials from './components/Testimonials'; +import FAQ from './components/FAQ'; +import Footer from './components/Footer'; + +export default function LandingPage() { + return ( +
    +
    + + + + + +
    +
    +
    + ); +} diff --git a/template/app/src/landing-page/components/Clients.tsx b/template/app/src/landing-page/components/Clients.tsx new file mode 100644 index 00000000..5c83fd3a --- /dev/null +++ b/template/app/src/landing-page/components/Clients.tsx @@ -0,0 +1,24 @@ +import AstroLogo from "../logos/AstroLogo"; +import OpenAILogo from "../logos/OpenAILogo"; +import PrismaLogo from "../logos/PrismaLogo"; +import SalesforceLogo from "../logos/SalesforceLogo"; + +export default function Clients() { + return ( +
    +

    + Built with / Used by: +

    + +
    + { + [, , , ].map((logo, index) => ( +
    + {logo} +
    + )) + } +
    +
    + ) +} diff --git a/template/app/src/landing-page/components/FAQ.tsx b/template/app/src/landing-page/components/FAQ.tsx new file mode 100644 index 00000000..1bac30e8 --- /dev/null +++ b/template/app/src/landing-page/components/FAQ.tsx @@ -0,0 +1,33 @@ +interface FAQ { + id: number; + question: string; + answer: string; + href?: string; +}; + +export default function FAQ({ faqs }: { faqs: FAQ[] }) { + return ( +
    +

    + Frequently asked questions +

    +
    + {faqs.map((faq) => ( +
    +
    + {faq.question} +
    +
    +

    {faq.answer}

    + {faq.href && ( + + Learn more → + + )} +
    +
    + ))} +
    +
    + ) +} diff --git a/template/app/src/landing-page/components/Features.tsx b/template/app/src/landing-page/components/Features.tsx new file mode 100644 index 00000000..a81b1319 --- /dev/null +++ b/template/app/src/landing-page/components/Features.tsx @@ -0,0 +1,37 @@ +interface Feature { + name: string; + description: string; + icon: string; + href: string; +}; + +export default function Features({ features }: { features: Feature[] }) { + return ( +
    +
    +

    + The Best Features +

    +

    + Don't work harder. +
    Work smarter. +

    +
    +
    +
    + {features.map((feature) => ( +
    +
    +
    +
    {feature.icon}
    +
    + {feature.name} +
    +
    {feature.description}
    +
    + ))} +
    +
    +
    + ) +} diff --git a/template/app/src/landing-page/components/Footer.tsx b/template/app/src/landing-page/components/Footer.tsx new file mode 100644 index 00000000..ca7bb576 --- /dev/null +++ b/template/app/src/landing-page/components/Footer.tsx @@ -0,0 +1,50 @@ +interface NavigationItem { + name: string; + href: string; +}; + +export default function Footer({ footerNavigation }: { + footerNavigation: { + app: NavigationItem[] + company: NavigationItem[] + } +}) { + return ( +
    +
    + +
    +
    +

    App

    + +
    +
    +

    Company

    + +
    +
    +
    +
    + ) +} diff --git a/template/app/src/landing-page/components/Hero.tsx b/template/app/src/landing-page/components/Hero.tsx new file mode 100644 index 00000000..c776d1ef --- /dev/null +++ b/template/app/src/landing-page/components/Hero.tsx @@ -0,0 +1,75 @@ +import openSaasBannerWebp from '../../client/static/open-saas-banner.webp'; +import { DocsUrl } from '../../shared/common'; + +export default function Hero() { + return ( +
    + + +
    +
    +
    +

    + Some cool words about your product +

    +

    + With some more exciting words about your product! +

    + +
    +
    +
    + App screenshot +
    +
    +
    +
    +
    + ); +} + +function TopGradient() { + return ( +