mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-11-21 23:08:06 +01:00
Refactor file upload and toast notifications (#472)
* Refactor file upload and toast notifications Replaces react-hot-toast with a custom toast system using @radix-ui/react-toast, updating all usages and adding new UI components for toast and dialog. Refactors file upload to use a two-step process: first generating an S3 upload URL, then adding the file to the database, and adds file deletion support with confirmation dialog and S3 cleanup. Updates Prisma schema, removes unused fields, and cleans up navigation and admin settings page. * Enforce file upload limit and update dependencies on opensaas-sh Added a check to restrict users to 2 file uploads in the demo, with a new helper function and error handling. Updated navigation items, improved landing page content, and removed unused dependencies (react-hot-toast). Added @radix-ui/react-toast, updated testimonials, and made minor content and code improvements. * update tests * Improve file deletion error handling and cleanup Refactors file deletion to delete the database record before attempting S3 deletion, ensuring the file is removed from the app even if S3 deletion fails. Adds error logging for failed S3 deletions to aid in manual cleanup. Also simplifies error handling in the file upload page and removes unused imports in the demo app page. * Add credit check and S3 file existence validation Added logic to decrement user credits or throw an error if out of credits in the AI demo app. Updated file upload operations to validate file existence in S3 before adding to the database, and implemented S3 file existence check utility. Minor UI and code improvements included. * Update s3Utils.ts * update app_diff * fix diff * Update deletions * Improve toast UI, error handling, and credit messaging Updated toast action hover style and icon spacing for better UI consistency. Enhanced error handling in file deletion to display specific error messages. Refined credit/subscription error message in GPT response operation for clarity and removed redundant credit decrement logic. * Refactor file upload validation and error handling Replaces error state management with toast notifications for file upload errors and success. Refactors file type validation to use a new ALLOWED_FILE_TYPES_CONST and type AllowedFileTypes. Updates validation logic to throw errors instead of returning error objects, and simplifies type handling across file upload modules. * Refactor file upload to use s3Key and add cleanup job Replaces the 'key' field with 's3Key' for file storage references throughout the codebase and database schema. Updates all related logic, types, and API contracts to use 's3Key'. Adds a scheduled job to delete old files from S3 and the database. Cleans up file type validation constants and improves consistency in file upload and download operations. * add orphaned file clean up * remove s3 cleanup job from template removed but added suggestion to docs. * Update SettingsPage.tsx * prettier format * Update UI, remove unused files Updated README with deployment and demo details. Removed unused App.tsx and package-lock.json files. Modified Main.css, NavBar constants, file uploading logic, file upload operations, and landing page content sections for improved UI and functionality. * remove pricing page from isMarketingPage
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
--- template/app/README.md
|
||||
+++ opensaas-sh/app/README.md
|
||||
@@ -1,6 +1,8 @@
|
||||
@@ -1,16 +1,31 @@
|
||||
-# <YOUR_APP_NAME>
|
||||
+# opensaas.sh (demo) app
|
||||
|
||||
-Built with [Wasp](https://wasp.sh), based on the [Open Saas](https://opensaas.sh) template.
|
||||
+This is a Wasp app based on Open Saas template with minimal modifications that make it into a demo app that showcases Open Saas's abilities.
|
||||
+
|
||||
+It is deployed to https://opensaas.sh and serves both as a landing page for Open Saas and as a demo app.
|
||||
|
||||
+It is deployed to https://opensaas.sh and serves both as a landing page for Open Saas and as a demo app.
|
||||
+
|
||||
## UI Components
|
||||
|
||||
@@ -8,9 +10,22 @@
|
||||
This template includes [ShadCN UI](https://ui.shadcn.com/) v2 for beautiful, accessible React components. See [SHADCN_SETUP.md](./SHADCN_SETUP.md) for details on how to use ShadCN components in your app.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
src/client/static/open-saas-banner-dark.svg
|
||||
src/client/static/open-saas-banner-light.svg
|
||||
src/landing-page/components/Hero.tsx
|
||||
src/landing-page/contentSections.ts
|
||||
src/payment/lemonSqueezy/checkoutUtils.ts
|
||||
src/payment/lemonSqueezy/paymentDetails.ts
|
||||
src/payment/lemonSqueezy/paymentProcessor.ts
|
||||
|
||||
@@ -119,7 +119,25 @@
|
||||
httpRoute: (POST, "/payments-webhook")
|
||||
}
|
||||
//#endregion
|
||||
@@ -281,7 +279,6 @@
|
||||
@@ -245,6 +243,17 @@
|
||||
fn: import { deleteFile } from "@src/file-upload/operations",
|
||||
entities: [User, File]
|
||||
}
|
||||
+
|
||||
+job deleteFilesJob {
|
||||
+ executor: PgBoss,
|
||||
+ perform: {
|
||||
+ fn: import { deleteFilesJob } from "@src/file-upload/workers"
|
||||
+ },
|
||||
+ schedule: {
|
||||
+ cron: "0 5 * * *" // every day at 5am
|
||||
+ },
|
||||
+ entities: [File]
|
||||
+}
|
||||
//#endregion
|
||||
|
||||
//#region Analytics
|
||||
@@ -291,7 +300,6 @@
|
||||
component: import AdminCalendar from "@src/admin/elements/calendar/CalendarPage"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
--- template/app/migrations/20250731133938_drop_upload_url_from_file/migration.sql
|
||||
+++ opensaas-sh/app/migrations/20250731133938_drop_upload_url_from_file/migration.sql
|
||||
@@ -0,0 +1,8 @@
|
||||
+/*
|
||||
+ Warnings:
|
||||
+
|
||||
+ - You are about to drop the column `uploadUrl` on the `File` table. All the data in the column will be lost.
|
||||
+
|
||||
+*/
|
||||
+-- AlterTable
|
||||
+ALTER TABLE "File" DROP COLUMN "uploadUrl";
|
||||
@@ -0,0 +1,14 @@
|
||||
--- template/app/migrations/20250806121259_add_s3_key_file/migration.sql
|
||||
+++ opensaas-sh/app/migrations/20250806121259_add_s3_key_file/migration.sql
|
||||
@@ -0,0 +1,11 @@
|
||||
+/*
|
||||
+ Warnings:
|
||||
+
|
||||
+ - You are about to drop the column `key` on the `File` table. All the data in the column will be lost.
|
||||
+ - Added the required column `s3Key` to the `File` table without a default value. This is not possible if the table is not empty.
|
||||
+
|
||||
+*/
|
||||
+-- AlterTable
|
||||
+DELETE FROM "File";
|
||||
+ALTER TABLE "File" DROP COLUMN "key",
|
||||
+ADD COLUMN "s3Key" TEXT NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,9 @@
|
||||
"@aws-sdk/client-s3": "^3.523.0",
|
||||
"@aws-sdk/s3-presigned-post": "^3.750.0",
|
||||
@@ -36,6 +41,7 @@
|
||||
"react-apexcharts": "1.4.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
+ "react-icons": "^5.5.0",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"stripe": "18.1.0",
|
||||
|
||||
11
opensaas-sh/app_diff/src/client/App.tsx.diff
Normal file
11
opensaas-sh/app_diff/src/client/App.tsx.diff
Normal file
@@ -0,0 +1,11 @@
|
||||
--- template/app/src/client/App.tsx
|
||||
+++ opensaas-sh/app/src/client/App.tsx
|
||||
@@ -18,7 +18,7 @@
|
||||
const location = useLocation();
|
||||
const isMarketingPage = useMemo(() => {
|
||||
return (
|
||||
- location.pathname === "/" || location.pathname.startsWith("/pricing")
|
||||
+ location.pathname === "/"
|
||||
);
|
||||
}, [location]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
--- template/app/src/client/Main.css
|
||||
+++ opensaas-sh/app/src/client/Main.css
|
||||
@@ -56,6 +56,64 @@
|
||||
@@ -56,8 +56,66 @@
|
||||
.border-gradient-primary > * {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
@@ -21,8 +21,8 @@
|
||||
+ hsl(var(--card)) 100%
|
||||
+ );
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
}
|
||||
|
||||
+/* Satoshi Font Family */
|
||||
+@font-face {
|
||||
+ font-family: "Satoshi";
|
||||
@@ -62,9 +62,11 @@
|
||||
+ font-weight: 900;
|
||||
+ font-style: normal;
|
||||
+ font-display: swap;
|
||||
}
|
||||
|
||||
+}
|
||||
+
|
||||
/* third-party libraries CSS */
|
||||
|
||||
.tableCheckbox:checked ~ div span {
|
||||
@@ -176,4 +234,22 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@@ -83,8 +85,8 @@
|
||||
+ p {
|
||||
+ @apply font-mono text-base leading-relaxed;
|
||||
+ }
|
||||
+}
|
||||
}
|
||||
+
|
||||
+.navbar-maxwidth-transition {
|
||||
+ transition: max-width 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
+}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
--- template/app/src/client/components/NavBar/constants.ts
|
||||
+++ opensaas-sh/app/src/client/components/NavBar/constants.ts
|
||||
@@ -9,7 +9,6 @@
|
||||
@@ -9,12 +9,12 @@
|
||||
|
||||
export const marketingNavigationItems: NavigationItem[] = [
|
||||
{ name: "Features", to: "/#features" },
|
||||
@@ -8,3 +8,9 @@
|
||||
...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 },
|
||||
...staticNavigationItems,
|
||||
] as const;
|
||||
|
||||
30
opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff
Normal file
30
opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff
Normal file
@@ -0,0 +1,30 @@
|
||||
--- template/app/src/file-upload/fileUploading.ts
|
||||
+++ opensaas-sh/app/src/file-upload/fileUploading.ts
|
||||
@@ -1,5 +1,7 @@
|
||||
-import axios from "axios";
|
||||
-import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from "./validation";
|
||||
+import type { User } from 'wasp/entities';
|
||||
+import axios from 'axios';
|
||||
+import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from './validation';
|
||||
+import { PrismaClient } from '@prisma/client';
|
||||
|
||||
type AllowedFileTypes = (typeof ALLOWED_FILE_TYPES)[number];
|
||||
export type FileWithValidType = File & { type: AllowedFileTypes };
|
||||
@@ -58,3 +60,17 @@
|
||||
function isFileWithAllowedFileType(file: File): file is FileWithValidType {
|
||||
return ALLOWED_FILE_TYPES.includes(file.type as AllowedFileTypes);
|
||||
}
|
||||
+
|
||||
+export async function checkIfUserHasReachedFileUploadLimit({ userId, prismaFileDelegate }: { userId: User['id']; prismaFileDelegate: PrismaClient['file'] }) {
|
||||
+ const numberOfFilesByUser = await prismaFileDelegate.count({
|
||||
+ where: {
|
||||
+ user: {
|
||||
+ id: userId,
|
||||
+ },
|
||||
+ },
|
||||
+ });
|
||||
+ if (numberOfFilesByUser >= 2) {
|
||||
+ return true;
|
||||
+ }
|
||||
+ return false;
|
||||
+}
|
||||
@@ -1,24 +1,33 @@
|
||||
--- template/app/src/file-upload/operations.ts
|
||||
+++ opensaas-sh/app/src/file-upload/operations.ts
|
||||
@@ -44,6 +44,21 @@
|
||||
userId: context.user.id,
|
||||
});
|
||||
@@ -6,7 +6,8 @@
|
||||
type DeleteFile,
|
||||
type GetAllFilesByUser,
|
||||
type GetDownloadFileSignedURL,
|
||||
-} from "wasp/server/operations";
|
||||
+} from 'wasp/server/operations';
|
||||
+import { checkIfUserHasReachedFileUploadLimit } from './fileUploading';
|
||||
|
||||
+ const numberOfFilesByUser = await context.entities.File.count({
|
||||
+ where: {
|
||||
+ user: {
|
||||
+ id: context.user.id,
|
||||
+ },
|
||||
+ },
|
||||
import * as z from "zod";
|
||||
import { ensureArgsSchemaOrThrowHttpError } from "../server/validation";
|
||||
@@ -37,11 +38,16 @@
|
||||
throw new HttpError(401);
|
||||
}
|
||||
|
||||
- const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(
|
||||
- createFileInputSchema,
|
||||
- rawArgs,
|
||||
- );
|
||||
+ const userFileLimitReached = await checkIfUserHasReachedFileUploadLimit({
|
||||
+ userId: context.user.id,
|
||||
+ prismaFileDelegate: context.entities.File,
|
||||
+ });
|
||||
+
|
||||
+ if (numberOfFilesByUser >= 2) {
|
||||
+ throw new HttpError(
|
||||
+ 403,
|
||||
+ "Thanks for trying Open SaaS. This demo only allows 2 file uploads per user.",
|
||||
+ );
|
||||
+ if (userFileLimitReached) {
|
||||
+ throw new HttpError(403, 'This demo only allows 2 file uploads per user.');
|
||||
+ }
|
||||
|
||||
+ const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
|
||||
+
|
||||
await context.entities.File.create({
|
||||
data: {
|
||||
name: fileName,
|
||||
return await getUploadFileSignedURLFromS3({
|
||||
fileType,
|
||||
fileName,
|
||||
|
||||
37
opensaas-sh/app_diff/src/file-upload/workers.ts.diff
Normal file
37
opensaas-sh/app_diff/src/file-upload/workers.ts.diff
Normal file
@@ -0,0 +1,37 @@
|
||||
--- template/app/src/file-upload/workers.ts
|
||||
+++ opensaas-sh/app/src/file-upload/workers.ts
|
||||
@@ -0,0 +1,34 @@
|
||||
+import type { DeleteFilesJob } from 'wasp/server/jobs';
|
||||
+import { deleteFileFromS3 } from './s3Utils';
|
||||
+
|
||||
+export const deleteFilesJob: DeleteFilesJob<never, void> = async (_args, context) => {
|
||||
+ const dayInMiliseconds = 1000 * 60 * 60 * 24;
|
||||
+ const sevenDaysAgo = Date.now() - 7 * dayInMiliseconds;
|
||||
+ const filesToDelete = await context.entities.File.findMany({
|
||||
+ where: {
|
||||
+ createdAt: {
|
||||
+ lt: new Date(sevenDaysAgo),
|
||||
+ },
|
||||
+ },
|
||||
+ select: { s3Key: true, id: true },
|
||||
+ });
|
||||
+
|
||||
+ const deletionResults = await Promise.allSettled(
|
||||
+ filesToDelete.map(async (file) => {
|
||||
+ await deleteFileFromS3({ s3Key: file.s3Key });
|
||||
+ return file.id;
|
||||
+ })
|
||||
+ );
|
||||
+
|
||||
+ const successfullyDeletedFromS3Ids = deletionResults
|
||||
+ .filter((result) => result.status === 'fulfilled')
|
||||
+ .map((result) => result.value);
|
||||
+
|
||||
+ const deletedFiles = await context.entities.File.deleteMany({
|
||||
+ where: {
|
||||
+ id: { in: successfullyDeletedFromS3Ids },
|
||||
+ },
|
||||
+ });
|
||||
+
|
||||
+ console.log(`Deleted ${deletedFiles.count} files`);
|
||||
+};
|
||||
@@ -1,47 +1,62 @@
|
||||
--- template/app/src/landing-page/contentSections.tsx
|
||||
+++ opensaas-sh/app/src/landing-page/contentSections.tsx
|
||||
@@ -0,0 +1,263 @@
|
||||
@@ -1,4 +1,9 @@
|
||||
-import daBoiAvatar from "../client/static/da-boi.webp";
|
||||
+import { routes } from "wasp/client/router";
|
||||
+import type { NavigationItem } from "../client/components/NavBar/NavBar";
|
||||
+import blog from "../client/static/assets/blog.webp";
|
||||
+import email from "../client/static/assets/email.webp";
|
||||
+import fileupload from "../client/static/assets/fileupload.webp";
|
||||
+import ai from "../client/static/assets/openapi.webp";
|
||||
+import kivo from "../client/static/examples/kivo.webp";
|
||||
+import messync from "../client/static/examples/messync.webp";
|
||||
+import microinfluencerClub from "../client/static/examples/microinfluencers.webp";
|
||||
+import promptpanda from "../client/static/examples/promptpanda.webp";
|
||||
+import reviewradar from "../client/static/examples/reviewradar.webp";
|
||||
+import scribeist from "../client/static/examples/scribeist.webp";
|
||||
+import searchcraft from "../client/static/examples/searchcraft.webp";
|
||||
import kivo from "../client/static/examples/kivo.webp";
|
||||
import messync from "../client/static/examples/messync.webp";
|
||||
import microinfluencerClub from "../client/static/examples/microinfluencers.webp";
|
||||
@@ -6,161 +11,253 @@
|
||||
import reviewradar from "../client/static/examples/reviewradar.webp";
|
||||
import scribeist from "../client/static/examples/scribeist.webp";
|
||||
import searchcraft from "../client/static/examples/searchcraft.webp";
|
||||
-import { BlogUrl, DocsUrl } from "../shared/common";
|
||||
-import type { GridFeature } from "./components/FeaturesGrid";
|
||||
+import logo from "../client/static/logo.webp";
|
||||
+import { BlogUrl, DocsUrl, GithubUrl, WaspUrl } from "../shared/common";
|
||||
+import { GridFeature } from "./components/FeaturesGrid";
|
||||
+
|
||||
|
||||
+export const landingPageNavigationItems: NavigationItem[] = [
|
||||
+ { name: "Features", to: "#features" },
|
||||
+ { name: "Documentation", to: DocsUrl },
|
||||
+ { name: "Blog", to: BlogUrl },
|
||||
+];
|
||||
+export const features: GridFeature[] = [
|
||||
+ {
|
||||
export const features: GridFeature[] = [
|
||||
{
|
||||
- name: "Cool Feature 1",
|
||||
- description: "Your feature",
|
||||
- emoji: "🤝",
|
||||
+ description:
|
||||
+ "Have a sweet AI-powered app concept? Get your idea shipped to potential customers in days!",
|
||||
+ icon: <img src={ai} alt="AI illustration" />,
|
||||
+ href: DocsUrl,
|
||||
href: DocsUrl,
|
||||
- size: "small",
|
||||
+ size: "medium",
|
||||
+ fullWidthIcon: true,
|
||||
+ align: "left",
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Cool Feature 2",
|
||||
- description: "Feature description",
|
||||
- emoji: "🔐",
|
||||
+ name: "Full-stack Type Safety",
|
||||
+ description:
|
||||
+ "Full support for TypeScript with auto-generated types that span the whole stack. Nothing to configure!",
|
||||
+ emoji: "🥞",
|
||||
+ href: DocsUrl,
|
||||
href: DocsUrl,
|
||||
- size: "small",
|
||||
+ size: "medium",
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Cool Feature 3",
|
||||
- description: "Describe your cool feature here",
|
||||
- emoji: "🥞",
|
||||
- href: DocsUrl,
|
||||
+ description:
|
||||
+ "File upload examples with AWS S3 presigned URLs are included and fully documented!",
|
||||
+ icon: (
|
||||
@@ -52,10 +67,15 @@
|
||||
+ />
|
||||
+ ),
|
||||
+ href: DocsUrl + "/guides/file-uploading/",
|
||||
+ size: "medium",
|
||||
size: "medium",
|
||||
+ fullWidthIcon: true,
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Cool Feature 4",
|
||||
- description: "Describe your cool feature here",
|
||||
- emoji: "💸",
|
||||
- href: DocsUrl,
|
||||
- size: "large",
|
||||
+ name: "Email Sending",
|
||||
+ description:
|
||||
+ "Email sending built-in. Combine it with the cron jobs feature to easily send emails to your customers.",
|
||||
@@ -64,16 +84,26 @@
|
||||
+ size: "medium",
|
||||
+ fullWidthIcon: true,
|
||||
+ direction: "col-reverse",
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Cool Feature 5",
|
||||
- description: "Describe your cool feature here",
|
||||
- emoji: "💼",
|
||||
- href: DocsUrl,
|
||||
- size: "large",
|
||||
+ name: "Open SaaS",
|
||||
+ description: "Try the demo app",
|
||||
+ icon: <img src={logo} alt="Wasp Logo" />,
|
||||
+ href: routes.LoginRoute.to,
|
||||
+ size: "medium",
|
||||
+ highlight: true,
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Cool Feature 6",
|
||||
- description: "It is cool",
|
||||
- emoji: "📈",
|
||||
- href: DocsUrl,
|
||||
- size: "small",
|
||||
+ name: "Blog w/ Astro",
|
||||
+ description:
|
||||
+ "Built-in blog with the Astro framework. Write your posts in Markdown, and watch your SEO performance take off.",
|
||||
@@ -81,8 +111,11 @@
|
||||
+ href: DocsUrl + "/start/guided-tour/",
|
||||
+ size: "medium",
|
||||
+ fullWidthIcon: true,
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Cool Feature 7",
|
||||
- description: "Cool feature",
|
||||
- emoji: "📧",
|
||||
+ name: "Deploy Anywhere. Easily.",
|
||||
+ description:
|
||||
+ "No vendor lock-in because you own all your code. Deploy yourself, or let Wasp deploy it for you with a single command.",
|
||||
@@ -93,26 +126,40 @@
|
||||
+ {
|
||||
+ name: "Complete Documentation & Support",
|
||||
+ description: "And a Discord community to help!",
|
||||
+ href: DocsUrl,
|
||||
+ size: "small",
|
||||
+ },
|
||||
+ {
|
||||
href: DocsUrl,
|
||||
size: "small",
|
||||
},
|
||||
{
|
||||
- name: "Cool Feature 8",
|
||||
- description: "Describe your cool feature here",
|
||||
- emoji: "🤖",
|
||||
- href: DocsUrl,
|
||||
- size: "medium",
|
||||
+ name: "E2E Tests w/ Playwright",
|
||||
+ description: "Tests and a CI pipeline w/ GitHub Actions",
|
||||
+ href: DocsUrl + "/guides/tests/",
|
||||
+ size: "small",
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Cool Feature 9",
|
||||
- description: "Describe your cool feature here",
|
||||
- emoji: "🚀",
|
||||
+ name: "Open-Source Philosophy",
|
||||
+ description:
|
||||
+ "The repo and framework are 100% open-source, and so are the services wherever possible. Still missing something? Contribute!",
|
||||
+ emoji: "🤝",
|
||||
+ href: DocsUrl,
|
||||
+ size: "medium",
|
||||
+ },
|
||||
+];
|
||||
+export const testimonials = [
|
||||
+ {
|
||||
href: DocsUrl,
|
||||
size: "medium",
|
||||
},
|
||||
];
|
||||
-
|
||||
export const testimonials = [
|
||||
{
|
||||
- name: "Da Boi",
|
||||
- role: "Wasp Mascot",
|
||||
- avatarSrc: daBoiAvatar,
|
||||
- socialUrl: "https://twitter.com/wasplang",
|
||||
- quote: "I don't even know how to code. I'm just a plushie.",
|
||||
+ name: "Max Khamrovskyi",
|
||||
+ role: "Senior Eng @ Red Hat",
|
||||
+ avatarSrc:
|
||||
@@ -120,8 +167,13 @@
|
||||
+ socialUrl: "https://twitter.com/maksim36ua",
|
||||
+ quote:
|
||||
+ "I used Wasp to build and sell my AI-augmented SaaS app for marketplace vendors within two months!",
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Mr. Foobar",
|
||||
- role: "Founder @ Cool Startup",
|
||||
- avatarSrc: daBoiAvatar,
|
||||
- socialUrl: "",
|
||||
- quote: "This product makes me cooler than I already am.",
|
||||
+ name: "Jonathan Cocharan",
|
||||
+ role: "Entrepreneur",
|
||||
+ avatarSrc:
|
||||
@@ -129,16 +181,21 @@
|
||||
+ socialUrl: "https://twitter.com/JonathanCochran",
|
||||
+ quote:
|
||||
+ "In just 6 nights... my SaaS app is live 🎉! Huge thanks to the amazing @wasplang community 🙌 for their guidance along the way. These tools are incredibly efficient 🤯!",
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Jamie",
|
||||
- role: "Happy Customer",
|
||||
- avatarSrc: daBoiAvatar,
|
||||
- socialUrl: "#",
|
||||
- quote: "My cats love it!",
|
||||
+ name: "Billy Howell",
|
||||
+ role: "Entrepreneur",
|
||||
+ avatarSrc:
|
||||
+ "https://pbs.twimg.com/profile_images/1877734205561430016/jjpG4mS6_400x400.jpg",
|
||||
+ socialUrl: "https://twitter.com/billyjhowell",
|
||||
+ quote:
|
||||
+ "Congrats! I am loving Wasp. It's really helped me, a self-taught coder increase my confidence. I feel like I've finally found the perfect, versatile stack for all my projects instead of trying out a new one each time.",
|
||||
+ },
|
||||
+ "Congrats! I am loving Wasp & Open SaaS. It's really helped me, a self-taught coder increase my confidence. I feel like I've finally found the perfect, versatile stack for all my projects instead of trying out a new one each time.",
|
||||
},
|
||||
+ {
|
||||
+ name: "Tim Skaggs",
|
||||
+ role: "Founder @ Antler US",
|
||||
@@ -193,14 +250,18 @@
|
||||
+ quote:
|
||||
+ "This is exactly the framework I've been dreaming of ever since I've been waiting to fully venture into the JS Backend Dev world. I believe Wasp will go above 50k stars this year. The documentation alone gives me the confidence that this is my permanent Nodejs framework and I'm staying with Wasp. Phenomenal work by the team... Please keep up your amazing spirits. Thank you",
|
||||
+ },
|
||||
+];
|
||||
+export const faqs = [
|
||||
+ {
|
||||
+ id: 1,
|
||||
];
|
||||
-
|
||||
export const faqs = [
|
||||
{
|
||||
id: 1,
|
||||
- question: "Whats the meaning of life?",
|
||||
- answer: "42.",
|
||||
- href: "https://en.wikipedia.org/wiki/42_(number)",
|
||||
+ question: "Why is this SaaS Template free and open-source?",
|
||||
+ answer:
|
||||
+ "We believe the best product is made when the community puts their heads together. We also believe a quality starting point for a web app should be free and available to everyone. Our hope is that together we can create the best SaaS template out there and bring our ideas to customers quickly.",
|
||||
+ },
|
||||
},
|
||||
+ {
|
||||
+ id: 2,
|
||||
+ question: "What's Wasp?",
|
||||
@@ -208,59 +269,92 @@
|
||||
+ answer:
|
||||
+ "It's the fastest way to develop full-stack React + NodeJS + Prisma apps and it's what gives this template superpowers. Wasp relies on React, NodeJS, and Prisma to define web components and server queries and actions. Wasp's secret sauce is its compiler which takes the client, server code, and config file and outputs the client app, server app and deployment code, supercharging the development experience. Combined with this template, you can build a SaaS app in record time.",
|
||||
+ },
|
||||
+];
|
||||
+export const footerNavigation = {
|
||||
+ app: [
|
||||
];
|
||||
-
|
||||
export const footerNavigation = {
|
||||
app: [
|
||||
+ { name: "Github", href: GithubUrl },
|
||||
+ { name: "Documentation", href: DocsUrl },
|
||||
+ { name: "Blog", href: BlogUrl },
|
||||
+ ],
|
||||
+ company: [
|
||||
{ name: "Documentation", href: DocsUrl },
|
||||
{ name: "Blog", href: BlogUrl },
|
||||
],
|
||||
company: [
|
||||
- { name: "About", href: "https://wasp.sh" },
|
||||
- { name: "Privacy", href: "#" },
|
||||
- { name: "Terms of Service", href: "#" },
|
||||
+ { name: "Terms of Service", href: GithubUrl + "/blob/main/LICENSE" },
|
||||
+ { name: "Made by the Wasp team = }", href: WaspUrl },
|
||||
+ ],
|
||||
+};
|
||||
+export const examples = [
|
||||
+ {
|
||||
],
|
||||
};
|
||||
-
|
||||
export const examples = [
|
||||
{
|
||||
- name: "Example #1",
|
||||
- description: "Describe your example here.",
|
||||
- imageSrc: kivo,
|
||||
- href: "#",
|
||||
+ name: "Microinfluencers",
|
||||
+ description: "microinfluencer.club",
|
||||
+ imageSrc: microinfluencerClub,
|
||||
+ href: "https://microinfluencer.club",
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Example #2",
|
||||
- description: "Describe your example here.",
|
||||
- imageSrc: messync,
|
||||
- href: "#",
|
||||
+ name: "Kivo",
|
||||
+ description: "kivo.dev",
|
||||
+ imageSrc: kivo,
|
||||
+ href: "https://kivo.dev",
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Example #3",
|
||||
- description: "Describe your example here.",
|
||||
- imageSrc: microinfluencerClub,
|
||||
- href: "#",
|
||||
+ name: "Searchcraft",
|
||||
+ description: "searchcraft.io",
|
||||
+ imageSrc: searchcraft,
|
||||
+ href: "https://www.searchcraft.io",
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Example #4",
|
||||
- description: "Describe your example here.",
|
||||
- imageSrc: promptpanda,
|
||||
- href: "#",
|
||||
+ name: "Scribeist",
|
||||
+ description: "scribeist.com",
|
||||
+ imageSrc: scribeist,
|
||||
+ href: "https://scribeist.com",
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Example #5",
|
||||
- description: "Describe your example here.",
|
||||
- imageSrc: reviewradar,
|
||||
- href: "#",
|
||||
+ name: "Messync",
|
||||
+ description: "messync.com",
|
||||
+ imageSrc: messync,
|
||||
+ href: "https://messync.com",
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Example #6",
|
||||
- description: "Describe your example here.",
|
||||
- imageSrc: scribeist,
|
||||
- href: "#",
|
||||
+ name: "Prompt Panda",
|
||||
+ description: "promptpanda.io",
|
||||
+ imageSrc: promptpanda,
|
||||
+ href: "https://promptpanda.io",
|
||||
+ },
|
||||
+ {
|
||||
},
|
||||
{
|
||||
- name: "Example #7",
|
||||
- description: "Describe your example here.",
|
||||
- imageSrc: searchcraft,
|
||||
- href: "#",
|
||||
+ name: "Review Radar",
|
||||
+ description: "reviewradar.ai",
|
||||
+ imageSrc: reviewradar,
|
||||
+ href: "https://reviewradar.ai",
|
||||
+ },
|
||||
+];
|
||||
},
|
||||
];
|
||||
|
||||
@@ -143,6 +143,116 @@ To begin customizing file uploads, is important to know where everything lives i
|
||||
- The `getAllFilesByUser` fetches all File information uploaded by the user. Note that the files do not exist in the app database, but rather the file data, its name and its `key`, which is used to fetch the file from S3.
|
||||
- The `getDownloadFileSignedURL` query fetches the presigned URL for a file to be downloaded from S3 using the file's `key` stored in the app's database.
|
||||
|
||||
### Cleaning up "orphaned" files in S3
|
||||
|
||||
In the current logic, files are first deleted from the app's database before attempting to delete them from S3. If, for some reason, the S3 deletion were to fail, the file would remain in S3 and not in the app's database and be orphaned:
|
||||
|
||||
```ts
|
||||
// src/file-upload/operations.ts
|
||||
export const deleteFile: DeleteFile<DeleteFileInput, File> = async (args, context) => {
|
||||
|
||||
const deletedFile = await context.entities.File.delete(args.fileId);
|
||||
|
||||
try {
|
||||
return await deleteFileFromS3({ s3Key: deletedFile.s3Key });
|
||||
} catch (error) {
|
||||
console.error(`S3 deletion failed. Orphaned file s3Key: ${deletedFile.s3Key}`, error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
To clean up these orphaned files, you could add a cleanup job that runs at an interval of your choosing to:
|
||||
|
||||
1. Fetch all file keys from S3
|
||||
2. Fetch all file keys from the app's database
|
||||
3. Compare the two lists and delete any files from S3 that are not in the database
|
||||
|
||||
Here's an example of how you could implement this:
|
||||
|
||||
```ts
|
||||
// .wasp config file
|
||||
job cleanUpOrphanedFilesS3Job {
|
||||
executor: PgBoss,
|
||||
perform: {
|
||||
fn: import { cleanUpOrphanedFilesS3 } from "@src/file-upload/workers"
|
||||
},
|
||||
schedule: {
|
||||
cron: "0 5 * * 0" // every week on Sunday at 5am
|
||||
},
|
||||
entities: [File]
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/file-upload/workers.ts
|
||||
|
||||
import type { CleanUpOrphanedFilesS3Job } from 'wasp/server/jobs';
|
||||
import { s3Client, deleteFileFromS3 } from './s3Utils';
|
||||
import { ListObjectsV2Command, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3';
|
||||
|
||||
export const cleanUpOrphanedFilesS3: CleanUpOrphanedFilesS3Job<never, void> = async (
|
||||
_args,
|
||||
context
|
||||
) => {
|
||||
const allFileKeysFromS3 = await fetchAllFileKeysFromS3();
|
||||
const allFileKeysFromDb = await context.entities.File.findMany({
|
||||
select: { s3Key: true },
|
||||
});
|
||||
await findAndDeleteOrphanedFilesInS3(allFileKeysFromS3, allFileKeysFromDb);
|
||||
};
|
||||
|
||||
const fetchAllFileKeysFromS3 = async () => {
|
||||
const allS3Keys: string[] = [];
|
||||
let continuationToken: string | undefined = undefined;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: process.env.AWS_S3_FILES_BUCKET,
|
||||
ContinuationToken: continuationToken,
|
||||
});
|
||||
|
||||
const response: ListObjectsV2CommandOutput = await s3Client.send(command);
|
||||
|
||||
if (response.Contents) {
|
||||
const keys = response.Contents.reduce((acc: string[], object) => {
|
||||
if (object.Key) {
|
||||
acc.push(object.Key);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
allS3Keys.push(...keys);
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
console.log(`Found ${allS3Keys.length} total files in S3`);
|
||||
|
||||
return allS3Keys;
|
||||
};
|
||||
|
||||
const findAndDeleteOrphanedFilesInS3 = async (
|
||||
allFileKeysFromS3: string[],
|
||||
allFileKeysFromDb: { s3Key: string }[]
|
||||
) => {
|
||||
const s3KeysNotFoundInDb = allFileKeysFromS3.filter(
|
||||
(s3Key) => !allFileKeysFromDb.some((file) => file.s3Key === s3Key)
|
||||
);
|
||||
|
||||
// Delete files from S3 that are not in the database
|
||||
// If any file deletion fails, the job can continue and pick it up next run.
|
||||
const s3DeletionResults = await Promise.allSettled(
|
||||
s3KeysNotFoundInDb.map((s3Key) => deleteFileFromS3({ s3Key }))
|
||||
);
|
||||
|
||||
const successfulDeletions = s3DeletionResults.filter((result) => result.status === 'fulfilled');
|
||||
|
||||
console.log(
|
||||
`Successfully deleted ${successfulDeletions.length} out of ${s3KeysNotFoundInDb.length} orphaned files from S3`
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Using Multer to upload files to your server
|
||||
|
||||
If you're looking to upload files to the app server, you can use the Multer middleware to handle file uploads. This will allow you to store files on your server and is a good option if you need a quick and dirty, free solution for simple file uploads.
|
||||
|
||||
@@ -32,8 +32,8 @@ The template comes with:
|
||||
We've also created a bunch of LLM-friendly documentation:
|
||||
- [Open SaaS Docs - LLMs.txt](https://docs.opensaas.sh/llms.txt) - Links to the raw text docs.
|
||||
- **[Open SaaS Docs - LLMs-full.txt](https://docs.opensaas.sh/llms-full.txt) - Complete docs as one text file.** ✅😎
|
||||
- Coming Soon! ~~[Wasp Docs - LLMs.txt](https://wasp.sh/llms.txt)~~ - Links to the raw text docs.
|
||||
- Coming Soon! ~~[Wasp Docs - LLMs-full.txt](https://wasp.sh/llms-full.txt)~~ - Complete docs as one text file.
|
||||
- [Wasp Docs - LLMs.txt](https://wasp.sh/llms.txt) - Links to the raw text docs.
|
||||
- **[Wasp Docs - LLMs-full.txt](https://wasp.sh/llms-full.txt) - Complete docs as one text file.**
|
||||
|
||||
Add these to your AI-assisted IDE settings so you can easily reference them in your chat sessions with the LLM.
|
||||
**In most cases, you'll want to pass the `llms-full.txt` to the LLM and ask it to help you with a specific task.**
|
||||
|
||||
@@ -92,16 +92,18 @@ See the Wasp Auth docs for available methods and complete guides [wasp-overview.
|
||||
- Redirect or show alternative content if the user is not authenticated.
|
||||
```typescript
|
||||
import { useAuth } from 'wasp/client/auth';
|
||||
import { Redirect } from 'wasp/client/router'; // Or use Link
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const MyProtectedPage = () => {
|
||||
const { data: user, isLoading, error } = useAuth(); // Returns AuthUser | null
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
// If error, it likely means the auth session is invalid/expired
|
||||
if (error || !user) {
|
||||
// Redirect to login page defined in main.wasp (auth.onAuthFailedRedirectTo)
|
||||
// Or return <Redirect to="/login" />;
|
||||
// or use the navigate hook from react-router-dom for more fine-grained control
|
||||
navigate('/some-other-path');
|
||||
return <div>Please log in to access this page.</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -221,8 +221,13 @@ page FileUploadPage {
|
||||
component: import FileUpload from "@src/file-upload/FileUploadPage"
|
||||
}
|
||||
|
||||
action createFile {
|
||||
fn: import { createFile } from "@src/file-upload/operations",
|
||||
action createFileUploadUrl {
|
||||
fn: import { createFileUploadUrl } from "@src/file-upload/operations",
|
||||
entities: [User, File]
|
||||
}
|
||||
|
||||
action addFileToDb {
|
||||
fn: import { addFileToDb } from "@src/file-upload/operations",
|
||||
entities: [User, File]
|
||||
}
|
||||
|
||||
@@ -235,6 +240,11 @@ query getDownloadFileSignedURL {
|
||||
fn: import { getDownloadFileSignedURL } from "@src/file-upload/operations",
|
||||
entities: [User, File]
|
||||
}
|
||||
|
||||
action deleteFile {
|
||||
fn: import { deleteFile } from "@src/file-upload/operations",
|
||||
entities: [User, File]
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Analytics
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.7",
|
||||
"apexcharts": "3.41.0",
|
||||
@@ -35,7 +36,6 @@
|
||||
"react-apexcharts": "1.4.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"stripe": "18.1.0",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
|
||||
@@ -60,8 +60,7 @@ model File {
|
||||
|
||||
name String
|
||||
type String
|
||||
key String
|
||||
uploadUrl String
|
||||
s3Key String
|
||||
}
|
||||
|
||||
model DailyStats {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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 {
|
||||
@@ -17,14 +16,9 @@ import DefaultLayout from "../../layout/DefaultLayout";
|
||||
|
||||
const SettingsPage = ({ user }: { user: AuthUser }) => {
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
// TODO add toast provider / wrapper
|
||||
// TODO implement
|
||||
event.preventDefault();
|
||||
const confirmed = confirm("Are you sure you want to save the changes?");
|
||||
if (confirmed) {
|
||||
toast.success("Your changes have been saved successfully!");
|
||||
} else {
|
||||
toast.error("Your changes have not been saved!");
|
||||
}
|
||||
alert("Not yet implemented");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { routes } from "wasp/client/router";
|
||||
import { Toaster } from "../components/ui/toaster";
|
||||
import "./Main.css";
|
||||
import NavBar from "./components/NavBar/NavBar";
|
||||
import {
|
||||
@@ -62,6 +63,7 @@ export default function App() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Toaster position="bottom-right" />
|
||||
<CookieConsentBanner />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,5 @@ export const marketingNavigationItems: NavigationItem[] = [
|
||||
export const demoNavigationitems: NavigationItem[] = [
|
||||
{ name: "AI Scheduler", to: routes.DemoAppRoute.to },
|
||||
{ name: "File Upload", to: routes.FileUploadRoute.to },
|
||||
{ name: "Pricing", to: routes.PricingPageRoute.to },
|
||||
...staticNavigationItems,
|
||||
] as const;
|
||||
|
||||
120
template/app/src/components/ui/dialog.tsx
Normal file
120
template/app/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground 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>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
149
template/app/src/components/ui/toast.tsx
Normal file
149
template/app/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> & {
|
||||
position?:
|
||||
| "top-left"
|
||||
| "top-center"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-center"
|
||||
| "bottom-right";
|
||||
}
|
||||
>(({ className, position, ...props }, ref) => {
|
||||
const positionClasses = position
|
||||
? {
|
||||
"top-left": "top-0 left-0",
|
||||
"top-center": "top-0 left-1/2 -translate-x-1/2",
|
||||
"top-right": "top-0 right-0",
|
||||
"bottom-left": "bottom-0 left-0",
|
||||
"bottom-center": "bottom-0 left-1/2 -translate-x-1/2",
|
||||
"bottom-right": "bottom-0 right-0",
|
||||
}[position]
|
||||
: "top-0 sm:bottom-0 sm:right-0 sm:top-auto";
|
||||
|
||||
return (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:flex-col md:max-w-[420px]",
|
||||
positionClasses,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90 focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border px-3 text-sm font-medium transition-colors focus:outline-none focus:ring-1 disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-foreground/50 hover:text-foreground absolute right-1 top-1 rounded-md p-1 opacity-0 transition-opacity focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
Toast,
|
||||
ToastAction,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
type ToastActionElement,
|
||||
type ToastProps,
|
||||
};
|
||||
43
template/app/src/components/ui/toaster.tsx
Normal file
43
template/app/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useToast } from "../../hooks/use-toast";
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "./toast";
|
||||
|
||||
export function Toaster({
|
||||
position,
|
||||
}: {
|
||||
position?:
|
||||
| "top-left"
|
||||
| "top-center"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-center"
|
||||
| "bottom-right";
|
||||
}) {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport position={position} />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
updateTask,
|
||||
useQuery,
|
||||
} from "wasp/client/operations";
|
||||
import { Link, routes } from "wasp/client/router";
|
||||
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { ArrowRight, Loader2, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
import { Checkbox } from "../components/ui/checkbox";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { ToastAction } from "../components/ui/toast";
|
||||
import { toast } from "../hooks/use-toast";
|
||||
import { cn } from "../lib/utils";
|
||||
import type {
|
||||
GeneratedSchedule,
|
||||
@@ -146,10 +149,33 @@ function NewTaskForm({
|
||||
hours: todaysHours,
|
||||
});
|
||||
if (response) {
|
||||
setResponse(response as unknown as GeneratedSchedule);
|
||||
setResponse(response);
|
||||
}
|
||||
} catch (err: any) {
|
||||
window.alert("Error: " + (err.message || "Something went wrong"));
|
||||
if (err.statusCode === 402) {
|
||||
toast({
|
||||
title: "⚠️ You are out of credits!",
|
||||
style: {
|
||||
minWidth: "400px",
|
||||
},
|
||||
action: (
|
||||
<ToastAction
|
||||
altText="Go to pricing page to buy credits/subscription"
|
||||
asChild
|
||||
>
|
||||
<Link to={routes.PricingPageRoute.to}>
|
||||
Go to pricing page <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err.message || "Something went wrong",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsPlanGenerating(false);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,10 @@ export const generateGptResponse: GenerateGptResponse<
|
||||
});
|
||||
transactions.push(decrementCredit);
|
||||
} else {
|
||||
throw new HttpError(402, "User has not paid or is out of credits");
|
||||
throw new HttpError(
|
||||
402,
|
||||
"User has no subscription and is out of credits",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import {
|
||||
addFileToDb,
|
||||
createFileUploadUrl,
|
||||
deleteFile,
|
||||
getAllFilesByUser,
|
||||
getDownloadFileSignedURL,
|
||||
useQuery,
|
||||
} from "wasp/client/operations";
|
||||
import type { File } from "wasp/entities";
|
||||
|
||||
import { Download, Trash } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "../components/ui/alert";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardTitle } from "../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Progress } from "../components/ui/progress";
|
||||
import { toast } from "../hooks/use-toast";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
type FileUploadError,
|
||||
type FileWithValidType,
|
||||
uploadFileWithProgress,
|
||||
validateFile,
|
||||
} from "./fileUploading";
|
||||
import { uploadFileWithProgress, validateFile } from "./fileUploading";
|
||||
import { ALLOWED_FILE_TYPES } from "./validation";
|
||||
|
||||
export default function FileUploadPage() {
|
||||
const [fileKeyForS3, setFileKeyForS3] = useState<File["key"]>("");
|
||||
const [fileKeyForS3, setFileKeyForS3] = useState<File["s3Key"]>("");
|
||||
const [uploadProgressPercent, setUploadProgressPercent] = useState<number>(0);
|
||||
const [uploadError, setUploadError] = useState<FileUploadError | null>(null);
|
||||
const [fileToDelete, setFileToDelete] = useState<Pick<
|
||||
File,
|
||||
"id" | "s3Key" | "name"
|
||||
> | null>(null);
|
||||
|
||||
const allUserFiles = useQuery(getAllFilesByUser, undefined, {
|
||||
// We disable automatic refetching because otherwise files would be refetched after `createFile` is called and the S3 URL is returned,
|
||||
@@ -33,7 +45,7 @@ export default function FileUploadPage() {
|
||||
const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } =
|
||||
useQuery(
|
||||
getDownloadFileSignedURL,
|
||||
{ key: fileKeyForS3 },
|
||||
{ s3Key: fileKeyForS3 },
|
||||
{ enabled: false },
|
||||
);
|
||||
|
||||
@@ -48,7 +60,11 @@ export default function FileUploadPage() {
|
||||
switch (urlQuery.status) {
|
||||
case "error":
|
||||
console.error("Error fetching download URL", urlQuery.error);
|
||||
alert("Error fetching download");
|
||||
toast({
|
||||
title: "Error fetching download link",
|
||||
description: "Please try again later.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
case "success":
|
||||
window.open(urlQuery.data, "_blank");
|
||||
@@ -71,152 +87,233 @@ export default function FileUploadPage() {
|
||||
}
|
||||
|
||||
const formData = new FormData(formElement);
|
||||
const file = formData.get("file-upload");
|
||||
const formDataFileUpload = formData.get("file-upload");
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
setUploadError({
|
||||
message: "Please select a file to upload.",
|
||||
code: "NO_FILE",
|
||||
if (
|
||||
!formDataFileUpload ||
|
||||
!(formDataFileUpload instanceof File) ||
|
||||
formDataFileUpload.size === 0
|
||||
) {
|
||||
toast({
|
||||
title: "No file selected",
|
||||
description: "Please select a file to upload.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileValidationError = validateFile(file);
|
||||
if (fileValidationError !== null) {
|
||||
setUploadError(fileValidationError);
|
||||
return;
|
||||
}
|
||||
const file = validateFile(formDataFileUpload);
|
||||
|
||||
const { s3UploadUrl, s3UploadFields, s3Key } = await createFileUploadUrl({
|
||||
fileType: file.type,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
await uploadFileWithProgress({
|
||||
file: file as FileWithValidType,
|
||||
file,
|
||||
s3UploadUrl,
|
||||
s3UploadFields,
|
||||
setUploadProgressPercent,
|
||||
});
|
||||
|
||||
await addFileToDb({
|
||||
s3Key,
|
||||
fileType: file.type,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
formElement.reset();
|
||||
allUserFiles.refetch();
|
||||
toast({
|
||||
title: "File uploaded",
|
||||
description: "Your file has been successfully uploaded.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
setUploadError({
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred while uploading the file.",
|
||||
code: "UPLOAD_FAILED",
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Error uploading file.";
|
||||
toast({
|
||||
title: "Error uploading file",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setUploadProgressPercent(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async ({ id, name }: Pick<File, "id" | "name">) => {
|
||||
try {
|
||||
await deleteFile({ id });
|
||||
toast({
|
||||
title: "File deleted",
|
||||
description: (
|
||||
<span>
|
||||
File <strong>{name}</strong> deleted.
|
||||
</span>
|
||||
),
|
||||
});
|
||||
allUserFiles.refetch();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Error deleting file.";
|
||||
toast({
|
||||
title: "Error",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setFileToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
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="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="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="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(",")}
|
||||
onChange={() => setUploadError(null)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<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" />
|
||||
<>
|
||||
<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="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="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(",")}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<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>
|
||||
)}
|
||||
{!!allUserFiles.data &&
|
||||
allUserFiles.data.length > 0 &&
|
||||
!allUserFiles.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{allUserFiles.data.map((file: File) => (
|
||||
<Card key={file.s3Key} className="p-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center",
|
||||
{
|
||||
"opacity-70":
|
||||
file.s3Key === fileKeyForS3 &&
|
||||
isDownloadUrlLoading,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<p className="text-foreground font-medium">
|
||||
{file.name}
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
onClick={() => setFileKeyForS3(file.s3Key)}
|
||||
disabled={
|
||||
file.s3Key === fileKeyForS3 &&
|
||||
isDownloadUrlLoading
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setFileToDelete(file)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Delete file"
|
||||
>
|
||||
<Trash className="text-destructive h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center">
|
||||
No files uploaded yet :(
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{uploadError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{uploadError.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</form>
|
||||
<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>
|
||||
)}
|
||||
{!!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">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center",
|
||||
{
|
||||
"opacity-70":
|
||||
file.key === fileKeyForS3 && isDownloadUrlLoading,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<p className="text-foreground font-medium">
|
||||
{file.name}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setFileKeyForS3(file.key)}
|
||||
disabled={
|
||||
file.key === fileKeyForS3 && isDownloadUrlLoading
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{file.key === fileKeyForS3 && isDownloadUrlLoading
|
||||
? "Loading..."
|
||||
: "Download"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center">
|
||||
No files uploaded yet :(
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{fileToDelete && (
|
||||
<Dialog
|
||||
open={!!fileToDelete}
|
||||
onOpenChange={(isOpen) => !isOpen && setFileToDelete(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete file</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<strong>{fileToDelete.name}</strong>? This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFileToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(fileToDelete)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
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 };
|
||||
type AllowedFileType = (typeof ALLOWED_FILE_TYPES)[number];
|
||||
interface FileUploadProgress {
|
||||
file: FileWithValidType;
|
||||
setUploadProgressPercent: (percentage: number) => void;
|
||||
}
|
||||
type AllowedFileTypes = (typeof ALLOWED_FILE_TYPES)[number];
|
||||
export type FileWithValidType = File & { type: AllowedFileTypes };
|
||||
|
||||
export async function uploadFileWithProgress({
|
||||
file,
|
||||
s3UploadUrl,
|
||||
s3UploadFields,
|
||||
setUploadProgressPercent,
|
||||
}: FileUploadProgress) {
|
||||
const { s3UploadUrl, s3UploadFields } = await createFile({
|
||||
fileType: file.type,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
}: {
|
||||
file: FileWithValidType;
|
||||
s3UploadUrl: string;
|
||||
s3UploadFields: Record<string, string>;
|
||||
setUploadProgressPercent: (percentage: number) => void;
|
||||
}) {
|
||||
const formData = getFileUploadFormData(file, s3UploadFields);
|
||||
|
||||
return axios.post(s3UploadUrl, formData, {
|
||||
@@ -44,29 +41,20 @@ function getFileUploadFormData(
|
||||
return formData;
|
||||
}
|
||||
|
||||
export interface FileUploadError {
|
||||
message: string;
|
||||
code: "NO_FILE" | "INVALID_FILE_TYPE" | "FILE_TOO_LARGE" | "UPLOAD_FAILED";
|
||||
}
|
||||
|
||||
export function validateFile(file: File) {
|
||||
export function validateFile(file: File): FileWithValidType {
|
||||
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,
|
||||
};
|
||||
throw new Error(
|
||||
`File size exceeds ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB limit.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAllowedFileType(file.type)) {
|
||||
return {
|
||||
message: `File type '${file.type}' is not supported.`,
|
||||
code: "INVALID_FILE_TYPE" as const,
|
||||
};
|
||||
if (!isFileWithAllowedFileType(file)) {
|
||||
throw new Error(`File type '${file.type}' is not supported.`);
|
||||
}
|
||||
|
||||
return null;
|
||||
return file;
|
||||
}
|
||||
|
||||
function isAllowedFileType(fileType: string): fileType is AllowedFileType {
|
||||
return (ALLOWED_FILE_TYPES as readonly string[]).includes(fileType);
|
||||
function isFileWithAllowedFileType(file: File): file is FileWithValidType {
|
||||
return ALLOWED_FILE_TYPES.includes(file.type as AllowedFileTypes);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { type File } from "wasp/entities";
|
||||
import { HttpError } from "wasp/server";
|
||||
import {
|
||||
type CreateFile,
|
||||
type AddFileToDb,
|
||||
type CreateFileUploadUrl,
|
||||
type DeleteFile,
|
||||
type GetAllFilesByUser,
|
||||
type GetDownloadFileSignedURL,
|
||||
} from "wasp/server/operations";
|
||||
import * as z from "zod";
|
||||
|
||||
import * as z from "zod";
|
||||
import { ensureArgsSchemaOrThrowHttpError } from "../server/validation";
|
||||
import {
|
||||
checkFileExistsInS3,
|
||||
deleteFileFromS3,
|
||||
getDownloadFileSignedURLFromS3,
|
||||
getUploadFileSignedURLFromS3,
|
||||
} from "./s3Utils";
|
||||
@@ -21,11 +25,12 @@ const createFileInputSchema = z.object({
|
||||
|
||||
type CreateFileInput = z.infer<typeof createFileInputSchema>;
|
||||
|
||||
export const createFile: CreateFile<
|
||||
export const createFileUploadUrl: CreateFileUploadUrl<
|
||||
CreateFileInput,
|
||||
{
|
||||
s3UploadUrl: string;
|
||||
s3UploadFields: Record<string, string>;
|
||||
s3Key: string;
|
||||
}
|
||||
> = async (rawArgs, context) => {
|
||||
if (!context.user) {
|
||||
@@ -37,27 +42,42 @@ export const createFile: CreateFile<
|
||||
rawArgs,
|
||||
);
|
||||
|
||||
const { s3UploadUrl, s3UploadFields, key } =
|
||||
await getUploadFileSignedURLFromS3({
|
||||
fileType,
|
||||
fileName,
|
||||
userId: context.user.id,
|
||||
});
|
||||
return await getUploadFileSignedURLFromS3({
|
||||
fileType,
|
||||
fileName,
|
||||
userId: context.user.id,
|
||||
});
|
||||
};
|
||||
|
||||
await context.entities.File.create({
|
||||
const addFileToDbInputSchema = z.object({
|
||||
s3Key: z.string(),
|
||||
fileType: z.enum(ALLOWED_FILE_TYPES),
|
||||
fileName: z.string(),
|
||||
});
|
||||
|
||||
type AddFileToDbInput = z.infer<typeof addFileToDbInputSchema>;
|
||||
|
||||
export const addFileToDb: AddFileToDb<AddFileToDbInput, File> = async (
|
||||
args,
|
||||
context,
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
}
|
||||
|
||||
const fileExists = await checkFileExistsInS3({ s3Key: args.s3Key });
|
||||
if (!fileExists) {
|
||||
throw new HttpError(404, "File not found in S3.");
|
||||
}
|
||||
|
||||
return context.entities.File.create({
|
||||
data: {
|
||||
name: fileName,
|
||||
key,
|
||||
uploadUrl: s3UploadUrl,
|
||||
type: fileType,
|
||||
name: args.fileName,
|
||||
s3Key: args.s3Key,
|
||||
type: args.fileType,
|
||||
user: { connect: { id: context.user.id } },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
s3UploadUrl,
|
||||
s3UploadFields,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (
|
||||
@@ -80,7 +100,7 @@ export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (
|
||||
};
|
||||
|
||||
const getDownloadFileSignedURLInputSchema = z.object({
|
||||
key: z.string().nonempty(),
|
||||
s3Key: z.string().nonempty(),
|
||||
});
|
||||
|
||||
type GetDownloadFileSignedURLInput = z.infer<
|
||||
@@ -91,9 +111,44 @@ export const getDownloadFileSignedURL: GetDownloadFileSignedURL<
|
||||
GetDownloadFileSignedURLInput,
|
||||
string
|
||||
> = async (rawArgs, _context) => {
|
||||
const { key } = ensureArgsSchemaOrThrowHttpError(
|
||||
const { s3Key } = ensureArgsSchemaOrThrowHttpError(
|
||||
getDownloadFileSignedURLInputSchema,
|
||||
rawArgs,
|
||||
);
|
||||
return await getDownloadFileSignedURLFromS3({ key });
|
||||
return await getDownloadFileSignedURLFromS3({ s3Key });
|
||||
};
|
||||
|
||||
const deleteFileInputSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
type DeleteFileInput = z.infer<typeof deleteFileInputSchema>;
|
||||
|
||||
export const deleteFile: DeleteFile<DeleteFileInput, File> = async (
|
||||
args,
|
||||
context,
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
}
|
||||
|
||||
const deletedFile = await context.entities.File.delete({
|
||||
where: {
|
||||
id: args.id,
|
||||
user: {
|
||||
id: context.user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await deleteFileFromS3({ s3Key: deletedFile.s3Key });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`S3 deletion failed. Orphaned file s3Key: ${deletedFile.s3Key}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return deletedFile;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
S3Client,
|
||||
S3ServiceException,
|
||||
} 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({
|
||||
export const s3Client = new S3Client({
|
||||
region: process.env.AWS_S3_REGION,
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_S3_IAM_ACCESS_KEY!,
|
||||
@@ -24,12 +30,12 @@ export const getUploadFileSignedURLFromS3 = async ({
|
||||
fileType,
|
||||
userId,
|
||||
}: S3Upload) => {
|
||||
const key = getS3Key(fileName, userId);
|
||||
const s3Key = getS3Key(fileName, userId);
|
||||
|
||||
const { url: s3UploadUrl, fields: s3UploadFields } =
|
||||
await createPresignedPost(s3Client, {
|
||||
Bucket: process.env.AWS_S3_FILES_BUCKET!,
|
||||
Key: key,
|
||||
Key: s3Key,
|
||||
Conditions: [["content-length-range", 0, MAX_FILE_SIZE_BYTES]],
|
||||
Fields: {
|
||||
"Content-Type": fileType,
|
||||
@@ -37,21 +43,45 @@ export const getUploadFileSignedURLFromS3 = async ({
|
||||
Expires: 3600,
|
||||
});
|
||||
|
||||
return { s3UploadUrl, key, s3UploadFields };
|
||||
return { s3UploadUrl, s3Key, s3UploadFields };
|
||||
};
|
||||
|
||||
export const getDownloadFileSignedURLFromS3 = async ({
|
||||
key,
|
||||
s3Key,
|
||||
}: {
|
||||
key: string;
|
||||
s3Key: string;
|
||||
}) => {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: process.env.AWS_S3_FILES_BUCKET,
|
||||
Key: key,
|
||||
Key: s3Key,
|
||||
});
|
||||
return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||
};
|
||||
|
||||
export const deleteFileFromS3 = async ({ s3Key }: { s3Key: string }) => {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: process.env.AWS_S3_FILES_BUCKET,
|
||||
Key: s3Key,
|
||||
});
|
||||
await s3Client.send(command);
|
||||
};
|
||||
|
||||
export const checkFileExistsInS3 = async ({ s3Key }: { s3Key: string }) => {
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: process.env.AWS_S3_FILES_BUCKET,
|
||||
Key: s3Key,
|
||||
});
|
||||
try {
|
||||
await s3Client.send(command);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && error.name === "NotFound") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
function getS3Key(fileName: string, userId: string) {
|
||||
const ext = path.extname(fileName).slice(1);
|
||||
return `${userId}/${randomUUID()}.${ext}`;
|
||||
|
||||
188
template/app/src/hooks/use-toast.ts
Normal file
188
template/app/src/hooks/use-toast.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "../components/ui/toast";
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { toast, useToast };
|
||||
@@ -90,7 +90,7 @@ export const makeStripePayment = async ({
|
||||
}) => {
|
||||
test.slow(); // Stripe payments take a long time to confirm and can cause tests to fail so we use a longer timeout
|
||||
|
||||
await page.click('text="Pricing"');
|
||||
await page.goto("/pricing");
|
||||
await page.waitForURL("**/pricing");
|
||||
|
||||
const buyBtn = page.locator(`button[aria-describedby="${planId}"]`);
|
||||
|
||||
Reference in New Issue
Block a user