From f2dbbd34ff36e83da3fea261842210edd856c2c6 Mon Sep 17 00:00:00 2001 From: simmmpleweb Date: Mon, 26 Aug 2024 18:01:09 +0300 Subject: [PATCH] 2.0.0 --- .env.local.example | 26 + .gitignore | 8 +- CHANGELOG.md | 20 + app/api/chatAPI/route.ts | 30 +- app/api/create-checkout-session/route.ts | 90 -- app/api/create-portal-link/route.ts | 45 - app/api/essayAPI/route.ts | 34 +- app/api/webhooks/route.ts | 4 +- app/auth/callback/route.ts | 43 +- app/dashboard/ai-chat/page.tsx | 32 +- app/dashboard/main/page.tsx | 34 +- app/dashboard/page.tsx | 20 +- app/dashboard/signin/[id]/page.tsx | 65 ++ app/dashboard/signin/page.tsx | 21 +- app/page.tsx | 20 +- app/supabase-server.ts | 59 -- components/audio/AudioProgressBar.tsx | 58 -- components/audio/VolumeInput.tsx | 51 -- components/audio/index.tsx | 155 ---- components/auth-ui/EmailSignIn.tsx | 104 +++ components/auth-ui/ForgotPassword.tsx | 109 +++ components/auth-ui/OauthSignIn.tsx | 53 ++ components/auth-ui/PasswordSignIn.tsx | 122 +++ components/auth-ui/Separator.tsx | 20 + components/auth-ui/Signup.tsx | 120 +++ components/auth-ui/UpdatePassword.tsx | 90 ++ components/auth/AuthUI.tsx | 194 ++--- components/auth/index.tsx | 9 +- components/dashboard/ai-chat/index.tsx | 55 +- components/dashboard/main/index.tsx | 45 +- components/footer/FooterAuthDefault.tsx | 2 +- components/layout/index.tsx | 75 +- components/navbar/NavbarAdmin.tsx | 33 +- components/navbar/NavbarLinksAdmin.tsx | 57 +- components/pricing/index.tsx | 587 ------------- components/sidebar/Sidebar.tsx | 106 +-- components/sidebar/components/Links.tsx | 44 +- components/sidebar/components/SidebarCard.tsx | 9 - contexts/layout.ts | 14 + package.json | 33 +- schema.sql | 3 +- supabase/.gitignore | 1 + supabase/config.toml.example | 82 ++ supabase/seed.sql | 0 tsconfig.json | 6 +- types/types.ts | 43 +- types/types_db.ts | 814 ++++++++++-------- utils/auth-helpers/client.ts | 44 + utils/auth-helpers/server.ts | 340 ++++++++ utils/auth-helpers/settings.ts | 49 ++ utils/cn.ts | 6 + utils/cookies.ts | 39 + utils/helpers.ts | 97 ++- utils/streams/chatStream.ts | 88 ++ utils/streams/essayStream.ts | 93 ++ utils/{stripe-client.ts => stripe/client.ts} | 0 utils/{stripe.ts => stripe/config.ts} | 9 +- utils/supabase-admin.ts | 88 +- utils/supabase-client.ts | 17 - utils/supabase-client.tsx | 35 - utils/supabase/admin.ts | 305 +++++++ utils/supabase/client.ts | 8 + utils/supabase/middleware.ts | 84 ++ utils/supabase/queries.ts | 17 + utils/supabase/server.ts | 43 + utils/useUser.tsx | 112 --- 66 files changed, 2893 insertions(+), 2226 deletions(-) create mode 100644 .env.local.example delete mode 100644 app/api/create-checkout-session/route.ts delete mode 100644 app/api/create-portal-link/route.ts create mode 100644 app/dashboard/signin/[id]/page.tsx delete mode 100644 components/audio/AudioProgressBar.tsx delete mode 100644 components/audio/VolumeInput.tsx delete mode 100644 components/audio/index.tsx create mode 100644 components/auth-ui/EmailSignIn.tsx create mode 100644 components/auth-ui/ForgotPassword.tsx create mode 100644 components/auth-ui/OauthSignIn.tsx create mode 100644 components/auth-ui/PasswordSignIn.tsx create mode 100644 components/auth-ui/Separator.tsx create mode 100644 components/auth-ui/Signup.tsx create mode 100644 components/auth-ui/UpdatePassword.tsx delete mode 100644 components/pricing/index.tsx create mode 100644 contexts/layout.ts create mode 100644 supabase/config.toml.example delete mode 100644 supabase/seed.sql create mode 100644 utils/auth-helpers/client.ts create mode 100644 utils/auth-helpers/server.ts create mode 100644 utils/auth-helpers/settings.ts create mode 100644 utils/cn.ts create mode 100644 utils/cookies.ts create mode 100644 utils/streams/chatStream.ts create mode 100644 utils/streams/essayStream.ts rename utils/{stripe-client.ts => stripe/client.ts} (100%) rename utils/{stripe.ts => stripe/config.ts} (61%) delete mode 100644 utils/supabase-client.ts delete mode 100644 utils/supabase-client.tsx create mode 100644 utils/supabase/admin.ts create mode 100644 utils/supabase/client.ts create mode 100644 utils/supabase/middleware.ts create mode 100644 utils/supabase/queries.ts create mode 100644 utils/supabase/server.ts delete mode 100644 utils/useUser.tsx diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..aa56306 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,26 @@ +NEXT_PUBLIC_SUPABASE_URL=https://********************.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=**************************************************************************************************************************************************************************************************************** + +NEXT_PUBLIC_OPENAI_API_KEY=sk-************************************************ +NEXT_PUBLIC_OPENAI_ASSISTANT_KEY=asst_************************ + +# Update these with your Supabase details from your project settings > API + SUPABASE_SERVICE_ROLE_KEY=*************************************************************************************************************************************************************************************************************************** + +# Update these with your Stripe credentials from https://dashboard.stripe.com/apikeys +# NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_*************************************************************************************************** +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_*************************************************************************************************** +# STRIPE_SECRET_KEY=sk_live_*************************************************************************************************** +STRIPE_SECRET_KEY=sk_test_*************************************************************************************************** +# The commented variable is usually for production webhook key. This you get in the Stripe dashboard and is usually shorter. +# STRIPE_WEBHOOK_SECRET=whsec_******************************** +STRIPE_WEBHOOK_SECRET=whsec_**************************************************************** + +# Update this with your stable site URL only for the production environment. +# NEXT_PUBLIC_SITE_URL=https://horizon-ui.com/shadcn-nextjs-boilerplate +# NEXT_PUBLIC_SITE_URL=https://******************.com + +NEXT_PUBLIC_AWS_S3_REGION=eu-north-1 +NEXT_PUBLIC_AWS_S3_ACCESS_KEY_ID=******************** +NEXT_PUBLIC_AWS_S3_SECRET_ACCESS_KEY=**************************************** +NEXT_PUBLIC_AWS_S3_BUCKET_NAME=mybucket \ No newline at end of file diff --git a/.gitignore b/.gitignore index 16ed7dd..d44c946 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ # misc .DS_Store *.pem +.supabase/seed.sql +supabase/seed.sql +supabase/migrations # debug npm-debug.log* @@ -40,10 +43,13 @@ yarn-error.log* next-env.d.ts package-lock.json +.env.local.production +.env.local.new +.env.local.non-docker components/ui yarn.lock # editors -.vscode +.vscode \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 309c516..07b5958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## [2.0.0] 2024-08-26 + +### Big update: Supabase SSR, Refactoring & custom auth components + +- #### Supabase SSR +- Utils ⁠ folder refactored, now functions are organized in separate folders based on usage +- ⁠New auth-related utils +- ⁠Functions like ⁠ getSessions ⁠ were removed because of the use of Supabase SSR +- session ⁠ object was replaced with ⁠ user ⁠ throughout the project + +- #### Layout refactoring +- ⁠The multiple addition of functionalities led to prop drilling, which was fixed by using contexts. + +- #### Separate auth pages +- ⁠Auth pages are dynamic Next.js pages, one for each of Update password, sign up, password sign in, etc. +- ⁠The forms for each type of authentication types are located in ⁠ @/components/auth-ui  + +- #### Added Docker support +- You can now develop locally via Docker by using Supabase CLI + ## [1.1.0] 2024-07-18 ### Added Main Dashboard Page diff --git a/app/api/chatAPI/route.ts b/app/api/chatAPI/route.ts index 2705bc4..9992bcb 100644 --- a/app/api/chatAPI/route.ts +++ b/app/api/chatAPI/route.ts @@ -1,13 +1,11 @@ import { ChatBody } from '@/types/types'; -import { OpenAIStream } from '@/utils/chatStream'; +import { OpenAIStream } from '@/utils/streams/chatStream'; +export const runtime = 'edge'; -export const runtime = 'edge' - -export async function GET(req: Request): Promise{ +export async function GET(req: Request): Promise { try { - const { inputMessage, model, apiKey } = - (await req.json()) as ChatBody; + const { inputMessage, model, apiKey } = (await req.json()) as ChatBody; let apiKeyFinal; if (apiKey) { @@ -16,23 +14,17 @@ export async function GET(req: Request): Promise{ apiKeyFinal = process.env.NEXT_PUBLIC_OPENAI_API_KEY; } - const stream = await OpenAIStream( - inputMessage, - model, - apiKeyFinal, - ); + const stream = await OpenAIStream(inputMessage, model, apiKeyFinal); return new Response(stream); } catch (error) { console.error(error); return new Response('Error', { status: 500 }); } - } -export async function POST(req: Request): Promise{ +export async function POST(req: Request): Promise { try { - const { inputMessage, model, apiKey } = - (await req.json()) as ChatBody; + const { inputMessage, model, apiKey } = (await req.json()) as ChatBody; let apiKeyFinal; if (apiKey) { @@ -41,17 +33,11 @@ export async function POST(req: Request): Promise{ apiKeyFinal = process.env.NEXT_PUBLIC_OPENAI_API_KEY; } - const stream = await OpenAIStream( - inputMessage, - model, - apiKeyFinal, - ); + const stream = await OpenAIStream(inputMessage, model, apiKeyFinal); return new Response(stream); } catch (error) { console.error(error); return new Response('Error', { status: 500 }); } - } - \ No newline at end of file diff --git a/app/api/create-checkout-session/route.ts b/app/api/create-checkout-session/route.ts deleted file mode 100644 index bda5ea9..0000000 --- a/app/api/create-checkout-session/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { cookies, headers } from 'next/headers'; -import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; -import { stripe } from '../../../utils/stripe'; -import { createOrRetrieveCustomer } from '../../../utils/supabase-admin'; -import { getURL } from '../../../utils/helpers'; -import { Database } from '../../../types/types_db'; - -export async function POST(req: Request) { - if (req.method === 'POST') { - const { price, quantity = 1, metadata = {} } = await req.json(); - - try { - const supabase = createRouteHandlerClient({cookies}); - const { - data: { user } - } = await supabase.auth.getUser(); - - const customer = await createOrRetrieveCustomer({ - uuid: user?.id || '', - email: user?.email || '' - }); - - let session; - if (price.type === 'recurring') { - session = await stripe.checkout.sessions.create({ - payment_method_types: ['card'], - billing_address_collection: 'required', - customer, - customer_update: { - address: 'auto' - }, - line_items: [ - { - price: price.id, - quantity - } - ], - mode: 'subscription', - allow_promotion_codes: true, - subscription_data: { - trial_from_plan: true, - metadata - }, - success_url: `${getURL()}/dashboard/main`, - cancel_url: `${getURL()}/dashboard/main` - }); - } else if (price.type === 'one_time') { - session = await stripe.checkout.sessions.create({ - payment_method_types: ['card'], - billing_address_collection: 'required', - customer, - customer_update: { - address: 'auto' - }, - line_items: [ - { - price: price.id, - quantity - } - ], - mode: 'payment', - allow_promotion_codes: true, - success_url: `${getURL()}/dashboard/main`, - cancel_url: `${getURL()}/dashboard/main` - }); - } - - if (session) { - return new Response(JSON.stringify({ sessionId: session.id }), { - status: 200 - }); - } else { - return new Response( - JSON.stringify({ - error: { statusCode: 500, message: 'Session is not defined' } - }), - { status: 500 } - ); - } - } catch (err: any) { - console.log(err); - return new Response(JSON.stringify(err), { status: 500 }); - } - } else { - return new Response('Method Not Allowed', { - headers: { Allow: 'POST' }, - status: 405 - }); - } -} diff --git a/app/api/create-portal-link/route.ts b/app/api/create-portal-link/route.ts deleted file mode 100644 index 182e24d..0000000 --- a/app/api/create-portal-link/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { cookies } from 'next/headers'; -import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; -import { stripe } from '@/utils/stripe'; -import { createOrRetrieveCustomer } from '@/utils/supabase-admin'; -import { getURL } from '@/utils/helpers'; -import { Database } from '@/types/types_db'; - -export async function POST(req: Request) { - if (req.method === 'POST') { - try { - const supabase = createRouteHandlerClient({cookies}); - const { - data: { user } - } = await supabase.auth.getUser(); - - if (!user) throw Error('Could not get user'); - const customer = await createOrRetrieveCustomer({ - uuid: user.id || '', - email: user.email || '' - }); - - if (!customer) throw Error('Could not get customer'); - const { url } = await stripe.billingPortal.sessions.create({ - customer, - return_url: `${getURL()}/dashboard/main` - }); - return new Response(JSON.stringify({ url }), { - status: 200 - }); - } catch (err: any) { - console.log(err); - return new Response( - JSON.stringify({ error: { statusCode: 500, message: err.message } }), - { - status: 500 - } - ); - } - } else { - return new Response('Method Not Allowed', { - headers: { Allow: 'POST' }, - status: 405 - }); - } -} diff --git a/app/api/essayAPI/route.ts b/app/api/essayAPI/route.ts index 0e14829..89c0dd3 100644 --- a/app/api/essayAPI/route.ts +++ b/app/api/essayAPI/route.ts @@ -1,13 +1,17 @@ import { EssayBody } from '@/types/types'; -import { OpenAIStream } from '@/utils/essayStream'; +import { OpenAIStream } from '@/utils/streams/essayStream'; +export const runtime = 'edge'; -export const runtime = 'edge' - -export async function GET(req: Request): Promise{ +export async function GET(req: Request): Promise { try { - const { topic, words, essayType, model, apiKey } = - (await req.json()) as EssayBody; + const { + topic, + words, + essayType, + model, + apiKey + } = (await req.json()) as EssayBody; let apiKeyFinal; if (apiKey) { @@ -21,7 +25,7 @@ export async function GET(req: Request): Promise{ essayType, words, model, - apiKeyFinal, + apiKeyFinal ); return new Response(stream); @@ -29,12 +33,16 @@ export async function GET(req: Request): Promise{ console.error(error); return new Response('Error', { status: 500 }); } - } -export async function POST(req: Request): Promise{ +export async function POST(req: Request): Promise { try { - const { topic, words, essayType, model, apiKey } = - (await req.json()) as EssayBody; + const { + topic, + words, + essayType, + model, + apiKey + } = (await req.json()) as EssayBody; let apiKeyFinal; if (apiKey) { @@ -48,7 +56,7 @@ export async function POST(req: Request): Promise{ essayType, words, model, - apiKeyFinal, + apiKeyFinal ); return new Response(stream); @@ -56,6 +64,4 @@ export async function POST(req: Request): Promise{ console.error(error); return new Response('Error', { status: 500 }); } - } - \ No newline at end of file diff --git a/app/api/webhooks/route.ts b/app/api/webhooks/route.ts index d078a11..9dad659 100644 --- a/app/api/webhooks/route.ts +++ b/app/api/webhooks/route.ts @@ -1,5 +1,5 @@ import Stripe from 'stripe'; -import { stripe } from '@/utils/stripe'; +import { stripe } from '@/utils/stripe/config'; import { upsertProductRecord, upsertPriceRecord, @@ -27,7 +27,7 @@ export async function POST(req: Request) { try { if (!sig || !webhookSecret) return; event = stripe.webhooks.constructEvent(body, sig, webhookSecret); - } catch (err: any) { + } catch (err) { console.log(`❌ Error message: ${err.message}`); return new Response(`Webhook Error: ${err.message}`, { status: 400 }); } diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 1e097fe..3555351 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -1,19 +1,36 @@ -import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs' -import { cookies } from 'next/headers' -import { NextResponse } from 'next/server' - -import type { NextRequest } from 'next/server' -import type { Database } from '@/types/types_db' +import { createClient } from '@/utils/supabase/server'; +import { NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; +import { getErrorRedirect, getStatusRedirect } from '@/utils/helpers'; export async function GET(request: NextRequest) { - const requestUrl = new URL(request.url) - const code = requestUrl.searchParams.get('code') + // The `/auth/callback` route is required for the server-side auth flow implemented + // by the `@supabase/ssr` package. It exchanges an auth code for the user's session. + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get('code'); if (code) { - const supabase = createRouteHandlerClient({ cookies }) - await supabase.auth.exchangeCodeForSession(code) + const supabase = createClient(); + + const { error } = await supabase.auth.exchangeCodeForSession(code); + + if (error) { + return NextResponse.redirect( + getErrorRedirect( + `${requestUrl.origin}/dashboard/signin`, + error.name, + "Sorry, we weren't able to log you in. Please try again." + ) + ); + } } - // URL to redirect to after sign in process completes - return NextResponse.redirect(`${requestUrl.origin}/dashboard/main`) -} \ No newline at end of file + // URL to redirect to after sign in process completes + return NextResponse.redirect( + getStatusRedirect( + `${requestUrl.origin}/dashboard/main`, + 'Success!', + 'You are now signed in.' + ) + ); +} diff --git a/app/dashboard/ai-chat/page.tsx b/app/dashboard/ai-chat/page.tsx index 4dfd894..87c4479 100644 --- a/app/dashboard/ai-chat/page.tsx +++ b/app/dashboard/ai-chat/page.tsx @@ -1,31 +1,19 @@ -import { - getSession, - getUserDetails, - getSubscription, - getActiveProductsWithPrices -} from '@/app/supabase-server'; +import { getUserDetails, getUser } from '@/utils/supabase/queries'; + import Chat from '@/components/dashboard/ai-chat'; import { redirect } from 'next/navigation'; +import { createClient } from '@/utils/supabase/server'; -export default async function Account() { - const [session, userDetails, products, subscription] = await Promise.all([ - getSession(), - getUserDetails(), - getActiveProductsWithPrices(), - getSubscription() +export default async function AiChat() { + const supabase = createClient(); + const [user, userDetails] = await Promise.all([ + getUser(supabase), + getUserDetails(supabase) ]); - if (!session) { + if (!user) { return redirect('/dashboard/signin'); } - return ( - - ); + return ; } diff --git a/app/dashboard/main/page.tsx b/app/dashboard/main/page.tsx index 0cf2522..37a6dac 100644 --- a/app/dashboard/main/page.tsx +++ b/app/dashboard/main/page.tsx @@ -1,32 +1,18 @@ -import { - getSession, - getUserDetails, - getSubscription, - getActiveProductsWithPrices -} from '@/app/supabase-server'; import Main from '@/components/dashboard/main'; import { redirect } from 'next/navigation'; +import { getUserDetails, getUser } from '@/utils/supabase/queries'; +import { createClient } from '@/utils/supabase/server'; export default async function Account() { - const [session, userDetails, products, subscription] = await Promise.all([ - getSession(), - getUserDetails(), - getActiveProductsWithPrices(), - getSubscription() + const supabase = createClient(); + const [user, userDetails] = await Promise.all([ + getUser(supabase), + getUserDetails(supabase) ]); - // if (!session) { - // return redirect('/dashboard/signin'); - // } + if (!user) { + return redirect('/dashboard/signin'); + } - return ( - // @ts-ignore -
- ); + return
; } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 03d5458..be0e66c 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,20 +1,14 @@ -import { - getSession, - getUserDetails, - getSubscription, -} from '@/app/supabase-server'; +import { getUser } from '@/utils/supabase/queries'; import { redirect } from 'next/navigation'; +import { createClient } from '@/utils/supabase/server'; -export default async function Account() { - const [session, userDetails, subscription] = await Promise.all([ - getSession(), - getUserDetails(), - getSubscription(), - ]); +export default async function Dashboard() { + const supabase = createClient(); + const [user] = await Promise.all([getUser(supabase)]); - if (!session) { + if (!user) { return redirect('/dashboard/signin'); } else { - redirect('/dashboard/ai-chat'); + redirect('/dashboard/main'); } } diff --git a/app/dashboard/signin/[id]/page.tsx b/app/dashboard/signin/[id]/page.tsx new file mode 100644 index 0000000..acd5d94 --- /dev/null +++ b/app/dashboard/signin/[id]/page.tsx @@ -0,0 +1,65 @@ +import DefaultAuth from '@/components/auth'; +import AuthUI from '@/components/auth/AuthUI'; +import { redirect } from 'next/navigation'; +import { createClient } from '@/utils/supabase/server'; +import { cookies } from 'next/headers'; +import { + getAuthTypes, + getViewTypes, + getDefaultSignInView, + getRedirectMethod +} from '@/utils/auth-helpers/settings'; + +export default async function SignIn({ + params, + searchParams +}: { + params: { id: string }; + searchParams: { disable_button: boolean }; +}) { + const { allowOauth, allowEmail, allowPassword } = getAuthTypes(); + const viewTypes = getViewTypes(); + const redirectMethod = getRedirectMethod(); + + // Declare 'viewProp' and initialize with the default value + let viewProp: string; + + // Assign url id to 'viewProp' if it's a valid string and ViewTypes includes it + if (typeof params.id === 'string' && viewTypes.includes(params.id)) { + viewProp = params.id; + } else { + const preferredSignInView = + cookies().get('preferredSignInView')?.value || null; + viewProp = getDefaultSignInView(preferredSignInView); + return redirect(`/dashboard/signin/${viewProp}`); + } + + // Check if the user is already logged in and redirect to the account page if so + const supabase = createClient(); + + const { + data: { user } + } = await supabase.auth.getUser(); + + if (user && viewProp !== 'update_password') { + return redirect('/dashboard/main'); + } else if (!user && viewProp === 'update_password') { + return redirect('/dashboard/signin'); + } + + return ( + +
+ +
+
+ ); +} diff --git a/app/dashboard/signin/page.tsx b/app/dashboard/signin/page.tsx index f2beebf..83e3fa0 100644 --- a/app/dashboard/signin/page.tsx +++ b/app/dashboard/signin/page.tsx @@ -1,18 +1,11 @@ -import { getSession } from '@/app/supabase-server'; -import DefaultAuth from '@/components/auth'; -import AuthUI from '@/components/auth/AuthUI'; import { redirect } from 'next/navigation'; +import { getDefaultSignInView } from '@/utils/auth-helpers/settings'; +import { cookies } from 'next/headers'; -export default async function SignIn() { - const session = await getSession(); +export default function SignIn() { + const preferredSignInView = + cookies().get('preferredSignInView')?.value || null; + const defaultView = getDefaultSignInView(preferredSignInView); - // if (session) { - // return redirect('/dashboard/main'); - // } - - return ( - - - - ); + return redirect(`/dashboard/signin/${defaultView}`); } diff --git a/app/page.tsx b/app/page.tsx index 03d5458..be0e66c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,20 +1,14 @@ -import { - getSession, - getUserDetails, - getSubscription, -} from '@/app/supabase-server'; +import { getUser } from '@/utils/supabase/queries'; import { redirect } from 'next/navigation'; +import { createClient } from '@/utils/supabase/server'; -export default async function Account() { - const [session, userDetails, subscription] = await Promise.all([ - getSession(), - getUserDetails(), - getSubscription(), - ]); +export default async function Dashboard() { + const supabase = createClient(); + const [user] = await Promise.all([getUser(supabase)]); - if (!session) { + if (!user) { return redirect('/dashboard/signin'); } else { - redirect('/dashboard/ai-chat'); + redirect('/dashboard/main'); } } diff --git a/app/supabase-server.ts b/app/supabase-server.ts index 4b85bde..960a0e1 100644 --- a/app/supabase-server.ts +++ b/app/supabase-server.ts @@ -6,62 +6,3 @@ import { cache } from 'react'; export const createServerSupabaseClient = cache(() => createServerComponentClient({ cookies }) ); - -export async function getSession() { - const supabase = createServerSupabaseClient(); - try { - const { - data: { session } - } = await supabase.auth.getSession(); - return session; - } catch (error) { - console.error('Error:', error); - return null; - } -} - -export async function getUserDetails() { - const supabase = createServerSupabaseClient(); - try { - const { data: userDetails } = await supabase - .from('users') - .select('*') - .single(); - return userDetails; - } catch (error) { - console.error('Error:', error); - return null; - } -} - -export async function getSubscription() { - const supabase = createServerSupabaseClient(); - try { - const { data: subscription } = await supabase - .from('subscriptions') - .select('*, prices(*, products(*))') - .in('status', ['trialing', 'active']) - .maybeSingle() - .throwOnError(); - return subscription; - } catch (error) { - console.error('Error:', error); - return null; - } -} - -export const getActiveProductsWithPrices = async () => { - const supabase = createServerSupabaseClient(); - const { data, error } = await supabase - .from('products') - .select('*, prices(*)') - .eq('active', true) - .eq('prices.active', true) - .order('metadata->index') - .order('unit_amount', { foreignTable: 'prices' }); - - if (error) { - console.log(error.message); - } - return data ?? []; -}; diff --git a/components/audio/AudioProgressBar.tsx b/components/audio/AudioProgressBar.tsx deleted file mode 100644 index 1543338..0000000 --- a/components/audio/AudioProgressBar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { cn } from '@/lib/utils'; -import * as SliderPrimitive from '@radix-ui/react-slider'; -import * as React from 'react'; - -const Slider = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - - - -)); -Slider.displayName = SliderPrimitive.Root.displayName; - -export default function AudioProgressBar(props: any) { - const { - duration, - currentProgress, - handleProgressChange, - elapsedDisplay, - durationDisplay, - setCurrentProgress, - } = props; - return ( -
-

- {elapsedDisplay} -

- { - setCurrentProgress(val); - }} - onChangeEnd={(val) => { - handleProgressChange(val); - }} - className={'w-[100%]'} - {...props} - /> -

- {durationDisplay} -

-
- ); -} diff --git a/components/audio/VolumeInput.tsx b/components/audio/VolumeInput.tsx deleted file mode 100644 index 23c136a..0000000 --- a/components/audio/VolumeInput.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { cn } from '@/lib/utils'; -// import { Slider } from '../ui/slider'; -import * as SliderPrimitive from '@radix-ui/react-slider'; -import * as React from 'react'; - -const Slider = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - - - -)); -Slider.displayName = SliderPrimitive.Root.displayName; - -interface VolumeInputProps { - volume: number; - onVolumeChange: (volume: any) => any; -} - -export default function VolumeInput({ - volume, - onVolumeChange, -}: VolumeInputProps) { - return ( -
- { - onVolumeChange(val); - }} - /> -
- ); -} diff --git a/components/audio/index.tsx b/components/audio/index.tsx deleted file mode 100644 index a317f42..0000000 --- a/components/audio/index.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { Button } from '../ui/button'; -import { Card } from '../ui/card'; -import AudioProgressBar from './AudioProgressBar'; -import VolumeInput from './VolumeInput'; -import * as React from 'react'; -import { MdPlayArrow, MdPause, MdVolumeUp, MdVolumeOff } from 'react-icons/md'; - -function formatDurationDisplay(duration: number) { - const min = Math.floor(duration / 60); - const sec = Math.floor(duration - min * 60); - const formatted = [min, sec].map((n) => (n < 10 ? '0' + n : n)).join(':'); - - return formatted; -} - -export default function AudioPlayer(props: { - src: any; - handleClick: any; - loading: any; -}) { - const { src } = props; - const audioRef = React.useRef(null); - - const [duration, setDuration] = React.useState(0); - const [started, setStarted] = React.useState(false); - const [currentProgress, setCurrentProgress] = React.useState(0); - const [buffered, setBuffered] = React.useState(0); - const [volume, setVolume] = React.useState(0.2); - const [isPlaying, setIsPlaying] = React.useState(false); - - const durationDisplay = formatDurationDisplay(duration); - const elapsedDisplay = formatDurationDisplay(currentProgress); - - const togglePlayPause = () => { - if (isPlaying) { - audioRef.current; - audioRef.current?.pause(); - setIsPlaying(false); - } else if (!started) { - // @ts-ignore - audioRef.current.load(); - // @ts-ignore - audioRef.current.onloadeddata = () => { - audioRef.current - ?.play() - .catch((error: any) => console.log('Error playing audio:', error)); - setIsPlaying(true); - setStarted(true); - }; - } else { - audioRef.current - ?.play() - .catch((error: any) => console.log('Error playing audio:', error)); - setIsPlaying(true); - } - }; - - const handleBufferProgress: React.ReactEventHandler = ( - e, - ) => { - const audio = e.currentTarget; - const dur = audio.duration; - if (dur > 0) { - for (let i = 0; i < audio.buffered.length; i++) { - if ( - audio.buffered.start(audio.buffered.length - 1 - i) < - audio.currentTime - ) { - const bufferedLength = audio.buffered.end( - audio.buffered.length - 1 - i, - ); - setBuffered(bufferedLength); - break; - } - } - } - }; - - const handleMuteUnmute = () => { - if (!audioRef.current) return; - - if (audioRef.current.volume !== 0) { - audioRef.current.volume = 0; - } else { - audioRef.current.volume = 1; - } - }; - - const handleVolumeChange = (volumeValue: number) => { - if (!audioRef.current) return; - audioRef.current.volume = volumeValue; - setVolume(volumeValue); - }; - - const handleProgressChange = (val: number) => { - if (!audioRef.current) return; - setCurrentProgress(val); - audioRef.current.currentTime = val; - }; - return ( - -
- - - */} -
-
-
-
- {volume === 0 ? ( - - ) : ( - - )} -
-
- -
-
-
-
- ); -} diff --git a/components/auth-ui/EmailSignIn.tsx b/components/auth-ui/EmailSignIn.tsx new file mode 100644 index 0000000..f9f3f06 --- /dev/null +++ b/components/auth-ui/EmailSignIn.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { signInWithEmail } from '@/utils/auth-helpers/server'; +import { handleRequest } from '@/utils/auth-helpers/client'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +// Define prop type with allowPassword boolean +interface EmailSignInProps { + allowPassword: boolean; + redirectMethod: string; + disableButton?: boolean; +} + +export default function EmailSignIn({ + allowPassword, + redirectMethod, + disableButton +}: EmailSignInProps) { + const router = redirectMethod === 'client' ? useRouter() : null; + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + setIsSubmitting(true); // Disable the button while the request is being handled + await handleRequest(e, signInWithEmail, router); + setIsSubmitting(false); + }; + + return ( +
+
handleSubmit(e)} + > +
+
+ + +
+ +
+
+ {allowPassword && ( + <> +

+ + Sign in with email and password + +

+

+ + Don't have an account? Sign up + +

+ + )} +
+ ); +} diff --git a/components/auth-ui/ForgotPassword.tsx b/components/auth-ui/ForgotPassword.tsx new file mode 100644 index 0000000..b1184ea --- /dev/null +++ b/components/auth-ui/ForgotPassword.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { requestPasswordUpdate } from '@/utils/auth-helpers/server'; +import { handleRequest } from '@/utils/auth-helpers/client'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +// Define prop type with allowEmail boolean +interface ForgotPasswordProps { + allowEmail: boolean; + redirectMethod: string; + disableButton?: boolean; +} + +export default function ForgotPassword({ + allowEmail, + redirectMethod +}: ForgotPasswordProps) { + const router = redirectMethod === 'client' ? useRouter() : null; + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + setIsSubmitting(true); // Disable the button while the request is being handled + await handleRequest(e, requestPasswordUpdate, router); + setIsSubmitting(false); + }; + + return ( +
+
handleSubmit(e)} + > +
+
+ + +
+ +
+
+

