├── .gitignore
├── .npmrc
├── .nvm
├── .vscode
└── settings.json
├── README.md
├── apps
├── dashboard
│ ├── .gitignore
│ ├── README.md
│ ├── next-env.d.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── postcss.config.js
│ ├── public
│ │ ├── circles.svg
│ │ ├── next.svg
│ │ ├── turborepo.svg
│ │ └── vercel.svg
│ ├── src
│ │ ├── app
│ │ │ ├── (app)
│ │ │ │ ├── account
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ ├── notifications
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── teams
│ │ │ │ │ │ ├── layout.tsx
│ │ │ │ │ │ ├── loading.tsx
│ │ │ │ │ │ └── page.tsx
│ │ │ │ ├── help
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── inbox
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── settings
│ │ │ │ │ ├── integrations
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ ├── members
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── pending
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ ├── security
│ │ │ │ │ └── page.tsx
│ │ │ │ │ └── tags
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ ├── (public)
│ │ │ │ ├── all-done
│ │ │ │ │ ├── event-emitter.tsx
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── schema.ts
│ │ │ │ ├── invite
│ │ │ │ │ └── [inviteCode]
│ │ │ │ │ │ ├── layout.tsx
│ │ │ │ │ │ └── page.tsx
│ │ │ │ └── login
│ │ │ │ │ └── page.tsx
│ │ │ ├── api
│ │ │ │ ├── auth
│ │ │ │ │ └── callback
│ │ │ │ │ │ └── route.ts
│ │ │ │ ├── tickets
│ │ │ │ │ └── route.ts
│ │ │ │ ├── trpc
│ │ │ │ │ └── [trpc]
│ │ │ │ │ │ └── route.ts
│ │ │ │ └── webhook
│ │ │ │ │ ├── new-user
│ │ │ │ │ └── route.ts
│ │ │ │ │ ├── slack
│ │ │ │ │ └── oauth_redirect
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── ticket
│ │ │ │ │ └── route.ts
│ │ │ ├── favicon.ico
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ └── not-found.tsx
│ │ ├── components
│ │ │ ├── accept-invitation-button.tsx
│ │ │ ├── account-sub-nav.tsx
│ │ │ ├── add-slack-integration-button.tsx
│ │ │ ├── alerts
│ │ │ │ ├── confirm-change-your-own-role.tsx
│ │ │ │ ├── confirm-leave-team-alert.tsx
│ │ │ │ ├── confirm-remove-team-member-alert.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── prohibit-last-owner-role-change-alert.tsx
│ │ │ ├── analytics-set-profile.tsx
│ │ │ ├── auth-token.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── chat-message-handler.tsx
│ │ │ ├── chat-message-user.tsx
│ │ │ ├── clear-all-filters-button.tsx
│ │ │ ├── clipboard-button.tsx
│ │ │ ├── create-seventy-seven-ticket-button.tsx
│ │ │ ├── create-tag-button.tsx
│ │ │ ├── create-team-button.tsx
│ │ │ ├── edit-ticket-tag-button.tsx
│ │ │ ├── forms
│ │ │ │ ├── assign-team-member-form.tsx
│ │ │ │ ├── chat-response-form.tsx
│ │ │ │ ├── create-team-form.tsx
│ │ │ │ ├── edit-display-name-form.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ └── use-team-form.ts
│ │ │ │ ├── invite-team-member-form.tsx
│ │ │ │ ├── snooze-ticket-form.tsx
│ │ │ │ ├── ticket-search-form.tsx
│ │ │ │ ├── ticket-tag-form.tsx
│ │ │ │ ├── update-team-avatar-form.tsx
│ │ │ │ └── update-team-name-form.tsx
│ │ │ ├── header.tsx
│ │ │ ├── invite-code-badge.tsx
│ │ │ ├── invite-team-member-button.tsx
│ │ │ ├── main-menu.tsx
│ │ │ ├── members-list-tab-nav.tsx
│ │ │ ├── modals
│ │ │ │ ├── assign-ticket-modal.tsx
│ │ │ │ ├── create-seventy-seven-ticket-modal.tsx
│ │ │ │ ├── create-team-modal.tsx
│ │ │ │ ├── create-ticket-tag-modal.tsx
│ │ │ │ ├── edit-original-message-modal.tsx
│ │ │ │ ├── edit-ticket-tag-modal.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── invite-team-member-modal.tsx
│ │ │ │ ├── snooze-ticket-modal.tsx
│ │ │ │ ├── ticket-tags-modal.tsx
│ │ │ │ └── view-original-message-content-modal.tsx.tsx
│ │ │ ├── no-ticket-selected.tsx
│ │ │ ├── notifications-email-switches.tsx
│ │ │ ├── page-wrapper.tsx
│ │ │ ├── pending-member-dropdown.tsx
│ │ │ ├── pending-team-members.tsx
│ │ │ ├── revoke-slack-integration-button.tsx
│ │ │ ├── select-team-dropdown.tsx
│ │ │ ├── selected-ticket.tsx
│ │ │ ├── settings-sub-nav.tsx
│ │ │ ├── sheets
│ │ │ │ ├── index.tsx
│ │ │ │ └── ticket-info-sheet.tsx
│ │ │ ├── sign-in-buttons
│ │ │ │ ├── sign-in-with-github-button.tsx
│ │ │ │ └── sign-in-with-google-button.tsx
│ │ │ ├── team-actions-dropdown.tsx
│ │ │ ├── team-actions-menu.tsx
│ │ │ ├── team-list-item.tsx
│ │ │ ├── team-members.tsx
│ │ │ ├── team-role-select.tsx
│ │ │ ├── theme-provider.tsx
│ │ │ ├── theme-switch.tsx
│ │ │ ├── ticket-action-dropdown.tsx
│ │ │ ├── ticket-chat-header.tsx
│ │ │ ├── ticket-chat.tsx
│ │ │ ├── ticket-filters
│ │ │ │ ├── ticket-filters-dropdown.tsx
│ │ │ │ └── use-ticket-filters.ts
│ │ │ ├── ticket-info-button.tsx
│ │ │ ├── ticket-list-item-badges.tsx
│ │ │ ├── ticket-list-item.tsx
│ │ │ ├── ticket-tags-table.tsx
│ │ │ ├── tickets-list.tsx
│ │ │ └── user-menu-dropdown.tsx
│ │ ├── hooks
│ │ │ ├── use-media-query.ts
│ │ │ ├── use-realtime-query.ts
│ │ │ ├── use-selected-ticket.ts
│ │ │ └── use-upload.ts
│ │ ├── lib
│ │ │ ├── analytics.tsx
│ │ │ └── search-params.ts
│ │ ├── middleware.ts
│ │ ├── store.ts
│ │ ├── trigger
│ │ │ ├── generate-ticket-summary.ts
│ │ │ └── unsnooze-ticket.ts
│ │ ├── trpc
│ │ │ ├── client.tsx
│ │ │ ├── init.ts
│ │ │ ├── query-client.ts
│ │ │ ├── routers
│ │ │ │ ├── _app.ts
│ │ │ │ ├── integrations-router.ts
│ │ │ │ ├── invites-router.ts
│ │ │ │ ├── messages-router.ts
│ │ │ │ ├── seventy-seven-router.ts
│ │ │ │ ├── teams-router.ts
│ │ │ │ ├── tickets-router.ts
│ │ │ │ ├── tickets-tags-router.ts
│ │ │ │ └── users-router.ts
│ │ │ └── server.ts
│ │ └── utils
│ │ │ ├── assertUnreachable.ts
│ │ │ ├── colors.ts
│ │ │ ├── data.ts
│ │ │ ├── get-role-name.ts
│ │ │ ├── insertIf.ts
│ │ │ ├── parseIncomingMessage.ts
│ │ │ ├── parseIncomingMessageWithAI.ts
│ │ │ ├── pluralize.ts
│ │ │ ├── random.ts
│ │ │ ├── sentencifyArray.tsx
│ │ │ ├── shortId.ts
│ │ │ ├── stripMarkupfromMessage.ts
│ │ │ ├── teamRoleEnumToWord.ts
│ │ │ └── validation
│ │ │ └── common.ts
│ ├── tailwind.config.ts
│ ├── trigger.config.ts
│ └── tsconfig.json
├── supabase
│ ├── package.json
│ ├── seed.ts
│ ├── supabase.code-workspace
│ ├── supabase
│ │ ├── .gitignore
│ │ ├── config.toml
│ │ ├── functions
│ │ │ └── .vscode
│ │ │ │ ├── extensions.json
│ │ │ │ └── settings.json
│ │ ├── migrations
│ │ │ └── 20241003124704_remote_schema.sql
│ │ └── seed.sql
│ └── tsconfig.json
└── website
│ ├── README.md
│ ├── next.config.mjs
│ ├── package.json
│ ├── postcss.config.js
│ ├── public
│ ├── email
│ │ ├── 77-logo.png
│ │ ├── acme.png
│ │ └── avatar.jpg
│ └── img
│ │ ├── 77-dark.png
│ │ ├── 77-dark.webp
│ │ ├── 77-light.png
│ │ └── 77-light.webp
│ ├── src
│ ├── actions
│ │ └── waitlist.ts
│ ├── app
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ ├── not-found.tsx
│ │ ├── opengraph-image.jpg
│ │ └── page.tsx
│ ├── components
│ │ ├── change-theme-button.tsx
│ │ ├── confetti-rain.tsx
│ │ ├── container.tsx
│ │ ├── header.tsx
│ │ ├── hero-heading.tsx
│ │ ├── theme-provider.tsx
│ │ └── waves.tsx
│ ├── store.ts
│ └── utils
│ │ ├── analytics.ts
│ │ └── safe-action.ts
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── biome.json
├── package.json
├── packages
├── analytics
│ ├── package.json
│ ├── src
│ │ └── index.tsx
│ └── tsconfig.json
├── email
│ ├── components
│ │ ├── footer.tsx
│ │ └── last-messages.tsx
│ ├── emails
│ │ ├── new-ticket.tsx
│ │ ├── snooze-expired.tsx
│ │ ├── team-invite.tsx
│ │ ├── ticket-closed.tsx
│ │ ├── ticket-message-response.tsx
│ │ └── waitlist-confirmation.tsx
│ ├── index.ts
│ ├── package.json
│ ├── tsconfig.json
│ └── types.ts
├── integrations
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ └── slack
│ │ │ └── index.ts
│ └── tsconfig.json
├── orm
│ ├── package.json
│ ├── src
│ │ ├── prisma.ts
│ │ └── prisma
│ │ │ ├── enums.ts
│ │ │ ├── extensions
│ │ │ └── loggingExtension.ts
│ │ │ └── schema.prisma
│ └── tsconfig.json
├── sdk
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ └── index.ts
│ └── tsconfig.json
├── supabase
│ ├── package.json
│ ├── src
│ │ ├── clients
│ │ │ ├── client.ts
│ │ │ ├── middleware.ts
│ │ │ └── server.ts
│ │ ├── session.ts
│ │ └── types
│ │ │ ├── db.ts
│ │ │ └── index.ts
│ └── tsconfig.json
├── typescript-config
│ ├── base.json
│ ├── nextjs.json
│ ├── package.json
│ └── react-library.json
└── ui
│ ├── components.json
│ ├── package.json
│ ├── postcss.config.js
│ ├── src
│ ├── components
│ │ ├── alert.tsx
│ │ ├── code-block.tsx
│ │ ├── color-picker.tsx
│ │ ├── combobox-dropdown.tsx
│ │ ├── combobox.tsx
│ │ ├── hooks
│ │ │ └── use-media-query.ts
│ │ ├── logo.tsx
│ │ ├── modal-responsive.tsx
│ │ ├── modal.tsx
│ │ ├── shadcn
│ │ │ ├── alert-dialog.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── button.tsx
│ │ │ ├── calendar.tsx
│ │ │ ├── card.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── command.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── drawer.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── form.tsx
│ │ │ ├── icon.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── select.tsx
│ │ │ ├── sheet.tsx
│ │ │ ├── skeleton.tsx
│ │ │ ├── sonner.tsx
│ │ │ ├── spinner.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── table.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── time-picker
│ │ │ │ ├── date-time-picker.tsx
│ │ │ │ ├── time-picker-input.tsx
│ │ │ │ ├── time-picker-inputs.tsx
│ │ │ │ └── time-picker-utils.ts
│ │ │ ├── toggle-group.tsx
│ │ │ ├── toggle.tsx
│ │ │ └── tooltip.tsx
│ │ └── sheet.tsx
│ ├── globals.css
│ └── utils.ts
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tsconfig.json
└── turbo.json
/.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 | # env
9 | .env
10 | .env.local
11 | .env.development.local
12 | .env.test.local
13 | .env.production.local
14 |
15 | # next.js
16 | .next/
17 | out/
18 | next-env.d.ts
19 |
20 | # Testing
21 | coverage
22 |
23 | # Turbo
24 | .turbo
25 |
26 | # Vercel
27 | .vercel
28 |
29 | # Build Outputs
30 | out/
31 | build
32 | dist
33 |
34 |
35 | # Debug
36 | npm-debug.log*
37 | yarn-debug.log*
38 | yarn-error.log*
39 |
40 | # Misc
41 | .DS_Store
42 | *.pem
43 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | public-hoist-pattern[]=*prisma*
--------------------------------------------------------------------------------
/.nvm:
--------------------------------------------------------------------------------
1 | 20.11.0
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "prettier.enable": false,
3 | "files.associations": {
4 | "*.css": "tailwindcss"
5 | },
6 | "editor.codeActionsOnSave": {
7 | "source.organizeImports.biome": "explicit"
8 | },
9 | "[prisma]": {
10 | "editor.defaultFormatter": "Prisma.prisma"
11 | },
12 | "[typescript]": {
13 | "editor.defaultFormatter": "biomejs.biome"
14 | },
15 | "[typescriptreact]": {
16 | "editor.defaultFormatter": "biomejs.biome"
17 | },
18 | "[json]": {
19 | "editor.defaultFormatter": "biomejs.biome"
20 | },
21 | "editor.formatOnSave": true,
22 | "tailwindCSS.experimental.configFile": "./packages/ui/tailwind.config.ts",
23 | "tailwindCSS.experimental.classRegex": [
24 | ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
25 | ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
26 | ],
27 | "typescript.enablePromptUseWorkspaceTsdk": true,
28 | "typescript.tsdk": "node_modules/typescript/lib",
29 | "typescript.preferences.autoImportFileExcludePatterns": ["next/router.d.ts", "next/dist/client/router.d.ts"]
30 | }
31 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | The open-source alternative to Zendesk
8 | A modern and simple platform to make customer support extremely easy
9 |
10 |
11 | Website ·
12 | Sign in ·
13 | 𝕏 / Twitter
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | ---
23 |
24 | Seventy Seven is an easy to use customer support service. Use the endpoint for your users to create a support ticket, and use our dashboard to conversate.
25 |
26 | ## Stack
27 | - Next.js (14) - Framework
28 | - Supabase - Database, authentication and storage
29 | - Resend - Transactional emails
30 | - Trigger.dev - Scheduled background jobs
31 | - TailwindCSS - Styling
32 | - Shadcn - UI
33 | - OpenPanel - Analytics
34 |
35 | ## Disclaimer
36 | The product is in beta which means that some of the functionality you want or need is either not yet implemented or could contain some bugs. Don't hesitate to hit me up on 𝕏 ([@c_alares](https://twitter.com/c_alares)) or create a github issue.
37 |
38 | ## Notes:
39 | We will add this Biome rule when they're is ready for this rule to be formatted on auto-save.
40 | ```
41 | "nursery": {
42 | "useSortedClasses": {
43 | "level": "warn",
44 | "options": {
45 | "attributes": ["className"],
46 | "functions": ["cn", "cva"]
47 | }
48 | }
49 | }
50 | ```
51 | Read more here: https://biomejs.dev/linter/rules/use-sorted-classes/
--------------------------------------------------------------------------------
/apps/dashboard/.gitignore:
--------------------------------------------------------------------------------
1 | .trigger
--------------------------------------------------------------------------------
/apps/dashboard/README.md:
--------------------------------------------------------------------------------
1 | # 77 Dashboard
2 |
--------------------------------------------------------------------------------
/apps/dashboard/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/apps/dashboard/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import("next").NextConfig} */
2 | const config = {
3 | transpilePackages: ['@seventy-seven/ui'],
4 | reactStrictMode: true,
5 | typescript: {
6 | ignoreBuildErrors: true,
7 | },
8 | images: {
9 | remotePatterns: [
10 | {
11 | protocol: 'http',
12 | hostname: '127.0.0.1',
13 | },
14 | {
15 | protocol: 'https',
16 | hostname: '**',
17 | },
18 | ],
19 | },
20 | }
21 |
22 | export default config
23 |
--------------------------------------------------------------------------------
/apps/dashboard/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = require('@seventy-seven/ui/postcss.config.js')
2 |
--------------------------------------------------------------------------------
/apps/dashboard/public/circles.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/apps/dashboard/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/dashboard/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/account/layout.tsx:
--------------------------------------------------------------------------------
1 | import { AccountSubNav } from '@/components/account-sub-nav'
2 |
3 | type Props = {
4 | children: React.ReactNode
5 | }
6 |
7 | const AccountLayout = ({ children }: Props) => {
8 | return (
9 |
10 |
11 |
12 |
{children}
13 |
14 |
15 | )
16 | }
17 |
18 | export default AccountLayout
19 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/account/notifications/page.tsx:
--------------------------------------------------------------------------------
1 | import { NotificationsEmailSwitches } from '@/components/notifications-email-switches'
2 | import { PageWrapper } from '@/components/page-wrapper'
3 | import { HydrateClient, trpc } from '@/trpc/server'
4 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@seventy-seven/ui/card'
5 | import { Icon } from '@seventy-seven/ui/icon'
6 | import Link from 'next/link'
7 |
8 | export const dynamic = 'force-dynamic'
9 |
10 | const NotificationsPage = () => {
11 | trpc.users.me.prefetch()
12 |
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 | Email
21 |
22 | Get an email notification when some of these events occur
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | Slack
33 |
34 |
35 |
36 |
37 | Slack notifications are based on your teams integrations. Check{' '}
38 |
39 | team integrations
40 | {' '}
41 | to handle those.
42 |
43 |
44 |
45 |
46 |
47 | )
48 | }
49 |
50 | export default NotificationsPage
51 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/account/page.tsx:
--------------------------------------------------------------------------------
1 | import { EditDisplayNameForm } from '@/components/forms/edit-display-name-form'
2 | import { PageWrapper } from '@/components/page-wrapper'
3 | import { HydrateClient, trpc } from '@/trpc/server'
4 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@seventy-seven/ui/card'
5 | import { Icon } from '@seventy-seven/ui/icon'
6 | import { Skeleton } from '@seventy-seven/ui/skeleton'
7 | import _dynamic from 'next/dynamic'
8 |
9 | export const dynamic = 'force-dynamic'
10 |
11 | const ThemeSwitch = _dynamic(() => import('@/components/theme-switch').then(({ ThemeSwitch }) => ThemeSwitch), {
12 | ssr: false,
13 | loading: () => ,
14 | })
15 |
16 | const AccountPage = () => {
17 | trpc.users.me.prefetch()
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Appearance
29 |
30 | Customize how Seventy Seven looks on your device
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | export default AccountPage
43 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/account/teams/layout.tsx:
--------------------------------------------------------------------------------
1 | import { PageWrapper } from '@/components/page-wrapper'
2 | import { HydrateClient, trpc } from '@/trpc/server'
3 |
4 | export const dynamic = 'force-dynamic'
5 |
6 | const TeamsLayout = ({ children }: { children: React.ReactNode }) => {
7 | trpc.teams.findMany.prefetch()
8 |
9 | return (
10 |
11 | {children}
12 |
13 | )
14 | }
15 |
16 | export default TeamsLayout
17 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/account/teams/loading.tsx:
--------------------------------------------------------------------------------
1 | const TeamsLoadingPage = () => {
2 | return Loading teams
3 | }
4 |
5 | export default TeamsLoadingPage
6 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/account/teams/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { CreateTeamButton } from '@/components/create-team-button'
4 | import { TeamListItem } from '@/components/team-list-item'
5 | import { trpc } from '@/trpc/client'
6 |
7 | const AccountTeamsPage = () => {
8 | const [userTeams] = trpc.teams.findMany.useSuspenseQuery()
9 |
10 | return (
11 | <>
12 |
13 |
14 |
15 |
16 | {userTeams.length === 0 ? (
17 | You don't belong to any team.
18 | ) : (
19 | userTeams.map((userTeam) => (
20 |
23 | ))
24 | )}
25 | >
26 | )
27 | }
28 |
29 | export default AccountTeamsPage
30 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/help/layout.tsx:
--------------------------------------------------------------------------------
1 | import { HydrateClient, trpc } from '@/trpc/server'
2 |
3 | type Props = {
4 | children: React.ReactNode
5 | }
6 |
7 | const HelpLayout = ({ children }: Props) => {
8 | trpc.users.me.prefetch()
9 |
10 | return (
11 |
12 | {children}
13 |
14 | )
15 | }
16 |
17 | export default HelpLayout
18 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/inbox/layout.tsx:
--------------------------------------------------------------------------------
1 | import { TicketSearchForm } from '@/components/forms/ticket-search-form'
2 | import { TicketFilterLoading, TicketFiltersDropdown } from '@/components/ticket-filters/ticket-filters-dropdown'
3 | import { HydrateClient, trpc } from '@/trpc/server'
4 | import { Suspense } from 'react'
5 |
6 | export const dynamic = 'force-dynamic'
7 |
8 | type Props = {
9 | children: React.ReactNode
10 | }
11 |
12 | const InboxLayout = ({ children }: Props) => {
13 | trpc.users.myCurrentTeam.prefetch()
14 |
15 | return (
16 |
17 |
18 |
19 |
20 | }>
21 |
22 |
23 |
24 | {children}
25 |
26 |
27 | )
28 | }
29 |
30 | export default InboxLayout
31 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/inbox/page.tsx:
--------------------------------------------------------------------------------
1 | import { NoTicketSelected } from '@/components/no-ticket-selected'
2 | import { SelectedTicket, SelectedTicketSkeleton } from '@/components/selected-ticket'
3 | import { TicketListSkeleton, TicketsList } from '@/components/tickets-list'
4 | import { ticketFiltersCache, ticketIdCache } from '@/lib/search-params'
5 | import { trpc } from '@/trpc/server'
6 | import { cn } from '@seventy-seven/ui/utils'
7 | import { Suspense } from 'react'
8 |
9 | type Props = {
10 | searchParams: Record
11 | }
12 |
13 | const InboxRootPage = ({ searchParams }: Props) => {
14 | const ticketId = ticketIdCache.parse(searchParams)
15 | const filter = ticketFiltersCache.parse(searchParams)
16 | const numberOfActiveFilters = Object.values(filter).filter((value) => value !== null).length
17 |
18 | trpc.tickets.findMany.prefetch({
19 | statuses: filter.statuses ?? undefined,
20 | memberIds: filter.assignees ?? undefined,
21 | query: filter.q ?? undefined,
22 | tags: filter.tags ?? undefined,
23 | })
24 |
25 | return (
26 |
27 |
32 | }>
33 |
34 |
35 |
36 |
37 |
42 | }>
43 | {ticketId.ticketId ? : }
44 |
45 |
46 |
47 | )
48 | }
49 |
50 | export default InboxRootPage
51 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { AlertProvider } from '@/components/alerts'
2 | import { AnalyticsSetProfile } from '@/components/analytics-set-profile'
3 | import { Header } from '@/components/header'
4 | import { ModalProvider } from '@/components/modals'
5 | import { SheetProvider } from '@/components/sheets'
6 | import { HydrateClient, trpc } from '@/trpc/server'
7 | import { Toaster } from '@seventy-seven/ui/sonner'
8 |
9 | export const dynamic = 'force-dynamic'
10 |
11 | type Props = {
12 | children: React.ReactNode
13 | }
14 |
15 | const AuthedLayout = ({ children }: Props) => {
16 | trpc.users.me.prefetch()
17 |
18 | return (
19 | <>
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | {children}
32 |
33 |
34 | >
35 | )
36 | }
37 |
38 | export default AuthedLayout
39 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/page.tsx:
--------------------------------------------------------------------------------
1 | import { trpc } from '@/trpc/server'
2 | import { redirect } from 'next/navigation'
3 |
4 | export const dynamic = 'force-dynamic'
5 |
6 | const AuthorizedPage = async () => {
7 | const user = await trpc.users.maybeMe()
8 |
9 | if (user) {
10 | redirect('/inbox')
11 | }
12 |
13 | return null
14 | }
15 |
16 | export default AuthorizedPage
17 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/settings/integrations/layout.tsx:
--------------------------------------------------------------------------------
1 | import { PageWrapper } from '@/components/page-wrapper'
2 | import { HydrateClient, trpc } from '@/trpc/server'
3 |
4 | export const dynamic = 'force-dynamic'
5 |
6 | const IntegrationsLayout = ({ children }: { children: React.ReactNode }) => {
7 | trpc.integrations.getSlackIntegration.prefetch()
8 |
9 | return (
10 |
11 | {children}
12 |
13 | )
14 | }
15 |
16 | export default IntegrationsLayout
17 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/settings/integrations/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { AddSlackIntegrationButton } from '@/components/add-slack-integration-button'
4 | import { RevokeSlackIntegrationButton } from '@/components/revoke-slack-integration-button'
5 | import { trpc } from '@/trpc/client'
6 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@seventy-seven/ui/card'
7 | import { Icon } from '@seventy-seven/ui/icon'
8 |
9 | const IntegrationsPage = () => {
10 | const [{ slackIntegration, slackInstallUrl }] = trpc.integrations.getSlackIntegration.useSuspenseQuery()
11 |
12 | if (slackIntegration) {
13 | return (
14 |
15 |
16 |
17 |
18 | Slack
19 |
20 |
21 | Slack notifications can be used to keep you and your team updated on relevant activities in Seventy Seven.
22 |
23 |
24 |
25 |
26 |
27 | Seventy Seven is connected to the channel{' '}
28 | {slackIntegration.slack_channel} in{' '}
29 | {slackIntegration.slack_team_name}
30 |
31 |
32 |
33 |
34 | <>
35 | Revoke slack integration
36 |
37 | >
38 |
39 |
40 | )
41 | }
42 |
43 | return (
44 |
45 |
46 |
47 |
48 | Slack
49 |
50 |
51 | Slack notifications can be used to keep you and your team updated on relevant activities in Seventy Seven.
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | )
60 | }
61 |
62 | export default IntegrationsPage
63 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/settings/layout.tsx:
--------------------------------------------------------------------------------
1 | import { SettingsSubNav } from '@/components/settings-sub-nav'
2 | import { HydrateClient, trpc } from '@/trpc/server'
3 |
4 | export const dynamic = 'force-dynamic'
5 |
6 | type Props = {
7 | children: React.ReactNode
8 | }
9 |
10 | const SettingsLayout = ({ children }: Props) => {
11 | trpc.users.me.prefetch()
12 |
13 | return (
14 |
15 |
16 |
17 |
Team settings
18 |
19 |
20 |
{children}
21 |
22 |
23 |
24 | )
25 | }
26 |
27 | export default SettingsLayout
28 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/settings/members/layout.tsx:
--------------------------------------------------------------------------------
1 | import { InviteTeamMemberButton } from '@/components/invite-team-member-button'
2 | import { MembersListTabNav } from '@/components/members-list-tab-nav'
3 | import { PageWrapper } from '@/components/page-wrapper'
4 | import { HydrateClient, trpc } from '@/trpc/server'
5 | import { Skeleton } from '@seventy-seven/ui/skeleton'
6 | import { Suspense } from 'react'
7 |
8 | export const dynamic = 'force-dynamic'
9 |
10 | type Props = {
11 | children: React.ReactNode
12 | }
13 |
14 | const MembersSettingsLayout = ({ children }: Props) => {
15 | trpc.users.myCurrentTeam.prefetch()
16 |
17 | return (
18 |
19 |
20 |
21 |
22 | }>
23 |
24 |
25 |
26 | {children}
27 |
28 |
29 | )
30 | }
31 |
32 | export default MembersSettingsLayout
33 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/settings/members/page.tsx:
--------------------------------------------------------------------------------
1 | import { TeamMembers, TeamMembersSkeleton } from '@/components/team-members'
2 | import { trpc } from '@/trpc/server'
3 | import { Suspense } from 'react'
4 |
5 | const MembersPage = () => {
6 | trpc.users.myCurrentTeam.prefetch()
7 |
8 | return (
9 | }>
10 |
11 |
12 | )
13 | }
14 |
15 | export default MembersPage
16 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/settings/members/pending/page.tsx:
--------------------------------------------------------------------------------
1 | import { PendingTeamMembers, PendingTeamMembersSkeleton } from '@/components/pending-team-members'
2 | import { HydrateClient, trpc } from '@/trpc/server'
3 | import { Suspense } from 'react'
4 |
5 | const PendingMembersPage = () => {
6 | trpc.teams.invites.prefetch()
7 |
8 | return (
9 |
10 | }>
11 |
12 |
13 |
14 | )
15 | }
16 |
17 | export default PendingMembersPage
18 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import { UpdateTeamAvatarForm } from '@/components/forms/update-team-avatar-form'
2 | import { UpdateTeamNameForm } from '@/components/forms/update-team-name-form'
3 | import { PageWrapper } from '@/components/page-wrapper'
4 |
5 | const SettingsPage = () => {
6 | return (
7 |
8 |
9 |
10 |
11 | )
12 | }
13 |
14 | export default SettingsPage
15 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/settings/security/page.tsx:
--------------------------------------------------------------------------------
1 | import { AuthToken } from '@/components/auth-token'
2 | import { HydrateClient, trpc } from '@/trpc/server'
3 |
4 | const SecurityPage = () => {
5 | trpc.users.myCurrentTeam.prefetch()
6 |
7 | return (
8 |
9 |
10 |
11 | )
12 | }
13 |
14 | export default SecurityPage
15 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/settings/tags/layout.tsx:
--------------------------------------------------------------------------------
1 | import { PageWrapper } from '@/components/page-wrapper'
2 | import { HydrateClient, trpc } from '@/trpc/server'
3 |
4 | const TagsLayout = ({ children }: { children: React.ReactNode }) => {
5 | trpc.users.myCurrentTeam.prefetch()
6 |
7 | return (
8 |
9 | {children}
10 |
11 | )
12 | }
13 |
14 | export default TagsLayout
15 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(app)/settings/tags/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { CreateTagButton } from '@/components/create-tag-button'
4 | import { TicketTagsTable } from '@/components/ticket-tags-table'
5 | import { trpc } from '@/trpc/client'
6 |
7 | const TagsPage = () => {
8 | const [user] = trpc.users.myCurrentTeam.useSuspenseQuery()
9 |
10 | return (
11 | <>
12 | {user.current_team.ticket_tags.length === 0 ? (
13 |
14 |
Create tag
15 |
You have not yet created any tags
16 |
17 | ) : (
18 |
19 |
Create tag
20 |
21 |
22 |
23 |
24 |
25 | )}
26 | >
27 | )
28 | }
29 |
30 | export default TagsPage
31 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(public)/all-done/event-emitter.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect } from 'react'
4 | import type { WindowEvent } from './schema'
5 |
6 | type Props = {
7 | event: WindowEvent
8 | }
9 |
10 | export const EventEmitter = ({ event }: Props) => {
11 | useEffect(() => {
12 | if (!window?.opener) {
13 | return
14 | }
15 |
16 | if (event) {
17 | window.opener.postMessage(event, '*')
18 | }
19 | }, [event])
20 |
21 | return null
22 | }
23 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(public)/all-done/page.tsx:
--------------------------------------------------------------------------------
1 | import { notFound } from 'next/navigation'
2 | import { EventEmitter } from './event-emitter'
3 | import { searchParamsSchema } from './schema'
4 |
5 | type Props = {
6 | searchParams: Record
7 | }
8 |
9 | const AllDonePage = ({ searchParams }: Props) => {
10 | const parsedSearchParams = searchParamsSchema.safeParse(searchParams)
11 |
12 | if (!parsedSearchParams.success) {
13 | notFound()
14 | }
15 |
16 | return (
17 | <>
18 |
19 |
20 |
All done, you can close this window!
21 |
22 | >
23 | )
24 | }
25 |
26 | export default AllDonePage
27 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(public)/all-done/schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const searchParamsSchema = z.object({
4 | event: z.literal('slack_oauth_completed'),
5 | })
6 |
7 | export type WindowEvent = z.infer['event']
8 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(public)/invite/[inviteCode]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster } from '@seventy-seven/ui/sonner'
2 |
3 | type Props = {
4 | children: React.ReactNode
5 | }
6 |
7 | const InviteCodeLayout = ({ children }: Props) => {
8 | return (
9 | <>
10 |
11 | {children}
12 | >
13 | )
14 | }
15 |
16 | export default InviteCodeLayout
17 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(public)/invite/[inviteCode]/page.tsx:
--------------------------------------------------------------------------------
1 | import { AcceptInvitationButton } from '@/components/accept-invitation-button'
2 | import { trpc } from '@/trpc/server'
3 | import { Logo } from '@seventy-seven/ui/logo'
4 | import Image from 'next/image'
5 | import { notFound } from 'next/navigation'
6 | import { z } from 'zod'
7 |
8 | const paramsSchema = z.object({
9 | inviteCode: z.string(),
10 | })
11 |
12 | type Props = {
13 | params: Record
14 | }
15 |
16 | const InviteCodePage = async ({ params }: Props) => {
17 | const parsedParams = paramsSchema.safeParse(params)
18 |
19 | if (!parsedParams.success) {
20 | notFound()
21 | }
22 |
23 | const user = await trpc.users.me()
24 |
25 | try {
26 | const invite = await trpc.invites.get({ inviteCode: parsedParams.data.inviteCode })
27 |
28 | if (!invite) {
29 | notFound()
30 | }
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
39 | {invite.team.image_url && (
40 |
47 | )}
48 |
{invite.team.name}
49 |
50 |
51 |
52 |
Hello, {user.full_name}!
53 |
54 | You have been invited to join the team {invite.team.name} by {invite.created_by.full_name}.
55 |
56 |
57 |
60 |
61 |
62 |
63 | )
64 | } catch (_error) {
65 | notFound()
66 | }
67 | }
68 |
69 | export default InviteCodePage
70 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/(public)/login/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignInWithGithubButton } from '@/components/sign-in-buttons/sign-in-with-github-button'
2 | import { SignInWithGoogleButton } from '@/components/sign-in-buttons/sign-in-with-google-button'
3 | import { Logo } from '@seventy-seven/ui/logo'
4 |
5 | type Props = {
6 | searchParams: {
7 | return_to?: string
8 | }
9 | }
10 |
11 | const LoginPage = ({ searchParams }: Props) => {
12 | return (
13 |
14 |
15 |
16 |
17 |
Welcome to 77
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | )
27 | }
28 |
29 | export default LoginPage
30 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/api/auth/callback/route.ts:
--------------------------------------------------------------------------------
1 | import { analyticsClient } from '@/lib/analytics'
2 | import { trpc } from '@/trpc/server'
3 | import { createClient } from '@seventy-seven/supabase/clients/server'
4 | import { NextResponse } from 'next/server'
5 |
6 | export async function GET(request: Request) {
7 | const requestUrl = new URL(request.url)
8 | const code = requestUrl.searchParams.get('code')
9 | const origin = requestUrl.origin
10 |
11 | const returnTo = requestUrl.searchParams.get('return_to')
12 |
13 | if (code) {
14 | const sb = createClient()
15 | await sb.auth.exchangeCodeForSession(code)
16 |
17 | const user = await trpc.users.me()
18 |
19 | analyticsClient.event('login', {
20 | email: user.email,
21 | full_name: user.full_name,
22 | profileId: user.id,
23 | })
24 | }
25 |
26 | if (returnTo) {
27 | return NextResponse.redirect(`${origin}/${returnTo}`)
28 | }
29 |
30 | return NextResponse.redirect(origin)
31 | }
32 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/api/trpc/[trpc]/route.ts:
--------------------------------------------------------------------------------
1 | import { createTRPCContext } from '@/trpc/init'
2 | import { appRouter } from '@/trpc/routers/_app'
3 | import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
4 |
5 | export const dynamic = 'force-dynamic'
6 |
7 | const handler = (req: Request) =>
8 | fetchRequestHandler({
9 | endpoint: '/api/trpc',
10 | req,
11 | router: appRouter,
12 | createContext: createTRPCContext,
13 | })
14 |
15 | export { handler as GET, handler as POST }
16 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/api/webhook/new-user/route.ts:
--------------------------------------------------------------------------------
1 | import { analyticsClient } from '@/lib/analytics'
2 | import { createResendClient } from '@seventy-seven/email'
3 | import { headers } from 'next/headers'
4 | import { NextResponse } from 'next/server'
5 | import { z } from 'zod'
6 |
7 | const newUserWebhookPostSchema = z.object({
8 | type: z.union([z.literal('INSERT'), z.literal('UPDATE'), z.literal('DELETE')]),
9 | table: z.literal('users'),
10 | record: z.object({
11 | id: z.string().uuid(),
12 | email: z.string().email(),
13 | full_name: z.string(),
14 | image_url: z.string().nullable(),
15 | created_at: z.string(),
16 | updated_at: z.string().nullable(),
17 | current_team_id: z.string().uuid(),
18 | }),
19 | })
20 |
21 | export async function POST(req: Request) {
22 | const apiKey = headers().get('X-Api-Key')
23 |
24 | if (!apiKey) {
25 | return NextResponse.json({ error: 'Missing API key' }, { status: 400 })
26 | }
27 |
28 | if (apiKey !== process.env.API_ROUTE_SECRET) {
29 | return NextResponse.json({ error: 'Invalid API key' }, { status: 403 })
30 | }
31 |
32 | const parsedBody = newUserWebhookPostSchema.safeParse(await req.json())
33 |
34 | if (!parsedBody.success) {
35 | const errors = parsedBody.error.errors.map((error) => ({
36 | path: error.path.join('.'),
37 | message: error.message,
38 | }))
39 |
40 | return NextResponse.json({ error: 'Invalid request body', errors }, { status: 400 })
41 | }
42 |
43 | const resend = createResendClient()
44 |
45 | const [firstName, lastName] = parsedBody.data.record.full_name.split(' ')
46 |
47 | // Create a new contact in Resend
48 | await resend.contacts.create({
49 | email: parsedBody.data.record.email,
50 | firstName,
51 | lastName,
52 | unsubscribed: false,
53 | // ID for the "General" audience
54 | audienceId: 'f90d06f7-da55-4db8-a55d-7bdbecdcba33',
55 | })
56 |
57 | analyticsClient.event('new_user', {
58 | email: parsedBody.data.record.email,
59 | full_name: parsedBody.data.record.full_name,
60 | profileId: parsedBody.data.record.id,
61 | })
62 |
63 | return NextResponse.json({ success: true }, { status: 200 })
64 | }
65 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianalares/seventy-seven/943563958c456f57ae35579b400eacf02cc02231/apps/dashboard/src/app/favicon.ico
--------------------------------------------------------------------------------
/apps/dashboard/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | pre::-webkit-scrollbar {
7 | display: none;
8 | }
9 |
10 | * {
11 | @apply border-border;
12 | }
13 |
14 | body {
15 | @apply bg-background text-foreground font-sans;
16 | }
17 | }
--------------------------------------------------------------------------------
/apps/dashboard/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeProvider } from '@/components/theme-provider'
2 | import { TRPCProvider } from '@/trpc/client'
3 | import { AnalyticsProvider } from '@seventy-seven/analytics'
4 | import '@seventy-seven/ui/globals.css'
5 | import { cn } from '@seventy-seven/ui/utils'
6 | import type { Metadata, Viewport } from 'next'
7 | import { Maven_Pro, Roboto } from 'next/font/google'
8 | import './globals.css'
9 |
10 | export const viewport: Viewport = {
11 | width: 'device-width',
12 | initialScale: 1,
13 | maximumScale: 1,
14 | userScalable: false,
15 | }
16 |
17 | export const metadata: Metadata = {
18 | metadataBase: new URL('https://seventy-seven.dev'),
19 | title: {
20 | default: 'Seventy Seven | The open source alternative to Zendesk',
21 | template: '%s | Seventy Seven',
22 | },
23 | description: 'A modern and simple platform to make customer support extremely easy',
24 | }
25 |
26 | const mavenPro = Maven_Pro({
27 | subsets: ['latin'],
28 | variable: '--maven-pro',
29 | })
30 |
31 | const roboto = Roboto({
32 | subsets: ['latin'],
33 | // light, normal, medium, bold
34 | weight: ['300', '400', '500', '700'],
35 | variable: '--roboto',
36 | })
37 |
38 | type Props = {
39 | children: React.ReactNode
40 | }
41 |
42 | const NEXT_PUBLIC_OPENPANEL_DASHBOARD_CLIENT_ID = process.env.NEXT_PUBLIC_OPENPANEL_DASHBOARD_CLIENT_ID
43 |
44 | if (!NEXT_PUBLIC_OPENPANEL_DASHBOARD_CLIENT_ID) {
45 | throw new Error('NEXT_PUBLIC_OPENPANEL_DASHBOARD_CLIENT_ID is required')
46 | }
47 |
48 | const RootLayout = async ({ children }: Props) => {
49 | return (
50 |
51 |
52 |
53 |
54 |
55 |
56 | {children}
57 |
58 |
59 |
60 |
61 | )
62 | }
63 |
64 | export default RootLayout
65 |
--------------------------------------------------------------------------------
/apps/dashboard/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | const NotFound = () => {
2 | return (
3 |
4 |
404
5 |
This page could not be found 🥺
6 |
7 | )
8 | }
9 |
10 | export default NotFound
11 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/accept-invitation-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import { Button } from '@seventy-seven/ui/button'
5 | import { useRouter } from 'next/navigation'
6 | import { toast } from 'sonner'
7 |
8 | type Props = {
9 | teamId: string
10 | }
11 |
12 | export const AcceptInvitationButton = ({ teamId }: Props) => {
13 | const router = useRouter()
14 |
15 | const acceptInvitationMutation = trpc.invites.accept.useMutation({
16 | onSuccess: () => {
17 | toast.success('Invitation accepted')
18 | router.push('/account/teams')
19 | },
20 | onError: (error) => {
21 | toast.error(error.message)
22 | },
23 | })
24 |
25 | return (
26 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/account-sub-nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { cn } from '@seventy-seven/ui/utils'
4 | import { motion } from 'framer-motion'
5 | import Link from 'next/link'
6 | import { usePathname, useSelectedLayoutSegment } from 'next/navigation'
7 |
8 | type SubLinkItemProps = {
9 | href: string
10 | label: string
11 | }
12 |
13 | const SubLinkItem = ({ href, label }: SubLinkItemProps) => {
14 | const segment = useSelectedLayoutSegment()
15 | const pathname = usePathname()
16 |
17 | const isActive = (href === '/account' && segment === null) || pathname === href
18 |
19 | return (
20 |
21 |
29 | {label}
30 |
31 | {isActive && (
32 |
36 | )}
37 |
38 | )
39 | }
40 |
41 | export const AccountSubNav = () => {
42 | return (
43 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/add-slack-integration-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@seventy-seven/ui/button'
4 | import { Icon } from '@seventy-seven/ui/icon'
5 | import { useRouter } from 'next/navigation'
6 |
7 | type Props = {
8 | url: string
9 | }
10 |
11 | export const AddSlackIntegrationButton = ({ url }: Props) => {
12 | const router = useRouter()
13 |
14 | const openPopup = () => {
15 | const width = 600
16 | const height = 800
17 | const left = window.screenX + (window.outerWidth - width) / 2
18 | const top = window.screenY + (window.outerHeight - height) / 2.5
19 |
20 | const popup = window.open(
21 | url,
22 | '',
23 | `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${width}, height=${height}, top=${top}, left=${left}`,
24 | )
25 |
26 | // The popup might have been blocked, so we redirect the user to the URL instead
27 | if (!popup) {
28 | window.location.href = url
29 | return
30 | }
31 |
32 | const listener = (e: MessageEvent) => {
33 | if (e.data === 'slack_oauth_completed') {
34 | router.refresh()
35 | window.removeEventListener('message', listener)
36 | popup.close()
37 | }
38 | }
39 |
40 | window.addEventListener('message', listener)
41 | }
42 |
43 | return (
44 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/alerts/confirm-change-your-own-role.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import type { TEAM_ROLE_ENUM } from '@seventy-seven/orm/enums'
5 | import { Alert, AlertCancel, AlertDescription, AlertFooter, AlertTitle } from '@seventy-seven/ui/alert'
6 | import { Button } from '@seventy-seven/ui/button'
7 | import { DialogHeader } from '@seventy-seven/ui/dialog'
8 | import { toast } from 'sonner'
9 | import { popAlert } from '.'
10 |
11 | type Props = {
12 | teamId: string
13 | memberId: string
14 | role: TEAM_ROLE_ENUM
15 | }
16 |
17 | export const ConfirmChangeYourOwnRoleAlert = ({ teamId, memberId, role }: Props) => {
18 | const trpcUtils = trpc.useUtils()
19 |
20 | const changeMemberRoleMutation = trpc.teams.changeMemberRole.useMutation({
21 | onSuccess: (updatedUserOnTeam) => {
22 | trpcUtils.users.myCurrentTeam.invalidate()
23 |
24 | toast.success(`Role for ${updatedUserOnTeam.user.full_name} changed to ${updatedUserOnTeam.role.toLowerCase()}`)
25 | popAlert('confirmChangeYourOwnRole')
26 | },
27 | onError: (error) => {
28 | toast.error(error.message)
29 | },
30 | })
31 |
32 | return (
33 |
34 |
35 | Change your own role
36 |
37 | You are about to change your own role. If you proceed you won't be able to change it back, someone with a
38 | higher role will have to change it for you.
39 |
40 |
41 |
42 | Are you sure you want to continue?
43 |
44 |
45 | Cancel
46 |
59 |
60 |
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/alerts/confirm-leave-team-alert.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import { Alert, AlertCancel, AlertDescription, AlertFooter, AlertTitle } from '@seventy-seven/ui/alert'
5 | import { Button } from '@seventy-seven/ui/button'
6 | import { DialogHeader } from '@seventy-seven/ui/dialog'
7 | import { toast } from 'sonner'
8 | import { popAlert } from '.'
9 |
10 | type Props = {
11 | teamId: string
12 | }
13 |
14 | export const ConfirmLeaveTeamAlert = ({ teamId }: Props) => {
15 | const trpcUtils = trpc.useUtils()
16 |
17 | const leaveTeamMutation = trpc.teams.leave.useMutation({
18 | onSuccess: (leftTeam) => {
19 | trpcUtils.users.me.invalidate()
20 | trpcUtils.teams.invites.invalidate()
21 | trpcUtils.teams.findMany.invalidate()
22 |
23 | toast.success(`You have left the team ${leftTeam.team.name}`)
24 | popAlert('confirmLeaveTeam')
25 | },
26 | onError: (error) => {
27 | toast.error(error.message)
28 | },
29 | })
30 |
31 | return (
32 |
33 |
34 | Leave team
35 |
36 | By leaving this team you will no longer have acces to this team. In order to regain access another team owner
37 | must invite you again.
38 |
39 |
40 |
41 | Are you sure you want to continue?
42 |
43 |
44 | Cancel
45 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/alerts/confirm-remove-team-member-alert.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import { Alert, AlertCancel, AlertDescription, AlertFooter, AlertTitle } from '@seventy-seven/ui/alert'
5 | import { Button } from '@seventy-seven/ui/button'
6 | import { DialogHeader } from '@seventy-seven/ui/dialog'
7 | import { toast } from 'sonner'
8 | import { popAlert } from '.'
9 |
10 | type Props = {
11 | teamId: string
12 | memberId: string
13 | }
14 |
15 | export const ConfirmRemoveTeamMemberAlert = ({ teamId, memberId }: Props) => {
16 | const trpcUtils = trpc.useUtils()
17 |
18 | const removeMemberMutation = trpc.teams.removeMember.useMutation({
19 | onSuccess: (deletedUserOnTeam) => {
20 | trpcUtils.users.myCurrentTeam.invalidate()
21 |
22 | toast.success(`${deletedUserOnTeam.user.full_name} was removed from the team "${deletedUserOnTeam.team.name}"`)
23 | popAlert('confirmRemoveTeamMember')
24 | },
25 | onError: (error) => {
26 | toast.error(error.message)
27 | },
28 | })
29 |
30 | return (
31 |
32 |
33 | Remove member
34 |
35 | You are about to remove a member from the team. This action cannot be undone.
36 |
37 |
38 |
39 | Are you sure you want to continue?
40 |
41 |
42 | Cancel
43 |
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/alerts/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { AlertDialog } from '@seventy-seven/ui/alert'
4 | import { createPushModal } from '@seventy-seven/ui/modal'
5 | import { ConfirmChangeYourOwnRoleAlert } from './confirm-change-your-own-role'
6 | import { ConfirmLeaveTeamAlert } from './confirm-leave-team-alert'
7 | import { ConfirmRemoveTeamMemberAlert } from './confirm-remove-team-member-alert'
8 | import { ProhibitLastOwnerRoleChangeAlert } from './prohibit-last-owner-role-change-alert'
9 |
10 | export const {
11 | pushModal: pushAlert,
12 | popModal: popAlert,
13 | ModalProvider: AlertProvider,
14 | } = createPushModal({
15 | modals: {
16 | confirmLeaveTeam: {
17 | Component: ConfirmLeaveTeamAlert,
18 | Wrapper: (props) => ,
19 | },
20 | confirmRemoveTeamMember: {
21 | Component: ConfirmRemoveTeamMemberAlert,
22 | Wrapper: (props) => ,
23 | },
24 | confirmChangeYourOwnRole: {
25 | Component: ConfirmChangeYourOwnRoleAlert,
26 | Wrapper: (props) => ,
27 | },
28 | prohibitLastOwnerRoleChange: {
29 | Component: ProhibitLastOwnerRoleChangeAlert,
30 | Wrapper: (props) => ,
31 | },
32 | },
33 | })
34 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/alerts/prohibit-last-owner-role-change-alert.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Alert, AlertDescription, AlertFooter, AlertTitle } from '@seventy-seven/ui/alert'
4 | import { Button } from '@seventy-seven/ui/button'
5 | import { DialogHeader } from '@seventy-seven/ui/dialog'
6 | import { popAlert } from '.'
7 |
8 | export const ProhibitLastOwnerRoleChangeAlert = () => {
9 | return (
10 |
11 |
12 | You are the last owner
13 |
14 | You are the last owner in this team and can therefore not change your role to member. Please transfer
15 | ownership before changing your role.
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/analytics-set-profile.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import { setProfile } from '@seventy-seven/analytics'
5 | import { useEffect } from 'react'
6 |
7 | export const AnalyticsSetProfile = () => {
8 | const { data: user } = trpc.users.maybeMe.useQuery()
9 |
10 | useEffect(() => {
11 | if (!user) {
12 | return
13 | }
14 |
15 | setProfile({
16 | profileId: user.id,
17 | firstName: user.full_name,
18 | email: user.email,
19 | avatar: user.image_url ?? undefined,
20 | })
21 | }, [user])
22 |
23 | return null
24 | }
25 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/avatar.tsx:
--------------------------------------------------------------------------------
1 | import { AvatarFallback, AvatarImage, Avatar as AvatarPrimitive } from '@seventy-seven/ui/avatar'
2 | import { cn } from '@seventy-seven/ui/utils'
3 |
4 | type Props = {
5 | imageUrl?: string
6 | name: string
7 | className?: string
8 | fallbackClassName?: string
9 | }
10 |
11 | export const Avatar = ({ imageUrl, name, className, fallbackClassName }: Props) => {
12 | const fullnameParts = name.split(' ')
13 | // @ts-ignore
14 | let initials = fullnameParts[0].charAt(0).toUpperCase()
15 |
16 | if (fullnameParts.length >= 2) {
17 | // @ts-ignore
18 | initials = (fullnameParts[0].charAt(0) + fullnameParts[fullnameParts.length - 1].charAt(0)).toUpperCase()
19 | }
20 |
21 | if (fullnameParts.length === 1) {
22 | // @ts-ignore
23 | initials = `${fullnameParts[0].charAt(0).toUpperCase()}${fullnameParts[0].charAt(1).toUpperCase()}`
24 | }
25 |
26 | return (
27 |
28 |
29 | {initials}
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/chat-message-handler.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@seventy-seven/ui/icon'
2 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@seventy-seven/ui/tooltip'
3 | import { format, formatDistance } from 'date-fns'
4 | import { Avatar } from './avatar'
5 |
6 | type Props = {
7 | name: string
8 | avatar?: string
9 | body: string
10 | date: Date
11 | }
12 |
13 | export const ChatMessageHandler = ({ name, avatar, body, date }: Props) => {
14 | return (
15 |
16 |
17 |
21 |
22 |
23 |
24 |
25 |
28 |
29 |
30 |
31 |
32 | {format(date, 'PPpp')}
33 |
34 |
35 |
36 |
37 |
38 |
39 | {body}
40 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/clear-all-filters-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@seventy-seven/ui/button'
4 | import { cn } from '@seventy-seven/ui/utils'
5 | import { useTicketFilters } from './ticket-filters/use-ticket-filters'
6 |
7 | type Props = {
8 | children: React.ReactNode
9 | className?: string
10 | }
11 |
12 | export const ClearAllFiltersButton = ({ children, className }: Props) => {
13 | const { clearFilters } = useTicketFilters()
14 |
15 | return (
16 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/clipboard-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@seventy-seven/ui/button'
4 | import { Icon } from '@seventy-seven/ui/icon'
5 | import { cn } from '@seventy-seven/ui/utils'
6 | import { AnimatePresence, motion } from 'framer-motion'
7 | import { useState } from 'react'
8 |
9 | type Props = {
10 | text: string
11 | }
12 |
13 | export const ClipboardButton = ({ text }: Props) => {
14 | const [isCopied, setIsCopied] = useState(false)
15 |
16 | const handleOnClick = () => {
17 | navigator.clipboard.writeText(text)
18 | setIsCopied(true)
19 |
20 | setTimeout(() => {
21 | setIsCopied(false)
22 | }, 800)
23 | }
24 |
25 | return (
26 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/create-seventy-seven-ticket-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { pushModal } from './modals'
4 |
5 | type Props = {
6 | children: React.ReactNode
7 | }
8 |
9 | export const CreateSeventySevenTicketButton = ({ children }: Props) => {
10 | return (
11 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/create-tag-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@seventy-seven/ui/button'
4 | import { Icon } from '@seventy-seven/ui/icon'
5 | import { cn } from '@seventy-seven/ui/utils'
6 | import { pushModal } from './modals'
7 |
8 | type Props = {
9 | children: React.ReactNode
10 | className?: string
11 | }
12 |
13 | export const CreateTagButton = ({ children, className }: Props) => {
14 | return (
15 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/create-team-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@seventy-seven/ui/button'
4 | import { Icon } from '@seventy-seven/ui/icon'
5 | import { cn } from '@seventy-seven/ui/utils'
6 | import { pushModal } from './modals'
7 |
8 | type Props = {
9 | className?: string
10 | }
11 |
12 | export const CreateTeamButton = ({ className }: Props) => {
13 | return (
14 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/edit-ticket-tag-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { UsersRouter } from '@/trpc/routers/users-router'
4 | import { Button } from '@seventy-seven/ui/button'
5 | import { Icon } from '@seventy-seven/ui/icon'
6 | import { pushModal } from './modals'
7 |
8 | type Props = {
9 | tag: UsersRouter.MyCurrentTeam['current_team']['ticket_tags'][number]
10 | }
11 |
12 | export const EditTicketTagButton = ({ tag }: Props) => {
13 | return (
14 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/forms/create-team-form.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@seventy-seven/ui/button'
2 | import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@seventy-seven/ui/form'
3 | import { Input } from '@seventy-seven/ui/input'
4 | import { type CreateTeamFormValues, useTeamForm } from './hooks/use-team-form'
5 |
6 | type Props = {
7 | onSubmit: (values: CreateTeamFormValues) => void
8 | loading?: boolean
9 | }
10 |
11 | export const CreateTeamForm = ({ onSubmit, loading }: Props) => {
12 | const form = useTeamForm()
13 |
14 | return (
15 |
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/forms/hooks/use-team-form.ts:
--------------------------------------------------------------------------------
1 | import { zodResolver } from '@hookform/resolvers/zod'
2 | import { type UseFormProps, useForm } from 'react-hook-form'
3 | import { z } from 'zod'
4 |
5 | export const createTeamFormSchema = z.object({
6 | name: z
7 | .string({ required_error: 'Team name is required' })
8 | .min(2, {
9 | message: 'Team name must be at least 2 characters long',
10 | })
11 | .max(32, {
12 | message: 'Team name must be at most 32 characters long',
13 | }),
14 | })
15 |
16 | export type CreateTeamFormValues = z.infer
17 |
18 | export type UseTeamFormArgs = Omit, 'resolver'>
19 |
20 | export const useTeamForm = (args?: UseTeamFormArgs) => {
21 | const form = useForm({
22 | resolver: zodResolver(createTeamFormSchema),
23 | ...args,
24 | })
25 |
26 | return form
27 | }
28 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/forms/ticket-search-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@seventy-seven/ui/button'
4 | import { Icon } from '@seventy-seven/ui/icon'
5 | import { Input } from '@seventy-seven/ui/input'
6 | import { cn } from '@seventy-seven/ui/utils'
7 | import { useForm } from 'react-hook-form'
8 | import { useTicketFilters } from '../ticket-filters/use-ticket-filters'
9 |
10 | type Props = {
11 | className?: string
12 | }
13 |
14 | export const TicketSearchForm = ({ className }: Props) => {
15 | const { filter, setFilter } = useTicketFilters()
16 |
17 | const form = useForm<{ query: string }>({
18 | defaultValues: {
19 | query: filter.q ?? '',
20 | },
21 | })
22 |
23 | const onSubmit = form.handleSubmit((values) => {
24 | setFilter({
25 | q: values.query.trim() === '' ? null : values.query,
26 | })
27 | })
28 |
29 | return (
30 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/header.tsx:
--------------------------------------------------------------------------------
1 | import { Logo } from '@seventy-seven/ui/logo'
2 | import Link from 'next/link'
3 | import { Suspense } from 'react'
4 | import { MainMenu } from './main-menu'
5 | import { SelectTeamDropdown } from './select-team-dropdown'
6 | import { UserMenuDropdown } from './user-menu-dropdown'
7 |
8 | export const Header = () => {
9 | return (
10 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/invite-code-badge.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Badge } from '@seventy-seven/ui/badge'
4 | import { Icon } from '@seventy-seven/ui/icon'
5 | import { AnimatePresence, motion } from 'framer-motion'
6 | import { useState } from 'react'
7 |
8 | type Props = {
9 | code: string
10 | }
11 | export const InviteCodeBadge = ({ code }: Props) => {
12 | const [isCopied, setIsCopied] = useState(false)
13 |
14 | const handleOnClick = () => {
15 | const url = new URL(`/invite/${code}`, window.location.origin)
16 |
17 | navigator.clipboard.writeText(url.toString())
18 | setIsCopied(true)
19 |
20 | setTimeout(() => {
21 | setIsCopied(false)
22 | }, 800)
23 | }
24 |
25 | return (
26 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/invite-team-member-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import { Button } from '@seventy-seven/ui/button'
5 | import { Icon } from '@seventy-seven/ui/icon'
6 | import { pushModal } from './modals'
7 |
8 | export const InviteTeamMemberButton = () => {
9 | const [team] = trpc.users.myCurrentTeam.useSuspenseQuery()
10 |
11 | return (
12 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/main-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@seventy-seven/ui/button'
4 | import { Icon, type IconName } from '@seventy-seven/ui/icon'
5 | import { cn } from '@seventy-seven/ui/utils'
6 | import Link from 'next/link'
7 | import { useSelectedLayoutSegment } from 'next/navigation'
8 |
9 | type LinkItemProps = {
10 | href: string
11 | label: string
12 | icon?: IconName
13 | className?: string
14 | }
15 |
16 | const LinkItem = ({ href, label, icon, className }: LinkItemProps) => {
17 | const segment = useSelectedLayoutSegment()
18 | const isActive = (segment === null && href === '/') || segment === href.substring(1)
19 |
20 | return (
21 |
22 |
35 |
36 | )
37 | }
38 |
39 | type Props = {
40 | className?: string
41 | }
42 |
43 | export const MainMenu = ({ className }: Props) => {
44 | return (
45 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/members-list-tab-nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { cn } from '@seventy-seven/ui/utils'
4 | import { motion } from 'framer-motion'
5 | import Link from 'next/link'
6 | import { usePathname } from 'next/navigation'
7 |
8 | export const MembersListTabNav = () => {
9 | const pathname = usePathname()
10 |
11 | return (
12 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/modals/assign-ticket-modal.tsx:
--------------------------------------------------------------------------------
1 | import type { TicketsRouter } from '@/trpc/routers/tickets-router'
2 | import { Modal, ModalDescription, ModalHeader, ModalTitle } from '@seventy-seven/ui/modal'
3 | import { AssignTeamMemberForm } from '../forms/assign-team-member-form'
4 |
5 | type Props = {
6 | ticket: TicketsRouter.FindById
7 | }
8 |
9 | export const AssignTicketModal = ({ ticket }: Props) => {
10 | return (
11 |
12 |
13 | Assign ticket
14 | Choose which team member you want to assign this ticket to.
15 |
16 |
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/modals/create-team-modal.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import { Modal, ModalDescription, ModalHeader, ModalTitle } from '@seventy-seven/ui/modal'
5 | import { toast } from 'sonner'
6 | import { popModal } from '.'
7 | import { CreateTeamForm } from '../forms/create-team-form'
8 |
9 | export const CreateTeamModal = () => {
10 | const trpcUtils = trpc.useUtils()
11 |
12 | const createTeamMutation = trpc.teams.create.useMutation({
13 | onSuccess: (createdTeam) => {
14 | trpcUtils.teams.findMany.invalidate()
15 |
16 | popModal('createTeamModal')
17 | toast.success(`Team "${createdTeam.name}" created successfully`)
18 | },
19 | onError: (error) => {
20 | popModal('createTeamModal')
21 | toast.error(error.message)
22 | },
23 | })
24 |
25 | return (
26 |
27 |
28 | Create a team
29 | The name of your team could be the name of your organization or company.
30 |
31 | {
33 | createTeamMutation.mutate({ name: values.name })
34 | }}
35 | loading={createTeamMutation.isPending}
36 | />{' '}
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/modals/create-ticket-tag-modal.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import { getRandomTagColor } from '@/utils/colors'
5 | import { Modal, ModalDescription, ModalHeader, ModalTitle } from '@seventy-seven/ui/modal'
6 | import { toast } from 'sonner'
7 | import { popModal } from '.'
8 | import { TicketTagForm } from '../forms/ticket-tag-form'
9 |
10 | export const CreateTicketTagModal = () => {
11 | const trpcUtils = trpc.useUtils()
12 |
13 | const createTagMutation = trpc.ticketTags.create.useMutation({
14 | onSuccess: () => {
15 | trpcUtils.users.myCurrentTeam.invalidate()
16 |
17 | toast.success('Tag created')
18 | popModal('createTicketTagModal')
19 | },
20 | onError: (error) => {
21 | toast.error(error.message)
22 | },
23 | })
24 |
25 | return (
26 |
27 |
28 | Create tag
29 | Change the color and/or the name
30 |
31 |
32 | {
38 | createTagMutation.mutate({
39 | name: values.name,
40 | color: values.color,
41 | })
42 | }}
43 | onClose={() => popModal('createTicketTagModal')}
44 | isLoading={createTagMutation.isPending}
45 | ctaText="Create tag"
46 | />
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/modals/edit-ticket-tag-modal.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import type { UsersRouter } from '@/trpc/routers/users-router'
5 | import { Modal, ModalDescription, ModalHeader, ModalTitle } from '@seventy-seven/ui/modal'
6 | import { toast } from 'sonner'
7 | import { popModal } from '.'
8 | import { TicketTagForm } from '../forms/ticket-tag-form'
9 |
10 | type Props = {
11 | tag: UsersRouter.MyCurrentTeam['current_team']['ticket_tags'][number]
12 | }
13 |
14 | export const EditTicketTagModal = ({ tag }: Props) => {
15 | const trpcUtils = trpc.useUtils()
16 |
17 | const editTagMutation = trpc.ticketTags.edit.useMutation({
18 | onSuccess: () => {
19 | trpcUtils.users.myCurrentTeam.invalidate()
20 |
21 | toast.success('Tag updated')
22 | popModal('editTicketTagModal')
23 | },
24 | onError: (error) => {
25 | toast.error(error.message)
26 | },
27 | })
28 |
29 | return (
30 |
31 |
32 | Edit tag
33 | Change the color and/or the name
34 |
35 |
36 | {
42 | editTagMutation.mutate({
43 | id: tag.id,
44 | name: values.name,
45 | color: values.color,
46 | })
47 | }}
48 | onClose={() => popModal('editTicketTagModal')}
49 | isLoading={editTagMutation.isPending}
50 | ctaText="Save tag"
51 | />
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/modals/index.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { createPushModal } from '@seventy-seven/ui/modal'
4 | import { AssignTicketModal } from './assign-ticket-modal'
5 | import { CreateSeventySevenTicketModal } from './create-seventy-seven-ticket-modal'
6 | import { CreateTeamModal } from './create-team-modal'
7 | import { CreateTicketTagModal } from './create-ticket-tag-modal'
8 | import { EditOriginalMessageModal } from './edit-original-message-modal'
9 | import { EditTicketTagModal } from './edit-ticket-tag-modal'
10 | import { InviteTeamMemberModal } from './invite-team-member-modal'
11 | import { SnoozeTicketModal } from './snooze-ticket-modal'
12 | import { TicketTagsModal } from './ticket-tags-modal'
13 | import { ViewOriginalMessageContentModal } from './view-original-message-content-modal.tsx'
14 |
15 | export const { pushModal, popModal, ModalProvider } = createPushModal({
16 | modals: {
17 | createTeamModal: CreateTeamModal,
18 | snoozeTicketModal: SnoozeTicketModal,
19 | inviteTeamMemberModal: InviteTeamMemberModal,
20 | assignTicketModal: AssignTicketModal,
21 | createSeventySevenTicketModal: CreateSeventySevenTicketModal,
22 | ticketTagsModal: TicketTagsModal,
23 | viewOriginalMessageContentModal: ViewOriginalMessageContentModal,
24 | editOriginalMessageModal: EditOriginalMessageModal,
25 | editTicketTagModal: EditTicketTagModal,
26 | createTicketTagModal: CreateTicketTagModal,
27 | },
28 | })
29 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/modals/invite-team-member-modal.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import type { UsersRouter } from '@/trpc/routers/users-router'
5 | import { pluralize } from '@/utils/pluralize'
6 | import { Modal, ModalDescription, ModalHeader, ModalTitle } from '@seventy-seven/ui/modal'
7 | import { toast } from 'sonner'
8 | import { popModal } from '.'
9 | import { InviteTeamMemberForm } from '../forms/invite-team-member-form'
10 |
11 | type Props = {
12 | team: UsersRouter.MyCurrentTeam['current_team']
13 | }
14 |
15 | export const InviteTeamMemberModal = ({ team }: Props) => {
16 | const trpcUtils = trpc.useUtils()
17 |
18 | const inviteTeamMembersMutation = trpc.teams.invite.useMutation({
19 | onSuccess: (numberOfCreatedInvites) => {
20 | trpcUtils.teams.invites.invalidate()
21 |
22 | popModal('inviteTeamMemberModal')
23 | toast.success(`You invited ${pluralize(numberOfCreatedInvites, 'member', 'members')}`)
24 | },
25 | onError: (error) => {
26 | toast.error(error.message)
27 | },
28 | })
29 |
30 | return (
31 |
32 |
33 | Invite member to {team.name}
34 | The user(s) will recieve an email with a link to join this team
35 |
36 | {
38 | inviteTeamMembersMutation.mutate({
39 | emails: values.invites.map(({ email }) => email),
40 | teamId: team.id,
41 | })
42 | }}
43 | loading={inviteTeamMembersMutation.isPending}
44 | />{' '}
45 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/modals/snooze-ticket-modal.tsx:
--------------------------------------------------------------------------------
1 | import { SnoozeTicketForm } from '@/components/forms/snooze-ticket-form'
2 | import { Modal, ModalDescription, ModalHeader, ModalTitle } from '@seventy-seven/ui/modal'
3 |
4 | type Props = {
5 | ticketId: string
6 | }
7 |
8 | export const SnoozeTicketModal = ({ ticketId }: Props) => {
9 | return (
10 |
11 |
12 | Snooze Ticket
13 | When the time has expired this will automatically be put back in your inbox
14 |
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/modals/view-original-message-content-modal.tsx.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@seventy-seven/ui/button'
2 | import { Modal, ModalDescription, ModalFooter, ModalHeader, ModalTitle } from '@seventy-seven/ui/modal'
3 | import { popModal, pushModal } from '.'
4 |
5 | type Props = {
6 | message: string
7 | messageId: string
8 | }
9 |
10 | export const ViewOriginalMessageContentModal = ({ message, messageId }: Props) => {
11 | return (
12 |
13 |
14 | Original message content
15 |
16 | This message contains content that the parser could not extract. If you want, you can edit this message so it
17 | looks better in the chat.
18 |
19 |
20 |
21 | {/* biome-ignore lint/security/noDangerouslySetInnerHtml: */}
22 |
23 |
24 |
25 |
28 |
38 |
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/no-ticket-selected.tsx:
--------------------------------------------------------------------------------
1 | export const NoTicketSelected = () => {
2 | return (
3 |
4 |
No ticket selected
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/page-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@seventy-seven/ui/utils'
2 |
3 | type Props = {
4 | children: React.ReactNode
5 | className?: string
6 | }
7 |
8 | export const PageWrapper = ({ children, className }: Props) => {
9 | return {children}
10 | }
11 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/pending-member-dropdown.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import { Button } from '@seventy-seven/ui/button'
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuItem,
9 | DropdownMenuTrigger,
10 | } from '@seventy-seven/ui/dropdown-menu'
11 | import { Icon } from '@seventy-seven/ui/icon'
12 | import { Spinner } from '@seventy-seven/ui/spinner'
13 | import { cn } from '@seventy-seven/ui/utils'
14 | import { toast } from 'sonner'
15 |
16 | type Props = {
17 | inviteId: string
18 | }
19 |
20 | export const PendingMemberDropdown = ({ inviteId }: Props) => {
21 | const trpcUtils = trpc.useUtils()
22 |
23 | const revokeInvitationMutation = trpc.teams.revokeInvitation.useMutation({
24 | onSuccess: (invitation) => {
25 | trpcUtils.teams.invites.invalidate()
26 |
27 | toast.success(`Invitation to ${invitation.email} was revoked`)
28 | },
29 | onError: (error) => {
30 | toast.error(error.message)
31 | },
32 | })
33 |
34 | return (
35 |
36 |
37 |
45 |
46 |
47 |
48 | revokeInvitationMutation.mutate({ inviteId })}
52 | >
53 |
54 | Revoke invite
55 |
56 |
57 |
58 | )
59 | }
60 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/revoke-slack-integration-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import { Button } from '@seventy-seven/ui/button'
5 | import { toast } from 'sonner'
6 |
7 | export const RevokeSlackIntegrationButton = () => {
8 | const trpcUtils = trpc.useUtils()
9 |
10 | const revokeSlackIntegrationMutation = trpc.integrations.revokeSlackIntegration.useMutation({
11 | onSuccess: (data) => {
12 | trpcUtils.integrations.getSlackIntegration.invalidate()
13 |
14 | if (data.success) {
15 | toast.success('Slack integration revoked')
16 | }
17 | },
18 | onError: (error) => {
19 | toast.error(error.message)
20 | },
21 | })
22 |
23 | return (
24 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/settings-sub-nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { cn } from '@seventy-seven/ui/utils'
4 | import { motion } from 'framer-motion'
5 | import Link from 'next/link'
6 | import { useSelectedLayoutSegment } from 'next/navigation'
7 |
8 | type SubLinkItemProps = {
9 | href: string
10 | label: string
11 | isActive: boolean
12 | }
13 |
14 | const SubLinkItem = ({ href, label, isActive }: SubLinkItemProps) => {
15 | return (
16 |
17 |
25 | {label}
26 |
27 | {isActive && (
28 |
32 | )}
33 |
34 | )
35 | }
36 |
37 | type Props = {
38 | className?: string
39 | }
40 |
41 | export const SettingsSubNav = ({ className }: Props) => {
42 | const segment = useSelectedLayoutSegment()
43 |
44 | return (
45 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/sheets/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { createPushModal } from '@seventy-seven/ui/modal'
4 | import { TicketInfoSheet } from './ticket-info-sheet'
5 |
6 | export const {
7 | pushModal: pushSheet,
8 | popModal: popSheet,
9 | ModalProvider: SheetProvider,
10 | } = createPushModal({
11 | modals: {
12 | ticketInfoSheet: TicketInfoSheet,
13 | },
14 | })
15 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/sign-in-buttons/sign-in-with-github-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { createClient } from '@seventy-seven/supabase/clients/client'
4 | import { Button } from '@seventy-seven/ui/button'
5 | import { Icon } from '@seventy-seven/ui/icon'
6 |
7 | type Props = {
8 | returnTo?: string
9 | }
10 |
11 | export const SignInWithGithubButton = ({ returnTo }: Props) => {
12 | const sb = createClient()
13 |
14 | const signIn = async () => {
15 | const redirectTo = new URL('/api/auth/callback', window.location.origin)
16 |
17 | if (returnTo) {
18 | redirectTo.searchParams.append('return_to', returnTo)
19 | }
20 |
21 | await sb.auth.signInWithOAuth({
22 | provider: 'github',
23 | options: {
24 | redirectTo: redirectTo.toString(),
25 | },
26 | })
27 | }
28 |
29 | return (
30 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/sign-in-buttons/sign-in-with-google-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { createClient } from '@seventy-seven/supabase/clients/client'
4 | import { Button } from '@seventy-seven/ui/button'
5 | import { Icon } from '@seventy-seven/ui/icon'
6 |
7 | type Props = {
8 | returnTo?: string
9 | }
10 |
11 | export const SignInWithGoogleButton = ({ returnTo }: Props) => {
12 | const sb = createClient()
13 |
14 | const signIn = async () => {
15 | const redirectTo = new URL('/api/auth/callback', window.location.origin)
16 |
17 | if (returnTo) {
18 | redirectTo.searchParams.append('return_to', returnTo)
19 | }
20 |
21 | await sb.auth.signInWithOAuth({
22 | provider: 'google',
23 | options: {
24 | redirectTo: redirectTo.toString(),
25 | },
26 | })
27 | }
28 |
29 | return (
30 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/team-actions-dropdown.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { UsersRouter } from '@/trpc/routers/users-router'
4 | import { Button } from '@seventy-seven/ui/button'
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuItem,
9 | DropdownMenuTrigger,
10 | } from '@seventy-seven/ui/dropdown-menu'
11 | import { Icon } from '@seventy-seven/ui/icon'
12 | import { Spinner } from '@seventy-seven/ui/spinner'
13 | import { pushAlert } from './alerts'
14 |
15 | type Props = {
16 | teamId: string
17 | userMember: UsersRouter.MyCurrentTeam['current_team']['members'][number]
18 | member: UsersRouter.MyCurrentTeam['current_team']['members'][number]
19 | }
20 |
21 | export const TeamActionsDropdown = ({ teamId, userMember, member }: Props) => {
22 | const isLoading = false
23 |
24 | const isUser = member.user.id === userMember.user.id
25 | const isNotUserAndIsNotOwner = !isUser && userMember.role !== 'OWNER'
26 |
27 | return (
28 |
29 |
30 |
34 |
35 |
36 |
37 | {isUser && (
38 |
41 | pushAlert('confirmLeaveTeam', {
42 | teamId,
43 | })
44 | }
45 | >
46 | Leave team
47 |
48 | )}
49 |
50 | {!isUser && userMember.role === 'OWNER' && (
51 |
54 | pushAlert('confirmRemoveTeamMember', {
55 | teamId,
56 | memberId: member.user.id,
57 | })
58 | }
59 | >
60 | Remove member
61 |
62 | )}
63 |
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/team-actions-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import { Button } from '@seventy-seven/ui/button'
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuItem,
9 | DropdownMenuSeparator,
10 | DropdownMenuTrigger,
11 | } from '@seventy-seven/ui/dropdown-menu'
12 | import { Icon } from '@seventy-seven/ui/icon'
13 | import { Spinner } from '@seventy-seven/ui/spinner'
14 | import Link from 'next/link'
15 | import { toast } from 'sonner'
16 | import { pushAlert } from './alerts'
17 |
18 | type Props = {
19 | teamId: string
20 | isCurrent: boolean
21 | }
22 |
23 | export const TeamActionsMenu = ({ teamId, isCurrent }: Props) => {
24 | const trpcUtils = trpc.useUtils()
25 |
26 | const switchTeamMutation = trpc.teams.switch.useMutation({
27 | onSuccess: (updatedUser) => {
28 | trpcUtils.users.me.invalidate()
29 | trpcUtils.teams.invites.invalidate()
30 | trpcUtils.teams.findMany.invalidate()
31 |
32 | toast.success(`Team "${updatedUser.current_team.name}" is now your current team`)
33 | },
34 | onError: (error) => {
35 | toast.error(error.message)
36 | },
37 | })
38 |
39 | return (
40 |
41 |
42 |
49 |
50 |
51 |
52 | {
55 | switchTeamMutation.mutate({
56 | teamId,
57 | })
58 | }}
59 | >
60 | Set as current
61 |
62 |
63 | {isCurrent && (
64 |
65 |
66 | Manage team
67 |
68 |
69 | )}
70 |
71 |
72 |
73 | pushAlert('confirmLeaveTeam', { teamId })}>
74 | Leave team
75 |
76 |
77 |
78 | )
79 | }
80 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/team-list-item.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { TeamsRouter } from '@/trpc/routers/teams-router'
4 | import { teamRoleEnumToWord } from '@/utils/teamRoleEnumToWord'
5 | import { TeamActionsMenu } from './team-actions-menu'
6 |
7 | type Props = {
8 | userTeam: TeamsRouter.FindMany[number]
9 | }
10 |
11 | export const TeamListItem = ({ userTeam }: Props) => {
12 | return (
13 |
14 |
15 |
16 | {userTeam.team.name}
17 | {userTeam.team.is_personal && (
18 | Personal
19 | )}
20 |
21 |
{teamRoleEnumToWord(userTeam.role)}
22 |
23 |
24 |
25 | {userTeam.isCurrent &&
Current
}
26 |
27 |
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ThemeProvider as NextThemesProvider } from 'next-themes'
4 | import type { ThemeProviderProps } from 'next-themes/dist/types'
5 |
6 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
7 | return {children}
8 | }
9 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/theme-switch.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Icon, type IconName } from '@seventy-seven/ui/icon'
4 | import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@seventy-seven/ui/select'
5 | import { cn } from '@seventy-seven/ui/utils'
6 | import { useTheme } from 'next-themes'
7 |
8 | const getThemeIcon = (theme?: string): IconName => {
9 | switch (theme) {
10 | case 'light':
11 | return 'sun'
12 | case 'dark':
13 | return 'moon'
14 | case 'system':
15 | return 'monitor'
16 | default:
17 | return 'monitor'
18 | }
19 | }
20 |
21 | type Props = {
22 | className?: string
23 | }
24 |
25 | export const ThemeSwitch = ({ className }: Props) => {
26 | const { theme, setTheme, themes } = useTheme()
27 | const themeIconName = getThemeIcon(theme)
28 |
29 | return (
30 |
31 |
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/ticket-chat-header.tsx:
--------------------------------------------------------------------------------
1 | import type { TicketsRouter } from '@/trpc/routers/tickets-router'
2 | import { TicketActionDropdown } from './ticket-action-dropdown'
3 | import { TicketInfoButton } from './ticket-info-button'
4 |
5 | type Props = {
6 | ticket: NonNullable
7 | }
8 |
9 | export const TicketChatHeader = ({ ticket }: Props) => {
10 | const lastMessageFromUser = ticket.messages.find((msg) => !!msg.sent_from_full_name)
11 | const senderFullName = lastMessageFromUser?.sent_from_full_name ?? ''
12 | const senderEmail = lastMessageFromUser?.sent_from_email ?? ''
13 |
14 | return (
15 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/ticket-filters/use-ticket-filters.ts:
--------------------------------------------------------------------------------
1 | import { ticketFiltersParsers } from '@/lib/search-params'
2 | import { useQueryStates } from 'nuqs'
3 |
4 | const statuses = ['unhandled', 'snoozed', 'starred', 'closed'] as const
5 | export type Status = (typeof statuses)[number]
6 |
7 | export const useTicketFilters = () => {
8 | const [filter, setFilter] = useQueryStates(ticketFiltersParsers, {
9 | shallow: false,
10 | })
11 |
12 | const hasFilters = Object.entries(filter)
13 | .filter(([key]) => ['statuses', 'assignees', 'tags'].includes(key))
14 | .some(([_key, value]) => value !== null)
15 |
16 | const clearFilters = () => {
17 | setFilter({
18 | assignees: null,
19 | statuses: null,
20 | tags: null,
21 | })
22 | }
23 |
24 | return { filter, setFilter, clearFilters, hasFilters }
25 | }
26 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/ticket-info-button.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@seventy-seven/ui/button'
2 | import { Icon } from '@seventy-seven/ui/icon'
3 | import { pushSheet } from './sheets'
4 |
5 | export const TicketInfoButton = () => {
6 | return (
7 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/apps/dashboard/src/components/ticket-tags-table.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { trpc } from '@/trpc/client'
4 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@seventy-seven/ui/table'
5 | import { EditTicketTagButton } from './edit-ticket-tag-button'
6 |
7 | export const TicketTagsTable = () => {
8 | const [user] = trpc.users.myCurrentTeam.useSuspenseQuery()
9 |
10 | return (
11 |
12 |
13 |
14 | Name
15 | Action
16 |
17 |
18 |
19 | {user.current_team.ticket_tags.map((tag) => (
20 |
21 |
22 |
23 |
24 |
{tag.name}
25 |
26 |
27 |
28 |
29 |
30 |
31 | ))}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/apps/dashboard/src/hooks/use-realtime-query.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@seventy-seven/supabase/clients/client'
2 | import type { Supabase } from '@seventy-seven/supabase/types'
3 | import { useEffect } from 'react'
4 |
5 | const supabase = createClient()
6 |
7 | type Options = {
8 | event: Supabase.RealtimeEvent
9 | table: Supabase.Table
10 | }
11 |
12 | const defaultOptions = {
13 | event: '*' as const,
14 | }
15 |
16 | export const useRealtimeQuery = (
17 | options: T,
18 | cb: (payload: Supabase.RealtimePayload, T['event']>) => void,
19 | ) => {
20 | const opts = { ...defaultOptions, ...options }
21 |
22 | useEffect(() => {
23 | const channel = supabase
24 | .channel('realtime_messages')
25 | .on(
26 | 'postgres_changes',
27 | {
28 | event: opts.event,
29 | schema: 'public',
30 | table: opts.table,
31 | },
32 | (payload) => {
33 | cb(payload as any) // Type assertion needed due to Supabase client limitations
34 | },
35 | )
36 | .subscribe()
37 |
38 | return () => {
39 | void supabase.removeChannel(channel)
40 | }
41 | }, [cb, opts.event, opts.table])
42 | }
43 |
--------------------------------------------------------------------------------
/apps/dashboard/src/hooks/use-selected-ticket.ts:
--------------------------------------------------------------------------------
1 | import { ticketIdParsers } from '@/lib/search-params'
2 | import { useQueryStates } from 'nuqs'
3 |
4 | export const useSelectedTicket = () => {
5 | const [ticketId, setTicketId] = useQueryStates(ticketIdParsers, {
6 | shallow: false,
7 | history: 'push',
8 | })
9 |
10 | return { ticketId, setTicketId }
11 | }
12 |
--------------------------------------------------------------------------------
/apps/dashboard/src/hooks/use-upload.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@seventy-seven/supabase/clients/client'
2 | import { useState } from 'react'
3 |
4 | export const BUCKETS = ['team-avatars'] as const
5 | type Bucket = (typeof BUCKETS)[number]
6 |
7 | export const useUpload = () => {
8 | const [isUploading, setIsUploading] = useState(false)
9 |
10 | const supabase = createClient()
11 |
12 | const uploadFile = async ({ bucket, path, file }: { bucket: Bucket; path: string[]; file: File }) => {
13 | setIsUploading(true)
14 |
15 | const storage = supabase.storage.from(bucket)
16 |
17 | const result = await storage.upload(path.join('/'), file, {
18 | upsert: true,
19 | cacheControl: '3600',
20 | })
21 |
22 | if (result.error) {
23 | throw new Error(result.error.message)
24 | }
25 |
26 | const {
27 | data: { publicUrl },
28 | } = storage.getPublicUrl(path.join('/'))
29 |
30 | setIsUploading(false)
31 |
32 | return {
33 | url: publicUrl,
34 | }
35 | }
36 |
37 | return {
38 | uploadFile,
39 | isUploading,
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/apps/dashboard/src/lib/analytics.tsx:
--------------------------------------------------------------------------------
1 | import { createAnalyticsClient } from '@seventy-seven/analytics'
2 |
3 | const NEXT_PUBLIC_OPENPANEL_DASHBOARD_CLIENT_ID = process.env.NEXT_PUBLIC_OPENPANEL_DASHBOARD_CLIENT_ID
4 | const OPENPANEL_DASHBOARD_CLIENT_SECRET = process.env.OPENPANEL_DASHBOARD_CLIENT_SECRET
5 |
6 | if (!NEXT_PUBLIC_OPENPANEL_DASHBOARD_CLIENT_ID) {
7 | throw new Error('NEXT_PUBLIC_OPENPANEL_DASHBOARD_CLIENT_ID is required')
8 | }
9 |
10 | if (!OPENPANEL_DASHBOARD_CLIENT_SECRET) {
11 | throw new Error('OPENPANEL_DASHBOARD_CLIENT_SECRET is required')
12 | }
13 |
14 | export const analyticsClient = createAnalyticsClient({
15 | clientId: NEXT_PUBLIC_OPENPANEL_DASHBOARD_CLIENT_ID,
16 | clientSecret: OPENPANEL_DASHBOARD_CLIENT_SECRET,
17 | })
18 |
--------------------------------------------------------------------------------
/apps/dashboard/src/lib/search-params.ts:
--------------------------------------------------------------------------------
1 | import { createSearchParamsCache, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
2 |
3 | // TICKET FILTERS
4 | export const statuses = ['unhandled', 'snoozed', 'starred', 'closed'] as const
5 | export type Status = (typeof statuses)[number]
6 |
7 | export const ticketFiltersParsers = {
8 | q: parseAsString,
9 | statuses: parseAsArrayOf(parseAsStringLiteral(statuses)),
10 | assignees: parseAsArrayOf(parseAsString),
11 | tags: parseAsArrayOf(parseAsString),
12 | }
13 |
14 | export const ticketFiltersCache = createSearchParamsCache(ticketFiltersParsers)
15 |
16 | // TICKET ID
17 | export const ticketIdParsers = {
18 | ticketId: parseAsString,
19 | }
20 |
21 | export const ticketIdCache = createSearchParamsCache(ticketIdParsers)
22 |
--------------------------------------------------------------------------------
/apps/dashboard/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@seventy-seven/supabase/clients/middleware'
2 | import { type NextRequest, NextResponse } from 'next/server'
3 |
4 | const OPEN_PATHS = ['/login', '/all-done']
5 |
6 | export async function middleware(req: NextRequest) {
7 | const nextUrl = req.nextUrl
8 |
9 | const supabase = createClient(req)
10 | const { data } = await supabase.auth.getUser()
11 |
12 | // Not logged in and not on an open path
13 | if (!data.user && !OPEN_PATHS.includes(nextUrl.pathname)) {
14 | return Response.redirect(new URL(`/login?return_to=${req.nextUrl.pathname}`, req.url))
15 | }
16 |
17 | return NextResponse.next()
18 | }
19 |
20 | export const config = {
21 | matcher: [
22 | /*
23 | * Match all request paths except for the ones starting with:
24 | * - _next/static (static files)
25 | * - _next/image (image optimization files)
26 | * - favicon.ico (favicon file)
27 | * Feel free to modify this pattern to include more paths.
28 | */
29 | '/((?!api|_next/static|_next/image|favicon.ico|opengraph-image.jpg|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
30 | ],
31 | }
32 |
--------------------------------------------------------------------------------
/apps/dashboard/src/store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand'
2 |
3 | interface SheetStore {
4 | isOpen: boolean
5 | open: () => void
6 | close: () => void
7 | }
8 |
9 | export const useMessagesListSheetStore = create()((set) => ({
10 | isOpen: false,
11 | open: () => set({ isOpen: true }),
12 | close: () => set({ isOpen: false }),
13 | }))
14 |
--------------------------------------------------------------------------------
/apps/dashboard/src/trpc/client.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | // ^-- to make sure we can mount the Provider from a server component
3 | import type { QueryClient } from '@tanstack/react-query'
4 | import { QueryClientProvider } from '@tanstack/react-query'
5 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
6 | import { httpBatchLink } from '@trpc/client'
7 | import { createTRPCReact } from '@trpc/react-query'
8 | import { useState } from 'react'
9 | import superjson from 'superjson'
10 | import { makeQueryClient } from './query-client'
11 | import type { AppRouter } from './routers/_app'
12 | export const trpc = createTRPCReact()
13 |
14 | let clientQueryClientSingleton: QueryClient
15 |
16 | function getQueryClient() {
17 | if (typeof window === 'undefined') {
18 | // Server: always make a new query client
19 | return makeQueryClient()
20 | }
21 |
22 | // Browser: use singleton pattern to keep the same query client
23 | // biome-ignore lint/suspicious/noAssignInExpressions:
24 | return (clientQueryClientSingleton ??= makeQueryClient())
25 | }
26 | function getUrl() {
27 | const base = (() => {
28 | if (typeof window !== 'undefined') return ''
29 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
30 | return 'http://localhost:3000'
31 | })()
32 | return `${base}/api/trpc`
33 | }
34 |
35 | export function TRPCProvider(
36 | props: Readonly<{
37 | children: React.ReactNode
38 | }>,
39 | ) {
40 | // NOTE: Avoid useState when initializing the query client if you don't
41 | // have a suspense boundary between this and the code that may
42 | // suspend because React will throw away the client on the initial
43 | // render if it suspends and there is no boundary
44 | const queryClient = getQueryClient()
45 |
46 | const [trpcClient] = useState(() =>
47 | trpc.createClient({
48 | links: [
49 | httpBatchLink({
50 | transformer: superjson, // <-- if you use a data transformer
51 | url: getUrl(),
52 | }),
53 | ],
54 | }),
55 | )
56 |
57 | return (
58 |
59 |
60 | {props.children}
61 |
62 |
63 |
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/apps/dashboard/src/trpc/init.ts:
--------------------------------------------------------------------------------
1 | import { analyticsClient } from '@/lib/analytics'
2 | import { prisma } from '@seventy-seven/orm/prisma'
3 | import { getUser } from '@seventy-seven/supabase/session'
4 | import { TRPCError, initTRPC } from '@trpc/server'
5 | import { cache } from 'react'
6 | import superjson from 'superjson'
7 |
8 | export const createTRPCContext = cache(async () => {
9 | const user = await getUser()
10 |
11 | return {
12 | prisma,
13 | user,
14 | analyticsClient,
15 | }
16 | })
17 |
18 | const t = initTRPC.context().create({
19 | transformer: superjson,
20 | })
21 |
22 | export const createCallerFactory = t.createCallerFactory
23 |
24 | export const createTRPCRouter = t.router
25 |
26 | export const baseProcedure = t.procedure
27 |
28 | export const authProcedure = t.procedure.use(async (opts) => {
29 | const { user } = opts.ctx
30 |
31 | if (!user) {
32 | throw new TRPCError({ code: 'UNAUTHORIZED' })
33 | }
34 |
35 | return opts.next({
36 | ctx: {
37 | ...opts.ctx,
38 | user,
39 | },
40 | })
41 | })
42 |
--------------------------------------------------------------------------------
/apps/dashboard/src/trpc/query-client.ts:
--------------------------------------------------------------------------------
1 | import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'
2 | import superjson from 'superjson'
3 |
4 | export function makeQueryClient() {
5 | return new QueryClient({
6 | defaultOptions: {
7 | queries: {
8 | staleTime: 30 * 1000,
9 | },
10 | dehydrate: {
11 | serializeData: superjson.serialize,
12 | shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
13 | },
14 | hydrate: {
15 | deserializeData: superjson.deserialize,
16 | },
17 | },
18 | })
19 | }
20 |
--------------------------------------------------------------------------------
/apps/dashboard/src/trpc/routers/_app.ts:
--------------------------------------------------------------------------------
1 | import type { inferRouterOutputs } from '@trpc/server'
2 | import { createTRPCRouter } from '../init'
3 | import { integrationsRouter } from './integrations-router'
4 | import { invitesRouter } from './invites-router'
5 | import { messagesRouter } from './messages-router'
6 | import { seventySevenRouter } from './seventy-seven-router'
7 | import { teamsRouter } from './teams-router'
8 | import { ticketsRouter } from './tickets-router'
9 | import { ticketTagsRouter } from './tickets-tags-router'
10 | import { usersRouter } from './users-router'
11 |
12 | export const appRouter = createTRPCRouter({
13 | users: usersRouter,
14 | teams: teamsRouter,
15 | tickets: ticketsRouter,
16 | messages: messagesRouter,
17 | integrations: integrationsRouter,
18 | ticketTags: ticketTagsRouter,
19 | invites: invitesRouter,
20 | seventySeven: seventySevenRouter,
21 | })
22 |
23 | // export type definition of API
24 | export type AppRouter = typeof appRouter
25 |
26 | export type RouterOutputs = inferRouterOutputs
27 |
--------------------------------------------------------------------------------
/apps/dashboard/src/trpc/routers/seventy-seven-router.ts:
--------------------------------------------------------------------------------
1 | import { SeventySevenClient } from '@seventy-seven/sdk'
2 | import { TRPCError } from '@trpc/server'
3 | import { z } from 'zod'
4 | import { authProcedure, createTRPCRouter } from '../init'
5 | import type { RouterOutputs } from './_app'
6 |
7 | const seventySevenClient = new SeventySevenClient(process.env.SEVENTY_SEVEN_AUTH_TOKEN!)
8 |
9 | export namespace SeventySevenRouter {
10 | export type CreateTicket = RouterOutputs['seventySeven']['createTicket']
11 | }
12 |
13 | export const seventySevenRouter = createTRPCRouter({
14 | createTicket: authProcedure
15 | .input(
16 | z.object({
17 | fullName: z.string({ required_error: 'Sender full name is required' }),
18 | subject: z.string({ required_error: 'Subject is required' }),
19 | body: z.string({ required_error: 'Body is required' }),
20 | }),
21 | )
22 | .mutation(async ({ input, ctx }) => {
23 | const user = await ctx.prisma.user.findUnique({
24 | where: { id: ctx.user.id },
25 | select: {
26 | email: true,
27 | image_url: true,
28 | },
29 | })
30 |
31 | if (!user) {
32 | throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' })
33 | }
34 |
35 | const createdTicket = await seventySevenClient
36 | .createTicket({
37 | senderFullName: input.fullName,
38 | senderEmail: user.email,
39 | subject: input.subject,
40 | body: input.body,
41 | senderAvatarUrl: user.image_url ?? undefined,
42 | })
43 | .catch(() => {
44 | throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create ticket' })
45 | })
46 |
47 | if (!createdTicket) {
48 | throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create ticket' })
49 | }
50 |
51 | return createdTicket
52 | }),
53 | })
54 |
--------------------------------------------------------------------------------
/apps/dashboard/src/trpc/server.ts:
--------------------------------------------------------------------------------
1 | import 'server-only' // <-- ensure this file cannot be imported from the client
2 |
3 | import { createHydrationHelpers } from '@trpc/react-query/rsc'
4 | import { cache } from 'react'
5 | import { createCallerFactory, createTRPCContext } from './init'
6 | import { makeQueryClient } from './query-client'
7 | import { appRouter } from './routers/_app'
8 |
9 | // IMPORTANT: Create a stable getter for the query client that
10 | // will return the same client during the same request.
11 | export const getQueryClient = cache(makeQueryClient)
12 | const caller = createCallerFactory(appRouter)(createTRPCContext)
13 | export const { trpc, HydrateClient } = createHydrationHelpers(caller, getQueryClient)
14 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/assertUnreachable.ts:
--------------------------------------------------------------------------------
1 | export const assertUnreachable = (x: never): never => {
2 | throw new Error("Didn't expect to get here", x)
3 | }
4 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/colors.ts:
--------------------------------------------------------------------------------
1 | import { randomInt } from './random'
2 |
3 | export const ticketTagColors = [
4 | '#f9d4f4',
5 | '#eddee0',
6 | '#eaface',
7 | '#f5ebea',
8 | '#ddeff0',
9 | '#e9dbf9',
10 | '#ebface',
11 | '#f5ebea',
12 | '#d6f5fb',
13 | '#ebfce0',
14 | '#f4e9ea',
15 | '#d7faf3',
16 | '#f5e6dc',
17 | '#f8fcdf',
18 | '#eae0fa',
19 | '#e1dff4',
20 | '#f5eee9',
21 | '#f5e8dc',
22 | ]
23 |
24 | export const getRandomTagColor = () => {
25 | const index = randomInt(0, ticketTagColors.length - 1)
26 | const color = ticketTagColors[index]!
27 |
28 | return color
29 | }
30 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/get-role-name.ts:
--------------------------------------------------------------------------------
1 | import type { TEAM_ROLE_ENUM } from '@seventy-seven/orm/enums'
2 | import { assertUnreachable } from './assertUnreachable'
3 |
4 | export const getRoleName = (role: TEAM_ROLE_ENUM) => {
5 | switch (role) {
6 | case 'MEMBER':
7 | return 'Member'
8 | case 'OWNER':
9 | return 'Owner'
10 |
11 | default:
12 | assertUnreachable(role)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/insertIf.ts:
--------------------------------------------------------------------------------
1 | export const insertIf = {
2 | array: (...args: [condition: boolean, ...rest: T]) => {
3 | const [condition, ...rest] = args
4 | return condition ? rest : []
5 | },
6 | object: >(condition: boolean, obj: T) => {
7 | return condition ? obj : {}
8 | },
9 | }
10 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/parseIncomingMessage.ts:
--------------------------------------------------------------------------------
1 | import { parse } from 'node-html-parser'
2 |
3 | export const parseIncomingMessage = (text: string, html: string) => {
4 | const parsedHtmlBody = parse(html)
5 |
6 | // If the mail was sent using Gmail, postmark will include irrelevant HTML in the body
7 | // So we try to prase it and get the content from the first div with dir="ltr" attribute
8 | const maybeContent = parsedHtmlBody.querySelector('div[dir="ltr"]')?.innerText
9 |
10 | if (maybeContent) {
11 | return {
12 | content: maybeContent,
13 | unableToParseContent: false,
14 | }
15 | }
16 |
17 | // The body can sometimes include a date signature that we don't want.
18 | // Example: Hello World!\n\nDen tors 23 maj 2024 kl 13:37 skrev Christian Alares
19 | if (text.includes('\n\nDen')) {
20 | const trimmedText = text.split('\n\nDen')[0]
21 |
22 | if (trimmedText) {
23 | return {
24 | content: trimmedText,
25 | unableToParseContent: false,
26 | }
27 | }
28 | }
29 |
30 | if (text.length > 0) {
31 | return {
32 | content: text,
33 | unableToParseContent: false,
34 | }
35 | }
36 |
37 | const strippedTagNames = [
38 | 'style',
39 | 'script',
40 | 'noscript',
41 | 'iframe',
42 | 'object',
43 | 'embed',
44 | 'applet',
45 | 'base',
46 | 'link',
47 | 'meta',
48 | 'title',
49 | 'head',
50 | ]
51 |
52 | // Remove all tags that are not relevant for the content
53 | parsedHtmlBody.querySelectorAll('*').forEach((el) => {
54 | if (strippedTagNames.includes(el.tagName.toLowerCase())) {
55 | el.remove()
56 | }
57 | })
58 |
59 | return {
60 | content: parsedHtmlBody.toString(),
61 | unableToParseContent: true,
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/parseIncomingMessageWithAI.ts:
--------------------------------------------------------------------------------
1 | // import { deepseek } from '@ai-sdk/deepseek'
2 | import { openai } from '@ai-sdk/openai'
3 | import { generateText } from 'ai'
4 |
5 | export const parseIncomingMessageWithAI = async (html: string) => {
6 | const model = openai('gpt-4o-mini')
7 |
8 | const { text } = await generateText({
9 | model,
10 | prompt: `You are a helpful assistant that can parse an incoming messages from a customer.
11 |
12 | The message is in HTML format and can differ depending on the email provider.
13 | The input will be a string and here is an example of the input:
14 |
15 | \n yes, hello!!
\n
\n\n
16 |
17 | In this case it should return:
18 | yes, hello!!
19 |
20 | Sometimes it could include other irrelavant markup or content than just that message so I need you do parse it and return that message.
21 |
22 | Make sure to only return the message and nothing else.
23 |
24 | Here is the html:
25 | ${html}`,
26 | })
27 |
28 | return text
29 | }
30 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/pluralize.ts:
--------------------------------------------------------------------------------
1 | export const pluralize = (nr: number, singular: string, plural: string) => {
2 | if (nr === 1) {
3 | return `${nr} ${singular}`
4 | }
5 |
6 | return `${nr} ${plural}`
7 | }
8 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/random.ts:
--------------------------------------------------------------------------------
1 | export function randomInt(...args: [number] | [number, number] | []) {
2 | switch (args.length) {
3 | // If one argument generate between 0 and args[0]
4 | case 1:
5 | return Math.floor(Math.random() * (args[0] + 1))
6 |
7 | // If two arguments generate between args[0] and args[1]
8 | case 2:
9 | return Math.floor(Math.random() * (args[1] - args[0] + 1)) + args[0]
10 |
11 | // Otherwise generate between 0 and 1
12 | default:
13 | return Math.random()
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/sentencifyArray.tsx:
--------------------------------------------------------------------------------
1 | export const sentencifyArray = (items: string[]) => {
2 | if (items.length === 0) {
3 | return ''
4 | }
5 |
6 | if (items.length === 1) {
7 | return items[0] ?? ''
8 | }
9 |
10 | const lastItem = items.pop()
11 | return `${items.join(', ')} and ${lastItem}`
12 | }
13 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/shortId.ts:
--------------------------------------------------------------------------------
1 | import { customAlphabet } from 'nanoid'
2 |
3 | const nanoid = customAlphabet('abcdefghijklmnopqrstuwxyz0123456789', 10)
4 |
5 | export const shortId = () => {
6 | return nanoid()
7 | }
8 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/stripMarkupfromMessage.ts:
--------------------------------------------------------------------------------
1 | import { parse } from 'node-html-parser'
2 |
3 | export const stripMarkupfromMessage = (html: string) => {
4 | const parsedHtml = parse(html)
5 |
6 | const strippedTagNames = [
7 | 'blockquote',
8 | 'style',
9 | 'script',
10 | 'noscript',
11 | 'iframe',
12 | 'object',
13 | 'embed',
14 | 'applet',
15 | 'base',
16 | 'link',
17 | 'meta',
18 | 'title',
19 | 'head',
20 | ]
21 |
22 | parsedHtml.querySelectorAll('*').forEach((el) => {
23 | if (strippedTagNames.includes(el.tagName.toLowerCase())) {
24 | el.remove()
25 | }
26 | })
27 |
28 | return parsedHtml.toString()
29 | }
30 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/teamRoleEnumToWord.ts:
--------------------------------------------------------------------------------
1 | import type { TEAM_ROLE_ENUM } from '@seventy-seven/orm/enums'
2 | import { assertUnreachable } from './assertUnreachable'
3 |
4 | export const teamRoleEnumToWord = (teamRole: TEAM_ROLE_ENUM) => {
5 | switch (teamRole) {
6 | case 'MEMBER':
7 | return 'Member'
8 | case 'OWNER':
9 | return 'Owner'
10 | default:
11 | assertUnreachable(teamRole)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/apps/dashboard/src/utils/validation/common.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const imageTypeSchema = z.union([z.literal('image/jpeg'), z.literal('image/png'), z.literal('image/webp')])
4 |
--------------------------------------------------------------------------------
/apps/dashboard/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | import baseConfig from '@seventy-seven/ui/tailwind.config.ts'
4 |
5 | const config = {
6 | content: ['./src/**/*.{ts,tsx}', '../../packages/ui/src/**/*.{ts,tsx}'],
7 | presets: [baseConfig],
8 | } satisfies Config
9 |
10 | export default config
11 |
--------------------------------------------------------------------------------
/apps/dashboard/trigger.config.ts:
--------------------------------------------------------------------------------
1 | import { prismaExtension } from '@trigger.dev/build/extensions/prisma'
2 | import { defineConfig } from '@trigger.dev/sdk/v3'
3 |
4 | export default defineConfig({
5 | project: 'proj_lxxeotjapxkvajtjegyr',
6 | runtime: 'node',
7 | logLevel: 'log',
8 | // The max compute seconds a task is allowed to run. If the task run exceeds this duration, it will be stopped.
9 | // You can override this on an individual task.
10 | // See https://trigger.dev/docs/runs/max-duration
11 | maxDuration: 3600,
12 | retries: {
13 | enabledInDev: true,
14 | default: {
15 | maxAttempts: 3,
16 | minTimeoutInMs: 1000,
17 | maxTimeoutInMs: 10000,
18 | factor: 2,
19 | randomize: true,
20 | },
21 | },
22 | dirs: ['./src/trigger'],
23 | build: {
24 | extensions: [
25 | prismaExtension({
26 | schema: '../../packages/orm/src/prisma/schema.prisma',
27 | }),
28 | ],
29 | },
30 | })
31 |
--------------------------------------------------------------------------------
/apps/dashboard/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@seventy-seven/typescript-config/nextjs.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "paths": {
6 | "@/*": ["./src/*"]
7 | }
8 | },
9 | "include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "trigger.config.ts"],
10 | "exclude": ["node_modules"]
11 | }
12 |
--------------------------------------------------------------------------------
/apps/supabase/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@seventy-seven/supabase-app",
3 | "private": true,
4 | "scripts": {
5 | "dev": "supabase start",
6 | "login": "supabase login",
7 | "db:reset": "tsx ./seed.ts"
8 | },
9 | "devDependencies": {
10 | "@faker-js/faker": "^8.4.1",
11 | "@seventy-seven/orm": "workspace:*",
12 | "@seventy-seven/supabase": "workspace:*",
13 | "@seventy-seven/typescript-config": "workspace:*",
14 | "@supabase/supabase-js": "^2.42.5",
15 | "dotenv": "^16.4.5",
16 | "supabase": "^1.162.4",
17 | "tsx": "^4.19.1",
18 | "typescript": "^5.4.5"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/apps/supabase/supabase.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "name": "project-root",
5 | "path": "./"
6 | },
7 | {
8 | "name": "supabase-functions",
9 | "path": "supabase/functions"
10 | }
11 | ],
12 | "settings": {
13 | "files.exclude": {
14 | "supabase/functions/": true
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/apps/supabase/supabase/.gitignore:
--------------------------------------------------------------------------------
1 | # Supabase
2 | .branches
3 | .temp
4 |
--------------------------------------------------------------------------------
/apps/supabase/supabase/functions/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["denoland.vscode-deno"]
3 | }
4 |
--------------------------------------------------------------------------------
/apps/supabase/supabase/functions/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "deno.enable": true,
3 | "deno.lint": true,
4 | "editor.defaultFormatter": "denoland.vscode-deno"
5 | }
6 |
--------------------------------------------------------------------------------
/apps/supabase/supabase/seed.sql:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianalares/seventy-seven/943563958c456f57ae35579b400eacf02cc02231/apps/supabase/supabase/seed.sql
--------------------------------------------------------------------------------
/apps/supabase/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@seventy-seven/typescript-config/base.json",
3 | "include": ["**/*.ts"],
4 | "exclude": ["node_modules"]
5 | }
6 |
--------------------------------------------------------------------------------
/apps/website/README.md:
--------------------------------------------------------------------------------
1 | # 77 Website
2 |
--------------------------------------------------------------------------------
/apps/website/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import("next").NextConfig} */
2 | const config = {
3 | transpilePackages: ['@seventy-seven/ui'],
4 | reactStrictMode: true,
5 | typescript: {
6 | ignoreBuildErrors: true,
7 | },
8 | }
9 |
10 | export default config
11 |
--------------------------------------------------------------------------------
/apps/website/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@seventy-seven/website",
3 | "version": "1.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --port 3001",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "biome check .",
10 | "check:types": "tsc --noEmit"
11 | },
12 | "dependencies": {
13 | "@hookform/resolvers": "^3.3.4",
14 | "@seventy-seven/analytics": "workspace:*",
15 | "@seventy-seven/email": "workspace:*",
16 | "@seventy-seven/orm": "workspace:*",
17 | "@seventy-seven/ui": "workspace:*",
18 | "class-variance-authority": "^0.7.0",
19 | "framer-motion": "^11.1.7",
20 | "lodash.debounce": "^4.0.8",
21 | "lucide-react": "^0.372.0",
22 | "next": "^14.2.2",
23 | "next-safe-action": "^6.2.0",
24 | "next-themes": "^0.3.0",
25 | "react": "^18.2.0",
26 | "react-confetti": "^6.1.0",
27 | "react-dom": "^18.2.0",
28 | "react-hook-form": "^7.51.3",
29 | "react-wrap-balancer": "^1.1.0",
30 | "zod": "^3.22.5",
31 | "zustand": "^4.5.2"
32 | },
33 | "devDependencies": {
34 | "@seventy-seven/typescript-config": "workspace:*",
35 | "@types/lodash.debounce": "^4.0.9",
36 | "@types/node": "^20.12.7",
37 | "@types/react": "^18.2.79",
38 | "@types/react-dom": "^18.2.25",
39 | "autoprefixer": "^10.4.19",
40 | "postcss": "^8.4.38",
41 | "tailwindcss": "^3.4.3",
42 | "typescript": "^5.4.5"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/apps/website/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = require('@seventy-seven/ui/postcss.config.js')
2 |
--------------------------------------------------------------------------------
/apps/website/public/email/77-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianalares/seventy-seven/943563958c456f57ae35579b400eacf02cc02231/apps/website/public/email/77-logo.png
--------------------------------------------------------------------------------
/apps/website/public/email/acme.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianalares/seventy-seven/943563958c456f57ae35579b400eacf02cc02231/apps/website/public/email/acme.png
--------------------------------------------------------------------------------
/apps/website/public/email/avatar.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianalares/seventy-seven/943563958c456f57ae35579b400eacf02cc02231/apps/website/public/email/avatar.jpg
--------------------------------------------------------------------------------
/apps/website/public/img/77-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianalares/seventy-seven/943563958c456f57ae35579b400eacf02cc02231/apps/website/public/img/77-dark.png
--------------------------------------------------------------------------------
/apps/website/public/img/77-dark.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianalares/seventy-seven/943563958c456f57ae35579b400eacf02cc02231/apps/website/public/img/77-dark.webp
--------------------------------------------------------------------------------
/apps/website/public/img/77-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianalares/seventy-seven/943563958c456f57ae35579b400eacf02cc02231/apps/website/public/img/77-light.png
--------------------------------------------------------------------------------
/apps/website/public/img/77-light.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianalares/seventy-seven/943563958c456f57ae35579b400eacf02cc02231/apps/website/public/img/77-light.webp
--------------------------------------------------------------------------------
/apps/website/src/actions/waitlist.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 | import { opServerClient } from '@/utils/analytics'
3 | import { action } from '@/utils/safe-action'
4 | import { componentToPlainText, createResendClient } from '@seventy-seven/email'
5 | import WaitlistConfirmation from '@seventy-seven/email/emails/waitlist-confirmation'
6 | import { Prisma, prisma } from '@seventy-seven/orm/prisma'
7 | import { z } from 'zod'
8 |
9 | export const joinWaitlist = action(
10 | z.object({
11 | email: z.string().email({ message: 'Please enter a valid email' }),
12 | }),
13 | async (values) => {
14 | const resend = createResendClient()
15 |
16 | const createdWaitlistEntry = await prisma.waitlist
17 | .create({
18 | data: {
19 | email: values.email,
20 | },
21 | })
22 | .catch((error) => {
23 | if (error instanceof Prisma.PrismaClientKnownRequestError) {
24 | if (error.code === 'P2002') {
25 | throw new Error('You are already on the waiting list')
26 | }
27 |
28 | throw error
29 | }
30 | })
31 |
32 | if (!createdWaitlistEntry) {
33 | throw new Error('Oops, something went wrong, try again later')
34 | }
35 |
36 | const template = WaitlistConfirmation()
37 |
38 | const { data, error } = await resend.emails.send({
39 | from: 'Christian from 77 ',
40 | to: [createdWaitlistEntry.email],
41 | subject: 'Welcome to 77!',
42 | react: template,
43 | text: componentToPlainText(template),
44 | tags: [{ name: 'waitlist_id', value: createdWaitlistEntry.id }],
45 | })
46 |
47 | if (error) {
48 | // biome-ignore lint/suspicious/noConsoleLog: Log here
49 | console.log(`Error sending email to ${values.email}`, error)
50 | }
51 |
52 | if (data) {
53 | await prisma.waitlist.update({
54 | where: {
55 | id: createdWaitlistEntry.id,
56 | },
57 | data: {
58 | email_id: data.id,
59 | },
60 | })
61 |
62 | opServerClient.event('waitlist_signup', { email: createdWaitlistEntry.email })
63 | }
64 |
65 | return true
66 | },
67 | )
68 |
--------------------------------------------------------------------------------
/apps/website/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianalares/seventy-seven/943563958c456f57ae35579b400eacf02cc02231/apps/website/src/app/favicon.ico
--------------------------------------------------------------------------------
/apps/website/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --bg-shade-1: #404040;
8 | --bg-shade-2: #858585;
9 | --bg-shade-3: #d3d3d3;
10 | }
11 |
12 | * {
13 | @apply border-border;
14 | }
15 |
16 | body {
17 | @apply bg-background text-foreground font-sans;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/apps/website/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | const NotFound = () => {
2 | return (
3 |
4 |
404
5 |
This page could not be found 🥺
6 |
7 | )
8 | }
9 |
10 | export default NotFound
11 |
--------------------------------------------------------------------------------
/apps/website/src/app/opengraph-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianalares/seventy-seven/943563958c456f57ae35579b400eacf02cc02231/apps/website/src/app/opengraph-image.jpg
--------------------------------------------------------------------------------
/apps/website/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Container } from '@/components/container'
2 | import { HeroHeading } from '@/components/hero-heading'
3 | import { Waves } from '@/components/waves'
4 | import { Button } from '@seventy-seven/ui/button'
5 | import Image from 'next/image'
6 | import Balancer from 'react-wrap-balancer'
7 | import productDark from '../../public/img/77-dark.webp'
8 | import productLight from '../../public/img/77-light.webp'
9 |
10 | const IndexPage = () => {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | A modern and simple platform to make customer support extremely easy
22 |
23 |
24 |
34 |
35 |
36 |
43 |
50 |
51 |
52 |
53 | )
54 | }
55 |
56 | export default IndexPage
57 |
--------------------------------------------------------------------------------
/apps/website/src/components/change-theme-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@seventy-seven/ui/button'
4 | import { Icon } from '@seventy-seven/ui/icon'
5 | import { AnimatePresence, motion } from 'framer-motion'
6 | import { useTheme } from 'next-themes'
7 |
8 | export const ChangeThemeButton = () => {
9 | const { setTheme, resolvedTheme } = useTheme()
10 |
11 | const onThemeChange = () => {
12 | if (resolvedTheme === 'light') {
13 | setTheme('dark')
14 | } else {
15 | setTheme('light')
16 | }
17 | }
18 |
19 | return (
20 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/apps/website/src/components/confetti-rain.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useStore } from '@/store'
4 | import dynamic from 'next/dynamic'
5 |
6 | const Confetti = dynamic(() => import('react-confetti'), {
7 | ssr: false,
8 | })
9 |
10 | export const ConfettiRain = () => {
11 | const showConfetti = useStore((state) => state.showConfetti)
12 | const setShowConfetti = useStore((state) => state.setShowConfetti)
13 |
14 | return (
15 | setShowConfetti(false)}
22 | />
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/apps/website/src/components/container.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@seventy-seven/ui/utils'
2 |
3 | type Props = {
4 | children: React.ReactNode
5 | className?: string
6 | as?: keyof JSX.IntrinsicElements
7 | }
8 |
9 | export const Container = ({ children, as: As = 'div', className }: Props) => (
10 | {children}
11 | )
12 |
--------------------------------------------------------------------------------
/apps/website/src/components/header.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@seventy-seven/ui/button'
4 | import { Icon } from '@seventy-seven/ui/icon'
5 | import { Logo } from '@seventy-seven/ui/logo'
6 | import dynamic from 'next/dynamic'
7 | import Link from 'next/link'
8 |
9 | const ChangeThemeButton = dynamic(
10 | () => import('./change-theme-button').then(({ ChangeThemeButton }) => ChangeThemeButton),
11 | {
12 | ssr: false,
13 | loading: () => null,
14 | },
15 | )
16 |
17 | export const Header = () => {
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {/*
*/}
37 |
38 |
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/apps/website/src/components/hero-heading.tsx:
--------------------------------------------------------------------------------
1 | import Balancer from 'react-wrap-balancer'
2 |
3 | export const HeroHeading = () => {
4 | return (
5 |
6 |
7 | The open-source
8 |
9 | alternative to Zendesk
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/apps/website/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ThemeProvider as NextThemesProvider } from 'next-themes'
4 | import type { ThemeProviderProps } from 'next-themes/dist/types'
5 |
6 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
7 | return {children}
8 | }
9 |
--------------------------------------------------------------------------------
/apps/website/src/store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand'
2 |
3 | type Store = {
4 | showConfetti: boolean
5 | setShowConfetti: (open: boolean) => void
6 | }
7 |
8 | export const useStore = create((set) => ({
9 | showConfetti: false,
10 | setShowConfetti: (showConfetti) => set({ showConfetti }),
11 | }))
12 |
--------------------------------------------------------------------------------
/apps/website/src/utils/analytics.ts:
--------------------------------------------------------------------------------
1 | import { createAnalyticsClient } from '@seventy-seven/analytics'
2 |
3 | const NEXT_PUBLIC_OPENPANEL_WEBSITE_CLIENT_ID = process.env.NEXT_PUBLIC_OPENPANEL_WEBSITE_CLIENT_ID
4 | const OPENPANEL_WEBSITE_CLIENT_SECRET = process.env.OPENPANEL_WEBSITE_CLIENT_SECRET
5 |
6 | if (!NEXT_PUBLIC_OPENPANEL_WEBSITE_CLIENT_ID) {
7 | throw new Error('NEXT_PUBLIC_OPENPANEL_WEBSITE_CLIENT_ID is required')
8 | }
9 |
10 | if (!OPENPANEL_WEBSITE_CLIENT_SECRET) {
11 | throw new Error('OPENPANEL_WEBSITE_CLIENT_SECRET is required')
12 | }
13 |
14 | export const opServerClient = createAnalyticsClient({
15 | clientId: NEXT_PUBLIC_OPENPANEL_WEBSITE_CLIENT_ID,
16 | clientSecret: OPENPANEL_WEBSITE_CLIENT_SECRET,
17 | })
18 |
--------------------------------------------------------------------------------
/apps/website/src/utils/safe-action.ts:
--------------------------------------------------------------------------------
1 | import { createSafeActionClient } from 'next-safe-action'
2 |
3 | export const action = createSafeActionClient({
4 | handleReturnedServerError: (e) => {
5 | return e.message || 'Oh no, something went wrong!'
6 | },
7 | })
8 |
--------------------------------------------------------------------------------
/apps/website/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | import baseConfig from '@seventy-seven/ui/tailwind.config.ts'
4 |
5 | const config = {
6 | content: ['./src/**/*.{ts,tsx}', '../../packages/ui/src/**/*.{ts,tsx}'],
7 | presets: [baseConfig],
8 | } satisfies Config
9 |
10 | export default config
11 |
--------------------------------------------------------------------------------
/apps/website/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@seventy-seven/typescript-config/nextjs.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "paths": {
6 | "@/*": ["./src/*"]
7 | }
8 | },
9 | "include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "tailwind.config.ts"],
10 | "exclude": ["node_modules"]
11 | }
12 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.6.2/schema.json",
3 | "organizeImports": {
4 | "enabled": true,
5 | "ignore": [".next/**/*", "types/**/*", "dist/**/*", ".trigger"]
6 | },
7 | "linter": {
8 | "ignore": [".next/**/*", "types/**/*", "dist/**/*", ".trigger"],
9 | "enabled": true,
10 | "rules": {
11 | "recommended": true,
12 | "correctness": {
13 | "noUnusedVariables": "error",
14 | "noUnusedImports": "warn"
15 | },
16 | "suspicious": {
17 | "noConsoleLog": "warn",
18 | "noExplicitAny": "off"
19 | },
20 | "style": {
21 | "useImportType": "error",
22 | "noNonNullAssertion": "off"
23 | },
24 | "complexity": {
25 | "noForEach": "off"
26 | },
27 | "performance": {
28 | "noAccumulatingSpread": "off"
29 | }
30 | }
31 | },
32 | "formatter": {
33 | "ignore": [".next/**/*", "types/**/*", "dist/**/*", ".trigger"],
34 | "enabled": true,
35 | "formatWithErrors": false,
36 | "indentStyle": "space",
37 | "indentWidth": 2,
38 | "lineWidth": 120
39 | },
40 | "javascript": {
41 | "formatter": {
42 | "semicolons": "asNeeded",
43 | "quoteStyle": "single"
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "seventy-seven",
3 | "private": true,
4 | "scripts": {
5 | "postinstall": "turbo prisma:generate",
6 | "build": "turbo build",
7 | "dev": "dotenv -- turbo dev",
8 | "dev:trigger": "@trigger.dev/cli@latest dev",
9 | "lint": "turbo lint",
10 | "check:types": "turbo check:types --concurrency 20",
11 | "prisma:generate": "turbo prisma:generate"
12 | },
13 | "devDependencies": {
14 | "@biomejs/biome": "^1.7.0",
15 | "@seventy-seven/typescript-config": "workspace:*",
16 | "dotenv-cli": "^7.4.1",
17 | "turbo": "^1.13.0",
18 | "typescript": "^5.4.5"
19 | },
20 | "packageManager": "pnpm@8.6.12"
21 | }
22 |
--------------------------------------------------------------------------------
/packages/analytics/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@seventy-seven/analytics",
3 | "version": "0.0.0",
4 | "private": true,
5 | "sideEffects": false,
6 | "main": "src/index.tsx",
7 | "scripts": {
8 | "lint": "biome check .",
9 | "check:types": "tsc --noEmit"
10 | },
11 | "dependencies": {
12 | "@openpanel/nextjs": "0.0.10-beta",
13 | "@vercel/functions": "^1.0.2"
14 | },
15 | "devDependencies": {
16 | "@seventy-seven/typescript-config": "workspace:*",
17 | "@types/node": "^20.14.2",
18 | "typescript": "^5.4.5"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/analytics/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { OpenpanelProvider, OpenpanelSdk } from '@openpanel/nextjs'
2 | import { waitUntil } from '@vercel/functions'
3 |
4 | export { setProfile } from '@openpanel/nextjs'
5 |
6 | export const createAnalyticsClient = ({ clientId, clientSecret }: { clientId: string; clientSecret: string }) => {
7 | const openpanelClient = new OpenpanelSdk({ clientId, clientSecret })
8 |
9 | const event = async (...args: Parameters) => {
10 | if (process.env.NODE_ENV !== 'production') {
11 | // biome-ignore lint/suspicious/noConsoleLog: Should only log in development
12 | console.log('openpanelClient.event', args)
13 | return null
14 | }
15 |
16 | return waitUntil(openpanelClient.event(...args))
17 | }
18 |
19 | return {
20 | ...openpanelClient,
21 | event,
22 | }
23 | }
24 |
25 | export const AnalyticsProvider = ({ clientId }: { clientId: string }) => {
26 | return (
27 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/packages/analytics/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@seventy-seven/typescript-config/nextjs.json",
3 | "compilerOptions": {
4 | "outDir": "dist"
5 | },
6 | "include": ["src"],
7 | "exclude": ["dist", "node_modules"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/email/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import { Column, Img, Link, Row, Section } from '@react-email/components'
2 |
3 | const baseUrl =
4 | process.env.VERCEL_ENV === 'production' ? 'https://seventy-seven.dev/email/' : 'http://localhost:3001/email'
5 |
6 | export const Footer = () => {
7 | return (
8 |
9 |
10 |
11 |
12 | Powered by
13 |
14 |
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/packages/email/index.ts:
--------------------------------------------------------------------------------
1 | import { render } from '@react-email/render'
2 | import { Resend } from 'resend'
3 |
4 | const RESEND_API_KEY = process.env.RESEND_API_KEY
5 |
6 | if (!RESEND_API_KEY) {
7 | throw new Error('RESEND_API_KEY is required')
8 | }
9 |
10 | export const createResendClient = () => new Resend(process.env.RESEND_API_KEY)
11 | export const componentToPlainText = (component: React.ReactElement) =>
12 | render(component, {
13 | plainText: true,
14 | })
15 |
--------------------------------------------------------------------------------
/packages/email/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@seventy-seven/email",
3 | "version": "0.0.0",
4 | "private": true,
5 | "exports": {
6 | ".": "./index.ts",
7 | "./emails/waitlist-confirmation": "./emails/waitlist-confirmation.tsx",
8 | "./emails/ticket-message-response": "./emails/ticket-message-response.tsx",
9 | "./emails/snooze-expired": "./emails/snooze-expired.tsx",
10 | "./emails/ticket-closed": "./emails/ticket-closed.tsx",
11 | "./emails/team-invite": "./emails/team-invite.tsx",
12 | "./emails/new-ticket": "./emails/new-ticket.tsx"
13 | },
14 | "scripts": {
15 | "lint": "biome check .",
16 | "check:types": "tsc --noEmit",
17 | "dev": "email dev --port 3002"
18 | },
19 | "dependencies": {
20 | "@seventy-seven/ui": "workspace:*",
21 | "@seventy-seven/orm": "workspace:*",
22 | "@react-email/components": "0.0.16",
23 | "@react-email/render": "0.0.12",
24 | "react": "^18.2.0",
25 | "react-dom": "^18.2.0",
26 | "react-email": "2.1.1",
27 | "resend": "^3.2.0"
28 | },
29 | "devDependencies": {
30 | "@types/node": "^20.12.7",
31 | "@types/react": "^18.2.79",
32 | "@types/react-dom": "^18.2.25"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/packages/email/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@seventy-seven/typescript-config/react-library.json",
3 | "include": ["src", "index.ts", "emails/waitlist-confirmation.tsx"],
4 | "exclude": ["node_modules"]
5 | }
6 |
--------------------------------------------------------------------------------
/packages/email/types.ts:
--------------------------------------------------------------------------------
1 | import type { Prisma } from '@seventy-seven/orm/prisma'
2 |
3 | export type Message = Prisma.MessageGetPayload<{
4 | select: {
5 | id: true
6 | body: true
7 | created_at: true
8 | sent_from_full_name: true
9 | sent_from_email: true
10 | sent_from_avatar_url: true
11 | handler: {
12 | select: {
13 | full_name: true
14 | image_url: true
15 | }
16 | }
17 | }
18 | }>
19 |
--------------------------------------------------------------------------------
/packages/integrations/README.md:
--------------------------------------------------------------------------------
1 | # @seventy-seven/sdk
2 |
3 | TypeScript SDK for managing tickets with [Seventy Seven](https://seventy-seven.dev).
--------------------------------------------------------------------------------
/packages/integrations/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@seventy-seven/integrations",
3 | "version": "1.0.0",
4 | "private": true,
5 | "sideEffects": false,
6 | "scripts": {
7 | "lint": "biome check .",
8 | "check:types": "tsc --noEmit"
9 | },
10 | "dependencies": {
11 | "@slack/bolt": "^3.18.0",
12 | "@slack/oauth": "^3.0.0"
13 | },
14 | "devDependencies": {
15 | "@seventy-seven/typescript-config": "workspace:*",
16 | "@types/node": "^20.14.2",
17 | "typescript": "^5.4.5"
18 | },
19 | "exports": {
20 | "./slack": "./src/slack/index.ts"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/packages/integrations/src/slack/index.ts:
--------------------------------------------------------------------------------
1 | import { LogLevel, App as SlackApp } from '@slack/bolt'
2 | import { InstallProvider } from '@slack/oauth'
3 |
4 | const SLACK_CLIENT_ID = process.env.SLACK_CLIENT_ID
5 | const SLACK_CLIENT_SECRET = process.env.SLACK_CLIENT_SECRET
6 | const SLACK_OAUTH_REDIRECT_URL = process.env.SLACK_OAUTH_REDIRECT_URL
7 | const SLACK_STATE_SECRET = process.env.SLACK_STATE_SECRET
8 | const SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET
9 |
10 | if (!SLACK_CLIENT_ID) {
11 | throw new Error('SLACK_CLIENT_ID is not defined')
12 | }
13 |
14 | if (!SLACK_CLIENT_SECRET) {
15 | throw new Error('SLACK_CLIENT_SECRET is not defined')
16 | }
17 |
18 | if (!SLACK_OAUTH_REDIRECT_URL) {
19 | throw new Error('SLACK_OAUTH_REDIRECT_URL is not defined')
20 | }
21 |
22 | if (!SLACK_STATE_SECRET) {
23 | throw new Error('SLACK_STATE_SECRET is not defined')
24 | }
25 |
26 | if (!SLACK_SIGNING_SECRET) {
27 | throw new Error('SLACK_SIGNING_SECRET is not defined')
28 | }
29 |
30 | export const slackInstaller = new InstallProvider({
31 | clientId: SLACK_CLIENT_ID,
32 | clientSecret: SLACK_CLIENT_SECRET,
33 | stateSecret: SLACK_STATE_SECRET,
34 | logLevel: process.env.NODE_ENV === 'development' ? LogLevel.DEBUG : undefined,
35 | })
36 |
37 | export const getInstallUrl = ({ teamId, userId }: { teamId: string; userId: string }) => {
38 | return slackInstaller.generateInstallUrl({
39 | scopes: ['incoming-webhook', 'chat:write', 'chat:write.public', 'team:read'],
40 | redirectUri: SLACK_OAUTH_REDIRECT_URL,
41 | metadata: JSON.stringify({ teamId, userId }),
42 | })
43 | }
44 |
45 | export const createSlackApp = ({ token, botId }: { token: string; botId: string }) => {
46 | return new SlackApp({
47 | signingSecret: SLACK_SIGNING_SECRET,
48 | token,
49 | botId,
50 | })
51 | }
52 |
--------------------------------------------------------------------------------
/packages/integrations/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@seventy-seven/typescript-config/base.json",
3 | "compilerOptions": {
4 | "outDir": "dist"
5 | },
6 | "include": ["src"],
7 | "exclude": ["dist", "node_modules"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/orm/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@seventy-seven/orm",
3 | "version": "0.0.0",
4 | "private": true,
5 | "exports": {
6 | "./prisma": "./src/prisma.ts",
7 | "./enums": "./src/prisma/enums.ts"
8 | },
9 | "scripts": {
10 | "lint": "biome check .",
11 | "check:types": "tsc --noEmit",
12 | "prisma:generate": "prisma generate --schema=./src/prisma/schema.prisma",
13 | "_prisma:push": "cd ../../ && npx prisma db push --schema=./packages/orm/src/prisma/schema.prisma --skip-generate",
14 | "prisma:push": "prisma db push --schema=./src/prisma/schema.prisma --skip-generate"
15 | },
16 | "dependencies": {
17 | "@prisma/client": "^6.2.1",
18 | "@prisma/extension-optimize": "^1.1.4"
19 | },
20 | "devDependencies": {
21 | "@types/node": "^20.12.7",
22 | "prisma": "^6.2.1"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/orm/src/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client'
2 | import { loggingExtension } from './prisma/extensions/loggingExtension'
3 | export * from '@prisma/client'
4 |
5 | const prismaClientSingleton = () => {
6 | return new PrismaClient({
7 | // log: process.env.NODE_ENV === 'development' ? ['info', 'query'] : [],
8 | }).$extends(loggingExtension())
9 | }
10 |
11 | type PrismaClientSingleton = ReturnType
12 |
13 | const globalForPrisma = globalThis as unknown as {
14 | prisma: PrismaClientSingleton | undefined
15 | }
16 |
17 | export const prisma = globalForPrisma.prisma ?? prismaClientSingleton()
18 |
19 | if (process.env.NODE_ENV !== 'production') {
20 | globalForPrisma.prisma = prisma
21 | }
22 |
--------------------------------------------------------------------------------
/packages/orm/src/prisma/enums.ts:
--------------------------------------------------------------------------------
1 | export { TEAM_ROLE_ENUM } from '@prisma/client'
2 |
--------------------------------------------------------------------------------
/packages/orm/src/prisma/extensions/loggingExtension.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from '@prisma/client'
2 |
3 | export const loggingExtension = () => {
4 | if (process.env.NODE_ENV !== 'development') {
5 | return Prisma.defineExtension({
6 | name: 'logging',
7 | })
8 | }
9 |
10 | const queryOperations = [
11 | 'findUnique',
12 | 'findMany',
13 | 'findFirst',
14 | 'count',
15 | 'aggregate',
16 | 'groupBy',
17 | 'findUniqueOrThrow',
18 | 'findFirstOrThrow',
19 | ]
20 |
21 | const mutationOperations = ['create', 'createMany', 'update', 'updateMany', 'upsert', 'delete', 'deleteMany']
22 |
23 | return Prisma.defineExtension({
24 | name: 'logging',
25 | query: {
26 | $allModels: {
27 | $allOperations: async ({ model, operation, args, query }) => {
28 | const now = performance.now()
29 | const results = await query(args)
30 | const elapsed = performance.now() - now
31 |
32 | const queryType = (() => {
33 | if (queryOperations.includes(operation)) {
34 | return '[QUERY]'
35 | }
36 |
37 | if (mutationOperations.includes(operation)) {
38 | return '[MUTATION]'
39 | }
40 |
41 | return ''
42 | })()
43 |
44 | const loggingResult = `${queryType} ${model}.${operation} took ${elapsed}ms`
45 | const hr = new Array(loggingResult.length).fill('-').join('')
46 |
47 | // biome-ignore lint/suspicious/noConsoleLog:
48 | console.log(loggingResult)
49 | // biome-ignore lint/suspicious/noConsoleLog:
50 | console.log(hr)
51 |
52 | return results
53 | },
54 | },
55 | },
56 | })
57 | }
58 |
--------------------------------------------------------------------------------
/packages/orm/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@seventy-seven/typescript-config/base.json",
3 | "include": ["src"],
4 | "exclude": ["node_modules"]
5 | }
6 |
--------------------------------------------------------------------------------
/packages/sdk/README.md:
--------------------------------------------------------------------------------
1 | # @seventy-seven/sdk
2 |
3 | TypeScript SDK for managing tickets with [Seventy Seven](https://seventy-seven.dev).
--------------------------------------------------------------------------------
/packages/sdk/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@seventy-seven/sdk",
3 | "private": false,
4 | "version": "0.0.0-beta.2",
5 | "main": "dist/index.js",
6 | "module": "dist/index.mjs",
7 | "types": "dist/index.d.ts",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/christianalares/seventy-seven"
11 | },
12 | "homepage": "https://seventy-seven.dev",
13 | "publishConfig": {
14 | "access": "public"
15 | },
16 | "files": ["dist"],
17 | "scripts": {
18 | "dev": "tsup tsup src/index.ts --format cjs,esm --dts --watch",
19 | "build": "rm -rf dist && tsup src/index.ts --format cjs,esm --dts",
20 | "npm:publish": "npm run build && npm publish",
21 | "lint": "biome check .",
22 | "check:types": "tsc --noEmit"
23 | },
24 | "devDependencies": {
25 | "@seventy-seven/typescript-config": "workspace:*",
26 | "tsup": "^8.0.2",
27 | "typescript": "^5.4.5"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/packages/sdk/src/index.ts:
--------------------------------------------------------------------------------
1 | export type CreateTicketPayload = {
2 | subject: string
3 | body: string
4 | senderFullName: string
5 | senderEmail: string
6 | senderAvatarUrl?: string
7 | meta?: TMeta
8 | }
9 |
10 | export type CreateTicketResponse = TMeta extends undefined
11 | ? {
12 | id: string
13 | subject: string
14 | }
15 | : {
16 | id: string
17 | subject: string
18 | meta: TMeta
19 | }
20 |
21 | export class SeventySevenClient {
22 | #authToken: string
23 |
24 | constructor(authToken: string) {
25 | this.#authToken = authToken
26 | }
27 |
28 | async createTicket(ticket: CreateTicketPayload) {
29 | try {
30 | const response = await fetch('https://app.seventy-seven.dev/api/tickets', {
31 | method: 'POST',
32 | headers: {
33 | 'Content-Type': 'application/json',
34 | Authorization: `Bearer ${this.#authToken}`,
35 | },
36 | body: JSON.stringify({
37 | subject: ticket.subject,
38 | body: ticket.body,
39 | senderFullName: ticket.senderFullName,
40 | senderEmail: ticket.senderEmail,
41 | senderAvatarUrl: ticket.senderAvatarUrl,
42 | meta: ticket.meta ?? undefined,
43 | }),
44 | })
45 |
46 | const createdTicket = await response.json()
47 | return createdTicket as CreateTicketResponse
48 | } catch (err) {
49 | if (err instanceof Error) {
50 | throw new Error(err.message)
51 | }
52 |
53 | throw new Error('An error occurred while creating ticket')
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/packages/sdk/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@seventy-seven/typescript-config/base.json",
3 | "compilerOptions": {
4 | "outDir": "dist"
5 | },
6 | "include": ["src"],
7 | "exclude": ["dist", "node_modules"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/supabase/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@seventy-seven/supabase",
3 | "version": "0.0.0",
4 | "private": true,
5 | "exports": {
6 | "./clients/server": "./src/clients/server.ts",
7 | "./clients/middleware": "./src/clients/middleware.ts",
8 | "./clients/client": "./src/clients/client.ts",
9 | "./session": "./src/session.ts",
10 | "./types": "./src/types/index.ts"
11 | },
12 | "scripts": {
13 | "lint": "biome check .",
14 | "check:types": "tsc --noEmit",
15 | "generate:types": "supabase gen types typescript --project-id nqofuchqmjrqzomjedyz --schema public > ./src/types/db.ts"
16 | },
17 | "dependencies": {
18 | "@seventy-seven/orm": "workspace:*",
19 | "@supabase/ssr": "^0.3.0",
20 | "@supabase/supabase-js": "^2.42.5",
21 | "next": "^14.2.2"
22 | },
23 | "devDependencies": {
24 | "supabase": "^1.162.4",
25 | "typescript": "^5.4.5"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/supabase/src/clients/client.ts:
--------------------------------------------------------------------------------
1 | import { createBrowserClient } from '@supabase/ssr'
2 | import type { Database } from '../types/db'
3 |
4 | export const createClient = () => {
5 | const NEXT_PUBLIC_SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
6 | const NEXT_PUBLIC_SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
7 |
8 | if (!NEXT_PUBLIC_SUPABASE_URL) {
9 | throw new Error('Missing env.NEXT_PUBLIC_SUPABASE_URL')
10 | }
11 |
12 | if (!NEXT_PUBLIC_SUPABASE_ANON_KEY) {
13 | throw new Error('Missing env.NEXT_PUBLIC_SUPABASE_ANON_KEY')
14 | }
15 |
16 | return createBrowserClient(NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY)
17 | }
18 |
--------------------------------------------------------------------------------
/packages/supabase/src/clients/middleware.ts:
--------------------------------------------------------------------------------
1 | import { type CookieOptions, createServerClient } from '@supabase/ssr'
2 | import { type NextRequest, NextResponse } from 'next/server'
3 | import type { Database } from '../types/db'
4 |
5 | export const createClient = (request: NextRequest) => {
6 | const NEXT_PUBLIC_SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
7 | const NEXT_PUBLIC_SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
8 |
9 | if (!NEXT_PUBLIC_SUPABASE_URL) {
10 | throw new Error('Missing env.NEXT_PUBLIC_SUPABASE_URL')
11 | }
12 |
13 | if (!NEXT_PUBLIC_SUPABASE_ANON_KEY) {
14 | throw new Error('Missing env.NEXT_PUBLIC_SUPABASE_ANON_KEY')
15 | }
16 |
17 | let response = NextResponse.next({
18 | request: {
19 | headers: request.headers,
20 | },
21 | })
22 |
23 | const supabase = createServerClient(NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, {
24 | cookies: {
25 | get(name: string) {
26 | return request.cookies.get(name)?.value
27 | },
28 | set(name: string, value: string, options: CookieOptions) {
29 | request.cookies.set({
30 | name,
31 | value,
32 | ...options,
33 | })
34 | response = NextResponse.next({
35 | request: {
36 | headers: request.headers,
37 | },
38 | })
39 | response.cookies.set({
40 | name,
41 | value,
42 | ...options,
43 | })
44 | },
45 | remove(name: string, options: CookieOptions) {
46 | request.cookies.set({
47 | name,
48 | value: '',
49 | ...options,
50 | })
51 | response = NextResponse.next({
52 | request: {
53 | headers: request.headers,
54 | },
55 | })
56 | response.cookies.set({
57 | name,
58 | value: '',
59 | ...options,
60 | })
61 | },
62 | },
63 | })
64 |
65 | return supabase
66 | }
67 |
--------------------------------------------------------------------------------
/packages/supabase/src/clients/server.ts:
--------------------------------------------------------------------------------
1 | import { type CookieOptions, createServerClient } from '@supabase/ssr'
2 | import { cookies } from 'next/headers'
3 | import type { Database } from '../types/db'
4 |
5 | type CreateClientOptions = {
6 | admin?: boolean
7 | }
8 |
9 | export const createClient = (options?: CreateClientOptions) => {
10 | const cookieStore = cookies()
11 |
12 | const NEXT_PUBLIC_SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
13 | const NEXT_PUBLIC_SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
14 | const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
15 |
16 | if (!NEXT_PUBLIC_SUPABASE_URL) {
17 | throw new Error('Missing env.NEXT_PUBLIC_SUPABASE_URL')
18 | }
19 |
20 | if (!NEXT_PUBLIC_SUPABASE_ANON_KEY) {
21 | throw new Error('Missing env.NEXT_PUBLIC_SUPABASE_ANON_KEY')
22 | }
23 |
24 | if (!SUPABASE_SERVICE_ROLE_KEY) {
25 | throw new Error('Missing env.SUPABASE_SERVICE_ROLE_KEY')
26 | }
27 |
28 | const key = options?.admin ? SUPABASE_SERVICE_ROLE_KEY : NEXT_PUBLIC_SUPABASE_ANON_KEY
29 |
30 | return createServerClient(NEXT_PUBLIC_SUPABASE_URL, key, {
31 | cookies: {
32 | get(name: string) {
33 | return cookieStore.get(name)?.value
34 | },
35 | set(name: string, value: string, options: CookieOptions) {
36 | try {
37 | cookieStore.set({ name, value, ...options })
38 | } catch (_error) {
39 | // The `set` method was called from a Server Component.
40 | // This can be ignored if you have middleware refreshing
41 | // user sessions.
42 | }
43 | },
44 | remove(name: string, options: CookieOptions) {
45 | try {
46 | cookieStore.set({ name, value: '', ...options })
47 | } catch (_error) {
48 | // The `delete` method was called from a Server Component.
49 | // This can be ignored if you have middleware refreshing
50 | // user sessions.
51 | }
52 | },
53 | },
54 | })
55 | }
56 |
--------------------------------------------------------------------------------
/packages/supabase/src/session.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from './clients/server'
2 |
3 | export const getUser = async () => {
4 | const sb = createClient()
5 |
6 | const {
7 | data: { user },
8 | } = await sb.auth.getUser()
9 |
10 | return user
11 | }
12 |
--------------------------------------------------------------------------------
/packages/supabase/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import type { Database } from './db'
2 | import {
3 | RealtimePostgresInsertPayload,
4 | RealtimePostgresUpdatePayload,
5 | RealtimePostgresDeletePayload,
6 | RealtimePostgresChangesPayload,
7 | } from '@supabase/supabase-js'
8 |
9 | export namespace Supabase {
10 | export type Table = keyof Database['public']['Tables']
11 | export type RealtimeEvent = 'INSERT' | 'UPDATE' | 'DELETE' | '*'
12 | export type TableRow = Database['public']['Tables'][T]['Row']
13 |
14 | export type RealtimePayload<
15 | TRow extends { [key: string]: any },
16 | TEvent extends Supabase.RealtimeEvent,
17 | > = TEvent extends 'INSERT'
18 | ? RealtimePostgresInsertPayload
19 | : TEvent extends 'UPDATE'
20 | ? RealtimePostgresUpdatePayload
21 | : TEvent extends 'DELETE'
22 | ? RealtimePostgresDeletePayload
23 | : RealtimePostgresChangesPayload
24 | }
25 |
--------------------------------------------------------------------------------
/packages/supabase/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@seventy-seven/typescript-config/base.json",
3 | "include": ["src"],
4 | "exclude": ["node_modules"]
5 | }
6 |
--------------------------------------------------------------------------------
/packages/typescript-config/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Default",
4 | "compilerOptions": {
5 | "esModuleInterop": true,
6 | "incremental": false,
7 | "isolatedModules": true,
8 | "lib": ["es2022", "DOM", "DOM.Iterable"],
9 | "module": "NodeNext",
10 | "moduleDetection": "force",
11 | "moduleResolution": "NodeNext",
12 | "noUncheckedIndexedAccess": true,
13 | "resolveJsonModule": true,
14 | "skipLibCheck": true,
15 | "strict": true,
16 | "target": "ES2022"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/packages/typescript-config/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 | "module": "ESNext",
8 | "moduleResolution": "Bundler",
9 | "allowJs": true,
10 | "jsx": "preserve",
11 | "noEmit": true
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/typescript-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@seventy-seven/typescript-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "publishConfig": {
7 | "access": "public"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/typescript-config/react-library.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "React Library",
4 | "extends": "./base.json",
5 | "compilerOptions": {
6 | "jsx": "react-jsx"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/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.ts",
8 | "css": "src/globals.css",
9 | "baseColor": "gray",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components/shadcn",
15 | "ui": "@/components/shadcn",
16 | "utils": "@/utils"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/packages/ui/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/packages/ui/src/components/alert.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from 'react'
2 | import {
3 | AlertDialogCancel,
4 | AlertDialogContent,
5 | AlertDialogDescription,
6 | AlertDialogFooter,
7 | AlertDialogHeader,
8 | AlertDialogTitle,
9 | } from './shadcn/alert-dialog'
10 |
11 | export { AlertDialog } from './shadcn/alert-dialog'
12 | export { createPushModal } from 'pushmodal'
13 |
14 | type Props = ComponentProps
15 |
16 | export const Alert = ({ children, ...restProps }: Props) => {
17 | return {children}
18 | }
19 |
20 | export const AlertHeader = ({ children }: { children: React.ReactNode }) => {
21 | return {children}
22 | }
23 |
24 | export const AlertTitle = ({ children }: { children: React.ReactNode }) => {
25 | return {children}
26 | }
27 |
28 | export const AlertDescription = ({ children }: { children: React.ReactNode }) => {
29 | return {children}
30 | }
31 |
32 | export const AlertFooter = AlertDialogFooter
33 | export const AlertCancel = AlertDialogCancel
34 |
--------------------------------------------------------------------------------
/packages/ui/src/components/code-block.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // @ts-ignore
4 | import { highlight } from 'sugar-high'
5 | import { cn } from '../utils'
6 |
7 | import { useState } from 'react'
8 | import { Tabs, TabsContent, TabsList, TabsTrigger } from './shadcn/tabs'
9 |
10 | type Tab = {
11 | id: string
12 | label: string
13 | code: string
14 | }
15 |
16 | type Props = {
17 | tabs: Tab[]
18 | className?: string
19 | }
20 |
21 | export const CodeBlock = ({ tabs, className }: Props) => {
22 | const [activeTabId, setActiveTabId] = useState(tabs[0]?.id)
23 |
24 | return (
25 |
30 |
31 | {tabs.map(({ id, label }) => (
32 |
33 | {label}
34 |
35 | {activeTabId === id && }
36 |
37 | ))}
38 |
39 |
40 | {tabs.map(({ id, code }) => (
41 |
42 |
43 | {/* biome-ignore lint/security/noDangerouslySetInnerHtmlWithChildren: */}
44 | {/* biome-ignore lint/security/noDangerouslySetInnerHtml: */}
45 |
46 |
47 |
48 | ))}
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/packages/ui/src/components/color-picker.tsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianalares/seventy-seven/943563958c456f57ae35579b400eacf02cc02231/packages/ui/src/components/color-picker.tsx
--------------------------------------------------------------------------------
/packages/ui/src/components/combobox.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 |
5 | import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './shadcn/command'
6 |
7 | const frameworks = [
8 | {
9 | id: 'next.js',
10 | label: 'Next.js',
11 | },
12 | {
13 | id: 'sveltekit',
14 | label: 'SvelteKit',
15 | },
16 | {
17 | id: 'nuxt.js',
18 | label: 'Nuxt.js',
19 | },
20 | {
21 | id: 'remix',
22 | label: 'Remix',
23 | },
24 | {
25 | id: 'astro',
26 | label: 'Astro',
27 | },
28 | ]
29 |
30 | export function Combobox() {
31 | const [_open, setOpen] = React.useState(false)
32 | const [label, setLabel] = React.useState('')
33 |
34 | return (
35 |
36 |
37 |
38 | No label found.
39 |
40 | {frameworks.map((item) => (
41 | {
45 | setLabel(value)
46 | setOpen(false)
47 | }}
48 | >
49 | {label}
50 |
51 | ))}
52 |
53 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/packages/ui/src/components/logo.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '../utils'
2 |
3 | type Props = {
4 | className?: string
5 | }
6 |
7 | export const Logo = ({ className }: Props) => {
8 | return (
9 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/packages/ui/src/components/modal-responsive.tsx:
--------------------------------------------------------------------------------
1 | import { useMediaQuery } from './hooks/use-media-query'
2 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './shadcn/dialog'
3 | import { Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle } from './shadcn/drawer'
4 |
5 | type Props = {
6 | children: React.ReactNode
7 | isOpen: boolean
8 | setIsOpen: (isOpen: boolean) => void
9 | }
10 |
11 | const useIsDesktop = () => {
12 | return useMediaQuery('(min-width: 768px)')
13 | }
14 |
15 | export const ModalParent = ({ children, isOpen, setIsOpen }: Props) => {
16 | const isDesktop = useIsDesktop()
17 |
18 | if (isDesktop) {
19 | return (
20 |
23 | )
24 | }
25 |
26 | return (
27 |
28 | {children}
29 |
30 | )
31 | }
32 |
33 | export const ModalContent = ({ children }: { children: React.ReactNode }) => {
34 | const isDesktop = useIsDesktop()
35 |
36 | if (isDesktop) {
37 | return {children}
38 | }
39 |
40 | return {children}
41 | }
42 |
43 | export const ModalHeader = ({ children }: { children: React.ReactNode }) => {
44 | const isDesktop = useIsDesktop()
45 |
46 | if (isDesktop) {
47 | return {children}
48 | }
49 |
50 | return {children}
51 | }
52 |
53 | export const ModalTitle = ({ children }: { children: React.ReactNode }) => {
54 | const isDesktop = useIsDesktop()
55 |
56 | if (isDesktop) {
57 | return {children}
58 | }
59 |
60 | return {children}
61 | }
62 |
63 | export const ModalDescription = ({ children }: { children: React.ReactNode }) => {
64 | const isDesktop = useIsDesktop()
65 |
66 | if (isDesktop) {
67 | return {children}
68 | }
69 |
70 | return {children}
71 | }
72 |
73 | export const ModalFooter = ({ children }: { children: React.ReactNode }) => {
74 | const isDesktop = useIsDesktop()
75 |
76 | if (isDesktop) {
77 | return {children}
78 | }
79 |
80 | return {children}
81 | }
82 |
--------------------------------------------------------------------------------
/packages/ui/src/components/modal.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from 'react'
2 | import { cn } from '../utils'
3 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './shadcn/dialog'
4 |
5 | export { createPushModal } from 'pushmodal'
6 |
7 | type Props = ComponentProps
8 |
9 | export const ModalWrapper = Dialog
10 |
11 | export const Modal = ({ children, className, ...restProps }: Props) => {
12 | return (
13 |
14 | {children}
15 |
16 | )
17 | }
18 |
19 | export const ModalHeader = ({ children, className }: { children: React.ReactNode; className?: string }) => {
20 | return {children}
21 | }
22 |
23 | export const ModalTitle = ({ children }: { children: React.ReactNode }) => {
24 | return {children}
25 | }
26 |
27 | export const ModalDescription = ({ children, className }: { children: React.ReactNode; className?: string }) => {
28 | return {children}
29 | }
30 |
31 | export const ModalFooter = ({ children, className }: { children: React.ReactNode; className?: string }) => {
32 | return {children}
33 | }
34 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/avatar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as AvatarPrimitive from '@radix-ui/react-avatar'
4 | import * as React from 'react'
5 |
6 | import { cn } from '../../utils'
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
17 | ))
18 | Avatar.displayName = AvatarPrimitive.Root.displayName
19 |
20 | const AvatarImage = React.forwardRef<
21 | React.ElementRef,
22 | React.ComponentPropsWithoutRef
23 | >(({ className, ...props }, ref) => (
24 |
25 | ))
26 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
27 |
28 | const AvatarFallback = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, ...props }, ref) => (
32 |
37 | ))
38 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
39 |
40 | export { Avatar, AvatarImage, AvatarFallback }
41 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/badge.tsx:
--------------------------------------------------------------------------------
1 | import { type VariantProps, cva } from 'class-variance-authority'
2 |
3 | import { cn } from '../../utils'
4 |
5 | const badgeVariants = cva(
6 | 'inline-flex items-center rounded-full border px-2.5 h-6 text-xs font-semibold 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 | },
16 | defaultVariants: {
17 | variant: 'default',
18 | },
19 | },
20 | )
21 |
22 | export interface BadgeProps extends React.HTMLAttributes, VariantProps {}
23 |
24 | function Badge({ className, variant, ...props }: BadgeProps) {
25 | return
26 | }
27 |
28 | export { Badge, badgeVariants }
29 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '../../utils'
4 |
5 | const Card = React.forwardRef>(({ className, ...props }, ref) => (
6 |
7 | ))
8 | Card.displayName = 'Card'
9 |
10 | const CardHeader = React.forwardRef>(
11 | ({ className, ...props }, ref) => (
12 |
13 | ),
14 | )
15 | CardHeader.displayName = 'CardHeader'
16 |
17 | const CardTitle = React.forwardRef>(
18 | ({ className, ...props }, ref) => (
19 |
20 | ),
21 | )
22 | CardTitle.displayName = 'CardTitle'
23 |
24 | const CardDescription = React.forwardRef>(
25 | ({ className, ...props }, ref) => (
26 |
27 | ),
28 | )
29 | CardDescription.displayName = 'CardDescription'
30 |
31 | const CardContent = React.forwardRef>(
32 | ({ className, ...props }, ref) => ,
33 | )
34 | CardContent.displayName = 'CardContent'
35 |
36 | const CardFooter = React.forwardRef>(
37 | ({ className, ...props }, ref) => (
38 |
43 | ),
44 | )
45 | CardFooter.displayName = 'CardFooter'
46 |
47 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
48 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/checkbox.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
4 | import { Check } from 'lucide-react'
5 | import * as React from 'react'
6 |
7 | import { cn } from '../../utils'
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
22 |
23 |
24 |
25 | ))
26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
27 |
28 | export { Checkbox }
29 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '../../utils'
4 |
5 | export interface InputProps extends React.InputHTMLAttributes {}
6 |
7 | const Input = React.forwardRef(({ className, type, ...props }, ref) => {
8 | return (
9 |
18 | )
19 | })
20 | Input.displayName = 'Input'
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as LabelPrimitive from '@radix-ui/react-label'
4 | import { type VariantProps, cva } from 'class-variance-authority'
5 | import * as React from 'react'
6 |
7 | import { cn } from '../../utils'
8 |
9 | const labelVariants = cva(
10 | 'text-sm leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef & VariantProps
16 | >(({ className, ...props }, ref) => (
17 |
18 | ))
19 | Label.displayName = LabelPrimitive.Root.displayName
20 |
21 | export { Label }
22 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/popover.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as PopoverPrimitive from '@radix-ui/react-popover'
4 | import * as React from 'react'
5 |
6 | import { cn } from '../../utils'
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '../../utils'
2 |
3 | function Skeleton({ className, ...props }: React.HTMLAttributes) {
4 | return
5 | }
6 |
7 | export { Skeleton }
8 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/sonner.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useTheme } from 'next-themes'
4 | import { Toaster as Sonner } from 'sonner'
5 |
6 | type ToasterProps = React.ComponentProps
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = 'system' } = useTheme()
10 |
11 | return (
12 |
28 | )
29 | }
30 |
31 | export { Toaster }
32 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '../../utils'
2 | import { Icon } from './icon'
3 |
4 | type Props = {
5 | className?: string
6 | }
7 |
8 | export const Spinner = ({ className }: Props) => {
9 | return
10 | }
11 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/switch.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as SwitchPrimitives from '@radix-ui/react-switch'
4 | import * as React from 'react'
5 |
6 | import { cn } from '../../utils'
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/tabs.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as TabsPrimitive from '@radix-ui/react-tabs'
4 | import * as React from 'react'
5 |
6 | import { cn } from '../../utils'
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '../../utils'
4 |
5 | export interface TextareaProps extends React.TextareaHTMLAttributes {}
6 |
7 | const Textarea = React.forwardRef(({ className, ...props }, ref) => {
8 | return (
9 |
17 | )
18 | })
19 | Textarea.displayName = 'Textarea'
20 |
21 | export { Textarea }
22 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/time-picker/date-time-picker.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { addHours, startOfMonth, startOfToday } from 'date-fns'
4 | import { useState } from 'react'
5 | import { Calendar, type CalendarProps } from '../calendar'
6 | import { TimePickerInputs } from './time-picker-inputs'
7 |
8 | type Props = Pick & {
9 | date: Date | undefined
10 | setDate: (date: Date | undefined) => void
11 | }
12 |
13 | export function DateTimePicker({ date, setDate, ...restProps }: Props) {
14 | const [month, setMonth] = useState(startOfMonth(new Date()))
15 |
16 | return (
17 |
18 | {
25 | setMonth(startOfToday())
26 | setDate(addHours(new Date(), 1))
27 | }}
28 | initialFocus
29 | fixedWeeks
30 | {...restProps}
31 | />
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/time-picker/time-picker-inputs.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { cn } from '../../../utils'
5 | import { Label } from '../label'
6 | import { TimePickerInput } from './time-picker-input'
7 |
8 | interface Props {
9 | date: Date | undefined
10 | setDate: (date: Date | undefined) => void
11 | className?: string
12 | }
13 |
14 | export function TimePickerInputs({ date, setDate, className }: Props) {
15 | const minuteRef = React.useRef(null)
16 | const hourRef = React.useRef(null)
17 | // const secondRef = React.useRef(null)
18 |
19 | return (
20 |
21 |
22 |
25 | minuteRef.current?.focus()}
31 | />
32 |
33 |
34 |
37 | hourRef.current?.focus()}
43 | // onRightFocus={() => secondRef.current?.focus()}
44 | />
45 |
46 | {/*
47 |
50 | minuteRef.current?.focus()}
56 | />
57 |
*/}
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/toggle-group.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
4 | import type { VariantProps } from 'class-variance-authority'
5 | import * as React from 'react'
6 |
7 | import { cn } from '../../utils'
8 | import { toggleVariants } from './toggle'
9 |
10 | const ToggleGroupContext = React.createContext>({
11 | size: 'default',
12 | variant: 'default',
13 | })
14 |
15 | const ToggleGroup = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef & VariantProps
18 | >(({ className, variant, size, children, ...props }, ref) => (
19 |
20 | {children}
21 |
22 | ))
23 |
24 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
25 |
26 | const ToggleGroupItem = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef & VariantProps
29 | >(({ className, children, variant, size, ...props }, ref) => {
30 | const context = React.useContext(ToggleGroupContext)
31 |
32 | return (
33 |
44 | {children}
45 |
46 | )
47 | })
48 |
49 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
50 |
51 | export { ToggleGroup, ToggleGroupItem }
52 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/toggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as TogglePrimitive from '@radix-ui/react-toggle'
4 | import { type VariantProps, cva } from 'class-variance-authority'
5 | import * as React from 'react'
6 |
7 | import { cn } from '../../utils'
8 |
9 | const toggleVariants = cva(
10 | 'inline-flex items-center justify-center rounded-md text-sm ring-offset-background transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground',
11 | {
12 | variants: {
13 | variant: {
14 | default: 'bg-transparent',
15 | outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
16 | },
17 | size: {
18 | default: 'h-10 px-3',
19 | sm: 'h-9 px-2.5',
20 | lg: 'h-11 px-5',
21 | },
22 | },
23 | defaultVariants: {
24 | variant: 'default',
25 | size: 'default',
26 | },
27 | },
28 | )
29 |
30 | const Toggle = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef & VariantProps
33 | >(({ className, variant, size, ...props }, ref) => (
34 |
35 | ))
36 |
37 | Toggle.displayName = TogglePrimitive.Root.displayName
38 |
39 | export { Toggle, toggleVariants }
40 |
--------------------------------------------------------------------------------
/packages/ui/src/components/shadcn/tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
4 | import * as React from 'react'
5 |
6 | import { cn } from '../../utils'
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/packages/ui/src/components/sheet.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from 'react'
2 | import { cn } from '../utils'
3 | import {
4 | SheetDescription as ShadSheetDescription,
5 | SheetFooter as ShadSheetFooter,
6 | SheetHeader as ShadSheetHeader,
7 | SheetTitle as ShadSheetTitle,
8 | SheetContent,
9 | } from './shadcn/sheet'
10 |
11 | export { createPushModal } from 'pushmodal'
12 |
13 | type Props = ComponentProps
14 |
15 | export const Sheet = ({ children, className, ...restProps }: Props) => {
16 | return (
17 |
18 | {children}
19 |
20 | )
21 | }
22 |
23 | export const SheetHeader = ({ children }: { children: React.ReactNode }) => {
24 | return {children}
25 | }
26 |
27 | export const SheetTitle = ({ children }: { children: React.ReactNode }) => {
28 | return {children}
29 | }
30 |
31 | export const SheetDescription = ({ children }: { children: React.ReactNode }) => {
32 | return {children}
33 | }
34 |
35 | export const SheetFooter = ({ children }: { children: React.ReactNode }) => {
36 | return {children}
37 | }
38 |
--------------------------------------------------------------------------------
/packages/ui/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/packages/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@seventy-seven/typescript-config/react-library.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "paths": {
6 | "@/*": ["./src/*"]
7 | }
8 | },
9 | "include": ["src"],
10 | "exclude": ["node_modules"]
11 | }
12 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'packages/*'
3 | - 'apps/*'
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@seventy-seven/typescript-config/base.json"
3 | }
4 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "pipeline": {
4 | "build": {
5 | "dependsOn": ["^build"],
6 | "outputs": [".next/**", "!.next/cache/**", "dist/**"]
7 | },
8 | "lint": {
9 | "dependsOn": ["^lint"]
10 | },
11 | "dev": {
12 | "cache": false,
13 | "persistent": true
14 | },
15 | "prisma:generate": {
16 | "cache": false
17 | },
18 | "prisma:push": {
19 | "cache": false
20 | },
21 | "check:types": {
22 | "persistent": true
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------