├── .node-version ├── Dockerfile ├── .eslintrc.json ├── public ├── cover.png ├── favicon.png ├── pattern.png ├── dashboard.png ├── dot-grid.png ├── furever_logo.png ├── testimonial.jpeg ├── dot-grid-dark.png ├── landing-page.jpeg ├── pattern-green.png ├── pattern-white.png ├── fonts │ ├── Sohne-Buch.otf │ ├── Sohne-Kraftig.otf │ └── Sohne-Halbfett.otf ├── pet_photos │ ├── apple.jpg │ ├── bisco.jpg │ ├── cedar.jpg │ ├── coco.jpg │ ├── dahlia.jpg │ ├── doudou.jpg │ ├── henley.jpg │ ├── hobbes.jpg │ ├── jake.jpg │ ├── jasper.jpg │ ├── juney.jpg │ ├── kiba.jpg │ ├── maple.jpg │ ├── miko.jpg │ ├── misiu.jpg │ ├── rambo.jpg │ ├── rowdy.jpg │ ├── shorty.jpg │ ├── udon.jpg │ ├── willow.jpg │ ├── bisco-1.jpg │ ├── stephen.jpg │ ├── waffles.jpg │ ├── default-1.jpg │ ├── default-2.jpg │ ├── default-3.jpg │ ├── hot-fudge.jpg │ ├── pebbleton.jpg │ └── feifei_celery.jpg ├── testimonial-portrait.jpg ├── onboarding-images │ ├── step-1.png │ ├── step-2.png │ ├── step-3.png │ └── pointinghand.png ├── brand-settings.svg ├── stripe.svg ├── stripe-gray.svg ├── pose_light.svg └── pose_red.svg ├── scripts ├── logo1.png ├── logo2.png └── logo3.png ├── requirements.txt ├── app ├── opengraph-image.png ├── contexts │ ├── settings │ │ ├── SettingsConsumer.ts │ │ ├── index.ts │ │ └── SettingsContext.tsx │ └── themes │ │ └── ThemeConstants.ts ├── components │ ├── testdata │ │ ├── Financing │ │ │ ├── types.ts │ │ │ ├── TransitionFinancingButton.tsx │ │ │ └── ManageFinancing.tsx │ │ ├── CreatePayoutsButton.tsx │ │ ├── CreateInterventionsButton.tsx │ │ └── CreateFinancialCreditButton.tsx │ ├── Container.tsx │ ├── AuthenticatedRoute.tsx │ ├── debug │ │ └── commands │ │ │ ├── CreateReceivedCredit.ts │ │ │ ├── CreateIssuingCardAuthorization.ts │ │ │ ├── ChangeLocale.tsx │ │ │ └── CreateCheckoutSession.ts │ ├── SubNav.tsx │ ├── Tools │ │ ├── ThemePicker.tsx │ │ ├── OverlaySelector.tsx │ │ └── LocaleSelector.tsx │ ├── AuthenticatedAndOnboardedRoute.tsx │ ├── CapitalFinancingPromotionSection.tsx │ ├── DataRequest.tsx │ ├── CustomersWidget.tsx │ ├── MonthToDateWidget.tsx │ ├── Screen.tsx │ └── EditPasswordButton.tsx ├── hooks │ ├── useSettings.ts │ ├── EmbeddedComponentProvider.tsx │ ├── EmbeddedComponentWrapper.tsx │ ├── useFinancialAccount.ts │ ├── useGetStripeAccount.tsx │ ├── ToolsPanelProvider.tsx │ ├── useExpressDashboardLoginLink.tsx │ ├── EmbeddedComponentBorderProvider.tsx │ └── useConnect.ts ├── api │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── get_stripe_account │ │ └── route.ts │ ├── capital │ │ ├── get_financing_offer │ │ │ └── route.ts │ │ ├── expire_test_financing │ │ │ └── route.ts │ │ ├── approve_test_financing │ │ │ └── route.ts │ │ ├── reject_test_financing │ │ │ └── route.ts │ │ ├── fully_repay_test_financing │ │ │ └── route.ts │ │ └── create_test_financing │ │ │ └── route.ts │ ├── list_charges │ │ └── route.ts │ ├── setup_accounts │ │ ├── create_financial_credit │ │ │ └── route.ts │ │ ├── create_disputes │ │ │ └── route.ts │ │ ├── create_risk_intervention │ │ │ └── route.ts │ │ ├── create_payouts │ │ │ └── route.ts │ │ ├── create_bank_account │ │ │ └── route.ts │ │ └── route.ts │ ├── financial_account │ │ └── route.ts │ ├── account_info │ │ └── route.ts │ ├── webhooks │ │ └── route.ts │ ├── password_update │ │ └── route.ts │ ├── login_link │ │ └── route.ts │ ├── debug │ │ ├── create_issuing_card_authorization │ │ │ └── route.ts │ │ ├── create_received_credit │ │ │ └── route.ts │ │ ├── get_demo_account │ │ │ └── route.ts │ │ └── create_checkout_session │ │ │ └── route.ts │ ├── primary_color │ │ └── route.ts │ ├── email_update │ │ └── route.ts │ ├── request_capabilities │ │ └── route.ts │ ├── company_name │ │ └── route.ts │ ├── company_logo │ │ └── route.ts │ └── payment_method_settings │ │ └── create_checkout_session │ │ └── route.ts ├── auth.tsx ├── (dashboard) │ ├── utils │ │ └── arePreviewComponentsEnabled.tsx │ ├── finances │ │ ├── cards │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── financing │ │ │ └── page.tsx │ ├── settings │ │ ├── documents │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── tax │ │ │ └── page.tsx │ ├── layout.tsx │ ├── payouts │ │ └── page.tsx │ ├── payments │ │ └── page.tsx │ ├── pets │ │ └── page.tsx │ └── home │ │ └── page.tsx ├── (auth) │ ├── onboarding │ │ ├── layout.tsx │ │ └── page.tsx │ ├── business │ │ └── page.tsx │ ├── login │ │ └── page.tsx │ ├── signup │ │ ├── page.tsx │ │ └── form.tsx │ └── layout.tsx ├── providers │ └── QueryProvider.tsx ├── layout.tsx ├── data │ ├── pets.json │ └── schedule.json ├── models │ └── salon.ts └── globals.css ├── postcss.config.js ├── .prettierrc ├── .cli.json ├── render.yaml ├── lib ├── stripe.ts ├── forms.ts └── dbConnect.ts ├── .vscode └── settings.json ├── next.config.mjs ├── types ├── environment.d.ts ├── express.d.ts ├── next-auth.d.ts ├── account.ts └── settings.ts ├── middleware.ts ├── components.json ├── components └── ui │ ├── collapsible.tsx │ ├── link.tsx │ ├── label.tsx │ ├── input.tsx │ ├── switch.tsx │ ├── badge.tsx │ ├── avatar.tsx │ ├── radiogroup.tsx │ ├── alert.tsx │ ├── button.tsx │ └── tabs.tsx ├── .gitignore ├── tsconfig.json ├── .github └── workflows │ └── test.yml ├── .env.example ├── LICENSE ├── package.json └── tailwind.config.ts /.node-version: -------------------------------------------------------------------------------- 1 | 22.16.0 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mongo:4.2 -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/cover.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/favicon.png -------------------------------------------------------------------------------- /public/pattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pattern.png -------------------------------------------------------------------------------- /scripts/logo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/scripts/logo1.png -------------------------------------------------------------------------------- /scripts/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/scripts/logo2.png -------------------------------------------------------------------------------- /scripts/logo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/scripts/logo3.png -------------------------------------------------------------------------------- /public/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/dashboard.png -------------------------------------------------------------------------------- /public/dot-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/dot-grid.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dotenv==1.0.1 2 | 3 | # pip install --pre stripe 4 | stripe==8.11.0b1 5 | 6 | black 7 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/app/opengraph-image.png -------------------------------------------------------------------------------- /public/furever_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/furever_logo.png -------------------------------------------------------------------------------- /public/testimonial.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/testimonial.jpeg -------------------------------------------------------------------------------- /public/dot-grid-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/dot-grid-dark.png -------------------------------------------------------------------------------- /public/landing-page.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/landing-page.jpeg -------------------------------------------------------------------------------- /public/pattern-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pattern-green.png -------------------------------------------------------------------------------- /public/pattern-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pattern-white.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/fonts/Sohne-Buch.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/fonts/Sohne-Buch.otf -------------------------------------------------------------------------------- /public/pet_photos/apple.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/apple.jpg -------------------------------------------------------------------------------- /public/pet_photos/bisco.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/bisco.jpg -------------------------------------------------------------------------------- /public/pet_photos/cedar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/cedar.jpg -------------------------------------------------------------------------------- /public/pet_photos/coco.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/coco.jpg -------------------------------------------------------------------------------- /public/pet_photos/dahlia.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/dahlia.jpg -------------------------------------------------------------------------------- /public/pet_photos/doudou.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/doudou.jpg -------------------------------------------------------------------------------- /public/pet_photos/henley.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/henley.jpg -------------------------------------------------------------------------------- /public/pet_photos/hobbes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/hobbes.jpg -------------------------------------------------------------------------------- /public/pet_photos/jake.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/jake.jpg -------------------------------------------------------------------------------- /public/pet_photos/jasper.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/jasper.jpg -------------------------------------------------------------------------------- /public/pet_photos/juney.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/juney.jpg -------------------------------------------------------------------------------- /public/pet_photos/kiba.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/kiba.jpg -------------------------------------------------------------------------------- /public/pet_photos/maple.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/maple.jpg -------------------------------------------------------------------------------- /public/pet_photos/miko.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/miko.jpg -------------------------------------------------------------------------------- /public/pet_photos/misiu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/misiu.jpg -------------------------------------------------------------------------------- /public/pet_photos/rambo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/rambo.jpg -------------------------------------------------------------------------------- /public/pet_photos/rowdy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/rowdy.jpg -------------------------------------------------------------------------------- /public/pet_photos/shorty.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/shorty.jpg -------------------------------------------------------------------------------- /public/pet_photos/udon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/udon.jpg -------------------------------------------------------------------------------- /public/pet_photos/willow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/willow.jpg -------------------------------------------------------------------------------- /public/fonts/Sohne-Kraftig.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/fonts/Sohne-Kraftig.otf -------------------------------------------------------------------------------- /public/pet_photos/bisco-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/bisco-1.jpg -------------------------------------------------------------------------------- /public/pet_photos/stephen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/stephen.jpg -------------------------------------------------------------------------------- /public/pet_photos/waffles.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/waffles.jpg -------------------------------------------------------------------------------- /public/fonts/Sohne-Halbfett.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/fonts/Sohne-Halbfett.otf -------------------------------------------------------------------------------- /public/pet_photos/default-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/default-1.jpg -------------------------------------------------------------------------------- /public/pet_photos/default-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/default-2.jpg -------------------------------------------------------------------------------- /public/pet_photos/default-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/default-3.jpg -------------------------------------------------------------------------------- /public/pet_photos/hot-fudge.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/hot-fudge.jpg -------------------------------------------------------------------------------- /public/pet_photos/pebbleton.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/pebbleton.jpg -------------------------------------------------------------------------------- /public/testimonial-portrait.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/testimonial-portrait.jpg -------------------------------------------------------------------------------- /public/onboarding-images/step-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/onboarding-images/step-1.png -------------------------------------------------------------------------------- /public/onboarding-images/step-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/onboarding-images/step-2.png -------------------------------------------------------------------------------- /public/onboarding-images/step-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/onboarding-images/step-3.png -------------------------------------------------------------------------------- /public/pet_photos/feifei_celery.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/pet_photos/feifei_celery.jpg -------------------------------------------------------------------------------- /public/onboarding-images/pointinghand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-connect-furever-demo/master/public/onboarding-images/pointinghand.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "bracketSpacing": false, 5 | "plugins": ["prettier-plugin-tailwindcss"] 6 | } 7 | -------------------------------------------------------------------------------- /app/contexts/settings/SettingsConsumer.ts: -------------------------------------------------------------------------------- 1 | import {SettingsContext} from './SettingsContext'; 2 | 3 | export const SettingsConsumer = SettingsContext.Consumer; 4 | -------------------------------------------------------------------------------- /.cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stripe-connect-furever-demo", 3 | "configureDotEnv": true, 4 | "integrations": [ 5 | { 6 | "name": "main" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /app/components/testdata/Financing/types.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | 3 | export type OfferState = 4 | | Stripe.Capital.FinancingOfferListParams.Status 5 | | 'no_offer'; 6 | -------------------------------------------------------------------------------- /app/hooks/useSettings.ts: -------------------------------------------------------------------------------- 1 | import {useContext} from 'react'; 2 | import {SettingsContext} from '@/app/contexts/settings'; 3 | 4 | export const useSettings = () => useContext(SettingsContext); 5 | -------------------------------------------------------------------------------- /app/contexts/settings/index.ts: -------------------------------------------------------------------------------- 1 | export {SettingsContext} from './SettingsContext'; 2 | export {SettingsConsumer} from './SettingsConsumer'; 3 | export {SettingsProvider} from './SettingsProvider'; 4 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: pserv 3 | name: mongodb 4 | env: docker 5 | autoDeploy: true 6 | disk: 7 | name: data 8 | mountPath: /data/db 9 | sizeGB: 10 10 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import {authOptions} from '@/lib/auth'; 3 | 4 | const handler = NextAuth(authOptions); 5 | 6 | export {handler as GET, handler as POST}; 7 | -------------------------------------------------------------------------------- /lib/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | 3 | export const latestApiVersion: Stripe.LatestApiVersion = '2025-12-15.preview'; 4 | 5 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { 6 | apiVersion: latestApiVersion, 7 | }); 8 | -------------------------------------------------------------------------------- /app/auth.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {SessionProvider} from 'next-auth/react'; 4 | import {ReactNode} from 'react'; 5 | 6 | export default function NextAuthProvider({children}: {children: ReactNode}) { 7 | return {children}; 8 | } 9 | -------------------------------------------------------------------------------- /app/(dashboard)/utils/arePreviewComponentsEnabled.tsx: -------------------------------------------------------------------------------- 1 | const arePreviewComponentsEnabled = 2 | process.env.NEXT_PUBLIC_ENABLE_PREVIEW_COMPONENTS === '1' || 3 | process.env.NEXT_PUBLIC_ENABLE_PREVIEW_COMPONENTS?.toLowerCase() === 'true'; 4 | 5 | export {arePreviewComponentsEnabled}; 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Run Prettier on save 3 | "editor.formatOnSave": true, 4 | // Use Prettier as the default formatter 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "[python]": { 7 | "editor.defaultFormatter": "ms-python.black-formatter" 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: 'https', 8 | hostname: 'files.stripe.com', 9 | }, 10 | ], 11 | }, 12 | }; 13 | 14 | export default nextConfig; 15 | -------------------------------------------------------------------------------- /lib/forms.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | 3 | export const UserFormSchema = z.object({ 4 | email: z.string().email({message: 'Invalid email address'}), 5 | password: z 6 | .string({ 7 | required_error: 'Password is required', 8 | }) 9 | .min(8, {message: 'Password must be 8 or more characters'}), 10 | }); 11 | -------------------------------------------------------------------------------- /types/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | STRIPE_SECRET_KEY: string; 5 | STRIPE_PUBLISHABLE_KEY: string; 6 | APP_NAME: string; 7 | PORT: number; 8 | SECRET: string; 9 | MONGO_URI: string; 10 | } 11 | } 12 | } 13 | 14 | export {}; 15 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | export {default} from 'next-auth/middleware'; 2 | 3 | export const config = { 4 | // specify the route you want to protect 5 | matcher: [ 6 | '/classes', 7 | '/payments', 8 | '/payouts', 9 | '/finances', 10 | '/finances/cards', 11 | '/settings', 12 | '/settings/paymentmethods', 13 | '/finance', 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /app/components/Container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type ContainerProps = { 4 | children: React.ReactNode; 5 | className?: string; 6 | }; 7 | 8 | const Container = ({children, className}: ContainerProps) => { 9 | return ( 10 |
11 | {children} 12 |
13 | ); 14 | }; 15 | 16 | export default Container; 17 | -------------------------------------------------------------------------------- /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": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /app/components/AuthenticatedRoute.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {getServerSession} from 'next-auth'; 3 | import {redirect} from 'next/navigation'; 4 | 5 | export default async function AuthenticatedRoute({ 6 | children, 7 | }: Readonly<{ 8 | children: React.ReactNode; 9 | }>) { 10 | const session = await getServerSession(); 11 | 12 | if (!session || !session.user) { 13 | return null; 14 | } 15 | 16 | return <>{children}; 17 | } 18 | -------------------------------------------------------------------------------- /app/(auth)/onboarding/layout.tsx: -------------------------------------------------------------------------------- 1 | import AuthenticatedRoute from '@/app/components/AuthenticatedRoute'; 2 | import {EmbeddedComponentWrapper} from '@/app/hooks/EmbeddedComponentWrapper'; 3 | 4 | export default function DashboardLayout({ 5 | children, 6 | }: Readonly<{ 7 | children: React.ReactNode; 8 | }>) { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {ChevronUp, ChevronDown} from 'lucide-react'; 3 | import {cn} from '@/lib/utils'; 4 | 5 | export interface CollapsibleProps { 6 | open: boolean; 7 | children: React.ReactNode; 8 | } 9 | 10 | const Collapsible = React.forwardRef( 11 | ({open, children, ...props}, ref) => { 12 | return <>{open && children}; 13 | } 14 | ); 15 | Collapsible.displayName = 'Collapsible'; 16 | 17 | export {Collapsible}; 18 | -------------------------------------------------------------------------------- /components/ui/link.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import {cn} from '@/lib/utils'; 4 | 5 | export interface InputProps 6 | extends React.LinkHTMLAttributes {} 7 | 8 | const Link = React.forwardRef( 9 | ({className, type, ...props}, ref) => { 10 | return ( 11 | 17 | ); 18 | } 19 | ); 20 | Link.displayName = 'Link'; 21 | 22 | export {Link}; 23 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | .python-version 23 | /.vscode 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env*.local 32 | .env 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | -------------------------------------------------------------------------------- /app/(dashboard)/finances/cards/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {ConnectIssuingCardsList} from '@stripe/react-connect-js'; 4 | import Container from '@/app/components/Container'; 5 | import EmbeddedComponentContainer from '@/app/components/EmbeddedComponentContainer'; 6 | 7 | export default function Finances() { 8 | return ( 9 | 10 |

Cards

11 | 12 | 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/components/debug/commands/CreateReceivedCredit.ts: -------------------------------------------------------------------------------- 1 | import {BadgeDollarSign} from 'lucide-react'; 2 | 3 | const createReceivedCredit = async () => { 4 | try { 5 | await fetch('/api/debug/create_received_credit', { 6 | method: 'POST', 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | }, 10 | }); 11 | 12 | return null; 13 | } catch (error: any) { 14 | return error; 15 | } 16 | }; 17 | 18 | const config = { 19 | name: 'Create ReceivedCredit', 20 | icon: BadgeDollarSign, 21 | description: 'Create a received credit for a Financial Account.', 22 | action: createReceivedCredit, 23 | }; 24 | 25 | export default config; 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /app/(auth)/business/page.tsx: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth'; 2 | import {redirect} from 'next/navigation'; 3 | import BusinessDetailsForm from './form'; 4 | 5 | export default async function Business() { 6 | const session = await getServerSession(); 7 | if (!session || !session.user?.email) { 8 | return redirect('/login'); 9 | } 10 | 11 | return ( 12 | <> 13 |
14 |

Business details

15 |
16 |

17 | Let us know a few more details of your business 18 |