+ + Sign in with email and password + +

+ {allowEmail && ( +

+ + Sign in via magic link + +

+ )} +

+ + Don't have an account? Sign up + +

+
+ ); +} diff --git a/components/auth-ui/OauthSignIn.tsx b/components/auth-ui/OauthSignIn.tsx new file mode 100644 index 0000000..7c3dc31 --- /dev/null +++ b/components/auth-ui/OauthSignIn.tsx @@ -0,0 +1,53 @@ +'use client'; + +import {Button} from '@/components/ui/button'; +import { signInWithOAuth } from '@/utils/auth-helpers/client'; +import { type Provider } from '@supabase/supabase-js'; +import { FcGoogle } from "react-icons/fc"; +import { useState } from 'react'; + +type OAuthProviders = { + name: Provider; + displayName: string; + icon: JSX.Element; +}; + +export default function OauthSignIn() { + const oAuthProviders: OAuthProviders[] = [ + { + name: 'google', + displayName: 'Google', + icon: + } + /* Add desired OAuth providers here */ + ]; + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + setIsSubmitting(true); // Disable the button while the request is being handled + await signInWithOAuth(e); + setIsSubmitting(false); + }; + + return ( +
+ {oAuthProviders.map((provider) => ( +
handleSubmit(e)} + > + + +
+ ))} +
+ ); +} diff --git a/components/auth-ui/PasswordSignIn.tsx b/components/auth-ui/PasswordSignIn.tsx new file mode 100644 index 0000000..1189c9e --- /dev/null +++ b/components/auth-ui/PasswordSignIn.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { signInWithPassword } from '@/utils/auth-helpers/server'; +import { handleRequest } from '@/utils/auth-helpers/client'; +import { useRouter } from 'next/navigation'; +import React, { useState } from 'react'; + +// Define prop type with allowEmail boolean +interface PasswordSignInProps { + allowEmail: boolean; + redirectMethod: string; +} + +export default function PasswordSignIn({ + allowEmail, + redirectMethod +}: PasswordSignInProps) { + const router = redirectMethod === 'client' ? useRouter() : null; + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + setIsSubmitting(true); // Disable the button while the request is being handled + await handleRequest(e, signInWithPassword, router); + setIsSubmitting(false); + }; + + return ( +
+
handleSubmit(e)} + > +
+
+ + + + +
+ +
+
+

