diff --git a/opensaas-sh/app_diff/deletions b/opensaas-sh/app_diff/deletions index 90fe220da..3344ea0e2 100644 --- a/opensaas-sh/app_diff/deletions +++ b/opensaas-sh/app_diff/deletions @@ -1,7 +1,6 @@ src/client/static/open-saas-banner-dark.png src/client/static/open-saas-banner-light.png 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 diff --git a/opensaas-sh/app_diff/main.wasp.diff b/opensaas-sh/app_diff/main.wasp.diff index 24b4b0763..efb6710ae 100644 --- a/opensaas-sh/app_diff/main.wasp.diff +++ b/opensaas-sh/app_diff/main.wasp.diff @@ -119,7 +119,26 @@ httpRoute: (POST, "/payments-webhook") } //#endregion -@@ -281,7 +279,6 @@ +@@ -245,6 +243,18 @@ + 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 0 * * *" // every day at midnight ++ // cron: "* * * * *" // every minute. useful for debugging ++ }, ++ entities: [File] ++} + //#endregion + + //#region Analytics +@@ -291,7 +301,6 @@ component: import AdminCalendar from "@src/admin/elements/calendar/CalendarPage" } diff --git a/opensaas-sh/app_diff/migrations/20250731133938_drop_upload_url_from_file/migration.sql.diff b/opensaas-sh/app_diff/migrations/20250731133938_drop_upload_url_from_file/migration.sql.diff new file mode 100644 index 000000000..1a2122bb3 --- /dev/null +++ b/opensaas-sh/app_diff/migrations/20250731133938_drop_upload_url_from_file/migration.sql.diff @@ -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"; diff --git a/opensaas-sh/app_diff/migrations/20250806121259_add_s3_key_file/migration.sql.diff b/opensaas-sh/app_diff/migrations/20250806121259_add_s3_key_file/migration.sql.diff new file mode 100644 index 000000000..b72ee9c56 --- /dev/null +++ b/opensaas-sh/app_diff/migrations/20250806121259_add_s3_key_file/migration.sql.diff @@ -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; diff --git a/opensaas-sh/app_diff/package-lock.json.diff b/opensaas-sh/app_diff/package-lock.json.diff index a26292375..9d604e79a 100644 --- a/opensaas-sh/app_diff/package-lock.json.diff +++ b/opensaas-sh/app_diff/package-lock.json.diff @@ -1,6 +1,6 @@ --- template/app/package-lock.json +++ opensaas-sh/app/package-lock.json -@@ -0,0 +1,13784 @@ +@@ -0,0 +1,13792 @@ +{ + "name": "opensaas", + "lockfileVersion": 3, @@ -27,6 +27,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", @@ -42,7 +43,6 @@ + "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", @@ -3249,6 +3249,40 @@ + } + } + }, ++ "node_modules/@radix-ui/react-toast": { ++ "version": "1.2.14", ++ "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz", ++ "integrity": "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==", ++ "license": "MIT", ++ "dependencies": { ++ "@radix-ui/primitive": "1.1.2", ++ "@radix-ui/react-collection": "1.1.7", ++ "@radix-ui/react-compose-refs": "1.1.2", ++ "@radix-ui/react-context": "1.1.2", ++ "@radix-ui/react-dismissable-layer": "1.1.10", ++ "@radix-ui/react-portal": "1.1.9", ++ "@radix-ui/react-presence": "1.1.4", ++ "@radix-ui/react-primitive": "2.1.3", ++ "@radix-ui/react-use-callback-ref": "1.1.1", ++ "@radix-ui/react-use-controllable-state": "1.2.2", ++ "@radix-ui/react-use-layout-effect": "1.1.1", ++ "@radix-ui/react-visually-hidden": "1.2.3" ++ }, ++ "peerDependencies": { ++ "@types/react": "*", ++ "@types/react-dom": "*", ++ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", ++ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" ++ }, ++ "peerDependenciesMeta": { ++ "@types/react": { ++ "optional": true ++ }, ++ "@types/react-dom": { ++ "optional": true ++ } ++ } ++ }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -7549,15 +7583,6 @@ + "node": ">=10.13.0" + } + }, -+ "node_modules/goober": { -+ "version": "2.1.16", -+ "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", -+ "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", -+ "license": "MIT", -+ "peerDependencies": { -+ "csstype": "^3.0.10" -+ } -+ }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", @@ -10629,23 +10654,6 @@ + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, -+ "node_modules/react-hot-toast": { -+ "version": "2.5.2", -+ "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz", -+ "integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==", -+ "license": "MIT", -+ "dependencies": { -+ "csstype": "^3.1.3", -+ "goober": "^2.1.16" -+ }, -+ "engines": { -+ "node": ">=10" -+ }, -+ "peerDependencies": { -+ "react": ">=16", -+ "react-dom": ">=16" -+ } -+ }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", diff --git a/opensaas-sh/app_diff/package.json.diff b/opensaas-sh/app_diff/package.json.diff index b0e1ec570..285a469f2 100644 --- a/opensaas-sh/app_diff/package.json.diff +++ b/opensaas-sh/app_diff/package.json.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", diff --git a/opensaas-sh/app_diff/src/client/App.tsx.diff b/opensaas-sh/app_diff/src/client/App.tsx.diff new file mode 100644 index 000000000..0d7f343bb --- /dev/null +++ b/opensaas-sh/app_diff/src/client/App.tsx.diff @@ -0,0 +1,24 @@ +--- template/app/src/client/App.tsx ++++ opensaas-sh/app/src/client/App.tsx +@@ -4,6 +4,7 @@ + import './Main.css'; + import NavBar from './components/NavBar/NavBar'; + import { demoNavigationitems, marketingNavigationItems } from './components/NavBar/constants'; ++import { useIsLandingPage } from './hooks/useIsLandingPage'; + import CookieConsentBanner from './components/cookie-consent/Banner'; + import { Toaster } from '../components/ui/toaster'; + +@@ -13,11 +14,8 @@ + */ + export default function App() { + const location = useLocation(); +- const isMarketingPage = useMemo(() => { +- return location.pathname === '/' || location.pathname.startsWith('/pricing'); +- }, [location]); +- +- const navigationItems = isMarketingPage ? marketingNavigationItems : demoNavigationitems; ++ const isLandingPage = useIsLandingPage(); ++ const navigationItems = isLandingPage ? marketingNavigationItems : demoNavigationitems; + + const shouldDisplayAppNavBar = useMemo(() => { + return ( diff --git a/opensaas-sh/app_diff/src/client/components/NavBar/constants.ts.diff b/opensaas-sh/app_diff/src/client/components/NavBar/constants.ts.diff index 32e07ec22..7eed19f13 100644 --- a/opensaas-sh/app_diff/src/client/components/NavBar/constants.ts.diff +++ b/opensaas-sh/app_diff/src/client/components/NavBar/constants.ts.diff @@ -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; diff --git a/opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff b/opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff index a85000b7d..bdc5f0c55 100644 --- a/opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff +++ b/opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff @@ -1,9 +1,28 @@ --- template/app/src/file-upload/fileUploading.ts +++ opensaas-sh/app/src/file-upload/fileUploading.ts -@@ -1,5 +1,5 @@ --import { createFile } from 'wasp/client/operations'; +@@ -1,5 +1,7 @@ ++import type { User } from 'wasp/entities'; import axios from 'axios'; -+import { createFile } from 'wasp/client/operations'; import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from './validation'; ++import { PrismaClient } from '@prisma/client'; - export type FileWithValidType = Omit & { type: AllowedFileType }; + type AllowedFileTypes = (typeof ALLOWED_FILE_TYPES)[number]; + export type FileWithValidType = File & { type: AllowedFileTypes }; +@@ -51,3 +53,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; ++} diff --git a/opensaas-sh/app_diff/src/file-upload/operations.ts.diff b/opensaas-sh/app_diff/src/file-upload/operations.ts.diff index bacecb0cb..feb0d18be 100644 --- a/opensaas-sh/app_diff/src/file-upload/operations.ts.diff +++ b/opensaas-sh/app_diff/src/file-upload/operations.ts.diff @@ -1,39 +1,34 @@ --- template/app/src/file-upload/operations.ts +++ opensaas-sh/app/src/file-upload/operations.ts -@@ -1,14 +1,14 @@ +@@ -1,6 +1,5 @@ -import * as z from 'zod'; -import { HttpError } from 'wasp/server'; import { type File } from 'wasp/entities'; +import { HttpError } from 'wasp/server'; import { - type CreateFile, type GetAllFilesByUser, type GetDownloadFileSignedURL, +@@ -8,6 +7,8 @@ + type CreateFileUploadUrl, + type AddFileToDb, } from 'wasp/server/operations'; ++import { checkIfUserHasReachedFileUploadLimit } from './fileUploading'; +import * as z from 'zod'; --import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils'; - import { ensureArgsSchemaOrThrowHttpError } from '../server/validation'; -+import { getDownloadFileSignedURLFromS3, getUploadFileSignedURLFromS3 } from './s3Utils'; - import { ALLOWED_FILE_TYPES } from './validation'; - - const createFileInputSchema = z.object({ -@@ -37,6 +37,18 @@ - userId: context.user.id, - }); + import { + getUploadFileSignedURLFromS3, +@@ -37,6 +38,14 @@ + throw new HttpError(401); + } -+ const numberOfFilesByUser = await context.entities.File.count({ -+ where: { -+ user: { -+ id: context.user.id, -+ }, -+ }, ++ 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.'); + } + - await context.entities.File.create({ - data: { - name: fileName, + const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs); + + return await getUploadFileSignedURLFromS3({ diff --git a/opensaas-sh/app_diff/src/file-upload/s3Utils.ts.diff b/opensaas-sh/app_diff/src/file-upload/s3Utils.ts.diff deleted file mode 100644 index ddf4b4ea1..000000000 --- a/opensaas-sh/app_diff/src/file-upload/s3Utils.ts.diff +++ /dev/null @@ -1,15 +0,0 @@ ---- template/app/src/file-upload/s3Utils.ts -+++ opensaas-sh/app/src/file-upload/s3Utils.ts -@@ -1,8 +1,8 @@ --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 { 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({ diff --git a/opensaas-sh/app_diff/src/file-upload/workers.ts.diff b/opensaas-sh/app_diff/src/file-upload/workers.ts.diff new file mode 100644 index 000000000..75c6112ba --- /dev/null +++ b/opensaas-sh/app_diff/src/file-upload/workers.ts.diff @@ -0,0 +1,32 @@ +--- template/app/src/file-upload/workers.ts ++++ opensaas-sh/app/src/file-upload/workers.ts +@@ -0,0 +1,29 @@ ++import type { DeleteFilesJob } from 'wasp/server/jobs'; ++import { deleteFileFromS3 } from './s3Utils'; ++ ++export const deleteFilesJob: DeleteFilesJob = async (_args, context) => { ++ const filesToDelete = await context.entities.File.findMany({ ++ where: { ++ createdAt: { ++ lt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7), ++ }, ++ }, ++ select: { s3Key: true, id: true }, ++ }); ++ ++ for (const file of filesToDelete) { ++ try { ++ await deleteFileFromS3({ s3Key: file.s3Key }); ++ } catch (err) { ++ console.error(`Failed to delete S3 file with key ${file.s3Key}:`, err); ++ } ++ } ++ ++ const deletedFiles = await context.entities.File.deleteMany({ ++ where: { ++ id: { in: filesToDelete.map((file) => file.id) }, ++ }, ++ }); ++ ++ console.log(`Deleted ${deletedFiles.count} files`); ++}; diff --git a/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff b/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff index 28ff9db0a..27cb7d75a 100644 --- a/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff +++ b/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff @@ -1,53 +1,73 @@ --- template/app/src/landing-page/contentSections.tsx +++ opensaas-sh/app/src/landing-page/contentSections.tsx -@@ -0,0 +1,244 @@ +@@ -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,226 @@ + 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: 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: File upload illustration, + 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.', @@ -56,16 +76,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: 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.', @@ -73,8 +103,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.', @@ -85,26 +118,54 @@ + { + 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: 'Mr. Foobar', +- role: 'Founder @ Cool Startup', +- avatarSrc: daBoiAvatar, +- socialUrl: '', +- quote: 'This product makes me cooler than I already am.', +- }, +- { +- name: 'Jamie', +- role: 'Happy Customer', +- avatarSrc: daBoiAvatar, +- socialUrl: '#', +- quote: 'My cats love it!', + name: 'Max Khamrovskyi', + role: 'Senior Eng @ Red Hat', + avatarSrc: 'https://pbs.twimg.com/profile_images/1719397191205179392/V_QrGPSO_400x400.jpg', @@ -113,20 +174,12 @@ + 'I used Wasp to build and sell my AI-augmented SaaS app for marketplace vendors within two months!', + }, + { -+ name: 'Jonathan Cocharan', -+ role: 'Entrepreneur', -+ avatarSrc: 'https://pbs.twimg.com/profile_images/1910056203863883776/jtfVWaEG_400x400.jpg', -+ 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: 'Billy Howell', -+ role: 'Entrepreneur', ++ role: 'Founder @ Stupid Simple Apps', + 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', @@ -140,7 +193,7 @@ + role: 'Founder @ Microinfluencer.club', + avatarSrc: 'https://pbs.twimg.com/profile_images/1927721707164377089/it8oCAkf_400x400.jpg', + socialUrl: 'https://twitter.com/CamBlackwood95', -+ quote: 'Setting up a full stack SaaS in 1 minute with WaspLang.', ++ quote: 'Setting up a full stack SaaS in 1 minute with Wasp.', + }, + { + name: 'JLegendz', @@ -173,11 +226,15 @@ + socialUrl: 'https://dev.to/wasp/our-web-framework-reached-9000-stars-on-github-9000-jij#comment-2dech', + 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.', @@ -188,60 +245,93 @@ + href: 'https://wasp-lang.dev', + 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', -+ }, -+]; + }, + ]; diff --git a/opensaas-sh/blog/src/content/docs/guides/vibe-coding.mdx b/opensaas-sh/blog/src/content/docs/guides/vibe-coding.mdx index b7ec3780f..5c352bcb1 100644 --- a/opensaas-sh/blog/src/content/docs/guides/vibe-coding.mdx +++ b/opensaas-sh/blog/src/content/docs/guides/vibe-coding.mdx @@ -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.** diff --git a/template/app/.cursor/rules/authentication.mdc b/template/app/.cursor/rules/authentication.mdc index cf8e7addc..25e622f43 100644 --- a/template/app/.cursor/rules/authentication.mdc +++ b/template/app/.cursor/rules/authentication.mdc @@ -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
Loading...
; // 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 ; + // or use the navigate hook from react-router-dom for more fine-grained control + navigate('/some-other-path'); return
Please log in to access this page.
; } diff --git a/template/app/main.wasp b/template/app/main.wasp index d1b3ea365..8befdd28d 100644 --- a/template/app/main.wasp +++ b/template/app/main.wasp @@ -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 diff --git a/template/app/package.json b/template/app/package.json index adb0b6119..4606d6074 100644 --- a/template/app/package.json +++ b/template/app/package.json @@ -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", diff --git a/template/app/schema.prisma b/template/app/schema.prisma index eda467170..3c4ff6e98 100644 --- a/template/app/schema.prisma +++ b/template/app/schema.prisma @@ -60,8 +60,7 @@ model File { name String type String - key String - uploadUrl String + s3Key String } model DailyStats { diff --git a/template/app/src/admin/elements/settings/SettingsPage.tsx b/template/app/src/admin/elements/settings/SettingsPage.tsx index 087d8ce38..4e6b425d5 100644 --- a/template/app/src/admin/elements/settings/SettingsPage.tsx +++ b/template/app/src/admin/elements/settings/SettingsPage.tsx @@ -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 { Card, CardContent, CardHeader, CardTitle } from '../../../components/ui/card'; @@ -15,15 +14,10 @@ const SettingsPage = ({ user }: { user: AuthUser }) => { useRedirectHomeUnlessUserIsAdmin({ user }); const handleSubmit = (event: FormEvent) => { - // 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 ( diff --git a/template/app/src/client/App.tsx b/template/app/src/client/App.tsx index b077dc822..6bd042e0c 100644 --- a/template/app/src/client/App.tsx +++ b/template/app/src/client/App.tsx @@ -5,6 +5,7 @@ import './Main.css'; import NavBar from './components/NavBar/NavBar'; import { demoNavigationitems, marketingNavigationItems } from './components/NavBar/constants'; import CookieConsentBanner from './components/cookie-consent/Banner'; +import { Toaster } from '../components/ui/toaster'; /** * use this component to wrap all child components @@ -52,6 +53,7 @@ export default function App() { )} + ); diff --git a/template/app/src/client/components/NavBar/constants.ts b/template/app/src/client/components/NavBar/constants.ts index df65fca96..8e0ecfad8 100644 --- a/template/app/src/client/components/NavBar/constants.ts +++ b/template/app/src/client/components/NavBar/constants.ts @@ -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; diff --git a/template/app/src/components/ui/dialog.tsx b/template/app/src/components/ui/dialog.tsx new file mode 100644 index 000000000..24c7b7454 --- /dev/null +++ b/template/app/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/template/app/src/components/ui/toast.tsx b/template/app/src/components/ui/toast.tsx new file mode 100644 index 000000000..7dd25f7a9 --- /dev/null +++ b/template/app/src/components/ui/toast.tsx @@ -0,0 +1,143 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "../../lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + 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 ( + + ) +}) +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, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/template/app/src/components/ui/toaster.tsx b/template/app/src/components/ui/toaster.tsx new file mode 100644 index 000000000..9ba3afdb6 --- /dev/null +++ b/template/app/src/components/ui/toaster.tsx @@ -0,0 +1,37 @@ +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 ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/template/app/src/demo-ai-app/DemoAppPage.tsx b/template/app/src/demo-ai-app/DemoAppPage.tsx index 517b08f2c..7ef9d4c12 100644 --- a/template/app/src/demo-ai-app/DemoAppPage.tsx +++ b/template/app/src/demo-ai-app/DemoAppPage.tsx @@ -1,4 +1,5 @@ import { type Task } from 'wasp/entities'; +import type { GeneratedSchedule, Task as ScheduleTask, TaskItem, TaskPriority } from './schedule'; import { createTask, @@ -8,8 +9,9 @@ import { updateTask, useQuery, } from 'wasp/client/operations'; +import { routes, Link } 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 { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'; @@ -17,7 +19,8 @@ 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 { toast } from '../hooks/use-toast'; +import { ToastAction } from '../components/ui/toast'; export default function DemoAppPage() { return ( @@ -130,10 +133,30 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask 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: ( + + + Go to pricing page + + + ), + }); + } else { + toast({ + title: 'Error', + description: err.message || 'Something went wrong', + variant: 'destructive', + }); + } } finally { setIsPlanGenerating(false); } diff --git a/template/app/src/demo-ai-app/operations.ts b/template/app/src/demo-ai-app/operations.ts index 26137b522..dc3bd32c6 100644 --- a/template/app/src/demo-ai-app/operations.ts +++ b/template/app/src/demo-ai-app/operations.ts @@ -83,7 +83,7 @@ export const generateGptResponse: GenerateGptResponse(''); + const [fileKeyForS3, setFileKeyForS3] = useState(''); const [uploadProgressPercent, setUploadProgressPercent] = useState(0); - const [uploadError, setUploadError] = useState(null); + const [fileToDelete, setFileToDelete] = useState | 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, @@ -28,7 +40,7 @@ export default function FileUploadPage() { }); const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } = useQuery( getDownloadFileSignedURL, - { key: fileKeyForS3 }, + { s3Key: fileKeyForS3 }, { enabled: false } ); @@ -43,7 +55,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'); @@ -66,31 +82,50 @@ 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, + s3UploadUrl, + s3UploadFields, + setUploadProgressPercent, + }); + + await addFileToDb({ + s3Key, + fileType: file.type, + fileName: file.name, + }); - await uploadFileWithProgress({ file: file as FileWithValidType, setUploadProgressPercent }); 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); @@ -98,86 +133,138 @@ export default function FileUploadPage() { }; return ( -
-
-
-

- AWS File Upload -

-
-

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

- - -
-
- - setUploadError(null)} - className='cursor-pointer' - /> -
-
- - {uploadProgressPercent > 0 && } -
- {uploadError && ( - - {uploadError.message} - - )} -
-
-
- Uploaded Files - {allUserFiles.isLoading &&

Loading...

} - {allUserFiles.error && ( - - Error: {allUserFiles.error.message} - - )} - {!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? ( -
- {allUserFiles.data.map((file: File) => ( - -
-

{file.name}

- -
-
- ))} + <> +
+
+
+

+ AWS File Upload +

+
+

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

+ + +
+
+ + +
+
+ + {uploadProgressPercent > 0 && }
- ) : ( -

No files uploaded yet :(

- )} -
- - + +
+
+ Uploaded Files + {allUserFiles.isLoading &&

Loading...

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

{file.name}

+
+ + +
+
+
+ ))} +
+ ) : ( +

No files uploaded yet :(

+ )} +
+ + +
-
+ {fileToDelete && ( + !isOpen && setFileToDelete(null)}> + + + Delete file + + Are you sure you want to delete {fileToDelete.name}? This action cannot be + undone. + + + + + + + + + )} + ); } diff --git a/template/app/src/file-upload/fileUploading.ts b/template/app/src/file-upload/fileUploading.ts index 3c94917de..8d35082ba 100644 --- a/template/app/src/file-upload/fileUploading.ts +++ b/template/app/src/file-upload/fileUploading.ts @@ -1,17 +1,20 @@ -import { createFile } from 'wasp/client/operations'; import axios from 'axios'; import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from './validation'; -export type FileWithValidType = Omit & { type: AllowedFileType }; -type AllowedFileType = (typeof ALLOWED_FILE_TYPES)[number]; -interface FileUploadProgress { +type AllowedFileTypes = (typeof ALLOWED_FILE_TYPES)[number]; +export type FileWithValidType = File & { type: AllowedFileTypes }; + +export async function uploadFileWithProgress({ + file, + s3UploadUrl, + s3UploadFields, + setUploadProgressPercent, +}: { file: FileWithValidType; + s3UploadUrl: string; + s3UploadFields: Record; setUploadProgressPercent: (percentage: number) => void; -} - -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, { @@ -33,29 +36,18 @@ function getFileUploadFormData(file: File, s3UploadFields: Record 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); } diff --git a/template/app/src/file-upload/operations.ts b/template/app/src/file-upload/operations.ts index 6fe8386e8..2db5a12b8 100644 --- a/template/app/src/file-upload/operations.ts +++ b/template/app/src/file-upload/operations.ts @@ -2,12 +2,19 @@ import * as z from 'zod'; import { HttpError } from 'wasp/server'; import { type File } from 'wasp/entities'; import { - type CreateFile, type GetAllFilesByUser, type GetDownloadFileSignedURL, + type DeleteFile, + type CreateFileUploadUrl, + type AddFileToDb, } from 'wasp/server/operations'; -import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils'; +import { + getUploadFileSignedURLFromS3, + getDownloadFileSignedURLFromS3, + deleteFileFromS3, + checkFileExistsInS3, +} from './s3Utils'; import { ensureArgsSchemaOrThrowHttpError } from '../server/validation'; import { ALLOWED_FILE_TYPES } from './validation'; @@ -18,11 +25,12 @@ const createFileInputSchema = z.object({ type CreateFileInput = z.infer; -export const createFile: CreateFile< +export const createFileUploadUrl: CreateFileUploadUrl< CreateFileInput, { s3UploadUrl: string; s3UploadFields: Record; + s3Key: string; } > = async (rawArgs, context) => { if (!context.user) { @@ -31,26 +39,39 @@ export const createFile: CreateFile< const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs); - const { s3UploadUrl, s3UploadFields, key } = await getUploadFileSignedURLFromS3({ + return await getUploadFileSignedURLFromS3({ fileType, fileName, userId: context.user.id, }); +}; + +const addFileToDbInputSchema = z.object({ + s3Key: z.string(), + fileType: z.enum(ALLOWED_FILE_TYPES), + fileName: z.string(), +}); + +type AddFileToDbInput = z.infer; + +export const addFileToDb: AddFileToDb = 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.'); + } - await context.entities.File.create({ + 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 = async (_args, context) => { @@ -69,7 +90,7 @@ export const getAllFilesByUser: GetAllFilesByUser = async (_args, }); }; -const getDownloadFileSignedURLInputSchema = z.object({ key: z.string().nonempty() }); +const getDownloadFileSignedURLInputSchema = z.object({ s3Key: z.string().nonempty() }); type GetDownloadFileSignedURLInput = z.infer; @@ -77,6 +98,35 @@ export const getDownloadFileSignedURL: GetDownloadFileSignedURL< GetDownloadFileSignedURLInput, string > = async (rawArgs, _context) => { - const { key } = ensureArgsSchemaOrThrowHttpError(getDownloadFileSignedURLInputSchema, rawArgs); - return await getDownloadFileSignedURLFromS3({ key }); + const { s3Key } = ensureArgsSchemaOrThrowHttpError(getDownloadFileSignedURLInputSchema, rawArgs); + return await getDownloadFileSignedURLFromS3({ s3Key }); +}; + +const deleteFileInputSchema = z.object({ + id: z.string(), +}); + +type DeleteFileInput = z.infer; + +export const deleteFile: DeleteFile = 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; }; diff --git a/template/app/src/file-upload/s3Utils.ts b/template/app/src/file-upload/s3Utils.ts index 9f16a65bc..b629b7669 100644 --- a/template/app/src/file-upload/s3Utils.ts +++ b/template/app/src/file-upload/s3Utils.ts @@ -1,6 +1,6 @@ import * as path from 'path'; import { randomUUID } from 'crypto'; -import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; +import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand } 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'; @@ -20,11 +20,11 @@ type S3Upload = { }; export const getUploadFileSignedURLFromS3 = async ({ fileName, 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, @@ -32,17 +32,41 @@ export const getUploadFileSignedURLFromS3 = async ({ fileName, fileType, userId Expires: 3600, }); - return { s3UploadUrl, key, s3UploadFields }; + return { s3UploadUrl, s3Key, s3UploadFields }; }; -export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) => { +export const getDownloadFileSignedURLFromS3 = async ({ s3Key }: { 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: any) { + if (error.name === 'NotFound') { + return false; + } + throw error; + } +}; + function getS3Key(fileName: string, userId: string) { const ext = path.extname(fileName).slice(1); return `${userId}/${randomUUID()}.${ext}`; diff --git a/template/app/src/hooks/use-toast.ts b/template/app/src/hooks/use-toast.ts new file mode 100644 index 000000000..a33ac511c --- /dev/null +++ b/template/app/src/hooks/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +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 + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +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 + +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(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 { useToast, toast } diff --git a/template/app/src/landing-page/contentSections.ts b/template/app/src/landing-page/contentSections.tsx similarity index 100% rename from template/app/src/landing-page/contentSections.ts rename to template/app/src/landing-page/contentSections.tsx diff --git a/template/e2e-tests/tests/utils.ts b/template/e2e-tests/tests/utils.ts index d002c9aec..51deeac96 100644 --- a/template/e2e-tests/tests/utils.ts +++ b/template/e2e-tests/tests/utils.ts @@ -84,7 +84,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}"]`);