mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-05-22 02:00:08 +02:00
fix seed, stats, footer, etc
This commit is contained in:
parent
646e33f881
commit
7850db18a3
@ -11,6 +11,9 @@ PRO_SUBSCRIPTION_PRICE_ID=price_...
|
|||||||
# after downloading starting the stripe cli (https://stripe.com/docs/stripe-cli) with `stripe listen --forward-to localhost:3001/stripe-webhook` it will output your signing secret
|
# after downloading starting the stripe cli (https://stripe.com/docs/stripe-cli) with `stripe listen --forward-to localhost:3001/stripe-webhook` it will output your signing secret
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
|
||||||
|
# set this as a comma-separated list of emails you want to give admin privileges to upon registeration
|
||||||
|
ADMIN_EMAILS=me@example.com,you@example.com,them@example.com
|
||||||
|
|
||||||
# this needs to be a string at least 32 characters long
|
# this needs to be a string at least 32 characters long
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
|
||||||
|
@ -60,7 +60,8 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
label: 'General',
|
label: 'General',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'User Permissions', link: 'general/user-permissions/' },
|
{ label: 'Admin Dashboard', link: '/general/admin-dashboard/' },
|
||||||
|
{ label: 'User Permissions', link: '/general/user-permissions/' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
62
docs/src/content/docs/general/admin-dashboard.md
Normal file
62
docs/src/content/docs/general/admin-dashboard.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
title: Admin Dashboard
|
||||||
|
---
|
||||||
|
This is a reference on how the Admin dashboard is set up and works.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
The Admin dashboard is only accessible to users with the `isAdmin` field set to true.
|
||||||
|
|
||||||
|
```tsx title="main.wasp" {5}
|
||||||
|
entity User {=psl
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String? @unique
|
||||||
|
password String?
|
||||||
|
isAdmin Boolean @default(false)
|
||||||
|
//...
|
||||||
|
```
|
||||||
|
|
||||||
|
To give yourself administrator priveledges, make sure you add your email adderesses to the `ADMIN_EMAILS` environment variable in `.env.server` file before registering/logging in with that email address.
|
||||||
|
|
||||||
|
```sh title=".env.server"
|
||||||
|
ADMIN_EMAILS=me@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
if you want to give administrator priveledges to other users, you can do so by adding them to `ADMIN_EMAILS` as a comma-separated list.
|
||||||
|
|
||||||
|
```sh title=".env.server"
|
||||||
|
ADMIN_EMAILS=me@example.com,you@example.com,them@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Dashboard Pages
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
The Admin dashboard is a single place for you to view your most important metrics and perform some admin tasks. At the moment, it pulls data from:
|
||||||
|
|
||||||
|
<!-- TODO: add photo -->
|
||||||
|
|
||||||
|
- [Stripe](/guides/stripe-integration):
|
||||||
|
- total revenue
|
||||||
|
- revenue for each day of the past week
|
||||||
|
- [Google or Plausible](/guides/analytics):
|
||||||
|
- total number of page views (non-unique)
|
||||||
|
- percentage change in page views from the previous day
|
||||||
|
- top sources/referrers with unique visitor count (i.e. how many people came from that source to your app)
|
||||||
|
- Database:
|
||||||
|
- total number of registered users
|
||||||
|
- daily change in number of registered users
|
||||||
|
- total number of paying users
|
||||||
|
- daily change in number of paying users
|
||||||
|
|
||||||
|
For a guide on how to integrate these services, check out the [Stripe](/guides/stripe-integration) and [Analytics guide](/guides/analytics) of the docs.
|
||||||
|
|
||||||
|
<!-- TODO: add repo links -->
|
||||||
|
:::tip[Help us improve]
|
||||||
|
We're always looking to improve the Admin dashboard. If you feel something is missing or could be improved, consider [opening an issue]() or [submitting a pull request]()
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Users
|
||||||
|
The Users page is where you can view all your users and their most important details. You can also search and filter users by:
|
||||||
|
- email address
|
||||||
|
- subscription status
|
||||||
|
|
@ -9,7 +9,15 @@ Plausible is an open-source, privacy-friendly alternative to Google Analytics. I
|
|||||||
|
|
||||||
## Plausible
|
## Plausible
|
||||||
<!-- TODO add plausible -->
|
<!-- TODO add plausible -->
|
||||||
*coming soon... until then, check out the [official documentation](https://plausible.io/docs)*
|
|
||||||
|
*coming soon...*
|
||||||
|
*until then, check out the [official documentation](https://plausible.io/docs)*
|
||||||
|
|
||||||
|
:::tip[Contribute!]
|
||||||
|
If you'd like to help us write this guide, click the "Edit page" button at the bottom of this page
|
||||||
|
|
||||||
|
As a completely free, open-source project, we appreciate any help 🙏
|
||||||
|
:::
|
||||||
|
|
||||||
## Google Analytics
|
## Google Analytics
|
||||||
|
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
title: Email Sending
|
|
||||||
---
|
|
||||||
|
|
||||||
This reference explains when emails
|
|
81
docs/src/content/docs/guides/email-sending.mdx
Normal file
81
docs/src/content/docs/guides/email-sending.mdx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
title: Email Sending
|
||||||
|
---
|
||||||
|
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
This guide explains how to use the integrated email sender and how you can integrate your own account in this template.
|
||||||
|
|
||||||
|
## Sending Emails
|
||||||
|
|
||||||
|
In the `main.wasp` config file, you'll see we've got the email sender set up for you:
|
||||||
|
|
||||||
|
```tsx title="main.wasp"
|
||||||
|
app SaaSTemplate {
|
||||||
|
// ...
|
||||||
|
emailSender: {
|
||||||
|
provider: SendGrid,
|
||||||
|
defaultFrom: {
|
||||||
|
name: "Open SaaS App",
|
||||||
|
// make sure this address is the same you registered your SendGrid or MailGun account with!
|
||||||
|
email: "my@email.com"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
This means that you can send emails from your app using the `send` function from the `email` module:
|
||||||
|
|
||||||
|
```tsx title="src/server/webhooks.ts"
|
||||||
|
import { emailSender } from "@wasp/email/index.js";
|
||||||
|
|
||||||
|
//...
|
||||||
|
|
||||||
|
if (subscription.cancel_at_period_end) {
|
||||||
|
await emailSender.send({
|
||||||
|
to: customer.email,
|
||||||
|
subject: 'We hate to see you go :(',
|
||||||
|
text: 'We hate to see you go. Here is a sweet offer...',
|
||||||
|
html: 'We hate to see you go. Here is a sweet offer...',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the example above, you can see that we're sending an email to the customer when we receive a cancel subscription event within the Stripe webhook.
|
||||||
|
|
||||||
|
This is a powerful feature and super simple to use.
|
||||||
|
|
||||||
|
:::tip[Sending Emails in Development]
|
||||||
|
In the `.env.server` file, we've set the `SEND_EMAILS_IN_DEVELOPMENT` env variable to true. This means that emails will be sent in development mode.
|
||||||
|
|
||||||
|
This is useful for testing, but you can turn it off by setting it to false, and the emails will be logged to the console instead.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Integrate your email sender
|
||||||
|
|
||||||
|
To set up your email sender, you first need an account with one of the supported email providers.
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="SendGrid">
|
||||||
|
- Register at SendGrid.com and then get your [API KEYS](https://app.sendgrid.com/settings/api_keys).
|
||||||
|
- Copy yours to the `.env.server` file under the `SENDGRID_API_KEY` variable.
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="MailGun">
|
||||||
|
- Go to [Mailgun](https://mailgun.com) and create an account.
|
||||||
|
- Go to [API Keys](https://app.mailgun.com/app/account/security/api_keys) and create a new API key.
|
||||||
|
- Copy the API key and add it to your .env.server file under the `MAILGUN_API_KEY=` variable.
|
||||||
|
- Go to [Domains](https://app.mailgun.com/app/domains) and create a new domain.
|
||||||
|
- Copy the domain and add it to your .env.server file as `MAILGUN_DOMAIN=`.
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
Make sure to change the `defaultFrom` email address in the `main.wasp` file to the same email address you used to register your account with.
|
||||||
|
|
||||||
|
```tsx title="main.wasp" {5}
|
||||||
|
emailSender: {
|
||||||
|
provider: SendGrid,
|
||||||
|
defaultFrom: {
|
||||||
|
name: "Open SaaS App",
|
||||||
|
email: "my@email.com"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want more detailed info, or would like to use SMTP, check out the [Wasp docs](https://wasp-lang.dev/docs/advanced/email).
|
22
main.wasp
22
main.wasp
@ -2,7 +2,7 @@ app SaaSTemplate {
|
|||||||
wasp: {
|
wasp: {
|
||||||
version: "^0.11.6"
|
version: "^0.11.6"
|
||||||
},
|
},
|
||||||
title: "My SaaS App",
|
title: "My Open SaaS App",
|
||||||
head: [
|
head: [
|
||||||
"<meta property='og:type' content='website' />",
|
"<meta property='og:type' content='website' />",
|
||||||
"<meta property='og:url' content='https://mySaaSapp.com' />", // TODO change url
|
"<meta property='og:url' content='https://mySaaSapp.com' />", // TODO change url
|
||||||
@ -26,7 +26,7 @@ app SaaSTemplate {
|
|||||||
methods: {
|
methods: {
|
||||||
email: {
|
email: {
|
||||||
fromField: {
|
fromField: {
|
||||||
name: "SaaS App",
|
name: "Open SaaS App",
|
||||||
// make sure this address is the same you registered your SendGrid or MailGun account with!
|
// make sure this address is the same you registered your SendGrid or MailGun account with!
|
||||||
email: "vince@wasp-lang.dev"
|
email: "vince@wasp-lang.dev"
|
||||||
},
|
},
|
||||||
@ -44,13 +44,15 @@ app SaaSTemplate {
|
|||||||
configFn: import { config } from "@server/auth/google.js",
|
configFn: import { config } from "@server/auth/google.js",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
signup: {
|
||||||
|
additionalFields: import setAdminUsers from "@server/auth/setAdminUsers.js",
|
||||||
|
},
|
||||||
onAuthFailedRedirectTo: "/",
|
onAuthFailedRedirectTo: "/",
|
||||||
},
|
},
|
||||||
db: {
|
db: {
|
||||||
system: PostgreSQL,
|
system: PostgreSQL,
|
||||||
seeds: [
|
seeds: [
|
||||||
import { devSeedUsers } from "@server/scripts/usersSeed.js",
|
import { devSeedUsers } from "@server/scripts/usersSeed.js",
|
||||||
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
@ -64,6 +66,7 @@ app SaaSTemplate {
|
|||||||
email: "vince@wasp-lang.dev" // TODO change to generic email before pushing to github
|
email: "vince@wasp-lang.dev" // TODO change to generic email before pushing to github
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// add your dependencies here. the quickest way to find the latest version is `npm view <package-name> version`
|
||||||
dependencies: [
|
dependencies: [
|
||||||
("@headlessui/react", "1.7.13"),
|
("@headlessui/react", "1.7.13"),
|
||||||
("@tailwindcss/forms", "^0.5.3"),
|
("@tailwindcss/forms", "^0.5.3"),
|
||||||
@ -104,7 +107,7 @@ entity User {=psl
|
|||||||
sendEmail Boolean @default(false)
|
sendEmail Boolean @default(false)
|
||||||
datePaid DateTime?
|
datePaid DateTime?
|
||||||
credits Int @default(3)
|
credits Int @default(3)
|
||||||
relatedObject RelatedObject[]
|
gptResponses GptResponse[]
|
||||||
externalAuthAssociations SocialLogin[]
|
externalAuthAssociations SocialLogin[]
|
||||||
contactFormMessages ContactFormMessage[]
|
contactFormMessages ContactFormMessage[]
|
||||||
psl=}
|
psl=}
|
||||||
@ -120,7 +123,7 @@ entity SocialLogin {=psl
|
|||||||
psl=}
|
psl=}
|
||||||
|
|
||||||
// This can be anything. In most cases, this will be your product
|
// This can be anything. In most cases, this will be your product
|
||||||
entity RelatedObject {=psl
|
entity GptResponse {=psl
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
content String
|
content String
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
@ -205,6 +208,7 @@ page EmailVerificationPage {
|
|||||||
|
|
||||||
route GptRoute { path: "/gpt", to: GptPage }
|
route GptRoute { path: "/gpt", to: GptPage }
|
||||||
page GptPage {
|
page GptPage {
|
||||||
|
authRequired: true,
|
||||||
component: import GptPage from "@client/app/GptPage"
|
component: import GptPage from "@client/app/GptPage"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -287,7 +291,7 @@ page AdminUIButtonsPage {
|
|||||||
|
|
||||||
action generateGptResponse {
|
action generateGptResponse {
|
||||||
fn: import { generateGptResponse } from "@server/actions.js",
|
fn: import { generateGptResponse } from "@server/actions.js",
|
||||||
entities: [User, RelatedObject]
|
entities: [User, GptResponse]
|
||||||
}
|
}
|
||||||
|
|
||||||
action stripePayment {
|
action stripePayment {
|
||||||
@ -307,9 +311,9 @@ action updateUserById {
|
|||||||
|
|
||||||
// 📚 Queries
|
// 📚 Queries
|
||||||
|
|
||||||
query getRelatedObjects {
|
query getGptResponses {
|
||||||
fn: import { getRelatedObjects } from "@server/queries.js",
|
fn: import { getGptResponses } from "@server/queries.js",
|
||||||
entities: [User, RelatedObject]
|
entities: [User, GptResponse]
|
||||||
}
|
}
|
||||||
|
|
||||||
query getDailyStats {
|
query getDailyStats {
|
||||||
|
25
migrations/20231130143812_gpt_response/migration.sql
Normal file
25
migrations/20231130143812_gpt_response/migration.sql
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `RelatedObject` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "RelatedObject" DROP CONSTRAINT "RelatedObject_userId_fkey";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "RelatedObject";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GptResponse" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GptResponse_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GptResponse" ADD CONSTRAINT "GptResponse_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
@ -1,6 +1,6 @@
|
|||||||
import { User } from '@wasp/entities';
|
import { User } from '@wasp/entities';
|
||||||
import { useQuery } from '@wasp/queries'
|
import { useQuery } from '@wasp/queries'
|
||||||
import getRelatedObjects from '@wasp/queries/getRelatedObjects'
|
import getGptResponses from '@wasp/queries/getGptResponses'
|
||||||
import logout from '@wasp/auth/logout';
|
import logout from '@wasp/auth/logout';
|
||||||
import { useState, Dispatch, SetStateAction } from 'react';
|
import { useState, Dispatch, SetStateAction } from 'react';
|
||||||
import { Link } from '@wasp/router'
|
import { Link } from '@wasp/router'
|
||||||
@ -10,7 +10,7 @@ import { TierIds } from '@wasp/shared/const';
|
|||||||
export default function AccountPage({ user }: { user: User }) {
|
export default function AccountPage({ user }: { user: User }) {
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const { data: relatedObjects, isLoading: isLoadingRelatedObjects } = useQuery(getRelatedObjects);
|
const { data: gptResponses, isLoading: isLoadingGptResponses } = useQuery(getGptResponses);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-10 px-6'>
|
<div className='mt-10 px-6'>
|
||||||
@ -45,10 +45,10 @@ export default function AccountPage({ user }: { user: User }) {
|
|||||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>I'm a cool customer.</dd>
|
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>I'm a cool customer.</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
|
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
|
||||||
<dt className='text-sm font-medium text-gray-500'>Most Recent User RelatedObject</dt>
|
<dt className='text-sm font-medium text-gray-500'>Most Recent GPT Response</dt>
|
||||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
|
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
|
||||||
{!!relatedObjects && relatedObjects.length > 0
|
{!!gptResponses && gptResponses.length > 0
|
||||||
? relatedObjects[relatedObjects.length - 1].content
|
? gptResponses[gptResponses.length - 1].content
|
||||||
: "You don't have any at this time."}
|
: "You don't have any at this time."}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,9 +7,9 @@ import DropdownUser from './DropdownUser';
|
|||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'GPT Wrapper', href: '/gpt' },
|
{ name: 'GPT Wrapper', href: '/gpt' },
|
||||||
{ name: 'Documentation', href: 'https://saas-template.gitbook.io/test' },
|
{ name: 'Documentation', href: '#' }, // TODO: add link to docs
|
||||||
{ name: 'Blog', href: 'https://saas-template.gitbook.io/posts/' },
|
{ name: 'Blog', href: '#' }, // TODO: add link to blog
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AppNavBar() {
|
export default function AppNavBar() {
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
@ -395,7 +395,7 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAQs */}
|
{/* FAQ */}
|
||||||
<div className='mx-auto max-w-2xl divide-y divide-gray-900/10 px-6 pb-8 sm:pb-24 sm:pt-12 lg:max-w-7xl lg:px-8 lg:pb-32'>
|
<div className='mx-auto max-w-2xl divide-y divide-gray-900/10 px-6 pb-8 sm:pb-24 sm:pt-12 lg:max-w-7xl lg:px-8 lg:pb-32'>
|
||||||
<h2 className='text-2xl font-bold leading-10 tracking-tight text-gray-900'>Frequently asked questions</h2>
|
<h2 className='text-2xl font-bold leading-10 tracking-tight text-gray-900'>Frequently asked questions</h2>
|
||||||
<dl className='mt-10 space-y-8 divide-y divide-gray-900/10'>
|
<dl className='mt-10 space-y-8 divide-y divide-gray-900/10'>
|
||||||
@ -409,123 +409,38 @@ export default function LandingPage() {
|
|||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CTA section */}
|
|
||||||
<div className='relative -z-10 mt-32 px-6 lg:px-8'>
|
|
||||||
<div
|
|
||||||
className='absolute inset-x-0 top-1/2 -z-10 flex -translate-y-1/2 transform-gpu justify-center overflow-hidden blur-3xl sm:bottom-0 sm:right-[calc(50%-6rem)] sm:top-auto sm:translate-y-0 sm:transform-gpu sm:justify-end'
|
|
||||||
aria-hidden='true'
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className='aspect-[1108/632] w-[69.25rem] flex-none bg-gradient-to-r from-[#ff80b5] to-[#9089fc] opacity-25'
|
|
||||||
style={{
|
|
||||||
clipPath:
|
|
||||||
'polygon(73.6% 48.6%, 91.7% 88.5%, 100% 53.9%, 97.4% 18.1%, 92.5% 15.4%, 75.7% 36.3%, 55.3% 52.8%, 46.5% 50.9%, 45% 37.4%, 50.3% 13.1%, 21.3% 36.2%, 0.1% 0.1%, 5.4% 49.1%, 21.4% 36.4%, 58.9% 100%, 73.6% 48.6%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='mx-auto max-w-2xl text-center'>
|
|
||||||
<h2 className='text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl'>
|
|
||||||
Boost your productivity.
|
|
||||||
<br />
|
|
||||||
Start using our app today.
|
|
||||||
</h2>
|
|
||||||
<p className='mx-auto mt-6 max-w-xl text-lg leading-8 text-gray-600'>
|
|
||||||
Incididunt sint fugiat pariatur cupidatat consectetur sit cillum anim id veniam aliqua proident excepteur
|
|
||||||
commodo do ea.
|
|
||||||
</p>
|
|
||||||
<div className='mt-10 flex items-center justify-center gap-x-6'>
|
|
||||||
<a
|
|
||||||
href='https://github.com/wasp-lang/saas-template-gpt'
|
|
||||||
className='rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'
|
|
||||||
>
|
|
||||||
Get started
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className='absolute left-1/2 right-0 top-full -z-10 hidden -translate-y-1/2 transform-gpu overflow-hidden blur-3xl sm:block'
|
|
||||||
aria-hidden='true'
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className='aspect-[1155/678] w-[72.1875rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30'
|
|
||||||
style={{
|
|
||||||
clipPath:
|
|
||||||
'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className='mx-auto mt-32 max-w-7xl px-6 lg:px-8'>
|
<div className='mx-auto mt-6 max-w-7xl px-6 lg:px-8'>
|
||||||
<footer
|
<footer aria-labelledby='footer-heading' className='relative border-t border-gray-900/10 py-24 sm:mt-32 '>
|
||||||
aria-labelledby='footer-heading'
|
|
||||||
className='relative border-t border-gray-900/10 py-24 sm:mt-56 sm:py-32'
|
|
||||||
>
|
|
||||||
<h2 id='footer-heading' className='sr-only'>
|
<h2 id='footer-heading' className='sr-only'>
|
||||||
Footer
|
Footer
|
||||||
</h2>
|
</h2>
|
||||||
<div className='xl:grid xl:grid-cols-3 xl:gap-8'>
|
<div className='flex items-center justify-end mt-10 gap-20'>
|
||||||
<img
|
<div>
|
||||||
className='h-7'
|
<h3 className='text-sm font-semibold leading-6 text-gray-900'>App</h3>
|
||||||
src='https://tailwindui.com/img/logos/mark.svg?color=indigo&shade=600'
|
<ul role='list' className='mt-6 space-y-4'>
|
||||||
alt='Company name'
|
{footerNavigation.app.map((item) => (
|
||||||
/>
|
<li key={item.name}>
|
||||||
<div className='mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0'>
|
<a href={item.href} className='text-sm leading-6 text-gray-600 hover:text-gray-900'>
|
||||||
<div className='md:grid md:grid-cols-2 md:gap-8'>
|
{item.name}
|
||||||
<div>
|
</a>
|
||||||
<h3 className='text-sm font-semibold leading-6 text-gray-900'>Solutions</h3>
|
</li>
|
||||||
<ul role='list' className='mt-6 space-y-4'>
|
))}
|
||||||
{footerNavigation.solutions.map((item) => (
|
</ul>
|
||||||
<li key={item.name}>
|
</div>
|
||||||
<a href={item.href} className='text-sm leading-6 text-gray-600 hover:text-gray-900'>
|
<div>
|
||||||
{item.name}
|
<h3 className='text-sm font-semibold leading-6 text-gray-900'>Company</h3>
|
||||||
</a>
|
<ul role='list' className='mt-6 space-y-4'>
|
||||||
</li>
|
{footerNavigation.company.map((item) => (
|
||||||
))}
|
<li key={item.name}>
|
||||||
</ul>
|
<a href={item.href} className='text-sm leading-6 text-gray-600 hover:text-gray-900'>
|
||||||
</div>
|
{item.name}
|
||||||
<div className='mt-10 md:mt-0'>
|
</a>
|
||||||
<h3 className='text-sm font-semibold leading-6 text-gray-900'>Support</h3>
|
</li>
|
||||||
<ul role='list' className='mt-6 space-y-4'>
|
))}
|
||||||
{footerNavigation.support.map((item) => (
|
</ul>
|
||||||
<li key={item.name}>
|
|
||||||
<a href={item.href} className='text-sm leading-6 text-gray-600 hover:text-gray-900'>
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='md:grid md:grid-cols-2 md:gap-8'>
|
|
||||||
<div>
|
|
||||||
<h3 className='text-sm font-semibold leading-6 text-gray-900'>Company</h3>
|
|
||||||
<ul role='list' className='mt-6 space-y-4'>
|
|
||||||
{footerNavigation.company.map((item) => (
|
|
||||||
<li key={item.name}>
|
|
||||||
<a href={item.href} className='text-sm leading-6 text-gray-600 hover:text-gray-900'>
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className='mt-10 md:mt-0'>
|
|
||||||
<h3 className='text-sm font-semibold leading-6 text-gray-900'>Legal</h3>
|
|
||||||
<ul role='list' className='mt-6 space-y-4'>
|
|
||||||
{footerNavigation.legal.map((item) => (
|
|
||||||
<li key={item.name}>
|
|
||||||
<a href={item.href} className='text-sm leading-6 text-gray-600 hover:text-gray-900'>
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
@ -25,12 +25,24 @@ export const features = [
|
|||||||
"No SaaS is complete without payments. That's why subscriptions and there necessary webhooks are built-in!",
|
"No SaaS is complete without payments. That's why subscriptions and there necessary webhooks are built-in!",
|
||||||
icon: '💸',
|
icon: '💸',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Admin Dashboard',
|
||||||
|
description:
|
||||||
|
"Graphs! Tables! Analytics all in one place! Ooooooooooh! Ahhhhhhhhh!",
|
||||||
|
icon: '📈',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Email Sending',
|
name: 'Email Sending',
|
||||||
description:
|
description:
|
||||||
"Email sending is built-in and pre-configured. Combine it with Wasp's cron jobs feature to easily send emails to your customers.",
|
"Email sending is built-in and pre-configured. Combine it with Wasp's cron jobs feature to easily send emails to your customers.",
|
||||||
icon: '📧',
|
icon: '📧',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'OpenAI Integration',
|
||||||
|
description:
|
||||||
|
"Technology is changing rapidly. Ship your new AI-powered app before it's already obsolete!",
|
||||||
|
icon: '🤖',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Deploy Anywhere',
|
name: 'Deploy Anywhere',
|
||||||
description: 'You own all your code, so deploy it wherever you want!',
|
description: 'You own all your code, so deploy it wherever you want!',
|
||||||
@ -69,35 +81,26 @@ export const tiers = [
|
|||||||
export const faqs = [
|
export const faqs = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
question: "What's the best thing about Switzerland?",
|
question: "Why is this amazing SaaS Template free and open-source?",
|
||||||
answer:
|
answer:
|
||||||
"I don't know, but the flag is a big plus. Lorem ipsum dolor sit amet consectetur adipisicing elit. Quas cupiditate laboriosam fugiat.",
|
"Because open-source is cool, and we love you ❤️",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
question: "What's Wasp?",
|
||||||
|
answer:
|
||||||
|
"It's the fastest way to develop full-stack React + NodeJS + Prisma apps. It's what gives this template superpowers.",
|
||||||
},
|
},
|
||||||
// More questions...
|
|
||||||
];
|
];
|
||||||
export const footerNavigation = {
|
export const footerNavigation = {
|
||||||
solutions: [
|
app: [
|
||||||
{ name: 'Hosting', href: '#' },
|
{ name: 'Pricing', href: '#pricing' },
|
||||||
{ name: 'Data Services', href: '#' },
|
{ name: 'Documentation', href: '#' }, // TODO: fill in
|
||||||
{ name: 'Uptime Monitoring', href: '#' },
|
{ name: 'Blog', href: '#' },
|
||||||
{ name: 'Enterprise Services', href: '#' },
|
|
||||||
],
|
|
||||||
support: [
|
|
||||||
{ name: 'Pricing', href: '#' },
|
|
||||||
{ name: 'Documentation', href: '#' },
|
|
||||||
{ name: 'Guides', href: '#' },
|
|
||||||
{ name: 'API Reference', href: '#' },
|
|
||||||
],
|
],
|
||||||
company: [
|
company: [
|
||||||
{ name: 'About', href: '#' },
|
{ name: 'About', href: '#' },
|
||||||
{ name: 'Blog', href: '#' },
|
|
||||||
{ name: 'Jobs', href: '#' },
|
|
||||||
{ name: 'Press', href: '#' },
|
|
||||||
{ name: 'Partners', href: '#' },
|
|
||||||
],
|
|
||||||
legal: [
|
|
||||||
{ name: 'Claim', href: '#' },
|
|
||||||
{ name: 'Privacy', href: '#' },
|
{ name: 'Privacy', href: '#' },
|
||||||
{ name: 'Terms', href: '#' },
|
{ name: 'Terms of Service', href: '#' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import HttpError from '@wasp/core/HttpError.js';
|
import HttpError from '@wasp/core/HttpError.js';
|
||||||
import type { RelatedObject, User } from '@wasp/entities';
|
import type { GptResponse, User } from '@wasp/entities';
|
||||||
import type { GenerateGptResponse, StripePayment } from '@wasp/actions/types';
|
import type { GenerateGptResponse, StripePayment } from '@wasp/actions/types';
|
||||||
import type { StripePaymentResult, OpenAIResponse } from './types';
|
import type { StripePaymentResult, OpenAIResponse } from './types';
|
||||||
import { UpdateCurrentUser, UpdateUserById } from '@wasp/actions/types';
|
import { UpdateCurrentUser, UpdateUserById } from '@wasp/actions/types';
|
||||||
@ -56,7 +56,7 @@ type GptPayload = {
|
|||||||
temperature: number;
|
temperature: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateGptResponse: GenerateGptResponse<GptPayload, RelatedObject> = async (
|
export const generateGptResponse: GenerateGptResponse<GptPayload, GptResponse> = async (
|
||||||
{ instructions, command, temperature },
|
{ instructions, command, temperature },
|
||||||
context
|
context
|
||||||
) => {
|
) => {
|
||||||
@ -106,7 +106,7 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, RelatedObject>
|
|||||||
|
|
||||||
const json = (await response.json()) as OpenAIResponse;
|
const json = (await response.json()) as OpenAIResponse;
|
||||||
console.log('response json', json);
|
console.log('response json', json);
|
||||||
return context.entities.RelatedObject.create({
|
return context.entities.GptResponse.create({
|
||||||
data: {
|
data: {
|
||||||
content: json?.choices[0].message.content,
|
content: json?.choices[0].message.content,
|
||||||
user: { connect: { id: context.user.id } },
|
user: { connect: { id: context.user.id } },
|
||||||
|
@ -3,7 +3,9 @@
|
|||||||
export async function getUserFields(_context: unknown, args: any) {
|
export async function getUserFields(_context: unknown, args: any) {
|
||||||
console.log('args', args.profile)
|
console.log('args', args.profile)
|
||||||
const email = args.profile.emails[0].value
|
const email = args.profile.emails[0].value
|
||||||
return { email };
|
const adminEmails = process.env.ADMIN_EMAILS?.split(',') || []
|
||||||
|
const isAdmin = adminEmails.includes(email)
|
||||||
|
return { email, isAdmin };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function config() {
|
export function config() {
|
||||||
|
11
src/server/auth/setAdminUsers.ts
Normal file
11
src/server/auth/setAdminUsers.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { defineAdditionalSignupFields } from '@wasp/auth/index.js'
|
||||||
|
|
||||||
|
export default defineAdditionalSignupFields({
|
||||||
|
isAdmin: (data) => {
|
||||||
|
if (!data.email) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const adminEmails = process.env.ADMIN_EMAILS?.split(',') || [];
|
||||||
|
return adminEmails.includes(data.email as string);
|
||||||
|
},
|
||||||
|
});
|
@ -1,7 +1,7 @@
|
|||||||
import HttpError from '@wasp/core/HttpError.js';
|
import HttpError from '@wasp/core/HttpError.js';
|
||||||
import type { DailyStats, RelatedObject, User, PageViewSource } from '@wasp/entities';
|
import type { DailyStats, GptResponse, User, PageViewSource } from '@wasp/entities';
|
||||||
import type {
|
import type {
|
||||||
GetRelatedObjects,
|
GetGptResponses,
|
||||||
GetDailyStats,
|
GetDailyStats,
|
||||||
GetPaginatedUsers,
|
GetPaginatedUsers,
|
||||||
} from '@wasp/queries/types';
|
} from '@wasp/queries/types';
|
||||||
@ -15,11 +15,11 @@ type DailyStatsValues = {
|
|||||||
weeklyStats: DailyStatsWithSources[];
|
weeklyStats: DailyStatsWithSources[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRelatedObjects: GetRelatedObjects<void, RelatedObject[]> = async (args, context) => {
|
export const getGptResponses: GetGptResponses<void, GptResponse[]> = async (args, context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401);
|
throw new HttpError(401);
|
||||||
}
|
}
|
||||||
return context.entities.RelatedObject.findMany({
|
return context.entities.GptResponse.findMany({
|
||||||
where: {
|
where: {
|
||||||
user: {
|
user: {
|
||||||
id: context.user.id,
|
id: context.user.id,
|
||||||
|
@ -4,10 +4,8 @@ import type { User } from '@wasp/entities';
|
|||||||
|
|
||||||
// in a terminal window run `wasp db seed` to seed your dev database with this data
|
// in a terminal window run `wasp db seed` to seed your dev database with this data
|
||||||
|
|
||||||
let prevUserId = 0;
|
|
||||||
export function createRandomUser(): Partial<User> {
|
export function createRandomUser(): Partial<User> {
|
||||||
const user: Partial<User> = {
|
const user: Partial<User> = {
|
||||||
id: ++prevUserId,
|
|
||||||
email: faker.internet.email(),
|
email: faker.internet.email(),
|
||||||
password: faker.internet.password({
|
password: faker.internet.password({
|
||||||
length: 12,
|
length: 12,
|
||||||
|
@ -192,26 +192,9 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// MIDDELWARE EXAMPLE
|
// This allows us to override Wasp's defaults and parse the raw body of the request from Stripe to verify the signature
|
||||||
// const defaultGlobalMiddleware: MiddlewareConfig = new Map([
|
|
||||||
// ['helmet', helmet()],
|
|
||||||
// ['cors', cors({ origin: config.allowedCORSOrigins })],
|
|
||||||
// ['logger', logger('dev')],
|
|
||||||
// ['express.json', express.json()],
|
|
||||||
// ['express.urlencoded', express.urlencoded({ extended: false })],
|
|
||||||
// ['cookieParser', cookieParser()],
|
|
||||||
// ]);
|
|
||||||
|
|
||||||
export const stripeMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => {
|
export const stripeMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => {
|
||||||
middlewareConfig.delete('express.json');
|
middlewareConfig.delete('express.json');
|
||||||
middlewareConfig.set('express.raw', express.raw({ type: 'application/json' }));
|
middlewareConfig.set('express.raw', express.raw({ type: 'application/json' }));
|
||||||
return middlewareConfig;
|
return middlewareConfig;
|
||||||
|
|
||||||
// let updatedMiddlewareConfig = new Map([
|
|
||||||
// // New entry as an array: [key, value]
|
|
||||||
// ['express.raw', express.raw({ type: 'application/json' })],
|
|
||||||
// ...Array.from(middlewareConfig.entries()),
|
|
||||||
// ]);
|
|
||||||
|
|
||||||
// return updatedMiddlewareConfig;
|
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import type { DailyStatsJob } from '@wasp/jobs/dailyStatsJob';
|
import type { DailyStatsJob } from '@wasp/jobs/dailyStatsJob';
|
||||||
import type { DailyStats } from '@wasp/entities';
|
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
// import { getDailyPageViews, getSources } from './plausibleAnalyticsUtils.js';
|
// import { getDailyPageViews, getSources } from './plausibleAnalyticsUtils.js';
|
||||||
import { getDailyPageViews, getSources } from './googleAnalyticsUtils.js';
|
import { getDailyPageViews, getSources } from './googleAnalyticsUtils.js';
|
||||||
|
@ -98,6 +98,15 @@ async function getPrevDayViewsChangePercent() {
|
|||||||
startDate: '2daysAgo',
|
startDate: '2daysAgo',
|
||||||
endDate: 'yesterday',
|
endDate: 'yesterday',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
orderBys: [
|
||||||
|
{
|
||||||
|
dimension: {
|
||||||
|
dimensionName: 'date',
|
||||||
|
},
|
||||||
|
desc: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
dimensions: [
|
dimensions: [
|
||||||
{
|
{
|
||||||
@ -111,6 +120,7 @@ async function getPrevDayViewsChangePercent() {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('response: ', JSON.stringify(response?.rows, null, 2));
|
||||||
let viewsFromYesterday;
|
let viewsFromYesterday;
|
||||||
let viewsFromDayBeforeYesterday;
|
let viewsFromDayBeforeYesterday;
|
||||||
|
|
||||||
@ -127,6 +137,7 @@ async function getPrevDayViewsChangePercent() {
|
|||||||
console.log('Page views are zero, so no percentage change');
|
console.log('Page views are zero, so no percentage change');
|
||||||
return '0';
|
return '0';
|
||||||
}
|
}
|
||||||
|
console.table({ viewsFromYesterday, viewsFromDayBeforeYesterday });
|
||||||
|
|
||||||
const change = ((viewsFromYesterday - viewsFromDayBeforeYesterday) / viewsFromDayBeforeYesterday) * 100;
|
const change = ((viewsFromYesterday - viewsFromDayBeforeYesterday) / viewsFromDayBeforeYesterday) * 100;
|
||||||
return change.toFixed(2);
|
return change.toFixed(2);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user