+ + Forgot your password? + +

+ {allowEmail && ( +

+ + Sign in via magic link + +

+ )} +

+ + Don't have an account? Sign up + +

+
+ ); +} diff --git a/components/auth-ui/Separator.tsx b/components/auth-ui/Separator.tsx new file mode 100644 index 0000000..1dcf753 --- /dev/null +++ b/components/auth-ui/Separator.tsx @@ -0,0 +1,20 @@ +interface SeparatorProps { + text: string; +} + +export default function Separator(props: { text?: string }) { + const { text } = props; + return ( +
+
+
+ {text && ( + + {text} + + )} +
+
+
+ ); +} diff --git a/components/auth-ui/Signup.tsx b/components/auth-ui/Signup.tsx new file mode 100644 index 0000000..5352f6c --- /dev/null +++ b/components/auth-ui/Signup.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import React from 'react'; +import Link from 'next/link'; +import { signUp } from '@/utils/auth-helpers/server'; +import { handleRequest } from '@/utils/auth-helpers/client'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +// Define prop type with allowEmail boolean +interface SignUpProps { + allowEmail: boolean; + redirectMethod: string; +} + +export default function SignUp({ allowEmail, redirectMethod }: SignUpProps) { + const router = redirectMethod === 'client' ? useRouter() : null; + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + setIsSubmitting(true); // Disable the button while the request is being handled + await handleRequest(e, signUp, router); + setIsSubmitting(false); + }; + + return ( +
+
handleSubmit(e)} + > +
+
+ + + + +
+ +
+
+

+ + Forgot your password? + +

+

+ + Already have an account? + +

