├── .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 |
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 |
44 |
45 |
46 |
{pet.name}
47 |
Joined {pet.date}
48 |
49 |
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 |
10 |
--------------------------------------------------------------------------------
/public/stripe-gray.svg:
--------------------------------------------------------------------------------
1 |
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 |
9 |
--------------------------------------------------------------------------------
/public/pose_red.svg:
--------------------------------------------------------------------------------
1 |
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 |
55 |
56 |
57 |
58 | {children}
59 |
60 |
61 |
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 |
47 |
48 |
49 |
50 |
51 |
58 | {data?.user?.companyName || 'Furever'}
59 |
60 |
61 |
62 |
63 | {children}
64 |
65 |
66 |
82 |
83 |
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 |
106 |
107 | >
108 | );
109 | };
110 |
111 | return (
112 |
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 |
118 |
119 | );
120 | }
121 |
--------------------------------------------------------------------------------