├── .dockerignore
├── .env.example
├── .eslintrc.js
├── .github
└── workflows
│ └── docker-image.yml
├── .gitignore
├── .husky
└── pre-commit
├── .prettierignore
├── .prettierrc.js
├── LICENSE
├── README.md
├── apps
├── docs
│ ├── README.md
│ ├── _snippets
│ │ └── snippet-example.mdx
│ ├── api-reference
│ │ ├── endpoint
│ │ │ ├── changelog
│ │ │ │ ├── delete-projects-changelogs.mdx
│ │ │ │ ├── get-projects-changelogs.mdx
│ │ │ │ ├── patch-projects-changelogs.mdx
│ │ │ │ └── post-projects-changelogs.mdx
│ │ │ ├── feedback-comments
│ │ │ │ ├── delete-projects-feedback-comments.mdx
│ │ │ │ └── get-projects-feedback-comments.mdx
│ │ │ ├── feedback-tags
│ │ │ │ ├── delete-projects-feedback-tags.mdx
│ │ │ │ ├── get-projects-feedback-tags.mdx
│ │ │ │ └── post-projects-feedback-tags.mdx
│ │ │ ├── feedback
│ │ │ │ ├── delete-projects-feedback.mdx
│ │ │ │ ├── get-projects-feedback-all.mdx
│ │ │ │ ├── get-projects-feedback-upvotes.mdx
│ │ │ │ ├── get-projects-feedback.mdx
│ │ │ │ ├── patch-projects-feedback.mdx
│ │ │ │ └── post-projects-feedback.mdx
│ │ │ ├── project-config
│ │ │ │ ├── get-projects-config.mdx
│ │ │ │ └── patch-projects-config.mdx
│ │ │ ├── project-invites
│ │ │ │ ├── delete-projects-invites.mdx
│ │ │ │ ├── get-projects-invites.mdx
│ │ │ │ └── post-projects-invites.mdx
│ │ │ ├── project-members
│ │ │ │ └── get-projects-members.mdx
│ │ │ ├── project
│ │ │ │ ├── delete-projects.mdx
│ │ │ │ ├── get-projects.mdx
│ │ │ │ └── patch-projects.mdx
│ │ │ └── public
│ │ │ │ ├── get--atom.mdx
│ │ │ │ └── get--changelogs.mdx
│ │ └── introduction.mdx
│ ├── development
│ │ ├── configuration.mdx
│ │ └── installation.mdx
│ ├── images
│ │ └── hero-dark.svg
│ ├── integrations
│ │ └── sso.mdx
│ ├── introduction.mdx
│ ├── logo
│ │ ├── dark.svg
│ │ ├── favicon.svg
│ │ └── light.svg
│ ├── mint.json
│ └── self-hosting
│ │ ├── docker.mdx
│ │ └── vercel.mdx
└── web
│ ├── .eslintrc.js
│ ├── Dockerfile
│ ├── app
│ ├── [project]
│ │ ├── changelog
│ │ │ ├── [id]
│ │ │ │ ├── loading.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── loading.tsx
│ │ │ ├── page.tsx
│ │ │ └── unsubscribe
│ │ │ │ └── page.tsx
│ │ ├── feedback
│ │ │ ├── [id]
│ │ │ │ ├── loading.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── loading.tsx
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── api
│ │ └── v1
│ │ │ ├── [slug]
│ │ │ ├── atom
│ │ │ │ └── route.ts
│ │ │ ├── changelogs
│ │ │ │ ├── route.ts
│ │ │ │ └── subscribers
│ │ │ │ │ └── route.ts
│ │ │ ├── sso
│ │ │ │ └── route.ts
│ │ │ └── views
│ │ │ │ └── route.ts
│ │ │ ├── profile
│ │ │ ├── notifications
│ │ │ │ ├── [notificationId]
│ │ │ │ │ └── route.ts
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ │ ├── projects
│ │ │ ├── [slug]
│ │ │ │ ├── analytics
│ │ │ │ │ └── route.ts
│ │ │ │ ├── api-keys
│ │ │ │ │ ├── [id]
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── route.ts
│ │ │ │ ├── changelogs
│ │ │ │ │ ├── [id]
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ ├── route.ts
│ │ │ │ │ └── subscribers
│ │ │ │ │ │ └── route.ts
│ │ │ │ ├── config
│ │ │ │ │ ├── domain
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ ├── integrations
│ │ │ │ │ │ ├── discord
│ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ ├── slack
│ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ └── sso
│ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── route.ts
│ │ │ │ ├── feedback
│ │ │ │ │ ├── [feedbackId]
│ │ │ │ │ │ ├── comments
│ │ │ │ │ │ │ ├── [commentId]
│ │ │ │ │ │ │ │ ├── route.ts
│ │ │ │ │ │ │ │ └── upvote
│ │ │ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ ├── route.ts
│ │ │ │ │ │ └── upvotes
│ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ ├── export
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ ├── route.ts
│ │ │ │ │ └── tags
│ │ │ │ │ │ ├── [name]
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ └── route.ts
│ │ │ │ ├── invites
│ │ │ │ │ ├── [inviteId]
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── route.ts
│ │ │ │ ├── members
│ │ │ │ │ └── route.ts
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ │ ├── route.ts
│ │ │ └── waitlist
│ │ │ └── route.ts
│ ├── auth
│ │ └── callback
│ │ │ └── route.ts
│ ├── dash
│ │ ├── (auth)
│ │ │ ├── login
│ │ │ │ └── page.tsx
│ │ │ └── signup
│ │ │ │ └── page.tsx
│ │ ├── [slug]
│ │ │ ├── analytics
│ │ │ │ ├── loading.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── changelog
│ │ │ │ ├── loading.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── feedback
│ │ │ │ ├── loading.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx
│ │ │ └── settings
│ │ │ │ ├── general
│ │ │ │ ├── loading.tsx
│ │ │ │ └── page.tsx
│ │ │ │ ├── hub
│ │ │ │ ├── loading.tsx
│ │ │ │ └── page.tsx
│ │ │ │ ├── integrations
│ │ │ │ ├── loading.tsx
│ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── team
│ │ │ │ ├── loading.tsx
│ │ │ │ └── page.tsx
│ │ ├── invite
│ │ │ └── [inviteId]
│ │ │ │ └── page.tsx
│ │ └── page.tsx
│ ├── favicon.ico
│ ├── globals.css
│ ├── home
│ │ ├── (links)
│ │ │ ├── demo
│ │ │ │ └── page.tsx
│ │ │ ├── deploy-vercel
│ │ │ │ └── page.tsx
│ │ │ ├── discord
│ │ │ │ └── page.tsx
│ │ │ ├── docs
│ │ │ │ └── page.tsx
│ │ │ ├── github
│ │ │ │ └── page.tsx
│ │ │ └── twitter
│ │ │ │ └── page.tsx
│ │ └── (pages)
│ │ │ ├── deploy
│ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ ├── layout.tsx
│ └── robots.ts
│ ├── components
│ ├── dashboard
│ │ ├── analytics
│ │ │ ├── bar-list.tsx
│ │ │ ├── chart-cards.tsx
│ │ │ └── line-chart.tsx
│ │ ├── changelogs
│ │ │ ├── api-sheet.tsx
│ │ │ ├── changelog-list.tsx
│ │ │ ├── content-editor.tsx
│ │ │ ├── date-picker.tsx
│ │ │ └── image-upload.tsx
│ │ ├── feedback
│ │ │ ├── feedback-table.tsx
│ │ │ ├── header-buttons.tsx
│ │ │ ├── status-combobox.tsx
│ │ │ └── tag-combobox.tsx
│ │ ├── modals
│ │ │ ├── add-api-key-modal.tsx
│ │ │ ├── add-custom-theme-modal.tsx
│ │ │ ├── add-edit-changelog-modal.tsx
│ │ │ ├── add-member-modal.tsx
│ │ │ ├── add-project-modal.tsx
│ │ │ ├── add-sso-modal.tsx
│ │ │ ├── add-tag-modal.tsx
│ │ │ ├── add-waitlist-modal.tsx
│ │ │ ├── connect-discord-modal.tsx
│ │ │ ├── connect-slack-modal.tsx
│ │ │ ├── edit-profile-modal.tsx
│ │ │ ├── send-feedback-modal.tsx
│ │ │ └── view-feedback-modal.tsx
│ │ └── settings
│ │ │ ├── category-tabs.tsx
│ │ │ ├── general-cards.tsx
│ │ │ ├── hub-cards.tsx
│ │ │ ├── integration-cards.tsx
│ │ │ └── team-table.tsx
│ ├── home
│ │ ├── changelog-section.tsx
│ │ ├── dashboard-section.tsx
│ │ ├── feedback-section.tsx
│ │ ├── footer.tsx
│ │ ├── header.tsx
│ │ ├── home-content.tsx
│ │ └── spotlight-card.tsx
│ ├── hub
│ │ ├── analytics-wrapper.tsx
│ │ ├── feedback
│ │ │ ├── button-header.tsx
│ │ │ ├── comments
│ │ │ │ ├── comment.tsx
│ │ │ │ ├── comments-list.tsx
│ │ │ │ └── sort-combobox.tsx
│ │ │ └── feedback-list.tsx
│ │ ├── modals
│ │ │ ├── create-post-modal.tsx
│ │ │ ├── login-signup-modal.tsx
│ │ │ └── subscribe-email-modal.tsx
│ │ ├── nav-bar.tsx
│ │ └── theme-wrapper.tsx
│ ├── layout
│ │ ├── accept-invite-form.tsx
│ │ ├── inbox-popover.tsx
│ │ ├── nav-bar-mobile.tsx
│ │ ├── nav-tabs.tsx
│ │ ├── onboarding.tsx
│ │ ├── project-dropdown.tsx
│ │ ├── sidebar.tsx
│ │ ├── theme-button.tsx
│ │ ├── title-provider.tsx
│ │ └── unsubscribe-card.tsx
│ ├── logo-provider.tsx
│ ├── shared
│ │ ├── file-drop.tsx
│ │ ├── icons
│ │ │ ├── analytics-dark.json
│ │ │ ├── analytics-light.json
│ │ │ ├── calendar-dark.json
│ │ │ ├── calendar-light.json
│ │ │ ├── chat-dark.json
│ │ │ ├── chat-light.json
│ │ │ ├── code-dark.json
│ │ │ ├── code-light.json
│ │ │ ├── feedback-dark.json
│ │ │ ├── feedback-light.json
│ │ │ ├── icons-animated.ts
│ │ │ ├── icons-static.tsx
│ │ │ ├── logout-dark.json
│ │ │ ├── logout-light.json
│ │ │ ├── profile-dark.json
│ │ │ ├── profile-light.json
│ │ │ ├── search-dark.json
│ │ │ ├── search-light.json
│ │ │ ├── settings-dark.json
│ │ │ ├── settings-light.json
│ │ │ ├── tag-label-dark.json
│ │ │ └── tag-label-light.json
│ │ ├── lottie-player.tsx
│ │ ├── placeholder.css
│ │ ├── tiptap-editor.tsx
│ │ ├── tooltip-label.tsx
│ │ ├── tooltip.tsx
│ │ └── user-dropdown.tsx
│ ├── theme-provider.tsx
│ └── user-auth-form.tsx
│ ├── emails
│ ├── changelog-email.tsx
│ ├── index.ts
│ └── project-invite.tsx
│ ├── lib
│ ├── api
│ │ ├── changelogs.ts
│ │ ├── comments.ts
│ │ ├── feedback.ts
│ │ ├── integrations.ts
│ │ ├── invites.ts
│ │ ├── projects.ts
│ │ ├── public.ts
│ │ └── user.ts
│ ├── auth.ts
│ ├── constants.ts
│ ├── hooks
│ │ ├── use-create-query.ts
│ │ └── use-scroll.ts
│ ├── supabase.ts
│ ├── tinybird.ts
│ ├── types.ts
│ └── utils.ts
│ ├── middleware.ts
│ ├── next.config.js
│ ├── package.json
│ ├── postcss.config.js
│ ├── public
│ ├── icon-192x192.png
│ ├── icon-256x256.png
│ ├── icon-384x384.png
│ ├── icon-512x512.png
│ ├── manifest.json
│ └── og-image.png
│ ├── tailwind.config.js
│ └── tsconfig.json
├── package.json
├── packages
├── client
│ ├── .gitignore
│ ├── README.md
│ ├── lib
│ │ ├── fetch.ts
│ │ └── types.ts
│ ├── package.json
│ ├── src
│ │ └── index.ts
│ ├── tsconfig.json
│ └── tsup.config.ts
├── eslint-config-custom
│ ├── library.js
│ ├── next.js
│ ├── package.json
│ ├── react-internal.js
│ └── rules.js
├── tailwind-config
│ ├── package.json
│ └── tailwind.config.js
├── tinybird
│ ├── datasources
│ │ └── click_events.datasource
│ └── endpoints
│ │ ├── clicks.pipe
│ │ ├── timeseries.pipe
│ │ ├── top_changelogs.pipe
│ │ ├── top_feedback.pipe
│ │ └── visitors.pipe
├── tsconfig
│ ├── base.json
│ ├── nextjs.json
│ ├── package.json
│ └── react-library.json
└── ui
│ ├── .eslintrc.js
│ ├── components.json
│ ├── components
│ └── ui
│ │ ├── accordion.tsx
│ │ ├── alert-dialog.tsx
│ │ ├── alert.tsx
│ │ ├── avatar.tsx
│ │ ├── background
│ │ └── background.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── calendar.tsx
│ │ ├── card.tsx
│ │ ├── command.tsx
│ │ ├── dialog.tsx
│ │ ├── drawer.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── popover.tsx
│ │ ├── radio-group.tsx
│ │ ├── responsive-dialog.tsx
│ │ ├── select.tsx
│ │ ├── separator.tsx
│ │ ├── sheet.tsx
│ │ ├── skeleton.tsx
│ │ ├── switch.tsx
│ │ ├── tabs.tsx
│ │ ├── textarea.tsx
│ │ └── tooltip.tsx
│ ├── declarations.d.ts
│ ├── lib
│ ├── hooks
│ │ └── use-media-query.ts
│ └── utils.ts
│ ├── package.json
│ ├── styles
│ ├── Satoshi-Variable.woff2
│ ├── fonts.ts
│ └── globals.css
│ └── tsconfig.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── supabase
├── .gitignore
├── config.toml
└── migrations
│ ├── 20231126133700_db_auth_schema.sql
│ ├── 20231126133809_storage_schema.sql
│ ├── 20231130182016_api_key_system.sql
│ ├── 20240113153829_custom_theming.sql
│ ├── 20240118215535_notification_system.sql
│ └── 20240130194400_changelog_subscribers.sql
└── turbo.json
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/node_modules
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | #! Supabase Project URL & Anon Key
2 | # https://app.supabase.com/project/_/settings/api
3 | NEXT_PUBLIC_SUPABASE_URL=your-project-url
4 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
5 | SUPABASE_SERVICE_ROLE_KEY=your-service-key # Only required for the single sign-on integration
6 |
7 | #! Root domain of your site
8 | # Example: feedbase.app ( without https:// and trailing slash )
9 | NEXT_PUBLIC_ROOT_DOMAIN=your-root-domain
10 |
11 | #? Subdomain hosting
12 | SUBDOMAIN_HOSTING=false # Set to true if you are hosting on a subdomain of your root domain (e.g. feedbase.domain.com)
13 | DASHBOARD_SUBDOMAIN=feedbase # The subdomain on which you are hosting (e.g. feedbase.domain.com -> feedbase)
14 | CUSTOM_DOMAIN_WHITELIST=feedback.domain.com,feedback2.domain.com # A comma separated list of custom domains using the same root domain you are hosting on
15 |
16 | #? Resend Api Key
17 | # Only required for Email Notifications & Team Invites, can be left blank if not using these features
18 | # https://resend.com/docs/dashboard/api-keys/introduction
19 | RESEND_API_KEY=your-resend-api-key
20 |
21 | #? Github OAuth (Local development only)
22 | # https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app
23 | GITHUB_CLIENT_ID=your-client-id
24 | GITHUB_SECRET=your-github-secret
25 |
26 | #? Vercel
27 | # Only required for custom domain support, can be left blank if not using this feature
28 | # https://vercel.com/docs/custom-domains
29 | VERCEL_PROJECT_ID=your-project-id
30 | VERCEL_TEAM_ID=your-team-id # Only required if you're project is part of a team and not a personal account
31 | VERCEL_AUTH_TOKEN=your-auth-token
32 |
33 | #? Tinybird
34 | # Only required if you want to use Tinybird for hub data analytics
35 | TINYBIRD_API_URL=https://api.eu-central-1.aws.tinybird.co # Make sure to use the correct region
36 | TINYBIRD_API_KEY=your-api-key
37 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | // This tells ESLint to load the config from the package `eslint-config-custom`
4 | extends: ['custom/library'],
5 | settings: {
6 | next: {
7 | rootDir: ['apps/*/', 'packages/*/'],
8 | },
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image CI
2 |
3 | on:
4 | release:
5 | types: [published]
6 | branches: [ "main" ]
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v3
14 |
15 | - name: Check out the repo
16 | uses: actions/checkout@v3
17 |
18 | - name: Log in to Docker Hub
19 | uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
20 | with:
21 | username: ${{ secrets.DOCKER_USERNAME }}
22 | password: ${{ secrets.DOCKER_PASSWORD }}
23 |
24 | - name: Extract metadata (tags, labels) for Docker
25 | id: meta
26 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
27 | with:
28 | images: chroxify/feedbase
29 |
30 | - name: Build and push Docker image
31 | uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
32 | with:
33 | context: .
34 | file: ./apps/web/Dockerfile
35 | push: true
36 | tags: ${{ steps.meta.outputs.tags }}
37 | labels: ${{ steps.meta.outputs.labels }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 | .pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | .next
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | # turbo
39 | .turbo
40 |
41 | # Auto Generated PWA files
42 | **/public/sw.js
43 | **/public/workbox-*.js
44 | **/public/worker-*.js
45 | **/public/sw.js.map
46 | **/public/workbox-*.js.map
47 | **/public/worker-*.js.map.tinyb
48 |
49 | # tinybird
50 | .venv
51 | .tinyb
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | pnpm run lint --fix && pnpm run ts && pnpm lint-staged
5 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .next
3 | .gitignore
4 | .DS_Store
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | bracketSpacing: true,
3 | bracketSameLine: true,
4 | singleQuote: true,
5 | jsxSingleQuote: true,
6 | trailingComma: 'es5',
7 | semi: true,
8 | printWidth: 110,
9 | arrowParens: 'always',
10 | importOrder: [
11 | '^(react/(.*)$)|^(react$)',
12 | '^(next/(.*)$)|^(next$)',
13 | '',
14 | '^types$',
15 | '^@/types/(.*)$',
16 | '^@/config/(.*)$',
17 | '^@/lib/(.*)$',
18 | '^@/components/(.*)$',
19 | '^@/styles/(.*)$',
20 | '^@/app/(.*)$',
21 | '^[./]',
22 | ],
23 | plugins: [
24 | /**
25 | * **NOTE** tailwind plugin must come last!
26 | * @see https://github.com/tailwindlabs/prettier-plugin-tailwindcss#compatibility-with-other-prettier-plugins
27 | */
28 | '@ianvs/prettier-plugin-sort-imports',
29 | 'prettier-plugin-tailwindcss',
30 | ],
31 | };
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
The Open-Source Product Management Tool
3 |
4 |
5 |
6 | Learn more »
7 |
8 |
9 | Introduction
10 | ·
11 | Tech Stack
12 | ·
13 | Deploy Your Own
14 | ·
15 | Roadmap
16 | ·
17 | Contributing
18 |
19 |
20 |
21 | ## Introduction
22 |
23 | Feedbase is an open-source product management tool that helps products to enhance customer relationships through its robust features. It enables efficient capturing, prioritization, and resolution of customer issues, while also providing valuable insights into your product's status.
24 |
25 | ## Tech Stack
26 |
27 | - [Next.js](https://nextjs.org/) – Framework
28 | - [Typescript](https://www.typescriptlang.org/) – Language
29 | - [Tailwind](https://tailwindcss.com/) – CSS
30 | - [Shadcn/ui](https://ui.shadcn.com/) – Component Library
31 | - [Supabase](https://supabase.com/) – Database & Auth
32 | - [Vercel](https://vercel.com/) – Hosting
33 |
34 | ## Deploy Your Own
35 |
36 | If you're interested in self-hosting your own Feedbase instance, check out the [documentation](https://docs.feedbase.app/self-hosting).
37 |
38 | ## Roadmap
39 |
40 | Feedbase is currently in very early stages of development. Here's what we have planned for the future:
41 |
42 | - [ ] Further customization of the public hub
43 | - [ ] Implementation of Roadmaps & Feature Requests
44 | - [ ] Integration with other tools like GitHub, Linear, etc.
45 | - [ ] Analytics & Insights options for the public hub
46 | - [ ] Blog & Users management
47 |
48 | and much much more, so stay tuned!
49 |
50 | ## Contributing
51 |
52 | We would love to have your help in making Feedbase better!
53 |
54 | Here's how you can contribute:
55 | - [Report a bug](https://github.com/chroxify/feedbase/issues/new?labels=bug) you found while using Feedbase
56 | - [Request a feature](https://github.com/chroxify/feedbase/issues/new?labels=enhancement) that you think will be useful
57 | - [Submit a pull request](https://github.com/chroxify/feedbase/pulls) if you want to contribute with new features or bug fixes
58 |
59 | ## License
60 | Feedbase is licensed under the [GNU Affero General Public License Version 3 (AGPLv3)](https://github.com/chroxify/feedbase/blob/main/LICENSE) .
61 |
--------------------------------------------------------------------------------
/apps/docs/README.md:
--------------------------------------------------------------------------------
1 | # Mintlify Starter Kit
2 |
3 | Click on `Use this template` to copy the Mintlify starter kit. The starter kit contains examples including
4 |
5 | - Guide pages
6 | - Navigation
7 | - Customizations
8 | - API Reference pages
9 | - Use of popular components
10 |
11 | ### Development
12 |
13 | Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command
14 |
15 | ```
16 | npm i -g mintlify
17 | ```
18 |
19 | Run the following command at the root of your documentation (where mint.json is)
20 |
21 | ```
22 | mintlify dev
23 | ```
24 |
25 | ### Publishing Changes
26 |
27 | Install our Github App to autopropagate changes from youre repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
28 |
29 | #### Troubleshooting
30 |
31 | - Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
32 | - Page loads as a 404 - Make sure you are running in a folder with `mint.json`
33 |
--------------------------------------------------------------------------------
/apps/docs/_snippets/snippet-example.mdx:
--------------------------------------------------------------------------------
1 | ## My Snippet
2 |
3 | This is an example of a reusable snippet
4 |
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/changelog/delete-projects-changelogs.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Delete a Changelog"
3 | openapi: delete /projects/{projectSlug}/changelogs/{changelogId}
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/changelog/get-projects-changelogs.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: List Changelogs
3 | openapi: get /projects/{projectSlug}/changelogs
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/changelog/patch-projects-changelogs.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Update a Changelog"
3 | openapi: put /projects/{projectSlug}/changelogs/{changelogId}
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/changelog/post-projects-changelogs.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Create a Changelog"
3 | openapi: post /projects/{projectSlug}/changelogs
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/feedback-comments/delete-projects-feedback-comments.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Delete a Comment
3 | openapi: delete /projects/{projectSlug}/feedback/{feedbackId}/comments/{commentId}
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/feedback-comments/get-projects-feedback-comments.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: List Feedback Comments
3 | openapi: get /projects/{projectSlug}/feedback/{feedbackId}/comments
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/feedback-tags/delete-projects-feedback-tags.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Delete a Feedback Tag
3 | openapi: delete /projects/{projectSlug}/feedback/tags/{tagName}
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/feedback-tags/get-projects-feedback-tags.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: List Feedback Tags
3 | openapi: get /projects/{projectSlug}/feedback/tags
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/feedback-tags/post-projects-feedback-tags.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Create a Feedback Tag
3 | openapi: post /projects/{projectSlug}/feedback/tags
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/feedback/delete-projects-feedback.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Delete a Feedback
3 | openapi: delete /projects/{projectSlug}/feedback/{feedbackId}
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/feedback/get-projects-feedback-all.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: List Project Feedback
3 | openapi: get /projects/{projectSlug}/feedback
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/feedback/get-projects-feedback-upvotes.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: List Feedback Upvoters
3 | openapi: get /projects/{projectSlug}/feedback/{feedbackId}/upvotes
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/feedback/get-projects-feedback.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Retrieve a Feedback
3 | openapi: get /projects/{projectSlug}/feedback/{feedbackId}
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/feedback/patch-projects-feedback.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Update a Feedback
3 | openapi: patch /projects/{projectSlug}/feedback/{feedbackId}
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/feedback/post-projects-feedback.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Create a Feedback
3 | openapi: post /projects/{projectSlug}/feedback
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/project-config/get-projects-config.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Retrieve a Project Config
3 | openapi: get /projects/{projectSlug}/config
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/project-config/patch-projects-config.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Update a Project Config
3 | openapi: patch /projects/{projectSlug}/config
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/project-invites/delete-projects-invites.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Delete a Project Invite
3 | openapi: delete /projects/{projectSlug}/invites/{inviteId}
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/project-invites/get-projects-invites.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: "List Project Invites"
3 | openapi: get /projects/{projectSlug}/invites
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/project-invites/post-projects-invites.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Create a Project Invite"
3 | openapi: post /projects/{projectSlug}/invites
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/project-members/get-projects-members.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: List Project Members
3 | openapi: get /projects/{projectSlug}/members
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/project/delete-projects.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Delete a Project
3 | openapi: delete /projects/{projectSlug}
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/project/get-projects.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Retrieve a Project
3 | openapi: get /projects/{projectSlug}
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/project/patch-projects.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Update a Project
3 | openapi: patch /projects/{projectSlug}
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/public/get--atom.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Retrieve Project Atom Feed
3 | openapi: get /{projectSlug}/atom
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/endpoint/public/get--changelogs.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: List Project Changelogs
3 | openapi: get /{projectSlug}/changelogs
4 | ---
--------------------------------------------------------------------------------
/apps/docs/api-reference/introduction.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Introduction'
3 | description: 'Welcome to the API documentation for Feedbase'
4 | ---
5 |
6 | ## Welcome
7 |
8 | Welcome to the API documentation for Feedbase. There are two different API's available:
9 |
10 |
11 | The public API is used to access public project data. This mainly includes data that is available on your public hub.
12 |
13 |
14 |
15 | The project API is used to access private project data. There are two different scopes for the project API:
16 | - Public scope should be used for our [Typescript SDK]('/sdk-reference')
17 | - Full Access scope is used to access project data and manage project settings
18 |
19 |
20 |
21 |
22 | ## Create a new API key
23 |
24 | To access the [Project API](/api-reference/endpoint/project/), you need to provide an API key. You can create a new API key with the necessary scopes by clicking below.
25 |
26 |
31 | Create a new API key to access the API.
32 |
33 |
34 | ## Authentication
35 |
36 | All project endpoints are protected and require a valid API key. You can provide the API key in the `Authorization` header as a Bearer token.
37 |
38 | ```typescript
39 | const response = await fetch('https://api.feedbase.app/v1', {
40 | method: 'GET',
41 | headers: {
42 | 'Authorization': 'Bearer fb_xxxxxxxxxxxxxxxxxxxx',
43 | },
44 | })
45 | ```
--------------------------------------------------------------------------------
/apps/docs/development/configuration.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Configuration'
3 | description: 'Setting up all necessary environment variables to run the project.'
4 | ---
5 |
6 | ## Environment Variables
7 |
8 | That that we have our project cloned, and database set up, we need to set up our environment variables. To do this, we will need to create a `.env` file in the root of our project.
9 |
10 | ### Supabase
11 |
12 | To retrieve the Supabase variables, we can run the following command in our terminal:
13 |
14 | ```bash
15 | supabase status
16 | ```
17 |
18 | Supabase must be running in order for this command to work.
19 |
20 | This will return a list of urls and keys about our project. We will need to add the following variables to our `.env` file:
21 |
22 | ```bash
23 | NEXT_PUBLIC_SUPABASE_URL= # API URL
24 | NEXT_PUBLIC_SUPABASE_ANON_KEY= # anon key
25 | ```
26 |
27 | ### Github OAuth
28 |
29 | For local authentication we will be using GitHub. To set this up, we will need to create a GitHub OAuth app. Go to your GitHub settings and click on "Developer Settings" in the sidebar. Then click on "OAuth Apps" and "New OAuth App". Fill out the form with the following information:
30 |
31 | - Application Name: `Development`
32 | - Homepage URL: `http://localhost:3000`
33 | - Authorization callback URL: `http://127.0.0.1:54321/auth/v1/callback`
34 |
35 | Once you have created the app, you will be given a Client ID and Client Secret. Add these to your `.env` file:
36 |
37 | ```bash
38 | GITHUB_CLIENT_ID= # client id
39 | GITHUB_CLIENT_SECRET= # client secret
40 | ```
41 |
42 | ### Resend
43 |
44 | This is optional. If you do not need to send emails, you can skip this step.
45 |
46 | To send emails, we will be using [Resend](https://resend.com/). Create an account and add your API key to your `.env` file:
47 |
48 | ```bash
49 | RESEND_API_KEY= # api key
50 | ```
51 |
52 | ### Other
53 |
54 | We will also need to add one other variables to our `.env` file:
55 |
56 | ```bash
57 | NEXT_PUBLIC_ROOT_DOMAIN= # root domain (e.g. localhost:3000)
58 | ```
59 |
60 | ## Next Steps
61 |
62 | Now that we have everything set up, we are ready to start the development server and begin building.
63 |
64 | ```bash
65 | pnpm dev
66 | ```
--------------------------------------------------------------------------------
/apps/docs/development/installation.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Installation
3 | description: 'Learn how to install Feedbase on your local machine'
4 | ---
5 |
6 | ## System Requirements
7 |
8 | - [Node.js 18.0.0](https://nodejs.org/en/download/) or higher
9 | - [pnpm 8.8.0](https://pnpm.io/installation) or higher
10 |
11 | ## Clone the repository
12 |
13 |
14 | ```bash
15 | git clone https://github.com/chroxify/feedbase.git && cd feedbase
16 | ```
17 |
18 |
19 | ```bash
20 | pnpm install
21 | ```
22 |
23 |
24 |
25 | ## Database Setup
26 | Feedbase is using [Supabase](https://supabase.io/) as its database. For local development, you can use the Supabase CLI to start a local Postgres database.
27 |
28 |
29 |
30 | Follow the instructions [here](https://github.com/supabase/cli?tab=readme-ov-file#install-the-cli) to install the Supabase CLI for your operating system.
31 |
32 |
33 | ```bash
34 | supabase start
35 | ```
36 |
37 |
38 | Now you can visit the local Supabase dashboard at [http://localhost:54323](http://localhost:54323) to make sure all migrations have been applied correctly.
39 |
40 |
--------------------------------------------------------------------------------
/apps/docs/introduction.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Welcome & Overview
3 | description: 'Welcome to Feedbase! This page will give you a quick overview of what Feedbase is and how to get started.'
4 | ---
5 |
6 |
10 |
11 | ## What is Feedbase?
12 |
13 | Feedbase is an open-source feedback collection and product update solution that allows you to collect feedback from your users and keep them updated on your product's progress.
14 |
15 | ## Quickstart
16 |
17 |
18 |
23 | Use our cloud hosted solution to get up and running in minutes
24 |
25 |
30 | Host Feedbase on your own servers or cloud provider for full control
31 |
32 |
37 | Develop and contribute to Feedbase locally with hot-reloading
38 |
39 |
44 | Learn more about Feedbase's API and how to use it
45 |
46 |
47 |
--------------------------------------------------------------------------------
/apps/docs/logo/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/apps/web/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: ['custom/next'],
4 | globals: {
5 | Messages: 'readonly',
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/apps/web/Dockerfile:
--------------------------------------------------------------------------------
1 | # src Dockerfile: https://github.com/vercel/turbo/blob/main/examples/with-docker/apps/web/Dockerfile
2 | FROM node:18-alpine AS alpine
3 |
4 | # setup pnpm on the alpine base
5 | FROM alpine as base
6 | ENV PNPM_HOME="/pnpm"
7 | ENV PATH="$PNPM_HOME:$PATH"
8 | RUN corepack enable
9 | RUN pnpm install turbo --global
10 | RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/*
11 |
12 | FROM base AS builder
13 | RUN apk add --no-cache libc6-compat
14 | RUN apk update
15 | # Set working directory
16 | WORKDIR /app
17 | COPY . .
18 | RUN turbo prune --scope=web --docker
19 |
20 | # Add lockfile and package.json's of isolated subworkspace
21 | FROM base AS installer
22 | RUN apk add --no-cache libc6-compat
23 | RUN apk update
24 | WORKDIR /app
25 |
26 | # First install the dependencies (as they change less often)
27 | COPY .gitignore .gitignore
28 | COPY --from=builder /app/out/json/ .
29 | COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
30 | COPY --from=builder /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
31 | RUN pnpm install
32 |
33 | # Build the project
34 | COPY --from=builder /app/out/full/ .
35 | COPY turbo.json turbo.json
36 | RUN turbo run build --filter=web
37 |
38 | # use alpine as the thinest image
39 | FROM alpine AS runner
40 | WORKDIR /app
41 |
42 | # Don't run production as root
43 | RUN addgroup --system --gid 1001 nodejs
44 | RUN adduser --system --uid 1001 nextjs
45 | USER nextjs
46 |
47 | COPY --from=installer /app/apps/web/next.config.js .
48 | COPY --from=installer /app/apps/web/package.json .
49 |
50 | # Automatically leverage output traces to reduce image size
51 | # https://nextjs.org/docs/advanced-features/output-file-tracing
52 | COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
53 | COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
54 | COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
55 |
56 | CMD node apps/web/server.js
--------------------------------------------------------------------------------
/apps/web/app/[project]/changelog/[id]/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from '@ui/components/ui/separator';
2 | import { Skeleton } from '@ui/components/ui/skeleton';
3 |
4 | export default function ChangelogPageLoading() {
5 | return (
6 |
9 | {/* Back Button */}
10 |
15 |
16 | {/* Content */}
17 |
18 | {/* Title */}
19 |
20 |
21 |
22 |
23 | {/* Image */}
24 |
25 |
26 | {/* Author & Share */}
27 |
28 |
29 | {/* Author Avatar */}
30 |
31 |
32 | {/* Name & Date */}
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | {/* Share */}
44 |
45 |
46 |
47 |
48 |
49 | {/* Content as html */}
50 |
51 | {[1, 2, 3, 4, 5, 6].map((index) => (
52 |
53 | ))}
54 |
55 |
56 | {/* Separator */}
57 |
58 |
59 | {/* Next & Previous */}
60 |
61 | {/* Previous */}
62 |
63 |
64 |
65 |
66 | {/* Next */}
67 |
68 |
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/apps/web/app/[project]/changelog/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from '@ui/components/ui/separator';
2 | import { Skeleton } from '@ui/components/ui/skeleton';
3 |
4 | export default function ChangelogLoading() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | {[1, 2, 3].map((index) => (
25 |
28 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | ))}
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/apps/web/app/[project]/changelog/unsubscribe/page.tsx:
--------------------------------------------------------------------------------
1 | import { notFound, redirect } from 'next/navigation';
2 | import { getProjectBySlug } from '@/lib/api/projects';
3 | import UnsubscribeChangelogCard from '@/components/layout/unsubscribe-card';
4 |
5 | export default async function ChangelogUnsubscribe({
6 | params,
7 | searchParams,
8 | }: {
9 | params: { project: string };
10 | searchParams: { subId: string };
11 | }) {
12 | if (!searchParams.subId) {
13 | redirect('/');
14 | }
15 |
16 | // Check if subId is in uuid format
17 | const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18 |
19 | if (!uuidRegex.test(searchParams.subId)) {
20 | redirect('/');
21 | }
22 |
23 | // Get project
24 | const { data: project, error } = await getProjectBySlug(params.project, 'server', true, false);
25 |
26 | // If project is undefined redirects to 404
27 | if (error?.status === 404 || !project) {
28 | notFound();
29 | }
30 |
31 | return (
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/app/[project]/feedback/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from '@ui/components/ui/separator';
2 | import { Skeleton } from '@ui/components/ui/skeleton';
3 | import FeedbackHeader from '@/components/hub/feedback/button-header';
4 |
5 | export default function FeedbackLoading() {
6 | return (
7 |
8 |
9 |
10 |
Feedback
11 |
12 | Have a suggestion or found a bug? Let us know!
13 |
14 |
15 |
16 |
17 | {/* Separator */}
18 |
19 |
20 | {/* Content */}
21 |
22 |
{' '}
23 | {/* Provide placeholder values */}
24 | {/* Main */}
25 |
26 | {[1, 2, 3, 4, 5].map((index) => (
27 |
28 | ))}
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/apps/web/app/[project]/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export default async function Hub() {
4 | // Redirect to changelog
5 | // This page doesn't really get called as this is catched in layout.tsx but still needed to cause 404
6 | redirect(`/feedback`);
7 | }
8 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/[slug]/atom/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getProjectBySlug } from '@/lib/api/projects';
3 | import { getPublicProjectChangelogs } from '@/lib/api/public';
4 |
5 | /*
6 | Generate atom feed for project changelog
7 | */
8 | export async function GET(req: Request, context: { params: { slug: string } }) {
9 | // Get project data
10 | const { data: project, error } = await getProjectBySlug(context.params.slug, 'route', true, false);
11 |
12 | // If any errors thrown, return error
13 | if (error) {
14 | return NextResponse.json({ error: error.message }, { status: error.status });
15 | }
16 |
17 | const { data: changelogs, error: changelogError } = await getPublicProjectChangelogs(
18 | context.params.slug,
19 | 'route',
20 | true,
21 | false
22 | );
23 |
24 | // If any errors thrown, return error
25 | if (changelogError) {
26 | return NextResponse.json({ error: changelogError.message }, { status: changelogError.status });
27 | }
28 |
29 | // Return atom formatted changelogs
30 | return new Response(
31 | `
32 |
33 | ${project.name} Changelog
34 | ${project.name}'s Changelog
35 |
36 |
37 | ${changelogs[0].publish_date}
38 | ${project.id} ${changelogs
39 | .map((post) => {
40 | return `
41 |
42 | ${post.id}
43 | ${post.title}
44 |
45 | ${post.publish_date}
46 | ${post.author.full_name}
47 | `;
48 | })
49 | .join('')}
50 | `,
51 | { status: 200, headers: { 'Content-Type': 'application/atom+xml; charset=utf-8' } }
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/[slug]/changelogs/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getPublicProjectChangelogs } from '@/lib/api/public';
3 |
4 | /*
5 | Get project changelogs
6 | GET /api/v1/projects/[slug]/changelogs
7 | */
8 | export async function GET(req: Request, context: { params: { slug: string } }) {
9 | const { data: changelogs, error } = await getPublicProjectChangelogs(
10 | context.params.slug,
11 | 'route',
12 | true,
13 | false
14 | );
15 |
16 | // If any errors thrown, return error
17 | if (error) {
18 | return NextResponse.json({ error: error.message }, { status: error.status });
19 | }
20 |
21 | // Return changelogs
22 | return NextResponse.json(changelogs, { status: 200 });
23 | }
24 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { subscribeToProjectChangelogs, unsubscribeFromProjectChangelogs } from '@/lib/api/public';
3 |
4 | /*
5 | Subscribe to project changelogs
6 | POST /api/v1/:slug/changelogs/subscribers
7 | {
8 | email: string,
9 | }
10 | */
11 | export async function POST(req: Request, context: { params: { slug: string } }) {
12 | const { email } = await req.json();
13 |
14 | // Check if email is provided
15 | if (!email) {
16 | return NextResponse.json({ error: 'email is required.' }, { status: 400 });
17 | }
18 |
19 | // Subscribe to project changelogs
20 | const { data: subscriber, error } = await subscribeToProjectChangelogs(context.params.slug, email);
21 |
22 | // If any errors thrown, return error
23 | if (error) {
24 | return NextResponse.json({ error: error.message }, { status: error.status });
25 | }
26 |
27 | // Return subscriber
28 | return NextResponse.json(subscriber, { status: 200 });
29 | }
30 |
31 | /*
32 | Unsubscribe from project changelogs
33 | DELETE /api/v1/:slug/changelogs/subscribers
34 | {
35 | subId: string,
36 | }
37 | */
38 | export async function DELETE(req: Request, context: { params: { slug: string } }) {
39 | const { subId } = await req.json();
40 |
41 | // Check if subId is provided
42 | if (!subId) {
43 | return NextResponse.json({ error: 'subId is required.' }, { status: 400 });
44 | }
45 |
46 | // Unsubscribe from project changelogs
47 | const { error } = await unsubscribeFromProjectChangelogs(context.params.slug, subId);
48 |
49 | // If any errors thrown, return error
50 | if (error) {
51 | return NextResponse.json({ error: error.message }, { status: error.status });
52 | }
53 |
54 | // Return success
55 | return NextResponse.json({ success: true }, { status: 200 });
56 | }
57 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/[slug]/views/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { recordClick } from '@/lib/tinybird';
3 |
4 | /*
5 | Record page view
6 | POST /api/v1/[project]/views
7 | {
8 | "feedbackId": "string",
9 | "changelogId": "string",
10 | }
11 | */
12 | export async function POST(req: NextRequest, context: { params: { slug: string } }) {
13 | const { feedbackId, changelogId } = await req.json();
14 |
15 | // Check for Tinybird env vars
16 | if (!process.env.TINYBIRD_API_URL || !process.env.TINYBIRD_API_KEY) {
17 | return NextResponse.json({ error: 'Tinybird environment variables not set' }, { status: 500 });
18 | }
19 |
20 | const data = await recordClick({
21 | req,
22 | projectId: context.params.slug,
23 | feedbackId,
24 | changelogId,
25 | });
26 |
27 | return NextResponse.json({ data }, { status: 200 });
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/profile/notifications/[notificationId]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { archiveUserNotification } from '@/lib/api/user';
3 |
4 | /*
5 | Archive a notification
6 | PATCH /api/v1/profile/notifications/:notificationId
7 | {
8 | "archived": true
9 | }
10 | */
11 | export async function PATCH(req: Request, context: { params: { notificationId: string } }) {
12 | // Get notification id
13 | const { archived } = await req.json();
14 |
15 | // Archive notification
16 | const { data: notification, error } = await archiveUserNotification(
17 | 'route',
18 | context.params.notificationId,
19 | archived
20 | );
21 |
22 | // Check for errors
23 | if (error) {
24 | return NextResponse.json({ error: error.message }, { status: error.status });
25 | }
26 |
27 | return NextResponse.json(notification, { status: 200 });
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/profile/notifications/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getUserNotifications } from '@/lib/api/user';
3 |
4 | /*
5 | Get user's notifications
6 | GET /api/v1/profile/notifications
7 | */
8 | export async function GET(req: Request) {
9 | // Get user's notifications
10 | const { data: notifications, error } = await getUserNotifications('route');
11 |
12 | // Check for errors
13 | if (error) {
14 | return NextResponse.json({ error: error.message }, { status: error.status });
15 | }
16 |
17 | return NextResponse.json(notifications, { status: 200 });
18 | }
19 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/profile/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { updateUserProfile } from '@/lib/api/user';
3 |
4 | /*
5 | Update Profile
6 | PATCH /api/v1/profile
7 | {
8 | "full_name": string,
9 | "avatar_url": string,
10 | }
11 | */
12 | export async function PATCH(req: Request) {
13 | const { full_name: fullName, avatar_url: avatarUrl } = await req.json();
14 |
15 | // Validate Request Body
16 | if (!fullName && !avatarUrl) {
17 | return NextResponse.json({ error: 'full_name or avatar_url is required.' }, { status: 400 });
18 | }
19 |
20 | // Update Profile
21 | const { data: profile, error } = await updateUserProfile('route', {
22 | full_name: fullName,
23 | avatar_url: avatarUrl,
24 | });
25 |
26 | // Check for errors
27 | if (error) {
28 | return NextResponse.json({ error: error.message }, { status: error.status });
29 | }
30 |
31 | return NextResponse.json(profile, { status: 200 });
32 | }
33 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/analytics/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { getProjectAnalytics } from '@/lib/api/projects';
3 |
4 | /*
5 | Get Analytics
6 | GET /api/v1/projects/:slug/analytics
7 | */
8 | export async function GET(req: NextRequest, context: { params: { slug: string } }) {
9 | // Check if tinybird variables are set
10 | if (!process.env.TINYBIRD_API_URL || !process.env.TINYBIRD_API_KEY) {
11 | return NextResponse.json({ error: 'Tinybird variables not set.' }, { status: 500 });
12 | }
13 |
14 | // Get query params
15 | const start = req.nextUrl.searchParams.get('start');
16 | const end = req.nextUrl.searchParams.get('end');
17 |
18 | // Check if start and end are valid dates
19 | if ((start && !Date.parse(start)) || (end && !Date.parse(end))) {
20 | return NextResponse.json({ error: 'Invalid start or end date.' }, { status: 400 });
21 | }
22 |
23 | const { data: analyticsData, error } = await getProjectAnalytics(context.params.slug, 'route');
24 |
25 | // If any errors thrown, return error
26 | if (error) {
27 | return NextResponse.json({ error: error.message }, { status: error.status });
28 | }
29 |
30 | // Return response
31 | return NextResponse.json(analyticsData, { status: 200 });
32 | }
33 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { deleteProjectApiKey } from '@/lib/api/projects';
3 |
4 | /*
5 | Delete api key for a project
6 | DELETE /api/v1/projects/:slug/config/api/:token
7 | */
8 | export async function DELETE(req: Request, context: { params: { slug: string; id: string } }) {
9 | const { error } = await deleteProjectApiKey(context.params.slug, context.params.id, 'route');
10 |
11 | if (error) {
12 | return NextResponse.json({ error }, { status: error.status });
13 | }
14 |
15 | return NextResponse.json({ success: true }, { status: 200 });
16 | }
17 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/api-keys/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { createProjectApiKey, getProjectApiKeys } from '@/lib/api/projects';
3 |
4 | /*
5 | Get all API keys for a project
6 | GET /api/v1/projects/:slug/config/api
7 | */
8 | export async function GET(req: Request, context: { params: { slug: string } }) {
9 | // Get all api keys
10 | const { data: apiKeys, error } = await getProjectApiKeys(context.params.slug, 'route');
11 |
12 | if (error) {
13 | return NextResponse.json(error, { status: error.status });
14 | }
15 |
16 | return NextResponse.json(apiKeys, { status: 200 });
17 | }
18 |
19 | /*
20 | Create a new API key for a project
21 | POST /api/v1/projects/:slug/config/api
22 | {
23 | "name": "string",
24 | "permissions": "string"
25 | }
26 | */
27 | export async function POST(req: Request, context: { params: { slug: string } }) {
28 | const { name, permission } = (await req.json()) as { name: string; permission: string };
29 |
30 | // Validate input
31 | if (!name || !permission) {
32 | return NextResponse.json({ error: 'name and permission are required' }, { status: 400 });
33 | }
34 |
35 | // Create api key
36 | const { data: apiKey, error } = await createProjectApiKey(
37 | context.params.slug,
38 | { name, permission },
39 | 'route'
40 | );
41 |
42 | if (error) {
43 | return NextResponse.json({ error: error.message }, { status: error.status });
44 | }
45 |
46 | return NextResponse.json(apiKey, { status: 200 });
47 | }
48 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { deleteChangelog, updateChangelog } from '@/lib/api/changelogs';
3 |
4 | export const runtime = 'edge';
5 |
6 | /*
7 | Update project changelog
8 | PUT /api/v1/projects/[slug]/changelogs/[id]
9 | {
10 | title: string;
11 | summary: string;
12 | content: string;
13 | image?: string;
14 | publish_date?: Date;
15 | published: boolean;
16 | }
17 | */
18 | export async function PUT(req: Request, context: { params: { slug: string; id: string } }) {
19 | const { title, summary, content, image, publishDate, published } = await req.json();
20 |
21 | const { data: changelog, error } = await updateChangelog(
22 | context.params.id,
23 | context.params.slug,
24 | { title, summary, content, image, publish_date: publishDate, published },
25 | 'route'
26 | );
27 |
28 | // If any errors thrown, return error
29 | if (error) {
30 | return NextResponse.json({ error: error.message }, { status: error.status });
31 | }
32 |
33 | // Return changelog
34 | return NextResponse.json(changelog, { status: 200 });
35 | }
36 |
37 | /*
38 | Delete project changelog
39 | DELETE /api/v1/projects/[slug]/changelogs/[id]
40 | */
41 | export async function DELETE(req: Request, context: { params: { slug: string; id: string } }) {
42 | const { data, error } = await deleteChangelog(context.params.id, context.params.slug, 'route');
43 |
44 | // If any errors thrown, return error
45 | if (error) {
46 | return NextResponse.json({ error: error.message }, { status: error.status });
47 | }
48 |
49 | // Return success
50 | return NextResponse.json(data, { status: 200 });
51 | }
52 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/changelogs/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { createChangelog, getAllProjectChangelogs } from '@/lib/api/changelogs';
3 | import { ChangelogProps } from '@/lib/types';
4 |
5 | export const runtime = 'edge';
6 |
7 | /*
8 | Create Changelog
9 | POST /api/v1/projects/[slug]/changelogs
10 | {
11 | title: string;
12 | summary: string;
13 | content: string;
14 | image?: string;
15 | publish_date?: Date;
16 | published: boolean;
17 | }
18 | */
19 | export async function POST(req: Request, context: { params: { slug: string } }) {
20 | const {
21 | title,
22 | summary,
23 | content,
24 | image,
25 | publish_date: publishDate,
26 | published,
27 | } = (await req.json()) as ChangelogProps['Insert'];
28 |
29 | // Validate Request Body
30 | if (published) {
31 | if (!title || !summary || !content) {
32 | return NextResponse.json(
33 | { error: 'title, summary, and content are required when publishing a changelog.' },
34 | { status: 400 }
35 | );
36 | }
37 | }
38 |
39 | const { data: changelog, error } = await createChangelog(
40 | context.params.slug,
41 | {
42 | title: title || '',
43 | summary: summary || '',
44 | content: content || '',
45 | image: image || '',
46 | publish_date: publishDate || null,
47 | published: published || false,
48 | project_id: 'dummy-id',
49 | slug: 'dummy-slug',
50 | author_id: 'dummy-author',
51 | },
52 | 'route'
53 | );
54 |
55 | // If any errors thrown, return error
56 | if (error) {
57 | return NextResponse.json({ error: error.message }, { status: error.status });
58 | }
59 |
60 | // Return changelog
61 | return NextResponse.json(changelog, { status: 200 });
62 | }
63 |
64 | /*
65 | Get project changelogs
66 | GET /api/v1/projects/[slug]/changelogs
67 | */
68 | export async function GET(req: Request, context: { params: { slug: string } }) {
69 | const { data: changelogs, error } = await getAllProjectChangelogs(context.params.slug, 'route', true);
70 |
71 | // If any errors thrown, return error
72 | if (error) {
73 | return NextResponse.json({ error: error.message }, { status: error.status });
74 | }
75 |
76 | // Return changelogs
77 | return NextResponse.json(changelogs, { status: 200 });
78 | }
79 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts:
--------------------------------------------------------------------------------
1 | import { getChangelogSubscribers } from '@/lib/api/changelogs';
2 |
3 | /*
4 | Download changelog subscribers
5 | GET /api/v1/projects/:slug/changelogs/subscribers/download
6 | */
7 | export async function GET(req: Request, context: { params: { slug: string } }) {
8 | const { data: subscribers, error } = await getChangelogSubscribers(context.params.slug, 'route');
9 |
10 | // If any errors thrown, return error
11 | if (error) {
12 | return new Response(error.message, { status: error.status });
13 | }
14 |
15 | // Create CSV
16 | const csv = `Email\n${subscribers
17 | .map((subscriber) => {
18 | return `${subscriber.email}\n`;
19 | })
20 | .join('')}`;
21 |
22 | // Return CSV
23 | return new Response(csv, {
24 | headers: {
25 | 'Content-Type': 'text/csv',
26 | 'Content-Disposition': 'attachment; filename=subscribers.csv',
27 | },
28 | });
29 | }
30 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { updateProjectConfigBySlug } from '@/lib/api/projects';
3 |
4 | /*
5 | Update Discord integration
6 | PATCH /api/v1/projects/:slug/config/integrations/discord
7 | {
8 | status: boolean,
9 | webhook: string,
10 | roleId: string,
11 | }
12 | */
13 | export async function PATCH(req: Request, context: { params: { slug: string } }) {
14 | const { status, webhook, roleId } = (await req.json()) as {
15 | status: boolean;
16 | webhook: string;
17 | roleId: string;
18 | };
19 |
20 | // If status is true, make sure webhook and roleId are not empty
21 | if (status && !webhook) {
22 | return NextResponse.json(
23 | { error: 'webhook is required when enabling Discord integration.' },
24 | { status: 400 }
25 | );
26 | }
27 |
28 | // Update project config
29 | const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug(
30 | context.params.slug,
31 | {
32 | integration_discord_status: status,
33 | integration_discord_webhook: status ? webhook : null,
34 | integration_discord_role_id: status ? roleId : null,
35 | },
36 | 'route'
37 | );
38 |
39 | // If any errors thrown, return error
40 | if (error) {
41 | return NextResponse.json({ error: error.message }, { status: error.status });
42 | }
43 |
44 | // Return updated project config
45 | return NextResponse.json(updatedProjectConfig, { status: 200 });
46 | }
47 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { updateProjectConfigBySlug } from '@/lib/api/projects';
3 |
4 | /*
5 | Update Slack integration
6 | PATCH /api/v1/projects/:slug/config/integrations/slack
7 | {
8 | status: boolean,
9 | webhook: string,
10 | }
11 | */
12 | export async function PATCH(req: Request, context: { params: { slug: string } }) {
13 | const { status, webhook } = (await req.json()) as {
14 | status: boolean;
15 | webhook: string;
16 | };
17 |
18 | // If status is true, make sure webhook and roleId are not empty
19 | if (status && !webhook) {
20 | return NextResponse.json(
21 | { error: 'webhook is required when enabling Slack integration.' },
22 | { status: 400 }
23 | );
24 | }
25 |
26 | // Update project config
27 | const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug(
28 | context.params.slug,
29 | {
30 | integration_slack_status: status,
31 | integration_slack_webhook: status ? webhook : null,
32 | },
33 | 'route'
34 | );
35 |
36 | // If any errors thrown, return error
37 | if (error) {
38 | return NextResponse.json({ error: error.message }, { status: error.status });
39 | }
40 |
41 | // Return updated project config
42 | return NextResponse.json(updatedProjectConfig, { status: 200 });
43 | }
44 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { updateProjectConfigBySlug } from '@/lib/api/projects';
3 |
4 | /*
5 | Update SSO configuration
6 | PATCH /api/v1/projects/:slug/config/integrations/sso
7 | {
8 | status: boolean,
9 | url: string,
10 | secret: string,
11 | }
12 | */
13 | export async function PATCH(req: Request, context: { params: { slug: string } }) {
14 | const { status, url, secret } = await req.json();
15 |
16 | if (status && (!url || !secret)) {
17 | return NextResponse.json(
18 | { error: 'url and secret are required when enabling SSO integration.' },
19 | { status: 400 }
20 | );
21 | }
22 |
23 | // Update project config
24 | const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug(
25 | context.params.slug,
26 | {
27 | integration_sso_status: status,
28 | integration_sso_url: status ? url : null,
29 | integration_sso_secret: status ? secret : null,
30 | },
31 | 'route'
32 | );
33 |
34 | // If any errors thrown, return error
35 | if (error) {
36 | return NextResponse.json({ error: error.message }, { status: error.status });
37 | }
38 |
39 | // Return updated project config
40 | return NextResponse.json(updatedProjectConfig, { status: 200 });
41 | }
42 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { deleteCommentForFeedbackById } from '@/lib/api/comments';
3 |
4 | /*
5 | Delete comment for feedback by id
6 | DELETE /api/v1/projects/[slug]/feedback/[id]/comments/[id]
7 | */
8 | export async function DELETE(
9 | req: Request,
10 | context: { params: { slug: string; feedbackId: string; commentId: string } }
11 | ) {
12 | const { data: comment, error } = await deleteCommentForFeedbackById(
13 | context.params.commentId,
14 | context.params.feedbackId,
15 | context.params.slug,
16 | 'route'
17 | );
18 |
19 | // If any errors thrown, return error
20 | if (error) {
21 | return NextResponse.json({ error: error.message }, { status: error.status });
22 | }
23 |
24 | // Return comment
25 | return NextResponse.json(comment, { status: 200 });
26 | }
27 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { upvoteCommentForFeedbackById } from '@/lib/api/comments';
3 |
4 | /*
5 | Upvote comment for feedback by id
6 | POST /api/v1/projects/[slug]/feedback/[id]/comments/[id]/upvote
7 | */
8 | export async function POST(
9 | req: Request,
10 | context: { params: { slug: string; feedbackId: string; commentId: string } }
11 | ) {
12 | const { data: comment, error } = await upvoteCommentForFeedbackById(
13 | context.params.commentId,
14 | context.params.feedbackId,
15 | context.params.slug,
16 | 'route'
17 | );
18 |
19 | // If any errors thrown, return error
20 | if (error) {
21 | return NextResponse.json({ error: error.message }, { status: error.status });
22 | }
23 |
24 | // Return comment
25 | return NextResponse.json(comment, { status: 200 });
26 | }
27 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { createCommentForFeedbackById, getCommentsForFeedbackById } from '@/lib/api/comments';
3 | import { FeedbackCommentProps } from '@/lib/types';
4 |
5 | /*
6 | Create feedback comment
7 | POST /api/v1/projects/[slug]/feedback/[id]/comments
8 | {
9 | content: string
10 | }
11 | */
12 | export async function POST(req: Request, context: { params: { slug: string; feedbackId: string } }) {
13 | const { content, reply_to_id: replyToId } = (await req.json()) as FeedbackCommentProps['Insert'];
14 |
15 | if (!content) {
16 | return NextResponse.json({ error: 'Content cannot be empty' }, { status: 400 });
17 | }
18 |
19 | const { data: comment, error } = await createCommentForFeedbackById(
20 | {
21 | feedback_id: context.params.feedbackId,
22 | content: content || '',
23 | user_id: 'dummy-id',
24 | reply_to_id: replyToId || null,
25 | },
26 | context.params.slug,
27 | 'route'
28 | );
29 |
30 | // If any errors thrown, return error
31 | if (error) {
32 | return NextResponse.json({ error: error.message }, { status: error.status });
33 | }
34 |
35 | // Return comment
36 | return NextResponse.json(comment, { status: 200 });
37 | }
38 |
39 | /*
40 | Get feedback comments
41 | GET /api/v1/projects/[slug]/feedback/[id]/comments
42 | */
43 | export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) {
44 | const { data: comments, error } = await getCommentsForFeedbackById(
45 | context.params.feedbackId,
46 | context.params.slug,
47 | 'route'
48 | );
49 |
50 | // If any errors thrown, return error
51 | if (error) {
52 | return NextResponse.json({ error: error.message }, { status: error.status });
53 | }
54 |
55 | // Return comments
56 | return NextResponse.json(comments, { status: 200 });
57 | }
58 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { deleteFeedbackByID, getFeedbackByID, updateFeedbackByID } from '@/lib/api/feedback';
3 | import { FeedbackWithUserInputProps } from '@/lib/types';
4 |
5 | /*
6 | Get Project Feedback by ID
7 | GET /api/v1/projects/[slug]/feedback/[id]
8 | */
9 | export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) {
10 | const { data: feedback, error } = await getFeedbackByID(
11 | context.params.feedbackId,
12 | context.params.slug,
13 | 'route'
14 | );
15 |
16 | // If any errors thrown, return error
17 | if (error) {
18 | return NextResponse.json({ error: error.message }, { status: error.status });
19 | }
20 |
21 | // Return feedback
22 | return NextResponse.json(feedback, { status: 200 });
23 | }
24 |
25 | /*
26 | Update Feedback by ID
27 | PATCH /api/v1/projects/[slug]/feedback/[id]
28 | {
29 | title: string;
30 | description: string;
31 | status: string;
32 | tags: string[];
33 | }
34 | */
35 | export async function PATCH(req: Request, context: { params: { slug: string; feedbackId: string } }) {
36 | const { title, description, status, tags } = (await req.json()) as FeedbackWithUserInputProps;
37 |
38 | const { data: feedback, error } = await updateFeedbackByID(
39 | context.params.feedbackId,
40 | context.params.slug,
41 | {
42 | title: title || '',
43 | description: description || '',
44 | status,
45 | project_id: 'dummy-id',
46 | user_id: 'dummy-id',
47 | tags: tags || undefined,
48 | },
49 | 'route'
50 | );
51 |
52 | // If any errors thrown, return error
53 | if (error) {
54 | return NextResponse.json({ error: error.message }, { status: error.status });
55 | }
56 |
57 | // Return feedback
58 | return NextResponse.json(feedback, { status: 200 });
59 | }
60 |
61 | /*
62 | Delete Feedback by ID
63 | DELETE /api/v1/projects/[slug]/feedback/[id]
64 | */
65 | export async function DELETE(req: Request, context: { params: { slug: string; feedbackId: string } }) {
66 | const { data: feedback, error } = await deleteFeedbackByID(
67 | context.params.feedbackId,
68 | context.params.slug,
69 | 'route'
70 | );
71 |
72 | // If any errors thrown, return error
73 | if (error) {
74 | return NextResponse.json({ error: error.message }, { status: error.status });
75 | }
76 |
77 | // Return feedback
78 | return NextResponse.json(feedback, { status: 200 });
79 | }
80 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { getFeedbackByID, getFeedbackUpvotersById, upvoteFeedbackByID } from '@/lib/api/feedback';
3 | import { getCurrentUser } from '@/lib/api/user';
4 |
5 | /*
6 | Get feedback upvotes
7 | GET /api/v1/projects/[slug]/feedback/[id]/upvote
8 | */
9 | export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) {
10 | const { data: feedback, error } = await getFeedbackByID(
11 | context.params.feedbackId,
12 | context.params.slug,
13 | 'route'
14 | );
15 |
16 | // If any errors thrown, return error
17 | if (error) {
18 | return NextResponse.json({ error: error.message }, { status: error.status });
19 | }
20 |
21 | // Get feedback upvoters
22 | const { data: upvoters, error: upvotersError } = await getFeedbackUpvotersById(
23 | context.params.feedbackId,
24 | context.params.slug,
25 | 'route'
26 | );
27 |
28 | // If any errors thrown, return error
29 | if (upvotersError) {
30 | return NextResponse.json({ error: upvotersError.message }, { status: upvotersError.status });
31 | }
32 |
33 | // Return feedback
34 | return NextResponse.json(
35 | {
36 | upvotes: feedback.upvotes,
37 | upvoters,
38 | },
39 | { status: 200 }
40 | );
41 | }
42 |
43 | /*
44 | Upvote a feedback
45 | POST /api/v1/projects/[slug]/feedback/[id]/upvote
46 | */
47 | export async function POST(req: NextRequest, context: { params: { slug: string; feedbackId: string } }) {
48 | const hasUpvoted = req.nextUrl.searchParams.get('has_upvoted');
49 |
50 | // Get current user
51 | const { data: isLoggedIn } = await getCurrentUser('route');
52 |
53 | // Upvote feedback
54 | const { data: feedback, error } = await upvoteFeedbackByID(
55 | context.params.feedbackId,
56 | context.params.slug,
57 | 'route',
58 | hasUpvoted ? hasUpvoted === 'true' : undefined,
59 | !isLoggedIn
60 | );
61 |
62 | // If any errors thrown, return error
63 | if (error) {
64 | return NextResponse.json({ error: error.message }, { status: error.status });
65 | }
66 |
67 | // Return feedback
68 | return NextResponse.json(feedback, { status: 200 });
69 | }
70 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts:
--------------------------------------------------------------------------------
1 | import { getAllProjectFeedback } from '@/lib/api/feedback';
2 | import { formatRootUrl } from '@/lib/utils';
3 |
4 | /*
5 | Export all feedback for a project
6 | GET /api/v1/projects/:slug/feedback/export
7 | */
8 | export async function GET(req: Request, context: { params: { slug: string } }) {
9 | const { data: feedback, error } = await getAllProjectFeedback(context.params.slug, 'route');
10 |
11 | // If any errors thrown, return error
12 | if (error) {
13 | return new Response(error.message, { status: error.status });
14 | }
15 |
16 | // Create CSV
17 | const csv = `ID,Link,Title,Content,Status,Upvotes,Comment Count,User ID,User Name,User Email,User Avatar,Tags,Created At\n${feedback
18 | .map((feedback) => {
19 | return `${feedback.id},${formatRootUrl(
20 | context.params.slug,
21 | `/feedback/${feedback.id}`
22 | )},"${feedback.title.replace(/"/g, '""')}","${feedback.description.replace(/"/g, '""')}",${
23 | feedback.status
24 | },${feedback.upvotes},${feedback.comment_count},${feedback.user_id},"${feedback.user.full_name.replace(
25 | /"/g,
26 | '""'
27 | )}","${feedback.user.email.replace(/"/g, '""')}",${
28 | feedback.user.avatar_url ? `"${feedback.user.avatar_url.replace(/"/g, '""')}"` : ''
29 | },${feedback.tags ? feedback.tags.map((tag) => tag.name.replace(/"/g, '""')).join(',') : ''},${
30 | feedback.created_at
31 | }\n`;
32 | })
33 | .join('')}`;
34 |
35 | // Return CSV
36 | const timestamp = new Date().getTime();
37 | return new Response(csv, {
38 | headers: {
39 | 'Content-Type': 'text/csv',
40 | 'Content-Disposition': `attachment; filename=${context.params.slug}-${timestamp}.csv`,
41 | },
42 | });
43 | }
44 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/feedback/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { createFeedback, getAllProjectFeedback } from '@/lib/api/feedback';
3 | import { FeedbackWithUserInputProps } from '@/lib/types';
4 |
5 | export const runtime = 'edge';
6 |
7 | /*
8 | Create Feedback
9 | POST /api/v1/projects/[slug]/feedback
10 | {
11 | title: string;
12 | description: string;
13 | status: string;
14 | tags: [id, id, id]
15 | }
16 | */
17 | export async function POST(req: Request, context: { params: { slug: string } }) {
18 | const { title, description, status, tags, user } = (await req.json()) as FeedbackWithUserInputProps;
19 |
20 | // Validate Request Body
21 | if (!title) {
22 | return NextResponse.json({ error: 'title is required when creating feedback.' }, { status: 400 });
23 | }
24 |
25 | const { data: feedback, error } = await createFeedback(
26 | context.params.slug,
27 | {
28 | title: title || '',
29 | description: description || '',
30 | status: status || '',
31 | project_id: 'dummy-id',
32 | user_id: 'dummy-id',
33 | tags: tags || [],
34 | user: user !== null ? user : undefined,
35 | },
36 | 'route'
37 | );
38 |
39 | // If any errors thrown, return error
40 | if (error) {
41 | return NextResponse.json({ error: error.message }, { status: error.status });
42 | }
43 |
44 | // Return feedback
45 | return NextResponse.json(feedback, { status: 200 });
46 | }
47 |
48 | /*
49 | Get Project Feedback
50 | GET /api/v1/projects/[slug]/feedback
51 | */
52 | export async function GET(req: Request, context: { params: { slug: string } }) {
53 | const { data: feedback, error } = await getAllProjectFeedback(context.params.slug, 'route', false);
54 |
55 | // If any errors thrown, return error
56 | if (error) {
57 | return NextResponse.json({ error: error.message }, { status: error.status });
58 | }
59 |
60 | // Return feedback
61 | return NextResponse.json(feedback, { status: 200 });
62 | }
63 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { deleteFeedbackTagByName } from '@/lib/api/feedback';
3 |
4 | /*
5 | Delete tag by name
6 | DELETE /api/v1/projects/:slug/feedback/tags/:name
7 | */
8 | export async function DELETE(req: Request, context: { params: { slug: string; name: string } }) {
9 | const { data: tag, error } = await deleteFeedbackTagByName(
10 | context.params.slug,
11 | context.params.name,
12 | 'route'
13 | );
14 |
15 | // If any errors thrown, return error
16 | if (error) {
17 | return NextResponse.json({ error: error.message }, { status: error.status });
18 | }
19 |
20 | // Return tag
21 | return NextResponse.json(tag, { status: 200 });
22 | }
23 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { createFeedbackTag, getAllFeedbackTags } from '@/lib/api/feedback';
3 | import { FeedbackTagProps } from '@/lib/types';
4 |
5 | /*
6 | Create new tag
7 | POST /api/v1/projects/:slug/feedback/tags
8 | {
9 | name: string,
10 | color: string
11 | }
12 | */
13 | export async function POST(req: Request, context: { params: { slug: string } }) {
14 | const { name, color } = (await req.json()) as FeedbackTagProps['Insert'];
15 |
16 | const { data: tag, error } = await createFeedbackTag(
17 | context.params.slug,
18 | {
19 | name: name || '',
20 | color: color || '',
21 | },
22 | 'route'
23 | );
24 |
25 | // If any errors thrown, return error
26 | if (error) {
27 | return NextResponse.json({ error: error.message }, { status: error.status });
28 | }
29 |
30 | // Return tag
31 | return NextResponse.json(tag, { status: 200 });
32 | }
33 |
34 | /*
35 | Get all feedback tags
36 | GET /api/v1/projects/:slug/feedback/tags
37 | */
38 | export async function GET(req: Request, context: { params: { slug: string } }) {
39 | const { data: tags, error } = await getAllFeedbackTags(context.params.slug, 'route');
40 |
41 | // If any errors thrown, return error
42 | if (error) {
43 | return NextResponse.json({ error: error.message }, { status: error.status });
44 | }
45 |
46 | // Return tags
47 | return NextResponse.json(tags, { status: 200 });
48 | }
49 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { acceptProjectInvite, deleteProjectInvite } from '@/lib/api/invites';
3 |
4 | /*
5 | Accept project invite
6 | POST /api/v1/projects/[slug]/invites/[inviteId]
7 | */
8 | export async function POST(req: Request, context: { params: { slug: string; inviteId: string } }) {
9 | const { data: invite, error } = await acceptProjectInvite(context.params.inviteId, 'route');
10 |
11 | // If any errors thrown, return error
12 | if (error) {
13 | return NextResponse.json({ error: error.message }, { status: error.status });
14 | }
15 |
16 | // Return success
17 | return NextResponse.json(invite, { status: 200 });
18 | }
19 |
20 | /*
21 | Delete project invite
22 | DELETE /api/v1/projects/[slug]/invites/[inviteId]
23 | */
24 | export async function DELETE(req: Request, context: { params: { slug: string; inviteId: string } }) {
25 | const { data: invite, error } = await deleteProjectInvite(context.params.inviteId, 'route');
26 |
27 | // If any errors thrown, return error
28 | if (error) {
29 | return NextResponse.json({ error: error.message }, { status: error.status });
30 | }
31 |
32 | // Return success
33 | return NextResponse.json(invite, { status: 200 });
34 | }
35 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/invites/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { createProjectInvite, getProjectInvites } from '@/lib/api/invites';
3 |
4 | /*
5 | Get all project invites
6 | GET /api/v1/projects/[slug]/invites
7 | */
8 | export async function GET(req: Request, context: { params: { slug: string } }) {
9 | const { data: invites, error } = await getProjectInvites(context.params.slug, 'route');
10 |
11 | // If any errors thrown, return error
12 | if (error) {
13 | return NextResponse.json({ error: error.message }, { status: error.status });
14 | }
15 |
16 | // Return invites
17 | return NextResponse.json(invites, { status: 200 });
18 | }
19 |
20 | /*
21 | Invite a new member to a project
22 | POST /api/v1/projects/[slug]/members
23 | {
24 | email: string
25 | }
26 | */
27 | export async function POST(req: Request, context: { params: { slug: string } }) {
28 | const { email } = await req.json();
29 |
30 | // Check if email is valid
31 | const emailRegex = new RegExp(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
32 | if (!emailRegex.test(email)) {
33 | return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
34 | }
35 |
36 | // Create invite
37 | const { data: invite, error } = await createProjectInvite(context.params.slug, 'route', email);
38 |
39 | // If any errors thrown, return error
40 | if (error) {
41 | return NextResponse.json({ error: error.message }, { status: error.status });
42 | }
43 |
44 | // Return invite
45 | return NextResponse.json(invite, { status: 200 });
46 | }
47 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/members/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getProjectMembers } from '@/lib/api/projects';
3 |
4 | /*
5 | Get all members of a project
6 | GET /api/v1/projects/[slug]/members
7 | */
8 | export async function GET(req: Request, context: { params: { slug: string } }) {
9 | const { data: members, error } = await getProjectMembers(context.params.slug, 'route');
10 |
11 | // If any errors thrown, return error
12 | if (error) {
13 | return NextResponse.json({ error: error.message }, { status: error.status });
14 | }
15 |
16 | // Return members
17 | return NextResponse.json(members, { status: 200 });
18 | }
19 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/[slug]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { deleteProjectBySlug, getProjectBySlug, updateProjectBySlug } from '@/lib/api/projects';
3 | import { ProjectProps } from '@/lib/types';
4 |
5 | /*
6 | Get project by slug
7 | GET /api/v1/projects/[slug]
8 | */
9 | export async function GET(req: Request, context: { params: { slug: string } }) {
10 | const { data: project, error } = await getProjectBySlug(context.params.slug, 'route');
11 | // If any errors thrown, return error
12 | if (error) {
13 | return NextResponse.json({ error: error.message }, { status: error.status });
14 | }
15 |
16 | // Return project
17 | return NextResponse.json({ project }, { status: 200 });
18 | }
19 |
20 | /*
21 | Update project by slug
22 | PATCH /api/v1/projects/[slug]
23 | {
24 | name: string,
25 | slug: string,
26 | icon: string,
27 | icon_radius: string,
28 | og_image: string
29 | }
30 | */
31 | export async function PATCH(req: Request, context: { params: { slug: string } }) {
32 | const {
33 | name,
34 | slug,
35 | icon,
36 | icon_radius: iconRadius,
37 | og_image: OGImage,
38 | } = (await req.json()) as ProjectProps['Update'];
39 |
40 | const { data: updatedProject, error } = await updateProjectBySlug(
41 | context.params.slug,
42 | { name, slug, icon, icon_radius: iconRadius, og_image: OGImage },
43 | 'route'
44 | );
45 |
46 | // If any errors thrown, return error
47 | if (error) {
48 | return NextResponse.json({ error: error.message }, { status: error.status });
49 | }
50 |
51 | // Return updated project
52 | return NextResponse.json(updatedProject, { status: 200 });
53 | }
54 |
55 | /*
56 | Delete project by slug
57 | DELETE /api/v1/projects/[slug]
58 | */
59 | export async function DELETE(req: Request, context: { params: { slug: string } }) {
60 | const { data, error } = await deleteProjectBySlug(context.params.slug, 'route');
61 |
62 | // If any errors thrown, return error
63 | if (error) {
64 | return NextResponse.json({ error: error.message }, { status: error.status });
65 | }
66 |
67 | // Return success
68 | return NextResponse.json({ data }, { status: 200 });
69 | }
70 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/projects/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { createProject } from '@/lib/api/projects';
3 | import { getUserProjects } from '@/lib/api/user';
4 |
5 | /*
6 | Create Project
7 | POST /api/v1/projects
8 | {
9 | "name": "Project Name",
10 | "slug": "project-slug",
11 | }
12 | */
13 | export async function POST(req: Request) {
14 | // Get Request Body
15 | const { name, slug } = await req.json();
16 |
17 | // Validate Request Body
18 | if (!name || !slug) {
19 | return NextResponse.json({ error: 'name and slug are required.' }, { status: 400 });
20 | }
21 |
22 | // Create Project
23 | const { data: project, error } = await createProject({ name, slug }, 'route');
24 |
25 | // Check for errors
26 | if (error) {
27 | return NextResponse.json({ error: error.message }, { status: error.status });
28 | }
29 |
30 | return NextResponse.json(project, { status: 201 });
31 | }
32 |
33 | /*
34 | Get User Projects
35 | GET /api/v1/projects
36 | */
37 | export async function GET(req: Request) {
38 | // Get User Projects
39 | const { data: projects, error } = await getUserProjects('route');
40 |
41 | // Check for errors
42 | if (error) {
43 | return NextResponse.json({ error: error.message }, { status: error.status });
44 | }
45 |
46 | // Return projects
47 | return NextResponse.json(projects, { status: 200 });
48 | }
49 |
--------------------------------------------------------------------------------
/apps/web/app/api/v1/waitlist/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 |
3 | /*
4 | Enroll in Waitlist
5 | POST /api/v1/waitlist
6 | {
7 | "name": "User Name",
8 | "email": "email@email.com"
9 | }
10 | */
11 | export async function POST(req: Request) {
12 | // Return data
13 | return NextResponse.json({ error: 'Waitlist is closed.' }, { status: 400 });
14 | }
15 |
--------------------------------------------------------------------------------
/apps/web/app/auth/callback/route.ts:
--------------------------------------------------------------------------------
1 | import { cookies } from 'next/headers';
2 | import { NextResponse } from 'next/server';
3 | import { createServerClient, type CookieOptions } from '@supabase/ssr';
4 |
5 | export const dynamic = 'force-dynamic';
6 |
7 | export async function GET(request: Request) {
8 | // The `/auth/callback` route is required for the server-side auth flow implemented
9 | // by the Auth Helpers package. It exchanges an auth code for the user's session.
10 | // https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-sign-in-with-code-exchange
11 | const cookieStore = cookies();
12 | const requestUrl = new URL(request.url);
13 | const code = requestUrl.searchParams.get('code');
14 | const redirect = requestUrl.searchParams.get('successRedirect');
15 |
16 | if (code) {
17 | const supabase = createServerClient(
18 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
19 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
20 | {
21 | cookies: {
22 | get(name: string) {
23 | return cookieStore.get(name)?.value;
24 | },
25 | set(name: string, value: string, options: CookieOptions) {
26 | cookieStore.set({ name, value, ...options });
27 | },
28 | remove(name: string, options: CookieOptions) {
29 | cookieStore.set({ name, value: '', ...options });
30 | },
31 | },
32 | }
33 | );
34 |
35 | await supabase.auth.exchangeCodeForSession(code);
36 | }
37 |
38 | // URL to redirect to after sign in process completes
39 | return NextResponse.redirect(new URL(redirect || '/', requestUrl.origin));
40 | }
41 |
--------------------------------------------------------------------------------
/apps/web/app/dash/(auth)/login/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import { cookies } from 'next/headers';
3 | import Link from 'next/link';
4 | import { redirect } from 'next/navigation';
5 | import { createServerClient } from '@supabase/ssr';
6 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card';
7 | import { UserAuthForm } from '@/components/user-auth-form';
8 |
9 | export const metadata: Metadata = {
10 | title: 'Sign in to Feedbase',
11 | description: 'Sign in to your Feedbase account.',
12 | };
13 |
14 | export default async function SignIn() {
15 | // Create a Supabase client configured to use cookies
16 | const cookieStore = cookies();
17 | const supabase = createServerClient(
18 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
19 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
20 | {
21 | cookies: {
22 | get(name: string) {
23 | return cookieStore.get(name)?.value;
24 | },
25 | },
26 | }
27 | );
28 |
29 | // Retrieve possible session
30 | const {
31 | data: { user },
32 | } = await supabase.auth.getUser();
33 |
34 | // If there is a session, redirect to projects
35 | if (user) {
36 | redirect('/');
37 | }
38 |
39 | return (
40 |
41 |
42 |
43 | Sign In
44 |
45 | Sign in with your email address to continue.
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | Don't have an account?{' '}
54 |
55 | Sign Up
56 |
57 |
58 |
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/apps/web/app/dash/(auth)/signup/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import { cookies } from 'next/headers';
3 | import Link from 'next/link';
4 | import { redirect } from 'next/navigation';
5 | import { createServerClient } from '@supabase/ssr';
6 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card';
7 | import { UserAuthForm } from '@/components/user-auth-form';
8 |
9 | export const metadata: Metadata = {
10 | title: 'Sign up to Feedbase',
11 | description: 'Sign up for a new Feedbase account.',
12 | };
13 |
14 | export default async function SignUp() {
15 | // Create a Supabase client configured to use cookies
16 | const cookieStore = cookies();
17 | const supabase = createServerClient(
18 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
19 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
20 | {
21 | cookies: {
22 | get(name: string) {
23 | return cookieStore.get(name)?.value;
24 | },
25 | },
26 | }
27 | );
28 |
29 | // Retrieve possible session
30 | const {
31 | data: { user },
32 | } = await supabase.auth.getUser();
33 |
34 | // If there is a session, redirect to projects
35 | if (user) {
36 | redirect('/');
37 | }
38 |
39 | return (
40 |
41 |
42 |
43 | Sign up
44 |
45 | Sign up with your email address to continue.
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | Already have an account?{' '}
54 |
55 | Sign In
56 |
57 |
58 |
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/analytics/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from '@ui/components/ui/skeleton';
2 |
3 | export default function FeedbackLoading() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/analytics/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { getProjectAnalytics } from '@/lib/api/projects';
3 | import AnalyticsCards from '@/components/dashboard/analytics/chart-cards';
4 |
5 | export default async function AnalyticsPage({ params }: { params: { slug: string } }) {
6 | const { data, error } = await getProjectAnalytics(params.slug, 'server');
7 |
8 | if (!data) {
9 | return Error: {error?.message}
;
10 | }
11 |
12 | return (
13 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/changelog/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from '@ui/components/ui/skeleton';
2 |
3 | export default function FeedbackLoading() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/feedback/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from '@ui/components/ui/skeleton';
2 |
3 | export default function FeedbackLoading() {
4 | return (
5 | <>
6 |
7 |
8 | >
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/feedback/page.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardDescription, CardHeader, CardTitle } from 'ui/components/ui/card';
2 | import { getAllFeedbackTags, getAllProjectFeedback } from '@/lib/api/feedback';
3 | import FeedbackTable from '@/components/dashboard/feedback/feedback-table';
4 | import FeedbackHeader from '@/components/dashboard/feedback/header-buttons';
5 |
6 | export default async function Feedback({
7 | params,
8 | searchParams,
9 | }: {
10 | params: { slug: string };
11 | searchParams: { tag: string };
12 | }) {
13 | const { data: feedback, error } = await getAllProjectFeedback(params.slug, 'server');
14 | if (error) {
15 | return {error.message}
;
16 | }
17 |
18 | const { data: tags, error: tagsError } = await getAllFeedbackTags(params.slug, 'server');
19 | if (tagsError) {
20 | return {tagsError.message}
;
21 | }
22 |
23 | return (
24 |
25 | {/* Header Row */}
26 |
27 |
28 | {/* Feedback Posts */}
29 | {/* If there is no feedback, show empty text in the center */}
30 | {feedback.length === 0 && (
31 |
32 |
33 | No feedback yet
34 |
35 | Once somone submits feedback, it will show up here. Make sure to share it so others can vote on
36 | it!
37 |
38 |
39 |
40 | )}
41 |
42 | {/* If there is feedback, show feedback list */}
43 | {feedback.length > 0 && }
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export default async function ProjectPage({ params }: { params: { slug: string } }) {
4 | // Redirect to changelog
5 | redirect(`/${params.slug}/changelog`);
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/settings/general/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from 'ui/components/ui/skeleton';
2 |
3 | export default function GeneralLoading() {
4 | return (
5 | // 3 cards with loading state
6 |
7 |
8 |
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/settings/general/page.tsx:
--------------------------------------------------------------------------------
1 | import { getProjectBySlug, getProjectConfigBySlug } from '@/lib/api/projects';
2 | import GeneralConfigCards from '@/components/dashboard/settings/general-cards';
3 |
4 | export default async function GeneralSettings({ params }: { params: { slug: string } }) {
5 | // Fetch project data
6 | const { data: project, error } = await getProjectBySlug(params.slug, 'server');
7 |
8 | if (error) {
9 | return {error.message}
;
10 | }
11 |
12 | // Fetch project config
13 | const { data: projectConfig, error: projectConfigError } = await getProjectConfigBySlug(
14 | params.slug,
15 | 'server'
16 | );
17 |
18 | if (projectConfigError) {
19 | return {projectConfigError.message}
;
20 | }
21 |
22 | return (
23 |
24 | {/* General Card */}
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/settings/hub/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from 'ui/components/ui/skeleton';
2 |
3 | export default function HubLoading() {
4 | return (
5 | // 3 cards with loading state
6 |
7 |
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/settings/hub/page.tsx:
--------------------------------------------------------------------------------
1 | import { getProjectBySlug, getProjectConfigBySlug } from '@/lib/api/projects';
2 | import HubConfigCards from '@/components/dashboard/settings/hub-cards';
3 |
4 | export default async function HubSettings({ params }: { params: { slug: string } }) {
5 | // Fetch project data
6 | const { data: project, error } = await getProjectBySlug(params.slug, 'server');
7 |
8 | if (error) {
9 | return {error.message}
;
10 | }
11 |
12 | // Fetch project config
13 | const { data: projectConfig, error: configError } = await getProjectConfigBySlug(params.slug, 'server');
14 |
15 | if (configError) {
16 | return {configError.message}
;
17 | }
18 |
19 | return (
20 |
21 | {/* Hub Card */}
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/settings/integrations/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from 'ui/components/ui/skeleton';
2 |
3 | export default function IntegrationsLoading() {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/settings/integrations/page.tsx:
--------------------------------------------------------------------------------
1 | import { getProjectConfigBySlug } from '@/lib/api/projects';
2 | import IntegrationCards from '@/components/dashboard/settings/integration-cards';
3 |
4 | export default async function IntegrationsSettings({ params }: { params: { slug: string } }) {
5 | const { data, error } = await getProjectConfigBySlug(params.slug, 'server');
6 |
7 | if (error) {
8 | return Error
;
9 | }
10 |
11 | return ;
12 | }
13 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/settings/layout.tsx:
--------------------------------------------------------------------------------
1 | import { headers } from 'next/headers';
2 | import CategoryTabs from '@/components/dashboard/settings/category-tabs';
3 |
4 | const tabs = [
5 | {
6 | name: 'General',
7 | slug: 'general',
8 | },
9 | {
10 | name: 'Hub',
11 | slug: 'hub',
12 | },
13 | {
14 | name: 'Team',
15 | slug: 'team',
16 | },
17 | {
18 | name: 'Integrations',
19 | slug: 'integrations',
20 | },
21 | ];
22 |
23 | export default function SettingsLayout({
24 | children,
25 | params,
26 | }: {
27 | children: React.ReactNode;
28 | params: { slug: string };
29 | }) {
30 | // Headers
31 | const headerList = headers();
32 | const pathname = headerList.get('x-pathname');
33 |
34 | // Retrieve the currently active tab
35 | const activeTabIndex = tabs.findIndex((tab) => pathname?.split('/')[3] === tab.slug);
36 |
37 | return (
38 |
39 | {/* Navigation tabs */}
40 |
41 |
42 | {/* Content */}
43 | {children}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export default async function Dashboard({ params }: { params: { slug: string } }) {
4 | // Redirect to settings/general
5 | redirect(`/${params.slug}/settings/general`);
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/settings/team/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from 'ui/components/ui/skeleton';
2 |
3 | export default function TeamLoading() {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/apps/web/app/dash/[slug]/settings/team/page.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from 'ui/components/ui/card';
2 | import { getProjectInvites } from '@/lib/api/invites';
3 | import { getProjectMembers } from '@/lib/api/projects';
4 | import { TeamTable } from '@/components/dashboard/settings/team-table';
5 |
6 | export default async function TeamSettings({ params }: { params: { slug: string } }) {
7 | const { data: members, error } = await getProjectMembers(params.slug, 'server');
8 |
9 | if (error) {
10 | return {error.message}
;
11 | }
12 |
13 | const { data: invites, error: invitesError } = await getProjectInvites(params.slug, 'server');
14 |
15 | if (invitesError) {
16 | return {invitesError.message}
;
17 | }
18 |
19 | return (
20 |
21 |
22 |
23 | Team
24 | Add or remove users that have access to this project.
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/apps/web/app/dash/invite/[inviteId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { notFound } from 'next/navigation';
2 | import { getProjectInvite } from '@/lib/api/invites';
3 | import { getCurrentUser } from '@/lib/api/user';
4 | import ProjectInviteForm from '@/components/layout/accept-invite-form';
5 |
6 | export default async function ProjectInvite({ params }: { params: { inviteId: string } }) {
7 | const { data: invite, error: inviteError } = await getProjectInvite(params.inviteId, 'server');
8 |
9 | if (inviteError) {
10 | return notFound();
11 | }
12 |
13 | // Get current user
14 | const { data: user } = await getCurrentUser('server');
15 |
16 | return (
17 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/apps/web/app/dash/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 | import { getUserProjects } from '@/lib/api/user';
3 | import Onboarding from '@/components/layout/onboarding';
4 |
5 | export default async function Projects() {
6 | const { data: projects, error } = await getUserProjects('server');
7 |
8 | if (error) {
9 | // Redirect to login if the user is not authenticated
10 | if (error.status === 401) {
11 | return redirect('/login');
12 | }
13 |
14 | return {error.message}
;
15 | }
16 |
17 | // Redirect to the first project
18 | if (projects.length > 0) {
19 | return redirect(`/${projects[0].slug}`);
20 | }
21 |
22 | // TODO: Improve this and make this redirect to an onboarding page if the user has no projects
23 | return (
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/feedbase/18dca32a923e12b3af54f1f761c063c7925dfeef/apps/web/app/favicon.ico
--------------------------------------------------------------------------------
/apps/web/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | /* App root background */
8 | --root-background: 230 35% 3%; /* #000000 */
9 |
10 | /* Utility background (Buttons, inputs, etc) */
11 | --background: 230 11% 12%; /* #0A0C10 | #060606*/
12 | --foreground: 210 40% 98%; /* #D6D6D6 */
13 |
14 | /* Muted background (Skeletons, Avatar, tabs, etc) */
15 | --muted: 230 11% 8%; /* #20242c */
16 | --muted-foreground: 215 6% 62%;
17 |
18 | --popover: 230 35% 3%; /* #0A0C10 */
19 | --popover-foreground: 210 40% 98%;
20 |
21 | --card: 230 35% 3%; /* #0A0C10 */
22 | --card-foreground: 210 40% 98%;
23 |
24 | --border: 222 9% 22%; /* #20242c */
25 | --input: 222 9% 22%; /* #20242c */
26 |
27 | --primary: 210 40% 98%;
28 | --primary-foreground: 230 35% 3%;
29 |
30 | /* Hover & active backgrounds (Nav buttons, etc) */
31 | --secondary: 227 11% 12%; /* #20242c */
32 | --secondary-foreground: 210 40% 98%;
33 |
34 | /* Button background hovers */
35 | --accent: 227 11% 15%; /* #20242c */
36 | --accent-foreground: 210 40% 98%;
37 |
38 | --destructive: 0 84.2% 60.2%;
39 | --destructive-foreground: 210 40% 98%;
40 |
41 | /* Selection & accent color */
42 | --highlight: 231 100% 78%;
43 |
44 | --ring: 230 9% 13%; /* #20242c */
45 |
46 | --radius: 0.5rem;
47 | }
48 |
49 | .light {
50 | /* App root background */
51 | --root-background: 0 0% 100%; /* #000000 */
52 | --background: 0 0% 100%;
53 | --foreground: 240 10% 3.9%;
54 | --card: 0 0% 100%;
55 | --card-foreground: 240 10% 3.9%;
56 | --popover: 0 0% 100%;
57 | --popover-foreground: 240 10% 3.9%;
58 | --primary: 240 5.9% 10%;
59 | --primary-foreground: 0 0% 98%;
60 | --secondary: 240 4.8% 95.9%;
61 | --secondary-foreground: 240 5.9% 10%;
62 | --muted: 240 4.8% 95.9%;
63 | --muted-foreground: 240 3.8% 46.1%;
64 | --accent: 240 4.8% 95.9%;
65 | --accent-foreground: 240 5.9% 10%;
66 | --destructive: 0 84.2% 60.2%;
67 | --destructive-foreground: 0 0% 98%;
68 | --border: 240 5.9% 90%;
69 | --input: 240 5.9% 90%;
70 | --highlight: 231 100% 78%;
71 | --ring: 240 5.9% 10%;
72 | }
73 | }
74 |
75 | @layer base {
76 | * {
77 | @apply border-border;
78 | }
79 | body {
80 | @apply bg-root text-foreground selection:bg-highlight/20 selection:text-highlight;
81 | }
82 | }
--------------------------------------------------------------------------------
/apps/web/app/home/(links)/demo/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 | import { formatRootUrl } from '@/lib/utils';
3 |
4 | export default function Demo() {
5 | redirect(formatRootUrl('hub'));
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/app/home/(links)/deploy-vercel/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export default function VercelDeploy() {
4 | redirect(
5 | 'https://vercel.com/new/clone?repository-url=https://github.com/chroxify/feedbase&env=NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY,NEXT_PUBLIC_ROOT_DOMAIN&project-name=Feedbase&repo-name=feedbase'
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/apps/web/app/home/(links)/discord/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export default function Docs() {
4 | redirect('https://discord.gg/UmWgwcxbpy');
5 | }
6 |
--------------------------------------------------------------------------------
/apps/web/app/home/(links)/docs/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 | import { formatRootUrl } from '@/lib/utils';
3 |
4 | export default function Docs() {
5 | redirect(formatRootUrl('docs'));
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/app/home/(links)/github/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export default function Github() {
4 | redirect('https://github.com/chroxify/feedbase');
5 | }
6 |
--------------------------------------------------------------------------------
/apps/web/app/home/(links)/twitter/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export default function Github() {
4 | redirect('https://x.com/0xChroxify');
5 | }
6 |
--------------------------------------------------------------------------------
/apps/web/app/home/(pages)/layout.tsx:
--------------------------------------------------------------------------------
1 | import HomeNav from '@/components/home/header';
2 |
3 | export default function HomeLayout({ children }: { children: React.ReactNode }) {
4 | return (
5 |
6 |
7 |
8 | {children}
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/apps/web/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import './globals.css';
2 | import { Metadata, Viewport } from 'next';
3 | import Script from 'next/script';
4 | import { cn } from '@ui/lib/utils';
5 | import { SpeedInsights } from '@vercel/speed-insights/next';
6 | import { GeistSans } from 'geist/font';
7 | import { Toaster } from 'sonner';
8 | import { formatRootUrl } from '@/lib/utils';
9 |
10 | export const metadata: Metadata = {
11 | title: 'Feedbase',
12 | description: 'Collect feedback & communicate product updates with ease.',
13 | metadataBase: new URL(formatRootUrl()),
14 | openGraph: {
15 | images: [
16 | {
17 | url: '/og-image.png',
18 | },
19 | ],
20 | },
21 | // PWA
22 | manifest: '/manifest.json',
23 | icons: [
24 | {
25 | rel: 'apple-touch-icon',
26 | url: '/favicon.ico',
27 | },
28 | ],
29 | };
30 |
31 | export const viewport: Viewport = {
32 | // Prevents auto-zoom on mobile for input fields
33 | width: 'device-width',
34 | initialScale: 1,
35 | maximumScale: 1,
36 | themeColor: '#06060A',
37 | };
38 |
39 | export default function RootLayout({ children }: { children: React.ReactNode }) {
40 | return (
41 |
42 |
43 | {process.env.NODE_ENV === 'production' && (
44 |
49 | )}
50 |
51 |
52 |
53 | {children}
54 |
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/apps/web/app/robots.ts:
--------------------------------------------------------------------------------
1 | import { MetadataRoute } from 'next';
2 |
3 | export default function robots(): MetadataRoute.Robots {
4 | return {
5 | rules: {
6 | userAgent: '*',
7 | allow: '/',
8 | disallow: '/api/v1/',
9 | },
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/apps/web/components/dashboard/changelogs/api-sheet.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { toast } from 'sonner';
5 | import { Alert, AlertDescription, AlertTitle } from 'ui/components/ui/alert';
6 | import { Button } from 'ui/components/ui/button';
7 | import { Input } from 'ui/components/ui/input';
8 | import { Label } from 'ui/components/ui/label';
9 | import {
10 | Sheet,
11 | SheetContent,
12 | SheetDescription,
13 | SheetFooter,
14 | SheetHeader,
15 | SheetTitle,
16 | SheetTrigger,
17 | } from 'ui/components/ui/sheet';
18 | import { formatRootUrl } from '@/lib/utils';
19 | import { CodeIcon } from '@/components/shared/icons/icons-animated';
20 | import LottiePlayer from '@/components/shared/lottie-player';
21 |
22 | export function ApiSheet({ projectSlug }: { projectSlug: string }) {
23 | const [isHover, setIsHover] = useState(false);
24 |
25 | return (
26 |
27 |
28 | {
32 | setIsHover(true);
33 | }}
34 | onMouseLeave={() => {
35 | setIsHover(false);
36 | }}
37 | className='flex items-center gap-2 font-light'>
38 |
39 | API
40 |
41 |
42 |
43 |
44 |
45 | Early Feature
46 |
47 | Kindly note that this feature is currently in alpha and thus has limited customizability.
48 |
49 |
50 | Changelogs API
51 |
52 | The Changelogs API enables seamless integration of public project updates into your custom
53 | websites and apps for enhanced personalization.
54 |
55 |
56 |
57 | {/* Endpoint */}
58 |
59 | Public Endpoint
60 |
61 | {
64 | navigator.clipboard.writeText(formatRootUrl('', `/api/v1/${projectSlug}/changelogs`));
65 | toast.success('Copied to clipboard');
66 | }}>
67 | Copy Endpoint
68 |
69 |
70 |
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/apps/web/components/dashboard/changelogs/content-editor.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import { Highlight } from '@tiptap/extension-highlight';
5 | import { Link } from '@tiptap/extension-link';
6 | import { Typography } from '@tiptap/extension-typography';
7 | import { AnyExtension, EditorContent, useEditor } from '@tiptap/react';
8 | import { StarterKit } from '@tiptap/starter-kit';
9 | import { ChangelogProps } from '@/lib/types';
10 | import TooltipLabel from '@/components/shared/tooltip-label';
11 |
12 | export default function RichTextEditor({
13 | data,
14 | setData,
15 | }: {
16 | data: ChangelogProps['Row'];
17 | setData: React.Dispatch>;
18 | }) {
19 | const editor = useEditor({
20 | extensions: [
21 | StarterKit as AnyExtension,
22 | Highlight,
23 | Link.configure({
24 | HTMLAttributes: {
25 | class: 'cursor-pointer',
26 | },
27 | }),
28 | Typography,
29 | ],
30 | content: data.content,
31 | editorProps: {
32 | attributes: {
33 | class: 'prose prose-invert prose-sm sm:prose-base dark:prose-invert m-5 focus:outline-none',
34 | },
35 | },
36 | onUpdate: ({ editor }) => {
37 | setData({ ...data, content: editor.getHTML() });
38 | },
39 | });
40 |
41 | return (
42 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/apps/web/components/dashboard/changelogs/date-picker.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import { cn } from '@ui/lib/utils';
5 | import { format } from 'date-fns';
6 | import { Button } from 'ui/components/ui/button';
7 | import { Calendar } from 'ui/components/ui/calendar';
8 | import { Popover, PopoverContent, PopoverTrigger } from 'ui/components/ui/popover';
9 | import { ChangelogProps } from '@/lib/types';
10 |
11 | export function PublishDatePicker({
12 | className,
13 | data,
14 | setData,
15 | }: {
16 | className?: string;
17 | data: ChangelogProps['Row'];
18 | setData: React.Dispatch>;
19 | }) {
20 | return (
21 |
22 |
23 |
30 | {data.publish_date ? (
31 | {format(new Date(data.publish_date), 'P')}
32 | ) : (
33 | Pick a date
34 | )}
35 |
36 |
37 |
38 | {
42 | setData({ ...data, publish_date: date?.toISOString() ?? null });
43 | }}
44 | />
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/apps/web/components/dashboard/feedback/header-buttons.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { usePathname, useRouter, useSearchParams } from 'next/navigation';
5 | import { Input } from 'ui/components/ui/input';
6 | import useCreateQueryString from '@/lib/hooks/use-create-query';
7 | import { FeedbackTagProps } from '@/lib/types';
8 | import { SearchIcon } from '@/components/shared/icons/icons-animated';
9 | import LottiePlayer from '@/components/shared/lottie-player';
10 | import { StatusCombobox } from './status-combobox';
11 | import { TagCombobox } from './tag-combobox';
12 |
13 | export default function FeedbackHeader({ tags }: { tags: FeedbackTagProps['Row'][] }) {
14 | const searchParams = useSearchParams();
15 | const createQueryString = useCreateQueryString(searchParams);
16 | const router = useRouter();
17 | const pathname = usePathname();
18 | const [animate, setAnimate] = useState(false);
19 |
20 | // Create comboTags array from tags: { value: name.toLowerCase(), label: name }
21 | const comboTags = tags.map((tag) => {
22 | return {
23 | value: tag.name.toLowerCase(),
24 | label: tag.name,
25 | color: tag.color,
26 | };
27 | });
28 |
29 | return (
30 |
31 | {/* Search */}
32 |
{
36 | setAnimate(true);
37 | }}
38 | onBlur={() => {
39 | setAnimate(false);
40 | }}>
41 | {/* Input */}
42 | {
46 | router.push(`${pathname}?${createQueryString('search', e.target.value)}`);
47 | }}
48 | />
49 |
50 | {/* Icon */}
51 |
56 |
57 |
58 | {/* Filter buttons */}
59 |
60 | {/* Status */}
61 | {
64 | router.push(`${pathname}?${createQueryString('status', status)}`);
65 | }}
66 | />
67 |
68 | {/* Tags */}
69 | {
73 | router.push(`${pathname}?${createQueryString('tags', tag.join(','))}`);
74 | }}
75 | />
76 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/apps/web/components/dashboard/settings/category-tabs.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 | import Link from 'next/link';
5 | import { usePathname } from 'next/navigation';
6 | import { cn } from '@ui/lib/utils';
7 | import { Button } from 'ui/components/ui/button';
8 | import { CategoryTabProps } from '@/lib/types';
9 |
10 | export default function CategoryTabs({
11 | tabs,
12 | initialTabIndex,
13 | projectSlug,
14 | }: {
15 | tabs: CategoryTabProps[];
16 | initialTabIndex: number;
17 | projectSlug: string;
18 | }) {
19 | const [activeTab, setActiveTab] = useState(initialTabIndex);
20 |
21 | const pathname = usePathname();
22 |
23 | // Get the current tab based on the pathname
24 | useEffect(() => {
25 | const currentTab = tabs.findIndex((tab) => pathname.split('/')[3] === tab.slug);
26 |
27 | if (currentTab !== -1) {
28 | setActiveTab(currentTab);
29 | }
30 | }, [pathname, tabs]);
31 |
32 | return (
33 |
34 | {tabs.map((tab, index) => (
35 |
36 |
42 | {tab.name}
43 |
44 |
45 | ))}
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/apps/web/components/home/footer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import { cn } from '@ui/lib/utils';
5 | import { formatRootUrl } from '@/lib/utils';
6 | import { Icons } from '../shared/icons/icons-static';
7 | import { navTabs } from './header';
8 |
9 | export default function HomeFooter({ fixed }: { fixed?: boolean }) {
10 | return (
11 |
12 |
13 | {/* Socials */}
14 |
15 |
20 |
21 |
22 |
23 |
28 |
29 |
30 |
31 |
32 | {/* Links */}
33 |
34 | {navTabs.map((tab) => (
35 |
39 | {tab.label}
40 |
41 | ))}
42 |
43 |
44 | {/* Name */}
45 |
46 | © {new Date().getFullYear()} Feedbase
47 |
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/apps/web/components/home/spotlight-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useRef, useState } from 'react';
4 | import { cn } from '@ui/lib/utils';
5 |
6 | export default function BentoCardWrapper({
7 | className,
8 | children,
9 | }: {
10 | className?: string;
11 | children: React.ReactNode;
12 | }) {
13 | const divRef = useRef(null);
14 | const [position, setPosition] = useState({ x: 0, y: 0 });
15 | const [opacity, setOpacity] = useState(0);
16 |
17 | const handleMouseMove = (e: React.MouseEvent) => {
18 | if (!divRef.current) return;
19 |
20 | const div = divRef.current;
21 | const rect = div.getBoundingClientRect();
22 |
23 | setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
24 | };
25 |
26 | const handleFocus = () => {
27 | setOpacity(1);
28 | };
29 |
30 | const handleBlur = () => {
31 | // setOpacity(0);
32 | };
33 |
34 | const handleMouseEnter = () => {
35 | setOpacity(1);
36 | };
37 |
38 | const handleMouseLeave = () => {
39 | setOpacity(0);
40 | };
41 |
42 | return (
43 |
54 |
61 | {children}
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/apps/web/components/hub/analytics-wrapper.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import { cn } from '@ui/lib/utils';
5 |
6 | export default function AnalyticsWrapper({
7 | children,
8 | projectSlug,
9 | feedbackId,
10 | changelogId,
11 | className,
12 | }: {
13 | children: React.ReactNode;
14 | projectSlug: string;
15 | feedbackId?: string;
16 | changelogId?: string;
17 | className?: string;
18 | }) {
19 | // Register View
20 | useEffect(() => {
21 | fetch(`/api/v1/${projectSlug}/views`, {
22 | method: 'POST',
23 | body: JSON.stringify({
24 | feedbackId,
25 | changelogId,
26 | }),
27 | });
28 | }, [projectSlug, feedbackId, changelogId]);
29 |
30 | return {children}
;
31 | }
32 |
--------------------------------------------------------------------------------
/apps/web/components/hub/modals/login-signup-modal.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import {
5 | ResponsiveDialog,
6 | ResponsiveDialogContent,
7 | ResponsiveDialogDescription,
8 | ResponsiveDialogHeader,
9 | ResponsiveDialogTitle,
10 | ResponsiveDialogTrigger,
11 | } from '@ui/components/ui/responsive-dialog';
12 | import { Separator } from '@ui/components/ui/separator';
13 | import { Tabs, TabsContent, TabsList, TabsTrigger } from 'ui/components/ui/tabs';
14 | import { UserAuthForm } from '@/components/user-auth-form';
15 |
16 | export default function AuthModal({
17 | projectSlug,
18 | children,
19 | disabled,
20 | }: {
21 | projectSlug: string;
22 | children: React.ReactNode;
23 | disabled?: boolean;
24 | }) {
25 | const [authType, setAuthType] = useState<'sign-in' | 'sign-up'>('sign-in');
26 |
27 | return (
28 |
29 | {children}
30 |
31 |
32 | {authType === 'sign-in' ? 'Sign In' : 'Sign Up'}
33 |
34 | {authType === 'sign-in' ? 'Sign in' : 'Sign up'} with your email address to continue.
35 |
36 |
37 | {
40 | setAuthType(value as 'sign-in' | 'sign-up');
41 | }}>
42 |
43 | Sign In
44 | Sign Up
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/apps/web/components/hub/theme-wrapper.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useState } from 'react';
4 | import { useServerInsertedHTML } from 'next/navigation';
5 | import { createStyleRegistry, StyleRegistry } from 'styled-jsx';
6 | import { ProjectConfigWithoutSecretProps } from '@/lib/types';
7 |
8 | export default function CustomThemeWrapper({
9 | children,
10 | projectConfig,
11 | }: {
12 | children: React.ReactNode;
13 | projectConfig: ProjectConfigWithoutSecretProps;
14 | }) {
15 | // eslint-disable-next-line react/hook-use-state
16 | const [jsxStyleRegistry] = useState(() => createStyleRegistry());
17 |
18 | useServerInsertedHTML(() => {
19 | const styles = jsxStyleRegistry.styles();
20 | jsxStyleRegistry.flush();
21 | return {styles}
;
22 | });
23 |
24 | return (
25 |
26 |
27 | {projectConfig.custom_theme === 'custom' && (
28 |
54 | )}
55 | {children}
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/apps/web/components/layout/nav-tabs.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 | import Link from 'next/link';
5 | import { usePathname } from 'next/navigation';
6 | import { cn } from '@ui/lib/utils';
7 | import { Button } from 'ui/components/ui/button';
8 | import { NavbarTabProps } from '@/lib/types';
9 | import LottiePlayer from '@/components/shared/lottie-player';
10 |
11 | export default function NavTabs({
12 | tabs,
13 | initialTabIndex,
14 | projectSlug,
15 | }: {
16 | tabs: NavbarTabProps[];
17 | initialTabIndex: number;
18 | projectSlug: string;
19 | }) {
20 | const [activeTab, setActiveTab] = useState(initialTabIndex);
21 | const [isHover, setIsHover] = useState('');
22 | const pathname = usePathname();
23 |
24 | // Check current active tab based on url
25 | useEffect(() => {
26 | // Check if any of the tab slugs are in the pathname
27 | const currentTab = tabs.findIndex((tab) => pathname.split('/')[2] === tab.slug);
28 |
29 | // If tab is found, set it as active
30 | if (currentTab !== -1) {
31 | setActiveTab(currentTab);
32 | }
33 | }, [pathname, tabs]);
34 |
35 | return (
36 |
37 | {tabs.map((tab, index) => (
38 | // If roadmap, don't link to the page
39 |
43 |
{
46 | setIsHover(tab.slug);
47 | }}
48 | onMouseLeave={() => {
49 | setIsHover('');
50 | }}
51 | className={cn(
52 | 'text-foreground/[85%] hover:text-foreground w-full items-center justify-start gap-1 border border-transparent p-1 font-light',
53 | activeTab === index && 'bg-secondary text-foreground hover:bg-secondary'
54 | )}>
55 | {/* Icon */}
56 |
57 |
58 |
59 |
60 | {/* Title */}
61 | {tab.name}
62 |
63 |
64 | ))}
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/apps/web/components/layout/sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { NavbarTabProps, ProjectProps } from '@/lib/types';
2 | import NavTabs from '@/components/layout/nav-tabs';
3 | import ProjectDropdown from '@/components/layout/project-dropdown';
4 |
5 | export default async function Sidebar({
6 | tabs,
7 | projects,
8 | activeTabIndex,
9 | currentProject,
10 | }: {
11 | tabs: NavbarTabProps[];
12 | projects: ProjectProps['Row'][];
13 | activeTabIndex: number;
14 | currentProject: ProjectProps['Row'];
15 | }) {
16 | return (
17 |
18 |
19 | {/* Projects */}
20 |
21 |
22 | {/* Main Tabs */}
23 |
24 |
25 | {/* Footer Buttons */}
26 | {/*
27 |
28 |
31 |
32 |
37 |
38 |
39 |
40 | Feedback
41 |
42 |
43 |
44 |
54 |
*/}
55 | {/*
*/}
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/apps/web/components/layout/theme-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { MoonIcon, SunIcon } from '@heroicons/react/24/solid';
4 | import { useTheme } from 'next-themes';
5 | import { Button } from 'ui/components/ui/button';
6 |
7 | export default function ToggleThemeButton() {
8 | const { theme, setTheme } = useTheme();
9 |
10 | function toggleTheme() {
11 | setTheme(theme === 'light' ? 'dark' : 'light');
12 | }
13 |
14 | return (
15 | {
19 | toggleTheme();
20 | }}>
21 |
22 | {theme === 'dark' ? : }
23 |
24 | {theme === 'dark' ? 'Light ' : 'Dark '}
25 | Mode
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/components/layout/title-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { usePathname } from 'next/navigation';
4 | import { IconObject } from '@/components/shared/icons/icons-animated';
5 |
6 | interface TitleProviderProps {
7 | tabs: {
8 | name: string;
9 | icon: IconObject;
10 | slug: string;
11 | }[];
12 | initialTitle: string;
13 | className?: string;
14 | }
15 |
16 | export default function TitleProvider({ tabs, initialTitle, className }: TitleProviderProps) {
17 | const pathname = usePathname();
18 |
19 | const currentTitle = tabs.find((tab) => pathname?.includes(tab.slug))?.name;
20 |
21 | return {currentTitle || initialTitle}
;
22 | }
23 |
--------------------------------------------------------------------------------
/apps/web/components/layout/unsubscribe-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useRouter } from 'next/navigation';
4 | import { Button } from '@ui/components/ui/button';
5 | import { toast } from 'sonner';
6 | import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card';
7 | import { ProjectProps } from '@/lib/types';
8 |
9 | export default function UnsubscribeChangelogCard({
10 | project,
11 | subId,
12 | }: {
13 | project: ProjectProps['Row'];
14 | subId: string;
15 | }) {
16 | const router = useRouter();
17 |
18 | // on click unsubscribe
19 | async function unsubscribe() {
20 | const promise = new Promise((resolve, reject) => {
21 | fetch(`/api/v1/${project.slug}/changelogs/subscribers`, {
22 | method: 'DELETE',
23 | headers: {
24 | 'Content-Type': 'application/json',
25 | },
26 | body: JSON.stringify({
27 | subId,
28 | }),
29 | })
30 | .then((res) => res.json())
31 | .then((data) => {
32 | if (data.error) {
33 | reject(data.error);
34 | } else {
35 | resolve(data);
36 | }
37 | })
38 | .catch((err) => {
39 | reject(err.message);
40 | });
41 | });
42 |
43 | toast.promise(promise, {
44 | loading: 'Unsubscribing...',
45 | success: () => {
46 | router.push(`/`);
47 | return `Unsubscribed! Redirecting...`;
48 | },
49 | error: (err) => {
50 | return err;
51 | },
52 | });
53 | }
54 |
55 | return (
56 |
57 |
58 | Unsubscribe from {project.name} Changelogs
59 |
60 | You will no longer receive changelog updates from {project.name}. You can resubscribe at any time.
61 |
62 |
63 |
64 | Unsubscribe
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/apps/web/components/logo-provider.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@ui/lib/utils';
2 | import { Icons } from '@/components/shared/icons/icons-static';
3 |
4 | export default function LogoProvider({ className }: { className?: string }) {
5 | return ;
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/components/shared/icons/icons-animated.ts:
--------------------------------------------------------------------------------
1 | export interface IconObject {
2 | dark: Record | string;
3 | light: Record | string;
4 | }
5 |
6 | function createIconObject(iconName: string): IconObject {
7 | return {
8 | dark: require(`@/components/shared/icons/${iconName}-dark.json`),
9 | light: require(`@/components/shared/icons/${iconName}-light.json`),
10 | };
11 | }
12 |
13 | export const FeedbackIcon = createIconObject('feedback');
14 | export const CalendarIcon = createIconObject('calendar');
15 | export const SettingsIcon = createIconObject('settings');
16 | export const TagLabelIcon = createIconObject('tag-label');
17 | export const CodeIcon = createIconObject('code');
18 | export const LogoutIcon = createIconObject('logout');
19 | export const ProfileIcon = createIconObject('profile');
20 | export const SearchIcon = createIconObject('search');
21 | export const ChatIcon = createIconObject('chat');
22 | export const AnalyticsIcon = createIconObject('analytics');
23 |
--------------------------------------------------------------------------------
/apps/web/components/shared/placeholder.css:
--------------------------------------------------------------------------------
1 | .tiptap p.is-editor-empty:first-child::before {
2 | color: hsla(210, 40%, 98%, 0.5);
3 | font-weight: 200;
4 | content: attr(data-placeholder);
5 | float: left;
6 | height: 0;
7 | pointer-events: none;
8 | }
9 |
10 | /* Light mode */
11 | :is(.light .tiptap) p.is-editor-empty:first-child::before {
12 | color: hsla(210, 40%, 2%, 0.5);
13 | font-weight: 200;
14 | content: attr(data-placeholder);
15 | float: left;
16 | height: 0;
17 | pointer-events: none;
18 | }
--------------------------------------------------------------------------------
/apps/web/components/shared/tiptap-editor.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import './placeholder.css';
4 | import React from 'react';
5 | import { CharacterCount } from '@tiptap/extension-character-count';
6 | import { Highlight } from '@tiptap/extension-highlight';
7 | import { Link } from '@tiptap/extension-link';
8 | import { Placeholder } from '@tiptap/extension-placeholder';
9 | import { Typography } from '@tiptap/extension-typography';
10 | import { AnyExtension, EditorContent, useEditor } from '@tiptap/react';
11 | import { StarterKit } from '@tiptap/starter-kit';
12 | import { cn } from '@ui/lib/utils';
13 |
14 | export default function RichTextEditor({
15 | content,
16 | setContent,
17 | placeholder,
18 | className,
19 | characterLimit,
20 | proseInvert,
21 | }: {
22 | content: string;
23 | setContent: React.Dispatch>;
24 | placeholder?: string;
25 | className?: string;
26 | characterLimit?: number;
27 | proseInvert?: boolean;
28 | }) {
29 | const editor = useEditor({
30 | extensions: [
31 | StarterKit as AnyExtension,
32 | Highlight,
33 | Typography,
34 | Link.configure({
35 | HTMLAttributes: {
36 | class: 'cursor-pointer',
37 | },
38 | }),
39 | Placeholder.configure({
40 | placeholder: placeholder || 'Write something...',
41 | }),
42 | CharacterCount.configure({
43 | limit: characterLimit || undefined,
44 | }),
45 | ],
46 | content,
47 | editorProps: {
48 | attributes: {
49 | class: `prose prose-sm dark:prose-invert focus:outline-none${proseInvert ? ' prose-invert' : ''}`,
50 | },
51 | },
52 | onUpdate: ({ editor }) => {
53 | setContent(editor.getHTML());
54 | },
55 | });
56 |
57 | return (
58 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/apps/web/components/shared/tooltip-label.tsx:
--------------------------------------------------------------------------------
1 | import { HelpCircle } from 'lucide-react';
2 | import { Label } from 'ui/components/ui/label';
3 | import DefaultTooltip from './tooltip';
4 |
5 | export default function TooltipLabel({ label, tooltip }: { label: string; tooltip: string }) {
6 | return (
7 |
8 | {label}
9 |
10 |
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/apps/web/components/shared/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui/components/ui/tooltip';
2 |
3 | export default function DefaultTooltip({
4 | children,
5 | content,
6 | disabled,
7 | }: {
8 | children: React.ReactNode;
9 | content: React.ReactNode;
10 | disabled?: boolean;
11 | }) {
12 | return (
13 |
14 |
15 |
16 | {children}
17 |
18 |
19 | {content}
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/apps/web/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import { ThemeProvider as NextThemesProvider } from 'next-themes';
5 | import { type ThemeProviderProps } from 'next-themes/dist/types';
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children} ;
9 | }
10 |
--------------------------------------------------------------------------------
/apps/web/emails/index.ts:
--------------------------------------------------------------------------------
1 | import { JSXElementConstructor, ReactElement } from 'react';
2 | import { Resend } from 'resend';
3 |
4 | export const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null;
5 |
6 | export const sendEmail = async ({
7 | email,
8 | subject,
9 | react,
10 | marketing,
11 | test = process.env.NODE_ENV === 'development',
12 | }: {
13 | email: string;
14 | subject: string;
15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
16 | react: ReactElement>;
17 | marketing?: boolean;
18 | test?: boolean;
19 | }) => {
20 | if (!resend) {
21 | throw new Error(
22 | 'Resend is not configured. You need to add a RESEND_API_KEY in your .env file for emails to work.'
23 | );
24 | }
25 | return resend.emails.send({
26 | from: marketing ? 'Christo from Feedbase ' : 'Feedbase ',
27 | to: test ? 'delivered@resend.dev' : email,
28 | subject,
29 | react,
30 | });
31 | };
32 |
33 | export const sendBatchEmails = async ({
34 | emails,
35 | subject,
36 | reactEmails,
37 | headers,
38 | marketing,
39 | test = process.env.NODE_ENV === 'development',
40 | }: {
41 | emails: string[];
42 | subject: string;
43 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
44 | reactEmails: ReactElement>[];
45 | headers?: Record[];
46 | marketing?: boolean;
47 | test?: boolean;
48 | }) => {
49 | if (!resend) {
50 | throw new Error(
51 | 'Resend is not configured. You need to add a RESEND_API_KEY in your .env file for emails to work.'
52 | );
53 | }
54 |
55 | if (emails.length !== reactEmails.length) {
56 | throw new Error('emails and reactEmails arrays must be the same length.');
57 | }
58 |
59 | if (headers && emails.length !== headers.length) {
60 | throw new Error('emails and headers arrays must be the same length.');
61 | }
62 |
63 | return resend.batch.create(
64 | emails.map((email) => ({
65 | from: marketing ? 'Christo from Feedbase ' : 'Feedbase ',
66 | to: test ? 'delivered@resend.dev' : email,
67 | subject,
68 | headers: headers ? headers[emails.indexOf(email)] : undefined,
69 | react: reactEmails[emails.indexOf(email)],
70 | }))
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/apps/web/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const DASH_DOMAIN =
2 | process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
3 | ? `https://dash.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`
4 | : 'http://dash.localhost:3000';
5 |
6 | export const PROSE_CN =
7 | 'prose prose-invert prose-p:font-extralight prose-zinc text-foreground/70 font-light prose-headings:font-medium prose-headings:text-foreground/80 prose-strong:text-foreground/80 prose-strong:font-normal prose-code:text-foreground/70 prose-code:font-light prose-code:font-monospace prose-blockquote:text-foreground/80 prose-blockquote:font-normal';
8 |
--------------------------------------------------------------------------------
/apps/web/lib/hooks/use-create-query.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { ReadonlyURLSearchParams } from 'next/navigation';
3 |
4 | const useCreateQueryString = (searchParams: ReadonlyURLSearchParams) =>
5 | useCallback(
6 | (name: string, value: string): string => {
7 | // If value is empty, remove the query param
8 | if (!value) {
9 | const params = new URLSearchParams(searchParams);
10 | params.delete(name.toLowerCase());
11 | return params.toString();
12 | }
13 |
14 | // Set the query param
15 | const params = new URLSearchParams(searchParams);
16 | params.set(name.toLowerCase(), value.toLowerCase());
17 | return params.toString();
18 | },
19 | [searchParams]
20 | );
21 |
22 | export default useCreateQueryString;
23 |
--------------------------------------------------------------------------------
/apps/web/lib/hooks/use-scroll.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react';
2 |
3 | export default function useScroll(threshold: number) {
4 | const [scrolled, setScrolled] = useState(false);
5 |
6 | const onScroll = useCallback(() => {
7 | setScrolled(window.scrollY > threshold);
8 | }, [threshold]);
9 |
10 | useEffect(() => {
11 | window.addEventListener('scroll', onScroll);
12 | return () => {
13 | window.removeEventListener('scroll', onScroll);
14 | };
15 | }, [onScroll]);
16 |
17 | return scrolled;
18 | }
19 |
--------------------------------------------------------------------------------
/apps/web/lib/tinybird.ts:
--------------------------------------------------------------------------------
1 | import { cookies } from 'next/headers';
2 | import { NextRequest, userAgent } from 'next/server';
3 |
4 | export async function recordClick({
5 | req,
6 | projectId,
7 | feedbackId,
8 | changelogId,
9 | }: {
10 | req: NextRequest;
11 | projectId: string;
12 | feedbackId?: string;
13 | changelogId?: string;
14 | }) {
15 | const cookieStore = cookies();
16 | const geo = process.env.VERCEL === '1' ? req.geo : null;
17 | const ua = userAgent(req);
18 | const referer = req.headers.get('referer');
19 |
20 | // Get session id
21 | const sessionId = cookieStore.get('fb_session_id');
22 |
23 | // If no session id, create one
24 | if (!sessionId) {
25 | cookieStore.set('fb_session_id', Math.random().toString(36).substring(2), {
26 | path: '/',
27 | expires: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours in milliseconds
28 | secure: true,
29 | });
30 | }
31 |
32 | const response = await fetch(`${process.env.TINYBIRD_API_URL}/v0/events?name=click_events`, {
33 | method: 'POST',
34 | headers: {
35 | Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,
36 | },
37 | body: JSON.stringify({
38 | timestamp: new Date(Date.now()).toISOString(),
39 | project: projectId,
40 | sessionId: sessionId?.value || 'Unknown',
41 | changelogId: changelogId || '_root',
42 | feedbackId: feedbackId || '_root',
43 | country: geo?.country || 'Unknown',
44 | city: geo?.city || 'Unknown',
45 | region: geo?.region || 'Unknown',
46 | latitude: geo?.latitude || 'Unknown',
47 | longitude: geo?.longitude || 'Unknown',
48 | ua: ua.ua || 'Unknown',
49 | browser: ua.browser.name || 'Unknown',
50 | browser_version: ua.browser.version || 'Unknown',
51 | engine: ua.engine.name || 'Unknown',
52 | engine_version: ua.engine.version || 'Unknown',
53 | os: ua.os.name || 'Unknown',
54 | os_version: ua.os.version || 'Unknown',
55 | device: ua.device.type || 'Unknown',
56 | device_vendor: ua.device.vendor || 'Unknown',
57 | device_model: ua.device.model || 'Unknown',
58 | cpu_architecture: ua.cpu?.architecture || 'Unknown',
59 | bot: ua.isBot,
60 | referer: referer ? new URL(referer).hostname : '(direct)',
61 | referer_url: referer || '(direct)',
62 | }),
63 | });
64 |
65 | const data = await response.json();
66 |
67 | return [data];
68 | }
69 |
--------------------------------------------------------------------------------
/apps/web/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 |
3 | let hostPath = ['http', 'localhost', '3000'];
4 | if (process.env.NEXT_PUBLIC_SUPABASE_URL) {
5 | hostPath = process.env.NEXT_PUBLIC_SUPABASE_URL.split(':');
6 | }
7 |
8 | const withPWA = require('next-pwa')({
9 | dest: 'public',
10 | register: true,
11 | skipWaiting: true,
12 | disable: process.env.NODE_ENV === 'development',
13 | });
14 |
15 | const nextConfig = {
16 | reactStrictMode: true,
17 | transpilePackages: ['ui'],
18 | output: 'standalone',
19 | images: {
20 | remotePatterns: [
21 | {
22 | protocol: hostPath[0],
23 | hostname: hostPath[1].replace('//', ''),
24 | port: hostPath[2],
25 | pathname: '/storage/v1/object/public/changelog-images/**',
26 | },
27 | {
28 | protocol: hostPath[0],
29 | hostname: hostPath[1].replace('//', ''),
30 | port: hostPath[2],
31 | pathname: '/storage/v1/object/public/projects/**',
32 | },
33 | ],
34 | },
35 | };
36 |
37 | module.exports = withPWA(nextConfig);
38 |
--------------------------------------------------------------------------------
/apps/web/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/apps/web/public/icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/feedbase/18dca32a923e12b3af54f1f761c063c7925dfeef/apps/web/public/icon-192x192.png
--------------------------------------------------------------------------------
/apps/web/public/icon-256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/feedbase/18dca32a923e12b3af54f1f761c063c7925dfeef/apps/web/public/icon-256x256.png
--------------------------------------------------------------------------------
/apps/web/public/icon-384x384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/feedbase/18dca32a923e12b3af54f1f761c063c7925dfeef/apps/web/public/icon-384x384.png
--------------------------------------------------------------------------------
/apps/web/public/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/feedbase/18dca32a923e12b3af54f1f761c063c7925dfeef/apps/web/public/icon-512x512.png
--------------------------------------------------------------------------------
/apps/web/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "theme_color": "#06060A",
3 | "background_color": "#06060A",
4 | "display": "standalone",
5 | "scope": "/",
6 | "start_url": "/",
7 | "name": "Feedbase",
8 | "short_name": "Feedbase",
9 | "description": "The open-source solution for collecting feedback & communicating product updates.",
10 | "icons": [
11 | {
12 | "src": "/icon-192x192.png",
13 | "sizes": "192x192",
14 | "type": "image/png"
15 | },
16 | {
17 | "src": "/icon-256x256.png",
18 | "sizes": "256x256",
19 | "type": "image/png"
20 | },
21 | {
22 | "src": "/icon-384x384.png",
23 | "sizes": "384x384",
24 | "type": "image/png"
25 | },
26 | {
27 | "src": "/icon-512x512.png",
28 | "sizes": "512x512",
29 | "type": "image/png"
30 | }
31 | ]
32 | }
33 |
--------------------------------------------------------------------------------
/apps/web/public/og-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/feedbase/18dca32a923e12b3af54f1f761c063c7925dfeef/apps/web/public/og-image.png
--------------------------------------------------------------------------------
/apps/web/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const sharedConfig = require('@feedbase/tailwind-config/tailwind.config.js');
2 |
3 | module.exports = {
4 | presets: [sharedConfig],
5 | content: [
6 | './app/**/*.{js,ts,jsx,tsx,mdx}',
7 | './components/**/*.{js,ts,jsx,tsx,mdx}',
8 | '../../packages/ui/**/*.{js,ts,jsx,tsx,mdx}',
9 | ],
10 | };
11 |
--------------------------------------------------------------------------------
/apps/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "tsconfig/nextjs.json",
3 | "compilerOptions": {
4 | "plugins": [{ "name": "next" }],
5 | "baseUrl": ".",
6 | "paths": {
7 | "@/*": ["./*"],
8 | "@ui/*": ["../../packages/ui/*"]
9 | }
10 | },
11 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
12 | "exclude": ["node_modules"]
13 | }
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "feedbase",
3 | "private": true,
4 | "scripts": {
5 | "build": "dotenv -- turbo run build",
6 | "dev": "dotenv -- turbo run dev",
7 | "lint": "turbo run lint --",
8 | "format": "prettier --write \"**/*.{js,jsx,ts,tsx}\"",
9 | "ts": "turbo run ts",
10 | "prepare": "husky install"
11 | },
12 | "devDependencies": {
13 | "@ianvs/prettier-plugin-sort-imports": "^4.1.0",
14 | "@feedbase/tailwind-config": "workspace:*",
15 | "dotenv-cli": "^7.3.0",
16 | "eslint": "^8.48.0",
17 | "eslint-config-custom": "workspace:*",
18 | "husky": "^8.0.0",
19 | "lint-staged": "^13.3.0",
20 | "prettier": "3.0.3",
21 | "prettier-plugin-tailwindcss": "^0.4.1",
22 | "turbo": "^1.10.14"
23 | },
24 | "packageManager": "pnpm@8.6.2",
25 | "lint-staged": {
26 | "*.{js,jsx,ts,tsx}": [
27 | "prettier --write"
28 | ]
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/packages/client/.gitignore:
--------------------------------------------------------------------------------
1 | dist
--------------------------------------------------------------------------------
/packages/client/README.md:
--------------------------------------------------------------------------------
1 | # @feedbase-ts/client
2 |
3 | This is the client-side library for Feedbase. It allows you to easily integrate Feedbase into your application.
4 |
5 | ## Installation
6 |
7 | ```bash
8 | npm install @feedbase/client
9 | ```
10 |
11 | ## Usage
12 |
13 | ```typescript
14 | import { Feedbase } from '@feedbase/client';
15 |
16 | // Create a new Feedbase instance
17 | const feedbase = new Feedbase(
18 | // The slug of your Feedbase project
19 | slug: 'feedbase',
20 | // The api key for your Feedbase project
21 | token: 'api-key',
22 | );
23 |
24 | // Submit Feedback
25 | feedbase.submitFeedback(
26 | // Title
27 | title: 'This is a title',
28 | // Description
29 | description: 'This is a description',
30 | // Email
31 | email: 'user@email.com',
32 | // Full Name (optional)
33 | fullName: 'John Doe',
34 | // Avatar URL (optional)
35 | avatarUrl: 'https://example.com/avatar.png',
36 | )
37 |
38 | // Get all changelogs
39 | feedbase.getChangelogs();
40 | ```
--------------------------------------------------------------------------------
/packages/client/lib/fetch.ts:
--------------------------------------------------------------------------------
1 | export class AuthError extends Error {
2 | constructor(message: string) {
3 | super(message);
4 | this.name = 'AuthError';
5 | }
6 | }
7 |
8 | export async function _request(
9 | url: string,
10 | method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
11 | token?: string,
12 | body?: any
13 | ) {
14 | let res: Response;
15 |
16 | try {
17 | res = await fetch(url, {
18 | method,
19 | headers: {
20 | 'Content-Type': 'application/json',
21 | ...(token ? { Authorization: `Bearer ${token}` } : {}),
22 | },
23 | ...(body ? { body: JSON.stringify(body) } : {}),
24 | });
25 | } catch (err) {
26 | console.error(err);
27 |
28 | // fetch failed, likely due to a network or CORS error
29 | throw new Error(`Unable to ${method} ${url}: ${err}`);
30 | }
31 |
32 | try {
33 | const data = await res.json();
34 | return { data, status: res.status };
35 | } catch (err) {
36 | console.error(err);
37 |
38 | // fetch failed, likely due to a network or CORS error
39 | throw new Error(`Unable to ${method} ${url}: ${err}`);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/client/lib/types.ts:
--------------------------------------------------------------------------------
1 | export interface Changelog {
2 | id: string;
3 | author_id: string;
4 | project_id: string;
5 | slug: string;
6 | title: string;
7 | summary: string | null;
8 | content: string | null;
9 | image: string | null;
10 | publish_date: string | null;
11 | published: boolean;
12 | }
13 |
14 | export type ChangelogsResponse =
15 | | {
16 | data: {
17 | changelogs: Changelog[];
18 | };
19 | error: null;
20 | }
21 | | {
22 | data: {
23 | changelogs: null;
24 | };
25 | error: Error;
26 | };
27 |
--------------------------------------------------------------------------------
/packages/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@feedbase-ts/client",
3 | "version": "1.0.1",
4 | "main": "./dist/index.js",
5 | "types": "./dist/index.d.ts",
6 | "license": "MIT",
7 | "private": false,
8 | "keywords": [
9 | "feedbase",
10 | "client",
11 | "api"
12 | ],
13 | "bugs": {
14 | "url": "https://github.com/chroxify/feedbase/issues"
15 | },
16 | "homepage": "https://docs.feedbase.app",
17 | "files": [
18 | "./dist/**"
19 | ],
20 | "scripts": {
21 | "build": "tsup"
22 | },
23 | "devDependencies": {
24 | "@types/node": "^20.2.1",
25 | "tsup": "^6.7.0",
26 | "tsx": "^3.12.7",
27 | "typescript": "^5.0.4"
28 | }
29 | }
--------------------------------------------------------------------------------
/packages/client/tsconfig.json:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "compilerOptions": {
4 | "target": "ESNext",
5 | "lib": ["ESNext", "dom"],
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "isolatedModules": true,
9 | "module": "ESNext",
10 | "moduleResolution": "node",
11 | "preserveWatchOutput": true,
12 | "skipLibCheck": true,
13 | "noEmit": true,
14 | "strict": true
15 | },
16 | "exclude": ["node_modules"]
17 | }
--------------------------------------------------------------------------------
/packages/client/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup';
2 |
3 | export default defineConfig({
4 | entry: ['src/index.ts'],
5 | format: ['cjs', 'esm'],
6 | splitting: false,
7 | sourcemap: true,
8 | clean: true,
9 | bundle: true,
10 | dts: true,
11 | });
12 |
--------------------------------------------------------------------------------
/packages/eslint-config-custom/library.js:
--------------------------------------------------------------------------------
1 | const { resolve } = require('node:path');
2 | const rules = require('./rules');
3 |
4 | const project = resolve(process.cwd(), 'tsconfig.json');
5 |
6 | /*
7 | * This is a custom ESLint configuration for use with
8 | * typescript packages.
9 | *
10 | * This config extends the Vercel Engineering Style Guide.
11 | * For more information, see https://github.com/vercel/style-guide
12 | *
13 | */
14 |
15 | module.exports = {
16 | extends: ['@vercel/style-guide/eslint/node', '@vercel/style-guide/eslint/typescript'].map(require.resolve),
17 | parserOptions: {
18 | project,
19 | },
20 | globals: {
21 | React: true,
22 | JSX: true,
23 | },
24 | settings: {
25 | 'import/resolver': {
26 | typescript: {
27 | project,
28 | },
29 | },
30 | },
31 | ignorePatterns: ['node_modules/', 'dist/'],
32 | rules,
33 | };
34 |
--------------------------------------------------------------------------------
/packages/eslint-config-custom/next.js:
--------------------------------------------------------------------------------
1 | const { resolve } = require('node:path');
2 | const rules = require('./rules');
3 |
4 | const project = resolve(process.cwd(), 'tsconfig.json');
5 |
6 | /*
7 | * This is a custom ESLint configuration for use with
8 | * Next.js apps.
9 | *
10 | * This config extends the Vercel Engineering Style Guide.
11 | * For more information, see https://github.com/vercel/style-guide
12 | *
13 | */
14 |
15 | module.exports = {
16 | extends: [
17 | '@vercel/style-guide/eslint/node',
18 | '@vercel/style-guide/eslint/typescript',
19 | '@vercel/style-guide/eslint/browser',
20 | '@vercel/style-guide/eslint/react',
21 | '@vercel/style-guide/eslint/next',
22 | // turborepo custom eslint configuration configures the following rules:
23 | // - https://github.com/vercel/turbo/blob/main/packages/eslint-plugin-turbo/docs/rules/no-undeclared-env-vars.md
24 | 'eslint-config-turbo',
25 | ].map(require.resolve),
26 | parserOptions: {
27 | project,
28 | },
29 | globals: {
30 | React: true,
31 | JSX: true,
32 | },
33 | settings: {
34 | 'import/resolver': {
35 | typescript: {
36 | project,
37 | },
38 | },
39 | 'import/core-modules': ['styled-jsx', 'styled-jsx/css'],
40 | },
41 | ignorePatterns: ['node_modules/', 'dist/'],
42 | rules,
43 | };
44 |
--------------------------------------------------------------------------------
/packages/eslint-config-custom/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "eslint-config-custom",
3 | "version": "0.0.0",
4 | "license": "MIT",
5 | "private": true,
6 | "dependencies": {
7 | "@vercel/style-guide": "^5.0.0",
8 | "eslint-config-turbo": "^1.10.12"
9 | },
10 | "peerDependencies": {
11 | "@next/eslint-plugin-next": "^12.3.0",
12 | "typescript": ">=4.8.0 <6.0.0",
13 | "eslint": ">=8.48.0 <9.0.0"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/eslint-config-custom/react-internal.js:
--------------------------------------------------------------------------------
1 | const { resolve } = require('node:path');
2 | const rules = require('./rules');
3 |
4 | const project = resolve(process.cwd(), 'tsconfig.json');
5 |
6 | /*
7 | * This is a custom ESLint configuration for use with
8 | * internal (bundled by their consumer) libraries
9 | * that utilize React.
10 | *
11 | * This config extends the Vercel Engineering Style Guide.
12 | * For more information, see https://github.com/vercel/style-guide
13 | *
14 | */
15 |
16 | module.exports = {
17 | extends: [
18 | '@vercel/style-guide/eslint/browser',
19 | '@vercel/style-guide/eslint/typescript',
20 | '@vercel/style-guide/eslint/react',
21 | ].map(require.resolve),
22 | parserOptions: {
23 | project,
24 | },
25 | globals: {
26 | JSX: true,
27 | },
28 | settings: {
29 | 'import/resolver': {
30 | typescript: {
31 | project,
32 | },
33 | },
34 | },
35 | ignorePatterns: ['node_modules/', 'dist/', '.eslintrc.js'],
36 | rules,
37 | };
38 |
--------------------------------------------------------------------------------
/packages/eslint-config-custom/rules.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | 'eslint-comments/require-description': 'off',
3 | 'import/no-default-export': 'off',
4 | 'import/no-extraneous-dependencies': 'off',
5 | 'import/order': 'off',
6 | 'jsx-a11y/heading-has-content': 'off',
7 | 'no-implicit-coercion': 'off',
8 | 'no-unused-vars': 'off', // enforced by tsconfig -> "noUnusedLocals": true
9 | 'react/function-component-definition': 'off',
10 | 'react/jsx-sort-props': 'off',
11 | 'react/no-unknown-property': 'off',
12 | 'react/no-unstable-nested-components': 'off',
13 | 'unicorn/filename-case': 'off',
14 | '@next/next/no-html-link-for-pages': 'off',
15 | '@typescript-eslint/array-type': 'off',
16 | '@typescript-eslint/consistent-type-definitions': 'off',
17 | '@typescript-eslint/consistent-type-imports': 'off',
18 | '@typescript-eslint/explicit-function-return-type': 'off',
19 | '@typescript-eslint/naming-convention': 'off',
20 | '@typescript-eslint/no-empty-interface': 'off',
21 | '@typescript-eslint/no-shadow': 'off',
22 | '@typescript-eslint/no-unnecessary-condition': 'off',
23 | '@typescript-eslint/no-unsafe-argument': 'off',
24 | '@typescript-eslint/no-unsafe-assignment': 'off',
25 | '@typescript-eslint/no-unsafe-member-access': 'off',
26 | '@typescript-eslint/no-unnecessary-type-arguments': 'off',
27 | '@typescript-eslint/no-unused-vars': 'off',
28 | '@typescript-eslint/require-await': 'off',
29 | '@typescript-eslint/no-non-null-assertion': 'off',
30 | '@typescript-eslint/no-unsafe-return': 'off',
31 | '@typescript-eslint/await-thenable': 'off',
32 | '@typescript-eslint/no-misused-promises': 'off',
33 | '@typescript-eslint/no-floating-promises': 'off',
34 | 'jsx-a11y/no-autofocus': 'off',
35 | 'no-nested-ternary': 'off',
36 | };
37 |
--------------------------------------------------------------------------------
/packages/tailwind-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@feedbase/tailwind-config",
3 | "version": "0.0.0",
4 | "license": "MIT",
5 | "private": true,
6 | "devDependencies": {
7 | "tailwindcss": "^3.3.3",
8 | "tailwindcss-animate": "^1.0.7"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/packages/tinybird/datasources/click_events.datasource:
--------------------------------------------------------------------------------
1 |
2 | SCHEMA >
3 | `timestamp` DateTime64(3) `json:$.timestamp`,
4 | `project` String `json:$.project`,
5 | `changelogId` String `json:$.changelogId`,
6 | `feedbackId` String `json:$.feedbackId`,
7 | `sessionId` String `json:$.sessionId`,
8 | `country` LowCardinality(String) `json:$.country`,
9 | `city` String `json:$.city`,
10 | `device` LowCardinality(String) `json:$.device`,
11 | `browser` LowCardinality(String) `json:$.browser`,
12 | `os` LowCardinality(String) `json:$.os`,
13 | `bot` UInt8 `json:$.bot`,
14 | `referer` String `json:$.referer`,
15 | `browser_version` String `json:$.browser_version`,
16 | `engine` LowCardinality(String) `json:$.engine`,
17 | `engine_version` String `json:$.engine_version`,
18 | `latitude` String `json:$.latitude`,
19 | `longitude` String `json:$.longitude`,
20 | `os_version` String `json:$.os_version`,
21 | `region` String `json:$.region`,
22 | `ua` String `json:$.ua`,
23 | `device_model` LowCardinality(String) `json:$.device_model`,
24 | `device_vendor` LowCardinality(String) `json:$.device_vendor`,
25 | `cpu_architecture` LowCardinality(String) `json:$.cpu_architecture`,
26 | `referer_url` String `json:$.referer_url`
27 |
28 |
29 | ENGINE "MergeTree"
30 | ENGINE_PARTITION_KEY "toYYYYMM(timestamp)"
31 | ENGINE_SORTING_KEY "project, timestamp, feedbackId, changelogId"
32 |
--------------------------------------------------------------------------------
/packages/tinybird/endpoints/clicks.pipe:
--------------------------------------------------------------------------------
1 | NODE clicks_node
2 | SQL >
3 |
4 | %
5 | SELECT COUNT(*)
6 | FROM click_events
7 | WHERE
8 | project IN {{ Array(project, 'String') }}
9 | {% if defined(feedbackId) %}
10 | AND feedbackId IN {{ Array(feedbackId, 'String') }}
11 | {% end %}
12 | {% if defined(changelogId) %}
13 | AND changelogId IN {{ Array(changelogId, 'String') }}
14 | {% end %}
15 | {% if defined(country) %} AND country = {{ country }} {% end %}
16 | {% if defined(city) %} AND city = {{ city }} {% end %}
17 | {% if defined(device) %} AND device = {{ device }} {% end %}
18 | {% if defined(browser) %} AND browser = {{ browser }} {% end %}
19 | {% if defined(os) %} AND os = {{ os }} {% end %}
20 | {% if defined(referer) %} AND referer = {{ referer }} {% end %}
21 | {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}
22 | {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}
23 |
24 |
25 |
--------------------------------------------------------------------------------
/packages/tinybird/endpoints/top_changelogs.pipe:
--------------------------------------------------------------------------------
1 | TOKEN "top_changelog_endpoint_read_6741" READ
2 |
3 | NODE top_changelog_node
4 | SQL >
5 |
6 | %
7 | WITH ClickEventsCount AS (
8 | SELECT
9 | changelogId as key,
10 | COUNT(*) as clicks,
11 | COUNT(DISTINCT sessionId) as visitors
12 | FROM click_events
13 | WHERE
14 | project IN {{ Array(project, 'String') }}
15 | {% if defined(country) %} AND country = {{ country }} {% end %}
16 | {% if defined(city) %} AND city = {{ city }} {% end %}
17 | {% if defined(device) %} AND device = {{ device }} {% end %}
18 | {% if defined(browser) %} AND browser = {{ browser }} {% end %}
19 | {% if defined(os) %} AND os = {{ os }} {% end %}
20 | {% if defined(referer) %} AND referer = {{ referer }} {% end %}
21 | {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}
22 | {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}
23 | GROUP BY changelogId
24 | )
25 |
26 | SELECT
27 | c.key,
28 | c.clicks,
29 | c.visitors
30 | FROM ClickEventsCount c
31 | ORDER BY c.clicks DESC, c.key
32 |
33 |
34 |
--------------------------------------------------------------------------------
/packages/tinybird/endpoints/top_feedback.pipe:
--------------------------------------------------------------------------------
1 | TOKEN "top_feedback_endpoint_read_0074" READ
2 |
3 | NODE top_feedback_node
4 | SQL >
5 |
6 | %
7 | WITH ClickEventsCount AS (
8 | SELECT
9 | feedbackId as key,
10 | COUNT(*) as clicks,
11 | COUNT(DISTINCT sessionId) as visitors
12 | FROM click_events
13 | WHERE
14 | project IN {{ Array(project, 'String', default='x') }}
15 | {% if defined(country) %} AND country = {{ country }} {% end %}
16 | {% if defined(city) %} AND city = {{ city }} {% end %}
17 | {% if defined(device) %} AND device = {{ device }} {% end %}
18 | {% if defined(browser) %} AND browser = {{ browser }} {% end %}
19 | {% if defined(os) %} AND os = {{ os }} {% end %}
20 | {% if defined(referer) %} AND referer = {{ referer }} {% end %}
21 | {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}
22 | {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}
23 | GROUP BY feedbackId
24 | )
25 |
26 | SELECT
27 | c.key,
28 | c.clicks,
29 | c.visitors
30 | FROM ClickEventsCount c
31 | ORDER BY c.clicks DESC, c.key
32 |
33 |
34 |
--------------------------------------------------------------------------------
/packages/tinybird/endpoints/visitors.pipe:
--------------------------------------------------------------------------------
1 | TOKEN "visitors_endpoint_read_3199" READ
2 |
3 | NODE visitors_node
4 | SQL >
5 |
6 | %
7 | SELECT COUNT(DISTINCT sessionId) as unique_visitors
8 | FROM click_events
9 | WHERE
10 | project IN {{ Array(project, 'String') }}
11 | {% if defined(feedbackId) %}
12 | AND feedbackId IN {{ Array(feedbackId, 'String') }}
13 | {% end %}
14 | {% if defined(changelogId) %}
15 | AND changelogId IN {{ Array(changelogId, 'String') }}
16 | {% end %}
17 | {% if defined(country) %} AND country = {{ country }} {% end %}
18 | {% if defined(city) %} AND city = {{ city }} {% end %}
19 | {% if defined(device) %} AND device = {{ device }} {% end %}
20 | {% if defined(browser) %} AND browser = {{ browser }} {% end %}
21 | {% if defined(os) %} AND os = {{ os }} {% end %}
22 | {% if defined(referer) %} AND referer = {{ referer }} {% end %}
23 | {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}
24 | {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}
25 |
26 |
27 |
--------------------------------------------------------------------------------
/packages/tsconfig/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Default",
4 | "compilerOptions": {
5 | "composite": false,
6 | "declaration": true,
7 | "declarationMap": true,
8 | "esModuleInterop": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "inlineSources": false,
11 | "isolatedModules": true,
12 | "moduleResolution": "node",
13 | "noUnusedLocals": true,
14 | "noUnusedParameters": false,
15 | "preserveWatchOutput": true,
16 | "skipLibCheck": true,
17 | "strict": true
18 | },
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------
/packages/tsconfig/nextjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Next.js",
4 | "extends": "./base.json",
5 | "compilerOptions": {
6 | "plugins": [{ "name": "next" }],
7 | "allowJs": true,
8 | "declaration": false,
9 | "declarationMap": false,
10 | "incremental": true,
11 | "jsx": "preserve",
12 | "lib": ["dom", "dom.iterable", "esnext"],
13 | "module": "esnext",
14 | "noEmit": true,
15 | "resolveJsonModule": true,
16 | "target": "es5"
17 | },
18 | "include": ["src", "app", "next-env.d.ts"],
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------
/packages/tsconfig/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tsconfig",
3 | "version": "0.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "publishConfig": {
7 | "access": "public"
8 | },
9 | "devDependencies": {
10 | "@types/node": "^20.8.0",
11 | "typescript": "^5.2.2"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/tsconfig/react-library.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "React Library",
4 | "extends": "./base.json",
5 | "compilerOptions": {
6 | "jsx": "preserve",
7 | "lib": ["ES2015", "DOM"],
8 | "module": "ESNext",
9 | "target": "es6"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/ui/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['custom/react-internal'],
3 | };
4 |
--------------------------------------------------------------------------------
/packages/ui/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "./styles/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@ui/components",
14 | "utils": "@ui/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/packages/ui/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as AccordionPrimitive from '@radix-ui/react-accordion';
5 | import { cn } from '@ui/lib/utils';
6 | import { ChevronDown } from 'lucide-react';
7 |
8 | const Accordion = AccordionPrimitive.Root;
9 |
10 | const AccordionItem = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
15 | ));
16 | AccordionItem.displayName = 'AccordionItem';
17 |
18 | const AccordionTrigger = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, children, ...props }, ref) => (
22 |
23 | svg]:rotate-180',
27 | className
28 | )}
29 | {...props}>
30 | {children}
31 |
32 |
33 |
34 | ));
35 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
36 |
37 | const AccordionContent = React.forwardRef<
38 | React.ElementRef,
39 | React.ComponentPropsWithoutRef
40 | >(({ className, children, ...props }, ref) => (
41 |
45 | {children}
46 |
47 | ));
48 |
49 | AccordionContent.displayName = AccordionPrimitive.Content.displayName;
50 |
51 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
52 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cn } from '@ui/lib/utils';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 |
5 | const alertVariants = cva(
6 | 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
7 | {
8 | variants: {
9 | variant: {
10 | default: 'bg-background text-foreground',
11 | destructive: 'border-destructive/50 text-destructive border-destructive [&>svg]:text-destructive',
12 | },
13 | },
14 | defaultVariants: {
15 | variant: 'default',
16 | },
17 | }
18 | );
19 |
20 | const Alert = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes & VariantProps
23 | >(({ className, variant, ...props }, ref) => (
24 |
25 | ));
26 | Alert.displayName = 'Alert';
27 |
28 | const AlertTitle = React.forwardRef>(
29 | ({ className, ...props }, ref) => (
30 |
31 | )
32 | );
33 | AlertTitle.displayName = 'AlertTitle';
34 |
35 | const AlertDescription = React.forwardRef>(
36 | ({ className, ...props }, ref) => (
37 |
38 | )
39 | );
40 | AlertDescription.displayName = 'AlertDescription';
41 |
42 | export { Alert, AlertTitle, AlertDescription };
43 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as AvatarPrimitive from '@radix-ui/react-avatar';
5 | import { cn } from '@ui/lib/utils';
6 |
7 | const Avatar = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
16 | ));
17 | Avatar.displayName = AvatarPrimitive.Root.displayName;
18 |
19 | const AvatarImage = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef
22 | >(({ className, ...props }, ref) => (
23 |
24 | ));
25 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
26 |
27 | const AvatarFallback = React.forwardRef<
28 | React.ElementRef,
29 | React.ComponentPropsWithoutRef
30 | >(({ className, ...props }, ref) => (
31 |
39 | ));
40 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
41 |
42 | export { Avatar, AvatarImage, AvatarFallback };
43 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/background/background.tsx:
--------------------------------------------------------------------------------
1 | export function Background() {
2 | return (
3 |
4 | );
5 | }
6 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cn } from '@ui/lib/utils';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 |
5 | const badgeVariants = cva(
6 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
7 | {
8 | variants: {
9 | variant: {
10 | default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
11 | secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
12 | destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
13 | outline: 'text-foreground',
14 | },
15 | size: {
16 | default: 'px-1.5 py-0.5 text-xs',
17 | xs: 'px-1 py-0.25 text-xs',
18 | },
19 | },
20 | defaultVariants: {
21 | variant: 'default',
22 | size: 'default',
23 | },
24 | }
25 | );
26 |
27 | export interface BadgeProps
28 | extends React.HTMLAttributes,
29 | VariantProps {}
30 |
31 | function Badge({ className, variant, size, ...props }: BadgeProps) {
32 | return
;
33 | }
34 |
35 | export { Badge, badgeVariants };
36 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Slot } from '@radix-ui/react-slot';
3 | import { cn } from '@ui/lib/utils';
4 | import { cva, type VariantProps } from 'class-variance-authority';
5 |
6 | const buttonVariants = cva(
7 | 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-root transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'bg-primary text-primary-foreground hover:bg-primary/90',
12 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
13 | outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
14 | secondary: 'bg-none text-secondary-foreground hover:bg-secondary/80',
15 | ghost: 'hover:bg-accent hover:text-accent-foreground',
16 | link: 'text-primary underline-offset-4 hover:underline',
17 | },
18 | size: {
19 | default: 'h-9 px-4 py-2',
20 | sm: 'h-9 rounded-md px-3',
21 | lg: 'h-11 rounded-md px-8',
22 | icon: 'h-10 w-10',
23 | },
24 | },
25 | defaultVariants: {
26 | variant: 'default',
27 | size: 'default',
28 | },
29 | }
30 | );
31 |
32 | export interface ButtonProps
33 | extends React.ButtonHTMLAttributes,
34 | VariantProps {
35 | asChild?: boolean;
36 | }
37 |
38 | const Button = React.forwardRef(
39 | ({ className, variant, size, asChild = false, ...props }, ref) => {
40 | const Comp = asChild ? Slot : 'button';
41 | return ;
42 | }
43 | );
44 | Button.displayName = 'Button';
45 |
46 | export { Button, buttonVariants };
47 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import { buttonVariants } from '@ui/components/ui/button';
5 | import { cn } from '@ui/lib/utils';
6 | import { ChevronLeft, ChevronRight } from 'lucide-react';
7 | import { DayPicker } from 'react-day-picker';
8 |
9 | export type CalendarProps = React.ComponentProps;
10 |
11 | function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
12 | return (
13 | ,
45 | IconRight: () => ,
46 | }}
47 | {...props}
48 | />
49 | );
50 | }
51 | Calendar.displayName = 'Calendar';
52 |
53 | export { Calendar };
54 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cn } from '@ui/lib/utils';
3 |
4 | const Card = React.forwardRef>(
5 | ({ className, ...props }, ref) => (
6 |
11 | )
12 | );
13 | Card.displayName = 'Card';
14 |
15 | const CardHeader = React.forwardRef>(
16 | ({ className, ...props }, ref) => (
17 |
18 | )
19 | );
20 | CardHeader.displayName = 'CardHeader';
21 |
22 | const CardTitle = React.forwardRef>(
23 | ({ className, ...props }, ref) => (
24 |
25 | )
26 | );
27 | CardTitle.displayName = 'CardTitle';
28 |
29 | const CardDescription = React.forwardRef>(
30 | ({ className, ...props }, ref) => (
31 |
32 | )
33 | );
34 | CardDescription.displayName = 'CardDescription';
35 |
36 | const CardContent = React.forwardRef>(
37 | ({ className, ...props }, ref) =>
38 | );
39 | CardContent.displayName = 'CardContent';
40 |
41 | const CardFooter = React.forwardRef>(
42 | ({ className, ...props }, ref) => (
43 |
44 | )
45 | );
46 | CardFooter.displayName = 'CardFooter';
47 |
48 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
49 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cn } from '@ui/lib/utils';
3 |
4 | export interface InputProps extends React.InputHTMLAttributes {}
5 |
6 | const Input = React.forwardRef(({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | );
18 | });
19 | Input.displayName = 'Input';
20 |
21 | export { Input };
22 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as LabelPrimitive from '@radix-ui/react-label';
5 | import { cn } from '@ui/lib/utils';
6 | import { cva, type VariantProps } from 'class-variance-authority';
7 |
8 | const labelVariants = cva(
9 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
10 | );
11 |
12 | const Label = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef & VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ));
22 | Label.displayName = LabelPrimitive.Root.displayName;
23 |
24 | export { Label };
25 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as PopoverPrimitive from '@radix-ui/react-popover';
5 | import { cn } from '@ui/lib/utils';
6 |
7 | const Popover = PopoverPrimitive.Root;
8 |
9 | const PopoverTrigger = PopoverPrimitive.Trigger;
10 |
11 | const PopoverContent = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
15 |
16 |
26 |
27 | ));
28 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
29 |
30 | export { Popover, PopoverTrigger, PopoverContent };
31 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
5 | import { cn } from '@ui/lib/utils';
6 | import { Circle } from 'lucide-react';
7 |
8 | const RadioGroup = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => {
12 | return ;
13 | });
14 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
15 |
16 | const RadioGroupItem = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, children, ...props }, ref) => {
20 | return (
21 |
28 |
29 |
30 |
31 |
32 | );
33 | });
34 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
35 |
36 | export { RadioGroup, RadioGroupItem };
37 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as SeparatorPrimitive from '@radix-ui/react-separator';
5 | import { cn } from '@ui/lib/utils';
6 |
7 | const Separator = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
11 |
22 | ));
23 | Separator.displayName = SeparatorPrimitive.Root.displayName;
24 |
25 | export { Separator };
26 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@ui/lib/utils';
2 |
3 | function Skeleton({ className, ...props }: React.HTMLAttributes) {
4 | return
;
5 | }
6 |
7 | export { Skeleton };
8 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as SwitchPrimitives from '@radix-ui/react-switch';
5 | import { cn } from '@ui/lib/utils';
6 |
7 | const Switch = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
18 |
23 |
24 | ));
25 | Switch.displayName = SwitchPrimitives.Root.displayName;
26 |
27 | export { Switch };
28 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as TabsPrimitive from '@radix-ui/react-tabs';
5 | import { cn } from '@ui/lib/utils';
6 |
7 | const Tabs = TabsPrimitive.Root;
8 |
9 | const TabsList = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 | ));
22 | TabsList.displayName = TabsPrimitive.List.displayName;
23 |
24 | const TabsTrigger = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, ...props }, ref) => (
28 |
36 | ));
37 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
38 |
39 | const TabsContent = React.forwardRef<
40 | React.ElementRef,
41 | React.ComponentPropsWithoutRef
42 | >(({ className, ...props }, ref) => (
43 |
51 | ));
52 | TabsContent.displayName = TabsPrimitive.Content.displayName;
53 |
54 | export { Tabs, TabsList, TabsTrigger, TabsContent };
55 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cn } from '@ui/lib/utils';
3 |
4 | export interface TextareaProps extends React.TextareaHTMLAttributes {}
5 |
6 | const Textarea = React.forwardRef(({ className, ...props }, ref) => {
7 | return (
8 |
16 | );
17 | });
18 | Textarea.displayName = 'Textarea';
19 |
20 | export { Textarea };
21 |
--------------------------------------------------------------------------------
/packages/ui/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as TooltipPrimitive from '@radix-ui/react-tooltip';
5 | import { cn } from '@ui/lib/utils';
6 |
7 | const TooltipProvider = TooltipPrimitive.Provider;
8 |
9 | const Tooltip = TooltipPrimitive.Root;
10 |
11 | const TooltipTrigger = TooltipPrimitive.Trigger;
12 |
13 | const TooltipContent = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, sideOffset = 4, ...props }, ref) => (
17 |
26 | ));
27 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
28 |
29 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
30 |
--------------------------------------------------------------------------------
/packages/ui/declarations.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.css' {
2 | const content: Record;
3 | export default content;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/ui/lib/hooks/use-media-query.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 |
5 | export default function useMediaQuery() {
6 | const [isPWA, setIsPWA] = useState(false);
7 | const [device, setDevice] = useState<'mobile' | 'tablet' | 'desktop' | null>(null);
8 | const [dimensions, setDimensions] = useState<{
9 | width: number;
10 | height: number;
11 | } | null>(null);
12 |
13 | useEffect(() => {
14 | // Check if PWA
15 | if (window.matchMedia('(display-mode: standalone)').matches) {
16 | setIsPWA(true);
17 | }
18 |
19 | // Check device
20 | const checkDevice = () => {
21 | if (window.matchMedia('(max-width: 640px)').matches) {
22 | setDevice('mobile');
23 | } else if (window.matchMedia('(min-width: 641px) and (max-width: 1024px)').matches) {
24 | setDevice('tablet');
25 | } else {
26 | setDevice('desktop');
27 | }
28 | setDimensions({ width: window.innerWidth, height: window.innerHeight });
29 | };
30 |
31 | // Initial detection
32 | checkDevice();
33 |
34 | // Listener for windows resize
35 | window.addEventListener('resize', checkDevice);
36 |
37 | // Cleanup listener
38 | return () => {
39 | window.removeEventListener('resize', checkDevice);
40 | };
41 | }, []);
42 |
43 | return {
44 | device,
45 | width: dimensions?.width,
46 | height: dimensions?.height,
47 | isMobile: device === 'mobile',
48 | isTablet: device === 'tablet',
49 | isDesktop: device === 'desktop',
50 | isPWA,
51 | };
52 | }
53 |
--------------------------------------------------------------------------------
/packages/ui/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
5 |
--------------------------------------------------------------------------------
/packages/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ui",
3 | "version": "0.0.0",
4 | "license": "MIT",
5 | "scripts": {
6 | "lint": "eslint .",
7 | "ts": "tsc --noEmit"
8 | },
9 | "devDependencies": {
10 | "@types/css-modules": "^1.0.3",
11 | "@types/react": "^18.2.25",
12 | "@types/react-dom": "^18.2.8",
13 | "eslint": "^8.50.0",
14 | "eslint-config-custom": "workspace:*",
15 | "react": "^18.2.0",
16 | "tsconfig": "workspace:*",
17 | "typescript": "^5.2.2"
18 | },
19 | "dependencies": {
20 | "@hookform/resolvers": "^3.3.1",
21 | "@next/font": "^13.5.3",
22 | "@radix-ui/react-accordion": "^1.1.2",
23 | "@radix-ui/react-alert-dialog": "^1.0.5",
24 | "@radix-ui/react-aspect-ratio": "^1.0.3",
25 | "@radix-ui/react-avatar": "^1.0.4",
26 | "@radix-ui/react-checkbox": "^1.0.4",
27 | "@radix-ui/react-collapsible": "^1.0.3",
28 | "@radix-ui/react-context-menu": "^2.1.5",
29 | "@radix-ui/react-dialog": "^1.0.5",
30 | "@radix-ui/react-dropdown-menu": "^2.0.6",
31 | "@radix-ui/react-hover-card": "^1.0.7",
32 | "@radix-ui/react-label": "^2.0.2",
33 | "@radix-ui/react-menubar": "^1.0.4",
34 | "@radix-ui/react-navigation-menu": "^1.1.4",
35 | "@radix-ui/react-popover": "^1.0.7",
36 | "@radix-ui/react-progress": "^1.0.3",
37 | "@radix-ui/react-radio-group": "^1.1.3",
38 | "@radix-ui/react-scroll-area": "^1.0.5",
39 | "@radix-ui/react-select": "^2.0.0",
40 | "@radix-ui/react-separator": "^1.0.3",
41 | "@radix-ui/react-slider": "^1.1.2",
42 | "@radix-ui/react-slot": "^1.0.2",
43 | "@radix-ui/react-switch": "^1.0.3",
44 | "@radix-ui/react-tabs": "^1.0.4",
45 | "@radix-ui/react-toast": "^1.1.5",
46 | "@radix-ui/react-toggle": "^1.0.3",
47 | "@radix-ui/react-tooltip": "^1.0.7",
48 | "@types/node": "^20.7.1",
49 | "class-variance-authority": "^0.7.0",
50 | "clsx": "^2.0.0",
51 | "cmdk": "^0.2.0",
52 | "date-fns": "^2.30.0",
53 | "lucide-react": "^0.279.0",
54 | "react-day-picker": "^8.8.2",
55 | "react-hook-form": "^7.46.2",
56 | "tailwind-merge": "^1.14.0",
57 | "tailwindcss": "^3.3.3",
58 | "tailwindcss-animate": "^1.0.7",
59 | "vaul": "^0.8.0",
60 | "zod": "^3.22.2"
61 | },
62 | "peerDependencies": {
63 | "react-dom": ">=18.0.0 <19.0.0"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/packages/ui/styles/Satoshi-Variable.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/feedbase/18dca32a923e12b3af54f1f761c063c7925dfeef/packages/ui/styles/Satoshi-Variable.woff2
--------------------------------------------------------------------------------
/packages/ui/styles/fonts.ts:
--------------------------------------------------------------------------------
1 | import { Inter, JetBrains_Mono } from '@next/font/google'; // eslint-disable-line
2 | import localFont from '@next/font/local';
3 |
4 | export const fontSans = Inter({
5 | subsets: ['latin'],
6 | variable: '--font-sans',
7 | });
8 |
9 | export const fontMono = JetBrains_Mono({
10 | subsets: ['latin'],
11 | variable: '--font-monospace',
12 | });
13 |
14 | export const satoshi = localFont({
15 | src: './Satoshi-Variable.woff2',
16 | variable: '--font-satoshi',
17 | weight: '300 700',
18 | });
19 |
--------------------------------------------------------------------------------
/packages/ui/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --darkest: 270 86% 3%;
8 | --darker: 240 6% 10%; /* zinc-900 */
9 | --dark: 240 5% 26%; /* zinc-700 */
10 | --lightest: 240 6% 90%; /* zinc-200 */
11 | --lighter: 240 5% 65%; /* zinc-400 */
12 | --primary-light: 255 92% 76%; /* violet-400 */
13 | --primary-dark: 263 70% 50%; /* violet-700 */
14 |
15 | --background: 0 0% 100%;
16 | --foreground: 240 10% 3.9%;
17 |
18 | --card: 0 0% 100%;
19 | --card-foreground: 240 10% 3.9%;
20 |
21 | --popover: 0 0% 100%;
22 | --popover-foreground: 240 10% 3.9%;
23 |
24 | --primary: 240 5.9% 10%;
25 | --primary-foreground: 0 0% 98%;
26 |
27 | --secondary: 240 4.8% 95.9%;
28 | --secondary-foreground: 240 5.9% 10%;
29 |
30 | --muted: 240 4.8% 95.9%;
31 | --muted-foreground: 240 3.8% 46.1%;
32 |
33 | --accent: 240 4.8% 95.9%;
34 | --accent-foreground: 240 5.9% 10%;
35 |
36 | --destructive: 0 84.2% 60.2%;
37 | --destructive-foreground: 0 0% 98%;
38 |
39 | --border: 240 5.9% 90%;
40 | --input: 240 5.9% 90%;
41 | --ring: 240 10% 3.9%;
42 |
43 | --radius: 0.5rem;
44 | }
45 |
46 | .dark {
47 | --background: 240 10% 3.9%;
48 | --foreground: 0 0% 98%;
49 |
50 | --card: 240 10% 3.9%;
51 | --card-foreground: 0 0% 98%;
52 |
53 | --popover: 240 10% 3.9%;
54 | --popover-foreground: 0 0% 98%;
55 |
56 | --primary: 0 0% 98%;
57 | --primary-foreground: 240 5.9% 10%;
58 |
59 | --secondary: 240 3.7% 15.9%;
60 | --secondary-foreground: 0 0% 98%;
61 |
62 | --muted: 240 3.7% 15.9%;
63 | --muted-foreground: 240 5% 64.9%;
64 |
65 | --accent: 240 3.7% 15.9%;
66 | --accent-foreground: 0 0% 98%;
67 |
68 | --destructive: 0 62.8% 30.6%;
69 | --destructive-foreground: 0 0% 98%;
70 |
71 | --border: 240 3.7% 15.9%;
72 | --input: 240 3.7% 15.9%;
73 | --ring: 240 4.9% 83.9%;
74 | }
75 | }
76 |
77 | @layer base {
78 | * {
79 | @apply border-border;
80 | }
81 | body {
82 | @apply bg-background text-foreground;
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/packages/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "tsconfig/react-library.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "paths": {
6 | "@ui/*": ["./*"]
7 | }
8 | },
9 | "include": ["."],
10 | "exclude": ["dist", "build", "node_modules"]
11 | }
12 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "apps/*"
3 | - "packages/*"
4 |
--------------------------------------------------------------------------------
/supabase/.gitignore:
--------------------------------------------------------------------------------
1 | # Supabase
2 | .branches
3 | .temp
4 |
--------------------------------------------------------------------------------
/supabase/migrations/20231126133809_storage_schema.sql:
--------------------------------------------------------------------------------
1 | INSERT INTO storage.buckets (id, name, public, avif_autodetection)
2 | VALUES('changelog-images', 'changelog-images', TRUE, FALSE);
3 |
4 | INSERT INTO storage.buckets (id, name, public, avif_autodetection)
5 | VALUES('projects', 'projects', TRUE, FALSE);
6 |
7 | INSERT INTO storage.buckets (id, name, public, avif_autodetection)
8 | VALUES('avatars', 'avatars', TRUE, FALSE);
9 |
10 | create policy "Anyone can insert 1oj01fe_0"
11 | on "storage"."objects"
12 | as permissive
13 | for insert
14 | to public
15 | with check ((bucket_id = 'avatars'::text));
16 |
17 |
18 | create policy "Anyone can select 1oj01fe_0"
19 | on "storage"."objects"
20 | as permissive
21 | for select
22 | to public
23 | using ((bucket_id = 'avatars'::text));
24 |
25 |
26 | create policy "Only authenticated users can insert 1lcb7wx_0"
27 | on "storage"."objects"
28 | as permissive
29 | for insert
30 | to authenticated
31 | with check ((bucket_id = 'changelog-images'::text));
32 |
33 |
34 | create policy "Only authenticated users can select and insert 1iiiika_0"
35 | on "storage"."objects"
36 | as permissive
37 | for insert
38 | to authenticated
39 | with check ((bucket_id = 'projects'::text));
40 |
41 |
42 | create policy "Only authenticated users can select and insert 1iiiika_1"
43 | on "storage"."objects"
44 | as permissive
45 | for select
46 | to authenticated
47 | using ((bucket_id = 'projects'::text));
--------------------------------------------------------------------------------
/supabase/migrations/20240113153829_custom_theming.sql:
--------------------------------------------------------------------------------
1 | create type "public"."theme_type" as enum ('default', 'light', 'custom');
2 |
3 | drop policy "Enable update for authenticated users only" on "public"."feedback";
4 |
5 | alter table "public"."project_configs" drop constraint "project_configs_project_id_fkey";
6 |
7 | alter table "public"."project_configs" add column "custom_theme" theme_type not null default 'default'::theme_type;
8 |
9 | alter table "public"."project_configs" add column "custom_theme_accent" text;
10 |
11 | alter table "public"."project_configs" add column "custom_theme_background" text;
12 |
13 | alter table "public"."project_configs" add column "custom_theme_border" text;
14 |
15 | alter table "public"."project_configs" add column "custom_theme_primary_foreground" text;
16 |
17 | alter table "public"."project_configs" add column "custom_theme_root" text;
18 |
19 | alter table "public"."project_configs" add column "custom_theme_secondary_background" text;
20 |
21 | alter table "public"."project_configs" add column "feedback_allow_anon_upvoting" boolean;
22 |
23 | alter table "public"."project_configs" add constraint "project_configs_project_id_fkey" FOREIGN KEY (project_id) REFERENCES projects(id) not valid;
24 |
25 | alter table "public"."project_configs" validate constraint "project_configs_project_id_fkey";
26 |
27 | create policy "Enable update for all users only"
28 | on "public"."feedback"
29 | as permissive
30 | for update
31 | to anon, authenticated
32 | using (true)
33 | with check (true);
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/supabase/migrations/20240118215535_notification_system.sql:
--------------------------------------------------------------------------------
1 | create type "public"."notification_types" as enum ('comment', 'post');
2 |
3 | create table "public"."notifications" (
4 | "id" uuid not null default gen_random_uuid(),
5 | "project_id" uuid not null,
6 | "has_archived" uuid[],
7 | "created_at" timestamp with time zone not null default now(),
8 | "initiator_id" uuid not null,
9 | "type" notification_types not null,
10 | "feedback_id" uuid not null,
11 | "comment_id" uuid
12 | );
13 |
14 |
15 | alter table "public"."notifications" enable row level security;
16 |
17 | CREATE UNIQUE INDEX notifications_pkey ON public.notifications USING btree (id);
18 |
19 | alter table "public"."notifications" add constraint "notifications_pkey" PRIMARY KEY using index "notifications_pkey";
20 |
21 | alter table "public"."notifications" add constraint "notifications_comment_id_fkey" FOREIGN KEY (comment_id) REFERENCES feedback_comments(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
22 |
23 | alter table "public"."notifications" validate constraint "notifications_comment_id_fkey";
24 |
25 | alter table "public"."notifications" add constraint "notifications_feedback_id_fkey" FOREIGN KEY (feedback_id) REFERENCES feedback(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
26 |
27 | alter table "public"."notifications" validate constraint "notifications_feedback_id_fkey";
28 |
29 | alter table "public"."notifications" add constraint "notifications_initiator_id_fkey" FOREIGN KEY (initiator_id) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
30 |
31 | alter table "public"."notifications" validate constraint "notifications_initiator_id_fkey";
32 |
33 | alter table "public"."notifications" add constraint "notifications_project_id_fkey" FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
34 |
35 | alter table "public"."notifications" validate constraint "notifications_project_id_fkey";
36 |
37 | create policy "Enable insert for authenticated users only"
38 | on "public"."notifications"
39 | as permissive
40 | for insert
41 | to authenticated
42 | with check (true);
43 |
44 |
45 | create policy "Enable read for authenticated users only"
46 | on "public"."notifications"
47 | as permissive
48 | for select
49 | to authenticated
50 | using (true);
51 |
52 |
53 | create policy "Enable update for authenticated users only"
54 | on "public"."notifications"
55 | as permissive
56 | for update
57 | to authenticated
58 | using (true)
59 | with check (true);
60 |
61 | alter table "public"."project_configs" add column "logo_redirect_url" text;
62 |
63 | alter table "public"."project_configs" add column "integration_slack_status" boolean not null default false;
64 |
65 | alter table "public"."project_configs" add column "integration_slack_webhook" text;
66 |
--------------------------------------------------------------------------------
/supabase/migrations/20240130194400_changelog_subscribers.sql:
--------------------------------------------------------------------------------
1 | create table "public"."changelog_subscribers" (
2 | "id" uuid not null default gen_random_uuid(),
3 | "project_id" uuid not null,
4 | "email" text not null,
5 | "created_at" timestamp with time zone not null default now()
6 | );
7 |
8 |
9 | alter table "public"."changelog_subscribers" enable row level security;
10 |
11 | CREATE UNIQUE INDEX changelog_subscribers_pkey ON public.changelog_subscribers USING btree (id);
12 |
13 | alter table "public"."changelog_subscribers" add constraint "changelog_subscribers_pkey" PRIMARY KEY using index "changelog_subscribers_pkey";
14 |
15 | alter table "public"."changelog_subscribers" add constraint "changelog_subscribers_project_id_fkey" FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
16 |
17 | alter table "public"."changelog_subscribers" validate constraint "changelog_subscribers_project_id_fkey";
18 |
19 | create policy "Enable insert for everyone"
20 | on "public"."changelog_subscribers"
21 | as permissive
22 | for insert
23 | to public
24 | with check (true);
25 |
26 |
27 | create policy "Enable read access for all users"
28 | on "public"."changelog_subscribers"
29 | as permissive
30 | for select
31 | to public
32 | using (true);
33 |
34 | create policy "Enable delete access for all users"
35 | on "public"."changelog_subscribers"
36 | as permissive
37 | for delete
38 | to public
39 | using (true);
40 |
41 | alter table "public"."project_configs" add column "changelog_enabled" boolean not null default true;
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "globalDependencies": ["**/.env"],
4 | "globalDotEnv": [".env"],
5 | "globalEnv": [
6 | "NEXT_PUBLIC_SUPABASE_URL",
7 | "NEXT_PUBLIC_SUPABASE_ANON_KEY",
8 | "NEXT_PUBLIC_ROOT_DOMAIN",
9 | "NEXT_PUBLIC_VERCEL_ENV",
10 | "SUPABASE_SERVICE_ROLE_KEY",
11 | "RESEND_API_KEY",
12 | "VERCEL_TEAM_ID",
13 | "VERCEL_PROJECT_ID",
14 | "VERCEL_AUTH_TOKEN",
15 | "TINYBIRD_API_URL",
16 | "TINYBIRD_API_KEY",
17 | "VERCEL",
18 | "CUSTOM_DOMAIN_WHITELIST",
19 | "NODE_ENV"
20 | ],
21 | "pipeline": {
22 | "build": {
23 | "dependsOn": ["^build"],
24 | "outputs": [".next/**", "!.next/cache/**"]
25 | },
26 | "dev": {
27 | "cache": false,
28 | "persistent": true,
29 | "env": ["GITHUB_CLIENT_ID", "GITHUB_SECRET"]
30 | },
31 | "topo": {
32 | "dependsOn": ["^topo"]
33 | },
34 | "lint": {
35 | "dependsOn": ["topo"]
36 | },
37 | "ts": {
38 | "dependsOn": ["topo"]
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------