19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | branches: [master] 7 | 8 | env: 9 | STRIPE_SECRET_KEY: sk_secret_key 10 | STRIPE_PUBLIC_KEY: pk_pub_key 11 | NEXT_PUBLIC_STRIPE_PUBLIC_KEY: pk_pub_key 12 | APP_NAME: Furever 13 | PORT: 3000 14 | SECRET: S3cr3t 15 | MONGO_URI: 'mongodb://localhost/furever' 16 | 17 | jobs: 18 | test: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions/setup-node@v1 23 | with: 24 | node-version: 22.x 25 | - run: yarn install --frozen-lockfile 26 | - run: yarn run validate-change 27 | - run: yarn run build 28 | -------------------------------------------------------------------------------- /app/components/debug/commands/CreateIssuingCardAuthorization.ts: -------------------------------------------------------------------------------- 1 | import {CreditCard} from 'lucide-react'; 2 | 3 | const createIssuingCardAuthorization = async () => { 4 | try { 5 | await fetch('/api/debug/create_issuing_card_authorization', { 6 | method: 'POST', 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | }, 10 | }); 11 | 12 | return null; 13 | } catch (error: any) { 14 | return error; 15 | } 16 | }; 17 | 18 | const config = { 19 | name: 'Create Issuing Card Authorization', 20 | icon: CreditCard, 21 | description: 'Create an issuing card authorization.', 22 | action: createIssuingCardAuthorization, 23 | }; 24 | 25 | export default config; 26 | -------------------------------------------------------------------------------- /app/components/debug/commands/ChangeLocale.tsx: -------------------------------------------------------------------------------- 1 | import {Globe} from 'lucide-react'; 2 | import Container from '@/app/components/Container'; 3 | import LocaleSelector from '@/app/components/Tools/LocaleSelector'; 4 | 5 | const changeLocale = async () => { 6 | return [null, RenderActionMenu]; 7 | }; 8 | 9 | const RenderActionMenu = (exit: () => void, settings: any) => { 10 | return ( 11 | 12 |

Set locale

13 | 14 |
15 | ); 16 | }; 17 | 18 | const config = { 19 | name: 'Change locale', 20 | icon: Globe, 21 | description: 'Changes the browser locale.', 22 | action: changeLocale, 23 | }; 24 | 25 | export default config; 26 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Get your Stripe keys from https://stripe.com/docs/keys 2 | STRIPE_SECRET_KEY="sk_INSERT_YOUR_SECRET_KEY" 3 | STRIPE_PUBLIC_KEY="pk_INSERT_YOUR_PUBLISHABLE_KEY" 4 | STRIPE_WEBHOOK_SECRET="whsec_INSERT_YOUR_WEBHOOK_SECRET" 5 | 6 | NEXT_PUBLIC_STRIPE_PUBLIC_KEY=$STRIPE_PUBLIC_KEY 7 | 8 | NEXTAUTH_URL="http://localhost:3000" 9 | NEXTAUTH_SECRET="YOUR SECRET" 10 | 11 | PORT=3000 12 | 13 | # Used for hashing cookies and sessions 14 | SECRET=$NEXTAUTH_SECRET 15 | 16 | # Database URI. See README.md for instructions on setting up a MongoDB database 17 | MONGO_URI="mongodb://127.0.0.1:27017/pose" 18 | 19 | # Enable preview components (set to 1 to enable). Note you need to request access to preview components to use them 20 | # NEXT_PUBLIC_ENABLE_PREVIEW_COMPONENTS=1 -------------------------------------------------------------------------------- /app/(dashboard)/settings/documents/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Container from '@/app/components/Container'; 4 | import EmbeddedComponentContainer from '@/app/components/EmbeddedComponentContainer'; 5 | import {ConnectDocuments} from '@stripe/react-connect-js'; 6 | 7 | export default function Documents() { 8 | return ( 9 | 10 |
11 |

Documents

12 |

13 | Access documents and account statements. 14 |