+ {allowEmail && ( +

+ + Sign in via magic link + +

+ )} +
+ ); +} diff --git a/components/auth-ui/UpdatePassword.tsx b/components/auth-ui/UpdatePassword.tsx new file mode 100644 index 0000000..43615bc --- /dev/null +++ b/components/auth-ui/UpdatePassword.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { updatePassword } from '@/utils/auth-helpers/server'; +import { handleRequest } from '@/utils/auth-helpers/client'; +import { useRouter } from 'next/navigation'; +import React, { useState } from 'react'; + +interface UpdatePasswordProps { + redirectMethod: string; +} + +export default function UpdatePassword({ + redirectMethod +}: UpdatePasswordProps) { + const router = redirectMethod === 'client' ? useRouter() : null; + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + setIsSubmitting(true); // Disable the button while the request is being handled + await handleRequest(e, updatePassword, router); + setIsSubmitting(false); + }; + + return ( +
+
handleSubmit(e)} + > +
+
+ + + + +
+ +
+
+
+ ); +} diff --git a/components/auth/AuthUI.tsx b/components/auth/AuthUI.tsx index b4bc074..19d3ed5 100644 --- a/components/auth/AuthUI.tsx +++ b/components/auth/AuthUI.tsx @@ -1,141 +1,75 @@ 'use client'; -import { useSupabase } from '@/app/supabase-provider'; -import { getURL } from '@/utils/helpers'; -import { Auth } from '@supabase/auth-ui-react'; -import { useTheme } from 'next-themes'; +import PasswordSignIn from '@/components/auth-ui/PasswordSignIn'; +import EmailSignIn from '@/components/auth-ui/EmailSignIn'; +import Separator from '@/components/auth-ui/Separator'; +import OauthSignIn from '@/components/auth-ui/OauthSignIn'; +import ForgotPassword from '@/components/auth-ui/ForgotPassword'; +import UpdatePassword from '@/components/auth-ui/UpdatePassword'; +import SignUp from '@/components/auth-ui/Signup'; -export default function AuthUI() { - const { supabase } = useSupabase(); - const { theme, setTheme } = useTheme(); - - const customTheme = { - default: { - colors: { - brand: 'hsl(var(--primary))', - brandAccent: 'hsl(var(--primary))', - brandButtonText: 'white', - defaultButtonBackground: 'hsl(var(--background))', - defaultButtonBackgroundHover: 'hsl(var(--background))', - defaultButtonBorder: 'hsl(var(--border))', - defaultButtonText: 'hsl(var(--foreground))', - dividerBackground: 'hsl(var(--border))', - inputBackground: 'transparent', - inputBorder: 'hsl(var(--border))', - inputBorderHover: 'hsl(var(--border))', - inputBorderFocus: 'hsl(var(--border))', - inputText: 'black', - inputLabelText: 'gray', - inputPlaceholder: 'darkgray', - messageText: 'gray', - messageTextDanger: 'red', - anchorTextColor: 'gray', - anchorTextHoverColor: 'darkgray', - }, - space: { - spaceSmall: '4px', - spaceMedium: '8px', - spaceLarge: '16px', - labelBottomMargin: '8px', - anchorBottomMargin: '4px', - emailInputSpacing: '4px', - socialAuthSpacing: '4px', - buttonPadding: '14px 14px', - inputPadding: '14px 14px', - }, - fontSizes: { - baseBodySize: '13px', - baseInputSize: '14px', - baseLabelSize: '14px', - baseButtonSize: '14px', - }, - fonts: { - bodyFontFamily: `Inter, sans-serif`, - buttonFontFamily: `Inter, sans-serif`, - inputFontFamily: `Inter, sans-serif`, - labelFontFamily: `Inter, sans-serif`, - }, - borderWidths: { - buttonBorderWidth: '1px', - inputBorderWidth: '1px', - }, - radii: { - borderRadiusButton: '8px', - buttonBorderRadius: '8px', - inputBorderRadius: '8px', - }, - }, - dark: { - colors: { - brand: 'hsl(var(--primary))', - brandAccent: 'hsl(var(--primary))', - brandButtonText: 'black', - defaultButtonprimary: 'hsl(var(--background))', - defaultButtonBackgroundHover: '#F7FAFC', - defaultButtonBorder: 'hsl(var(--border))', - defaultButtonText: 'white', - dividerBackground: 'hsl(var(--border))', - inputBackground: 'transparent', - inputBorder: 'hsl(var(--border))', - inputBorderHover: 'hsl(var(--border))', - inputBorderFocus: 'hsl(var(--border))', - inputText: 'white', - inputLabelText: 'white', - inputPlaceholder: 'hsl(240, 5%, 65%)', - messageText: 'white', - messageTextDanger: 'red', - anchorTextColor: 'white', - anchorTextHoverColor: 'white', - }, - space: { - spaceSmall: '4px', - spaceMedium: '8px', - spaceLarge: '16px', - labelBottomMargin: '8px', - anchorBottomMargin: '4px', - emailInputSpacing: '4px', - socialAuthSpacing: '4px', - buttonPadding: '14px 14px', - inputPadding: '14px 14px', - }, - fontSizes: { - baseBodySize: '13px', - baseInputSize: '14px', - baseLabelSize: '14px', - baseButtonSize: '14px', - }, - fonts: { - bodyFontFamily: `Inter, sans-serif`, - buttonFontFamily: `Inter, sans-serif`, - inputFontFamily: `Inter, sans-serif`, - labelFontFamily: `Inter, sans-serif`, - }, - borderWidths: { - buttonBorderWidth: '1px', - inputBorderWidth: '1px', - }, - radii: { - borderRadiusButton: '8px', - buttonBorderRadius: '8px', - inputBorderRadius: '8px', - }, - }, - }; +export default function AuthUI(props: any) { return ( -
+

- Sign In + {props.viewProp === 'signup' + ? 'Sign Up' + : props.viewProp === 'forgot_password' + ? 'Forgot Password' + : props.viewProp === 'update_password' + ? 'Update Password' + : props.viewProp === 'email_signin' + ? 'Email Sign In' + : 'Sign In'}

- Enter your email and password to sign in! + {props.viewProp === 'signup' + ? 'Enter your email and password to sign up!' + : props.viewProp === 'forgot_password' + ? 'Enter your email to get a passoword reset link!' + : props.viewProp === 'update_password' + ? 'Choose a new password for your account!' + : props.viewProp === 'email_signin' + ? 'Enter your email to get a magic link!' + : 'Enter your email and password to sign in!'}

- + {props.viewProp !== 'update_password' && + props.viewProp !== 'signup' && + props.allowOauth && ( + <> + + + + )} + {props.viewProp === 'password_signin' && ( + + )} + {props.viewProp === 'email_signin' && ( + + )} + {props.viewProp === 'forgot_password' && ( + + )} + {props.viewProp === 'update_password' && ( + + )} + {props.viewProp === 'signup' && ( + + )}
); } diff --git a/components/auth/index.tsx b/components/auth/index.tsx index 093315d..a2c7436 100644 --- a/components/auth/index.tsx +++ b/components/auth/index.tsx @@ -10,6 +10,7 @@ import { IoMoon, IoSunny } from 'react-icons/io5'; interface DefaultAuthLayoutProps extends PropsWithChildren { children: JSX.Element; + viewProp: any; } export default function DefaultAuthLayout(props: DefaultAuthLayoutProps) { @@ -18,10 +19,12 @@ export default function DefaultAuthLayout(props: DefaultAuthLayoutProps) { return (
- +
- -

Back to the website

+ +

+ Back to the website +

{children} diff --git a/components/dashboard/ai-chat/index.tsx b/components/dashboard/ai-chat/index.tsx index 532b0ef..868405e 100644 --- a/components/dashboard/ai-chat/index.tsx +++ b/components/dashboard/ai-chat/index.tsx @@ -10,12 +10,11 @@ import { AccordionTrigger } from '@/components/ui/accordion'; import { Button } from '@/components/ui/button'; -import { useToast } from '@/components/ui/use-toast'; import Bgdark from '@/public/img/dark/ai-chat/bg-image.png'; import Bg from '@/public/img/light/ai-chat/bg-image.png'; import { ChatBody, OpenAIModel } from '@/types/types'; import { Database } from '@/types/types_db'; -import { Session, User } from '@supabase/supabase-js'; +import { User } from '@supabase/supabase-js'; import { useTheme } from 'next-themes'; import Image from 'next/image'; import { useState } from 'react'; @@ -35,14 +34,10 @@ interface SubscriptionWithProduct extends Subscription { } interface Props { - session: Session | null; user: User | null | undefined; - products: ProductWithPrices[]; - subscription: SubscriptionWithProduct | null; userDetails: { [x: string]: any } | null; } export default function Chat(props: Props) { - const { toast } = useToast(); const { theme, setTheme } = useTheme(); // *** If you use .env.local variable for your API key, method which we recommend, use the apiKey variable commented below // Input States @@ -51,7 +46,7 @@ export default function Chat(props: Props) { // Response message const [outputCode, setOutputCode] = useState(''); // ChatGPT model - const [model, setModel] = useState('gpt-4-1106-preview'); + const [model, setModel] = useState('gpt-3.5-turbo'); // Loading state const [loading, setLoading] = useState(false); @@ -60,7 +55,7 @@ export default function Chat(props: Props) { setInputOnSubmit(inputMessage); // Chat post conditions(maximum number of characters, valid message etc.) - const maxCodeLength = model === 'gpt-4-1106-preview' ? 700 : 700; + const maxCodeLength = model === 'gpt-3.5-turbo' ? 700 : 700; if (!inputMessage) { alert('Please enter your subject.'); @@ -139,24 +134,18 @@ export default function Chat(props: Props) { return (
-
- -
+
{/* Model Change */}
setModel('gpt-4-1106-preview')} + } h-[70xp] w-[174px] + ${ + model === 'gpt-3.5-turbo' ? '' : '' + } rounded-lg text-base font-semibold text-zinc-950 dark:text-white`} + onClick={() => setModel('gpt-3.5-turbo')} > - GPT-4 + GPT-3.5
setModel('gpt-4o')} + onClick={() => setModel('gpt-4-1106-preview')} > - GPT-4o + GPT-4
+ { - try { - const { url } = await postData({ - url: '/api/create-portal-link' - }); - router.push(url); - } catch (error) { - if (error) return alert((error as Error).message); - } - }; - return ( diff --git a/components/footer/FooterAuthDefault.tsx b/components/footer/FooterAuthDefault.tsx index 6023f19..6edb63d 100644 --- a/components/footer/FooterAuthDefault.tsx +++ b/components/footer/FooterAuthDefault.tsx @@ -2,7 +2,7 @@ export default function Footer() { return ( -
+
- +
); diff --git a/components/navbar/NavbarLinksAdmin.tsx b/components/navbar/NavbarLinksAdmin.tsx index 110d5f8..667c636 100644 --- a/components/navbar/NavbarLinksAdmin.tsx +++ b/components/navbar/NavbarLinksAdmin.tsx @@ -1,35 +1,37 @@ 'use client'; -import { useSupabase } from '@/app/supabase-provider'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, - DropdownMenuTrigger, + DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { OpenContext, UserContext } from '@/contexts/layout'; +import { handleRequest } from '@/utils/auth-helpers/client'; +import { SignOut } from '@/utils/auth-helpers/server'; +import { getRedirectMethod } from '@/utils/auth-helpers/settings'; import { useTheme } from 'next-themes'; -import { useRouter } from 'next/navigation'; -import React from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import React, { useContext } from 'react'; import { useState, useEffect } from 'react'; import { FiAlignJustify } from 'react-icons/fi'; import { HiOutlineMoon, HiOutlineSun, HiOutlineInformationCircle, - HiOutlineArrowRightOnRectangle, + HiOutlineArrowRightOnRectangle } from 'react-icons/hi2'; -export default function HeaderLinks(props: { - userDetails: { [x: string]: any } | null; - [x: string]: any; -}) { - const { onOpen } = props; - const { supabase } = useSupabase(); - const router = useRouter(); +export default function HeaderLinks(props: { [x: string]: any }) { + const { open, setOpen } = useContext(OpenContext); + const user = useContext(UserContext); const { theme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); - + const router = getRedirectMethod() === 'client' ? useRouter() : null; + const onOpen = () => { + setOpen(false); + }; useEffect(() => { setMounted(true); }, []); @@ -93,21 +95,24 @@ export default function HeaderLinks(props: { - +
handleRequest(e, SignOut, router)}> + + +
- - US + + + {user?.user_metadata.full_name + ? `${user?.user_metadata.full_name[0]}` + : `${user?.email[0].toUpperCase()}`} +
diff --git a/components/pricing/index.tsx b/components/pricing/index.tsx deleted file mode 100644 index d06f114..0000000 --- a/components/pricing/index.tsx +++ /dev/null @@ -1,587 +0,0 @@ -'use client'; - -// @ts-nocheck -// Custom components -import { FooterWebsite } from '../footer/FooterWebsite'; -import { Badge } from '../ui/badge'; -import { Button } from '../ui/button'; -import Faq from '@/components/landing/faq'; -import InnerContent from '@/components/layout/innerContent'; -import NavbarFixed from '@/components/navbar/NavbarFixed'; -import { Card } from '@/components/ui/card'; -import { Database } from '@/types/types_db'; -import { postData } from '@/utils/helpers'; -import { getStripe } from '@/utils/stripe-client'; -import { Session, User } from '@supabase/supabase-js'; -import { useRouter } from 'next/navigation'; -import React, { useState } from 'react'; -import { - FaCcVisa, - FaCcMastercard, - FaCcPaypal, - FaCcAmex, - FaCcApplePay, -} from 'react-icons/fa'; -import { HiOutlineCheckCircle, HiOutlineXCircle } from 'react-icons/hi2'; -import { MdAttachMoney, MdLock } from 'react-icons/md'; - -type Subscription = Database['public']['Tables']['subscriptions']['Row']; -type Product = Database['public']['Tables']['products']['Row']; -type Price = Database['public']['Tables']['prices']['Row']; -interface ProductWithPrices extends Product { - prices: Price[]; -} -interface PriceWithProduct extends Price { - products: Product | null; -} -interface SubscriptionWithProduct extends Subscription { - prices: PriceWithProduct | null; -} - -interface Props { - session: Session | null; - user: User | null | undefined; - products: ProductWithPrices[]; - subscription: SubscriptionWithProduct | null; -} - -export default function Pricing({ - session, - user, - products, - subscription, -}: Props) { - const router = useRouter(); - // const [billingInterval, setBillingInterval] = - // useState('month'); - const [priceIdLoading, setPriceIdLoading] = useState(); - - const handleCheckout = async (price: Price) => { - setPriceIdLoading(price.id); - if (!user) { - return router.push('/dashboard/signin'); - } - if (subscription) { - return router.push('/dashboard/subscription'); - } - try { - const { sessionId } = await postData({ - url: '/api/create-checkout-session', - data: { price }, - }); - - const stripe = await getStripe(); - stripe?.redirectToCheckout({ sessionId }); - } catch (error) { - return alert((error as Error)?.message); - } finally { - setPriceIdLoading(undefined); - } - }; - const [version, setVersion] = useState('monthly'); - - return ( -
- - - {/* Title */} -
-
- - PRICING PLANS - -

- Choose the right pricing plan for you and your business -

-
-
-
- {/* LEFT */} - -
-

Free Plan

-
-
-
-

- Free -

-
- - - -
- - {/* Features */} -
-
- - - Standard Essays - -
-
- - - Up to 4 Essay types - -
-
- - - Up to 200 words per Essay - -
-
- - - Essay Tones (Academic, etc.) - -
-
- - - Academic Citation formats (APA, etc) - -
-
- - - Academic Levels (Master, etc.) - -
-
- - - Premium features - -
-
- - - Priority Support - -
-
-
- {/* CENTER */} - {products.map((product) => { - const price = product?.prices?.find( - (price) => price.id === 'price_1OAEhUDUwD2aqzMkbQUIFuiI', - ); - if (product.id === 'prod_OXGZkl2lnZ9VId') { - if (!price) return null; - return ( - -
-

- {/* {product.name?.toString()} */} - Unlimited Plan -

-
-
-
-

- $ - {price.unit_amount !== null - ? price.unit_amount / 100 - : price.unit_amount} -

-
-

- {version !== 'yearly' ? '/month' : '/month'} -

-

- reg.$108 -

-
-
- -
- {/* Features */} -
-
- - - Unlimited Premium Essays / year - -
-
- - - Access to 12+ Essay types - -
-
- - - Up to 2500 words per Essay - -
-
- - - Academic Citation formats (APA, etc) - -
-
- - - Essay Tones (Academic, etc.) - -
-
- - - Academic Levels (Master, etc.) - -
-
- - - Exceptional Essays in seconds - -
-
- - - Easy-to-use Essay Generator - -
-
- - - Priority Support - -
-
-
- ); - } - })} - {/* EXAMPLE CENTER */} - -
-

- Unlimited Plan -

-
-
-
-

- $9 -

-
-

- {version !== 'yearly' ? '/month' : '/month'} -

-
-
- -
- {/* Features */} -
-
- - - Unlimited Premium Essays / year - -
-
- - - Access to 12+ Essay types - -
-
- - - Up to 2500 words per Essay - -
-
- - - Academic Citation formats (APA, etc) - -
-
- - - Essay Tones (Academic, etc.) - -
-
- - - Academic Levels (Master, etc.) - -
-
- - - Exceptional Essays in seconds - -
-
- - - Easy-to-use Essay Generator - -
-
- - - Priority Support - -
-
-
- {/* EXAMPLE RIGHT */} - -
-

- Yearly Plan -

- - SAVE 35% - -
-
-
-

- $69 -

-
-

- /year -

-

- reg.$108 -

-
-
- -
- - {/* Features */} -
-
- - - Unlimited Premium Essays / year - -
-
- - - Access to 12+ Essay types - -
-
- - - Up to 2500 words per Essay - -
-
- - - Academic Citation formats (APA, etc) - -
-
- - - Essay Tones (Academic, etc.) - -
-
- - - Academic Levels (Master, etc.) - -
-
- - - Exceptional Essays in seconds - -
-
- - - Easy-to-use Essay Generator - -
-
- - - Priority Support - -
-
-
- {/* RIGHT */} - {products.map((product) => { - const price = product?.prices?.find( - (price) => price.id === 'price_1OAHvkDUwD2aqzMkHMJCFa5U', - ); - if (product.id === 'prod_OXGeiZteF0OyEt') { - if (!price) return null; - return ( - -
-

- {/* {product.name?.toString()} */} - Yearly Plan -

- - SAVE 35% - -
-
-
-

- $ - {price.unit_amount !== null - ? price.unit_amount / 100 - : price.unit_amount} -

-
-

- /year -

-

- reg.$108 -

-
-
- -
- - {/* Features */} -
-
- - - Unlimited Premium Essays / year - -
-
- - - Access to 12+ Essay types - -
-
- - - Up to 2500 words per Essay - -
-
- - - Academic Citation formats (APA, etc) - -
-
- - - Essay Tones (Academic, etc.) - -
-
- - - Academic Levels (Master, etc.) - -
-
- - - Exceptional Essays in seconds - -
-
- - - Easy-to-use Essay Generator - -
-
- - - Priority Support - -
-
-
- ); - } - })} -
-
-
- - - 14-Days money back - -
-
- - - Secured AES-256 Encrypted payments powered by Stripe: - -
-
- - - - - -
-
- -
- - - -
- ); -} diff --git a/components/sidebar/Sidebar.tsx b/components/sidebar/Sidebar.tsx index 867d37e..df6a44b 100644 --- a/components/sidebar/Sidebar.tsx +++ b/components/sidebar/Sidebar.tsx @@ -2,70 +2,38 @@ import { Badge } from '../ui/badge'; import { Button } from '../ui/button'; -import { useSupabase } from '@/app/supabase-provider'; import { renderThumb, renderTrack, - renderView, + renderView } from '@/components/scrollbar/Scrollbar'; import Links from '@/components/sidebar/components/Links'; import SidebarCard from '@/components/sidebar/components/SidebarCard'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Card } from '@/components/ui/card'; import { IRoute } from '@/types/types'; -import { Database } from '@/types/types_db'; -import { postData } from '@/utils/helpers'; -import { getStripe } from '@/utils/stripe-client'; -import { useRouter } from 'next/navigation'; -import React, { PropsWithChildren } from 'react'; -import { useState } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import React, { PropsWithChildren, useContext } from 'react'; import { Scrollbars } from 'react-custom-scrollbars-2'; import { HiX } from 'react-icons/hi'; import { HiBolt } from 'react-icons/hi2'; import { HiOutlineArrowRightOnRectangle } from 'react-icons/hi2'; +import { getRedirectMethod } from '@/utils/auth-helpers/settings'; +import { SignOut } from '@/utils/auth-helpers/server'; +import { handleRequest } from '@/utils/auth-helpers/client'; +import { UserContext, UserDetailsContext } from '@/contexts/layout'; export interface SidebarProps extends PropsWithChildren { routes: IRoute[]; [x: string]: any; } -interface SidebarLinksProps extends PropsWithChildren { - routes: IRoute[]; - [x: string]: any; -} - -type Price = Database['public']['Tables']['prices']['Row']; function Sidebar(props: SidebarProps) { - const router = useRouter(); - const { supabase } = useSupabase(); + const router = getRedirectMethod() === 'client' ? useRouter() : null; const { routes } = props; - const [plan, setPlan] = useState({ - product: 'prod_OXGZkl2lnZ9VId', - price: 'price_1OAEhUDUwD2aqzMkbQUIFuiI', - }); - const [priceIdLoading, setPriceIdLoading] = useState(); - const handleCheckout = async (price: Price) => { - setPriceIdLoading(price.id); - if (!props.user) { - return router.push('/dashboard/signin'); - } - if (props.subscription) { - return router.push('/dashboard/settings'); - } - try { - const { sessionId } = await postData({ - url: '/api/create-checkout-session', - data: { price }, - }); - const stripe = await getStripe(); - stripe?.redirectToCheckout({ sessionId }); - } catch (error) { - return alert((error as Error)?.message); - } finally { - setPriceIdLoading(undefined); - } - }; + const user = useContext(UserContext); + const userDetails = useContext(UserDetailsContext); // SIDEBAR return (
{/* Nav item */}
    - +
{/* Free Horizon Card */} @@ -128,34 +87,39 @@ function Sidebar(props: SidebarProps) {
diff --git a/components/sidebar/components/Links.tsx b/components/sidebar/components/Links.tsx index d7f6c71..b54df7a 100644 --- a/components/sidebar/components/Links.tsx +++ b/components/sidebar/components/Links.tsx @@ -2,20 +2,9 @@ /* eslint-disable */ import NavLink from '@/components/link/NavLink'; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '@/components/ui/accordion'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; import { IRoute } from '@/types/types'; -import { useTheme } from 'next-themes'; -import { usePathname, useRouter } from 'next/navigation'; +import { usePathname } from 'next/navigation'; import { PropsWithChildren, useCallback } from 'react'; -import { FaCircle } from 'react-icons/fa'; -import { IoMdAdd } from 'react-icons/io'; interface SidebarLinksProps extends PropsWithChildren { routes: IRoute[]; @@ -32,7 +21,13 @@ export function SidebarLinks(props: SidebarLinksProps) { (routeName: string) => { return pathname?.includes(routeName); }, - [pathname], + [pathname] + ); + const activeLayout = useCallback( + (routeName: string) => { + return pathname?.includes('/ai'); + }, + [pathname] ); // this function creates the links and collapses that appear in the sidebar (left menu) @@ -104,29 +99,6 @@ export function SidebarLinks(props: SidebarLinksProps) { } }); }; - // this function creates the links from the secondary accordions (for example auth -> sign-in -> default) - const createAccordionLinks = (routes: IRoute[]) => { - return routes.map((route: IRoute, key: number) => { - return ( -
  • - - -

    - {route.name} -

    -
    -
  • - ); - }); - }; // BRAND return <>{createLinks(routes)}; } diff --git a/components/sidebar/components/SidebarCard.tsx b/components/sidebar/components/SidebarCard.tsx index cb8c7ea..6390f29 100644 --- a/components/sidebar/components/SidebarCard.tsx +++ b/components/sidebar/components/SidebarCard.tsx @@ -1,18 +1,9 @@ 'use client'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import SidebarImage from '@/public/SidebarBadge.png'; -import { Database } from '@/types/types_db'; -import { useTheme } from 'next-themes'; import Image from 'next/image'; -import { BiSolidCheckSquare } from 'react-icons/bi'; -import { IoIosStar } from 'react-icons/io'; -type Price = Database['public']['Tables']['prices']['Row']; -interface SidebarCard { - [x: string]: any; -} export default function SidebarDocs() { return (
    diff --git a/contexts/layout.ts b/contexts/layout.ts new file mode 100644 index 0000000..13987e0 --- /dev/null +++ b/contexts/layout.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react'; +import { User } from '@supabase/supabase-js'; + +interface OpenContextType { + open: boolean; + setOpen: React.Dispatch>; +} +type UserDetails = { [x: string]: any } | null; + +export const OpenContext = createContext(undefined); +export const UserContext = createContext(undefined); +export const UserDetailsContext = createContext( + undefined +); diff --git a/package.json b/package.json index b184b1f..19e9dba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shadcn-nextjs-boilerplate", - "version": "1.1.0", + "version": "2.0.0", "private": true, "scripts": { "init": "npm install && npx shadcn-ui@latest add --all", @@ -8,7 +8,21 @@ "build": "next build", "start": "next start", "lint": "next lint", - "preinstall": "npx npm-force-resolutions" + "preinstall": "npx npm-force-resolutions", + "stripe:login": "stripe login", + "stripe:listen": "stripe listen --forward-to=localhost:3000/api/webhooks", + "stripe:fixtures": "stripe fixtures fixtures/stripe-fixtures.json", + "supabase:start": "npx supabase start", + "supabase:stop": "npx supabase stop", + "supabase:status": "npx supabase status", + "supabase:restart": "npm run supabase:stop && npm run supabase:start", + "supabase:reset": "npx supabase db reset", + "supabase:link": "npx supabase link", + "supabase:generate-types": "npx supabase gen types typescript --local --schema public > types_db.ts", + "supabase:generate-migration": "npx supabase db diff | npx supabase migration new", + "supabase:generate-seed": "npx supabase db dump --data-only -f supabase/seed.sql", + "supabase:push": "npx supabase db push", + "supabase:pull": "npx supabase db pull" }, "dependencies": { "@aws-sdk/client-s3": "^3.583.0", @@ -48,10 +62,8 @@ "@radix-ui/react-tooltip": "^1.1.2", "@stripe/stripe-js": "^3.4.1", "@supabase/auth-helpers-nextjs": "^0.10.0", - "@supabase/auth-helpers-react": "^0.5.0", - "@supabase/auth-ui-react": "^0.4.7", - "@supabase/auth-ui-shared": "^0.1.8", - "@supabase/supabase-js": "^2.43.4", + "@supabase/ssr": "^0.4.0", + "@supabase/supabase-js": "^2.45.1", "@tanstack/react-table": "^8.17.3", "@testing-library/jest-dom": "^6.4.5", "@testing-library/react": "^15.0.7", @@ -60,14 +72,13 @@ "@uiw/react-codemirror": "^4.22.1", "adblock-detect-react": "^1.3.1", "apexcharts": "3.49.1", - "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "classnames": "^2.5.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", "crypto": "^1.0.1", "date-fns": "^3.6.0", - "embla-carousel-react": "^8.1.6", + "embla-carousel-react": "^8.2.0", "endent": "^2.1.0", "eventsource-parser": "^1.1.2", "framer-motion": "^11.2.6", @@ -76,7 +87,6 @@ "next": "^14.2.3", "next-themes": "^0.3.0", "openai": "^4.47.1", - "paddle-sdk": "^4.6.2", "php-serialize": "^4.1.1", "react": "18.3.1", "react-apexcharts": "1.4.1", @@ -85,12 +95,12 @@ "react-dom": "18.3.1", "react-github-btn": "^1.4.0", "react-github-button": "^0.1.11", - "react-hook-form": "^7.52.1", + "react-hook-form": "^7.53.0", "react-icons": "^5.2.1", "react-is": "^18.3.1", "react-markdown": "^9.0.1", "react-merge-refs": "^2.1.1", - "react-resizable-panels": "^2.0.20", + "react-resizable-panels": "^2.1.1", "react-router-dom": "^6.23.1", "react-to-print": "^2.15.1", "recharts": "^2.12.7", @@ -128,6 +138,7 @@ "postcss": "^8.4.38", "prettier": "^3.2.5", "prettier-plugin-tailwindcss": "^0.5.14", + "supabase": "^1.172.2", "tailwindcss": "^3.4.3", "typescript": "5.4.5" }, diff --git a/schema.sql b/schema.sql index 7ddfea0..b02e764 100644 --- a/schema.sql +++ b/schema.sql @@ -7,7 +7,8 @@ create table users ( id uuid references auth.users not null primary key, full_name text, avatar_url text, - credits bigint DEFAULT 3, + credits bigint DEFAULT 0, + trial_credits bigint DEFAULT 3, -- The customer's billing address, stored in JSON format. billing_address jsonb, -- Stores your customer's payment instruments. diff --git a/supabase/.gitignore b/supabase/.gitignore index 773c7c3..538f38b 100644 --- a/supabase/.gitignore +++ b/supabase/.gitignore @@ -1,3 +1,4 @@ # Supabase .branches .temp +config.toml diff --git a/supabase/config.toml.example b/supabase/config.toml.example new file mode 100644 index 0000000..befe926 --- /dev/null +++ b/supabase/config.toml.example @@ -0,0 +1,82 @@ +# A string used to distinguish different Supabase projects on the same host. Defaults to the working +# directory name when running `supabase init`. +project_id = "horizon-ui-shadcn" + +[api] +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. public and storage are always included. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. public is always included. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[db] +# Port to use for the local database URL. +port = 54322 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 15 + +[studio] +# Port to use for Supabase Studio. +port = 54323 + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +# Port to use for the email testing server web interface. +port = 54324 +smtp_port = 54325 +pop3_port = 54326 + +[storage] +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +[auth] +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://localhost:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://localhost:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one +# week). +jwt_expiry = 3600 +# Allow/disallow new user signups to your project. +enable_signup = true + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.google] +enabled = true +client_id = "************************************************" +secret = "***********************************************" +# Overrides the default auth redirectUrl. +redirect_uri = "http://127.0.0.1:54321/auth/v1/callback" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" + +[analytics] +enabled = false +port = 54327 +vector_port = 54328 +# Setup BigQuery project to enable log viewer on local development stack. +# See: https://supabase.com/docs/guides/getting-started/local-development#enabling-local-logging +gcp_project_id = "" +gcp_project_number = "" +gcp_jwt_path = "supabase/gcloud.json" diff --git a/supabase/seed.sql b/supabase/seed.sql deleted file mode 100644 index e69de29..0000000 diff --git a/tsconfig.json b/tsconfig.json index 8471b4b..6f9e5db 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { - "target": "ES2020", - "module": "nodenext", + "target": "ES2020", "lib": [ "dom", "dom.iterable", @@ -13,7 +12,8 @@ "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, - "moduleResolution": "node", + "module": "esnext", + "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", diff --git a/types/types.ts b/types/types.ts index d45439c..3ef2c78 100644 --- a/types/types.ts +++ b/types/types.ts @@ -1,14 +1,18 @@ -import Stripe from 'stripe';import { ComponentType, ReactNode } from 'react'; +import Stripe from 'stripe'; +import { ComponentType, ReactNode } from 'react'; - -export type OpenAIModel = 'gpt-3.5-turbo' | 'gpt-4' | 'gpt-4-1106-preview'| 'gpt-4o'; +export type OpenAIModel = + | 'gpt-3.5-turbo' + | 'gpt-4' + | 'gpt-4-1106-preview' + | 'gpt-4o'; export interface TranslateBody { // inputLanguage: string; // outputLanguage: string; topic: string; - paragraphs:string, - essayType:string, + paragraphs: string; + essayType: string; model: OpenAIModel; type?: 'review' | 'refactor' | 'complexity' | 'normal'; } @@ -97,8 +101,8 @@ export interface IRoute { layout?: string; exact?: boolean; component?: ComponentType; - disabled?:boolean - icon?: JSX.Element ; + disabled?: boolean; + icon?: JSX.Element; secondary?: boolean; collapse?: boolean; items?: IRoute[]; @@ -108,19 +112,14 @@ export interface IRoute { export interface EssayBody { topic: string; - words: "300" | "200"; - essayType: - | '' - | 'Argumentative' - | 'Classic' - | 'Persuasive' - | 'Critique' + words: '300' | '200'; + essayType: '' | 'Argumentative' | 'Classic' | 'Persuasive' | 'Critique'; model: OpenAIModel; apiKey?: string | undefined; } export interface PremiumEssayBody { - words:string; - topic: string; + words: string; + topic: string; essayType: | '' | 'Argumentative' @@ -134,14 +133,10 @@ export interface PremiumEssayBody { | 'Expository' | 'Cause and Effect' | 'Reflective' - | "Informative"; - tone:string; - citation:string; - level:string; + | 'Informative'; + tone: string; + citation: string; + level: string; model: OpenAIModel; apiKey?: string | undefined; } - -declare global { - var Paddle: any; -} \ No newline at end of file diff --git a/types/types_db.ts b/types/types_db.ts index 3bc756a..66e2839 100644 --- a/types/types_db.ts +++ b/types/types_db.ts @@ -4,231 +4,235 @@ export type Json = | boolean | null | { [key: string]: Json | undefined } - | Json[] + | Json[]; export interface Database { public: { Tables: { customers: { Row: { - id: string - stripe_customer_id: string | null - } + id: string; + stripe_customer_id: string | null; + }; Insert: { - id: string - stripe_customer_id?: string | null - } + id: string; + stripe_customer_id?: string | null; + }; Update: { - id?: string - stripe_customer_id?: string | null - } + id?: string; + stripe_customer_id?: string | null; + }; Relationships: [ { - foreignKeyName: "customers_id_fkey" - columns: ["id"] - referencedRelation: "users" - referencedColumns: ["id"] + foreignKeyName: 'customers_id_fkey'; + columns: ['id']; + referencedRelation: 'users'; + referencedColumns: ['id']; } - ] - } + ]; + }; prices: { Row: { - active: boolean | null - currency: string | null - description: string | null - id: string - interval: Database["public"]["Enums"]["pricing_plan_interval"] | null - interval_count: number | null - metadata: Json | null - product_id: string | null - trial_period_days: number | null - type: Database["public"]["Enums"]["pricing_type"] | null - unit_amount: number | null - } + active: boolean | null; + currency: string | null; + description: string | null; + id: string; + interval: Database['public']['Enums']['pricing_plan_interval'] | null; + interval_count: number | null; + metadata: Json | null; + product_id: string | null; + trial_period_days: number | null; + type: Database['public']['Enums']['pricing_type'] | null; + unit_amount: number | null; + }; Insert: { - active?: boolean | null - currency?: string | null - description?: string | null - id: string - interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null - interval_count?: number | null - metadata?: Json | null - product_id?: string | null - trial_period_days?: number | null - type?: Database["public"]["Enums"]["pricing_type"] | null - unit_amount?: number | null - } + active?: boolean | null; + currency?: string | null; + description?: string | null; + id: string; + interval?: + | Database['public']['Enums']['pricing_plan_interval'] + | null; + interval_count?: number | null; + metadata?: Json | null; + product_id?: string | null; + trial_period_days?: number | null; + type?: Database['public']['Enums']['pricing_type'] | null; + unit_amount?: number | null; + }; Update: { - active?: boolean | null - currency?: string | null - description?: string | null - id?: string - interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null - interval_count?: number | null - metadata?: Json | null - product_id?: string | null - trial_period_days?: number | null - type?: Database["public"]["Enums"]["pricing_type"] | null - unit_amount?: number | null - } + active?: boolean | null; + currency?: string | null; + description?: string | null; + id?: string; + interval?: + | Database['public']['Enums']['pricing_plan_interval'] + | null; + interval_count?: number | null; + metadata?: Json | null; + product_id?: string | null; + trial_period_days?: number | null; + type?: Database['public']['Enums']['pricing_type'] | null; + unit_amount?: number | null; + }; Relationships: [ { - foreignKeyName: "prices_product_id_fkey" - columns: ["product_id"] - referencedRelation: "products" - referencedColumns: ["id"] + foreignKeyName: 'prices_product_id_fkey'; + columns: ['product_id']; + referencedRelation: 'products'; + referencedColumns: ['id']; } - ] - } + ]; + }; products: { Row: { - active: boolean | null - description: string | null - id: string - image: string | null - metadata: Json | null - name: string | null - } + active: boolean | null; + description: string | null; + id: string; + image: string | null; + metadata: Json | null; + name: string | null; + }; Insert: { - active?: boolean | null - description?: string | null - id: string - image?: string | null - metadata?: Json | null - name?: string | null - } + active?: boolean | null; + description?: string | null; + id: string; + image?: string | null; + metadata?: Json | null; + name?: string | null; + }; Update: { - active?: boolean | null - description?: string | null - id?: string - image?: string | null - metadata?: Json | null - name?: string | null - } - Relationships: [] - } + active?: boolean | null; + description?: string | null; + id?: string; + image?: string | null; + metadata?: Json | null; + name?: string | null; + }; + Relationships: []; + }; subscriptions: { Row: { - cancel_at: string | null - cancel_at_period_end: boolean | null - canceled_at: string | null - created: string - current_period_end: string - current_period_start: string - ended_at: string | null - id: string - metadata: Json | null - price_id: string | null - quantity: number | null - status: Database["public"]["Enums"]["subscription_status"] | null - trial_end: string | null - trial_start: string | null - user_id: string - } + cancel_at: string | null; + cancel_at_period_end: boolean | null; + canceled_at: string | null; + created: string; + current_period_end: string; + current_period_start: string; + ended_at: string | null; + id: string; + metadata: Json | null; + price_id: string | null; + quantity: number | null; + status: Database['public']['Enums']['subscription_status'] | null; + trial_end: string | null; + trial_start: string | null; + user_id: string; + }; Insert: { - cancel_at?: string | null - cancel_at_period_end?: boolean | null - canceled_at?: string | null - created?: string - current_period_end?: string - current_period_start?: string - ended_at?: string | null - id: string - metadata?: Json | null - price_id?: string | null - quantity?: number | null - status?: Database["public"]["Enums"]["subscription_status"] | null - trial_end?: string | null - trial_start?: string | null - user_id: string - } + cancel_at?: string | null; + cancel_at_period_end?: boolean | null; + canceled_at?: string | null; + created?: string; + current_period_end?: string; + current_period_start?: string; + ended_at?: string | null; + id: string; + metadata?: Json | null; + price_id?: string | null; + quantity?: number | null; + status?: Database['public']['Enums']['subscription_status'] | null; + trial_end?: string | null; + trial_start?: string | null; + user_id: string; + }; Update: { - cancel_at?: string | null - cancel_at_period_end?: boolean | null - canceled_at?: string | null - created?: string - current_period_end?: string - current_period_start?: string - ended_at?: string | null - id?: string - metadata?: Json | null - price_id?: string | null - quantity?: number | null - status?: Database["public"]["Enums"]["subscription_status"] | null - trial_end?: string | null - trial_start?: string | null - user_id?: string - } + cancel_at?: string | null; + cancel_at_period_end?: boolean | null; + canceled_at?: string | null; + created?: string; + current_period_end?: string; + current_period_start?: string; + ended_at?: string | null; + id?: string; + metadata?: Json | null; + price_id?: string | null; + quantity?: number | null; + status?: Database['public']['Enums']['subscription_status'] | null; + trial_end?: string | null; + trial_start?: string | null; + user_id?: string; + }; Relationships: [ { - foreignKeyName: "subscriptions_price_id_fkey" - columns: ["price_id"] - referencedRelation: "prices" - referencedColumns: ["id"] + foreignKeyName: 'subscriptions_price_id_fkey'; + columns: ['price_id']; + referencedRelation: 'prices'; + referencedColumns: ['id']; }, { - foreignKeyName: "subscriptions_user_id_fkey" - columns: ["user_id"] - referencedRelation: "users" - referencedColumns: ["id"] + foreignKeyName: 'subscriptions_user_id_fkey'; + columns: ['user_id']; + referencedRelation: 'users'; + referencedColumns: ['id']; } - ] - } + ]; + }; users: { Row: { - avatar_url: string | null - billing_address: Json | null - full_name: string | null - id: string - payment_method: Json | null - } + avatar_url: string | null; + billing_address: Json | null; + full_name: string | null; + id: string; + payment_method: Json | null; + }; Insert: { - avatar_url?: string | null - billing_address?: Json | null - full_name?: string | null - id: string - payment_method?: Json | null - } + avatar_url?: string | null; + billing_address?: Json | null; + full_name?: string | null; + id: string; + payment_method?: Json | null; + }; Update: { - avatar_url?: string | null - billing_address?: Json | null - full_name?: string | null - id?: string - payment_method?: Json | null - } + avatar_url?: string | null; + billing_address?: Json | null; + full_name?: string | null; + id?: string; + payment_method?: Json | null; + }; Relationships: [ { - foreignKeyName: "users_id_fkey" - columns: ["id"] - referencedRelation: "users" - referencedColumns: ["id"] + foreignKeyName: 'users_id_fkey'; + columns: ['id']; + referencedRelation: 'users'; + referencedColumns: ['id']; } - ] - } - } + ]; + }; + }; Views: { - [_ in never]: never - } + [_ in never]: never; + }; Functions: { - [_ in never]: never - } + [_ in never]: never; + }; Enums: { - pricing_plan_interval: "day" | "week" | "month" | "year" - pricing_type: "one_time" | "recurring" + pricing_plan_interval: 'day' | 'week' | 'month' | 'year'; + pricing_type: 'one_time' | 'recurring'; subscription_status: - | "trialing" - | "active" - | "canceled" - | "incomplete" - | "incomplete_expired" - | "past_due" - | "unpaid" - | "paused" - } + | 'trialing' + | 'active' + | 'canceled' + | 'incomplete' + | 'incomplete_expired' + | 'past_due' + | 'unpaid' + | 'paused'; + }; CompositeTypes: { - [_ in never]: never - } - } + [_ in never]: never; + }; + }; } export interface DatabaseTTS { @@ -236,222 +240,306 @@ export interface DatabaseTTS { Tables: { customers: { Row: { - id: string - stripe_customer_id: string | null - } + id: string; + stripe_customer_id: string | null; + }; Insert: { - id: string - stripe_customer_id?: string | null - } + id: string; + stripe_customer_id?: string | null; + }; Update: { - id?: string - stripe_customer_id?: string | null - } + id?: string; + stripe_customer_id?: string | null; + }; Relationships: [ { - foreignKeyName: "customers_id_fkey" - columns: ["id"] - referencedRelation: "users" - referencedColumns: ["id"] + foreignKeyName: 'customers_id_fkey'; + columns: ['id']; + referencedRelation: 'users'; + referencedColumns: ['id']; } - ] - } + ]; + }; prices: { Row: { - active: boolean | null - currency: string | null - description: string | null - id: string - interval: Database["public"]["Enums"]["pricing_plan_interval"] | null - interval_count: number | null - metadata: Json | null - product_id: string | null - trial_period_days: number | null - type: Database["public"]["Enums"]["pricing_type"] | null - unit_amount: number | null - } + active: boolean | null; + currency: string | null; + description: string | null; + id: string; + interval: Database['public']['Enums']['pricing_plan_interval'] | null; + interval_count: number | null; + metadata: Json | null; + product_id: string | null; + trial_period_days: number | null; + type: Database['public']['Enums']['pricing_type'] | null; + unit_amount: number | null; + }; Insert: { - active?: boolean | null - currency?: string | null - description?: string | null - id: string - interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null - interval_count?: number | null - metadata?: Json | null - product_id?: string | null - trial_period_days?: number | null - type?: Database["public"]["Enums"]["pricing_type"] | null - unit_amount?: number | null - } + active?: boolean | null; + currency?: string | null; + description?: string | null; + id: string; + interval?: + | Database['public']['Enums']['pricing_plan_interval'] + | null; + interval_count?: number | null; + metadata?: Json | null; + product_id?: string | null; + trial_period_days?: number | null; + type?: Database['public']['Enums']['pricing_type'] | null; + unit_amount?: number | null; + }; Update: { - active?: boolean | null - currency?: string | null - description?: string | null - id?: string - interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null - interval_count?: number | null - metadata?: Json | null - product_id?: string | null - trial_period_days?: number | null - type?: Database["public"]["Enums"]["pricing_type"] | null - unit_amount?: number | null - } + active?: boolean | null; + currency?: string | null; + description?: string | null; + id?: string; + interval?: + | Database['public']['Enums']['pricing_plan_interval'] + | null; + interval_count?: number | null; + metadata?: Json | null; + product_id?: string | null; + trial_period_days?: number | null; + type?: Database['public']['Enums']['pricing_type'] | null; + unit_amount?: number | null; + }; Relationships: [ { - foreignKeyName: "prices_product_id_fkey" - columns: ["product_id"] - referencedRelation: "products" - referencedColumns: ["id"] + foreignKeyName: 'prices_product_id_fkey'; + columns: ['product_id']; + referencedRelation: 'products'; + referencedColumns: ['id']; } - ] - } + ]; + }; products: { Row: { - active: boolean | null - description: string | null - id: string - image: string | null - metadata: Json | null - name: string | null - } + active: boolean | null; + description: string | null; + id: string; + image: string | null; + metadata: Json | null; + name: string | null; + }; Insert: { - active?: boolean | null - description?: string | null - id: string - image?: string | null - metadata?: Json | null - name?: string | null - } + active?: boolean | null; + description?: string | null; + id: string; + image?: string | null; + metadata?: Json | null; + name?: string | null; + }; Update: { - active?: boolean | null - description?: string | null - id?: string - image?: string | null - metadata?: Json | null - name?: string | null - } - Relationships: [] - } + active?: boolean | null; + description?: string | null; + id?: string; + image?: string | null; + metadata?: Json | null; + name?: string | null; + }; + Relationships: []; + }; subscriptions: { Row: { - cancel_at: string | null - cancel_at_period_end: boolean | null - canceled_at: string | null - created: string - current_period_end: string - current_period_start: string - ended_at: string | null - id: string - metadata: Json | null - price_id: string | null - quantity: number | null - status: Database["public"]["Enums"]["subscription_status"] | null - trial_end: string | null - trial_start: string | null - user_id: string - } + cancel_at: string | null; + cancel_at_period_end: boolean | null; + canceled_at: string | null; + created: string; + current_period_end: string; + current_period_start: string; + ended_at: string | null; + id: string; + metadata: Json | null; + price_id: string | null; + quantity: number | null; + status: Database['public']['Enums']['subscription_status'] | null; + trial_end: string | null; + trial_start: string | null; + user_id: string; + }; Insert: { - cancel_at?: string | null - cancel_at_period_end?: boolean | null - canceled_at?: string | null - created?: string - current_period_end?: string - current_period_start?: string - ended_at?: string | null - id: string - metadata?: Json | null - price_id?: string | null - quantity?: number | null - status?: Database["public"]["Enums"]["subscription_status"] | null - trial_end?: string | null - trial_start?: string | null - user_id: string - } + cancel_at?: string | null; + cancel_at_period_end?: boolean | null; + canceled_at?: string | null; + created?: string; + current_period_end?: string; + current_period_start?: string; + ended_at?: string | null; + id: string; + metadata?: Json | null; + price_id?: string | null; + quantity?: number | null; + status?: Database['public']['Enums']['subscription_status'] | null; + trial_end?: string | null; + trial_start?: string | null; + user_id: string; + }; Update: { - cancel_at?: string | null - cancel_at_period_end?: boolean | null - canceled_at?: string | null - created?: string - current_period_end?: string - current_period_start?: string - ended_at?: string | null - id?: string - metadata?: Json | null - price_id?: string | null - quantity?: number | null - status?: Database["public"]["Enums"]["subscription_status"] | null - trial_end?: string | null - trial_start?: string | null - user_id?: string - } + cancel_at?: string | null; + cancel_at_period_end?: boolean | null; + canceled_at?: string | null; + created?: string; + current_period_end?: string; + current_period_start?: string; + ended_at?: string | null; + id?: string; + metadata?: Json | null; + price_id?: string | null; + quantity?: number | null; + status?: Database['public']['Enums']['subscription_status'] | null; + trial_end?: string | null; + trial_start?: string | null; + user_id?: string; + }; Relationships: [ { - foreignKeyName: "subscriptions_price_id_fkey" - columns: ["price_id"] - referencedRelation: "prices" - referencedColumns: ["id"] + foreignKeyName: 'subscriptions_price_id_fkey'; + columns: ['price_id']; + referencedRelation: 'prices'; + referencedColumns: ['id']; }, { - foreignKeyName: "subscriptions_user_id_fkey" - columns: ["user_id"] - referencedRelation: "users" - referencedColumns: ["id"] + foreignKeyName: 'subscriptions_user_id_fkey'; + columns: ['user_id']; + referencedRelation: 'users'; + referencedColumns: ['id']; } - ] - } + ]; + }; users: { Row: { - avatar_url: string | null - billing_address: Json | null - full_name: string | null - id: string - payment_method: Json | null - } + avatar_url: string | null; + billing_address: Json | null; + full_name: string | null; + id: string; + payment_method: Json | null; + }; Insert: { - avatar_url?: string | null - billing_address?: Json | null - full_name?: string | null - id: string - payment_method?: Json | null - } + avatar_url?: string | null; + billing_address?: Json | null; + full_name?: string | null; + id: string; + payment_method?: Json | null; + }; Update: { - avatar_url?: string | null - billing_address?: Json | null - full_name?: string | null - id?: string - payment_method?: Json | null - } + avatar_url?: string | null; + billing_address?: Json | null; + full_name?: string | null; + id?: string; + payment_method?: Json | null; + }; Relationships: [ { - foreignKeyName: "users_id_fkey" - columns: ["id"] - referencedRelation: "users" - referencedColumns: ["id"] + foreignKeyName: 'users_id_fkey'; + columns: ['id']; + referencedRelation: 'users'; + referencedColumns: ['id']; } - ] - } - } + ]; + }; + }; Views: { - [_ in never]: never - } + [_ in never]: never; + }; Functions: { - [_ in never]: never - } + [_ in never]: never; + }; Enums: { - pricing_plan_interval: "day" | "week" | "month" | "year" - pricing_type: "one_time" | "recurring" + pricing_plan_interval: 'day' | 'week' | 'month' | 'year'; + pricing_type: 'one_time' | 'recurring'; subscription_status: - | "trialing" - | "active" - | "canceled" - | "incomplete" - | "incomplete_expired" - | "past_due" - | "unpaid" - | "paused" - } + | 'trialing' + | 'active' + | 'canceled' + | 'incomplete' + | 'incomplete_expired' + | 'past_due' + | 'unpaid' + | 'paused'; + }; CompositeTypes: { - [_ in never]: never + [_ in never]: never; + }; + }; +} + +export type Tables< + PublicTableNameOrOptions extends + | keyof (Database['public']['Tables'] & Database['public']['Views']) + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof (Database[PublicTableNameOrOptions['schema']]['Tables'] & + Database[PublicTableNameOrOptions['schema']]['Views']) + : never = never +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? (Database[PublicTableNameOrOptions['schema']]['Tables'] & + Database[PublicTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R; + } + ? R + : never + : PublicTableNameOrOptions extends keyof (Database['public']['Tables'] & + Database['public']['Views']) + ? (Database['public']['Tables'] & + Database['public']['Views'])[PublicTableNameOrOptions] extends { + Row: infer R; + } + ? R + : never + : never; + +export type TablesInsert< + PublicTableNameOrOptions extends + | keyof Database['public']['Tables'] + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicTableNameOrOptions['schema']]['Tables'] + : never = never +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I; + } + ? I + : never + : PublicTableNameOrOptions extends keyof Database['public']['Tables'] + ? Database['public']['Tables'][PublicTableNameOrOptions] extends { + Insert: infer I; } - } -} \ No newline at end of file + ? I + : never + : never; + +export type TablesUpdate< + PublicTableNameOrOptions extends + | keyof Database['public']['Tables'] + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicTableNameOrOptions['schema']]['Tables'] + : never = never +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U; + } + ? U + : never + : PublicTableNameOrOptions extends keyof Database['public']['Tables'] + ? Database['public']['Tables'][PublicTableNameOrOptions] extends { + Update: infer U; + } + ? U + : never + : never; + +export type Enums< + PublicEnumNameOrOptions extends + | keyof Database['public']['Enums'] + | { schema: keyof Database }, + EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicEnumNameOrOptions['schema']]['Enums'] + : never = never +> = PublicEnumNameOrOptions extends { schema: keyof Database } + ? Database[PublicEnumNameOrOptions['schema']]['Enums'][EnumName] + : PublicEnumNameOrOptions extends keyof Database['public']['Enums'] + ? Database['public']['Enums'][PublicEnumNameOrOptions] + : never; diff --git a/utils/auth-helpers/client.ts b/utils/auth-helpers/client.ts new file mode 100644 index 0000000..19c66fd --- /dev/null +++ b/utils/auth-helpers/client.ts @@ -0,0 +1,44 @@ +'use client'; + +import { createClient } from '@/utils/supabase/client'; +import { type Provider } from '@supabase/supabase-js'; +import { getURL } from '@/utils/helpers'; +import { redirectToPath } from './server'; +import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'; + +export async function handleRequest( + e: React.FormEvent, + requestFunc: (formData: FormData) => Promise, + router: AppRouterInstance | null = null +): Promise { + // Prevent default form submission refresh + e.preventDefault(); + + const formData = new FormData(e.currentTarget); + const redirectUrl: string = await requestFunc(formData); + + if (router) { + // If client-side router is provided, use it to redirect + return router.push(redirectUrl); + } else { + // Otherwise, redirect server-side + return await redirectToPath(redirectUrl); + } +} + +export async function signInWithOAuth(e: React.FormEvent) { + // Prevent default form submission refresh + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const provider = String(formData.get('provider')).trim() as Provider; + + // Create client-side supabase client and call signInWithOAuth + const supabase = createClient(); + const redirectURL = getURL('/auth/callback'); + await supabase.auth.signInWithOAuth({ + provider: provider, + options: { + redirectTo: redirectURL + } + }); +} diff --git a/utils/auth-helpers/server.ts b/utils/auth-helpers/server.ts new file mode 100644 index 0000000..c8caed1 --- /dev/null +++ b/utils/auth-helpers/server.ts @@ -0,0 +1,340 @@ +'use server'; + +import { createClient } from '@/utils/supabase/server'; +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { getURL, getErrorRedirect, getStatusRedirect } from '@/utils/helpers'; +import { getAuthTypes } from '@/utils/auth-helpers/settings'; + +function isValidEmail(email: string) { + var regex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/; + return regex.test(email); +} + +export async function redirectToPath(path: string) { + return redirect(path); +} + +export async function SignOut(formData: FormData) { + const pathName = String(formData.get('pathName')).trim(); + + const supabase = createClient(); + const { error } = await supabase.auth.signOut(); + + if (error) { + return getErrorRedirect( + pathName, + 'Hmm... Something went wrong.', + 'You could not be signed out.' + ); + } + + return '/dashboard/signin'; +} + +export async function signInWithEmail(formData: FormData) { + const cookieStore = cookies(); + const callbackURL = getURL('/auth/callback'); + + const email = String(formData.get('email')).trim(); + let redirectPath: string; + + if (!isValidEmail(email)) { + redirectPath = getErrorRedirect( + '/dashboard/signin/email_signin', + 'Invalid email address.', + 'Please try again.' + ); + } + + const supabase = createClient(); + let options = { + emailRedirectTo: callbackURL, + shouldCreateUser: true + }; + + // If allowPassword is false, do not create a new user + const { allowPassword } = getAuthTypes(); + if (allowPassword) options.shouldCreateUser = false; + const { data, error } = await supabase.auth.signInWithOtp({ + email, + options: options + }); + + if (error) { + redirectPath = getErrorRedirect( + '/dashboard/signin/email_signin', + 'You could not be signed in.', + error.message + ); + } else if (data) { + cookieStore.set('preferredSignInView', 'email_signin', { path: '/' }); + redirectPath = getStatusRedirect( + '/dashboard/signin/email_signin', + 'Success!', + 'Please check your email for a magic link. You may now close this tab.', + true + ); + } else { + redirectPath = getErrorRedirect( + '/dashboard/signin/email_signin', + 'Hmm... Something went wrong.', + 'You could not be signed in.' + ); + } + + return redirectPath; +} + +export async function requestPasswordUpdate(formData: FormData) { + const callbackURL = getURL('/auth/reset_password'); + + // Get form data + const email = String(formData.get('email')).trim(); + let redirectPath: string; + + if (!isValidEmail(email)) { + redirectPath = getErrorRedirect( + '/dashboard/signin/forgot_password', + 'Invalid email address.', + 'Please try again.' + ); + } + + const supabase = createClient(); + + const { data, error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: callbackURL + }); + + if (error) { + redirectPath = getErrorRedirect( + '/dashboard/signin/forgot_password', + error.message, + 'Please try again.' + ); + } else if (data) { + redirectPath = getStatusRedirect( + '/dashboard/signin/forgot_password', + 'Success!', + 'Please check your email for a password reset link. You may now close this tab.', + true + ); + } else { + redirectPath = getErrorRedirect( + '/dashboard/signin/forgot_password', + 'Hmm... Something went wrong.', + 'Password reset email could not be sent.' + ); + } + + return redirectPath; +} + +export async function signInWithPassword(formData: FormData) { + const cookieStore = cookies(); + const email = String(formData.get('email')).trim(); + const password = String(formData.get('password')).trim(); + let redirectPath: string; + + const supabase = createClient(); + const { error, data } = await supabase.auth.signInWithPassword({ + email, + password + }); + + if (error) { + redirectPath = getErrorRedirect( + '/dashboard/signin/password_signin', + 'Sign in failed.', + error.message + ); + } else if (data.user) { + cookieStore.set('preferredSignInView', 'password_signin', { path: '/' }); + redirectPath = getStatusRedirect('/', 'Success!', 'You are now signed in.'); + } else { + redirectPath = getErrorRedirect( + '/dashboard/signin/password_signin', + 'Hmm... Something went wrong.', + 'You could not be signed in.' + ); + } + + return redirectPath; +} + +export async function signUp(formData: FormData) { + const callbackURL = getURL('/auth/callback'); + + const email = String(formData.get('email')).trim(); + const password = String(formData.get('password')).trim(); + let redirectPath: string; + + if (!isValidEmail(email)) { + redirectPath = getErrorRedirect( + '/dashboard/signin/signup', + 'Invalid email address.', + 'Please try again.' + ); + } + + const supabase = createClient(); + const { error, data } = await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: callbackURL + } + }); + + if (error) { + redirectPath = getErrorRedirect( + '/dashboard/signin/signup', + 'Sign up failed.', + error.message + ); + } else if (data.session) { + redirectPath = getStatusRedirect('/', 'Success!', 'You are now signed in.'); + } else if ( + data.user && + data.user.identities && + data.user.identities.length == 0 + ) { + redirectPath = getErrorRedirect( + '/dashboard/signin/signup', + 'Sign up failed.', + 'There is already an account associated with this email address. Try resetting your password.' + ); + } else if (data.user) { + redirectPath = getStatusRedirect( + '/', + 'Success!', + 'Please check your email for a confirmation link. You may now close this tab.' + ); + } else { + redirectPath = getErrorRedirect( + '/dashboard/signin/signup', + 'Hmm... Something went wrong.', + 'You could not be signed up.' + ); + } + + return redirectPath; +} + +export async function updatePassword(formData: FormData) { + const password = String(formData.get('password')).trim(); + const passwordConfirm = String(formData.get('passwordConfirm')).trim(); + let redirectPath: string; + + // Check that the password and confirmation match + if (password !== passwordConfirm) { + redirectPath = getErrorRedirect( + '/dashboard/signin/update_password', + 'Your password could not be updated.', + 'Passwords do not match.' + ); + } + + const supabase = createClient(); + const { error, data } = await supabase.auth.updateUser({ + password + }); + + if (error) { + redirectPath = getErrorRedirect( + '/dashboard/signin/update_password', + 'Your password could not be updated.', + error.message + ); + } else if (data.user) { + redirectPath = getStatusRedirect( + '/', + 'Success!', + 'Your password has been updated.' + ); + } else { + redirectPath = getErrorRedirect( + '/dashboard/signin/update_password', + 'Hmm... Something went wrong.', + 'Your password could not be updated.' + ); + } + + return redirectPath; +} + +export async function updateEmail(formData: FormData) { + // Get form data + const newEmail = String(formData.get('newEmail')).trim(); + + // Check that the email is valid + if (!isValidEmail(newEmail)) { + return getErrorRedirect( + '/dashboard/settings', + 'Your email could not be updated.', + 'Invalid email address.' + ); + } + + const supabase = createClient(); + + const callbackUrl = getURL( + getStatusRedirect( + '/dashboard/settings', + 'Success!', + `Your email has been updated.` + ) + ); + + const { error } = await supabase.auth.updateUser( + { email: newEmail }, + { + emailRedirectTo: callbackUrl + } + ); + + if (error) { + return getErrorRedirect( + '/dashboard/settings', + 'Your email could not be updated.', + error.message + ); + } else { + return getStatusRedirect( + '/dashboard/settings', + 'Confirmation emails sent.', + `You will need to confirm the update by clicking the links sent to both the old and new email addresses.` + ); + } +} + +export async function updateName(formData: FormData) { + // Get form data + const fullName = String(formData.get('fullName')).trim(); + + const supabase = createClient(); + const { error, data } = await supabase.auth.updateUser({ + data: { full_name: fullName } + }); + + if (error) { + return getErrorRedirect( + '/dashboard/settings', + 'Your name could not be updated.', + error.message + ); + } else if (data.user) { + return getStatusRedirect( + '/dashboard/settings', + 'Success!', + 'Your name has been updated.' + ); + } else { + return getErrorRedirect( + '/dashboard/settings', + 'Hmm... Something went wrong.', + 'Your name could not be updated.' + ); + } +} diff --git a/utils/auth-helpers/settings.ts b/utils/auth-helpers/settings.ts new file mode 100644 index 0000000..fb159e8 --- /dev/null +++ b/utils/auth-helpers/settings.ts @@ -0,0 +1,49 @@ +// Boolean toggles to determine which auth types are allowed +const allowOauth = true; +const allowEmail = true; +const allowPassword = true; + +// Boolean toggle to determine whether auth interface should route through server or client +// (Currently set to false because screen sometimes flickers with server redirects) +const allowServerRedirect = false; + +// Check that at least one of allowPassword and allowEmail is true +if (!allowPassword && !allowEmail) + throw new Error('At least one of allowPassword and allowEmail must be true'); + +export const getAuthTypes = () => { + return { allowOauth, allowEmail, allowPassword }; +}; + +export const getViewTypes = () => { + // Define the valid view types + let viewTypes: string[] = []; + if (allowEmail) { + viewTypes = [...viewTypes, 'email_signin']; + } + if (allowPassword) { + viewTypes = [ + ...viewTypes, + 'password_signin', + 'forgot_password', + 'update_password', + 'signup' + ]; + } + + return viewTypes; +}; + +export const getDefaultSignInView = (preferredSignInView: string | null) => { + // Define the default sign in view + let defaultView = allowPassword ? 'password_signin' : 'email_signin'; + if (preferredSignInView && getViewTypes().includes(preferredSignInView)) { + defaultView = preferredSignInView; + } + + return defaultView; +}; + +export const getRedirectMethod = () => { + return allowServerRedirect ? 'server' : 'client'; +}; diff --git a/utils/cn.ts b/utils/cn.ts new file mode 100644 index 0000000..9ad0df4 --- /dev/null +++ b/utils/cn.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/utils/cookies.ts b/utils/cookies.ts new file mode 100644 index 0000000..6c6e74f --- /dev/null +++ b/utils/cookies.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { parse, serialize } from 'cookie'; + +// Function to parse cookies from the request +export function parseCookies(req: NextRequest) { + const cookieHeader = req.headers.get('cookie'); + return cookieHeader ? parse(cookieHeader) : {}; +} + +// Function to set cookies in the response +export function setCookie( + res: NextResponse, + name: string, + value: any, + options: any = {} +) { + const stringValue = + typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value); + + if (options.maxAge) { + options.expires = new Date(Date.now() + options.maxAge * 1000); + } + + res.cookies.set(name, stringValue, options); +} + +// Function to get a specific cookie +export function getCookie(req: NextRequest, name: string) { + const cookies = parseCookies(req); + const value = cookies[name]; + if (value && value.startsWith('j:')) { + try { + return JSON.parse(value.slice(2)); + } catch (e) { + return null; + } + } + return value; +} diff --git a/utils/helpers.ts b/utils/helpers.ts index 9cb0b9b..c875025 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -1,10 +1,8 @@ import { Database } from '@/types/types_db'; - - type Price = Database['public']['Tables']['prices']['Row']; -export const getURL = () => { +export const getURL = (path?: string) => { let url = process?.env?.NEXT_PUBLIC_SITE_URL ?? // Set this to your site URL in production env. process?.env?.NEXT_PUBLIC_VERCEL_URL ?? // Automatically set by Vercel. @@ -13,6 +11,14 @@ export const getURL = () => { url = url.includes('http') ? url : `https://${url}`; // Make sure to including trailing `/`. url = url.charAt(url.length - 1) === '/' ? url : `${url}/`; + + if (path) { + path = path.replace(/^\/+/, ''); + + // Concatenate the URL and the path. + return path ? `${url}/${path}` : url; + } + return url; }; @@ -46,3 +52,88 @@ export const toDateTime = (secs: number) => { t.setSeconds(secs); return t; }; + +export const calculateTrialEndUnixTimestamp = ( + trialPeriodDays: number | null | undefined +) => { + // Check if trialPeriodDays is null, undefined, or less than 2 days + if ( + trialPeriodDays === null || + trialPeriodDays === undefined || + trialPeriodDays < 2 + ) { + return undefined; + } + + const currentDate = new Date(); // Current date and time + const trialEnd = new Date( + currentDate.getTime() + (trialPeriodDays + 1) * 24 * 60 * 60 * 1000 + ); // Add trial days + return Math.floor(trialEnd.getTime() / 1000); // Convert to Unix timestamp in seconds +}; + +const toastKeyMap: { [key: string]: string[] } = { + status: ['status', 'status_description'], + error: ['error', 'error_description'] +}; + +const getToastRedirect = ( + path: string, + toastType: string, + toastName: string, + toastDescription: string = '', + disableButton: boolean = false, + arbitraryParams: string = '' +): string => { + const [nameKey, descriptionKey] = toastKeyMap[toastType]; + + let redirectPath = `${path}?${nameKey}=${encodeURIComponent(toastName)}`; + + if (toastDescription) { + redirectPath += `&${descriptionKey}=${encodeURIComponent( + toastDescription + )}`; + } + + if (disableButton) { + redirectPath += `&disable_button=true`; + } + + if (arbitraryParams) { + redirectPath += `&${arbitraryParams}`; + } + + return redirectPath; +}; + +export const getStatusRedirect = ( + path: string, + statusName: string, + statusDescription: string = '', + disableButton: boolean = false, + arbitraryParams: string = '' +) => + getToastRedirect( + path, + 'status', + statusName, + statusDescription, + disableButton, + arbitraryParams + ); + +export const getErrorRedirect = ( + path: string, + errorName: string, + errorDescription: string = '', + disableButton: boolean = false, + arbitraryParams: string = '' +) => + getToastRedirect( + path, + 'error', + errorName, + errorDescription, + disableButton, + arbitraryParams + ); diff --git a/utils/streams/chatStream.ts b/utils/streams/chatStream.ts new file mode 100644 index 0000000..e610d8c --- /dev/null +++ b/utils/streams/chatStream.ts @@ -0,0 +1,88 @@ +import endent from 'endent'; +import { + createParser, + ParsedEvent, + ReconnectInterval, +} from 'eventsource-parser'; + +const createPrompt = (inputMessage: string) => { + const data = (inputMessage: string) => { + return endent` + You are ChatGPT, but if you are asked what your name is, you present yourself as Horizon UI Boilerplate and you can be bought from your website https://horizon-ui.com/boilerplate, and the link should be bold. Also, you are very friendly and formal. The generated content must be in markdown format and it must include all markdown characteristics, but it shouldn't be rendered. The title must be bold, and there should be a   between every paragraph or title. Do not include information about console logs or print messages. + ${inputMessage} + `; + }; + + if (inputMessage) { + return data(inputMessage); + } +}; + +export async function OpenAIStream ( + inputMessage: string, + model: string, + key: string | undefined, +) { + const prompt = createPrompt(inputMessage); + + const system = { role: 'system', content: prompt }; + + const res = await fetch(`https://api.openai.com/v1/chat/completions`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${key || process.env.NEXT_PUBLIC_OPENAI_API_KEY}`, + }, + method: 'POST', + body: JSON.stringify({ + model, + messages: [system], + temperature: 0, + stream: true, + }), + }); + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + if (res.status !== 200) { + const statusText = res.statusText; + const result = await res.body?.getReader().read(); + throw new Error( + `OpenAI API returned an error: ${ + decoder.decode(result?.value) || statusText + }`, + ); + } + + const stream = new ReadableStream({ + async start(controller) { + const onParse = (event: ParsedEvent | ReconnectInterval) => { + if (event.type === 'event') { + const data = event.data; + + if (data === '[DONE]') { + controller.close(); + return; + } + + try { + const json = JSON.parse(data); + const text = json.choices[0].delta.content; + const queue = encoder.encode(text); + controller.enqueue(queue); + } catch (e) { + controller.error(e); + } + } + }; + + const parser = createParser(onParse); + + for await (const chunk of res.body as any) { + parser.feed(decoder.decode(chunk)); + } + }, + }); + + return stream; +}; diff --git a/utils/streams/essayStream.ts b/utils/streams/essayStream.ts new file mode 100644 index 0000000..eacc24a --- /dev/null +++ b/utils/streams/essayStream.ts @@ -0,0 +1,93 @@ +import endent from 'endent'; +import { + createParser, + ParsedEvent, + ReconnectInterval, +} from 'eventsource-parser'; + +const createPrompt = (topic: string, words: string, essayType: string) => { + const data = (topic: any, words: string, essayType: string) => { + return endent` + You are an expert formal essay writer and generator. + You know very well all types of essays. Generate an formal ${essayType} essay about ${topic}, which has a number of maximum ${words} words. + The generated content should NOT be longer than ${words} words. + The essay must be in markdown format but not rendered, it must include all markdown characteristics. The title must be bold, and there should be a   between every paragraph. + Do not include informations about console logs or print messages. + `; + }; + + if (essayType) { + return data(topic, words, essayType); + } +}; + +export async function OpenAIStream ( + topic: string, + essayType: string, + words: string, + model: string, + key: string | undefined, +) { + const prompt = createPrompt(topic, words, essayType); + + const system = { role: 'system', content: prompt }; + + const res = await fetch(`https://api.openai.com/v1/chat/completions`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${key || process.env.NEXT_PUBLIC_OPENAI_API_KEY}`, + }, + method: 'POST', + body: JSON.stringify({ + model, + messages: [system], + temperature: 0, + stream: true, + }), + }); + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + if (res.status !== 200) { + const statusText = res.statusText; + const result = await res.body?.getReader().read(); + throw new Error( + `OpenAI API returned an error: ${ + decoder.decode(result?.value) || statusText + }`, + ); + } + + const stream = new ReadableStream({ + async start(controller) { + const onParse = (event: ParsedEvent | ReconnectInterval) => { + if (event.type === 'event') { + const data = event.data; + + if (data === '[DONE]') { + controller.close(); + return; + } + + try { + const json = JSON.parse(data); + const text = json.choices[0].delta.content; + const queue = encoder.encode(text); + controller.enqueue(queue); + } catch (e) { + controller.error(e); + } + } + }; + + const parser = createParser(onParse); + + for await (const chunk of res.body as any) { + parser.feed(decoder.decode(chunk)); + } + }, + }); + + return stream; +}; diff --git a/utils/stripe-client.ts b/utils/stripe/client.ts similarity index 100% rename from utils/stripe-client.ts rename to utils/stripe/client.ts diff --git a/utils/stripe.ts b/utils/stripe/config.ts similarity index 61% rename from utils/stripe.ts rename to utils/stripe/config.ts index ef56988..ff203e4 100644 --- a/utils/stripe.ts +++ b/utils/stripe/config.ts @@ -4,12 +4,15 @@ export const stripe = new Stripe( process.env.STRIPE_SECRET_KEY_LIVE ?? process.env.STRIPE_SECRET_KEY ?? '', { // https://github.com/stripe/stripe-node#configuration - apiVersion: '2022-11-15', + // https://stripe.com/docs/api/versioning + // @ts-ignore + apiVersion: null, // Register this as an official Stripe plugin. // https://stripe.com/docs/building-plugins#setappinfo appInfo: { - name: 'Next.js Subscription Starter', - version: '0.1.0' + name: 'Horizon AI Boilerplate', + version: '1.1.0', + url: 'https://github.com/horizon-ui/shadcn-nextjs-boilerplate' } } ); diff --git a/utils/supabase-admin.ts b/utils/supabase-admin.ts index 066d618..4b312be 100644 --- a/utils/supabase-admin.ts +++ b/utils/supabase-admin.ts @@ -1,5 +1,5 @@ import { toDateTime } from './helpers'; -import { stripe } from './stripe'; +import { stripe } from './stripe/config'; import { createClient } from '@supabase/supabase-js'; import Stripe from 'stripe'; import type { Database } from '@/types/types_db'; @@ -63,12 +63,14 @@ const createOrRetrieveCustomer = async ({ .single(); if (error || !data?.stripe_customer_id) { // No customer record found, let's create one. - const customerData: { metadata: { supabaseUUID: string }; email?: string } = - { - metadata: { - supabaseUUID: uuid - } - }; + const customerData: { + metadata: { supabaseUUID: string }; + email?: string; + } = { + metadata: { + supabaseUUID: uuid + } + }; if (email) customerData.email = email; const customer = await stripe.customers.create(customerData); // Now insert the customer ID into our Supabase mapping table. @@ -111,7 +113,10 @@ const manageSubscriptionStatusChange = async ( createAction = false ) => { // Get customer's UUID from mapping table. - const { data: customerData, error: noCustomerError } = await supabaseAdmin + const { + data: customerData, + error: noCustomerError + } = await supabaseAdmin .from('customers') .select('id') .eq('stripe_customer_id', customerId) @@ -124,40 +129,39 @@ const manageSubscriptionStatusChange = async ( expand: ['default_payment_method'] }); // Upsert the latest status of the subscription object. - const subscriptionData: Database['public']['Tables']['subscriptions']['Insert'] = - { - id: subscription.id, - user_id: uuid, - metadata: subscription.metadata, - status: subscription.status, - price_id: subscription.items.data[0].price.id, - //TODO check quantity on subscription - // @ts-ignore - quantity: subscription.quantity, - cancel_at_period_end: subscription.cancel_at_period_end, - cancel_at: subscription.cancel_at - ? toDateTime(subscription.cancel_at).toISOString() - : null, - canceled_at: subscription.canceled_at - ? toDateTime(subscription.canceled_at).toISOString() - : null, - current_period_start: toDateTime( - subscription.current_period_start - ).toISOString(), - current_period_end: toDateTime( - subscription.current_period_end - ).toISOString(), - created: toDateTime(subscription.created).toISOString(), - ended_at: subscription.ended_at - ? toDateTime(subscription.ended_at).toISOString() - : null, - trial_start: subscription.trial_start - ? toDateTime(subscription.trial_start).toISOString() - : null, - trial_end: subscription.trial_end - ? toDateTime(subscription.trial_end).toISOString() - : null - }; + const subscriptionData: Database['public']['Tables']['subscriptions']['Insert'] = { + id: subscription.id, + user_id: uuid, + metadata: subscription.metadata, + status: subscription.status, + price_id: subscription.items.data[0].price.id, + //TODO check quantity on subscription + // @ts-ignore + quantity: subscription.quantity, + cancel_at_period_end: subscription.cancel_at_period_end, + cancel_at: subscription.cancel_at + ? toDateTime(subscription.cancel_at).toISOString() + : null, + canceled_at: subscription.canceled_at + ? toDateTime(subscription.canceled_at).toISOString() + : null, + current_period_start: toDateTime( + subscription.current_period_start + ).toISOString(), + current_period_end: toDateTime( + subscription.current_period_end + ).toISOString(), + created: toDateTime(subscription.created).toISOString(), + ended_at: subscription.ended_at + ? toDateTime(subscription.ended_at).toISOString() + : null, + trial_start: subscription.trial_start + ? toDateTime(subscription.trial_start).toISOString() + : null, + trial_end: subscription.trial_end + ? toDateTime(subscription.trial_end).toISOString() + : null + }; const { error } = await supabaseAdmin .from('subscriptions') diff --git a/utils/supabase-client.ts b/utils/supabase-client.ts deleted file mode 100644 index 17d7ba6..0000000 --- a/utils/supabase-client.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - createBrowserSupabaseClient, - User -} from '@supabase/auth-helpers-nextjs'; - -import type { Database } from '@/types/types_db'; - -export const supabase = createBrowserSupabaseClient(); - -export const updateUserName = async (user: User, name: string) => { - await supabase - .from('users') - .update({ - full_name: name - }) - .eq('id', user.id); -}; diff --git a/utils/supabase-client.tsx b/utils/supabase-client.tsx deleted file mode 100644 index 94405b5..0000000 --- a/utils/supabase-client.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { ProductWithPrice } from '../types/types'; -import type { Database } from '../types/types_db'; -import { - createBrowserSupabaseClient, - User, -} from '@supabase/auth-helpers-nextjs'; - -export const supabase = createBrowserSupabaseClient(); - -export const getActiveProductsWithPrices = async (): Promise< - ProductWithPrice[] -> => { - const { data, error } = await supabase - .from('products') - .select('*, prices(*)') - .eq('active', true) - .eq('prices.active', true) - .order('metadata->index') - .order('unit_amount', { foreignTable: 'prices' }); - - if (error) { - console.log(error.message); - } - // TODO: improve the typing here. - return (data as any) || []; -}; - -export const updateUserName = async (user: User, name: string) => { - await supabase - .from('users') - .update({ - full_name: name, - }) - .eq('id', user.id); -}; diff --git a/utils/supabase/admin.ts b/utils/supabase/admin.ts new file mode 100644 index 0000000..9459753 --- /dev/null +++ b/utils/supabase/admin.ts @@ -0,0 +1,305 @@ +import { toDateTime } from '@/utils/helpers'; +import { stripe } from '@/utils/stripe/config'; +import { createClient } from '@supabase/supabase-js'; +import Stripe from 'stripe'; +import type { Database, Tables, TablesInsert } from '@/types/types_db'; + +type Product = Tables<'products'>; +type Price = Tables<'prices'>; + +// Change to control trial period length +const TRIAL_PERIOD_DAYS = 0; + +// Note: supabaseAdmin uses the SERVICE_ROLE_KEY which you must only use in a secure server-side context +// as it has admin privileges and overwrites RLS policies! +const supabaseAdmin = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL || '', + process.env.SUPABASE_SERVICE_ROLE_KEY || '' +); + +const upsertProductRecord = async (product: Stripe.Product) => { + const productData: Product = { + id: product.id, + active: product.active, + name: product.name, + description: product.description ?? null, + image: product.images?.[0] ?? null, + metadata: product.metadata + }; + + const { error: upsertError } = await supabaseAdmin + .from('products') + .upsert([productData]); + if (upsertError) + throw new Error(`Product insert/update failed: ${upsertError.message}`); + console.log(`Product inserted/updated: ${product.id}`); +}; + +const upsertPriceRecord = async ( + price: Stripe.Price, + retryCount = 0, + maxRetries = 3 +) => { + const priceData: Price = { + id: price.id, + description: '', + metadata: { shit: true }, + product_id: typeof price.product === 'string' ? price.product : '', + active: price.active, + currency: price.currency, + type: price.type, + unit_amount: price.unit_amount ?? null, + interval: price.recurring?.interval ?? null, + interval_count: price.recurring?.interval_count ?? null, + trial_period_days: price.recurring?.trial_period_days ?? TRIAL_PERIOD_DAYS + }; + + const { error: upsertError } = await supabaseAdmin + .from('prices') + .upsert([priceData]); + + if (upsertError?.message.includes('foreign key constraint')) { + if (retryCount < maxRetries) { + console.log(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await upsertPriceRecord(price, retryCount + 1, maxRetries); + } else { + throw new Error( + `Price insert/update failed after ${maxRetries} retries: ${upsertError.message}` + ); + } + } else if (upsertError) { + throw new Error(`Price insert/update failed: ${upsertError.message}`); + } else { + console.log(`Price inserted/updated: ${price.id}`); + } +}; + +const deleteProductRecord = async (product: Stripe.Product) => { + const { error: deletionError } = await supabaseAdmin + .from('products') + .delete() + .eq('id', product.id); + if (deletionError) + throw new Error(`Product deletion failed: ${deletionError.message}`); + console.log(`Product deleted: ${product.id}`); +}; + +const deletePriceRecord = async (price: Stripe.Price) => { + const { error: deletionError } = await supabaseAdmin + .from('prices') + .delete() + .eq('id', price.id); + if (deletionError) + throw new Error(`Price deletion failed: ${deletionError.message}`); + console.log(`Price deleted: ${price.id}`); +}; + +const upsertCustomerToSupabase = async (uuid: string, customerId: string) => { + const { error: upsertError } = await supabaseAdmin + .from('customers') + .upsert([{ id: uuid, stripe_customer_id: customerId }]); + + if (upsertError) + throw new Error( + `Supabase customer record creation failed: ${upsertError.message}` + ); + + return customerId; +}; + +const createCustomerInStripe = async (uuid: string, email: string) => { + const customerData = { metadata: { supabaseUUID: uuid }, email: email }; + const newCustomer = await stripe.customers.create(customerData); + if (!newCustomer) throw new Error('Stripe customer creation failed.'); + + return newCustomer.id; +}; + +const createOrRetrieveCustomer = async ({ + email, + uuid +}: { + email: string; + uuid: string; +}) => { + // Check if the customer already exists in Supabase + const { + data: existingSupabaseCustomer, + error: queryError + } = await supabaseAdmin + .from('customers') + .select('*') + .eq('id', uuid) + .maybeSingle(); + + if (queryError) { + throw new Error(`Supabase customer lookup failed: ${queryError.message}`); + } + + // Retrieve the Stripe customer ID using the Supabase customer ID, with email fallback + let stripeCustomerId: string | undefined; + if (existingSupabaseCustomer?.stripe_customer_id) { + const existingStripeCustomer = await stripe.customers.retrieve( + existingSupabaseCustomer.stripe_customer_id + ); + stripeCustomerId = existingStripeCustomer.id; + } else { + // If Stripe ID is missing from Supabase, try to retrieve Stripe customer ID by email + const stripeCustomers = await stripe.customers.list({ email: email }); + stripeCustomerId = + stripeCustomers.data.length > 0 ? stripeCustomers.data[0].id : undefined; + } + + // If still no stripeCustomerId, create a new customer in Stripe + const stripeIdToInsert = stripeCustomerId + ? stripeCustomerId + : await createCustomerInStripe(uuid, email); + if (!stripeIdToInsert) throw new Error('Stripe customer creation failed.'); + + if (existingSupabaseCustomer && stripeCustomerId) { + // If Supabase has a record but doesn't match Stripe, update Supabase record + if (existingSupabaseCustomer.stripe_customer_id !== stripeCustomerId) { + const { error: updateError } = await supabaseAdmin + .from('customers') + .update({ stripe_customer_id: stripeCustomerId }) + .eq('id', uuid); + + if (updateError) + throw new Error( + `Supabase customer record update failed: ${updateError.message}` + ); + console.warn( + `Supabase customer record mismatched Stripe ID. Supabase record updated.` + ); + } + // If Supabase has a record and matches Stripe, return Stripe customer ID + return stripeCustomerId; + } else { + console.warn( + `Supabase customer record was missing. A new record was created.` + ); + + // If Supabase has no record, create a new record and return Stripe customer ID + const upsertedStripeCustomer = await upsertCustomerToSupabase( + uuid, + stripeIdToInsert + ); + if (!upsertedStripeCustomer) + throw new Error('Supabase customer record creation failed.'); + + return upsertedStripeCustomer; + } +}; + +/** + * Copies the billing details from the payment method to the customer object. + */ +const copyBillingDetailsToCustomer = async ( + uuid: string, + payment_method: Stripe.PaymentMethod +) => { + //Todo: check this assertion + const customer = payment_method.customer as string; + const { name, phone, address } = payment_method.billing_details; + if (!name || !phone || !address) return; + //@ts-ignore + await stripe.customers.update(customer, { name, phone, address }); + const { error: updateError } = await supabaseAdmin + .from('users') + .update({ + billing_address: { ...address }, + payment_method: { ...payment_method[payment_method.type] } + }) + .eq('id', uuid); + if (updateError) + throw new Error(`Customer update failed: ${updateError.message}`); +}; + +const manageSubscriptionStatusChange = async ( + subscriptionId: string, + customerId: string, + createAction = false +) => { + // Get customer's UUID from mapping table. + const { + data: customerData, + error: noCustomerError + } = await supabaseAdmin + .from('customers') + .select('id') + .eq('stripe_customer_id', customerId) + .single(); + + if (noCustomerError) + throw new Error(`Customer lookup failed: ${noCustomerError.message}`); + + const { id: uuid } = customerData!; + + const subscription = await stripe.subscriptions.retrieve(subscriptionId, { + expand: ['default_payment_method'] + }); + // Upsert the latest status of the subscription object. + const subscriptionData: TablesInsert<'subscriptions'> = { + id: subscription.id, + user_id: uuid, + metadata: subscription.metadata, + status: subscription.status, + price_id: subscription.items.data[0].price.id, + //TODO check quantity on subscription + // @ts-ignore + quantity: subscription.quantity, + cancel_at_period_end: subscription.cancel_at_period_end, + cancel_at: subscription.cancel_at + ? toDateTime(subscription.cancel_at).toISOString() + : null, + canceled_at: subscription.canceled_at + ? toDateTime(subscription.canceled_at).toISOString() + : null, + current_period_start: toDateTime( + subscription.current_period_start + ).toISOString(), + current_period_end: toDateTime( + subscription.current_period_end + ).toISOString(), + created: toDateTime(subscription.created).toISOString(), + ended_at: subscription.ended_at + ? toDateTime(subscription.ended_at).toISOString() + : null, + trial_start: subscription.trial_start + ? toDateTime(subscription.trial_start).toISOString() + : null, + trial_end: subscription.trial_end + ? toDateTime(subscription.trial_end).toISOString() + : null + }; + + const { error: upsertError } = await supabaseAdmin + .from('subscriptions') + .upsert([subscriptionData]); + if (upsertError) + throw new Error( + `Subscription insert/update failed: ${upsertError.message}` + ); + console.log( + `Inserted/updated subscription [${subscription.id}] for user [${uuid}]` + ); + + // For a new subscription copy the billing details to the customer object. + // NOTE: This is a costly operation and should happen at the very end. + if (createAction && subscription.default_payment_method && uuid) + //@ts-ignore + await copyBillingDetailsToCustomer( + uuid, + subscription.default_payment_method as Stripe.PaymentMethod + ); +}; + +export { + upsertProductRecord, + upsertPriceRecord, + deleteProductRecord, + deletePriceRecord, + createOrRetrieveCustomer, + manageSubscriptionStatusChange +}; diff --git a/utils/supabase/client.ts b/utils/supabase/client.ts new file mode 100644 index 0000000..e6db2a1 --- /dev/null +++ b/utils/supabase/client.ts @@ -0,0 +1,8 @@ +import { createBrowserClient } from '@supabase/ssr'; + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} diff --git a/utils/supabase/middleware.ts b/utils/supabase/middleware.ts new file mode 100644 index 0000000..8fdadf5 --- /dev/null +++ b/utils/supabase/middleware.ts @@ -0,0 +1,84 @@ +import { createServerClient, type CookieOptions } from '@supabase/ssr'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const createClient = (request: NextRequest) => { + // Create an unmodified response + let response = NextResponse.next({ + request: { + headers: request.headers + } + }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return request.cookies.get(name)?.value; + }, + set(name: string, value: string, options: CookieOptions) { + // If the cookie is updated, update the cookies for the request and response + request.cookies.set({ + name, + value, + ...options + }); + response = NextResponse.next({ + request: { + headers: request.headers + } + }); + response.cookies.set({ + name, + value, + ...options + }); + }, + remove(name: string, options: CookieOptions) { + // If the cookie is removed, update the cookies for the request and response + request.cookies.set({ + name, + value: '', + ...options + }); + response = NextResponse.next({ + request: { + headers: request.headers + } + }); + response.cookies.set({ + name, + value: '', + ...options + }); + } + } + } + ); + + return { supabase, response }; +}; + +export const updateSession = async (request: NextRequest) => { + try { + // This `try/catch` block is only here for the interactive tutorial. + // Feel free to remove once you have Supabase connected. + const { supabase, response } = createClient(request); + + // This will refresh session if expired - required for Server Components + // https://supabase.com/docs/guides/auth/server-side/nextjs + await supabase.auth.getUser(); + + return response; + } catch (e) { + // If you are here, a Supabase client could not be created! + // This is likely because you have not set up environment variables. + // Check out http://localhost:3000 for Next Steps. + return NextResponse.next({ + request: { + headers: request.headers + } + }); + } +}; \ No newline at end of file diff --git a/utils/supabase/queries.ts b/utils/supabase/queries.ts new file mode 100644 index 0000000..80b68a6 --- /dev/null +++ b/utils/supabase/queries.ts @@ -0,0 +1,17 @@ +import { SupabaseClient } from '@supabase/supabase-js'; +import { cache } from 'react'; + +export const getUser = cache(async (supabase: SupabaseClient) => { + const { + data: { user } + } = await supabase.auth.getUser(); + return user; +}); + +export const getUserDetails = cache(async (supabase: SupabaseClient) => { + const { data: userDetails } = await supabase + .from('users') + .select('*') + .single(); + return userDetails; +}); diff --git a/utils/supabase/server.ts b/utils/supabase/server.ts new file mode 100644 index 0000000..1abc817 --- /dev/null +++ b/utils/supabase/server.ts @@ -0,0 +1,43 @@ +import { createServerClient, type CookieOptions } from '@supabase/ssr'; +import { cookies } from 'next/headers'; +import { Database } from '@/types/types_db'; + +// Define a function to create a Supabase client for server-side operations +// The function takes a cookie store created with next/headers cookies as an argument +export const createClient = () => { + const cookieStore = cookies(); + + return createServerClient( + // Pass Supabase URL and anonymous key from the environment to the client + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + + // Define a cookies object with methods for interacting with the cookie store and pass it to the client + { + cookies: { + // The get method is used to retrieve a cookie by its name + get(name: string) { + return cookieStore.get(name)?.value; + }, + // The set method is used to set a cookie with a given name, value, and options + set(name: string, value: string, options: CookieOptions) { + try { + cookieStore.set({ name, value, ...options }); + } catch (error) { + // If the set method is called from a Server Component, an error may occur + // This can be ignored if there is middleware refreshing user sessions + } + }, + // The remove method is used to delete a cookie by its name + remove(name: string, options: CookieOptions) { + try { + cookieStore.set({ name, value: '', ...options }); + } catch (error) { + // If the remove method is called from a Server Component, an error may occur + // This can be ignored if there is middleware refreshing user { s + } + } + } + } + ); +}; \ No newline at end of file diff --git a/utils/useUser.tsx b/utils/useUser.tsx deleted file mode 100644 index 9333e52..0000000 --- a/utils/useUser.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useEffect, useState, createContext, useContext } from 'react' -import { - useUser as useSupaUser, - useSessionContext, - User, -} from '@supabase/auth-helpers-react' - -import { UserDetails } from '@/types/types' - -type UserContextType = { - accessToken: string | null - user: User | null - userDetails: UserDetails | null - isLoading: boolean - subscriptions: any | null - historySubscriptions: any | null -} - -export const UserContext = createContext(undefined) - -export interface Props { - [propName: string]: any -} - -export const MyUserContextProvider = (props: Props) => { - const { - session, - isLoading: isLoadingUser, - supabaseClient: supabase, - } = useSessionContext() - const user = useSupaUser() - const accessToken = session?.access_token ?? null - const [isLoadingData, setIsloadingData] = useState(false) - const [userDetails, setUserDetails] = useState(null) - const [subscriptions, setSubscription] = useState(null) - const [historySubscriptions, setHistorySubscription] = useState( - null, - ) - - const getUserDetails = () => supabase.from('users').select('*').single() - const getSubscription = () => - supabase - .from('paddle_subscriptions') - .select('*') - .eq('user_id', user?.id) - .in('status', ['trialing', 'active', 'deleted']) - .gt('subscription_end_date', new Date().toISOString()) - .order('created_at', { ascending: false }) - - const getHistorySubscription = () => - supabase - .from('paddle_payment_history') - .select('*') - .eq('user_id', user?.id) - .order('created_at', { ascending: false }) - - useEffect(() => { - if ( - user && - !isLoadingData && - !userDetails && - !subscriptions && - !historySubscriptions - ) { - setIsloadingData(true) - Promise.allSettled([ - getUserDetails(), - getSubscription(), - getHistorySubscription(), - ]).then((results) => { - const userDetailsPromise = results[0] - const subscriptionPromise = results[1] - const historySubscriptionPromise = results[2] - - if (userDetailsPromise.status === 'fulfilled') - setUserDetails(userDetailsPromise.value.data as UserDetails) - - if (subscriptionPromise.status === 'fulfilled') { - setSubscription(subscriptionPromise.value.data as any) - } - - if (historySubscriptionPromise.status === 'fulfilled') { - setHistorySubscription(historySubscriptionPromise.value.data as any) - } - setIsloadingData(false) - }) - } else if (!user && !isLoadingUser && !isLoadingData) { - setUserDetails(null) - setSubscription(null) - setHistorySubscription(null) - } - }, [user, isLoadingUser]) - - const value = { - accessToken, - user, - userDetails, - isLoading: isLoadingUser || isLoadingData, - subscriptions, - historySubscriptions, - } - - return -} - -export const useUser = () => { - const context = useContext(UserContext) - if (context === undefined) { - throw new Error(`useUser must be used within a MyUserContextProvider.`) - } - return context -}