From c8298f1c1201a2b325ec0f02aa10cbfe1b18b5ad Mon Sep 17 00:00:00 2001 From: Marcelo dos Reis Date: Wed, 12 Feb 2025 18:04:45 -0300 Subject: [PATCH 01/11] refactor: remove legacy services and create auth email and subscriptions services --- README.md | 26 ++- .../(domains)/(auth)/confirm-signup/page.tsx | 6 +- .../(domains)/(auth)/forgot-password/page.tsx | 6 +- src/app/(domains)/(auth)/layout.tsx | 8 +- .../(domains)/(auth)/new-password/page.tsx | 6 +- src/app/(domains)/(auth)/signin/page.tsx | 6 +- src/app/(domains)/(auth)/signup/page.tsx | 6 +- src/app/(domains)/dashboard/settings/page.tsx | 6 +- .../(domains)/dashboard/subscription/page.tsx | 6 +- src/components/MyAccount.tsx | 6 +- src/components/Navbar.tsx | 6 +- src/components/OAuth.tsx | 10 +- src/components/SettingsOptions.tsx | 6 +- src/hooks/useCheckout.ts | 6 +- src/middleware.ts | 24 ++- .../api/payments/create-billing-portal.ts | 11 +- src/pages/api/payments/get-subscription.ts | 6 +- src/pages/api/webhooks.ts | 23 ++- src/services/api/subscription.ts | 28 --- src/services/auth.ts | 87 ++++++++++ src/services/email.ts | 53 ++++++ src/services/mailgun.ts | 46 ----- src/services/subscription.ts | 66 +++++++ src/services/supabase.ts | 162 ------------------ 24 files changed, 308 insertions(+), 308 deletions(-) delete mode 100644 src/services/api/subscription.ts create mode 100644 src/services/auth.ts create mode 100644 src/services/email.ts delete mode 100644 src/services/mailgun.ts create mode 100644 src/services/subscription.ts delete mode 100644 src/services/supabase.ts diff --git a/README.md b/README.md index 635b8aa..20ba353 100644 --- a/README.md +++ b/README.md @@ -58,15 +58,23 @@ src/ ### SQL Script for Creating the `subscriptions` Table in Supabase: ```sql -create table subscriptions ( - id uuid primary key default uuid_generate_v4(), - user_id uuid not null references auth.users(id) on delete cascade, - stripe_subscription_id text unique, - plan text check (plan in ('free', 'starter', 'creator', 'pro')) not null default 'free', - status text check (status in ('active', 'canceled', 'past_due', 'incomplete', 'trialing')) not null default 'active', - current_period_start timestamp with time zone, - current_period_end timestamp with time zone, - created_at timestamp with time zone default now() +CREATE TABLE subscriptions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + stripe_subscription_id TEXT UNIQUE, + plan TEXT CHECK (plan IN ('free', 'starter', 'creator', 'pro')) NOT NULL DEFAULT 'free', + status TEXT CHECK (status IN ('active', 'canceled', 'past_due', 'incomplete', 'trialing')) NOT NULL DEFAULT 'active', + current_period_start TIMESTAMP WITH TIME ZONE, + current_period_end TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + message TEXT NOT NULL, + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); ``` diff --git a/src/app/(domains)/(auth)/confirm-signup/page.tsx b/src/app/(domains)/(auth)/confirm-signup/page.tsx index d843011..cd44eb5 100644 --- a/src/app/(domains)/(auth)/confirm-signup/page.tsx +++ b/src/app/(domains)/(auth)/confirm-signup/page.tsx @@ -6,7 +6,7 @@ import BackLink from "@/components/BackLink"; import Spinner from "@/components/Spinner"; import { useI18n } from '@/hooks/useI18n'; import { supabase } from "@/libs/supabase/client"; -import SupabaseService from "@/services/supabase"; +import AuthService from "@/services/auth"; type State = { isLoading: boolean; @@ -61,10 +61,10 @@ export default function ConfirmSignUp() { async function handleConfirmSignup(token: string) { dispatch({ type: "SET_LOADING", isLoading: true }); - const SupabaseServiceInstance = new SupabaseService(supabase); + const AuthServiceInstance = new AuthService(supabase); try { - const response = await SupabaseServiceInstance.confirmEmail(token, 'signup'); + const response = await AuthServiceInstance.confirmEmail(token, 'signup'); if (response?.id) { dispatch({ type: "CONFIRMATION_SUCCESS" }); } else { diff --git a/src/app/(domains)/(auth)/forgot-password/page.tsx b/src/app/(domains)/(auth)/forgot-password/page.tsx index ad27720..6cad45d 100644 --- a/src/app/(domains)/(auth)/forgot-password/page.tsx +++ b/src/app/(domains)/(auth)/forgot-password/page.tsx @@ -7,7 +7,7 @@ import ButtonComponent from "@/components/Button"; import InputComponent from "@/components/Input"; import { useI18n } from "@/hooks/useI18n"; import { supabase } from "@/libs/supabase/client"; -import SupabaseService from "@/services/supabase"; +import AuthService from "@/services/auth"; import { isValidEmail } from "@/utils/isValidEmail"; const initialState = { @@ -66,8 +66,8 @@ export default function ForgotPassword() { throw new Error("Validation Error"); } - const SupabaseServiceInstance = new SupabaseService(supabase); - const response = await SupabaseServiceInstance.forgotPassword(state.inputValue.email); + const AuthServiceInstance = new AuthService(supabase); + const response = await AuthServiceInstance.forgotPassword(state.inputValue.email); if (response) { dispatch({ type: "SET_SUCCESS", payload: true }); diff --git a/src/app/(domains)/(auth)/layout.tsx b/src/app/(domains)/(auth)/layout.tsx index f0226bd..d3fe37a 100644 --- a/src/app/(domains)/(auth)/layout.tsx +++ b/src/app/(domains)/(auth)/layout.tsx @@ -1,7 +1,7 @@ import { redirect } from 'next/navigation'; import { createClient } from '@/libs/supabase/server'; -import SupabaseService from '@/services/supabase'; +import AuthService from '@/services/auth'; type Props = { children: React.ReactNode; @@ -9,9 +9,9 @@ type Props = { export default async function AuthLayout({ children }: Props) { const supabase = await createClient(); - const SupabaseServiceInstance = new SupabaseService(supabase); - - const userId = await SupabaseServiceInstance.getUserId(); + const AuthServiceInstance = new AuthService(supabase); + const userId = await AuthServiceInstance.getUserId(); + if (userId) { redirect('/dashboard'); } diff --git a/src/app/(domains)/(auth)/new-password/page.tsx b/src/app/(domains)/(auth)/new-password/page.tsx index 7684d11..1b4473d 100644 --- a/src/app/(domains)/(auth)/new-password/page.tsx +++ b/src/app/(domains)/(auth)/new-password/page.tsx @@ -10,7 +10,7 @@ import InputComponent from "@/components/Input"; import PasswordStrengthIndicator from "@/components/PasswordStrength"; import { useI18n } from '@/hooks/useI18n'; import { supabase } from "@/libs/supabase/client"; -import SupabaseService from "@/services/supabase"; +import AuthService from "@/services/auth"; const initialState = { isLoading: false, @@ -90,9 +90,9 @@ export default function NewPassword() { throw new Error("Validation Error"); } - const SupabaseServiceInstance = new SupabaseService(supabase); + const AuthServiceInstance = new AuthService(supabase); - const response = await SupabaseServiceInstance.newPassword(state.inputValue.password); + const response = await AuthServiceInstance.updatePassword(state.inputValue.password); if (response) { dispatch({ type: "SET_PASSWORD_CHANGED", payload: true }); diff --git a/src/app/(domains)/(auth)/signin/page.tsx b/src/app/(domains)/(auth)/signin/page.tsx index 7426cf3..a4b9bc0 100644 --- a/src/app/(domains)/(auth)/signin/page.tsx +++ b/src/app/(domains)/(auth)/signin/page.tsx @@ -12,7 +12,7 @@ import OAuth from "@/components/OAuth"; import { ROUTES } from '@/constants/ROUTES'; import { useI18n } from '@/hooks/useI18n'; import { supabase } from '@/libs/supabase/client'; -import SupabaseService from '@/services/supabase'; +import AuthService from '@/services/auth'; import { isValidEmail } from '@/utils/isValidEmail'; const initialState = { @@ -72,8 +72,8 @@ export default function SignIn() { throw new Error("Validation Error"); } - const SupabaseServiceInstance = new SupabaseService(supabase); - const response = await SupabaseServiceInstance.signIn(state.inputValue.email, state.inputValue.password); + const AuthServiceInstance = new AuthService(supabase); + const response = await AuthServiceInstance.signIn(state.inputValue.email, state.inputValue.password); if (response?.id) { router.push(ROUTES.dashboard); diff --git a/src/app/(domains)/(auth)/signup/page.tsx b/src/app/(domains)/(auth)/signup/page.tsx index 113258a..ed28801 100644 --- a/src/app/(domains)/(auth)/signup/page.tsx +++ b/src/app/(domains)/(auth)/signup/page.tsx @@ -10,7 +10,7 @@ import OAuth from "@/components/OAuth"; import PasswordStrengthIndicator from "@/components/PasswordStrength"; import { useI18n } from '@/hooks/useI18n'; import { supabase } from "@/libs/supabase/client"; -import SupabaseService from "@/services/supabase"; +import AuthService from "@/services/auth"; import { isValidEmail } from "@/utils/isValidEmail"; const initialState = { @@ -90,8 +90,8 @@ export default function SignUp() { throw new Error("Terms not accepted"); } - const SupabaseServiceInstance = new SupabaseService(supabase); - const response = await SupabaseServiceInstance.signUp(state.inputValue.email, state.inputValue.password); + const AuthServiceInstance = new AuthService(supabase); + const response = await AuthServiceInstance.signUp(state.inputValue.email, state.inputValue.password); if (response?.id) { dispatch({ type: "SET_REGISTRATION_COMPLETE", payload: true }); diff --git a/src/app/(domains)/dashboard/settings/page.tsx b/src/app/(domains)/dashboard/settings/page.tsx index 96f47ef..133b7ba 100644 --- a/src/app/(domains)/dashboard/settings/page.tsx +++ b/src/app/(domains)/dashboard/settings/page.tsx @@ -2,15 +2,15 @@ import { headers } from "next/headers"; import SettingsOptions from "@/components/SettingsOptions"; import { createClient } from "@/libs/supabase/server"; -import SupabaseService from "@/services/supabase"; +import AuthService from "@/services/auth"; import { capitalize } from "@/utils/capitalize"; import { loadTranslationsSSR } from '@/utils/loadTranslationsSSR'; export default async function Settings() { const { translate } = await loadTranslationsSSR(); const supabase = await createClient(); - const SupabaseServiceInstance = new SupabaseService(supabase); - const data = await SupabaseServiceInstance.getUser(); + const AuthServiceInstance = new AuthService(supabase); + const data = await AuthServiceInstance.getUser(); const sharedData = JSON.parse((await headers()).get('x-shared-data') || '{}'); return ( diff --git a/src/app/(domains)/dashboard/subscription/page.tsx b/src/app/(domains)/dashboard/subscription/page.tsx index 20d7736..d127d51 100644 --- a/src/app/(domains)/dashboard/subscription/page.tsx +++ b/src/app/(domains)/dashboard/subscription/page.tsx @@ -3,7 +3,7 @@ import { headers } from "next/headers"; import ManageBilling from "@/components/ManageBilling"; import PricingSection from "@/components/Pricing"; import { createClient } from "@/libs/supabase/server"; -import SupabaseService from "@/services/supabase"; +import AuthService from "@/services/auth"; import { capitalize } from "@/utils/capitalize"; import { loadTranslationsSSR } from '@/utils/loadTranslationsSSR'; @@ -11,8 +11,8 @@ export default async function Subscription() { const { translate } = await loadTranslationsSSR(); const sharedData = JSON.parse((await headers()).get('x-shared-data') || '{}'); const supabase = await createClient(); - const SupabaseServiceInstance = new SupabaseService(supabase); - const session = await SupabaseServiceInstance.getSession(); + const AuthServiceInstance = new AuthService(supabase); + const session = await AuthServiceInstance.getSession(); const currentPlanText = translate("subscription-current-plan-description"); const currentPlan = capitalize(sharedData?.plan); diff --git a/src/components/MyAccount.tsx b/src/components/MyAccount.tsx index 36de522..c5b189e 100644 --- a/src/components/MyAccount.tsx +++ b/src/components/MyAccount.tsx @@ -5,9 +5,9 @@ import { useState, useEffect, useRef } from 'react'; import { useI18n } from "@/hooks/useI18n"; import { supabase } from '@/libs/supabase/client'; -import SupabaseService from '@/services/supabase'; +import AuthService from '@/services/auth'; -const SupabaseServiceInstance = new SupabaseService(supabase); +const AuthServiceInstance = new AuthService(supabase); function MyAccount() { const { translate } = useI18n(); @@ -58,7 +58,7 @@ function MyAccount() { { - await SupabaseServiceInstance.signOut(); + await AuthServiceInstance.signOut(); window.location.reload(); }} > diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 73b7664..11f1fec 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -6,7 +6,7 @@ import { useEffect, useState } from "react"; import { useI18n } from "@/hooks/useI18n"; import { supabase } from '@/libs/supabase/client'; -import SupabaseService from '@/services/supabase'; +import AuthService from '@/services/auth'; import LanguageSelector from "./LanguageSelector"; import Spinner from "./Spinner"; @@ -20,8 +20,8 @@ export default function Navbar() { useEffect(() => { const getUserSession = async () => { - const SupabaseServiceInstance = new SupabaseService(supabase); - const user = await SupabaseServiceInstance.getUserId(); + const AuthServiceInstance = new AuthService(supabase); + const user = await AuthServiceInstance.getUserId(); if (!!user) { setIsLogged(true); } else { diff --git a/src/components/OAuth.tsx b/src/components/OAuth.tsx index b11bfad..053fd1c 100644 --- a/src/components/OAuth.tsx +++ b/src/components/OAuth.tsx @@ -1,25 +1,25 @@ import { PROVIDERS_IMAGE_URL } from "@/constants/PROVIDERS_IMAGE_URL"; import { supabase } from "@/libs/supabase/client"; -import SupabaseService from "@/services/supabase"; +import AuthService from "@/services/auth"; export default function OAuth() { - const SupabaseServiceInstance = new SupabaseService(supabase); + const AuthServiceInstance = new AuthService(supabase); const PROVIDERS_MAP = [ { provider: 'Google', logo: PROVIDERS_IMAGE_URL.Google, - onClick: () => SupabaseServiceInstance.signInProvider('google') + onClick: () => AuthServiceInstance.signInWithProvider('google') }, { provider: 'Facebook', logo: PROVIDERS_IMAGE_URL.Facebook, - onClick: () => SupabaseServiceInstance.signInProvider('facebook') + onClick: () => AuthServiceInstance.signInWithProvider('facebook') }, { provider: 'Twitter', logo: PROVIDERS_IMAGE_URL.Twitter, - onClick: () => SupabaseServiceInstance.signInProvider('twitter') + onClick: () => AuthServiceInstance.signInWithProvider('twitter') } ] return ( diff --git a/src/components/SettingsOptions.tsx b/src/components/SettingsOptions.tsx index 28afb19..900a1ee 100644 --- a/src/components/SettingsOptions.tsx +++ b/src/components/SettingsOptions.tsx @@ -6,7 +6,7 @@ import ButtonComponent from "@/components/Button"; import { useI18n } from "@/hooks/useI18n"; import { useToast } from "@/hooks/useToast"; import { supabase } from "@/libs/supabase/client"; -import SupabaseService from "@/services/supabase"; +import AuthService from "@/services/auth"; type SettingsOptionsProps = { userEmail?: string; @@ -15,7 +15,7 @@ type SettingsOptionsProps = { function SettingsOptions({ userEmail, currentPlan }: SettingsOptionsProps) { const { translate } = useI18n(); - const SupabaseServiceInstance = new SupabaseService(supabase); + const AuthServiceInstance = new AuthService(supabase); const { addToast } = useToast(); const [isLoading, setIsLoading] = useState({ forgotPassword: false, @@ -23,7 +23,7 @@ function SettingsOptions({ userEmail, currentPlan }: SettingsOptionsProps) { const handleForgotPassword = async (userEmail: string) => { setIsLoading((data) => ({ ...data, forgotPassword: true })); - const response = await SupabaseServiceInstance.forgotPassword(userEmail); + const response = await AuthServiceInstance.forgotPassword(userEmail); if (response) { await addToast({ diff --git a/src/hooks/useCheckout.ts b/src/hooks/useCheckout.ts index 39cba59..11c119a 100644 --- a/src/hooks/useCheckout.ts +++ b/src/hooks/useCheckout.ts @@ -3,8 +3,8 @@ import { FIXED_CURRENCY } from "@/constants/FIXED_CURRENCY"; import { HAS_FREE_TRIAL } from "@/constants/HAS_FREE_TRIAL"; import { useToast } from "@/hooks/useToast"; import { supabase } from '@/libs/supabase/client'; +import AuthService from '@/services/auth'; import StripeService from '@/services/stripe'; -import SupabaseService from '@/services/supabase'; export const useCheckout = () => { const { addToast } = useToast(); @@ -15,8 +15,8 @@ export const useCheckout = () => { } setIsLoading(true); - const SupabaseServiceInstance = new SupabaseService(supabase); - const user = await SupabaseServiceInstance.getUserId(); + const AuthServiceInstance = new AuthService(supabase); + const user = await AuthServiceInstance.getUserId(); if (!user) { window.location.href = '/signin'; diff --git a/src/middleware.ts b/src/middleware.ts index d0bd2f7..7ee33d0 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,8 +3,7 @@ import type { NextRequest } from 'next/server'; import { updateSession } from '@/libs/supabase/middleware'; import { createClient } from '@/libs/supabase/server'; -import { fetchSubscription } from '@/services/api/subscription'; -import SupabaseService from '@/services/supabase'; +import AuthService from '@/services/auth'; export async function middleware(request: NextRequest) { await updateSession(request); @@ -12,16 +11,31 @@ export async function middleware(request: NextRequest) { if (url.pathname.startsWith('/dashboard')) { const supabase = await createClient(); - const SupabaseServiceInstance = new SupabaseService(supabase); + const AuthServiceInstance = new AuthService(supabase); - const userId = await SupabaseServiceInstance.getUserId(); + const userId = await AuthServiceInstance.getUserId(); if (!userId) { const redirectUrl = new URL('/signin', request.url); return NextResponse.redirect(redirectUrl); } - const subscription = await fetchSubscription(userId); + + const subscriptionRequest = await fetch( + `${process.env.NEXT_PUBLIC_PROJECT_URL}/api/payments/get-subscription?userId=${userId}`, + { + method: 'GET', + cache: 'no-store', + } + ); + + if (!subscriptionRequest.ok) { + console.error('Failed to fetch subscription:', subscriptionRequest.statusText); + return null; + } + + const data = await subscriptionRequest.json(); + const subscription = data.subscription; const plan = subscription && subscription?.status === 'active' diff --git a/src/pages/api/payments/create-billing-portal.ts b/src/pages/api/payments/create-billing-portal.ts index 41a20d6..4018ef5 100644 --- a/src/pages/api/payments/create-billing-portal.ts +++ b/src/pages/api/payments/create-billing-portal.ts @@ -2,8 +2,9 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { stripe } from '@/libs/stripe'; import { supabaseServerClient } from '@/libs/supabase/server'; +import AuthService from '@/services/auth'; import StripeService from '@/services/stripe'; -import SupabaseService from '@/services/supabase'; +import SubscriptionService from '@/services/subscription'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { @@ -18,18 +19,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } - const SupabaseServiceInstance = new SupabaseService(supabaseServerClient); + const AuthServiceInstance = new AuthService(supabaseServerClient); + const SubscriptionServiceInstance = new SubscriptionService(supabaseServerClient); + const StripeServiceInstance = new StripeService(stripe); - const user = await SupabaseServiceInstance.getUser(token); + const user = await AuthServiceInstance.getUser(token); if (!user) { return res.status(401).json({ error: 'User not authenticated' }); } - const subscription = await SupabaseServiceInstance.getSubscriptionByUserId(user.id) + const subscription = await SubscriptionServiceInstance.getSubscriptionByUserId(user.id) if (!subscription) { return res.status(404).json({ error: 'No subscription found for user' }); diff --git a/src/pages/api/payments/get-subscription.ts b/src/pages/api/payments/get-subscription.ts index 20c33a8..bf99d30 100644 --- a/src/pages/api/payments/get-subscription.ts +++ b/src/pages/api/payments/get-subscription.ts @@ -1,7 +1,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { supabaseServerClient } from '@/libs/supabase/server'; -import SupabaseService from '@/services/supabase'; +import SubscriptionServiceInstance from '@/services/subscription'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -13,8 +13,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } try { - const SupabaseServiceInstance = new SupabaseService(supabaseServerClient); - const subscription = await SupabaseServiceInstance.getSubscriptionByUserId(userId); + const SubscriptionServiceInstanceInstance = new SubscriptionServiceInstance(supabaseServerClient); + const subscription = await SubscriptionServiceInstanceInstance.getSubscriptionByUserId(userId); if (!subscription) { return res.status(404).json({ error: 'Subscription not found' }); diff --git a/src/pages/api/webhooks.ts b/src/pages/api/webhooks.ts index 05a568f..a449722 100644 --- a/src/pages/api/webhooks.ts +++ b/src/pages/api/webhooks.ts @@ -3,9 +3,10 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { FINISH_CHECKOUT_EMAIL } from '@/constants/EMAILS'; import { stripe } from '@/libs/stripe'; import { supabaseServerClient } from '@/libs/supabase/server'; -import { sendEmail } from '@/services/mailgun'; +import AuthService from '@/services/auth'; +import EmailService from '@/services/email'; import StripeService from '@/services/stripe'; -import SupabaseService from '@/services/supabase'; +import SubscriptionService from '@/services/subscription'; const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; @@ -27,7 +28,11 @@ async function getRawBody(req: NextApiRequest): Promise { export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === 'POST') { const StripeServiceInstance = new StripeService(stripe); - const SupabaseServiceInstance = new SupabaseService(supabaseServerClient); + const AuthServiceInstance = new AuthService(supabaseServerClient); + const SubscriptionServiceInstance = new SubscriptionService(supabaseServerClient); + const EmailServiceInstance = new EmailService(); + + const sig = req.headers['stripe-signature']; const rawBody = await getRawBody(req); @@ -51,7 +56,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // eslint-disable-next-line @typescript-eslint/no-explicit-any const session = event.data.object as any; const { userId, plan } = session.metadata; - await SupabaseServiceInstance.upsertSubscription({ + await SubscriptionServiceInstance.upsertSubscription({ user_id: userId, stripe_subscription_id: session.subscription, plan, @@ -59,9 +64,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) current_period_start: new Date(session.current_period_start * 1000), current_period_end: new Date(session.current_period_end * 1000), }); - const email = (await SupabaseServiceInstance.getUserById(userId))?.email; + const email = (await AuthServiceInstance.getUserById(userId))?.email; if (!email) throw new Error("Missing User Data in Completed Checkout"); - await sendEmail({ + await EmailServiceInstance.sendEmail({ from: 'Sassy - Powerful Micro-SaaS', to: [email], subject: "Welcome to Sassy!", @@ -75,13 +80,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // eslint-disable-next-line @typescript-eslint/no-explicit-any const subscription = event.data.object as any; - await SupabaseServiceInstance.updateSubscriptionPeriod( + await SubscriptionServiceInstance.updateSubscriptionPeriod( subscription.id, new Date(subscription.current_period_start * 1000), new Date(subscription.current_period_end * 1000) ); - await SupabaseServiceInstance.updateSubscriptionStatus( + await SubscriptionServiceInstance.updateSubscriptionStatus( subscription.id, subscription.status ); @@ -91,7 +96,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) case 'customer.subscription.deleted': { // eslint-disable-next-line @typescript-eslint/no-explicit-any const subscription = event.data.object as any; - await SupabaseServiceInstance.cancelSubscription(subscription.id); + await SubscriptionServiceInstance.cancelSubscription(subscription.id); break; } diff --git a/src/services/api/subscription.ts b/src/services/api/subscription.ts deleted file mode 100644 index f2983ce..0000000 --- a/src/services/api/subscription.ts +++ /dev/null @@ -1,28 +0,0 @@ -interface Subscription { - id: string; - user_id: string; - stripe_subscription_id: string; - plan: string; - status: string; - current_period_start: string; - current_period_end: string; - created_at: string; -} - -export async function fetchSubscription(userId: string): Promise { - const res = await fetch( - `${process.env.NEXT_PUBLIC_PROJECT_URL}/api/payments/get-subscription?userId=${userId}`, - { - method: 'GET', - cache: 'no-store', - } - ); - - if (!res.ok) { - console.error('Failed to fetch subscription:', res.statusText); - return null; - } - - const data = await res.json(); - return data.subscription; -} diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..d0b56b4 --- /dev/null +++ b/src/services/auth.ts @@ -0,0 +1,87 @@ +import { EmailOtpType, Provider, User, Session, SupabaseClient } from '@supabase/supabase-js'; + +export default class AuthService { + constructor(private supabase: SupabaseClient) { } + + async getUserId(): Promise { + const user = await this.getUser(); + return user?.id || null; + } + + async getUser(accessToken?: string): Promise { + const { data } = await this.supabase.auth.getUser(accessToken); + return data?.user || null; + } + + async getUserById(id: string): Promise { + const { data } = await this.supabase.auth.admin.getUserById(id); + return data?.user || null; + } + + async getSession(): Promise { + const { data } = await this.supabase.auth.getSession(); + return data?.session || null; + } + + async signUp(email: string, password: string): Promise { + const { data, error } = await this.supabase.auth.signUp({ email, password }); + this.handleError(error); + return data.user; + } + + async signIn(email: string, password: string): Promise { + const { data, error } = await this.supabase.auth.signInWithPassword({ email, password }); + if (error) { + await this.resendEmail(email); + throw error; + } + return data.user; + } + + async signInWithProvider(provider: Provider): Promise { + const { error } = await this.supabase.auth.signInWithOAuth({ + provider, + options: { redirectTo: `${process.env.NEXT_PUBLIC_PROJECT_URL}/confirm-signup?oauth=${provider}` } + }); + this.handleError(error); + } + + async signOut(): Promise { + const { error } = await this.supabase.auth.signOut(); + this.handleError(error); + } + + async confirmEmail(token: string, type: EmailOtpType): Promise { + const { data, error } = await this.supabase.auth.verifyOtp({ token_hash: token, type }); + this.handleError(error); + return data.user; + } + + async forgotPassword(email: string): Promise { + const { error } = await this.supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${process.env.NEXT_PUBLIC_PROJECT_URL}/new-password` + }); + this.handleError(error); + return true; + } + + async updatePassword(password: string): Promise { + const { error } = await this.supabase.auth.updateUser({ password }); + this.handleError(error); + return true; + } + + async resendEmail(email: string): Promise { + const { error } = await this.supabase.auth.resend({ email, type: 'signup' }); + this.handleError(error); + } + + async validateCode(code: string): Promise { + const { error } = await this.supabase.auth.getUser(code); + this.handleError(error); + } + + private handleError(error: unknown): void { + if (error) throw error; + } +} diff --git a/src/services/email.ts b/src/services/email.ts new file mode 100644 index 0000000..10f2b55 --- /dev/null +++ b/src/services/email.ts @@ -0,0 +1,53 @@ +import FormData from "form-data"; +import Mailgun from "mailgun.js"; + +type EmailParams = { + from: string; + to: string[]; + subject: string; + text: string; + html: string; +}; + +export default class EmailService { + private mailgun: Mailgun; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mg: any; + + constructor() { + this.mailgun = new Mailgun(FormData); + this.mg = this.mailgun.client({ + username: "api", + key: process.env.MAILGUN_SECRET_KEY || "", + }); + } + + async sendEmail({ from, to, subject, text, html }: EmailParams): Promise { + this.validateEmailParams({ from, to, subject, text, html }); + + const message = { + from: `${from} `, + to, + subject, + text, + html, + }; + + try { + await this.mg.messages.create( + process.env.MAILGUN_SECRET_DOMAIN || "MAILGUN_SECRET_DOMAIN", + message + ); + console.log(`Email sent successfully to: ${to.join(", ")}`); + } catch (error) { + console.error("Error sending email:", error); + throw new Error(`Failed to send email to: ${to.join(", ")}. Error: ${error}`); + } + } + + private validateEmailParams({ from, to, subject, text, html }: EmailParams): void { + if (!from || !to || !subject || !text || !html) { + throw new Error("Missing required email parameters."); + } + } +} \ No newline at end of file diff --git a/src/services/mailgun.ts b/src/services/mailgun.ts deleted file mode 100644 index a867990..0000000 --- a/src/services/mailgun.ts +++ /dev/null @@ -1,46 +0,0 @@ -import FormData from "form-data"; -import Mailgun from "mailgun.js"; - -export const sendEmail = async ({ - from, - to, - subject, - text, - html, -}: { - from: string; - to: string[]; - subject: string; - text: string; - html: string; -}) => { - if (!from || !to || !subject || !text || !html) { - throw new Error("Missing required email parameters."); - } - - const mailgun = new Mailgun(FormData); - const mg = mailgun.client({ - username: "api", - key: process.env.MAILGUN_SECRET_KEY || "", - }); - - const message = { - from: `${from} `, - to, - subject, - text, - html, - }; - - - try { - await mg.messages.create( - process.env.MAILGUN_SECRET_DOMAIN || "MAILGUN_SECRET_DOMAIN", - message - ); - console.log(`Email sent successfully to: ${to.join(", ")}`); - } catch (error) { - console.error("Error sending email:", error); - throw new Error(`Failed to send email to: ${to.join(", ")}. Error: ${error}`); - } -}; \ No newline at end of file diff --git a/src/services/subscription.ts b/src/services/subscription.ts new file mode 100644 index 0000000..9387de4 --- /dev/null +++ b/src/services/subscription.ts @@ -0,0 +1,66 @@ +import { SupabaseClient } from '@supabase/supabase-js'; + + +type SubscriptionData = { + user_id: string; + stripe_subscription_id: string; + plan: string; + status: string; + current_period_start: Date; + current_period_end: Date; +}; + +export default class SubscriptionService { + constructor(private supabase: SupabaseClient) {} + + async getSubscriptionByUserId(userId: string): Promise { + const { data, error } = await this.supabase + .from('subscriptions') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(1); + + this.handleError(error); + return data?.[0] || null; + } + + async upsertSubscription(subscriptionData: SubscriptionData): Promise { + const { error } = await this.supabase + .from('subscriptions') + .upsert(subscriptionData); + + this.handleError(error); + } + + async updateSubscriptionStatus(stripeSubscriptionId: string, status: string): Promise { + const { error } = await this.supabase + .from('subscriptions') + .update({ status }) + .eq('stripe_subscription_id', stripeSubscriptionId); + + this.handleError(error); + } + + async updateSubscriptionPeriod(stripeSubscriptionId: string, periodStart: Date, periodEnd: Date): Promise { + const { error } = await this.supabase + .from('subscriptions') + .update({ current_period_start: periodStart, current_period_end: periodEnd }) + .eq('stripe_subscription_id', stripeSubscriptionId); + + this.handleError(error); + } + + async cancelSubscription(stripeSubscriptionId: string): Promise { + const { error } = await this.supabase + .from('subscriptions') + .update({ status: 'canceled' }) + .eq('stripe_subscription_id', stripeSubscriptionId); + + this.handleError(error); + } + + private handleError(error: unknown): void { + if (error) throw error; + } +} \ No newline at end of file diff --git a/src/services/supabase.ts b/src/services/supabase.ts deleted file mode 100644 index 8e107bb..0000000 --- a/src/services/supabase.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { EmailOtpType, Provider, User, Session } from '@supabase/supabase-js'; -import { SupabaseClient } from '@supabase/supabase-js'; - -type SubscriptionData = { - user_id: string; - stripe_subscription_id: string; - plan: string; - status: string; - current_period_start: Date; - current_period_end: Date; -}; - - -export default class SupabaseService { - private supabase: SupabaseClient; - - constructor(supabase: SupabaseClient) { - this.supabase = supabase; - } - - async getUserId(): Promise { - const data = await this.getUser(); - return data?.id || null; - } - - async getUser(accessToken?: string): Promise { - const { data } = await this.supabase.auth.getUser(accessToken); - return data?.user || null; - } - - async getUserById(id: string): Promise { - const { data } = await this.supabase.auth.admin.getUserById(id); - return data?.user || null; - } - - async getSession(): Promise { - const { data } = await this.supabase.auth.getSession(); - return data?.session || null; - } - - async signUp(email: string, password: string): Promise { - const { data, error } = await this.supabase.auth.signUp({ email, password }); - if (error) throw error; - return data.user; - } - - async signIn(email: string, password: string): Promise { - const { data, error } = await this.supabase.auth.signInWithPassword({ email, password }); - if (error) { - this.resendEmail(email); - throw error - }; - return data.user; - } - - async signInProvider(provider: Provider): Promise { - const { error } = await this.supabase.auth.signInWithOAuth({ provider, options: { redirectTo: `${process.env.NEXT_PUBLIC_PROJECT_URL}/confirm-signup?oauth=${provider}` } }); - if (error) throw error - return true; - } - - async signOut(): Promise { - const { error } = await this.supabase.auth.signOut(); - if (error) throw error; - } - - async confirmEmail(token: string, type: EmailOtpType): Promise { - const { data, error } = await this.supabase.auth.verifyOtp({ token_hash: token, type }); - if (error) throw error; - return data.user; - } - - async forgotPassword(email: string): Promise { - const { error } = await this.supabase.auth.resetPasswordForEmail(email, { redirectTo: `${process.env.NEXT_PUBLIC_PROJECT_URL}/new-password` }); - if (error) throw error; - return true; - } - - async newPassword(password: string): Promise { - const { error } = await this.supabase.auth.updateUser({ password }); - if (error) throw error; - return true; - } - - async resendEmail(email: string): Promise { - const { error } = await this.supabase.auth.resend({ - email, - type: 'signup' - }); - if (error) throw error; - return true; - } - - async validateCode(code: string): Promise { - const { error } = await this.supabase.auth.getUser( - code - ) - if (error) throw error; - return true; - } - - async getSubscriptionByUserId(userId: string) { - const { data, error } = await this.supabase - .from('subscriptions') - .select('*') - .eq('user_id', userId) - .order('created_at', { ascending: false }) - .limit(1); - - if (error) throw error; - return data[0]; - } - - async upsertSubscription(subscriptionData: SubscriptionData) { - const { error } = await this.supabase - .from('subscriptions') - .upsert(subscriptionData); - - if (error) throw error; - return true; - } - - async updateSubscriptionStatus( - stripeSubscriptionId: string, - status: string - ) { - const { error } = await this.supabase - .from('subscriptions') - .update({ status }) - .eq('stripe_subscription_id', stripeSubscriptionId); - - if (error) throw error; - return true; - } - - async updateSubscriptionPeriod( - stripeSubscriptionId: string, - periodStart: Date, - periodEnd: Date - ) { - const { error } = await this.supabase - .from('subscriptions') - .update({ - current_period_start: periodStart, - current_period_end: periodEnd, - }) - .eq('stripe_subscription_id', stripeSubscriptionId); - - if (error) throw error; - return true; - } - - async cancelSubscription(stripeSubscriptionId: string) { - const { error } = await this.supabase - .from('subscriptions') - .update({ status: 'canceled' }) - .eq('stripe_subscription_id', stripeSubscriptionId); - - if (error) throw error; - return true; - } -} From 856b94b9ff1be3feb325ea1a86386dbf844ffb16 Mon Sep 17 00:00:00 2001 From: Marcelo dos Reis Date: Mon, 17 Feb 2025 22:54:44 -0300 Subject: [PATCH 02/11] Release v0.0.6 (#20) * feat: init base of feature * chore: remove loading only in fail in signin * feat: add i18n * feat: update roadmap docs * feat: update github templates * feat: add i18n in mobile * refactor: create navbar dashboard component and add languague selector in dashboard * chore: update future roadmap * chore: create issue finalized markdown template * fix: add vercel json to use public in static * fix: get locale in production with fetch * chore: remove vercel file * fix: moving locales to src * chore: move location to src * chore: remove useless locale public files * chore: add env.example in project github * Create CI/CD Pipeline for Build and Testing Before Deployment to Vercel enhancement New feature or request (#12) * feat: create pipeline * fix: pull request template * fix: run pipeline in all branches * chore: fix pipeline using env * chore: fix pipeline using env 2 * chore: update ci pipeline * chore: add all supabase vars * chore: update packages * feat: add Multi-Currency Support in Stripe (#14) * Add Localization and Translations (#16) * chore: add sections translations * feat: add ssr locale function * refactor: language selector component * feat: add all home translation * feat: add i18n in terms and privacy page * chore: translate all auth domain * chore: add i18n in payment area * chore: add dashboard translation * feat: add portugues i18n * chore: add currency in client * chore: refactor constants * Refactor and Tax Adaptation (#17) * chore: add sections translations * feat: add ssr locale function * refactor: language selector component * feat: add all home translation * feat: add i18n in terms and privacy page * chore: translate all auth domain * chore: add i18n in payment area * chore: add dashboard translation * feat: add portugues i18n * chore: add currency in client * chore: refactor constants * refactor: create optional tax adapt currency * chore: solving conflits 2 * chore: add translations again * Support for Mailgun (#19) * refactor: console.logs * chore: add support to mailgun transactional email * refactor: mailgun domain * docs: update docs * chore: update readme docs --- .env.example | 7 +- README.md | 5 + docs/feature-roadmap.md | 3 +- package-lock.json | 127 ++++++++++++++++++ package.json | 2 + .../(domains)/(auth)/new-password/page.tsx | 2 +- src/app/(domains)/(auth)/signin/page.tsx | 2 +- src/app/(domains)/(auth)/signup/page.tsx | 2 +- src/constants/EMAILS.ts | 15 +++ src/hooks/useCheckout.ts | 1 - src/pages/api/payments/get-plans.ts | 4 +- src/pages/api/webhooks.ts | 12 +- src/services/mailgun.ts | 46 +++++++ src/services/supabase.ts | 12 +- 14 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 src/constants/EMAILS.ts create mode 100644 src/services/mailgun.ts diff --git a/.env.example b/.env.example index 2bcfadf..28e9388 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,11 @@ NEXT_PUBLIC_DATADOG_APPLICATION_ID= NEXT_PUBLIC_DATADOG_CLIENT_TOKEN= SUPABASE_SECRET_KEY= + STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= -DATADOG_SECRET_KEY= \ No newline at end of file + +DATADOG_SECRET_KEY= + +MAILGUN_SECRET_KEY= +MAILGUN_SECRET_DOMAIN= \ No newline at end of file diff --git a/README.md b/README.md index db21749..635b8aa 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,14 @@ NEXT_PUBLIC_DATADOG_APPLICATION_ID= NEXT_PUBLIC_DATADOG_CLIENT_TOKEN= SUPABASE_SECRET_KEY= + STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= + DATADOG_SECRET_KEY= + +MAILGUN_SECRET_KEY= +MAILGUN_SECRET_DOMAIN= ``` > **Note:** Replace the above values with your own Supabase and Stripe keys. diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index d3dd8ab..1661e39 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -18,9 +18,8 @@ | **Feature** | **Status** | |----------------------------------------------------|------------| -| Integration with Transactional Emails | ⬜ | +| Integration with Transactional Emails | ✅ | | Notification Integration | ⬜ | -| Custom Webhooks for Notifications | ⬜ | | Team Features | ⬜ | diff --git a/package-lock.json b/package-lock.json index a1af5d5..475af0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "@stripe/stripe-js": "^5.5.0", "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.47.11", + "form-data": "^4.0.1", "jest": "^29.7.0", + "mailgun.js": "^11.1.0", "next": "15.1.3", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -2661,6 +2663,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2687,6 +2695,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2822,6 +2841,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3197,6 +3222,18 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3427,6 +3464,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -4639,6 +4685,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.4.tgz", @@ -4685,6 +4751,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -6485,6 +6565,20 @@ "yallist": "^3.0.2" } }, + "node_modules/mailgun.js": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/mailgun.js/-/mailgun.js-11.1.0.tgz", + "integrity": "sha512-pXYcQT3nU32gMjUjZpl2FdQN4Vv2iobqYiXqyyevk0vXTKQj8Or0ifLXLNAGqMHnymTjV0OphBpurkchvHsRAg==", + "license": "MIT", + "dependencies": { + "axios": "^1.7.4", + "base-64": "^1.0.0", + "url-join": "^4.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -6547,6 +6641,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -7400,6 +7515,12 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8827,6 +8948,12 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 597f5d7..f281d21 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "@stripe/stripe-js": "^5.5.0", "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.47.11", + "form-data": "^4.0.1", "jest": "^29.7.0", + "mailgun.js": "^11.1.0", "next": "15.1.3", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/app/(domains)/(auth)/new-password/page.tsx b/src/app/(domains)/(auth)/new-password/page.tsx index 536cfcc..7684d11 100644 --- a/src/app/(domains)/(auth)/new-password/page.tsx +++ b/src/app/(domains)/(auth)/new-password/page.tsx @@ -100,7 +100,7 @@ export default function NewPassword() { dispatch({ type: "SET_ERRORS", payload: { general: translate("new-password-error-general") } }); } } catch (err) { - console.log("Error", err); + console.error("Error", err); if (err instanceof Error && err.message !== "Validation Error") { dispatch({ type: "SET_ERRORS", payload: { general: translate("new-password-error-unexpected") } }); } diff --git a/src/app/(domains)/(auth)/signin/page.tsx b/src/app/(domains)/(auth)/signin/page.tsx index d53aa15..7426cf3 100644 --- a/src/app/(domains)/(auth)/signin/page.tsx +++ b/src/app/(domains)/(auth)/signin/page.tsx @@ -81,7 +81,7 @@ export default function SignIn() { dispatch({ type: "SET_ERRORS", payload: { general: translate("signIn-invalid-credentials") } }); } } catch (err) { - console.log("Error", err); + console.error("Error", err); if (err instanceof Error && err.message !== "Validation Error") { dispatch({ type: "SET_ERRORS", payload: { general: translate("signIn-general-error") } }); } diff --git a/src/app/(domains)/(auth)/signup/page.tsx b/src/app/(domains)/(auth)/signup/page.tsx index 85e4b98..113258a 100644 --- a/src/app/(domains)/(auth)/signup/page.tsx +++ b/src/app/(domains)/(auth)/signup/page.tsx @@ -99,7 +99,7 @@ export default function SignUp() { dispatch({ type: "SET_ERRORS", payload: { general: translate("signUp-general-error") } }); } } catch (err) { - console.log("Error", err); + console.error("Error", err); if (err instanceof Error && err.message !== "Validation Error" && err.message !== "Terms not accepted") { dispatch({ type: "SET_ERRORS", payload: { general: translate("signUp-general-error") } }); } diff --git a/src/constants/EMAILS.ts b/src/constants/EMAILS.ts new file mode 100644 index 0000000..5785ec3 --- /dev/null +++ b/src/constants/EMAILS.ts @@ -0,0 +1,15 @@ +export const FINISH_CHECKOUT_EMAIL = ` +
+

