diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..7f60354c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: Build +on: + push: + branches: + - main + - dev + pull_request: + types: [opened, synchronize, reopened] +jobs: + sonarqube: + name: SonarQube + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v5 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/README.md b/README.md index 670ae490..a1026f7a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fasttify - Dropshipping Ecommerce Platform -Welcome to **Fasttify**, the ultimate SaaS solution for creating and managing personalized dropshipping stores effortlessly. Built on **AWS Amplify** with a modern **Next.js** front end, Fasttify combines scalability, performance, and a user-friendly interface. +Welcome to **Fasttify**, the ultimate SaaS solution for creating and managing personalized dropshipping stores effortlessly. Built on **AWS Amplify Gen2** with a modern **Next.js** front end, Fasttify combines scalability, performance, and a user-friendly interface. ## Overview @@ -12,21 +12,20 @@ Fasttify empowers users to build their ecommerce business with: ## Features -- **Authentication**: Secure and customizable user sign-up and sign-in powered by AWS Cognito. +- **Authentication**: Secure and customizable user sign-up and sign-in powered by AWS Cognito with Amplify Gen2's improved TypeScript support. - **Subscriptions**: Integrated with **Mercado Pago**, enabling easy subscription management with upgrade, downgrade, and cancellation functionality. -- **Custom Plans**: Personalize user plans with AWS Lambda and custom attributes. -- **API and Database**: Leverages AWS AppSync (GraphQL API) and DynamoDB for fast, scalable data management. +- **Custom Plans**: Personalize user plans with AWS Lambda and custom attributes, leveraging Gen2's enhanced type safety. +- **API and Database**: Utilizes Amplify Gen2's improved data modeling with TypeScript for AWS AppSync (GraphQL API) and DynamoDB, providing fast, scalable data management. - **Webhooks**: Stay synchronized with real-time notifications for subscription updates. +- **Type Safety**: Benefit from Amplify Gen2's TypeScript-first approach for better developer experience and fewer runtime errors. +- **Local Development**: Enhanced local development experience with Gen2's improved tooling and emulators. ## Quick Start 1. **Clone the Repository**: ```bash - git clone https://github.com/Stivenjs/Fasttify.git - - cd fasttify ``` @@ -36,19 +35,23 @@ Fasttify empowers users to build their ecommerce business with: npm install ``` -3. **Setup Amplify**: +3. **Setup Amplify Gen2**: - Initialize Amplify in your project: ```bash - amplify init + npx @aws-amplify/cli@latest init ``` - Add authentication: ```bash - amplify add auth + npx @aws-amplify/cli@latest add auth + ``` + - Generate the TypeScript definitions: + ```bash + npx @aws-amplify/cli@latest generate ``` - - Push the changes: + - Deploy your backend: ```bash - amplify push + npx @aws-amplify/cli@latest deploy ``` 4. **Start the Development Server**: @@ -59,6 +62,47 @@ Fasttify empowers users to build their ecommerce business with: Your app will be live at `http://localhost:3000`. +## Amplify Gen2 Benefits + +Fasttify leverages AWS Amplify Gen2 to provide: + +- **TypeScript-First Experience**: Improved type safety and developer experience. +- **Simplified Resource Definition**: Define your backend resources using TypeScript. +- **Enhanced Local Development**: Test your app locally with improved emulators. +- **Flexible Deployment Options**: Deploy your entire stack or individual resources. +- **Better Performance**: Optimized client libraries for faster application performance. +- **Improved DX**: Better error messages and development workflows. + +## AWS Account Setup for Local Development + +### Prerequisites + +If you already have an AWS account and a locally configured profile, you only need to add the IAM role `AmplifyBackendDeployFullAccess` to your configured AWS profile. + +### IAM Identity Center Configuration + +If you don't have a configured AWS profile, follow these steps: + +1. **Enable IAM Identity Center**: + + - Sign in to the AWS console + - Access the IAM Identity Center page and select "Enable" + - When prompted, select "Enable with AWS Organizations" and click "Continue" + +2. **Configure a user with Amplify permissions**: + + - Open CloudShell from the AWS console + - Run commands to set up appropriate permissions + +3. **Create a permission set for Amplify**: + - In the IAM Identity Center navigation, select "Permission sets" + - Select "Create permission set" + - Choose "Custom permission set" and click "Next" + - Expand "AWS Managed Policies" and search for "amplify" + - Select "AmplifyBackendDeployFullAccess" and click "Next" + - Name the permission set "amplify-policy" and click "Next" + - Review and select "Create" + ## Deploying to AWS To deploy Fasttify to AWS: @@ -67,7 +111,7 @@ To deploy Fasttify to AWS: 2. Set up branches for production and development. 3. Deploy directly from Amplify Console. -Refer to the [AWS Amplify Deployment Guide](https://docs.amplify.aws/nextjs/start/quickstart/nextjs-app-router-client-components/#deploy-a-fullstack-app-to-aws) for detailed instructions. +Refer to the [AWS Amplify Gen2 Deployment Guide](https://docs.amplify.aws/gen2/deploy/fullstack-app/) for detailed instructions. ## Contributing diff --git a/amplify/.config/project-config.json b/amplify/.config/project-config.json index c5bcaa8d..4719ec30 100644 --- a/amplify/.config/project-config.json +++ b/amplify/.config/project-config.json @@ -1,6 +1,6 @@ { "whyContinueWithGen1": "Prefer not to answer", - "projectName": "MasterDop", + "projectName": "fasttify", "version": "3.1", "frontend": "javascript", "javascript": { diff --git a/amplify/auth/post-confirmation/handler.ts b/amplify/auth/post-confirmation/handler.ts index 85ac4f34..8a2865cb 100644 --- a/amplify/auth/post-confirmation/handler.ts +++ b/amplify/auth/post-confirmation/handler.ts @@ -51,6 +51,7 @@ export const handler: PostConfirmationTriggerHandler = async event => { // Crear registro en la tabla UserSubscription try { await client.models.UserSubscription.create({ + id: event.userName, userId: event.userName, subscriptionId: `trial-${event.userName}-${Date.now()}`, // ID único para la suscripción de prueba planName: 'Royal', diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index f13f5bb7..70aeac2d 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -71,6 +71,7 @@ const schema = a UserSubscription: a .model({ + id: a.id().required(), userId: a.string().required(), // Llave primaria (external_reference) subscriptionId: a.string().required(), // Id de la suscripción planName: a.string().required(), // Nombre del plan (reason) @@ -80,6 +81,7 @@ const schema = a planPrice: a.float(), // Precio del plan lastFourDigits: a.integer(), // Últimos 4 dígitos de la tarjeta }) + .identifier(['id']) .authorization(allow => [ allow.ownerDefinedIn('userId').to(['read', 'update', 'delete']), allow.authenticated().to(['create']), diff --git a/amplify/functions/planScheduler/handler.ts b/amplify/functions/planScheduler/handler.ts index ea6bf8cb..f77828fb 100644 --- a/amplify/functions/planScheduler/handler.ts +++ b/amplify/functions/planScheduler/handler.ts @@ -21,6 +21,7 @@ export const handler: EventBridgeHandler<'Scheduled Event', null, void> = async const now = new Date() // 2. Consultar DynamoDB para obtener las suscripciones pendientes con un plan asignado + // Modificado para solo procesar suscripciones que realmente han expirado o tienen un cambio de plan programado const pendingSubscriptionsResponse = await clientSchema.models.UserSubscription.list({ filter: { pendingPlan: { attributeExists: true }, @@ -29,24 +30,47 @@ export const handler: EventBridgeHandler<'Scheduled Event', null, void> = async }) const pendingSubscriptions = pendingSubscriptionsResponse.data || [] + console.log(`Found ${pendingSubscriptions.length} subscriptions to process`) // 3. Iterar sobre cada registro pendiente for (const subscription of pendingSubscriptions) { const userId = subscription.userId if (!userId) { - console.warn('⚠️ Suscripción sin userId, omitiendo...') + console.warn('Subscription without userId, skipping') continue } // Leer el valor del plan pendiente desde el registro const newPlan = subscription.pendingPlan if (!newPlan) { - console.warn( - `⚠️ La suscripción de ${userId} no tiene un plan pendiente válido, omitiendo...` + console.warn(`The subscription for ${userId} does not have a valid pending plan, skipping`) + continue + } + + // Verificar si la suscripción realmente ha expirado o es un cambio de plan programado + const nextPaymentDate = subscription.nextPaymentDate + ? new Date(subscription.nextPaymentDate) + : null + const pendingStartDate = subscription.pendingStartDate + ? new Date(subscription.pendingStartDate) + : null + + // Solo procesar si: + // 1. No hay fecha de próximo pago (suscripción expirada) + // 2. La fecha de próximo pago ya pasó (suscripción expirada) + // 3. La fecha de inicio pendiente está definida y ya pasó (cambio de plan programado) + const shouldProcess = + !nextPaymentDate || nextPaymentDate <= now || (pendingStartDate && pendingStartDate <= now) + + if (!shouldProcess) { + console.log( + `Skipping subscription for ${userId}: not expired yet and no pending plan change due` ) continue } + console.log(`Processing subscription for ${userId}: changing plan to ${newPlan}`) + try { // 3.1. Actualizar el atributo en Cognito para asignar el plan pendiente const updateCommand = new AdminUpdateUserAttributesCommand({ @@ -56,7 +80,7 @@ export const handler: EventBridgeHandler<'Scheduled Event', null, void> = async }) await cognitoClient.send(updateCommand) } catch (cognitoError) { - console.error(`❌ Error actualizando usuario ${userId} en Cognito:`, cognitoError) + console.error(`Error updating user ${userId} in Cognito:`, cognitoError) continue } @@ -73,13 +97,10 @@ export const handler: EventBridgeHandler<'Scheduled Event', null, void> = async lastFourDigits: null, }) } catch (dbError) { - console.error( - `❌ Error actualizando suscripción de usuario ${userId} en DynamoDB:`, - dbError - ) + console.error(`Error updating user subscription ${userId} in DynamoDB:`, dbError) } } } catch (error) { - console.error('❌ Error en la Lambda programada:', error) + console.error('Error in scheduled Lambda:', error) } } diff --git a/app/(with-navbar)/account-settings/components/AccountSettings.tsx b/app/(with-navbar)/account-settings/components/AccountSettings.tsx index 69f8ee91..bb9eb424 100644 --- a/app/(with-navbar)/account-settings/components/AccountSettings.tsx +++ b/app/(with-navbar)/account-settings/components/AccountSettings.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Pencil, BadgeCheck, LogOut } from 'lucide-react' +import { Pencil, BadgeCheck } from 'lucide-react' import { Button } from '@/components/ui/button' import { EditProfileDialog } from '@/app/(with-navbar)/account-settings/components/EditProfileDialog' import { @@ -158,9 +158,6 @@ export function AccountSettings() {

