├── .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 | Seventy Seven logo 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 | Seventy Seven dashboard 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 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /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 |
    21 | 22 |
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 | {invite.team.name} 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 |
58 | 59 |
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 |
    18 | 19 |

    {name}

    20 |
    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 |
    16 | 17 | ( 21 | 22 | Team name 23 | 24 | 25 | 26 | 27 | 28 | 29 | )} 30 | /> 31 | 32 |
    33 | 36 |
    37 | 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 |
    31 |
    32 | 33 | 34 | 41 | 42 | {(filter.q ?? '').length > 0 && ( 43 | 58 | )} 59 |
    60 | 64 |
    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 |
    11 |
    12 | 13 | 14 | 15 | 16 | 17 |
    18 | 19 |
    20 | 21 | 22 | 23 | 24 |
    25 |
    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 |
    16 |
    17 |

    {ticket.subject}

    18 | 19 | {senderFullName} - {senderEmail} 20 | 21 |
    22 | 23 |
    24 | 25 | 26 |
    27 |
    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
    On 15 Jan 2025 at 23:07:53, Christian Alares from Seventy Seven <seventy-seven@seventy-seven.dev> wrote:
    \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 |
    25 | 33 |
    34 | 35 |
    36 | Example of dashboard 43 | Example of dashboard 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 |
    39 | 40 | 43 | 49 |
    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 | 77 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 | 15 | 77 16 | 17 | 21 | 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 | 21 | {children} 22 | 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 |