This commit is contained in:
Franjo Mindek
2025-09-18 11:42:49 +02:00
parent 054c1f8dc5
commit 3f463fb202
155 changed files with 4138 additions and 2994 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,6 +1,6 @@
# These are supported funding model platforms
github: [ wasp-lang ] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
github: [wasp-lang] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username

View File

@@ -5,12 +5,12 @@ on:
branches:
- main
paths:
- 'opensaas-sh/blog/**'
- "opensaas-sh/blog/**"
pull_request:
branches:
- main
paths:
- 'opensaas-sh/blog/**'
- "opensaas-sh/blog/**"
jobs:
build:
@@ -23,7 +23,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 'lts/*'
node-version: "lts/*"
- name: Install dependencies
working-directory: ./opensaas-sh/blog
@@ -47,7 +47,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 'lts/*'
node-version: "lts/*"
- name: Install dependencies
working-directory: ./opensaas-sh/blog
@@ -64,10 +64,10 @@ jobs:
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@v2
with:
publish-dir: './opensaas-sh/blog/dist'
publish-dir: "./opensaas-sh/blog/dist"
production-branch: main
github-token: ${{ secrets.GITHUB_TOKEN }}
deploy-message: 'Deploy from GitHub Actions'
deploy-message: "Deploy from GitHub Actions"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

View File

@@ -24,7 +24,7 @@ jobs:
id: setup-node
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: "22"
- name: Docker setup
uses: docker/setup-buildx-action@v3
@@ -56,7 +56,7 @@ jobs:
run: |
./tools/patch.sh
- name: '[e2e-tests] Install Node.js dependencies for Playwright tests'
- name: "[e2e-tests] Install Node.js dependencies for Playwright tests"
if: steps.cache-e2e-tests.outputs.cache-hit != 'true'
working-directory: ./template
run: |
@@ -78,14 +78,14 @@ jobs:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}-${{ runner.os }}
- name: '[e2e-tests] Set up Playwright'
- name: "[e2e-tests] Set up Playwright"
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
working-directory: ./template
run: |
cd e2e-tests
npx playwright install --with-deps
- name: '[e2e-tests] Install Stripe CLI'
- name: "[e2e-tests] Install Stripe CLI"
run: |
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
@@ -95,13 +95,13 @@ jobs:
# For Stripe webhooks to work in development, we need to run the Stripe CLI to listen for webhook events.
# The Stripe CLI will receive the webhook events from Stripe test payments and
# forward them to our local server so that we can test the payment flow in our e2e tests.
- name: '[e2e-tests] Run Stripe CLI to listen for webhooks'
- name: "[e2e-tests] Run Stripe CLI to listen for webhooks"
env:
STRIPE_DEVICE_NAME: ${{ secrets.STRIPE_DEVICE_NAME }}
run: |
stripe listen --api-key ${{ secrets.STRIPE_KEY }} --forward-to localhost:3001/payments-webhook &
- name: '[e2e-tests] Run Playwright tests'
- name: "[e2e-tests] Run Playwright tests"
env:
# The e2e tests are testing parts of the app that need certain env vars, so we need to access them here.
# These secretes can be set in your GitHub repo settings, e.g. https://github.com/<account>/<repo>/settings/secrets/actions

View File