Welcome to Sassy! 🎉

+

Hello,

+

We are thrilled to have you with us! Your subscription to the {plan} plan has been successfully activated.

+

Enjoy all the powerful features Sassy has to offer. If you have any questions, feel free to reach out.

+
+ + Go to Dashboard + +
+

Thank you for choosing Sassy!

+

— The Sassy Team

+
+` \ No newline at end of file diff --git a/src/hooks/useCheckout.ts b/src/hooks/useCheckout.ts index cd1fac7..39cba59 100644 --- a/src/hooks/useCheckout.ts +++ b/src/hooks/useCheckout.ts @@ -11,7 +11,6 @@ export const useCheckout = () => { const handleCheckout = async ({ plan, isAnnual, setIsLoading }: { plan: Plan, isAnnual: boolean, setIsLoading: (isLoading: boolean) => void }) => { if (plan.id === 'free') { - console.log('Free plan selected'); return; } diff --git a/src/pages/api/payments/get-plans.ts b/src/pages/api/payments/get-plans.ts index 81b575c..6f9da9a 100644 --- a/src/pages/api/payments/get-plans.ts +++ b/src/pages/api/payments/get-plans.ts @@ -9,9 +9,9 @@ import { InputData, transformPurchasePlansDTO } from '@/utils/transformPurchaseP export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === 'GET') { - const locale = req.cookies?.['locale']; - const { translate } = await loadTranslationsSSR(locale); + const locale = req.cookies?.['locale']|| 'en-US'; + const { translate } = await loadTranslationsSSR(locale); const { currency } = req.query; if (!currency) { diff --git a/src/pages/api/webhooks.ts b/src/pages/api/webhooks.ts index 3ba8a7d..05a568f 100644 --- a/src/pages/api/webhooks.ts +++ b/src/pages/api/webhooks.ts @@ -1,7 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next'; +import { FINISH_CHECKOUT_EMAIL } from '@/constants/EMAILS'; import { stripe } from '@/libs/stripe'; import { supabaseServerClient } from '@/libs/supabase/server'; +import { sendEmail } from '@/services/mailgun'; import StripeService from '@/services/stripe'; import SupabaseService from '@/services/supabase'; @@ -49,7 +51,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // eslint-disable-next-line @typescript-eslint/no-explicit-any const session = event.data.object as any; const { userId, plan } = session.metadata; - await SupabaseServiceInstance.upsertSubscription({ user_id: userId, stripe_subscription_id: session.subscription, @@ -58,6 +59,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) current_period_start: new Date(session.current_period_start * 1000), current_period_end: new Date(session.current_period_end * 1000), }); + const email = (await SupabaseServiceInstance.getUserById(userId))?.email; + if (!email) throw new Error("Missing User Data in Completed Checkout"); + await sendEmail({ + from: 'Sassy - Powerful Micro-SaaS', + to: [email], + subject: "Welcome to Sassy!", + text: "Welcome to Sassy! Your subscription has been activated.", + html: FINISH_CHECKOUT_EMAIL.replace("{plan}", plan), + }); break; } diff --git a/src/services/mailgun.ts b/src/services/mailgun.ts new file mode 100644 index 0000000..a867990 --- /dev/null +++ b/src/services/mailgun.ts @@ -0,0 +1,46 @@ +import FormData from "form-data"; +import Mailgun from "mailgun.js"; + +export const sendEmail = async ({ + from, + to, + subject, + text, + html, +}: { + from: string; + to: string[]; + subject: string; + text: string; + html: string; +}) => { + if (!from || !to || !subject || !text || !html) { + throw new Error("Missing required email parameters."); + } + + const mailgun = new Mailgun(FormData); + const mg = mailgun.client({ + username: "api", + key: process.env.MAILGUN_SECRET_KEY || "", + }); + + const message = { + from: `${from} `, + to, + subject, + text, + html, + }; + + + try { + await mg.messages.create( + process.env.MAILGUN_SECRET_DOMAIN || "MAILGUN_SECRET_DOMAIN", + message + ); + console.log(`Email sent successfully to: ${to.join(", ")}`); + } catch (error) { + console.error("Error sending email:", error); + throw new Error(`Failed to send email to: ${to.join(", ")}. Error: ${error}`); + } +}; \ No newline at end of file diff --git a/src/services/supabase.ts b/src/services/supabase.ts index d4f8c35..8e107bb 100644 --- a/src/services/supabase.ts +++ b/src/services/supabase.ts @@ -28,6 +28,11 @@ export default class SupabaseService { return data?.user || null; } + async getUserById(id: string): Promise { + const { data } = await this.supabase.auth.admin.getUserById(id); + return data?.user || null; + } + async getSession(): Promise { const { data } = await this.supabase.auth.getSession(); return data?.session || null; @@ -99,10 +104,11 @@ export default class SupabaseService { .from('subscriptions') .select('*') .eq('user_id', userId) - .single(); - + .order('created_at', { ascending: false }) + .limit(1); + if (error) throw error; - return data; + return data[0]; } async upsertSubscription(subscriptionData: SubscriptionData) { From 64bd14949d331833fa95c8e178b0750c53c5de46 Mon Sep 17 00:00:00 2001 From: Marcelo dos Reis Date: Mon, 17 Feb 2025 23:14:38 -0300 Subject: [PATCH 03/11] refactor: create new payment service and remove stripe service --- src/hooks/useCheckout.ts | 4 +- .../api/payments/create-billing-portal.ts | 8 +- src/pages/api/payments/create-checkout.ts | 6 +- src/pages/api/payments/get-plans.ts | 6 +- src/pages/api/webhooks.ts | 6 +- src/services/payment.ts | 125 ++++++++++++++++++ src/services/stripe.ts | 108 --------------- 7 files changed, 140 insertions(+), 123 deletions(-) create mode 100644 src/services/payment.ts delete mode 100644 src/services/stripe.ts diff --git a/src/hooks/useCheckout.ts b/src/hooks/useCheckout.ts index 11c119a..263650b 100644 --- a/src/hooks/useCheckout.ts +++ b/src/hooks/useCheckout.ts @@ -4,7 +4,7 @@ import { HAS_FREE_TRIAL } from "@/constants/HAS_FREE_TRIAL"; import { useToast } from "@/hooks/useToast"; import { supabase } from '@/libs/supabase/client'; import AuthService from '@/services/auth'; -import StripeService from '@/services/stripe'; +import PaymentService from '@/services/payment'; export const useCheckout = () => { const { addToast } = useToast(); @@ -35,7 +35,7 @@ export const useCheckout = () => { const sessionId = jsonResponse.id; if (sessionId) { - await StripeService.redirectToCheckout(sessionId); + await PaymentService.redirectToCheckout(sessionId); } else { addToast({ id: Date.now().toString(), diff --git a/src/pages/api/payments/create-billing-portal.ts b/src/pages/api/payments/create-billing-portal.ts index 4018ef5..e19faca 100644 --- a/src/pages/api/payments/create-billing-portal.ts +++ b/src/pages/api/payments/create-billing-portal.ts @@ -3,7 +3,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { stripe } from '@/libs/stripe'; import { supabaseServerClient } from '@/libs/supabase/server'; import AuthService from '@/services/auth'; -import StripeService from '@/services/stripe'; +import PaymentService from '@/services/payment'; import SubscriptionService from '@/services/subscription'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -22,7 +22,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const AuthServiceInstance = new AuthService(supabaseServerClient); const SubscriptionServiceInstance = new SubscriptionService(supabaseServerClient); - const StripeServiceInstance = new StripeService(stripe); + const PaymentServiceInstance = new PaymentService(stripe); const user = await AuthServiceInstance.getUser(token); @@ -37,13 +37,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!subscription) { return res.status(404).json({ error: 'No subscription found for user' }); } - const customerId = await StripeServiceInstance.getCustomerIdFromSubscription(subscription.stripe_subscription_id); + const customerId = await PaymentServiceInstance.getCustomerIdFromSubscription(subscription.stripe_subscription_id); if (!customerId) { return res.status(404).json({ error: 'No customerId found for subscription' }); } - const portalSession = await StripeServiceInstance.createBillingPortalSession(customerId); + const portalSession = await PaymentServiceInstance.createBillingPortalSession(customerId); return res.status(200).json({ url: portalSession.url }); } catch (error) { diff --git a/src/pages/api/payments/create-checkout.ts b/src/pages/api/payments/create-checkout.ts index 7555bc2..38ecfa3 100644 --- a/src/pages/api/payments/create-checkout.ts +++ b/src/pages/api/payments/create-checkout.ts @@ -1,7 +1,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { stripe } from '@/libs/stripe'; -import StripeService from '@/services/stripe'; +import PaymentService from '@/services/payment'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { @@ -17,8 +17,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const freeTrial = hasFreeTrial ? Number(hasFreeTrial.split('d')?.[0]) : 0; try { - const stripeServiceInstance = new StripeService(stripe); - const session = await stripeServiceInstance.createCheckoutSession( + const PaymentServiceInstance = new PaymentService(stripe); + const session = await PaymentServiceInstance.createCheckoutSession( priceId, plan, userId, diff --git a/src/pages/api/payments/get-plans.ts b/src/pages/api/payments/get-plans.ts index 6f9da9a..048841c 100644 --- a/src/pages/api/payments/get-plans.ts +++ b/src/pages/api/payments/get-plans.ts @@ -2,7 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; import Stripe from 'stripe'; import { stripe } from '@/libs/stripe'; -import StripeService from '@/services/stripe'; +import PaymentService from '@/services/payment'; import { loadTranslationsSSR } from '@/utils/loadTranslationsSSR'; import { InputData, transformPurchasePlansDTO } from '@/utils/transformPurchasePlansDTO'; @@ -20,8 +20,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) try { - const StripeServiceInstance = new StripeService(stripe); - const prices = await StripeServiceInstance.listActivePrices(); + const PaymentServiceInstance = new PaymentService(stripe); + const prices = await PaymentServiceInstance.listActivePrices(); const response = prices?.map((price) => { const product = price.product as Stripe.Product; diff --git a/src/pages/api/webhooks.ts b/src/pages/api/webhooks.ts index a449722..c0f90e8 100644 --- a/src/pages/api/webhooks.ts +++ b/src/pages/api/webhooks.ts @@ -5,7 +5,7 @@ import { stripe } from '@/libs/stripe'; import { supabaseServerClient } from '@/libs/supabase/server'; import AuthService from '@/services/auth'; import EmailService from '@/services/email'; -import StripeService from '@/services/stripe'; +import PaymentService from '@/services/payment'; import SubscriptionService from '@/services/subscription'; const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; @@ -27,7 +27,7 @@ async function getRawBody(req: NextApiRequest): Promise { export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === 'POST') { - const StripeServiceInstance = new StripeService(stripe); + const PaymentServiceInstance = new PaymentService(stripe); const AuthServiceInstance = new AuthService(supabaseServerClient); const SubscriptionServiceInstance = new SubscriptionService(supabaseServerClient); const EmailServiceInstance = new EmailService(); @@ -44,7 +44,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) let event; try { - event = StripeServiceInstance.constructWebhookEvent(rawBody, sig, endpointSecret); + event = PaymentServiceInstance.constructWebhookEvent(rawBody, sig, endpointSecret); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Erro desconhecido'; console.error('Erro ao verificar a assinatura do webhook:', errorMessage); diff --git a/src/services/payment.ts b/src/services/payment.ts new file mode 100644 index 0000000..d117660 --- /dev/null +++ b/src/services/payment.ts @@ -0,0 +1,125 @@ +import { loadStripe } from '@stripe/stripe-js'; +import Stripe from 'stripe'; + +import { calculateCurrencyAmount } from '@/utils/calculateCurrencyAmount'; + +export default class PaymentService { + private stripe: Stripe; + + constructor(stripe: Stripe) { + this.stripe = stripe; + } + + async createCheckoutSession( + priceId: string, + plan: string, + userId: string, + origin: string, + freeTrial?: number, + currency?: string + ): Promise { + const price = await this.getPrice(priceId); + const recurringInterval = price.recurring?.interval; + + if (!recurringInterval) { + throw new Error('Recurring interval not found for this price'); + } + + const sessionData = this.buildCheckoutSessionData( + priceId, + plan, + userId, + origin, + recurringInterval, + freeTrial, + currency, + price.unit_amount + ); + + return await this.stripe.checkout.sessions.create(sessionData); + } + + async listActivePrices(): Promise { + const prices = await this.stripe.prices.list({ active: true, expand: ['data.product'] }); + return prices.data; + } + + constructWebhookEvent(rawBody: string | Buffer, sig: string | string[], secret: string): Stripe.Event { + return this.stripe.webhooks.constructEvent(rawBody, sig, secret); + } + + async getCustomerIdFromSubscription(subscriptionId: string): Promise { + try { + const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); + return subscription.customer as string; + } catch (error) { + console.error('Erro ao buscar assinatura:', error); + return null; + } + } + + async createBillingPortalSession(customerId: string): Promise { + return await this.stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${process.env.NEXT_PUBLIC_PROJECT_URL}/dashboard/subscription`, + }); + } + + static redirectToCheckout(sessionId: string): void { + loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!).then((clientStripe) => { + clientStripe?.redirectToCheckout({ sessionId }); + }); + } + + private async getPrice(priceId: string): Promise { + return this.stripe.prices.retrieve(priceId); + } + + private buildCheckoutSessionData( + priceId: string, + plan: string, + userId: string, + origin: string, + recurringInterval: Stripe.Price.Recurring.Interval, + freeTrial?: number, + currency?: string, + unitAmount?: number | null + ): Stripe.Checkout.SessionCreateParams { + const sessionData: Stripe.Checkout.SessionCreateParams = { + payment_method_types: ['card'], + line_items: [this.buildLineItem(priceId, plan, recurringInterval, currency, unitAmount)], + mode: 'subscription', + success_url: `${origin}/payments?status=success`, + cancel_url: `${origin}/payments?status=cancel`, + metadata: { userId, plan }, + }; + + if (freeTrial) { + sessionData.subscription_data = { trial_period_days: freeTrial }; + } + + return sessionData; + } + + private buildLineItem( + priceId: string, + plan: string, + recurringInterval: Stripe.Price.Recurring.Interval, + currency?: string, + unitAmount?: number | null + ): Stripe.Checkout.SessionCreateParams.LineItem { + if (!currency || !unitAmount) { + return { price: priceId, quantity: 1 }; + } + + return { + price_data: { + currency, + product_data: { name: plan }, + recurring: { interval: recurringInterval }, + unit_amount: calculateCurrencyAmount(String(unitAmount), currency), + }, + quantity: 1, + }; + } +} diff --git a/src/services/stripe.ts b/src/services/stripe.ts deleted file mode 100644 index 8c9d1af..0000000 --- a/src/services/stripe.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { loadStripe } from '@stripe/stripe-js'; -import Stripe from 'stripe'; - -import { calculateCurrencyAmount } from '@/utils/calculateCurrencyAmount'; - -export default class StripeService { - private stripe: Stripe; - - constructor(stripe: Stripe) { - this.stripe = stripe; - } - - async createCheckoutSession( - priceId: string, - plan: string, - userId: string, - origin: string, - freeTrial?: number, - currency?: string, - ): Promise { - - const price = await this.stripe.prices.retrieve(priceId); - const recurringInterval = price.recurring?.interval; - - if (!recurringInterval) { - throw new Error('Recurring interval not found for this price'); - } - - const sessionData: Stripe.Checkout.SessionCreateParams = { - payment_method_types: ['card'], - line_items: [{ - price: priceId, - quantity: 1, - }], - mode: 'subscription', - success_url: `${origin}/payments?status=success`, - cancel_url: `${origin}/payments?status=cancel`, - metadata: { userId, plan }, - }; - - if (currency) { - const unitAmount = price.unit_amount; - const newUnitAmount = calculateCurrencyAmount(String(unitAmount), currency); - - sessionData.line_items = [{ - price_data: { - currency: currency, - product_data: { - name: plan, - }, - recurring: { - interval: recurringInterval, - }, - unit_amount: newUnitAmount, - }, - quantity: 1, - }]; - } - - if (freeTrial) { - sessionData.subscription_data = { - trial_period_days: freeTrial, - }; - } - - const session = await this.stripe.checkout.sessions.create(sessionData); - return session; - } - - - async listActivePrices(): Promise { - const prices = await this.stripe.prices.list({ - active: true, - expand: ['data.product'], - }); - return prices.data; - } - - constructWebhookEvent(rawBody: string | Buffer, sig: string | string[], endpointSecret: string): Stripe.Event { - return this.stripe.webhooks.constructEvent(rawBody, sig, endpointSecret); - } - - async getCustomerIdFromSubscription(subscriptionId: string): Promise { - try { - const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); - return subscription.customer as string; - } catch (error) { - console.error('Erro ao buscar assinatura:', error); - return null; - } - }; - - async createBillingPortalSession(customerId: string) { - const portal = await this.stripe.billingPortal.sessions.create({ - customer: customerId, - return_url: `${process.env.NEXT_PUBLIC_PROJECT_URL}/dashboard/subscription`, - }); - - return portal; - } - static redirectToCheckout(sessionId: string): void { - loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!).then((clientStripe) => { - if (clientStripe) { - clientStripe.redirectToCheckout({ sessionId }); - } - }); - } -} From eca762a3136c2b85aeb52d6e561da46d97932c0f Mon Sep 17 00:00:00 2001 From: Marcelo dos Reis Date: Mon, 17 Feb 2025 23:20:04 -0300 Subject: [PATCH 04/11] feat: create notification service --- src/services/notification.ts | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/services/notification.ts diff --git a/src/services/notification.ts b/src/services/notification.ts new file mode 100644 index 0000000..db94885 --- /dev/null +++ b/src/services/notification.ts @@ -0,0 +1,56 @@ +import { SupabaseClient } from "@supabase/supabase-js"; + +type Notification = { + id?: string; + user_id: string; + message: string; + is_read?: boolean; + created_at?: Date; +}; + +export default class NotificationService { + constructor(private supabase: SupabaseClient) {} + + async createNotification( + notification: Omit + ): Promise { + const { error } = await this.supabase + .from("notifications") + .insert(notification); + + this.handleError(error); + } + + async getNotificationsByUserId(userId: string): Promise { + const { data, error } = await this.supabase + .from("notifications") + .select("*") + .eq("user_id", userId) + .order("created_at", { ascending: false }); + + this.handleError(error); + return data || []; + } + + async markAsRead(notificationId: string): Promise { + const { error } = await this.supabase + .from("notifications") + .update({ is_read: true }) + .eq("id", notificationId); + + this.handleError(error); + } + + async deleteNotification(notificationId: string): Promise { + const { error } = await this.supabase + .from("notifications") + .delete() + .eq("id", notificationId); + + this.handleError(error); + } + + private handleError(error: unknown): void { + if (error) throw error; + } +} From 943e44e90c482576f40f62e87a16084178c6ed7e Mon Sep 17 00:00:00 2001 From: Marcelo dos Reis Date: Mon, 24 Mar 2025 21:12:42 -0300 Subject: [PATCH 05/11] Update Next Version and Add Minimum Flag (#22) * fix: update next version * fix: add minimum flag * chore: update node in workflows --- .github/workflows/ci.yml | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72e2047..edd4597 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '22.11.0' + node-version: '20.18.3' - run: npm install - run: npm run lint @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '22.11.0' + node-version: '20.18.3' - run: npm install - run: npm test @@ -40,7 +40,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '22.11.0' + node-version: '20.18.3' - run: | echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" > .env echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env diff --git a/package.json b/package.json index f281d21..25c87c9 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "form-data": "^4.0.1", "jest": "^29.7.0", "mailgun.js": "^11.1.0", - "next": "15.1.3", + "next": "^15.2.3", "react": "^19.0.0", "react-dom": "^19.0.0", "stripe": "^17.5.0" From e8c4f24a06bb0bc490aa4c4b78f122e58e06beed Mon Sep 17 00:00:00 2001 From: Marcelo dos Reis Date: Fri, 18 Apr 2025 15:11:09 -0300 Subject: [PATCH 06/11] Refactor all i18n Structure and Coverage (#23) * chore: create new structure to i18n * chore: refactor more translations * chore: change how works section * chore: add options object in how section * refactor: all i18n pricing component * refactor: testimonials section in home i18n * refactor: all home page i18n * fix: i18n file * refactor: i18n in terms and privacy page * refactor: page render sections * refactor: all i18n in signin page * refactor: all i18n in sign up page * refactor: i18n in ForgotPassword page * refactor: i18n in ConfirmSignUp page * refactor: update new-password i18n * refactor: removing all i18n PaymentStatus * refactor: create one dashboard component folder * refactor: dashboard component * refactor: add locale in subscription and manage-billing * refactor: all the i18n * chore: finish i18n --- package-lock.json | 348 +++++----- public/locales/en-US.json | 597 ++++++++++++------ public/locales/pt-BR.json | 597 ++++++++++++------ .../(domains)/(auth)/confirm-signup/page.tsx | 20 +- .../(domains)/(auth)/forgot-password/page.tsx | 26 +- .../(domains)/(auth)/new-password/page.tsx | 358 ++++++----- src/app/(domains)/(auth)/signin/page.tsx | 24 +- src/app/(domains)/(auth)/signup/page.tsx | 33 +- .../(domains)/(home)/_sections/FaqSection.tsx | 32 +- .../(home)/_sections/FeaturesSection.tsx | 16 +- src/app/(domains)/(home)/_sections/Footer.tsx | 10 +- .../(home)/_sections/HeroSection.tsx | 6 +- .../(home)/_sections/HowItWorksSection.tsx | 16 +- .../(home)/_sections/TestimonialSection.tsx | 20 +- .../(home)/terms-and-privacy/page.tsx | 104 ++- src/app/(domains)/(payment)/payments/page.tsx | 71 ++- src/app/(domains)/dashboard/layout.tsx | 23 +- src/app/(domains)/dashboard/page.tsx | 4 +- src/app/(domains)/dashboard/settings/page.tsx | 4 +- .../(domains)/dashboard/subscription/page.tsx | 12 +- src/app/layout.tsx | 4 +- src/components/ClientDashboard.tsx | 43 -- .../{FeatureMenu.tsx => Dashboard/Menu.tsx} | 10 +- src/components/Dashboard/Modal.tsx | 28 + src/components/Dashboard/Navbar/MyAccount.tsx | 83 +++ .../Navbar}/Notification/index.tsx | 0 .../Navbar}/Notification/style.css | 0 .../Navbar/index.tsx} | 6 +- src/components/Dashboard/index.tsx | 21 + src/components/FooterAuthScreen.tsx | 8 +- src/components/ManageBilling.tsx | 99 +-- src/components/Modal.tsx | 15 +- src/components/MyAccount.tsx | 74 --- src/components/Navbar.tsx | 24 +- src/components/Pricing/PlanCard.tsx | 8 +- src/components/Pricing/index.tsx | 12 +- src/components/SettingsOptions.tsx | 18 +- src/constants/SUBSCRIPTION_PLANS_BASE.ts | 0 src/contexts/i18nContext.tsx | 29 +- src/hooks/useFetchPlans.ts | 102 +-- src/hooks/useI18n.ts | 30 +- src/locales/en-US.json | 202 ------ src/locales/pt-BR.json | 202 ------ src/utils/loadTranslationsSSR.ts | 69 +- src/utils/transformPurchasePlansDTO.ts | 40 +- 45 files changed, 1812 insertions(+), 1636 deletions(-) delete mode 100644 src/components/ClientDashboard.tsx rename src/components/{FeatureMenu.tsx => Dashboard/Menu.tsx} (88%) create mode 100644 src/components/Dashboard/Modal.tsx create mode 100644 src/components/Dashboard/Navbar/MyAccount.tsx rename src/components/{ => Dashboard/Navbar}/Notification/index.tsx (100%) rename src/components/{ => Dashboard/Navbar}/Notification/style.css (100%) rename src/components/{DashboardNavbar.tsx => Dashboard/Navbar/index.tsx} (85%) create mode 100644 src/components/Dashboard/index.tsx delete mode 100644 src/components/MyAccount.tsx create mode 100644 src/constants/SUBSCRIPTION_PLANS_BASE.ts delete mode 100644 src/locales/en-US.json delete mode 100644 src/locales/pt-BR.json diff --git a/package-lock.json b/package-lock.json index 475af0c..2ab5b95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "form-data": "^4.0.1", "jest": "^29.7.0", "mailgun.js": "^11.1.0", - "next": "15.1.3", + "next": "^15.2.3", "react": "^19.0.0", "react-dom": "^19.0.0", "stripe": "^17.5.0" @@ -35,6 +35,7 @@ "eslint-plugin-n": "^16.6.2", "eslint-plugin-promise": "^6.6.0", "postcss": "^8.4.49", + "prettier": "3.5.3", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", "typescript-eslint": "^8.17.0" @@ -236,25 +237,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", - "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", + "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.7" + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.10" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", - "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", + "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.7" + "@babel/types": "^7.26.10" }, "bin": { "parser": "bin/babel-parser.js" @@ -486,14 +487,14 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" @@ -527,9 +528,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", - "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", + "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", @@ -596,9 +597,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", "license": "MIT", "optional": true, "dependencies": { @@ -716,9 +717,9 @@ "license": "BSD-3-Clause" }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz", + "integrity": "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==", "cpu": [ "arm64" ], @@ -734,13 +735,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.1.0" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.1.tgz", + "integrity": "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==", "cpu": [ "x64" ], @@ -756,13 +757,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.1.0" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", "cpu": [ "arm64" ], @@ -776,9 +777,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", + "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", "cpu": [ "x64" ], @@ -792,9 +793,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", + "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", "cpu": [ "arm" ], @@ -808,9 +809,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", + "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", "cpu": [ "arm64" ], @@ -823,10 +824,26 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", + "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", + "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", "cpu": [ "s390x" ], @@ -840,9 +857,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", + "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", "cpu": [ "x64" ], @@ -856,9 +873,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", + "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", "cpu": [ "arm64" ], @@ -872,9 +889,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", + "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", "cpu": [ "x64" ], @@ -888,9 +905,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.1.tgz", + "integrity": "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==", "cpu": [ "arm" ], @@ -906,13 +923,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.1.0" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.1.tgz", + "integrity": "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==", "cpu": [ "arm64" ], @@ -928,13 +945,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.1.0" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.1.tgz", + "integrity": "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==", "cpu": [ "s390x" ], @@ -950,13 +967,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.1.0" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.1.tgz", + "integrity": "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==", "cpu": [ "x64" ], @@ -972,13 +989,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.1.0" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.1.tgz", + "integrity": "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==", "cpu": [ "arm64" ], @@ -994,13 +1011,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.1.tgz", + "integrity": "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==", "cpu": [ "x64" ], @@ -1016,20 +1033,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.1.0" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.1.tgz", + "integrity": "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.4.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -1039,9 +1056,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.1.tgz", + "integrity": "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==", "cpu": [ "ia32" ], @@ -1058,9 +1075,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.1.tgz", + "integrity": "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==", "cpu": [ "x64" ], @@ -1607,9 +1624,9 @@ } }, "node_modules/@next/env": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.3.tgz", - "integrity": "sha512-Q1tXwQCGWyA3ehMph3VO+E6xFPHDKdHFYosadt0F78EObYxPio0S09H9UGYznDe6Wc8eLKLG89GqcFJJDiK5xw==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz", + "integrity": "sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1623,9 +1640,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.3.tgz", - "integrity": "sha512-aZtmIh8jU89DZahXQt1La0f2EMPt/i7W+rG1sLtYJERsP7GRnNFghsciFpQcKHcGh4dUiyTB5C1X3Dde/Gw8gg==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.1.tgz", + "integrity": "sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==", "cpu": [ "arm64" ], @@ -1639,9 +1656,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.3.tgz", - "integrity": "sha512-aw8901rjkVBK5mbq5oV32IqkJg+CQa6aULNlN8zyCWSsePzEG3kpDkAFkkTOh3eJ0p95KbkLyWBzslQKamXsLA==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.1.tgz", + "integrity": "sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==", "cpu": [ "x64" ], @@ -1655,9 +1672,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.3.tgz", - "integrity": "sha512-YbdaYjyHa4fPK4GR4k2XgXV0p8vbU1SZh7vv6El4bl9N+ZSiMfbmqCuCuNU1Z4ebJMumafaz6UCC2zaJCsdzjw==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.1.tgz", + "integrity": "sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==", "cpu": [ "arm64" ], @@ -1671,9 +1688,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.3.tgz", - "integrity": "sha512-qgH/aRj2xcr4BouwKG3XdqNu33SDadqbkqB6KaZZkozar857upxKakbRllpqZgWl/NDeSCBYPmUAZPBHZpbA0w==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.1.tgz", + "integrity": "sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==", "cpu": [ "arm64" ], @@ -1687,9 +1704,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.3.tgz", - "integrity": "sha512-uzafnTFwZCPN499fNVnS2xFME8WLC9y7PLRs/yqz5lz1X/ySoxfaK2Hbz74zYUdEg+iDZPd8KlsWaw9HKkLEVw==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.1.tgz", + "integrity": "sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==", "cpu": [ "x64" ], @@ -1703,9 +1720,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.3.tgz", - "integrity": "sha512-el6GUFi4SiDYnMTTlJJFMU+GHvw0UIFnffP1qhurrN1qJV3BqaSRUjkDUgVV44T6zpw1Lc6u+yn0puDKHs+Sbw==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.1.tgz", + "integrity": "sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==", "cpu": [ "x64" ], @@ -1719,9 +1736,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.3.tgz", - "integrity": "sha512-6RxKjvnvVMM89giYGI1qye9ODsBQpHSHVo8vqA8xGhmRPZHDQUE4jcDbhBwK0GnFMqBnu+XMg3nYukNkmLOLWw==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.1.tgz", + "integrity": "sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==", "cpu": [ "arm64" ], @@ -1735,9 +1752,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.3.tgz", - "integrity": "sha512-VId/f5blObG7IodwC5Grf+aYP0O8Saz1/aeU3YcWqNdIUAmFQY3VEPKPaIzfv32F/clvanOb2K2BR5DtDs6XyQ==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.1.tgz", + "integrity": "sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==", "cpu": [ "x64" ], @@ -2696,9 +2713,9 @@ } }, "node_modules/axios": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz", + "integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -6746,12 +6763,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/next/-/next-15.1.3.tgz", - "integrity": "sha512-5igmb8N8AEhWDYzogcJvtcRDU6n4cMGtBklxKD4biYv4LXN8+awc/bbQ2IM2NQHdVPgJ6XumYXfo3hBtErg1DA==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/next/-/next-15.3.1.tgz", + "integrity": "sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g==", "license": "MIT", "dependencies": { - "@next/env": "15.1.3", + "@next/env": "15.3.1", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", @@ -6766,15 +6783,15 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.1.3", - "@next/swc-darwin-x64": "15.1.3", - "@next/swc-linux-arm64-gnu": "15.1.3", - "@next/swc-linux-arm64-musl": "15.1.3", - "@next/swc-linux-x64-gnu": "15.1.3", - "@next/swc-linux-x64-musl": "15.1.3", - "@next/swc-win32-arm64-msvc": "15.1.3", - "@next/swc-win32-x64-msvc": "15.1.3", - "sharp": "^0.33.5" + "@next/swc-darwin-arm64": "15.3.1", + "@next/swc-darwin-x64": "15.3.1", + "@next/swc-linux-arm64-gnu": "15.3.1", + "@next/swc-linux-arm64-musl": "15.3.1", + "@next/swc-linux-x64-gnu": "15.3.1", + "@next/swc-linux-x64-musl": "15.3.1", + "@next/swc-win32-arm64-msvc": "15.3.1", + "@next/swc-win32-x64-msvc": "15.3.1", + "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -7457,6 +7474,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -7870,9 +7903,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7931,16 +7964,16 @@ } }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.1.tgz", + "integrity": "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==", "hasInstallScript": true, "license": "Apache-2.0", "optional": true, "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "semver": "^7.7.1" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -7949,25 +7982,26 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.34.1", + "@img/sharp-darwin-x64": "0.34.1", + "@img/sharp-libvips-darwin-arm64": "1.1.0", + "@img/sharp-libvips-darwin-x64": "1.1.0", + "@img/sharp-libvips-linux-arm": "1.1.0", + "@img/sharp-libvips-linux-arm64": "1.1.0", + "@img/sharp-libvips-linux-ppc64": "1.1.0", + "@img/sharp-libvips-linux-s390x": "1.1.0", + "@img/sharp-libvips-linux-x64": "1.1.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", + "@img/sharp-libvips-linuxmusl-x64": "1.1.0", + "@img/sharp-linux-arm": "0.34.1", + "@img/sharp-linux-arm64": "0.34.1", + "@img/sharp-linux-s390x": "0.34.1", + "@img/sharp-linux-x64": "0.34.1", + "@img/sharp-linuxmusl-arm64": "0.34.1", + "@img/sharp-linuxmusl-x64": "0.34.1", + "@img/sharp-wasm32": "0.34.1", + "@img/sharp-win32-ia32": "0.34.1", + "@img/sharp-win32-x64": "0.34.1" } }, "node_modules/shebang-command": { diff --git a/public/locales/en-US.json b/public/locales/en-US.json index be69bc2..9f2eae1 100644 --- a/public/locales/en-US.json +++ b/public/locales/en-US.json @@ -1,202 +1,397 @@ { - "title": "Sassy - powerful micro-saas template", - "home-navbar-pricing": "Pricing", - "home-navbar-features": "Features", - "home-navbar-faq": "FAQ", - "home-navbar-signin": "Sign In", - "home-navbar-try": "Try For Free", - "home-navbar-dashboard": "Dashboard", - "home-section-hero-title": "Welcome to Micro-SaaS Creator", - "home-section-hero-description": "Empower your vision with tools to build Micro-SaaS solutions effortlessly.", - "home-section-hero-button": "Get Started", - "home-section-how-title": "How Sassy Works", - "home-section-how-description": "Set up your subscription service in just three easy steps.", - "home-section-how-first-title": "Sign Up", - "home-section-how-first-description": "Create an account and set up your profile. Secure and fast with Supabase integration.", - "home-section-how-second-title": "Integrate Stripe", - "home-section-how-second-description": "Connect Stripe for seamless billing and manage monthly or annual subscriptions.", - "home-section-how-third-title": "Manage Subscriptions", - "home-section-how-third-description": "Easily conetor and manage your subscriptions, with real-time updates.", - "component-pricing-title": "Pricing Plans", - "component-pricing-description": "Simple and transparent pricing to suit your needs.", - "component-pricing-trial-title": "You have a free trial for", - "component-pricing-trial-description": "Try our service with no commitment.", - "component-pricing-selected-tag": "Selected Plan", - "component-pricing-popular-tag": "Most Popular", - "component-pricing-button": "Subscribe", - "component-pricing-button-subscribed": "Current Plan", - "component-pricing-plan-starter-title": "Starter", - "component-pricing-plan-starter-description": "Ideal for individuals launching first Micro-SaaS.", - "component-pricing-plan-starter-feature-first": "✔ Access to core features", - "component-pricing-plan-starter-feature-second": "✔ 1 project", - "component-pricing-plan-starter-feature-third": " ", - "component-pricing-plan-starter-extra": "Everything in Free, plus", - "component-pricing-plan-creator-title": "Creator", - "component-pricing-plan-creator-description": "Great for creators looking to expand their reach.", - "component-pricing-plan-creator-feature-first": "✔ Access to core features", - "component-pricing-plan-creator-feature-second": "✔ 3 projects", - "component-pricing-plan-creator-feature-third": "✔ Email support", - "component-pricing-plan-creator-extra": "Everything in Starter, plus", - "component-pricing-plan-pro-title": "Pro", - "component-pricing-plan-pro-description": "Perfect for teams scaling their Micro-SaaS business.", - "component-pricing-plan-pro-feature-first": "✔ Access to core features", - "component-pricing-plan-pro-feature-second": "✔ Unlimited projects", - "component-pricing-plan-pro-feature-third": "✔ Priority support", - "component-pricing-plan-pro-extra": "Everything in Creator, plus", - "component-pricing-toggle-monthly": "Monthly", - "component-pricing-toggle-annual": "Annual", - "component-pricing-subscription-plans-free-name": "Free", - "component-pricing-subscription-plans-free-price-monthly": "${value}/month", - "component-pricing-subscription-plans-free-price-annual": "${value}/year", - "component-pricing-subscription-plans-free-description": "Ideal for individuals trying out our service.", - "component-pricing-subscription-plans-free-feature-1": "✔ Access to basic features", - "component-pricing-subscription-plans-free-feature-2": "✔ 1 project", - "component-pricing-subscription-plans-free-extra-features": " ", - "home-section-features-title": "Modern Features for Your SaaS", - "home-section-features-description": "Everything you need to build powerful Micro-SaaS applications.", - "home-section-feature-oauth-title": "OAuth Authentication", - "home-section-feature-oauth-description": "Seamlessly integrate with Google, Facebook, and Twitter to provide a smooth login experience.", - "home-section-feature-subscription-title": "Subscription Management", - "home-section-feature-subscription-description": "Effortlessly manage subscriptions with Stripe's robust tools and real-time updates.", - "home-section-feature-responsive-title": "Responsive Design", - "home-section-feature-responsive-description": "Crafted with TailwindCSS for a seamless experience across all devices.", - "home-section-testimonials-title": "What Our Users Say", - "home-section-testimonials-description": "Hear directly from our satisfied customers.", - "home-section-testimonial-1": "Sassy has been a game-changer for our Micro-SaaS project. The integration with Stripe and Supabase is seamless, and it helped us get started so much faster!", - "home-section-testimonial-2": "I was looking for a simple and reliable way to manage subscriptions for my SaaS product. Sassy’s Stripe integration is fantastic and easy to use!", - "home-section-testimonial-3": "The built-in authentication with Supabase and the payment gateway via Stripe saved us hours of development time. Highly recommend it!", - "home-section-testimonial-4": "As a solo developer, I needed a solid foundation for my Micro-SaaS. Sassy provided that and more! The flexibility of the template is incredible.", - "home-section-testimonial-5": "Sassy’s responsive design and clean architecture made it easy to deploy. I’ve been able to focus on building features rather than handling the basics.", - "home-section-testimonial-6": "The ease of integrating OAuth authentication with Google and Facebook made the user login process a breeze. This platform is a must-have for any developer.", - "home-section-testimonial-7": "With the subscription management system already in place, I could concentrate on my product’s core features. Sassy has been a huge time-saver.", - "home-section-testimonial-8": "Stripe webhooks and subscription handling worked flawlessly with my project. I’m really impressed with how well everything is integrated.", - "home-section-faq-title": "Frequently Asked Questions", - "home-section-faq-description": "Answers to help you understand how to leverage this template to kickstart your Micro-SaaS development.", - "home-section-faq-question-1": "What is this template for?", - "home-section-faq-answer-1": "This is a powerful template designed to accelerate the development of Micro-SaaS applications. It provides an example setup for subscription management, but you can easily adapt it to suit any SaaS product.", - "home-section-faq-question-2": "How do I get started with the template?", - "home-section-faq-answer-2": "Start by cloning the repository, setting up your environment with Next.js 15, and configuring TypeScript. The template is ready for development with integrations like Stripe and Supabase to manage your user data and payments.", - "home-section-faq-question-3": "Is this template focused on subscriptions?", - "home-section-faq-answer-3": "Subscriptions are just one example of functionality provided in this template. You can customize it to fit your Micro-SaaS application's needs, whether it's for billing, authentication, or any other aspect of your service.", - "home-section-faq-question-4": "What payment options are included?", - "home-section-faq-answer-4": "The template comes with an example of monthly and annual subscription management using Stripe. You can modify the payment flow to fit your app’s business model.", - "home-section-faq-question-5": "Can I integrate other services into this template?", - "home-section-faq-answer-5": "Yes, the template is built to be flexible. It supports integrations with Supabase for user management, Stripe for payments, and OAuth for authentication with services like Google, Facebook, and Twitter.", - "home-section-faq-question-6": "Is this template production-ready?", - "home-section-faq-answer-6": "While this template provides a solid foundation, it’s intended for development and customization. You'll need to tweak it to fit your production environment and business requirements.", - "home-section-faq-question-7": "How do I customize the template?", - "home-section-faq-answer-7": "You can easily modify the code to fit your specific use case. The architecture is modular, with separate components for authentication, payments, and user management, allowing for easy customization.", - "home-footer-title": "Ready to Get Started?", - "home-footer-join": "Join Sassy Today", - "home-footer-terms": "Terms & Privacy", - "home-footer-github": "GitHub", - "home-footer-copyright": "© 2025 Sassy. All rights reserved.", - "terms-privacy-title": "Terms and Privacy Policy", - "terms-privacy-terms-of-use": "Terms of Use", - "terms-privacy-terms-of-use-description": "Welcome to our service. By using our website, you agree to comply with the following terms and conditions.", - "terms-privacy-privacy-policy": "Privacy Policy", - "terms-privacy-privacy-policy-description": "We value your privacy and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, and safeguard your data.", - "terms-privacy-data-collection": "Data Collection", - "terms-privacy-data-collection-description": "We may collect personal information such as your name, email address, and usage data to improve our service and provide a better user experience.", - "terms-privacy-data-security": "Data Security", - "terms-privacy-data-security-description": "We implement a variety of security measures to ensure the safety of your personal data. However, no method of transmission over the internet is completely secure, so we cannot guarantee absolute security.", - "terms-privacy-changes-policy": "Changes to this Policy", - "terms-privacy-changes-policy-description": "We may update this Terms and Privacy Policy from time to time. Any changes will be posted on this page with an updated date.", - "terms-privacy-contact-us": "Contact Us", - "terms-privacy-contact-us-description": "If you have any questions about these terms or our privacy policy, feel free to contact us.", - "signIn-back-to-home": "Back To Home", - "signIn-title": "Sign In", - "signIn-subtitle": "Enter your details to use an account", - "signIn-invalid-email": "Invalid email format.", - "signIn-invalid-password": "Password must be at least 6 characters long.", - "signIn-invalid-credentials": "Invalid email or password.", - "signIn-general-error": "Something went wrong. Please try again.", - "signIn-email": "Email", - "signIn-password": "Password", - "signIn-forgot-password": "Forgot your password?", - "signIn-button": "Sign In", - "signUp-back-to-login": "Back To Login", - "signUp-title": "Sign Up", - "signUp-subtitle": "Create your account to get started", - "signUp-invalid-email": "Invalid email format.", - "signUp-invalid-password": "Password must be at least 6 characters long.", - "signUp-passwords-do-not-match": "Passwords do not match.", - "signUp-terms-not-accepted": "You must accept the terms and conditions.", - "signUp-general-error": "Something went wrong. Please try again.", - "signUp-email": "Email", - "signUp-password": "Password", - "signUp-confirm-password": "Confirm Password", - "signUp-terms-label": "I accept the Terms and Privacy Policy", - "signUp-button": "Sign Up", - "component-footer-auth-dont-have-account": "Don't have an account?", - "component-footer-auth-create-account": "Create an account", - "component-footer-auth-already-have-account": "Already have an account?", - "component-footer-auth-go-back-to-login": "Go back to login", - "forgot-password-title": "Forgot Password", - "forgot-password-description": "Enter your email to receive a password reset link.", - "forgot-password-email-label": "Email", - "forgot-password-email-placeholder": "Enter your email", - "forgot-password-button-text": "Send Reset Link", - "forgot-password-invalid-email": "Invalid email format.", - "forgot-password-general-error": "Something went wrong. Please try again.", - "forgot-password-check-inbox-title": "Check Your Inbox", - "forgot-password-check-inbox-description": "A password reset link has been sent to your email. Please check your inbox.", - "forgot-password-back-to-login": "Back To Login", - "confirm-signup-error-token-missing": "Invalid or missing token.", - "confirm-signup-error-failed": "Email confirmation failed. Please try again.", - "confirm-signup-error-unexpected": "An unexpected error occurred.", - "confirm-signup-back-to-login": "Back To Login", - "confirm-signup-email-confirmed": "Email Confirmed", - "confirm-signup-success-message": "Your email has been successfully confirmed.", - "confirm-signup-go-to-dashboard": "Go To Dashboard", - "confirm-signup-oauth-success": "OAuth Successfully", - "confirm-signup-oauth-success-message": "Your provider has been confirmed register.", - "new-password-error-token-missing": "Invalid or missing token.", - "new-password-error-password-valid": "Password must be at least 6 characters long.", - "new-password-error-password-match": "Passwords do not match.", - "new-password-error-general": "Failed to change the password. Please try again.", - "new-password-error-unexpected": "Something went wrong. Please try again.", - "new-password-back-to-login": "Back To Login", - "new-password-success-message": "Your password has been changed successfully!", - "new-password-create-title": "Create a New Password", - "new-password-subtitle": "Set your new password to continue", - "new-password-password-label": "Password", - "new-password-confirm-password-label": "Confirm Password", - "new-password-submit-button": "Change Password", - "payment-status-back-to-billing": "Back To Billing", - "payment-status-success-title": "Purchase Confirmed", - "payment-status-success-message": "Your purchase has been successfully confirmed.", - "payment-status-back-to-settings": "Back To Settings", - "payment-status-cancel-title": "Purchase Cancelled", - "payment-status-cancel-message": "Your purchase has been cancelled.", - "payment-status-back-to-home": "Back To Home", - "payment-status-unknown-title": "Unknown Status", - "payment-status-unknown-message": "The status of your purchase is unknown.", - "component-client-dashboard-modal-title": "Important Notice", - "component-client-dashboard-modal-text": "To test our app, you need to start a free trial. No features are available after the trial period.", - "component-client-dashboard-modal-button": "Start Free Trial", - "component-feature-menu-tab-free": "Free", - "component-feature-menu-tab-starter": "Starter/Creator", - "component-feature-menu-tab-pro": "Pro", - "component-dashboard-navbar-title": "Dashboard", - "component-my-account-button": "My Account", - "component-my-account-profile": "Profile", - "component-my-account-subscription": "Subscription", - "component-my-account-terms-privacy": "Terms and Privacy", - "component-my-account-sign-out": "Sign Out", - "settings-name-label": "Name", - "settings-email-label": "Email", - "subscription-current-plan": "Current Plan", - "subscription-current-plan-description": "You are currently on the {plan} plan.", - "component-manage-billing-title": "Subscription Details", - "component-manage-billing-button": "Manage billing info", - "component-manage-billing-error": "Could not redirect to the billing portal.", - "failed-to-create-billing-portal": "Failed to create billing portal session.", - "component-settings-options-change-password": "Change Password", - "component-settings-options-current-plan": "Current Plan", - "component-settings-options-manage-subscription": "Manage Subscription", - "component-settings-options-password-change-request-sent": "Password Change Request Sent!", - "component-settings-options-password-change-description": "Please check your inbox for an email to complete the process.", - "component-settings-options-password-change-failed": "Password Change Failed", - "component-settings-options-password-change-error": "An error occurred while processing your request. Please try again later." -} \ No newline at end of file + "title": "Sassy - powerful micro-saas template", + "pages": { + "home": { + "sections": { + "hero": { + "title": "Welcome to Micro-SaaS Creator", + "description": "Empower your vision with tools to build Micro-SaaS solutions effortlessly.", + "button": "Get Started" + }, + "how": { + "title": "How Sassy Works", + "description": "Set up your subscription service in just three easy steps.", + "options": { + "first": { + "title": "Sign Up", + "description": "Create an account and set up your profile. Secure and fast with Supabase integration." + }, + "second": { + "title": "Integrate Stripe", + "description": "Connect Stripe for seamless billing and manage monthly or annual subscriptions." + }, + "third": { + "title": "Manage Subscriptions", + "description": "Easily conetor and manage your subscriptions, with real-time updates." + } + } + }, + "features": { + "title": "Modern Features for Your SaaS", + "description": "Everything you need to build powerful Micro-SaaS applications.", + "oauth": { + "title": "OAuth Authentication", + "description": "Seamlessly integrate with Google, Facebook, and Twitter to provide a smooth login experience." + }, + "subscription": { + "title": "Subscription Management", + "description": "Effortlessly manage subscriptions with Stripe's robust tools and real-time updates." + }, + "responsive": { + "title": "Responsive Design", + "description": "Crafted with TailwindCSS for a seamless experience across all devices." + } + }, + "testimonials": { + "title": "What Our Users Say", + "description": "Hear directly from our satisfied customers.", + "list": { + "1": "Sassy has been a game-changer for our Micro-SaaS project. The integration with Stripe and Supabase is seamless, and it helped us get started so much faster!", + "2": "I was looking for a simple and reliable way to manage subscriptions for my SaaS product. Sassy’s Stripe integration is fantastic and easy to use!", + "3": "The built-in authentication with Supabase and the payment gateway via Stripe saved us hours of development time. Highly recommend it!", + "4": "As a solo developer, I needed a solid foundation for my Micro-SaaS. Sassy provided that and more! The flexibility of the template is incredible.", + "5": "Sassy’s responsive design and clean architecture made it easy to deploy. I’ve been able to focus on building features rather than handling the basics.", + "6": "The ease of integrating OAuth authentication with Google and Facebook made the user login process a breeze. This platform is a must-have for any developer.", + "7": "With the subscription management system already in place, I could concentrate on my product’s core features. Sassy has been a huge time-saver.", + "8": "Stripe webhooks and subscription handling worked flawlessly with my project. I’m really impressed with how well everything is integrated." + } + }, + "faq": { + "title": "Frequently Asked Questions", + "description": "Answers to help you understand how to leverage this template to kickstart your Micro-SaaS development.", + "items": { + "1": { + "question": "What is this template for?", + "answer": "This is a powerful template designed to accelerate the development of Micro-SaaS applications. It provides an example setup for subscription management, but you can easily adapt it to suit any SaaS product." + }, + "2": { + "question": "How do I get started with the template?", + "answer": "Start by cloning the repository, setting up your environment with Next.js 15, and configuring TypeScript. The template is ready for development with integrations like Stripe and Supabase to manage your user data and payments." + }, + "3": { + "question": "Is this template focused on subscriptions?", + "answer": "Subscriptions are just one example of functionality provided in this template. You can customize it to fit your Micro-SaaS application's needs, whether it's for billing, authentication, or any other aspect of your service." + }, + "4": { + "question": "What payment options are included?", + "answer": "The template comes with an example of monthly and annual subscription management using Stripe. You can modify the payment flow to fit your app’s business model." + }, + "5": { + "question": "Can I integrate other services into this template?", + "answer": "Yes, the template is built to be flexible. It supports integrations with Supabase for user management, Stripe for payments, and OAuth for authentication with services like Google, Facebook, and Twitter." + }, + "6": { + "question": "Is this template production-ready?", + "answer": "While this template provides a solid foundation, it’s intended for development and customization. You'll need to tweak it to fit your production environment and business requirements." + }, + "7": { + "question": "How do I customize the template?", + "answer": "You can easily modify the code to fit your specific use case. The architecture is modular, with separate components for authentication, payments, and user management, allowing for easy customization." + } + } + } + }, + "footer": { + "title": "Ready to Get Started?", + "join": "Join Sassy Today", + "terms": "Terms & Privacy", + "github": "GitHub", + "copyright": "© 2025 Sassy. All rights reserved." + } + }, + "terms-and-privacy": { + "title": "Terms and Privacy Policy", + "terms": { + "title": "Terms of Use", + "description": "Welcome to our service. By using our website, you agree to comply with the following terms and conditions." + }, + "policy": { + "title": "Privacy Policy", + "description": "We value your privacy and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, and safeguard your data." + }, + "collection": { + "title": "Data Collection", + "description": "We may collect personal information such as your name, email address, and usage data to improve our service and provide a better user experience." + }, + "security": { + "title": "Data Security", + "description": "We implement a variety of security measures to ensure the safety of your personal data. However, no method of transmission over the internet is completely secure, so we cannot guarantee absolute security." + }, + "changes": { + "title": "Changes to this Policy", + "description": "We may update this Terms and Privacy Policy from time to time. Any changes will be posted on this page with an updated date." + }, + "contact": { + "title": "Contact Us", + "description": "If you have any questions about these terms or our privacy policy, feel free to contact us." + } + }, + "signin": { + "title": "Sign In", + "description": "Enter your details to use an account", + "inputs": { + "email": "Email", + "password": "Password" + }, + "actions": { + "back": "Back To Home", + "forgot-password": "Forgot your password?", + "submit": "Sign In" + }, + "errors": { + "email": "Invalid email format.", + "password": "Password must be at least 6 characters long.", + "credentials": "Invalid email or password.", + "error": "Something went wrong. Please try again." + } + }, + "signup": { + "back-to-login": "Back To Login", + "title": "Sign Up", + "description": "Create your account to get started", + "inputs": { + "email": "Email", + "password": "Password", + "confirm-password": "Confirm Password", + "terms": "I accept the Terms and Privacy Policy" + }, + "actions": { + "submit": "Sign Up", + "success": "Please check your inbox for an email to complete the process." + }, + "errors": { + "email": "Invalid email format.", + "password": "Password must be at least 6 characters long.", + "confirm-password": "Passwords do not match.", + "terms": "You must accept the terms and conditions.", + "error": "Something went wrong. Please try again." + } + }, + "forgot-password": { + "title": "Forgot Password", + "description": "Enter your email to receive a password reset link.", + "inputs": { + "email": { + "label": "Email", + "placeholder": "Enter your email" + } + }, + "actions": { + "submit": "Send Reset Link", + "back": "Back To Login", + "inbox": { + "title": "Check Your Inbox", + "description": "A password reset link has been sent to your email. Please check your inbox." + } + }, + "errors": { + "email": "Invalid email format.", + "error": "Something went wrong. Please try again." + } + }, + "confirm-signup": { + "messages": { + "email": { + "title": "Email Confirmed", + "description": "Your email has been successfully confirmed." + }, + "oauth": { + "title": "OAuth Successfully", + "description": "Your provider has been confirmed register." + } + }, + "actions": { + "dashboard": "Go To Dashboard" + }, + "errors": { + "missing-token": "Invalid or missing token.", + "failed": "Email confirmation failed. Please try again.", + "unexpected": "An unexpected error occurred." + } + }, + "new-password": { + "title": "Create a New Password", + "description": "Set your new password to continue", + "inputs": { + "password": "Password", + "confirm-password": "Confirm Password" + }, + "actions": { + "submit": "Change Password", + "dashboard": "Back To dashboard", + "success": "Your password has been changed successfully!" + }, + "errors": { + "token-missing": "Invalid or missing token.", + "password-valid": "Password must be at least 6 characters long.", + "password-match": "Passwords do not match.", + "general": "Failed to change the password. Please try again.", + "unexpected": "Something went wrong. Please try again." + } + }, + "payments": { + "status": { + "success": { + "title": "Purchase Confirmed", + "description": "Your purchase has been successfully confirmed." + }, + "cancel": { + "title": "Purchase Cancelled", + "description": "Your purchase has been cancelled." + }, + "unknown": { + "title": "Unknown Status", + "description": "The status of your purchase is unknown." + } + }, + "actions": { + "billing": "Back To Billing", + "settings": "Back To Settings", + "home": "Back To Home" + } + }, + "subscription": { + "plan": { + "title": "Current Plan", + "description": "You are currently on the {plan} plan." + } + }, + "settings": { + "name": "Name", + "email": "Email" + } + }, + "components": { + "navbar": { + "pricing": "Pricing", + "features": "Features", + "faq": "FAQ", + "signin": "Sign In", + "try": "Try For Free", + "dashboard": "Dashboard" + }, + "pricing": { + "title": "Pricing Plans", + "description": "Simple and transparent pricing to suit your needs.", + "toggle": { + "monthly": "Monthly", + "annual": "Annual" + }, + "trial": { + "title": "You have a free trial for", + "description": "Try our service with no commitment." + }, + "tag": { + "selected": "Selected Plan", + "popular": "Most Popular" + }, + "button": { + "default": "Subscribe", + "subscribed": "Current Plan" + }, + "plans": { + "prices": { + "monthly": "${value}/month", + "annual": "${value}/year" + }, + "free": { + "title": "Free", + "description": "Ideal for individuals trying out our service.", + "features": { + "first": "✔ Access to basic features", + "second": "✔ 1 project", + "third": " " + }, + "extra": "" + }, + "starter": { + "title": "Starter", + "description": "Ideal for individuals launching first Micro-SaaS.", + "features": { + "first": "✔ Access to core features", + "second": "✔ 1 project", + "third": " " + }, + "extra": "Everything in Free, plus" + }, + "creator": { + "title": "Creator", + "description": "Great for creators looking to expand their reach.", + "features": { + "first": "✔ Access to core features", + "second": "✔ 3 project", + "third": "✔ Email support" + }, + "extra": "Everything in Starter, plus" + }, + "pro": { + "title": "Pro", + "description": "Perfect for teams scaling their Micro-SaaS business.", + "features": { + "first": "✔ Access to core features", + "second": "✔ Unlimited projects", + "third": "✔ Priority support" + }, + "extra": "Everything in Creator, plus" + } + } + }, + "footer-auth": { + "dont-have-account": "Don't have an account?", + "create-account": "Create an account", + "already-have-account": "Already have an account?", + "go-back-to-login": "Go back to login" + }, + "dashboard": { + "modal": { + "title": "Important Notice", + "description": "To test our app, you need to start a free trial. No features are available after the trial period.", + "actions": { + "proceed": "Start Free Trial" + } + }, + "navbar": { + "title": "Dashboard", + "my-account": { + "options": { + "button": "My Account", + "profile": "Profile", + "subscription": "Subscription", + "terms-privacy": "Terms and Privacy", + "sign-out": "Sign Out" + } + } + }, + "menu": { + "options": { + "free": "Free", + "starter": "Starter/Creator", + "pro": "Pro" + } + } + }, + "manage-billing": { + "title": "Subscription Details", + "actions": { + "proceed": "Manage billing info" + } + }, + "settings-options": { + "plan": "Current Plan", + "subscription": "Manage Subscription", + "actions": { + "change-password": "Change Password" + }, + "toast": { + "success": { + "title": "Password Change Request Sent!", + "description": "Please check your inbox for an email to complete the process." + }, + "error": { + "title": "Password Change Failed", + "description": "An error occurred while processing your request. Please try again later." + } + } + } + } +} diff --git a/public/locales/pt-BR.json b/public/locales/pt-BR.json index c9071ac..3886cac 100644 --- a/public/locales/pt-BR.json +++ b/public/locales/pt-BR.json @@ -1,202 +1,397 @@ { - "title": "Sassy - template poderoso para micro-SaaS", - "home-navbar-pricing": "Preços", - "home-navbar-features": "Funcionalidades", - "home-navbar-faq": "FAQ", - "home-navbar-signin": "Entrar", - "home-navbar-try": "Experimente", - "home-navbar-dashboard": "Painel", - "home-section-hero-title": "Bem-vindo ao Criador de Micro-SaaS", - "home-section-hero-description": "Dê vida à sua visão com ferramentas para construir soluções Micro-SaaS sem esforço.", - "home-section-hero-button": "Começar", - "home-section-how-title": "Como o Sassy Funciona", - "home-section-how-description": "Configure seu serviço de assinatura em apenas três etapas simples.", - "home-section-how-first-title": "Cadastre-se", - "home-section-how-first-description": "Crie uma conta e configure seu perfil. Rápido e seguro com integração ao Supabase.", - "home-section-how-second-title": "Integre o Stripe", - "home-section-how-second-description": "Conecte o Stripe para faturamento sem interrupções e gerencie assinaturas mensais ou anuais.", - "home-section-how-third-title": "Gerencie Assinaturas", - "home-section-how-third-description": "Gerencie facilmente suas assinaturas, com atualizações em tempo real.", - "component-pricing-title": "Planos de Preços", - "component-pricing-description": "Preços simples e transparentes para atender às suas necessidades.", - "component-pricing-trial-title": "Você tem um período de teste gratuito por", - "component-pricing-trial-description": "Experimente nosso serviço sem compromisso.", - "component-pricing-selected-tag": "Plano Selecionado", - "component-pricing-popular-tag": "Mais Popular", - "component-pricing-button": "Assinar", - "component-pricing-button-subscribed": "Plano Atual", - "component-pricing-plan-starter-title": "Iniciante", - "component-pricing-plan-starter-description": "Ideal para indivíduos lançando seu primeiro Micro-SaaS.", - "component-pricing-plan-starter-feature-first": "✔ Acesso às funcionalidades principais", - "component-pricing-plan-starter-feature-second": "✔ 1 projeto", - "component-pricing-plan-starter-feature-third": " ", - "component-pricing-plan-starter-extra": "Tudo no Plano Gratuito, mais", - "component-pricing-plan-creator-title": "Criador", - "component-pricing-plan-creator-description": "Ótimo para criadores que desejam expandir seu alcance.", - "component-pricing-plan-creator-feature-first": "✔ Acesso às funcionalidades principais", - "component-pricing-plan-creator-feature-second": "✔ 3 projetos", - "component-pricing-plan-creator-feature-third": "✔ Suporte por e-mail", - "component-pricing-plan-creator-extra": "Tudo no Plano Iniciante, mais", - "component-pricing-plan-pro-title": "Pro", - "component-pricing-plan-pro-description": "Perfeito para equipes que estão escalando seu negócio Micro-SaaS.", - "component-pricing-plan-pro-feature-first": "✔ Acesso às funcionalidades principais", - "component-pricing-plan-pro-feature-second": "✔ Projetos ilimitados", - "component-pricing-plan-pro-feature-third": "✔ Suporte prioritário", - "component-pricing-plan-pro-extra": "Tudo no Plano Criador, mais", - "component-pricing-toggle-monthly": "Mensal", - "component-pricing-toggle-annual": "Anual", - "component-pricing-subscription-plans-free-name": "Grátis", - "component-pricing-subscription-plans-free-price-monthly": "${value}/mês", - "component-pricing-subscription-plans-free-price-annual": "${value}/ano", - "component-pricing-subscription-plans-free-description": "Ideal para indivíduos que estão testando nosso serviço.", - "component-pricing-subscription-plans-free-feature-1": "✔ Acesso aos recursos básicos", - "component-pricing-subscription-plans-free-feature-2": "✔ 1 projeto", - "component-pricing-subscription-plans-free-extra-features": " ", - "home-section-features-title": "Funcionalidades Modernas para o Seu SaaS", - "home-section-features-description": "Tudo o que você precisa para construir poderosas aplicações Micro-SaaS.", - "home-section-feature-oauth-title": "Autenticação OAuth", - "home-section-feature-oauth-description": "Integração perfeita com Google, Facebook e Twitter para oferecer uma experiência de login fluida.", - "home-section-feature-subscription-title": "Gerenciamento de Assinaturas", - "home-section-feature-subscription-description": "Gerencie assinaturas facilmente com as ferramentas robustas do Stripe e atualizações em tempo real.", - "home-section-feature-responsive-title": "Design Responsivo", - "home-section-feature-responsive-description": "Desenvolvido com TailwindCSS para uma experiência sem interrupções em todos os dispositivos.", - "home-section-testimonials-title": "O que nossos usuários dizem", - "home-section-testimonials-description": "Ouça diretamente de nossos clientes satisfeitos.", - "home-section-testimonial-1": "Sassy foi um divisor de águas para o nosso projeto Micro-SaaS. A integração com o Stripe e o Supabase é perfeita e nos ajudou a começar muito mais rápido!", - "home-section-testimonial-2": "Eu estava procurando uma forma simples e confiável de gerenciar assinaturas para meu produto SaaS. A integração com o Stripe do Sassy é fantástica e fácil de usar!", - "home-section-testimonial-3": "A autenticação integrada com o Supabase e o gateway de pagamento via Stripe nos economizou horas de tempo de desenvolvimento. Recomendo muito!", - "home-section-testimonial-4": "Como desenvolvedor solo, eu precisava de uma base sólida para o meu Micro-SaaS. O Sassy forneceu isso e muito mais! A flexibilidade do template é incrível.", - "home-section-testimonial-5": "O design responsivo e a arquitetura limpa do Sassy facilitaram a implantação. Consegui focar na construção de funcionalidades ao invés de lidar com o básico.", - "home-section-testimonial-6": "A facilidade de integrar autenticação OAuth com Google e Facebook tornou o processo de login dos usuários muito mais simples. Esta plataforma é essencial para qualquer desenvolvedor.", - "home-section-testimonial-7": "Com o sistema de gerenciamento de assinaturas já implementado, pude me concentrar nas funcionalidades principais do meu produto. O Sassy foi um grande poupador de tempo.", - "home-section-testimonial-8": "Os webhooks do Stripe e o gerenciamento de assinaturas funcionaram perfeitamente no meu projeto. Fiquei realmente impressionado com a integração de tudo.", - "home-section-faq-title": "Perguntas Frequentes", - "home-section-faq-description": "Respostas para ajudar você a entender como aproveitar este template para iniciar o desenvolvimento do seu Micro-SaaS.", - "home-section-faq-question-1": "Para que serve este template?", - "home-section-faq-answer-1": "Este é um template poderoso projetado para acelerar o desenvolvimento de aplicações Micro-SaaS. Ele fornece uma configuração de exemplo para gerenciamento de assinaturas, mas você pode facilmente adaptá-lo para qualquer produto SaaS.", - "home-section-faq-question-2": "Como começo a usar o template?", - "home-section-faq-answer-2": "Comece clonando o repositório, configurando seu ambiente com Next.js 15 e configurando o TypeScript. O template está pronto para desenvolvimento com integrações como Stripe e Supabase para gerenciar seus dados de usuário e pagamentos.", - "home-section-faq-question-3": "Este template é focado em assinaturas?", - "home-section-faq-answer-3": "Assinaturas são apenas um exemplo de funcionalidade fornecida neste template. Você pode personalizá-lo para atender às necessidades do seu Micro-SaaS, seja para cobrança, autenticação ou qualquer outro aspecto do seu serviço.", - "home-section-faq-question-4": "Quais opções de pagamento estão incluídas?", - "home-section-faq-answer-4": "O template vem com um exemplo de gerenciamento de assinaturas mensais e anuais usando o Stripe. Você pode modificar o fluxo de pagamento para se ajustar ao modelo de negócios do seu aplicativo.", - "home-section-faq-question-5": "Posso integrar outros serviços neste template?", - "home-section-faq-answer-5": "Sim, o template foi desenvolvido para ser flexível. Ele suporta integrações com Supabase para gerenciamento de usuários, Stripe para pagamentos e OAuth para autenticação com serviços como Google, Facebook e Twitter.", - "home-section-faq-question-6": "Este template está pronto para produção?", - "home-section-faq-answer-6": "Embora o template forneça uma base sólida, ele é destinado ao desenvolvimento e personalização. Você precisará ajustá-lo para se adequar ao seu ambiente de produção e aos requisitos de negócios.", - "home-section-faq-question-7": "Como personalizo o template?", - "home-section-faq-answer-7": "Você pode facilmente modificar o código para atender ao seu caso específico. A arquitetura é modular, com componentes separados para autenticação, pagamentos e gerenciamento de usuários, permitindo uma personalização fácil.", - "home-footer-title": "Pronto para começar?", - "home-footer-join": "Junte-se ao Sassy Hoje", - "home-footer-terms": "Termos & Privacidade", - "home-footer-github": "GitHub", - "home-footer-copyright": "© 2025 Sassy. Todos os direitos reservados.", - "terms-privacy-title": "Termos e Política de Privacidade", - "terms-privacy-terms-of-use": "Termos de Uso", - "terms-privacy-terms-of-use-description": "Bem-vindo ao nosso serviço. Ao usar nosso site, você concorda em cumprir os seguintes termos e condições.", - "terms-privacy-privacy-policy": "Política de Privacidade", - "terms-privacy-privacy-policy-description": "Valorizamos sua privacidade e estamos comprometidos em proteger suas informações pessoais. Esta Política de Privacidade explica como coletamos, usamos e protegemos seus dados.", - "terms-privacy-data-collection": "Coleta de Dados", - "terms-privacy-data-collection-description": "Podemos coletar informações pessoais, como nome, endereço de e-mail e dados de uso, para melhorar nosso serviço e oferecer uma melhor experiência ao usuário.", - "terms-privacy-data-security": "Segurança dos Dados", - "terms-privacy-data-security-description": "Implementamos uma variedade de medidas de segurança para garantir a proteção dos seus dados pessoais. No entanto, nenhum método de transmissão pela internet é completamente seguro, portanto, não podemos garantir segurança absoluta.", - "terms-privacy-changes-policy": "Alterações nesta Política", - "terms-privacy-changes-policy-description": "Podemos atualizar esta Política de Termos e Privacidade de tempos em tempos. Quaisquer alterações serão publicadas nesta página com a data atualizada.", - "terms-privacy-contact-us": "Entre em Contato", - "terms-privacy-contact-us-description": "Se você tiver dúvidas sobre estes termos ou nossa política de privacidade, entre em contato conosco.", - "signIn-back-to-home": "Voltar para a Página Inicial", - "signIn-title": "Entrar", - "signIn-subtitle": "Informe seus dados para acessar sua conta", - "signIn-invalid-email": "Formato de e-mail inválido.", - "signIn-invalid-password": "A senha deve ter pelo menos 6 caracteres.", - "signIn-invalid-credentials": "E-mail ou senha inválidos.", - "signIn-general-error": "Algo deu errado. Por favor, tente novamente.", - "signIn-email": "E-mail", - "signIn-password": "Senha", - "signIn-forgot-password": "Esqueceu sua senha?", - "signIn-button": "Entrar", - "signUp-back-to-login": "Voltar para Login", - "signUp-title": "Cadastrar-se", - "signUp-subtitle": "Crie sua conta para começar", - "signUp-invalid-email": "Formato de e-mail inválido.", - "signUp-invalid-password": "A senha deve ter pelo menos 6 caracteres.", - "signUp-passwords-do-not-match": "As senhas não coincidem.", - "signUp-terms-not-accepted": "Você deve aceitar os termos e condições.", - "signUp-general-error": "Algo deu errado. Por favor, tente novamente.", - "signUp-email": "E-mail", - "signUp-password": "Senha", - "signUp-confirm-password": "Confirmar Senha", - "signUp-terms-label": "Eu aceito os Termos e a Política de Privacidade", - "signUp-button": "Cadastrar", - "component-footer-auth-dont-have-account": "Não tem uma conta?", - "component-footer-auth-create-account": "Crie uma conta", - "component-footer-auth-already-have-account": "Já tem uma conta?", - "component-footer-auth-go-back-to-login": "Voltar para o login", - "forgot-password-title": "Esqueceu a Senha", - "forgot-password-description": "Digite seu e-mail para receber o link de redefinição de senha.", - "forgot-password-email-label": "E-mail", - "forgot-password-email-placeholder": "Digite seu e-mail", - "forgot-password-button-text": "Enviar Link de Redefinição", - "forgot-password-invalid-email": "Formato de e-mail inválido.", - "forgot-password-general-error": "Algo deu errado. Por favor, tente novamente.", - "forgot-password-check-inbox-title": "Verifique Sua Caixa de Entrada", - "forgot-password-check-inbox-description": "Um link para redefinir a senha foi enviado para seu e-mail. Por favor, verifique sua caixa de entrada.", - "forgot-password-back-to-login": "Voltar para Login", - "confirm-signup-error-token-missing": "Token inválido ou ausente.", - "confirm-signup-error-failed": "Falha na confirmação de e-mail. Por favor, tente novamente.", - "confirm-signup-error-unexpected": "Ocorreu um erro inesperado.", - "confirm-signup-back-to-login": "Voltar para Login", - "confirm-signup-email-confirmed": "E-mail Confirmado", - "confirm-signup-success-message": "Seu e-mail foi confirmado com sucesso.", - "confirm-signup-go-to-dashboard": "Ir para o Painel", - "confirm-signup-oauth-success": "OAuth Bem-sucedido", - "confirm-signup-oauth-success-message": "Seu provedor foi confirmado com sucesso.", - "new-password-error-token-missing": "Token inválido ou ausente.", - "new-password-error-password-valid": "A senha deve ter pelo menos 6 caracteres.", - "new-password-error-password-match": "As senhas não coincidem.", - "new-password-error-general": "Falha ao alterar a senha. Por favor, tente novamente.", - "new-password-error-unexpected": "Algo deu errado. Por favor, tente novamente.", - "new-password-back-to-login": "Voltar para Login", - "new-password-success-message": "Sua senha foi alterada com sucesso!", - "new-password-create-title": "Criar uma Nova Senha", - "new-password-subtitle": "Defina sua nova senha para continuar", - "new-password-password-label": "Senha", - "new-password-confirm-password-label": "Confirmar Senha", - "new-password-submit-button": "Alterar Senha", - "payment-status-back-to-billing": "Voltar para Faturamento", - "payment-status-success-title": "Compra Confirmada", - "payment-status-success-message": "Sua compra foi confirmada com sucesso.", - "payment-status-back-to-settings": "Voltar para Configurações", - "payment-status-cancel-title": "Compra Cancelada", - "payment-status-cancel-message": "Sua compra foi cancelada.", - "payment-status-back-to-home": "Voltar para Início", - "payment-status-unknown-title": "Status Desconhecido", - "payment-status-unknown-message": "O status da sua compra é desconhecido.", - "component-client-dashboard-modal-title": "Aviso Importante", - "component-client-dashboard-modal-text": "Para testar nosso aplicativo, você precisa iniciar um teste gratuito. Nenhuma funcionalidade estará disponível após o período de teste.", - "component-client-dashboard-modal-button": "Iniciar Teste Gratuito", - "component-feature-menu-tab-free": "Grátis", - "component-feature-menu-tab-starter": "Starter/Criador", - "component-feature-menu-tab-pro": "Pro", - "component-dashboard-navbar-title": "Painel", - "component-my-account-button": "Minha Conta", - "component-my-account-profile": "Perfil", - "component-my-account-subscription": "Assinatura", - "component-my-account-terms-privacy": "Termos e Privacidade", - "component-my-account-sign-out": "Sair", - "settings-name-label": "Nome", - "settings-email-label": "E-mail", - "subscription-current-plan": "Plano Atual", - "subscription-current-plan-description": "Você está no plano {plan}.", - "component-manage-billing-title": "Detalhes da Assinatura", - "component-manage-billing-button": "Gerenciar informações de faturamento", - "component-manage-billing-error": "Não foi possível redirecionar para o portal de faturamento.", - "failed-to-create-billing-portal": "Falha ao criar a sessão do portal de faturamento.", - "component-settings-options-change-password": "Alterar Senha", - "component-settings-options-current-plan": "Plano Atual", - "component-settings-options-manage-subscription": "Gerenciar Assinatura", - "component-settings-options-password-change-request-sent": "Solicitação de Alteração de Senha Enviada!", - "component-settings-options-password-change-description": "Por favor, verifique sua caixa de entrada para um e-mail para concluir o processo.", - "component-settings-options-password-change-failed": "Falha na Alteração de Senha", - "component-settings-options-password-change-error": "Ocorreu um erro ao processar sua solicitação. Por favor, tente novamente mais tarde." -} \ No newline at end of file + "title": "Sassy - template micro-saas poderoso", + "pages": { + "home": { + "sections": { + "hero": { + "title": "Bem-vindo ao Criador de Micro-SaaS", + "description": "Dê poder à sua visão com ferramentas para criar soluções Micro-SaaS sem esforço.", + "button": "Começar" + }, + "how": { + "title": "Como o Sassy Funciona", + "description": "Configure seu serviço de assinatura em apenas três passos fáceis.", + "options": { + "first": { + "title": "Cadastre-se", + "description": "Crie uma conta e configure seu perfil. Seguro e rápido com integração Supabase." + }, + "second": { + "title": "Integre o Stripe", + "description": "Conecte o Stripe para cobrança sem complicações e gerencie assinaturas mensais ou anuais." + }, + "third": { + "title": "Gerencie Assinaturas", + "description": "Conecte e gerencie facilmente suas assinaturas, com atualizações em tempo real." + } + } + }, + "features": { + "title": "Recursos Modernos para Seu SaaS", + "description": "Tudo o que você precisa para construir aplicações Micro-SaaS poderosas.", + "oauth": { + "title": "Autenticação OAuth", + "description": "Integre facilmente com Google, Facebook e Twitter para uma experiência de login suave." + }, + "subscription": { + "title": "Gestão de Assinaturas", + "description": "Gerencie assinaturas facilmente com as ferramentas robustas do Stripe e atualizações em tempo real." + }, + "responsive": { + "title": "Design Responsivo", + "description": "Desenvolvido com TailwindCSS para uma experiência perfeita em todos os dispositivos." + } + }, + "testimonials": { + "title": "O que Nossos Usuários Dizem", + "description": "Ouça diretamente de nossos clientes satisfeitos.", + "list": { + "1": "O Sassy foi um divisor de águas para nosso projeto Micro-SaaS. A integração com Stripe e Supabase é perfeita e nos ajudou a começar muito mais rápido!", + "2": "Eu procurava uma forma simples e confiável de gerenciar assinaturas para meu produto SaaS. A integração do Sassy com o Stripe é fantástica e fácil de usar!", + "3": "A autenticação integrada com Supabase e o gateway de pagamento via Stripe nos economizou horas de desenvolvimento. Recomendo muito!", + "4": "Como desenvolvedor solo, eu precisava de uma base sólida para meu Micro-SaaS. O Sassy forneceu isso e muito mais! A flexibilidade do template é incrível.", + "5": "O design responsivo do Sassy e a arquitetura limpa facilitaram o deploy. Pude focar em construir recursos ao invés de lidar com o básico.", + "6": "A facilidade de integrar autenticação OAuth com Google e Facebook tornou o login dos usuários muito simples. Esta plataforma é indispensável para qualquer desenvolvedor.", + "7": "Com o sistema de gestão de assinaturas já pronto, pude me concentrar nos recursos principais do meu produto. O Sassy economizou muito do meu tempo.", + "8": "Os webhooks do Stripe e o gerenciamento de assinaturas funcionaram perfeitamente no meu projeto. Estou realmente impressionado com a integração de tudo." + } + }, + "faq": { + "title": "Perguntas Frequentes", + "description": "Respostas para te ajudar a entender como aproveitar este template para iniciar seu desenvolvimento Micro-SaaS.", + "items": { + "1": { + "question": "Para que serve este template?", + "answer": "Este é um template poderoso projetado para acelerar o desenvolvimento de aplicações Micro-SaaS. Ele fornece um exemplo de configuração para gestão de assinaturas, mas você pode adaptá-lo facilmente para qualquer produto SaaS." + }, + "2": { + "question": "Como começo a usar o template?", + "answer": "Comece clonando o repositório, configurando seu ambiente com Next.js 15 e ajustando o TypeScript. O template está pronto para desenvolvimento com integrações como Stripe e Supabase para gerenciar dados de usuários e pagamentos." + }, + "3": { + "question": "Este template é focado em assinaturas?", + "answer": "Assinaturas são apenas um exemplo de funcionalidade fornecida neste template. Você pode personalizá-lo para atender às necessidades do seu Micro-SaaS, seja para cobrança, autenticação ou qualquer outro aspecto do seu serviço." + }, + "4": { + "question": "Quais opções de pagamento estão incluídas?", + "answer": "O template vem com um exemplo de gestão de assinaturas mensais e anuais usando Stripe. Você pode modificar o fluxo de pagamento para se adequar ao modelo de negócio do seu app." + }, + "5": { + "question": "Posso integrar outros serviços neste template?", + "answer": "Sim, o template foi construído para ser flexível. Ele suporta integrações com Supabase para gestão de usuários, Stripe para pagamentos e OAuth para autenticação com serviços como Google, Facebook e Twitter." + }, + "6": { + "question": "Este template está pronto para produção?", + "answer": "Embora este template forneça uma base sólida, ele é destinado ao desenvolvimento e customização. Você precisará ajustá-lo para o seu ambiente de produção e requisitos de negócio." + }, + "7": { + "question": "Como personalizo o template?", + "answer": "Você pode modificar facilmente o código para o seu caso de uso específico. A arquitetura é modular, com componentes separados para autenticação, pagamentos e gestão de usuários, permitindo fácil personalização." + } + } + } + }, + "footer": { + "title": "Pronto para Começar?", + "join": "Junte-se ao Sassy Hoje", + "terms": "Termos & Privacidade", + "github": "GitHub", + "copyright": "© 2025 Sassy. Todos os direitos reservados." + } + }, + "terms-and-privacy": { + "title": "Termos e Política de Privacidade", + "terms": { + "title": "Termos de Uso", + "description": "Bem-vindo ao nosso serviço. Ao usar nosso site, você concorda em cumprir os seguintes termos e condições." + }, + "policy": { + "title": "Política de Privacidade", + "description": "Valorizamos sua privacidade e estamos comprometidos em proteger suas informações pessoais. Esta Política de Privacidade explica como coletamos, usamos e protegemos seus dados." + }, + "collection": { + "title": "Coleta de Dados", + "description": "Podemos coletar informações pessoais como seu nome, e-mail e dados de uso para melhorar nosso serviço e proporcionar uma melhor experiência ao usuário." + }, + "security": { + "title": "Segurança dos Dados", + "description": "Implementamos diversas medidas de segurança para garantir a proteção dos seus dados pessoais. No entanto, nenhum método de transmissão pela internet é totalmente seguro, então não podemos garantir segurança absoluta." + }, + "changes": { + "title": "Alterações nesta Política", + "description": "Podemos atualizar estes Termos e Política de Privacidade periodicamente. Quaisquer alterações serão publicadas nesta página com a data atualizada." + }, + "contact": { + "title": "Fale Conosco", + "description": "Se você tiver dúvidas sobre estes termos ou nossa política de privacidade, entre em contato conosco." + } + }, + "signin": { + "title": "Entrar", + "description": "Digite seus dados para acessar sua conta", + "inputs": { + "email": "E-mail", + "password": "Senha" + }, + "actions": { + "back": "Voltar para o Início", + "forgot-password": "Esqueceu sua senha?", + "submit": "Entrar" + }, + "errors": { + "email": "Formato de e-mail inválido.", + "password": "A senha deve ter pelo menos 6 caracteres.", + "credentials": "E-mail ou senha inválidos.", + "error": "Algo deu errado. Por favor, tente novamente." + } + }, + "signup": { + "back-to-login": "Voltar para o Login", + "title": "Cadastrar", + "description": "Crie sua conta para começar", + "inputs": { + "email": "E-mail", + "password": "Senha", + "confirm-password": "Confirmar Senha", + "terms": "Eu aceito os Termos e a Política de Privacidade" + }, + "actions": { + "submit": "Cadastrar", + "success": "Por favor, verifique seu e-mail para completar o processo." + }, + "errors": { + "email": "Formato de e-mail inválido.", + "password": "A senha deve ter pelo menos 6 caracteres.", + "confirm-password": "As senhas não coincidem.", + "terms": "Você deve aceitar os termos e condições.", + "error": "Algo deu errado. Por favor, tente novamente." + } + }, + "forgot-password": { + "title": "Esqueceu a Senha", + "description": "Digite seu e-mail para receber um link de redefinição de senha.", + "inputs": { + "email": { + "label": "E-mail", + "placeholder": "Digite seu e-mail" + } + }, + "actions": { + "submit": "Enviar Link de Redefinição", + "back": "Voltar para o Login", + "inbox": { + "title": "Verifique sua Caixa de Entrada", + "description": "Um link de redefinição de senha foi enviado para seu e-mail. Por favor, verifique sua caixa de entrada." + } + }, + "errors": { + "email": "Formato de e-mail inválido.", + "error": "Algo deu errado. Por favor, tente novamente." + } + }, + "confirm-signup": { + "messages": { + "email": { + "title": "E-mail Confirmado", + "description": "Seu e-mail foi confirmado com sucesso." + }, + "oauth": { + "title": "OAuth com Sucesso", + "description": "Seu provedor foi registrado com sucesso." + } + }, + "actions": { + "dashboard": "Ir para o Painel" + }, + "errors": { + "missing-token": "Token inválido ou ausente.", + "failed": "Falha na confirmação do e-mail. Por favor, tente novamente.", + "unexpected": "Ocorreu um erro inesperado." + } + }, + "new-password": { + "title": "Criar Nova Senha", + "description": "Defina sua nova senha para continuar", + "inputs": { + "password": "Senha", + "confirm-password": "Confirmar Senha" + }, + "actions": { + "submit": "Alterar Senha", + "dashboard": "Voltar para o Painel", + "success": "Sua senha foi alterada com sucesso!" + }, + "errors": { + "token-missing": "Token inválido ou ausente.", + "password-valid": "A senha deve ter pelo menos 6 caracteres.", + "password-match": "As senhas não coincidem.", + "general": "Falha ao alterar a senha. Por favor, tente novamente.", + "unexpected": "Algo deu errado. Por favor, tente novamente." + } + }, + "payments": { + "status": { + "success": { + "title": "Compra Confirmada", + "description": "Sua compra foi confirmada com sucesso." + }, + "cancel": { + "title": "Compra Cancelada", + "description": "Sua compra foi cancelada." + }, + "unknown": { + "title": "Status Desconhecido", + "description": "O status da sua compra é desconhecido." + } + }, + "actions": { + "billing": "Voltar para Cobrança", + "settings": "Voltar para Configurações", + "home": "Voltar para o Início" + } + }, + "subscription": { + "plan": { + "title": "Plano Atual", + "description": "Você está atualmente no plano {plan}." + } + }, + "settings": { + "name": "Nome", + "email": "E-mail" + } + }, + "components": { + "navbar": { + "pricing": "Preços", + "features": "Recursos", + "faq": "FAQ", + "signin": "Entrar", + "try": "Teste Grátis", + "dashboard": "Painel" + }, + "pricing": { + "title": "Planos de Preços", + "description": "Preços simples e transparentes para atender às suas necessidades.", + "toggle": { + "monthly": "Mensal", + "annual": "Anual" + }, + "trial": { + "title": "Você tem um teste grátis por", + "description": "Experimente nosso serviço sem compromisso." + }, + "tag": { + "selected": "Plano Selecionado", + "popular": "Mais Popular" + }, + "button": { + "default": "Assinar", + "subscribed": "Plano Atual" + }, + "plans": { + "prices": { + "monthly": "R${value}/mês", + "annual": "R${value}/ano" + }, + "free": { + "title": "Grátis", + "description": "Ideal para quem está testando nosso serviço.", + "features": { + "first": "✔ Acesso a recursos básicos", + "second": "✔ 1 projeto", + "third": " " + }, + "extra": "" + }, + "starter": { + "title": "Starter", + "description": "Ideal para quem está lançando o primeiro Micro-SaaS.", + "features": { + "first": "✔ Acesso a recursos principais", + "second": "✔ 1 projeto", + "third": " " + }, + "extra": "Tudo do Grátis, e mais" + }, + "creator": { + "title": "Creator", + "description": "Ótimo para criadores que querem expandir seu alcance.", + "features": { + "first": "✔ Acesso a recursos principais", + "second": "✔ 3 projetos", + "third": "✔ Suporte por e-mail" + }, + "extra": "Tudo do Starter, e mais" + }, + "pro": { + "title": "Pro", + "description": "Perfeito para equipes que estão escalando seu Micro-SaaS.", + "features": { + "first": "✔ Acesso a recursos principais", + "second": "✔ Projetos ilimitados", + "third": "✔ Suporte prioritário" + }, + "extra": "Tudo do Creator, e mais" + } + } + }, + "footer-auth": { + "dont-have-account": "Não tem uma conta?", + "create-account": "Criar uma conta", + "already-have-account": "Já tem uma conta?", + "go-back-to-login": "Voltar para o login" + }, + "dashboard": { + "modal": { + "title": "Aviso Importante", + "description": "Para testar nosso app, você precisa iniciar um teste grátis. Nenhum recurso estará disponível após o período de teste.", + "actions": { + "proceed": "Iniciar Teste Grátis" + } + }, + "navbar": { + "title": "Painel", + "my-account": { + "options": { + "button": "Minha Conta", + "profile": "Perfil", + "subscription": "Assinatura", + "terms-privacy": "Termos e Privacidade", + "sign-out": "Sair" + } + } + }, + "menu": { + "options": { + "free": "Grátis", + "starter": "Starter/Creator", + "pro": "Pro" + } + } + }, + "manage-billing": { + "title": "Detalhes da Assinatura", + "actions": { + "proceed": "Gerenciar informações de cobrança" + } + }, + "settings-options": { + "plan": "Plano Atual", + "subscription": "Gerenciar Assinatura", + "actions": { + "change-password": "Alterar Senha" + }, + "toast": { + "success": { + "title": "Solicitação de Troca de Senha Enviada!", + "description": "Por favor, verifique seu e-mail para completar o processo." + }, + "error": { + "title": "Falha na Troca de Senha", + "description": "Ocorreu um erro ao processar sua solicitação. Por favor, tente novamente mais tarde." + } + } + } + } +} diff --git a/src/app/(domains)/(auth)/confirm-signup/page.tsx b/src/app/(domains)/(auth)/confirm-signup/page.tsx index d843011..b4ad755 100644 --- a/src/app/(domains)/(auth)/confirm-signup/page.tsx +++ b/src/app/(domains)/(auth)/confirm-signup/page.tsx @@ -37,7 +37,7 @@ function confirmationReducer(state: State, action: ConfirmSignupAction): State { } export default function ConfirmSignUp() { - const { translate } = useI18n(); + const { translate } = useI18n("pages.confirm-signup"); const [state, dispatch] = useReducer(confirmationReducer, { isLoading: true, error: null, @@ -55,7 +55,7 @@ export default function ConfirmSignUp() { } else if (!token && oauth) { dispatch({ type: "CONFIRMATION_OAUTH" }); } else { - dispatch({ type: "CONFIRMATION_FAILURE", error: translate("confirm-signup-error-token-missing") }); + dispatch({ type: "CONFIRMATION_FAILURE", error: translate("errors.missing-token") }); } }, []); @@ -68,13 +68,13 @@ export default function ConfirmSignUp() { if (response?.id) { dispatch({ type: "CONFIRMATION_SUCCESS" }); } else { - throw new Error(translate("confirm-signup-error-failed")); + throw new Error(translate("errors.failed")); } } catch (error: unknown) { if (error instanceof Error) { dispatch({ type: "CONFIRMATION_FAILURE", error: error.message }); } else { - dispatch({ type: "CONFIRMATION_FAILURE", error: translate("confirm-signup-error-unexpected") }); + dispatch({ type: "CONFIRMATION_FAILURE", error: translate("errors.unexpected") }); } } }; @@ -106,9 +106,9 @@ const ConfirmationMessage = () => { const { translate } = useI18n(); return ( <> - -

{translate("confirm-signup-email-confirmed")}

-

{translate("confirm-signup-success-message")}

+ +

{translate("messages.email.title")}

+

{translate("messages.email.description")}

); }; @@ -117,9 +117,9 @@ const ConfirmationOAuthMessage = () => { const { translate } = useI18n(); return ( <> - -

{translate("confirm-signup-oauth-success")}

-

{translate("confirm-signup-oauth-success-message")}

+ +

{translate("messages.oauth.title")}

+

{translate("messages.oauth.description")}

); }; diff --git a/src/app/(domains)/(auth)/forgot-password/page.tsx b/src/app/(domains)/(auth)/forgot-password/page.tsx index ad27720..58051d1 100644 --- a/src/app/(domains)/(auth)/forgot-password/page.tsx +++ b/src/app/(domains)/(auth)/forgot-password/page.tsx @@ -46,7 +46,7 @@ function reducer(state: ForgotPasswordStateType, action: ForgotPasswordAction) { } export default function ForgotPassword() { - const { translate } = useI18n(); + const { translate } = useI18n("pages.forgot-password"); const [state, dispatch] = useReducer(reducer, initialState); async function handleForgotPassword() { @@ -60,7 +60,7 @@ export default function ForgotPassword() { dispatch({ type: "SET_ERRORS", payload: { - email: translate("forgot-password-invalid-email"), + email: translate("errors.email"), }, }); throw new Error("Validation Error"); @@ -72,11 +72,11 @@ export default function ForgotPassword() { if (response) { dispatch({ type: "SET_SUCCESS", payload: true }); } else { - dispatch({ type: "SET_ERRORS", payload: { general: translate("forgot-password-general-error") } }); + dispatch({ type: "SET_ERRORS", payload: { general: translate("errors.error") } }); } } catch (err) { if (err instanceof Error && err.message !== "Validation Error") { - dispatch({ type: "SET_ERRORS", payload: { general: translate("forgot-password-general-error") } }); + dispatch({ type: "SET_ERRORS", payload: { general: translate("errors.error") } }); } } finally { dispatch({ type: "SET_LOADING", payload: false }); @@ -86,18 +86,18 @@ export default function ForgotPassword() { if (state.isSuccess) { return ( <> - -

{translate("forgot-password-check-inbox-title")}

-

{translate("forgot-password-check-inbox-description")}

+ +

{translate("actions.inbox.title")}

+

{translate("actions.inbox.description")}

); } return ( <> - -

{translate("forgot-password-title")}

-

{translate("forgot-password-description")}

+ +

{translate("title")}

+

{translate("description")}

{ @@ -108,8 +108,8 @@ export default function ForgotPassword() { dispatch({ type: "SET_INPUT_VALUE", payload: { email: e.target.value } }) @@ -125,7 +125,7 @@ export default function ForgotPassword() { )} - {translate("forgot-password-button-text")} + {translate("actions.submit")} diff --git a/src/app/(domains)/(auth)/new-password/page.tsx b/src/app/(domains)/(auth)/new-password/page.tsx index 7684d11..018feca 100644 --- a/src/app/(domains)/(auth)/new-password/page.tsx +++ b/src/app/(domains)/(auth)/new-password/page.tsx @@ -8,173 +8,229 @@ import BackLinkComponent from "@/components/BackLink"; import ButtonComponent from "@/components/Button"; import InputComponent from "@/components/Input"; import PasswordStrengthIndicator from "@/components/PasswordStrength"; -import { useI18n } from '@/hooks/useI18n'; +import { useI18n } from "@/hooks/useI18n"; import { supabase } from "@/libs/supabase/client"; import SupabaseService from "@/services/supabase"; const initialState = { - isLoading: false, - isSuccess: false, - tokenValue: "", - tokenError: "", - inputValue: { - password: "", - confirmPassword: "", - }, - errors: { - password: "", - confirmPassword: "", - general: "", - }, + isLoading: false, + isSuccess: false, + tokenValue: "", + tokenError: "", + inputValue: { + password: "", + confirmPassword: "", + }, + errors: { + password: "", + confirmPassword: "", + general: "", + }, }; export type NewPasswordStateType = typeof initialState; export type NewPasswordAction = - | { type: "SET_LOADING"; payload: boolean } - | { type: "SET_INPUT_VALUE"; payload: { password?: string; confirmPassword?: string } } - | { type: "SET_ERRORS"; payload: { password?: string; confirmPassword?: string; general?: string } } - | { type: "SET_PASSWORD_CHANGED"; payload: boolean } - | { type: "SET_TOKEN_VALUE"; payload: string } - | { type: "SET_TOKEN_ERROR"; payload: string }; + | { type: "SET_LOADING"; payload: boolean } + | { + type: "SET_INPUT_VALUE"; + payload: { password?: string; confirmPassword?: string }; + } + | { + type: "SET_ERRORS"; + payload: { + password?: string; + confirmPassword?: string; + general?: string; + }; + } + | { type: "SET_PASSWORD_CHANGED"; payload: boolean } + | { type: "SET_TOKEN_VALUE"; payload: string } + | { type: "SET_TOKEN_ERROR"; payload: string }; function reducer(state: NewPasswordStateType, action: NewPasswordAction) { - switch (action.type) { - case "SET_LOADING": - return { ...state, isLoading: action.payload }; - case "SET_INPUT_VALUE": - return { ...state, inputValue: { ...state.inputValue, ...action.payload } }; - case "SET_ERRORS": - return { ...state, errors: { ...state.errors, ...action.payload } }; - case "SET_PASSWORD_CHANGED": - return { ...state, isSuccess: action.payload }; - case "SET_TOKEN_VALUE": - return { ...state, tokenValue: action.payload }; - case "SET_TOKEN_ERROR": - return { ...state, tokenError: action.payload }; - default: - return state; - } + switch (action.type) { + case "SET_LOADING": + return { ...state, isLoading: action.payload }; + case "SET_INPUT_VALUE": + return { + ...state, + inputValue: { ...state.inputValue, ...action.payload }, + }; + case "SET_ERRORS": + return { ...state, errors: { ...state.errors, ...action.payload } }; + case "SET_PASSWORD_CHANGED": + return { ...state, isSuccess: action.payload }; + case "SET_TOKEN_VALUE": + return { ...state, tokenValue: action.payload }; + case "SET_TOKEN_ERROR": + return { ...state, tokenError: action.payload }; + default: + return state; + } } export default function NewPassword() { - const { translate } = useI18n(); - const [state, dispatch] = useReducer(reducer, initialState); - const searchParams = useSearchParams(); - - useEffect(() => { - const token = searchParams?.get("code"); - if (!token) { - dispatch({ type: "SET_TOKEN_ERROR", payload: translate("new-password-error-token-missing") }); - } else { - dispatch({ type: "SET_TOKEN_VALUE", payload: token }); - } - }, [searchParams]); - - async function handleNewPassword() { - try { - dispatch({ type: "SET_LOADING", payload: true }); - dispatch({ type: "SET_ERRORS", payload: { password: "", confirmPassword: "", general: "" } }); - - const isPasswordValid = state.inputValue.password.length >= 6; - const isPasswordsMatch = state.inputValue.password === state.inputValue.confirmPassword; - - if (!isPasswordValid || !isPasswordsMatch) { - dispatch({ - type: "SET_ERRORS", - payload: { - password: isPasswordValid ? "" : translate("new-password-error-password-valid"), - confirmPassword: isPasswordsMatch ? "" : translate("new-password-error-password-match"), - }, - }); - throw new Error("Validation Error"); - } - - const SupabaseServiceInstance = new SupabaseService(supabase); - - const response = await SupabaseServiceInstance.newPassword(state.inputValue.password); - - if (response) { - dispatch({ type: "SET_PASSWORD_CHANGED", payload: true }); - } else { - dispatch({ type: "SET_ERRORS", payload: { general: translate("new-password-error-general") } }); - } - } catch (err) { - console.error("Error", err); - if (err instanceof Error && err.message !== "Validation Error") { - dispatch({ type: "SET_ERRORS", payload: { general: translate("new-password-error-unexpected") } }); - } - } finally { - dispatch({ type: "SET_LOADING", payload: false }); - } + const { translate } = useI18n("pages.new-password"); + const [state, dispatch] = useReducer(reducer, initialState); + const searchParams = useSearchParams(); + + useEffect(() => { + const token = searchParams?.get("code"); + if (!token) { + dispatch({ + type: "SET_TOKEN_ERROR", + payload: translate("errors.token-missing"), + }); + } else { + dispatch({ type: "SET_TOKEN_VALUE", payload: token }); } - - return ( + }, [searchParams, translate]); + + async function handleNewPassword() { + try { + dispatch({ type: "SET_LOADING", payload: true }); + dispatch({ + type: "SET_ERRORS", + payload: { password: "", confirmPassword: "", general: "" }, + }); + + const isPasswordValid = state.inputValue.password.length >= 6; + const isPasswordsMatch = + state.inputValue.password === state.inputValue.confirmPassword; + + if (!isPasswordValid || !isPasswordsMatch) { + dispatch({ + type: "SET_ERRORS", + payload: { + password: isPasswordValid ? "" : translate("errors.password-valid"), + confirmPassword: isPasswordsMatch + ? "" + : translate("errors.password-match"), + }, + }); + throw new Error("Validation Error"); + } + + const SupabaseServiceInstance = new SupabaseService(supabase); + + const response = await SupabaseServiceInstance.newPassword( + state.inputValue.password + ); + + if (response) { + dispatch({ type: "SET_PASSWORD_CHANGED", payload: true }); + } else { + dispatch({ + type: "SET_ERRORS", + payload: { general: translate("errors.general") }, + }); + } + } catch (err) { + console.error("Error", err); + if (err instanceof Error && err.message !== "Validation Error") { + dispatch({ + type: "SET_ERRORS", + payload: { general: translate("errors.unexpected") }, + }); + } + } finally { + dispatch({ type: "SET_LOADING", payload: false }); + } + } + + return ( + <> + {state.tokenError ? ( +
+

{state.tokenError}

+
+ ) : state.isSuccess ? ( + <> + +
+

+ {translate("actions.success")} +

+
+ + ) : ( <> - {state.tokenError ? ( -
-

{state.tokenError}

-
- ) : state.isSuccess ? ( - <> - -
-

{translate("new-password-success-message")}

-
- - ) : ( - <> -

{translate("new-password-create-title")}

-

{translate("new-password-subtitle")}

-
{ - e.preventDefault(); - handleNewPassword(); - }}> -
- - dispatch({ type: "SET_INPUT_VALUE", payload: { password: e.target.value } }) - } - /> - - {state.errors.password && ( -

{state.errors.password}

- )} -
- -
- - dispatch({ type: "SET_INPUT_VALUE", payload: { confirmPassword: e.target.value } }) - } - /> - {state.errors.confirmPassword && ( -

{state.errors.confirmPassword}

- )} -
- - {state.errors.general && ( -

{state.errors.general}

- )} - - - {translate("new-password-submit-button")} - -
- +

+ {translate("title")} +

+

+ {translate("description")} +

+
{ + e.preventDefault(); + handleNewPassword(); + }} + > +
+ + dispatch({ + type: "SET_INPUT_VALUE", + payload: { password: e.target.value }, + }) + } + /> + + {state.errors.password && ( +

+ {state.errors.password} +

+ )} +
+ +
+ + dispatch({ + type: "SET_INPUT_VALUE", + payload: { confirmPassword: e.target.value }, + }) + } + /> + {state.errors.confirmPassword && ( +

+ {state.errors.confirmPassword} +

+ )} +
+ + {state.errors.general && ( +

+ {state.errors.general} +

)} + + + {translate("actions.submit")} + +
- ); + )} + + ); } diff --git a/src/app/(domains)/(auth)/signin/page.tsx b/src/app/(domains)/(auth)/signin/page.tsx index 7426cf3..d2489c4 100644 --- a/src/app/(domains)/(auth)/signin/page.tsx +++ b/src/app/(domains)/(auth)/signin/page.tsx @@ -51,7 +51,7 @@ function reducer(state: SignInStateType, action: SignInAction) { export default function SignIn() { const [state, dispatch] = useReducer(reducer, initialState); const router = useRouter(); - const { translate } = useI18n(); + const { translate } = useI18n("pages.signin"); async function handleSignIn() { try { @@ -65,8 +65,8 @@ export default function SignIn() { dispatch({ type: "SET_ERRORS", payload: { - email: isValidEmailResponse ? "" : translate("signIn-invalid-email"), - password: isPasswordValid ? "" : translate("signIn-invalid-password"), + email: isValidEmailResponse ? "" : translate("errors.email"), + password: isPasswordValid ? "" : translate("errors.password"), }, }); throw new Error("Validation Error"); @@ -78,12 +78,12 @@ export default function SignIn() { if (response?.id) { router.push(ROUTES.dashboard); } else { - dispatch({ type: "SET_ERRORS", payload: { general: translate("signIn-invalid-credentials") } }); + dispatch({ type: "SET_ERRORS", payload: { general: translate("errors.credentials") } }); } } catch (err) { console.error("Error", err); if (err instanceof Error && err.message !== "Validation Error") { - dispatch({ type: "SET_ERRORS", payload: { general: translate("signIn-general-error") } }); + dispatch({ type: "SET_ERRORS", payload: { general: translate("errors.error") } }); } dispatch({ type: "SET_LOADING", payload: false }); } @@ -91,9 +91,9 @@ export default function SignIn() { return ( <> - -

{translate('signIn-title')}

-

{translate('signIn-subtitle')}

+ +

{translate('title')}

+

{translate('description')}

{ @@ -104,7 +104,7 @@ export default function SignIn() { @@ -120,7 +120,7 @@ export default function SignIn() { @@ -134,7 +134,7 @@ export default function SignIn() { @@ -143,7 +143,7 @@ export default function SignIn() { )} - {translate('signIn-button')} + {translate('actions.submit')} diff --git a/src/app/(domains)/(auth)/signup/page.tsx b/src/app/(domains)/(auth)/signup/page.tsx index 113258a..618b4a9 100644 --- a/src/app/(domains)/(auth)/signup/page.tsx +++ b/src/app/(domains)/(auth)/signup/page.tsx @@ -59,7 +59,7 @@ function reducer(state: SignUpStateType, action: SignUpAction) { export default function SignUp() { const [state, dispatch] = useReducer(reducer, initialState); - const { translate } = useI18n(); + const { translate } = useI18n("pages.signup"); async function handleSignUp() { try { @@ -74,18 +74,17 @@ export default function SignUp() { dispatch({ type: "SET_ERRORS", payload: { - email: isValidEmailResponse ? "" : translate("signUp-invalid-email"), - password: isPasswordValid ? "" : translate("signUp-invalid-password"), - confirmPassword: isPasswordsMatch ? "" : translate("signUp-passwords-do-not-match"), + email: isValidEmailResponse ? "" : translate("errors.email"), + password: isPasswordValid ? "" : translate("errors.password"), + confirmPassword: isPasswordsMatch ? "" : translate("errors.confirm-password"), }, }); - throw new Error("Validation Error"); } if (!state.isTermsAccepted) { dispatch({ type: "SET_ERRORS", - payload: { terms: translate("signUp-terms-not-accepted") }, + payload: { terms: translate("errors.terms") }, }); throw new Error("Terms not accepted"); } @@ -96,12 +95,12 @@ export default function SignUp() { if (response?.id) { dispatch({ type: "SET_REGISTRATION_COMPLETE", payload: true }); } else { - dispatch({ type: "SET_ERRORS", payload: { general: translate("signUp-general-error") } }); + dispatch({ type: "SET_ERRORS", payload: { general: translate("general-error") } }); } } catch (err) { console.error("Error", err); if (err instanceof Error && err.message !== "Validation Error" && err.message !== "Terms not accepted") { - dispatch({ type: "SET_ERRORS", payload: { general: translate("signUp-general-error") } }); + dispatch({ type: "SET_ERRORS", payload: { general: translate("errors.error") } }); } } finally { dispatch({ type: "SET_LOADING", payload: false }); @@ -112,15 +111,15 @@ export default function SignUp() { <> {state.isRegistrationComplete ? ( <> - +
-

{translate("signUp-general-error")}

+

{translate("actions.success")}

) : ( <> -

{translate("signUp-title")}

-

{translate("signUp-subtitle")}

+

{translate("title")}

+

{translate("description")}

{ @@ -132,7 +131,7 @@ export default function SignUp() { @@ -148,7 +147,7 @@ export default function SignUp() { @@ -165,7 +164,7 @@ export default function SignUp() { @@ -192,7 +191,7 @@ export default function SignUp() { className="h-4 w-4 text-indigo-600 border-gray-300 rounded" /> {state.errors.terms && ( @@ -200,7 +199,7 @@ export default function SignUp() { )} - {translate("signUp-button")} + {translate("actions.submit")} diff --git a/src/app/(domains)/(home)/_sections/FaqSection.tsx b/src/app/(domains)/(home)/_sections/FaqSection.tsx index 4091e1b..ed5766d 100644 --- a/src/app/(domains)/(home)/_sections/FaqSection.tsx +++ b/src/app/(domains)/(home)/_sections/FaqSection.tsx @@ -7,32 +7,32 @@ const FaqSection = () => { const faqs = [ { - question: translate('home-section-faq-question-1'), - answer: translate('home-section-faq-answer-1'), + question: translate('pages.home.sections.faq.items.1.question'), + answer: translate('pages.home.sections.faq.items.1.answer'), }, { - question: translate('home-section-faq-question-2'), - answer: translate('home-section-faq-answer-2'), + question: translate('pages.home.sections.faq.items.2.question'), + answer: translate('pages.home.sections.faq.items.2.answer'), }, { - question: translate('home-section-faq-question-3'), - answer: translate('home-section-faq-answer-3'), + question: translate('pages.home.sections.faq.items.3.question'), + answer: translate('pages.home.sections.faq.items.3.answer'), }, { - question: translate('home-section-faq-question-4'), - answer: translate('home-section-faq-answer-4'), + question: translate('pages.home.sections.faq.items.4.question'), + answer: translate('pages.home.sections.faq.items.4.answer'), }, { - question: translate('home-section-faq-question-5'), - answer: translate('home-section-faq-answer-5'), + question: translate('pages.home.sections.faq.items.5.question'), + answer: translate('pages.home.sections.faq.items.5.answer'), }, { - question: translate('home-section-faq-question-6'), - answer: translate('home-section-faq-answer-6'), + question: translate('pages.home.sections.faq.items.6.question'), + answer: translate('pages.home.sections.faq.items.6.answer'), }, { - question: translate('home-section-faq-question-7'), - answer: translate('home-section-faq-answer-7'), + question: translate('pages.home.sections.faq.items.7.question'), + answer: translate('pages.home.sections.faq.items.7.answer'), }, ]; @@ -40,10 +40,10 @@ const FaqSection = () => {

- {translate('home-section-faq-title')} + {translate('pages.home.sections.faq.title')}

- {translate('home-section-faq-description')} + {translate('pages.home.sections.faq.description')}

diff --git a/src/app/(domains)/(home)/_sections/FeaturesSection.tsx b/src/app/(domains)/(home)/_sections/FeaturesSection.tsx index bd2861f..4100ae8 100644 --- a/src/app/(domains)/(home)/_sections/FeaturesSection.tsx +++ b/src/app/(domains)/(home)/_sections/FeaturesSection.tsx @@ -9,16 +9,16 @@ export default function FeaturesSection() { const features = [ { - title: translate('home-section-feature-oauth-title'), - description: translate('home-section-feature-oauth-description'), + title: translate('pages.home.sections.features.oauth.title'), + description: translate('pages.home.sections.features.oauth.description'), }, { - title: translate('home-section-feature-subscription-title'), - description: translate('home-section-feature-subscription-description'), + title: translate('pages.home.sections.features.subscription.title'), + description: translate('pages.home.sections.features.subscription.description'), }, { - title: translate('home-section-feature-responsive-title'), - description: translate('home-section-feature-responsive-description'), + title: translate('pages.home.sections.features.responsive.title'), + description: translate('pages.home.sections.features.responsive.description'), }, ]; @@ -27,10 +27,10 @@ export default function FeaturesSection() {

- {translate('home-section-features-title')} + {translate('pages.home.sections.features.title')}

- {translate('home-section-features-description')} + {translate('pages.home.sections.features.description')}

diff --git a/src/app/(domains)/(home)/_sections/Footer.tsx b/src/app/(domains)/(home)/_sections/Footer.tsx index 80be4cf..5919595 100644 --- a/src/app/(domains)/(home)/_sections/Footer.tsx +++ b/src/app/(domains)/(home)/_sections/Footer.tsx @@ -16,14 +16,14 @@ export default function Footer({ isDashboard }: FooterProps) {

- {translate("home-footer-copyright")} + {translate("pages.home.footer.copyright")}

diff --git a/src/app/(domains)/(home)/_sections/HeroSection.tsx b/src/app/(domains)/(home)/_sections/HeroSection.tsx index 722e1c9..e389d56 100644 --- a/src/app/(domains)/(home)/_sections/HeroSection.tsx +++ b/src/app/(domains)/(home)/_sections/HeroSection.tsx @@ -10,14 +10,14 @@ export default async function HeroSection() {

- {translate("home-section-hero-title")} + {translate("pages.home.sections.hero.title")}

- {translate("home-section-hero-description")} + {translate("pages.home.sections.hero.description")}

diff --git a/src/app/(domains)/(home)/_sections/HowItWorksSection.tsx b/src/app/(domains)/(home)/_sections/HowItWorksSection.tsx index 375b646..fcb9085 100644 --- a/src/app/(domains)/(home)/_sections/HowItWorksSection.tsx +++ b/src/app/(domains)/(home)/_sections/HowItWorksSection.tsx @@ -6,26 +6,26 @@ export default async function HowItWorksSection() { return (
-

{translate("home-section-how-title")}

-

{translate("home-section-how-description")}

+

{translate("pages.home.sections.how.title")}

+

{translate("pages.home.sections.how.description")}

1
-

{translate("home-section-how-first-title")}

-

{translate("home-section-how-first-description")}

+

{translate("pages.home.sections.how.options.first.title")}

+

{translate("pages.home.sections.how.options.first.description")}

2
-

{translate("home-section-how-second-title")}

-

{translate("home-section-how-second-description")}

+

{translate("pages.home.sections.how.options.second.title")}

+

{translate("pages.home.sections.how.options.second.description")}

3
-

{translate("home-section-how-third-title")}

-

{translate("home-section-how-third-description")}

+

{translate("pages.home.sections.how.options.third.title")}

+

{translate("pages.home.sections.how.options.third.description")}

diff --git a/src/app/(domains)/(home)/_sections/TestimonialSection.tsx b/src/app/(domains)/(home)/_sections/TestimonialSection.tsx index 491b970..2be490d 100644 --- a/src/app/(domains)/(home)/_sections/TestimonialSection.tsx +++ b/src/app/(domains)/(home)/_sections/TestimonialSection.tsx @@ -6,24 +6,24 @@ const TestimonialSection = () => { const { translate } = useI18n(); const testimonials = [ - translate('home-section-testimonial-1'), - translate('home-section-testimonial-2'), - translate('home-section-testimonial-3'), - translate('home-section-testimonial-4'), - translate('home-section-testimonial-5'), - translate('home-section-testimonial-6'), - translate('home-section-testimonial-7'), - translate('home-section-testimonial-8'), + translate('pages.home.sections.testimonials.list.1'), + translate('pages.home.sections.testimonials.list.2'), + translate('pages.home.sections.testimonials.list.3'), + translate('pages.home.sections.testimonials.list.4'), + translate('pages.home.sections.testimonials.list.5'), + translate('pages.home.sections.testimonials.list.6'), + translate('pages.home.sections.testimonials.list.7'), + translate('pages.home.sections.testimonials.list.8'), ]; return (

- {translate('home-section-testimonials-title')} + {translate('pages.home.sections.testimonials.title')}

- {translate('home-section-testimonials-description')} + {translate('pages.home.sections.testimonials.description')}

diff --git a/src/app/(domains)/(home)/terms-and-privacy/page.tsx b/src/app/(domains)/(home)/terms-and-privacy/page.tsx index 222c252..2621374 100644 --- a/src/app/(domains)/(home)/terms-and-privacy/page.tsx +++ b/src/app/(domains)/(home)/terms-and-privacy/page.tsx @@ -5,69 +5,65 @@ import { loadTranslationsSSR } from "@/utils/loadTranslationsSSR"; import Navbar from "../../../../components/Navbar"; import Footer from "../_sections/Footer"; +interface TranslationFunction { + (key: string): string; +} + +interface SectionProps { + titleKey: string; + descriptionKey: string; + translate: TranslationFunction; +} + + +interface SectionConfig { + title: string; + description: string; +} + +const Section: React.FC = ({ titleKey, descriptionKey, translate }) => ( +
+

+ {translate(titleKey)} +

+

+ {translate(descriptionKey)} +

+
+); + +const sections: SectionConfig[] = [ + { title: "terms.title", description: "terms.description" }, + { title: "policy.title", description: "policy.description" }, + { title: "collection.title", description: "collection.description" }, + { title: "security.title", description: "security.description" }, + { title: "changes.title", description: "changes.description" }, + { title: "contact.title", description: "contact.description" }, +]; + + async function TermsAndPrivacy() { const { translate } = await loadTranslationsSSR(); return ( -
+
-
+

- {translate("terms-privacy-title")} + {translate("pages.terms-and-privacy.title")}

-
-

- {translate("terms-privacy-terms-of-use")} -

-

- {translate("terms-privacy-terms-of-use-description")} -

-
-
-

- {translate("terms-privacy-privacy-policy")} -

-

- {translate("terms-privacy-privacy-policy-description")} -

-
-
-

- {translate("terms-privacy-data-collection")} -

-

- {translate("terms-privacy-data-collection-description")} -

-
-
-

- {translate("terms-privacy-data-security")} -

-

- {translate("terms-privacy-data-security-description")} -

-
-
-

- {translate("terms-privacy-changes-policy")} -

-

- {translate("terms-privacy-changes-policy-description")} -

-
-
-

- {translate("terms-privacy-contact-us")} -

-

- {translate("terms-privacy-contact-us-description")} -

-
-
-
+ {sections.map((section) => ( +
+ ))} +
); } -export default TermsAndPrivacy; +export default TermsAndPrivacy; \ No newline at end of file diff --git a/src/app/(domains)/(payment)/payments/page.tsx b/src/app/(domains)/(payment)/payments/page.tsx index 7166a9b..afc1614 100644 --- a/src/app/(domains)/(payment)/payments/page.tsx +++ b/src/app/(domains)/(payment)/payments/page.tsx @@ -1,39 +1,48 @@ -'use client'; -import { useSearchParams } from 'next/navigation'; +"use client"; +import { useSearchParams } from "next/navigation"; import BackLink from "@/components/BackLink"; -import { useI18n } from '@/hooks/useI18n'; +import { useI18n } from "@/hooks/useI18n"; const PaymentStatus = () => { - const { translate } = useI18n(); - const searchParams = useSearchParams(); - const status = searchParams && searchParams.get('status'); + const { translate } = useI18n(); + const status = useSearchParams()?.get("status"); - return ( -
-
- {status === 'success' ? ( - <> - -

{translate("payment-status-success-title")}

-

{translate("payment-status-success-message")}

- - ) : status === 'cancel' ? ( - <> - -

{translate("payment-status-cancel-title")}

-

{translate("payment-status-cancel-message")}

- - ) : ( - <> - -

{translate("payment-status-unknown-title")}

-

{translate("payment-status-unknown-message")}

- - )} -
-
- ); + const statusMap = { + success: { + href: "/dashboard/subscription", + label: translate("pages.payments.actions.billing"), + title: translate("pages.payments.status.success.title"), + description: translate("pages.payments.status.success.description"), + }, + cancel: { + href: "/dashboard/subscription", + label: translate("pages.payments.actions.settings"), + title: translate("pages.payments.status.cancel.title"), + description: translate("pages.payments.status.cancel.description"), + }, + unknown: { + href: "./", + label: translate("pages.payments.actions.home"), + title: translate("pages.payments.status.unknown.title"), + description: translate("pages.payments.status.unknown.description"), + }, + }; + + const { href, label, title, description } = + statusMap[status as keyof typeof statusMap] || statusMap.unknown; + + return ( +
+
+ +

+ {title} +

+

{description}

+
+
+ ); }; export default PaymentStatus; diff --git a/src/app/(domains)/dashboard/layout.tsx b/src/app/(domains)/dashboard/layout.tsx index e05aa40..8309d6f 100644 --- a/src/app/(domains)/dashboard/layout.tsx +++ b/src/app/(domains)/dashboard/layout.tsx @@ -1,17 +1,16 @@ -import DashboardNavbar from "@/components/DashboardNavbar"; +import { Navbar } from "@/components/Dashboard/Navbar"; type Props = { - children: React.ReactNode; -} + children: React.ReactNode; +}; export default async function DashboardLayout({ children }: Props) { - return ( -
-
- - {children} -
-
- - ); + return ( + <> + +
+
{children}
+
+ + ); } diff --git a/src/app/(domains)/dashboard/page.tsx b/src/app/(domains)/dashboard/page.tsx index 959b2f4..c3bba2c 100644 --- a/src/app/(domains)/dashboard/page.tsx +++ b/src/app/(domains)/dashboard/page.tsx @@ -1,6 +1,6 @@ import { headers } from 'next/headers'; -import { ClientDashboard } from "@/components/ClientDashboard"; +import { Dashboard as DashboardComponent } from "@/components/Dashboard"; import { ModalProvider } from "@/contexts/ModalContext"; @@ -10,7 +10,7 @@ export default async function Dashboard() { return (
- +
); diff --git a/src/app/(domains)/dashboard/settings/page.tsx b/src/app/(domains)/dashboard/settings/page.tsx index 96f47ef..9bdae09 100644 --- a/src/app/(domains)/dashboard/settings/page.tsx +++ b/src/app/(domains)/dashboard/settings/page.tsx @@ -20,7 +20,7 @@ export default async function Settings() {
{data?.user_metadata?.name && (
- + - +
-

{translate("subscription-current-plan")}

+

+ {translate("pages.subscription.plan.title")} +

{currentPlanText.replace("{plan}", currentPlan)}

- {sharedData?.plan !== 'free' && session?.access_token && ( + {sharedData?.plan !== "free" && session?.access_token && ( )}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 285e91b..e313388 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -22,11 +22,11 @@ type Props = { }; export default async function RootLayout({ children }: Props) { - const { translations, locale } = await loadTranslationsSSR(); + const { translate, translations, locale } = await loadTranslationsSSR(); return ( - {translations.title} + {translate('title')} diff --git a/src/components/ClientDashboard.tsx b/src/components/ClientDashboard.tsx deleted file mode 100644 index d66bca3..0000000 --- a/src/components/ClientDashboard.tsx +++ /dev/null @@ -1,43 +0,0 @@ -'use client'; - -import { useI18n } from "@/hooks/useI18n"; - -import FeatureMenu from "./FeatureMenu"; -import Modal from "./Modal"; - -type ComponentClientDashboardProps = { - plan: "free" | "starter" | "creator" | "pro"; -} - -export const ClientDashboard = ({ plan }: ComponentClientDashboardProps) => { - const { translate } = useI18n(); - - const handleTabChange = (tab: string) => { - console.log(tab); - }; - - return ( - <> - - -
- -

- {translate("component-client-dashboard-modal-text")} -

- - {translate("component-client-dashboard-modal-button")} - -
-
- - ); -}; diff --git a/src/components/FeatureMenu.tsx b/src/components/Dashboard/Menu.tsx similarity index 88% rename from src/components/FeatureMenu.tsx rename to src/components/Dashboard/Menu.tsx index 1332aa1..e42cbb1 100644 --- a/src/components/FeatureMenu.tsx +++ b/src/components/Dashboard/Menu.tsx @@ -13,17 +13,17 @@ type Tab = { }; const tabs: Tab[] = [ - { name: "component-feature-menu-tab-free", href: "/feature1", requiredPlan: "free" }, - { name: "component-feature-menu-tab-starter", href: "/feature2", requiredPlan: "starter" }, - { name: "component-feature-menu-tab-pro", href: "/feature3", requiredPlan: "pro" }, + { name: "components.dashboard.menu.options.free", href: "/feature1", requiredPlan: "free" }, + { name: "components.dashboard.menu.options.starter", href: "/feature2", requiredPlan: "starter" }, + { name: "components.dashboard.menu.options.pro", href: "/feature3", requiredPlan: "pro" }, ]; -type FeatureMenuProps = { +type MenuProps = { activePlan: "free" | "starter" | "creator" | "pro"; onTabChange: (activeTab: string) => void; }; -export default function FeatureMenu({ activePlan, onTabChange }: FeatureMenuProps) { +export function Menu({ activePlan, onTabChange }: MenuProps) { const [activeTab, setActiveTab] = useState(""); const { openModal } = useModal(); const { translate } = useI18n(); diff --git a/src/components/Dashboard/Modal.tsx b/src/components/Dashboard/Modal.tsx new file mode 100644 index 0000000..ae5d87b --- /dev/null +++ b/src/components/Dashboard/Modal.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useI18n } from "@/hooks/useI18n"; + +import { Modal as ModalComponent } from "../Modal"; + +export function Modal() { + const { translate } = useI18n(); + + return ( + +
+ +

+ {translate("components.dashboard.modal.description")} +

+ + {translate("components.dashboard.modal.actions.proceed")} + +
+
+ ); +} diff --git a/src/components/Dashboard/Navbar/MyAccount.tsx b/src/components/Dashboard/Navbar/MyAccount.tsx new file mode 100644 index 0000000..7c04b61 --- /dev/null +++ b/src/components/Dashboard/Navbar/MyAccount.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { ArrowRightEndOnRectangleIcon } from "@heroicons/react/24/solid"; +import { useState, useEffect, useRef } from "react"; + +import { useI18n } from "@/hooks/useI18n"; +import { supabase } from "@/libs/supabase/client"; +import SupabaseService from "@/services/supabase"; + +const SupabaseServiceInstance = new SupabaseService(supabase); + +function MyAccount() { + const { translate } = useI18n("components.dashboard.navbar.my-account"); + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + const toggleMenu = () => setIsOpen(!isOpen); + + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + useEffect(() => { + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } else { + document.removeEventListener("mousedown", handleClickOutside); + } + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + return ( + + ); +} + +export default MyAccount; diff --git a/src/components/Notification/index.tsx b/src/components/Dashboard/Navbar/Notification/index.tsx similarity index 100% rename from src/components/Notification/index.tsx rename to src/components/Dashboard/Navbar/Notification/index.tsx diff --git a/src/components/Notification/style.css b/src/components/Dashboard/Navbar/Notification/style.css similarity index 100% rename from src/components/Notification/style.css rename to src/components/Dashboard/Navbar/Notification/style.css diff --git a/src/components/DashboardNavbar.tsx b/src/components/Dashboard/Navbar/index.tsx similarity index 85% rename from src/components/DashboardNavbar.tsx rename to src/components/Dashboard/Navbar/index.tsx index 269c4b6..be1c94c 100644 --- a/src/components/DashboardNavbar.tsx +++ b/src/components/Dashboard/Navbar/index.tsx @@ -2,11 +2,11 @@ import { useI18n } from "@/hooks/useI18n"; -import LanguageSelector from "./LanguageSelector"; import MyAccount from "./MyAccount"; // import Notification from "./Notification"; +import LanguageSelector from "../../LanguageSelector"; -export default function DashboardNavbar() { +export function Navbar() { const { translate } = useI18n(); return ( @@ -15,7 +15,7 @@ export default function DashboardNavbar() {
diff --git a/src/components/Dashboard/index.tsx b/src/components/Dashboard/index.tsx new file mode 100644 index 0000000..71831be --- /dev/null +++ b/src/components/Dashboard/index.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Menu } from "./Menu"; +import { Modal } from "./Modal"; + +type ComponentDashboardProps = { + plan: "free" | "starter" | "creator" | "pro"; +}; + +export const Dashboard = ({ plan }: ComponentDashboardProps) => { + const handleTabChange = (tab: string) => { + console.log(tab); + }; + + return ( + <> + + + + ); +}; diff --git a/src/components/FooterAuthScreen.tsx b/src/components/FooterAuthScreen.tsx index 2d52b66..0c7437e 100644 --- a/src/components/FooterAuthScreen.tsx +++ b/src/components/FooterAuthScreen.tsx @@ -12,16 +12,16 @@ export default function FooterAuthScreen({ screen }: FooterAuthScreenProps) {
{screen === 'signin' ? ( <> - {translate("component-footer-auth-dont-have-account")}{" "} + {translate("components.footer-auth.dont-have-account")}{" "} - {translate("component-footer-auth-create-account")} + {translate("components.footer-auth.create-account")} ) : ( <> - {translate("component-footer-auth-already-have-account")}{" "} + {translate("components.footer-auth.already-have-account")}{" "} - {translate("component-footer-auth-go-back-to-login")} + {translate("components.footer-auth.go-back-to-login")} )} diff --git a/src/components/ManageBilling.tsx b/src/components/ManageBilling.tsx index 46115de..6c355ce 100644 --- a/src/components/ManageBilling.tsx +++ b/src/components/ManageBilling.tsx @@ -1,4 +1,4 @@ -'use client'; +"use client"; import { useRouter } from "next/navigation"; @@ -8,59 +8,64 @@ import { useI18n } from "@/hooks/useI18n"; import ButtonComponent from "./Button"; -export async function handleManageBilling(redirect: (url: string) => void, accessToken: string, translate: (key: string) => string): Promise { - try { - const response = await fetch('/api/payments/create-billing-portal', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, - }); +export async function handleManageBilling( + redirect: (url: string) => void, + accessToken: string +): Promise { + try { + const response = await fetch("/api/payments/create-billing-portal", { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); - if (!response.ok) { - throw new Error('failed-to-create-billing-portal'); - } - - const { url } = await response.json(); - redirect(url); - } catch (error) { - console.error('Error redirecting to billing portal:', error); - throw new Error(translate('component-manage-billing-error')); + if (!response.ok) { + throw new Error("Failed to create billing portal session."); } + + const { url } = await response.json(); + redirect(url); + } catch (error) { + console.error("Error redirecting to billing portal:", error); + } } type Props = { - accessToken: string; + accessToken: string; }; export default function ManageBilling({ accessToken }: Props) { - const router = useRouter(); - const [isLoading, setIsLoading] = useState(false); - const { translate } = useI18n(); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const { translate } = useI18n("components.manage-billing"); - return ( -
-
-

{translate("component-manage-billing-title")}

-
- { - setIsLoading(true); - try { - await handleManageBilling(router.push, accessToken, translate); - } catch (error) { - console.error(error); - } finally { - setIsLoading(false); - } - }} - type="button" - variant="outlined" - isLoading={isLoading} - size="small" - className="text-indigo-600 hover:underline border border-indigo-600 rounded px-4 py-2 md:w-1/6 w-2/4"> - {translate("component-manage-billing-button")} - -
- ); + return ( +
+
+

+ {translate("title")} +

+
+ { + setIsLoading(true); + try { + await handleManageBilling(router.push, accessToken); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + }} + type="button" + variant="outlined" + isLoading={isLoading} + size="small" + className="text-indigo-600 hover:underline border border-indigo-600 rounded px-4 py-2 md:w-1/6 w-2/4" + > + {translate("actions.proceed")} + +
+ ); } diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 87bff5e..970e727 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from "react"; -import { useModal } from '@/hooks/useModal'; +import { useModal } from "@/hooks/useModal"; interface ModalProps { title: string; @@ -13,15 +13,18 @@ const Modal: React.FC = ({ title, children }) => { useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (modalRef.current && !modalRef.current.contains(event.target as Node)) { + if ( + modalRef.current && + !modalRef.current.contains(event.target as Node) + ) { closeModal(); } }; - document.addEventListener('mousedown', handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); return () => { - document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener("mousedown", handleClickOutside); }; }, [closeModal]); @@ -42,4 +45,4 @@ const Modal: React.FC = ({ title, children }) => { ); }; -export default Modal; +export { Modal }; diff --git a/src/components/MyAccount.tsx b/src/components/MyAccount.tsx deleted file mode 100644 index 36de522..0000000 --- a/src/components/MyAccount.tsx +++ /dev/null @@ -1,74 +0,0 @@ -'use client'; - -import { ArrowRightEndOnRectangleIcon } from '@heroicons/react/24/solid'; -import { useState, useEffect, useRef } from 'react'; - -import { useI18n } from "@/hooks/useI18n"; -import { supabase } from '@/libs/supabase/client'; -import SupabaseService from '@/services/supabase'; - -const SupabaseServiceInstance = new SupabaseService(supabase); - -function MyAccount() { - const { translate } = useI18n(); - const [isOpen, setIsOpen] = useState(false); - const menuRef = useRef(null); - - const toggleMenu = () => setIsOpen(!isOpen); - - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - useEffect(() => { - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - } else { - document.removeEventListener('mousedown', handleClickOutside); - } - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen]); - - return ( - - ); -} - -export default MyAccount; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 73b7664..549345f 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -48,13 +48,13 @@ export default function Navbar() { @@ -65,15 +65,15 @@ export default function Navbar() { : !isLogged ? ( <> - {translate('home-navbar-signin')} + {translate('components.navbar.signin')} - {translate('home-navbar-try')} + {translate('components.navbar.try')} ) : - {translate('home-navbar-dashboard')} + {translate('components.navbar.dashboard')} } @@ -104,13 +104,13 @@ export default function Navbar() {
@@ -124,13 +124,13 @@ export default function Navbar() { href="/signin" className="py-2 px-4 border border-indigo-600 text-indigo-600 rounded hover:bg-indigo-100" > - {translate('home-navbar-signin')} + {translate('components.navbar.signin')} - {translate('home-navbar-try')} + {translate('components.navbar.try')} ) : ( @@ -138,7 +138,7 @@ export default function Navbar() { href="/dashboard" className="py-2 px-4 bg-indigo-600 text-white rounded hover:bg-indigo-700" > - {translate('home-navbar-dashboard')} + {translate('components.navbar.dashboard')} )}
diff --git a/src/components/Pricing/PlanCard.tsx b/src/components/Pricing/PlanCard.tsx index 6c4399d..3e2267b 100644 --- a/src/components/Pricing/PlanCard.tsx +++ b/src/components/Pricing/PlanCard.tsx @@ -42,12 +42,12 @@ export default function PlanCard({ plan, isAnnual, isSelected, isMostPopular, ha > {isSelected && ( - {translate('component-pricing-selected-tag')} + {translate('components.pricing.tag.selected')} )} {isMostPopular && !isSelected && ( - {translate('component-pricing-popular-tag')} + {translate('components.pricing.tag.popular')} )}

{plan.name}

@@ -62,8 +62,8 @@ export default function PlanCard({ plan, isAnnual, isSelected, isMostPopular, ha onClick={() => handle(plan)} > {isSelected - ? translate('component-pricing-button-subscribed') - : translate('component-pricing-button') + ? translate('components.pricing.button.subscribed') + : translate('components.pricing.button.default') }
    diff --git a/src/components/Pricing/index.tsx b/src/components/Pricing/index.tsx index a8e34f6..08c0985 100644 --- a/src/components/Pricing/index.tsx +++ b/src/components/Pricing/index.tsx @@ -25,20 +25,20 @@ export default function Pricing({ selectedOption, hasFreeplan = true }: PricingP return (
    -

    {translate('component-pricing-title')}

    -

    {translate('component-pricing-description')}

    +

    {translate('components.pricing.title')}

    +

    {translate('components.pricing.description')}

    {HAS_FREE_TRIAL && (
    -

    {translate('component-pricing-trial-title')} {HAS_FREE_TRIAL}!

    -

    {translate('component-pricing-trial-description')}

    +

    {translate('components.pricing.trial.title')} {HAS_FREE_TRIAL}!

    +

    {translate('components.pricing.trial.description')}

    )} {userEmail && }
    -

    {translate('component-settings-options-current-plan')}

    +

    {translate('plan')}

    {currentPlan}

    - {translate('component-settings-options-manage-subscription')} + {translate('subscription')}
    diff --git a/src/constants/SUBSCRIPTION_PLANS_BASE.ts b/src/constants/SUBSCRIPTION_PLANS_BASE.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/contexts/i18nContext.tsx b/src/contexts/i18nContext.tsx index a25101e..30a9a25 100644 --- a/src/contexts/i18nContext.tsx +++ b/src/contexts/i18nContext.tsx @@ -1,13 +1,19 @@ -'use client'; +"use client"; -import { createContext, ReactNode } from 'react'; +import { createContext, ReactNode } from "react"; export interface I18nContextProps { locale: string; translate: (key: string) => string; } -export const I18nContext = createContext(undefined); +export const I18nContext = createContext( + undefined +); + +export interface Translations { + [key: string]: string | Translations; +} export const I18nProvider = ({ children, @@ -16,13 +22,24 @@ export const I18nProvider = ({ }: { children: ReactNode; locale: string; - translations: Record; + translations: Translations; }) => { - const translate = (key: string) => translations[key] || key; + const translate = (key: string): string => { + const keys = key.split("."); + let value: unknown = translations; + for (const k of keys) { + if (typeof value === "object" && value !== null && k in value) { + value = (value as Record)[k]; + } else { + return key; + } + } + return typeof value === "string" ? value : key; + }; return ( {children} ); -}; \ No newline at end of file +}; diff --git a/src/hooks/useFetchPlans.ts b/src/hooks/useFetchPlans.ts index 2f55546..57daa52 100644 --- a/src/hooks/useFetchPlans.ts +++ b/src/hooks/useFetchPlans.ts @@ -1,50 +1,54 @@ -import { useState, useEffect } from 'react'; - -import { Plan } from '@/components/Pricing/PlanCard'; -import { FIXED_CURRENCY } from '@/constants/FIXED_CURRENCY'; -import { HAS_FREE_TRIAL } from '@/constants/HAS_FREE_TRIAL'; - -import { useI18n } from './useI18n'; - - -export const useFetchPlans = (hasFreeplan: boolean, setIsLoading: (isLoading: boolean) => void) => { - const { translate } = useI18n(); - const SUBSCRIPTION_PLANS_BASE: Plan[] = [ - { - id: 'free', - name: translate('component-pricing-subscription-plans-free-name'), - priceMonthly: translate('component-pricing-subscription-plans-free-price-monthly').replace("{value}", "0"), - priceAnnual: translate('component-pricing-subscription-plans-free-price-annual').replace("{value}", "0"), - description: translate('component-pricing-subscription-plans-free-description'), - features: [ - translate('component-pricing-subscription-plans-free-feature-1'), - translate('component-pricing-subscription-plans-free-feature-2'), - ], - extraFeatures: translate('component-pricing-subscription-plans-free-extra-features'), - }, - ]; - - const [plans, setPlans] = useState(hasFreeplan && !HAS_FREE_TRIAL ? SUBSCRIPTION_PLANS_BASE : []); - - - - - useEffect(() => { - const fetchPlans = async () => { - try { - const response = await fetch(`/api/payments/get-plans?currency=${FIXED_CURRENCY}`); - const data: Plan[] = await response.json(); - setPlans((prev: Plan[]) => { - if (!prev) return data; - return prev.length >= 4 ? [...prev] : [...prev, ...data]; - }); - } catch (error) { - console.error('Erro ao buscar planos:', error); - } - setIsLoading(false); - }; - fetchPlans(); - }, []); - - return { plans, setIsLoading }; +import { useState, useEffect } from "react"; + +import { Plan } from "@/components/Pricing/PlanCard"; +import { FIXED_CURRENCY } from "@/constants/FIXED_CURRENCY"; +import { HAS_FREE_TRIAL } from "@/constants/HAS_FREE_TRIAL"; + +import { useI18n } from "./useI18n"; + +export const useFetchPlans = ( + hasFreeplan: boolean, + setIsLoading: (isLoading: boolean) => void +) => { + const { translate } = useI18n("components.pricing.plans"); + const SUBSCRIPTION_PLANS_BASE: Plan[] = [ + { + id: "free", + name: translate("free.title"), + description: translate("free.description"), + priceMonthly: translate("prices.monthly").replace("{value}", "0"), + priceAnnual: translate("prices.annual").replace("{value}", "0"), + features: [ + translate("free.features.first"), + translate("free.features.second"), + translate("free.features.third"), + ], + extraFeatures: translate("free.extra"), + }, + ]; + + const basePlan = + hasFreeplan && !HAS_FREE_TRIAL ? SUBSCRIPTION_PLANS_BASE : []; + const [plans, setPlans] = useState(basePlan); + + useEffect(() => { + const fetchPlans = async () => { + try { + const response = await fetch( + `/api/payments/get-plans?currency=${FIXED_CURRENCY}` + ); + const data: Plan[] = await response.json(); + setPlans((prev: Plan[]) => { + if (!prev) return data; + return prev.length >= 4 ? [...prev] : [...prev, ...data]; + }); + } catch (error) { + console.error("Erro ao buscar planos:", error); + } + setIsLoading(false); + }; + fetchPlans(); + }, [setIsLoading]); + + return { plans, setIsLoading }; }; diff --git a/src/hooks/useI18n.ts b/src/hooks/useI18n.ts index 3c9a07d..e75d3d9 100644 --- a/src/hooks/useI18n.ts +++ b/src/hooks/useI18n.ts @@ -1,12 +1,24 @@ import { useContext } from "react"; -import { I18nContext, I18nContextProps } from "@/contexts/i18nContext"; - -export const useI18n = (): I18nContextProps => { - const context = useContext(I18nContext); - if (!context) { - throw new Error('useI18n must be used within an I18nProvider'); - } - return context; +import { I18nContext } from "@/contexts/i18nContext"; + +export const useI18n = (basePath?: string) => { + const context = useContext(I18nContext); + if (!context) { + throw new Error("useI18n must be used within an I18nProvider"); + } + + const { translate } = context; + + type TFunction = typeof translate; + + const scopedT: TFunction = (key: string) => { + const fullKey = basePath ? `${basePath}.${key}` : key; + return translate(fullKey); + }; + + return { + ...context, + translate: scopedT, }; - \ No newline at end of file +}; diff --git a/src/locales/en-US.json b/src/locales/en-US.json deleted file mode 100644 index be69bc2..0000000 --- a/src/locales/en-US.json +++ /dev/null @@ -1,202 +0,0 @@ -{ - "title": "Sassy - powerful micro-saas template", - "home-navbar-pricing": "Pricing", - "home-navbar-features": "Features", - "home-navbar-faq": "FAQ", - "home-navbar-signin": "Sign In", - "home-navbar-try": "Try For Free", - "home-navbar-dashboard": "Dashboard", - "home-section-hero-title": "Welcome to Micro-SaaS Creator", - "home-section-hero-description": "Empower your vision with tools to build Micro-SaaS solutions effortlessly.", - "home-section-hero-button": "Get Started", - "home-section-how-title": "How Sassy Works", - "home-section-how-description": "Set up your subscription service in just three easy steps.", - "home-section-how-first-title": "Sign Up", - "home-section-how-first-description": "Create an account and set up your profile. Secure and fast with Supabase integration.", - "home-section-how-second-title": "Integrate Stripe", - "home-section-how-second-description": "Connect Stripe for seamless billing and manage monthly or annual subscriptions.", - "home-section-how-third-title": "Manage Subscriptions", - "home-section-how-third-description": "Easily conetor and manage your subscriptions, with real-time updates.", - "component-pricing-title": "Pricing Plans", - "component-pricing-description": "Simple and transparent pricing to suit your needs.", - "component-pricing-trial-title": "You have a free trial for", - "component-pricing-trial-description": "Try our service with no commitment.", - "component-pricing-selected-tag": "Selected Plan", - "component-pricing-popular-tag": "Most Popular", - "component-pricing-button": "Subscribe", - "component-pricing-button-subscribed": "Current Plan", - "component-pricing-plan-starter-title": "Starter", - "component-pricing-plan-starter-description": "Ideal for individuals launching first Micro-SaaS.", - "component-pricing-plan-starter-feature-first": "✔ Access to core features", - "component-pricing-plan-starter-feature-second": "✔ 1 project", - "component-pricing-plan-starter-feature-third": " ", - "component-pricing-plan-starter-extra": "Everything in Free, plus", - "component-pricing-plan-creator-title": "Creator", - "component-pricing-plan-creator-description": "Great for creators looking to expand their reach.", - "component-pricing-plan-creator-feature-first": "✔ Access to core features", - "component-pricing-plan-creator-feature-second": "✔ 3 projects", - "component-pricing-plan-creator-feature-third": "✔ Email support", - "component-pricing-plan-creator-extra": "Everything in Starter, plus", - "component-pricing-plan-pro-title": "Pro", - "component-pricing-plan-pro-description": "Perfect for teams scaling their Micro-SaaS business.", - "component-pricing-plan-pro-feature-first": "✔ Access to core features", - "component-pricing-plan-pro-feature-second": "✔ Unlimited projects", - "component-pricing-plan-pro-feature-third": "✔ Priority support", - "component-pricing-plan-pro-extra": "Everything in Creator, plus", - "component-pricing-toggle-monthly": "Monthly", - "component-pricing-toggle-annual": "Annual", - "component-pricing-subscription-plans-free-name": "Free", - "component-pricing-subscription-plans-free-price-monthly": "${value}/month", - "component-pricing-subscription-plans-free-price-annual": "${value}/year", - "component-pricing-subscription-plans-free-description": "Ideal for individuals trying out our service.", - "component-pricing-subscription-plans-free-feature-1": "✔ Access to basic features", - "component-pricing-subscription-plans-free-feature-2": "✔ 1 project", - "component-pricing-subscription-plans-free-extra-features": " ", - "home-section-features-title": "Modern Features for Your SaaS", - "home-section-features-description": "Everything you need to build powerful Micro-SaaS applications.", - "home-section-feature-oauth-title": "OAuth Authentication", - "home-section-feature-oauth-description": "Seamlessly integrate with Google, Facebook, and Twitter to provide a smooth login experience.", - "home-section-feature-subscription-title": "Subscription Management", - "home-section-feature-subscription-description": "Effortlessly manage subscriptions with Stripe's robust tools and real-time updates.", - "home-section-feature-responsive-title": "Responsive Design", - "home-section-feature-responsive-description": "Crafted with TailwindCSS for a seamless experience across all devices.", - "home-section-testimonials-title": "What Our Users Say", - "home-section-testimonials-description": "Hear directly from our satisfied customers.", - "home-section-testimonial-1": "Sassy has been a game-changer for our Micro-SaaS project. The integration with Stripe and Supabase is seamless, and it helped us get started so much faster!", - "home-section-testimonial-2": "I was looking for a simple and reliable way to manage subscriptions for my SaaS product. Sassy’s Stripe integration is fantastic and easy to use!", - "home-section-testimonial-3": "The built-in authentication with Supabase and the payment gateway via Stripe saved us hours of development time. Highly recommend it!", - "home-section-testimonial-4": "As a solo developer, I needed a solid foundation for my Micro-SaaS. Sassy provided that and more! The flexibility of the template is incredible.", - "home-section-testimonial-5": "Sassy’s responsive design and clean architecture made it easy to deploy. I’ve been able to focus on building features rather than handling the basics.", - "home-section-testimonial-6": "The ease of integrating OAuth authentication with Google and Facebook made the user login process a breeze. This platform is a must-have for any developer.", - "home-section-testimonial-7": "With the subscription management system already in place, I could concentrate on my product’s core features. Sassy has been a huge time-saver.", - "home-section-testimonial-8": "Stripe webhooks and subscription handling worked flawlessly with my project. I’m really impressed with how well everything is integrated.", - "home-section-faq-title": "Frequently Asked Questions", - "home-section-faq-description": "Answers to help you understand how to leverage this template to kickstart your Micro-SaaS development.", - "home-section-faq-question-1": "What is this template for?", - "home-section-faq-answer-1": "This is a powerful template designed to accelerate the development of Micro-SaaS applications. It provides an example setup for subscription management, but you can easily adapt it to suit any SaaS product.", - "home-section-faq-question-2": "How do I get started with the template?", - "home-section-faq-answer-2": "Start by cloning the repository, setting up your environment with Next.js 15, and configuring TypeScript. The template is ready for development with integrations like Stripe and Supabase to manage your user data and payments.", - "home-section-faq-question-3": "Is this template focused on subscriptions?", - "home-section-faq-answer-3": "Subscriptions are just one example of functionality provided in this template. You can customize it to fit your Micro-SaaS application's needs, whether it's for billing, authentication, or any other aspect of your service.", - "home-section-faq-question-4": "What payment options are included?", - "home-section-faq-answer-4": "The template comes with an example of monthly and annual subscription management using Stripe. You can modify the payment flow to fit your app’s business model.", - "home-section-faq-question-5": "Can I integrate other services into this template?", - "home-section-faq-answer-5": "Yes, the template is built to be flexible. It supports integrations with Supabase for user management, Stripe for payments, and OAuth for authentication with services like Google, Facebook, and Twitter.", - "home-section-faq-question-6": "Is this template production-ready?", - "home-section-faq-answer-6": "While this template provides a solid foundation, it’s intended for development and customization. You'll need to tweak it to fit your production environment and business requirements.", - "home-section-faq-question-7": "How do I customize the template?", - "home-section-faq-answer-7": "You can easily modify the code to fit your specific use case. The architecture is modular, with separate components for authentication, payments, and user management, allowing for easy customization.", - "home-footer-title": "Ready to Get Started?", - "home-footer-join": "Join Sassy Today", - "home-footer-terms": "Terms & Privacy", - "home-footer-github": "GitHub", - "home-footer-copyright": "© 2025 Sassy. All rights reserved.", - "terms-privacy-title": "Terms and Privacy Policy", - "terms-privacy-terms-of-use": "Terms of Use", - "terms-privacy-terms-of-use-description": "Welcome to our service. By using our website, you agree to comply with the following terms and conditions.", - "terms-privacy-privacy-policy": "Privacy Policy", - "terms-privacy-privacy-policy-description": "We value your privacy and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, and safeguard your data.", - "terms-privacy-data-collection": "Data Collection", - "terms-privacy-data-collection-description": "We may collect personal information such as your name, email address, and usage data to improve our service and provide a better user experience.", - "terms-privacy-data-security": "Data Security", - "terms-privacy-data-security-description": "We implement a variety of security measures to ensure the safety of your personal data. However, no method of transmission over the internet is completely secure, so we cannot guarantee absolute security.", - "terms-privacy-changes-policy": "Changes to this Policy", - "terms-privacy-changes-policy-description": "We may update this Terms and Privacy Policy from time to time. Any changes will be posted on this page with an updated date.", - "terms-privacy-contact-us": "Contact Us", - "terms-privacy-contact-us-description": "If you have any questions about these terms or our privacy policy, feel free to contact us.", - "signIn-back-to-home": "Back To Home", - "signIn-title": "Sign In", - "signIn-subtitle": "Enter your details to use an account", - "signIn-invalid-email": "Invalid email format.", - "signIn-invalid-password": "Password must be at least 6 characters long.", - "signIn-invalid-credentials": "Invalid email or password.", - "signIn-general-error": "Something went wrong. Please try again.", - "signIn-email": "Email", - "signIn-password": "Password", - "signIn-forgot-password": "Forgot your password?", - "signIn-button": "Sign In", - "signUp-back-to-login": "Back To Login", - "signUp-title": "Sign Up", - "signUp-subtitle": "Create your account to get started", - "signUp-invalid-email": "Invalid email format.", - "signUp-invalid-password": "Password must be at least 6 characters long.", - "signUp-passwords-do-not-match": "Passwords do not match.", - "signUp-terms-not-accepted": "You must accept the terms and conditions.", - "signUp-general-error": "Something went wrong. Please try again.", - "signUp-email": "Email", - "signUp-password": "Password", - "signUp-confirm-password": "Confirm Password", - "signUp-terms-label": "I accept the Terms and Privacy Policy", - "signUp-button": "Sign Up", - "component-footer-auth-dont-have-account": "Don't have an account?", - "component-footer-auth-create-account": "Create an account", - "component-footer-auth-already-have-account": "Already have an account?", - "component-footer-auth-go-back-to-login": "Go back to login", - "forgot-password-title": "Forgot Password", - "forgot-password-description": "Enter your email to receive a password reset link.", - "forgot-password-email-label": "Email", - "forgot-password-email-placeholder": "Enter your email", - "forgot-password-button-text": "Send Reset Link", - "forgot-password-invalid-email": "Invalid email format.", - "forgot-password-general-error": "Something went wrong. Please try again.", - "forgot-password-check-inbox-title": "Check Your Inbox", - "forgot-password-check-inbox-description": "A password reset link has been sent to your email. Please check your inbox.", - "forgot-password-back-to-login": "Back To Login", - "confirm-signup-error-token-missing": "Invalid or missing token.", - "confirm-signup-error-failed": "Email confirmation failed. Please try again.", - "confirm-signup-error-unexpected": "An unexpected error occurred.", - "confirm-signup-back-to-login": "Back To Login", - "confirm-signup-email-confirmed": "Email Confirmed", - "confirm-signup-success-message": "Your email has been successfully confirmed.", - "confirm-signup-go-to-dashboard": "Go To Dashboard", - "confirm-signup-oauth-success": "OAuth Successfully", - "confirm-signup-oauth-success-message": "Your provider has been confirmed register.", - "new-password-error-token-missing": "Invalid or missing token.", - "new-password-error-password-valid": "Password must be at least 6 characters long.", - "new-password-error-password-match": "Passwords do not match.", - "new-password-error-general": "Failed to change the password. Please try again.", - "new-password-error-unexpected": "Something went wrong. Please try again.", - "new-password-back-to-login": "Back To Login", - "new-password-success-message": "Your password has been changed successfully!", - "new-password-create-title": "Create a New Password", - "new-password-subtitle": "Set your new password to continue", - "new-password-password-label": "Password", - "new-password-confirm-password-label": "Confirm Password", - "new-password-submit-button": "Change Password", - "payment-status-back-to-billing": "Back To Billing", - "payment-status-success-title": "Purchase Confirmed", - "payment-status-success-message": "Your purchase has been successfully confirmed.", - "payment-status-back-to-settings": "Back To Settings", - "payment-status-cancel-title": "Purchase Cancelled", - "payment-status-cancel-message": "Your purchase has been cancelled.", - "payment-status-back-to-home": "Back To Home", - "payment-status-unknown-title": "Unknown Status", - "payment-status-unknown-message": "The status of your purchase is unknown.", - "component-client-dashboard-modal-title": "Important Notice", - "component-client-dashboard-modal-text": "To test our app, you need to start a free trial. No features are available after the trial period.", - "component-client-dashboard-modal-button": "Start Free Trial", - "component-feature-menu-tab-free": "Free", - "component-feature-menu-tab-starter": "Starter/Creator", - "component-feature-menu-tab-pro": "Pro", - "component-dashboard-navbar-title": "Dashboard", - "component-my-account-button": "My Account", - "component-my-account-profile": "Profile", - "component-my-account-subscription": "Subscription", - "component-my-account-terms-privacy": "Terms and Privacy", - "component-my-account-sign-out": "Sign Out", - "settings-name-label": "Name", - "settings-email-label": "Email", - "subscription-current-plan": "Current Plan", - "subscription-current-plan-description": "You are currently on the {plan} plan.", - "component-manage-billing-title": "Subscription Details", - "component-manage-billing-button": "Manage billing info", - "component-manage-billing-error": "Could not redirect to the billing portal.", - "failed-to-create-billing-portal": "Failed to create billing portal session.", - "component-settings-options-change-password": "Change Password", - "component-settings-options-current-plan": "Current Plan", - "component-settings-options-manage-subscription": "Manage Subscription", - "component-settings-options-password-change-request-sent": "Password Change Request Sent!", - "component-settings-options-password-change-description": "Please check your inbox for an email to complete the process.", - "component-settings-options-password-change-failed": "Password Change Failed", - "component-settings-options-password-change-error": "An error occurred while processing your request. Please try again later." -} \ No newline at end of file diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json deleted file mode 100644 index ac8c1d6..0000000 --- a/src/locales/pt-BR.json +++ /dev/null @@ -1,202 +0,0 @@ -{ - "title": "Sassy - template poderoso para micro-SaaS", - "home-navbar-pricing": "Preços", - "home-navbar-features": "Funcionalidades", - "home-navbar-faq": "FAQ", - "home-navbar-signin": "Entrar", - "home-navbar-try": "Experimente", - "home-navbar-dashboard": "Painel", - "home-section-hero-title": "Bem-vindo ao Criador de Micro-SaaS", - "home-section-hero-description": "Dê vida à sua visão com ferramentas para construir soluções Micro-SaaS sem esforço.", - "home-section-hero-button": "Começar", - "home-section-how-title": "Como o Sassy Funciona", - "home-section-how-description": "Configure seu serviço de assinatura em apenas três etapas simples.", - "home-section-how-first-title": "Cadastre-se", - "home-section-how-first-description": "Crie uma conta e configure seu perfil. Rápido e seguro com integração ao Supabase.", - "home-section-how-second-title": "Integre o Stripe", - "home-section-how-second-description": "Conecte o Stripe para faturamento sem interrupções e gerencie assinaturas mensais ou anuais.", - "home-section-how-third-title": "Gerencie Assinaturas", - "home-section-how-third-description": "Gerencie facilmente suas assinaturas, com atualizações em tempo real.", - "component-pricing-title": "Planos de Preços", - "component-pricing-description": "Preços simples e transparentes para atender às suas necessidades.", - "component-pricing-trial-title": "Você tem um período de teste gratuito por", - "component-pricing-trial-description": "Experimente nosso serviço sem compromisso.", - "component-pricing-selected-tag": "Plano Selecionado", - "component-pricing-popular-tag": "Mais Popular", - "component-pricing-button": "Assinar", - "component-pricing-button-subscribed": "Plano Atual", - "component-pricing-plan-starter-title": "Iniciante", - "component-pricing-plan-starter-description": "Ideal para indivíduos lançando seu primeiro Micro-SaaS.", - "component-pricing-plan-starter-feature-first": "✔ Acesso às funcionalidades principais", - "component-pricing-plan-starter-feature-second": "✔ 1 projeto", - "component-pricing-plan-starter-feature-third": " ", - "component-pricing-plan-starter-extra": "Tudo no Plano Gratuito, mais", - "component-pricing-plan-creator-title": "Criador", - "component-pricing-plan-creator-description": "Ótimo para criadores que desejam expandir seu alcance.", - "component-pricing-plan-creator-feature-first": "✔ Acesso às funcionalidades principais", - "component-pricing-plan-creator-feature-second": "✔ 3 projetos", - "component-pricing-plan-creator-feature-third": "✔ Suporte por e-mail", - "component-pricing-plan-creator-extra": "Tudo no Plano Iniciante, mais", - "component-pricing-plan-pro-title": "Pro", - "component-pricing-plan-pro-description": "Perfeito para equipes que estão escalando seu negócio Micro-SaaS.", - "component-pricing-plan-pro-feature-first": "✔ Acesso às funcionalidades principais", - "component-pricing-plan-pro-feature-second": "✔ Projetos ilimitados", - "component-pricing-plan-pro-feature-third": "✔ Suporte prioritário", - "component-pricing-plan-pro-extra": "Tudo no Plano Criador, mais", - "component-pricing-toggle-monthly": "Mensal", - "component-pricing-toggle-annual": "Anual", - "component-pricing-subscription-plans-free-name": "Grátis", - "component-pricing-subscription-plans-free-price-monthly": "${value}/mês", - "component-pricing-subscription-plans-free-price-annual": "${value}/ano", - "component-pricing-subscription-plans-free-description": "Ideal para indivíduos que estão testando nosso serviço.", - "component-pricing-subscription-plans-free-feature-1": "✔ Acesso aos recursos básicos", - "component-pricing-subscription-plans-free-feature-2": "✔ 1 projeto", - "component-pricing-subscription-plans-free-extra-features": " ", - "home-section-features-title": "Funcionalidades Modernas para o Seu SaaS", - "home-section-features-description": "Tudo o que você precisa para construir poderosas aplicações Micro-SaaS.", - "home-section-feature-oauth-title": "Autenticação OAuth", - "home-section-feature-oauth-description": "Integração perfeita com Google, Facebook e Twitter para oferecer uma experiência de login fluida.", - "home-section-feature-subscription-title": "Gerenciamento de Assinaturas", - "home-section-feature-subscription-description": "Gerencie assinaturas facilmente com as ferramentas robustas do Stripe e atualizações em tempo real.", - "home-section-feature-responsive-title": "Design Responsivo", - "home-section-feature-responsive-description": "Desenvolvido com TailwindCSS para uma experiência sem interrupções em todos os dispositivos.", - "home-section-testimonials-title": "O que nossos usuários dizem", - "home-section-testimonials-description": "Ouça diretamente de nossos clientes satisfeitos.", - "home-section-testimonial-1": "Sassy foi um divisor de águas para o nosso projeto Micro-SaaS. A integração com o Stripe e o Supabase é perfeita e nos ajudou a começar muito mais rápido!", - "home-section-testimonial-2": "Eu estava procurando uma forma simples e confiável de gerenciar assinaturas para meu produto SaaS. A integração com o Stripe do Sassy é fantástica e fácil de usar!", - "home-section-testimonial-3": "A autenticação integrada com o Supabase e o gateway de pagamento via Stripe nos economizou horas de tempo de desenvolvimento. Recomendo muito!", - "home-section-testimonial-4": "Como desenvolvedor solo, eu precisava de uma base sólida para o meu Micro-SaaS. O Sassy forneceu isso e muito mais! A flexibilidade do template é incrível.", - "home-section-testimonial-5": "O design responsivo e a arquitetura limpa do Sassy facilitaram a implantação. Consegui focar na construção de funcionalidades ao invés de lidar com o básico.", - "home-section-testimonial-6": "A facilidade de integrar autenticação OAuth com Google e Facebook tornou o processo de login dos usuários muito mais simples. Esta plataforma é essencial para qualquer desenvolvedor.", - "home-section-testimonial-7": "Com o sistema de gerenciamento de assinaturas já implementado, pude me concentrar nas funcionalidades principais do meu produto. O Sassy foi um grande poupador de tempo.", - "home-section-testimonial-8": "Os webhooks do Stripe e o gerenciamento de assinaturas funcionaram perfeitamente no meu projeto. Fiquei realmente impressionado com a integração de tudo.", - "home-section-faq-title": "Perguntas Frequentes", - "home-section-faq-description": "Respostas para ajudar você a entender como aproveitar este template para iniciar o desenvolvimento do seu Micro-SaaS.", - "home-section-faq-question-1": "Para que serve este template?", - "home-section-faq-answer-1": "Este é um template poderoso projetado para acelerar o desenvolvimento de aplicações Micro-SaaS. Ele fornece uma configuração de exemplo para gerenciamento de assinaturas, mas você pode facilmente adaptá-lo para qualquer produto SaaS.", - "home-section-faq-question-2": "Como começo a usar o template?", - "home-section-faq-answer-2": "Comece clonando o repositório, configurando seu ambiente com Next.js 15 e configurando o TypeScript. O template está pronto para desenvolvimento com integrações como Stripe e Supabase para gerenciar seus dados de usuário e pagamentos.", - "home-section-faq-question-3": "Este template é focado em assinaturas?", - "home-section-faq-answer-3": "Assinaturas são apenas um exemplo de funcionalidade fornecida neste template. Você pode personalizá-lo para atender às necessidades do seu Micro-SaaS, seja para cobrança, autenticação ou qualquer outro aspecto do seu serviço.", - "home-section-faq-question-4": "Quais opções de pagamento estão incluídas?", - "home-section-faq-answer-4": "O template vem com um exemplo de gerenciamento de assinaturas mensais e anuais usando o Stripe. Você pode modificar o fluxo de pagamento para se ajustar ao modelo de negócios do seu aplicativo.", - "home-section-faq-question-5": "Posso integrar outros serviços neste template?", - "home-section-faq-answer-5": "Sim, o template foi desenvolvido para ser flexível. Ele suporta integrações com Supabase para gerenciamento de usuários, Stripe para pagamentos e OAuth para autenticação com serviços como Google, Facebook e Twitter.", - "home-section-faq-question-6": "Este template está pronto para produção?", - "home-section-faq-answer-6": "Embora o template forneça uma base sólida, ele é destinado ao desenvolvimento e personalização. Você precisará ajustá-lo para se adequar ao seu ambiente de produção e aos requisitos de negócios.", - "home-section-faq-question-7": "Como personalizo o template?", - "home-section-faq-answer-7": "Você pode facilmente modificar o código para atender ao seu caso específico. A arquitetura é modular, com componentes separados para autenticação, pagamentos e gerenciamento de usuários, permitindo uma personalização fácil.", - "home-footer-title": "Pronto para começar?", - "home-footer-join": "Junte-se ao Sassy Hoje", - "home-footer-terms": "Termos & Privacidade", - "home-footer-github": "GitHub", - "home-footer-copyright": "© 2025 Sassy. Todos os direitos reservados.", - "terms-privacy-title": "Termos e Política de Privacidade", - "terms-privacy-terms-of-use": "Termos de Uso", - "terms-privacy-terms-of-use-description": "Bem-vindo ao nosso serviço. Ao usar nosso site, você concorda em cumprir os seguintes termos e condições.", - "terms-privacy-privacy-policy": "Política de Privacidade", - "terms-privacy-privacy-policy-description": "Valorizamos sua privacidade e estamos comprometidos em proteger suas informações pessoais. Esta Política de Privacidade explica como coletamos, usamos e protegemos seus dados.", - "terms-privacy-data-collection": "Coleta de Dados", - "terms-privacy-data-collection-description": "Podemos coletar informações pessoais, como nome, endereço de e-mail e dados de uso, para melhorar nosso serviço e oferecer uma melhor experiência ao usuário.", - "terms-privacy-data-security": "Segurança dos Dados", - "terms-privacy-data-security-description": "Implementamos uma variedade de medidas de segurança para garantir a proteção dos seus dados pessoais. No entanto, nenhum método de transmissão pela internet é completamente seguro, portanto, não podemos garantir segurança absoluta.", - "terms-privacy-changes-policy": "Alterações nesta Política", - "terms-privacy-changes-policy-description": "Podemos atualizar esta Política de Termos e Privacidade de tempos em tempos. Quaisquer alterações serão publicadas nesta página com a data atualizada.", - "terms-privacy-contact-us": "Entre em Contato", - "terms-privacy-contact-us-description": "Se você tiver dúvidas sobre estes termos ou nossa política de privacidade, entre em contato conosco.", - "signIn-back-to-home": "Voltar para a Página Inicial", - "signIn-title": "Entrar", - "signIn-subtitle": "Informe seus dados para acessar sua conta", - "signIn-invalid-email": "Formato de e-mail inválido.", - "signIn-invalid-password": "A senha deve ter pelo menos 6 caracteres.", - "signIn-invalid-credentials": "E-mail ou senha inválidos.", - "signIn-general-error": "Algo deu errado. Por favor, tente novamente.", - "signIn-email": "E-mail", - "signIn-password": "Senha", - "signIn-forgot-password": "Esqueceu sua senha?", - "signIn-button": "Entrar", - "signUp-back-to-login": "Voltar para Login", - "signUp-title": "Cadastrar-se", - "signUp-subtitle": "Crie sua conta para começar", - "signUp-invalid-email": "Formato de e-mail inválido.", - "signUp-invalid-password": "A senha deve ter pelo menos 6 caracteres.", - "signUp-passwords-do-not-match": "As senhas não coincidem.", - "signUp-terms-not-accepted": "Você deve aceitar os termos e condições.", - "signUp-general-error": "Algo deu errado. Por favor, tente novamente.", - "signUp-email": "E-mail", - "signUp-password": "Senha", - "signUp-confirm-password": "Confirmar Senha", - "signUp-terms-label": "Eu aceito os Termos e a Política de Privacidade", - "signUp-button": "Cadastrar", - "component-footer-auth-dont-have-account": "Não tem uma conta?", - "component-footer-auth-create-account": "Crie uma conta", - "component-footer-auth-already-have-account": "Já tem uma conta?", - "component-footer-auth-go-back-to-login": "Voltar para o login", - "forgot-password-title": "Esqueceu a Senha", - "forgot-password-description": "Digite seu e-mail para receber o link de redefinição de senha.", - "forgot-password-email-label": "E-mail", - "forgot-password-email-placeholder": "Digite seu e-mail", - "forgot-password-button-text": "Enviar Link de Redefinição", - "forgot-password-invalid-email": "Formato de e-mail inválido.", - "forgot-password-general-error": "Algo deu errado. Por favor, tente novamente.", - "forgot-password-check-inbox-title": "Verifique Sua Caixa de Entrada", - "forgot-password-check-inbox-description": "Um link para redefinir a senha foi enviado para seu e-mail. Por favor, verifique sua caixa de entrada.", - "forgot-password-back-to-login": "Voltar para Login", - "confirm-signup-error-token-missing": "Token inválido ou ausente.", - "confirm-signup-error-failed": "Falha na confirmação de e-mail. Por favor, tente novamente.", - "confirm-signup-error-unexpected": "Ocorreu um erro inesperado.", - "confirm-signup-back-to-login": "Voltar para Login", - "confirm-signup-email-confirmed": "E-mail Confirmado", - "confirm-signup-success-message": "Seu e-mail foi confirmado com sucesso.", - "confirm-signup-go-to-dashboard": "Ir para o Painel", - "confirm-signup-oauth-success": "OAuth Bem-sucedido", - "confirm-signup-oauth-success-message": "Seu provedor foi confirmado com sucesso.", - "new-password-error-token-missing": "Token inválido ou ausente.", - "new-password-error-password-valid": "A senha deve ter pelo menos 6 caracteres.", - "new-password-error-password-match": "As senhas não coincidem.", - "new-password-error-general": "Falha ao alterar a senha. Por favor, tente novamente.", - "new-password-error-unexpected": "Algo deu errado. Por favor, tente novamente.", - "new-password-back-to-login": "Voltar para Login", - "new-password-success-message": "Sua senha foi alterada com sucesso!", - "new-password-create-title": "Criar uma Nova Senha", - "new-password-subtitle": "Defina sua nova senha para continuar", - "new-password-password-label": "Senha", - "new-password-confirm-password-label": "Confirmar Senha", - "new-password-submit-button": "Alterar Senha", - "payment-status-back-to-billing": "Voltar para Faturamento", - "payment-status-success-title": "Compra Confirmada", - "payment-status-success-message": "Sua compra foi confirmada com sucesso.", - "payment-status-back-to-settings": "Voltar para Configurações", - "payment-status-cancel-title": "Compra Cancelada", - "payment-status-cancel-message": "Sua compra foi cancelada.", - "payment-status-back-to-home": "Voltar para Início", - "payment-status-unknown-title": "Status Desconhecido", - "payment-status-unknown-message": "O status da sua compra é desconhecido.", - "component-client-dashboard-modal-title": "Aviso Importante", - "component-client-dashboard-modal-text": "Para testar nosso aplicativo, você precisa iniciar um teste gratuito. Nenhuma funcionalidade estará disponível após o período de teste.", - "component-client-dashboard-modal-button": "Iniciar Teste Gratuito", - "component-feature-menu-tab-free": "Grátis", - "component-feature-menu-tab-starter": "Starter/Criador", - "component-feature-menu-tab-pro": "Pro", - "component-dashboard-navbar-title": "Painel", - "component-my-account-button": "Minha Conta", - "component-my-account-profile": "Perfil", - "component-my-account-subscription": "Assinatura", - "component-my-account-terms-privacy": "Termos e Privacidade", - "component-my-account-sign-out": "Sair", - "settings-name-label": "Nome", - "settings-email-label": "E-mail", - "subscription-current-plan": "Plano Atual", - "subscription-current-plan-description": "Você está no plano {plan}.", - "component-manage-billing-title": "Detalhes da Assinatura", - "component-manage-billing-button": "Gerenciar informações de faturamento", - "component-manage-billing-error": "Não foi possível redirecionar para o portal de faturamento.", - "failed-to-create-billing-portal": "Falha ao criar a sessão do portal de faturamento.", - "component-settings-options-change-password": "Alterar Senha", - "component-settings-options-current-plan": "Plano Atual", - "component-settings-options-manage-subscription": "Gerenciar Assinatura", - "component-settings-options-password-change-request-sent": "Solicitação de Alteração de Senha Enviada!", - "component-settings-options-password-change-description": "Por favor, verifique sua caixa de entrada para um e-mail para concluir o processo.", - "component-settings-options-password-change-failed": "Falha na Alteração de Senha", - "component-settings-options-password-change-error": "Ocorreu um erro ao processar sua solicitação. Por favor, tente novamente mais tarde." -} diff --git a/src/utils/loadTranslationsSSR.ts b/src/utils/loadTranslationsSSR.ts index 1a72272..4df278c 100644 --- a/src/utils/loadTranslationsSSR.ts +++ b/src/utils/loadTranslationsSSR.ts @@ -1,25 +1,64 @@ import { cookies } from 'next/headers'; +import { Translations } from '@/contexts/i18nContext'; + import enUSLocale from '../../public/locales/en-US.json'; import ptBRLocale from '../../public/locales/pt-BR.json'; -type Translations = Record; - -export async function loadTranslationsSSR(locale?: string): Promise<{ translate: (key: string) => string; translations: Translations; locale: string }> { - let resolvedLocale: string = locale || 'en-US'; +type SupportedLocale = 'en-US' | 'pt-BR'; +type TranslationsMap = Record; - if (!locale) { - const cookieStore = await cookies(); - resolvedLocale = cookieStore.get('locale')?.value || 'en-US'; - } +const DEFAULT_LOCALE: SupportedLocale = 'en-US'; - const translationsMap: Record = { - 'en-US': enUSLocale, - 'pt-BR': ptBRLocale, - }; +const translationsMap: TranslationsMap = { + 'en-US': enUSLocale, + 'pt-BR': ptBRLocale, +}; - const translations = translationsMap[resolvedLocale as keyof typeof translationsMap] || enUSLocale; - const translate = (key: string) => translations[key] || key; +function isValidLocale(locale: string): locale is SupportedLocale { + return Object.keys(translationsMap).includes(locale); +} - return { translate, translations, locale: resolvedLocale }; +function resolveTranslationValue(obj: unknown, key: string): string { + if (typeof obj === 'string') return obj; + if (obj === undefined || obj === null) return key; + if (typeof obj !== 'object') return key; + return key; } + +export async function loadTranslationsSSR(locale?: string) { + let resolvedLocale: SupportedLocale; + + if (locale && isValidLocale(locale)) { + resolvedLocale = locale; + } else { + const cookieStore = await cookies(); + const cookieLocale = cookieStore.get('locale')?.value; + resolvedLocale = cookieLocale && isValidLocale(cookieLocale) + ? cookieLocale + : DEFAULT_LOCALE; + } + + const translations = translationsMap[resolvedLocale]; + + const translate = (key: string): string => { + const keys = key.split('.'); + let value: unknown = translations; + + for (const k of keys) { + if (typeof value === 'object' && value !== null && k in value) { + value = (value as Record)[k]; + } else { + return key; + } + } + + return resolveTranslationValue(value, key); + }; + + return { + translate, + translations, + locale: resolvedLocale + }; +} \ No newline at end of file diff --git a/src/utils/transformPurchasePlansDTO.ts b/src/utils/transformPurchasePlansDTO.ts index 5bdf1b9..c190770 100644 --- a/src/utils/transformPurchasePlansDTO.ts +++ b/src/utils/transformPurchasePlansDTO.ts @@ -27,36 +27,36 @@ export function transformPurchasePlansDTO(data: InputData[], translate: (key: st const planDetails: Record = { "Starter": { id: "starter", - name: translate('component-pricing-plan-starter-title'), - description: translate('component-pricing-plan-starter-description'), + name: translate('components.pricing.plans.starter.title'), + description: translate('components.pricing.plans.starter.description'), features: [ - translate('component-pricing-plan-starter-feature-first'), - translate('component-pricing-plan-starter-feature-second'), - translate('component-pricing-plan-starter-feature-third'), + translate('components.pricing.plans.starter.features.first'), + translate('components.pricing.plans.starter.features.second'), + translate('components.pricing.plans.starter.features.third'), ], - extraFeatures: translate('component-pricing-plan-starter-extra') + extraFeatures: translate('components.pricing.plans.starter.extra') }, "Creator": { id: "creator", - name: translate('component-pricing-plan-creator-title'), - description: translate('component-pricing-plan-creator-description'), + name: translate('components.pricing.plans.creator.title'), + description: translate('components.pricing.plans.creator.description'), features: [ - translate('component-pricing-plan-creator-feature-first'), - translate('component-pricing-plan-creator-feature-second'), - translate('component-pricing-plan-creator-feature-third'), + translate('components.pricing.plans.creator.features.first'), + translate('components.pricing.plans.creator.features.second'), + translate('components.pricing.plans.creator.features.third'), ], - extraFeatures: translate('component-pricing-plan-creator-extra') + extraFeatures: translate('components.pricing.plans.creator.extra') }, "Pro": { id: "pro", - name: translate('component-pricing-plan-pro-title'), - description: translate('component-pricing-plan-pro-description'), + name: translate('components.pricing.plans.pro.title'), + description: translate('components.pricing.plans.pro.description'), features: [ - translate('component-pricing-plan-pro-feature-first'), - translate('component-pricing-plan-pro-feature-second'), - translate('component-pricing-plan-pro-feature-third'), + translate('components.pricing.plans.pro.features.first'), + translate('components.pricing.plans.pro.features.second'), + translate('components.pricing.plans.pro.features.third'), ], - extraFeatures: translate('component-pricing-plan-pro-extra') + extraFeatures: translate('components.pricing.plans.pro.extra') } }; @@ -67,10 +67,10 @@ export function transformPurchasePlansDTO(data: InputData[], translate: (key: st if (interval === 'month') { - plansMap[planName].priceMonthly = formatPrice('component-pricing-subscription-plans-free-price-monthly', String(newUnitAmount)); + plansMap[planName].priceMonthly = formatPrice('components.pricing.plans.prices.monthly', String(newUnitAmount)); plansMap[planName].idMonthly = id; } else if (interval === 'year') { - plansMap[planName].priceAnnual = formatPrice('component-pricing-subscription-plans-free-price-annual', String(newUnitAmount)); + plansMap[planName].priceAnnual = formatPrice('components.pricing.plans.prices.annual', String(newUnitAmount)); plansMap[planName].idAnnual = id; } }; From bff6ea9e6654b18440362a4f07fe83734979c0ff Mon Sep 17 00:00:00 2001 From: Marcelo dos Reis Date: Fri, 18 Apr 2025 15:20:58 -0300 Subject: [PATCH 07/11] chore: renaming consts files --- src/app/(domains)/(auth)/signin/page.tsx | 2 +- src/app/(domains)/(home)/_sections/Footer.tsx | 2 +- src/components/FooterAuthScreen.tsx | 2 +- src/components/OAuth.tsx | 2 +- src/components/PasswordStrength.tsx | 2 +- src/components/Pricing/index.tsx | 2 +- src/constants/SUBSCRIPTION_PLANS_BASE.ts | 0 .../{EMAILS.ts => finish-checkout-email.ts} | 0 .../{FIXED_CURRENCY.ts => fixed-currency.ts} | 0 .../{HAS_FREE_TRIAL.ts => has-free-trial.ts} | 0 ...PTIONS.ts => password-strength-options.ts} | 0 ...RS_IMAGE_URL.ts => providers-image-url.ts} | 0 .../{ROUTES.ts => routes-constants.ts} | 0 src/hooks/useCheckout.ts | 4 +- src/hooks/useFetchPlans.ts | 4 +- src/pages/api/webhooks.ts | 70 +++++++++++-------- 16 files changed, 49 insertions(+), 41 deletions(-) delete mode 100644 src/constants/SUBSCRIPTION_PLANS_BASE.ts rename src/constants/{EMAILS.ts => finish-checkout-email.ts} (100%) rename src/constants/{FIXED_CURRENCY.ts => fixed-currency.ts} (100%) rename src/constants/{HAS_FREE_TRIAL.ts => has-free-trial.ts} (100%) rename src/constants/{PASSWORD_STRENGTH_OPTIONS.ts => password-strength-options.ts} (100%) rename src/constants/{PROVIDERS_IMAGE_URL.ts => providers-image-url.ts} (100%) rename src/constants/{ROUTES.ts => routes-constants.ts} (100%) diff --git a/src/app/(domains)/(auth)/signin/page.tsx b/src/app/(domains)/(auth)/signin/page.tsx index e931bdc..ffa6eb5 100644 --- a/src/app/(domains)/(auth)/signin/page.tsx +++ b/src/app/(domains)/(auth)/signin/page.tsx @@ -9,7 +9,7 @@ import ButtonComponent from "@/components/Button"; import FooterAuthScreenComponent from "@/components/FooterAuthScreen"; import InputComponent from "@/components/Input"; import OAuth from "@/components/OAuth"; -import { ROUTES } from '@/constants/ROUTES'; +import { ROUTES } from '@/constants/routes-constants'; import { useI18n } from '@/hooks/useI18n'; import { supabase } from '@/libs/supabase/client'; import AuthService from '@/services/auth'; diff --git a/src/app/(domains)/(home)/_sections/Footer.tsx b/src/app/(domains)/(home)/_sections/Footer.tsx index 5919595..1ff341a 100644 --- a/src/app/(domains)/(home)/_sections/Footer.tsx +++ b/src/app/(domains)/(home)/_sections/Footer.tsx @@ -1,6 +1,6 @@ "use client"; -import { ROUTES } from "@/constants/ROUTES"; +import { ROUTES } from "@/constants/routes-constants"; import { useI18n } from "@/hooks/useI18n" type FooterProps = { diff --git a/src/components/FooterAuthScreen.tsx b/src/components/FooterAuthScreen.tsx index 0c7437e..f0a3f87 100644 --- a/src/components/FooterAuthScreen.tsx +++ b/src/components/FooterAuthScreen.tsx @@ -1,4 +1,4 @@ -import { ROUTES } from "@/constants/ROUTES"; +import { ROUTES } from "@/constants/routes-constants"; import { useI18n } from '@/hooks/useI18n'; type FooterAuthScreenProps = { diff --git a/src/components/OAuth.tsx b/src/components/OAuth.tsx index 053fd1c..932cac7 100644 --- a/src/components/OAuth.tsx +++ b/src/components/OAuth.tsx @@ -1,4 +1,4 @@ -import { PROVIDERS_IMAGE_URL } from "@/constants/PROVIDERS_IMAGE_URL"; +import { PROVIDERS_IMAGE_URL } from "@/constants/providers-image-url"; import { supabase } from "@/libs/supabase/client"; import AuthService from "@/services/auth"; diff --git a/src/components/PasswordStrength.tsx b/src/components/PasswordStrength.tsx index bd8a49f..5ea804a 100644 --- a/src/components/PasswordStrength.tsx +++ b/src/components/PasswordStrength.tsx @@ -1,4 +1,4 @@ -import { PASSWORD_STRENGTH_OPTIONS } from "@/constants/PASSWORD_STRENGTH_OPTIONS"; +import { PASSWORD_STRENGTH_OPTIONS } from "@/constants/password-strength-options"; type PasswordStrengthProps = { password: string; diff --git a/src/components/Pricing/index.tsx b/src/components/Pricing/index.tsx index 08c0985..0e9cb18 100644 --- a/src/components/Pricing/index.tsx +++ b/src/components/Pricing/index.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; -import { HAS_FREE_TRIAL } from '@/constants/HAS_FREE_TRIAL'; +import { HAS_FREE_TRIAL } from '@/constants/has-free-trial'; import { useCheckout } from '@/hooks/useCheckout'; import { useFetchPlans } from '@/hooks/useFetchPlans'; import { useI18n } from '@/hooks/useI18n'; diff --git a/src/constants/SUBSCRIPTION_PLANS_BASE.ts b/src/constants/SUBSCRIPTION_PLANS_BASE.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/constants/EMAILS.ts b/src/constants/finish-checkout-email.ts similarity index 100% rename from src/constants/EMAILS.ts rename to src/constants/finish-checkout-email.ts diff --git a/src/constants/FIXED_CURRENCY.ts b/src/constants/fixed-currency.ts similarity index 100% rename from src/constants/FIXED_CURRENCY.ts rename to src/constants/fixed-currency.ts diff --git a/src/constants/HAS_FREE_TRIAL.ts b/src/constants/has-free-trial.ts similarity index 100% rename from src/constants/HAS_FREE_TRIAL.ts rename to src/constants/has-free-trial.ts diff --git a/src/constants/PASSWORD_STRENGTH_OPTIONS.ts b/src/constants/password-strength-options.ts similarity index 100% rename from src/constants/PASSWORD_STRENGTH_OPTIONS.ts rename to src/constants/password-strength-options.ts diff --git a/src/constants/PROVIDERS_IMAGE_URL.ts b/src/constants/providers-image-url.ts similarity index 100% rename from src/constants/PROVIDERS_IMAGE_URL.ts rename to src/constants/providers-image-url.ts diff --git a/src/constants/ROUTES.ts b/src/constants/routes-constants.ts similarity index 100% rename from src/constants/ROUTES.ts rename to src/constants/routes-constants.ts diff --git a/src/hooks/useCheckout.ts b/src/hooks/useCheckout.ts index 263650b..ec4e797 100644 --- a/src/hooks/useCheckout.ts +++ b/src/hooks/useCheckout.ts @@ -1,6 +1,6 @@ import { Plan } from "@/components/Pricing/PlanCard"; -import { FIXED_CURRENCY } from "@/constants/FIXED_CURRENCY"; -import { HAS_FREE_TRIAL } from "@/constants/HAS_FREE_TRIAL"; +import { FIXED_CURRENCY } from "@/constants/fixed-currency"; +import { HAS_FREE_TRIAL } from "@/constants/has-free-trial"; import { useToast } from "@/hooks/useToast"; import { supabase } from '@/libs/supabase/client'; import AuthService from '@/services/auth'; diff --git a/src/hooks/useFetchPlans.ts b/src/hooks/useFetchPlans.ts index 57daa52..7baee3b 100644 --- a/src/hooks/useFetchPlans.ts +++ b/src/hooks/useFetchPlans.ts @@ -1,8 +1,8 @@ import { useState, useEffect } from "react"; import { Plan } from "@/components/Pricing/PlanCard"; -import { FIXED_CURRENCY } from "@/constants/FIXED_CURRENCY"; -import { HAS_FREE_TRIAL } from "@/constants/HAS_FREE_TRIAL"; +import { FIXED_CURRENCY } from "@/constants/fixed-currency"; +import { HAS_FREE_TRIAL } from "@/constants/has-free-trial"; import { useI18n } from "./useI18n"; diff --git a/src/pages/api/webhooks.ts b/src/pages/api/webhooks.ts index d1bad95..32a2979 100644 --- a/src/pages/api/webhooks.ts +++ b/src/pages/api/webhooks.ts @@ -1,12 +1,12 @@ -import { NextApiRequest, NextApiResponse } from 'next'; +import { NextApiRequest, NextApiResponse } from "next"; -import { FINISH_CHECKOUT_EMAIL } from '@/constants/EMAILS'; -import { stripe } from '@/libs/stripe'; -import { supabaseServerClient } from '@/libs/supabase/server'; -import AuthService from '@/services/auth'; -import EmailService from '@/services/email'; -import PaymentService from '@/services/payment'; -import SubscriptionService from '@/services/subscription'; +import { FINISH_CHECKOUT_EMAIL } from "@/constants/finish-checkout-email"; +import { stripe } from "@/libs/stripe"; +import { supabaseServerClient } from "@/libs/supabase/server"; +import AuthService from "@/services/auth"; +import EmailService from "@/services/email"; +import PaymentService from "@/services/payment"; +import SubscriptionService from "@/services/subscription"; const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; @@ -21,38 +21,45 @@ async function getRawBody(req: NextApiRequest): Promise { for await (const chunk of req) { chunks.push(chunk); } - return Buffer.concat(chunks).toString('utf-8'); + return Buffer.concat(chunks).toString("utf-8"); } - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === 'POST') { +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method === "POST") { const PaymentServiceInstance = new PaymentService(stripe); const AuthServiceInstance = new AuthService(supabaseServerClient); - const SubscriptionServiceInstance = new SubscriptionService(supabaseServerClient); + const SubscriptionServiceInstance = new SubscriptionService( + supabaseServerClient + ); const EmailServiceInstance = new EmailService(); - - - const sig = req.headers['stripe-signature']; + const sig = req.headers["stripe-signature"]; const rawBody = await getRawBody(req); if (!sig || !endpointSecret) { - return res.status(400).send('Webhook Error: Missing Stripe signature'); + return res.status(400).send("Webhook Error: Missing Stripe signature"); } let event; try { - event = PaymentServiceInstance.constructWebhookEvent(rawBody, sig, endpointSecret); + event = PaymentServiceInstance.constructWebhookEvent( + rawBody, + sig, + endpointSecret + ); } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Erro desconhecido'; - console.error('Erro ao verificar a assinatura do webhook:', errorMessage); + const errorMessage = + err instanceof Error ? err.message : "Erro desconhecido"; + console.error("Erro ao verificar a assinatura do webhook:", errorMessage); return res.status(400).send(`Webhook Error: ${errorMessage}`); } try { switch (event.type) { - case 'checkout.session.completed': { + case "checkout.session.completed": { // eslint-disable-next-line @typescript-eslint/no-explicit-any const session = event.data.object as any; const { userId, plan } = session.metadata; @@ -60,15 +67,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) user_id: userId, stripe_subscription_id: session.subscription, plan, - status: 'active', + status: "active", current_period_start: new Date(session.current_period_start * 1000), current_period_end: new Date(session.current_period_end * 1000), }); const email = (await AuthServiceInstance.getUserById(userId))?.email; - if (!email) throw new Error("Missing User Data in Completed Checkout"); - + if (!email) + throw new Error("Missing User Data in Completed Checkout"); + await EmailServiceInstance.sendEmail({ - from: 'Sassy - Powerful Micro-SaaS', + from: "Sassy - Powerful Micro-SaaS", to: [email], subject: "Welcome to Sassy!", text: "Welcome to Sassy! Your subscription has been activated.", @@ -77,7 +85,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) break; } - case 'customer.subscription.updated': { + case "customer.subscription.updated": { // eslint-disable-next-line @typescript-eslint/no-explicit-any const subscription = event.data.object as any; @@ -94,7 +102,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) break; } - case 'customer.subscription.deleted': { + case "customer.subscription.deleted": { // eslint-disable-next-line @typescript-eslint/no-explicit-any const subscription = event.data.object as any; await SubscriptionServiceInstance.cancelSubscription(subscription.id); @@ -106,11 +114,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } res.json({ received: true }); } catch (error) { - console.error('Error handling webhook event:', error); - res.status(500).send('Internal Server Error'); + console.error("Error handling webhook event:", error); + res.status(500).send("Internal Server Error"); } } else { - res.setHeader('Allow', 'POST'); - res.status(405).end('Method Not Allowed'); + res.setHeader("Allow", "POST"); + res.status(405).end("Method Not Allowed"); } } From b3e0ce51ae8f5543751e25432a02f64b33f2f5ad Mon Sep 17 00:00:00 2001 From: Marcelo dos Reis Date: Fri, 18 Apr 2025 15:27:44 -0300 Subject: [PATCH 08/11] chore: renaming frontend --- .../(auth)/confirm-signup/page.tsx | 0 .../(auth)/forgot-password/page.tsx | 0 src/app/{(domains) => (frontend)}/(auth)/layout.tsx | 0 .../(auth)/new-password/page.tsx | 0 .../{(domains) => (frontend)}/(auth)/signin/page.tsx | 0 .../{(domains) => (frontend)}/(auth)/signup/page.tsx | 0 .../(home)/_sections/FaqSection.tsx | 0 .../(home)/_sections/FeaturesSection.tsx | 0 .../(home)/_sections/Footer.tsx | 0 .../(home)/_sections/HeroSection.tsx | 0 .../(home)/_sections/HowItWorksSection.tsx | 0 .../(home)/_sections/TestimonialSection.tsx | 0 src/app/{(domains) => (frontend)}/(home)/page.tsx | 10 +++++----- .../(home)/terms-and-privacy/page.tsx | 0 src/app/{(domains) => (frontend)}/(payment)/layout.tsx | 0 .../(payment)/payments/page.tsx | 0 src/app/{(domains) => (frontend)}/dashboard/layout.tsx | 0 src/app/{(domains) => (frontend)}/dashboard/page.tsx | 0 .../dashboard/settings/page.tsx | 0 .../dashboard/subscription/page.tsx | 0 20 files changed, 5 insertions(+), 5 deletions(-) rename src/app/{(domains) => (frontend)}/(auth)/confirm-signup/page.tsx (100%) rename src/app/{(domains) => (frontend)}/(auth)/forgot-password/page.tsx (100%) rename src/app/{(domains) => (frontend)}/(auth)/layout.tsx (100%) rename src/app/{(domains) => (frontend)}/(auth)/new-password/page.tsx (100%) rename src/app/{(domains) => (frontend)}/(auth)/signin/page.tsx (100%) rename src/app/{(domains) => (frontend)}/(auth)/signup/page.tsx (100%) rename src/app/{(domains) => (frontend)}/(home)/_sections/FaqSection.tsx (100%) rename src/app/{(domains) => (frontend)}/(home)/_sections/FeaturesSection.tsx (100%) rename src/app/{(domains) => (frontend)}/(home)/_sections/Footer.tsx (100%) rename src/app/{(domains) => (frontend)}/(home)/_sections/HeroSection.tsx (100%) rename src/app/{(domains) => (frontend)}/(home)/_sections/HowItWorksSection.tsx (100%) rename src/app/{(domains) => (frontend)}/(home)/_sections/TestimonialSection.tsx (100%) rename src/app/{(domains) => (frontend)}/(home)/page.tsx (59%) rename src/app/{(domains) => (frontend)}/(home)/terms-and-privacy/page.tsx (100%) rename src/app/{(domains) => (frontend)}/(payment)/layout.tsx (100%) rename src/app/{(domains) => (frontend)}/(payment)/payments/page.tsx (100%) rename src/app/{(domains) => (frontend)}/dashboard/layout.tsx (100%) rename src/app/{(domains) => (frontend)}/dashboard/page.tsx (100%) rename src/app/{(domains) => (frontend)}/dashboard/settings/page.tsx (100%) rename src/app/{(domains) => (frontend)}/dashboard/subscription/page.tsx (100%) diff --git a/src/app/(domains)/(auth)/confirm-signup/page.tsx b/src/app/(frontend)/(auth)/confirm-signup/page.tsx similarity index 100% rename from src/app/(domains)/(auth)/confirm-signup/page.tsx rename to src/app/(frontend)/(auth)/confirm-signup/page.tsx diff --git a/src/app/(domains)/(auth)/forgot-password/page.tsx b/src/app/(frontend)/(auth)/forgot-password/page.tsx similarity index 100% rename from src/app/(domains)/(auth)/forgot-password/page.tsx rename to src/app/(frontend)/(auth)/forgot-password/page.tsx diff --git a/src/app/(domains)/(auth)/layout.tsx b/src/app/(frontend)/(auth)/layout.tsx similarity index 100% rename from src/app/(domains)/(auth)/layout.tsx rename to src/app/(frontend)/(auth)/layout.tsx diff --git a/src/app/(domains)/(auth)/new-password/page.tsx b/src/app/(frontend)/(auth)/new-password/page.tsx similarity index 100% rename from src/app/(domains)/(auth)/new-password/page.tsx rename to src/app/(frontend)/(auth)/new-password/page.tsx diff --git a/src/app/(domains)/(auth)/signin/page.tsx b/src/app/(frontend)/(auth)/signin/page.tsx similarity index 100% rename from src/app/(domains)/(auth)/signin/page.tsx rename to src/app/(frontend)/(auth)/signin/page.tsx diff --git a/src/app/(domains)/(auth)/signup/page.tsx b/src/app/(frontend)/(auth)/signup/page.tsx similarity index 100% rename from src/app/(domains)/(auth)/signup/page.tsx rename to src/app/(frontend)/(auth)/signup/page.tsx diff --git a/src/app/(domains)/(home)/_sections/FaqSection.tsx b/src/app/(frontend)/(home)/_sections/FaqSection.tsx similarity index 100% rename from src/app/(domains)/(home)/_sections/FaqSection.tsx rename to src/app/(frontend)/(home)/_sections/FaqSection.tsx diff --git a/src/app/(domains)/(home)/_sections/FeaturesSection.tsx b/src/app/(frontend)/(home)/_sections/FeaturesSection.tsx similarity index 100% rename from src/app/(domains)/(home)/_sections/FeaturesSection.tsx rename to src/app/(frontend)/(home)/_sections/FeaturesSection.tsx diff --git a/src/app/(domains)/(home)/_sections/Footer.tsx b/src/app/(frontend)/(home)/_sections/Footer.tsx similarity index 100% rename from src/app/(domains)/(home)/_sections/Footer.tsx rename to src/app/(frontend)/(home)/_sections/Footer.tsx diff --git a/src/app/(domains)/(home)/_sections/HeroSection.tsx b/src/app/(frontend)/(home)/_sections/HeroSection.tsx similarity index 100% rename from src/app/(domains)/(home)/_sections/HeroSection.tsx rename to src/app/(frontend)/(home)/_sections/HeroSection.tsx diff --git a/src/app/(domains)/(home)/_sections/HowItWorksSection.tsx b/src/app/(frontend)/(home)/_sections/HowItWorksSection.tsx similarity index 100% rename from src/app/(domains)/(home)/_sections/HowItWorksSection.tsx rename to src/app/(frontend)/(home)/_sections/HowItWorksSection.tsx diff --git a/src/app/(domains)/(home)/_sections/TestimonialSection.tsx b/src/app/(frontend)/(home)/_sections/TestimonialSection.tsx similarity index 100% rename from src/app/(domains)/(home)/_sections/TestimonialSection.tsx rename to src/app/(frontend)/(home)/_sections/TestimonialSection.tsx diff --git a/src/app/(domains)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx similarity index 59% rename from src/app/(domains)/(home)/page.tsx rename to src/app/(frontend)/(home)/page.tsx index 7bdd97d..ccae683 100644 --- a/src/app/(domains)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -1,8 +1,8 @@ -import FaqSection from "@/app/(domains)/(home)/_sections/FaqSection"; -import FeaturesSection from "@/app/(domains)/(home)/_sections/FeaturesSection"; -import HeroSection from "@/app/(domains)/(home)/_sections/HeroSection"; -import HowItWorksSection from "@/app/(domains)/(home)/_sections/HowItWorksSection"; -import TestimonialSection from "@/app/(domains)/(home)/_sections/TestimonialSection"; +import FaqSection from "@/app/(frontend)/(home)/_sections/FaqSection"; +import FeaturesSection from "@/app/(frontend)/(home)/_sections/FeaturesSection"; +import HeroSection from "@/app/(frontend)/(home)/_sections/HeroSection"; +import HowItWorksSection from "@/app/(frontend)/(home)/_sections/HowItWorksSection"; +import TestimonialSection from "@/app/(frontend)/(home)/_sections/TestimonialSection"; import Navbar from "@/components/Navbar"; import PricingSection from "@/components/Pricing"; diff --git a/src/app/(domains)/(home)/terms-and-privacy/page.tsx b/src/app/(frontend)/(home)/terms-and-privacy/page.tsx similarity index 100% rename from src/app/(domains)/(home)/terms-and-privacy/page.tsx rename to src/app/(frontend)/(home)/terms-and-privacy/page.tsx diff --git a/src/app/(domains)/(payment)/layout.tsx b/src/app/(frontend)/(payment)/layout.tsx similarity index 100% rename from src/app/(domains)/(payment)/layout.tsx rename to src/app/(frontend)/(payment)/layout.tsx diff --git a/src/app/(domains)/(payment)/payments/page.tsx b/src/app/(frontend)/(payment)/payments/page.tsx similarity index 100% rename from src/app/(domains)/(payment)/payments/page.tsx rename to src/app/(frontend)/(payment)/payments/page.tsx diff --git a/src/app/(domains)/dashboard/layout.tsx b/src/app/(frontend)/dashboard/layout.tsx similarity index 100% rename from src/app/(domains)/dashboard/layout.tsx rename to src/app/(frontend)/dashboard/layout.tsx diff --git a/src/app/(domains)/dashboard/page.tsx b/src/app/(frontend)/dashboard/page.tsx similarity index 100% rename from src/app/(domains)/dashboard/page.tsx rename to src/app/(frontend)/dashboard/page.tsx diff --git a/src/app/(domains)/dashboard/settings/page.tsx b/src/app/(frontend)/dashboard/settings/page.tsx similarity index 100% rename from src/app/(domains)/dashboard/settings/page.tsx rename to src/app/(frontend)/dashboard/settings/page.tsx diff --git a/src/app/(domains)/dashboard/subscription/page.tsx b/src/app/(frontend)/dashboard/subscription/page.tsx similarity index 100% rename from src/app/(domains)/dashboard/subscription/page.tsx rename to src/app/(frontend)/dashboard/subscription/page.tsx From 2be154121387ba8dbb0b91e85dd24e5a82ae1e29 Mon Sep 17 00:00:00 2001 From: Marcelo dos Reis Date: Fri, 18 Apr 2025 15:31:37 -0300 Subject: [PATCH 09/11] chore: update apiVersion in stripe --- src/libs/stripe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/stripe.ts b/src/libs/stripe.ts index c94e8aa..9d8e443 100644 --- a/src/libs/stripe.ts +++ b/src/libs/stripe.ts @@ -1,6 +1,6 @@ import Stripe from 'stripe'; export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { - apiVersion: '2024-12-18.acacia', + apiVersion: '2025-02-24.acacia', }); From 4626bbd05e2b4f6ab53aee073932a76230e3edea Mon Sep 17 00:00:00 2001 From: Marcelo dos Reis Date: Mon, 28 Apr 2025 21:52:48 -0300 Subject: [PATCH 10/11] feat: finish base of notification feature --- README.md | 3 +- docs/feature-roadmap.md | 2 +- public/locales/en-US.json | 10 + public/locales/pt-BR.json | 10 + .../(frontend)/(auth)/new-password/page.tsx | 3 +- src/app/(frontend)/dashboard/page.tsx | 4 +- .../Dashboard/Navbar/Notification/index.tsx | 222 +++++++++++++----- src/components/Dashboard/Navbar/index.tsx | 4 +- src/hooks/useCheckout.ts | 6 +- src/pages/api/webhooks.ts | 10 + src/services/notification.ts | 7 +- 11 files changed, 205 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 20ba353..e7fa7df 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,8 @@ CREATE TABLE subscriptions ( CREATE TABLE notifications ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - message TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, is_read BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 1661e39..24e80e4 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -19,7 +19,7 @@ | **Feature** | **Status** | |----------------------------------------------------|------------| | Integration with Transactional Emails | ✅ | -| Notification Integration | ⬜ | +| Notification Integration | ✅ | | Team Features | ⬜ | diff --git a/public/locales/en-US.json b/public/locales/en-US.json index 9f2eae1..c77634b 100644 --- a/public/locales/en-US.json +++ b/public/locales/en-US.json @@ -360,6 +360,16 @@ "terms-privacy": "Terms and Privacy", "sign-out": "Sign Out" } + }, + "notification": { + "title": "Notification", + "title-plural": "Notifications", + "options": { + "empty": { + "title": "No notifications", + "description": "You will be notified when there is news." + } + } } }, "menu": { diff --git a/public/locales/pt-BR.json b/public/locales/pt-BR.json index 3886cac..e1f4cdb 100644 --- a/public/locales/pt-BR.json +++ b/public/locales/pt-BR.json @@ -360,6 +360,16 @@ "terms-privacy": "Termos e Privacidade", "sign-out": "Sair" } + }, + "notification": { + "title": "Notificação", + "title-plural": "Notificações", + "options": { + "empty": { + "title": "Nenhuma notificação", + "description": "Você será notificado quando houver novidades." + } + } } }, "menu": { diff --git a/src/app/(frontend)/(auth)/new-password/page.tsx b/src/app/(frontend)/(auth)/new-password/page.tsx index b937718..c4ea38b 100644 --- a/src/app/(frontend)/(auth)/new-password/page.tsx +++ b/src/app/(frontend)/(auth)/new-password/page.tsx @@ -77,6 +77,7 @@ export default function NewPassword() { useEffect(() => { const token = searchParams?.get("code"); + console.log(token); if (!token) { dispatch({ type: "SET_TOKEN_ERROR", @@ -85,7 +86,7 @@ export default function NewPassword() { } else { dispatch({ type: "SET_TOKEN_VALUE", payload: token }); } - }, [searchParams, translate]); + }, []); async function handleNewPassword() { try { diff --git a/src/app/(frontend)/dashboard/page.tsx b/src/app/(frontend)/dashboard/page.tsx index c3bba2c..9672395 100644 --- a/src/app/(frontend)/dashboard/page.tsx +++ b/src/app/(frontend)/dashboard/page.tsx @@ -1,6 +1,6 @@ import { headers } from 'next/headers'; -import { Dashboard as DashboardComponent } from "@/components/Dashboard"; +import { Dashboard as DashboardContainer } from "@/components/Dashboard"; import { ModalProvider } from "@/contexts/ModalContext"; @@ -10,7 +10,7 @@ export default async function Dashboard() { return (
    - +
    ); diff --git a/src/components/Dashboard/Navbar/Notification/index.tsx b/src/components/Dashboard/Navbar/Notification/index.tsx index cbbc9f0..c07f61f 100644 --- a/src/components/Dashboard/Navbar/Notification/index.tsx +++ b/src/components/Dashboard/Navbar/Notification/index.tsx @@ -1,79 +1,175 @@ "use client"; -import { BellIcon } from '@heroicons/react/24/outline'; -import { useState, useEffect, useRef } from 'react'; +import { BellIcon } from "@heroicons/react/24/outline"; +import { useState, useEffect, useRef, useCallback } from "react"; -import './style.css'; +import { useI18n } from "@/hooks/useI18n"; +import { supabase } from "@/libs/supabase/client"; +import AuthService from "@/services/auth"; +import NotificationService, { + Notification as TNotification, +} from "@/services/notification"; -function Notification() { - const [notificationsOpen, setNotificationsOpen] = useState(false); - const [notifications, setNotifications] = useState([ - { id: 1, message: 'Notification 1', description: 'Description 1', read: false }, - { id: 2, message: 'Notification 2', description: 'Description 2', read: false }, - { id: 3, message: 'Notification 3', description: 'Description 3', read: true }, - ]); +export default function Notification() { + const { translate } = useI18n("components.dashboard.navbar.notification"); + const [notificationsOpen, setNotificationsOpen] = useState(false); + const [notifications, setNotifications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const notificationsRef = useRef(null); - const unreadCount = notifications.filter(notification => !notification.read).length; - const notificationsRef = useRef(null); + const fetchNotifications = useCallback(async () => { + try { + const authService = new AuthService(supabase); + const notificationService = new NotificationService(supabase); - const toggleNotifications = () => setNotificationsOpen(!notificationsOpen); + const userId = await authService.getUserId(); + if (!userId) { + setNotifications([]); + return; + } - const markAsRead = (id: number) => { - setNotifications(notifications.map(n => n.id === id ? { ...n, read: true } : n)); - }; + const userNotifications = + await notificationService.getNotificationsByUserId(userId); + setNotifications(userNotifications); + } catch (error) { + console.error("Failed to fetch notifications:", error); + setNotifications([]); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchNotifications(); + }, [fetchNotifications]); - const handleClickOutside = (event: { target: unknown; }) => { - if (notificationsRef.current && !notificationsRef.current.contains(event.target as Node)) { - setNotificationsOpen(false); - } + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + notificationsRef.current && + !notificationsRef.current.contains(event.target as Node) + ) { + setNotificationsOpen(false); + } }; - useEffect(() => { - if (notificationsOpen) { - document.addEventListener('mousedown', handleClickOutside); - } else { - document.removeEventListener('mousedown', handleClickOutside); - } - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [notificationsOpen]); + if (notificationsOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [notificationsOpen]); + + const handleMarkAsRead = async (id: string) => { + try { + const notificationService = new NotificationService(supabase); + await notificationService.markAsRead(id); + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)) + ); + } catch (error) { + console.error("Failed to mark notification as read:", error); + } + }; + + const unreadCount = notifications.filter((n) => !n.is_read).length; + + if (isLoading) { return ( -
    -
    - - {unreadCount > 0 && ( - - {unreadCount} - - )} -
    +
    +
    +
    + ); + } + + return ( +
    + - {notificationsOpen && ( -
    -

    Notifications

    -
      - {notifications.map(notification => ( -
    • markAsRead(notification.id)} - > -
      -

      {notification.message}

      -

      {notification.description}

      -
      - {!notification.read && ( - - )} -
    • - ))} -
    -
    + {notificationsOpen && ( +
    +
    +

    + {translate("title")} +

    + {notifications.length > 0 && ( + + {notifications.length}{" "} + {notifications.length === 1 + ? translate("title") + : translate("title-plural")} + )} +
    + + {notifications.length > 0 ? ( +
      + {notifications.map((notification) => ( +
    • handleMarkAsRead(notification.id)} + className={` + py-3 transition-colors duration-150 ease-in-out cursor-pointer + ${notification.is_read ? "bg-white" : "bg-gray-50"} + hover:bg-gray-100 p-2 + `} + > +
      +
      +

      + {notification.title} +

      +

      + {notification.description} +

      +
      + {!notification.is_read && ( +
      +
      +
      + )} +
      +
    • + ))} +
    + ) : ( +
    +
    + + + +
    +

    + {translate("options.empty.title")} +

    +

    + {translate("options.empty.description")} +

    +
    + )}
    - ); + )} +
    + ); } - -export default Notification; diff --git a/src/components/Dashboard/Navbar/index.tsx b/src/components/Dashboard/Navbar/index.tsx index be1c94c..453aeb7 100644 --- a/src/components/Dashboard/Navbar/index.tsx +++ b/src/components/Dashboard/Navbar/index.tsx @@ -3,7 +3,7 @@ import { useI18n } from "@/hooks/useI18n"; import MyAccount from "./MyAccount"; -// import Notification from "./Notification"; +import Notification from "./Notification"; import LanguageSelector from "../../LanguageSelector"; export function Navbar() { @@ -20,7 +20,7 @@ export function Navbar() {
    - {/* */} +
    diff --git a/src/hooks/useCheckout.ts b/src/hooks/useCheckout.ts index ec4e797..08ae7b6 100644 --- a/src/hooks/useCheckout.ts +++ b/src/hooks/useCheckout.ts @@ -16,9 +16,9 @@ export const useCheckout = () => { setIsLoading(true); const AuthServiceInstance = new AuthService(supabase); - const user = await AuthServiceInstance.getUserId(); + const userId = await AuthServiceInstance.getUserId(); - if (!user) { + if (!userId) { window.location.href = '/signin'; return; } @@ -28,7 +28,7 @@ export const useCheckout = () => { const response = await fetch('/api/payments/create-checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ priceId, plan: plan.id, userId: user, hasFreeTrial: HAS_FREE_TRIAL, currency: FIXED_CURRENCY }), + body: JSON.stringify({ priceId, plan: plan.id, userId: userId, hasFreeTrial: HAS_FREE_TRIAL, currency: FIXED_CURRENCY }), }); const jsonResponse = await response.json(); diff --git a/src/pages/api/webhooks.ts b/src/pages/api/webhooks.ts index 32a2979..0b079b7 100644 --- a/src/pages/api/webhooks.ts +++ b/src/pages/api/webhooks.ts @@ -5,6 +5,7 @@ import { stripe } from "@/libs/stripe"; import { supabaseServerClient } from "@/libs/supabase/server"; import AuthService from "@/services/auth"; import EmailService from "@/services/email"; +import NotificationService from "@/services/notification"; import PaymentService from "@/services/payment"; import SubscriptionService from "@/services/subscription"; @@ -35,6 +36,9 @@ export default async function handler( supabaseServerClient ); const EmailServiceInstance = new EmailService(); + const NotificationServiceInstance = new NotificationService( + supabaseServerClient + ); const sig = req.headers["stripe-signature"]; const rawBody = await getRawBody(req); @@ -82,6 +86,12 @@ export default async function handler( text: "Welcome to Sassy! Your subscription has been activated.", html: FINISH_CHECKOUT_EMAIL.replace("{plan}", plan), }); + + await NotificationServiceInstance.createNotification({ + title: "Welcome to Sassy!", + description: "Your subscription has been activated", + user_id: userId, + }); break; } diff --git a/src/services/notification.ts b/src/services/notification.ts index db94885..0d2e0b4 100644 --- a/src/services/notification.ts +++ b/src/services/notification.ts @@ -1,9 +1,10 @@ import { SupabaseClient } from "@supabase/supabase-js"; -type Notification = { - id?: string; +export type Notification = { + id: string; user_id: string; - message: string; + title: string; + description: string; is_read?: boolean; created_at?: Date; }; From 8abb7ebd823d45243a4631bd716693d17cbe3c0d Mon Sep 17 00:00:00 2001 From: Marcelo dos Reis Date: Mon, 28 Apr 2025 22:02:15 -0300 Subject: [PATCH 11/11] chore: update docs --- README.md | 1 + src/services/email.ts | 90 +++++++++++++++++++++++------------------ src/services/mailgun.ts | 46 --------------------- 3 files changed, 52 insertions(+), 85 deletions(-) delete mode 100644 src/services/mailgun.ts diff --git a/README.md b/README.md index e7fa7df..28d0d74 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Welcome to **Sassy**, a powerful template generator designed to accelerate the d | Webhooks for Stripe Events | ✅ | | Subscriptions & Payments API Routes | ✅ | | User Authentication (Supabase) | ✅ | +| E-Mail + Custom Notification | ✅ | | Personalized Dashboard | ✅ | | Responsive Design + Landing Page | ✅ | | Logs & Monitoring (Datadog) | ✅ | diff --git a/src/services/email.ts b/src/services/email.ts index 10f2b55..7478f00 100644 --- a/src/services/email.ts +++ b/src/services/email.ts @@ -1,53 +1,65 @@ import FormData from "form-data"; import Mailgun from "mailgun.js"; +import { IMailgunClient } from "mailgun.js/Interfaces"; type EmailParams = { - from: string; - to: string[]; - subject: string; - text: string; - html: string; + from: string; + to: string[]; + subject: string; + text: string; + html: string; }; export default class EmailService { - private mailgun: Mailgun; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private mg: any; + private mailgunClient: IMailgunClient; - constructor() { - this.mailgun = new Mailgun(FormData); - this.mg = this.mailgun.client({ - username: "api", - key: process.env.MAILGUN_SECRET_KEY || "", - }); - } + constructor() { + this.mailgunClient = new Mailgun(FormData).client({ + username: "api", + key: process.env.MAILGUN_SECRET_KEY || "", + }); + } - async sendEmail({ from, to, subject, text, html }: EmailParams): Promise { - this.validateEmailParams({ from, to, subject, text, html }); + async sendEmail({ + from, + to, + subject, + text, + html, + }: EmailParams): Promise { + this.validateEmailParams({ from, to, subject, text, html }); - const message = { - from: `${from} `, - to, - subject, - text, - html, - }; + const message = { + from: `${from} `, + to, + subject, + text, + html, + }; - try { - await this.mg.messages.create( - process.env.MAILGUN_SECRET_DOMAIN || "MAILGUN_SECRET_DOMAIN", - message - ); - console.log(`Email sent successfully to: ${to.join(", ")}`); - } catch (error) { - console.error("Error sending email:", error); - throw new Error(`Failed to send email to: ${to.join(", ")}. Error: ${error}`); - } + try { + await this.mailgunClient.messages.create( + process.env.MAILGUN_SECRET_DOMAIN || "MAILGUN_SECRET_DOMAIN", + message + ); + console.log(`Email sent successfully to: ${to.join(", ")}`); + } catch (error) { + console.error("Error sending email:", error); + throw new Error( + `Failed to send email to: ${to.join(", ")}. Error: ${error}` + ); } + } - private validateEmailParams({ from, to, subject, text, html }: EmailParams): void { - if (!from || !to || !subject || !text || !html) { - throw new Error("Missing required email parameters."); - } + private validateEmailParams({ + from, + to, + subject, + text, + html, + }: EmailParams): void { + if (!from || !to || !subject || !text || !html) { + throw new Error("Missing required email parameters."); } -} \ No newline at end of file + } +} diff --git a/src/services/mailgun.ts b/src/services/mailgun.ts deleted file mode 100644 index a867990..0000000 --- a/src/services/mailgun.ts +++ /dev/null @@ -1,46 +0,0 @@ -import FormData from "form-data"; -import Mailgun from "mailgun.js"; - -export const sendEmail = async ({ - from, - to, - subject, - text, - html, -}: { - from: string; - to: string[]; - subject: string; - text: string; - html: string; -}) => { - if (!from || !to || !subject || !text || !html) { - throw new Error("Missing required email parameters."); - } - - const mailgun = new Mailgun(FormData); - const mg = mailgun.client({ - username: "api", - key: process.env.MAILGUN_SECRET_KEY || "", - }); - - const message = { - from: `${from} `, - to, - subject, - text, - html, - }; - - - try { - await mg.messages.create( - process.env.MAILGUN_SECRET_DOMAIN || "MAILGUN_SECRET_DOMAIN", - message - ); - console.log(`Email sent successfully to: ${to.join(", ")}`); - } catch (error) { - console.error("Error sending email:", error); - throw new Error(`Failed to send email to: ${to.join(", ")}. Error: ${error}`); - } -}; \ No newline at end of file