mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-04-13 14:29:04 +02:00
Add reminder about the CORS policy
This commit is contained in:
parent
dbca3a20c1
commit
e04b00317e
52
opensaas-sh/app/.cursorrules
Normal file
52
opensaas-sh/app/.cursorrules
Normal file
@ -0,0 +1,52 @@
|
||||
// Wasp Import Rules
|
||||
- Path to Wasp functions within .ts files must come from 'wasp', not '@wasp'!
|
||||
✓ import { Task } from 'wasp/entities'
|
||||
✓ import type { GetTasks } from 'wasp/server/operations'
|
||||
✓ import { getTasks, useQuery } from 'wasp/client/operations'
|
||||
✗ import { getTasks, useQuery } from '@wasp/...'
|
||||
✗ import { getTasks, useQuery } from '@src/feature/operations.ts'
|
||||
|
||||
- Path to external imports within 'main.wasp' must start with "@src/"!
|
||||
✓ component: import { LoginPage } from "@src/client/pages/auth/LoginPage.tsx"
|
||||
✗ component: import { LoginPage } from "@client/pages/auth/LoginPage.tsx"
|
||||
- In the client's root component, use the Outlet component rather than children
|
||||
✓ import { Outlet } from 'react-router-dom';
|
||||
|
||||
// Wasp DB Schema Rules
|
||||
- Add databse models to the 'schema.prisma' file, NOT to 'main.wasp' as "entities"
|
||||
- Do NOT add a db.system nor a db.prisma property to 'main.wasp'. This is taken care of in 'schema.prisma'
|
||||
- Keep the 'schema.prisma' within the root of the project
|
||||
|
||||
// Wasp Operations
|
||||
- Types are generated automatically from the function definition in 'main.wasp',
|
||||
✓ import type { GetTimeLogs, CreateTimeLog, UpdateTimeLog } from 'wasp/server/operations'
|
||||
- Wasp also generates entity types based on the models in 'schema.prisma'
|
||||
✓ import type { Project, TimeLog } from 'wasp/entities'
|
||||
- Make sure that all Entities that should be included in the operations context are defined in its definition in 'main.wasp'
|
||||
✓ action createTimeLog { fn: import { createTimeLog } from "@src/server/timeLogs/operations.js", entities: [TimeLog, Project] }
|
||||
|
||||
// Wasp Auth
|
||||
- When creating Auth pages, use the LoginForm and SignupForm components provided by Wasp
|
||||
✓ import { LoginForm } from 'wasp/client/auth'
|
||||
- Wasp takes care of creating the user's auth model id, username, and password for a user, so a user model DOES NOT need these properties
|
||||
✓ model User { id Int @id @default(autoincrement()) }
|
||||
|
||||
// Wasp Dependencies
|
||||
- Do NOT add dependencies to 'main.wasp'
|
||||
- Install dependencies via 'npm install' instead
|
||||
|
||||
// Wasp
|
||||
- Use the latest Wasp version, ^0.16.0
|
||||
- Always use typescript for Wasp code.
|
||||
- When creating Wasp operations (queries and actions) combine them into an operations.ts file within the feature directory rather than into separate queries.ts and actions.ts files
|
||||
|
||||
// React
|
||||
- Use relative imports for other react components
|
||||
- If importing a function from an operations file, defer to the wasp import rules
|
||||
|
||||
// CSS
|
||||
- Use Tailwind CSS for styling.
|
||||
- Do not use inline styles unless necessary
|
||||
|
||||
// General
|
||||
- Use single quotes
|
1
opensaas-sh/app/.env.client
Normal file
1
opensaas-sh/app/.env.client
Normal file
@ -0,0 +1 @@
|
||||
REACT_APP_GOOGLE_ANALYTICS_ID=G-H3LSJCK95H
|
4
opensaas-sh/app/.env.client.example
Normal file
4
opensaas-sh/app/.env.client.example
Normal file
@ -0,0 +1,4 @@
|
||||
# All client-side env vars must start with REACT_APP_ https://wasp.sh/docs/project/env-vars
|
||||
|
||||
# See https://docs.opensaas.sh/guides/analytics/#google-analytics
|
||||
REACT_APP_GOOGLE_ANALYTICS_ID=G-...
|
56
opensaas-sh/app/.env.server.example
Normal file
56
opensaas-sh/app/.env.server.example
Normal file
@ -0,0 +1,56 @@
|
||||
# NOTE: you can let Wasp set up your Postgres DB by running `wasp start db` in a separate terminal window.
|
||||
# then, in a new terminal window, run `wasp db migrate-dev` and finally `wasp start`.
|
||||
# If you use `wasp start db` then you DO NOT need to add a DATABASE_URL env variable here.
|
||||
# DATABASE_URL=
|
||||
|
||||
# For testing, go to https://dashboard.stripe.com/test/apikeys and get a test stripe key that starts with "sk_test_..."
|
||||
STRIPE_API_KEY=sk_test_...
|
||||
# After downloading starting the stripe cli (https://stripe.com/docs/stripe-cli) with `stripe listen --forward-to localhost:3001/payments-webhook` it will output your signing secret
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
# You can find your Stripe customer portal URL in the Stripe Dashboard under the 'Customer Portal' settings.
|
||||
STRIPE_CUSTOMER_PORTAL_URL=https://billing.stripe.com/...
|
||||
|
||||
# For testing, create a new store in test mode on https://lemonsqueezy.com
|
||||
LEMONSQUEEZY_API_KEY=eyJ...
|
||||
# After creating a store, you can find your store id in the store settings https://app.lemonsqueezy.com/settings/stores
|
||||
LEMONSQUEEZY_STORE_ID=012345
|
||||
# define your own webhook secret when creating a new webhook on https://app.lemonsqueezy.com/settings/webhooks
|
||||
LEMONSQUEEZY_WEBHOOK_SECRET=my-webhook-secret
|
||||
|
||||
# If using Stripe, go to https://dashboard.stripe.com/test/products and click on + Add Product
|
||||
# If using Lemon Squeezy, go to https://app.lemonsqueezy.com/products and create new products and variants
|
||||
PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=012345
|
||||
PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID=012345
|
||||
PAYMENTS_CREDITS_10_PLAN_ID=012345
|
||||
|
||||
# set this as a comma-separated list of emails you want to give admin privileges to upon registeration
|
||||
ADMIN_EMAILS=me@example.com,you@example.com,them@example.com
|
||||
|
||||
# see our guide for setting up google auth: https://wasp.sh/docs/auth/social-auth/google
|
||||
GOOGLE_CLIENT_ID=722...
|
||||
GOOGLE_CLIENT_SECRET=GOC...
|
||||
|
||||
# get your sendgrid api key at https://app.sendgrid.com/settings/api_keys
|
||||
SENDGRID_API_KEY=test...
|
||||
|
||||
# (OPTIONAL) get your openai api key at https://platform.openai.com/account
|
||||
OPENAI_API_KEY=sk-k...
|
||||
|
||||
# (OPTIONAL) get your plausible api key at https://plausible.io/login or https://your-plausible-instance.com/login
|
||||
PLAUSIBLE_API_KEY=gUTgtB...
|
||||
# You will find your site id in the Plausible dashboard. It will look like 'opensaas.sh'
|
||||
PLAUSIBLE_SITE_ID=yoursite.com
|
||||
PLAUSIBLE_BASE_URL=https://plausible.io/api # if you are self-hosting plausible, change this to your plausible instance's base url
|
||||
|
||||
# (OPTIONAL) get your google service account key at https://console.cloud.google.com/iam-admin/serviceaccounts
|
||||
GOOGLE_ANALYTICS_CLIENT_EMAIL=email@example.gserviceaccount.com
|
||||
# Make sure you convert the private key within the JSON file to base64 first with `echo -n "PRIVATE_KEY" | base64`. see the docs for more info.
|
||||
GOOGLE_ANALYTICS_PRIVATE_KEY=LS02...
|
||||
# You will find your Property ID in the Google Analytics dashboard. It will look like '987654321'
|
||||
GOOGLE_ANALYTICS_PROPERTY_ID=123456789
|
||||
|
||||
# (OPTIONAL) get your aws s3 credentials at https://console.aws.amazon.com and create a new IAM user with S3 access
|
||||
AWS_S3_IAM_ACCESS_KEY=ACK...
|
||||
AWS_S3_IAM_SECRET_KEY=t+33a...
|
||||
AWS_S3_FILES_BUCKET=your-bucket-name
|
||||
AWS_S3_REGION=your-region
|
25
opensaas-sh/app/.env.vault
Normal file
25
opensaas-sh/app/.env.vault
Normal file
@ -0,0 +1,25 @@
|
||||
#/-------------------.env.vault---------------------/
|
||||
#/ cloud-agnostic vaulting standard /
|
||||
#/ [how it works](https://dotenv.org/env-vault) /
|
||||
#/--------------------------------------------------/
|
||||
|
||||
# development
|
||||
DOTENV_VAULT_DEVELOPMENT="STJRGW84NkbUqenj6Eyt98WiAcvzWsWhOKwyktUfAJ0dR8TlNnSdCDRsCRisCzKuDTTZtgCf6FyTgT48HsYuwFZqydg/aFeM4skfLpyUy0wz0K90hq2sNEB85HCAwZaTTf9ye5fEBq9P1PAEoYlHEFtPXO1exSkXyxQHzkv5/j2xLJVkg89cZ+MBaQQtzmgK/A9ya5eVSX2zIhum+ipZKIE04pjc4H8fu5YgxY91rOVTFHBQc1dlbuCr+vvmvgHxNS2gO+eM5UcmZk6FY4yY+GUZmf+xGw5ZPwFjiS3awC+jNhdebWnkrk/PopAqBC3i7eH3fdaPvJW2RWkMMrY4+gtN8Hs9lPNopWkl3uavAva9qmvrCE1KLu94IaQh34Cy6dRurPQDF/MmnugqYzcEQvvtIV1sgZig6Id4c9P67gs/Q9NvKXvbeNxzXYTwkF3SvnMRXk33yBKbXHxE1vIn9k9mbMnX3DVeVdT5TDR9BKs9xGMuvP7rQv5RA/xSnzMFg8LeJ+oZ2zNUWWN8tOyx6K4WwiIJN39AOLYElXAFa3UTSk12GtzaySM5rQdcWn0YLN3uncye26zRCA+BpR231fgrSpfJJ2VkNBG0pTh1ELySOFug6mItLOb7CQGXjZrrwTSfEBhhpMZn/c2Ec6p/nBN2UcHAmB55N6GDFuAkagv9l/4C5fIbS6iGHYAOwwYKczSBryi3+5y7sDYkdVhOTzuKTfkpY1FpkEgUY8FFnW2GjYodH2T+l23Q84a7A9VjxZAyZJkgwGnezO+M8fWcwtqt3iywEVhTd5Hi7Nxe+Q53oQeUrwnTKSkx5Ig5uR1RJIybW+gWvpSOEcUZN6AVf67/UPu9RHpwHDQ5YCGGqJwDzR/f5QxR0kqiW3B0ZDWpJX7zFk2QiLGLzDZ6xmdaGenbJ0jWMA116Xm+ot0GCEqim+RVk768zSAxIQFOCcllXWhmxwTO2/SzY6j7Hxf1Ps8e0uCVc7FPemh7nBYLRlSOI/Pjk9gLUkyKhV7IEiaL2BbPFLGOFkOk0AGNc+m/y1BH+CnJYYKCK1AXNquKM9+MV8Wmure2iBIm2oNlDOcJl48YpUQ0BcKzmFw06OJHVULPVn7da7XZ2/34SvyFcsYARE6NZLLMMg810S1cok0gQ3FpxKv3wxP1jRXntEC1ZxUqJQE/hZ5IxoOxhAJGQfqYbJIQSE5oE8Ve7E0X2lTYVEPiwziYOeovKidJX1vccZh4ggxgYsRzT/qAiS/FcvgsWz9kY6n0e1iKLEmsGR96SDpzNSWV/57iSuOd6nKtcU8HWhekTMnTQK4wrhXuYQHZhLeRnGQIjHzNyzJ8lV6HVcot6NZPPHjOfohQ9eIhQi7qUC7JPcLFkWixjrPTAEJVlQelbAOPHPOUyYEoejcPi/0LMTrQPYN/To/lhDHn/5N9vhlk8flj0zuJHpCFq8laDsn9wdvymuK/0MQ7DrPPU00nNzQjXDtT9xbb6I9ZX7XtgTVHVnWtEwxCQRYkmbR0T+0TzDLXlQObITanuFVa19ZSeATGc+PhxFAva5yxPih2L+j3BTN43ap/X3j54DDylyNHDQlpC21wOa+HFGftYUAWx8/w8i5gr7ZU66aVQWZspSLP7BIFzMaVVjAM5gVqaCLTpcnCE3oMMYddsqsXUbkJrmdlh4cP8ao2Y9YHzv3KRTyAU4wDxhkwWdDqnUSRVXkcDeZUuCQJsHFRvXVzgCNDSktMewTC4xMNniW6KNUwbqzWxGBCz6g/0WUJEaJYG+teu1jBMPDhNFFlw6BjIoNX7iButmm81XCG8D1uEgRvo+H4lxAL4rPyPNCQieT34XHk/QxcQsqij2wnTenGUwWQt++kzMLvL/69RtvaskA8I3aLXDLn3ATMU4JlMvD1MqRHrGb3tzoz+lXKWVgrJR+BSYUBXZc9EAUsj4G4+NsRyMPbBWE2ADgM4ksDflXCccbelu/4S50eS9dEXf7ms0CeYh+dFmqjhCXI8JrgL+bUdOwDbEJVPjM2yOVM1wATGAa8+0nLaJbIwI41JVXBx/uLZ8pt2pyvQB9cyLQR00hcjbLymHBfLP51B6nlaNhUq4yZsI/JM59XNmEYAY8GZJFuvIBvSimC5plUCc1bfGhK694k7Fv1bKtlIzXd4kCy5bKNiwWMTVbOy3Uv87FQTa61z5mnz/XGmRwU/22v4VOWy11BKWGYkYJGN1KrAVkUenCKQw6FjgVXBbfJ6m2k9tl5dfQ48ppToorUZkFiKsPuc8V6DhcUt2stxEETnUnU9K2p8QyyhfPykBK6YWbShDobeA0TPF+vo2ypkVOvhNo7F4cQnvvVKl9MJmmAxFOisB9yHXREk2GSlivNJeTqIz8Vv4K42/wNMAO2ZQqSPlE4wt2ueFQqLMkG+UDlD6gujSx8D/OzSTfCx+vLTzdqsbubPxjcPx+pQA=="
|
||||
DOTENV_VAULT_DEVELOPMENT_VERSION=12
|
||||
|
||||
# ci
|
||||
DOTENV_VAULT_CI="pveNDc605iqf5ZOh9mvYpFOc925852DGrvz7y+vs82ub3bZvTiLhJMuJKZEDtyjn7yBUdfMRsAP6wAL8/73te+4y7K56lT48MuvFlpvwM9ERkCzJY1yZ6ib3eBzTax/jon2H5kfEHBM9TRIa5B8JrCHbYqhcrnxj0VyLEa4x1I4pMTdesRZ+907FTdExaRSFFujdkUaScytOV93DTr2lQZpUGBga5CbKr3KyeSS1+ONGSWc+BQiAkdhmnvPsyKHnWRKHSFVhInne2W00LFZLBZ56I4+G7D+x+ze3/WNb5GSq7eNlVwnTJgj5AXJBjtAV/uD7cmOnJ3h+lOkvz3Wry10BaICTAx5SVfOuD5Pr/FLgR2zJau+pgqhRdcp7gG4y2thWN5ZtlLGnJlL7xgnRaD+oIYBn6fg8CCZsnsX2XZHAzJcgntph2pUd0At4UawSijDVZuylcneNdF3CJ68/fcqqpR94LYU61w4WasrAWj1hFiRV1MTpLkdQWTMT6K5k5TDouuljI3nt+Upf9mDK7efjy7OTiiArNxKpUxUy31Kl0TgWTsttDFbcOGwbsxq1t/Tu9Ph0eEhwHc2dTUuSuhV+PHxE8GwUvGiIDBbAZZq7ZyeLc5btdP++8zdXBtYM7bIa9qubaCWakruk31w6Gm5ySjGY+R1HGURldXJmypsAEcu5kK3IX4Z8IubF79T8ZvutLdoXJPSaH9x8ij7EcamTVfZPmekj6FpI3sqy7rmaUup73MBld9a3r9AQTzXbEmR+2xbK488D1il8PUqu9J3QkUeF2rpB97Uy2mgl1zF8VXiGkdP1Hpqeck8FpS89GWFF4FA3oBE/Keqvqvvs/f+KzDn2AvHHwrT6fnVBdeuSp4hK+sSkig1rh+1P69zYjIDqpph0kT+Ri7uNMkbrXX7iT5he9J1LrXtiM/DLxXqi8M3FfeN7aaSxQ2nzac1mlN+vwsIqJbzOSm+ABGHNJNjPjBZpMbeBZsFwogaCbFd2oorp02h6yC6741qwH7gFEr1EJVcu+TJspHyKpq4TFIk+srRMhuCqwUAkdzY4skjEGVhxxYMiKAtRg5IKYICqbfNsestMipAcf22teriGnmqBe9Yuzq9IXZXRcl/etagr0/B3SQhNlVj2u9VMBfhj5ZY+QcQDfSjqOcW/DyphyBU4T1/rHn1x5RznCvtbOPZ+XXFApZaeoKzj/s8JxHzM8aegITx3jVRwXnr0/0FnHTnC34b124K6HM3oKxdPdrtACgW8gH+mgXCaStBqwBdk1aV62+YQxWdHXjy9FAvTF8BktcxwE0TXC5Qu0JsoS8uYbbQmaQCNWIJWF/KptW+g9gkKDBV2Iq5mXwMLHzCtzxGarj5jqznVEX4OdbYbgSOZ5mB5JEU2OwSn2QtzqUVquAN+DygbDVrh1+KA9bk4HMt38rMKHV/i9VBHkuApYT7OUIexCu4jxUDBnid/Hnm3OfMvXrUesoqFv2cYEHX8R/jvhLx36jEWeaTYL+e9XFpSLlWJTGc7wAg0Zj+XJlNtLhFlB6pw"
|
||||
DOTENV_VAULT_CI_VERSION=8
|
||||
|
||||
# staging
|
||||
DOTENV_VAULT_STAGING="nAXVAHQaoF1R6J23PDrfp6kH0L+nXmZaI0OUfcCBU7QSfP+K3mbkdO92cbKVUN6ycc1wxRue6BFLEUgBLuCJQwUdKYevK81fs/ACrHYxuW6Px85FG3NJVLGvgF/3qiqu0RRfcpeB6lYJCAPFsYcoA9Cx8QDkYmvSlWAGmLJQ7cqzHUNlveOqtrXsLFV2OjlWjlYD1l7VQkaXnsY+Mij60DbRIadusoDuAB3z5m7N8/ogMOGENlUl9HRJJ4ppcJiHoBm7pxrLpK0oubx3KEDTSkPVlpNb1YYMeU2VKiGYCOMyXX8IguISrrgU4hHEvbLM8F3gnKHyx59qbwU/32yX7ofIIPsTLLTwD+mHWElPoLs9WWq249RYEmRT7w5iuCdjl9Qlr36EJKPYc7UG+P0DZROb7pXcbtYg8/gfaCXOQG5Ue2FMon4bEphkmY1CKaJKtPTXNuecDVTa8O4hEM65xrEClieVUgexEaM0g7fEUo8wOljZx96gG8Mf4RqHX3xy4M0d4/2UluE053kavUaTULhRPG/oTHIGiKFRnG0BfgQFcGRXkFgLG9zerVAoWEGAcBGHn5NECIUWqaD2Z7sdEZd9R0SZmtwUXoPV1jqHNeKEvt7fUN8xcPsV1MFAUi2cAyGLkrl541KbWpU0IpHK1pIkbfUVyDOL4htSD/SajhtlyJ79q7Xd5sKsb273ZQsmtk3ZREBjQUFA2NP+fPH7ifttVeWi3EFUi8+8cWwgNHM6Cn7LaFitah6+DcCQ6+l7SneFTEbzMlprzazBBL/Gu4kr6pNXGX0RZnR8uhs6aZZgV2h9V14s0uZx3EcAFqC3g+I5X08Fi+2ERpcfWUvFdjx5akmDQllSB+oqy6SxiJvqFnfWvVPQNhx1xh2x8OplZbzykEJ+Tfl0mlc5UHHoLQqje0jagBRSHWQbxC0jIGGyqdhnz3uG672krM/zTTc2wKWMk4EZFDG7Yc7oz3DcJXjFBgxEagd9jYxqLIN0RAH9pq+0aUP7daz0CbX+xhTd988dl+LblcnEN59XEsBOhWCd5bm51KJZcKQ1UpsVVPGHnTT7MPE2+6BheEr1dgu/LDUPDW8NT0QwBUwXwxH1ROuTOu2+LWNdFrV11sEpKGfW2bjqrkpbx/xovahbFyCSq12T0EBInAFF5bq2pB0Ik2vfEH8gNL0Z+FoqH0/aDrSuw6j8xqmd2rKL3XwL5bYVZDCc1isAWXESA9CHHTSx1NGoa1kpunJkGJY//2rn5srK9RlOOHxxUrtqVBMfEGsj7qIv/Ebsgr3dvFpdVXiRZ5s3KZZs6YlSXyukklehkjtYoMM2TaZ478ioFOtD8yx12PjI3Pfoapo2La2boM48RFcipmVNpWNsLjGXaYyhYVV3RBsZAMvkOnYRVFUiZ50+ETdfizX0H23+mmkBt8FjNqHhvg9BIRaYnKTar1FPAd840lo4w2e7n2mKufHwykI5ILrJV0ozhPy7Yr8kltPiQYjpy1ua2scUdnKCngdX0KjqNQz072e6uYlhRpOib6CEwZXqMbr/"
|
||||
DOTENV_VAULT_STAGING_VERSION=8
|
||||
|
||||
# production
|
||||
DOTENV_VAULT_PRODUCTION="zGpYx7O6vRv3vYt0aW1H6UF3TSi6DUFgqjeKzkNcJukFWQWvTgX5HYtLKQnBAGCDpuWY+KWfiFGNu/zVouo5LTlQb6apzBLzj0dr+PxxDpjXe8s+q7OSPH7Ist4UiVurC2nAMjgFsUpDA2LUkfPOuWPRvg1UGYjTaxEADTYCI/vk0Uegv4R11K1/HcCGXlliJk2QAVaT5ZYSo0VKvcRu5HYHSX4aAwJgnucm3xvNmPn9Cjkxx1jZL5jYfzLS1etDEPn6slDj88NzZ8BvXHeSJUftpR352azOJpL0GbSKrwoPNYU9F2JCthilLqPZPC6LJzg0/4p5vUKA83gqnDnUxEHR+ZlNK0MRTQPEwrZitruOaP1ggWIsEJA/DZFMLA5oQrq4kp+p2E99YbsPNmV0LfvsA6LtqKv393jsuzSdJ94zeLhQlRRECiTQRVEee3ug5tJ0f34N7acRSv4EdgriKp8poIsE4miVK2xXwrek9jRxeXLHzu7Oo8yBkYaX2DqtXyxf+8Qq2WgvdRm9QgfGdiwftYsjXO52jhHI7z7bJ3R/W5BR+Z0QbR83LR3ZWdI+AYY9F2CkYF62nW5TVxiWU0M4DZ5KUgVo+iNgm+AV0nefgFvud6Ln3VtNcb2bT0RFAw4MJK0LyYlNewZBJQhlfRiN+3xJKqLbDKMlhGmSHF8RRNYN9MfKwOfF62jNFlwPjgyGCjuQL96L97z50Q63TMbpEciR0uNcMavIADPs1qDhCSkYO4rQO1IgOUF34trra9KKRfoP2UdDJhiqvlTQ2Ygu2uc164Vho0E003jlBBZBI+UUJhPz4cYTtFJjpinRCp/Q0mUeiTLbo1V7T32IQ+ZkUqO7FRuwxR282OzKQVRZiUxuOAa7WxdQzrcXbCOcCvJWX8hTVrSim9z+NAFxNyTCwVBhek9EfwCfxJSCuFuI9axlb6xA1sZ/61G5+wuKeEpUvlTbQC/ZT1QJqpKMmujEePAfW++dzJJgW/nX9o0CIt3KTzwVzImAe+/oNhzKn0TbzsvMqeOaTgymo2re0Rdw3P/Fk5SUQTM7+2GIFC+iRC5a3LN7UOFP+UdQ21J+WGkkl+yvJyyjY3IRSoFWqSCU7BAjULmJmtkl1Y/60vIj+GET5hu7VGaTlIgaWiVZc4eGAu6UAeGzasTjYWNQh50p44VxQlYBijJC+JUhzPkFOLjaLx6rRZzTQVl9NUxIxdkaOn+BImJQadaC2Ad3YOMpsLVJF/p25fi2L2e2MtsDdcx2ZNlpV1GIrYGqMIiujPdpoMzibj4VmObVMB6dazpyqgAf/QXdGguyv/4JJBcNUNPbqHqMl0yY+DzGzxPCKx6WViR63HZJeC5hJE39wNEgeYGJcXrL5K2siwrFENns8Os93aoE/8xgiPdj3vLQmcL0Z30XEqPl6/V4mTgc2Oa1SAi1NGLXnVsYJ/h1Rr4a9T11zkKhRCIvT4Jd5WpvGjG3f9t7YUigb6BqlMc0BTJ6ufQP/xBBOHqzoUs3gVk4HTMDfpYwO9k9SJNkfWBn1iV102BJ"
|
||||
DOTENV_VAULT_PRODUCTION_VERSION=8
|
||||
|
||||
#/----------------settings/metadata-----------------/
|
||||
DOTENV_VAULT="vlt_47e3eeb0730e831e688049600e59f8975260a1f00302ae08684ed87ba67872d0"
|
||||
DOTENV_API_URL="https://vault.dotenv.org"
|
||||
DOTENV_CLI="npx dotenv-vault@latest"
|
22
opensaas-sh/app/.gitignore
vendored
Normal file
22
opensaas-sh/app/.gitignore
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
.wasp/
|
||||
node_modules/
|
||||
|
||||
# Ignore all dotenv files by default to prevent accidentally committing any secrets.
|
||||
# To include specific dotenv files, use the `!` operator or adjust these rules.
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# These two we added only because dotenv-vault keeps adding them if it doesn't find them,
|
||||
# even though we don't need them. Remove them once dotenv-vault stops doing that.
|
||||
.env*
|
||||
.flaskenv*
|
||||
|
||||
# Don't ignore example dotenv files.
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# We don't want to ignore .env.client as it doesn't have any secrets.
|
||||
!.env.client
|
||||
# These are config files for dotenv-vault, so we don't want to ignore them.
|
||||
!.env.project
|
||||
!.env.vault
|
3
opensaas-sh/app/.waspignore
Normal file
3
opensaas-sh/app/.waspignore
Normal file
@ -0,0 +1,3 @@
|
||||
# Ignore editor tmp files
|
||||
**/*~
|
||||
**/#*#
|
1
opensaas-sh/app/.wasproot
Normal file
1
opensaas-sh/app/.wasproot
Normal file
@ -0,0 +1 @@
|
||||
File marking the root of Wasp project.
|
25
opensaas-sh/app/README.md
Normal file
25
opensaas-sh/app/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
# opensaas.sh (demo) app
|
||||
|
||||
This is a Wasp app based on Open Saas template with minimal modifications that make it into a demo app that showcases Open Saas's abilities.
|
||||
|
||||
It is deployed to https://opensaas.sh and serves both as a landing page for Open Saas and as a demo app.
|
||||
|
||||
## Development
|
||||
|
||||
### .env files
|
||||
`.env.client` file is versioned, but `.env.server` file you have to obtain by running `npm run env:pull`, since it has secrets in it.
|
||||
This will generate `.env.server` based on the `.env.vault`.
|
||||
We are using https://vault.dotenv.org to power this and have an account/organization up there.
|
||||
If you modify .env.server and want to persist the changes (for yourself and for the other team members), do `npm run env:push`.
|
||||
|
||||
### Running locally
|
||||
- Make sure you have the `.env.client` and `.env.server` files with correct dev values in the root of the project.
|
||||
- Run the database with `wasp start db` and leave it running.
|
||||
- Run `wasp start` and leave it running.
|
||||
- [OPTIONAL]: If this is the first time starting the app, or you've just made changes to your entities/prisma schema, also run `wasp db migrate-dev`.
|
||||
|
||||
## Deployment
|
||||
|
||||
This app is deployed to fly.io, Wasp org, via `wasp deploy fly deploy`.
|
||||
|
||||
You can run `npm run deploy` to deploy it via `wasp deploy fly deploy` with required client side env vars correctly set.
|
25
opensaas-sh/app/fly-client.toml
Normal file
25
opensaas-sh/app/fly-client.toml
Normal file
@ -0,0 +1,25 @@
|
||||
# fly.toml app configuration file generated for open-saas-wasp-sh-client on 2023-12-04T12:34:07+01:00
|
||||
#
|
||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
|
||||
app = "open-saas-wasp-sh-client"
|
||||
primary_region = "ams"
|
||||
|
||||
[build]
|
||||
|
||||
[http_service]
|
||||
internal_port = 8043
|
||||
force_https = true
|
||||
auto_stop_machines = true
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
processes = ["app"]
|
||||
|
||||
[http_service.http_options.response]
|
||||
pristine = true
|
||||
|
||||
[[vm]]
|
||||
cpu_kind = "shared"
|
||||
cpus = 1
|
||||
memory_mb = 1024
|
22
opensaas-sh/app/fly-server.toml
Normal file
22
opensaas-sh/app/fly-server.toml
Normal file
@ -0,0 +1,22 @@
|
||||
# fly.toml app configuration file generated for open-saas-wasp-sh-server on 2023-12-04T12:33:59+01:00
|
||||
#
|
||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
|
||||
app = "open-saas-wasp-sh-server"
|
||||
primary_region = "ams"
|
||||
|
||||
[build]
|
||||
|
||||
[http_service]
|
||||
internal_port = 8080
|
||||
force_https = true
|
||||
auto_stop_machines = true
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
processes = ["app"]
|
||||
|
||||
[[vm]]
|
||||
cpu_kind = "shared"
|
||||
cpus = 1
|
||||
memory_mb = 1024
|
338
opensaas-sh/app/main.wasp
Normal file
338
opensaas-sh/app/main.wasp
Normal file
@ -0,0 +1,338 @@
|
||||
app OpenSaaS {
|
||||
wasp: {
|
||||
version: "^0.16.0"
|
||||
},
|
||||
|
||||
title: "Open SaaS",
|
||||
|
||||
head: [
|
||||
"<meta charset='utf-8' />",
|
||||
"<meta name='description' content='Build and launch your SaaS application faster with our free, open-source starter kit. Features include auth, payments, AI example app, and admin dashboard.' />",
|
||||
"<meta name='author' content='Open SaaS' />",
|
||||
"<meta name='keywords' content='saas, starter, boilerplate, free, open source, authentication, payments' />",
|
||||
|
||||
"<meta property='og:site_name' content='Open SaaS' />",
|
||||
"<meta property='og:type' content='website' />",
|
||||
"<meta property='og:title' content='Open SaaS' />",
|
||||
"<meta property='og:url' content='https://opensaas.sh' />",
|
||||
"<meta property='og:description' content='Free, open-source SaaS boilerplate starter for React & NodeJS.' />",
|
||||
"<meta property='og:image' content='https://opensaas.sh/public-banner.webp' />",
|
||||
|
||||
"<meta name=\"twitter:title\" content=\"Open SaaS\" />",
|
||||
"<meta name=\"twitter:text:title\" content=\"Open SaaS\" />",
|
||||
"<meta name='twitter:image' content='https://opensaas.sh/public-banner.webp' />",
|
||||
"<meta name=\"twitter:image:alt\" content=\"Open SaaS\" />",
|
||||
"<meta name='twitter:image:width' content='800' />",
|
||||
"<meta name='twitter:image:height' content='400' />",
|
||||
"<meta name='twitter:card' content='summary_large_image' />",
|
||||
"<script defer data-domain='opensaas.sh' data-api='/waspara/wasp/event' src='/waspara/wasp/script.js'></script>",
|
||||
],
|
||||
|
||||
// 🔐 Auth out of the box! https://wasp.sh/docs/auth/overview
|
||||
auth: {
|
||||
userEntity: User,
|
||||
methods: {
|
||||
// NOTE: If you decide to not use email auth, make sure to also delete the related routes and pages below.
|
||||
// (RequestPasswordReset(Route|Page), PasswordReset(Route|Page), EmailVerification(Route|Page))
|
||||
email: {
|
||||
fromField: {
|
||||
name: "Open SaaS App",
|
||||
email: "vince@wasp-lang.dev"
|
||||
},
|
||||
emailVerification: {
|
||||
clientRoute: EmailVerificationRoute,
|
||||
getEmailContentFn: import { getVerificationEmailContent } from "@src/auth/email-and-pass/emails",
|
||||
},
|
||||
passwordReset: {
|
||||
clientRoute: PasswordResetRoute,
|
||||
getEmailContentFn: import { getPasswordResetEmailContent } from "@src/auth/email-and-pass/emails",
|
||||
},
|
||||
userSignupFields: import { getEmailUserFields } from "@src/auth/userSignupFields",
|
||||
},
|
||||
google: {
|
||||
userSignupFields: import { getGoogleUserFields } from "@src/auth/userSignupFields",
|
||||
configFn: import { getGoogleAuthConfig } from "@src/auth/userSignupFields",
|
||||
},
|
||||
gitHub: {
|
||||
userSignupFields: import { getGitHubUserFields } from "@src/auth/userSignupFields",
|
||||
configFn: import { getGitHubAuthConfig } from "@src/auth/userSignupFields",
|
||||
},
|
||||
discord: {
|
||||
userSignupFields: import { getDiscordUserFields } from "@src/auth/userSignupFields",
|
||||
configFn: import { getDiscordAuthConfig } from "@src/auth/userSignupFields"
|
||||
}
|
||||
},
|
||||
onAfterSignup: import { onAfterSignup } from "@src/auth/hooks",
|
||||
onAuthFailedRedirectTo: "/login",
|
||||
onAuthSucceededRedirectTo: "/demo-app",
|
||||
},
|
||||
|
||||
db: {
|
||||
// Run `wasp db seed` to seed the database with the seed functions below:
|
||||
seeds: [
|
||||
// Populates the database with a bunch of fake users to work with during development.
|
||||
import { seedMockUsers } from "@src/server/scripts/dbSeeds",
|
||||
]
|
||||
},
|
||||
|
||||
client: {
|
||||
rootComponent: import App from "@src/client/App",
|
||||
},
|
||||
|
||||
emailSender: {
|
||||
// NOTE: "Dummy" provider is just for local development purposes.
|
||||
// Make sure to check the server logs for the email confirmation url (it will not be sent to an address)!
|
||||
// Once you are ready for production, switch to e.g. "SendGrid" or "Mailgun" providers. Check out https://docs.opensaas.sh/guides/email-sending/ .
|
||||
provider: SendGrid,
|
||||
defaultFrom: {
|
||||
name: "Open SaaS App",
|
||||
// When using a real provider, e.g. SendGrid, you must use the same email address that you configured your account to send out emails with!
|
||||
email: "vince@wasp-lang.dev"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
route LandingPageRoute { path: "/", to: LandingPage }
|
||||
page LandingPage {
|
||||
component: import LandingPage from "@src/landing-page/LandingPage"
|
||||
}
|
||||
|
||||
//#region Auth Pages
|
||||
route LoginRoute { path: "/login", to: LoginPage }
|
||||
page LoginPage {
|
||||
component: import Login from "@src/auth/LoginPage"
|
||||
}
|
||||
|
||||
route SignupRoute { path: "/signup", to: SignupPage }
|
||||
page SignupPage {
|
||||
component: import { Signup } from "@src/auth/SignupPage"
|
||||
}
|
||||
|
||||
route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordResetPage }
|
||||
page RequestPasswordResetPage {
|
||||
component: import { RequestPasswordResetPage } from "@src/auth/email-and-pass/RequestPasswordResetPage",
|
||||
}
|
||||
|
||||
route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage }
|
||||
page PasswordResetPage {
|
||||
component: import { PasswordResetPage } from "@src/auth/email-and-pass/PasswordResetPage",
|
||||
}
|
||||
|
||||
route EmailVerificationRoute { path: "/email-verification", to: EmailVerificationPage }
|
||||
page EmailVerificationPage {
|
||||
component: import { EmailVerificationPage } from "@src/auth/email-and-pass/EmailVerificationPage",
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region User
|
||||
route AccountRoute { path: "/account", to: AccountPage }
|
||||
page AccountPage {
|
||||
authRequired: true,
|
||||
component: import Account from "@src/user/AccountPage"
|
||||
}
|
||||
|
||||
query getPaginatedUsers {
|
||||
fn: import { getPaginatedUsers } from "@src/user/operations",
|
||||
entities: [User]
|
||||
}
|
||||
|
||||
action updateIsUserAdminById {
|
||||
fn: import { updateIsUserAdminById } from "@src/user/operations",
|
||||
entities: [User]
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Demo AI App
|
||||
route DemoAppRoute { path: "/demo-app", to: DemoAppPage }
|
||||
page DemoAppPage {
|
||||
authRequired: true,
|
||||
component: import DemoAppPage from "@src/demo-ai-app/DemoAppPage"
|
||||
}
|
||||
|
||||
action generateGptResponse {
|
||||
fn: import { generateGptResponse } from "@src/demo-ai-app/operations",
|
||||
entities: [User, Task, GptResponse]
|
||||
}
|
||||
|
||||
action createTask {
|
||||
fn: import { createTask } from "@src/demo-ai-app/operations",
|
||||
entities: [Task]
|
||||
}
|
||||
|
||||
action deleteTask {
|
||||
fn: import { deleteTask } from "@src/demo-ai-app/operations",
|
||||
entities: [Task]
|
||||
}
|
||||
|
||||
action updateTask {
|
||||
fn: import { updateTask } from "@src/demo-ai-app/operations",
|
||||
entities: [Task]
|
||||
}
|
||||
|
||||
query getGptResponses {
|
||||
fn: import { getGptResponses } from "@src/demo-ai-app/operations",
|
||||
entities: [User, GptResponse]
|
||||
}
|
||||
|
||||
query getAllTasksByUser {
|
||||
fn: import { getAllTasksByUser } from "@src/demo-ai-app/operations",
|
||||
entities: [Task]
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Payment
|
||||
route PricingPageRoute { path: "/pricing", to: PricingPage }
|
||||
page PricingPage {
|
||||
component: import PricingPage from "@src/payment/PricingPage"
|
||||
}
|
||||
|
||||
route CheckoutRoute { path: "/checkout", to: CheckoutPage }
|
||||
page CheckoutPage {
|
||||
authRequired: true,
|
||||
component: import Checkout from "@src/payment/CheckoutPage"
|
||||
}
|
||||
|
||||
query getCustomerPortalUrl {
|
||||
fn: import { getCustomerPortalUrl } from "@src/payment/operations",
|
||||
entities: [User]
|
||||
}
|
||||
|
||||
action generateCheckoutSession {
|
||||
fn: import { generateCheckoutSession } from "@src/payment/operations",
|
||||
entities: [User]
|
||||
}
|
||||
|
||||
api paymentsWebhook {
|
||||
fn: import { stripeWebhook } from "@src/payment/stripe/webhook",
|
||||
entities: [User],
|
||||
middlewareConfigFn: import { stripeMiddlewareConfigFn } from "@src/payment/stripe/webhook",
|
||||
httpRoute: (POST, "/payments-webhook")
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region File Upload
|
||||
route FileUploadRoute { path: "/file-upload", to: FileUploadPage }
|
||||
page FileUploadPage {
|
||||
authRequired: true,
|
||||
component: import FileUpload from "@src/file-upload/FileUploadPage"
|
||||
}
|
||||
|
||||
action createFile {
|
||||
fn: import { createFile } from "@src/file-upload/operations",
|
||||
entities: [User, File]
|
||||
}
|
||||
|
||||
query getAllFilesByUser {
|
||||
fn: import { getAllFilesByUser } from "@src/file-upload/operations",
|
||||
entities: [User, File]
|
||||
}
|
||||
|
||||
query getDownloadFileSignedURL {
|
||||
fn: import { getDownloadFileSignedURL } from "@src/file-upload/operations",
|
||||
entities: [User, File]
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Analytics
|
||||
query getDailyStats {
|
||||
fn: import { getDailyStats } from "@src/analytics/operations",
|
||||
entities: [User, DailyStats]
|
||||
}
|
||||
|
||||
job dailyStatsJob {
|
||||
executor: PgBoss,
|
||||
perform: {
|
||||
fn: import { calculateDailyStats } from "@src/analytics/stats"
|
||||
},
|
||||
schedule: {
|
||||
cron: "0 * * * *" // every hour. useful in production
|
||||
// cron: "* * * * *" // every minute. useful for debugging
|
||||
},
|
||||
entities: [User, DailyStats, Logs, PageViewSource]
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Admin Dashboard
|
||||
route AdminRoute { path: "/admin", to: AnalyticsDashboardPage }
|
||||
page AnalyticsDashboardPage {
|
||||
authRequired: true,
|
||||
component: import AnalyticsDashboardPage from "@src/admin/dashboards/analytics/AnalyticsDashboardPage"
|
||||
}
|
||||
|
||||
route AdminUsersRoute { path: "/admin/users", to: AdminUsersPage }
|
||||
page AdminUsersPage {
|
||||
authRequired: true,
|
||||
component: import AdminUsers from "@src/admin/dashboards/users/UsersDashboardPage"
|
||||
}
|
||||
|
||||
route AdminSettingsRoute { path: "/admin/settings", to: AdminSettingsPage }
|
||||
page AdminSettingsPage {
|
||||
authRequired: true,
|
||||
component: import AdminSettings from "@src/admin/elements/settings/SettingsPage"
|
||||
}
|
||||
|
||||
route AdminChartsRoute { path: "/admin/chart", to: AdminChartsPage }
|
||||
page AdminChartsPage {
|
||||
authRequired: true,
|
||||
component: import AdminCharts from "@src/admin/elements/charts/ChartsPage"
|
||||
}
|
||||
|
||||
route AdminFormElementsRoute { path: "/admin/forms/form-elements", to: AdminFormElementsPage }
|
||||
page AdminFormElementsPage {
|
||||
authRequired: true,
|
||||
component: import AdminForms from "@src/admin/elements/forms/FormElementsPage"
|
||||
}
|
||||
|
||||
route AdminFormLayoutsRoute { path: "/admin/forms/form-layouts", to: AdminFormLayoutsPage }
|
||||
page AdminFormLayoutsPage {
|
||||
authRequired: true,
|
||||
component: import AdminForms from "@src/admin/elements/forms/FormLayoutsPage"
|
||||
}
|
||||
|
||||
route AdminCalendarRoute { path: "/admin/calendar", to: AdminCalendarPage }
|
||||
page AdminCalendarPage {
|
||||
authRequired: true,
|
||||
component: import AdminCalendar from "@src/admin/elements/calendar/CalendarPage"
|
||||
}
|
||||
|
||||
route AdminUIAlertsRoute { path: "/admin/ui/alerts", to: AdminUIAlertsPage }
|
||||
page AdminUIAlertsPage {
|
||||
authRequired: true,
|
||||
component: import AdminUI from "@src/admin/elements/ui-elements/AlertsPage"
|
||||
}
|
||||
|
||||
route AdminUIButtonsRoute { path: "/admin/ui/buttons", to: AdminUIButtonsPage }
|
||||
page AdminUIButtonsPage {
|
||||
authRequired: true,
|
||||
component: import AdminUI from "@src/admin/elements/ui-elements/ButtonsPage"
|
||||
}
|
||||
|
||||
route NotFoundRoute { path: "*", to: NotFoundPage }
|
||||
page NotFoundPage {
|
||||
component: import { NotFoundPage } from "@src/client/components/NotFoundPage"
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Contact Form Messages
|
||||
// TODO:
|
||||
// add functionality to allow users to send messages to admin
|
||||
// and make them accessible via the admin dashboard
|
||||
route AdminMessagesRoute { path: "/admin/messages", to: AdminMessagesPage }
|
||||
page AdminMessagesPage {
|
||||
authRequired: true,
|
||||
component: import AdminMessages from "@src/messages/MessagesPage"
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Newsletter
|
||||
job sendNewsletter {
|
||||
executor: PgBoss,
|
||||
perform: {
|
||||
fn: import { checkAndQueueNewsletterEmails } from "@src/newsletter/sendNewsletter"
|
||||
},
|
||||
schedule: {
|
||||
cron: "0 7 * * 1" // at 7:00 am every Monday
|
||||
},
|
||||
entities: [User]
|
||||
}
|
||||
//#endregion
|
118
opensaas-sh/app/migrations/20231213174854_init/migration.sql
Normal file
118
opensaas-sh/app/migrations/20231213174854_init/migration.sql
Normal file
@ -0,0 +1,118 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"email" TEXT,
|
||||
"username" TEXT,
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastActiveTimestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"isEmailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isMockUser" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT true,
|
||||
"emailVerificationSentAt" TIMESTAMP(3),
|
||||
"passwordResetSentAt" TIMESTAMP(3),
|
||||
"stripeId" TEXT,
|
||||
"checkoutSessionId" TEXT,
|
||||
"hasPaid" BOOLEAN NOT NULL DEFAULT false,
|
||||
"subscriptionTier" TEXT,
|
||||
"subscriptionStatus" TEXT,
|
||||
"sendEmail" BOOLEAN NOT NULL DEFAULT false,
|
||||
"datePaid" TIMESTAMP(3),
|
||||
"credits" INTEGER NOT NULL DEFAULT 3,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SocialLogin" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SocialLogin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- 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")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContactFormMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"isRead" BOOLEAN NOT NULL DEFAULT false,
|
||||
"repliedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ContactFormMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DailyStats" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"totalViews" INTEGER NOT NULL DEFAULT 0,
|
||||
"prevDayViewsChangePercent" TEXT NOT NULL DEFAULT '0',
|
||||
"userCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"paidUserCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"userDelta" INTEGER NOT NULL DEFAULT 0,
|
||||
"paidUserDelta" INTEGER NOT NULL DEFAULT 0,
|
||||
"totalRevenue" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"totalProfit" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "DailyStats_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PageViewSource" (
|
||||
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"name" TEXT NOT NULL,
|
||||
"visitors" INTEGER NOT NULL,
|
||||
"dailyStatsId" INTEGER,
|
||||
|
||||
CONSTRAINT "PageViewSource_pkey" PRIMARY KEY ("date","name")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Logs" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"message" TEXT NOT NULL,
|
||||
"level" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SocialLogin_provider_providerId_userId_key" ON "SocialLogin"("provider", "providerId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DailyStats_date_key" ON "DailyStats"("date");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SocialLogin" ADD CONSTRAINT "SocialLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GptResponse" ADD CONSTRAINT "GptResponse_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContactFormMessage" ADD CONSTRAINT "ContactFormMessage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PageViewSource" ADD CONSTRAINT "PageViewSource_dailyStatsId_fkey" FOREIGN KEY ("dailyStatsId") REFERENCES "DailyStats"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Task" (
|
||||
"id" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"time" TEXT NOT NULL DEFAULT '1',
|
||||
"isDone" BOOLEAN NOT NULL DEFAULT false,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "File" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"uploadUrl" TEXT NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "File_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "File" ADD CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
@ -0,0 +1,44 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Auth" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" INTEGER,
|
||||
|
||||
CONSTRAINT "Auth_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuthIdentity" (
|
||||
"providerName" TEXT NOT NULL,
|
||||
"providerUserId" TEXT NOT NULL,
|
||||
"providerData" TEXT NOT NULL DEFAULT '{}',
|
||||
"authId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "AuthIdentity_pkey" PRIMARY KEY ("providerName","providerUserId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Auth" ADD CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuthIdentity" ADD CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,21 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `emailVerificationSentAt` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `isEmailVerified` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `password` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `passwordResetSentAt` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the `SocialLogin` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SocialLogin" DROP CONSTRAINT "SocialLogin_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "emailVerificationSentAt",
|
||||
DROP COLUMN "isEmailVerified",
|
||||
DROP COLUMN "password",
|
||||
DROP COLUMN "passwordResetSentAt";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "SocialLogin";
|
@ -0,0 +1,5 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "User_email_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "User_username_key";
|
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `hasPaid` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "hasPaid";
|
@ -0,0 +1,65 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[username]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Auth" DROP CONSTRAINT "Auth_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ContactFormMessage" DROP CONSTRAINT "ContactFormMessage_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "File" DROP CONSTRAINT "File_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "GptResponse" DROP CONSTRAINT "GptResponse_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Task" DROP CONSTRAINT "Task_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Auth" ALTER COLUMN "userId" SET DATA TYPE TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ContactFormMessage" ALTER COLUMN "userId" SET DATA TYPE TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ALTER COLUMN "userId" SET DATA TYPE TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "GptResponse" ALTER COLUMN "userId" SET DATA TYPE TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Task" ALTER COLUMN "userId" SET DATA TYPE TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP CONSTRAINT "User_pkey",
|
||||
ALTER COLUMN "id" DROP DEFAULT,
|
||||
ALTER COLUMN "id" SET DATA TYPE TEXT,
|
||||
ADD CONSTRAINT "User_pkey" PRIMARY KEY ("id");
|
||||
DROP SEQUENCE "User_id_seq";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GptResponse" ADD CONSTRAINT "GptResponse_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "File" ADD CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContactFormMessage" ADD CONSTRAINT "ContactFormMessage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Auth" ADD CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,16 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `sendEmail` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `subscriptionTier` on the `User` table. All the data in the column will be lost.
|
||||
- A unique constraint covering the columns `[stripeId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "sendEmail",
|
||||
DROP COLUMN "subscriptionTier",
|
||||
ADD COLUMN "sendNewsletter" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "subscriptionPlan" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_stripeId_key" ON "User"("stripeId");
|
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `checkoutSessionId` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN IF EXISTS "checkoutSessionId";
|
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `lastActiveTimestamp` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "lastActiveTimestamp";
|
3
opensaas-sh/app/migrations/migration_lock.toml
Normal file
3
opensaas-sh/app/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
11737
opensaas-sh/app/package-lock.json
generated
Normal file
11737
opensaas-sh/app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
opensaas-sh/app/package.json
Normal file
44
opensaas-sh/app/package.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "opensaas",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"env:pull": "npx dotenv-vault@latest pull development .env.server",
|
||||
"env:push": "npx dotenv-vault@latest push development .env.server",
|
||||
"deploy": "REACT_APP_GOOGLE_ANALYTICS_ID=G-H3LSJCK95H wasp deploy fly deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.523.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.523.0",
|
||||
"@faker-js/faker": "8.3.1",
|
||||
"@google-analytics/data": "4.1.0",
|
||||
"@headlessui/react": "1.7.13",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.7",
|
||||
"apexcharts": "3.41.0",
|
||||
"clsx": "^2.1.0",
|
||||
"headlessui": "^0.0.0",
|
||||
"node-fetch": "3.3.0",
|
||||
"openai": "^4.55.3",
|
||||
"prettier": "3.1.1",
|
||||
"prettier-plugin-tailwindcss": "0.5.11",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-apexcharts": "1.4.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "4.11.0",
|
||||
"stripe": "11.15.0",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"vanilla-cookieconsent": "^3.0.1",
|
||||
"wasp": "file:.wasp/out/sdk/wasp",
|
||||
"zod": "^3.23.8",
|
||||
"tailwindcss": "^3.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/react": "^18.0.37",
|
||||
"prisma": "5.19.1",
|
||||
"typescript": "^5.1.0",
|
||||
"vite": "^4.3.9"
|
||||
}
|
||||
}
|
6
opensaas-sh/app/postcss.config.cjs
Normal file
6
opensaas-sh/app/postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
0
opensaas-sh/app/public/.gitkeep
Normal file
0
opensaas-sh/app/public/.gitkeep
Normal file
BIN
opensaas-sh/app/public/favicon.ico
Normal file
BIN
opensaas-sh/app/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 168 KiB |
BIN
opensaas-sh/app/public/fonts/Satoshi-Black.eot
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Black.eot
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Black.ttf
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Black.ttf
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Black.woff
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Black.woff
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Black.woff2
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Black.woff2
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-BlackItalic.eot
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-BlackItalic.eot
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-BlackItalic.ttf
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-BlackItalic.ttf
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-BlackItalic.woff
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-BlackItalic.woff
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-BlackItalic.woff2
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-BlackItalic.woff2
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Bold.eot
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Bold.eot
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Bold.ttf
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Bold.ttf
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Bold.woff
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Bold.woff
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Bold.woff2
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Bold.woff2
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-BoldItalic.eot
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-BoldItalic.eot
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-BoldItalic.ttf
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-BoldItalic.woff
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-BoldItalic.woff
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-BoldItalic.woff2
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Italic.eot
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Italic.eot
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Italic.ttf
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Italic.ttf
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Italic.woff
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Italic.woff
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Italic.woff2
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Italic.woff2
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Light.eot
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Light.eot
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Light.ttf
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Light.ttf
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Light.woff
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Light.woff
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Light.woff2
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Light.woff2
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-LightItalic.eot
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-LightItalic.eot
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-LightItalic.ttf
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-LightItalic.ttf
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-LightItalic.woff
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-LightItalic.woff
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-LightItalic.woff2
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-LightItalic.woff2
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Medium.eot
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Medium.eot
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Medium.ttf
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Medium.ttf
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Medium.woff
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Medium.woff
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Medium.woff2
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Medium.woff2
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-MediumItalic.eot
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-MediumItalic.eot
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-MediumItalic.ttf
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-MediumItalic.woff
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-MediumItalic.woff
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-MediumItalic.woff2
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Regular.eot
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Regular.eot
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Regular.ttf
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Regular.ttf
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Regular.woff
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Regular.woff
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Regular.woff2
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Regular.woff2
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Variable.eot
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Variable.eot
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Variable.ttf
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Variable.ttf
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Variable.woff
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Variable.woff
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-Variable.woff2
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-Variable.woff2
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-VariableItalic.eot
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-VariableItalic.eot
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-VariableItalic.ttf
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-VariableItalic.ttf
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-VariableItalic.woff
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-VariableItalic.woff
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/fonts/Satoshi-VariableItalic.woff2
Normal file
BIN
opensaas-sh/app/public/fonts/Satoshi-VariableItalic.woff2
Normal file
Binary file not shown.
BIN
opensaas-sh/app/public/public-banner.webp
Normal file
BIN
opensaas-sh/app/public/public-banner.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 145 KiB |
115
opensaas-sh/app/schema.prisma
Normal file
115
opensaas-sh/app/schema.prisma
Normal file
@ -0,0 +1,115 @@
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
email String? @unique
|
||||
username String? @unique
|
||||
isAdmin Boolean @default(true)
|
||||
// isMockUser is an extra property for the demo app ensuring that all users can access
|
||||
// the admin dashboard but won't be able to see the other users' data, only mock user data.
|
||||
isMockUser Boolean @default(false)
|
||||
|
||||
stripeId String? @unique
|
||||
subscriptionStatus String? // 'active', 'cancel_at_period_end', 'past_due', 'deleted'
|
||||
subscriptionPlan String? // 'hobby', 'pro'
|
||||
sendNewsletter Boolean @default(false)
|
||||
datePaid DateTime?
|
||||
credits Int @default(3)
|
||||
|
||||
gptResponses GptResponse[]
|
||||
contactFormMessages ContactFormMessage[]
|
||||
tasks Task[]
|
||||
files File[]
|
||||
}
|
||||
|
||||
model GptResponse {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
content String
|
||||
}
|
||||
|
||||
model Task {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
description String
|
||||
time String @default("1")
|
||||
isDone Boolean @default(false)
|
||||
}
|
||||
|
||||
model File {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
name String
|
||||
type String
|
||||
key String
|
||||
uploadUrl String
|
||||
}
|
||||
|
||||
model DailyStats {
|
||||
id Int @id @default(autoincrement())
|
||||
date DateTime @default(now()) @unique
|
||||
|
||||
totalViews Int @default(0)
|
||||
prevDayViewsChangePercent String @default("0")
|
||||
userCount Int @default(0)
|
||||
paidUserCount Int @default(0)
|
||||
userDelta Int @default(0)
|
||||
paidUserDelta Int @default(0)
|
||||
totalRevenue Float @default(0)
|
||||
totalProfit Float @default(0)
|
||||
|
||||
sources PageViewSource[]
|
||||
}
|
||||
|
||||
model PageViewSource {
|
||||
@@id([date, name])
|
||||
name String
|
||||
date DateTime @default(now())
|
||||
|
||||
dailyStats DailyStats? @relation(fields: [dailyStatsId], references: [id])
|
||||
dailyStatsId Int?
|
||||
|
||||
visitors Int
|
||||
}
|
||||
|
||||
model Logs {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
message String
|
||||
level String
|
||||
}
|
||||
|
||||
model ContactFormMessage {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
content String
|
||||
isRead Boolean @default(false)
|
||||
repliedAt DateTime?
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
import { Link } from 'wasp/client/router';
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, getDailyStats } from 'wasp/client/operations';
|
||||
import TotalSignupsCard from './TotalSignupsCard';
|
||||
import TotalPageViewsCard from './TotalPageViewsCard';
|
||||
import TotalPayingUsersCard from './TotalPayingUsersCard';
|
||||
import TotalRevenueCard from './TotalRevenueCard';
|
||||
import RevenueAndProfitChart from './RevenueAndProfitChart';
|
||||
import SourcesTable from './SourcesTable';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin';
|
||||
import { cn } from '../../../client/cn';
|
||||
|
||||
const Dashboard = ({ user }: { user: AuthUser }) => {
|
||||
const [isDemoInfoVisible, setIsDemoInfoVisible] = useState(false);
|
||||
useRedirectHomeUnlessUserIsAdmin({ user });
|
||||
|
||||
const { data: stats, isLoading, error } = useQuery(getDailyStats);
|
||||
|
||||
const didUserCloseDemoInfo = localStorage.getItem('didUserCloseDemoInfo') === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
if (didUserCloseDemoInfo || !stats) {
|
||||
setIsDemoInfoVisible(false);
|
||||
} else if (!didUserCloseDemoInfo && stats) {
|
||||
setIsDemoInfoVisible(true);
|
||||
}
|
||||
}, [stats]);
|
||||
|
||||
const handleDemoInfoClose = () => {
|
||||
try {
|
||||
localStorage.setItem('didUserCloseDemoInfo', 'true');
|
||||
setIsDemoInfoVisible(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedSources = useMemo(() => {
|
||||
return stats?.dailyStats?.sources?.slice().sort((a, b) => b.visitors - a.visitors);
|
||||
}, [stats?.dailyStats?.sources]);
|
||||
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
{isDemoInfoVisible && (
|
||||
<div className='fixed z-999 bottom-0 mb-2 left-1/2 -translate-x-1/2 lg:mb-4 bg-gray-700 rounded-full px-3.5 py-2 text-sm text-white duration-300 ease-in-out hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-600'>
|
||||
<div className='px-4 flex flex-row gap-2 items-center my-1'>
|
||||
<span className='text-gray-100 text-center'>
|
||||
This is actual data from Stripe test purchases. <br /> Try out purchasing a{' '}
|
||||
<Link to='/pricing' className='underline text-yellow-400'>
|
||||
test product
|
||||
</Link>
|
||||
!
|
||||
</span>
|
||||
<button className=' pl-2.5 text-gray-400 text-xl font-bold' onClick={() => handleDemoInfoClose()}>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='relative'>
|
||||
<div
|
||||
className={cn({
|
||||
'opacity-25': !stats,
|
||||
})}
|
||||
>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-4 2xl:gap-7.5'>
|
||||
<TotalPageViewsCard
|
||||
totalPageViews={stats?.dailyStats.totalViews}
|
||||
prevDayViewsChangePercent={stats?.dailyStats.prevDayViewsChangePercent}
|
||||
/>
|
||||
<TotalRevenueCard
|
||||
dailyStats={stats?.dailyStats}
|
||||
weeklyStats={stats?.weeklyStats}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<TotalPayingUsersCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
|
||||
<TotalSignupsCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
|
||||
</div>
|
||||
|
||||
<div className='mt-4 grid grid-cols-12 gap-4 md:mt-6 md:gap-6 2xl:mt-7.5 2xl:gap-7.5'>
|
||||
<RevenueAndProfitChart weeklyStats={stats?.weeklyStats} isLoading={isLoading} />
|
||||
|
||||
<div className='col-span-12 xl:col-span-8'>
|
||||
<SourcesTable sources={sortedSources} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!stats && (
|
||||
<div className='absolute inset-0 flex items-start justify-center bg-white/50 dark:bg-boxdark-2/50'>
|
||||
<div className='rounded-lg bg-white p-8 shadow-lg dark:bg-boxdark'>
|
||||
<p className='text-2xl font-bold text-boxdark dark:text-white'>No daily stats generated yet</p>
|
||||
<p className='mt-2 text-sm text-bodydark2'>
|
||||
Stats will appear here once the daily stats job has run
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
@ -0,0 +1,242 @@
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import ReactApexChart from 'react-apexcharts';
|
||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
||||
|
||||
const options: ApexOptions = {
|
||||
legend: {
|
||||
show: false,
|
||||
position: 'top',
|
||||
horizontalAlign: 'left',
|
||||
},
|
||||
colors: ['#3C50E0', '#80CAEE'],
|
||||
chart: {
|
||||
fontFamily: 'Satoshi, sans-serif',
|
||||
height: 335,
|
||||
type: 'area',
|
||||
dropShadow: {
|
||||
enabled: true,
|
||||
color: '#623CEA14',
|
||||
top: 10,
|
||||
blur: 4,
|
||||
left: 0,
|
||||
opacity: 0.1,
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 1024,
|
||||
options: {
|
||||
chart: {
|
||||
height: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
breakpoint: 1366,
|
||||
options: {
|
||||
chart: {
|
||||
height: 350,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
stroke: {
|
||||
width: [2, 2],
|
||||
curve: 'straight',
|
||||
},
|
||||
// labels: {
|
||||
// show: false,
|
||||
// position: "top",
|
||||
// },
|
||||
grid: {
|
||||
xaxis: {
|
||||
lines: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
lines: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
markers: {
|
||||
size: 4,
|
||||
colors: '#fff',
|
||||
strokeColors: ['#3056D3', '#80CAEE'],
|
||||
strokeWidth: 3,
|
||||
strokeOpacity: 0.9,
|
||||
strokeDashArray: 0,
|
||||
fillOpacity: 1,
|
||||
discrete: [],
|
||||
hover: {
|
||||
size: undefined,
|
||||
sizeOffset: 5,
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
type: 'category',
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
style: {
|
||||
fontSize: '0px',
|
||||
},
|
||||
},
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
};
|
||||
|
||||
interface ChartOneState {
|
||||
series: {
|
||||
name: string;
|
||||
data: number[];
|
||||
}[];
|
||||
}
|
||||
|
||||
const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
const dailyRevenueArray = useMemo(() => {
|
||||
if (!!weeklyStats && weeklyStats?.length > 0) {
|
||||
const sortedWeeks = weeklyStats?.sort((a, b) => {
|
||||
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
});
|
||||
return sortedWeeks.map((stat) => stat.totalRevenue);
|
||||
}
|
||||
}, [weeklyStats]);
|
||||
|
||||
const daysOfWeekArr = useMemo(() => {
|
||||
if (!!weeklyStats && weeklyStats?.length > 0) {
|
||||
const datesArr = weeklyStats?.map((stat) => {
|
||||
// get day of week, month, and day of month
|
||||
const dateArr = stat.date.toString().split(' ');
|
||||
return dateArr.slice(0, 3).join(' ');
|
||||
});
|
||||
return datesArr;
|
||||
}
|
||||
}, [weeklyStats]);
|
||||
|
||||
const [state, setState] = useState<ChartOneState>({
|
||||
series: [
|
||||
{
|
||||
name: 'Profit',
|
||||
data: [4, 7, 10, 11, 13, 14, 17],
|
||||
},
|
||||
],
|
||||
});
|
||||
const [chartOptions, setChartOptions] = useState<ApexOptions>(options);
|
||||
|
||||
useEffect(() => {
|
||||
if (dailyRevenueArray && dailyRevenueArray.length > 0) {
|
||||
setState((prevState) => {
|
||||
// Check if a "Revenue" series already exists
|
||||
const existingSeriesIndex = prevState.series.findIndex((series) => series.name === 'Revenue');
|
||||
|
||||
if (existingSeriesIndex >= 0) {
|
||||
// Update existing "Revenue" series data
|
||||
return {
|
||||
...prevState,
|
||||
series: prevState.series.map((serie, index) => {
|
||||
if (index === existingSeriesIndex) {
|
||||
return { ...serie, data: dailyRevenueArray };
|
||||
}
|
||||
return serie;
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// Add "Revenue" series as it does not exist yet
|
||||
return {
|
||||
...prevState,
|
||||
series: [
|
||||
...prevState.series,
|
||||
{
|
||||
name: 'Revenue',
|
||||
data: dailyRevenueArray,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [dailyRevenueArray]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!!daysOfWeekArr && daysOfWeekArr?.length > 0 && !!dailyRevenueArray && dailyRevenueArray?.length > 0) {
|
||||
setChartOptions({
|
||||
...options,
|
||||
xaxis: {
|
||||
...options.xaxis,
|
||||
categories: daysOfWeekArr,
|
||||
},
|
||||
yaxis: {
|
||||
...options.yaxis,
|
||||
// get the min & max values to the neareast hundred
|
||||
max: Math.ceil(Math.max(...dailyRevenueArray) / 100) * 100,
|
||||
min: Math.floor(Math.min(...dailyRevenueArray) / 100) * 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [daysOfWeekArr, dailyRevenueArray]);
|
||||
|
||||
return (
|
||||
<div className='col-span-12 rounded-sm border border-stroke bg-white px-5 pt-7.5 pb-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:col-span-8'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap'>
|
||||
<div className='flex w-full flex-wrap gap-3 sm:gap-5'>
|
||||
<div className='flex min-w-47.5'>
|
||||
<span className='mt-1 mr-2 flex h-4 w-full max-w-4 items-center justify-center rounded-full border border-primary'>
|
||||
<span className='block h-2.5 w-full max-w-2.5 rounded-full bg-primary'></span>
|
||||
</span>
|
||||
<div className='w-full'>
|
||||
<p className='font-semibold text-primary'>Total Profit</p>
|
||||
<p className='text-sm font-medium'>Last 7 Days</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-w-47.5'>
|
||||
<span className='mt-1 mr-2 flex h-4 w-full max-w-4 items-center justify-center rounded-full border border-secondary'>
|
||||
<span className='block h-2.5 w-full max-w-2.5 rounded-full bg-secondary'></span>
|
||||
</span>
|
||||
<div className='w-full'>
|
||||
<p className='font-semibold text-secondary'>Total Revenue</p>
|
||||
<p className='text-sm font-medium'>Last 7 Days</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex w-full max-w-45 justify-end'>
|
||||
<div className='inline-flex items-center rounded-md bg-whiter p-1.5 dark:bg-meta-4'>
|
||||
<button className='rounded bg-white py-1 px-3 text-xs font-medium text-black shadow-card hover:bg-white hover:shadow-card dark:bg-boxdark dark:text-white dark:hover:bg-boxdark'>
|
||||
Day
|
||||
</button>
|
||||
<button className='rounded py-1 px-3 text-xs font-medium text-black hover:bg-white hover:shadow-card dark:text-white dark:hover:bg-boxdark'>
|
||||
Week
|
||||
</button>
|
||||
<button className='rounded py-1 px-3 text-xs font-medium text-black hover:bg-white hover:shadow-card dark:text-white dark:hover:bg-boxdark'>
|
||||
Month
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div id='chartOne' className='-ml-5'>
|
||||
<ReactApexChart options={chartOptions} series={state.series} type='area' height={350} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevenueAndProfitChart;
|
@ -0,0 +1,47 @@
|
||||
import { type PageViewSource } from 'wasp/entities';
|
||||
|
||||
const SourcesTable = ({ sources }: { sources: PageViewSource[] | undefined }) => {
|
||||
return (
|
||||
<div className='rounded-sm border border-stroke bg-white px-5 pt-6 pb-2.5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1'>
|
||||
<h4 className='mb-6 text-xl font-semibold text-black dark:text-white'>Top Sources</h4>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<div className='grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 '>
|
||||
<div className='p-2.5 xl:p-5'>
|
||||
<h5 className='text-sm font-medium uppercase xsm:text-base'>Source</h5>
|
||||
</div>
|
||||
<div className='p-2.5 text-center xl:p-5'>
|
||||
<h5 className='text-sm font-medium uppercase xsm:text-base'>Visitors</h5>
|
||||
</div>
|
||||
<div className='hidden p-2.5 text-center sm:block xl:p-5'>
|
||||
<h5 className='text-sm font-medium uppercase xsm:text-base'>Sales</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sources && sources.length > 0 ? (
|
||||
sources.map((source) => (
|
||||
<div className='grid grid-cols-3 border-b border-stroke dark:border-strokedark'>
|
||||
<div className='flex items-center gap-3 p-2.5 xl:p-5'>
|
||||
<p className='text-black dark:text-white'>{source.name}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-center p-2.5 xl:p-5'>
|
||||
<p className='text-black dark:text-white'>{source.visitors}</p>
|
||||
</div>
|
||||
|
||||
<div className='hidden items-center justify-center p-2.5 sm:flex xl:p-5'>
|
||||
<p className='text-black dark:text-white'>--</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='flex items-center justify-center p-2.5 xl:p-5'>
|
||||
<p className='text-black dark:text-white'>No data to display</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SourcesTable;
|
@ -0,0 +1,55 @@
|
||||
import { cn } from '../../../client/cn';
|
||||
import { UpArrow, DownArrow } from '../../../client/icons/icons-arrows';
|
||||
|
||||
type PageViewsStats = {
|
||||
totalPageViews: number | undefined;
|
||||
prevDayViewsChangePercent: string | undefined;
|
||||
};
|
||||
|
||||
const TotalPageViewsCard = ({ totalPageViews, prevDayViewsChangePercent }: PageViewsStats) => {
|
||||
const isDeltaPositive = parseInt(prevDayViewsChangePercent || '') > 0;
|
||||
|
||||
return (
|
||||
<div className='rounded-sm border border-stroke bg-white py-6 px-7.5 shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-meta-2 dark:bg-meta-4'>
|
||||
<svg
|
||||
className='fill-primary dark:fill-white'
|
||||
width='22'
|
||||
height='16'
|
||||
viewBox='0 0 22 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M11 15.1156C4.19376 15.1156 0.825012 8.61876 0.687512 8.34376C0.584387 8.13751 0.584387 7.86251 0.687512 7.65626C0.825012 7.38126 4.19376 0.918762 11 0.918762C17.8063 0.918762 21.175 7.38126 21.3125 7.65626C21.4156 7.86251 21.4156 8.13751 21.3125 8.34376C21.175 8.61876 17.8063 15.1156 11 15.1156ZM2.26876 8.00001C3.02501 9.27189 5.98126 13.5688 11 13.5688C16.0188 13.5688 18.975 9.27189 19.7313 8.00001C18.975 6.72814 16.0188 2.43126 11 2.43126C5.98126 2.43126 3.02501 6.72814 2.26876 8.00001Z'
|
||||
fill=''
|
||||
/>
|
||||
<path
|
||||
d='M11 10.9219C9.38438 10.9219 8.07812 9.61562 8.07812 8C8.07812 6.38438 9.38438 5.07812 11 5.07812C12.6156 5.07812 13.9219 6.38438 13.9219 8C13.9219 9.61562 12.6156 10.9219 11 10.9219ZM11 6.625C10.2437 6.625 9.625 7.24375 9.625 8C9.625 8.75625 10.2437 9.375 11 9.375C11.7563 9.375 12.375 8.75625 12.375 8C12.375 7.24375 11.7563 6.625 11 6.625Z'
|
||||
fill=''
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 flex items-end justify-between'>
|
||||
<div>
|
||||
<h4 className='text-title-md font-bold text-black dark:text-white'>{totalPageViews}</h4>
|
||||
<span className='text-sm font-medium'>Total page views</span>
|
||||
</div>
|
||||
|
||||
{prevDayViewsChangePercent && parseInt(prevDayViewsChangePercent) !== 0 && (
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
||||
'text-meta-3': isDeltaPositive,
|
||||
'text-meta-5': !isDeltaPositive,
|
||||
})}
|
||||
>
|
||||
{prevDayViewsChangePercent}%{parseInt(prevDayViewsChangePercent) > 0 ? <UpArrow /> : <DownArrow />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotalPageViewsCard;
|
@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '../../../client/cn';
|
||||
import { UpArrow, DownArrow } from '../../../client/icons/icons-arrows';
|
||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
||||
|
||||
const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
||||
const isDeltaPositive = useMemo(() => {
|
||||
return !!dailyStats?.paidUserDelta && dailyStats?.paidUserDelta > 0;
|
||||
}, [dailyStats]);
|
||||
|
||||
return (
|
||||
<div className='rounded-sm border border-stroke bg-white py-6 px-7.5 shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-meta-2 dark:bg-meta-4'>
|
||||
<svg
|
||||
className='fill-primary dark:fill-white'
|
||||
width='22'
|
||||
height='22'
|
||||
viewBox='0 0 22 22'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M21.1063 18.0469L19.3875 3.23126C19.2157 1.71876 17.9438 0.584381 16.3969 0.584381H5.56878C4.05628 0.584381 2.78441 1.71876 2.57816 3.23126L0.859406 18.0469C0.756281 18.9063 1.03128 19.7313 1.61566 20.3844C2.20003 21.0375 2.99066 21.3813 3.85003 21.3813H18.1157C18.975 21.3813 19.8 21.0031 20.35 20.3844C20.9 19.7656 21.2094 18.9063 21.1063 18.0469ZM19.2157 19.3531C18.9407 19.6625 18.5625 19.8344 18.15 19.8344H3.85003C3.43753 19.8344 3.05941 19.6625 2.78441 19.3531C2.50941 19.0438 2.37191 18.6313 2.44066 18.2188L4.12503 3.43751C4.19378 2.71563 4.81253 2.16563 5.56878 2.16563H16.4313C17.1532 2.16563 17.7719 2.71563 17.875 3.43751L19.5938 18.2531C19.6282 18.6656 19.4907 19.0438 19.2157 19.3531Z'
|
||||
fill=''
|
||||
/>
|
||||
<path
|
||||
d='M14.3345 5.29375C13.922 5.39688 13.647 5.80938 13.7501 6.22188C13.7845 6.42813 13.8189 6.63438 13.8189 6.80625C13.8189 8.35313 12.547 9.625 11.0001 9.625C9.45327 9.625 8.1814 8.35313 8.1814 6.80625C8.1814 6.6 8.21577 6.42813 8.25015 6.22188C8.35327 5.80938 8.07827 5.39688 7.66577 5.29375C7.25327 5.19063 6.84077 5.46563 6.73765 5.87813C6.6689 6.1875 6.63452 6.49688 6.63452 6.80625C6.63452 9.2125 8.5939 11.1719 11.0001 11.1719C13.4064 11.1719 15.3658 9.2125 15.3658 6.80625C15.3658 6.49688 15.3314 6.1875 15.2626 5.87813C15.1595 5.46563 14.747 5.225 14.3345 5.29375Z'
|
||||
fill=''
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 flex items-end justify-between'>
|
||||
<div>
|
||||
<h4 className='text-title-md font-bold text-black dark:text-white'>{dailyStats?.paidUserCount}</h4>
|
||||
<span className='text-sm font-medium'>Total Paying Users</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
||||
'text-meta-3': isDeltaPositive,
|
||||
'text-meta-5': !isDeltaPositive,
|
||||
})}
|
||||
>
|
||||
{isLoading ? '...' : dailyStats?.paidUserDelta !== 0 ? dailyStats?.paidUserDelta : '-'}
|
||||
{dailyStats?.paidUserDelta !== 0 ? isDeltaPositive ? <UpArrow /> : <DownArrow /> : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotalPayingUsersCard;
|
@ -0,0 +1,62 @@
|
||||
import { useMemo } from 'react';
|
||||
import { UpArrow, DownArrow } from '../../../client/icons/icons-arrows';
|
||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
||||
|
||||
const TotalRevenueCard = ({dailyStats, weeklyStats, isLoading}: DailyStatsProps) => {
|
||||
const isDeltaPositive = useMemo(() => {
|
||||
if (!weeklyStats) return false;
|
||||
return (weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) > 0;
|
||||
}, [weeklyStats]);
|
||||
|
||||
const deltaPercentage = useMemo(() => {
|
||||
if ( !weeklyStats || weeklyStats.length < 2 || isLoading) return;
|
||||
if ( weeklyStats[1]?.totalRevenue === 0 || weeklyStats[0]?.totalRevenue === 0 ) return 0;
|
||||
|
||||
weeklyStats.sort((a, b) => b.id - a.id);
|
||||
|
||||
const percentage = ((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) / weeklyStats[1]?.totalRevenue) * 100;
|
||||
return Math.floor(percentage);
|
||||
}, [weeklyStats]);
|
||||
|
||||
return (
|
||||
<div className='rounded-sm border border-stroke bg-white py-6 px-7.5 shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-meta-2 dark:bg-meta-4'>
|
||||
<svg
|
||||
className='fill-primary dark:fill-white'
|
||||
width='20'
|
||||
height='22'
|
||||
viewBox='0 0 20 22'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M11.7531 16.4312C10.3781 16.4312 9.27808 17.5312 9.27808 18.9062C9.27808 20.2812 10.3781 21.3812 11.7531 21.3812C13.1281 21.3812 14.2281 20.2812 14.2281 18.9062C14.2281 17.5656 13.0937 16.4312 11.7531 16.4312ZM11.7531 19.8687C11.2375 19.8687 10.825 19.4562 10.825 18.9406C10.825 18.425 11.2375 18.0125 11.7531 18.0125C12.2687 18.0125 12.6812 18.425 12.6812 18.9406C12.6812 19.4219 12.2343 19.8687 11.7531 19.8687Z'
|
||||
fill=''
|
||||
/>
|
||||
<path
|
||||
d='M5.22183 16.4312C3.84683 16.4312 2.74683 17.5312 2.74683 18.9062C2.74683 20.2812 3.84683 21.3812 5.22183 21.3812C6.59683 21.3812 7.69683 20.2812 7.69683 18.9062C7.69683 17.5656 6.56245 16.4312 5.22183 16.4312ZM5.22183 19.8687C4.7062 19.8687 4.2937 19.4562 4.2937 18.9406C4.2937 18.425 4.7062 18.0125 5.22183 18.0125C5.73745 18.0125 6.14995 18.425 6.14995 18.9406C6.14995 19.4219 5.73745 19.8687 5.22183 19.8687Z'
|
||||
fill=''
|
||||
/>
|
||||
<path
|
||||
d='M19.0062 0.618744H17.15C16.325 0.618744 15.6031 1.23749 15.5 2.06249L14.95 6.01562H1.37185C1.0281 6.01562 0.684353 6.18749 0.443728 6.46249C0.237478 6.73749 0.134353 7.11562 0.237478 7.45937C0.237478 7.49374 0.237478 7.49374 0.237478 7.52812L2.36873 13.9562C2.50623 14.4375 2.9531 14.7812 3.46873 14.7812H12.9562C14.2281 14.7812 15.3281 13.8187 15.5 12.5469L16.9437 2.26874C16.9437 2.19999 17.0125 2.16562 17.0812 2.16562H18.9375C19.35 2.16562 19.7281 1.82187 19.7281 1.37499C19.7281 0.928119 19.4187 0.618744 19.0062 0.618744ZM14.0219 12.3062C13.9531 12.8219 13.5062 13.2 12.9906 13.2H3.7781L1.92185 7.56249H14.7094L14.0219 12.3062Z'
|
||||
fill=''
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 flex items-end justify-between'>
|
||||
<div>
|
||||
<h4 className='text-title-md font-bold text-black dark:text-white'>${dailyStats?.totalRevenue}</h4>
|
||||
<span className='text-sm font-medium'>Total Revenue</span>
|
||||
</div>
|
||||
|
||||
<span className='flex items-center gap-1 text-sm font-medium text-meta-3'>
|
||||
{isLoading ? '...' : !!deltaPercentage ? deltaPercentage + '%' : '-'}
|
||||
{!!deltaPercentage ? isDeltaPositive ? <UpArrow /> : <DownArrow /> : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotalRevenueCard;
|
@ -0,0 +1,57 @@
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '../../../client/cn';
|
||||
import { UpArrow } from '../../../client/icons/icons-arrows';
|
||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
||||
|
||||
const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
||||
const isDeltaPositive = useMemo(() => {
|
||||
return !!dailyStats?.userDelta && dailyStats.userDelta > 0;
|
||||
}, [dailyStats]);
|
||||
|
||||
return (
|
||||
<div className='rounded-sm border border-stroke bg-white py-6 px-7.5 shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-meta-2 dark:bg-meta-4'>
|
||||
<svg
|
||||
className='fill-primary dark:fill-white'
|
||||
width='22'
|
||||
height='18'
|
||||
viewBox='0 0 22 18'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M7.18418 8.03751C9.31543 8.03751 11.0686 6.35313 11.0686 4.25626C11.0686 2.15938 9.31543 0.475006 7.18418 0.475006C5.05293 0.475006 3.2998 2.15938 3.2998 4.25626C3.2998 6.35313 5.05293 8.03751 7.18418 8.03751ZM7.18418 2.05626C8.45605 2.05626 9.52168 3.05313 9.52168 4.29063C9.52168 5.52813 8.49043 6.52501 7.18418 6.52501C5.87793 6.52501 4.84668 5.52813 4.84668 4.29063C4.84668 3.05313 5.9123 2.05626 7.18418 2.05626Z'
|
||||
fill=''
|
||||
/>
|
||||
<path
|
||||
d='M15.8124 9.6875C17.6687 9.6875 19.1468 8.24375 19.1468 6.42188C19.1468 4.6 17.6343 3.15625 15.8124 3.15625C13.9905 3.15625 12.478 4.6 12.478 6.42188C12.478 8.24375 13.9905 9.6875 15.8124 9.6875ZM15.8124 4.7375C16.8093 4.7375 17.5999 5.49375 17.5999 6.45625C17.5999 7.41875 16.8093 8.175 15.8124 8.175C14.8155 8.175 14.0249 7.41875 14.0249 6.45625C14.0249 5.49375 14.8155 4.7375 15.8124 4.7375Z'
|
||||
fill=''
|
||||
/>
|
||||
<path
|
||||
d='M15.9843 10.0313H15.6749C14.6437 10.0313 13.6468 10.3406 12.7874 10.8563C11.8593 9.61876 10.3812 8.79376 8.73115 8.79376H5.67178C2.85303 8.82814 0.618652 11.0625 0.618652 13.8469V16.3219C0.618652 16.975 1.13428 17.4906 1.7874 17.4906H20.2468C20.8999 17.4906 21.4499 16.9406 21.4499 16.2875V15.4625C21.4155 12.4719 18.9749 10.0313 15.9843 10.0313ZM2.16553 15.9438V13.8469C2.16553 11.9219 3.74678 10.3406 5.67178 10.3406H8.73115C10.6562 10.3406 12.2374 11.9219 12.2374 13.8469V15.9438H2.16553V15.9438ZM19.8687 15.9438H13.7499V13.8469C13.7499 13.2969 13.6468 12.7469 13.4749 12.2313C14.0937 11.7844 14.8499 11.5781 15.6405 11.5781H15.9499C18.0812 11.5781 19.8343 13.3313 19.8343 15.4625V15.9438H19.8687Z'
|
||||
fill=''
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 flex items-end justify-between'>
|
||||
<div>
|
||||
<h4 className='text-title-md font-bold text-black dark:text-white'>{dailyStats?.userCount}</h4>
|
||||
<span className='text-sm font-medium'>Total Signups</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
||||
'text-meta-3': isDeltaPositive,
|
||||
'text-meta-5': !isDeltaPositive,
|
||||
})}
|
||||
>
|
||||
{isLoading ? '...' : isDeltaPositive ? dailyStats?.userDelta : '-'}
|
||||
{!!dailyStats && isDeltaPositive && <UpArrow />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotalSignupsCard;
|
@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '../../../client/cn';
|
||||
|
||||
const DropdownDefault = () => {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const trigger = useRef<any>(null);
|
||||
const dropdown = useRef<any>(null);
|
||||
|
||||
// close on click outside
|
||||
useEffect(() => {
|
||||
const clickHandler = ({ target }: MouseEvent) => {
|
||||
if (!dropdown.current) return;
|
||||
if (!dropdownOpen || dropdown.current.contains(target) || trigger.current.contains(target)) return;
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
document.addEventListener('click', clickHandler);
|
||||
return () => document.removeEventListener('click', clickHandler);
|
||||
});
|
||||
|
||||
// close if the esc key is pressed
|
||||
useEffect(() => {
|
||||
const keyHandler = ({ keyCode }: KeyboardEvent) => {
|
||||
if (!dropdownOpen || keyCode !== 27) return;
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', keyHandler);
|
||||
return () => document.removeEventListener('keydown', keyHandler);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<button ref={trigger} onClick={() => setDropdownOpen(!dropdownOpen)}>
|
||||
<svg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='M2.25 11.25C3.49264 11.25 4.5 10.2426 4.5 9C4.5 7.75736 3.49264 6.75 2.25 6.75C1.00736 6.75 0 7.75736 0 9C0 10.2426 1.00736 11.25 2.25 11.25Z'
|
||||
fill='#98A6AD'
|
||||
/>
|
||||
<path
|
||||
d='M9 11.25C10.2426 11.25 11.25 10.2426 11.25 9C11.25 7.75736 10.2426 6.75 9 6.75C7.75736 6.75 6.75 7.75736 6.75 9C6.75 10.2426 7.75736 11.25 9 11.25Z'
|
||||
fill='#98A6AD'
|
||||
/>
|
||||
<path
|
||||
d='M15.75 11.25C16.9926 11.25 18 10.2426 18 9C18 7.75736 16.9926 6.75 15.75 6.75C14.5074 6.75 13.5 7.75736 13.5 9C13.5 10.2426 14.5074 11.25 15.75 11.25Z'
|
||||
fill='#98A6AD'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
ref={dropdown}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
onBlur={() => setDropdownOpen(false)}
|
||||
className={cn(
|
||||
'absolute right-0 top-full z-40 w-40 space-y-1 rounded-sm border border-stroke bg-white p-1.5 shadow-default dark:border-strokedark dark:bg-boxdark',
|
||||
{
|
||||
block: dropdownOpen,
|
||||
hidden: !dropdownOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<button className='flex w-full items-center gap-2 rounded-sm py-1.5 px-4 text-left text-sm hover:bg-gray dark:hover:bg-meta-4'>
|
||||
<svg
|
||||
className='fill-current'
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 16 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<g clipPath='url(#clip0_62_9787)'>
|
||||
<path
|
||||
d='M15.55 2.97499C15.55 2.77499 15.475 2.57499 15.325 2.42499C15.025 2.12499 14.725 1.82499 14.45 1.52499C14.175 1.24999 13.925 0.974987 13.65 0.724987C13.525 0.574987 13.375 0.474986 13.175 0.449986C12.95 0.424986 12.75 0.474986 12.575 0.624987L10.875 2.32499H2.02495C1.17495 2.32499 0.449951 3.02499 0.449951 3.89999V14C0.449951 14.85 1.14995 15.575 2.02495 15.575H12.15C13 15.575 13.725 14.875 13.725 14V5.12499L15.35 3.49999C15.475 3.34999 15.55 3.17499 15.55 2.97499ZM8.19995 8.99999C8.17495 9.02499 8.17495 9.02499 8.14995 9.02499L6.34995 9.62499L6.94995 7.82499C6.94995 7.79999 6.97495 7.79999 6.97495 7.77499L11.475 3.27499L12.725 4.49999L8.19995 8.99999ZM12.575 14C12.575 14.25 12.375 14.45 12.125 14.45H2.02495C1.77495 14.45 1.57495 14.25 1.57495 14V3.87499C1.57495 3.62499 1.77495 3.42499 2.02495 3.42499H9.72495L6.17495 6.99999C6.04995 7.12499 5.92495 7.29999 5.87495 7.49999L4.94995 10.3C4.87495 10.5 4.92495 10.675 5.02495 10.85C5.09995 10.95 5.24995 11.1 5.52495 11.1H5.62495L8.49995 10.15C8.67495 10.1 8.84995 9.97499 8.97495 9.84999L12.575 6.24999V14ZM13.5 3.72499L12.25 2.49999L13.025 1.72499C13.225 1.92499 14.05 2.74999 14.25 2.97499L13.5 3.72499Z'
|
||||
fill=''
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_62_9787'>
|
||||
<rect width='16' height='16' fill='white' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<button className='flex w-full items-center gap-2 rounded-sm py-1.5 px-4 text-left text-sm hover:bg-gray dark:hover:bg-meta-4'>
|
||||
<svg
|
||||
className='fill-current'
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 16 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M12.225 2.20005H10.3V1.77505C10.3 1.02505 9.70005 0.425049 8.95005 0.425049H7.02505C6.27505 0.425049 5.67505 1.02505 5.67505 1.77505V2.20005H3.75005C3.02505 2.20005 2.42505 2.80005 2.42505 3.52505V4.27505C2.42505 4.82505 2.75005 5.27505 3.22505 5.47505L3.62505 13.75C3.67505 14.775 4.52505 15.575 5.55005 15.575H10.4C11.425 15.575 12.275 14.775 12.325 13.75L12.75 5.45005C13.225 5.25005 13.55 4.77505 13.55 4.25005V3.50005C13.55 2.80005 12.95 2.20005 12.225 2.20005ZM6.82505 1.77505C6.82505 1.65005 6.92505 1.55005 7.05005 1.55005H8.97505C9.10005 1.55005 9.20005 1.65005 9.20005 1.77505V2.20005H6.85005V1.77505H6.82505ZM3.57505 3.52505C3.57505 3.42505 3.65005 3.32505 3.77505 3.32505H12.225C12.325 3.32505 12.425 3.40005 12.425 3.52505V4.27505C12.425 4.37505 12.35 4.47505 12.225 4.47505H3.77505C3.67505 4.47505 3.57505 4.40005 3.57505 4.27505V3.52505V3.52505ZM10.425 14.45H5.57505C5.15005 14.45 4.80005 14.125 4.77505 13.675L4.40005 5.57505H11.625L11.25 13.675C11.2 14.1 10.85 14.45 10.425 14.45Z'
|
||||
fill=''
|
||||
/>
|
||||
<path
|
||||
d='M8.00005 8.1001C7.70005 8.1001 7.42505 8.3501 7.42505 8.6751V11.8501C7.42505 12.1501 7.67505 12.4251 8.00005 12.4251C8.30005 12.4251 8.57505 12.1751 8.57505 11.8501V8.6751C8.57505 8.3501 8.30005 8.1001 8.00005 8.1001Z'
|
||||
fill=''
|
||||
/>
|
||||
<path
|
||||
d='M9.99994 8.60004C9.67494 8.57504 9.42494 8.80004 9.39994 9.12504L9.24994 11.325C9.22494 11.625 9.44994 11.9 9.77494 11.925C9.79994 11.925 9.79994 11.925 9.82494 11.925C10.1249 11.925 10.3749 11.7 10.3749 11.4L10.5249 9.20004C10.5249 8.87504 10.2999 8.62504 9.99994 8.60004Z'
|
||||
fill=''
|
||||
/>
|
||||
<path
|
||||
d='M5.97497 8.60004C5.67497 8.62504 5.42497 8.90004 5.44997 9.20004L5.62497 11.4C5.64997 11.7 5.89997 11.925 6.17497 11.925C6.19997 11.925 6.19997 11.925 6.22497 11.925C6.52497 11.9 6.77497 11.625 6.74997 11.325L6.57497 9.12504C6.57497 8.80004 6.29997 8.57504 5.97497 8.60004Z'
|
||||
fill=''
|
||||
/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownDefault;
|
@ -0,0 +1,56 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import { useState, useEffect } from 'react';
|
||||
import UsersTable from './UsersTable';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin';
|
||||
|
||||
const Users = ({ user }: { user: AuthUser }) => {
|
||||
const [isDemoInfoVisible, setIsDemoInfoVisible] = useState(false);
|
||||
useRedirectHomeUnlessUserIsAdmin({user});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (localStorage.getItem('isDemoInfoVisible') === 'false') {
|
||||
// do nothing
|
||||
} else {
|
||||
setIsDemoInfoVisible(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDemoInfoClose = () => {
|
||||
try {
|
||||
localStorage.setItem('isDemoInfoVisible', 'false');
|
||||
setIsDemoInfoVisible(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
{/* Floating Demo Announcement */}
|
||||
{isDemoInfoVisible && (
|
||||
<div className='fixed z-999 bottom-0 mb-2 left-1/2 -translate-x-1/2 lg:mb-4 bg-gray-700 rounded-full px-3.5 py-2 text-sm text-white duration-300 ease-in-out hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-600'>
|
||||
<div className='px-4 flex flex-row gap-2 items-center my-1'>
|
||||
<span className='text-gray-100'>
|
||||
You are viewing mock user data only ;)
|
||||
</span>
|
||||
<button className=' pl-2.5 text-gray-400 text-xl font-bold' onClick={() => handleDemoInfoClose()}>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Breadcrumb pageName='Users' />
|
||||
<div className='flex flex-col gap-10'>
|
||||
<UsersTable />
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
255
opensaas-sh/app/src/admin/dashboards/users/UsersTable.tsx
Normal file
255
opensaas-sh/app/src/admin/dashboards/users/UsersTable.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
import { SubscriptionStatus } from '../../../payment/plans';
|
||||
import { useQuery, getPaginatedUsers } from 'wasp/client/operations';
|
||||
import { useState, useEffect } from 'react';
|
||||
import SwitcherOne from '../../elements/forms/SwitcherOne';
|
||||
import LoadingSpinner from '../../layout/LoadingSpinner';
|
||||
import DropdownEditDelete from './DropdownEditDelete';
|
||||
import { updateIsUserAdminById } from 'wasp/client/operations';
|
||||
import { type User } from 'wasp/entities';
|
||||
|
||||
function AdminSwitch({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) {
|
||||
return (
|
||||
<SwitcherOne isOn={isAdmin} onChange={(value) => updateIsUserAdminById({ id: id, isAdmin: value })} />
|
||||
);
|
||||
}
|
||||
|
||||
const UsersTable = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [emailFilter, setEmailFilter] = useState<string | undefined>(undefined);
|
||||
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
|
||||
const [subscriptionStatusFilter, setSubcriptionStatusFilter] = useState<Array<SubscriptionStatus | null>>(
|
||||
[]
|
||||
);
|
||||
|
||||
const skipPages = currentPage - 1;
|
||||
|
||||
const { data, isLoading } = useQuery(getPaginatedUsers, {
|
||||
skipPages,
|
||||
filter: {
|
||||
...(emailFilter && { emailContains: emailFilter }),
|
||||
...(isAdminFilter !== undefined && { isAdmin: isAdminFilter }),
|
||||
...(subscriptionStatusFilter.length > 0 && { subscriptionStatusIn: subscriptionStatusFilter }),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(
|
||||
function backToPageOne() {
|
||||
setCurrentPage(1);
|
||||
},
|
||||
[emailFilter, subscriptionStatusFilter, isAdminFilter]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='flex-col flex items-start justify-between p-6 gap-3 w-full bg-gray-100/40 dark:bg-gray-700/50'>
|
||||
<span className='text-sm font-medium'>Filters:</span>
|
||||
<div className='flex items-center justify-between gap-3 w-full px-2'>
|
||||
<div className='relative flex items-center gap-3 '>
|
||||
<label htmlFor='email-filter' className='block text-sm text-gray-700 dark:text-white'>
|
||||
email:
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='email-filter'
|
||||
placeholder='dude@example.com'
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setEmailFilter(value === '' ? undefined : value);
|
||||
}}
|
||||
className='rounded border border-stroke py-2 px-5 bg-white outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
<label htmlFor='status-filter' className='block text-sm ml-2 text-gray-700 dark:text-white'>
|
||||
status:
|
||||
</label>
|
||||
<div className='flex-grow relative z-20 rounded border border-stroke pr-8 outline-none bg-white transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
|
||||
<div className='flex items-center'>
|
||||
{subscriptionStatusFilter.length > 0 ? (
|
||||
subscriptionStatusFilter.map((opt) => (
|
||||
<span
|
||||
key={opt}
|
||||
className='z-30 flex items-center my-1 mx-2 py-1 px-2 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
>
|
||||
{opt ? opt : 'has not subscribed'}
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSubcriptionStatusFilter((prevValue) => {
|
||||
return prevValue?.filter((val) => val !== opt);
|
||||
});
|
||||
}}
|
||||
className='z-30 cursor-pointer pl-2 hover:text-danger'
|
||||
>
|
||||
<XIcon />
|
||||
</span>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className='bg-white text-gray-500 py-2 px-5 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'>
|
||||
Select Status Filters
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const selectedValue = e.target.value == 'has_not_subscribed' ? null : e.target.value;
|
||||
|
||||
console.log(selectedValue);
|
||||
if (selectedValue === 'clear-all') {
|
||||
setSubcriptionStatusFilter([]);
|
||||
} else {
|
||||
setSubcriptionStatusFilter((prevValue) => {
|
||||
if (prevValue.includes(selectedValue as SubscriptionStatus)) {
|
||||
return prevValue.filter((val) => val !== selectedValue);
|
||||
} else {
|
||||
return [...prevValue, selectedValue as SubscriptionStatus];
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
name='status-filter'
|
||||
id='status-filter'
|
||||
className='absolute top-0 left-0 z-20 h-full w-full bg-white opacity-0'
|
||||
>
|
||||
<option value='select-filters'>Select filters</option>
|
||||
{[...Object.values(SubscriptionStatus), null]
|
||||
.filter((status) => !subscriptionStatusFilter.includes(status))
|
||||
.map((status) => {
|
||||
const extendedStatus = status ?? 'has_not_subscribed'
|
||||
return <option key={extendedStatus} value={extendedStatus}>
|
||||
{extendedStatus}
|
||||
</option>
|
||||
})}
|
||||
</select>
|
||||
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
|
||||
<ChevronDownIcon />
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<label htmlFor='isAdmin-filter' className='block text-sm ml-2 text-gray-700 dark:text-white'>
|
||||
isAdmin:
|
||||
</label>
|
||||
<select
|
||||
name='isAdmin-filter'
|
||||
onChange={(e) => {
|
||||
if (e.target.value === 'both') {
|
||||
setIsAdminFilter(undefined);
|
||||
} else {
|
||||
setIsAdminFilter(e.target.value === 'true');
|
||||
}
|
||||
}}
|
||||
className='relative z-20 w-full appearance-none rounded border border-stroke bg-white p-2 pl-4 pr-8 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'
|
||||
>
|
||||
<option value='both'>both</option>
|
||||
<option value='true'>true</option>
|
||||
<option value='false'>false</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
<div className='max-w-60'>
|
||||
<span className='text-md mr-2 text-black dark:text-white'>page</span>
|
||||
<input
|
||||
type='number'
|
||||
min={1}
|
||||
defaultValue={currentPage}
|
||||
max={data?.totalPages}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.currentTarget.value);
|
||||
if (data?.totalPages && value <= data?.totalPages && value > 0) {
|
||||
setCurrentPage(value);
|
||||
}
|
||||
}}
|
||||
className='rounded-md border-1 border-stroke bg-transparent px-4 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
<span className='text-md text-black dark:text-white'> / {data?.totalPages} </span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-9 border-t-4 border-stroke py-4.5 px-4 dark:border-strokedark md:px-6 '>
|
||||
<div className='col-span-3 flex items-center'>
|
||||
<p className='font-medium'>Email / Username</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='font-medium'>Subscription Status</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='font-medium'>Stripe ID</p>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<p className='font-medium'>Is Admin</p>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<p className='font-medium'></p>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className='-mt-40'>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
{!!data?.users &&
|
||||
data?.users?.length > 0 &&
|
||||
data.users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className='grid grid-cols-9 gap-4 border-t border-stroke py-4.5 px-4 dark:border-strokedark md:px-6 '
|
||||
>
|
||||
<div className='col-span-3 flex items-center'>
|
||||
<div className='flex flex-col gap-1 '>
|
||||
<p className='text-sm text-black dark:text-white'>{user.email}</p>
|
||||
<p className='text-sm text-black dark:text-white'>{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='text-sm text-black dark:text-white'>{user.subscriptionStatus}</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='text-sm text-meta-3'>{user.stripeId}</p>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<div className='text-sm text-black dark:text-white'>
|
||||
<AdminSwitch {...user} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<DropdownEditDelete />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function ChevronDownIcon() {
|
||||
return (
|
||||
<svg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g opacity='0.8'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M5.29289 8.29289C5.68342 7.90237 6.31658 7.90237 6.70711 8.29289L12 13.5858L17.2929 8.29289C17.6834 7.90237 18.3166 7.90237 18.7071 8.29289C19.0976 8.68342 19.0976 9.31658 18.7071 9.70711L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L5.29289 9.70711C4.90237 9.31658 4.90237 8.68342 5.29289 8.29289Z'
|
||||
fill='#637381'
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon() {
|
||||
return (
|
||||
<svg width='14' height='14' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M9.35355 3.35355C9.54882 3.15829 9.54882 2.84171 9.35355 2.64645C9.15829 2.45118 8.84171 2.45118 8.64645 2.64645L6 5.29289L3.35355 2.64645C3.15829 2.45118 2.84171 2.45118 2.64645 2.64645C2.45118 2.84171 2.45118 3.15829 2.64645 3.35355L5.29289 6L2.64645 8.64645C2.45118 8.84171 2.45118 9.15829 2.64645 9.35355C2.84171 9.54882 3.15829 9.54882 3.35355 9.35355L6 6.70711L8.64645 9.35355C8.84171 9.54882 9.15829 9.54882 9.35355 9.35355C9.54882 9.15829 9.54882 8.84171 9.35355 8.64645L6.70711 6L9.35355 3.35355Z'
|
||||
fill='currentColor'
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsersTable;
|
198
opensaas-sh/app/src/admin/elements/calendar/CalendarPage.tsx
Normal file
198
opensaas-sh/app/src/admin/elements/calendar/CalendarPage.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin';
|
||||
|
||||
const Calendar = ({ user }: { user: AuthUser }) => {
|
||||
useRedirectHomeUnlessUserIsAdmin({ user });
|
||||
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<Breadcrumb pageName='Calendar' />
|
||||
|
||||
{/* <!-- ====== Calendar Section Start ====== --> */}
|
||||
<div className='w-full max-w-full rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<table className='w-full'>
|
||||
<thead>
|
||||
<tr className='grid grid-cols-7 rounded-t-sm bg-primary text-white'>
|
||||
<th className='flex h-15 items-center justify-center rounded-tl-sm p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Sunday </span>
|
||||
<span className='block lg:hidden'> Sun </span>
|
||||
</th>
|
||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Monday </span>
|
||||
<span className='block lg:hidden'> Mon </span>
|
||||
</th>
|
||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Tuesday </span>
|
||||
<span className='block lg:hidden'> Tue </span>
|
||||
</th>
|
||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Wednesday </span>
|
||||
<span className='block lg:hidden'> Wed </span>
|
||||
</th>
|
||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Thursday </span>
|
||||
<span className='block lg:hidden'> Thur </span>
|
||||
</th>
|
||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Friday </span>
|
||||
<span className='block lg:hidden'> Fri </span>
|
||||
</th>
|
||||
<th className='flex h-15 items-center justify-center rounded-tr-sm p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Saturday </span>
|
||||
<span className='block lg:hidden'> Sat </span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* <!-- Line 1 --> */}
|
||||
<tr className='grid grid-cols-7'>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>1</span>
|
||||
<div className='group h-16 w-full flex-grow cursor-pointer py-1 md:h-30'>
|
||||
<span className='group-hover:text-primary md:hidden'>More</span>
|
||||
<div className='event invisible absolute left-2 z-99 mb-1 flex w-[200%] flex-col rounded-sm border-l-[3px] border-primary bg-gray px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 dark:bg-meta-4 md:visible md:w-[190%] md:opacity-100'>
|
||||
<span className='event-name text-sm font-semibold text-black dark:text-white'>
|
||||
Redesign Website
|
||||
</span>
|
||||
<span className='time text-sm font-medium text-black dark:text-white'>1 Dec - 2 Dec</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>2</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>3</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>4</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>5</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>6</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>7</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 1 --> */}
|
||||
{/* <!-- Line 2 --> */}
|
||||
<tr className='grid grid-cols-7'>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>8</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>9</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>10</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>11</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>12</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>13</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>14</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 2 --> */}
|
||||
{/* <!-- Line 3 --> */}
|
||||
<tr className='grid grid-cols-7'>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>15</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>16</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>17</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>18</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>19</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>20</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>21</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 3 --> */}
|
||||
{/* <!-- Line 4 --> */}
|
||||
<tr className='grid grid-cols-7'>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>22</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>23</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>24</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>25</span>
|
||||
<div className='group h-16 w-full flex-grow cursor-pointer py-1 md:h-30'>
|
||||
<span className='group-hover:text-primary md:hidden'>More</span>
|
||||
<div className='event invisible absolute left-2 z-99 mb-1 flex w-[300%] flex-col rounded-sm border-l-[3px] border-primary bg-gray px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 dark:bg-meta-4 md:visible md:w-[290%] md:opacity-100'>
|
||||
<span className='event-name text-sm font-semibold text-black dark:text-white'>App Design</span>
|
||||
<span className='time text-sm font-medium text-black dark:text-white'>25 Dec - 27 Dec</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>26</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>27</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>28</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 4 --> */}
|
||||
{/* <!-- Line 5 --> */}
|
||||
<tr className='grid grid-cols-7'>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>29</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>30</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>31</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>1</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>2</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>3</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-stroke p-2 transition duration-500 hover:bg-gray dark:border-strokedark dark:hover:bg-meta-4 md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium text-black dark:text-white'>4</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 5 --> */}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* <!-- ====== Calendar Section End ====== --> */}
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calendar;
|
138
opensaas-sh/app/src/admin/elements/charts/BarChart.tsx
Normal file
138
opensaas-sh/app/src/admin/elements/charts/BarChart.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
import React, { useState } from 'react';
|
||||
import ReactApexChart from 'react-apexcharts';
|
||||
|
||||
interface BarChartState {
|
||||
series: { data: number[] }[];
|
||||
}
|
||||
|
||||
const BarChart: React.FC = () => {
|
||||
const [state, setState] = useState<BarChartState>({
|
||||
series: [
|
||||
{
|
||||
data: [
|
||||
168, 385, 201, 298, 187, 195, 291, 110, 215, 390, 280, 112, 123, 212,
|
||||
270, 190, 310, 115, 90, 380, 112, 223, 292, 170, 290, 110, 115, 290,
|
||||
380, 312,
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const options: ApexOptions = {
|
||||
colors: ['#3C50E0'],
|
||||
chart: {
|
||||
fontFamily: 'Satoshi, sans-serif',
|
||||
type: 'bar',
|
||||
height: 350,
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: '55%',
|
||||
// endingShape: "rounded",
|
||||
borderRadius: 2,
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
stroke: {
|
||||
show: true,
|
||||
width: 4,
|
||||
colors: ['transparent'],
|
||||
},
|
||||
xaxis: {
|
||||
categories: [
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'10',
|
||||
'11',
|
||||
'12',
|
||||
'13',
|
||||
'14',
|
||||
'15',
|
||||
'16',
|
||||
'17',
|
||||
'18',
|
||||
'19',
|
||||
'20',
|
||||
'21',
|
||||
'22',
|
||||
'23',
|
||||
'24',
|
||||
'25',
|
||||
'26',
|
||||
'27',
|
||||
'28',
|
||||
'29',
|
||||
'30',
|
||||
],
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
horizontalAlign: 'left',
|
||||
fontFamily: 'inter',
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Visitors',
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
yaxis: {
|
||||
lines: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
opacity: 1,
|
||||
},
|
||||
tooltip: {
|
||||
x: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-span-12 rounded-sm border border-stroke bg-white px-5 pt-7.5 pb-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-black dark:text-white">
|
||||
Visitors Analytics
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<div id="chartFour" className="-ml-5">
|
||||
<ReactApexChart
|
||||
options={options}
|
||||
series={state.series}
|
||||
type="bar"
|
||||
height={350}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarChart;
|
27
opensaas-sh/app/src/admin/elements/charts/ChartsPage.tsx
Normal file
27
opensaas-sh/app/src/admin/elements/charts/ChartsPage.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import BarChart from './BarChart';
|
||||
import PieChart from './PieChart';
|
||||
import DataStats from './DataStatsChart';
|
||||
import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin';
|
||||
|
||||
const Chart = ({ user }: { user: AuthUser }) => {
|
||||
useRedirectHomeUnlessUserIsAdmin({ user });
|
||||
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<Breadcrumb pageName='Chart' />
|
||||
|
||||
<div className='grid grid-cols-12 gap-4 md:gap-6 2xl:gap-7.5'>
|
||||
<DataStats />
|
||||
<div className='col-span-12'>
|
||||
<BarChart />
|
||||
</div>
|
||||
<PieChart />
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chart;
|
102
opensaas-sh/app/src/admin/elements/charts/DataStatsChart.tsx
Normal file
102
opensaas-sh/app/src/admin/elements/charts/DataStatsChart.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
const DataStatsChart = () => {
|
||||
return (
|
||||
<div className="col-span-12 rounded-sm border border-stroke bg-white p-7.5 shadow-default dark:border-strokedark dark:bg-boxdark">
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-4 xl:gap-0">
|
||||
<div className="flex items-center justify-center gap-2 border-b border-stroke pb-5 dark:border-strokedark xl:border-b-0 xl:border-r xl:pb-0">
|
||||
<div>
|
||||
<h4 className="mb-0.5 text-xl font-semibold text-black dark:text-white md:text-title-lg">
|
||||
4,350
|
||||
</h4>
|
||||
<p className="text-sm font-medium">Unique Visitors</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
width="19"
|
||||
height="19"
|
||||
viewBox="0 0 19 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.25259 5.87281L4.22834 9.89706L3.16751 8.83623L9.00282 3.00092L14.8381 8.83623L13.7773 9.89705L9.75306 5.87281L9.75306 15.0046L8.25259 15.0046L8.25259 5.87281Z"
|
||||
fill="#10B981"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-meta-3">18%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 border-b border-stroke pb-5 dark:border-strokedark xl:border-b-0 xl:border-r xl:pb-0">
|
||||
<div>
|
||||
<h4 className="mb-0.5 text-xl font-semibold text-black dark:text-white md:text-title-lg">
|
||||
55.9K
|
||||
</h4>
|
||||
<p className="text-sm font-medium">Total Pageviews</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
width="19"
|
||||
height="19"
|
||||
viewBox="0 0 19 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.25259 5.87281L4.22834 9.89706L3.16751 8.83623L9.00282 3.00092L14.8381 8.83623L13.7773 9.89705L9.75306 5.87281L9.75306 15.0046L8.25259 15.0046L8.25259 5.87281Z"
|
||||
fill="#10B981"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-meta-3">25%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 border-b border-stroke pb-5 dark:border-strokedark sm:border-b-0 sm:pb-0 xl:border-r">
|
||||
<div>
|
||||
<h4 className="mb-0.5 text-xl font-semibold text-black dark:text-white md:text-title-lg">
|
||||
54%
|
||||
</h4>
|
||||
<p className="text-sm font-medium">Bounce Rate</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
width="19"
|
||||
height="19"
|
||||
viewBox="0 0 19 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.75302 12.1328L13.7773 8.10856L14.8381 9.16939L9.00279 15.0047L3.16748 9.16939L4.22831 8.10856L8.25256 12.1328V3.00098H9.75302V12.1328Z"
|
||||
fill="#F0950C"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-meta-8">7%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div>
|
||||
<h4 className="mb-0.5 text-xl font-semibold text-black dark:text-white md:text-title-lg">
|
||||
2m 56s
|
||||
</h4>
|
||||
<p className="text-sm font-medium">Visit Duration</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
width="19"
|
||||
height="19"
|
||||
viewBox="0 0 19 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.25259 5.87281L4.22834 9.89706L3.16751 8.83623L9.00282 3.00092L14.8381 8.83623L13.7773 9.89705L9.75306 5.87281L9.75306 15.0046L8.25259 15.0046L8.25259 5.87281Z"
|
||||
fill="#10B981"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-meta-3">12%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataStatsChart;
|
150
opensaas-sh/app/src/admin/elements/charts/PieChart.tsx
Normal file
150
opensaas-sh/app/src/admin/elements/charts/PieChart.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
import React, { useState } from 'react';
|
||||
import ReactApexChart from 'react-apexcharts';
|
||||
|
||||
interface PieChartState {
|
||||
series: number[];
|
||||
}
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'donut',
|
||||
},
|
||||
colors: ['#10B981', '#375E83', '#259AE6', '#FFA70B'],
|
||||
labels: ['Remote', 'Hybrid', 'Onsite', 'Leave'],
|
||||
legend: {
|
||||
show: true,
|
||||
position: 'bottom',
|
||||
},
|
||||
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '65%',
|
||||
background: 'transparent',
|
||||
},
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 2600,
|
||||
options: {
|
||||
chart: {
|
||||
width: 380,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
breakpoint: 640,
|
||||
options: {
|
||||
chart: {
|
||||
width: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const PieChart: React.FC = () => {
|
||||
const [state, setState] = useState<PieChartState>({
|
||||
series: [65, 34, 12, 56],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="col-span-12 rounded-sm border border-stroke bg-white px-5 pt-7.5 pb-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:col-span-5">
|
||||
<div className="mb-3 justify-between gap-4 sm:flex">
|
||||
<div>
|
||||
<h5 className="text-xl font-semibold text-black dark:text-white">
|
||||
Visitors Analytics
|
||||
</h5>
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative z-20 inline-block">
|
||||
<select
|
||||
name=""
|
||||
id=""
|
||||
className="relative z-20 inline-flex appearance-none bg-transparent py-1 pl-3 pr-8 text-sm font-medium outline-none"
|
||||
>
|
||||
<option value="">Monthly</option>
|
||||
<option value="">Yearly</option>
|
||||
</select>
|
||||
<span className="absolute top-1/2 right-3 z-10 -translate-y-1/2">
|
||||
<svg
|
||||
width="10"
|
||||
height="6"
|
||||
viewBox="0 0 10 6"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0.47072 1.08816C0.47072 1.02932 0.500141 0.955772 0.54427 0.911642C0.647241 0.808672 0.809051 0.808672 0.912022 0.896932L4.85431 4.60386C4.92785 4.67741 5.06025 4.67741 5.14851 4.60386L9.09079 0.896932C9.19376 0.793962 9.35557 0.808672 9.45854 0.911642C9.56151 1.01461 9.5468 1.17642 9.44383 1.27939L5.50155 4.98632C5.22206 5.23639 4.78076 5.23639 4.51598 4.98632L0.558981 1.27939C0.50014 1.22055 0.47072 1.16171 0.47072 1.08816Z"
|
||||
fill="#637381"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M1.22659 0.546578L5.00141 4.09604L8.76422 0.557869C9.08459 0.244537 9.54201 0.329403 9.79139 0.578788C10.112 0.899434 10.0277 1.36122 9.77668 1.61224L9.76644 1.62248L5.81552 5.33722C5.36257 5.74249 4.6445 5.7544 4.19352 5.32924C4.19327 5.32901 4.19377 5.32948 4.19352 5.32924L0.225953 1.61241C0.102762 1.48922 -4.20186e-08 1.31674 -3.20269e-08 1.08816C-2.40601e-08 0.905899 0.0780105 0.712197 0.211421 0.578787C0.494701 0.295506 0.935574 0.297138 1.21836 0.539529L1.22659 0.546578ZM4.51598 4.98632C4.78076 5.23639 5.22206 5.23639 5.50155 4.98632L9.44383 1.27939C9.5468 1.17642 9.56151 1.01461 9.45854 0.911642C9.35557 0.808672 9.19376 0.793962 9.09079 0.896932L5.14851 4.60386C5.06025 4.67741 4.92785 4.67741 4.85431 4.60386L0.912022 0.896932C0.809051 0.808672 0.647241 0.808672 0.54427 0.911642C0.500141 0.955772 0.47072 1.02932 0.47072 1.08816C0.47072 1.16171 0.50014 1.22055 0.558981 1.27939L4.51598 4.98632Z"
|
||||
fill="#637381"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<div id="chartThree" className="mx-auto flex justify-center">
|
||||
<ReactApexChart
|
||||
options={options}
|
||||
series={state.series}
|
||||
type="donut"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-mx-8 flex flex-wrap items-center justify-center gap-y-3">
|
||||
<div className="w-full px-8 sm:w-1/2">
|
||||
<div className="flex w-full items-center">
|
||||
<span className="mr-2 block h-3 w-full max-w-3 rounded-full bg-primary"></span>
|
||||
<p className="flex w-full justify-between text-sm font-medium text-black dark:text-white">
|
||||
<span> Desktop </span>
|
||||
<span> 65% </span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full px-8 sm:w-1/2">
|
||||
<div className="flex w-full items-center">
|
||||
<span className="mr-2 block h-3 w-full max-w-3 rounded-full bg-[#6577F3]"></span>
|
||||
<p className="flex w-full justify-between text-sm font-medium text-black dark:text-white">
|
||||
<span> Tablet </span>
|
||||
<span> 34% </span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full px-8 sm:w-1/2">
|
||||
<div className="flex w-full items-center">
|
||||
<span className="mr-2 block h-3 w-full max-w-3 rounded-full bg-[#8FD0EF]"></span>
|
||||
<p className="flex w-full justify-between text-sm font-medium text-black dark:text-white">
|
||||
<span> Mobile </span>
|
||||
<span> 45% </span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full px-8 sm:w-1/2">
|
||||
<div className="flex w-full items-center">
|
||||
<span className="mr-2 block h-3 w-full max-w-3 rounded-full bg-[#0FADCF]"></span>
|
||||
<p className="flex w-full justify-between text-sm font-medium text-black dark:text-white">
|
||||
<span> Unknown </span>
|
||||
<span> 12% </span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PieChart;
|
42
opensaas-sh/app/src/admin/elements/forms/CheckboxOne.tsx
Normal file
42
opensaas-sh/app/src/admin/elements/forms/CheckboxOne.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../client/cn';
|
||||
|
||||
const CheckboxOne = () => {
|
||||
const [isChecked, setIsChecked] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="checkboxLabelOne"
|
||||
className="flex cursor-pointer select-none items-center"
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="checkboxLabelOne"
|
||||
className="sr-only"
|
||||
onChange={() => {
|
||||
setIsChecked(!isChecked);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn('mr-4 flex h-5 w-5 items-center justify-center rounded-full border', {
|
||||
'border-primary': isChecked,
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={cn('h-2.5 w-2.5 rounded-full bg-transparent', {
|
||||
'!bg-primary': isChecked,
|
||||
})}
|
||||
>
|
||||
{' '}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
Checkbox Text
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckboxOne;
|
45
opensaas-sh/app/src/admin/elements/forms/CheckboxTwo.tsx
Normal file
45
opensaas-sh/app/src/admin/elements/forms/CheckboxTwo.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../client/cn';
|
||||
|
||||
const CheckboxTwo = () => {
|
||||
const [enabled, setEnabled] = useState<boolean>(false);
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor='checkboxLabelTwo' className='flex cursor-pointer text-sm text-gray-700 select-none items-center'>
|
||||
enabled:
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='checkboxLabelTwo'
|
||||
className='sr-only'
|
||||
onChange={() => {
|
||||
setEnabled(!enabled);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn('ml-2 flex h-5 w-5 items-center justify-center rounded border', {
|
||||
'border-primary bg-gray dark:bg-transparent': enabled,
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={cn('opacity-0', {
|
||||
'!opacity-100': enabled,
|
||||
})}
|
||||
>
|
||||
<svg width='11' height='8' viewBox='0 0 11 8' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='M10.0915 0.951972L10.0867 0.946075L10.0813 0.940568C9.90076 0.753564 9.61034 0.753146 9.42927 0.939309L4.16201 6.22962L1.58507 3.63469C1.40401 3.44841 1.11351 3.44879 0.932892 3.63584C0.755703 3.81933 0.755703 4.10875 0.932892 4.29224L0.932878 4.29225L0.934851 4.29424L3.58046 6.95832C3.73676 7.11955 3.94983 7.2 4.1473 7.2C4.36196 7.2 4.55963 7.11773 4.71406 6.9584L10.0468 1.60234C10.2436 1.4199 10.2421 1.1339 10.0915 0.951972ZM4.2327 6.30081L4.2317 6.2998C4.23206 6.30015 4.23237 6.30049 4.23269 6.30082L4.2327 6.30081Z'
|
||||
fill='#3056D3'
|
||||
stroke='#3056D3'
|
||||
strokeWidth='0.4'
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckboxTwo;
|
290
opensaas-sh/app/src/admin/elements/forms/FormElementsPage.tsx
Normal file
290
opensaas-sh/app/src/admin/elements/forms/FormElementsPage.tsx
Normal file
@ -0,0 +1,290 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import CheckboxOne from './CheckboxOne';
|
||||
import SwitcherTwo from './SwitcherTwo';
|
||||
import SwitcherOne from './SwitcherOne';
|
||||
import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin';
|
||||
import { useState } from 'react';
|
||||
|
||||
const FormElements = ({ user }: { user: AuthUser }) => {
|
||||
useRedirectHomeUnlessUserIsAdmin({ user });
|
||||
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<Breadcrumb pageName='FormElements' />
|
||||
|
||||
<div className='grid grid-cols-1 gap-9 sm:grid-cols-2'>
|
||||
<div className='flex flex-col gap-9'>
|
||||
{/* <!-- Input Fields --> */}
|
||||
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='border-b border-stroke py-4 px-6.5 dark:border-strokedark'>
|
||||
<h3 className='font-medium text-black dark:text-white'>Input Fields</h3>
|
||||
</div>
|
||||
<div className='flex flex-col gap-5.5 p-6.5'>
|
||||
<div>
|
||||
<label className='mb-3 block text-black dark:text-white'>Default Input</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Default Input'
|
||||
className='w-full rounded-lg border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-3 block text-black dark:text-white'>Active Input</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Active Input'
|
||||
className='w-full rounded-lg border-[1.5px] border-primary bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:bg-form-input'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-3 block font-medium text-black dark:text-white'>Disabled label</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Disabled label'
|
||||
disabled
|
||||
className='w-full rounded-lg border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary dark:disabled:bg-black'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <!-- Toggle switch input --> */}
|
||||
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='border-b border-stroke py-4 px-6.5 dark:border-strokedark'>
|
||||
<h3 className='font-medium text-black dark:text-white'>Toggle switch input</h3>
|
||||
</div>
|
||||
<SwitchExamples />
|
||||
</div>
|
||||
|
||||
{/* <!-- Time and date --> */}
|
||||
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='border-b border-stroke py-4 px-6.5 dark:border-strokedark'>
|
||||
<h3 className='font-medium text-black dark:text-white'>Time and date</h3>
|
||||
</div>
|
||||
<div className='flex flex-col gap-5.5 p-6.5'>
|
||||
<div>
|
||||
<label className='mb-3 block text-black dark:text-white'>Date picker</label>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='date'
|
||||
className='custom-input-date custom-input-date-1 w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-3 block text-black dark:text-white'>Select date</label>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='date'
|
||||
className='custom-input-date custom-input-date-2 w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <!-- File upload --> */}
|
||||
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='border-b border-stroke py-4 px-6.5 dark:border-strokedark'>
|
||||
<h3 className='font-medium text-black dark:text-white'>File upload</h3>
|
||||
</div>
|
||||
<div className='flex flex-col gap-5.5 p-6.5'>
|
||||
<div>
|
||||
<label className='mb-3 block text-black dark:text-white'>Attach file</label>
|
||||
<input
|
||||
type='file'
|
||||
className='w-full cursor-pointer rounded-lg border-[1.5px] border-stroke bg-transparent font-medium outline-none transition file:mr-5 file:border-collapse file:cursor-pointer file:border-0 file:border-r file:border-solid file:border-stroke file:bg-whiter file:py-3 file:px-5 file:hover:bg-primary file:hover:bg-opacity-10 focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:file:border-form-strokedark dark:file:bg-white/30 dark:file:text-white dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-3 block text-black dark:text-white'>Attach file</label>
|
||||
<input
|
||||
type='file'
|
||||
className='w-full rounded-md border border-stroke p-3 outline-none transition file:mr-4 file:rounded file:border-[0.5px] file:border-stroke file:bg-[#EEEEEE] file:py-1 file:px-2.5 file:text-sm file:font-medium focus:border-primary file:focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:file:border-strokedark dark:file:bg-white/30 dark:file:text-white'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-9'>
|
||||
{/* <!-- Textarea Fields --> */}
|
||||
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='border-b border-stroke py-4 px-6.5 dark:border-strokedark'>
|
||||
<h3 className='font-medium text-black dark:text-white'>Textarea Fields</h3>
|
||||
</div>
|
||||
<div className='flex flex-col gap-5.5 p-6.5'>
|
||||
<div>
|
||||
<label className='mb-3 block text-black dark:text-white'>Default textarea</label>
|
||||
<textarea
|
||||
rows={6}
|
||||
placeholder='Default textarea'
|
||||
className='w-full rounded-lg border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-3 block text-black dark:text-white'>Active textarea</label>
|
||||
<textarea
|
||||
rows={6}
|
||||
placeholder='Active textarea'
|
||||
className='w-full rounded-lg border-[1.5px] border-primary bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:bg-form-input'
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-3 block text-black dark:text-white'>Disabled textarea</label>
|
||||
<textarea
|
||||
rows={6}
|
||||
disabled
|
||||
placeholder='Disabled textarea'
|
||||
className='w-full rounded-lg border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary dark:disabled:bg-black'
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <!-- Checkbox and radio --> */}
|
||||
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='border-b border-stroke py-4 px-6.5 dark:border-strokedark'>
|
||||
<h3 className='font-medium text-black dark:text-white'>Checkbox and radio</h3>
|
||||
</div>
|
||||
<div className='flex flex-col gap-5.5 p-6.5'>
|
||||
<CheckboxOne />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <!-- Select input --> */}
|
||||
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='border-b border-stroke py-4 px-6.5 dark:border-strokedark'>
|
||||
<h3 className='font-medium text-black dark:text-white'>Select input</h3>
|
||||
</div>
|
||||
<div className='flex flex-col gap-5.5 p-6.5'>
|
||||
<div>
|
||||
<label className='mb-3 block text-black dark:text-white'>Select Country</label>
|
||||
<div className='relative z-20 bg-white dark:bg-form-input'>
|
||||
<span className='absolute top-1/2 left-4 z-30 -translate-y-1/2'>
|
||||
<GlobeIcon />
|
||||
</span>
|
||||
<select className='relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 px-12 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
|
||||
<option value=''>USA</option>
|
||||
<option value=''>UK</option>
|
||||
<option value=''>Canada</option>
|
||||
</select>
|
||||
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
|
||||
<ChevronDownIcon />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-3 block text-black dark:text-white'>Multiselect Dropdown</label>
|
||||
<div className='relative z-20 w-full rounded border border-stroke p-1.5 pr-8 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
|
||||
<div className='flex flex-wrap items-center'>
|
||||
<span className='m-1.5 flex items-center justify-center rounded border-[.5px] border-stroke bg-gray py-1.5 px-2.5 text-sm font-medium dark:border-strokedark dark:bg-white/30'>
|
||||
Design
|
||||
<span className='cursor-pointer pl-2 hover:text-danger'>
|
||||
<XIcon />
|
||||
</span>
|
||||
</span>
|
||||
<span className='m-1.5 flex items-center justify-center rounded border-[.5px] border-stroke bg-gray py-1.5 px-2.5 text-sm font-medium dark:border-strokedark dark:bg-white/30'>
|
||||
Development
|
||||
<span className='cursor-pointer pl-2 hover:text-danger'>
|
||||
<XIcon />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
name=''
|
||||
id=''
|
||||
className='absolute top-0 left-0 z-20 h-full w-full bg-transparent opacity-0'
|
||||
>
|
||||
<option value=''>Option</option>
|
||||
<option value=''>Option</option>
|
||||
</select>
|
||||
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
|
||||
<ChevronDownIcon />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
|
||||
function SwitchExamples() {
|
||||
const [isFirstOn, setIsFirstOn] = useState<boolean>(false);
|
||||
const [isSecondOn, setIsSecondOn] = useState<boolean>(false);
|
||||
return (
|
||||
<div className='flex flex-col gap-5.5 p-6.5'>
|
||||
<SwitcherOne isOn={isFirstOn} onChange={setIsFirstOn} />
|
||||
<SwitcherTwo isOn={isSecondOn} onChange={setIsSecondOn} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobeIcon() {
|
||||
return (
|
||||
<svg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g opacity='0.8'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M10.0007 2.50065C5.85852 2.50065 2.50065 5.85852 2.50065 10.0007C2.50065 14.1428 5.85852 17.5007 10.0007 17.5007C14.1428 17.5007 17.5007 14.1428 17.5007 10.0007C17.5007 5.85852 14.1428 2.50065 10.0007 2.50065ZM0.833984 10.0007C0.833984 4.93804 4.93804 0.833984 10.0007 0.833984C15.0633 0.833984 19.1673 4.93804 19.1673 10.0007C19.1673 15.0633 15.0633 19.1673 10.0007 19.1673C4.93804 19.1673 0.833984 15.0633 0.833984 10.0007Z'
|
||||
fill='#637381'
|
||||
></path>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M0.833984 9.99935C0.833984 9.53911 1.20708 9.16602 1.66732 9.16602H18.334C18.7942 9.16602 19.1673 9.53911 19.1673 9.99935C19.1673 10.4596 18.7942 10.8327 18.334 10.8327H1.66732C1.20708 10.8327 0.833984 10.4596 0.833984 9.99935Z'
|
||||
fill='#637381'
|
||||
></path>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M7.50084 10.0008C7.55796 12.5632 8.4392 15.0301 10.0006 17.0418C11.5621 15.0301 12.4433 12.5632 12.5005 10.0008C12.4433 7.43845 11.5621 4.97153 10.0007 2.95982C8.4392 4.97153 7.55796 7.43845 7.50084 10.0008ZM10.0007 1.66749L9.38536 1.10547C7.16473 3.53658 5.90275 6.69153 5.83417 9.98346C5.83392 9.99503 5.83392 10.0066 5.83417 10.0182C5.90275 13.3101 7.16473 16.4651 9.38536 18.8962C9.54325 19.069 9.76655 19.1675 10.0007 19.1675C10.2348 19.1675 10.4581 19.069 10.6159 18.8962C12.8366 16.4651 14.0986 13.3101 14.1671 10.0182C14.1674 10.0066 14.1674 9.99503 14.1671 9.98346C14.0986 6.69153 12.8366 3.53658 10.6159 1.10547L10.0007 1.66749Z'
|
||||
fill='#637381'
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronDownIcon() {
|
||||
return (
|
||||
<svg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g opacity='0.8'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M5.29289 8.29289C5.68342 7.90237 6.31658 7.90237 6.70711 8.29289L12 13.5858L17.2929 8.29289C17.6834 7.90237 18.3166 7.90237 18.7071 8.29289C19.0976 8.68342 19.0976 9.31658 18.7071 9.70711L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L5.29289 9.70711C4.90237 9.31658 4.90237 8.68342 5.29289 8.29289Z'
|
||||
fill='#637381'
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon() {
|
||||
return (
|
||||
<svg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M9.35355 3.35355C9.54882 3.15829 9.54882 2.84171 9.35355 2.64645C9.15829 2.45118 8.84171 2.45118 8.64645 2.64645L6 5.29289L3.35355 2.64645C3.15829 2.45118 2.84171 2.45118 2.64645 2.64645C2.45118 2.84171 2.45118 3.15829 2.64645 3.35355L5.29289 6L2.64645 8.64645C2.45118 8.84171 2.45118 9.15829 2.64645 9.35355C2.84171 9.54882 3.15829 9.54882 3.35355 9.35355L6 6.70711L8.64645 9.35355C8.84171 9.54882 9.15829 9.54882 9.35355 9.35355C9.54882 9.15829 9.54882 8.84171 9.35355 8.64645L6.70711 6L9.35355 3.35355Z'
|
||||
fill='currentColor'
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormElements;
|
230
opensaas-sh/app/src/admin/elements/forms/FormLayoutsPage.tsx
Normal file
230
opensaas-sh/app/src/admin/elements/forms/FormLayoutsPage.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin';
|
||||
|
||||
const FormLayout = ({ user }: { user: AuthUser }) => {
|
||||
useRedirectHomeUnlessUserIsAdmin({ user });
|
||||
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<Breadcrumb pageName='FormLayout' />
|
||||
|
||||
<div className='grid grid-cols-1 gap-9 sm:grid-cols-2'>
|
||||
<div className='flex flex-col gap-9'>
|
||||
{/* <!-- Contact Form --> */}
|
||||
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='border-b border-stroke py-4 px-6.5 dark:border-strokedark'>
|
||||
<h3 className='font-medium text-black dark:text-white'>Contact Form</h3>
|
||||
</div>
|
||||
<form action='#'>
|
||||
<div className='p-6.5'>
|
||||
<div className='mb-4.5 flex flex-col gap-6 xl:flex-row'>
|
||||
<div className='w-full xl:w-1/2'>
|
||||
<label className='mb-2.5 block text-black dark:text-white'>First name</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Enter your first name'
|
||||
className='w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-full xl:w-1/2'>
|
||||
<label className='mb-2.5 block text-black dark:text-white'>Last name</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Enter your last name'
|
||||
className='w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-4.5'>
|
||||
<label className='mb-2.5 block text-black dark:text-white'>
|
||||
Email <span className='text-meta-1'>*</span>
|
||||
</label>
|
||||
<input
|
||||
type='email'
|
||||
placeholder='Enter your email address'
|
||||
className='w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mb-4.5'>
|
||||
<label className='mb-2.5 block text-black dark:text-white'>Subject</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Select subject'
|
||||
className='w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mb-4.5'>
|
||||
<label className='mb-2.5 block text-black dark:text-white'>Subject</label>
|
||||
<div className='relative z-20 bg-transparent dark:bg-form-input'>
|
||||
<select className='relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 px-5 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'>
|
||||
<option value=''>Type your subject</option>
|
||||
<option value=''>USA</option>
|
||||
<option value=''>UK</option>
|
||||
<option value=''>Canada</option>
|
||||
</select>
|
||||
<span className='absolute top-1/2 right-4 z-30 -translate-y-1/2'>
|
||||
<svg
|
||||
className='fill-current'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<g opacity='0.8'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M5.29289 8.29289C5.68342 7.90237 6.31658 7.90237 6.70711 8.29289L12 13.5858L17.2929 8.29289C17.6834 7.90237 18.3166 7.90237 18.7071 8.29289C19.0976 8.68342 19.0976 9.31658 18.7071 9.70711L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L5.29289 9.70711C4.90237 9.31658 4.90237 8.68342 5.29289 8.29289Z'
|
||||
fill=''
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-6'>
|
||||
<label className='mb-2.5 block text-black dark:text-white'>Message</label>
|
||||
<textarea
|
||||
rows={6}
|
||||
placeholder='Type your message'
|
||||
className='w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button className='flex w-full justify-center rounded bg-primary p-3 font-medium text-gray'>
|
||||
Send Message
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-9'>
|
||||
{/* <!-- Sign In Form --> */}
|
||||
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='border-b border-stroke py-4 px-6.5 dark:border-strokedark'>
|
||||
<h3 className='font-medium text-black dark:text-white'>Sign In Form</h3>
|
||||
</div>
|
||||
<form action='#'>
|
||||
<div className='p-6.5'>
|
||||
<div className='mb-4.5'>
|
||||
<label className='mb-2.5 block text-black dark:text-white'>Email</label>
|
||||
<input
|
||||
type='email'
|
||||
placeholder='Enter your email address'
|
||||
className='w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2.5 block text-black dark:text-white'>Password</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='Enter password'
|
||||
className='w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-5 mb-5.5 flex items-center justify-between'>
|
||||
<label htmlFor='formCheckbox' className='flex cursor-pointer'>
|
||||
<div className='relative pt-0.5'>
|
||||
<input type='checkbox' id='formCheckbox' className='taskCheckbox sr-only' />
|
||||
<div className='box mr-3 flex h-5 w-5 items-center justify-center rounded border border-stroke dark:border-strokedark'>
|
||||
<span className='text-white opacity-0'>
|
||||
<svg
|
||||
className='fill-current'
|
||||
width='10'
|
||||
height='7'
|
||||
viewBox='0 0 10 7'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M9.70685 0.292804C9.89455 0.480344 10 0.734667 10 0.999847C10 1.26503 9.89455 1.51935 9.70685 1.70689L4.70059 6.7072C4.51283 6.89468 4.2582 7 3.9927 7C3.72721 7 3.47258 6.89468 3.28482 6.7072L0.281063 3.70701C0.0986771 3.5184 -0.00224342 3.26578 3.785e-05 3.00357C0.00231912 2.74136 0.10762 2.49053 0.29326 2.30511C0.4789 2.11969 0.730026 2.01451 0.992551 2.01224C1.25508 2.00996 1.50799 2.11076 1.69683 2.29293L3.9927 4.58607L8.29108 0.292804C8.47884 0.105322 8.73347 0 8.99896 0C9.26446 0 9.51908 0.105322 9.70685 0.292804Z'
|
||||
fill=''
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>Remember me</p>
|
||||
</label>
|
||||
|
||||
<a href='#' className='text-sm text-primary'>
|
||||
Forget password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button className='flex w-full justify-center rounded bg-primary p-3 font-medium text-gray'>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* <!-- Sign Up Form --> */}
|
||||
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='border-b border-stroke py-4 px-6.5 dark:border-strokedark'>
|
||||
<h3 className='font-medium text-black dark:text-white'>Sign Up Form</h3>
|
||||
</div>
|
||||
<form action='#'>
|
||||
<div className='p-6.5'>
|
||||
<div className='mb-4.5'>
|
||||
<label className='mb-2.5 block text-black dark:text-white'>Name</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Enter your full name'
|
||||
className='w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mb-4.5'>
|
||||
<label className='mb-2.5 block text-black dark:text-white'>Email</label>
|
||||
<input
|
||||
type='email'
|
||||
placeholder='Enter your email address'
|
||||
className='w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mb-4.5'>
|
||||
<label className='mb-2.5 block text-black dark:text-white'>Password</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='Enter password'
|
||||
className='w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mb-5.5'>
|
||||
<label className='mb-2.5 block text-black dark:text-white'>Re-type Password</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='Re-enter password'
|
||||
className='w-full rounded border-[1.5px] border-stroke bg-transparent py-3 px-5 font-medium outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className='flex w-full justify-center rounded bg-primary p-3 font-medium text-gray'>
|
||||
Sign Up
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormLayout;
|
30
opensaas-sh/app/src/admin/elements/forms/SwitcherOne.tsx
Normal file
30
opensaas-sh/app/src/admin/elements/forms/SwitcherOne.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useId } from 'react';
|
||||
import { cn } from '../../../client/cn';
|
||||
|
||||
function SwitcherOne({ isOn, onChange }: { isOn: boolean; onChange: (value: boolean) => void }) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<label htmlFor={id} className='flex cursor-pointer select-none items-center'>
|
||||
<div className='relative'>
|
||||
<input
|
||||
id={id}
|
||||
type='checkbox'
|
||||
className='sr-only'
|
||||
checked={isOn}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<div className='reblock h-8 w-14 rounded-full bg-meta-9 dark:bg-[#5A616B]'></div>
|
||||
<div
|
||||
className={cn('absolute left-1 top-1 h-6 w-6 rounded-full bg-white dark:bg-gray-400 transition', {
|
||||
'!right-1 !translate-x-full !bg-primary dark:!bg-white': isOn,
|
||||
})}
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SwitcherOne;
|
73
opensaas-sh/app/src/admin/elements/forms/SwitcherTwo.tsx
Normal file
73
opensaas-sh/app/src/admin/elements/forms/SwitcherTwo.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useId } from 'react';
|
||||
import { cn } from '../../../client/cn';
|
||||
|
||||
function SwitcherTwo({ isOn, onChange }: { isOn: boolean; onChange: (value: boolean) => void }) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className='flex cursor-pointer select-none items-center'>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id={id}
|
||||
className='sr-only'
|
||||
checked={isOn}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<div className='block h-8 w-14 rounded-full bg-meta-9 dark:bg-[#5A616B]'></div>
|
||||
<div
|
||||
className={cn(
|
||||
'dot absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-white transition',
|
||||
{
|
||||
'!right-1 !translate-x-full !bg-primary dark:!bg-white': isOn,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className={cn('hidden', { '!block': isOn })}>
|
||||
<CheckIcon />
|
||||
</span>
|
||||
<span className={cn({ hidden: isOn })}>
|
||||
<XIcon />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const XIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
className='h-4 w-4 stroke-current'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M6 18L18 6M6 6l12 12'></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const CheckIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
className='fill-white dark:fill-black'
|
||||
width='11'
|
||||
height='8'
|
||||
viewBox='0 0 11 8'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M10.0915 0.951972L10.0867 0.946075L10.0813 0.940568C9.90076 0.753564 9.61034 0.753146 9.42927 0.939309L4.16201 6.22962L1.58507 3.63469C1.40401 3.44841 1.11351 3.44879 0.932892 3.63584C0.755703 3.81933 0.755703 4.10875 0.932892 4.29224L0.932878 4.29225L0.934851 4.29424L3.58046 6.95832C3.73676 7.11955 3.94983 7.2 4.1473 7.2C4.36196 7.2 4.55963 7.11773 4.71406 6.9584L10.0468 1.60234C10.2436 1.4199 10.2421 1.1339 10.0915 0.951972ZM4.2327 6.30081L4.2317 6.2998C4.23206 6.30015 4.23237 6.30049 4.23269 6.30082L4.2327 6.30081Z'
|
||||
fill=''
|
||||
stroke=''
|
||||
strokeWidth='0.4'
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default SwitcherTwo;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user