@@ -1,6 +1,7 @@
Thanks so much for considering contributing to Open SaaS 🙏
## Considerations before Contributing
Check if there is a GitHub issue already for the thing you would like to work on. If there is no issue yet, create a new one.
Let us know, in the issue, that you would like to work on it and how you plan to approach it.
@@ -15,6 +16,7 @@ Repo is divided into two main parts: [template](/template) dir and [opensaas-sh]
`opensaas-sh` is the app deployed to https://opensaas.sh , and is actually made with open saas! It contains a demo app and open saas docs. We keep it updated as we work on the template.
## How to Contribute
1. Make sure you understand the basics of how open-saas works (check out [docs](https://docs.opensaas.sh)).
2. Check out this repo (`main` branch) and make sure you are able to get the app in [template/app/](/template/app) running (to set it up, follow the same steps as for running a new open-saas app, as explained in the open-saas docs).
3. Create a new git branch for your work (aka feature branch) and do your changes on it.

View File

@@ -1,4 +1,5 @@
## Welcome to your new SaaS App! 🎉
<a href="https://www.producthunt.com/products/open-saas?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-open&#0045;saas&#0045;2" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=991058&theme=neutral&t=1753776395137" alt="Open&#0032;SaaS - The&#0032;open&#0045;source&#0032;SaaS&#0032;boilerplate&#0032;with&#0032;superpowers&#0033; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
https://github.com/user-attachments/assets/3856276b-23e9-455e-a564-b5f26f4f0e98
@@ -38,6 +39,7 @@ Because we're using Wasp as the full-stack framework, we can leverage a lot of i
- 🚀 [One-command Deploy](https://wasp.sh/docs/advanced/deployment/overview) - Easily deploy your DB, Server, & Client with one commaned to [Railway](https://railway.app) or [Fly.io](https://fly.io) via the CLI. Or deploy manually to any other hosting serivce of your choice.
You also get access to Wasp's diverse, helpful community if you get stuck or need help.
- 🤝 [Wasp Discord](https://discord.gg/aCamt5wCpS)
## Getting Started
@@ -45,6 +47,7 @@ You also get access to Wasp's diverse, helpful community if you get stuck or nee
### Simple Instructions
First, to install the latest version of [Wasp](https://wasp.sh/) on macOS, Linux, or Windows with WSL, run the following command:
```bash
curl -sSL https://get.wasp.sh/installer.sh | sh
```
@@ -66,6 +69,7 @@ We've documented everything in great detail, including installation instructions
## Getting Help & Providing Feedback
There are two ways to get help or provide feedback (and we try to always respond quickly!):
1. [Open an issue](https://github.com/wasp-lang/open-saas/issues)
2. [Wasp Discord](https://discord.gg/aCamt5wCpS) -- please direct questions to the #🙋questions forum channel
@@ -74,4 +78,3 @@ There are two ways to get help or provide feedback (and we try to always respond
Note that we've tried to get as many of the core features of a SaaS app into this template as possible, but there still might be some missing features or functionality.
We could always use some help tying up loose ends: contributions are welcome! Check out [CONTRIBUTING.md](/CONTRIBUTING.md) for more details.

View File

@@ -12,7 +12,6 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production

View File

@@ -1,100 +1,102 @@
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import starlightBlog from 'starlight-blog';
import starlight from "@astrojs/starlight";
import { defineConfig } from "astro/config";
import starlightBlog from "starlight-blog";
import tailwind from '@astrojs/tailwind';
import tailwind from "@astrojs/tailwind";
// https://astro.build/config
export default defineConfig({
site: 'https://docs.opensaas.sh',
trailingSlash: 'always',
site: "https://docs.opensaas.sh",
trailingSlash: "always",
integrations: [
starlight({
title: 'OpenSaaS.sh',
description: 'Open SaaS is a free, open-source, full-stack SaaS starter kit for React + NodeJS.',
customCss: ['./src/styles/tailwind.css'],
title: "OpenSaaS.sh",
description:
"Open SaaS is a free, open-source, full-stack SaaS starter kit for React + NodeJS.",
customCss: ["./src/styles/tailwind.css"],
logo: {
src: '/src/assets/logo.webp',
alt: 'Open SaaS',
src: "/src/assets/logo.webp",
alt: "Open SaaS",
},
head: [
{
tag: 'script',
tag: "script",
attrs: {
defer: true,
'data-domain': 'docs.opensaas.sh',
'data-api': 'https://opensaas.sh/wasparadocs/wasp/event',
src: 'https://opensaas.sh/wasparadocs/wasp/script.js',
"data-domain": "docs.opensaas.sh",
"data-api": "https://opensaas.sh/wasparadocs/wasp/event",
src: "https://opensaas.sh/wasparadocs/wasp/script.js",
},
},
{
tag: 'script',
tag: "script",
attrs: {
defer: true,
src: '/piggy.js',
src: "/piggy.js",
},
},
],
editLink: {
baseUrl: 'https://github.com/wasp-lang/open-saas/edit/main/opensaas-sh/blog',
baseUrl:
"https://github.com/wasp-lang/open-saas/edit/main/opensaas-sh/blog",
},
components: {
SiteTitle: './src/components/MyHeader.astro',
SiteTitle: "./src/components/MyHeader.astro",
// We customized ThemeSelect to include a "Copy URL for LLMs" button
ThemeSelect: './src/components/MyRightNavBarItems.astro',
Head: './src/components/HeadWithOGImage.astro',
ThemeSelect: "./src/components/MyRightNavBarItems.astro",
Head: "./src/components/HeadWithOGImage.astro",
},
social: {
github: 'https://github.com/wasp-lang/open-saas',
twitter: 'https://twitter.com/wasplang',
discord: 'https://discord.gg/aCamt5wCpS',
github: "https://github.com/wasp-lang/open-saas",
twitter: "https://twitter.com/wasplang",
discord: "https://discord.gg/aCamt5wCpS",
},
sidebar: [
{
label: 'Start Here',
label: "Start Here",
items: [
{ label: 'Introduction', link: '/' },
{ label: 'Getting Started', link: '/start/getting-started/' },
{ label: 'Guided Tour', link: '/start/guided-tour/' },
{ label: "Introduction", link: "/" },
{ label: "Getting Started", link: "/start/getting-started/" },
{ label: "Guided Tour", link: "/start/guided-tour/" },
],
},
{
label: 'Guides',
autogenerate: { directory: '/guides/' },
label: "Guides",
autogenerate: { directory: "/guides/" },
},
{
label: 'General',
autogenerate: { directory: '/general/' },
label: "General",
autogenerate: { directory: "/general/" },
},
],
plugins: [
starlightBlog({
title: 'Blog',
customCss: ['./src/styles/tailwind.css'],
title: "Blog",
customCss: ["./src/styles/tailwind.css"],
authors: {
vince: {
name: 'Vince',
title: 'Dev Rel @ Wasp',
picture: '/CRAIG_ROCK.png', // Images in the `public` directory are supported.
url: 'https://wasp.sh',
name: "Vince",
title: "Dev Rel @ Wasp",
picture: "/CRAIG_ROCK.png", // Images in the `public` directory are supported.
url: "https://wasp.sh",
},
matija: {
name: 'Matija',
title: 'CEO @ Wasp',
picture: '/matija.jpeg', // Images in the `public` directory are supported.
url: 'https://wasp.sh',
name: "Matija",
title: "CEO @ Wasp",
picture: "/matija.jpeg", // Images in the `public` directory are supported.
url: "https://wasp.sh",
},
milica: {
name: 'Milica',
title: 'Growth @ Wasp',
picture: '/milica.jpg', // Images in the `public` directory are supported.
url: 'https://wasp.sh',
name: "Milica",
title: "Growth @ Wasp",
picture: "/milica.jpg", // Images in the `public` directory are supported.
url: "https://wasp.sh",
},
martin: {
name: 'Martin',
title: 'CTO @ Wasp',
picture: '/martin.jpg', // Images in the `public` directory are supported.
url: 'https://wasp.sh',
name: "Martin",
title: "CTO @ Wasp",
picture: "/martin.jpg", // Images in the `public` directory are supported.
url: "https://wasp.sh",
},
},
}),

View File

@@ -1,14 +1,14 @@
{
"name": "docs",
"type": "module",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"generate-llm-files": "node ./scripts/generate-llm-files.mjs"
"build": "astro check && astro build",
"dev": "astro dev",
"generate-llm-files": "node ./scripts/generate-llm-files.mjs",
"preview": "astro preview",
"start": "astro dev"
},
"dependencies": {
"@astrojs/check": "^0.9.4",

View File

@@ -1,57 +1,59 @@
import fs from 'fs-extra';
import path from 'path';
import { globSync } from 'glob';
import fm from 'front-matter';
import fm from "front-matter";
import fs from "fs-extra";
import { globSync } from "glob";
import path from "path";
const BLOG_ROOT = process.cwd();
const PUBLIC_DIR = path.join(BLOG_ROOT, 'public');
const DOCS_BASE_DIR = path.join(BLOG_ROOT, 'src/content/docs');
const ORDERED_SOURCES = ['index.mdx', 'start', 'guides', 'general']; // Relative to DOCS_BASE_DIR
const GITHUB_RAW_BASE_URL = 'https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/';
const OPEN_SAAS_DOCS_BASE_URL = 'https://docs.opensaas.sh/';
const LLM_FULL_FILENAME = 'llms-full.txt';
const LLM_OVERVIEW_FILENAME = 'llms.txt';
const PUBLIC_DIR = path.join(BLOG_ROOT, "public");
const DOCS_BASE_DIR = path.join(BLOG_ROOT, "src/content/docs");
const ORDERED_SOURCES = ["index.mdx", "start", "guides", "general"]; // Relative to DOCS_BASE_DIR
const GITHUB_RAW_BASE_URL =
"https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/";
const OPEN_SAAS_DOCS_BASE_URL = "https://docs.opensaas.sh/";
const LLM_FULL_FILENAME = "llms-full.txt";
const LLM_OVERVIEW_FILENAME = "llms.txt";
const OVERVIEW_FILE = path.join(PUBLIC_DIR, LLM_OVERVIEW_FILENAME);
const FULL_CONCAT_FILE = path.join(PUBLIC_DIR, LLM_FULL_FILENAME);
function cleanContent(content) {
if (!content) return '';
if (!content) return "";
let cleaned = content;
// Remove lines starting with 'import ... from ...;' or just 'import ...'
cleaned = cleaned.replace(/^import\s+.*(?:from\s+['"].*['"])?;?\s*$/gm, '');
cleaned = cleaned.replace(/^import\s+.*(?:from\s+['"].*['"])?;?\s*$/gm, "");
// Remove lines like {/* TODO: ... */}
cleaned = cleaned.replace(/^\{\/\*.*\*\/\}\s*$/gm, '');
cleaned = cleaned.replace(/^\{\/\*.*\*\/\}\s*$/gm, "");
// Remove lines like <!-- TODO: ... -->
cleaned = cleaned.replace(/^<!--.*-->\s*$/gm, '');
cleaned = cleaned.replace(/^<!--.*-->\s*$/gm, "");
// Remove <Image ... /> tags
cleaned = cleaned.replace(/<Image[^>]*?\/>/g, '');
cleaned = cleaned.replace(/<Image[^>]*?\/>/g, "");
// Remove <HiddenLLMHelper /> tag
cleaned = cleaned.replace(/<HiddenLLMHelper\s*\/>/g, '');
cleaned = cleaned.replace(/<HiddenLLMHelper\s*\/>/g, "");
// Remove Emojis using specific ranges (less likely to remove digits)
cleaned = cleaned.replace(
/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]/gu,
''
"",
);
cleaned = cleaned.replace(/\u00A0/g, ' ');
cleaned = cleaned.replace(/\u00A0/g, " ");
// Remove Box Drawing Characters (single and double line)
cleaned = cleaned.replace(/[│├└─╔═╗║╚╝]/g, '');
cleaned = cleaned.replace(/[│├└─╔═╗║╚╝]/g, "");
// Remove more than two line breaks
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
return cleaned.trim();
}
async function generateFiles() {
console.log('Starting LLM file generation...');
console.log("Starting LLM file generation...");
const introSummary = '> Open SaaS is a free, open-source, full-stack starter kit for building SaaS applications quickly. It leverages the [Wasp](https://wasp.sh/docs) framework (React, Node.js, Prisma) and integrates with various services like Stripe/Lemon Squeezy, OpenAI API, AWS S3, and different email providers.';
const introSummary =
"> Open SaaS is a free, open-source, full-stack starter kit for building SaaS applications quickly. It leverages the [Wasp](https://wasp.sh/docs) framework (React, Node.js, Prisma) and integrates with various services like Stripe/Lemon Squeezy, OpenAI API, AWS S3, and different email providers.";
let orderedSourceFiles = [];
const fullConcatenatedFileUrl = OPEN_SAAS_DOCS_BASE_URL + LLM_FULL_FILENAME;
let overviewContent = `# Open SaaS Documentation for LLMs\n\n${introSummary}\n\n## Full Documentation\n - [View Complete LLM-formatted Open SaaS Documentation](${fullConcatenatedFileUrl})\n\n## Individual documentation sections and guides:\n`;
let fullConcatContent = '';
let fullConcatContent = "";
console.log('Gathering source files...');
console.log("Gathering source files...");
for (const sourceItem of ORDERED_SOURCES) {
const itemPath = path.join(DOCS_BASE_DIR, sourceItem);
try {
@@ -60,56 +62,66 @@ async function generateFiles() {
orderedSourceFiles.push(itemPath);
console.log(` Added file: ${sourceItem}`);
} else if (stats.isDirectory()) {
const files = globSync(path.join(itemPath, '**/*.{md,mdx}').replace(/\\/g, '/'), { nodir: true }).sort();
const files = globSync(
path.join(itemPath, "**/*.{md,mdx}").replace(/\\/g, "/"),
{ nodir: true },
).sort();
orderedSourceFiles.push(...files);
console.log(` Added ${files.length} files from directory: ${sourceItem}`);
console.log(
` Added ${files.length} files from directory: ${sourceItem}`,
);
}
} catch (error) {
if (error.code === 'ENOENT') {
console.warn(`Warning: Source item not found: ${itemPath}`);
if (error.code === "ENOENT") {
console.warn(`Warning: Source item not found: ${itemPath}`);
} else {
console.warn(`Warning: Could not access source item: ${itemPath} - ${error.message}`);
console.warn(
`Warning: Could not access source item: ${itemPath} - ${error.message}`,
);
}
}
}
console.log(`Total source files found: ${orderedSourceFiles.length}`);
console.log('Processing files...');
console.log("Processing files...");
for (const sourceFilePath of orderedSourceFiles) {
try {
const rawContent = await fs.readFile(sourceFilePath, 'utf8');
const rawContent = await fs.readFile(sourceFilePath, "utf8");
const { attributes, body } = fm(rawContent);
const processedContent = cleanContent(body);
const title = attributes.title || path.basename(sourceFilePath, path.extname(sourceFilePath));
const title =
attributes.title ||
path.basename(sourceFilePath, path.extname(sourceFilePath));
const relativeSourcePathForGithub = path.relative(BLOG_ROOT, sourceFilePath).replace(/\\/g, '/');
const relativeSourcePathForGithub = path
.relative(BLOG_ROOT, sourceFilePath)
.replace(/\\/g, "/");
const githubRawUrl = GITHUB_RAW_BASE_URL + relativeSourcePathForGithub;
overviewContent += `- [${title}](${githubRawUrl})\n`;
fullConcatContent += `# ${title}\n\n${processedContent}\n\n---\n\n`;
} catch (error) {
console.error(`Error processing file ${sourceFilePath}:`, error);
console.error(`Error processing file ${sourceFilePath}:`, error);
}
}
console.log('Writing output files to public/ ...');
console.log("Writing output files to public/ ...");
try {
await fs.writeFile(OVERVIEW_FILE, overviewContent.trim(), 'utf8');
await fs.writeFile(OVERVIEW_FILE, overviewContent.trim(), "utf8");
console.log(`Generated overview file: ${OVERVIEW_FILE}`);
await fs.writeFile(FULL_CONCAT_FILE, fullConcatContent.trim(), 'utf8');
await fs.writeFile(FULL_CONCAT_FILE, fullConcatContent.trim(), "utf8");
console.log(`Generated full concatenated file: ${FULL_CONCAT_FILE}`);
} catch (error) {
console.error('Error writing output files:', error);
console.error("Error writing output files:", error);
}
console.log('LLM file generation complete.');
console.log("LLM file generation complete.");
}
generateFiles().catch(error => {
console.error('Unhandled error during LLM file generation:', error);
generateFiles().catch((error) => {
console.error("Unhandled error during LLM file generation:", error);
process.exit(1);
});

View File

@@ -1,16 +1,24 @@
import path from 'path';
import { existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { existsSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
export const BANNER_PATH = '/banner-images';
export const BANNER_PATH = "/banner-images";
export const DEFAULT_BANNER_IMAGE = 'opensaas.webp';
export const DEFAULT_BANNER_IMAGE = "opensaas.webp";
export const getBannerImageFilename = ({ path }: { path: string }) =>
path.replace(/.*\//, '').replace(/\.\w+$/, '.webp');
path.replace(/.*\//, "").replace(/\.\w+$/, ".webp");
export const checkBannerImageExists = ({ bannerImageFileName }: { bannerImageFileName: string }) => {
export const checkBannerImageExists = ({
bannerImageFileName,
}: {
bannerImageFileName: string;
}) => {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const imagePath = path.join(__dirname, `../../public/${BANNER_PATH}`, bannerImageFileName);
const imagePath = path.join(
__dirname,
`../../public/${BANNER_PATH}`,
bannerImageFileName,
);
return existsSync(imagePath);
};

View File

@@ -1,7 +1,6 @@
import { defineCollection } from 'astro:content';
import { i18nSchema, docsSchema } from '@astrojs/starlight/schema';
import { blogSchema } from 'starlight-blog/schema';
import { z } from 'astro:content';
import { docsSchema, i18nSchema } from "@astrojs/starlight/schema";
import { defineCollection, z } from "astro:content";
import { blogSchema } from "starlight-blog/schema";
export const collections = {
docs: defineCollection({
@@ -16,5 +15,5 @@ export const collections = {
},
}),
}),
i18n: defineCollection({ type: 'data', schema: i18nSchema() }),
i18n: defineCollection({ type: "data", schema: i18nSchema() }),
};

View File

@@ -1,22 +1,22 @@
declare module 'virtual:starlight-blog-config' {
const StarlightBlogConfig: import('./libs/config').StarlightBlogConfig;
declare module "virtual:starlight-blog-config" {
const StarlightBlogConfig: import("./libs/config").StarlightBlogConfig;
export default StarlightBlogConfig;
}
declare module 'virtual:starlight/user-config' {
const Config: import('@astrojs/starlight/types').StarlightConfig;
declare module "virtual:starlight/user-config" {
const Config: import("@astrojs/starlight/types").StarlightConfig;
export default Config;
}
declare module 'virtual:starlight/user-images' {
type ImageMetadata = import('astro').ImageMetadata;
declare module "virtual:starlight/user-images" {
type ImageMetadata = import("astro").ImageMetadata;
export const logos: {
dark?: ImageMetadata;
light?: ImageMetadata;
};
}
declare module 'virtual:astro-config' {
const Config: import('@astrojs/types').Config;
declare module "virtual:astro-config" {
const Config: import("@astrojs/types").Config;
export default Config;
}

View File

@@ -1,8 +1,8 @@
import starlightPlugin from "@astrojs/starlight-tailwind";
import colors from "tailwindcss/colors";
const yellow = colors.yellow
const gray = colors.gray
const yellow = colors.yellow;
const gray = colors.gray;
/** @type {import('tailwindcss').Config} */
export default {
@@ -10,7 +10,8 @@ export default {
theme: {
extend: {
colors: {
accent: yellow, gray
accent: yellow,
gray,
},
},
},

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
TOOLS_DIR=$(dirname "$(realpath "$0")") # Assumes this script is in `tools/`.
TOOLS_DIR=$(dirname "$(realpath "$0")") # Assumes this script is in `tools/`.
cd "${TOOLS_DIR}" && cd ../..
rm -rf opensaas-sh/app_diff

View File

@@ -8,28 +8,28 @@
# Determine the patch command to use based on OS
PATCH_CMD="patch"
if [[ "$(uname)" == "Darwin" ]]; then
# On macOS, require gpatch to be installed
if command -v gpatch &> /dev/null; then
PATCH_CMD="gpatch"
else
echo "Error: GNU patch (gpatch) not found. On MacOS, this script requires GNU patch."
echo "Install it with: brew install gpatch"
exit 1
fi
# On macOS, require gpatch to be installed
if command -v gpatch &> /dev/null; then
PATCH_CMD="gpatch"
else
echo "Error: GNU patch (gpatch) not found. On MacOS, this script requires GNU patch."
echo "Install it with: brew install gpatch"
exit 1
fi
fi
# List all the source files in the specified dir.
# "Source" files are any files that are not gitignored.
list_source_files() {
local dir=$1
(cd "${dir}" && git ls-files --cached --others --exclude-standard | sort)
local dir=$1
(cd "${dir}" && git ls-files --cached --others --exclude-standard | sort)
}
# Check if the required arguments are provided.
if [ "$#" -ne 3 ]; then
echo "Usage: $0 <BASE_DIR> <DERIVED_DIR> <ACTION>"
echo "<ACTION> should be either 'diff' to get the diff between the specified dirs or 'patch' to apply such existing diff onto base dir."
exit 1
echo "Usage: $0 <BASE_DIR> <DERIVED_DIR> <ACTION>"
echo "<ACTION> should be either 'diff' to get the diff between the specified dirs or 'patch' to apply such existing diff onto base dir."
exit 1
fi
BASE_DIR=$1
@@ -41,41 +41,41 @@ DIFF_DIR_DELETIONS="${DIFF_DIR}/deletions"
# Based on base dir and derived dir, creates a diff dir that contains the diff between the two dirs.
recreate_diff_dir() {
mkdir -p "${DIFF_DIR}"
mkdir -p "${DIFF_DIR}"
# List all the source files in the base and derived dirs. We skip gitignored files.
local BASE_FILES
BASE_FILES=$(list_source_files "${BASE_DIR}") # File paths relative to the base dir.
local DERIVED_FILES
DERIVED_FILES=$(list_source_files "${DERIVED_DIR}") # File paths relative to the derived dir.
# List all the source files in the base and derived dirs. We skip gitignored files.
local BASE_FILES
BASE_FILES=$(list_source_files "${BASE_DIR}") # File paths relative to the base dir.
local DERIVED_FILES
DERIVED_FILES=$(list_source_files "${DERIVED_DIR}") # File paths relative to the derived dir.
# For each source file in the derived dir, generate a .diff file between it and the
# corresponding source file in the base dir.
while IFS= read -r filepath; do
local derivedFilepath="${DERIVED_DIR}/${filepath}"
local baseFilepath="${BASE_DIR}/${filepath}"
# For each source file in the derived dir, generate a .diff file between it and the
# corresponding source file in the base dir.
while IFS= read -r filepath; do
local derivedFilepath="${DERIVED_DIR}/${filepath}"
local baseFilepath="${BASE_DIR}/${filepath}"
local filepathToBeUsedAsBase="${baseFilepath}"
# If the file is not one of the source files in base dir (e.g. is gitignored or doesn't exist),
# then we set it to /dev/null to indicate it doesn't exist for our purposes.
if ! echo "${BASE_FILES}" | grep -q "^${filepath}$"; then
filepathToBeUsedAsBase="/dev/null"
fi
local filepathToBeUsedAsBase="${baseFilepath}"
# If the file is not one of the source files in base dir (e.g. is gitignored or doesn't exist),
# then we set it to /dev/null to indicate it doesn't exist for our purposes.
if ! echo "${BASE_FILES}" | grep -q "^${filepath}$"; then
filepathToBeUsedAsBase="/dev/null"
fi
local DIFF_OUTPUT
DIFF_OUTPUT=$(diff -Nu --label "${baseFilepath}" --label "${derivedFilepath}" "${filepathToBeUsedAsBase}" "${derivedFilepath}")
if [ $? -eq 1 ]; then
mkdir -p "${DIFF_DIR}/$(dirname "${filepath}")"
echo "${DIFF_OUTPUT}" > "${DIFF_DIR}/${filepath}.diff"
echo "Generated ${DIFF_DIR}/${filepath}.diff"
fi
done <<< "${DERIVED_FILES}"
local DIFF_OUTPUT
DIFF_OUTPUT=$(diff -Nu --label "${baseFilepath}" --label "${derivedFilepath}" "${filepathToBeUsedAsBase}" "${derivedFilepath}")
if [ $? -eq 1 ]; then
mkdir -p "${DIFF_DIR}/$(dirname "${filepath}")"
echo "${DIFF_OUTPUT}" > "${DIFF_DIR}/${filepath}.diff"
echo "Generated ${DIFF_DIR}/${filepath}.diff"
fi
done <<< "${DERIVED_FILES}"
local FILES_ONLY_IN_BASE
FILES_ONLY_IN_BASE=$(comm -23 <(echo "${BASE_FILES}") <(echo "${DERIVED_FILES}"))
echo "${FILES_ONLY_IN_BASE}" > "${DIFF_DIR_DELETIONS}"
local FILES_ONLY_IN_BASE
FILES_ONLY_IN_BASE=$(comm -23 <(echo "${BASE_FILES}") <(echo "${DERIVED_FILES}"))
echo "${FILES_ONLY_IN_BASE}" > "${DIFF_DIR_DELETIONS}"
echo "DONE: generated ${DIFF_DIR}/"
echo "DONE: generated ${DIFF_DIR}/"
}
RED_COLOR='\033[0;31m'
@@ -84,76 +84,76 @@ RESET_COLOR='\033[0m'
# Patches the diff dir onto the base dir to get the derived dir.
recreate_derived_dir() {
mkdir -p "${DERIVED_DIR}"
mkdir -p "${DERIVED_DIR}"
local BASE_FILES
BASE_FILES=$(list_source_files "${BASE_DIR}") # File paths relative to the base dir.
local BASE_FILES
BASE_FILES=$(list_source_files "${BASE_DIR}") # File paths relative to the base dir.
# Copy all the source files from the base dir over to the derived dir.
while IFS= read -r filepath; do
mkdir -p "${DERIVED_DIR}/$(dirname ${filepath})"
cp "${BASE_DIR}/${filepath}" "${DERIVED_DIR}/${filepath}"
done <<< "${BASE_FILES}"
# Copy all the source files from the base dir over to the derived dir.
while IFS= read -r filepath; do
mkdir -p "${DERIVED_DIR}/$(dirname ${filepath})"
cp "${BASE_DIR}/${filepath}" "${DERIVED_DIR}/${filepath}"
done <<< "${BASE_FILES}"
# For each .diff file in diff dir, apply the patch to the corresponding base file in the derived dir.
#local num_patches_failed
local num_patches_failed=0
while IFS= read -r diff_filepath; do
local derived_filepath
derived_filepath="${diff_filepath#${DIFF_DIR}/}"
derived_filepath="${derived_filepath%.diff}"
# For each .diff file in diff dir, apply the patch to the corresponding base file in the derived dir.
#local num_patches_failed
local num_patches_failed=0
while IFS= read -r diff_filepath; do
local derived_filepath
derived_filepath="${diff_filepath#${DIFF_DIR}/}"
derived_filepath="${derived_filepath%.diff}"
local patch_output
local patch_exit_code
patch_output=$("${PATCH_CMD}" --no-backup-if-mismatch --merge "${DERIVED_DIR}/${derived_filepath}" < "${diff_filepath}")
patch_exit_code=$?
if [ ${patch_exit_code} -eq 0 ]; then
echo "${patch_output}"
echo -e "${GREEN_COLOR}[OK]${RESET_COLOR}"
else
echo "${patch_output}"
echo -e "${RED_COLOR}[Failed with exit code ${patch_exit_code}]${RESET_COLOR}"
num_patches_failed=$((num_patches_failed + 1))
fi
echo ""
done < <(find "${DIFF_DIR}" -name "*.diff")
# Delete any files that exist in the base dir but shouldn't exist in the derived dir.
# TODO: also allow deletion of dirs.
if [ -f "${DIFF_DIR_DELETIONS}" ]; then
while IFS= read -r filepath; do
local derived_dir_filepath
local rm_exit_code
derived_dir_filepath="${DERIVED_DIR}/${filepath}"
rm "${derived_dir_filepath}"
rm_exit_code=$?
if [ ${rm_exit_code} -eq 0 ]; then
echo "Deleted ${derived_dir_filepath}"
echo -e "${GREEN_COLOR}[OK]${RESET_COLOR}"
else
echo "Failed to delete ${derived_dir_filepath}"
echo -e "${RED_COLOR}[Failed with exit code ${rm_exit_code}]${RESET_COLOR}"
fi
echo ""
done < "${DIFF_DIR_DELETIONS}"
fi
echo "DONE: generated ${DERIVED_DIR}/"
if [ ${num_patches_failed} -gt 0 ]; then
echo -e "${RED_COLOR}${num_patches_failed} patches failed, look into generated files for merge conflicts.${RESET_COLOR}"
exit 1;
local patch_output
local patch_exit_code
patch_output=$("${PATCH_CMD}" --no-backup-if-mismatch --merge "${DERIVED_DIR}/${derived_filepath}" < "${diff_filepath}")
patch_exit_code=$?
if [ ${patch_exit_code} -eq 0 ]; then
echo "${patch_output}"
echo -e "${GREEN_COLOR}[OK]${RESET_COLOR}"
else
echo -e "${GREEN_COLOR}All patches successfully applied.${RESET_COLOR}"
echo "${patch_output}"
echo -e "${RED_COLOR}[Failed with exit code ${patch_exit_code}]${RESET_COLOR}"
num_patches_failed=$((num_patches_failed + 1))
fi
echo ""
done < <(find "${DIFF_DIR}" -name "*.diff")
# Delete any files that exist in the base dir but shouldn't exist in the derived dir.
# TODO: also allow deletion of dirs.
if [ -f "${DIFF_DIR_DELETIONS}" ]; then
while IFS= read -r filepath; do
local derived_dir_filepath
local rm_exit_code
derived_dir_filepath="${DERIVED_DIR}/${filepath}"
rm "${derived_dir_filepath}"
rm_exit_code=$?
if [ ${rm_exit_code} -eq 0 ]; then
echo "Deleted ${derived_dir_filepath}"
echo -e "${GREEN_COLOR}[OK]${RESET_COLOR}"
else
echo "Failed to delete ${derived_dir_filepath}"
echo -e "${RED_COLOR}[Failed with exit code ${rm_exit_code}]${RESET_COLOR}"
fi
echo ""
done < "${DIFF_DIR_DELETIONS}"
fi
echo "DONE: generated ${DERIVED_DIR}/"
if [ ${num_patches_failed} -gt 0 ]; then
echo -e "${RED_COLOR}${num_patches_failed} patches failed, look into generated files for merge conflicts.${RESET_COLOR}"
exit 1
else
echo -e "${GREEN_COLOR}All patches successfully applied.${RESET_COLOR}"
fi
}
if [ "$ACTION" == "diff" ]; then
recreate_diff_dir
recreate_diff_dir
elif [ "$ACTION" == "patch" ]; then
recreate_derived_dir
recreate_derived_dir
else
echo "Invalid action specified. Use 'diff' to get a diff between specified dirs or 'patch' to patch the existing diff onto base dir."
exit 1
echo "Invalid action specified. Use 'diff' to get a diff between specified dirs or 'patch' to patch the existing diff onto base dir."
exit 1
fi

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
TOOLS_DIR=$(dirname "$(realpath "$0")") # Assumes this script is in `tools/`.
TOOLS_DIR=$(dirname "$(realpath "$0")") # Assumes this script is in `tools/`.
cd "${TOOLS_DIR}" && cd ../..
# Removes all files except for some gitignored files that we don't want to bother regenerating each time,

View File

@@ -1,6 +1,7 @@
# <YOUR_APP_NAME>
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.

View File

@@ -3,9 +3,10 @@
### PRD / initial prompt
I want to create a `<insert-type-of-app-here>` app with the current SaaS boilerplate template project I'm in which uses Wasp and already has payment processing, AWS S3 file upload, a landing page, an admin dashboard, and authentication already setup. Leveraging Wasp's full-stack features (such as Auth), let's build the app based on the following spec:
- `<insert-feature-spec-here>`
- `<insert-feature-spec-here>`
- `<insert-feature-spec-here>`
- `<insert-feature-spec-here>`
- `<insert-feature-spec-here>`
- `<insert-feature-spec-here>`
With this in mind, I want you to first evaluate the project template and think about a few possible PRD approaches before landing on the best one. Provide reasoning why this would be the best approach. Remember we're using Wasp, a full-stack framework with batteries included, that can do some of the heavy lifting for us, and we want to use a modified vertical slice implementation approach for LLM-assisted coding so we can start with basic implementations of features first, and add on complexity from there.

View File

@@ -9,8 +9,8 @@ This template includes [ShadCN UI](https://ui.shadcn.com/) v2 for beautiful, acc
## 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`.
- 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`.

View File

@@ -1,13 +1,13 @@
import { type AuthUser } from 'wasp/auth';
import { getDailyStats, useQuery } from 'wasp/client/operations';
import { cn } from '../../../lib/utils';
import DefaultLayout from '../../layout/DefaultLayout';
import RevenueAndProfitChart from './RevenueAndProfitChart';
import SourcesTable from './SourcesTable';
import TotalPageViewsCard from './TotalPageViewsCard';
import TotalPayingUsersCard from './TotalPayingUsersCard';
import TotalRevenueCard from './TotalRevenueCard';
import TotalSignupsCard from './TotalSignupsCard';
import { type AuthUser } from "wasp/auth";
import { getDailyStats, useQuery } from "wasp/client/operations";
import { cn } from "../../../lib/utils";
import DefaultLayout from "../../layout/DefaultLayout";
import RevenueAndProfitChart from "./RevenueAndProfitChart";
import SourcesTable from "./SourcesTable";
import TotalPageViewsCard from "./TotalPageViewsCard";
import TotalPayingUsersCard from "./TotalPayingUsersCard";
import TotalRevenueCard from "./TotalRevenueCard";
import TotalSignupsCard from "./TotalSignupsCard";
const Dashboard = ({ user }: { user: AuthUser }) => {
const { data: stats, isLoading, error } = useQuery(getDailyStats);
@@ -15,11 +15,11 @@ const Dashboard = ({ user }: { user: AuthUser }) => {
if (error) {
return (
<DefaultLayout user={user}>
<div className='flex h-full items-center justify-center'>
<div className='rounded-lg bg-card p-8 shadow-lg'>
<p className='text-2xl font-bold text-red-500'>Error</p>
<p className='mt-2 text-sm text-muted-foreground'>
{error.message || 'Something went wrong while fetching stats.'}
<div className="flex h-full items-center justify-center">
<div className="bg-card rounded-lg p-8 shadow-lg">
<p className="text-2xl font-bold text-red-500">Error</p>
<p className="text-muted-foreground mt-2 text-sm">
{error.message || "Something went wrong while fetching stats."}
</p>
</div>
</div>
@@ -29,40 +29,53 @@ const Dashboard = ({ user }: { user: AuthUser }) => {
return (
<DefaultLayout user={user}>
<div className='relative'>
<div className="relative">
<div
className={cn({
'opacity-25': !stats,
"opacity-25": !stats,
})}
>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-4 2xl:gap-7.5'>
<div className="2xl:gap-7.5 grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-4">
<TotalPageViewsCard
totalPageViews={stats?.dailyStats.totalViews}
prevDayViewsChangePercent={stats?.dailyStats.prevDayViewsChangePercent}
prevDayViewsChangePercent={
stats?.dailyStats.prevDayViewsChangePercent
}
/>
<TotalRevenueCard
dailyStats={stats?.dailyStats}
weeklyStats={stats?.weeklyStats}
isLoading={isLoading}
/>
<TotalPayingUsersCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
<TotalSignupsCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
<TotalPayingUsersCard
dailyStats={stats?.dailyStats}
isLoading={isLoading}
/>
<TotalSignupsCard
dailyStats={stats?.dailyStats}
isLoading={isLoading}
/>
</div>
<div className='mt-4 grid grid-cols-12 gap-4 md:mt-6 md:gap-6 2xl:mt-7.5 2xl:gap-7.5'>
<RevenueAndProfitChart weeklyStats={stats?.weeklyStats} isLoading={isLoading} />
<div className="2xl:mt-7.5 2xl:gap-7.5 mt-4 grid grid-cols-12 gap-4 md:mt-6 md:gap-6">
<RevenueAndProfitChart
weeklyStats={stats?.weeklyStats}
isLoading={isLoading}
/>
<div className='col-span-12 xl:col-span-8'>
<div className="col-span-12 xl:col-span-8">
<SourcesTable sources={stats?.dailyStats?.sources} />
</div>
</div>
</div>
{!stats && (
<div className='absolute inset-0 flex items-start justify-center bg-background/50'>
<div className='rounded-lg bg-card p-8 shadow-lg'>
<p className='text-2xl font-bold text-foreground'>No daily stats generated yet</p>
<p className='mt-2 text-sm text-muted-foreground'>
<div className="bg-background/50 absolute inset-0 flex items-start justify-center">
<div className="bg-card rounded-lg p-8 shadow-lg">
<p className="text-foreground text-2xl font-bold">
No daily stats generated yet
</p>
<p className="text-muted-foreground mt-2 text-sm">
Stats will appear here once the daily stats job has run
</p>
</div>

View File

@@ -1,22 +1,22 @@
import { ApexOptions } from 'apexcharts';
import { useEffect, useMemo, useState } from 'react';
import ReactApexChart from 'react-apexcharts';
import { type DailyStatsProps } from '../../../analytics/stats';
import { ApexOptions } from "apexcharts";
import { useEffect, useMemo, useState } from "react";
import ReactApexChart from "react-apexcharts";
import { type DailyStatsProps } from "../../../analytics/stats";
const options: ApexOptions = {
legend: {
show: false,
position: 'top',
horizontalAlign: 'left',
position: "top",
horizontalAlign: "left",
},
colors: ['#3C50E0', '#80CAEE'],
colors: ["#3C50E0", "#80CAEE"],
chart: {
fontFamily: 'Satoshi, sans-serif',
fontFamily: "Satoshi, sans-serif",
height: 335,
type: 'area',
type: "area",
dropShadow: {
enabled: true,
color: '#623CEA14',
color: "#623CEA14",
top: 10,
blur: 4,
left: 0,
@@ -47,7 +47,7 @@ const options: ApexOptions = {
],
stroke: {
width: [2, 2],
curve: 'straight',
curve: "straight",
},
// labels: {
// show: false,
@@ -70,8 +70,8 @@ const options: ApexOptions = {
},
markers: {
size: 4,
colors: '#fff',
strokeColors: ['#3056D3', '#80CAEE'],
colors: "#fff",
strokeColors: ["#3056D3", "#80CAEE"],
strokeWidth: 3,
strokeOpacity: 0.9,
strokeDashArray: 0,
@@ -83,7 +83,7 @@ const options: ApexOptions = {
},
},
xaxis: {
type: 'category',
type: "category",
axisBorder: {
show: false,
},
@@ -94,7 +94,7 @@ const options: ApexOptions = {
yaxis: {
title: {
style: {
fontSize: '0px',
fontSize: "0px",
},
},
min: 0,
@@ -123,8 +123,8 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
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(' ');
const dateArr = stat.date.toString().split(" ");
return dateArr.slice(0, 3).join(" ");
});
return datesArr;
}
@@ -133,7 +133,7 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
const [state, setState] = useState<ChartOneState>({
series: [
{
name: 'Profit',
name: "Profit",
data: [4, 7, 10, 11, 13, 14, 17],
},
],
@@ -144,7 +144,9 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
if (dailyRevenueArray && dailyRevenueArray.length > 0) {
setState((prevState) => {
// Check if a "Revenue" series already exists
const existingSeriesIndex = prevState.series.findIndex((series) => series.name === 'Revenue');
const existingSeriesIndex = prevState.series.findIndex(
(series) => series.name === "Revenue",
);
if (existingSeriesIndex >= 0) {
// Update existing "Revenue" series data
@@ -164,7 +166,7 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
series: [
...prevState.series,
{
name: 'Revenue',
name: "Revenue",
data: dailyRevenueArray,
},
],
@@ -198,37 +200,41 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
}, [daysOfWeekArr, dailyRevenueArray]);
return (
<div className='col-span-12 rounded-sm border border-border bg-card px-5 pt-7.5 pb-5 shadow-default sm:px-7.5 xl:col-span-8'>
<div className='flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap'>
<div className='flex w-full flex-wrap gap-3 sm:gap-5'>
<div className='flex min-w-47.5'>
<span className='mt-1 mr-2 flex h-4 w-full max-w-4 items-center justify-center rounded-full border border-primary'>
<span className='block h-2.5 w-full max-w-2.5 rounded-full bg-primary'></span>
<div className="border-border bg-card pt-7.5 shadow-default sm:px-7.5 col-span-12 rounded-sm border px-5 pb-5 xl:col-span-8">
<div className="flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap">
<div className="flex w-full flex-wrap gap-3 sm:gap-5">
<div className="min-w-47.5 flex">
<span className="border-primary mr-2 mt-1 flex h-4 w-full max-w-4 items-center justify-center rounded-full border">
<span className="bg-primary block h-2.5 w-full max-w-2.5 rounded-full"></span>
</span>
<div className='w-full'>
<p className='font-semibold text-primary'>Total Profit</p>
<p className='text-sm font-medium text-muted-foreground'>Last 7 Days</p>
<div className="w-full">
<p className="text-primary font-semibold">Total Profit</p>
<p className="text-muted-foreground text-sm font-medium">
Last 7 Days
</p>
</div>
</div>
<div className='flex min-w-47.5'>
<span className='mt-1 mr-2 flex h-4 w-full max-w-4 items-center justify-center rounded-full border border-secondary'>
<span className='block h-2.5 w-full max-w-2.5 rounded-full bg-secondary'></span>
<div className="min-w-47.5 flex">
<span className="border-secondary mr-2 mt-1 flex h-4 w-full max-w-4 items-center justify-center rounded-full border">
<span className="bg-secondary block h-2.5 w-full max-w-2.5 rounded-full"></span>
</span>
<div className='w-full'>
<p className='font-semibold text-secondary'>Total Revenue</p>
<p className='text-sm font-medium text-muted-foreground'>Last 7 Days</p>
<div className="w-full">
<p className="text-secondary font-semibold">Total Revenue</p>
<p className="text-muted-foreground text-sm font-medium">
Last 7 Days
</p>
</div>
</div>
</div>
<div className='flex w-full max-w-45 justify-end'>
<div className='inline-flex items-center rounded-md bg-muted p-1.5'>
<button className='rounded bg-background py-1 px-3 text-xs font-medium text-foreground shadow-card hover:bg-background hover:shadow-card'>
<div className="max-w-45 flex w-full justify-end">
<div className="bg-muted inline-flex items-center rounded-md p-1.5">
<button className="bg-background text-foreground shadow-card hover:bg-background hover:shadow-card rounded px-3 py-1 text-xs font-medium">
Day
</button>
<button className='rounded py-1 px-3 text-xs font-medium text-muted-foreground hover:bg-background hover:shadow-card'>
<button className="text-muted-foreground hover:bg-background hover:shadow-card rounded px-3 py-1 text-xs font-medium">
Week
</button>
<button className='rounded py-1 px-3 text-xs font-medium text-muted-foreground hover:bg-background hover:shadow-card'>
<button className="text-muted-foreground hover:bg-background hover:shadow-card rounded px-3 py-1 text-xs font-medium">
Month
</button>
</div>
@@ -236,8 +242,13 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
</div>
<div>
<div id='chartOne' className='-ml-5'>
<ReactApexChart options={chartOptions} series={state.series} type='area' height={350} />
<div id="chartOne" className="-ml-5">
<ReactApexChart
options={chartOptions}
series={state.series}
type="area"
height={350}
/>
</div>
</div>
</div>

View File

@@ -1,42 +1,54 @@
import { type PageViewSource } from 'wasp/entities';
import { type PageViewSource } from "wasp/entities";
const SourcesTable = ({ sources }: { sources: PageViewSource[] | undefined }) => {
const SourcesTable = ({
sources,
}: {
sources: PageViewSource[] | undefined;
}) => {
return (
<div className='rounded-sm border border-border bg-card px-5 pt-6 pb-2.5 shadow-default sm:px-7.5 xl:pb-1'>
<h4 className='mb-6 text-xl font-semibold text-foreground'>Top Sources</h4>
<div className="border-border bg-card shadow-default sm:px-7.5 rounded-sm border px-5 pb-2.5 pt-6 xl:pb-1">
<h4 className="text-foreground mb-6 text-xl font-semibold">
Top Sources
</h4>
<div className='flex flex-col'>
<div className='grid grid-cols-3 rounded-sm bg-gray-2 '>
<div className='p-2.5 xl:p-5'>
<h5 className='text-sm font-medium uppercase xsm:text-base'>Source</h5>
<div className="flex flex-col">
<div className="bg-gray-2 grid grid-cols-3 rounded-sm">
<div className="p-2.5 xl:p-5">
<h5 className="xsm:text-base text-sm font-medium uppercase">
Source
</h5>
</div>
<div className='p-2.5 text-center xl:p-5'>
<h5 className='text-sm font-medium uppercase xsm:text-base'>Visitors</h5>
<div className="p-2.5 text-center xl:p-5">
<h5 className="xsm:text-base text-sm font-medium uppercase">
Visitors
</h5>
</div>
<div className='hidden p-2.5 text-center sm:block xl:p-5'>
<h5 className='text-sm font-medium uppercase xsm:text-base'>Sales</h5>
<div className="hidden p-2.5 text-center sm:block xl:p-5">
<h5 className="xsm:text-base text-sm font-medium uppercase">
Sales
</h5>
</div>
</div>
{sources && sources.length > 0 ? (
sources.map((source) => (
<div className='grid grid-cols-3 border-b border-border'>
<div className='flex items-center gap-3 p-2.5 xl:p-5'>
<p className='text-foreground'>{source.name}</p>
<div className="border-border grid grid-cols-3 border-b">
<div className="flex items-center gap-3 p-2.5 xl:p-5">
<p className="text-foreground">{source.name}</p>
</div>
<div className='flex items-center justify-center p-2.5 xl:p-5'>
<p className='text-foreground'>{source.visitors}</p>
<div className="flex items-center justify-center p-2.5 xl:p-5">
<p className="text-foreground">{source.visitors}</p>
</div>
<div className='hidden items-center justify-center p-2.5 sm:flex xl:p-5'>
<p className='text-foreground'>--</p>
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
<p className="text-foreground">--</p>
</div>
</div>
))
) : (
<div className='flex items-center justify-center p-2.5 xl:p-5'>
<p className='text-foreground'>No data to display</p>
<div className="flex items-center justify-center p-2.5 xl:p-5">
<p className="text-foreground">No data to display</p>
</div>
)}
</div>

View File

@@ -1,42 +1,57 @@
import { ArrowDown, ArrowUp, Eye } from 'lucide-react';
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
import { cn } from '../../../lib/utils';
import { ArrowDown, ArrowUp, Eye } from "lucide-react";
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
import { cn } from "../../../lib/utils";
type PageViewsStats = {
totalPageViews: number | undefined;
prevDayViewsChangePercent: string | undefined;
};
const TotalPageViewsCard = ({ totalPageViews, prevDayViewsChangePercent }: PageViewsStats) => {
const prevDayViewsChangePercentValue = parseInt(prevDayViewsChangePercent || '');
const TotalPageViewsCard = ({
totalPageViews,
prevDayViewsChangePercent,
}: PageViewsStats) => {
const prevDayViewsChangePercentValue = parseInt(
prevDayViewsChangePercent || "",
);
const isDeltaPositive = prevDayViewsChangePercentValue > 0;
return (
<Card>
<CardHeader>
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
<Eye className='size-6' />
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
<Eye className="size-6" />
</div>
</CardHeader>
<CardContent className='flex justify-between'>
<CardContent className="flex justify-between">
<div>
<h4 className='text-title-md font-bold text-foreground'>{totalPageViews}</h4>
<span className='text-sm font-medium text-muted-foreground'>Total page views</span>
<h4 className="text-title-md text-foreground font-bold">
{totalPageViews}
</h4>
<span className="text-muted-foreground text-sm font-medium">
Total page views
</span>
</div>
<span
className={cn('flex items-center gap-1 text-sm font-medium', {
'text-success':
isDeltaPositive && prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0,
'text-destructive':
!isDeltaPositive && prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0,
'text-muted-foreground': !prevDayViewsChangePercent || prevDayViewsChangePercentValue === 0,
className={cn("flex items-center gap-1 text-sm font-medium", {
"text-success":
isDeltaPositive &&
prevDayViewsChangePercent &&
prevDayViewsChangePercentValue !== 0,
"text-destructive":
!isDeltaPositive &&
prevDayViewsChangePercent &&
prevDayViewsChangePercentValue !== 0,
"text-muted-foreground":
!prevDayViewsChangePercent ||
prevDayViewsChangePercentValue === 0,
})}
>
{prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0
? `${prevDayViewsChangePercent}%`
: '-'}
: "-"}
{prevDayViewsChangePercent &&
prevDayViewsChangePercentValue !== 0 &&
(isDeltaPositive ? <ArrowUp /> : <ArrowDown />)}

View File

@@ -1,8 +1,8 @@
import { ArrowDown, ArrowUp, ShoppingBag } from 'lucide-react';
import { useMemo } from 'react';
import { type DailyStatsProps } from '../../../analytics/stats';
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
import { cn } from '../../../lib/utils';
import { ArrowDown, ArrowUp, ShoppingBag } from "lucide-react";
import { useMemo } from "react";
import { type DailyStatsProps } from "../../../analytics/stats";
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
import { cn } from "../../../lib/utils";
const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
const isDeltaPositive = useMemo(() => {
@@ -12,25 +12,30 @@ const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
return (
<Card>
<CardHeader>
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
<ShoppingBag className='size-6' />
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
<ShoppingBag className="size-6" />
</div>
</CardHeader>
<CardContent className='flex justify-between'>
<CardContent className="flex justify-between">
<div>
<h4 className='text-title-md font-bold text-foreground'>{dailyStats?.paidUserCount}</h4>
<span className='text-sm font-medium text-muted-foreground'>Total Paying Users</span>
<h4 className="text-title-md text-foreground font-bold">
{dailyStats?.paidUserCount}
</h4>
<span className="text-muted-foreground text-sm font-medium">
Total Paying Users
</span>
</div>
<span
className={cn('flex items-center gap-1 text-sm font-medium', {
'text-success': isDeltaPositive && !isLoading,
'text-destructive': !isDeltaPositive && !isLoading && dailyStats?.paidUserDelta !== 0,
'text-muted-foreground': isLoading || !dailyStats?.paidUserDelta,
className={cn("flex items-center gap-1 text-sm font-medium", {
"text-success": isDeltaPositive && !isLoading,
"text-destructive":
!isDeltaPositive && !isLoading && dailyStats?.paidUserDelta !== 0,
"text-muted-foreground": isLoading || !dailyStats?.paidUserDelta,
})}
>
{isLoading ? '...' : dailyStats?.paidUserDelta ?? '-'}
{isLoading ? "..." : (dailyStats?.paidUserDelta ?? "-")}
{!isLoading &&
(dailyStats?.paidUserDelta ?? 0) !== 0 &&
(isDeltaPositive ? <ArrowUp /> : <ArrowDown />)}

View File

@@ -1,10 +1,14 @@
import { ArrowDown, ArrowUp, ShoppingCart } from 'lucide-react';
import { useMemo } from 'react';
import { type DailyStatsProps } from '../../../analytics/stats';
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
import { cn } from '../../../lib/utils';
import { ArrowDown, ArrowUp, ShoppingCart } from "lucide-react";
import { useMemo } from "react";
import { type DailyStatsProps } from "../../../analytics/stats";
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
import { cn } from "../../../lib/utils";
const TotalRevenueCard = ({ dailyStats, weeklyStats, isLoading }: DailyStatsProps) => {
const TotalRevenueCard = ({
dailyStats,
weeklyStats,
isLoading,
}: DailyStatsProps) => {
const isDeltaPositive = useMemo(() => {
if (!weeklyStats) return false;
return weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue > 0;
@@ -12,37 +16,54 @@ const TotalRevenueCard = ({ dailyStats, weeklyStats, isLoading }: DailyStatsProp
const deltaPercentage = useMemo(() => {
if (!weeklyStats || weeklyStats.length < 2 || isLoading) return;
if (weeklyStats[1]?.totalRevenue === 0 || weeklyStats[0]?.totalRevenue === 0) return 0;
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;
((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) /
weeklyStats[1]?.totalRevenue) *
100;
return Math.floor(percentage);
}, [weeklyStats]);
return (
<Card>
<CardHeader>
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
<ShoppingCart className='size-6' />
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
<ShoppingCart className="size-6" />
</div>
</CardHeader>
<CardContent className='flex justify-between'>
<CardContent className="flex justify-between">
<div>
<h4 className='text-title-md font-bold text-foreground'>${dailyStats?.totalRevenue}</h4>
<span className='text-sm font-medium text-muted-foreground'>Total Revenue</span>
<h4 className="text-title-md text-foreground font-bold">
${dailyStats?.totalRevenue}
</h4>
<span className="text-muted-foreground text-sm font-medium">
Total Revenue
</span>
</div>
<span
className={cn('flex items-center gap-1 text-sm font-medium', {
'text-success': isDeltaPositive && !isLoading && deltaPercentage !== 0,
'text-destructive': !isDeltaPositive && !isLoading && deltaPercentage !== 0,
'text-muted-foreground': isLoading || !deltaPercentage || deltaPercentage === 0,
className={cn("flex items-center gap-1 text-sm font-medium", {
"text-success":
isDeltaPositive && !isLoading && deltaPercentage !== 0,
"text-destructive":
!isDeltaPositive && !isLoading && deltaPercentage !== 0,
"text-muted-foreground":
isLoading || !deltaPercentage || deltaPercentage === 0,
})}
>
{isLoading ? '...' : deltaPercentage && deltaPercentage !== 0 ? `${deltaPercentage}%` : '-'}
{isLoading
? "..."
: deltaPercentage && deltaPercentage !== 0
? `${deltaPercentage}%`
: "-"}
{!isLoading &&
deltaPercentage &&
deltaPercentage !== 0 &&

View File

@@ -1,8 +1,8 @@
import { ArrowUp, UsersRound } from 'lucide-react';
import { useMemo } from 'react';
import { type DailyStatsProps } from '../../../analytics/stats';
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
import { cn } from '../../../lib/utils';
import { ArrowUp, UsersRound } from "lucide-react";
import { useMemo } from "react";
import { type DailyStatsProps } from "../../../analytics/stats";
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
import { cn } from "../../../lib/utils";
const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
const isDeltaPositive = useMemo(() => {
@@ -12,25 +12,30 @@ const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
return (
<Card>
<CardHeader>
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
<UsersRound className='size-6' />
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
<UsersRound className="size-6" />
</div>
</CardHeader>
<CardContent className='flex justify-between'>
<CardContent className="flex justify-between">
<div>
<h4 className='text-title-md font-bold text-foreground'>{dailyStats?.userCount}</h4>
<span className='text-sm font-medium text-muted-foreground'>Total Signups</span>
<h4 className="text-title-md text-foreground font-bold">
{dailyStats?.userCount}
</h4>
<span className="text-muted-foreground text-sm font-medium">
Total Signups
</span>
</div>
<span
className={cn('flex items-center gap-1 text-sm font-medium', {
'text-success': isDeltaPositive && !isLoading,
'text-destructive': !isDeltaPositive && !isLoading && dailyStats?.userDelta !== 0,
'text-muted-foreground': isLoading || !dailyStats?.userDelta,
className={cn("flex items-center gap-1 text-sm font-medium", {
"text-success": isDeltaPositive && !isLoading,
"text-destructive":
!isDeltaPositive && !isLoading && dailyStats?.userDelta !== 0,
"text-muted-foreground": isLoading || !dailyStats?.userDelta,
})}
>
{isLoading ? '...' : dailyStats?.userDelta ?? '-'}
{isLoading ? "..." : (dailyStats?.userDelta ?? "-")}
{!isLoading && (dailyStats?.userDelta ?? 0) > 0 && <ArrowUp />}
</span>
</CardContent>

View File

@@ -1,18 +1,18 @@
import { MessageCircleMore } from 'lucide-react';
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
import { MessageCircleMore } from "lucide-react";
import { Link as WaspRouterLink, routes } from "wasp/client/router";
const MessageButton = () => {
return (
<li className='relative' x-data='{ dropdownOpen: false, notifying: true }'>
<li className="relative" x-data="{ dropdownOpen: false, notifying: true }">
<WaspRouterLink
className='relative flex h-8.5 w-8.5 items-center justify-center rounded-full border-[0.5px] border-stroke bg-gray hover:text-primary dark:border-strokedark dark:bg-meta-4 dark:text-white'
className="h-8.5 w-8.5 border-stroke bg-gray hover:text-primary dark:border-strokedark dark:bg-meta-4 relative flex items-center justify-center rounded-full border-[0.5px] dark:text-white"
to={routes.AdminMessagesRoute.to}
>
<span className='absolute -top-0.5 -right-0.5 z-1 h-2 w-2 rounded-full bg-meta-1'>
<span className="z-1 bg-meta-1 absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full">
{/* TODO: only animate if there are new messages */}
<span className='absolute -z-1 inline-flex h-full w-full animate-ping rounded-full bg-meta-1 opacity-75'></span>
<span className="-z-1 bg-meta-1 absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
</span>
<MessageCircleMore className='size-5' />
<MessageCircleMore className="size-5" />
</WaspRouterLink>
</li>
);

View File

@@ -1,13 +1,13 @@
// TODO: Add messages page
import type { AuthUser } from "wasp/auth"
import DefaultLayout from "../../layout/DefaultLayout"
import type { AuthUser } from "wasp/auth";
import DefaultLayout from "../../layout/DefaultLayout";
function AdminMessages({user} : {user: AuthUser}) {
function AdminMessages({ user }: { user: AuthUser }) {
return (
<DefaultLayout user={user}>
<div>This page is under construction 🚧</div>
</DefaultLayout>
)
);
}
export default AdminMessages
export default AdminMessages;

View File

@@ -1,26 +1,26 @@
import { Ellipsis, SquarePen, Trash2 } from 'lucide-react';
import { Ellipsis, SquarePen, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../../../components/ui/dropdown-menu';
} from "../../../components/ui/dropdown-menu";
const DropdownEditDelete = () => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button>
<Ellipsis className='size-4' />
<Ellipsis className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-40'>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem>
<SquarePen className='size-4 mr-2' />
<SquarePen className="mr-2 size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem>
<Trash2 className='size-4 mr-2' />
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,13 +1,13 @@
import { type AuthUser } from 'wasp/auth';
import UsersTable from './UsersTable';
import Breadcrumb from '../../layout/Breadcrumb';
import DefaultLayout from '../../layout/DefaultLayout';
import { type AuthUser } from "wasp/auth";
import Breadcrumb from "../../layout/Breadcrumb";
import DefaultLayout from "../../layout/DefaultLayout";
import UsersTable from "./UsersTable";
const Users = ({ user }: { user: AuthUser }) => {
return (
<DefaultLayout user={user}>
<Breadcrumb pageName='Users' />
<div className='flex flex-col gap-10'>
<Breadcrumb pageName="Users" />
<div className="flex flex-col gap-10">
<UsersTable />
</div>
</DefaultLayout>

View File

@@ -1,27 +1,39 @@
import { X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useAuth } from 'wasp/client/auth';
import { getPaginatedUsers, updateIsUserAdminById, useQuery } from 'wasp/client/operations';
import { type User } from 'wasp/entities';
import useDebounce from '../../../client/hooks/useDebounce';
import { Button } from '../../../components/ui/button';
import { Checkbox } from '../../../components/ui/checkbox';
import { Input } from '../../../components/ui/input';
import { Label } from '../../../components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../components/ui/select';
import { Switch } from '../../../components/ui/switch';
import { SubscriptionStatus } from '../../../payment/plans';
import LoadingSpinner from '../../layout/LoadingSpinner';
import DropdownEditDelete from './DropdownEditDelete';
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "wasp/client/auth";
import {
getPaginatedUsers,
updateIsUserAdminById,
useQuery,
} from "wasp/client/operations";
import { type User } from "wasp/entities";
import useDebounce from "../../../client/hooks/useDebounce";
import { Button } from "../../../components/ui/button";
import { Checkbox } from "../../../components/ui/checkbox";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../components/ui/select";
import { Switch } from "../../../components/ui/switch";
import { SubscriptionStatus } from "../../../payment/plans";
import LoadingSpinner from "../../layout/LoadingSpinner";
import DropdownEditDelete from "./DropdownEditDelete";
function AdminSwitch({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) {
function AdminSwitch({ id, isAdmin }: Pick<User, "id" | "isAdmin">) {
const { data: currentUser } = useAuth();
const isCurrentUser = currentUser?.id === id;
return (
<Switch
checked={isAdmin}
onCheckedChange={(value) => updateIsUserAdminById({ id: id, isAdmin: value })}
onCheckedChange={(value) =>
updateIsUserAdminById({ id: id, isAdmin: value })
}
disabled={isCurrentUser}
/>
);
@@ -30,10 +42,12 @@ function AdminSwitch({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) {
const UsersTable = () => {
const [currentPage, setCurrentPage] = useState(1);
const [emailFilter, setEmailFilter] = useState<string | undefined>(undefined);
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
const [subscriptionStatusFilter, setSubscriptionStatusFilter] = useState<Array<SubscriptionStatus | null>>(
[]
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(
undefined,
);
const [subscriptionStatusFilter, setSubscriptionStatusFilter] = useState<
Array<SubscriptionStatus | null>
>([]);
const debouncedEmailFilter = useDebounce(emailFilter, 300);
@@ -44,7 +58,9 @@ const UsersTable = () => {
filter: {
...(debouncedEmailFilter && { emailContains: debouncedEmailFilter }),
...(isAdminFilter !== undefined && { isAdmin: isAdminFilter }),
...(subscriptionStatusFilter.length > 0 && { subscriptionStatusIn: subscriptionStatusFilter }),
...(subscriptionStatusFilter.length > 0 && {
subscriptionStatusIn: subscriptionStatusFilter,
}),
},
});
@@ -52,7 +68,7 @@ const UsersTable = () => {
function backToPageOne() {
setCurrentPage(1);
},
[debouncedEmailFilter, subscriptionStatusFilter, isAdminFilter]
[debouncedEmailFilter, subscriptionStatusFilter, isAdminFilter],
);
const handleStatusToggle = (status: SubscriptionStatus | null) => {
@@ -69,85 +85,99 @@ const UsersTable = () => {
setSubscriptionStatusFilter([]);
};
const hasActiveFilters = subscriptionStatusFilter && subscriptionStatusFilter.length > 0;
const hasActiveFilters =
subscriptionStatusFilter && subscriptionStatusFilter.length > 0;
return (
<div className='flex flex-col gap-4'>
<div className='rounded-sm border border-border bg-card shadow'>
<div className='flex-col flex items-start justify-between p-6 gap-3 w-full bg-muted/40'>
<span className='text-sm font-medium'>Filters:</span>
<div className='flex items-center justify-between gap-3 w-full px-2'>
<div className='relative flex items-center gap-3 '>
<Label htmlFor='email-filter' className='text-sm text-muted-foreground'>
<div className="flex flex-col gap-4">
<div className="border-border bg-card rounded-sm border shadow">
<div className="bg-muted/40 flex w-full flex-col items-start justify-between gap-3 p-6">
<span className="text-sm font-medium">Filters:</span>
<div className="flex w-full items-center justify-between gap-3 px-2">
<div className="relative flex items-center gap-3">
<Label
htmlFor="email-filter"
className="text-muted-foreground text-sm"
>
email:
</Label>
<Input
type='text'
id='email-filter'
placeholder='dude@example.com'
type="text"
id="email-filter"
placeholder="dude@example.com"
onChange={(e) => {
const value = e.currentTarget.value;
setEmailFilter(value === '' ? undefined : value);
setEmailFilter(value === "" ? undefined : value);
}}
/>
<Label htmlFor='status-filter' className='text-sm ml-2 text-muted-foreground'>
<Label
htmlFor="status-filter"
className="text-muted-foreground ml-2 text-sm"
>
status:
</Label>
<div className='relative'>
<div className="relative">
<Select>
<SelectTrigger className='w-full min-w-[200px]'>
<SelectValue placeholder='Select Status Filter' />
<SelectTrigger className="w-full min-w-[200px]">
<SelectValue placeholder="Select Status Filter" />
</SelectTrigger>
<SelectContent className='w-[300px]'>
<div className='p-2'>
<div className='flex items-center justify-between mb-2'>
<span className='text-sm font-medium'>Subscription Status</span>
<SelectContent className="w-[300px]">
<div className="p-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">
Subscription Status
</span>
{subscriptionStatusFilter.length > 0 && (
<button
onClick={clearAllStatusFilters}
className='text-xs text-muted-foreground hover:text-foreground'
className="text-muted-foreground hover:text-foreground text-xs"
>
Clear all
</button>
)}
</div>
<div className='space-y-2'>
<div className='flex items-center space-x-2'>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id='all-statuses'
id="all-statuses"
checked={subscriptionStatusFilter.length === 0}
onCheckedChange={() => clearAllStatusFilters()}
/>
<Label
htmlFor='all-statuses'
className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
htmlFor="all-statuses"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
All Statuses
</Label>
</div>
<div className='flex items-center space-x-2'>
<div className="flex items-center space-x-2">
<Checkbox
id='has-not-subscribed'
id="has-not-subscribed"
checked={subscriptionStatusFilter.includes(null)}
onCheckedChange={() => handleStatusToggle(null)}
/>
<Label
htmlFor='has-not-subscribed'
className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
htmlFor="has-not-subscribed"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Has Not Subscribed
</Label>
</div>
{Object.values(SubscriptionStatus).map((status) => (
<div key={status} className='flex items-center space-x-2'>
<div
key={status}
className="flex items-center space-x-2"
>
<Checkbox
id={status}
checked={subscriptionStatusFilter.includes(status)}
checked={subscriptionStatusFilter.includes(
status,
)}
onCheckedChange={() => handleStatusToggle(status)}
/>
<Label
htmlFor={status}
className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{status}
</Label>
@@ -158,63 +188,75 @@ const UsersTable = () => {
</SelectContent>
</Select>
</div>
<div className='flex items-center gap-2'>
<Label htmlFor='admin-filter' className='text-sm ml-2 text-muted-foreground'>
<div className="flex items-center gap-2">
<Label
htmlFor="admin-filter"
className="text-muted-foreground ml-2 text-sm"
>
isAdmin:
</Label>
<Select
onValueChange={(value) => {
if (value === 'both') {
if (value === "both") {
setIsAdminFilter(undefined);
} else {
setIsAdminFilter(value === 'true');
setIsAdminFilter(value === "true");
}
}}
>
<SelectTrigger className='w-full'>
<SelectValue placeholder='both' />
<SelectTrigger className="w-full">
<SelectValue placeholder="both" />
</SelectTrigger>
<SelectContent>
<SelectItem value='both'>both</SelectItem>
<SelectItem value='true'>true</SelectItem>
<SelectItem value='false'>false</SelectItem>
<SelectItem value="both">both</SelectItem>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{data?.totalPages && (
<div className='max-w-60 flex flex-row items-center'>
<span className='text-md mr-2 text-foreground'>page</span>
<div className="flex max-w-60 flex-row items-center">
<span className="text-md text-foreground mr-2">page</span>
<Input
type='number'
type="number"
min={1}
defaultValue={currentPage}
max={data?.totalPages}
onChange={(e) => {
const value = parseInt(e.currentTarget.value);
if (data?.totalPages && value <= data?.totalPages && value > 0) {
if (
data?.totalPages &&
value <= data?.totalPages &&
value > 0
) {
setCurrentPage(value);
}
}}
className='w-20'
className="w-20"
/>
<span className='text-md text-foreground'> /{data?.totalPages} </span>
<span className="text-md text-foreground">
{" "}
/{data?.totalPages}{" "}
</span>
</div>
)}
</div>
{hasActiveFilters && (
<div className='flex items-center gap-2 px-2 pt-2 border-border'>
<span className='text-sm font-medium text-muted-foreground'>Active Filters:</span>
<div className='flex flex-wrap gap-2'>
<div className="border-border flex items-center gap-2 px-2 pt-2">
<span className="text-muted-foreground text-sm font-medium">
Active Filters:
</span>
<div className="flex flex-wrap gap-2">
{subscriptionStatusFilter.map((status) => (
<Button
key={status ?? 'null'}
variant='outline'
size='sm'
key={status ?? "null"}
variant="outline"
size="sm"
onClick={() => handleStatusToggle(status)}
>
<X className='w-3 h-3 mr-1' />
{status ?? 'Has Not Subscribed'}
<X className="mr-1 h-3 w-3" />
{status ?? "Has Not Subscribed"}
</Button>
))}
</div>
@@ -222,46 +264,53 @@ const UsersTable = () => {
)}
</div>
<div className='grid grid-cols-9 border-t-4 border-border py-4.5 px-4 md:px-6 '>
<div className='col-span-3 flex items-center'>
<p className='font-medium'>Email / Username</p>
<div className="border-border py-4.5 grid grid-cols-9 border-t-4 px-4 md:px-6">
<div className="col-span-3 flex items-center">
<p className="font-medium">Email / Username</p>
</div>
<div className='col-span-2 flex items-center'>
<p className='font-medium'>Subscription Status</p>
<div className="col-span-2 flex items-center">
<p className="font-medium">Subscription Status</p>
</div>
<div className='col-span-2 flex items-center'>
<p className='font-medium'>Stripe ID</p>
<div className="col-span-2 flex items-center">
<p className="font-medium">Stripe ID</p>
</div>
<div className='col-span-1 flex items-center'>
<p className='font-medium'>Is Admin</p>
<div className="col-span-1 flex items-center">
<p className="font-medium">Is Admin</p>
</div>
<div className='col-span-1 flex items-center'>
<p className='font-medium'></p>
<div className="col-span-1 flex items-center">
<p className="font-medium"></p>
</div>
</div>
{isLoading && <LoadingSpinner />}
{!!data?.users &&
data?.users?.length > 0 &&
data.users.map((user) => (
<div key={user.id} className='grid grid-cols-9 gap-4 py-4.5 px-4 md:px-6 '>
<div className='col-span-3 flex items-center'>
<div className='flex flex-col gap-1 '>
<p className='text-sm text-foreground'>{user.email}</p>
<p className='text-sm text-foreground'>{user.username}</p>
<div
key={user.id}
className="py-4.5 grid grid-cols-9 gap-4 px-4 md:px-6"
>
<div className="col-span-3 flex items-center">
<div className="flex flex-col gap-1">
<p className="text-foreground text-sm">{user.email}</p>
<p className="text-foreground text-sm">{user.username}</p>
</div>
</div>
<div className='col-span-2 flex items-center'>
<p className='text-sm text-foreground'>{user.subscriptionStatus}</p>
<div className="col-span-2 flex items-center">
<p className="text-foreground text-sm">
{user.subscriptionStatus}
</p>
</div>
<div className='col-span-2 flex items-center'>
<p className='text-sm text-muted-foreground'>{user.paymentProcessorUserId}</p>
<div className="col-span-2 flex items-center">
<p className="text-muted-foreground text-sm">
{user.paymentProcessorUserId}
</p>
</div>
<div className='col-span-1 flex items-center'>
<div className='text-sm text-foreground'>
<div className="col-span-1 flex items-center">
<div className="text-foreground text-sm">
<AdminSwitch {...user} />
</div>
</div>
<div className='col-span-1 flex items-center'>
<div className="col-span-1 flex items-center">
<DropdownEditDelete />
</div>
</div>

View File

@@ -1,179 +1,191 @@
import { type AuthUser } from 'wasp/auth';
import Breadcrumb from '../../layout/Breadcrumb';
import DefaultLayout from '../../layout/DefaultLayout';
import { type AuthUser } from "wasp/auth";
import Breadcrumb from "../../layout/Breadcrumb";
import DefaultLayout from "../../layout/DefaultLayout";
const Calendar = ({ user }: { user: AuthUser }) => {
return (
<DefaultLayout user={user}>
<Breadcrumb pageName='Calendar' />
<div className='w-full max-w-full rounded-sm border border-border bg-card shadow-default'>
<table className='w-full'>
<Breadcrumb pageName="Calendar" />
<div className="border-border bg-card shadow-default w-full max-w-full rounded-sm border">
<table className="w-full">
<thead>
<tr className='grid grid-cols-7 rounded-t-sm bg-primary text-primary-foreground'>
<th className='flex h-15 items-center justify-center rounded-tl-sm p-1 text-xs font-semibold sm:text-base xl:p-5'>
<span className='hidden lg:block'> Sunday </span>
<span className='block lg:hidden'> Sun </span>
<tr className="bg-primary text-primary-foreground grid grid-cols-7 rounded-t-sm">
<th className="h-15 flex items-center justify-center rounded-tl-sm p-1 text-xs font-semibold sm:text-base xl:p-5">
<span className="hidden lg:block"> Sunday </span>
<span className="block lg:hidden"> Sun </span>
</th>
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
<span className='hidden lg:block'> Monday </span>
<span className='block lg:hidden'> Mon </span>
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
<span className="hidden lg:block"> Monday </span>
<span className="block lg:hidden"> Mon </span>
</th>
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
<span className='hidden lg:block'> Tuesday </span>
<span className='block lg:hidden'> Tue </span>
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
<span className="hidden lg:block"> Tuesday </span>
<span className="block lg:hidden"> Tue </span>
</th>
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
<span className='hidden lg:block'> Wednesday </span>
<span className='block lg:hidden'> Wed </span>
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
<span className="hidden lg:block"> Wednesday </span>
<span className="block lg:hidden"> Wed </span>
</th>
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
<span className='hidden lg:block'> Thursday </span>
<span className='block lg:hidden'> Thur </span>
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
<span className="hidden lg:block"> Thursday </span>
<span className="block lg:hidden"> Thur </span>
</th>
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
<span className='hidden lg:block'> Friday </span>
<span className='block lg:hidden'> Fri </span>
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
<span className="hidden lg:block"> Friday </span>
<span className="block lg:hidden"> Fri </span>
</th>
<th className='flex h-15 items-center justify-center rounded-tr-sm p-1 text-xs font-semibold sm:text-base xl:p-5'>
<span className='hidden lg:block'> Saturday </span>
<span className='block lg:hidden'> Sat </span>
<th className="h-15 flex items-center justify-center rounded-tr-sm p-1 text-xs font-semibold sm:text-base xl:p-5">
<span className="hidden lg:block"> Saturday </span>
<span className="block lg:hidden"> Sat </span>
</th>
</tr>
</thead>
<tbody>
{/* <!-- Line 1 --> */}
<tr className='grid grid-cols-7'>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>1</span>
<div className='group h-16 w-full flex-grow cursor-pointer py-1 md:h-30'>
<span className='group-hover:text-primary md:hidden'>More</span>
<div className='event invisible absolute left-2 z-99 mb-1 flex w-[200%] flex-col rounded-sm border-l-[3px] border-primary bg-muted px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 md:visible md:w-[190%] md:opacity-100'>
<span className='event-name text-sm font-semibold text-foreground'>Redesign Website</span>
<span className='time text-sm font-medium text-foreground'>1 Dec - 2 Dec</span>
<tr className="grid grid-cols-7">
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">1</span>
<div className="md:h-30 group h-16 w-full flex-grow cursor-pointer py-1">
<span className="group-hover:text-primary md:hidden">
More
</span>
<div className="event z-99 border-primary bg-muted invisible absolute left-2 mb-1 flex w-[200%] flex-col rounded-sm border-l-[3px] px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 md:visible md:w-[190%] md:opacity-100">
<span className="event-name text-foreground text-sm font-semibold">
Redesign Website
</span>
<span className="time text-foreground text-sm font-medium">
1 Dec - 2 Dec
</span>
</div>
</div>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>2</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">2</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>3</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">3</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>4</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">4</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>5</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">5</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>6</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">6</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>7</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">7</span>
</td>
</tr>
{/* <!-- Line 1 --> */}
{/* <!-- Line 2 --> */}
<tr className='grid grid-cols-7'>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>8</span>
<tr className="grid grid-cols-7">
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">8</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>9</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">9</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>10</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">10</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>11</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">11</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>12</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">12</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>13</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">13</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>14</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">14</span>
</td>
</tr>
{/* <!-- Line 2 --> */}
<tr className='grid grid-cols-7'>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>15</span>
<tr className="grid grid-cols-7">
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">15</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>16</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">16</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>17</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">17</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>18</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">18</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>19</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">19</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>20</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">20</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>21</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">21</span>
</td>
</tr>
{/* <!-- Line 3 --> */}
<tr className='grid grid-cols-7'>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>22</span>
<tr className="grid grid-cols-7">
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">22</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>23</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">23</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>24</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">24</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>25</span>
<div className='group h-16 w-full flex-grow cursor-pointer py-1 md:h-30'>
<span className='group-hover:text-primary md:hidden'>More</span>
<div className='event invisible absolute left-2 z-99 mb-1 flex w-[300%] flex-col rounded-sm border-l-[3px] border-primary bg-muted px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 md:visible md:w-[290%] md:opacity-100'>
<span className='event-name text-sm font-semibold text-foreground'>App Design</span>
<span className='time text-sm font-medium text-foreground'>25 Dec - 27 Dec</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">25</span>
<div className="md:h-30 group h-16 w-full flex-grow cursor-pointer py-1">
<span className="group-hover:text-primary md:hidden">
More
</span>
<div className="event z-99 border-primary bg-muted invisible absolute left-2 mb-1 flex w-[300%] flex-col rounded-sm border-l-[3px] px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 md:visible md:w-[290%] md:opacity-100">
<span className="event-name text-foreground text-sm font-semibold">
App Design
</span>
<span className="time text-foreground text-sm font-medium">
25 Dec - 27 Dec
</span>
</div>
</div>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>26</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">26</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>27</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">27</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>28</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">28</span>
</td>
</tr>
{/* <!-- Line 4 --> */}
<tr className='grid grid-cols-7'>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>29</span>
<tr className="grid grid-cols-7">
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">29</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>30</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">30</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>31</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">31</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>1</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">1</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>2</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">2</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>3</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">3</span>
</td>
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
<span className='font-medium'>4</span>
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
<span className="font-medium">4</span>
</td>
</tr>
{/* <!-- Line 5 --> */}

View File

@@ -1,176 +1,202 @@
import { FileText, Mail, Upload, User } from 'lucide-react';
import { FormEvent } from 'react';
import toast from 'react-hot-toast';
import { type AuthUser } from 'wasp/auth';
import { Button } from '../../../components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../../../components/ui/card';
import { Input } from '../../../components/ui/input';
import { Label } from '../../../components/ui/label';
import { Textarea } from '../../../components/ui/textarea';
import Breadcrumb from '../../layout/Breadcrumb';
import DefaultLayout from '../../layout/DefaultLayout';
import { FileText, Mail, Upload, User } from "lucide-react";
import { FormEvent } from "react";
import toast from "react-hot-toast";
import { type AuthUser } from "wasp/auth";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import Breadcrumb from "../../layout/Breadcrumb";
import DefaultLayout from "../../layout/DefaultLayout";
const SettingsPage = ({ user }: { user: AuthUser }) => {
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
// TODO add toast provider / wrapper
event.preventDefault();
const confirmed = confirm('Are you sure you want to save the changes?');
const confirmed = confirm("Are you sure you want to save the changes?");
if (confirmed) {
toast.success('Your changes have been saved successfully!');
toast.success("Your changes have been saved successfully!");
} else {
toast.error('Your changes have not been saved!');
toast.error("Your changes have not been saved!");
}
};
return (
<DefaultLayout user={user}>
<div className='mx-auto max-w-270'>
<Breadcrumb pageName='Settings' />
<div className="max-w-270 mx-auto">
<Breadcrumb pageName="Settings" />
<div className='grid grid-cols-5 gap-8'>
<div className='col-span-5 xl:col-span-3'>
<div className="grid grid-cols-5 gap-8">
<div className="col-span-5 xl:col-span-3">
<Card>
<CardHeader>
<CardTitle>Personal Information</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<div className='mb-5.5 flex flex-col gap-5.5 sm:flex-row'>
<div className='w-full sm:w-1/2'>
<Label htmlFor='full-name' className='mb-3 block text-sm font-medium text-foreground'>
<div className="mb-5.5 gap-5.5 flex flex-col sm:flex-row">
<div className="w-full sm:w-1/2">
<Label
htmlFor="full-name"
className="text-foreground mb-3 block text-sm font-medium"
>
Full Name
</Label>
<div className='relative'>
<User className='absolute left-4.5 top-2 h-5 w-5 text-muted-foreground' />
<div className="relative">
<User className="left-4.5 text-muted-foreground absolute top-2 h-5 w-5" />
<Input
className='pl-11.5'
type='text'
name='fullName'
id='full-name'
placeholder='Devid Jhon'
defaultValue='Devid Jhon'
className="pl-11.5"
type="text"
name="fullName"
id="full-name"
placeholder="Devid Jhon"
defaultValue="Devid Jhon"
/>
</div>
</div>
<div className='w-full sm:w-1/2'>
<div className="w-full sm:w-1/2">
<Label
htmlFor='phone-number'
className='mb-3 block text-sm font-medium text-foreground'
htmlFor="phone-number"
className="text-foreground mb-3 block text-sm font-medium"
>
Phone Number
</Label>
<Input
type=''
name='phoneNumber'
id='phone-number'
placeholder='+990 3343 7865'
defaultValue='+990 3343 7865'
type=""
name="phoneNumber"
id="phone-number"
placeholder="+990 3343 7865"
defaultValue="+990 3343 7865"
/>
</div>
</div>
<div className='mb-5.5'>
<Label htmlFor='email-address' className='mb-3 block text-sm font-medium text-foreground'>
<div className="mb-5.5">
<Label
htmlFor="email-address"
className="text-foreground mb-3 block text-sm font-medium"
>
Email Address
</Label>
<div className='relative'>
<Mail className='absolute left-4.5 top-2 h-5 w-5 text-muted-foreground' />
<div className="relative">
<Mail className="left-4.5 text-muted-foreground absolute top-2 h-5 w-5" />
<Input
className='pl-11.5'
type='email'
name='emailAddress'
id='email-address'
placeholder='devidjond45@gmail.com'
defaultValue='devidjond45@gmail.com'
className="pl-11.5"
type="email"
name="emailAddress"
id="email-address"
placeholder="devidjond45@gmail.com"
defaultValue="devidjond45@gmail.com"
/>
</div>
</div>
<div className='mb-5.5'>
<Label htmlFor='username' className='mb-3 block text-sm font-medium text-foreground'>
<div className="mb-5.5">
<Label
htmlFor="username"
className="text-foreground mb-3 block text-sm font-medium"
>
Username
</Label>
<Input
type='text'
name='Username'
id='username'
placeholder='devidjhon24'
defaultValue='devidjhon24'
type="text"
name="Username"
id="username"
placeholder="devidjhon24"
defaultValue="devidjhon24"
/>
</div>
<div className='mb-5.5'>
<Label htmlFor='bio' className='mb-3 block text-sm font-medium text-foreground'>
<div className="mb-5.5">
<Label
htmlFor="bio"
className="text-foreground mb-3 block text-sm font-medium"
>
BIO
</Label>
<div className='relative'>
<FileText className='absolute left-4.5 top-4 h-5 w-5 text-muted-foreground' />
<div className="relative">
<FileText className="left-4.5 text-muted-foreground absolute top-4 h-5 w-5" />
<Textarea
className='w-full rounded border border-border bg-background py-3 pl-11.5 pr-4.5 text-foreground focus:border-primary focus-visible:outline-none'
name='bio'
id='bio'
className="border-border bg-background pl-11.5 pr-4.5 text-foreground focus:border-primary w-full rounded border py-3 focus-visible:outline-none"
name="bio"
id="bio"
rows={6}
placeholder='Write your bio here'
defaultValue='Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque posuere fermentum urna, eu condimentum mauris tempus ut. Donec fermentum blandit aliquet.'
placeholder="Write your bio here"
defaultValue="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque posuere fermentum urna, eu condimentum mauris tempus ut. Donec fermentum blandit aliquet."
></Textarea>
</div>
</div>
<div className='flex justify-end gap-4.5'>
<Button variant='outline' type='submit'>
<div className="gap-4.5 flex justify-end">
<Button variant="outline" type="submit">
Cancel
</Button>
<Button type='submit'>Save</Button>
<Button type="submit">Save</Button>
</div>
</form>
</CardContent>
</Card>
</div>
<div className='col-span-5 xl:col-span-2'>
<div className="col-span-5 xl:col-span-2">
<Card>
<CardHeader>
<CardTitle>Your Photo</CardTitle>
</CardHeader>
<CardContent>
<form action='#'>
<div className='mb-4 flex items-center gap-3'>
<div className='h-14 w-14 rounded-full'>{/* <img src={userThree} alt="User" /> */}</div>
<form action="#">
<div className="mb-4 flex items-center gap-3">
<div className="h-14 w-14 rounded-full">
{/* <img src={userThree} alt="User" /> */}
</div>
<div>
<span className='mb-1.5 text-foreground'>Edit your photo</span>
<span className='flex gap-2.5'>
<button className='text-sm hover:text-primary'>Delete</button>
<button className='text-sm hover:text-primary'>Update</button>
<span className="text-foreground mb-1.5">
Edit your photo
</span>
<span className="flex gap-2.5">
<button className="hover:text-primary text-sm">
Delete
</button>
<button className="hover:text-primary text-sm">
Update
</button>
</span>
</div>
</div>
<div
id='FileUpload'
className='relative mb-5.5 block w-full cursor-pointer appearance-none rounded border-2 border-dashed border-primary bg-background py-4 px-4 sm:py-7.5'
id="FileUpload"
className="mb-5.5 border-primary bg-background sm:py-7.5 relative block w-full cursor-pointer appearance-none rounded border-2 border-dashed px-4 py-4"
>
<input
type='file'
accept='image/*'
className='absolute inset-0 z-50 m-0 h-full w-full cursor-pointer p-0 opacity-0 outline-none'
type="file"
accept="image/*"
className="absolute inset-0 z-50 m-0 h-full w-full cursor-pointer p-0 opacity-0 outline-none"
/>
<div className='flex flex-col items-center justify-center space-y-3'>
<span className='flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background'>
<Upload className='h-4 w-4 text-primary' />
<div className="flex flex-col items-center justify-center space-y-3">
<span className="border-border bg-background flex h-10 w-10 items-center justify-center rounded-full border">
<Upload className="text-primary h-4 w-4" />
</span>
<p>
<span className='text-primary'>Click to upload</span> or drag and drop
<span className="text-primary">Click to upload</span> or
drag and drop
</p>
<p className='mt-1.5'>SVG, PNG, JPG or GIF</p>
<p className="mt-1.5">SVG, PNG, JPG or GIF</p>
<p>(max, 800 X 800px)</p>
</div>
</div>
<div className='flex justify-end gap-4.5'>
<Button variant='outline' type='submit'>
<div className="gap-4.5 flex justify-end">
<Button variant="outline" type="submit">
Cancel
</Button>
<Button type='submit'>Save</Button>
<Button type="submit">Save</Button>
</div>
</form>
</CardContent>

View File

@@ -1,44 +1,44 @@
import { Heart, Plus, Trash2 } from 'lucide-react';
import { type AuthUser } from 'wasp/auth';
import { Button } from '../../../components/ui/button';
import Breadcrumb from '../../layout/Breadcrumb';
import DefaultLayout from '../../layout/DefaultLayout';
import { Heart, Plus, Trash2 } from "lucide-react";
import { type AuthUser } from "wasp/auth";
import { Button } from "../../../components/ui/button";
import Breadcrumb from "../../layout/Breadcrumb";
import DefaultLayout from "../../layout/DefaultLayout";
const Buttons = ({ user }: { user: AuthUser }) => {
return (
<DefaultLayout user={user}>
<Breadcrumb pageName='Buttons' />
<Breadcrumb pageName="Buttons" />
{/* Button Variants */}
<div className='mb-10 rounded-sm border border-border bg-card shadow-default'>
<div className='border-b border-border px-7 py-4'>
<h3 className='font-medium text-foreground'>Button Variants</h3>
<div className="border-border bg-card shadow-default mb-10 rounded-sm border">
<div className="border-border border-b px-7 py-4">
<h3 className="text-foreground font-medium">Button Variants</h3>
</div>
<div className='p-4 md:p-6 xl:p-9'>
<div className='flex flex-wrap gap-4'>
<Button variant='default'>Default</Button>
<Button variant='outline'>Outline</Button>
<Button variant='secondary'>Secondary</Button>
<Button variant='ghost'>Ghost</Button>
<Button variant='link'>Link</Button>
<Button variant='destructive'>Destructive</Button>
<div className="p-4 md:p-6 xl:p-9">
<div className="flex flex-wrap gap-4">
<Button variant="default">Default</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
<Button variant="destructive">Destructive</Button>
</div>
</div>
</div>
{/* Button Sizes */}
<div className='mb-10 rounded-sm border border-border bg-card shadow-default'>
<div className='border-b border-border px-7 py-4'>
<h3 className='font-medium text-foreground'>Button Sizes</h3>
<div className="border-border bg-card shadow-default mb-10 rounded-sm border">
<div className="border-border border-b px-7 py-4">
<h3 className="text-foreground font-medium">Button Sizes</h3>
</div>
<div className='p-4 md:p-6 xl:p-9'>
<div className='flex flex-wrap items-center gap-4'>
<Button size='sm'>Small</Button>
<Button size='default'>Default</Button>
<Button size='lg'>Large</Button>
<Button size='icon'>
<div className="p-4 md:p-6 xl:p-9">
<div className="flex flex-wrap items-center gap-4">
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon">
<Plus />
</Button>
</div>
@@ -46,22 +46,22 @@ const Buttons = ({ user }: { user: AuthUser }) => {
</div>
{/* Button with Icon */}
<div className='mb-10 rounded-sm border border-border bg-card shadow-default'>
<div className='border-b border-border px-7 py-4'>
<h3 className='font-medium text-foreground'>Button with Icon</h3>
<div className="border-border bg-card shadow-default mb-10 rounded-sm border">
<div className="border-border border-b px-7 py-4">
<h3 className="text-foreground font-medium">Button with Icon</h3>
</div>
<div className='p-4 md:p-6 xl:p-9'>
<div className='flex flex-wrap gap-4'>
<div className="p-4 md:p-6 xl:p-9">
<div className="flex flex-wrap gap-4">
<Button>
<Plus />
Add Item
</Button>
<Button variant='outline'>
<Button variant="outline">
<Heart />
Like
</Button>
<Button variant='destructive'>
<Button variant="destructive">
<Trash2 />
Delete
</Button>

View File

@@ -1,19 +1,21 @@
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
import { Link as WaspRouterLink, routes } from "wasp/client/router";
interface BreadcrumbProps {
pageName: string;
}
const Breadcrumb = ({ pageName }: BreadcrumbProps) => {
return (
<div className='mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<h2 className='text-title-md2 font-semibold text-foreground'>{pageName}</h2>
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-title-md2 text-foreground font-semibold">
{pageName}
</h2>
<nav>
<ul className='flex items-center gap-1'>
<ul className="flex items-center gap-1">
<li>
<WaspRouterLink to={routes.AdminRoute.to}>Dashboard</WaspRouterLink>
</li>
<li>/</li>
<li className='font-medium'>{pageName}</li>
<li className="font-medium">{pageName}</li>
</ul>
</nav>
</div>

View File

@@ -1,8 +1,8 @@
import { type AuthUser } from 'wasp/auth';
import { FC, ReactNode, useState } from 'react';
import { Navigate } from 'react-router-dom';
import Header from './Header';
import Sidebar from './Sidebar';
import { FC, ReactNode, useState } from "react";
import { Navigate } from "react-router-dom";
import { type AuthUser } from "wasp/auth";
import Header from "./Header";
import Sidebar from "./Sidebar";
interface Props {
user: AuthUser;
@@ -13,17 +13,23 @@ const DefaultLayout: FC<Props> = ({ children, user }) => {
const [sidebarOpen, setSidebarOpen] = useState(false);
if (!user.isAdmin) {
return <Navigate to='/' replace />;
return <Navigate to="/" replace />;
}
return (
<div className='bg-background text-foreground'>
<div className='flex h-screen overflow-hidden'>
<div className="bg-background text-foreground">
<div className="flex h-screen overflow-hidden">
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
<div className='relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden'>
<Header sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} user={user} />
<div className="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
<Header
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
user={user}
/>
<main>
<div className='mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10'>{children}</div>
<div className="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10">
{children}
</div>
</main>
</div>
</div>

View File

@@ -1,8 +1,8 @@
import { type AuthUser } from 'wasp/auth';
import DarkModeSwitcher from '../../client/components/DarkModeSwitcher';
import { cn } from '../../lib/utils';
import MessageButton from '../dashboards/messages/MessageButton';
import { UserDropdown } from '../../user/UserDropdown';
import { type AuthUser } from "wasp/auth";
import DarkModeSwitcher from "../../client/components/DarkModeSwitcher";
import { cn } from "../../lib/utils";
import { UserDropdown } from "../../user/UserDropdown";
import MessageButton from "../dashboards/messages/MessageButton";
const Header = (props: {
sidebarOpen: string | boolean | undefined;
@@ -10,61 +10,61 @@ const Header = (props: {
user: AuthUser;
}) => {
return (
<header className='sticky top-0 z-10 flex w-full bg-background border-b border-border shadow-sm'>
<div className='flex flex-grow items-center justify-between sm:justify-end sm:gap-5 px-8 py-5'>
<div className='flex items-center gap-2 sm:gap-4 lg:hidden'>
<header className="bg-background border-border sticky top-0 z-10 flex w-full border-b shadow-sm">
<div className="flex flex-grow items-center justify-between px-8 py-5 sm:justify-end sm:gap-5">
<div className="flex items-center gap-2 sm:gap-4 lg:hidden">
{/* <!-- Hamburger Toggle BTN --> */}
<button
aria-controls='sidebar'
aria-controls="sidebar"
onClick={(e) => {
e.stopPropagation();
props.setSidebarOpen(!props.sidebarOpen);
}}
className='z-99999 block rounded-sm border border-border bg-background p-1.5 shadow-sm lg:hidden'
className="z-99999 border-border bg-background block rounded-sm border p-1.5 shadow-sm lg:hidden"
>
<span className='relative block h-5.5 w-5.5 cursor-pointer'>
<span className='du-block absolute right-0 h-full w-full'>
<span className="h-5.5 w-5.5 relative block cursor-pointer">
<span className="du-block absolute right-0 h-full w-full">
<span
className={cn(
'relative top-0 left-0 my-1 block h-0.5 w-0 rounded-sm bg-foreground delay-[0] duration-200 ease-in-out',
"bg-foreground relative left-0 top-0 my-1 block h-0.5 w-0 rounded-sm delay-[0] duration-200 ease-in-out",
{
'!w-full delay-300': !props.sidebarOpen,
}
"!w-full delay-300": !props.sidebarOpen,
},
)}
></span>
<span
className={cn(
'relative top-0 left-0 my-1 block h-0.5 w-0 rounded-sm bg-foreground delay-150 duration-200 ease-in-out',
"bg-foreground relative left-0 top-0 my-1 block h-0.5 w-0 rounded-sm delay-150 duration-200 ease-in-out",
{
'delay-400 !w-full': !props.sidebarOpen,
}
"delay-400 !w-full": !props.sidebarOpen,
},
)}
></span>
<span
className={cn(
'relative top-0 left-0 my-1 block h-0.5 w-0 rounded-sm bg-foreground delay-200 duration-200 ease-in-out',
"bg-foreground relative left-0 top-0 my-1 block h-0.5 w-0 rounded-sm delay-200 duration-200 ease-in-out",
{
'!w-full delay-500': !props.sidebarOpen,
}
"!w-full delay-500": !props.sidebarOpen,
},
)}
></span>
</span>
<span className='absolute right-0 h-full w-full rotate-45'>
<span className="absolute right-0 h-full w-full rotate-45">
<span
className={cn(
'absolute left-2.5 top-0 block h-full w-0.5 rounded-sm bg-foreground delay-300 duration-200 ease-in-out',
"bg-foreground absolute left-2.5 top-0 block h-full w-0.5 rounded-sm delay-300 duration-200 ease-in-out",
{
'!h-0 !delay-[0]': !props.sidebarOpen,
}
"!h-0 !delay-[0]": !props.sidebarOpen,
},
)}
></span>
<span
className={cn(
'delay-400 absolute left-0 top-2.5 block h-0.5 w-full rounded-sm bg-foreground duration-200 ease-in-out',
"delay-400 bg-foreground absolute left-0 top-2.5 block h-0.5 w-full rounded-sm duration-200 ease-in-out",
{
'!h-0 !delay-200': !props.sidebarOpen,
}
"!h-0 !delay-200": !props.sidebarOpen,
},
)}
></span>
</span>
@@ -74,7 +74,7 @@ const Header = (props: {
{/* <!-- Hamburger Toggle BTN --> */}
</div>
<ul className='flex items-center gap-2 2xsm:gap-4'>
<ul className="2xsm:gap-4 flex items-center gap-2">
{/* <!-- Dark Mode Toggler --> */}
<DarkModeSwitcher />
{/* <!-- Dark Mode Toggler --> */}
@@ -84,7 +84,7 @@ const Header = (props: {
{/* <!-- Chat Notification Area --> */}
</ul>
<div className='flex items-center gap-3 2xsm:gap-7'>
<div className="2xsm:gap-7 flex items-center gap-3">
{/* <!-- User Area --> */}
<UserDropdown user={props.user} />
{/* <!-- User Area --> */}

View File

@@ -1,7 +1,7 @@
const LoadingSpinner = () => {
return (
<div className='flex py-10 items-center justify-center'>
<div className='h-16 w-16 animate-spin rounded-full border-4 border-solid border-primary border-t-transparent'></div>
<div className="flex items-center justify-center py-10">
<div className="border-primary h-16 w-16 animate-spin rounded-full border-4 border-solid border-t-transparent"></div>
</div>
);
};

View File

@@ -7,12 +7,12 @@ import {
Settings,
Sheet,
X,
} from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import Logo from '../../client/static/logo.webp';
import { cn } from '../../lib/utils';
import SidebarLinkGroup from './SidebarLinkGroup';
} from "lucide-react";
import React, { useEffect, useRef, useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import Logo from "../../client/static/logo.webp";
import { cn } from "../../lib/utils";
import SidebarLinkGroup from "./SidebarLinkGroup";
interface SidebarProps {
sidebarOpen: boolean;
@@ -26,20 +26,25 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
const trigger = useRef<any>(null);
const sidebar = useRef<any>(null);
const storedSidebarExpanded = localStorage.getItem('sidebar-expanded');
const storedSidebarExpanded = localStorage.getItem("sidebar-expanded");
const [sidebarExpanded, setSidebarExpanded] = useState(
storedSidebarExpanded === null ? false : storedSidebarExpanded === 'true'
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;
if (
!sidebarOpen ||
sidebar.current.contains(target) ||
trigger.current.contains(target)
)
return;
setSidebarOpen(false);
};
document.addEventListener('click', clickHandler);
return () => document.removeEventListener('click', clickHandler);
document.addEventListener("click", clickHandler);
return () => document.removeEventListener("click", clickHandler);
});
// close if the esc key is pressed
@@ -48,16 +53,16 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
if (!sidebarOpen || keyCode !== 27) return;
setSidebarOpen(false);
};
document.addEventListener('keydown', keyHandler);
return () => document.removeEventListener('keydown', keyHandler);
document.addEventListener("keydown", keyHandler);
return () => document.removeEventListener("keydown", keyHandler);
});
useEffect(() => {
localStorage.setItem('sidebar-expanded', sidebarExpanded.toString());
localStorage.setItem("sidebar-expanded", sidebarExpanded.toString());
if (sidebarExpanded) {
document.querySelector('body')?.classList.add('sidebar-expanded');
document.querySelector("body")?.classList.add("sidebar-expanded");
} else {
document.querySelector('body')?.classList.remove('sidebar-expanded');
document.querySelector("body")?.classList.remove("sidebar-expanded");
}
}, [sidebarExpanded]);
@@ -65,49 +70,51 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
<aside
ref={sidebar}
className={cn(
'absolute left-0 top-0 z-9999 flex h-screen w-72.5 flex-col overflow-y-hidden bg-muted border-r duration-300 ease-linear lg:static lg:translate-x-0',
"z-9999 w-72.5 bg-muted absolute left-0 top-0 flex h-screen flex-col overflow-y-hidden border-r duration-300 ease-linear lg:static lg:translate-x-0",
{
'translate-x-0': sidebarOpen,
'-translate-x-full': !sidebarOpen,
}
"translate-x-0": sidebarOpen,
"-translate-x-full": !sidebarOpen,
},
)}
>
{/* <!-- SIDEBAR HEADER --> */}
<div className='flex items-center justify-between gap-2 px-6 py-5.5 lg:py-6.5'>
<NavLink to='/'>
<img src={Logo} alt='Logo' width={50} />
<div className="py-5.5 lg:py-6.5 flex items-center justify-between gap-2 px-6">
<NavLink to="/">
<img src={Logo} alt="Logo" width={50} />
</NavLink>
<button
ref={trigger}
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-controls='sidebar'
aria-controls="sidebar"
aria-expanded={sidebarOpen}
className='block lg:hidden'
className="block lg:hidden"
>
<X />
</button>
</div>
{/* <!-- SIDEBAR HEADER --> */}
<div className='no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear'>
<div className="no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear">
{/* <!-- Sidebar Menu --> */}
<nav className='mt-5 py-4 px-4 lg:mt-9 lg:px-6'>
<nav className="mt-5 px-4 py-4 lg:mt-9 lg:px-6">
{/* <!-- Menu Group --> */}
<div>
<h3 className='mb-4 ml-4 text-sm font-semibold text-muted-foreground'>MENU</h3>
<h3 className="text-muted-foreground mb-4 ml-4 text-sm font-semibold">
MENU
</h3>
<ul className='mb-6 flex flex-col gap-1.5'>
<ul className="mb-6 flex flex-col gap-1.5">
{/* <!-- Menu Item Dashboard --> */}
<NavLink
to='/admin'
to="/admin"
end
className={({ isActive }) =>
cn(
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
{
'bg-accent text-accent-foreground': isActive,
}
"bg-accent text-accent-foreground": isActive,
},
)
}
>
@@ -120,14 +127,14 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
{/* <!-- Menu Item Users --> */}
<li>
<NavLink
to='/admin/users'
to="/admin/users"
end
className={({ isActive }) =>
cn(
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
{
'bg-accent text-accent-foreground': isActive,
}
"bg-accent text-accent-foreground": isActive,
},
)
}
>
@@ -140,14 +147,14 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
{/* <!-- Menu Item Settings --> */}
<li>
<NavLink
to='/admin/settings'
to="/admin/settings"
end
className={({ isActive }) =>
cn(
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
{
'bg-accent text-accent-foreground': isActive,
}
"bg-accent text-accent-foreground": isActive,
},
)
}
>
@@ -161,20 +168,22 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
{/* <!-- Others Group --> */}
<div>
<h3 className='mb-4 ml-4 text-sm font-semibold text-muted-foreground'>Extra Components</h3>
<h3 className="text-muted-foreground mb-4 ml-4 text-sm font-semibold">
Extra Components
</h3>
<ul className='mb-6 flex flex-col gap-1.5'>
<ul className="mb-6 flex flex-col gap-1.5">
{/* <!-- Menu Item Calendar --> */}
<li>
<NavLink
to='/admin/calendar'
to="/admin/calendar"
end
className={({ isActive }) =>
cn(
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
{
'bg-accent text-accent-foreground': isActive,
}
"bg-accent text-accent-foreground": isActive,
},
)
}
>
@@ -185,21 +194,26 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
{/* <!-- Menu Item Calendar --> */}
{/* <!-- Menu Item Ui Elements --> */}
<SidebarLinkGroup activeCondition={pathname === '/ui' || pathname.includes('ui')}>
<SidebarLinkGroup
activeCondition={pathname === "/ui" || pathname.includes("ui")}
>
{(handleClick, open) => {
return (
<React.Fragment>
<NavLink
to='#'
to="#"
className={cn(
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
{
'bg-accent text-accent-foreground': pathname.includes('ui'),
}
"bg-accent text-accent-foreground":
pathname.includes("ui"),
},
)}
onClick={(e) => {
e.preventDefault();
sidebarExpanded ? handleClick() : setSidebarExpanded(true);
sidebarExpanded
? handleClick()
: setSidebarExpanded(true);
}}
>
<LayoutTemplate />
@@ -207,16 +221,20 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
{open ? <ChevronUp /> : <ChevronDown />}
</NavLink>
{/* <!-- Dropdown Menu Start --> */}
<div className={cn('translate transform overflow-hidden', { hidden: !open })}>
<ul className='mt-4 mb-5.5 flex flex-col gap-2.5 pl-6'>
<div
className={cn("translate transform overflow-hidden", {
hidden: !open,
})}
>
<ul className="mb-5.5 mt-4 flex flex-col gap-2.5 pl-6">
<li>
<NavLink
to='/admin/ui/buttons'
to="/admin/ui/buttons"
end
className={({ isActive }) =>
cn(
'group relative flex items-center gap-2.5 rounded-md px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:text-accent',
{ '!text-accent': isActive }
"text-muted-foreground hover:text-accent group relative flex items-center gap-2.5 rounded-md px-4 font-medium duration-300 ease-in-out",
{ "!text-accent": isActive },
)
}
>

View File

@@ -1,4 +1,4 @@
import { ReactNode, useState } from 'react';
import { ReactNode, useState } from "react";
interface SidebarLinkGroupProps {
children: (handleClick: () => void, open: boolean) => ReactNode;

View File

@@ -1,6 +1,6 @@
import { type DailyStats, type PageViewSource } from 'wasp/entities';
import { HttpError, prisma } from 'wasp/server';
import { type GetDailyStats } from 'wasp/server/operations';
import { type DailyStats, type PageViewSource } from "wasp/entities";
import { HttpError, prisma } from "wasp/server";
import { type GetDailyStats } from "wasp/server/operations";
type DailyStatsWithSources = DailyStats & {
sources: PageViewSource[];
@@ -11,18 +11,27 @@ type DailyStatsValues = {
weeklyStats: DailyStatsWithSources[];
};
export const getDailyStats: GetDailyStats<void, DailyStatsValues | undefined> = async (_args, context) => {
export const getDailyStats: GetDailyStats<
void,
DailyStatsValues | undefined
> = async (_args, context) => {
if (!context.user) {
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
throw new HttpError(
401,
"Only authenticated users are allowed to perform this operation",
);
}
if (!context.user.isAdmin) {
throw new HttpError(403, 'Only admins are allowed to perform this operation');
throw new HttpError(
403,
"Only admins are allowed to perform this operation",
);
}
const statsQuery = {
orderBy: {
date: 'desc',
date: "desc",
},
include: {
sources: true,
@@ -35,7 +44,9 @@ export const getDailyStats: GetDailyStats<void, DailyStatsValues | undefined> =
]);
if (!dailyStats) {
console.log('\x1b[34mNote: No daily stats have been generated by the dailyStatsJob yet. \x1b[0m');
console.log(
"\x1b[34mNote: No daily stats have been generated by the dailyStatsJob yet. \x1b[0m",
);
return undefined;
}

View File

@@ -1,7 +1,10 @@
import { BetaAnalyticsDataClient } from '@google-analytics/data';
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 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({
@@ -16,19 +19,19 @@ export async function getSources() {
property: `properties/${PROPERTY_ID}`,
dateRanges: [
{
startDate: '2020-01-01',
endDate: 'today',
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',
name: "source",
},
],
metrics: [
{
name: 'activeUsers',
name: "activeUsers",
},
],
});
@@ -44,7 +47,7 @@ export async function getSources() {
}
});
} else {
throw new Error('No response from Google Analytics');
throw new Error("No response from Google Analytics");
}
return activeUsersPerReferrer;
@@ -65,13 +68,13 @@ async function getTotalPageViews() {
property: `properties/${PROPERTY_ID}`,
dateRanges: [
{
startDate: '2020-01-01', // go back to earliest date of your app
endDate: 'today',
startDate: "2020-01-01", // go back to earliest date of your app
endDate: "today",
},
],
metrics: [
{
name: 'screenPageViews',
name: "screenPageViews",
},
],
});
@@ -80,7 +83,7 @@ async function getTotalPageViews() {
// @ts-ignore
totalViews = parseInt(response.rows[0].metricValues[0].value);
} else {
throw new Error('No response from Google Analytics');
throw new Error("No response from Google Analytics");
}
return totalViews;
}
@@ -91,26 +94,26 @@ async function getPrevDayViewsChangePercent() {
dateRanges: [
{
startDate: '2daysAgo',
endDate: 'yesterday',
startDate: "2daysAgo",
endDate: "yesterday",
},
],
orderBys: [
{
dimension: {
dimensionName: 'date',
dimensionName: "date",
},
desc: true,
},
],
dimensions: [
{
name: 'date',
name: "date",
},
],
metrics: [
{
name: 'screenPageViews',
name: "screenPageViews",
},
],
});
@@ -128,14 +131,17 @@ async function getPrevDayViewsChangePercent() {
viewsFromYesterday = parseInt(viewsFromYesterday);
viewsFromDayBeforeYesterday = parseInt(viewsFromDayBeforeYesterday);
if (viewsFromYesterday === 0 || viewsFromDayBeforeYesterday === 0) {
return '0';
return "0";
}
console.table({ viewsFromYesterday, viewsFromDayBeforeYesterday });
const change = ((viewsFromYesterday - viewsFromDayBeforeYesterday) / viewsFromDayBeforeYesterday) * 100;
const change =
((viewsFromYesterday - viewsFromDayBeforeYesterday) /
viewsFromDayBeforeYesterday) *
100;
return change.toFixed(0);
}
} else {
return '0';
return "0";
}
}

View File

@@ -3,7 +3,7 @@ const PLAUSIBLE_SITE_ID = process.env.PLAUSIBLE_SITE_ID!;
const PLAUSIBLE_BASE_URL = process.env.PLAUSIBLE_BASE_URL;
const headers = {
'Content-Type': 'application/json',
"Content-Type": "application/json",
Authorization: `Bearer ${PLAUSIBLE_API_KEY}`,
};
@@ -20,7 +20,7 @@ type PageViewSourcesResult = {
{
source: string;
visitors: number;
}
},
];
};
@@ -38,12 +38,12 @@ async function getTotalPageViews() {
const response = await fetch(
`${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&metrics=pageviews`,
{
method: 'GET',
method: "GET",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
Authorization: `Bearer ${PLAUSIBLE_API_KEY}`,
},
}
},
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
@@ -56,12 +56,19 @@ async function getTotalPageViews() {
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];
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);
const pageViewsDayBeforeYesterday =
await getPageviewsForDate(dayBeforeYesterday);
console.table({
pageViewsYesterday,
@@ -72,9 +79,12 @@ async function getPrevDayViewsChangePercent() {
let change = 0;
if (pageViewsYesterday === 0 || pageViewsDayBeforeYesterday === 0) {
return '0';
return "0";
} else {
change = ((pageViewsYesterday - pageViewsDayBeforeYesterday) / pageViewsDayBeforeYesterday) * 100;
change =
((pageViewsYesterday - pageViewsDayBeforeYesterday) /
pageViewsDayBeforeYesterday) *
100;
}
return change.toFixed(0);
}
@@ -82,7 +92,7 @@ async function getPrevDayViewsChangePercent() {
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',
method: "GET",
headers: headers,
});
if (!response.ok) {
@@ -95,7 +105,7 @@ async function getPageviewsForDate(date: string) {
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',
method: "GET",
headers: headers,
});
if (!response.ok) {

View File

@@ -1,16 +1,26 @@
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 { listOrders } from "@lemonsqueezy/lemonsqueezy.js";
import Stripe from "stripe";
import { type DailyStats } from "wasp/entities";
import { type DailyStatsJob } from "wasp/server/jobs";
import { stripe } from "../payment/stripe/stripeClient";
import {
getDailyPageViews,
getSources,
} from "./providers/plausibleAnalyticsUtils";
// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
import { paymentProcessor } from '../payment/paymentProcessor';
import { SubscriptionStatus } from '../payment/plans';
import { paymentProcessor } from "../payment/paymentProcessor";
import { SubscriptionStatus } from "../payment/plans";
export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?: DailyStats[]; isLoading?: boolean };
export type DailyStatsProps = {
dailyStats?: DailyStats;
weeklyStats?: DailyStats[];
isLoading?: boolean;
};
export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, context) => {
export const calculateDailyStats: DailyStatsJob<never, void> = async (
_args,
context,
) => {
const nowUTC = new Date(Date.now());
nowUTC.setUTCHours(0, 0, 0, 0);
@@ -44,14 +54,16 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
let totalRevenue;
switch (paymentProcessor.id) {
case 'stripe':
case "stripe":
totalRevenue = await fetchTotalStripeRevenue();
break;
case 'lemonsqueezy':
case "lemonsqueezy":
totalRevenue = await fetchTotalLemonSqueezyRevenue();
break;
default:
throw new Error(`Unsupported payment processor: ${paymentProcessor.id}`);
throw new Error(
`Unsupported payment processor: ${paymentProcessor.id}`,
);
}
const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews();
@@ -63,7 +75,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
});
if (!dailyStats) {
console.log('No daily stat found for today, creating one...');
console.log("No daily stat found for today, creating one...");
dailyStats = await context.entities.DailyStats.create({
data: {
date: nowUTC,
@@ -77,7 +89,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
},
});
} else {
console.log('Daily stat found for today, updating it...');
console.log("Daily stat found for today, updating it...");
dailyStats = await context.entities.DailyStats.update({
where: {
id: dailyStats.id,
@@ -97,7 +109,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
for (const source of sources) {
let visitors = source.visitors;
if (typeof source.visitors !== 'number') {
if (typeof source.visitors !== "number") {
visitors = parseInt(source.visitors);
}
await context.entities.PageViewSource.upsert({
@@ -121,11 +133,11 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
console.table({ dailyStats });
} catch (error: any) {
console.error('Error calculating daily stats: ', error);
console.error("Error calculating daily stats: ", error);
await context.entities.Logs.create({
data: {
message: `Error calculating daily stats: ${error?.message}`,
level: 'job-error',
level: "job-error",
},
});
}
@@ -139,7 +151,7 @@ async function fetchTotalStripeRevenue() {
// gte: startTimestamp,
// lt: endTimestamp
// },
type: 'charge',
type: "charge",
};
let hasMore = true;
@@ -147,14 +159,15 @@ async function fetchTotalStripeRevenue() {
const balanceTransactions = await stripe.balanceTransactions.list(params);
for (const transaction of balanceTransactions.data) {
if (transaction.type === 'charge') {
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;
params.starting_after =
balanceTransactions.data[balanceTransactions.data.length - 1].id;
} else {
hasMore = false;
}
@@ -194,7 +207,7 @@ async function fetchTotalLemonSqueezyRevenue() {
// 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);
console.error("Error fetching Lemon Squeezy revenue:", error);
throw error;
}
}

View File

@@ -1,13 +1,11 @@
import { ReactNode } from 'react';
import { ReactNode } from "react";
export function AuthPageLayout({children} : {children: ReactNode }) {
export function AuthPageLayout({ children }: { children: ReactNode }) {
return (
<div className='flex min-h-full flex-col justify-center pt-10 sm:px-6 lg:px-8'>
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
<div className='bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10 dark:bg-white dark:text-gray-900'>
<div className='-mt-8'>
{ children }
</div>
<div className="flex min-h-full flex-col justify-center pt-10 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white px-4 py-8 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10 dark:bg-white dark:text-gray-900">
<div className="-mt-8">{children}</div>
</div>
</div>
</div>

View File

@@ -1,23 +1,26 @@
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
import { LoginForm } from 'wasp/client/auth';
import { AuthPageLayout } from './AuthPageLayout';
import { LoginForm } from "wasp/client/auth";
import { Link as WaspRouterLink, routes } from "wasp/client/router";
import { AuthPageLayout } from "./AuthPageLayout";
export default function Login() {
return (
<AuthPageLayout>
<LoginForm />
<br />
<span className='text-sm font-medium text-gray-900 dark:text-gray-900'>
Don't have an account yet?{' '}
<WaspRouterLink to={routes.SignupRoute.to} className='underline'>
<span className="text-sm font-medium text-gray-900 dark:text-gray-900">
Don't have an account yet?{" "}
<WaspRouterLink to={routes.SignupRoute.to} className="underline">
go to signup
</WaspRouterLink>
.
</span>
<br />
<span className='text-sm font-medium text-gray-900'>
Forgot your password?{' '}
<WaspRouterLink to={routes.RequestPasswordResetRoute.to} className='underline'>
<span className="text-sm font-medium text-gray-900">
Forgot your password?{" "}
<WaspRouterLink
to={routes.RequestPasswordResetRoute.to}
className="underline"
>
reset it
</WaspRouterLink>
.

View File

@@ -1,15 +1,15 @@
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
import { SignupForm } from 'wasp/client/auth';
import { AuthPageLayout } from './AuthPageLayout';
import { SignupForm } from "wasp/client/auth";
import { Link as WaspRouterLink, routes } from "wasp/client/router";
import { AuthPageLayout } from "./AuthPageLayout";
export function Signup() {
return (
<AuthPageLayout>
<SignupForm />
<br />
<span className='text-sm font-medium text-gray-900'>
<span className="text-sm font-medium text-gray-900">
I already have an account (
<WaspRouterLink to={routes.LoginRoute.to} className='underline'>
<WaspRouterLink to={routes.LoginRoute.to} className="underline">
go to login
</WaspRouterLink>
).

View File

@@ -1,14 +1,17 @@
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
import { VerifyEmailForm } from 'wasp/client/auth';
import { AuthPageLayout } from '../AuthPageLayout';
import { VerifyEmailForm } from "wasp/client/auth";
import { Link as WaspRouterLink, routes } from "wasp/client/router";
import { AuthPageLayout } from "../AuthPageLayout";
export function EmailVerificationPage() {
return (
<AuthPageLayout>
<VerifyEmailForm />
<br />
<span className='text-sm font-medium text-gray-900'>
If everything is okay, <WaspRouterLink to={routes.LoginRoute.to} className='underline'>go to login</WaspRouterLink>
<span className="text-sm font-medium text-gray-900">
If everything is okay,{" "}
<WaspRouterLink to={routes.LoginRoute.to} className="underline">
go to login
</WaspRouterLink>
</span>
</AuthPageLayout>
);

View File

@@ -1,14 +1,15 @@
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
import { ResetPasswordForm } from 'wasp/client/auth';
import { AuthPageLayout } from '../AuthPageLayout';
import { ResetPasswordForm } from "wasp/client/auth";
import { Link as WaspRouterLink, routes } from "wasp/client/router";
import { AuthPageLayout } from "../AuthPageLayout";
export function PasswordResetPage() {
return (
<AuthPageLayout>
<ResetPasswordForm />
<br />
<span className='text-sm font-medium text-gray-900'>
If everything is okay, <WaspRouterLink to={routes.LoginRoute.to}>go to login</WaspRouterLink>
<span className="text-sm font-medium text-gray-900">
If everything is okay,{" "}
<WaspRouterLink to={routes.LoginRoute.to}>go to login</WaspRouterLink>
</span>
</AuthPageLayout>
);

View File

@@ -1,5 +1,5 @@
import { ForgotPasswordForm } from 'wasp/client/auth';
import { AuthPageLayout } from '../AuthPageLayout';
import { ForgotPasswordForm } from "wasp/client/auth";
import { AuthPageLayout } from "../AuthPageLayout";
export function RequestPasswordResetPage() {
return (

View File

@@ -1,7 +1,12 @@
import { type GetVerificationEmailContentFn, type GetPasswordResetEmailContentFn } from 'wasp/server/auth';
import {
type GetPasswordResetEmailContentFn,
type GetVerificationEmailContentFn,
} from "wasp/server/auth";
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ verificationLink }) => ({
subject: 'Verify your email',
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({
verificationLink,
}) => ({
subject: "Verify your email",
text: `Click the link below to verify your email: ${verificationLink}`,
html: `
<p>Click the link below to verify your email</p>
@@ -9,8 +14,10 @@ export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ ver
`,
});
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ passwordResetLink }) => ({
subject: 'Password reset',
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({
passwordResetLink,
}) => ({
subject: "Password reset",
text: `Click the link below to reset your password: ${passwordResetLink}`,
html: `
<p>Click the link below to reset your password</p>

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
import { defineUserSignupFields } from 'wasp/auth/providers/types';
import { defineUserSignupFields } from "wasp/auth/providers/types";
import { z } from "zod";
const adminEmails = process.env.ADMIN_EMAILS?.split(',') || [];
const adminEmails = process.env.ADMIN_EMAILS?.split(",") || [];
const emailDataSchema = z.object({
email: z.string(),
@@ -29,9 +29,12 @@ const githubDataSchema = z.object({
z.object({
email: z.string(),
verified: z.boolean(),
})
}),
)
.min(1, 'You need to have an email address associated with your GitHub account to sign up.'),
.min(
1,
"You need to have an email address associated with your GitHub account to sign up.",
),
login: z.string(),
}),
});
@@ -65,7 +68,7 @@ function getGithubEmailInfo(githubData: z.infer<typeof githubDataSchema>) {
// instead of ["user"] and access args.profile.username instead
export function getGitHubAuthConfig() {
return {
scopes: ['user'],
scopes: ["user"],
};
}
@@ -96,7 +99,7 @@ export const getGoogleUserFields = defineUserSignupFields({
export function getGoogleAuthConfig() {
return {
scopes: ['profile', 'email'], // must include at least 'profile' for Google
scopes: ["profile", "email"], // must include at least 'profile' for Google
};
}
@@ -113,7 +116,9 @@ export const getDiscordUserFields = defineUserSignupFields({
const discordData = discordDataSchema.parse(data);
// Users need to have an email for payment processing.
if (!discordData.profile.email) {
throw new Error('You need to have an email address associated with your Discord account to sign up.');
throw new Error(
"You need to have an email address associated with your Discord account to sign up.",
);
}
return discordData.profile.email;
},
@@ -132,6 +137,6 @@ export const getDiscordUserFields = defineUserSignupFields({
export function getDiscordAuthConfig() {
return {
scopes: ['identify', 'email'],
scopes: ["identify", "email"],
};
}

View File

@@ -1,10 +1,13 @@
import { useEffect, useMemo } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { routes } from 'wasp/client/router';
import './Main.css';
import NavBar from './components/NavBar/NavBar';
import { demoNavigationitems, marketingNavigationItems } from './components/NavBar/constants';
import CookieConsentBanner from './components/cookie-consent/Banner';
import { useEffect, useMemo } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { routes } from "wasp/client/router";
import "./Main.css";
import NavBar from "./components/NavBar/NavBar";
import {
demoNavigationitems,
marketingNavigationItems,
} from "./components/NavBar/constants";
import CookieConsentBanner from "./components/cookie-consent/Banner";
/**
* use this component to wrap all child components
@@ -13,24 +16,29 @@ import CookieConsentBanner from './components/cookie-consent/Banner';
export default function App() {
const location = useLocation();
const isMarketingPage = useMemo(() => {
return location.pathname === '/' || location.pathname.startsWith('/pricing');
return (
location.pathname === "/" || location.pathname.startsWith("/pricing")
);
}, [location]);
const navigationItems = isMarketingPage ? marketingNavigationItems : demoNavigationitems;
const navigationItems = isMarketingPage
? marketingNavigationItems
: demoNavigationitems;
const shouldDisplayAppNavBar = useMemo(() => {
return (
location.pathname !== routes.LoginRoute.build() && location.pathname !== routes.SignupRoute.build()
location.pathname !== routes.LoginRoute.build() &&
location.pathname !== routes.SignupRoute.build()
);
}, [location]);
const isAdminDashboard = useMemo(() => {
return location.pathname.startsWith('/admin');
return location.pathname.startsWith("/admin");
}, [location]);
useEffect(() => {
if (location.hash) {
const id = location.hash.replace('#', '');
const id = location.hash.replace("#", "");
const element = document.getElementById(id);
if (element) {
element.scrollIntoView();
@@ -40,13 +48,15 @@ export default function App() {
return (
<>
<div className='min-h-screen bg-background text-foreground'>
<div className="bg-background text-foreground min-h-screen">
{isAdminDashboard ? (
<Outlet />
) : (
<>
{shouldDisplayAppNavBar && <NavBar navigationItems={navigationItems} />}
<div className='mx-auto max-w-screen-2xl'>
{shouldDisplayAppNavBar && (
<NavBar navigationItems={navigationItems} />
)}
<div className="mx-auto max-w-screen-2xl">
<Outlet />
</div>
</>

View File

@@ -22,14 +22,22 @@
/* Text gradient utilities */
.text-gradient-primary {
background: linear-gradient(to right, hsl(var(--secondary-muted)), hsl(var(--secondary)));
background: linear-gradient(
to right,
hsl(var(--secondary-muted)),
hsl(var(--secondary))
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-gradient-primary-diagonal {
background: linear-gradient(135deg, hsl(var(--secondary-muted)), hsl(var(--secondary)));
background: linear-gradient(
135deg,
hsl(var(--secondary-muted)),
hsl(var(--secondary))
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -37,7 +45,11 @@
/* Border gradient utilities */
.border-gradient-primary {
background: linear-gradient(to right, hsl(var(--secondary-muted)), hsl(var(--secondary)));
background: linear-gradient(
to right,
hsl(var(--secondary-muted)),
hsl(var(--secondary))
);
padding: 1px;
}
@@ -52,8 +64,8 @@
* 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-family: "Satoshi";
src: url("/fonts/Satoshi-Regular.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;

View File

@@ -1,34 +1,34 @@
import { Moon, Sun } from 'lucide-react';
import { Label } from '../../components/ui/label';
import { cn } from '../../lib/utils';
import useColorMode from '../hooks/useColorMode';
import { Moon, Sun } from "lucide-react";
import { Label } from "../../components/ui/label";
import { cn } from "../../lib/utils";
import useColorMode from "../hooks/useColorMode";
const DarkModeSwitcher = () => {
const [colorMode, setColorMode] = useColorMode();
const isInLightMode = colorMode === 'light';
const isInLightMode = colorMode === "light";
return (
<div>
<Label
className={cn(
'relative m-0 block h-7.5 w-14 rounded-full transition-colors duration-300 ease-in-out cursor-pointer bg-muted'
"h-7.5 bg-muted relative m-0 block w-14 cursor-pointer rounded-full transition-colors duration-300 ease-in-out",
)}
>
<input
type='checkbox'
type="checkbox"
onChange={() => {
if (typeof setColorMode === 'function') {
setColorMode(isInLightMode ? 'dark' : 'light');
if (typeof setColorMode === "function") {
setColorMode(isInLightMode ? "dark" : "light");
}
}}
className='absolute top-0 z-50 m-0 h-full w-full cursor-pointer opacity-0'
className="absolute top-0 z-50 m-0 h-full w-full cursor-pointer opacity-0"
/>
<span
className={cn(
'absolute top-1/2 left-[3px] flex h-6 w-6 -translate-y-1/2 translate-x-0 items-center justify-center rounded-full bg-white shadow-md border border-border transition-all duration-300 ease-in-out',
"border-border absolute left-[3px] top-1/2 flex h-6 w-6 -translate-y-1/2 translate-x-0 items-center justify-center rounded-full border bg-white shadow-md transition-all duration-300 ease-in-out",
{
'!right-[3px] !translate-x-full': !isInLightMode,
}
"!right-[3px] !translate-x-full": !isInLightMode,
},
)}
>
<ModeIcon isInLightMode={isInLightMode} />
@@ -40,14 +40,18 @@ const DarkModeSwitcher = () => {
function ModeIcon({ isInLightMode }: { isInLightMode: boolean }) {
const iconStyle =
'absolute inset-0 flex items-center justify-center transition-opacity ease-in-out duration-300';
"absolute inset-0 flex items-center justify-center transition-opacity ease-in-out duration-300";
return (
<>
<span className={cn(iconStyle, isInLightMode ? 'opacity-100' : 'opacity-0')}>
<Sun className='size-4 stroke-amber-500 fill-amber-500' />
<span
className={cn(iconStyle, isInLightMode ? "opacity-100" : "opacity-0")}
>
<Sun className="size-4 fill-amber-500 stroke-amber-500" />
</span>
<span className={cn(iconStyle, !isInLightMode ? 'opacity-100' : 'opacity-0')}>
<Moon className='size-4 stroke-slate-600 fill-slate-600' />
<span
className={cn(iconStyle, !isInLightMode ? "opacity-100" : "opacity-0")}
>
<Moon className="size-4 fill-slate-600 stroke-slate-600" />
</span>
</>
);

View File

@@ -1,30 +1,30 @@
const ANNOUNCEMENT_URL = 'https://github.com/wasp-lang/wasp';
const ANNOUNCEMENT_URL = "https://github.com/wasp-lang/wasp";
export function Announcement() {
return (
<div className='relative flex justify-center items-center gap-3 p-3 w-full bg-gradient-to-r from-accent to-secondary font-semibold text-primary-foreground text-center'>
<div className="from-accent to-secondary text-primary-foreground relative flex w-full items-center justify-center gap-3 bg-gradient-to-r p-3 text-center font-semibold">
<a
href={ANNOUNCEMENT_URL}
target='_blank'
rel='noopener noreferrer'
className='hidden lg:block cursor-pointer hover:opacity-90 hover:drop-shadow transition-opacity'
target="_blank"
rel="noopener noreferrer"
className="hidden cursor-pointer transition-opacity hover:opacity-90 hover:drop-shadow lg:block"
>
Support Open-Source Software!
</a>
<div className='hidden lg:block self-stretch w-0.5 bg-primary-foreground/20'></div>
<div className="bg-primary-foreground/20 hidden w-0.5 self-stretch lg:block"></div>
<a
href={ANNOUNCEMENT_URL}
target='_blank'
rel='noopener noreferrer'
className='hidden lg:block cursor-pointer rounded-full bg-background/20 px-2.5 py-1 text-xs hover:bg-background/30 transition-colors tracking-wider'
target="_blank"
rel="noopener noreferrer"
className="bg-background/20 hover:bg-background/30 hidden cursor-pointer rounded-full px-2.5 py-1 text-xs tracking-wider transition-colors lg:block"
>
Star Our Repo on Github
</a>
<a
href={ANNOUNCEMENT_URL}
target='_blank'
rel='noopener noreferrer'
className='lg:hidden cursor-pointer rounded-full bg-background/20 px-2.5 py-1 text-xs hover:bg-background/30 transition-colors'
target="_blank"
rel="noopener noreferrer"
className="bg-background/20 hover:bg-background/30 cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:hidden"
>
Star the Our Repo and Support Open-Source!
</a>

View File

@@ -1,24 +1,34 @@
import { LogIn, Menu } from 'lucide-react';
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { Link as ReactRouterLink } from 'react-router-dom';
import { useAuth } from 'wasp/client/auth';
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../../components/ui/sheet';
import { cn } from '../../../lib/utils';
import { throttleWithTrailingInvocation } from '../../../shared/utils';
import { UserDropdown } from '../../../user/UserDropdown';
import { UserMenuItems } from '../../../user/UserMenuItems';
import { useIsLandingPage } from '../../hooks/useIsLandingPage';
import logo from '../../static/logo.webp';
import DarkModeSwitcher from '../DarkModeSwitcher';
import { Announcement } from './Announcement';
import { LogIn, Menu } from "lucide-react";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { Link as ReactRouterLink } from "react-router-dom";
import { useAuth } from "wasp/client/auth";
import { Link as WaspRouterLink, routes } from "wasp/client/router";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "../../../components/ui/sheet";
import { cn } from "../../../lib/utils";
import { throttleWithTrailingInvocation } from "../../../shared/utils";
import { UserDropdown } from "../../../user/UserDropdown";
import { UserMenuItems } from "../../../user/UserMenuItems";
import { useIsLandingPage } from "../../hooks/useIsLandingPage";
import logo from "../../static/logo.webp";
import DarkModeSwitcher from "../DarkModeSwitcher";
import { Announcement } from "./Announcement";
export interface NavigationItem {
name: string;
to: string;
}
export default function NavBar({ navigationItems }: { navigationItems: NavigationItem[] }) {
export default function NavBar({
navigationItems,
}: {
navigationItems: NavigationItem[];
}) {
const [isScrolled, setIsScrolled] = useState(false);
const isLandingPage = useIsLandingPage();
@@ -27,10 +37,10 @@ export default function NavBar({ navigationItems }: { navigationItems: Navigatio
setIsScrolled(window.scrollY > 0);
}, 50);
window.addEventListener('scroll', throttledHandler);
window.addEventListener("scroll", throttledHandler);
return () => {
window.removeEventListener('scroll', throttledHandler);
window.removeEventListener("scroll", throttledHandler);
throttledHandler.cancel();
};
}, []);
@@ -38,42 +48,57 @@ export default function NavBar({ navigationItems }: { navigationItems: Navigatio
return (
<>
{isLandingPage && <Announcement />}
<header className={cn('sticky top-0 z-50 transition-all duration-300', isScrolled && 'top-4')}>
<header
className={cn(
"sticky top-0 z-50 transition-all duration-300",
isScrolled && "top-4",
)}
>
<div
className={cn('transition-all duration-300', {
'mx-4 md:mx-20 pr-2 lg:pr-0 rounded-full shadow-lg bg-background/90 backdrop-blur-lg border border-border':
className={cn("transition-all duration-300", {
"bg-background/90 border-border mx-4 rounded-full border pr-2 shadow-lg backdrop-blur-lg md:mx-20 lg:pr-0":
isScrolled,
'mx-0 bg-background/80 backdrop-blur-lg border-b border-border': !isScrolled,
"bg-background/80 border-border mx-0 border-b backdrop-blur-lg":
!isScrolled,
})}
>
<nav
className={cn('flex items-center justify-between transition-all duration-300', {
'p-3 lg:px-6': isScrolled,
'p-6 lg:px-8': !isScrolled,
})}
aria-label='Global'
className={cn(
"flex items-center justify-between transition-all duration-300",
{
"p-3 lg:px-6": isScrolled,
"p-6 lg:px-8": !isScrolled,
},
)}
aria-label="Global"
>
<div className='flex items-center gap-6'>
<div className="flex items-center gap-6">
<WaspRouterLink
to={routes.LandingPageRoute.to}
className='flex items-center text-foreground duration-300 ease-in-out hover:text-primary transition-colors'
className="text-foreground hover:text-primary flex items-center transition-colors duration-300 ease-in-out"
>
<NavLogo isScrolled={isScrolled} />
<span
className={cn('font-semibold leading-6 text-foreground transition-all duration-300', {
'ml-2 text-sm': !isScrolled,
'ml-2 text-xs': isScrolled,
})}
className={cn(
"text-foreground font-semibold leading-6 transition-all duration-300",
{
"ml-2 text-sm": !isScrolled,
"ml-2 text-xs": isScrolled,
},
)}
>
Your SaaS
</span>
</WaspRouterLink>
<ul className='hidden lg:flex items-center gap-6 ml-4'>
<ul className="ml-4 hidden items-center gap-6 lg:flex">
{renderNavigationItems(navigationItems)}
</ul>
</div>
<NavBarMobileMenu isScrolled={isScrolled} navigationItems={navigationItems} />
<NavBarMobileMenu
isScrolled={isScrolled}
navigationItems={navigationItems}
/>
<NavBarDesktopUserDropdown isScrolled={isScrolled} />
</nav>
</div>
@@ -86,31 +111,34 @@ function NavBarDesktopUserDropdown({ isScrolled }: { isScrolled: boolean }) {
const { data: user, isLoading: isUserLoading } = useAuth();
return (
<div className='hidden lg:flex lg:flex-1 gap-3 justify-end items-center'>
<ul className='flex justify-center items-center gap-2 sm:gap-4'>
<div className="hidden items-center justify-end gap-3 lg:flex lg:flex-1">
<ul className="flex items-center justify-center gap-2 sm:gap-4">
<DarkModeSwitcher />
</ul>
{isUserLoading ? null : !user ? (
<WaspRouterLink
to={routes.LoginRoute.to}
className={cn('font-semibold leading-6 ml-3 transition-all duration-300', {
'text-sm': !isScrolled,
'text-xs': isScrolled,
})}
className={cn(
"ml-3 font-semibold leading-6 transition-all duration-300",
{
"text-sm": !isScrolled,
"text-xs": isScrolled,
},
)}
>
<div className='flex items-center duration-300 ease-in-out text-foreground hover:text-primary transition-colors'>
Log in{' '}
<div className="text-foreground hover:text-primary flex items-center transition-colors duration-300 ease-in-out">
Log in{" "}
<LogIn
size={isScrolled ? '1rem' : '1.1rem'}
className={cn('transition-all duration-300', {
'ml-1 mt-[0.1rem]': !isScrolled,
'ml-1': isScrolled,
size={isScrolled ? "1rem" : "1.1rem"}
className={cn("transition-all duration-300", {
"ml-1 mt-[0.1rem]": !isScrolled,
"ml-1": isScrolled,
})}
/>
</div>
</WaspRouterLink>
) : (
<div className='ml-3'>
<div className="ml-3">
<UserDropdown user={user} />
</div>
)}
@@ -129,51 +157,56 @@ function NavBarMobileMenu({
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return (
<div className='flex lg:hidden'>
<div className="flex lg:hidden">
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild>
<button
type='button'
type="button"
className={cn(
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-muted hover:bg-accent transition-colors'
"text-muted-foreground hover:text-muted hover:bg-accent inline-flex items-center justify-center rounded-md transition-colors",
)}
>
<span className='sr-only'>Open main menu</span>
<span className="sr-only">Open main menu</span>
<Menu
className={cn('transition-all duration-300', {
'size-8 p-1': !isScrolled,
'size-6 p-0.5': isScrolled,
className={cn("transition-all duration-300", {
"size-8 p-1": !isScrolled,
"size-6 p-0.5": isScrolled,
})}
aria-hidden='true'
aria-hidden="true"
/>
</button>
</SheetTrigger>
<SheetContent side='right' className='w-[300px] sm:w-[400px]'>
<SheetContent side="right" className="w-[300px] sm:w-[400px]">
<SheetHeader>
<SheetTitle className='flex items-center'>
<SheetTitle className="flex items-center">
<WaspRouterLink to={routes.LandingPageRoute.to}>
<span className='sr-only'>Your SaaS</span>
<span className="sr-only">Your SaaS</span>
<NavLogo isScrolled={false} />
</WaspRouterLink>
</SheetTitle>
</SheetHeader>
<div className='mt-6 flow-root'>
<div className='-my-6 divide-y divide-border'>
<ul className='space-y-2 py-6'>{renderNavigationItems(navigationItems, setMobileMenuOpen)}</ul>
<div className='py-6'>
<div className="mt-6 flow-root">
<div className="divide-border -my-6 divide-y">
<ul className="space-y-2 py-6">
{renderNavigationItems(navigationItems, setMobileMenuOpen)}
</ul>
<div className="py-6">
{isUserLoading ? null : !user ? (
<WaspRouterLink to={routes.LoginRoute.to}>
<div className='flex justify-end items-center duration-300 ease-in-out text-foreground hover:text-primary transition-colors'>
Log in <LogIn size='1.1rem' className='ml-1' />
<div className="text-foreground hover:text-primary flex items-center justify-end transition-colors duration-300 ease-in-out">
Log in <LogIn size="1.1rem" className="ml-1" />
</div>
</WaspRouterLink>
) : (
<ul className='space-y-2'>
<UserMenuItems user={user} onItemClick={() => setMobileMenuOpen(false)} />
<ul className="space-y-2">
<UserMenuItems
user={user}
onItemClick={() => setMobileMenuOpen(false)}
/>
</ul>
)}
</div>
<div className='py-6'>
<div className="py-6">
<DarkModeSwitcher />
</div>
</div>
@@ -186,12 +219,12 @@ function NavBarMobileMenu({
function renderNavigationItems(
navigationItems: NavigationItem[],
setMobileMenuOpen?: Dispatch<SetStateAction<boolean>>
setMobileMenuOpen?: Dispatch<SetStateAction<boolean>>,
) {
const menuStyles = cn({
'block rounded-lg px-3 py-2 text-sm font-medium leading-7 text-foreground hover:bg-accent hover:text-accent-foreground transition-colors':
"block rounded-lg px-3 py-2 text-sm font-medium leading-7 text-foreground hover:bg-accent hover:text-accent-foreground transition-colors":
!!setMobileMenuOpen,
'text-sm font-normal leading-6 text-foreground duration-300 ease-in-out hover:text-primary transition-colors':
"text-sm font-normal leading-6 text-foreground duration-300 ease-in-out hover:text-primary transition-colors":
!setMobileMenuOpen,
});
@@ -202,7 +235,7 @@ function renderNavigationItems(
to={item.to}
className={menuStyles}
onClick={setMobileMenuOpen && (() => setMobileMenuOpen(false))}
target={item.to.startsWith('http') ? '_blank' : undefined}
target={item.to.startsWith("http") ? "_blank" : undefined}
>
{item.name}
</ReactRouterLink>
@@ -213,11 +246,11 @@ function renderNavigationItems(
const NavLogo = ({ isScrolled }: { isScrolled: boolean }) => (
<img
className={cn('transition-all duration-500', {
'size-8': !isScrolled,
'size-7': isScrolled,
className={cn("transition-all duration-500", {
"size-8": !isScrolled,
"size-7": isScrolled,
})}
src={logo}
alt='Your SaaS App'
alt="Your SaaS App"
/>
);

View File

@@ -1,21 +1,21 @@
import { routes } from 'wasp/client/router';
import { BlogUrl, DocsUrl } from '../../../shared/common';
import type { NavigationItem } from './NavBar';
import { routes } from "wasp/client/router";
import { BlogUrl, DocsUrl } from "../../../shared/common";
import type { NavigationItem } from "./NavBar";
const staticNavigationItems: NavigationItem[] = [
{ name: 'Documentation', to: DocsUrl },
{ name: 'Blog', to: BlogUrl },
{ name: "Documentation", to: DocsUrl },
{ name: "Blog", to: BlogUrl },
];
export const marketingNavigationItems: NavigationItem[] = [
{ name: 'Features', to: '/#features' },
{ name: 'Pricing', to: routes.PricingPageRoute.to },
{ name: "Features", to: "/#features" },
{ name: "Pricing", to: routes.PricingPageRoute.to },
...staticNavigationItems,
] as const;
export const demoNavigationitems: NavigationItem[] = [
{ name: 'AI Scheduler', to: routes.DemoAppRoute.to },
{ name: 'File Upload', to: routes.FileUploadRoute.to },
{ name: 'Pricing', to: routes.PricingPageRoute.to },
{ name: "AI Scheduler", to: routes.DemoAppRoute.to },
{ name: "File Upload", to: routes.FileUploadRoute.to },
{ name: "Pricing", to: routes.PricingPageRoute.to },
...staticNavigationItems,
] as const;

View File

@@ -1,17 +1,19 @@
import { useAuth } from 'wasp/client/auth';
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
import { useAuth } from "wasp/client/auth";
import { Link as WaspRouterLink, routes } from "wasp/client/router";
export function NotFoundPage() {
const { data: user } = useAuth();
return (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-center'>
<h1 className='text-6xl font-bold mb-4'>404</h1>
<p className='text-lg text-bodydark mb-8'>Oops! The page you're looking for doesn't exist.</p>
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="mb-4 text-6xl font-bold">404</h1>
<p className="text-bodydark mb-8 text-lg">
Oops! The page you're looking for doesn't exist.
</p>
<WaspRouterLink
to={user ? routes.DemoAppRoute.to : routes.LandingPageRoute.to}
className='inline-block px-8 py-3 text-accent-foreground font-semibold bg-accent rounded-lg hover:bg-accent/90 transition duration-300'
className="text-accent-foreground bg-accent hover:bg-accent/90 inline-block rounded-lg px-8 py-3 font-semibold transition duration-300"
>
Go Back Home
</WaspRouterLink>

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import * as CookieConsent from 'vanilla-cookieconsent';
import 'vanilla-cookieconsent/dist/cookieconsent.css';
import getConfig from './Config';
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
@@ -13,7 +13,7 @@ const CookieConsentBanner = () => {
CookieConsent.run(getConfig());
}, []);
return <div id='cookieconsent'></div>;
return <div id="cookieconsent"></div>;
};
export default CookieConsentBanner;

View File

@@ -1,4 +1,4 @@
import type { CookieConsentConfig } from 'vanilla-cookieconsent';
import type { CookieConsentConfig } from "vanilla-cookieconsent";
declare global {
interface Window {
@@ -10,26 +10,26 @@ const getConfig = () => {
// See https://cookieconsent.orestbida.com/reference/configuration-reference.html for configuration options.
const config: CookieConsentConfig = {
// Default configuration for the modal.
root: 'body',
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',
mode: "opt-in",
revision: 0,
// Default configuration for the cookie.
cookie: {
name: 'cc_cookie',
name: "cc_cookie",
domain: location.hostname,
path: '/',
sameSite: 'Lax',
path: "/",
sameSite: "Lax",
expiresAfterDays: 365,
},
guiOptions: {
consentModal: {
layout: 'box',
position: 'bottom right',
layout: "box",
position: "bottom right",
equalWeightButtons: true,
flipButtons: false,
},
@@ -47,7 +47,7 @@ const getConfig = () => {
name: /^_ga/, // regex: match all cookies starting with '_ga'
},
{
name: '_gid', // string: exact cookie name
name: "_gid", // string: exact cookie name
},
],
},
@@ -55,22 +55,23 @@ const getConfig = () => {
// https://cookieconsent.orestbida.com/reference/configuration-reference.html#category-services
services: {
ga: {
label: 'Google Analytics',
label: "Google Analytics",
onAccept: () => {
try {
const GA_ANALYTICS_ID = import.meta.env.REACT_APP_GOOGLE_ANALYTICS_ID;
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');
throw new Error("Google Analytics ID is missing");
}
window.dataLayer = window.dataLayer || [];
function gtag(..._args: unknown[]) {
(window.dataLayer as Array<any>).push(arguments);
}
gtag('js', new Date());
gtag('config', GA_ANALYTICS_ID);
gtag("js", new Date());
gtag("config", GA_ANALYTICS_ID);
// Adding the script tag dynamically to the DOM.
const script = document.createElement('script');
const script = document.createElement("script");
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ANALYTICS_ID}`;
script.async = true;
document.body.appendChild(script);
@@ -85,15 +86,15 @@ const getConfig = () => {
},
language: {
default: 'en',
default: "en",
translations: {
en: {
consentModal: {
title: 'We use cookies',
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',
"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: `

View File

@@ -1,18 +1,17 @@
import { useEffect } from 'react';
import useLocalStorage from './useLocalStorage';
import { useEffect } from "react";
import useLocalStorage from "./useLocalStorage";
export default function useColorMode() {
const [colorMode, setColorMode] = useLocalStorage('color-theme', 'light');
const [colorMode, setColorMode] = useLocalStorage("color-theme", "light");
useEffect(() => {
const className = 'dark';
const className = "dark";
const bodyClass = window.document.body.classList;
colorMode === 'dark'
colorMode === "dark"
? bodyClass.add(className)
: bodyClass.remove(className);
}, [colorMode]);
return [colorMode, setColorMode];
};
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from "react";
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);

View File

@@ -1,10 +1,10 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
export const useIsLandingPage = () => {
const location = useLocation();
return useMemo(() => {
return location.pathname === '/';
return location.pathname === "/";
}, [location]);
};

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from "react";
type SetValue<T> = T | ((val: T) => T);
function useLocalStorage<T>(
key: string,
initialValue: T
initialValue: T,
): [T, (value: SetValue<T>) => void] {
// State to store our value
// Pass initial state function to useState so logic is only executed once
@@ -26,7 +26,7 @@ function useLocalStorage<T>(
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
typeof storedValue === 'function'
typeof storedValue === "function"
? storedValue(storedValue)
: storedValue;
// Save state

View File

@@ -1,8 +1,8 @@
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import * as React from 'react';
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const Accordion = AccordionPrimitive.Root;
@@ -10,25 +10,29 @@ const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn('border-b', className)} {...props} />
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className='flex'>
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180',
className
"flex flex-1 items-center justify-between py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className='h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200' />
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
@@ -40,10 +44,10 @@ const AccordionContent = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className='overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down'
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;

View File

@@ -1,44 +1,59 @@
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: 'bg-background text-foreground',
default: "bg-background text-foreground",
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
}
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role='alert' className={cn(alertVariants({ variant }), className)} {...props} />
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn('mb-1 font-medium leading-none tracking-tight', className)} {...props} />
)
);
AlertTitle.displayName = 'AlertTitle';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
)
);
AlertDescription.displayName = 'AlertDescription';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertDescription, AlertTitle };

View File

@@ -1,7 +1,7 @@
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import * as React from 'react';
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
@@ -9,7 +9,10 @@ const Avatar = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
@@ -19,7 +22,11 @@ const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
@@ -29,7 +36,10 @@ const AvatarFallback = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
className={cn(
"bg-muted flex h-full w-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
));

View File

@@ -1,33 +1,37 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
}
},
);
export interface ButtonProps
@@ -38,10 +42,16 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
}
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = 'Button';
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -1,59 +1,99 @@
import * as React from 'react';
import * as React from "react";
import { cva, VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
import { cva, VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const cardVariants = cva('rounded-xl border shadow hover:shadow-lg transition-all duration-300 xur', {
variants: {
variant: {
default: 'bg-card text-card-foreground',
accent: 'bg-card-accent text-card-accent-foreground hover:scale-[1.02]',
faded: 'text-card-faded-foreground scale-95 opacity-50',
bento: 'bg-card-subtle text-card-subtle-foreground hover:scale-[1.02] border-none shadow-none',
const cardVariants = cva(
"rounded-xl border shadow hover:shadow-lg transition-all duration-300 xur",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
accent: "bg-card-accent text-card-accent-foreground hover:scale-[1.02]",
faded: "text-card-faded-foreground scale-95 opacity-50",
bento:
"bg-card-subtle text-card-subtle-foreground hover:scale-[1.02] border-none shadow-none",
},
},
},
});
);
interface CardProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof cardVariants> {}
interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, variant = 'default', ...props }, ref) => (
<div ref={ref} className={cn(cardVariants({ variant, className }))} {...props} />
)
({ className, variant = "default", ...props }, ref) => (
<div
ref={ref}
className={cn(cardVariants({ variant, className }))}
{...props}
/>
),
);
Card.displayName = 'Card';
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
);
CardHeader.displayName = 'CardHeader';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
)
);
CardTitle.displayName = 'CardTitle';
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
)
);
CardDescription.displayName = 'CardDescription';
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
);
CardContent.displayName = 'CardContent';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

View File

@@ -1,8 +1,8 @@
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import * as React from 'react';
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
@@ -11,13 +11,15 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
"border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground peer h-4 w-4 shrink-0 rounded-sm border shadow focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className='h-4 w-4' />
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));

View File

@@ -1,8 +1,8 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import * as React from 'react';
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
@@ -25,17 +25,18 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className='ml-auto' />
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -44,13 +45,14 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
className
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -61,9 +63,9 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
className
"bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
@@ -80,9 +82,9 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
className
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
@@ -96,21 +98,22 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className='h-4 w-4' />
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@@ -119,14 +122,14 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className='h-2 w-2 fill-current' />
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
@@ -142,7 +145,11 @@ const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
@@ -154,16 +161,24 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,

View File

@@ -1,6 +1,6 @@
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import {
Controller,
FormProvider,
@@ -8,10 +8,10 @@ import {
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form';
} from "react-hook-form";
import { cn } from '../../lib/utils';
import { Label } from './label';
import { cn } from "../../lib/utils";
import { Label } from "./label";
const Form = FormProvider;
@@ -22,7 +22,9 @@ type FormFieldContextValue<
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
@@ -45,7 +47,7 @@ const useFormField = () => {
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
@@ -64,20 +66,23 @@ type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
FormItem.displayName = 'FormItem';
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
@@ -86,66 +91,87 @@ const FormLabel = React.forwardRef<
const { error, formItemId } = useFormField();
return (
<Label ref={ref} className={cn(error && 'text-destructive', className)} htmlFor={formItemId} {...props} />
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-[0.8rem] text-muted-foreground', className)}
{...props}
/>
);
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-muted-foreground text-[0.8rem]", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : children;
if (!body) {
return null;
}
);
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : children;
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-destructive text-[0.8rem] font-medium", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-[0.8rem] font-medium text-destructive', className)}
{...props}
>
{body}
</p>
);
}
);
FormMessage.displayName = 'FormMessage';
export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useFormField };
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
};

View File

@@ -1,22 +1,22 @@
import * as React from 'react';
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
"border-input file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
}
},
);
Input.displayName = 'Input';
Input.displayName = "Input";
export { Input };

View File

@@ -1,20 +1,25 @@
'use client';
"use client";
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;

View File

@@ -1,7 +1,7 @@
import * as ProgressPrimitive from '@radix-ui/react-progress';
import * as React from 'react';
import * as ProgressPrimitive from "@radix-ui/react-progress";
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
@@ -9,11 +9,14 @@ const Progress = React.forwardRef<
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className='h-full w-full flex-1 bg-primary transition-all'
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

View File

@@ -1,8 +1,8 @@
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import * as React from 'react';
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const Select = SelectPrimitive.Root;
@@ -17,14 +17,14 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
"border-input ring-offset-background data-[placeholder]:text-muted-foreground focus:ring-ring flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className='h-4 w-4 opacity-50' />
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
@@ -36,10 +36,13 @@ const SelectScrollUpButton = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className='h-4 w-4' />
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
@@ -50,26 +53,30 @@ const SelectScrollDownButton = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className='h-4 w-4' />
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
@@ -77,9 +84,9 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
@@ -96,7 +103,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
@@ -109,14 +116,14 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className='absolute right-2 flex h-3.5 w-3.5 items-center justify-center'>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className='h-4 w-4' />
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
@@ -128,7 +135,11 @@ const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
<SelectPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;

View File

@@ -1,24 +1,29 @@
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import * as React from 'react';
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
));
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -1,9 +1,9 @@
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import * as React from 'react';
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const Sheet = SheetPrimitive.Root;
@@ -19,8 +19,8 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className,
)}
{...props}
ref={ref}
@@ -29,56 +29,76 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: 'right',
side: "right",
},
}
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Close className='absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary'>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
)
);
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
@@ -86,7 +106,7 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
className={cn("text-foreground text-lg font-semibold", className)}
{...props}
/>
));
@@ -98,7 +118,7 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));

View File

@@ -1,7 +1,7 @@
import * as SwitchPrimitives from '@radix-ui/react-switch';
import * as React from 'react';
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
@@ -9,15 +9,15 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
"focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
"bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>

View File

@@ -1,21 +1,22 @@
import * as React from 'react';
import * as React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };

View File

@@ -1,4 +1,4 @@
import { type Task } from 'wasp/entities';
import { type Task } from "wasp/entities";
import {
createTask,
@@ -7,34 +7,45 @@ import {
getAllTasksByUser,
updateTask,
useQuery,
} from 'wasp/client/operations';
} from "wasp/client/operations";
import { Loader2, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Button } from '../components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { Checkbox } from '../components/ui/checkbox';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { cn } from '../lib/utils';
import type { GeneratedSchedule, Task as ScheduleTask, TaskItem, TaskPriority } from './schedule';
import { Loader2, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "../components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../components/ui/card";
import { Checkbox } from "../components/ui/checkbox";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { cn } from "../lib/utils";
import type {
GeneratedSchedule,
Task as ScheduleTask,
TaskItem,
TaskPriority,
} from "./schedule";
export default function DemoAppPage() {
return (
<div className='py-10 lg:mt-10'>
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
<div className='mx-auto max-w-4xl text-center'>
<h2 className='mt-2 text-4xl font-bold tracking-tight text-foreground sm:text-5xl'>
<span className='text-primary'>AI</span> Day Scheduler
<div className="py-10 lg:mt-10">
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<div className="mx-auto max-w-4xl text-center">
<h2 className="text-foreground mt-2 text-4xl font-bold tracking-tight sm:text-5xl">
<span className="text-primary">AI</span> Day Scheduler
</h2>
</div>
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-muted-foreground'>
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!
<p className="text-muted-foreground mx-auto mt-6 max-w-2xl text-center text-lg leading-8">
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!
</p>
{/* begin AI-powered Todo List */}
<Card className='my-8 bg-muted/10'>
<CardContent className='sm:w-[90%] md:w-[70%] lg:w-[50%] py-10 px-6 mx-auto my-8 space-y-10'>
<Card className="bg-muted/10 my-8">
<CardContent className="mx-auto my-8 space-y-10 px-6 py-10 sm:w-[90%] md:w-[70%] lg:w-[50%]">
<NewTaskForm handleCreateTask={createTask} />
</CardContent>
</Card>
@@ -44,82 +55,87 @@ export default function DemoAppPage() {
);
}
function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask }) {
const [description, setDescription] = useState<string>('');
function NewTaskForm({
handleCreateTask,
}: {
handleCreateTask: typeof createTask;
}) {
const [description, setDescription] = useState<string>("");
const [todaysHours, setTodaysHours] = useState<number>(8);
const [response, setResponse] = useState<GeneratedSchedule | null>({
tasks: [
{
name: 'Respond to emails',
priority: 'high' as TaskPriority,
name: "Respond to emails",
priority: "high" as TaskPriority,
},
{
name: 'Learn WASP',
priority: 'low' as TaskPriority,
name: "Learn WASP",
priority: "low" as TaskPriority,
},
{
name: 'Read a book',
priority: 'medium' as TaskPriority,
name: "Read a book",
priority: "medium" as TaskPriority,
},
],
taskItems: [
{
description: 'Read introduction and chapter 1',
description: "Read introduction and chapter 1",
time: 0.5,
taskName: 'Read a book',
taskName: "Read a book",
},
{
description: 'Read chapter 2 and take notes',
description: "Read chapter 2 and take notes",
time: 0.3,
taskName: 'Read a book',
taskName: "Read a book",
},
{
description: 'Read chapter 3 and summarize key points',
description: "Read chapter 3 and summarize key points",
time: 0.2,
taskName: 'Read a book',
taskName: "Read a book",
},
{
description: 'Check and respond to important emails',
description: "Check and respond to important emails",
time: 1,
taskName: 'Respond to emails',
taskName: "Respond to emails",
},
{
description: 'Organize and prioritize remaining emails',
description: "Organize and prioritize remaining emails",
time: 0.5,
taskName: 'Respond to emails',
taskName: "Respond to emails",
},
{
description: 'Draft responses to urgent emails',
description: "Draft responses to urgent emails",
time: 0.5,
taskName: 'Respond to emails',
taskName: "Respond to emails",
},
{
description: 'Watch tutorial video on WASP',
description: "Watch tutorial video on WASP",
time: 0.5,
taskName: 'Learn WASP',
taskName: "Learn WASP",
},
{
description: 'Complete online quiz on the basics of WASP',
description: "Complete online quiz on the basics of WASP",
time: 1.5,
taskName: 'Learn WASP',
taskName: "Learn WASP",
},
{
description: 'Review quiz answers and clarify doubts',
description: "Review quiz answers and clarify doubts",
time: 1,
taskName: 'Learn WASP',
taskName: "Learn WASP",
},
],
});
const [isPlanGenerating, setIsPlanGenerating] = useState<boolean>(false);
const { data: tasks, isLoading: isTasksLoading } = useQuery(getAllTasksByUser);
const { data: tasks, isLoading: isTasksLoading } =
useQuery(getAllTasksByUser);
const handleSubmit = async () => {
try {
await handleCreateTask({ description });
setDescription('');
setDescription("");
} catch (err: any) {
window.alert('Error: ' + (err.message || 'Something went wrong'));
window.alert("Error: " + (err.message || "Something went wrong"));
}
};
@@ -133,45 +149,47 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
setResponse(response as unknown as GeneratedSchedule);
}
} catch (err: any) {
window.alert('Error: ' + (err.message || 'Something went wrong'));
window.alert("Error: " + (err.message || "Something went wrong"));
} finally {
setIsPlanGenerating(false);
}
};
return (
<div className='flex flex-col justify-center gap-10'>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-3'>
<div className="flex flex-col justify-center gap-10">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<Input
type='text'
id='description'
className='flex-1'
placeholder='Enter task description'
type="text"
id="description"
className="flex-1"
placeholder="Enter task description"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
handleSubmit();
}
}}
/>
<Button
type='button'
type="button"
onClick={handleSubmit}
disabled={!description}
variant='default'
size='default'
variant="default"
size="default"
>
Add Task
</Button>
</div>
</div>
<div className='space-y-10 col-span-full'>
{isTasksLoading && <div className='text-muted-foreground'>Loading...</div>}
<div className="col-span-full space-y-10">
{isTasksLoading && (
<div className="text-muted-foreground">Loading...</div>
)}
{tasks!! && tasks.length > 0 ? (
<div className='space-y-4'>
<div className="space-y-4">
{tasks.map((task: Task) => (
<Todo
key={task.id}
@@ -181,18 +199,21 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
time={task.time}
/>
))}
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-3'>
<Label htmlFor='time' className='text-sm text-muted-foreground text-nowrap font-semibold'>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<Label
htmlFor="time"
className="text-muted-foreground text-nowrap text-sm font-semibold"
>
How many hours will you work today?
</Label>
<Input
type='number'
id='time'
type="number"
id="time"
step={0.5}
min={1}
max={24}
className='min-w-[7rem] text-center'
className="min-w-[7rem] text-center"
value={todaysHours}
onChange={(e) => setTodaysHours(+e.currentTarget.value)}
/>
@@ -200,32 +221,36 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
</div>
</div>
) : (
<div className='text-muted-foreground text-center'>Add tasks to begin</div>
<div className="text-muted-foreground text-center">
Add tasks to begin
</div>
)}
</div>
<Button
type='button'
type="button"
disabled={isPlanGenerating || tasks?.length === 0}
onClick={() => handleGeneratePlan()}
variant='default'
size='default'
className='w-full'
data-testid='generate-schedule-button'
variant="default"
size="default"
className="w-full"
data-testid="generate-schedule-button"
>
{isPlanGenerating ? (
<>
<Loader2 className='inline-block mr-2 animate-spin' />
<Loader2 className="mr-2 inline-block animate-spin" />
Generating...
</>
) : (
'Generate Schedule'
"Generate Schedule"
)}
</Button>
{!!response && (
<div className='flex flex-col'>
<h3 className='text-lg font-semibold text-foreground mb-4'>Today's Schedule</h3>
<div className="flex flex-col">
<h3 className="text-foreground mb-4 text-lg font-semibold">
Today's Schedule
</h3>
<Schedule schedule={response} />
</div>
)}
@@ -233,7 +258,7 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
);
}
type TodoProps = Pick<Task, 'id' | 'isDone' | 'description' | 'time'>;
type TodoProps = Pick<Task, "id" | "isDone" | "description" | "time">;
function Todo({ id, isDone, description, time }: TodoProps) {
const handleCheckboxChange = async (checked: boolean) => {
@@ -255,53 +280,53 @@ function Todo({ id, isDone, description, time }: TodoProps) {
};
return (
<Card className='p-4'>
<div className='flex items-center justify-between w-full'>
<div className='flex items-center justify-between gap-5 w-full'>
<div className='flex items-center gap-3'>
<Card className="p-4">
<div className="flex w-full items-center justify-between">
<div className="flex w-full items-center justify-between gap-5">
<div className="flex items-center gap-3">
<Checkbox
checked={isDone}
onCheckedChange={handleCheckboxChange}
className='data-[state=checked]:bg-primary data-[state=checked]:border-primary'
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<span
className={cn('text-foreground', {
'line-through text-muted-foreground': isDone,
className={cn("text-foreground", {
"text-muted-foreground line-through": isDone,
})}
>
{description}
</span>
</div>
<div className='flex items-center gap-2'>
<div className="flex items-center gap-2">
<Input
id='time'
type='number'
id="time"
type="number"
min={0.5}
step={0.5}
className={cn('w-18 h-8 text-center text-xs', {
'pointer-events-none opacity-50': isDone,
className={cn("w-18 h-8 text-center text-xs", {
"pointer-events-none opacity-50": isDone,
})}
value={time}
onChange={handleTimeChange}
/>
<span
className={cn('italic text-muted-foreground text-xs', {
'text-muted-foreground': isDone,
className={cn("text-muted-foreground text-xs italic", {
"text-muted-foreground": isDone,
})}
>
hrs
</span>
</div>
</div>
<div className='flex items-center justify-end w-15'>
<div className="w-15 flex items-center justify-end">
<Button
variant='ghost'
size='sm'
variant="ghost"
size="sm"
onClick={handleDeleteClick}
title='Remove task'
className='p-1 h-auto text-destructive hover:text-destructive/80'
title="Remove task"
className="text-destructive hover:text-destructive/80 h-auto p-1"
>
<Trash2 size='20' />
<Trash2 size="20" />
</Button>
</div>
</div>
@@ -311,56 +336,76 @@ function Todo({ id, isDone, description, time }: TodoProps) {
function Schedule({ schedule }: { schedule: GeneratedSchedule }) {
return (
<div className='flex flex-col gap-6 py-6' data-testid='schedule'>
<div className='space-y-4'>
<div className="flex flex-col gap-6 py-6" data-testid="schedule">
<div className="space-y-4">
{!!schedule.tasks ? (
schedule.tasks
.map((task) => <TaskCard key={task.name} task={task} taskItems={schedule.taskItems} />)
.map((task) => (
<TaskCard
key={task.name}
task={task}
taskItems={schedule.taskItems}
/>
))
.sort((a, b) => {
const priorityOrder: TaskPriority[] = ['low', 'medium', 'high'];
const priorityOrder: TaskPriority[] = ["low", "medium", "high"];
if (a.props.task.priority && b.props.task.priority) {
return (
priorityOrder.indexOf(b.props.task.priority) - priorityOrder.indexOf(a.props.task.priority)
priorityOrder.indexOf(b.props.task.priority) -
priorityOrder.indexOf(a.props.task.priority)
);
} else {
return 0;
}
})
) : (
<div className='text-muted-foreground text-center'>OpenAI didn't return any Tasks. Try again.</div>
<div className="text-muted-foreground text-center">
OpenAI didn't return any Tasks. Try again.
</div>
)}
</div>
</div>
);
}
function TaskCard({ task, taskItems }: { task: ScheduleTask; taskItems: TaskItem[] }) {
function TaskCard({
task,
taskItems,
}: {
task: ScheduleTask;
taskItems: TaskItem[];
}) {
const taskPriorityToColorMap: Record<TaskPriority, string> = {
high: 'bg-destructive/10 border-destructive/20 text-red-500',
medium: 'bg-warning/10 border-warning/20 text-warning',
low: 'bg-success/10 border-success/20 text-success',
high: "bg-destructive/10 border-destructive/20 text-red-500",
medium: "bg-warning/10 border-warning/20 text-warning",
low: "bg-success/10 border-success/20 text-success",
};
return (
<Card className={cn('border-2', taskPriorityToColorMap[task.priority])}>
<CardHeader className='pb-3'>
<CardTitle className='flex items-center justify-between text-base'>
<Card className={cn("border-2", taskPriorityToColorMap[task.priority])}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-base">
<span>{task.name}</span>
<span className='text-xs font-medium italic'> {task.priority} priority</span>
<span className="text-xs font-medium italic">
{" "}
{task.priority} priority
</span>
</CardTitle>
</CardHeader>
<CardContent className='pt-0'>
<CardContent className="pt-0">
{!!taskItems ? (
<ul className='space-y-2'>
<ul className="space-y-2">
{taskItems.map((taskItem) => {
if (taskItem.taskName === task.name) {
return <TaskCardItem key={taskItem.description} {...taskItem} />;
return (
<TaskCardItem key={taskItem.description} {...taskItem} />
);
}
return null;
})}
</ul>
) : (
<div className='text-muted-foreground text-center'>
<div className="text-muted-foreground text-center">
OpenAI didn't return any Task Items. Try again.
</div>
)}
@@ -373,7 +418,7 @@ function TaskCardItem({ description, time }: TaskItem) {
const [isDone, setIsDone] = useState<boolean>(false);
const formattedTime = useMemo(() => {
if (time === 0) return '0min';
if (time === 0) return "0min";
const hours = Math.floor(time);
const minutes = Math.round((time - hours) * 60);
@@ -381,32 +426,32 @@ function TaskCardItem({ description, time }: TaskItem) {
if (hours > 0) parts.push(`${hours}hr`);
if (minutes > 0) parts.push(`${minutes}min`);
return parts.join(' ');
return parts.join(" ");
}, [time]);
const handleCheckedChange = (checked: boolean | 'indeterminate') => {
const handleCheckedChange = (checked: boolean | "indeterminate") => {
setIsDone(checked === true);
};
return (
<li className='flex items-center justify-between gap-4 p-2 rounded-md'>
<div className='flex items-center gap-3 flex-1'>
<li className="flex items-center justify-between gap-4 rounded-md p-2">
<div className="flex flex-1 items-center gap-3">
<Checkbox
checked={isDone}
onCheckedChange={handleCheckedChange}
className='data-[state=checked]:bg-primary data-[state=checked]:border-primary'
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<span
className={cn('leading-tight text-sm', {
'line-through text-muted-foreground opacity-50': isDone,
className={cn("text-sm leading-tight", {
"text-muted-foreground line-through opacity-50": isDone,
})}
>
{description}
</span>
</div>
<span
className={cn('text-sm text-muted-foreground', {
'line-through opacity-50': isDone,
className={cn("text-muted-foreground text-sm", {
"line-through opacity-50": isDone,
})}
>
{formattedTime}

View File

@@ -1,7 +1,7 @@
import type { PrismaPromise } from '@prisma/client';
import OpenAI from 'openai';
import type { GptResponse, Task, User } from 'wasp/entities';
import { HttpError, prisma } from 'wasp/server';
import type { PrismaPromise } from "@prisma/client";
import OpenAI from "openai";
import type { GptResponse, Task, User } from "wasp/entities";
import { HttpError, prisma } from "wasp/server";
import type {
CreateTask,
DeleteTask,
@@ -9,18 +9,18 @@ import type {
GetAllTasksByUser,
GetGptResponses,
UpdateTask,
} from 'wasp/server/operations';
import * as z from 'zod';
import { SubscriptionStatus } from '../payment/plans';
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
import { GeneratedSchedule, TaskPriority } from './schedule';
} from "wasp/server/operations";
import * as z from "zod";
import { SubscriptionStatus } from "../payment/plans";
import { ensureArgsSchemaOrThrowHttpError } from "../server/validation";
import { GeneratedSchedule, TaskPriority } from "./schedule";
const openAi = setUpOpenAi();
function setUpOpenAi(): OpenAI {
if (process.env.OPENAI_API_KEY) {
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
} else {
throw new Error('OpenAI API key is not set');
throw new Error("OpenAI API key is not set");
}
}
@@ -31,15 +31,21 @@ const generateGptResponseInputSchema = z.object({
type GenerateGptResponseInput = z.infer<typeof generateGptResponseInputSchema>;
export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput, GeneratedSchedule> = async (
rawArgs,
context
) => {
export const generateGptResponse: GenerateGptResponse<
GenerateGptResponseInput,
GeneratedSchedule
> = async (rawArgs, context) => {
if (!context.user) {
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
throw new HttpError(
401,
"Only authenticated users are allowed to perform this operation",
);
}
const { hours } = ensureArgsSchemaOrThrowHttpError(generateGptResponseInputSchema, rawArgs);
const { hours } = ensureArgsSchemaOrThrowHttpError(
generateGptResponseInputSchema,
rawArgs,
);
const tasks = await context.entities.Task.findMany({
where: {
user: {
@@ -48,10 +54,13 @@ export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput,
},
});
console.log('Calling open AI api');
console.log("Calling open AI api");
const generatedSchedule = await generateScheduleWithGpt(tasks, hours);
if (generatedSchedule === null) {
throw new HttpError(500, 'Encountered a problem in communication with OpenAI');
throw new HttpError(
500,
"Encountered a problem in communication with OpenAI",
);
}
const createResponse = context.entities.GptResponse.create({
@@ -83,11 +92,11 @@ export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput,
});
transactions.push(decrementCredit);
} else {
throw new HttpError(402, 'User has not paid or is out of credits');
throw new HttpError(402, "User has not paid or is out of credits");
}
}
console.log('Decrementing credits and saving response');
console.log("Decrementing credits and saving response");
await prisma.$transaction(transactions);
return generatedSchedule;
@@ -106,12 +115,18 @@ const createTaskInputSchema = z.object({
type CreateTaskInput = z.infer<typeof createTaskInputSchema>;
export const createTask: CreateTask<CreateTaskInput, Task> = async (rawArgs, context) => {
export const createTask: CreateTask<CreateTaskInput, Task> = async (
rawArgs,
context,
) => {
if (!context.user) {
throw new HttpError(401);
}
const { description } = ensureArgsSchemaOrThrowHttpError(createTaskInputSchema, rawArgs);
const { description } = ensureArgsSchemaOrThrowHttpError(
createTaskInputSchema,
rawArgs,
);
const task = await context.entities.Task.create({
data: {
@@ -131,12 +146,18 @@ const updateTaskInputSchema = z.object({
type UpdateTaskInput = z.infer<typeof updateTaskInputSchema>;
export const updateTask: UpdateTask<UpdateTaskInput, Task> = async (rawArgs, context) => {
export const updateTask: UpdateTask<UpdateTaskInput, Task> = async (
rawArgs,
context,
) => {
if (!context.user) {
throw new HttpError(401);
}
const { id, isDone, time } = ensureArgsSchemaOrThrowHttpError(updateTaskInputSchema, rawArgs);
const { id, isDone, time } = ensureArgsSchemaOrThrowHttpError(
updateTaskInputSchema,
rawArgs,
);
const task = await context.entities.Task.update({
where: {
@@ -160,12 +181,18 @@ const deleteTaskInputSchema = z.object({
type DeleteTaskInput = z.infer<typeof deleteTaskInputSchema>;
export const deleteTask: DeleteTask<DeleteTaskInput, Task> = async (rawArgs, context) => {
export const deleteTask: DeleteTask<DeleteTaskInput, Task> = async (
rawArgs,
context,
) => {
if (!context.user) {
throw new HttpError(401);
}
const { id } = ensureArgsSchemaOrThrowHttpError(deleteTaskInputSchema, rawArgs);
const { id } = ensureArgsSchemaOrThrowHttpError(
deleteTaskInputSchema,
rawArgs,
);
const task = await context.entities.Task.delete({
where: {
@@ -181,7 +208,10 @@ export const deleteTask: DeleteTask<DeleteTaskInput, Task> = async (rawArgs, con
//#endregion
//#region Queries
export const getGptResponses: GetGptResponses<void, GptResponse[]> = async (_args, context) => {
export const getGptResponses: GetGptResponses<void, GptResponse[]> = async (
_args,
context,
) => {
if (!context.user) {
throw new HttpError(401);
}
@@ -194,7 +224,10 @@ export const getGptResponses: GetGptResponses<void, GptResponse[]> = async (_arg
});
};
export const getAllTasksByUser: GetAllTasksByUser<void, Task[]> = async (_args, context) => {
export const getAllTasksByUser: GetAllTasksByUser<void, Task[]> = async (
_args,
context,
) => {
if (!context.user) {
throw new HttpError(401);
}
@@ -205,96 +238,102 @@ export const getAllTasksByUser: GetAllTasksByUser<void, Task[]> = async (_args,
},
},
orderBy: {
createdAt: 'desc',
createdAt: "desc",
},
});
};
//#endregion
async function generateScheduleWithGpt(tasks: Task[], hours: number): Promise<GeneratedSchedule | null> {
async function generateScheduleWithGpt(
tasks: Task[],
hours: number,
): Promise<GeneratedSchedule | null> {
const parsedTasks = tasks.map(({ description, time }) => ({
description,
time,
}));
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.
model: "gpt-3.5-turbo", // you can use any model here, e.g. 'gpt-3.5-turbo', 'gpt-4', etc.
messages: [
{
role: 'system',
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.',
"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',
role: "user",
content: `I will work ${hours} hours today. Here are the tasks I have to complete: ${JSON.stringify(
parsedTasks
parsedTasks,
)}. Please help me plan my day by breaking the tasks down into actionable subtasks with time and priority status.`,
},
],
tools: [
{
type: 'function',
type: "function",
function: {
name: 'parseTodaysSchedule',
description: 'parses the days tasks and returns a schedule',
name: "parseTodaysSchedule",
description: "parses the days tasks and returns a schedule",
parameters: {
type: 'object',
type: "object",
properties: {
tasks: {
type: 'array',
description: 'Name of main tasks provided by user, ordered by priority',
type: "array",
description:
"Name of main tasks provided by user, ordered by priority",
items: {
type: 'object',
type: "object",
properties: {
name: {
type: 'string',
description: 'Name of main task provided by user',
type: "string",
description: "Name of main task provided by user",
},
priority: {
type: 'string',
enum: ['low', 'medium', 'high'] as TaskPriority[],
description: 'task priority',
type: "string",
enum: ["low", "medium", "high"] as TaskPriority[],
description: "task priority",
},
},
},
},
taskItems: {
type: 'array',
type: "array",
items: {
type: 'object',
type: "object",
properties: {
description: {
type: 'string',
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',
type: "number",
description:
"time allocated for a given subtask in hours, e.g. 0.5",
},
taskName: {
type: 'string',
description: 'name of main task related to subtask',
type: "string",
description: "name of main task related to subtask",
},
},
},
},
},
required: ['tasks', 'taskItems', 'time', 'priority'],
required: ["tasks", "taskItems", "time", "priority"],
},
},
},
],
tool_choice: {
type: 'function',
type: "function",
function: {
name: 'parseTodaysSchedule',
name: "parseTodaysSchedule",
},
},
temperature: 1,
});
const gptResponse = completion?.choices[0]?.message?.tool_calls?.[0]?.function.arguments;
const gptResponse =
completion?.choices[0]?.message?.tool_calls?.[0]?.function.arguments;
return gptResponse !== undefined ? JSON.parse(gptResponse) : null;
}

View File

@@ -1,4 +1,4 @@
export type TaskPriority = 'low' | 'medium' | 'high';
export type TaskPriority = "low" | "medium" | "high";
export type GeneratedSchedule = {
tasks: Task[]; // Main tasks provided by user, ordered by priority

View File

@@ -1,23 +1,27 @@
import { FormEvent, useEffect, useState } from 'react';
import { getAllFilesByUser, getDownloadFileSignedURL, useQuery } from 'wasp/client/operations';
import type { File } from 'wasp/entities';
import { Alert, AlertDescription } from '../components/ui/alert';
import { Button } from '../components/ui/button';
import { Card, CardContent, CardTitle } from '../components/ui/card';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Progress } from '../components/ui/progress';
import { cn } from '../lib/utils';
import { FormEvent, useEffect, useState } from "react";
import {
getAllFilesByUser,
getDownloadFileSignedURL,
useQuery,
} from "wasp/client/operations";
import type { File } from "wasp/entities";
import { Alert, AlertDescription } from "../components/ui/alert";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Progress } from "../components/ui/progress";
import { cn } from "../lib/utils";
import {
type FileUploadError,
type FileWithValidType,
uploadFileWithProgress,
validateFile,
} from './fileUploading';
import { ALLOWED_FILE_TYPES } from './validation';
} from "./fileUploading";
import { ALLOWED_FILE_TYPES } from "./validation";
export default function FileUploadPage() {
const [fileKeyForS3, setFileKeyForS3] = useState<File['key']>('');
const [fileKeyForS3, setFileKeyForS3] = useState<File["key"]>("");
const [uploadProgressPercent, setUploadProgressPercent] = useState<number>(0);
const [uploadError, setUploadError] = useState<FileUploadError | null>(null);
@@ -26,11 +30,12 @@ export default function FileUploadPage() {
// 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 }
);
const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } =
useQuery(
getDownloadFileSignedURL,
{ key: fileKeyForS3 },
{ enabled: false },
);
useEffect(() => {
allUserFiles.refetch();
@@ -41,17 +46,17 @@ export default function FileUploadPage() {
refetchDownloadUrl()
.then((urlQuery) => {
switch (urlQuery.status) {
case 'error':
console.error('Error fetching download URL', urlQuery.error);
alert('Error fetching download');
case "error":
console.error("Error fetching download URL", urlQuery.error);
alert("Error fetching download");
return;
case 'success':
window.open(urlQuery.data, '_blank');
case "success":
window.open(urlQuery.data, "_blank");
return;
}
})
.finally(() => {
setFileKeyForS3('');
setFileKeyForS3("");
});
}
}, [fileKeyForS3]);
@@ -62,16 +67,16 @@ export default function FileUploadPage() {
const formElement = e.target;
if (!(formElement instanceof HTMLFormElement)) {
throw new Error('Event target is not a form element');
throw new Error("Event target is not a form element");
}
const formData = new FormData(formElement);
const file = formData.get('file-upload');
const file = formData.get("file-upload");
if (!file || !(file instanceof File)) {
setUploadError({
message: 'Please select a file to upload.',
code: 'NO_FILE',
message: "Please select a file to upload.",
code: "NO_FILE",
});
return;
}
@@ -82,15 +87,20 @@ export default function FileUploadPage() {
return;
}
await uploadFileWithProgress({ file: file as FileWithValidType, setUploadProgressPercent });
await uploadFileWithProgress({
file: file as FileWithValidType,
setUploadProgressPercent,
});
formElement.reset();
allUserFiles.refetch();
} catch (error) {
console.error('Error uploading file:', 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',
error instanceof Error
? error.message
: "An unexpected error occurred while uploading the file.",
code: "UPLOAD_FAILED",
});
} finally {
setUploadProgressPercent(0);
@@ -98,81 +108,110 @@ export default function FileUploadPage() {
};
return (
<div className='py-10 lg:mt-10'>
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
<div className='mx-auto max-w-4xl text-center'>
<h2 className='mt-2 text-4xl font-bold tracking-tight text-foreground sm:text-5xl'>
<span className='text-primary'>AWS</span> File Upload
<div className="py-10 lg:mt-10">
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<div className="mx-auto max-w-4xl text-center">
<h2 className="text-foreground mt-2 text-4xl font-bold tracking-tight sm:text-5xl">
<span className="text-primary">AWS</span> File Upload
</h2>
</div>
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-muted-foreground'>
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 🤝
<p className="text-muted-foreground mx-auto mt-6 max-w-2xl text-center text-lg leading-8">
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 🤝
</p>
<Card className='my-8'>
<CardContent className='space-y-10 my-10 py-8 px-4 mx-auto sm:max-w-lg'>
<form onSubmit={handleUpload} className='flex flex-col gap-4'>
<div className='space-y-2'>
<Label htmlFor='file-upload' className='text-sm font-medium text-foreground'>
<Card className="my-8">
<CardContent className="mx-auto my-10 space-y-10 px-4 py-8 sm:max-w-lg">
<form onSubmit={handleUpload} className="flex flex-col gap-4">
<div className="space-y-2">
<Label
htmlFor="file-upload"
className="text-foreground text-sm font-medium"
>
Select a file to upload
</Label>
<Input
type='file'
id='file-upload'
name='file-upload'
accept={ALLOWED_FILE_TYPES.join(',')}
type="file"
id="file-upload"
name="file-upload"
accept={ALLOWED_FILE_TYPES.join(",")}
onChange={() => setUploadError(null)}
className='cursor-pointer'
className="cursor-pointer"
/>
</div>
<div className='space-y-2'>
<Button type='submit' disabled={uploadProgressPercent > 0} className='w-full'>
{uploadProgressPercent > 0 ? `Uploading ${uploadProgressPercent}%` : 'Upload'}
<div className="space-y-2">
<Button
type="submit"
disabled={uploadProgressPercent > 0}
className="w-full"
>
{uploadProgressPercent > 0
? `Uploading ${uploadProgressPercent}%`
: "Upload"}
</Button>
{uploadProgressPercent > 0 && <Progress value={uploadProgressPercent} className='w-full' />}
{uploadProgressPercent > 0 && (
<Progress value={uploadProgressPercent} className="w-full" />
)}
</div>
{uploadError && (
<Alert variant='destructive'>
<Alert variant="destructive">
<AlertDescription>{uploadError.message}</AlertDescription>
</Alert>
)}
</form>
<div className='border-b-2 border-border'></div>
<div className='space-y-4 col-span-full'>
<CardTitle className='text-xl font-bold text-foreground'>Uploaded Files</CardTitle>
{allUserFiles.isLoading && <p className='text-muted-foreground'>Loading...</p>}
<div className="border-border border-b-2"></div>
<div className="col-span-full space-y-4">
<CardTitle className="text-foreground text-xl font-bold">
Uploaded Files
</CardTitle>
{allUserFiles.isLoading && (
<p className="text-muted-foreground">Loading...</p>
)}
{allUserFiles.error && (
<Alert variant='destructive'>
<AlertDescription>Error: {allUserFiles.error.message}</AlertDescription>
<Alert variant="destructive">
<AlertDescription>
Error: {allUserFiles.error.message}
</AlertDescription>
</Alert>
)}
{!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? (
<div className='space-y-3'>
{!!allUserFiles.data &&
allUserFiles.data.length > 0 &&
!allUserFiles.isLoading ? (
<div className="space-y-3">
{allUserFiles.data.map((file: File) => (
<Card key={file.key} className='p-4'>
<Card key={file.key} className="p-4">
<div
className={cn(
'flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3',
"flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center",
{
'opacity-70': file.key === fileKeyForS3 && isDownloadUrlLoading,
}
"opacity-70":
file.key === fileKeyForS3 && isDownloadUrlLoading,
},
)}
>
<p className='text-foreground font-medium'>{file.name}</p>
<p className="text-foreground font-medium">
{file.name}
</p>
<Button
onClick={() => setFileKeyForS3(file.key)}
disabled={file.key === fileKeyForS3 && isDownloadUrlLoading}
variant='outline'
size='sm'
disabled={
file.key === fileKeyForS3 && isDownloadUrlLoading
}
variant="outline"
size="sm"
>
{file.key === fileKeyForS3 && isDownloadUrlLoading ? 'Loading...' : 'Download'}
{file.key === fileKeyForS3 && isDownloadUrlLoading
? "Loading..."
: "Download"}
</Button>
</div>
</Card>
))}
</div>
) : (
<p className='text-muted-foreground text-center'>No files uploaded yet :(</p>
<p className="text-muted-foreground text-center">
No files uploaded yet :(
</p>
)}
</div>
</CardContent>

View File

@@ -1,55 +1,66 @@
import { createFile } from 'wasp/client/operations';
import axios from 'axios';
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from './validation';
import axios from "axios";
import { createFile } from "wasp/client/operations";
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from "./validation";
export type FileWithValidType = Omit<File, 'type'> & { type: AllowedFileType };
export type FileWithValidType = Omit<File, "type"> & { type: AllowedFileType };
type AllowedFileType = (typeof ALLOWED_FILE_TYPES)[number];
interface FileUploadProgress {
file: FileWithValidType;
setUploadProgressPercent: (percentage: number) => void;
}
export async function uploadFileWithProgress({ file, setUploadProgressPercent }: FileUploadProgress) {
const { s3UploadUrl, s3UploadFields } = await createFile({ fileType: file.type, fileName: file.name });
export async function uploadFileWithProgress({
file,
setUploadProgressPercent,
}: FileUploadProgress) {
const { s3UploadUrl, s3UploadFields } = await createFile({
fileType: file.type,
fileName: file.name,
});
const formData = getFileUploadFormData(file, s3UploadFields);
return axios.post(s3UploadUrl, formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const percentage = Math.round((progressEvent.loaded / progressEvent.total) * 100);
const percentage = Math.round(
(progressEvent.loaded / progressEvent.total) * 100,
);
setUploadProgressPercent(percentage);
}
},
});
}
function getFileUploadFormData(file: File, s3UploadFields: Record<string, string>) {
function getFileUploadFormData(
file: File,
s3UploadFields: Record<string, string>,
) {
const formData = new FormData();
Object.entries(s3UploadFields).forEach(([key, value]) => {
formData.append(key, value);
});
formData.append('file', file);
formData.append("file", file);
return formData;
}
export interface FileUploadError {
message: string;
code: 'NO_FILE' | 'INVALID_FILE_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED';
code: "NO_FILE" | "INVALID_FILE_TYPE" | "FILE_TOO_LARGE" | "UPLOAD_FAILED";
}
export function validateFile(file: File) {
if (file.size > MAX_FILE_SIZE_BYTES) {
return {
message: `File size exceeds ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB limit.`,
code: 'FILE_TOO_LARGE' as const,
code: "FILE_TOO_LARGE" as const,
};
}
if (!isAllowedFileType(file.type)) {
return {
message: `File type '${file.type}' is not supported.`,
code: 'INVALID_FILE_TYPE' as const,
code: "INVALID_FILE_TYPE" as const,
};
}

View File

@@ -1,15 +1,18 @@
import * as z from 'zod';
import { HttpError } from 'wasp/server';
import { type File } from 'wasp/entities';
import { type File } from "wasp/entities";
import { HttpError } from "wasp/server";
import {
type CreateFile,
type GetAllFilesByUser,
type GetDownloadFileSignedURL,
} from 'wasp/server/operations';
} from "wasp/server/operations";
import * as z from "zod";
import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils';
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
import { ALLOWED_FILE_TYPES } from './validation';
import { ensureArgsSchemaOrThrowHttpError } from "../server/validation";
import {
getDownloadFileSignedURLFromS3,
getUploadFileSignedURLFromS3,
} from "./s3Utils";
import { ALLOWED_FILE_TYPES } from "./validation";
const createFileInputSchema = z.object({
fileType: z.enum(ALLOWED_FILE_TYPES),
@@ -29,13 +32,17 @@ export const createFile: CreateFile<
throw new HttpError(401);
}
const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(
createFileInputSchema,
rawArgs,
);
const { s3UploadUrl, s3UploadFields, key } = await getUploadFileSignedURLFromS3({
fileType,
fileName,
userId: context.user.id,
});
const { s3UploadUrl, s3UploadFields, key } =
await getUploadFileSignedURLFromS3({
fileType,
fileName,
userId: context.user.id,
});
await context.entities.File.create({
data: {
@@ -53,7 +60,10 @@ export const createFile: CreateFile<
};
};
export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (_args, context) => {
export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (
_args,
context,
) => {
if (!context.user) {
throw new HttpError(401);
}
@@ -64,19 +74,26 @@ export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (_args,
},
},
orderBy: {
createdAt: 'desc',
createdAt: "desc",
},
});
};
const getDownloadFileSignedURLInputSchema = z.object({ key: z.string().nonempty() });
const getDownloadFileSignedURLInputSchema = z.object({
key: z.string().nonempty(),
});
type GetDownloadFileSignedURLInput = z.infer<typeof getDownloadFileSignedURLInputSchema>;
type GetDownloadFileSignedURLInput = z.infer<
typeof getDownloadFileSignedURLInputSchema
>;
export const getDownloadFileSignedURL: GetDownloadFileSignedURL<
GetDownloadFileSignedURLInput,
string
> = async (rawArgs, _context) => {
const { key } = ensureArgsSchemaOrThrowHttpError(getDownloadFileSignedURLInputSchema, rawArgs);
const { key } = ensureArgsSchemaOrThrowHttpError(
getDownloadFileSignedURLInputSchema,
rawArgs,
);
return await getDownloadFileSignedURLFromS3({ key });
};

View File

@@ -1,9 +1,9 @@
import * as path from 'path';
import { randomUUID } from 'crypto';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
import { MAX_FILE_SIZE_BYTES } from './validation';
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "crypto";
import * as path from "path";
import { MAX_FILE_SIZE_BYTES } from "./validation";
const s3Client = new S3Client({
region: process.env.AWS_S3_REGION,
@@ -19,23 +19,32 @@ type S3Upload = {
userId: string;
};
export const getUploadFileSignedURLFromS3 = async ({ fileName, fileType, userId }: S3Upload) => {
export const getUploadFileSignedURLFromS3 = async ({
fileName,
fileType,
userId,
}: S3Upload) => {
const key = getS3Key(fileName, userId);
const { url: s3UploadUrl, fields: s3UploadFields } = await createPresignedPost(s3Client, {
Bucket: process.env.AWS_S3_FILES_BUCKET!,
Key: key,
Conditions: [['content-length-range', 0, MAX_FILE_SIZE_BYTES]],
Fields: {
'Content-Type': fileType,
},
Expires: 3600,
});
const { url: s3UploadUrl, fields: s3UploadFields } =
await createPresignedPost(s3Client, {
Bucket: process.env.AWS_S3_FILES_BUCKET!,
Key: key,
Conditions: [["content-length-range", 0, MAX_FILE_SIZE_BYTES]],
Fields: {
"Content-Type": fileType,
},
Expires: 3600,
});
return { s3UploadUrl, key, s3UploadFields };
};
export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) => {
export const getDownloadFileSignedURLFromS3 = async ({
key,
}: {
key: string;
}) => {
const command = new GetObjectCommand({
Bucket: process.env.AWS_S3_FILES_BUCKET,
Key: key,

View File

@@ -1,10 +1,10 @@
// Set this to the max file size you want to allow (currently 5MB).
export const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
export const ALLOWED_FILE_TYPES = [
'image/jpeg',
'image/png',
'application/pdf',
'text/*',
'video/quicktime',
'video/mp4',
"image/jpeg",
"image/png",
"application/pdf",
"text/*",
"video/quicktime",
"video/mp4",
] as const;

View File

@@ -1,23 +1,23 @@
import HighlightedFeature from './components/HighlightedFeature';
import aiReadyDark from '../client/static/assets/aiready-dark.webp';
import aiReady from '../client/static/assets/aiready.webp';
import aiReadyDark from "../client/static/assets/aiready-dark.webp";
import aiReady from "../client/static/assets/aiready.webp";
import HighlightedFeature from "./components/HighlightedFeature";
export default function AIReady() {
return (
<HighlightedFeature
name='Example Feature Highlight'
description='Yo! Use this component to show off the most important features in your app.'
name="Example Feature Highlight"
description="Yo! Use this component to show off the most important features in your app."
highlightedComponent={<AIReadyExample />}
direction='row-reverse'
direction="row-reverse"
/>
);
}
const AIReadyExample = () => {
return (
<div className='w-full'>
<img src={aiReady} alt='AI Ready' className='dark:hidden' />
<img src={aiReadyDark} alt='AI Ready' className='hidden dark:block' />
<div className="w-full">
<img src={aiReady} alt="AI Ready" className="dark:hidden" />
<img src={aiReadyDark} alt="AI Ready" className="hidden dark:block" />
</div>
);
};

View File

@@ -1,16 +1,22 @@
import ExamplesCarousel from './components/ExamplesCarousel';
import FAQ from './components/FAQ';
import FeaturesGrid from './components/FeaturesGrid';
import Footer from './components/Footer';
import Hero from './components/Hero';
import Testimonials from './components/Testimonials';
import AIReady from './ExampleHighlightedFeature';
import { examples, faqs, features, footerNavigation, testimonials } from './contentSections';
import ExamplesCarousel from "./components/ExamplesCarousel";
import FAQ from "./components/FAQ";
import FeaturesGrid from "./components/FeaturesGrid";
import Footer from "./components/Footer";
import Hero from "./components/Hero";
import Testimonials from "./components/Testimonials";
import {
examples,
faqs,
features,
footerNavigation,
testimonials,
} from "./contentSections";
import AIReady from "./ExampleHighlightedFeature";
export default function LandingPage() {
return (
<div className='bg-background text-foreground'>
<main className='isolate'>
<div className="bg-background text-foreground">
<main className="isolate">
<Hero />
<ExamplesCarousel examples={examples} />
<AIReady />
@@ -22,4 +28,3 @@ export default function LandingPage() {
</div>
);
}

View File

@@ -1,20 +1,25 @@
import AstroLogo from '../logos/AstroLogo';
import OpenAILogo from '../logos/OpenAILogo';
import PrismaLogo from '../logos/PrismaLogo';
import SalesforceLogo from '../logos/SalesforceLogo';
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 (
<div className='mt-12 mx-auto max-w-7xl px-6 lg:px-8 flex flex-col items-between gap-y-6'>
<h2 className='mb-6 text-center font-semibold tracking-wide text-muted-foreground'>
<div className="items-between mx-auto mt-12 flex max-w-7xl flex-col gap-y-6 px-6 lg:px-8">
<h2 className="text-muted-foreground mb-6 text-center font-semibold tracking-wide">
Built with / Used by:
</h2>
<div className='mx-auto grid max-w-lg grid-cols-2 items-center gap-x-8 gap-y-12 sm:max-w-xl md:grid-cols-4 sm:gap-x-10 sm:gap-y-14 lg:mx-0 lg:max-w-none'>
{[<SalesforceLogo />, <PrismaLogo />, <AstroLogo />, <OpenAILogo />].map((logo, index) => (
<div className="mx-auto grid max-w-lg grid-cols-2 items-center gap-x-8 gap-y-12 sm:max-w-xl sm:gap-x-10 sm:gap-y-14 md:grid-cols-4 lg:mx-0 lg:max-w-none">
{[
<SalesforceLogo />,
<PrismaLogo />,
<AstroLogo />,
<OpenAILogo />,
].map((logo, index) => (
<div
key={index}
className='flex justify-center col-span-1 max-h-12 w-full object-contain opacity-80 hover:opacity-100 transition-opacity'
className="col-span-1 flex max-h-12 w-full justify-center object-contain opacity-80 transition-opacity hover:opacity-100"
>
{logo}
</div>

View File

@@ -1,5 +1,5 @@
import { forwardRef, useEffect, useRef, useState } from 'react';
import { Card, CardContent } from '../../components/ui/card';
import { forwardRef, useEffect, useRef, useState } from "react";
import { Card, CardContent } from "../../components/ui/card";
const EXAMPLES_CAROUSEL_INTERVAL = 3000;
const EXAMPLES_CAROUSEL_SCROLL_TIMEOUT = 200;
@@ -21,10 +21,13 @@ const ExamplesCarousel = ({ examples }: { examples: ExampleApp[] }) => {
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
observerRef.current = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting), {
threshold: 0.5,
rootMargin: '-200px 0px -100px 0px',
});
observerRef.current = new IntersectionObserver(
([entry]) => setIsInView(entry.isIntersecting),
{
threshold: 0.5,
rootMargin: "-200px 0px -100px 0px",
},
);
if (containerRef.current) {
observerRef.current.observe(containerRef.current);
@@ -55,17 +58,22 @@ const ExamplesCarousel = ({ examples }: { examples: ExampleApp[] }) => {
scrollTimeoutRef.current = setTimeout(() => {
if (scrollContainerRef.current) {
const scrollContainer = scrollContainerRef.current;
const targetCard = scrollContainer.children[currentExample] as HTMLElement | undefined;
const targetCard = scrollContainer.children[currentExample] as
| HTMLElement
| undefined;
if (targetCard) {
const containerRect = scrollContainer.getBoundingClientRect();
const cardRect = targetCard.getBoundingClientRect();
const scrollLeft =
targetCard.offsetLeft - scrollContainer.offsetLeft - containerRect.width / 2 + cardRect.width / 2;
targetCard.offsetLeft -
scrollContainer.offsetLeft -
containerRect.width / 2 +
cardRect.width / 2;
scrollContainer.scrollTo({
left: scrollLeft,
behavior: 'smooth',
behavior: "smooth",
});
}
}
@@ -98,12 +106,14 @@ const ExamplesCarousel = ({ examples }: { examples: ExampleApp[] }) => {
return (
<div
ref={containerRef}
className='relative w-screen left-1/2 -translate-x-1/2 flex flex-col items-center my-16'
className="relative left-1/2 my-16 flex w-screen -translate-x-1/2 flex-col items-center"
>
<h2 className='mb-6 text-center font-semibold tracking-wide text-muted-foreground'>Used by:</h2>
<div className='w-full max-w-full overflow-hidden'>
<h2 className="text-muted-foreground mb-6 text-center font-semibold tracking-wide">
Used by:
</h2>
<div className="w-full max-w-full overflow-hidden">
<div
className='flex overflow-x-auto no-scrollbar scroll-smooth pb-10 pt-4 gap-4 px-4 snap-x snap-mandatory'
className="no-scrollbar flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth px-4 pb-10 pt-4"
ref={scrollContainerRef}
>
{examples.map((example, index) => (
@@ -133,29 +143,35 @@ const ExampleCard = forwardRef<HTMLDivElement, ExampleCardProps>(
return (
<a
href={example.href}
target='_blank'
rel='noopener noreferrer'
className='flex-shrink-0 snap-center'
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 snap-center"
onMouseEnter={() => onMouseEnter(index)}
>
<Card
ref={ref}
className='overflow-hidden w-[280px] sm:w-[320px] md:w-[350px] transition-all duration-200 hover:scale-105'
variant={isCurrent ? 'default' : 'faded'}
className="w-[280px] overflow-hidden transition-all duration-200 hover:scale-105 sm:w-[320px] md:w-[350px]"
variant={isCurrent ? "default" : "faded"}
>
<CardContent className='p-0 h-full'>
<img src={example.imageSrc} alt={example.name} className='w-full h-auto aspect-video' />
<div className='p-4'>
<p className='font-bold'>{example.name}</p>
<p className='text-xs text-muted-foreground'>{example.description}</p>
<CardContent className="h-full p-0">
<img
src={example.imageSrc}
alt={example.name}
className="aspect-video h-auto w-full"
/>
<div className="p-4">
<p className="font-bold">{example.name}</p>
<p className="text-muted-foreground text-xs">
{example.description}
</p>
</div>
</CardContent>
</Card>
</a>
);
}
},
);
ExampleCard.displayName = 'ExampleCard';
ExampleCard.displayName = "ExampleCard";
export default ExamplesCarousel;

View File

@@ -1,4 +1,9 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../../components/ui/accordion';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../../components/ui/accordion";
interface FAQ {
id: number;
@@ -9,28 +14,30 @@ interface FAQ {
export default function FAQ({ faqs }: { faqs: FAQ[] }) {
return (
<div className='mt-32 mx-auto max-w-4xl px-6 pb-8 sm:pb-24 sm:pt-12 lg:max-w-7xl lg:px-8 lg:py-32'>
<h2 className='text-2xl font-bold leading-10 tracking-tight text-foreground text-center mb-12'>
<div className="mx-auto mt-32 max-w-4xl px-6 pb-8 sm:pb-24 sm:pt-12 lg:max-w-7xl lg:px-8 lg:py-32">
<h2 className="text-foreground mb-12 text-center text-2xl font-bold leading-10 tracking-tight">
Frequently asked questions
</h2>
<Accordion type='single' collapsible className='w-full space-y-4'>
<Accordion type="single" collapsible className="w-full space-y-4">
{faqs.map((faq) => (
<AccordionItem
key={faq.id}
value={`faq-${faq.id}`}
className='border border-border rounded-lg px-6 py-2 hover:bg-muted/20 transition-all duration-200'
className="border-border hover:bg-muted/20 rounded-lg border px-6 py-2 transition-all duration-200"
>
<AccordionTrigger className='text-left text-base font-semibold leading-7 text-foreground hover:text-primary transition-colors duration-200'>
<AccordionTrigger className="text-foreground hover:text-primary text-left text-base font-semibold leading-7 transition-colors duration-200">
{faq.question}
</AccordionTrigger>
<AccordionContent className='text-muted-foreground'>
<div className='flex flex-col items-start justify-between gap-4'>
<p className='text-base leading-7 text-muted-foreground flex-1'>{faq.answer}</p>
<AccordionContent className="text-muted-foreground">
<div className="flex flex-col items-start justify-between gap-4">
<p className="text-muted-foreground flex-1 text-base leading-7">
{faq.answer}
</p>
{faq.href && (
<a
href={faq.href}
className='text-base leading-7 text-primary hover:text-primary/80 transition-colors duration-200 font-medium whitespace-nowrap flex-shrink-0'
className="text-primary hover:text-primary/80 flex-shrink-0 whitespace-nowrap text-base font-medium leading-7 transition-colors duration-200"
>
Learn more
</a>

View File

@@ -1,4 +1,4 @@
import SectionTitle from './SectionTitle';
import SectionTitle from "./SectionTitle";
export interface Feature {
name: string;
@@ -9,26 +9,28 @@ export interface Feature {
export default function Features({ features }: { features: Feature[] }) {
return (
<div id='features' className='mx-auto mt-48 max-w-7xl px-6 lg:px-8'>
<div id="features" className="mx-auto mt-48 max-w-7xl px-6 lg:px-8">
<SectionTitle
title={
<p className='mt-2 text-4xl font-bold tracking-tight text-foreground sm:text-5xl'>
The <span className='text-secondary'>Best</span> Features
<p className="text-foreground mt-2 text-4xl font-bold tracking-tight sm:text-5xl">
The <span className="text-secondary">Best</span> Features
</p>
}
description="Don't work harder. Work smarter."
/>
<div className='mx-auto mt-16 max-w-2xl sm:mt-20 lg:mt-24 lg:max-w-4xl'>
<dl className='grid max-w-xl grid-cols-1 gap-x-8 gap-y-10 lg:max-w-none lg:grid-cols-2 lg:gap-y-16'>
<div className="mx-auto mt-16 max-w-2xl sm:mt-20 lg:mt-24 lg:max-w-4xl">
<dl className="grid max-w-xl grid-cols-1 gap-x-8 gap-y-10 lg:max-w-none lg:grid-cols-2 lg:gap-y-16">
{features.map((feature) => (
<div key={feature.name} className='relative pl-16'>
<dt className='text-base font-semibold leading-7 text-foreground'>
<div className='absolute left-0 top-0 flex h-10 w-10 items-center justify-center border border-accent bg-accent/30 rounded-lg'>
<div className='text-2xl'>{feature.icon}</div>
<div key={feature.name} className="relative pl-16">
<dt className="text-foreground text-base font-semibold leading-7">
<div className="border-accent bg-accent/30 absolute left-0 top-0 flex h-10 w-10 items-center justify-center rounded-lg border">
<div className="text-2xl">{feature.icon}</div>
</div>
{feature.name}
</dt>
<dd className='mt-2 text-base leading-7 text-muted-foreground'>{feature.description}</dd>
<dd className="text-muted-foreground mt-2 text-base leading-7">
{feature.description}
</dd>
</div>
))}
</dl>

View File

@@ -1,15 +1,20 @@
import React from 'react';
import { Card, CardContent, CardDescription, CardTitle } from '../../components/ui/card';
import { cn } from '../../lib/utils';
import { Feature } from './Features';
import SectionTitle from './SectionTitle';
import React from "react";
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "../../components/ui/card";
import { cn } from "../../lib/utils";
import { Feature } from "./Features";
import SectionTitle from "./SectionTitle";
export interface GridFeature extends Omit<Feature, 'icon'> {
export interface GridFeature extends Omit<Feature, "icon"> {
icon?: React.ReactNode;
emoji?: string;
direction?: 'col' | 'row' | 'col-reverse' | 'row-reverse';
align?: 'center' | 'left';
size: 'small' | 'medium' | 'large';
direction?: "col" | "row" | "col-reverse" | "row-reverse";
align?: "center" | "left";
size: "small" | "medium" | "large";
fullWidthIcon?: boolean;
}
@@ -18,18 +23,27 @@ interface FeaturesGridProps {
className?: string;
}
const FeaturesGrid = ({ features, className = '' }: FeaturesGridProps) => {
const FeaturesGrid = ({ features, className = "" }: FeaturesGridProps) => {
return (
<div className='flex flex-col gap-4 my-16 md:my-24 lg:my-40 max-w-7xl mx-auto' id='features'>
<SectionTitle title='Features' description='These are some of the features of the product.' />
<div
className="mx-auto my-16 flex max-w-7xl flex-col gap-4 md:my-24 lg:my-40"
id="features"
>
<SectionTitle
title="Features"
description="These are some of the features of the product."
/>
<div
className={cn(
'grid grid-cols-2 gap-4 md:grid-cols-4 lg:grid-cols-6 mx-4 md:mx-6 lg:mx-8 auto-rows-[minmax(140px,auto)]',
className
"mx-4 grid auto-rows-[minmax(140px,auto)] grid-cols-2 gap-4 md:mx-6 md:grid-cols-4 lg:mx-8 lg:grid-cols-6",
className,
)}
>
{features.map((feature) => (
<FeaturesGridItem key={feature.name + feature.description} {...feature} />
<FeaturesGridItem
key={feature.name + feature.description}
{...feature}
/>
))}
</div>
</div>
@@ -42,56 +56,77 @@ function FeaturesGridItem({
icon,
emoji,
href,
direction = 'col',
align = 'center',
size = 'medium',
direction = "col",
align = "center",
size = "medium",
fullWidthIcon = true,
}: GridFeature) {
const gridFeatureSizeToClasses: Record<GridFeature['size'], string> = {
small: 'col-span-1',
medium: 'col-span-2 md:col-span-2 lg:col-span-2',
large: 'col-span-2 md:col-span-2 lg:col-span-2 row-span-2',
const gridFeatureSizeToClasses: Record<GridFeature["size"], string> = {
small: "col-span-1",
medium: "col-span-2 md:col-span-2 lg:col-span-2",
large: "col-span-2 md:col-span-2 lg:col-span-2 row-span-2",
};
const directionToClass: Record<NonNullable<GridFeature['direction']>, string> = {
col: 'flex-col',
row: 'flex-row',
'row-reverse': 'flex-row-reverse',
'col-reverse': 'flex-col-reverse',
const directionToClass: Record<
NonNullable<GridFeature["direction"]>,
string
> = {
col: "flex-col",
row: "flex-row",
"row-reverse": "flex-row-reverse",
"col-reverse": "flex-col-reverse",
};
const gridFeatureCard = (
<Card
className={cn(
'h-full min-h-[140px] transition-all duration-300 hover:shadow-lg cursor-pointer',
gridFeatureSizeToClasses[size]
"h-full min-h-[140px] cursor-pointer transition-all duration-300 hover:shadow-lg",
gridFeatureSizeToClasses[size],
)}
variant='bento'
variant="bento"
>
<CardContent className='p-4 h-full flex flex-col justify-center items-center'>
<CardContent className="flex h-full flex-col items-center justify-center p-4">
{fullWidthIcon && (icon || emoji) ? (
<div className='w-full flex justify-center items-center mb-3'>
{icon ? icon : emoji ? <span className='text-4xl'>{emoji}</span> : null}
<div className="mb-3 flex w-full items-center justify-center">
{icon ? (
icon
) : emoji ? (
<span className="text-4xl">{emoji}</span>
) : null}
</div>
) : (
<div
className={cn(
'flex items-center gap-3',
"flex items-center gap-3",
directionToClass[direction],
align === 'center' ? 'justify-center items-center' : 'justify-start'
align === "center"
? "items-center justify-center"
: "justify-start",
)}
>
<div className='flex h-10 w-10 items-center justify-center rounded-lg'>
{icon ? icon : emoji ? <span className='text-2xl'>{emoji}</span> : null}
<div className="flex h-10 w-10 items-center justify-center rounded-lg">
{icon ? (
icon
) : emoji ? (
<span className="text-2xl">{emoji}</span>
) : null}
</div>
<CardTitle className={cn(align === 'center' ? 'text-center' : 'text-left')}>{name}</CardTitle>
<CardTitle
className={cn(align === "center" ? "text-center" : "text-left")}
>
{name}
</CardTitle>
</div>
)}
{fullWidthIcon && (icon || emoji) && <CardTitle className='text-center mb-2'>{name}</CardTitle>}
{fullWidthIcon && (icon || emoji) && (
<CardTitle className="mb-2 text-center">{name}</CardTitle>
)}
<CardDescription
className={cn(
'text-xs leading-relaxed',
fullWidthIcon || direction === 'col' || align === 'center' ? 'text-center' : 'text-left'
"text-xs leading-relaxed",
fullWidthIcon || direction === "col" || align === "center"
? "text-center"
: "text-left",
)}
>
{description}
@@ -102,7 +137,12 @@ function FeaturesGridItem({
if (href) {
return (
<a href={href} target='_blank' rel='noopener noreferrer' className={gridFeatureSizeToClasses[size]}>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={gridFeatureSizeToClasses[size]}
>
{gridFeatureCard}
</a>
);

View File

@@ -1,30 +1,37 @@
interface NavigationItem {
name: string;
href: string;
};
}
export default function Footer({ footerNavigation }: {
export default function Footer({
footerNavigation,
}: {
footerNavigation: {
app: NavigationItem[]
company: NavigationItem[]
}
app: NavigationItem[];
company: NavigationItem[];
};
}) {
return (
<div className='mx-auto mt-6 max-w-7xl px-6 lg:px-8 dark:bg-boxdark-2'>
<div className="dark:bg-boxdark-2 mx-auto mt-6 max-w-7xl px-6 lg:px-8">
<footer
aria-labelledby='footer-heading'
className='relative border-t border-gray-900/10 dark:border-gray-200/10 py-24 sm:mt-32'
aria-labelledby="footer-heading"
className="relative border-t border-gray-900/10 py-24 sm:mt-32 dark:border-gray-200/10"
>
<h2 id='footer-heading' className='sr-only'>
<h2 id="footer-heading" className="sr-only">
Footer
</h2>
<div className='flex items-start justify-end mt-10 gap-20'>
<div className="mt-10 flex items-start justify-end gap-20">
<div>
<h3 className='text-sm font-semibold leading-6 text-gray-900 dark:text-white'>App</h3>
<ul role='list' className='mt-6 space-y-4'>
<h3 className="text-sm font-semibold leading-6 text-gray-900 dark:text-white">
App
</h3>
<ul role="list" className="mt-6 space-y-4">
{footerNavigation.app.map((item) => (
<li key={item.name}>
<a href={item.href} className='text-sm leading-6 text-gray-600 hover:text-gray-900 dark:text-white'>
<a
href={item.href}
className="text-sm leading-6 text-gray-600 hover:text-gray-900 dark:text-white"
>
{item.name}
</a>
</li>
@@ -32,11 +39,16 @@ export default function Footer({ footerNavigation }: {
</ul>
</div>
<div>
<h3 className='text-sm font-semibold leading-6 text-gray-900 dark:text-white'>Company</h3>
<ul role='list' className='mt-6 space-y-4'>
<h3 className="text-sm font-semibold leading-6 text-gray-900 dark:text-white">
Company
</h3>
<ul role="list" className="mt-6 space-y-4">
{footerNavigation.company.map((item) => (
<li key={item.name}>
<a href={item.href} className='text-sm leading-6 text-gray-600 hover:text-gray-900 dark:text-white'>
<a
href={item.href}
className="text-sm leading-6 text-gray-600 hover:text-gray-900 dark:text-white"
>
{item.name}
</a>
</li>
@@ -46,5 +58,5 @@ export default function Footer({ footerNavigation }: {
</div>
</footer>
</div>
)
);
}

View File

@@ -1,51 +1,53 @@
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
import openSaasBannerDark from '../../client/static/open-saas-banner-dark.png';
import openSaasBannerLight from '../../client/static/open-saas-banner-light.png';
import { Button } from '../../components/ui/button';
import { Link as WaspRouterLink, routes } from "wasp/client/router";
import openSaasBannerDark from "../../client/static/open-saas-banner-dark.png";
import openSaasBannerLight from "../../client/static/open-saas-banner-light.png";
import { Button } from "../../components/ui/button";
export default function Hero() {
return (
<div className='relative pt-14 w-full'>
<div className="relative w-full pt-14">
<TopGradient />
<BottomGradient />
<div className='md:p-24'>
<div className='mx-auto max-w-8xl px-6 lg:px-8'>
<div className='lg:mb-18 mx-auto max-w-3xl text-center'>
<h1 className='text-5xl font-bold text-foreground sm:text-6xl'>
Some <span className='italic'>cool</span> words about{' '}
<span className='text-gradient-primary'>your product</span>
<div className="md:p-24">
<div className="max-w-8xl mx-auto px-6 lg:px-8">
<div className="lg:mb-18 mx-auto max-w-3xl text-center">
<h1 className="text-foreground text-5xl font-bold sm:text-6xl">
Some <span className="italic">cool</span> words about{" "}
<span className="text-gradient-primary">your product</span>
</h1>
<p className='mt-6 mx-auto max-w-2xl text-lg leading-8 text-muted-foreground'>
<p className="text-muted-foreground mx-auto mt-6 max-w-2xl text-lg leading-8">
With some more exciting words about your product!
</p>
<div className='mt-10 flex items-center justify-center gap-x-6'>
<Button size='lg' variant='outline' asChild>
<WaspRouterLink to={routes.PricingPageRoute.to}>Learn More</WaspRouterLink>
<div className="mt-10 flex items-center justify-center gap-x-6">
<Button size="lg" variant="outline" asChild>
<WaspRouterLink to={routes.PricingPageRoute.to}>
Learn More
</WaspRouterLink>
</Button>
<Button size='lg' variant='default' asChild>
<Button size="lg" variant="default" asChild>
<WaspRouterLink to={routes.SignupRoute.to}>
Get Started <span aria-hidden='true'></span>
Get Started <span aria-hidden="true"></span>
</WaspRouterLink>
</Button>
</div>
</div>
<div className='mt-14 flow-root sm:mt-14'>
<div className='hidden md:flex m-2 justify-center rounded-xl lg:-m-4 lg:rounded-2xl lg:p-4'>
<div className="mt-14 flow-root sm:mt-14">
<div className="m-2 hidden justify-center rounded-xl md:flex lg:-m-4 lg:rounded-2xl lg:p-4">
<img
src={openSaasBannerLight}
alt='App screenshot'
alt="App screenshot"
width={1000}
height={530}
loading='lazy'
className='rounded-md shadow-2xl ring-1 ring-gray-900/10 dark:hidden'
loading="lazy"
className="rounded-md shadow-2xl ring-1 ring-gray-900/10 dark:hidden"
/>
<img
src={openSaasBannerDark}
alt='App screenshot'
alt="App screenshot"
width={1000}
height={530}
loading='lazy'
className='rounded-md shadow-2xl ring-1 ring-gray-900/10 hidden dark:block'
loading="lazy"
className="hidden rounded-md shadow-2xl ring-1 ring-gray-900/10 dark:block"
/>
</div>
</div>
@@ -58,13 +60,14 @@ export default function Hero() {
function TopGradient() {
return (
<div
className='absolute top-0 right-0 -z-10 transform-gpu overflow-hidden w-full blur-3xl sm:top-0'
aria-hidden='true'
className="absolute right-0 top-0 -z-10 w-full transform-gpu overflow-hidden blur-3xl sm:top-0"
aria-hidden="true"
>
<div
className='aspect-[1020/880] w-[70rem] flex-none sm:right-1/4 sm:translate-x-1/2 dark:hidden bg-gradient-to-tr from-amber-400 to-purple-300 opacity-10'
className="aspect-[1020/880] w-[70rem] flex-none bg-gradient-to-tr from-amber-400 to-purple-300 opacity-10 sm:right-1/4 sm:translate-x-1/2 dark:hidden"
style={{
clipPath: 'polygon(80% 20%, 90% 55%, 50% 100%, 70% 30%, 20% 50%, 50% 0)',
clipPath:
"polygon(80% 20%, 90% 55%, 50% 100%, 70% 30%, 20% 50%, 50% 0)",
}}
/>
</div>
@@ -74,13 +77,13 @@ function TopGradient() {
function BottomGradient() {
return (
<div
className='absolute inset-x-0 top-[calc(100%-40rem)] sm:top-[calc(100%-65rem)] -z-10 transform-gpu overflow-hidden blur-3xl'
aria-hidden='true'
className="absolute inset-x-0 top-[calc(100%-40rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-65rem)]"
aria-hidden="true"
>
<div
className='relative aspect-[1020/880] sm:-left-3/4 sm:translate-x-1/4 dark:hidden bg-gradient-to-br from-amber-400 to-purple-300 opacity-10 w-[90rem]'
className="relative aspect-[1020/880] w-[90rem] bg-gradient-to-br from-amber-400 to-purple-300 opacity-10 sm:-left-3/4 sm:translate-x-1/4 dark:hidden"
style={{
clipPath: 'ellipse(80% 30% at 80% 50%)',
clipPath: "ellipse(80% 30% at 80% 50%)",
}}
/>
</div>

View File

@@ -1,11 +1,11 @@
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
interface FeatureProps {
name: string;
description: string | React.ReactNode;
direction?: 'row' | 'row-reverse';
direction?: "row" | "row-reverse";
highlightedComponent: React.ReactNode;
tilt?: 'left' | 'right';
tilt?: "left" | "right";
}
/**
@@ -15,34 +15,34 @@ interface FeatureProps {
const HighlightedFeature = ({
name,
description,
direction = 'row',
direction = "row",
highlightedComponent,
tilt,
}: FeatureProps) => {
const tiltToClass: Record<Required<FeatureProps>['tilt'], string> = {
left: 'rotate-1',
right: '-rotate-1',
const tiltToClass: Record<Required<FeatureProps>["tilt"], string> = {
left: "rotate-1",
right: "-rotate-1",
};
return (
<div
className={cn(
'max-w-6xl mx-auto flex flex-col items-center my-50 gap-x-20 gap-y-10 justify-between px-8 md:px-4 transition-all duration-300 ease-in-out',
direction === 'row' ? 'md:flex-row' : 'md:flex-row-reverse'
"my-50 mx-auto flex max-w-6xl flex-col items-center justify-between gap-x-20 gap-y-10 px-8 transition-all duration-300 ease-in-out md:px-4",
direction === "row" ? "md:flex-row" : "md:flex-row-reverse",
)}
>
<div className='flex-col flex-1'>
<h2 className='text-4xl font-bold mb-2'>{name}</h2>
{typeof description === 'string' ? (
<p className='text-muted-foreground'>{description}</p>
<div className="flex-1 flex-col">
<h2 className="mb-2 text-4xl font-bold">{name}</h2>
{typeof description === "string" ? (
<p className="text-muted-foreground">{description}</p>
) : (
description
)}
</div>
<div
className={cn(
'flex flex-1 my-10 transition-transform duration-300 ease-in-out w-full items-center justify-center',
tilt && tiltToClass[tilt]
"my-10 flex w-full flex-1 items-center justify-center transition-transform duration-300 ease-in-out",
tilt && tiltToClass[tilt],
)}
>
{highlightedComponent}

View File

@@ -7,20 +7,24 @@ export default function SectionTitle({
titleComponent?: React.ReactNode;
}) {
const titleElement =
typeof title === 'string' ? (
<h3 className='mt-2 text-4xl font-bold tracking-tight text-foreground sm:text-5xl'>{title}</h3>
typeof title === "string" ? (
<h3 className="text-foreground mt-2 text-4xl font-bold tracking-tight sm:text-5xl">
{title}
</h3>
) : (
title
);
const descriptionElement =
typeof description === 'string' ? (
<p className='mt-4 text-lg leading-8 text-muted-foreground'>{description}</p>
typeof description === "string" ? (
<p className="text-muted-foreground mt-4 text-lg leading-8">
{description}
</p>
) : (
description
);
return (
<div className='mx-auto max-w-2xl text-center mb-8'>
<div className="mx-auto mb-8 max-w-2xl text-center">
{titleElement}
{descriptionElement}
</div>

Some files were not shown because too many files have changed in this diff Show More