- diff --git a/app/(with-navbar)/account-settings/components/CancellationDialog.tsx b/app/(with-navbar)/account-settings/components/CancellationDialog.tsx index 01ece90a..c0490f0c 100644 --- a/app/(with-navbar)/account-settings/components/CancellationDialog.tsx +++ b/app/(with-navbar)/account-settings/components/CancellationDialog.tsx @@ -42,7 +42,7 @@ export function CancellationDialog({ await onCancel() setIsCancelled(true) } catch (error) { - console.error('Error al cancelar la suscripción:', error) + console.error('Error unsubscribing:', error) } finally { setIsSubmitting(false) } diff --git a/app/(with-navbar)/account-settings/components/ChangeEmailDialog.tsx b/app/(with-navbar)/account-settings/components/ChangeEmailDialog.tsx index bdad3374..b9191c22 100644 --- a/app/(with-navbar)/account-settings/components/ChangeEmailDialog.tsx +++ b/app/(with-navbar)/account-settings/components/ChangeEmailDialog.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { Button } from '@/components/ui/button' -import { Loader2 } from 'lucide-react' +import { Loader } from '@/components/ui/loader' import { Dialog, DialogContent, @@ -44,7 +44,6 @@ export function ChangeEmailDialog({ open, onOpenChange, currentEmail }: ChangeEm }) const { - register: registerVerification, handleSubmit: handleSubmitVerification, formState: { errors: verificationErrors }, reset: resetVerification, @@ -149,7 +148,7 @@ export function ChangeEmailDialog({ open, onOpenChange, currentEmail }: ChangeEm + + + + ) + } + + return ( + <> + + + + + ) +} + +// Componente de fallback para errores +function ErrorFallback() { + return ( + + + Error al cargar la suscripción + Por favor, intenta de nuevo más tarde. + + + ) +} + +// Componente principal +export function PaymentSettings() { + const { setCognitoUsername } = useSubscriptionStore() + const { user } = useUserStore() + + // Usar useEffect para inicializar el recurso de suscripción + useEffect(() => { + if (user?.cognitoUsername) { + setCognitoUsername(user.cognitoUsername) + } + }, [user?.cognitoUsername, setCognitoUsername]) + return (
@@ -111,58 +165,21 @@ export function PaymentSettings() {

Gestiona tu plan de suscripción y método de pago

- {loading ? ( - - - Cargando información de suscripción... - - - ) : error ? ( - - - Error al cargar la suscripción - Por favor, intenta de nuevo más tarde. - - - ) : !currentSubscription || !currentSubscription.nextPaymentDate ? ( - - - No tienes una suscripción activa - - Explora nuestros planes y elige el que mejor se adapte a tus necesidades. - - -
- - - -
-
- ) : ( - <> - - - - - )} + + + + } + > + +
) } diff --git a/app/(with-navbar)/account-settings/components/SideBar.tsx b/app/(with-navbar)/account-settings/components/SideBar.tsx index 4ac1cc69..39d2740f 100644 --- a/app/(with-navbar)/account-settings/components/SideBar.tsx +++ b/app/(with-navbar)/account-settings/components/SideBar.tsx @@ -7,9 +7,16 @@ import { routes } from '@/utils/routes' interface SidebarProps { currentView: string onViewChange: (view: string) => void + hideSessionsOption?: boolean + isUserLoading?: boolean } -export function Sidebar({ currentView, onViewChange }: SidebarProps) { +export function Sidebar({ + currentView, + onViewChange, + hideSessionsOption = false, + isUserLoading = false, +}: SidebarProps) { const router = useRouter() const handleViewChange = (view: string): void => { @@ -56,18 +63,20 @@ export function Sidebar({ currentView, onViewChange }: SidebarProps) { Pagos - + {!isUserLoading && !hideSessionsOption && ( + + )} diff --git a/app/(with-navbar)/account-settings/page.tsx b/app/(with-navbar)/account-settings/page.tsx index 81078cd7..d25c75ed 100644 --- a/app/(with-navbar)/account-settings/page.tsx +++ b/app/(with-navbar)/account-settings/page.tsx @@ -5,9 +5,11 @@ import { AccountSettings } from '@/app/(with-navbar)/account-settings/components import { PaymentSettings } from '@/app/(with-navbar)/account-settings/components/PaymentSettings' import { ActiveSessions } from '@/app/(with-navbar)/account-settings/components/ActiveSessions' import { useState, useEffect, Suspense } from 'react' +import { Loader } from '@/components/ui/loader' import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' import { useSearchParams } from 'next/navigation' +import useUserStore from '@/zustand-states/userStore' +import outputs from '@/amplify_outputs.json' Amplify.configure(outputs) const existingConfig = Amplify.getConfig() @@ -24,13 +26,22 @@ function AccountSettingsContent() { const searchParams = useSearchParams() const sectionParam = searchParams.get('section') const [currentView, setCurrentView] = useState(sectionParam || 'cuenta') + const { user, loading } = useUserStore() + + const isGoogleUser = user?.identities?.some( + identity => identity.providerType === 'Google' || identity.providerName === 'Google' + ) useEffect(() => { // Update view when URL parameter changes if (sectionParam && ['cuenta', 'pagos', 'sesiones'].includes(sectionParam)) { - setCurrentView(sectionParam) + if (isGoogleUser && sectionParam === 'sesiones') { + setCurrentView('cuenta') + } else { + setCurrentView(sectionParam) + } } - }, [sectionParam]) + }, [sectionParam, isGoogleUser]) useEffect(() => { document.title = 'Mi Perfil • Fasttify' @@ -38,11 +49,16 @@ function AccountSettingsContent() { return (
- +
{currentView === 'cuenta' && } {currentView === 'pagos' && } - {currentView === 'sesiones' && } + {!isGoogleUser && currentView === 'sesiones' && }
) @@ -53,9 +69,13 @@ export default function AccountSettingsPage() {
-
-
+ } > diff --git a/app/(with-navbar)/landing/components/DocsLanding.tsx b/app/(with-navbar)/landing/components/DocsLanding.tsx index 218b939c..2d79ce81 100644 --- a/app/(with-navbar)/landing/components/DocsLanding.tsx +++ b/app/(with-navbar)/landing/components/DocsLanding.tsx @@ -3,7 +3,7 @@ import { Platform } from '@/app/(with-navbar)/landing/components/Platform' import { Footer } from '@/app/(with-navbar)/landing/components/Footer' import { StepGuide } from '@/app/(with-navbar)/landing/components/StepGuide' import { FirstView } from '@/app/(with-navbar)/landing/components/FirstView' -import { AboutUs } from './AboutUs' +import { AboutUs } from '@/app/(with-navbar)/landing/components/AboutUs' import { FashionSlider } from '@/app/(with-navbar)/landing/components/FashionSlider' import { Feature } from '@/app/(with-navbar)/landing/components/Feature' import { Testimonials } from '@/app/(with-navbar)/landing/components/Testimonials' diff --git a/app/(with-navbar)/landing/components/NavBar.tsx b/app/(with-navbar)/landing/components/NavBar.tsx index 9e6e0f66..22d80287 100644 --- a/app/(with-navbar)/landing/components/NavBar.tsx +++ b/app/(with-navbar)/landing/components/NavBar.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react' import Link from 'next/link' import Image from 'next/image' -import { Menu, ChevronDown, X } from 'lucide-react' +import { Menu, ChevronDown } from 'lucide-react' import { Button } from '@/components/ui/button' import { Amplify } from 'aws-amplify' import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' diff --git a/app/(with-navbar)/page.tsx b/app/(with-navbar)/page.tsx index 6100636a..a7196006 100644 --- a/app/(with-navbar)/page.tsx +++ b/app/(with-navbar)/page.tsx @@ -3,11 +3,10 @@ import { useEffect } from 'react' import { getCurrentUser, fetchUserAttributes } from 'aws-amplify/auth' import { ConsoleLogger, Hub } from 'aws-amplify/utils' -import DocsLanding from '@/app/(with-navbar)/landing/components/DocsLanding' +import { Amplify } from 'aws-amplify' import 'aws-amplify/auth/enable-oauth-listener' - +import DocsLanding from '@/app/(with-navbar)/landing/components/DocsLanding' import outputs from '@/amplify_outputs.json' -import { Amplify } from 'aws-amplify' Amplify.configure(outputs) const existingConfig = Amplify.getConfig() @@ -31,15 +30,15 @@ export default function Home() { const userAttributes = await fetchUserAttributes() logger.log({ user, userAttributes }) } catch (error) { - logger.error('Error al obtener la sesión del usuario:', error) + logger.error('Error getting user session:', error) } break case 'signInWithRedirect_failure': - logger.error('Error en el inicio de sesión con redirección:', payload.data) + logger.error('Login failed with redirect:', payload.data) break case 'customOAuthState': const state = payload.data - logger.log('Estado personalizado:', state) + logger.log('Custom status:', state) break } }) diff --git a/app/(without-navbar)/login/components/forgot-password/ForgotPasswordForm.tsx b/app/(without-navbar)/login/components/forgot-password/ForgotPasswordForm.tsx index 3adad5db..fa3613ba 100644 --- a/app/(without-navbar)/login/components/forgot-password/ForgotPasswordForm.tsx +++ b/app/(without-navbar)/login/components/forgot-password/ForgotPasswordForm.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' +import { Loader } from '@/components/ui/loader' import { resetPassword, confirmResetPassword } from 'aws-amplify/auth' import { Button } from '@/components/ui/button' import { @@ -241,7 +242,14 @@ export function ForgotPasswordForm({ onBack }: ForgotPasswordFormProps) { className="w-full bg-black text-white hover:bg-black/90" disabled={isLoading} > - {isLoading ? 'Confirmando...' : 'Confirmar nueva contraseña'} + {isLoading ? ( + <> + + Confirmando... + + ) : ( + 'Confirmar nueva contraseña' + )} diff --git a/app/store/components/domains/utils/storeProfileUtils.ts b/app/store/components/domains/utils/storeProfileUtils.ts index 64b58898..11827f77 100644 --- a/app/store/components/domains/utils/storeProfileUtils.ts +++ b/app/store/components/domains/utils/storeProfileUtils.ts @@ -32,13 +32,12 @@ export const createStoreNameValidator = ( return debounce(async (name: string) => { if (name !== originalStoreName) { setNameChanged(true) - // Solo verificamos con la API si tiene al menos 3 caracteres + if (name.length >= 3) { await checkStoreName(name) - // Luego de la validación, si "exists" es false, se considera válido + setIsStoreNameValid(!exists) } else { - // Mantener inválido si es muy corto setIsStoreNameValid(false) } } else if (name === originalStoreName) { @@ -128,7 +127,7 @@ export const handleStoreProfileSubmit = async ( return false } } catch (error) { - console.error('Error al actualizar la información de la tienda:', error) + console.error('Error updating store information:', error) toast.error('Ocurrió un error al actualizar la información') return false } diff --git a/app/store/components/images-selector/image-selector-modal.tsx b/app/store/components/images-selector/image-selector-modal.tsx index dba3cb64..b40aa1d7 100644 --- a/app/store/components/images-selector/image-selector-modal.tsx +++ b/app/store/components/images-selector/image-selector-modal.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useCallback } from 'react' -import { Search, Grid, List, Upload, Trash2, Loader2 } from 'lucide-react' +import { Search, Grid, List, Upload, Trash2 } from 'lucide-react' +import { Loader } from '@/components/ui/loader' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' @@ -82,7 +83,7 @@ export default function ImageSelectorModal({ } finally { setIsUploading(false) setUploadPreview(null) - // Limpiar el input de archivo + if (fileInputRef.current) { fileInputRef.current.value = '' } @@ -206,7 +207,7 @@ export default function ImageSelectorModal({ />
- + Subiendo imagen...
@@ -215,7 +216,7 @@ export default function ImageSelectorModal({ {/* Loading state */} {loading && (
- + Cargando imágenes...
)} diff --git a/app/store/components/payments/ApiKeyModal.tsx b/app/store/components/payments/ApiKeyModal.tsx index 701f3f34..3d4a3dc0 100644 --- a/app/store/components/payments/ApiKeyModal.tsx +++ b/app/store/components/payments/ApiKeyModal.tsx @@ -1,7 +1,6 @@ import { useState, useEffect } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { z } from 'zod' import { Eye, EyeOff, Info } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -25,6 +24,8 @@ import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { Badge } from '@/components/ui/badge' import { Card, CardContent } from '@/components/ui/card' +import { createApiKeySchema, PaymentGatewayType, PAYMENT_GATEWAYS } from '@/lib/schemas/api-keys' + import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' @@ -38,75 +39,6 @@ Amplify.configure({ }, }) -const PAYMENT_GATEWAYS = { - mercadoPago: { - name: 'Mercado Pago', - transactionFee: 3.99, - publicKeyPrefix: 'APP_USR-', - privateKeyPrefix: 'TEST-', - publicKeyPattern: /^APP_USR-[a-zA-Z0-9-]+$/, - privateKeyPattern: /^TEST-[a-zA-Z0-9-]+$/, - publicKeyPlaceholder: 'APP_USR-1234567890123456', - privateKeyPlaceholder: 'Kw4aC0rZVgLZQn209NbEKPuXLzBD28Zx', - color: 'bg-blue-100 text-blue-800 border-blue-200', - description: 'Popular en América Latina con amplio soporte regional.', - publicKeyLabel: 'Access Token', - privateKeyLabel: 'Clave Secreta', - }, - wompi: { - name: 'Wompi', - transactionFee: 2.9, - publicKeyPrefix: 'pub_', - privateKeyPrefix: 'prv_', - publicKeyPattern: /^(pub_test|pub_prod|prod)_[a-zA-Z0-9]{16,}$/, - privateKeyPattern: /^[a-zA-Z0-9_-]+$/, - publicKeyPlaceholder: 'pub_prod_1234567890abcdef', - privateKeyPlaceholder: 'prod_integrity_Z5mMke9x0k8gpErbDqwrJXMqs', - color: 'bg-purple-100 text-purple-800 border-purple-200', - description: 'Tarifas más bajas con fuerte soporte en Colombia y otras regiones.', - publicKeyLabel: 'Llave Pública', - privateKeyLabel: 'Firma (Signature)', - }, -} - -type PaymentGatewayType = keyof typeof PAYMENT_GATEWAYS - -const createApiKeySchema = (gateway: PaymentGatewayType) => { - if (gateway === 'wompi') { - return z.object({ - publicKey: z - .string() - .min(16, { message: 'La llave pública debe tener al menos 16 caracteres' }) - .regex(PAYMENT_GATEWAYS.wompi.publicKeyPattern, { - message: 'Formato de llave pública inválido. Debe comenzar con pub_ o prod_', - }), - privateKey: z - .string() - .min(32, { message: 'La firma debe tener al menos 32 caracteres' }) - .max(128, { message: 'La firma no puede exceder 128 caracteres' }) - .regex(PAYMENT_GATEWAYS.wompi.privateKeyPattern, { - message: 'La firma solo puede contener caracteres alfanuméricos, guiones y guiones bajos', - }), - }) - } - - const gatewayConfig = PAYMENT_GATEWAYS[gateway] - return z.object({ - publicKey: z - .string() - .min(10, { message: 'La clave pública debe tener al menos 10 caracteres' }) - .regex(gatewayConfig.publicKeyPattern, { - message: `La clave pública debe comenzar con '${gatewayConfig.publicKeyPrefix}' seguido del formato correcto`, - }), - privateKey: z - .string() - .min(10, { message: 'La clave privada debe tener al menos 10 caracteres' }) - .regex(gatewayConfig.privateKeyPattern, { - message: `La clave privada debe comenzar con '${gatewayConfig.privateKeyPrefix}' seguido del formato correcto`, - }), - }) -} - type ApiKeyFormValues = { publicKey: string privateKey: string diff --git a/app/store/components/payments/PaymentSettings.tsx b/app/store/components/payments/PaymentSettings.tsx index 31436afc..6ab77a3b 100644 --- a/app/store/components/payments/PaymentSettings.tsx +++ b/app/store/components/payments/PaymentSettings.tsx @@ -49,11 +49,11 @@ export function PaymentSettings() { queryKey: ['storePaymentInfo', storeId], queryFn: () => getStorePaymentInfo(storeId), enabled: !!storeId, - staleTime: Infinity, // Los datos nunca se consideran obsoletos - gcTime: Infinity, // Los datos permanecen en caché indefinidamente - refetchOnWindowFocus: false, // No refetch al enfocar la ventana - refetchOnMount: false, // No refetch al montar el componente - refetchOnReconnect: false, // No refetch al reconectar + staleTime: 5 * 60 * 1000, // Los datos se consideran frescos por 5 minutos + gcTime: 5 * 60 * 1000, // Los datos se eliminan de caché después de 5 minutos sin usarse + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, }) const storeRecordId = data?.id || null @@ -94,7 +94,7 @@ export function PaymentSettings() { toast.error('Error de configuración', { description: 'Uyps! Hubo un error al configurar la pasarela de pago.', }) - console.error('No se encontró el ID del registro de la tienda') + console.error('Store record ID not found') return false } @@ -113,7 +113,7 @@ export function PaymentSettings() { if (encryptedPublicKey) { configData.publicKey = encryptedPublicKey } else { - console.error('Error al encriptar la clave pública de Wompi') + console.error('Error encrypting Wompi public key') toast.error('Error de configuración', { description: 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', @@ -133,7 +133,7 @@ export function PaymentSettings() { if (encryptedSignature) { configData.signature = encryptedSignature } else { - console.error('Error al encriptar la firma de Wompi') + console.error('Error encrypting Wompi signature') toast.error('Error de configuración', { description: 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', @@ -153,7 +153,7 @@ export function PaymentSettings() { if (encryptedPublicKey) { configData.publicKey = encryptedPublicKey } else { - console.error('Error al encriptar la clave pública de Mercado Pago') + console.error('Error encrypting the Mercado Pago public key') toast.error('Error de configuración', { description: 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', @@ -173,7 +173,7 @@ export function PaymentSettings() { if (encryptedPrivateKey) { configData.privateKey = encryptedPrivateKey } else { - console.error('Error al encriptar la clave privada de Mercado Pago') + console.error('Error encrypting the Mercado Pago private key') toast.error('Error de configuración', { description: 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', @@ -184,7 +184,6 @@ export function PaymentSettings() { } if (isEncrypting) { - // Si todavía está encriptando, mostrar un mensaje genérico toast.loading('Configurando pasarela de pago...') return false } @@ -197,7 +196,7 @@ export function PaymentSettings() { return success } catch (err) { - console.error('Error al configurar la pasarela de pago:', err) + console.error('Error configuring the payment gateway:', err) toast.error('Error de configuración', { description: 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', }) diff --git a/app/store/components/product-management/ProductForm.tsx b/app/store/components/product-management/ProductForm.tsx index 3285a872..da004fdd 100644 --- a/app/store/components/product-management/ProductForm.tsx +++ b/app/store/components/product-management/ProductForm.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' -import { Loader2 } from 'lucide-react' +import { Loader } from '@/components/ui/loader' import { Button } from '@/components/ui/button' import { Form } from '@/components/ui/form' import { Card, CardContent } from '@/components/ui/card' @@ -309,7 +309,7 @@ export function ProductForm({ storeId, productId }: ProductFormProps) { > {isSubmitting ? ( <> - + Actualizando... ) : ( @@ -324,7 +324,7 @@ export function ProductForm({ storeId, productId }: ProductFormProps) { > {isSubmitting ? ( <> - + Creando... ) : ( diff --git a/app/store/components/product-management/ProductList.tsx b/app/store/components/product-management/ProductList.tsx index 9e20d38d..cd5359c9 100644 --- a/app/store/components/product-management/ProductList.tsx +++ b/app/store/components/product-management/ProductList.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/navigation' import { toast } from 'sonner' import { routes } from '@/utils/routes' -import { Loader2 } from 'lucide-react' +import { Loader } from '@/components/ui/loader' // Hooks import { useProductFilters } from '@/app/store/components/product-management/hooks/useProductFilters' @@ -125,7 +125,7 @@ export function ProductList({
{loading && (
- +

Cargando productos...

)} diff --git a/app/store/components/product-management/collection-form/form-page.tsx b/app/store/components/product-management/collection-form/form-page.tsx index a4894b91..c0c707b6 100644 --- a/app/store/components/product-management/collection-form/form-page.tsx +++ b/app/store/components/product-management/collection-form/form-page.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react' -import { ArrowLeft, Edit, Loader2 } from 'lucide-react' +import { ArrowLeft, Edit } from 'lucide-react' +import { Loader } from '@/components/ui/loader' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { @@ -209,7 +209,7 @@ export function FormPage() { > {isSubmitting ? ( <> - + Guardando... ) : ( diff --git a/app/store/components/product-management/collection-form/product-section.tsx b/app/store/components/product-management/collection-form/product-section.tsx index e9c3a4a9..fbd0c4e6 100644 --- a/app/store/components/product-management/collection-form/product-section.tsx +++ b/app/store/components/product-management/collection-form/product-section.tsx @@ -36,7 +36,6 @@ export function ProductSection({ const [sortOption, setSortOption] = useState('mas-recientes') const { storeId } = useStoreDataStore() - // Usar el hook useProducts para cargar los productos const { products, loading } = useProducts(storeId ?? undefined, { limit: 100, sortDirection: 'DESC', @@ -51,7 +50,7 @@ export function ProductSection({ if (isDialogOpen) { setDialogSelectedProducts(selectedProducts.map(p => p.id)) } - }, [isDialogOpen]) // Solo se ejecuta cuando el diálogo se abre, no cuando selectedProducts cambia + }, [isDialogOpen]) // Filtrar productos basados en el término de búsqueda const filteredProducts = products.filter( diff --git a/app/store/components/product-management/hooks/usePriceSuggestion.ts b/app/store/components/product-management/hooks/usePriceSuggestion.ts index 4fd2f7d7..5640d960 100644 --- a/app/store/components/product-management/hooks/usePriceSuggestion.ts +++ b/app/store/components/product-management/hooks/usePriceSuggestion.ts @@ -69,16 +69,16 @@ export function usePriceSuggestion() { }) if (errors) { - throw new Error(errors[0]?.message || 'Error en la generación de sugerencia de precio') + throw new Error(errors[0]?.message || 'Error generating price suggestion') } else if (data) { setResult(data as PriceSuggestionResult) return data as PriceSuggestionResult } else { - throw new Error('No se recibió respuesta del servicio') + throw new Error('No response was received from the service') } } catch (err: any) { - console.error('Error al generar sugerencia de precio:', err) - const errorMessage = err.message || 'Error desconocido' + console.error('Error generating price suggestion:', err) + const errorMessage = err.message || 'Unknown error' setError(new Error(errorMessage)) throw new Error(errorMessage) } finally { diff --git a/app/store/components/product-management/hooks/useProductDescription.ts b/app/store/components/product-management/hooks/useProductDescription.ts index 6e4c5262..dc2a6801 100644 --- a/app/store/components/product-management/hooks/useProductDescription.ts +++ b/app/store/components/product-management/hooks/useProductDescription.ts @@ -58,16 +58,16 @@ export function useProductDescription() { }) if (errors) { - throw new Error(errors[0]?.message || 'Error en la generación de descripción') + throw new Error(errors[0]?.message || 'Error in generating description') } else if (data) { setDescription(data) return data } else { - throw new Error('No se recibió respuesta del servicio') + throw new Error('No response was received from the service') } } catch (err: any) { - console.error('Error al generar descripción:', err) - const errorMessage = err.message || 'Error desconocido' + console.error('Error generating description:', err) + const errorMessage = err.message || 'Unknown error' setError(new Error(errorMessage)) throw new Error(errorMessage) } finally { diff --git a/app/store/components/store-config/LogoUploader.tsx b/app/store/components/store-config/LogoUploader.tsx index 2b526e78..97aa0698 100644 --- a/app/store/components/store-config/LogoUploader.tsx +++ b/app/store/components/store-config/LogoUploader.tsx @@ -13,7 +13,8 @@ import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { toast } from 'sonner' import Image from 'next/image' -import { Upload, ImageIcon, Loader2 } from 'lucide-react' +import { Upload, ImageIcon } from 'lucide-react' +import { Loader } from '@/components/ui/loader' import { useLogoUpload } from '@/app/store/hooks/useLogoUpload' import { useUserStoreData } from '@/app/(without-navbar)/first-steps/hooks/useUserStoreData' import useStoreDataStore from '@/zustand-states/storeDataStore' @@ -26,7 +27,6 @@ export function LogoUploader() { const { uploadLogo, status, error, reset } = useLogoUpload() const { updateUserStore } = useUserStoreData() - const { currentStore, isLoading } = useStoreDataStore() useEffect(() => { @@ -162,7 +162,7 @@ export function LogoUploader() { if (isLoading) { return ( ) @@ -244,7 +244,7 @@ export function LogoUploader() { > {status === 'uploading' ? ( <> - + Subiendo... ) : ( @@ -314,7 +314,7 @@ export function LogoUploader() { > {status === 'uploading' ? ( <> - + Subiendo... ) : ( diff --git a/app/store/hooks/useApiKeyDecryption.ts b/app/store/hooks/useApiKeyDecryption.ts index afeb4af5..22fd2fbb 100644 --- a/app/store/hooks/useApiKeyDecryption.ts +++ b/app/store/hooks/useApiKeyDecryption.ts @@ -49,7 +49,7 @@ export function useApiKeyDecryption() { return null } catch (error) { - console.error('Error descifrando clave API:', error) + console.error('Error decrypting API key:', error) return null } finally { setIsDecrypting(false) diff --git a/app/store/hooks/useCollections.ts b/app/store/hooks/useCollections.ts index e3e5779a..09534269 100644 --- a/app/store/hooks/useCollections.ts +++ b/app/store/hooks/useCollections.ts @@ -7,7 +7,6 @@ const client = generateClient() // Clave base para las consultas de colecciones const COLLECTIONS_KEY = 'collections' -const PRODUCTS_KEY = 'products' /** * Interfaz para los datos de entrada de una colección @@ -47,7 +46,7 @@ export const useCollections = () => { const result = await operation() if (result.errors && result.errors.length > 0) { setError(result.errors) - throw new Error(result.errors[0].message || 'Error en la operación') + throw new Error(result.errors[0].message || 'Operation error') } return result.data } catch (err) { diff --git a/app/store/hooks/useLogoUpload.ts b/app/store/hooks/useLogoUpload.ts index 08ae6ba1..8c9b3e55 100644 --- a/app/store/hooks/useLogoUpload.ts +++ b/app/store/hooks/useLogoUpload.ts @@ -73,7 +73,7 @@ export function useLogoUpload(): UseLogoUploadReturn { } } catch (err) { setStatus('error') - setError(err instanceof Error ? err.message : 'Ha ocurrido un error desconocido') + setError(err instanceof Error ? err.message : 'An unknown error has occurred') console.error('Error uploading logo:', err) return null } diff --git a/app/store/hooks/useProducts.ts b/app/store/hooks/useProducts.ts index c8c92abe..f4d992d6 100644 --- a/app/store/hooks/useProducts.ts +++ b/app/store/hooks/useProducts.ts @@ -317,7 +317,7 @@ export function useProducts( // Consulta para obtener un producto específico const fetchProductById = async (id: string): Promise => { if (!storeId) { - console.error('No se puede obtener el producto: storeId no definido') + console.error('Cannot get product: storeId not defined') return null } @@ -339,7 +339,7 @@ export function useProducts( return existingProduct } else { console.error( - `Acceso denegado: El producto ${id} no pertenece a la tienda actual ${storeId}` + `Access denied: Product ${id} does not belong to the current store ${storeId}` ) return null } @@ -365,12 +365,12 @@ export function useProducts( } else { // El producto no pertenece a la tienda actual o no existe console.error( - `Acceso denegado: El producto ${id} no pertenece a la tienda actual ${storeId}` + `Access denied: Product ${id} does not belong to the current store ${storeId}` ) return null } } catch (error) { - console.error(`Error al verificar el producto ${id}:`, error) + console.error(`Error verifying product ${id}:`, error) return null } } @@ -402,7 +402,7 @@ export function useProducts( try { return await createProductMutation.mutateAsync(productData) } catch (err) { - console.error('Error al crear producto:', err) + console.error('Error creating product:', err) return null } }, @@ -410,7 +410,7 @@ export function useProducts( try { return await updateProductMutation.mutateAsync(productData) } catch (err) { - console.error('Error al actualizar producto:', err) + console.error('Error updating product:', err) return null } }, @@ -419,7 +419,7 @@ export function useProducts( await deleteProductMutation.mutateAsync(id) return true } catch (err) { - console.error('Error al eliminar producto:', err) + console.error('Error deleting product:', err) return false } }, @@ -428,13 +428,11 @@ export function useProducts( await deleteMultipleProductsMutation.mutateAsync(ids) return true } catch (err) { - console.error('Error al eliminar múltiples productos:', err) + console.error('Error deleting multiple products:', err) return false } }, fetchProduct: fetchProductById, - - // Otras funciones útiles refreshProducts: () => refetch(), } } diff --git a/components/ui/unsaved-changes-alert.tsx b/components/ui/unsaved-changes-alert.tsx index 4b2658a9..79bd4ba9 100644 --- a/components/ui/unsaved-changes-alert.tsx +++ b/components/ui/unsaved-changes-alert.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from 'react' import { Button } from '@/components/ui/button' -import { AlertCircle, Loader2 } from 'lucide-react' +import { AlertCircle } from 'lucide-react' +import { Loader } from '@/components/ui/loader' import { motion, AnimatePresence } from 'framer-motion' interface UnsavedChangesAlertProps { @@ -80,7 +81,7 @@ export function UnsavedChangesAlert({ > {isSaving ? ( <> - + Guardando... ) : ( diff --git a/hooks/auth/useAuth.ts b/hooks/auth/useAuth.ts index 368c025e..25f9012d 100644 --- a/hooks/auth/useAuth.ts +++ b/hooks/auth/useAuth.ts @@ -78,7 +78,7 @@ export const useAuth = () => { clearUser() } } catch (error) { - console.error('Error al obtener el usuario:', error) + console.error('Error getting user:', error) clearUser() } finally { setLoading(false) diff --git a/lib/schemas/api-keys.ts b/lib/schemas/api-keys.ts new file mode 100644 index 00000000..9b297453 --- /dev/null +++ b/lib/schemas/api-keys.ts @@ -0,0 +1,72 @@ +// src/lib/validation/apiKeys.ts +import { z } from 'zod' + +export const PAYMENT_GATEWAYS = { + mercadoPago: { + name: 'Mercado Pago', + transactionFee: 3.99, + publicKeyPrefix: 'APP_USR-', + privateKeyPrefix: 'TEST-', + publicKeyPattern: /^APP_USR-[a-zA-Z0-9-]+$/, + privateKeyPattern: /^TEST-[a-zA-Z0-9-]+$/, + publicKeyPlaceholder: 'APP_USR-1234567890123456', + privateKeyPlaceholder: 'Kw4aC0rZVgLZQn209NbEKPuXLzBD28Zx', + color: 'bg-blue-100 text-blue-800 border-blue-200', + description: 'Popular en América Latina con amplio soporte regional.', + publicKeyLabel: 'Access Token', + privateKeyLabel: 'Clave Secreta', + }, + wompi: { + name: 'Wompi', + transactionFee: 2.9, + publicKeyPrefix: 'pub_', + privateKeyPrefix: 'prv_', + publicKeyPattern: /^(pub_test|pub_prod|prod)_[a-zA-Z0-9]{16,}$/, + privateKeyPattern: /^[a-zA-Z0-9_-]+$/, + publicKeyPlaceholder: 'pub_prod_1234567890abcdef', + privateKeyPlaceholder: 'prod_integrity_Z5mMke9x0k8gpErbDqwrJXMqs', + color: 'bg-purple-100 text-purple-800 border-purple-200', + description: 'Tarifas más bajas con fuerte soporte en Colombia y otras regiones.', + publicKeyLabel: 'Llave Pública', + privateKeyLabel: 'Firma (Signature)', + }, +} as const + +export type PaymentGatewayType = keyof typeof PAYMENT_GATEWAYS + +export const createApiKeySchema = (gateway: PaymentGatewayType) => { + const config = PAYMENT_GATEWAYS[gateway] + + if (gateway === 'wompi') { + return z.object({ + publicKey: z + .string() + .min(16, { message: 'La llave pública debe tener al menos 16 caracteres' }) + .regex(config.publicKeyPattern, { + message: 'Formato de llave pública inválido. Debe comenzar con pub_ o prod_', + }), + privateKey: z + .string() + .min(32, { message: 'La firma debe tener al menos 32 caracteres' }) + .max(128, { message: 'La firma no puede exceder 128 caracteres' }) + .regex(config.privateKeyPattern, { + message: 'La firma solo puede contener caracteres alfanuméricos, guiones y guiones bajos', + }), + }) + } + + return z.object({ + publicKey: z + .string() + .min(10, { message: 'La clave pública debe tener al menos 10 caracteres' }) + .regex(config.publicKeyPattern, { + message: `La clave pública debe comenzar con '${config.publicKeyPrefix}' seguido del formato correcto`, + }), + privateKey: z + .string() + .min(10, { message: 'La clave privada debe tener al menos 10 caracteres' }) + .regex(config.privateKeyPattern, { + message: `La clave privada debe comenzar con '${config.privateKeyPrefix}' seguido del formato correcto`, + }), + }) +} diff --git a/lib/schemas/email-change.ts b/lib/schemas/email-change.ts index 8de6e469..4655d8bd 100644 --- a/lib/schemas/email-change.ts +++ b/lib/schemas/email-change.ts @@ -7,3 +7,10 @@ export const emailSchema = z.object({ export const verificationCodeSchema = z.object({ verificationCode: z.string().min(6, 'El código debe tener al menos 6 caracteres'), }) + +export const formSchema = z.object({ + firstName: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'), + lastName: z.string().min(2, 'El apellido debe tener al menos 2 caracteres'), + phone: z.string().min(10, 'El teléfono debe tener al menos 10 caracteres'), + bio: z.string(), +}) diff --git a/lib/schemas/password-change.ts b/lib/schemas/password-change.ts new file mode 100644 index 00000000..389c7f53 --- /dev/null +++ b/lib/schemas/password-change.ts @@ -0,0 +1,20 @@ +import * as z from 'zod' + +export const passwordSchema = z + .object({ + oldPassword: z.string().min(1, 'La contraseña actual es requerida'), + newPassword: z + .string() + .min(8, 'La nueva contraseña debe tener al menos 8 caracteres') + .regex( + /[!@#$%^&*()\-_=+{};:,<.>]/, + 'La contraseña debe contener al menos una letra mayúscula, una minúscula, un número y un carácter especial' + ), + confirmPassword: z.string().min(1, 'Confirma tu nueva contraseña'), + }) + .refine(data => data.newPassword === data.confirmPassword, { + message: 'Las contraseñas no coinciden', + path: ['confirmPassword'], + }) + +export type PasswordFormValues = z.infer diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..d557ef9a --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,4 @@ +sonar.projectKey=Fasttify_fasttify +sonar.organization=fasttify + + diff --git a/zustand-states/useSubscriptionStore.ts b/zustand-states/useSubscriptionStore.ts index 649b7f14..87d6d715 100644 --- a/zustand-states/useSubscriptionStore.ts +++ b/zustand-states/useSubscriptionStore.ts @@ -4,7 +4,7 @@ import { type Schema } from '@/amplify/data/resource' const client = generateClient() -// Definimos un tipo con solo los campos necesarios +// tipo con solo los campos necesarios interface MinimalSubscription { subscriptionId: Schema['UserSubscription']['type']['subscriptionId'] planName: Schema['UserSubscription']['type']['planName'] @@ -20,66 +20,124 @@ interface SubscriptionState { loading: boolean error: string | null setCognitoUsername: (username: string | null) => void - fetchSubscription: () => Promise + fetchSubscription: () => Promise + subscriptionResource: { + read: () => MinimalSubscription | null + preload: (username: string) => void + } } -export const useSubscriptionStore = create((set, get) => ({ - cognitoUsername: null, - subscription: null, - loading: false, - error: null, - setCognitoUsername: username => set({ cognitoUsername: username }), - fetchSubscription: async () => { - const { cognitoUsername } = get() - - if (!cognitoUsername) { - set({ subscription: null, loading: false, error: null }) - return - } +function createResource() { + let status = 'pending' + let result: MinimalSubscription | null = null + let error: Error | null = null + let suspender: Promise | null = null - set({ loading: true, error: null }) - - try { - const { data, errors } = await client.models.UserSubscription.list({ - filter: { userId: { eq: cognitoUsername } }, - selectionSet: [ - 'subscriptionId', - 'planName', - 'pendingPlan', - 'nextPaymentDate', - 'lastFourDigits', - 'createdAt', - ], - - authMode: 'userPool', - }) - - if (errors && errors.length > 0) { - throw new Error('Error al obtener la suscripción') + return { + read() { + if (status === 'pending' && suspender) { + throw suspender + } else if (status === 'error') { + throw error + } else { + return result } + }, + preload(username: string) { + if (!username) return + + status = 'pending' + suspender = fetchSubscriptionData(username) + .then(data => { + status = 'success' + result = data + }) + .catch(e => { + status = 'error' + error = e + }) + }, + } +} + +// Función auxiliar para obtener los datos de suscripción +async function fetchSubscriptionData(username: string): Promise { + try { + const { data, errors } = await client.models.UserSubscription.list({ + filter: { userId: { eq: username } }, + selectionSet: [ + 'subscriptionId', + 'planName', + 'pendingPlan', + 'nextPaymentDate', + 'lastFourDigits', + 'createdAt', + ], + authMode: 'userPool', + }) + + if (errors && errors.length > 0) { + throw new Error('Error getting subscription') + } + + const sortedData = data.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + + if (sortedData.length === 0) { + return null + } - const sortedData = data.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ) - - const minimalSubscription: MinimalSubscription | null = - sortedData.length > 0 - ? { - subscriptionId: sortedData[0].subscriptionId, - planName: sortedData[0].planName, - pendingPlan: sortedData[0].pendingPlan, - nextPaymentDate: sortedData[0].nextPaymentDate, - lastFourDigits: sortedData[0].lastFourDigits, - createdAt: sortedData[0].createdAt, - } - : null - - set({ - subscription: minimalSubscription, - loading: false, - }) - } catch (error) { - set({ error: 'Error al cargar la suscripción', loading: false }) + return { + subscriptionId: sortedData[0].subscriptionId, + planName: sortedData[0].planName, + pendingPlan: sortedData[0].pendingPlan, + nextPaymentDate: sortedData[0].nextPaymentDate, + lastFourDigits: sortedData[0].lastFourDigits, + createdAt: sortedData[0].createdAt, } - }, -})) + } catch (error) { + console.error('Error fetching subscription:', error) + throw error + } +} + +export const useSubscriptionStore = create((set, get) => { + const subscriptionResource = createResource() + + return { + cognitoUsername: null, + subscription: null, + loading: false, + error: null, + subscriptionResource, + setCognitoUsername: username => { + set({ cognitoUsername: username }) + if (username) { + subscriptionResource.preload(username) + } + }, + fetchSubscription: async () => { + const { cognitoUsername } = get() + + if (!cognitoUsername) { + set({ subscription: null, loading: false, error: null }) + return null + } + + set({ loading: true, error: null }) + + try { + const subscription = await fetchSubscriptionData(cognitoUsername) + set({ + subscription, + loading: false, + }) + return subscription + } catch (error) { + set({ error: 'Error loading subscription', loading: false }) + return null + } + }, + } +})