15 |
16 | 17 | 18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/components/debug/commands/CreateCheckoutSession.ts: -------------------------------------------------------------------------------- 1 | import {Link} from 'lucide-react'; 2 | 3 | const createPaymentLink = async () => { 4 | try { 5 | const response = await fetch('/api/debug/create_checkout_session', { 6 | method: 'POST', 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | }, 10 | }); 11 | 12 | if (response.ok) { 13 | const data = await response.json(); 14 | window.location = data.checkout_session; 15 | } 16 | 17 | return null; 18 | } catch (error: any) { 19 | return error; 20 | } 21 | }; 22 | 23 | const config = { 24 | name: 'Create Checkout Session', 25 | icon: Link, 26 | description: 'Create a Checkout Session.', 27 | action: createPaymentLink, 28 | }; 29 | 30 | export default config; 31 | -------------------------------------------------------------------------------- /app/contexts/settings/SettingsContext.tsx: -------------------------------------------------------------------------------- 1 | import {createContext} from 'react'; 2 | 3 | import type {Settings} from '@/types/settings'; 4 | import {OverlayOption} from '@stripe/connect-js'; 5 | 6 | export const defaultSettings: Settings = { 7 | locale: 'en-US', 8 | theme: 'light', 9 | overlay: 'dialog', 10 | }; 11 | 12 | export interface SettingsContextType { 13 | locale?: string; 14 | theme?: string; 15 | overlay?: OverlayOption; 16 | primaryColor?: string; 17 | companyName?: string; 18 | companyLogoUrl?: string; 19 | handleUpdate: (settings: Settings) => void; 20 | enableBetaComponents?: boolean; 21 | } 22 | 23 | export const SettingsContext = createContext({ 24 | ...defaultSettings, 25 | handleUpdate: (_settings: Settings) => {}, 26 | }); 27 | -------------------------------------------------------------------------------- /app/hooks/EmbeddedComponentProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, useContext} from 'react'; 2 | import {StripeConnectInstance} from '@stripe/connect-js'; 3 | 4 | type IConnectJSContext = { 5 | connectInstance?: StripeConnectInstance; 6 | }; 7 | 8 | const ConnectJSContext = createContext({}); 9 | 10 | export const useConnectJSContext = () => { 11 | const context = useContext(ConnectJSContext); 12 | return context; 13 | }; 14 | 15 | export const EmbeddedComponentProvider = ({ 16 | children, 17 | connectInstance, 18 | }: { 19 | children: React.ReactNode; 20 | connectInstance?: StripeConnectInstance; 21 | }) => { 22 | return ( 23 | 24 | {children} 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /app/(dashboard)/finances/layout.tsx: -------------------------------------------------------------------------------- 1 | import SubNav from '@/app/components/SubNav'; 2 | 3 | export default function FinancesLayout({ 4 | children, 5 | }: Readonly<{ 6 | children: React.ReactNode; 7 | }>) { 8 | return ( 9 | <> 10 |
11 |

Finances

12 |
13 | 21 |
22 |
23 | {children} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/hooks/EmbeddedComponentWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {ConnectComponentsProvider} from '@stripe/react-connect-js'; 4 | import {EmbeddedComponentProvider} from '@/app/hooks/EmbeddedComponentProvider'; 5 | import {useConnect} from '@/app/hooks/useConnect'; 6 | 7 | export const EmbeddedComponentWrapper = ({ 8 | children, 9 | }: { 10 | children: React.ReactNode; 11 | }) => { 12 | const {hasError, stripeConnectInstance} = useConnect(); 13 | if (hasError || !stripeConnectInstance) { 14 | return null; 15 | } 16 | 17 | return ( 18 | 19 | 20 | {children} 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import {getServerSession} from 'next-auth'; 3 | import {redirect} from 'next/navigation'; 4 | import Form from './form'; 5 | 6 | export default async function Login() { 7 | const session = await getServerSession(); 8 | 9 | if (session) { 10 | redirect('/home'); 11 | } 12 | 13 | return ( 14 | <> 15 |
16 |

Log in

17 |
18 |
19 |
20 |
21 | New user?{' '} 22 | 23 | Create an account{' '} 24 | 25 |
26 |
27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/hooks/useFinancialAccount.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | 3 | export const useFinancialAccount = () => { 4 | const [financialAccount, setFinancialAccount] = useState(null); 5 | const [loading, setLoading] = useState(true); 6 | const [error, setError] = useState(null); 7 | 8 | useEffect(() => { 9 | const fetchData = async () => { 10 | setLoading(true); 11 | try { 12 | const response = await fetch('/api/financial_account'); 13 | const json = await response.json(); 14 | setFinancialAccount(json.financial_account); 15 | setLoading(false); 16 | } catch (error: any) { 17 | setError(error); 18 | setLoading(false); 19 | } 20 | }; 21 | fetchData(); 22 | }, []); 23 | 24 | return {loading, financialAccount, error}; 25 | }; 26 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as LabelPrimitive from '@radix-ui/react-label'; 5 | import {cva, type VariantProps} from 'class-variance-authority'; 6 | 7 | import {cn} from '@/lib/utils'; 8 | 9 | const labelVariants = cva( 10 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({className, ...props}, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export {Label}; 27 | -------------------------------------------------------------------------------- /app/providers/QueryProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; 4 | import {ReactQueryDevtools} from '@tanstack/react-query-devtools'; 5 | import {useState} from 'react'; 6 | 7 | export default function QueryProvider({children}: {children: React.ReactNode}) { 8 | const [queryClient] = useState( 9 | () => 10 | new QueryClient({ 11 | defaultOptions: { 12 | queries: { 13 | staleTime: 60 * 1000, // 1 minute 14 | gcTime: 10 * 60 * 1000, // 10 minutes 15 | retry: 2, 16 | refetchOnWindowFocus: false, 17 | }, 18 | }, 19 | }) 20 | ); 21 | 22 | return ( 23 | 24 | {children} 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import {cn} from '@/lib/utils'; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({className, type, ...props}, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = 'Input'; 24 | 25 | export {Input}; 26 | -------------------------------------------------------------------------------- /app/(dashboard)/finances/financing/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, {useState, useEffect} from 'react'; 4 | import {ConnectCapitalFinancing} from '@stripe/react-connect-js'; 5 | import Container from '@/app/components/Container'; 6 | import EmbeddedComponentContainer from '@/app/components/EmbeddedComponentContainer'; 7 | import {CapitalFinancingPromotionSection} from '@/app/components/CapitalFinancingPromotionSection'; 8 | 9 | function CapitalFinancingSection() { 10 | return ( 11 | 12 | 13 | {}} /> 14 | 15 | 16 | ); 17 | } 18 | 19 | export default function FinancingPage() { 20 | return ( 21 | <> 22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/hooks/useGetStripeAccount.tsx: -------------------------------------------------------------------------------- 1 | import {useQuery} from '@tanstack/react-query'; 2 | import {useSession} from 'next-auth/react'; 3 | import Stripe from 'stripe'; 4 | 5 | const fetchStripeAccount = async (): Promise => { 6 | const response = await fetch('/api/get_stripe_account'); 7 | if (!response.ok) { 8 | throw new Error(`HTTP error! status: ${response.status}`); 9 | } 10 | return response.json(); 11 | }; 12 | 13 | export const useGetStripeAccount = () => { 14 | const {data: session, status} = useSession(); 15 | 16 | const { 17 | data: stripeAccount, 18 | isLoading: loading, 19 | error, 20 | } = useQuery({ 21 | queryKey: ['stripeAccount', session?.user.stripeAccountId], 22 | queryFn: fetchStripeAccount, 23 | enabled: status === 'authenticated', 24 | }); 25 | 26 | return { 27 | stripeAccount: stripeAccount || null, 28 | loading, 29 | error: error?.message || null, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /app/components/SubNav.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import {usePathname} from 'next/navigation'; 5 | 6 | type SubNavProps = { 7 | base: string; 8 | routes: SubNavItem[]; 9 | }; 10 | 11 | type SubNavItem = { 12 | path: string; 13 | label: string; 14 | }; 15 | 16 | export default function SubNav({base, routes}: SubNavProps) { 17 | const pathname = usePathname(); 18 | 19 | return ( 20 |
21 | {routes.map(({path, label}, index) => { 22 | return ( 23 | 32 | {label} 33 | 34 | ); 35 | })} 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /public/brand-settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/api/get_stripe_account/route.ts: -------------------------------------------------------------------------------- 1 | import {type NextRequest} from 'next/server'; 2 | import {getServerSession} from 'next-auth/next'; 3 | import {authOptions} from '@/lib/auth'; 4 | import {stripe} from '@/lib/stripe'; 5 | 6 | export async function GET(req: NextRequest) { 7 | try { 8 | const session = await getServerSession(authOptions); 9 | 10 | if (!session?.user?.stripeAccountId) { 11 | return new Response('Unauthorized or no Stripe account found', { 12 | status: 401, 13 | }); 14 | } 15 | 16 | const stripeAccountId = session.user.stripeAccountId; 17 | 18 | // Retrieve the Stripe account 19 | const account = await stripe.accounts.retrieve(stripeAccountId); 20 | 21 | return new Response(JSON.stringify(account), { 22 | status: 200, 23 | headers: { 24 | 'Content-Type': 'application/json', 25 | }, 26 | }); 27 | } catch (error) { 28 | console.error('Error retrieving Stripe account:', error); 29 | return new Response('Failed to retrieve Stripe account', { 30 | status: 500, 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/api/capital/get_financing_offer/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | 5 | export async function GET() { 6 | try { 7 | const session = await getServerSession(authOptions); 8 | 9 | if (!session) { 10 | return new Response('The current route requires authentication', { 11 | status: 403, 12 | }); 13 | } 14 | 15 | const connected_account = session.user.stripeAccountId; 16 | 17 | const offer = ( 18 | await stripe.capital.financingOffers.list({ 19 | connected_account: connected_account, 20 | limit: 1, 21 | }) 22 | ).data.at(0); 23 | 24 | return new Response( 25 | JSON.stringify({ 26 | offer, 27 | }), 28 | {status: 200, headers: {'Content-Type': 'application/json'}} 29 | ); 30 | } catch (error: any) { 31 | console.error( 32 | 'An error occurred when calling the Stripe API to list financing offers', 33 | error 34 | ); 35 | return new Response(error.message, {status: 500}); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/api/list_charges/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | import {NextRequest} from 'next/server'; 5 | 6 | export async function GET(req: NextRequest) { 7 | const json = await req.json(); 8 | let {count} = json; 9 | if (!count) { 10 | count = 3; 11 | } 12 | 13 | try { 14 | const session = await getServerSession(authOptions); 15 | 16 | const charges = await stripe.charges.list( 17 | { 18 | limit: count, 19 | }, 20 | { 21 | stripeAccount: session?.user.stripeAccountId, 22 | } 23 | ); 24 | return new Response( 25 | JSON.stringify({ 26 | charge_count: charges.data.length, 27 | charges: charges.data, 28 | }), 29 | {status: 200, headers: {'Content-Type': 'application/json'}} 30 | ); 31 | } catch (error: any) { 32 | console.error( 33 | 'An error occurred when calling the Stripe API to create an account session', 34 | error 35 | ); 36 | return new Response(error.message, {status: 500}); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /types/express.d.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | declare global { 4 | namespace Express { 5 | export interface User { 6 | salon: { 7 | license: string; 8 | name: string; 9 | specialty: string; 10 | }; 11 | type: 'individual' | 'company'; 12 | country: string; 13 | id: string; 14 | email: string; 15 | password: string; 16 | created: Date; 17 | firstName: string; 18 | lastName: string; 19 | stripeAccountId: string; 20 | businessName: string; 21 | setup: boolean; 22 | quickstartAccount: boolean; 23 | changedPassword: boolean; 24 | primaryColor: string; 25 | companyName: string; 26 | companyLogoUrl: string; 27 | 28 | // MongoDB methods 29 | isModified: (field: string) => boolean; 30 | isNew: boolean; 31 | generateHash: (password: string) => string; 32 | save: () => Promise; 33 | set: (body: Record) => void; 34 | } 35 | export interface Request { 36 | body: Record; 37 | user?: User; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Stripe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/components/Tools/ThemePicker.tsx: -------------------------------------------------------------------------------- 1 | import {useContext, useCallback} from 'react'; 2 | import {SettingsContext} from '@/app/contexts/settings'; 3 | import {RadioGroup, RadioGroupItem} from '@/components/ui/radiogroup'; 4 | 5 | const ThemePicker = () => { 6 | const settings = useContext(SettingsContext); 7 | 8 | const setTheme = useCallback( 9 | (value: string) => { 10 | settings.handleUpdate({theme: value}); 11 | const root = document.querySelector(':root'); 12 | root && root.classList.remove('light', 'dark'); 13 | root && root.classList.add(value); 14 | console.log('Theme changed to:', value); 15 | }, 16 | [settings] 17 | ); 18 | 19 | return ( 20 | 21 |
22 | 27 | 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default ThemePicker; 34 | -------------------------------------------------------------------------------- /app/api/setup_accounts/create_financial_credit/route.ts: -------------------------------------------------------------------------------- 1 | import {authOptions} from '@/lib/auth'; 2 | import {stripe} from '@/lib/stripe'; 3 | import {getServerSession} from 'next-auth'; 4 | import {NextRequest} from 'next/server'; 5 | 6 | export async function POST(req: NextRequest) { 7 | const json = await req.json(); 8 | const {financialAccount} = json; 9 | try { 10 | const session = await getServerSession(authOptions); 11 | const accountId = session?.user.stripeAccountId; 12 | 13 | await stripe.testHelpers.treasury.receivedCredits.create( 14 | { 15 | amount: 1000, 16 | currency: 'usd', 17 | financial_account: financialAccount, 18 | network: 'ach', 19 | }, 20 | { 21 | stripeAccount: accountId, 22 | } 23 | ); 24 | 25 | return new Response('Success', { 26 | status: 200, 27 | headers: {'Content-Type': 'application/json'}, 28 | }); 29 | } catch (error: any) { 30 | console.error( 31 | 'An error occurred when calling the Stripe API to create payouts', 32 | error 33 | ); 34 | return new Response(error.message, {status: 500}); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import AuthenticatedAndOnboardedRoute from '@/app/components/AuthenticatedAndOnboardedRoute'; 3 | 4 | import Nav from '@/app/components/Nav'; 5 | import Container from '@/app/components/Container'; 6 | import { 7 | useToolsContext, 8 | ToolsPanelProvider, 9 | } from '@/app/hooks/ToolsPanelProvider'; 10 | import {EmbeddedComponentWrapper} from '@/app/hooks/EmbeddedComponentWrapper'; 11 | import OnboardingDialog from '../components/OnboardingDialog'; 12 | import {DataRequest} from '../components/DataRequest'; 13 | import Screen from '../components/Screen'; 14 | import * as React from 'react'; 15 | import {useSettings} from '../hooks/useSettings'; 16 | 17 | export default function DashboardLayout({ 18 | children, 19 | }: Readonly<{ 20 | children: React.ReactNode; 21 | }>) { 22 | return ( 23 | 24 | 25 | 26 | 27 | {children} 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import NextAuth, {DefaultSession} from 'next-auth'; 2 | import Stripe from 'stripe'; 3 | 4 | declare module 'next-auth' { 5 | /** 6 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context 7 | */ 8 | interface Session { 9 | user: { 10 | /** The user's Stripe account. */ 11 | stripeAccountId: string; 12 | businessName?: string | null; 13 | password?: string | null; 14 | setup?: boolean | null; 15 | changedPassword: boolean; 16 | 17 | // Custom branding options 18 | primaryColor?: string | null; 19 | companyName?: string | null; 20 | companyLogoUrl?: string | null; 21 | } & DefaultSession['user']; 22 | } 23 | } 24 | 25 | declare module 'next-auth/jwt' { 26 | interface JWT extends DefaultJWT { 27 | user: { 28 | id: string; 29 | name?: string | null; 30 | email?: string | null; 31 | image?: string | null; 32 | setup?: boolean | null; 33 | stripeAccountId: string; 34 | primaryColor?: string | null; 35 | companyName?: string | null; 36 | companyLogoUrl?: string | null; 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/dbConnect.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | declare global { 3 | var mongoose: any; // This must be a `var` and not a `let / const` 4 | } 5 | 6 | const MONGO_URI = process.env.MONGO_URI!; 7 | 8 | if (!MONGO_URI) { 9 | throw new Error( 10 | 'Please define the MONGO_URI environment variable inside .env.local' 11 | ); 12 | } 13 | 14 | let cached = global.mongoose; 15 | 16 | if (!cached) { 17 | cached = global.mongoose = {conn: null, promise: null}; 18 | } 19 | 20 | async function dbConnect() { 21 | if (cached.conn) { 22 | return cached.conn; 23 | } 24 | if (!cached.promise) { 25 | console.log('Setting up database'); 26 | const opts = { 27 | bufferCommands: false, 28 | }; 29 | cached.promise = mongoose.connect(MONGO_URI, opts).then((mongoose) => { 30 | console.log('Got the db!'); 31 | return mongoose; 32 | }); 33 | } 34 | try { 35 | cached.conn = await cached.promise; 36 | } catch (e) { 37 | console.error('Could not connect to the database:', e); 38 | cached.promise = Promise.reject(e); 39 | cached.conn = null; 40 | throw e; 41 | } 42 | 43 | return cached.conn; 44 | } 45 | 46 | export default dbConnect; 47 | -------------------------------------------------------------------------------- /app/api/financial_account/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | 5 | export async function GET() { 6 | try { 7 | const session = await getServerSession(authOptions); 8 | 9 | const financialAccounts = await stripe.treasury.financialAccounts.list( 10 | { 11 | limit: 3, 12 | }, 13 | { 14 | stripeAccount: session?.user.stripeAccountId, 15 | } 16 | ); 17 | 18 | if (financialAccounts.data.length === 0) { 19 | console.error('No financial accounts found for user'); 20 | return new Response('No financial accounts found for user', { 21 | status: 400, 22 | }); 23 | } 24 | 25 | return new Response( 26 | JSON.stringify({ 27 | financial_account: financialAccounts.data[0].id, 28 | }), 29 | {status: 200, headers: {'Content-Type': 'application/json'}} 30 | ); 31 | } catch (error: any) { 32 | console.error( 33 | 'An error occurred when calling the Stripe API to create an account session', 34 | error 35 | ); 36 | return new Response(error.message, {status: 500}); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/(auth)/onboarding/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {ConnectAccountOnboarding} from '@stripe/react-connect-js'; 4 | import EmbeddedComponentContainer from '@/app/components/EmbeddedComponentContainer'; 5 | import React from 'react'; 6 | import {LoaderCircle} from 'lucide-react'; 7 | 8 | export default function Onboarding() { 9 | const [hasCompletedOnboarding, setHasCompletedOnboarding] = 10 | React.useState(false); 11 | 12 | if (hasCompletedOnboarding) { 13 | // If we render this, we are in the process of redirecting 14 | return ( 15 |
16 | 17 |
18 | ); 19 | } 20 | 21 | return ( 22 | 23 | { 25 | // Since redirecting takes some time, we set the state first to hide the onboarding component to avoid showing the default "success" screen 26 | setHasCompletedOnboarding(true); 27 | 28 | window.location.href = '/home?shownux=true'; 29 | }} 30 | /> 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/components/testdata/CreatePayoutsButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from '@/components/ui/button'; 2 | import {LoaderCircle, Plus} from 'lucide-react'; 3 | import React from 'react'; 4 | 5 | export default function CreatePayoutsButton({classes}: {classes?: string}) { 6 | const [buttonLoading, setButtonLoading] = React.useState(false); 7 | const onClick = async () => { 8 | setButtonLoading(true); 9 | try { 10 | const res = await fetch('/api/setup_accounts/create_payouts', { 11 | method: 'POST', 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | }, 15 | }); 16 | 17 | if (res.ok) { 18 | setButtonLoading(false); 19 | window.location.reload(); 20 | } 21 | } catch (e) { 22 | console.log('Error with creating test payout: ', e); 23 | } 24 | }; 25 | return ( 26 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as SwitchPrimitives from '@radix-ui/react-switch'; 5 | 6 | import {cn} from '@/lib/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 | -------------------------------------------------------------------------------- /app/components/testdata/CreateInterventionsButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from '@/components/ui/button'; 2 | import {LoaderCircle, Plus} from 'lucide-react'; 3 | import React from 'react'; 4 | 5 | export default function CreateInterventionsButton({ 6 | classes, 7 | }: { 8 | classes?: string; 9 | }) { 10 | const [buttonLoading, setButtonLoading] = React.useState(false); 11 | const onClick = async () => { 12 | setButtonLoading(true); 13 | try { 14 | const res = await fetch('/api/setup_accounts/create_risk_intervention', { 15 | method: 'POST', 16 | headers: { 17 | 'Content-Type': 'application/json', 18 | }, 19 | }); 20 | 21 | if (res.ok) { 22 | setButtonLoading(false); 23 | window.location.reload(); 24 | } 25 | } catch (e) { 26 | console.log('Error with creating test intervention: ', e); 27 | } 28 | }; 29 | return ( 30 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/components/AuthenticatedAndOnboardedRoute.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {useSession} from 'next-auth/react'; 4 | import {useRouter} from 'next/navigation'; 5 | import {useEffect} from 'react'; 6 | import {LoaderCircle} from 'lucide-react'; 7 | import {useGetStripeAccount} from '../hooks/useGetStripeAccount'; 8 | 9 | const LoadingView = () => { 10 | return ( 11 |
12 | 13 |
14 | ); 15 | }; 16 | 17 | const RedirectToOnboarding = () => { 18 | const router = useRouter(); 19 | 20 | useEffect(() => { 21 | router.push('/onboarding'); 22 | }, [router]); 23 | 24 | return ; 25 | }; 26 | 27 | export default function AuthenticatedAndOnboardedRoute({ 28 | children, 29 | }: Readonly<{ 30 | children: React.ReactNode; 31 | }>) { 32 | const {data: session, status} = useSession(); 33 | const {stripeAccount, loading} = useGetStripeAccount(); 34 | 35 | const isLoading = !session || !session.user || loading; 36 | const shouldRedirect = !stripeAccount?.details_submitted; 37 | 38 | if (isLoading) { 39 | return ; 40 | } else if (shouldRedirect) { 41 | return ; 42 | } 43 | 44 | return <>{children}; 45 | } 46 | -------------------------------------------------------------------------------- /app/api/setup_accounts/create_disputes/route.ts: -------------------------------------------------------------------------------- 1 | import Salon from '@/app/models/salon'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | import {getServerSession} from 'next-auth'; 5 | 6 | function getRandomInt(min: number, max: number) { 7 | return Math.floor(Math.random() * (max - min + 1)) + min; 8 | } 9 | 10 | export async function POST() { 11 | try { 12 | const session = await getServerSession(authOptions); 13 | const accountId = session?.user.stripeAccountId; 14 | await stripe.paymentIntents.create( 15 | { 16 | amount: getRandomInt(5000, 20000), 17 | currency: 'usd', 18 | automatic_payment_methods: { 19 | enabled: true, 20 | allow_redirects: 'never', 21 | }, 22 | confirm: true, 23 | payment_method: 'pm_card_createDispute', 24 | }, 25 | { 26 | stripeAccount: accountId, 27 | } 28 | ); 29 | return new Response('Success', { 30 | status: 200, 31 | headers: {'Content-Type': 'application/json'}, 32 | }); 33 | } catch (error: any) { 34 | console.error( 35 | 'An error occurred when calling the Stripe API to create payouts', 36 | error 37 | ); 38 | return new Response(error.message, {status: 500}); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/api/account_info/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {NextRequest} from 'next/server'; 4 | import Salon from '@/app/models/salon'; 5 | import dbConnect from '@/lib/dbConnect'; 6 | import {getToken} from 'next-auth/jwt'; 7 | 8 | export async function GET(req: NextRequest) { 9 | try { 10 | const token = await getToken({req}); 11 | 12 | if (!token?.email) { 13 | return new Response('Unauthorized', {status: 401}); 14 | } 15 | await dbConnect(); 16 | const user = await Salon.findOne({ 17 | email: token?.email, 18 | }); 19 | 20 | if (!user) { 21 | return new Response('User not found', {status: 404}); 22 | } 23 | 24 | return new Response( 25 | JSON.stringify({ 26 | changedPassword: user.changedPassword || false, 27 | password: user.changedPassword ? '' : user.password || '', 28 | businessName: user.businessName || '', 29 | setup: user.setup || false, 30 | email: user.email || '', 31 | }), 32 | {status: 200, headers: {'Content-Type': 'application/json'}} 33 | ); 34 | } catch (error: any) { 35 | console.error('An error occurred when retrieving account info', error); 36 | return new Response(error.message, {status: 500}); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/api/webhooks/route.ts: -------------------------------------------------------------------------------- 1 | import {type NextRequest} from 'next/server'; 2 | import {NextResponse} from 'next/server'; 3 | import {stripe} from '@/lib/stripe'; 4 | 5 | export async function POST(req: NextRequest) { 6 | const body = await req.text(); 7 | 8 | const sig = req.headers.get('stripe-signature'); 9 | if (!sig) { 10 | return NextResponse.json( 11 | {error: 'Cannot find the webhook signature'}, 12 | {status: 400} 13 | ); 14 | } 15 | 16 | const secret = process.env.STRIPE_WEBHOOK_SECRET; 17 | if (!secret) { 18 | return NextResponse.json( 19 | {error: 'Cannot find the webhook secret'}, 20 | {status: 400} 21 | ); 22 | } 23 | 24 | let event; 25 | try { 26 | event = stripe.webhooks.constructEvent( 27 | body, 28 | sig, 29 | process.env.STRIPE_WEBHOOK_SECRET || '' 30 | ); 31 | } catch (err: any) { 32 | return NextResponse.json( 33 | {error: `Webhook Error: ${err.message}`}, 34 | {status: 400} 35 | ); 36 | } 37 | 38 | // Handle events - see full event list: https://docs.stripe.com/api/events/types 39 | switch (event.type) { 40 | case 'account.updated': 41 | break; 42 | default: 43 | console.log('Unhandled event type', event.type); 44 | break; 45 | } 46 | 47 | return NextResponse.json({}); 48 | } 49 | -------------------------------------------------------------------------------- /app/components/Tools/OverlaySelector.tsx: -------------------------------------------------------------------------------- 1 | import {useContext, useCallback} from 'react'; 2 | import { 3 | Select, 4 | SelectContent, 5 | SelectItem, 6 | SelectTrigger, 7 | SelectValue, 8 | } from '@/components/ui/select'; 9 | import {SettingsContext} from '@/app/contexts/settings'; 10 | import {OverlayOption} from '@stripe/connect-js'; 11 | 12 | const Overlays: Array<{overlayType: OverlayOption; label: string}> = [ 13 | {label: 'Dialog', overlayType: 'dialog'}, 14 | {label: 'Drawer', overlayType: 'drawer'}, 15 | ]; 16 | 17 | const OverlaySelector = () => { 18 | const settings = useContext(SettingsContext); 19 | 20 | const setOverlay = useCallback( 21 | (value: OverlayOption) => { 22 | settings.handleUpdate({overlay: value}); 23 | }, 24 | [settings] 25 | ); 26 | 27 | const overlay = 28 | Overlays.find((o) => o.overlayType === settings.overlay) || Overlays[0]!; 29 | 30 | return ( 31 | 40 | ); 41 | }; 42 | 43 | export default OverlaySelector; 44 | -------------------------------------------------------------------------------- /app/hooks/ToolsPanelProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React, {createContext, useContext, useState, useCallback} from 'react'; 3 | 4 | type IToolsContext = { 5 | open: boolean; 6 | handleOpenChange: (open: boolean) => void; 7 | }; 8 | 9 | const ToolsContext = createContext({ 10 | open: false, 11 | handleOpenChange: () => {}, 12 | }); 13 | 14 | export const useToolsContext = () => { 15 | return useContext(ToolsContext); 16 | }; 17 | 18 | export const ToolsPanelProvider = ({children}: {children: React.ReactNode}) => { 19 | const localWindow = typeof window !== 'undefined' ? window : null; 20 | const [open, setOpen] = useState( 21 | Boolean(Number(localWindow?.localStorage.getItem('toolsOpen'))) 22 | ); 23 | 24 | const handleOpenChange = useCallback( 25 | (toolsOpen: boolean) => { 26 | if (!localWindow) { 27 | return; 28 | } 29 | 30 | setOpen(toolsOpen); 31 | 32 | if (toolsOpen) { 33 | localWindow.localStorage.setItem('toolsOpen', '1'); 34 | setOpen(true); 35 | } else { 36 | localWindow.localStorage.setItem('toolsOpen', '0'); 37 | setOpen(false); 38 | } 39 | }, 40 | [localWindow] 41 | ); 42 | 43 | return ( 44 | 45 | {children} 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /app/hooks/useExpressDashboardLoginLink.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import {useGetStripeAccount} from '@/app/hooks/useGetStripeAccount'; 3 | 4 | export const useExpressDashboardLoginLink = () => { 5 | const {stripeAccount, loading} = useGetStripeAccount(); 6 | const [hasExpressDashboardAccess, setHasExpressDashboardAccess] = 7 | useState(false); 8 | const [expressDashboardLoginLink, setExpressDashboardLoginLink] = 9 | useState(); 10 | 11 | useEffect(() => { 12 | if (!loading && stripeAccount) { 13 | const hasAccess = 14 | stripeAccount?.controller?.stripe_dashboard?.type === 'express'; 15 | setHasExpressDashboardAccess(hasAccess); 16 | 17 | if (hasAccess) { 18 | const fetchLoginLink = async () => { 19 | try { 20 | const res = await fetch('/api/login_link'); 21 | const data = await res.json(); 22 | setExpressDashboardLoginLink(data.url); 23 | } catch (error) { 24 | console.error('Error fetching login link:', error); 25 | } 26 | }; 27 | fetchLoginLink(); 28 | } 29 | } else if (!loading) { 30 | setHasExpressDashboardAccess(false); 31 | setExpressDashboardLoginLink(undefined); 32 | } 33 | }, [stripeAccount, loading]); 34 | 35 | return {hasExpressDashboardAccess, expressDashboardLoginLink}; 36 | }; 37 | -------------------------------------------------------------------------------- /app/(dashboard)/payouts/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {ConnectPayouts} from '@stripe/react-connect-js'; 4 | import Container from '@/app/components/Container'; 5 | import EmbeddedComponentContainer from '@/app/components/EmbeddedComponentContainer'; 6 | import {LoaderCircle} from 'lucide-react'; 7 | import {useSession} from 'next-auth/react'; 8 | import React from 'react'; 9 | 10 | export default function Payouts() { 11 | const {data: session} = useSession(); 12 | const [loading, setLoading] = React.useState(true); 13 | 14 | React.useEffect(() => { 15 | setLoading(!session?.user.setup); 16 | }, [session?.user.setup]); 17 | 18 | return ( 19 | <> 20 |
21 |

Payouts

22 |
23 | 24 | 25 | {loading ? ( 26 |
27 | 31 | Creating test data 32 |
33 | ) : ( 34 | 35 | )} 36 |
37 |
38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/api/password_update/route.ts: -------------------------------------------------------------------------------- 1 | import {type NextRequest} from 'next/server'; 2 | import {getServerSession} from 'next-auth/next'; 3 | import {authOptions} from '@/lib/auth'; 4 | import Salon from '@/app/models/salon'; 5 | import {getToken} from 'next-auth/jwt'; 6 | 7 | export async function POST(req: NextRequest) { 8 | try { 9 | const session = await getServerSession(authOptions); 10 | if (!session) { 11 | return new Response('Unauthorized', {status: 401}); 12 | } 13 | 14 | const json = await req.json(); 15 | const token = await getToken({req}); 16 | let user = await Salon.findOne({ 17 | email: token?.email, 18 | }); 19 | 20 | const oldPass = user?.password; 21 | 22 | const {newPassword} = json; 23 | const updatedPassword = newPassword || oldPass; 24 | const update = { 25 | password: updatedPassword, 26 | changedPassword: true, 27 | }; 28 | console.log('updating account with, ', update); 29 | 30 | await Salon.findOneAndUpdate({email: session?.user?.email}, update); 31 | 32 | return new Response(JSON.stringify({success: true}), { 33 | status: 200, 34 | headers: {'Content-Type': 'application/json'}, 35 | }); 36 | } catch (error: any) { 37 | console.error('An error occurred when updating account password', error); 38 | return new Response(JSON.stringify({error: error.message}), {status: 500}); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/api/login_link/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | 5 | export async function GET() { 6 | try { 7 | const session = await getServerSession(authOptions); 8 | 9 | if (!session?.user?.stripeAccountId) { 10 | console.error('No connected account found for user'); 11 | return new Response('No connected account found for user', { 12 | status: 400, 13 | }); 14 | } 15 | 16 | const stripeAccount = await stripe.accounts.retrieve( 17 | session?.user?.stripeAccountId 18 | ); 19 | 20 | if (stripeAccount?.controller?.stripe_dashboard?.type !== 'express') { 21 | console.error('User does not have access to Express dashboard'); 22 | return new Response('User does not have access to Express dashboard.', { 23 | status: 400, 24 | }); 25 | } 26 | 27 | const link = await stripe.accounts.createLoginLink( 28 | session?.user?.stripeAccountId 29 | ); 30 | 31 | return new Response( 32 | JSON.stringify({ 33 | url: link.url, 34 | }), 35 | {status: 200, headers: {'Content-Type': 'application/json'}} 36 | ); 37 | } catch (error: any) { 38 | console.error( 39 | 'An error occurred when calling the Stripe API to create a login link', 40 | error 41 | ); 42 | return new Response(error.message, {status: 500}); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/api/setup_accounts/create_risk_intervention/route.ts: -------------------------------------------------------------------------------- 1 | import {Stripe} from 'stripe'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | import {getServerSession} from 'next-auth'; 5 | 6 | const merchantIssueResource = Stripe.StripeResource.extend({ 7 | create: Stripe.StripeResource.method({ 8 | method: 'POST', 9 | path: '/test_helpers/demo/merchant_issue', 10 | }) as (...args: any[]) => Promise>, 11 | }); 12 | 13 | /** 14 | * Generates test intervention for the logged-in salon. This is only used for testing purposes 15 | */ 16 | export async function POST() { 17 | try { 18 | const session = await getServerSession(authOptions); 19 | const accountId = session?.user.stripeAccountId; 20 | 21 | const interventionResource = new merchantIssueResource(stripe); 22 | const interventionResponse = await interventionResource.create({ 23 | account: accountId, 24 | issue_type: 'additional_info', 25 | }); 26 | 27 | console.log('Created interventionResponse!', interventionResponse); 28 | 29 | return new Response('Success', { 30 | status: 200, 31 | headers: {'Content-Type': 'application/json'}, 32 | }); 33 | } catch (error: any) { 34 | console.error( 35 | 'An error occurred when calling the Stripe API to create a risk intervention', 36 | error 37 | ); 38 | return new Response(error.message, {status: 500}); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {cva, type VariantProps} from 'class-variance-authority'; 3 | 4 | import {cn} from '@/lib/utils'; 5 | 6 | const badgeVariants = cva( 7 | 'inline-flex items-center rounded-md border px-1.5 h-[22px] text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-gray-200 border-gray-300 text-primary-foreground', 12 | blue: 'bg-sky-100 border-sky-200 text-sky-700 dark:bg-sky-900 dark:border-sky-700 dark:text-sky-300', 13 | red: 'bg-rose-100 border-rose-200 text-rose-700 dark:bg-rose-900 dark:border-rose-700 dark:text-rose-300', 14 | secondary: 15 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 16 | destructive: 17 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', 18 | outline: 'text-foreground', 19 | }, 20 | }, 21 | defaultVariants: { 22 | variant: 'default', 23 | }, 24 | } 25 | ); 26 | 27 | export interface BadgeProps 28 | extends React.HTMLAttributes, 29 | VariantProps {} 30 | 31 | function Badge({className, variant, ...props}: BadgeProps) { 32 | return
; 33 | } 34 | 35 | export {Badge, badgeVariants}; 36 | -------------------------------------------------------------------------------- /app/api/capital/expire_test_financing/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | 5 | export async function POST() { 6 | try { 7 | const session = await getServerSession(authOptions); 8 | if (!session) { 9 | return new Response('The current route requires authentication', { 10 | status: 403, 11 | }); 12 | } 13 | 14 | const connected_account = session.user.stripeAccountId; 15 | const offer = ( 16 | await stripe.capital.financingOffers.list({ 17 | connected_account: connected_account, 18 | limit: 1, 19 | }) 20 | ).data 21 | .filter((o) => o.status === 'delivered') 22 | .at(0); 23 | 24 | if (offer === undefined) { 25 | throw Error( 26 | 'Unable to find offer with status `delivered` for connected account: ' + 27 | connected_account 28 | ); 29 | } 30 | 31 | await stripe.rawRequest( 32 | 'POST', 33 | `/v1/capital/financing_offers/${offer!.id}/expire`, 34 | {} 35 | ); 36 | 37 | return new Response( 38 | JSON.stringify({ 39 | offer, 40 | }), 41 | {status: 200, headers: {'Content-Type': 'application/json'}} 42 | ); 43 | } catch (error: any) { 44 | console.error( 45 | 'An error occurred when calling the Stripe API to expire test financing offer', 46 | error 47 | ); 48 | return new Response(error.message, {status: 500}); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/api/capital/approve_test_financing/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | 5 | export async function POST() { 6 | try { 7 | const session = await getServerSession(authOptions); 8 | if (!session) { 9 | return new Response('The current route requires authentication', { 10 | status: 403, 11 | }); 12 | } 13 | 14 | const connected_account = session.user.stripeAccountId; 15 | 16 | const offer = ( 17 | await stripe.capital.financingOffers.list({ 18 | connected_account: connected_account, 19 | limit: 1, 20 | }) 21 | ).data 22 | .filter((o) => o.status === 'accepted') 23 | .at(0); 24 | 25 | if (offer === undefined) { 26 | throw Error( 27 | 'Unable to find offer with status `accepted` for connected account: ' + 28 | connected_account 29 | ); 30 | } 31 | 32 | await stripe.rawRequest( 33 | 'POST', 34 | `/v1/capital/financing_offers/${offer!.id}/payout`, 35 | {} 36 | ); 37 | 38 | return new Response( 39 | JSON.stringify({ 40 | offer, 41 | }), 42 | {status: 200, headers: {'Content-Type': 'application/json'}} 43 | ); 44 | } catch (error: any) { 45 | console.error( 46 | 'An error occurred when calling the Stripe API to payout test financing offer', 47 | error 48 | ); 49 | return new Response(error.message, {status: 500}); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/api/debug/create_issuing_card_authorization/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | 5 | export async function POST() { 6 | try { 7 | const session = await getServerSession(authOptions); 8 | 9 | const issuingCards = await stripe.issuing.cards.list( 10 | { 11 | limit: 1, 12 | status: 'active', 13 | }, 14 | { 15 | stripeAccount: session?.user.stripeAccountId, 16 | } 17 | ); 18 | 19 | if (issuingCards.data.length === 0) { 20 | console.error('No active issuing cards found for user'); 21 | return new Response('No issuing cards found for user', { 22 | status: 400, 23 | }); 24 | } 25 | 26 | const authorization = 27 | await stripe.testHelpers.issuing.authorizations.create( 28 | { 29 | amount: 1000, 30 | card: issuingCards.data[0].id, 31 | }, 32 | { 33 | stripeAccount: session?.user.stripeAccountId, 34 | } 35 | ); 36 | 37 | return new Response( 38 | JSON.stringify({ 39 | received_credit: authorization.id, 40 | }), 41 | {status: 200, headers: {'Content-Type': 'application/json'}} 42 | ); 43 | } catch (error: any) { 44 | console.error( 45 | 'An error occurred when calling the Stripe API to create an issuing authorization', 46 | error 47 | ); 48 | return new Response(error.message, {status: 500}); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/api/capital/reject_test_financing/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | 5 | export async function POST() { 6 | try { 7 | const session = await getServerSession(authOptions); 8 | 9 | if (!session) { 10 | return new Response('The current route requires authentication', { 11 | status: 403, 12 | }); 13 | } 14 | 15 | const connected_account = session.user.stripeAccountId; 16 | 17 | const offer = ( 18 | await stripe.capital.financingOffers.list({ 19 | connected_account: connected_account, 20 | limit: 1, 21 | }) 22 | ).data 23 | .filter((o) => o.status === 'accepted') 24 | .at(0); 25 | 26 | if (offer === undefined) { 27 | throw Error( 28 | 'Unable to find offer with status `accepted` for connected account: ' + 29 | connected_account 30 | ); 31 | } 32 | 33 | await stripe.rawRequest( 34 | 'POST', 35 | `/v1/capital/financing_offers/${offer!.id}/revoke_v2`, 36 | {} 37 | ); 38 | 39 | return new Response( 40 | JSON.stringify({ 41 | offer, 42 | }), 43 | {status: 200, headers: {'Content-Type': 'application/json'}} 44 | ); 45 | } catch (error: any) { 46 | console.error( 47 | 'An error occurred when calling the Stripe API to reject test financing offer', 48 | error 49 | ); 50 | return new Response(error.message, {status: 500}); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/api/capital/fully_repay_test_financing/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | 5 | export async function POST() { 6 | try { 7 | const session = await getServerSession(authOptions); 8 | 9 | if (!session) { 10 | return new Response('The current route requires authentication', { 11 | status: 403, 12 | }); 13 | } 14 | 15 | const connected_account = session.user.stripeAccountId; 16 | 17 | const offer = ( 18 | await stripe.capital.financingOffers.list({ 19 | connected_account: connected_account, 20 | limit: 1, 21 | }) 22 | ).data 23 | .filter((o) => o.status === 'paid_out') 24 | .at(0); 25 | 26 | if (offer === undefined) { 27 | throw Error( 28 | 'Unable to find offer with status `paid_out` for connected account: ' + 29 | connected_account 30 | ); 31 | } 32 | 33 | await stripe.rawRequest( 34 | 'POST', 35 | `/v1/capital/financing_offers/${offer!.id}/fully_repay`, 36 | {} 37 | ); 38 | 39 | return new Response( 40 | JSON.stringify({ 41 | offer, 42 | }), 43 | {status: 200, headers: {'Content-Type': 'application/json'}} 44 | ); 45 | } catch (error: any) { 46 | console.error( 47 | 'An error occurred when calling the Stripe API to fully repay test financing offer', 48 | error 49 | ); 50 | return new Response(error.message, {status: 500}); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/components/CapitalFinancingPromotionSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Container from './Container'; 3 | import {ConnectCapitalFinancingPromotion} from '@stripe/react-connect-js'; 4 | import {FinancingProductType} from '@stripe/connect-js'; 5 | 6 | import EmbeddedComponentContainer from '@/app/components/EmbeddedComponentContainer'; 7 | import type {FinancingPromotionLayoutType} from '@stripe/connect-js'; 8 | 9 | export function CapitalFinancingPromotionSection({ 10 | className = '', 11 | layout = 'full', 12 | }: { 13 | className?: string; 14 | layout?: FinancingPromotionLayoutType; 15 | }) { 16 | // Only show the financing offer if there is one to show 17 | const [showFinancingOffer, setShowFinancingOffer] = React.useState(false); 18 | const handleFinancingOfferLoaded = ({productType}: FinancingProductType) => { 19 | switch (productType) { 20 | case 'none': 21 | setShowFinancingOffer(false); 22 | break; 23 | case 'standard': 24 | case 'refill': 25 | setShowFinancingOffer(true); 26 | break; 27 | } 28 | }; 29 | 30 | return ( 31 | 36 | 37 | 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /app/components/DataRequest.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import {useSession} from 'next-auth/react'; 3 | import * as React from 'react'; 4 | 5 | export const DataRequest = ({ 6 | children, 7 | }: Readonly<{ 8 | children: React.ReactNode; 9 | }>) => { 10 | const {data: session, update} = useSession(); 11 | React.useEffect(() => { 12 | const fetchData = async () => { 13 | console.log('fetching data', session?.user.setup, session?.user); 14 | if (session?.user.setup) { 15 | return; 16 | } 17 | const info = await fetch('/api/account_info', { 18 | method: 'GET', 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | }, 22 | }); 23 | if (info.ok) { 24 | const data = await info.json(); 25 | if (data.setup) { 26 | await update({ 27 | user: { 28 | ...session?.user, 29 | setup: true, 30 | }, 31 | }); 32 | return; 33 | } 34 | } else { 35 | console.error('Failed to fetch account info:', info.status); 36 | } 37 | 38 | const res = await fetch('/api/setup_accounts', { 39 | method: 'POST', 40 | headers: { 41 | 'Content-Type': 'application/json', 42 | }, 43 | }); 44 | if (res.ok) { 45 | await update({ 46 | user: { 47 | ...session?.user, 48 | setup: true, 49 | }, 50 | }); 51 | } 52 | }; 53 | setTimeout(() => fetchData(), 10000); 54 | }); 55 | 56 | return <>{children}; 57 | }; 58 | -------------------------------------------------------------------------------- /app/api/debug/create_received_credit/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | 5 | export async function POST() { 6 | try { 7 | const session = await getServerSession(authOptions); 8 | 9 | const financialAccounts = await stripe.treasury.financialAccounts.list( 10 | { 11 | limit: 1, 12 | }, 13 | { 14 | stripeAccount: session?.user.stripeAccountId, 15 | } 16 | ); 17 | 18 | if (financialAccounts.data.length === 0) { 19 | console.error('No financial accounts found for user'); 20 | return new Response('No financial accounts found for user', { 21 | status: 400, 22 | }); 23 | } 24 | 25 | const receivedCredit = 26 | await stripe.testHelpers.treasury.receivedCredits.create( 27 | { 28 | amount: 1000, 29 | currency: 'usd', 30 | financial_account: financialAccounts.data[0].id, 31 | network: 'ach', 32 | }, 33 | { 34 | stripeAccount: session?.user.stripeAccountId, 35 | } 36 | ); 37 | 38 | return new Response( 39 | JSON.stringify({ 40 | received_credit: receivedCredit.id, 41 | }), 42 | {status: 200, headers: {'Content-Type': 'application/json'}} 43 | ); 44 | } catch (error: any) { 45 | console.error( 46 | 'An error occurred when calling the Stripe API to create a received credit', 47 | error 48 | ); 49 | return new Response(error.message, {status: 500}); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/api/capital/create_test_financing/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | import {NextRequest} from 'next/server'; 5 | 6 | export async function POST(req: NextRequest) { 7 | try { 8 | const session = await getServerSession(authOptions); 9 | 10 | if (!session) { 11 | return new Response('The current route requires authentication', { 12 | status: 403, 13 | }); 14 | } 15 | 16 | const connected_account = session.user.stripeAccountId; 17 | 18 | const state = (await req.json())['offerState'] || 'delivered'; 19 | 20 | await stripe.rawRequest('POST', '/v1/capital/financing_offers/test_mode', { 21 | max_premium_amount: 10000_00, 22 | max_advance_amount: 100000_00, 23 | max_withhold_rate_str: 0.15, 24 | is_refill: false, 25 | financing_type: 'flex_loan', 26 | state, 27 | is_youlend: false, 28 | is_fixed_term: false, 29 | 'loan_repayment_details[repayment_interval_duration_days]': 60, 30 | 'loan_repayment_details[target_payback_weeks]': 42, 31 | country: 'US', 32 | connected_account, 33 | }); 34 | 35 | return new Response(JSON.stringify({}), { 36 | status: 200, 37 | headers: {'Content-Type': 'application/json'}, 38 | }); 39 | } catch (error: any) { 40 | console.error( 41 | 'An error occurred when calling the Stripe API to create test financing offer', 42 | error 43 | ); 44 | return new Response(error.message, {status: 500}); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as AvatarPrimitive from '@radix-ui/react-avatar'; 5 | 6 | import {cn} from '@/lib/utils'; 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({className, ...props}, ref) => ( 12 | 20 | )); 21 | Avatar.displayName = AvatarPrimitive.Root.displayName; 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({className, ...props}, ref) => ( 27 | 32 | )); 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({className, ...props}, ref) => ( 39 | 47 | )); 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 49 | 50 | export {Avatar, AvatarImage, AvatarFallback}; 51 | -------------------------------------------------------------------------------- /components/ui/radiogroup.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import {CheckIcon} from '@radix-ui/react-icons'; 5 | import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; 6 | 7 | import {cn} from '@/lib/utils'; 8 | 9 | const RadioGroup = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({className, ...props}, ref) => { 13 | return ( 14 | 19 | ); 20 | }); 21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; 22 | 23 | const RadioGroupItem = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({className, ...props}, ref) => { 27 | return ( 28 | 36 | 37 | 38 | 39 | 40 | ); 41 | }); 42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; 43 | 44 | export {RadioGroup, RadioGroupItem}; 45 | -------------------------------------------------------------------------------- /app/components/Tools/LocaleSelector.tsx: -------------------------------------------------------------------------------- 1 | import {useContext, useCallback} from 'react'; 2 | import { 3 | Select, 4 | SelectContent, 5 | SelectItem, 6 | SelectTrigger, 7 | SelectValue, 8 | } from '@/components/ui/select'; 9 | import {LocaleType, Locales} from '@/types/settings'; 10 | import {SettingsContext} from '@/app/contexts/settings'; 11 | 12 | type LocaleProps = { 13 | localeUpdated?: () => void; 14 | }; 15 | 16 | const LocaleSelector = ({localeUpdated}: LocaleProps) => { 17 | const settings = useContext(SettingsContext); 18 | 19 | const setLocale = useCallback( 20 | (value: string) => { 21 | const locale = Locales.find((l) => `${l.locale}-${l.label}` === value); 22 | if (!locale) { 23 | return; 24 | } 25 | 26 | settings.handleUpdate({locale: locale.locale}); 27 | if (localeUpdated) { 28 | localeUpdated(); 29 | } 30 | }, 31 | [localeUpdated, settings] 32 | ); 33 | 34 | // Find the locale display name 35 | const locale = 36 | Locales.find((l) => l.locale === settings.locale) || Locales[0]!; 37 | 38 | return ( 39 | 53 | ); 54 | }; 55 | 56 | export default LocaleSelector; 57 | -------------------------------------------------------------------------------- /app/contexts/themes/ThemeConstants.ts: -------------------------------------------------------------------------------- 1 | export const defaultPrimaryColor = '#27AE60'; 2 | 3 | export const LightTheme = { 4 | fontFamily: 'Sohne, inherit', 5 | colorPrimary: defaultPrimaryColor, 6 | colorBackground: '#ffffff', 7 | colorBorder: '#D8DEE4', 8 | 9 | buttonPrimaryColorBackground: defaultPrimaryColor, 10 | buttonPrimaryColorText: '#f4f4f5', 11 | 12 | badgeSuccessColorBackground: '#D6FCE6', 13 | badgeSuccessColorText: '#1E884B', 14 | badgeSuccessColorBorder: '#94D5AF', 15 | 16 | badgeWarningColorBackground: '#FFEACC', 17 | badgeWarningColorText: '#C95B4D', 18 | badgeWarningColorBorder: '#FFD28C', 19 | 20 | overlayBackdropColor: 'rgba(0,0,0,0.3)', 21 | }; 22 | 23 | export const DarkTheme = { 24 | fontFamily: 'Sohne, inherit', 25 | colorPrimary: defaultPrimaryColor, 26 | colorBackground: '#1e222a', 27 | colorBorder: '#4b5563', 28 | 29 | colorText: '#C9CED8', 30 | colorSecondaryText: '#8C99AD', 31 | 32 | buttonPrimaryColorBackground: defaultPrimaryColor, 33 | buttonPrimaryColorText: '#f4f4f5', 34 | 35 | buttonSecondaryColorBackground: '#292E38', 36 | buttonSecondaryColorText: '#C9CED8', 37 | 38 | badgeNeutralColorBackground: '#252B37', 39 | badgeNeutralColorText: '#E2E5F0', 40 | badgeNeutralColorBorder: '#3E4554', 41 | 42 | badgeSuccessColorBackground: '#0c4223', 43 | badgeSuccessColorText: '#43C67A', 44 | badgeSuccessColorBorder: '#156236', 45 | 46 | badgeWarningColorBackground: '#400A00', 47 | badgeWarningColorText: '#F98A23', 48 | badgeWarningColorBorder: '#632013', 49 | 50 | badgeDangerColorBackground: '#400a00', 51 | badgeDangerColorText: '#C95B4D', 52 | badgeDangerColorBorder: '#632013', 53 | 54 | overlayBackdropColor: 'rgba(0,0,0,0.5)', 55 | }; 56 | -------------------------------------------------------------------------------- /app/components/testdata/CreateFinancialCreditButton.tsx: -------------------------------------------------------------------------------- 1 | import {useFinancialAccount} from '@/app/hooks/useFinancialAccount'; 2 | import {Button} from '@/components/ui/button'; 3 | import {LoaderCircle, Plus} from 'lucide-react'; 4 | import React from 'react'; 5 | 6 | export default function CreateFinancialCreditButton({ 7 | classes, 8 | }: { 9 | classes?: string; 10 | }) { 11 | const { 12 | financialAccount, 13 | error: useFinancialAccountError, 14 | loading, 15 | } = useFinancialAccount(); 16 | const [buttonLoading, setButtonLoading] = React.useState(false); 17 | const displayFinancialAccount = 18 | !useFinancialAccountError && financialAccount && !loading; 19 | 20 | if (!displayFinancialAccount) { 21 | return null; 22 | } 23 | 24 | const onClick = async () => { 25 | setButtonLoading(true); 26 | try { 27 | const data = { 28 | financialAccount: financialAccount, 29 | }; 30 | const res = await fetch('/api/setup_accounts/create_financial_credit', { 31 | method: 'POST', 32 | headers: { 33 | 'Content-Type': 'application/json', 34 | }, 35 | body: JSON.stringify(data), 36 | }); 37 | 38 | if (res.ok) { 39 | setButtonLoading(false); 40 | window.location.reload(); 41 | } 42 | } catch (e) { 43 | console.log('Error with creating test financial credit: ', e); 44 | } 45 | }; 46 | return ( 47 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /app/api/setup_accounts/create_payouts/route.ts: -------------------------------------------------------------------------------- 1 | import Salon from '@/app/models/salon'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | import {getServerSession} from 'next-auth'; 5 | 6 | function getRandomInt(min: number, max: number) { 7 | return Math.floor(Math.random() * (max - min + 1)) + min; 8 | } 9 | 10 | export async function POST() { 11 | try { 12 | const session = await getServerSession(authOptions); 13 | const accountId = session?.user.stripeAccountId; 14 | 15 | const balance = await stripe.balance.retrieve({ 16 | stripeAccount: accountId, 17 | }); 18 | 19 | // Find the first balance currency that can be paid out 20 | const selectedBalance = balance.available.find(({amount}) => amount > 0); 21 | if (selectedBalance) { 22 | const {amount, currency} = selectedBalance; 23 | for (let i = 0; i < 3; i++) { 24 | await stripe.payouts.create( 25 | { 26 | amount: getRandomInt(100, amount), 27 | currency: currency, 28 | description: 'TEST PAYOUT', 29 | }, 30 | { 31 | stripeAccount: accountId, 32 | } 33 | ); 34 | } 35 | } else { 36 | throw new Error( 37 | 'You do not have any available balance to payout. Create a test payment in the "Payments" tab first with the "Successful" status to immediately add funds to your account.' 38 | ); 39 | } 40 | 41 | return new Response('Success', { 42 | status: 200, 43 | headers: {'Content-Type': 'application/json'}, 44 | }); 45 | } catch (error: any) { 46 | console.error( 47 | 'An error occurred when calling the Stripe API to create payouts', 48 | error 49 | ); 50 | return new Response(error.message, {status: 500}); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/api/setup_accounts/create_bank_account/route.ts: -------------------------------------------------------------------------------- 1 | import Salon from '@/app/models/salon'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | import {getServerSession} from 'next-auth'; 5 | import {type NextRequest} from 'next/server'; 6 | 7 | export async function POST(req: NextRequest) { 8 | try { 9 | const session = await getServerSession(authOptions); 10 | const accountId = session?.user.stripeAccountId; 11 | const json = await req.json(); 12 | 13 | if (!accountId) { 14 | throw new Error('Stripe account ID is undefined'); 15 | } 16 | 17 | const { 18 | country, 19 | currency, 20 | account_holder_name, 21 | account_holder_type, 22 | routing_number, 23 | account_number, 24 | } = json; 25 | 26 | const token = await stripe.tokens.create( 27 | { 28 | bank_account: { 29 | country: country, 30 | currency: currency, 31 | account_holder_name: account_holder_name, 32 | account_holder_type: account_holder_type, 33 | routing_number: routing_number, 34 | account_number: account_number, 35 | }, 36 | }, 37 | { 38 | stripeAccount: accountId, 39 | } 40 | ); 41 | if (!token) { 42 | throw new Error('Token was not returned'); 43 | } 44 | await stripe.accounts.createExternalAccount(accountId, { 45 | external_account: token.id, 46 | }); 47 | 48 | return new Response('Success', { 49 | status: 200, 50 | headers: {'Content-Type': 'application/json'}, 51 | }); 52 | } catch (error: any) { 53 | console.error( 54 | 'An error occurred when calling the Stripe API to create a bank account', 55 | error 56 | ); 57 | return new Response(error.message, {status: 500}); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {cva, type VariantProps} from 'class-variance-authority'; 3 | 4 | import {cn} from '@/lib/utils'; 5 | 6 | const alertVariants = cva( 7 | 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-background text-foreground', 12 | destructive: 13 | 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: 'default', 18 | }, 19 | } 20 | ); 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({className, variant, ...props}, ref) => ( 26 |
32 | )); 33 | Alert.displayName = 'Alert'; 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({className, ...props}, ref) => ( 39 |
44 | )); 45 | AlertTitle.displayName = 'AlertTitle'; 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({className, ...props}, ref) => ( 51 |
56 | )); 57 | AlertDescription.displayName = 'AlertDescription'; 58 | 59 | export {Alert, AlertTitle, AlertDescription}; 60 | -------------------------------------------------------------------------------- /app/api/debug/get_demo_account/route.ts: -------------------------------------------------------------------------------- 1 | import {stripe} from '@/lib/stripe'; 2 | 3 | export async function POST() { 4 | try { 5 | // If we have a demo account in the env file, use it 6 | let demoAccountId: string | undefined = process.env.EXAMPLE_DEMO_ACCOUNT; 7 | if (!demoAccountId) { 8 | // Look for a demo account 9 | const accounts = await stripe.accounts.list({ 10 | limit: 20, 11 | }); 12 | const demoAccount = accounts.data.find((account) => { 13 | const metadata = account.metadata; 14 | if (!metadata) { 15 | return false; 16 | } 17 | 18 | // Get an account that is a demo account, not restricted, 19 | // and not high or elevated fraud, and can take charges and payouts 20 | return ( 21 | metadata.demo_account === 'true' && 22 | metadata.restricted !== 'true' && 23 | metadata.elevated_fraud !== 'true' && 24 | metadata.high_fraud !== 'true' && 25 | account.charges_enabled && 26 | account.payouts_enabled 27 | ); 28 | }); 29 | if (!demoAccount) { 30 | console.error('No demo account found'); 31 | return new Response( 32 | JSON.stringify({ 33 | error: 'No demo account found', 34 | }), 35 | {status: 400} 36 | ); 37 | } 38 | demoAccountId = demoAccount.id; 39 | } 40 | 41 | return new Response( 42 | JSON.stringify({ 43 | accountId: demoAccountId, 44 | }), 45 | {status: 200, headers: {'Content-Type': 'application/json'}} 46 | ); 47 | } catch (error: any) { 48 | console.error( 49 | 'An error occurred when calling the Stripe API to get a demo account', 50 | error 51 | ); 52 | return new Response( 53 | JSON.stringify({ 54 | error: error.message, 55 | }), 56 | {status: 500} 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/components/testdata/Financing/TransitionFinancingButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from '@/components/ui/button'; 2 | import {LoaderCircle} from 'lucide-react'; 3 | import React from 'react'; 4 | import Stripe from 'stripe'; 5 | import {OfferState} from './types'; 6 | import {UseFormReturn} from 'react-hook-form'; 7 | 8 | const OFFER_STATES_TO_DISPLAY_ON: OfferState[] = ['accepted']; 9 | 10 | export function TransitionFinancingButton({ 11 | classes, 12 | offerState, 13 | label, 14 | fetchUrl, 15 | visibleForOfferStates, 16 | fetchMethod: method = 'POST', 17 | fetchBody = {}, 18 | form, 19 | }: { 20 | offerState: OfferState; 21 | label: string; 22 | fetchUrl: string; 23 | visibleForOfferStates: OfferState[]; 24 | classes?: string; 25 | fetchMethod?: string; 26 | fetchBody?: {}; 27 | form?: UseFormReturn; 28 | }) { 29 | const [buttonLoading, setButtonLoading] = React.useState(false); 30 | const onClick = async () => { 31 | setButtonLoading(true); 32 | try { 33 | const res = await fetch(fetchUrl, { 34 | method, 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | }, 38 | body: JSON.stringify(fetchBody), 39 | }); 40 | setButtonLoading(false); 41 | 42 | if (res.ok) { 43 | window.location.reload(); 44 | } 45 | } catch (e) { 46 | console.log(`Error attempting to \`${label}\`: `, e); 47 | setButtonLoading(false); 48 | } 49 | }; 50 | 51 | if (visibleForOfferStates.includes(offerState)) { 52 | return ( 53 | 65 | ); 66 | } else { 67 | return undefined; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/(dashboard)/payments/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import {ConnectPayments} from '@stripe/react-connect-js'; 5 | import Container from '@/app/components/Container'; 6 | import EmbeddedComponentContainer from '@/app/components/EmbeddedComponentContainer'; 7 | import MonthToDateWidget from '@/app/components/MonthToDateWidget'; 8 | import CustomersWidget from '@/app/components/CustomersWidget'; 9 | import {Button} from '@/components/ui/button'; 10 | import {LoaderCircle, Plus} from 'lucide-react'; 11 | import {useSession} from 'next-auth/react'; 12 | import CreatePaymentsButton from '@/app/components/testdata/CreatePaymentsButton'; 13 | 14 | export default function Payments() { 15 | const {data: session} = useSession(); 16 | const [loading, setLoading] = React.useState(true); 17 | 18 | React.useEffect(() => { 19 | setLoading(!session?.user.setup); 20 | }, [session?.user.setup]); 21 | 22 | return ( 23 | <> 24 |
25 |

Payments

26 |
27 |
28 |
29 | 30 |
31 |
32 | 33 |
34 |
35 | 36 |

Recent payments

37 | 38 | {loading ? ( 39 |
40 | 44 | Creating test data 45 |
46 | ) : ( 47 | 48 | )} 49 |
50 |
51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Slot} from '@radix-ui/react-slot'; 3 | import {cva, type VariantProps} from 'class-variance-authority'; 4 | 5 | import {cn} from '@/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring ring-[#A9DFBF] focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-accent text-accent-foreground hover:opacity-80', 13 | destructive: 'bg-destructive text-destructive-foreground', 14 | outline: 15 | 'border border-2 border-accent bg-background text-accent hover:bg-accent-subdued hover:text-accent-foreground', 16 | secondary: 17 | 'bg-screen-foreground text-secondary-foreground hover:bg-gray-100 dark:hover:bg-gray-700', 18 | ghost: 'hover:bg-gray-100 dark:hover:bg-gray-700', 19 | link: 'text-primary underline-offset-4 hover:underline', 20 | }, 21 | size: { 22 | default: 'px-4 py-2 text-base', 23 | sm: 'rounded-md px-3 py-1 text-sm', 24 | lg: 'rounded-md px-5 py-3 text-lg', 25 | icon: 'h-10 w-10', 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: 'default', 30 | size: 'default', 31 | }, 32 | } 33 | ); 34 | 35 | export interface ButtonProps 36 | extends React.ButtonHTMLAttributes, 37 | VariantProps { 38 | asChild?: boolean; 39 | } 40 | 41 | const Button = React.forwardRef( 42 | ({className, variant, size, asChild = false, ...props}, ref) => { 43 | const Comp = asChild ? Slot : 'button'; 44 | return ( 45 | 50 | ); 51 | } 52 | ); 53 | Button.displayName = 'Button'; 54 | 55 | export {Button, buttonVariants}; 56 | -------------------------------------------------------------------------------- /app/api/primary_color/route.ts: -------------------------------------------------------------------------------- 1 | import Salon from '@/app/models/salon'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {getServerSession} from 'next-auth'; 4 | 5 | export async function GET() { 6 | try { 7 | const session = await getServerSession(authOptions); 8 | 9 | if (!session?.user?.email) { 10 | return new Response('Unauthorized', {status: 401}); 11 | } 12 | 13 | const salon = await Salon.findOne({email: session.user.email}); 14 | 15 | if (!salon) { 16 | return new Response('Salon not found', {status: 404}); 17 | } 18 | 19 | return new Response(JSON.stringify({primaryColor: salon.primaryColor}), { 20 | status: 200, 21 | headers: {'Content-Type': 'application/json'}, 22 | }); 23 | } catch (error: any) { 24 | console.error('Error fetching primary color:', error); 25 | return new Response(error.message, {status: 500}); 26 | } 27 | } 28 | 29 | export async function POST(request: Request) { 30 | try { 31 | const session = await getServerSession(authOptions); 32 | 33 | if (!session?.user?.email) { 34 | return new Response('Unauthorized', {status: 401}); 35 | } 36 | 37 | const {primaryColor} = await request.json(); 38 | 39 | if (!primaryColor) { 40 | return new Response('Primary color is required', {status: 400}); 41 | } 42 | 43 | if (!/^#[0-9A-F]{6}$/i.test(primaryColor)) { 44 | return new Response('Invalid color format', {status: 400}); 45 | } 46 | 47 | const updatedSalon = await Salon.findOneAndUpdate( 48 | {email: session.user.email}, 49 | {primaryColor: primaryColor}, 50 | {new: true} 51 | ); 52 | 53 | if (!updatedSalon) { 54 | return new Response('Salon not found', {status: 404}); 55 | } 56 | 57 | return new Response(JSON.stringify({success: true, primaryColor}), { 58 | status: 200, 59 | headers: {'Content-Type': 'application/json'}, 60 | }); 61 | } catch (error: any) { 62 | console.error('An error occurred when updating the primary color:', error); 63 | return new Response(error.message, {status: 500}); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as TabsPrimitive from '@radix-ui/react-tabs'; 5 | 6 | import {cn} from '@/lib/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 | -------------------------------------------------------------------------------- /app/components/CustomersWidget.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, {useContext} from 'react'; 4 | import Container from './Container'; 5 | import {Badge} from '@/components/ui/badge'; 6 | import {SparkLineChart} from '@mui/x-charts/SparkLineChart'; 7 | import {SettingsContext} from '../contexts/settings'; 8 | import {calculateSecondaryColor} from '@/lib/utils'; 9 | import {defaultPrimaryColor} from '../contexts/themes/ThemeConstants'; 10 | 11 | const CustomersWidget = () => { 12 | const {primaryColor} = useContext(SettingsContext); 13 | const secondaryColor = calculateSecondaryColor( 14 | primaryColor || defaultPrimaryColor, 15 | { 16 | opacity: 0.25, 17 | darkenAmount: 0.1, 18 | } 19 | ); 20 | 21 | return ( 22 | 23 |
24 |
25 |

Customers

26 |
27 |
424
28 | 29 | +3.1% 30 | 31 |
32 |
33 |
34 |
35 | 42 |
43 |
44 | 51 |
52 |
53 |
54 |
55 | ); 56 | }; 57 | 58 | export default CustomersWidget; 59 | -------------------------------------------------------------------------------- /app/components/MonthToDateWidget.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, {useContext} from 'react'; 4 | import Container from './Container'; 5 | import {Badge} from '@/components/ui/badge'; 6 | import {SparkLineChart} from '@mui/x-charts/SparkLineChart'; 7 | import {calculateSecondaryColor} from '@/lib/utils'; 8 | import {SettingsContext} from '../contexts/settings'; 9 | import {defaultPrimaryColor} from '../contexts/themes/ThemeConstants'; 10 | 11 | const MonthToDateWidget = () => { 12 | const {primaryColor} = useContext(SettingsContext); 13 | const secondaryColor = calculateSecondaryColor( 14 | primaryColor || defaultPrimaryColor, 15 | { 16 | opacity: 0.25, 17 | darkenAmount: 0.1, 18 | } 19 | ); 20 | 21 | return ( 22 | 23 |
24 |
25 |

Month-to-date

26 |
27 |
$13.3k
28 | 29 | +7.5% 30 | 31 |
32 |
33 |
34 |
35 | 42 |
43 |
44 | 51 |
52 |
53 |
54 |
55 | ); 56 | }; 57 | 58 | export default MonthToDateWidget; 59 | -------------------------------------------------------------------------------- /app/(dashboard)/pets/page.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from '@/components/ui/button'; 2 | import Image from 'next/image'; 3 | import Container from '@/app/components/Container'; 4 | import pets from '@/app/data/pets.json'; 5 | 6 | import { 7 | Plus as PlusIcon, 8 | Phone as PhoneIcon, 9 | Mail as EmailIcon, 10 | } from 'lucide-react'; 11 | 12 | const shuffle = (array: object[]) => { 13 | for (let i = array.length - 1; i > 0; i--) { 14 | const j = Math.floor(Math.random() * (i + 1)); 15 | [array[i], array[j]] = [array[j], array[i]]; // Swap elements 16 | } 17 | return array; 18 | }; 19 | 20 | export default function Pets() { 21 | shuffle(pets); 22 | 23 | return ( 24 | <> 25 |
26 |

Pets

27 |
28 |
29 | {pets.map((pet, key) => { 30 | return ( 31 | 35 | {`Photo 44 |
45 |
46 |

{pet.name}

47 |

Joined {pet.date}

48 |
49 |
50 | 51 | 52 |
53 |
54 |
55 | ); 56 | })} 57 |
58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /app/api/email_update/route.ts: -------------------------------------------------------------------------------- 1 | import {type NextRequest} from 'next/server'; 2 | import {getServerSession} from 'next-auth/next'; 3 | import {authOptions} from '@/lib/auth'; 4 | import Salon from '@/app/models/salon'; 5 | import {getToken} from 'next-auth/jwt'; 6 | 7 | export async function POST(req: NextRequest) { 8 | try { 9 | const session = await getServerSession(authOptions); 10 | if (!session) { 11 | return new Response('Unauthorized', {status: 401}); 12 | } 13 | 14 | const json = await req.json(); 15 | 16 | const {newEmail, inputPassword} = json; 17 | const token = await getToken({req}); 18 | 19 | if (token?.email === newEmail) { 20 | return new Response( 21 | JSON.stringify({error: 'New email is the same as the old email'}), 22 | { 23 | status: 400, 24 | headers: {'Content-Type': 'application/json'}, 25 | } 26 | ); 27 | } 28 | 29 | const user = await Salon.findOne({email: token?.email}); 30 | if (!user.validatePassword(inputPassword)) { 31 | return new Response(JSON.stringify({error: 'Incorrect password'}), { 32 | status: 400, 33 | headers: {'Content-Type': 'application/json'}, 34 | }); 35 | } 36 | const possibleUser = await Salon.findOne({ 37 | email: newEmail, 38 | }); 39 | 40 | if (possibleUser) { 41 | return new Response(JSON.stringify({error: 'Email already in use'}), { 42 | status: 400, 43 | headers: {'Content-Type': 'application/json'}, 44 | }); 45 | } 46 | 47 | const update = { 48 | email: newEmail, 49 | }; 50 | console.log('updating account with, ', update); 51 | 52 | await Salon.findOneAndUpdate({email: session?.user?.email}, update); 53 | 54 | return new Response( 55 | JSON.stringify({ 56 | email: newEmail, 57 | }), 58 | { 59 | status: 200, 60 | headers: {'Content-Type': 'application/json'}, 61 | } 62 | ); 63 | } catch (error: any) { 64 | console.error('An error occurred when updating account email', error); 65 | return new Response(JSON.stringify({error: error.message}), { 66 | status: 500, 67 | headers: {'Content-Type': 'application/json'}, 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/api/request_capabilities/route.ts: -------------------------------------------------------------------------------- 1 | import {authOptions} from '@/lib/auth'; 2 | import {stripe} from '@/lib/stripe'; 3 | import {getServerSession} from 'next-auth'; 4 | import {NextRequest} from 'next/server'; 5 | 6 | export async function POST(req: NextRequest) { 7 | const session = await getServerSession(authOptions); 8 | if (!session) { 9 | return new Response('Unauthorized', {status: 401}); 10 | } 11 | const accountId = session.user.stripeAccountId; 12 | 13 | const json = await req.json(); 14 | const {capabilities} = json; 15 | 16 | try { 17 | await stripe.accounts.update(accountId, { 18 | capabilities, 19 | }); 20 | 21 | // If the user requested Treasury, create a financial account if none exists 22 | if (capabilities.treasury?.requested) { 23 | const financialAccounts = await stripe.treasury.financialAccounts.list( 24 | { 25 | limit: 1, 26 | }, 27 | { 28 | stripeAccount: accountId, 29 | } 30 | ); 31 | 32 | if (financialAccounts.data.length === 0) { 33 | await stripe.treasury.financialAccounts.create( 34 | { 35 | supported_currencies: ['usd'], 36 | features: { 37 | card_issuing: {requested: true}, 38 | deposit_insurance: {requested: true}, 39 | financial_addresses: {aba: {requested: true}}, 40 | inbound_transfers: {ach: {requested: true}}, 41 | intra_stripe_flows: {requested: true}, 42 | outbound_payments: { 43 | ach: {requested: true}, 44 | us_domestic_wire: {requested: true}, 45 | }, 46 | outbound_transfers: { 47 | ach: {requested: true}, 48 | us_domestic_wire: {requested: true}, 49 | }, 50 | }, 51 | }, 52 | {stripeAccount: accountId} 53 | ); 54 | } 55 | } 56 | 57 | return new Response('Success', { 58 | status: 200, 59 | headers: {'Content-Type': 'application/json'}, 60 | }); 61 | } catch (error: any) { 62 | console.error( 63 | 'An error occurred when calling the Stripe API to create a checkout session', 64 | error 65 | ); 66 | return new Response(error.message, {status: 500}); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /types/account.ts: -------------------------------------------------------------------------------- 1 | export const businessTypes = ['individual', 'company', 'other'] as const; 2 | export type BusinessType = (typeof businessTypes)[number]; 3 | 4 | export const countries = [ 5 | 'AL', 6 | 'AG', 7 | 'AR', 8 | 'AM', 9 | 'AU', 10 | 'AT', 11 | 'BS', 12 | 'BH', 13 | 'BE', 14 | 'BJ', 15 | 'BO', 16 | 'BA', 17 | 'BW', 18 | 'BR', 19 | 'BN', 20 | 'BG', 21 | 'KH', 22 | 'CA', 23 | 'CL', 24 | 'CO', 25 | 'CR', 26 | 'HR', 27 | 'CY', 28 | 'CZ', 29 | 'CI', 30 | 'DK', 31 | 'DO', 32 | 'EC', 33 | 'EG', 34 | 'SV', 35 | 'EE', 36 | 'ET', 37 | 'FI', 38 | 'FR', 39 | 'GM', 40 | 'DE', 41 | 'GH', 42 | 'GR', 43 | 'GT', 44 | 'GY', 45 | 'HK', 46 | 'HU', 47 | 'IS', 48 | 'IN', 49 | 'IE', 50 | 'IL', 51 | 'IT', 52 | 'JM', 53 | 'JP', 54 | 'JO', 55 | 'KE', 56 | 'KR', 57 | 'KW', 58 | 'LV', 59 | 'LI', 60 | 'LT', 61 | 'LU', 62 | 'MO', 63 | 'MG', 64 | 'MY', 65 | 'MT', 66 | 'MU', 67 | 'MX', 68 | 'MD', 69 | 'MC', 70 | 'MN', 71 | 'MA', 72 | 'NA', 73 | 'NL', 74 | 'NZ', 75 | 'NG', 76 | 'MK', 77 | 'NO', 78 | 'OM', 79 | 'PK', 80 | 'PA', 81 | 'PY', 82 | 'PE', 83 | 'PH', 84 | 'PL', 85 | 'PT', 86 | 'QA', 87 | 'RO', 88 | 'RW', 89 | 'LC', 90 | 'SA', 91 | 'SN', 92 | 'RS', 93 | 'SG', 94 | 'SK', 95 | 'SI', 96 | 'ZA', 97 | 'ES', 98 | 'LK', 99 | 'SE', 100 | 'CH', 101 | 'TW', 102 | 'TZ', 103 | 'TH', 104 | 'TT', 105 | 'TN', 106 | 'TR', 107 | 'AE', 108 | 'GB', 109 | 'US', 110 | 'UY', 111 | 'UZ', 112 | 'VN', 113 | ] as const; 114 | export type Country = (typeof countries)[number]; 115 | 116 | export const stripeDashboardTypes = ['none', 'full', 'express'] as const; 117 | export type StripeDashboardType = (typeof stripeDashboardTypes)[number]; 118 | 119 | export const paymentLosses = ['stripe', 'application'] as const; 120 | export type PaymentLosses = (typeof paymentLosses)[number]; 121 | 122 | export const feePayers = ['account', 'application'] as const; 123 | export type FeePayer = (typeof feePayers)[number]; 124 | 125 | export type ControllerProperties = { 126 | stripeDashboardType: StripeDashboardType; 127 | paymentLosses: PaymentLosses; 128 | feePayer: FeePayer; 129 | }; 130 | -------------------------------------------------------------------------------- /app/hooks/EmbeddedComponentBorderProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { 4 | createContext, 5 | useCallback, 6 | useContext, 7 | useEffect, 8 | useState, 9 | } from 'react'; 10 | 11 | type IEmbeddedComponentBorderContext = { 12 | enableBorder: boolean; 13 | handleEnableBorderChange: (enableBorder: boolean) => void; 14 | }; 15 | 16 | const EmbeddedComponentBorderContext = 17 | createContext({ 18 | enableBorder: false, 19 | handleEnableBorderChange: () => {}, 20 | }); 21 | 22 | export const useEmbeddedComponentBorder = () => { 23 | return useContext(EmbeddedComponentBorderContext); 24 | }; 25 | 26 | export const EmbeddedComponentBorderProvider = ({ 27 | children, 28 | }: { 29 | children: React.ReactNode; 30 | }) => { 31 | const localWindow = typeof window !== 'undefined' ? window : null; 32 | 33 | const [enableBorder, setEnableBorder] = useState( 34 | Boolean(Number(localWindow?.localStorage.getItem('enableBorder'))) 35 | ); 36 | 37 | const handleEnableBorderChange = useCallback( 38 | (enableBorder: boolean) => { 39 | if (!localWindow) { 40 | return; 41 | } 42 | 43 | if (enableBorder) { 44 | localWindow.localStorage.setItem('enableBorder', '1'); 45 | setEnableBorder(true); 46 | } else { 47 | localWindow.localStorage.setItem('enableBorder', '0'); 48 | setEnableBorder(false); 49 | } 50 | }, 51 | [localWindow] 52 | ); 53 | 54 | useEffect(() => { 55 | const handleToggleBorder = (e: KeyboardEvent) => { 56 | if (e.key === 'b' && e.metaKey && localWindow) { 57 | if (Number(localWindow.localStorage.getItem('enableBorder'))) { 58 | handleEnableBorderChange(false); 59 | } else { 60 | handleEnableBorderChange(true); 61 | } 62 | } 63 | }; 64 | 65 | // Keyboard shortcut to enable/disable border 66 | document.addEventListener('keydown', handleToggleBorder); 67 | () => document.removeEventListener('keydown', handleToggleBorder); 68 | }, [handleEnableBorderChange, localWindow]); 69 | 70 | return ( 71 | 74 | {children} 75 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /app/(dashboard)/settings/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {signOut} from 'next-auth/react'; 4 | import SubNav from '@/app/components/SubNav'; 5 | import {Button} from '@/components/ui/button'; 6 | import {useConnectJSContext} from '@/app/hooks/EmbeddedComponentProvider'; 7 | import {ExternalLink} from 'lucide-react'; 8 | import {useExpressDashboardLoginLink} from '@/app/hooks/useExpressDashboardLoginLink'; 9 | 10 | export default function SettingsLayout({ 11 | children, 12 | }: Readonly<{ 13 | children: React.ReactNode; 14 | }>) { 15 | const connectJSContext = useConnectJSContext(); 16 | 17 | const {hasExpressDashboardAccess, expressDashboardLoginLink} = 18 | useExpressDashboardLoginLink(); 19 | 20 | return ( 21 | <> 22 |
23 |
24 |

Your account

25 |
26 |
27 | 35 | {hasExpressDashboardAccess && ( 36 |
37 | 48 |
49 | )} 50 |
51 | 61 |
62 |
63 |
64 | {children} 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /public/stripe.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/stripe-gray.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {Inter as FontSans} from 'next/font/google'; 4 | import {cn} from '@/lib/utils'; 5 | import './globals.css'; 6 | import NextAuthProvider from './auth'; 7 | import DebugMenu from '@/app/components/debug/DebugMenu'; 8 | import {SettingsProvider} from '@/app/contexts/settings'; 9 | import {EmbeddedComponentBorderProvider} from '@/app/hooks/EmbeddedComponentBorderProvider'; 10 | import QueryProvider from '@/app/providers/QueryProvider'; 11 | import {useSession} from 'next-auth/react'; 12 | import {useEffect} from 'react'; 13 | 14 | const fontSans = FontSans({ 15 | subsets: ['latin'], 16 | variable: '--font-sans', 17 | }); 18 | 19 | function DynamicTitle() { 20 | const {data: session} = useSession(); 21 | 22 | useEffect(() => { 23 | const companyName = session?.user?.companyName || 'Furever'; 24 | document.title = 25 | companyName === 'Furever' ? companyName : `(DEMO) ${companyName}`; 26 | }, [session?.user?.companyName]); 27 | 28 | return null; 29 | } 30 | 31 | function DynamicFavicon() { 32 | const {data: session} = useSession(); 33 | const defaultFavicon = '/favicon.png'; 34 | 35 | useEffect(() => { 36 | const companyLogo = session?.user?.companyLogoUrl; 37 | const link = 38 | (document.querySelector("link[rel*='icon']") as HTMLLinkElement) || 39 | document.createElement('link'); 40 | link.type = 'image/x-icon'; 41 | link.rel = 'shortcut icon'; 42 | link.href = companyLogo || defaultFavicon; 43 | document.getElementsByTagName('head')[0].appendChild(link); 44 | }, [session?.user?.companyLogoUrl]); 45 | 46 | return null; 47 | } 48 | 49 | export default function RootLayout({ 50 | children, 51 | }: Readonly<{ 52 | children: React.ReactNode; 53 | }>) { 54 | return ( 55 | 56 | 57 | Furever 58 | 59 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | {children} 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /public/pose_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/pose_red.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/(dashboard)/settings/tax/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Container from '@/app/components/Container'; 4 | import EmbeddedComponentContainer from '@/app/components/EmbeddedComponentContainer'; 5 | import { 6 | ConnectTaxSettings, 7 | ConnectTaxRegistrations, 8 | ConnectTaxThresholdMonitoring, 9 | ConnectExportTaxTransactions, 10 | } from '@stripe/react-connect-js'; 11 | import {arePreviewComponentsEnabled} from '../../utils/arePreviewComponentsEnabled'; 12 | 13 | export default function Tax() { 14 | return ( 15 | <> 16 | 17 |

Tax

18 |

19 | Configure these settings to automatically calculate and collect tax on 20 | your payments. 21 |

22 | 23 | 24 | 25 |
26 | 27 |

Tax registrations

28 |

29 | Locations where you have a registration, and want to collect taxes. 30 |

31 | 32 | 33 | 34 |
35 | {arePreviewComponentsEnabled && ( 36 | <> 37 | 38 |

Threshold Monitoring

39 |

40 | Sales tracked by tax location. Locations where thresholds have 41 | been exceeded may require registering to collect taxes. 42 |

43 | 47 | 48 | 49 |
50 | 51 |

Export tax transactions

52 |

53 | Retrieve and export your tax transactions for reporting and 54 | analysis purposes, ensuring compliance with tax regulations. 55 |

56 | 57 | 58 | 59 |
60 | 61 | )} 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /app/data/pets.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Kiba", 4 | "date": "June 12", 5 | "profilePhoto": "kiba" 6 | }, 7 | { 8 | "name": "Udon", 9 | "date": "Apr 24", 10 | "profilePhoto": "udon" 11 | }, 12 | { 13 | "name": "Rowdy", 14 | "date": "Jan 31", 15 | "profilePhoto": "rowdy" 16 | }, 17 | { 18 | "name": "Bisco", 19 | "date": "Jun 18", 20 | "profilePhoto": "bisco" 21 | }, 22 | { 23 | "name": "Apple", 24 | "date": "Mar 22", 25 | "profilePhoto": "apple" 26 | }, 27 | { 28 | "name": "Waffles", 29 | "date": "Jan 4", 30 | "profilePhoto": "waffles" 31 | }, 32 | { 33 | "name": "Hot Fudge", 34 | "date": "April 19", 35 | "profilePhoto": "hot-fudge" 36 | }, 37 | { 38 | "name": "Stephen", 39 | "date": "May 7", 40 | "profilePhoto": "stephen" 41 | }, 42 | { 43 | "name": "Coco", 44 | "date": "Aug 31", 45 | "profilePhoto": "coco" 46 | }, 47 | { 48 | "name": "Pebbleton", 49 | "date": "Feb 29", 50 | "profilePhoto": "pebbleton" 51 | }, 52 | { 53 | "name": "Dahlia", 54 | "date": "Sept 1", 55 | "profilePhoto": "dahlia" 56 | }, 57 | { 58 | "name": "Misiu", 59 | "date": "Nov 22", 60 | "profilePhoto": "misiu" 61 | }, 62 | { 63 | "name": "Hobbes", 64 | "date": "Oct 9", 65 | "profilePhoto": "hobbes" 66 | }, 67 | { 68 | "name": "Jasper", 69 | "date": "Dec 10", 70 | "profilePhoto": "jasper" 71 | }, 72 | { 73 | "name": "Henley", 74 | "date": "Feb 3", 75 | "profilePhoto": "henley" 76 | }, 77 | { 78 | "name": "Maple", 79 | "date": "Aug 9", 80 | "profilePhoto": "maple" 81 | }, 82 | { 83 | "name": "Shorty", 84 | "date": "Aug 13", 85 | "profilePhoto": "shorty" 86 | }, 87 | { 88 | "name": "Juney", 89 | "date": "Oct 14", 90 | "profilePhoto": "juney" 91 | }, 92 | { 93 | "name": "Jake", 94 | "date": "Mar 1", 95 | "profilePhoto": "jake" 96 | }, 97 | { 98 | "name": "Miko", 99 | "date": "Nov 23", 100 | "profilePhoto": "miko" 101 | }, 102 | { 103 | "name": "Feifei and Celery", 104 | "date": "Jan 18", 105 | "profilePhoto": "feifei_celery" 106 | }, 107 | { 108 | "name": "Doudou", 109 | "date": "May 5", 110 | "profilePhoto": "doudou" 111 | }, 112 | { 113 | "name": "Cedar", 114 | "date": "July 1", 115 | "profilePhoto": "cedar" 116 | }, 117 | { 118 | "name": "Rambo", 119 | "date": "Dec 11", 120 | "profilePhoto": "rambo" 121 | }, 122 | { 123 | "name": "Willow", 124 | "date": "July 25", 125 | "profilePhoto": "willow" 126 | } 127 | ] 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "furever", 3 | "description": "This is a test app built by Stripe: Furever is a SaaS platform for pet groomers, meant to showcase functionality that can be achieved by using Stripe Connect", 4 | "version": "0.1.0", 5 | "private": true, 6 | "engines": { 7 | "node": "22.x" 8 | }, 9 | "scripts": { 10 | "dev": "next dev", 11 | "build": "next build", 12 | "start": "next start", 13 | "lint": "next lint && npm run prettier-check", 14 | "prettier": "prettier './**/*.{js,ts,md,html,css,tsx}' --write", 15 | "prettier-check": "prettier './**/*.{js,ts,md,html,tsx}' --check", 16 | "typescript": "tsc -p .", 17 | "validate-change": "yarn lint && yarn typescript" 18 | }, 19 | "dependencies": { 20 | "@emotion/react": "^11.11.4", 21 | "@emotion/styled": "^11.11.5", 22 | "@hookform/resolvers": "^3.3.4", 23 | "@mui/material": "^5.15.14", 24 | "@mui/x-charts": "^7.1.0", 25 | "@radix-ui/react-avatar": "^1.0.4", 26 | "@radix-ui/react-dialog": "^1.0.5", 27 | "@radix-ui/react-icons": "^1.3.0", 28 | "@radix-ui/react-label": "^2.0.2", 29 | "@radix-ui/react-navigation-menu": "^1.1.4", 30 | "@radix-ui/react-radio-group": "^1.1.3", 31 | "@radix-ui/react-select": "^2.0.0", 32 | "@radix-ui/react-slot": "^1.0.2", 33 | "@radix-ui/react-switch": "^1.0.3", 34 | "@radix-ui/react-tabs": "^1.0.4", 35 | "@stripe/connect-js": "3.3.42-preview-1", 36 | "@stripe/react-connect-js": "3.3.41-preview-1", 37 | "bcrypt": "^5.1.1", 38 | "bcryptjs": "^2.4.3", 39 | "class-variance-authority": "^0.7.0", 40 | "clsx": "^2.1.0", 41 | "cmdk": "^1.0.0", 42 | "lucide-react": "^0.363.0", 43 | "micro-cors": "^0.1.1", 44 | "mongoose": "^5.11.9", 45 | "next": "14.1.4", 46 | "next-auth": "^4.24.7", 47 | "random-words": "^2.0.1", 48 | "react": "^18", 49 | "react-dom": "^18", 50 | "react-hook-form": "^7.51.1", 51 | "sharp": "^0.33.4", 52 | "stripe": "20.2.0-alpha.1", 53 | "tailwind-merge": "^2.2.2", 54 | "tailwindcss-animate": "^1.0.7", 55 | "yarn": "^1.22.22", 56 | "zod": "^3.22.4" 57 | }, 58 | "devDependencies": { 59 | "@tanstack/react-query": "^5.84.2", 60 | "@tanstack/react-query-devtools": "^5.84.2", 61 | "@types/bcrypt": "^5.0.2", 62 | "@types/bcryptjs": "^2.4.6", 63 | "@types/express": "^4.17.21", 64 | "@types/node": "^20", 65 | "@types/react": "^18", 66 | "@types/react-dom": "^18", 67 | "autoprefixer": "^10.4.19", 68 | "css-loader": "^7.1.1", 69 | "eslint": "^8", 70 | "eslint-config-next": "14.1.4", 71 | "postcss": "^8", 72 | "prettier": "^3.2.5", 73 | "prettier-plugin-tailwindcss": "^0.5.13", 74 | "tailwindcss": "^3.3.0", 75 | "typescript": "5.4.3" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/(auth)/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'; 3 | import {Sparkles, KeyRound, Pencil} from 'lucide-react'; 4 | import Form from './form'; 5 | import QuickstartButton from '@/app/components/QuickstartButton'; 6 | import {getServerSession} from 'next-auth'; 7 | import {redirect} from 'next/navigation'; 8 | 9 | export default async function Signup() { 10 | const session = await getServerSession(); 11 | 12 | if (session) { 13 | redirect('/home'); 14 | } 15 | 16 | return ( 17 | <> 18 |
19 |

Get started

20 | 24 | 25 | 29 | Quickstart 30 | 31 | 35 | Create an account 36 | 37 | 38 | 39 | 40 | 41 | 45 |
46 | 47 |

48 | Skip account onboarding and go straight to dashboard. 49 |

50 |
51 |
52 | 53 |

54 | A random username and password will be chosen for you. 55 |

56 |
57 |
58 | 59 |

60 | You can update the username and password to something memorable. 61 |

62 |
63 | 64 |
65 |
66 |
67 |
68 | Already have an account?{' '} 69 | 70 | Log in 71 | 72 |
73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /app/components/Screen.tsx: -------------------------------------------------------------------------------- 1 | import Nav from '@/app/components/Nav'; 2 | import Container from '@/app/components/Container'; 3 | import { 4 | useToolsContext, 5 | ToolsPanelProvider, 6 | } from '@/app/hooks/ToolsPanelProvider'; 7 | import ToolsPanel from '@/app/components/ToolsPanel'; 8 | import OnboardingDialog from '../components/OnboardingDialog'; 9 | import {useSettings} from '../hooks/useSettings'; 10 | import {SettingsContext} from '../contexts/settings'; 11 | import {useContext} from 'react'; 12 | import {hasCustomBranding} from '@/lib/utils'; 13 | 14 | export default function Screen({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | const {open, handleOpenChange} = useToolsContext(); 20 | const settings = useContext(SettingsContext); 21 | const hasCustomBrandingValues = hasCustomBranding(settings); 22 | 23 | const getBackground = () => { 24 | if (settings.theme === 'light') { 25 | if (hasCustomBrandingValues) { 26 | return 'bg-screen-custom'; 27 | } else { 28 | return 'bg-paw-pattern bg-[size:426px]'; 29 | } 30 | } else { 31 | return 'bg-screen-background'; 32 | } 33 | }; 34 | 35 | return ( 36 |
37 |
40 | {/* Tools Panel container */} 41 |
45 | 46 |
47 | 48 | {/* Furever site container */} 49 |
54 |
62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /app/data/schedule.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "groomer": "Catherine", 5 | "sessions": [ 6 | { 7 | "id": 1, 8 | "name": "Wash and groom", 9 | "startTime": "9:30am", 10 | "endTime": "10:20am", 11 | "startTimeMinutes": 30, 12 | "endTimeMinutes": 80, 13 | "pet": "Udon", 14 | "profilePhoto": "udon", 15 | "petType": "dog" 16 | }, 17 | { 18 | "id": 2, 19 | "name": "Deluxe wash and groom", 20 | "startTime": "10:30am", 21 | "endTime": "11:30am", 22 | "startTimeMinutes": 90, 23 | "endTimeMinutes": 150, 24 | "pet": "Kiba", 25 | "profilePhoto": "kiba", 26 | "petType": "dog" 27 | }, 28 | { 29 | "id": 3, 30 | "name": "Full service – large dog", 31 | "startTime": "12:30pm", 32 | "endTime": "1:30pm", 33 | "startTimeMinutes": 210, 34 | "endTimeMinutes": 270, 35 | "pet": "Rowdy", 36 | "profilePhoto": "rowdy", 37 | "petType": "dog" 38 | }, 39 | { 40 | "id": 4, 41 | "name": "Deshedding", 42 | "startTime": "2:00pm", 43 | "endTime": "2:50pm", 44 | "startTimeMinutes": 300, 45 | "endTimeMinutes": 350, 46 | "pet": "Apple", 47 | "profilePhoto": "apple", 48 | "petType": "cat" 49 | } 50 | ] 51 | }, 52 | { 53 | "id": "2", 54 | "groomer": "Michael", 55 | "sessions": [ 56 | { 57 | "id": 1, 58 | "name": "Full service – medium cat", 59 | "startTime": "10:00am", 60 | "endTime": "10:45am", 61 | "startTimeMinutes": 60, 62 | "endTimeMinutes": 105, 63 | "pet": "Stephen", 64 | "profilePhoto": "stephen", 65 | "petType": "cat" 66 | }, 67 | { 68 | "id": 2, 69 | "name": "Wash and groom", 70 | "startTime": "11:00am", 71 | "endTime": "11:50am", 72 | "startTimeMinutes": 120, 73 | "endTimeMinutes": 170, 74 | "pet": "Juney", 75 | "profilePhoto": "juney", 76 | "petType": "dog" 77 | }, 78 | { 79 | "id": 3, 80 | "name": "Full service – medium cat", 81 | "startTime": "12:00pm", 82 | "endTime": "12:50pm", 83 | "startTimeMinutes": 180, 84 | "endTimeMinutes": 230, 85 | "pet": "Bisco", 86 | "profilePhoto": "bisco", 87 | "petType": "cat" 88 | }, 89 | { 90 | "id": 4, 91 | "name": "Deluxe wash and groom", 92 | "startTime": "1:30pm", 93 | "endTime": "2:30pm", 94 | "startTimeMinutes": 270, 95 | "endTimeMinutes": 330, 96 | "pet": "Jake", 97 | "profilePhoto": "jake", 98 | "petType": "dog" 99 | } 100 | ] 101 | } 102 | ] 103 | -------------------------------------------------------------------------------- /app/api/company_name/route.ts: -------------------------------------------------------------------------------- 1 | import Salon from '@/app/models/salon'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {getServerSession} from 'next-auth'; 4 | 5 | export async function GET() { 6 | try { 7 | const session = await getServerSession(authOptions); 8 | 9 | if (!session?.user?.email) { 10 | return new Response('Unauthorized', {status: 401}); 11 | } 12 | 13 | const salon = await Salon.findOne({email: session.user.email}); 14 | 15 | if (!salon) { 16 | return new Response('Salon not found', {status: 404}); 17 | } 18 | 19 | return new Response(JSON.stringify({companyName: salon.companyName}), { 20 | status: 200, 21 | headers: {'Content-Type': 'application/json'}, 22 | }); 23 | } catch (error: any) { 24 | console.error('Error fetching company name:', error); 25 | return new Response(error.message, {status: 500}); 26 | } 27 | } 28 | 29 | export async function POST(request: Request) { 30 | try { 31 | const session = await getServerSession(authOptions); 32 | 33 | if (!session?.user?.email) { 34 | return new Response('Unauthorized', {status: 401}); 35 | } 36 | 37 | const {companyName} = await request.json(); 38 | 39 | if (!companyName) { 40 | return new Response('Company name is required', {status: 400}); 41 | } 42 | 43 | if (companyName.length > 100) { 44 | return new Response('Company name must be less than 100 characters', { 45 | status: 400, 46 | }); 47 | } 48 | 49 | const updatedSalon = await Salon.findOneAndUpdate( 50 | {email: session.user.email}, 51 | {companyName: companyName}, 52 | {new: true} 53 | ); 54 | 55 | if (!updatedSalon) { 56 | return new Response('Salon not found', {status: 404}); 57 | } 58 | 59 | return new Response(JSON.stringify({success: true, companyName}), { 60 | status: 200, 61 | headers: {'Content-Type': 'application/json'}, 62 | }); 63 | } catch (error: any) { 64 | console.error('An error occurred when updating the company name:', error); 65 | return new Response(error.message, {status: 500}); 66 | } 67 | } 68 | 69 | export async function DELETE() { 70 | try { 71 | const session = await getServerSession(authOptions); 72 | 73 | if (!session?.user?.email) { 74 | return new Response('Unauthorized', {status: 401}); 75 | } 76 | 77 | const updatedSalon = await Salon.findOneAndUpdate( 78 | {email: session.user.email}, 79 | {$unset: {companyName: 1}}, 80 | {new: true} 81 | ); 82 | 83 | if (!updatedSalon) { 84 | return new Response('Salon not found', {status: 404}); 85 | } 86 | 87 | return new Response(JSON.stringify({success: true}), { 88 | status: 200, 89 | headers: {'Content-Type': 'application/json'}, 90 | }); 91 | } catch (error: any) { 92 | console.error('An error occurred when deleting the company name:', error); 93 | return new Response(error.message, {status: 500}); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /app/hooks/useConnect.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useMemo, useState, useCallback} from 'react'; 2 | import {type StripeConnectInstance} from '@stripe/connect-js'; 3 | import {loadConnectAndInitialize} from '@stripe/connect-js'; 4 | import {useSettings} from '@/app/hooks/useSettings'; 5 | import { 6 | DarkTheme, 7 | defaultPrimaryColor, 8 | LightTheme, 9 | } from '@/app/contexts/themes/ThemeConstants'; 10 | 11 | export const useConnect = () => { 12 | const [hasError, setHasError] = useState(false); 13 | const [stripeConnectInstance, setStripeConnectInstance] = 14 | useState(null); 15 | 16 | const settings = useSettings(); 17 | const locale = settings.locale; 18 | const theme = settings.theme; 19 | const overlay = settings.overlay; 20 | const primaryColor = settings.primaryColor || defaultPrimaryColor; 21 | 22 | const fetchClientSecret = useCallback(async () => { 23 | // Fetch the AccountSession client secret 24 | const response = await fetch('/api/account_session', { 25 | method: 'POST', 26 | body: JSON.stringify({}), 27 | }); 28 | if (!response.ok) { 29 | // Handle errors on the client side here 30 | const {error} = await response.json(); 31 | console.warn('An error occurred: ', error); 32 | setHasError(true); 33 | return undefined; 34 | } else { 35 | const {client_secret: clientSecret} = await response.json(); 36 | setHasError(false); 37 | return clientSecret; 38 | } 39 | }, []); 40 | 41 | const appearanceVariables = useMemo(() => { 42 | const baseTheme = theme === 'dark' ? DarkTheme : LightTheme; 43 | 44 | // If we have a custom primary color, override the theme colors 45 | if (primaryColor && primaryColor !== defaultPrimaryColor) { 46 | return { 47 | ...baseTheme, 48 | colorPrimary: primaryColor, 49 | buttonPrimaryColorBackground: primaryColor, 50 | }; 51 | } 52 | 53 | return baseTheme; 54 | }, [theme, primaryColor]); 55 | 56 | useEffect(() => { 57 | if (stripeConnectInstance) { 58 | stripeConnectInstance.update({ 59 | appearance: { 60 | overlays: overlay || 'dialog', 61 | variables: appearanceVariables || LightTheme, 62 | }, 63 | locale, 64 | }); 65 | } else { 66 | const instance = loadConnectAndInitialize({ 67 | publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY!, 68 | appearance: { 69 | overlays: overlay || 'dialog', 70 | variables: appearanceVariables || LightTheme, 71 | }, 72 | locale, 73 | fetchClientSecret: async () => { 74 | return await fetchClientSecret(); 75 | }, 76 | }); 77 | 78 | setStripeConnectInstance(instance); 79 | } 80 | }, [ 81 | stripeConnectInstance, 82 | locale, 83 | fetchClientSecret, 84 | appearanceVariables, 85 | overlay, 86 | ]); 87 | 88 | return { 89 | hasError, 90 | stripeConnectInstance, 91 | }; 92 | }; 93 | -------------------------------------------------------------------------------- /app/components/testdata/Financing/ManageFinancing.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {OfferState} from './types'; 3 | import {CreateFlexLoanButton} from './CreateFlexLoanButton'; 4 | import {LoaderCircle} from 'lucide-react'; 5 | import {TransitionFinancingButton} from './TransitionFinancingButton'; 6 | 7 | export default function ManageFinancing({classes}: {classes?: string}) { 8 | const [loading, setLoading] = React.useState(true); 9 | const [offerState, setOfferState] = React.useState( 10 | undefined 11 | ); 12 | 13 | const response = React.useCallback(() => { 14 | if (offerState === undefined) { 15 | fetch('/api/capital/get_financing_offer', { 16 | method: 'get', 17 | }) 18 | .then(async (res) => { 19 | const status = (await res.json()).offer?.status; 20 | setOfferState(status || 'no_offer'); 21 | }) 22 | .catch(async (err) => { 23 | // Handle errors on the client side here 24 | console.warn('An error occurred: ', err); 25 | }) 26 | .finally(() => { 27 | setLoading(false); 28 | }); 29 | } 30 | }, [offerState]); 31 | response(); 32 | 33 | if (!offerState && !loading) { 34 | return undefined; 35 | } 36 | 37 | return ( 38 | <> 39 | {!loading && offerState && ( 40 | <> 41 | 54 | 55 | 62 | 63 | 70 | 77 | 78 | 85 | 86 | )} 87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /app/api/company_logo/route.ts: -------------------------------------------------------------------------------- 1 | import Salon from '@/app/models/salon'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | import {getServerSession} from 'next-auth'; 5 | 6 | export async function POST(request: Request) { 7 | try { 8 | const session = await getServerSession(authOptions); 9 | 10 | if (!session?.user?.email) { 11 | return new Response('Unauthorized', {status: 401}); 12 | } 13 | 14 | const formData = await request.formData(); 15 | const file = formData.get('file') as File; 16 | 17 | if (!file) { 18 | return new Response('No file uploaded', {status: 400}); 19 | } 20 | 21 | // Validate file size (max 5MB) 22 | if (file.size > 5 * 1024 * 1024) { 23 | return new Response('File size must be less than 5MB', {status: 400}); 24 | } 25 | 26 | // Validate file type (only images) 27 | if (!file.type.startsWith('image/')) { 28 | return new Response('Only image files are allowed', {status: 400}); 29 | } 30 | 31 | const arrayBuffer = await file.arrayBuffer(); 32 | const buffer = Buffer.from(arrayBuffer); 33 | 34 | const stripeFile = await stripe.files.create({ 35 | file: { 36 | data: buffer, 37 | name: file.name, 38 | type: file.type, 39 | }, 40 | purpose: 'business_logo', 41 | }); 42 | 43 | const fileLink = await stripe.fileLinks.create({ 44 | file: stripeFile.id, 45 | }); 46 | 47 | const fileUrl = fileLink.url; 48 | 49 | const updatedSalon = await Salon.findOneAndUpdate( 50 | {email: session.user.email}, 51 | {companyLogoUrl: fileUrl}, 52 | {new: true} 53 | ); 54 | 55 | if (!updatedSalon) { 56 | return new Response('Salon not found', {status: 404}); 57 | } 58 | 59 | return new Response( 60 | JSON.stringify({success: true, companyLogoUrl: fileUrl}), 61 | { 62 | status: 200, 63 | headers: {'Content-Type': 'application/json'}, 64 | } 65 | ); 66 | } catch (error: any) { 67 | console.error('An error occurred when uploading company logo:', error); 68 | return new Response(error.message, {status: 500}); 69 | } 70 | } 71 | 72 | export async function DELETE(request: Request) { 73 | try { 74 | const session = await getServerSession(authOptions); 75 | 76 | if (!session?.user?.email) { 77 | return new Response('Unauthorized', {status: 401}); 78 | } 79 | 80 | const updatedSalon = await Salon.findOneAndUpdate( 81 | {email: session.user.email}, 82 | {companyLogoUrl: null}, 83 | {new: true} 84 | ); 85 | 86 | if (!updatedSalon) { 87 | return new Response('Salon not found', {status: 404}); 88 | } 89 | 90 | return new Response(JSON.stringify({success: true}), { 91 | status: 200, 92 | headers: {'Content-Type': 'application/json'}, 93 | }); 94 | } catch (error: any) { 95 | console.error('An error occurred when deleting company logo:', error); 96 | return new Response(error.message, {status: 500}); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/(dashboard)/home/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | import Schedule from '@/app/components/Schedule'; 4 | import MonthToDateWidget from '@/app/components/MonthToDateWidget'; 5 | import CustomersWidget from '@/app/components/CustomersWidget'; 6 | import EmbeddedComponentContainer from '@/app/components/EmbeddedComponentContainer'; 7 | import { 8 | ConnectNotificationBanner, 9 | ConnectBalances, 10 | } from '@stripe/react-connect-js'; 11 | import {useSession} from 'next-auth/react'; 12 | import {redirect} from 'next/navigation'; 13 | import Container from '@/app/components/Container'; 14 | import {CapitalFinancingPromotionSection} from '@/app/components/CapitalFinancingPromotionSection'; 15 | import {useGetStripeAccount} from '@/app/hooks/useGetStripeAccount'; 16 | 17 | export default function Dashboard() { 18 | const {data: session} = useSession(); 19 | if (!session) { 20 | redirect('/'); 21 | } 22 | const {stripeAccount} = useGetStripeAccount(); 23 | 24 | const BREAKPOINT = 1190; 25 | const [showBanner, setShowBanner] = React.useState(false); 26 | 27 | const renderConditionallyCallback = (response: { 28 | total: number; 29 | actionRequired: number; 30 | }) => { 31 | if (response && response.total > 0) { 32 | setShowBanner(true); 33 | } else { 34 | setShowBanner(false); 35 | } 36 | }; 37 | 38 | return ( 39 | <> 40 |

41 | Woof woof, {stripeAccount?.individual?.first_name || 'human'}! 42 |

43 |
44 | 48 | 51 | 52 |
53 |
54 | 55 | 56 | 57 |
58 |
59 | 60 | 61 | 62 | 63 | 64 | 68 |
69 |

70 | Performance 71 |

72 |
73 | 74 | 75 |
76 |
77 |
78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image from 'next/image'; 4 | import Container from '@/app/components/Container'; 5 | import FureverLogo from '@/public/furever_logo.png'; 6 | import Stripe from '@/public/stripe-gray.svg'; 7 | import Link from 'next/link'; 8 | import {signOut} from 'next-auth/react'; 9 | import {useSession} from 'next-auth/react'; 10 | import {Button} from '@/components/ui/button'; 11 | import {hasCustomBranding} from '@/lib/utils'; 12 | import {SettingsContext} from '../contexts/settings'; 13 | import {useContext} from 'react'; 14 | 15 | export default function AuthLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode; 19 | }>) { 20 | const {data, status} = useSession(); 21 | const settings = useContext(SettingsContext); 22 | const hasCustomBrandingValues = hasCustomBranding(settings); 23 | 24 | const SignOut = () => { 25 | if (status == 'unauthenticated') { 26 | return; 27 | } 28 | 29 | return ( 30 |

31 | Signed in as {data?.user?.email}.{' '} 32 | 39 |

40 | ); 41 | }; 42 | 43 | return ( 44 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type {Config} from 'tailwindcss'; 2 | 3 | const config = { 4 | darkMode: ['class'], 5 | important: true, 6 | content: [ 7 | './pages/**/*.{ts,tsx}', 8 | './components/**/*.{ts,tsx}', 9 | './app/**/*.{ts,tsx}', 10 | './src/**/*.{ts,tsx}', 11 | ], 12 | prefix: '', 13 | theme: { 14 | container: { 15 | center: true, 16 | padding: '2rem', 17 | screens: { 18 | '2xl': '1400px', 19 | }, 20 | }, 21 | extend: { 22 | colors: { 23 | border: 'hsl(var(--border))', 24 | input: 'hsl(var(--input))', 25 | ring: 'hsl(var(--ring))', 26 | background: 'hsl(var(--background))', 27 | banner: '#0e5b2f', 28 | foreground: 'hsl(var(--foreground))', 29 | dialog: { 30 | background: 'var(--dialog-background)', 31 | content: 'hsl(var--dialog-content)', 32 | }, 33 | primary: { 34 | DEFAULT: 'var(--primary)', 35 | foreground: 'var(--primary-foreground)', 36 | }, 37 | secondary: { 38 | DEFAULT: 'var(--subdued)', 39 | }, 40 | offset: { 41 | DEFAULT: 'var(--offset)', 42 | }, 43 | subdued: { 44 | DEFAULT: 'var(--subdued)', 45 | foreground: 'var(--subdued-foreground)', 46 | }, 47 | destructive: { 48 | DEFAULT: 'var(--destructive)', 49 | foreground: 'var(--destructive-foreground)', 50 | }, 51 | accent: { 52 | DEFAULT: 'var(--accent)', 53 | subdued: 'var(--accent-subdued)', 54 | foreground: 'var(--accent-foreground)', 55 | }, 56 | success: { 57 | DEFAULT: 'var(--success)', 58 | border: 'var(--success-border)', 59 | foreground: 'var(--success-foreground)', 60 | }, 61 | fill: 'var(--primary)', 62 | tools: { 63 | background: 'var(--tools-background)', 64 | }, 65 | screen: { 66 | background: 'var(--screen-background)', 67 | foreground: 'var(--screen-foreground)', 68 | custom: 'var(--offset)', 69 | }, 70 | component: { 71 | DEFAULT: 'var(--embedded-component)', 72 | hover: 'var(--embedded-component-hover)', 73 | }, 74 | }, 75 | borderRadius: { 76 | lg: 'var(--radius)', 77 | md: 'calc(var(--radius) - 2px)', 78 | sm: 'calc(var(--radius) - 4px)', 79 | }, 80 | transitionProperty: { 81 | border: 'border-color', 82 | }, 83 | backgroundImage: { 84 | 'paw-pattern': "url('/pattern.png')", 85 | 'paw-pattern-white': "url('/pattern-white.png')", 86 | 'dot-grid': "url('/dot-grid.png')", 87 | 'dot-grid-dark': "url('/dot-grid-dark.png')", 88 | }, 89 | boxShadow: { 90 | md: '0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -4px rgba(0, 0, 0, 0.08)', 91 | }, 92 | }, 93 | fontFamily: { 94 | sans: ['Sohne', 'ui-sans-serif', 'system-ui', 'sans-serif'], 95 | mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'], 96 | }, 97 | }, 98 | plugins: [require('tailwindcss-animate')], 99 | } satisfies Config; 100 | 101 | export default config; 102 | -------------------------------------------------------------------------------- /app/models/salon.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import bcrypt from 'bcryptjs'; 3 | 4 | const Schema = mongoose.Schema; 5 | 6 | const salonSchemaName = 'SalonV3'; 7 | 8 | // Define the Salon schema. 9 | const SalonSchema = new Schema({ 10 | email: { 11 | type: String, 12 | required: true, 13 | unique: true, 14 | validate: { 15 | // Custom validator to check if the email was already used. 16 | validator: SalonEmailValidator, 17 | message: 'This email already exists. Please try to log in instead.', 18 | }, 19 | }, 20 | password: { 21 | type: String, 22 | required: true, 23 | }, 24 | firstName: String, 25 | lastName: String, 26 | // Stripe account ID to send payments obtained with Stripe Connect. 27 | stripeAccountId: String, 28 | // Can be no_dashboard_soll, no_dashboard_poll, dashboard_soll. Default is no_dashboard_soll 29 | accountConfig: String, 30 | businessName: String, 31 | quickstartAccount: Boolean, 32 | changedPassword: Boolean, 33 | setup: Boolean, 34 | primaryColor: String, 35 | companyName: String, // Custom company name to replace "Furever" 36 | companyLogoUrl: String, // URL to custom company logo uploaded to Stripe 37 | }); 38 | 39 | // Check the email address to make sure it's unique (no existing salon with that address). 40 | function SalonEmailValidator(email: string) { 41 | // Asynchronously resolve a promise to validate whether an email already exists 42 | return new Promise((resolve, reject) => { 43 | // Only check model updates for new salons (or if the email address is updated). 44 | // @ts-ignore - 'this' implicitly has type 'any' because it does not have a type annotation.ts(2683) 45 | if (this.isNew || this.isModified('email')) { 46 | // Try to find a matching salon 47 | Salon.findOne({email}).exec((err, salon) => { 48 | // Handle errors 49 | if (err) { 50 | console.log(err); 51 | resolve(false); 52 | return; 53 | } 54 | // Validate depending on whether a matching salon exists. 55 | if (salon) { 56 | resolve(false); 57 | } else { 58 | resolve(true); 59 | } 60 | }); 61 | } else { 62 | resolve(true); 63 | } 64 | }); 65 | } 66 | 67 | // Generate a password hash (with an auto-generated salt for simplicity here). 68 | SalonSchema.methods.generateHash = function (password) { 69 | return bcrypt.hashSync(password, 8); 70 | }; 71 | 72 | // Check if the password is valid by comparing with the stored hash. 73 | SalonSchema.methods.validatePassword = function (password) { 74 | if (!this.changedPassword) { 75 | return password == this.password; 76 | } 77 | return bcrypt.compareSync(password, this.password); 78 | }; 79 | 80 | // Pre-save hook to define some default properties for salons. 81 | SalonSchema.pre('save', function (next) { 82 | // Make sure the password is hashed before being stored. 83 | if (this.isModified('password') && !this.quickstartAccount) { 84 | this.password = this.generateHash(this.password); 85 | } else if (this.quickstartAccount) { 86 | this.password = this.password; 87 | } 88 | next(); 89 | }); 90 | 91 | const Salon = 92 | mongoose.models[salonSchemaName] || 93 | mongoose.model(salonSchemaName, SalonSchema); 94 | 95 | export default Salon; 96 | -------------------------------------------------------------------------------- /app/api/setup_accounts/route.ts: -------------------------------------------------------------------------------- 1 | import Salon from '@/app/models/salon'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | import {getServerSession} from 'next-auth'; 5 | 6 | function getRandomInt(min: number, max: number) { 7 | return Math.floor(Math.random() * (max - min + 1)) + min; 8 | } 9 | 10 | export async function POST() { 11 | try { 12 | const session = await getServerSession(authOptions); 13 | if (!session) { 14 | return new Response('The current route requires authentication', { 15 | status: 403, 16 | }); 17 | } 18 | 19 | const accountId = session.user.stripeAccountId; 20 | 21 | // Wait for account verification to complete 22 | while (true) { 23 | const account = await stripe.accounts.retrieve(accountId); 24 | 25 | if ( 26 | account.requirements?.disabled_reason !== 27 | 'requirements.pending_verification' 28 | ) { 29 | console.log('Account verification completed'); 30 | break; 31 | } 32 | 33 | console.log( 34 | 'Account still pending verification, checking again in 1 second' 35 | ); 36 | await new Promise((resolve) => setTimeout(resolve, 1000)); 37 | } 38 | 39 | const charges = await stripe.charges.list( 40 | { 41 | limit: 1, 42 | }, 43 | { 44 | stripeAccount: session?.user.stripeAccountId, 45 | } 46 | ); 47 | const chargeCount = charges.data.length; 48 | if (chargeCount > 0) { 49 | return new Response('Already setup', {status: 200}); 50 | } 51 | 52 | for (let i = 0; i < 10; i++) { 53 | await stripe.paymentIntents.create( 54 | { 55 | amount: getRandomInt(5000, 200000), 56 | currency: 'usd', 57 | automatic_payment_methods: { 58 | enabled: true, 59 | allow_redirects: 'never', 60 | }, 61 | confirm: true, 62 | payment_method: 'pm_card_bypassPending', 63 | description: 'Classic wash and groom', 64 | receipt_email: 'receipt_test@stripe.com', 65 | }, 66 | { 67 | stripeAccount: accountId, 68 | } 69 | ); 70 | } 71 | await stripe.paymentIntents.create( 72 | { 73 | amount: getRandomInt(5000, 20000), 74 | currency: 'usd', 75 | automatic_payment_methods: { 76 | enabled: true, 77 | allow_redirects: 'never', 78 | }, 79 | confirm: true, 80 | payment_method: 'pm_card_createDispute', 81 | receipt_email: 'dispute_test@stripe.com', 82 | }, 83 | { 84 | stripeAccount: accountId, 85 | } 86 | ); 87 | for (let i = 0; i < 3; i++) { 88 | await stripe.payouts.create( 89 | { 90 | amount: getRandomInt(5000, 20000), 91 | currency: 'USD', 92 | description: 'TEST PAYOUT', 93 | }, 94 | { 95 | stripeAccount: accountId, 96 | } 97 | ); 98 | } 99 | const update = { 100 | setup: true, 101 | }; 102 | console.log('updating account with, ', update); 103 | 104 | await Salon.findOneAndUpdate({email: session?.user?.email}, update); 105 | 106 | return new Response('Success', { 107 | status: 200, 108 | headers: {'Content-Type': 'application/json'}, 109 | }); 110 | } catch (error: any) { 111 | console.error( 112 | 'An error occurred when calling the Stripe API to create test data', 113 | error 114 | ); 115 | return new Response(error.message, {status: 500}); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /types/settings.ts: -------------------------------------------------------------------------------- 1 | import {OverlayOption} from '@stripe/connect-js'; 2 | 3 | export type LocaleType = 4 | | 'bg-BG' 5 | | 'cs-CZ' 6 | | 'da-DK' 7 | | 'de-DE' 8 | | 'el-GR' 9 | | 'en-GB' 10 | | 'en-US' 11 | | 'es-419' 12 | | 'es-ES' 13 | | 'et-EE' 14 | | 'fi-FI' 15 | | 'fil-PH' 16 | | 'fr-CA' 17 | | 'fr-FR' 18 | | 'hr-HR' 19 | | 'hu-HU' 20 | | 'id-ID' 21 | | 'it-IT' 22 | | 'ja-JP' 23 | | 'ko-KR' 24 | | 'lt-LT' 25 | | 'lv-LV' 26 | | 'ms-MY' 27 | | 'mt-MT' 28 | | 'nb-NO' 29 | | 'nl-NL' 30 | | 'pl-PL' 31 | | 'pt-BR' 32 | | 'pt-PT' 33 | | 'ro-RO' 34 | | 'sk-SK' 35 | | 'sl-SI' 36 | | 'sv-SE' 37 | | 'th-TH' 38 | | 'tr-TR' 39 | | 'vi-VN' 40 | | 'zh-Hans' 41 | | 'zh-Hant-HK' 42 | | 'zh-Hant-TW'; 43 | 44 | // https://docs.google.com/spreadsheets/d/1-Bu7w2kuBYPXinuTdxrt3T8scrNaMxL8elSJr9aiVtE/edit#gid=0 45 | export const Locales: Array<{locale: LocaleType; label: string}> = [ 46 | {label: 'United States (English)', locale: 'en-US'}, 47 | {label: 'Brazil (Português)', locale: 'pt-BR'}, 48 | {label: 'Bulgarian (Български)', locale: 'bg-BG'}, 49 | {label: 'Canada (English)', locale: 'en-US'}, 50 | {label: 'Canada (Français)', locale: 'fr-CA'}, 51 | {label: 'Chinese Simplified (简体中文)', locale: 'zh-Hans'}, 52 | {label: 'Croatian (hrvatski)', locale: 'hr-HR'}, 53 | {label: 'Czech (čeština)', locale: 'cs-CZ'}, 54 | {label: 'Danish (dansk)', locale: 'da-DK'}, 55 | {label: 'Estonian (eesti)', locale: 'et-EE'}, 56 | {label: 'Filipino (Filipino)', locale: 'fil-PH'}, 57 | {label: 'Finnish (suomi)', locale: 'fi-FI'}, 58 | {label: 'France (Français)', locale: 'fr-FR'}, 59 | {label: 'German (Deutsch)', locale: 'de-DE'}, 60 | {label: 'Greek (ελληνικά)', locale: 'el-GR'}, 61 | {label: 'Hong Kong (中文)', locale: 'zh-Hant-HK'}, 62 | {label: 'Hong Kong (English)', locale: 'en-GB'}, 63 | {label: 'Hungarian (magyar)', locale: 'hu-HU'}, 64 | {label: 'India (English)', locale: 'en-GB'}, 65 | {label: 'Indonesia (Bahasa Indonesia)', locale: 'id-ID'}, 66 | {label: 'Ireland (English)', locale: 'en-GB'}, 67 | {label: 'Italian (Italiano)', locale: 'it-IT'}, 68 | {label: 'Japanese (日本語)', locale: 'ja-JP'}, 69 | {label: 'Korean (한국어)', locale: 'ko-KR'}, 70 | {label: 'Latin America (Español)', locale: 'es-419'}, 71 | {label: 'Latvian (latviešu)', locale: 'lv-LV'}, 72 | {label: 'Lithuanian (lietuvių)', locale: 'lt-LT'}, 73 | {label: 'Malaysia (Melayu)', locale: 'ms-MY'}, 74 | {label: 'Malta (Malti)', locale: 'mt-MT'}, 75 | {label: 'Netherlands (Nederlands)', locale: 'nl-NL'}, 76 | {label: 'New Zealand (English)', locale: 'en-GB'}, 77 | {label: 'Norway (Norsk bokmål)', locale: 'nb-NO'}, 78 | {label: 'Poland (polski)', locale: 'pl-PL'}, 79 | {label: 'Portugal (Português)', locale: 'pt-PT'}, 80 | {label: 'Romania (română)', locale: 'ro-RO'}, 81 | {label: 'Singapore (English)', locale: 'en-GB'}, 82 | {label: 'Slovakia (slovenčina)', locale: 'sk-SK'}, 83 | {label: 'Slovenian (slovenščina)', locale: 'sl-SI'}, 84 | {label: 'Spanish (Español)', locale: 'es-ES'}, 85 | {label: 'Swedish (svenska)', locale: 'sv-SE'}, 86 | {label: 'Taiwan (臺灣華語)', locale: 'zh-Hant-TW'}, 87 | {label: 'Thai (ไทย)', locale: 'th-TH'}, 88 | {label: 'Turkish (Türkçe)', locale: 'tr-TR'}, 89 | {label: 'United Kingdom (English)', locale: 'en-GB'}, 90 | {label: 'Vietnamese (Tiếng Việt)', locale: 'vi-VN'}, 91 | ]; 92 | 93 | export interface Settings { 94 | locale?: LocaleType; 95 | theme?: string; 96 | overlay?: OverlayOption; 97 | primaryColor?: string; 98 | companyName?: string; 99 | companyLogoUrl?: string; 100 | isInitialized?: boolean; 101 | previewEnabled?: boolean; 102 | } 103 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | *, 7 | ::before, 8 | ::after { 9 | @apply dark:border-neutral-700; 10 | } 11 | 12 | :root { 13 | --background: 0 0% 100%; 14 | --foreground: 0 0% 100%; 15 | 16 | --dialog-background: rgba(197, 197, 197, 0.7); 17 | --dialog-content: white; 18 | 19 | --screen-background: white; 20 | --screen-foreground: white; 21 | 22 | --embedded-component: #7f81fa; 23 | --embedded-component-hover: #6c6ee6; 24 | 25 | --offset: #f5f6f8; 26 | 27 | --accent: #27ae60; 28 | --accent-foreground: #f4f4f5; 29 | --accent-subdued: #e9f7ef; 30 | 31 | --primary: #353a44; 32 | --subdued: #596171; 33 | 34 | --radius: 0.5rem; 35 | 36 | --success: #d6fce6; 37 | --success-border: #94d5af; 38 | --success-foreground: #1e884b; 39 | 40 | --destructive: #e74c3c; 41 | --destructive-foreground: #e9f7ef; 42 | --ring: 145 63.4% 41.8%; 43 | 44 | --tools-background: white; 45 | } 46 | .dark { 47 | --background: 220 18.37% 9.61%; 48 | --foreground: 220 16.67% 14.12%; 49 | 50 | --screen-background: #1b1e25; 51 | --screen-foreground: #1e222a; 52 | 53 | --dialog-content: 220, 18.37%, 9.61%; 54 | 55 | --dialog-background: rgba(0, 0, 0, 0.7); 56 | 57 | --offset: #1e222a; 58 | --accent: #27ae60; 59 | --accent-foreground: #14171d; 60 | --accent-subdued: rgba(39, 174, 96, 0.1); 61 | 62 | --primary: #c9ced8; 63 | --subdued: #8c99ad; 64 | 65 | --radius: 0.5rem; 66 | 67 | --success: #0c4223; 68 | --success-border: #156236; 69 | --success-foreground: #27ae60; 70 | 71 | --destructive: #400a00; 72 | --destructive-border: #632013; 73 | --destructive-foreground: #e9f7ef; 74 | --ring: 145 63.4% 41.8%; 75 | --tools-background: #1b1e25; 76 | } 77 | .light { 78 | --background: 0 0% 100%; 79 | --foreground: 0 0% 100%; 80 | 81 | --dialog-background: rgba(197, 197, 197, 70%); 82 | --dialog-content: white; 83 | 84 | --screen-background: white; 85 | --screen-foreground: white; 86 | 87 | --offset: #f5f6f8; 88 | 89 | --accent: #27ae60; 90 | --accent-foreground: #f4f4f5; 91 | --accent-subdued: #e9f7ef; 92 | 93 | --primary: #353a44; 94 | --subdued: #596171; 95 | 96 | --radius: 0.5rem; 97 | 98 | --success: #d6fce6; 99 | --success-border: #94d5af; 100 | --success-foreground: #1e884b; 101 | 102 | --destructive: #e74c3c; 103 | --destructive-foreground: #e9f7ef; 104 | --ring: 145 63.4% 41.8%; 105 | 106 | --tools-background: white; 107 | } 108 | } 109 | 110 | @layer base { 111 | body { 112 | @apply bg-background text-primary; 113 | } 114 | 115 | /* Hide scrollbar for Chrome, Safari and Opera */ 116 | .no-scrollbar::-webkit-scrollbar { 117 | display: none; 118 | } 119 | /* Hide scrollbar for IE, Edge and Firefox */ 120 | .no-scrollbar { 121 | -ms-overflow-style: none; /* IE and Edge */ 122 | scrollbar-width: none; /* Firefox */ 123 | } 124 | } 125 | 126 | @layer base { 127 | @font-face { 128 | font-family: 'Sohne'; 129 | font-style: normal; 130 | font-weight: 400; 131 | font-display: swap; 132 | src: url(/fonts/Sohne-Buch.otf) format('opentype'); 133 | } 134 | 135 | @font-face { 136 | font-family: 'Sohne'; 137 | font-style: normal; 138 | font-weight: 500; 139 | font-display: swap; 140 | src: url(/fonts/Sohne-Kraftig.otf) format('opentype'); 141 | } 142 | 143 | @font-face { 144 | font-family: 'Sohne'; 145 | font-style: normal; 146 | font-weight: 600; 147 | font-display: swap; 148 | src: url(/fonts/Sohne-Halbfett.otf) format('opentype'); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /app/api/debug/create_checkout_session/route.ts: -------------------------------------------------------------------------------- 1 | import {getServerSession} from 'next-auth/next'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | 5 | const customers = [ 6 | { 7 | email: 'labradoodle@stripe.com', 8 | name: 'Odie', 9 | description: 'Full grooming package for large Labradoodle', 10 | }, 11 | { 12 | email: 'poodle@stripe.com', 13 | name: 'Snoopy ', 14 | description: 'Nail trimming for toy Poodle', 15 | }, 16 | { 17 | email: 'golden_retriever@stripe.com', 18 | name: 'Dug', 19 | description: 20 | 'Hydro surge warm water shampoo & conditioner for Golden Retriever', 21 | }, 22 | { 23 | email: 'siamese_cat@stripe.com', 24 | name: 'Garfield', 25 | description: 'Flea and tick treatments for Siamese cat', 26 | }, 27 | { 28 | email: 'argente_rabbit@stripe.com', 29 | name: 'Bugs Bunny', 30 | description: 'Fur brushing and trimming for Argente Rabbit', 31 | }, 32 | ]; 33 | 34 | function getRandomInt(min: number, max: number) { 35 | return Math.floor(Math.random() * (max - min + 1)) + min; 36 | } 37 | 38 | export async function POST() { 39 | let checkoutSession; 40 | try { 41 | const session = await getServerSession(authOptions); 42 | const currency = 'usd'; 43 | const redirectUrl = `${process.env.NEXTAUTH_URL}/payments`; 44 | 45 | const {description: nameAndDescription} = 46 | customers[Math.floor(Math.random() * customers.length)]; 47 | 48 | let automaticTaxEnabled: boolean = false; 49 | let taxCode: undefined | string = undefined; 50 | let tax_behavior: undefined | 'exclusive' = undefined; 51 | 52 | if (session?.user.stripeAccountId) { 53 | const taxSettings = await stripe.tax.settings.retrieve({ 54 | stripeAccount: session?.user.stripeAccountId, 55 | }); 56 | automaticTaxEnabled = taxSettings.status === 'active'; 57 | taxCode = taxSettings.defaults.tax_code 58 | ? taxSettings.defaults.tax_code 59 | : 'txcd_99999999'; 60 | 61 | tax_behavior = automaticTaxEnabled ? 'exclusive' : undefined; 62 | } 63 | 64 | checkoutSession = await stripe.checkout.sessions.create( 65 | { 66 | line_items: [ 67 | { 68 | price_data: { 69 | unit_amount: getRandomInt(4000, 10000), // Use a random amount if input is not provided 70 | currency: currency, 71 | product_data: { 72 | name: nameAndDescription, 73 | description: nameAndDescription, 74 | tax_code: taxCode, 75 | }, 76 | tax_behavior, 77 | }, 78 | quantity: 1, 79 | }, 80 | ], 81 | payment_intent_data: { 82 | description: nameAndDescription, 83 | statement_descriptor: 'FurEver', 84 | }, 85 | mode: 'payment', 86 | success_url: redirectUrl, 87 | cancel_url: redirectUrl, 88 | automatic_tax: { 89 | enabled: automaticTaxEnabled, 90 | }, 91 | }, 92 | { 93 | stripeAccount: session?.user.stripeAccountId, 94 | } 95 | ); 96 | 97 | if (!checkoutSession || !checkoutSession.url) { 98 | throw new Error('Session URL was not returned'); 99 | } 100 | 101 | return new Response( 102 | JSON.stringify({ 103 | checkout_session: checkoutSession.url, 104 | }), 105 | {status: 200, headers: {'Content-Type': 'application/json'}} 106 | ); 107 | } catch (error: any) { 108 | console.error( 109 | 'An error occurred when calling the Stripe API to create a checkout session', 110 | error 111 | ); 112 | return new Response(error.message, {status: 500}); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /app/api/payment_method_settings/create_checkout_session/route.ts: -------------------------------------------------------------------------------- 1 | import {Stripe} from 'stripe'; 2 | import {authOptions} from '@/lib/auth'; 3 | import {stripe} from '@/lib/stripe'; 4 | import {getServerSession} from 'next-auth'; 5 | import {NextRequest} from 'next/server'; 6 | 7 | const customers = [ 8 | { 9 | email: 'labradoodle@stripe.com', 10 | name: 'Odie', 11 | description: 'Full grooming package for large Labradoodle', 12 | }, 13 | { 14 | email: 'poodle@stripe.com', 15 | name: 'Snoopy ', 16 | description: 'Nail trimming for toy Poodle', 17 | }, 18 | { 19 | email: 'golden_retriever@stripe.com', 20 | name: 'Dug', 21 | description: 22 | 'Hydro surge warm water shampoo & conditioner for Golden Retriever', 23 | }, 24 | { 25 | email: 'siamese_cat@stripe.com', 26 | name: 'Garfield', 27 | description: 'Flea and tick treatments for Siamese cat', 28 | }, 29 | { 30 | email: 'argente_rabbit@stripe.com', 31 | name: 'Bugs Bunny', 32 | description: 'Fur brushing and trimming for Argente Rabbit', 33 | }, 34 | ]; 35 | 36 | function getRandomInt(min: number, max: number) { 37 | return Math.floor(Math.random() * (max - min + 1)) + min; 38 | } 39 | 40 | /** 41 | * Generates a test Checkout Session for a merchant. This is used to show the payment 42 | * methods that are available after toggling them in the Payment Method settings. 43 | */ 44 | export async function POST(req: NextRequest) { 45 | try { 46 | const session = await getServerSession(authOptions); 47 | const redirectUrl = `${process.env.NEXTAUTH_URL}/settings`; 48 | const params = await req.json(); 49 | 50 | if (!session?.user?.stripeAccountId) { 51 | return new Response('Unauthorized or no Stripe account found', { 52 | status: 401, 53 | }); 54 | } 55 | 56 | const stripeAccount = await stripe.accounts.retrieve( 57 | session.user.stripeAccountId 58 | ); 59 | 60 | const currency = 61 | params.currency && params.currency !== '_default' 62 | ? params.currency 63 | : stripeAccount.default_currency; 64 | const amount = params.amount 65 | ? parseFloat(params.amount) * 100 66 | : getRandomInt(4000, 12000); 67 | 68 | const {description: nameAndDescription} = 69 | customers[Math.floor(Math.random() * customers.length)]; 70 | 71 | const checkoutSessionResponse = await stripe.checkout.sessions.create( 72 | { 73 | line_items: [ 74 | { 75 | price_data: { 76 | currency, 77 | unit_amount: amount, 78 | product_data: { 79 | name: nameAndDescription, 80 | description: nameAndDescription, 81 | }, 82 | }, 83 | quantity: 1, 84 | }, 85 | ], 86 | payment_intent_data: { 87 | description: nameAndDescription, 88 | statement_descriptor: 'FurEver', 89 | }, 90 | mode: 'payment', 91 | success_url: redirectUrl, 92 | cancel_url: redirectUrl, 93 | }, 94 | { 95 | stripeAccount: session?.user.stripeAccountId, 96 | } 97 | ); 98 | 99 | console.log('Created checkout session!', checkoutSessionResponse); 100 | 101 | if (!checkoutSessionResponse || !checkoutSessionResponse.url) { 102 | throw new Error('Session URL was not returned'); 103 | } 104 | 105 | return new Response( 106 | JSON.stringify({ 107 | checkoutSessionResponse, 108 | }), 109 | {status: 200, headers: {'Content-Type': 'application/json'}} 110 | ); 111 | } catch (error: any) { 112 | console.error( 113 | 'An error occurred when calling the Stripe API to create a checkout session', 114 | error 115 | ); 116 | return new Response(error.message, {status: 500}); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /app/components/EditPasswordButton.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { 3 | Dialog, 4 | DialogClose, 5 | DialogContent, 6 | DialogHeader, 7 | DialogTitle, 8 | DialogTrigger, 9 | } from '@/components/ui/dialog'; 10 | import {z} from 'zod'; 11 | import {Button} from '@/components/ui/button'; 12 | import {Input} from '@/components/ui/input'; 13 | import {useSession} from 'next-auth/react'; 14 | import {redirect} from 'next/navigation'; 15 | import React from 'react'; 16 | import { 17 | Form, 18 | FormControl, 19 | FormField, 20 | FormItem, 21 | FormLabel, 22 | FormMessage, 23 | } from '@/components/ui/form'; 24 | import {zodResolver} from '@hookform/resolvers/zod'; 25 | import {useForm} from 'react-hook-form'; 26 | import bcrypt from 'bcryptjs'; 27 | 28 | const formSchema = z.object({ 29 | password: z.string().min(8), 30 | }); 31 | 32 | const EditPasswordButton = () => { 33 | const [open, setOpen] = React.useState(false); 34 | 35 | const EditAccountForm = () => { 36 | const {data: session} = useSession(); 37 | 38 | if (!session) { 39 | redirect('/home'); 40 | } 41 | 42 | const form = useForm>({ 43 | resolver: zodResolver(formSchema), 44 | defaultValues: { 45 | password: '', 46 | }, 47 | }); 48 | 49 | const onSubmit = async (values: z.infer) => { 50 | console.log('submitting'); 51 | const data = { 52 | newPassword: bcrypt.hashSync(values.password, 8), 53 | changedPassword: true, 54 | }; 55 | 56 | const response = await fetch('/api/password_update', { 57 | method: 'POST', 58 | body: JSON.stringify(data), 59 | }); 60 | if (!response.ok) { 61 | // Handle errors on the client side here 62 | const {error} = await response.json(); 63 | console.warn('An error occurred: ', error); 64 | return undefined; 65 | } else { 66 | setOpen(false); 67 | window.location.reload(); 68 | } 69 | }; 70 | 71 | return ( 72 | <> 73 | 74 | 75 |
76 | ( 80 | 81 | Password 82 | 83 | 89 | 90 | 91 | 92 | )} 93 | /> 94 |
95 |
96 | 97 | 100 | 101 | 104 |
105 | 106 | 107 | 108 | ); 109 | }; 110 | 111 | return ( 112 | 113 | 114 | Edit password 115 | 116 | 117 | 118 | Edit password 119 | 120 | 121 | 122 | 123 | ); 124 | }; 125 | 126 | export default EditPasswordButton; 127 | -------------------------------------------------------------------------------- /app/(auth)/signup/form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import {signIn} from 'next-auth/react'; 5 | import {useRouter} from 'next/navigation'; 6 | import {ArrowRight, Loader2} from 'lucide-react'; 7 | import {zodResolver} from '@hookform/resolvers/zod'; 8 | import {useForm} from 'react-hook-form'; 9 | import {z} from 'zod'; 10 | import {Button} from '@/components/ui/button'; 11 | import { 12 | Form, 13 | FormControl, 14 | FormField, 15 | FormItem, 16 | FormLabel, 17 | FormMessage, 18 | } from '@/components/ui/form'; 19 | import {Input} from '@/components/ui/input'; 20 | import {UserFormSchema} from '@/lib/forms'; 21 | 22 | export default function SignupForm() { 23 | const router = useRouter(); 24 | 25 | const form = useForm>({ 26 | resolver: zodResolver(UserFormSchema), 27 | defaultValues: { 28 | email: '', 29 | password: '', 30 | }, 31 | }); 32 | 33 | const onSubmit = async (values: z.infer) => { 34 | try { 35 | const result = await signIn('signup', { 36 | email: values.email, 37 | password: values.password, 38 | redirect: false, 39 | }); 40 | 41 | if (result?.error) { 42 | // Just assume the error is because the email is already in use 43 | form.setError('root', { 44 | type: 'manual', 45 | message: 'The email is already in use.', 46 | }); 47 | } else if (result?.ok) { 48 | router.push('/business'); 49 | } 50 | } catch (error: any) { 51 | console.error('An error occurred when signing in', error); 52 | } 53 | }; 54 | 55 | return ( 56 |
57 | 58 |
59 | ( 63 | 64 | Email 65 | 66 | 71 | 72 | 73 | 74 | )} 75 | /> 76 |
77 |
78 | ( 82 | 83 | Password 84 | 85 | 90 | 91 | 92 | 93 | )} 94 | /> 95 |
96 | 112 | {form.formState.errors.root && ( 113 |

114 | {form.formState.errors.root.message} 115 |

116 | )} 117 |
118 | 119 | ); 120 | } 121 | --------------------------------------------------------------------------------