diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..dd84ea78 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 00000000..48d5f81f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,10 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..5eca4d07 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,98 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: ["*"] + pull_request: + branches: ["*"] + schedule: + - cron: '27 8 * * 3' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index a847e339..62ac6f59 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -38,10 +38,10 @@ jobs: run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - git add . + git diff --name-only | grep -v "^.github/" if git diff --staged --quiet; then echo "✅ No hay cambios de formateo. No es necesario hacer commit." exit 0 fi git commit -m "✨ Formateo automático con Prettier" - git push origin $GITHUB_REF_NAME + git push origin $GITHUB_REF_NAME \ No newline at end of file diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml new file mode 100644 index 00000000..c02685dd --- /dev/null +++ b/.github/workflows/unit_test.yml @@ -0,0 +1,30 @@ +name: Ejecutar tests con Jest + +on: + push: + branches: + - '*' # En cualquier push a cualquier rama + pull_request: + branches: + - main + - dev + +jobs: + tests: + name: Ejecutar pruebas unitarias + runs-on: ubuntu-latest + + steps: + - name: Checkout del repositorio + uses: actions/checkout@v4 + + - name: Configurar Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Instalar dependencias + run: npm ci + + - name: Ejecutar pruebas + run: npm run test -- --coverage diff --git a/.gitignore b/.gitignore index 345a4ed1..1f551fa3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ /.pnp .pnp.js .yarn/install-state.gz - +.pnpm-store # testing /coverage @@ -48,6 +48,7 @@ amplify/mock-data amplify/mock-api-resources amplify/backend/amplify-meta.json amplify/backend/.temp +amplify/node_modules/ build/ dist/ aws-exports.js diff --git a/.husky/pre-commit b/.husky/pre-commit index 6b636e2c..d0a77842 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1 @@ - -npx lint-staged +npx lint-staged \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..f665b002 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +corepack disable diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..034e8480 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. diff --git a/amplify.yml b/amplify.yml index f08c6557..e69489b5 100644 --- a/amplify.yml +++ b/amplify.yml @@ -18,4 +18,4 @@ frontend: paths: - .next/cache/**/* - .npm/**/* - - node_modules/**/* + - node_modules/**/* \ No newline at end of file diff --git a/amplify/auth/post-authentication/handler.ts b/amplify/auth/post-authentication/handler.ts index 994a930c..cde11f63 100644 --- a/amplify/auth/post-authentication/handler.ts +++ b/amplify/auth/post-authentication/handler.ts @@ -17,7 +17,6 @@ export const handler: PostAuthenticationTriggerHandler = async event => { const isGoogleLogin = identities.some((identity: any) => identity.providerName === 'Google') if (!isGoogleLogin) { - console.log('El usuario no inició sesión con Google. Terminando ejecución.') return event } diff --git a/amplify/backend.ts b/amplify/backend.ts index fded7c05..dde375ef 100644 --- a/amplify/backend.ts +++ b/amplify/backend.ts @@ -10,6 +10,10 @@ import { checkStoreName } from './functions/checkStoreName/resource' import { checkStoreDomain } from './functions/checkStoreDomain/resource' import { postConfirmation } from './auth/post-confirmation/resource' import { apiKeyManager } from './functions/LambdaEncryptKeys/resource' +import { getStoreProducts } from './functions/getStoreProducts/resource' +import { getStoreData } from './functions/getStoreData/resource' +import { getStoreCollections } from './functions/getStoreCollections/resource' +import { storeImages } from './functions/storeImages/resource' import { data, generateHaikuFunction, @@ -19,7 +23,6 @@ import { import { Stack } from 'aws-cdk-lib' import { AuthorizationType, Cors, LambdaIntegration, RestApi } from 'aws-cdk-lib/aws-apigateway' import { Policy, PolicyStatement, Effect } from 'aws-cdk-lib/aws-iam' -import * as s3 from 'aws-cdk-lib/aws-s3' /** * Definición del backend con sus respectivos recursos. @@ -43,6 +46,10 @@ const backend = defineBackend({ generateProductDescriptionFunction, generatePriceSuggestionFunction, templates, + getStoreProducts, + getStoreData, + storeImages, + getStoreCollections, }) backend.generateHaikuFunction.resources.lambda.addToRolePolicy( @@ -106,13 +113,16 @@ backend.postConfirmation.resources.lambda.addToRolePolicy( }) ) -const s3Bucket = backend.templates.resources.bucket - -const cfnBucket = s3Bucket.node.defaultChild as s3.CfnBucket - -cfnBucket.accelerateConfiguration = { - accelerationStatus: 'Enabled', -} +backend.storeImages.resources.lambda.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:ListBucket', 's3:GetObject', 's3:PutObject', 's3:DeleteObject'], + resources: [ + backend.productsImages.resources.bucket.bucketArn, + `${backend.productsImages.resources.bucket.bucketArn}/*`, + ], + }) +) const apiStack = backend.createStack('api-stack') @@ -281,6 +291,93 @@ const apiKeyManagerIntegration = new LambdaIntegration(backend.apiKeyManager.res const apiKeyManagerResource = apiKeyManagerApi.root.addResource('api-keys') apiKeyManagerResource.addMethod('POST', apiKeyManagerIntegration) +/** + * + * API para Obtener Productos de Tienda + * + */ + +const getStoreProductsApi = new RestApi(apiStack, 'GetStoreProductsApi', { + restApiName: 'GetStoreProductsApi', + deploy: true, + deployOptions: { stageName: 'dev' }, + defaultCorsPreflightOptions: { + allowOrigins: Cors.ALL_ORIGINS, + allowMethods: Cors.ALL_METHODS, + allowHeaders: Cors.DEFAULT_HEADERS, + }, +}) + +const getStoreProductsIntegration = new LambdaIntegration(backend.getStoreProducts.resources.lambda) + +const getStoreProductsResource = getStoreProductsApi.root.addResource('get-store-products') +getStoreProductsResource.addMethod('GET', getStoreProductsIntegration) + +/** + * + * API para Obtener Datos de Tienda + * + */ + +const getStoreDataApi = new RestApi(apiStack, 'GetStoreDataApi', { + restApiName: 'GetStoreDataApi', + deploy: true, + deployOptions: { stageName: 'dev' }, + defaultCorsPreflightOptions: { + allowOrigins: Cors.ALL_ORIGINS, + allowMethods: Cors.ALL_METHODS, + allowHeaders: Cors.DEFAULT_HEADERS, + }, +}) +const getStoreDataIntegration = new LambdaIntegration(backend.getStoreData.resources.lambda) + +const getStoreDataResource = getStoreDataApi.root.addResource('get-store-data') +getStoreDataResource.addMethod('GET', getStoreDataIntegration) + +/** + * + * API para Almacenar Imágenes + * + */ + +const storeImagesApi = new RestApi(apiStack, 'StoreImagesApi', { + restApiName: 'StoreImagesApi', + deploy: true, + deployOptions: { stageName: 'dev' }, + defaultCorsPreflightOptions: { + allowOrigins: Cors.ALL_ORIGINS, + allowMethods: Cors.ALL_METHODS, + allowHeaders: Cors.DEFAULT_HEADERS, + }, +}) +const storeImagesIntegration = new LambdaIntegration(backend.storeImages.resources.lambda) +const storeImagesResource = storeImagesApi.root.addResource('store-images') + +storeImagesResource.addMethod('POST', storeImagesIntegration) + +/** + * + * API para Obtener Colecciones de Tienda + * + */ +const getStoreCollectionsApi = new RestApi(apiStack, 'GetStoreCollectionsApi', { + restApiName: 'GetStoreCollectionsApi', + deploy: true, + deployOptions: { stageName: 'dev' }, + defaultCorsPreflightOptions: { + allowOrigins: Cors.ALL_ORIGINS, + allowMethods: Cors.ALL_METHODS, + allowHeaders: Cors.DEFAULT_HEADERS, + }, +}) + +const getStoreCollectionsIntegration = new LambdaIntegration( + backend.getStoreCollections.resources.lambda +) +const getStoreCollectionsResource = getStoreCollectionsApi.root.addResource('get-store-collections') + +getStoreCollectionsResource.addMethod('GET', getStoreCollectionsIntegration) + /** * * Política de IAM para Invocar las APIs @@ -298,6 +395,10 @@ const apiRestPolicy = new Policy(apiStack, 'RestApiPolicy', { `${checkStoreNameApi.arnForExecuteApi('*', '/check-store-name', 'dev')}`, `${checkStoreDomainApi.arnForExecuteApi('*', '/check-store-domain', 'dev')}`, `${apiKeyManagerApi.arnForExecuteApi('*', '/api-keys', 'dev')}`, + `${getStoreProductsApi.arnForExecuteApi('*', '/get-store-products', 'dev')}`, + `${getStoreDataApi.arnForExecuteApi('*', '/get-store-data', 'dev')}`, + `${storeImagesApi.arnForExecuteApi('*', '/store-images', 'dev')}`, + `${getStoreCollectionsApi.arnForExecuteApi('*', '/get-store-collections', 'dev')}`, ], }), ], @@ -350,6 +451,26 @@ backend.addOutput({ region: Stack.of(apiKeyManagerApi).region, apiName: apiKeyManagerApi.restApiName, }, + GetStoreProductsApi: { + endpoint: getStoreProductsApi.url, + region: Stack.of(getStoreProductsApi).region, + apiName: getStoreProductsApi.restApiName, + }, + GetStoreDataApi: { + endpoint: getStoreDataApi.url, + region: Stack.of(getStoreDataApi).region, + apiName: getStoreDataApi.restApiName, + }, + StoreImagesApi: { + endpoint: storeImagesApi.url, + region: Stack.of(storeImagesApi).region, + apiName: storeImagesApi.restApiName, + }, + GetStoreCollectionsApi: { + endpoint: getStoreCollectionsApi.url, + region: Stack.of(getStoreCollectionsApi).region, + apiName: getStoreCollectionsApi.restApiName, + }, }, }, }) diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index ee6bd3aa..f13f5bb7 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -6,6 +6,9 @@ import { planScheduler } from '../functions/planScheduler/resource' import { checkStoreName } from '../functions/checkStoreName/resource' import { checkStoreDomain } from '../functions/checkStoreDomain/resource' import { apiKeyManager } from '../functions/LambdaEncryptKeys/resource' +import { getStoreProducts } from '../functions/getStoreProducts/resource' +import { getStoreData } from '../functions/getStoreData/resource' +import { getStoreCollections } from '../functions/getStoreCollections/resource' export const MODEL_ID = 'us.anthropic.claude-3-haiku-20240307-v1:0' @@ -109,6 +112,7 @@ const schema = a onboardingCompleted: a.boolean().required(), onboardingData: a.json(), }) + .secondaryIndexes(index => [index('storeId')]) .authorization(allow => [allow.authenticated().to(['read', 'update', 'delete', 'create'])]), Product: a @@ -131,13 +135,33 @@ const schema = a featured: a.boolean(), // Producto destacado tags: a.json(), // Array de etiquetas variants: a.json(), // Variantes del producto + collectionId: a.string(), // ID de la colección supplier: a.string(), // Proveedor del producto + collection: a.belongsTo('Collection', 'collectionId'), // Relación con la colección owner: a.string().required(), // Usuario que creo el producto }) .authorization(allow => [ allow.ownerDefinedIn('owner').to(['update', 'delete', 'read', 'create']), // Solo el creador puede editar y eliminar allow.guest().to(['read']), ]), + + Collection: a + .model({ + storeId: a.string().required(), // Relaciona la colección con la tienda + title: a.string().required(), // Nombre de la colección + description: a.string(), // Descripción de la colección + image: a.string(), // URL de la imagen de la colección + slug: a.string(), // URL amigable de la colección + isActive: a.boolean().required(), + sortOrder: a.integer(), // Orden de la colección + owner: a.string().required(), // Usuario que creo la colección + products: a.hasMany('Product', 'collectionId'), // Relación con productos + }) + .secondaryIndexes(index => [index('storeId')]) + .authorization(allow => [ + allow.ownerDefinedIn('owner').to(['update', 'delete', 'read', 'create']), // Solo el creador puede editar y eliminar + allow.guest().to(['read']), // Visitantes pueden ver las colecciones + ]), }) .authorization(allow => [ allow.resource(postConfirmation), @@ -147,6 +171,9 @@ const schema = a allow.resource(checkStoreName), allow.resource(checkStoreDomain), allow.resource(apiKeyManager), + allow.resource(getStoreProducts), + allow.resource(getStoreData), + allow.resource(getStoreCollections), ]) export type Schema = ClientSchema diff --git a/amplify/functions/cancelPlan/src/handler.ts b/amplify/functions/cancelPlan/src/handler.ts index 7bec04b5..fb383f49 100644 --- a/amplify/functions/cancelPlan/src/handler.ts +++ b/amplify/functions/cancelPlan/src/handler.ts @@ -2,7 +2,7 @@ import axios from 'axios' import { Amplify } from 'aws-amplify' import { generateClient } from 'aws-amplify/data' import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime' -import { env } from '../../../../.amplify/generated/env/hookPlan' +import { env } from '$amplify/env/hookPlan' import { type Schema } from '../../../data/resource' // Configurar Amplify para acceso a datos @@ -53,7 +53,6 @@ export const handler = async (event: any) => { error.response.data.message.toLowerCase().includes('cancelled preapproval') ) { // Tratamos el error como si fuera exitoso. - console.log('La preaprobación ya estaba cancelada.') response = error.response // Usamos el objeto de error.response para continuar. } else { throw error // Para otros errores, relanzamos. diff --git a/amplify/functions/createSubscription/handler.ts b/amplify/functions/createSubscription/handler.ts index b306aece..df76da00 100644 --- a/amplify/functions/createSubscription/handler.ts +++ b/amplify/functions/createSubscription/handler.ts @@ -1,5 +1,5 @@ import { APIGatewayProxyHandler } from 'aws-lambda' -import { env } from '../../../.amplify/generated/env/createSubscription' +import { env } from '$amplify/env/createSubscription' const MERCADOPAGO_API_URL = 'https://api.mercadopago.com/preapproval' diff --git a/amplify/functions/getStoreCollections/handler.ts b/amplify/functions/getStoreCollections/handler.ts new file mode 100644 index 00000000..d2a74e77 --- /dev/null +++ b/amplify/functions/getStoreCollections/handler.ts @@ -0,0 +1,153 @@ +import { Amplify } from 'aws-amplify' +import { generateClient } from 'aws-amplify/data' +import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime' +import { env } from '$amplify/env/getStoreCollections' +import { type Schema } from '../../data/resource' + +// Lazy-load del cliente para evitar reconfiguración en cada invocación +let clientSchema: ReturnType> | null = null + +const initializeClient = async () => { + if (!clientSchema) { + const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(env) + Amplify.configure(resourceConfig, libraryOptions) + clientSchema = generateClient() + } + return clientSchema +} + +export const handler = async (event: any) => { + // Obtener parámetros de la consulta + const storeId = event.queryStringParameters?.storeId + const collectionId = event.queryStringParameters?.collectionId + const slug = event.queryStringParameters?.slug + + // Verificar si se proporcionó un ID de tienda + if (!storeId) { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Store ID is required' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + try { + const client = await initializeClient() + + // Caso 1: Obtener una colección específica por ID + if (collectionId) { + const { data: collection } = await client.models.Collection.get({ id: collectionId }) + + if (!collection || collection.storeId !== storeId) { + return { + statusCode: 404, + body: JSON.stringify({ message: 'Collection not found' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + // Obtener productos asociados a esta colección + const { data: products } = await client.models.Product.list({ + filter: { + collectionId: { eq: collectionId }, + status: { eq: 'active' }, + }, + }) + + return { + statusCode: 200, + body: JSON.stringify({ + collection: { + ...collection, + products: products || [], + }, + }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + // Caso 2: Obtener una colección por slug + if (slug) { + const { data: collections } = await client.models.Collection.list({ + filter: { + storeId: { eq: storeId }, + slug: { eq: slug }, + isActive: { eq: true }, + }, + }) + + if (!collections || collections.length === 0) { + return { + statusCode: 404, + body: JSON.stringify({ message: 'Collection not found' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + const collection = collections[0] + + // Obtener productos asociados a esta colección + const { data: products } = await client.models.Product.list({ + filter: { + collectionId: { eq: collection.id }, + status: { eq: 'active' }, + }, + }) + + return { + statusCode: 200, + body: JSON.stringify({ + collection: { + ...collection, + products: products || [], + }, + }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + // Caso 3: Obtener todas las colecciones de la tienda + const { data: collections } = await client.models.Collection.list({ + filter: { + storeId: { eq: storeId }, + isActive: { eq: true }, + }, + }) + + return { + statusCode: 200, + body: JSON.stringify({ + collections: collections || [], + }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } catch (error) { + console.error('Error fetching collections data:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error fetching collections data' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} diff --git a/amplify/functions/getStoreCollections/resource.ts b/amplify/functions/getStoreCollections/resource.ts new file mode 100644 index 00000000..b251059c --- /dev/null +++ b/amplify/functions/getStoreCollections/resource.ts @@ -0,0 +1,6 @@ +import { defineFunction } from '@aws-amplify/backend' + +export const getStoreCollections = defineFunction({ + name: 'getStoreCollections', + entry: 'handler.ts', +}) diff --git a/amplify/functions/getStoreData/handler.ts b/amplify/functions/getStoreData/handler.ts new file mode 100644 index 00000000..dab78003 --- /dev/null +++ b/amplify/functions/getStoreData/handler.ts @@ -0,0 +1,74 @@ +import { Amplify } from 'aws-amplify' +import { generateClient } from 'aws-amplify/data' +import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime' +import { env } from '$amplify/env/getStoreData' +import { type Schema } from '../../data/resource' + +// Lazy-load del cliente para evitar reconfiguración en cada invocación +let clientSchema: ReturnType> | null = null + +const initializeClient = async () => { + if (!clientSchema) { + const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(env) + Amplify.configure(resourceConfig, libraryOptions) + clientSchema = generateClient() + } + return clientSchema +} +export const handler = async (event: any) => { + const storeId = event.queryStringParameters?.storeId + + if (!storeId) { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Store ID is required' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + try { + const client = await initializeClient() + const { data: store } = await client.models.UserStore.list({ + filter: { + storeId: { + eq: storeId, + }, + }, + }) + + if (!store) { + return { + statusCode: 404, + body: JSON.stringify({ message: 'Store not found' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + return { + statusCode: 200, + body: JSON.stringify({ + store: store, + }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } catch (error) { + console.error('Error fetching store data:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error fetching store data' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} diff --git a/amplify/functions/getStoreData/resource.ts b/amplify/functions/getStoreData/resource.ts new file mode 100644 index 00000000..c35f24bb --- /dev/null +++ b/amplify/functions/getStoreData/resource.ts @@ -0,0 +1,6 @@ +import { defineFunction } from '@aws-amplify/backend' + +export const getStoreData = defineFunction({ + name: 'getStoreData', + entry: 'handler.ts', +}) diff --git a/amplify/functions/getStoreProducts/handler.ts b/amplify/functions/getStoreProducts/handler.ts new file mode 100644 index 00000000..6f9f5670 --- /dev/null +++ b/amplify/functions/getStoreProducts/handler.ts @@ -0,0 +1,62 @@ +import { Amplify } from 'aws-amplify' +import { generateClient } from 'aws-amplify/data' +import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime' +import { env } from '$amplify/env/getStoreProducts' +import { type Schema } from '../../data/resource' + +let clientSchema: ReturnType> | null = null + +const initializeClient = async () => { + if (!clientSchema) { + const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(env) + Amplify.configure(resourceConfig, libraryOptions) + clientSchema = generateClient() + } + return clientSchema +} + +export const handler = async (event: any) => { + const storeId = event.queryStringParameters?.storeId + + if (!storeId) { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Store ID is required' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + try { + const client = await initializeClient() + const { data: products } = await client.models.Product.list({ + filter: { + storeId: { eq: storeId }, + status: { eq: 'active' }, // Opcional: filtrar solo productos activos + }, + }) + + return { + statusCode: 200, + body: JSON.stringify({ + products: products, + }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } catch (error) { + console.error('Error fetching store products:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error fetching store products' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} diff --git a/amplify/functions/getStoreProducts/resource.ts b/amplify/functions/getStoreProducts/resource.ts new file mode 100644 index 00000000..904b113b --- /dev/null +++ b/amplify/functions/getStoreProducts/resource.ts @@ -0,0 +1,6 @@ +import { defineFunction } from '@aws-amplify/backend' + +export const getStoreProducts = defineFunction({ + name: 'getStoreProducts', + entry: 'handler.ts', +}) diff --git a/amplify/functions/planManagement/src/handler.ts b/amplify/functions/planManagement/src/handler.ts index 39577f07..e1fa9575 100644 --- a/amplify/functions/planManagement/src/handler.ts +++ b/amplify/functions/planManagement/src/handler.ts @@ -1,6 +1,6 @@ import axios from 'axios' import { APIGatewayProxyHandler } from 'aws-lambda' -import { env } from '../../../../.amplify/generated/env/planManagement' +import { env } from '$amplify/env/planManagement' /** * Función auxiliar que calcula la fecha de finalización exactamente un mes después de la fecha de inicio. @@ -48,9 +48,6 @@ export const handler: APIGatewayProxyHandler = async event => { const startDate = new Date() const endDate = calcularEndDate(startDate) - console.log('📅 start_date:', startDate.toISOString()) - console.log('📅 end_date:', endDate.toISOString()) - // 3. Construir el payload para actualizar la suscripción en Mercado Pago. // De acuerdo con la documentación, se debe enviar: // { @@ -71,12 +68,8 @@ export const handler: APIGatewayProxyHandler = async event => { currency_id: currencyId, }, } - - console.log('📦 Payload a enviar:', JSON.stringify(payload, null, 2)) - // 4. Realizar la solicitud PUT a Mercado Pago para actualizar la suscripción. const url = `https://api.mercadopago.com/preapproval/${subscriptionId}` - console.log('🔍 URL de actualización:', url) const response = await axios.put(url, payload, { headers: { @@ -85,13 +78,10 @@ export const handler: APIGatewayProxyHandler = async event => { }, }) - console.log('✅ Respuesta de Mercado Pago:', JSON.stringify(response.data, null, 2)) - // 5. Extraer la URL de confirmación de la respuesta. // Se asume que Mercado Pago devuelve la URL en el campo 'init_point'. const confirmationUrl = response.data?.init_point if (confirmationUrl) { - console.log('🔗 URL de confirmación recibida:', confirmationUrl) } else { console.warn('⚠️ No se recibió URL de confirmación en la respuesta.') } diff --git a/amplify/functions/planScheduler/handler.ts b/amplify/functions/planScheduler/handler.ts index 8f26585d..ea6bf8cb 100644 --- a/amplify/functions/planScheduler/handler.ts +++ b/amplify/functions/planScheduler/handler.ts @@ -5,28 +5,22 @@ import { import { Amplify } from 'aws-amplify' import { generateClient } from 'aws-amplify/data' import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime' -import { env } from '../../../.amplify/generated/env/planScheduler' +import { env } from '$amplify/env/planScheduler' import { type Schema } from '../../data/resource' import type { EventBridgeHandler } from 'aws-lambda' -// Configurar Amplify para acceso a datos const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(env) Amplify.configure(resourceConfig, libraryOptions) -// Inicializar el cliente para DynamoDB (Amplify Data) const clientSchema = generateClient() - -// Inicializar el cliente de Cognito const cognitoClient = new CognitoIdentityProviderClient() export const handler: EventBridgeHandler<'Scheduled Event', null, void> = async (event: any) => { try { // 1. Obtener la fecha actual const now = new Date() - console.log('📅 Fecha actual:', now.toISOString()) // 2. Consultar DynamoDB para obtener las suscripciones pendientes con un plan asignado - console.log('🔍 Consultando suscripciones pendientes...') const pendingSubscriptionsResponse = await clientSchema.models.UserSubscription.list({ filter: { pendingPlan: { attributeExists: true }, @@ -35,15 +29,9 @@ export const handler: EventBridgeHandler<'Scheduled Event', null, void> = async }) const pendingSubscriptions = pendingSubscriptionsResponse.data || [] - console.log('📊 Suscripciones encontradas:', pendingSubscriptions.length) - - // Consulta adicional para fines de log (opcional) - const allSubscriptionsResponse = await clientSchema.models.UserSubscription.list() - console.log('📋 Todas las suscripciones:', JSON.stringify(allSubscriptionsResponse, null, 2)) // 3. Iterar sobre cada registro pendiente for (const subscription of pendingSubscriptions) { - console.log('➡️ Procesando suscripción:', JSON.stringify(subscription, null, 2)) const userId = subscription.userId if (!userId) { console.warn('⚠️ Suscripción sin userId, omitiendo...') @@ -61,14 +49,12 @@ export const handler: EventBridgeHandler<'Scheduled Event', null, void> = async try { // 3.1. Actualizar el atributo en Cognito para asignar el plan pendiente - console.log(`🔄 Actualizando plan de usuario ${userId} en Cognito a '${newPlan}'...`) const updateCommand = new AdminUpdateUserAttributesCommand({ UserPoolId: env.USER_POOL_ID, Username: userId, UserAttributes: [{ Name: 'custom:plan', Value: newPlan }], }) await cognitoClient.send(updateCommand) - console.log(`✅ Usuario ${userId} actualizado en Cognito.`) } catch (cognitoError) { console.error(`❌ Error actualizando usuario ${userId} en Cognito:`, cognitoError) continue @@ -76,7 +62,6 @@ export const handler: EventBridgeHandler<'Scheduled Event', null, void> = async try { // 3.2. Actualizar el registro en DynamoDB asignando el plan pendiente - console.log(`🔄 Actualizando suscripción en DynamoDB para usuario ${userId}...`) await clientSchema.models.UserSubscription.update({ id: userId, subscriptionId: subscription.subscriptionId, @@ -87,7 +72,6 @@ export const handler: EventBridgeHandler<'Scheduled Event', null, void> = async planPrice: null, lastFourDigits: null, }) - console.log(`✅ Suscripción de usuario ${userId} actualizada en DynamoDB.`) } catch (dbError) { console.error( `❌ Error actualizando suscripción de usuario ${userId} en DynamoDB:`, @@ -95,8 +79,6 @@ export const handler: EventBridgeHandler<'Scheduled Event', null, void> = async ) } } - - console.log('✅ Se han actualizado todas las suscripciones pendientes.') } catch (error) { console.error('❌ Error en la Lambda programada:', error) } diff --git a/amplify/functions/storeImages/handler.ts b/amplify/functions/storeImages/handler.ts new file mode 100644 index 00000000..3728f561 --- /dev/null +++ b/amplify/functions/storeImages/handler.ts @@ -0,0 +1,233 @@ +import { + S3Client, + ListObjectsV2Command, + PutObjectCommand, + DeleteObjectCommand, +} from '@aws-sdk/client-s3' +import { env } from '$amplify/env/storeImages' + +// Inicializar el cliente S3 +const s3Client = new S3Client() + +const bucketName = env.BUCKET_NAME +// URL base de CloudFront +const cloudFrontDomain = 'https://d1etr7t5j9fzio.cloudfront.net' + +export const handler = async (event: any) => { + try { + const body = event.body ? JSON.parse(event.body) : {} + const { action, storeId } = body + + if (!storeId) { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Store ID is required' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + // Manejar diferentes acciones + switch (action) { + case 'list': + return await listImages(storeId, body.limit, body.prefix) + case 'upload': + return await uploadImage(storeId, body.filename, body.contentType, body.fileContent) + case 'delete': + return await deleteImage(body.key) + default: + return { + statusCode: 400, + body: JSON.stringify({ message: 'Invalid action' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + } catch (error) { + console.error('Error processing request:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error processing request' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} + +// Función para listar imágenes +async function listImages(storeId: string, limit: number = 1000, prefix: string = '') { + try { + // Configurar el prefijo para las imágenes de la tienda + const storePrefix = prefix ? `products/${storeId}/${prefix}` : `products/${storeId}/` + + // Listar objetos en el bucket con el prefijo de la tienda + const listCommand = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: storePrefix, + MaxKeys: limit, + }) + + const listResponse = await s3Client.send(listCommand) + + if (!listResponse.Contents) { + return { + statusCode: 200, + body: JSON.stringify({ images: [] }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + // Generar URLs para cada objeto usando CloudFront + const imagePromises = listResponse.Contents.map(async item => { + if (!item.Key) return null + + // Omitir objetos de carpeta + if (item.Key.endsWith('/')) return null + + // Construir la URL de CloudFront + const cloudFrontUrl = `${cloudFrontDomain}/${item.Key}` + + // Extraer el nombre del archivo de la clave + const keyParts = item.Key.split('/') + const filename = keyParts[keyParts.length - 1] + + // Determinar el tipo de archivo a partir de la extensión + const fileExtension = filename.split('.').pop()?.toLowerCase() || '' + let fileType = 'application/octet-stream' + + if (fileExtension === 'jpg' || fileExtension === 'jpeg') fileType = 'image/jpeg' + else if (fileExtension === 'png') fileType = 'image/png' + else if (fileExtension === 'gif') fileType = 'image/gif' + else if (fileExtension === 'webp') fileType = 'image/webp' + + return { + key: item.Key, + url: cloudFrontUrl, + filename, + lastModified: item.LastModified, + size: item.Size, + type: fileType, + } + }) + + const imageResults = await Promise.all(imagePromises) + const validImages = imageResults.filter((img): img is NonNullable => img !== null) + + return { + statusCode: 200, + body: JSON.stringify({ images: validImages }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } catch (error) { + console.error('Error listing images:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error listing images' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} + +// Función para subir una imagen +async function uploadImage( + storeId: string, + filename: string, + contentType: string, + fileContent: string +) { + try { + // Decodificar el contenido del archivo de base64 + const buffer = Buffer.from(fileContent, 'base64') + const timestamp = new Date().getTime() + const key = `products/${storeId}/${timestamp}-${filename}` + + // Subir el archivo a S3 + const putCommand = new PutObjectCommand({ + Bucket: bucketName, + Key: key, + Body: buffer, + ContentType: contentType, + }) + + await s3Client.send(putCommand) + + // Construir la URL de CloudFront + const cloudFrontUrl = `${cloudFrontDomain}/${key}` + + // Crear un objeto de imagen para devolver + const image = { + key, + url: cloudFrontUrl, + filename, + lastModified: new Date(), + size: buffer.length, + type: contentType, + } + + return { + statusCode: 200, + body: JSON.stringify({ image }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } catch (error) { + console.error('Error uploading image:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error uploading image' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} + +// Función para eliminar una imagen +async function deleteImage(key: string) { + try { + // Eliminar el objeto de S3 + const deleteCommand = new DeleteObjectCommand({ + Bucket: bucketName, + Key: key, + }) + + await s3Client.send(deleteCommand) + + return { + statusCode: 200, + body: JSON.stringify({ success: true }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } catch (error) { + console.error('Error deleting image:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error deleting image' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} diff --git a/amplify/functions/storeImages/resource.ts b/amplify/functions/storeImages/resource.ts new file mode 100644 index 00000000..e128d4d6 --- /dev/null +++ b/amplify/functions/storeImages/resource.ts @@ -0,0 +1,10 @@ +import { defineFunction, secret } from '@aws-amplify/backend' + +export const storeImages = defineFunction({ + name: 'storeImages', + entry: 'handler.ts', + resourceGroupName: 'storeImages', + environment: { + BUCKET_NAME: secret('BUCKET_NAME'), + }, +}) diff --git a/amplify/functions/webHookPlan/src/handler.ts b/amplify/functions/webHookPlan/src/handler.ts index c1f791e0..e2cfe36e 100644 --- a/amplify/functions/webHookPlan/src/handler.ts +++ b/amplify/functions/webHookPlan/src/handler.ts @@ -9,7 +9,7 @@ import axios from 'axios' import { Amplify } from 'aws-amplify' import { generateClient } from 'aws-amplify/data' import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime' -import { env } from '../../../../.amplify/generated/env/hookPlan' +import { env } from '$amplify/env/hookPlan' import { type Schema } from '../../../data/resource' const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(env) @@ -28,7 +28,6 @@ export const handler: APIGatewayProxyHandler = async event => { const signature = event.headers['x-signature'] || event.headers['X-Signature'] if (!signature) throw new Error('Firma no proporcionada en el webhook.') - console.log('✅ Firma recibida:', signature) const match = signature.match(/ts=([^,]+),v1=([^,]+)/) if (!match) throw new Error('Formato de firma no válido.') diff --git a/amplify/functions/webHookPlan/src/package-lock.json b/amplify/functions/webHookPlan/src/package-lock.json index af3b1b28..645ca6b6 100644 --- a/amplify/functions/webHookPlan/src/package-lock.json +++ b/amplify/functions/webHookPlan/src/package-lock.json @@ -19,9 +19,9 @@ "license": "MIT" }, "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.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", + "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", diff --git a/amplify/node_modules/.package-lock.json b/amplify/node_modules/.package-lock.json deleted file mode 100644 index fd854147..00000000 --- a/amplify/node_modules/.package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "amplify", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/amplify/team-provider-info.json b/amplify/team-provider-info.json deleted file mode 100644 index 94ff95fe..00000000 --- a/amplify/team-provider-info.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "dev": { - "awscloudformation": { - "AuthRoleName": "amplify-masterdop-dev-aee47-authRole", - "UnauthRoleArn": "arn:aws:iam::626635400208:role/amplify-masterdop-dev-aee47-unauthRole", - "AuthRoleArn": "arn:aws:iam::626635400208:role/amplify-masterdop-dev-aee47-authRole", - "Region": "us-east-2", - "DeploymentBucketName": "amplify-masterdop-dev-aee47-deployment", - "UnauthRoleName": "amplify-masterdop-dev-aee47-unauthRole", - "StackName": "amplify-masterdop-dev-aee47", - "StackId": "arn:aws:cloudformation:us-east-2:626635400208:stack/amplify-masterdop-dev-aee47/ef000410-da71-11ef-8308-025f8b152883", - "AmplifyAppId": "d23tc1swd1uet5" - }, - "categories": { - "function": { - "epaycoplan": { - "epaycoPublicKey": "999ab911b17469076407db00b81c0d6b", - "epaycoPrivateKey": "27e890acd75ede256aa9ff3507d40cff", - "secretsPathAmplifyAppId": "d23tc1swd1uet5" - } - } - } - } -} diff --git a/app/(with-navbar)/account-settings/hooks/useUpdateProfilePicture.ts b/app/(with-navbar)/account-settings/hooks/useUpdateProfilePicture.ts index 2fac29d3..bdb27c7e 100644 --- a/app/(with-navbar)/account-settings/hooks/useUpdateProfilePicture.ts +++ b/app/(with-navbar)/account-settings/hooks/useUpdateProfilePicture.ts @@ -24,6 +24,9 @@ export function useUpdateProfilePicture() { const result = await uploadData({ path: `public/profile-pictures/${userData?.sub}/${uniqueFileName}`, data: file, + options: { + contentType: file.type, + }, }).result // 2. Construir la URL pública manualmente. diff --git a/app/(with-navbar)/landing/components/NavBar.tsx b/app/(with-navbar)/landing/components/NavBar.tsx index 7e72d345..9e6e0f66 100644 --- a/app/(with-navbar)/landing/components/NavBar.tsx +++ b/app/(with-navbar)/landing/components/NavBar.tsx @@ -155,7 +155,7 @@ export function Navbar() { ))} - + Precios diff --git a/app/(without-navbar)/login/hooks/SignIn.ts b/app/(without-navbar)/login/hooks/SignIn.ts index 346cf38d..b2d22214 100644 --- a/app/(without-navbar)/login/hooks/SignIn.ts +++ b/app/(without-navbar)/login/hooks/SignIn.ts @@ -1,9 +1,5 @@ import { useState, useCallback } from 'react' import { signIn, resendSignUpCode, type SignInInput } from 'aws-amplify/auth' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' - -Amplify.configure(outputs) interface UseAuthReturn { login: (email: string, password: string) => Promise diff --git a/app/(without-navbar)/login/hooks/signUp.ts b/app/(without-navbar)/login/hooks/signUp.ts index bf0b771f..5520476f 100644 --- a/app/(without-navbar)/login/hooks/signUp.ts +++ b/app/(without-navbar)/login/hooks/signUp.ts @@ -1,7 +1,4 @@ import { signUp, confirmSignUp } from 'aws-amplify/auth' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' -Amplify.configure(outputs) export async function handleSignUp(email: string, password: string, nickName: string) { try { diff --git a/app/(without-navbar)/my-store/components/StoreSelector.tsx b/app/(without-navbar)/my-store/components/StoreSelector.tsx index 510cc3d8..68ff2b95 100644 --- a/app/(without-navbar)/my-store/components/StoreSelector.tsx +++ b/app/(without-navbar)/my-store/components/StoreSelector.tsx @@ -1,11 +1,13 @@ import Image from 'next/image' import Link from 'next/link' +import { Suspense } from 'react' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { PlusCircle } from 'lucide-react' -import { useUserStores } from '@/app/(without-navbar)/my-store/hooks/useUserStores' +import { getUserStores } from '@/app/(without-navbar)/my-store/hooks/useUserStores' import { useAuthUser } from '@/hooks/auth/useAuthUser' import { motion, AnimatePresence } from 'framer-motion' +import { Loader } from '@/components/ui/loader' import { routes } from '@/utils/routes' function getInitials(name: string) { @@ -17,61 +19,24 @@ function getInitials(name: string) { .slice(0, 2) } -export function StoreSelector() { - const { userData } = useAuthUser() - const cognitoUsername = userData?.['cognito:username'] - const userPlan = userData?.['custom:plan'] - - // Use the optimized hook that combines store fetching and limit checking - const { stores, loading, canCreateStore, error } = useUserStores(cognitoUsername, userPlan) +function StoreError({ message }: { message: string }) { + return
{message}
+} +// Componente para mostrar la lista de tiendas +function StoreList({ stores, canCreateStore }: { stores: any[]; canCreateStore: boolean }) { return ( - -
- Logo -

Selecciona una tienda

-

para continuar a tu dashboard

-
- - {error && ( -
- Hubo un error al cargar tus tiendas. Por favor, intenta de nuevo. -
- )} - + <> - {loading ? ( - // Loading state - Array(2) - .fill(0) - .map((_, index) => ( -
-
-
-
-
-
-
- )) - ) : stores.length > 0 ? ( + {stores.length > 0 ? ( // Stores list stores.map((store, index) => ( + + ) +} + +// Componente que carga los datos con Suspense +function StoreData({ userId, userPlan }: { userId: string | null; userPlan?: string }) { + // Obtenemos los datos directamente + const result = getUserStores(userId, userPlan) + const { + stores = [], + canCreateStore = false, + error, + } = result as { stores: any[]; canCreateStore: boolean; error?: string } + + if (error) { + return ( + + ) + } + + return +} + +// Componente principal +export function StoreSelector() { + const { userData } = useAuthUser() + const cognitoUsername = userData?.['cognito:username'] + const userPlan = userData?.['custom:plan'] + + return ( + +
+ Logo +

Selecciona una tienda

+

para continuar a tu dashboard

+
+ + + } + > + + () -// Define store limits by plan const STORE_LIMITS = { Imperial: 5, Majestic: 3, Royal: 1, } +// Caché para almacenar promesas de datos +const storeCache = new Map() + /** - * Hook personalizado para obtener las tiendas de un usuario y verificar límites. - * Gestiona el estado de carga y errores durante la consulta. + * Función para obtener las tiendas de un usuario + * Esta función es compatible con Suspense */ -export const useUserStores = (userId: string | null, userPlan?: string) => { - const [stores, setStores] = useState([]) - const [allStores, setAllStores] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [canCreateStore, setCanCreateStore] = useState(false) - - /** - * Efecto que se ejecuta cuando cambia el userId - * Realiza la consulta a la base de datos para obtener las tiendas del usuario - */ - useEffect(() => { - const fetchStores = async () => { - if (!userId) { - console.log('useUserStores: No userId provided, returning empty stores array') - setStores([]) - setAllStores([]) - setCanCreateStore(false) - setLoading(false) - return - } - - try { - console.log(`useUserStores: Fetching stores for userId: ${userId}`) - - // Obtener todas las tiendas del usuario (para verificar límites) - const { data: allUserStores } = await client.models.UserStore.list({ - authMode: 'userPool', - filter: { - userId: { eq: userId }, - }, - selectionSet: ['storeId', 'storeName', 'storeType', 'onboardingCompleted'], - }) +export function getUserStores(userId: string | null, userPlan?: string) { + // Si no hay userId, devolver datos vacíos + if (!userId) { + return { + stores: [], + allStores: [], + canCreateStore: false, + error: null, + storeCount: 0, + } + } - console.log(`useUserStores: Found ${allUserStores?.length || 0} total stores for user`) - console.log('useUserStores: All stores data:', JSON.stringify(allUserStores)) + // Crear una clave única para la caché + const cacheKey = `${userId}-${userPlan || 'default'}` - // Guardar todas las tiendas - setAllStores(allUserStores || []) + // Si no existe en caché, crear una nueva promesa + if (!storeCache.has(cacheKey)) { + const promise = fetchUserStores(userId, userPlan) + storeCache.set(cacheKey, promise) + } + // Usar la promesa de la caché + return use(storeCache.get(cacheKey)) +} - // Filtrar solo las tiendas con onboarding completado para mostrar - const completedStores = - allUserStores?.filter(store => store.onboardingCompleted === true) || [] - setStores(completedStores) +/** + * Función que realiza la consulta a la base de datos + */ +async function fetchUserStores(userId: string, userPlan?: string) { + try { + // Obtener todas las tiendas del usuario (para verificar límites) + const { data: allUserStores } = await client.models.UserStore.list({ + authMode: 'userPool', + filter: { + userId: { eq: userId }, + }, + selectionSet: ['storeId', 'storeName', 'storeType', 'onboardingCompleted'], + }) - console.log(`useUserStores: Found ${completedStores.length} completed stores for user`) + // Filtrar solo las tiendas con onboarding completado para mostrar + const completedStores = allUserStores?.filter(store => store.onboardingCompleted === true) || [] - // Verificar límite de tiendas según el plan - const currentCount = allUserStores?.length || 0 - const limit = userPlan ? STORE_LIMITS[userPlan as keyof typeof STORE_LIMITS] || 0 : 0 - setCanCreateStore(currentCount < limit) + // Verificar límite de tiendas según el plan + const currentCount = allUserStores?.length || 0 + const limit = userPlan ? STORE_LIMITS[userPlan as keyof typeof STORE_LIMITS] || 0 : 0 - console.log( - `useUserStores: Store limit check - Current: ${currentCount}, Limit: ${limit}, Can create: ${currentCount < limit}` - ) - } catch (err) { - console.error('useUserStores: Error fetching stores:', err) - setError(err) - // En caso de error, establecer arrays vacíos - setStores([]) - setAllStores([]) - setCanCreateStore(false) - } finally { - setLoading(false) - } + return { + stores: completedStores, + allStores: allUserStores || [], + canCreateStore: currentCount < limit, + error: null, + storeCount: allUserStores?.length || 0, + } + } catch (err) { + console.error('getUserStores: Error fetching stores:', err) + return { + stores: [], + allStores: [], + canCreateStore: false, + error: err, + storeCount: 0, } - - fetchStores() - }, [userId, userPlan]) - - return { - stores, // Tiendas con onboarding completado (para mostrar) - allStores, // Todas las tiendas (para referencia) - loading, - error, - canCreateStore, // Si el usuario puede crear más tiendas - storeCount: allStores.length, // Número total de tiendas } } diff --git a/app/store/[slug]/collections/[collectionId]/page.tsx b/app/store/[slug]/collections/[collectionId]/page.tsx new file mode 100644 index 00000000..8fff0c4b --- /dev/null +++ b/app/store/[slug]/collections/[collectionId]/page.tsx @@ -0,0 +1,7 @@ +'use client' + +import { FormPage } from '@/app/store/components/product-management/collection-form/form-page' + +export default function CollectionEditPage() { + return +} diff --git a/app/store/[slug]/collections/new/page.tsx b/app/store/[slug]/collections/new/page.tsx new file mode 100644 index 00000000..faba237a --- /dev/null +++ b/app/store/[slug]/collections/new/page.tsx @@ -0,0 +1,19 @@ +'use client' + +import { FormPage } from '@/app/store/components/product-management/collection-form/form-page' +import { Amplify } from 'aws-amplify' +import outputs from '@/amplify_outputs.json' + +Amplify.configure(outputs) +const existingConfig = Amplify.getConfig() +Amplify.configure({ + ...existingConfig, + API: { + ...existingConfig.API, + REST: outputs.custom.APIs, + }, +}) + +export default function CollectionPage() { + return +} diff --git a/app/store/[slug]/collections/page.tsx b/app/store/[slug]/collections/page.tsx new file mode 100644 index 00000000..39f22b17 --- /dev/null +++ b/app/store/[slug]/collections/page.tsx @@ -0,0 +1,19 @@ +'use client' + +import { CollectionsPage } from '@/app/store/components/product-management/collections/collections-page' +import { Amplify } from 'aws-amplify' +import outputs from '@/amplify_outputs.json' + +Amplify.configure(outputs) +const existingConfig = Amplify.getConfig() +Amplify.configure({ + ...existingConfig, + API: { + ...existingConfig.API, + REST: outputs.custom.APIs, + }, +}) + +export default function CollectionsPages() { + return +} diff --git a/app/store/[slug]/products/inventory/page.tsx b/app/store/[slug]/inventory/page.tsx similarity index 100% rename from app/store/[slug]/products/inventory/page.tsx rename to app/store/[slug]/inventory/page.tsx diff --git a/app/store/components/images-selector/image-selector-modal.tsx b/app/store/components/images-selector/image-selector-modal.tsx new file mode 100644 index 00000000..dba3cb64 --- /dev/null +++ b/app/store/components/images-selector/image-selector-modal.tsx @@ -0,0 +1,371 @@ +import { useState, useRef, useCallback } from 'react' +import { Search, Grid, List, Upload, Trash2, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '@/components/ui/dropdown-menu' +import { useS3Images, type S3Image } from '@/app/store/hooks/useS3Images' +import Image from 'next/image' + +interface ImageSelectorModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onSelect?: (image: S3Image | null) => void + initialSelectedImage?: string | null +} + +export default function ImageSelectorModal({ + open, + onOpenChange, + onSelect, + initialSelectedImage = null, +}: ImageSelectorModalProps) { + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') + const [selectedImage, setSelectedImage] = useState(initialSelectedImage) + const [searchTerm, setSearchTerm] = useState('') + const fileInputRef = useRef(null) + const [isUploading, setIsUploading] = useState(false) + const [uploadPreview, setUploadPreview] = useState(null) + // Usar el hook useS3Images para obtener, cargar y eliminar imágenes + const { images, loading, error, uploadImage, deleteImage } = useS3Images({ + limit: 100, + }) + // Filtrar imágenes según el término de búsqueda + const filteredImages = images.filter(img => + img.filename.toLowerCase().includes(searchTerm.toLowerCase()) + ) + + // Manejar la selección de imágenes + const handleImageSelect = (image: S3Image) => { + const newSelectedKey = selectedImage === image.key ? null : image.key + setSelectedImage(newSelectedKey) + } + + // Manejar la confirmación de selección + const handleConfirm = () => { + const selected = images.find(img => img.key === selectedImage) || null + if (onSelect) { + onSelect(selected) + } + onOpenChange(false) + } + + // Manejar la carga de archivos + const handleFileUpload = async (event: React.ChangeEvent) => { + const files = event.target.files + if (!files || files.length === 0) return + + const file = files[0] + setIsUploading(true) + + // Crear una vista previa de la imagen + const reader = new FileReader() + reader.onload = e => { + if (e.target?.result) { + setUploadPreview(e.target.result as string) + } + } + reader.readAsDataURL(file) + + try { + const uploadedImage = await uploadImage(file) + if (uploadedImage) { + setSelectedImage(uploadedImage.key) + } + } catch (error) { + console.error('Error uploading image:', error) + } finally { + setIsUploading(false) + setUploadPreview(null) + // Limpiar el input de archivo + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + } + + // Manejar la eliminación de imágenes + const handleDeleteImage = async (key: string) => { + try { + const success = await deleteImage(key) + if (success) { + if (selectedImage === key) { + setSelectedImage(null) + } + } + } catch (error) {} + } + + // Manejar el arrastrar y soltar + const onDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const file = e.dataTransfer.files[0] + + try { + const uploadedImage = await uploadImage(file) + if (uploadedImage) { + setSelectedImage(uploadedImage.key) + } + } catch (error) {} + } + }, + [uploadImage] + ) + + const onDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + + return ( + + + +
+ Seleccionar imagen +
+
+ +
+ {/* Search and filters */} +
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ + + + + + setViewMode('grid')}> + + Cuadrícula + + setViewMode('list')}> + + Lista + + + +
+
+ + {/* Drop zone */} +
+ + +

Arrastrar y soltar imágenes

+
+ + {/* Upload preview */} + {isUploading && uploadPreview && ( +
+
+ Preview +
+
+ + Subiendo imagen... +
+
+ )} + + {/* Loading state */} + {loading && ( +
+ + Cargando imágenes... +
+ )} + + {/* Error state */} + {error && ( +
+ Error al cargar las imágenes. Por favor, intenta de nuevo. +
+ )} + + {/* Empty state */} + {!loading && filteredImages.length === 0 && ( +
+ No hay imágenes disponibles. Sube algunas imágenes para comenzar. +
+ )} + + {/* Image gallery - Grid view */} + {viewMode === 'grid' && ( +
+ {filteredImages.map((image, index) => ( +
handleImageSelect(image)} + > +
+ handleImageSelect(image)} + onClick={e => e.stopPropagation()} + /> +
+
+ +
+
+ {image.filename} +
+ +
+
{image.filename}
+
+ {image.type?.split('/')[1]?.toUpperCase() || 'IMG'} +
+
+
+ ))} +
+ )} + + {/* Image gallery - List view */} + {viewMode === 'list' && ( +
+ {filteredImages.map((image, index) => ( +
handleImageSelect(image)} + > +
+ handleImageSelect(image)} + onClick={e => e.stopPropagation()} + /> +
+
+ {image.filename} +
+
+
{image.filename}
+
+ {image.type?.split('/')[1]?.toUpperCase() || 'IMG'} • + {image.size ? ` ${Math.round(image.size / 1024)} KB` : ''} +
+
+ +
+ ))} +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/app/store/components/product-management/InventoryTracking.tsx b/app/store/components/product-management/InventoryTracking.tsx index 88f08f89..a2720cfc 100644 --- a/app/store/components/product-management/InventoryTracking.tsx +++ b/app/store/components/product-management/InventoryTracking.tsx @@ -5,7 +5,7 @@ import { Icons } from '@/app/store/icons/index' export function InventoryTracking() { return ( -
+

Inventario

diff --git a/app/store/components/product-management/ProductList.tsx b/app/store/components/product-management/ProductList.tsx index 3b81b239..9e20d38d 100644 --- a/app/store/components/product-management/ProductList.tsx +++ b/app/store/components/product-management/ProductList.tsx @@ -100,7 +100,7 @@ export function ProductList({
{/* Header con título y acciones */}
-

Productos

+

Productos

void +}) { + // Implementar lógica para manejar el cambio y pasar el valor al componente padre + const [editorContent, setEditorContent] = useState(initialValue) + const [isBold, setIsBold] = useState(false) + const [isItalic, setIsItalic] = useState(false) + const [isUnderline, setIsUnderline] = useState(false) + const editorRef = useRef(null) + + // Inicializar el contenido del editor con el valor inicial + useEffect(() => { + if (editorRef.current && initialValue) { + editorRef.current.innerHTML = initialValue + setEditorContent(initialValue) + } + }, [initialValue]) + + useEffect(() => { + const handleSelectionChange = () => { + if (window.getSelection()?.toString()) { + // Update formatting states based on current selection + setIsBold(document.queryCommandState('bold')) + setIsItalic(document.queryCommandState('italic')) + setIsUnderline(document.queryCommandState('underline')) + } + } + + document.addEventListener('selectionchange', handleSelectionChange) + return () => { + document.removeEventListener('selectionchange', handleSelectionChange) + } + }, []) + + // Función para actualizar el contenido y notificar al componente padre + const updateContent = (content: string) => { + setEditorContent(content) + onChange(content) + } + + return ( +
+
+ +
+ + + + +
+ + {/* Resto de los botones de la barra de herramientas */} +
+
{ + const target = e.target as HTMLDivElement + const content = target.innerHTML + updateContent(content) + }} + onKeyDown={e => { + if (e.key === 'b' && (e.ctrlKey || e.metaKey)) { + setIsBold(!isBold) + } + if (e.key === 'i' && (e.ctrlKey || e.metaKey)) { + setIsItalic(!isItalic) + } + if (e.key === 'u' && (e.ctrlKey || e.metaKey)) { + setIsUnderline(!isUnderline) + } + }} + /> +
+ ) +} diff --git a/app/store/components/product-management/collection-form/form-page.tsx b/app/store/components/product-management/collection-form/form-page.tsx new file mode 100644 index 00000000..a4894b91 --- /dev/null +++ b/app/store/components/product-management/collection-form/form-page.tsx @@ -0,0 +1,232 @@ +import { useState, useEffect } from 'react' +import { ArrowLeft, Edit, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { DescriptionEditor } from '@/app/store/components/product-management/collection-form/description-editor' +import { ProductSection } from '@/app/store/components/product-management/collection-form/product-section' +import { PublicationSection } from '@/app/store/components/product-management/collection-form/publication-section' +import { ImageSection } from '@/app/store/components/product-management/collection-form/image-section' +import { useRouter, useParams, usePathname } from 'next/navigation' +import useStoreDataStore from '@/zustand-states/storeDataStore' +import useUserStore from '@/zustand-states/userStore' +import { useCollections } from '@/app/store/hooks/useCollections' +import { Amplify } from 'aws-amplify' +import { getStoreId } from '@/utils/store-utils' +import outputs from '@/amplify_outputs.json' +import { UnsavedChangesAlert } from '@/components/ui/unsaved-changes-alert' +import { useCollectionForm } from '@/app/store/components/product-management/utils/collection-form-utils' + +Amplify.configure(outputs) +const existingConfig = Amplify.getConfig() +Amplify.configure({ + ...existingConfig, + API: { + ...existingConfig.API, + REST: outputs.custom.APIs, + }, +}) + +export function FormPage() { + const router = useRouter() + const pathname = usePathname() + const params = useParams() + const storeId = getStoreId(params, pathname) + const { currentStore } = useStoreDataStore() + const { user } = useUserStore() + + // Obtener el ID de la colección de los parámetros + const collectionId = (params?.collectionId as string) || (params?.id as string) + const isEditing = !!collectionId + + // Usar el hook de colecciones + const { + useGetCollection, + useCreateCollection, + useUpdateCollection, + useDeleteCollection, + addProductToCollection, + removeProductFromCollection, + } = useCollections() + + // Consultar datos de la colección si estamos editando + const { data: collectionData, isLoading, error } = useGetCollection(collectionId || '') + + // Usar el hook personalizado para la lógica del formulario + const { + title, + description, + slug, + isActive, + imageUrl, + isSubmitting, + hasUnsavedChanges, + selectedProducts, + isDataLoaded, + setTitle, + setIsActive, + setIsSubmitting, + handleAddProduct, + handleRemoveProduct, + handleDescriptionChange, + handleImageChange, + handleSaveCollection, + handleDeleteCollection, + handleDiscardChanges, + } = useCollectionForm({ + isEditing, + collectionId, + storeId, + collectionData, + currentStore, + user, + useCreateCollection, + useUpdateCollection, + useDeleteCollection, + addProductToCollection, + removeProductFromCollection, + }) + + return ( +
+
+ {/* Header */} +
+
+ +

+ {isEditing ? 'Editar colección' : 'Nueva colección'} +

+
+ +
+ +
+
+ {/* Title Section */} +
+
+
+ + setTitle(e.target.value)} + className="border-gray-300" + /> +
+ +
+ + +
+
+
+ + {/* Products Section */} + + + {/* SEO Section */} +
+
+

Publicación del motor de búsqueda

+ +
+
+
Mi tienda
+
+ {currentStore?.customDomain} › collections › {slug || 'frontpage'} +
+
{title}
+
+
+
+ +
+ {/* Publication Section */} + + + {/* Image Section */} + + + {/* Theme Template Section */} +
+

Plantilla de tema

+ +
+
+
+ + {/* Footer */} +
+ {isEditing && ( + + )} + +
+
+ + {/* Alerta de cambios sin guardar - only show if data is loaded */} + {hasUnsavedChanges && !isSubmitting && isDataLoaded && ( + + )} +
+ ) +} diff --git a/app/store/components/product-management/collection-form/image-section.tsx b/app/store/components/product-management/collection-form/image-section.tsx new file mode 100644 index 00000000..b329a323 --- /dev/null +++ b/app/store/components/product-management/collection-form/image-section.tsx @@ -0,0 +1,131 @@ +import { useState, useEffect } from 'react' +import { Edit, Image as ImageIcon, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import Image from 'next/image' +import ImageSelectorModal from '@/app/store/components/images-selector/image-selector-modal' +import { S3Image } from '@/app/store/hooks/useS3Images' +import { Amplify } from 'aws-amplify' +import outputs from '@/amplify_outputs.json' + +Amplify.configure(outputs) +const existingConfig = Amplify.getConfig() +Amplify.configure({ + ...existingConfig, + API: { + ...existingConfig.API, + REST: outputs.custom.APIs, + }, +}) + +// Añadir props para imageUrl y onImageChange +export function ImageSection({ + imageUrl = '', + onImageChange, +}: { + imageUrl: string + onImageChange: (image: string) => void +}) { + // Implementar lógica para manejar el cambio y pasar el valor al componente padre + const [isModalOpen, setIsModalOpen] = useState(false) + const [selectedImage, setSelectedImage] = useState(null) + + // Inicializar selectedImage si hay una imageUrl proporcionada + useEffect(() => { + if (imageUrl && !selectedImage) { + // Crear un objeto de imagen a partir de la URL + setSelectedImage({ + key: imageUrl, + url: imageUrl, + filename: imageUrl.split('/').pop() || 'imagen', + type: 'image/jpeg', + size: 0, + lastModified: new Date(), + }) + } + }, [imageUrl, selectedImage]) + + const handleImageSelect = (image: S3Image | null) => { + setSelectedImage(image) + + // Llamar a onImageChange con la URL de la imagen seleccionada + if (image) { + onImageChange(image.url) + } else { + onImageChange('') + } + } + + // Resto del componente permanece igual + return ( +
+
+

Imagen

+ +
+ + {selectedImage ? ( +
+
setIsModalOpen(true)} + > + {selectedImage.filename} +
+ +
+
+
+ + {selectedImage.filename} + + +
+
+ ) : ( +
setIsModalOpen(true)} + > +
+ +
+

Agregar imagen

+

Recomendado: 1200 x 1200px .jpg

+
+ )} + + +
+ ) +} diff --git a/app/store/components/product-management/collection-form/product-section.tsx b/app/store/components/product-management/collection-form/product-section.tsx new file mode 100644 index 00000000..e9c3a4a9 --- /dev/null +++ b/app/store/components/product-management/collection-form/product-section.tsx @@ -0,0 +1,304 @@ +import { useState, useEffect } from 'react' +import { X, Image as ImageIcon } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Checkbox } from '@/components/ui/checkbox' +import { SearchInput } from '@/app/store/components/product-management/collection-form/search-input' +import { useProducts, IProduct } from '@/app/store/hooks/useProducts' +import useStoreDataStore from '@/zustand-states/storeDataStore' + +interface ProductSectionProps { + selectedProducts: IProduct[] + onAddProduct: (product: IProduct) => void + onRemoveProduct: (productId: string) => void +} + +export function ProductSection({ + selectedProducts = [], + onAddProduct, + onRemoveProduct, +}: ProductSectionProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + 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', + sortField: 'createdAt', + }) + + // Estado para los productos seleccionados en el diálogo + const [dialogSelectedProducts, setDialogSelectedProducts] = useState([]) + + // Inicializar los productos seleccionados en el diálogo + useEffect(() => { + if (isDialogOpen) { + setDialogSelectedProducts(selectedProducts.map(p => p.id)) + } + }, [isDialogOpen]) // Solo se ejecuta cuando el diálogo se abre, no cuando selectedProducts cambia + + // Filtrar productos basados en el término de búsqueda + const filteredProducts = products.filter( + product => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) && product.status === 'active' + ) + + // Ordenar productos según la opción seleccionada + const sortedProducts = [...filteredProducts].sort((a, b) => { + switch (sortOption) { + case 'mas-recientes': + // Ordenar por fecha de creación descendente (más recientes primero) + return new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime() + case 'mas-antiguos': + // Ordenar por fecha de creación ascendente (más antiguos primero) + return new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime() + case 'precio-mayor': + // Ordenar por precio descendente (mayor precio primero) + return (b.price || 0) - (a.price || 0) + case 'precio-menor': + // Ordenar por precio ascendente (menor precio primero) + return (a.price || 0) - (b.price || 0) + default: + return 0 + } + }) + + // Manejar la selección de productos en el diálogo + const handleProductSelect = (productId: string) => { + setDialogSelectedProducts(prev => { + if (prev.includes(productId)) { + return prev.filter(id => id !== productId) + } else { + return [...prev, productId] + } + }) + } + + // Confirmar la selección de productos + const handleConfirmSelection = () => { + // Crear un conjunto de IDs de productos actualmente seleccionados + const currentSelectedIds = new Set(selectedProducts.map(p => p.id)) + + // Añadir productos nuevos (los que están en dialogSelectedProducts pero no en currentSelectedIds) + dialogSelectedProducts.forEach(productId => { + if (!currentSelectedIds.has(productId)) { + const product = products.find(p => p.id === productId) + if (product) { + onAddProduct(product) + } + } + }) + + // Eliminar productos que ya no están seleccionados + selectedProducts.forEach(product => { + if (!dialogSelectedProducts.includes(product.id)) { + onRemoveProduct(product.id) + } + }) + + setIsDialogOpen(false) + } + + // Manejar cambio en el término de búsqueda + const handleSearchChange = (value: string) => { + setSearchTerm(value) + } + + return ( + <> +
+

Productos

+
+ ) => + handleSearchChange(e.target.value) + } + /> + +
+ +
+
+ + {selectedProducts.length > 0 ? ( +
+ {selectedProducts.map((product, index) => ( +
+
{index + 1}.
+
+ {product.images && + (Array.isArray(product.images) + ? product.images.length > 0 + : typeof product.images === 'string' && + product.images !== '[]' && + product.images !== '') ? ( + {product.name} + ) : ( +
+ +
+ )} +
+
{product.name}
+
+ + {product.status === 'active' ? 'Activo' : 'Inactivo'} + + +
+
+ ))} +
+ ) : ( +
+

No hay productos seleccionados

+ +
+ )} +
+ + {/* Product Selection Dialog */} + + + + Seleccionar productos + +
+ ) => + handleSearchChange(e.target.value) + } + /> +
+ + {loading ? ( +
Cargando productos...
+ ) : sortedProducts.length === 0 ? ( +
No se encontraron productos
+ ) : ( +
+ {sortedProducts.map(product => ( +
+ handleProductSelect(product.id)} + className="mr-2" + /> +
+ {product.images && + (Array.isArray(product.images) + ? product.images.length > 0 + : typeof product.images === 'string' && + product.images !== '[]' && + product.images !== '') ? ( + {product.name} + ) : ( +
+ +
+ )} +
+ +
+ ))} +
+ )} + + + + + +
+
+ + ) +} diff --git a/app/store/components/product-management/collection-form/publication-section.tsx b/app/store/components/product-management/collection-form/publication-section.tsx new file mode 100644 index 00000000..ee499b80 --- /dev/null +++ b/app/store/components/product-management/collection-form/publication-section.tsx @@ -0,0 +1,79 @@ +import { Button } from '@/components/ui/button' +import { X } from 'lucide-react' + +// Añadir props para isActive y onActiveChange +export function PublicationSection({ + isActive = true, + onActiveChange, +}: { + isActive?: boolean + onActiveChange: (isActive: boolean) => void +}) { + // Implementar lógica para manejar el cambio y pasar el valor al componente padre + return ( +
+
+

Publicación

+ +
+
+
+

Canales de ventas

+
+
+ Tienda online + +
+
+ +
+ +

+ Para añadir esta colección a la navegación de tu tienda online, necesitas{' '} + + actualizar tu menú + +

+
+ +
+
+ Point of Sale +
+
+
+ ) +} diff --git a/app/store/components/product-management/collection-form/search-input.tsx b/app/store/components/product-management/collection-form/search-input.tsx new file mode 100644 index 00000000..b1b4a73e --- /dev/null +++ b/app/store/components/product-management/collection-form/search-input.tsx @@ -0,0 +1,27 @@ +import React, { ChangeEvent } from 'react' +import { Search } from 'lucide-react' +import { Input } from '@/components/ui/input' + +export interface SearchInputProps { + placeholder?: string + className?: string + value?: string + onChange?: (e: ChangeEvent) => void +} + +export function SearchInput({ placeholder, className, value, onChange }: SearchInputProps) { + return ( +
+
+ +
+ +
+ ) +} diff --git a/app/store/components/product-management/collections/collections-footer.tsx b/app/store/components/product-management/collections/collections-footer.tsx new file mode 100644 index 00000000..49a88576 --- /dev/null +++ b/app/store/components/product-management/collections/collections-footer.tsx @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function CollectionsFooter() { + return ( +
+ Más información sobre{' '} + + colecciones + +
+ ) +} diff --git a/app/store/components/product-management/collections/collections-header.tsx b/app/store/components/product-management/collections/collections-header.tsx new file mode 100644 index 00000000..b7f47e14 --- /dev/null +++ b/app/store/components/product-management/collections/collections-header.tsx @@ -0,0 +1,28 @@ +import { Button } from '@/components/ui/button' +import { useRouter } from 'next/navigation' +import { routes } from '@/utils/routes' + +interface CollectionsHeaderProps { + storeId: string +} + +export default function CollectionsHeader({ storeId }: CollectionsHeaderProps) { + const router = useRouter() + return ( +
+
+

Colecciones

+

+ Organiza tus productos en colecciones para facilitar la navegación de tus clientes. +

+
+ +
+ ) +} diff --git a/app/store/components/product-management/collections/collections-page.tsx b/app/store/components/product-management/collections/collections-page.tsx new file mode 100644 index 00000000..3ee7a1f3 --- /dev/null +++ b/app/store/components/product-management/collections/collections-page.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react' +import { getStoreId } from '@/utils/store-utils' +import { useParams, usePathname } from 'next/navigation' +import CollectionsHeader from '@/app/store/components/product-management/collections/collections-header' +import CollectionsTabs from '@/app/store/components/product-management/collections/collections-tabs' +import CollectionsTable from '@/app/store/components/product-management/collections/collections-table' +import CollectionsFooter from '@/app/store/components/product-management/collections/collections-footer' +import { Amplify } from 'aws-amplify' +import outputs from '@/amplify_outputs.json' +import { useCollections } from '@/app/store/hooks/useCollections' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' + +Amplify.configure(outputs) +const existingConfig = Amplify.getConfig() +Amplify.configure({ + ...existingConfig, + API: { + ...existingConfig.API, + REST: outputs.custom.APIs, + }, +}) + +type FilterType = 'all' | 'active' | 'inactive' + +// Skeleton component for the collections table +function CollectionsTableSkeleton() { + return ( +
+ + + + + + + + + + + + + + + + + + + {Array(5) + .fill(0) + .map((_, index) => ( + + + + + +
+ + +
+
+ + + + + + +
+ ))} +
+
+
+ ) +} + +export function CollectionsPage() { + const pathname = usePathname() + const params = useParams() + const storeId = getStoreId(params, pathname) + + // Estado para el filtro activo + const [activeFilter, setActiveFilter] = useState('all') + // Estado para el término de búsqueda + const [searchTerm, setSearchTerm] = useState('') + + // Usar el hook de colecciones + const { useListCollections } = useCollections() + + // Obtener las colecciones de la tienda + const { data: collections, isLoading, error } = useListCollections(storeId) + + // Filtrar colecciones según el tab activo y el término de búsqueda + const filteredCollections = collections?.filter(collection => { + // Filtrar por estado activo/inactivo + if (activeFilter === 'all') { + // No filtrar por estado + } else if (activeFilter === 'active' && !collection.isActive) { + return false + } else if (activeFilter === 'inactive' && collection.isActive) { + return false + } + + // Filtrar por término de búsqueda + if (searchTerm && !collection.title.toLowerCase().includes(searchTerm.toLowerCase())) { + return false + } + + return true + }) + + // Manejar cambio de filtro y búsqueda + const handleFilterChange = (filter: FilterType, search?: string) => { + setActiveFilter(filter) + if (search !== undefined) { + setSearchTerm(search) + } + } + + return ( +
+ +
+ + + {isLoading ? ( + + ) : error ? ( +
+ Error al cargar las colecciones. Por favor, intenta de nuevo. +
+ ) : ( + + )} +
+ +
+ ) +} diff --git a/app/store/components/product-management/collections/collections-table.tsx b/app/store/components/product-management/collections/collections-table.tsx new file mode 100644 index 00000000..378255f1 --- /dev/null +++ b/app/store/components/product-management/collections/collections-table.tsx @@ -0,0 +1,76 @@ +import { Checkbox } from '@/components/ui/checkbox' +import { FileText } from 'lucide-react' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import Link from 'next/link' +import { routes } from '@/utils/routes' + +// Definir la interfaz para las props +interface CollectionsTableProps { + collections: any[] + storeId: string +} + +export default function CollectionsTable({ collections, storeId }: CollectionsTableProps) { + return ( +
+ + + + + + + Título + Productos + Condiciones del producto + + + + {collections.length === 0 ? ( + + + No hay colecciones disponibles + + + ) : ( + collections.map(collection => ( + + + + + + + + + {collection.title || 'Sin título'} + + + {collection.products?.length || 0} + + {collection.isActive ? ( + + Activa + + ) : ( + + Borrador + + )} + + + )) + )} + +
+
+ ) +} diff --git a/app/store/components/product-management/collections/collections-tabs.tsx b/app/store/components/product-management/collections/collections-tabs.tsx new file mode 100644 index 00000000..13440a58 --- /dev/null +++ b/app/store/components/product-management/collections/collections-tabs.tsx @@ -0,0 +1,56 @@ +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Input } from '@/components/ui/input' +import { Search } from 'lucide-react' +import { useState } from 'react' + +type FilterType = 'all' | 'active' | 'inactive' + +export default function CollectionsTabs({ + activeFilter, + onFilterChange, +}: { + activeFilter?: FilterType + onFilterChange?: (filter: FilterType, search?: string) => void +}) { + const [searchTerm, setSearchTerm] = useState('') + + const handleTabChange = (value: FilterType) => { + if (onFilterChange) { + onFilterChange(value, searchTerm) + } + } + + const handleSearchChange = (e: React.ChangeEvent) => { + const newSearchTerm = e.target.value + setSearchTerm(newSearchTerm) + if (onFilterChange) { + onFilterChange(activeFilter || 'all', newSearchTerm) + } + } + + return ( +
+ void} + > + + Todas + Activas + Inactivas + + +
+ + +
+
+ ) +} diff --git a/app/store/components/product-management/utils/collection-form-utils.ts b/app/store/components/product-management/utils/collection-form-utils.ts new file mode 100644 index 00000000..5933914e --- /dev/null +++ b/app/store/components/product-management/utils/collection-form-utils.ts @@ -0,0 +1,343 @@ +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' +import { IProduct } from '@/app/store/hooks/useProducts' +import { CollectionInput } from '@/app/store/hooks/useCollections' +import { routes } from '@/utils/routes' + +export interface CollectionFormState { + title: string + description: string + slug: string + isActive: boolean + imageUrl: string + selectedProducts: IProduct[] +} + +export interface CollectionFormActions { + setTitle: (title: string) => void + setDescription: (description: string) => void + setSlug: (slug: string) => void + setIsActive: (isActive: boolean) => void + setImageUrl: (imageUrl: string) => void + handleAddProduct: (product: IProduct) => void + handleRemoveProduct: (productId: string) => void +} + +export interface UseCollectionFormProps { + isEditing: boolean + collectionId: string + storeId: string + collectionData: any + currentStore: any + user: any + useCreateCollection: any + useUpdateCollection: any + useDeleteCollection: any + addProductToCollection: (collectionId: string, productId: string) => Promise + removeProductFromCollection: (productId: string) => Promise +} + +export const useCollectionForm = ({ + isEditing, + collectionId, + storeId, + collectionData, + currentStore, + user, + useCreateCollection, + useUpdateCollection, + useDeleteCollection, + addProductToCollection, + removeProductFromCollection, +}: UseCollectionFormProps) => { + const router = useRouter() + + const [isDataLoaded, setIsDataLoaded] = useState(false) + + // Estados para el formulario + const [title, setTitle] = useState('Página de inicio') + const [description, setDescription] = useState('') + const [slug, setSlug] = useState('') + const [isActive, setIsActive] = useState(true) + const [imageUrl, setImageUrl] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + // Estado para controlar cambios sin guardar + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [initialFormState, setInitialFormState] = useState({ + title: '', + description: '', + slug: '', + isActive: true, + imageUrl: '', + }) + + // Estado para almacenar los productos seleccionados + const [selectedProducts, setSelectedProducts] = useState([]) + const [initialSelectedProducts, setInitialSelectedProducts] = useState([]) + + // Mutaciones para crear, actualizar y eliminar colecciones + const createCollection = useCreateCollection() + const updateCollection = useUpdateCollection() + const deleteCollection = useDeleteCollection() + + // Cargar datos de la colección si estamos editando + useEffect(() => { + if (isEditing && collectionData) { + const title = collectionData.title || '' + const description = collectionData.description || '' + const slug = collectionData.slug || '' + const isActive = collectionData.isActive + const imageUrl = collectionData.image || '' + + // Establecer valores iniciales + setTitle(title) + setDescription(description) + setSlug(slug) + setIsActive(isActive) + setImageUrl(imageUrl) + + // Guardar estado inicial del formulario + setInitialFormState({ + title, + description, + slug, + isActive, + imageUrl, + }) + + // Si hay productos en la colección, establecerlos como seleccionados + if (collectionData.products && collectionData.products.length > 0) { + setSelectedProducts(collectionData.products) + setInitialSelectedProducts(collectionData.products) + } + + // Mark data as loaded + setIsDataLoaded(true) + } else if (!isEditing) { + // Para nueva colección, establecer valores por defecto + const defaultTitle = 'Página de inicio' + setInitialFormState({ + title: defaultTitle, + description: '', + slug: '', + isActive: true, + imageUrl: '', + }) + + // Mark data as loaded for new collections too + setIsDataLoaded(true) + } + }, [isEditing, collectionData]) + + // Detectar cambios en el formulario + useEffect(() => { + // Only check for changes if data is loaded and not submitting + if (isDataLoaded && !isSubmitting) { + const currentState = { + title, + description, + slug, + isActive, + imageUrl, + } + + const productsChanged = + JSON.stringify(selectedProducts) !== JSON.stringify(initialSelectedProducts) + + const formChanged = + currentState.title !== initialFormState.title || + currentState.description !== initialFormState.description || + currentState.slug !== initialFormState.slug || + currentState.isActive !== initialFormState.isActive || + currentState.imageUrl !== initialFormState.imageUrl || + productsChanged + + setHasUnsavedChanges(formChanged) + } + }, [ + title, + description, + slug, + isActive, + imageUrl, + selectedProducts, + initialFormState, + initialSelectedProducts, + isSubmitting, + isDataLoaded, // Add this dependency + ]) + + // Función para añadir un producto a la selección + const handleAddProduct = (product: IProduct) => { + setSelectedProducts(prev => [...prev, product]) + } + + // Función para eliminar un producto de la selección + const handleRemoveProduct = (productId: string) => { + setSelectedProducts(prev => prev.filter(p => p.id !== productId)) + } + + // Función para manejar la descripción desde el editor + const handleDescriptionChange = (content: string) => { + setDescription(content) + } + + // Función para manejar la imagen seleccionada + const handleImageChange = (url: string) => { + setImageUrl(url) + } + + // Función para guardar la colección + const handleSaveCollection = async () => { + if (!currentStore?.id || !user?.userId) { + toast.error('No se pudo identificar la tienda o el usuario') + return + } + + setIsSubmitting(true) + + try { + const collectionData: CollectionInput = { + storeId: currentStore.storeId, + title, + description, + image: imageUrl, + slug: slug || title.toLowerCase().replace(/\s+/g, '-'), + isActive, + sortOrder: 0, + owner: user.userId, + } + + let savedCollection + + if (isEditing) { + // Actualizar colección existente + savedCollection = await updateCollection.mutateAsync({ + id: collectionId, + data: collectionData, + }) + + // Obtener los productos actuales de la colección + const currentProductIds = new Set(initialSelectedProducts.map(p => p.id)) + const newProductIds = new Set(selectedProducts.map(p => p.id)) + + // Productos a eliminar (están en currentProductIds pero no en newProductIds) + for (const product of initialSelectedProducts) { + if (!newProductIds.has(product.id)) { + // Eliminar producto de la colección + await removeProductFromCollection(product.id) + } + } + + // Productos a añadir (están en newProductIds pero no en currentProductIds) + for (const product of selectedProducts) { + if (!currentProductIds.has(product.id)) { + // Añadir producto a la colección + await addProductToCollection(savedCollection.id, product.id) + } + } + } else { + // Crear nueva colección + savedCollection = await createCollection.mutateAsync(collectionData) + + // Añadir todos los productos seleccionados a la nueva colección + for (const product of selectedProducts) { + await addProductToCollection(savedCollection.id, product.id) + } + } + + // Actualizar estado inicial para reflejar el estado actual + setInitialFormState({ + title, + description, + slug, + isActive, + imageUrl, + }) + setInitialSelectedProducts([...selectedProducts]) + setHasUnsavedChanges(false) + + toast.success( + isEditing ? 'Colección actualizada correctamente' : 'Colección creada correctamente' + ) + + // Redirigir a la lista de colecciones + router.push(routes.store.collections(storeId)) + // No desactivamos isSubmitting para mantener el botón deshabilitado hasta la redirección + } catch (error) { + console.error('Error al guardar la colección:', error) + toast.error('Ocurrió un error al guardar la colección') + setIsSubmitting(false) // Solo desactivamos en caso de error + } + } + + // Función para eliminar la colección + const handleDeleteCollection = async () => { + if (!isEditing) return + + if (confirm('¿Estás seguro de que deseas eliminar esta colección?')) { + setIsSubmitting(true) + try { + await deleteCollection.mutateAsync(collectionId) + toast.success('Colección eliminada correctamente') + router.push(routes.store.collections(storeId)) + // No desactivamos isSubmitting para mantener el botón deshabilitado hasta la redirección + } catch (error) { + console.error('Error al eliminar la colección:', error) + toast.error('Ocurrió un error al eliminar la colección') + setIsSubmitting(false) // Solo desactivamos en caso de error + } + } + } + + // Función para descartar cambios + const handleDiscardChanges = () => { + if (isEditing && collectionData) { + setTitle(initialFormState.title) + setDescription(initialFormState.description) + setSlug(initialFormState.slug) + setIsActive(initialFormState.isActive) + setImageUrl(initialFormState.imageUrl) + setSelectedProducts(initialSelectedProducts) + } else { + // Para nueva colección, restablecer a valores por defecto + setTitle('Página de inicio') + setDescription('') + setSlug('') + setIsActive(true) + setImageUrl('') + setSelectedProducts([]) + } + setHasUnsavedChanges(false) + } + + return { + // Estado + title, + description, + slug, + isActive, + imageUrl, + isSubmitting, + hasUnsavedChanges, + selectedProducts, + isDataLoaded, // Add this to the returned object + + // Acciones + setTitle, + setDescription, + setSlug, + setIsActive, + setImageUrl, + setIsSubmitting, + handleAddProduct, + handleRemoveProduct, + handleDescriptionChange, + handleImageChange, + handleSaveCollection, + handleDeleteCollection, + handleDiscardChanges, + } +} diff --git a/app/store/components/search-bar/SearchRoutes.tsx b/app/store/components/search-bar/SearchRoutes.tsx index 771c6c36..571f3b42 100644 --- a/app/store/components/search-bar/SearchRoutes.tsx +++ b/app/store/components/search-bar/SearchRoutes.tsx @@ -57,7 +57,7 @@ export function generateSearchRoutes(storeId: string): SearchRoute[] { keywords: ['nuevo', 'crear', 'agregar'], }, { - path: routes.store.products.categories(storeId), + path: routes.store.categories(storeId), label: 'Categorías', icon: Package, section: 'Productos', diff --git a/app/store/components/sidebar/app-sidebar.tsx b/app/store/components/sidebar/app-sidebar.tsx index f18d3cc7..e07cc8ae 100644 --- a/app/store/components/sidebar/app-sidebar.tsx +++ b/app/store/components/sidebar/app-sidebar.tsx @@ -53,13 +53,17 @@ export function AppSidebar({ ...props }: React.ComponentProps) { url: routes.store.products.main(storeId), icon: ShoppingBag, items: [ + { + title: 'Colecciones', + url: routes.store.collections(storeId), + }, { title: 'Inventario', - url: routes.store.products.list(storeId), + url: routes.store.inventory(storeId), }, { title: 'Categorías', - url: routes.store.products.categories(storeId), + url: routes.store.categories(storeId), }, ], }, diff --git a/app/store/hooks/useCollections.ts b/app/store/hooks/useCollections.ts new file mode 100644 index 00000000..e3e5779a --- /dev/null +++ b/app/store/hooks/useCollections.ts @@ -0,0 +1,245 @@ +import { useState } from 'react' +import { generateClient } from 'aws-amplify/data' +import type { Schema } from '@/amplify/data/resource' +import { useQuery, useMutation, useQueryClient, UseQueryResult } from '@tanstack/react-query' + +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 + */ +export interface CollectionInput { + storeId: string // ID de la tienda a la que pertenece la colección + title: string // Título de la colección + description?: string // Descripción opcional de la colección + image?: string // URL de la imagen de la colección (opcional) + slug?: string // URL amigable para la colección (opcional) + isActive: boolean // Indica si la colección está activa + sortOrder?: number // Orden de clasificación (opcional) + owner: string // Usuario propietario de la colección +} + +/** + * Hook personalizado para gestionar colecciones de productos con React Query + * + * Este hook proporciona funciones para crear, leer, actualizar y eliminar colecciones, + * así como para gestionar los productos dentro de las colecciones. + * Utiliza React Query para mantener los datos en caché durante 5 minutos. + */ +export const useCollections = () => { + const [error, setError] = useState(null) // Estado de error + const queryClient = useQueryClient() + + /** + * Función auxiliar para ejecutar operaciones con manejo de errores + * @param operation - Función que realiza la operación con la API + * @returns Los datos resultantes o null en caso de error + */ + const performOperation = async ( + operation: () => Promise<{ data: T; errors?: any[] }> + ): Promise => { + setError(null) + try { + 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') + } + return result.data + } catch (err) { + setError(err) + throw err + } + } + + /** + * Obtiene una colección por su ID usando React Query + * @param id - ID de la colección a obtener + * @returns Resultado de la consulta con la colección + */ + const useGetCollection = (id: string): UseQueryResult => { + return useQuery({ + queryKey: [COLLECTIONS_KEY, id], + queryFn: async () => { + // Obtener la colección + const collection = await performOperation(() => + client.models.Collection.get({ id }, { authMode: 'userPool' }) + ) + + // Si la colección existe, obtener sus productos + if (collection) { + // Obtener productos de la colección + const productsData = await performOperation(() => + client.models.Product.list({ + filter: { collectionId: { eq: id } }, + authMode: 'userPool', + }) + ) + + // Añadir productos a la colección + return { + ...collection, + products: productsData, + } + } + + return collection + }, + staleTime: 5 * 60 * 1000, // 5 minutos en caché + enabled: !!id, + }) + } + + /** + * Lista todas las colecciones, opcionalmente filtradas por tienda usando React Query + * @param storeId - ID de la tienda para filtrar (opcional) + * @returns Resultado de la consulta con el array de colecciones + */ + const useListCollections = (storeId?: string): UseQueryResult => { + return useQuery({ + queryKey: [COLLECTIONS_KEY, 'list', storeId], + queryFn: () => { + const filter = storeId ? { storeId: { eq: storeId } } : undefined + return performOperation(() => + client.models.Collection.list({ + filter, + authMode: 'userPool', + }) + ) + }, + staleTime: 5 * 60 * 1000, // 5 minutos en caché + }) + } + + /** + * Obtiene los productos de una colección específica + * @param collectionId - ID de la colección + * @returns Resultado de la consulta con los productos de la colección + */ + const useGetCollectionProducts = (collectionId: string): UseQueryResult => { + return useQuery({ + queryKey: [COLLECTIONS_KEY, collectionId, 'products'], + queryFn: () => { + return performOperation(() => + client.models.Product.list({ + filter: { collectionId: { eq: collectionId } }, + authMode: 'userPool', + }) + ) + }, + staleTime: 5 * 60 * 1000, // 5 minutos en caché + enabled: !!collectionId, + }) + } + + /** + * Crea una nueva colección usando React Query + */ + const useCreateCollection = () => { + return useMutation({ + mutationFn: (collectionInput: CollectionInput) => + performOperation(() => + client.models.Collection.create(collectionInput, { + authMode: 'userPool', + }) + ), + onSuccess: () => { + // Invalidar consultas para actualizar la lista + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY, 'list'] }) + }, + }) + } + + /** + * Actualiza una colección existente usando React Query + */ + const useUpdateCollection = () => { + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + performOperation(() => + client.models.Collection.update( + { + id, + ...data, + }, + { + authMode: 'userPool', + } + ) + ), + onSuccess: data => { + // Actualizar la colección en caché + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY, data?.id] }) + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY, 'list'] }) + }, + }) + } + + /** + * Elimina una colección usando React Query + */ + const useDeleteCollection = () => { + return useMutation({ + mutationFn: (id: string) => + performOperation(() => client.models.Collection.delete({ id }, { authMode: 'userPool' })), + onSuccess: (_, id) => { + // Eliminar la colección de la caché + queryClient.removeQueries({ queryKey: [COLLECTIONS_KEY, id] }) + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY, 'list'] }) + }, + }) + } + + /** + * Añade un producto a una colección + * @param collectionId - ID de la colección + * @param productId - ID del producto a añadir + * @returns El producto actualizado o null en caso de error + */ + const addProductToCollection = async (collectionId: string, productId: string) => { + // Actualizar el producto para asignarle la colección + return performOperation(() => + client.models.Product.update( + { + id: productId, + collectionId: collectionId, + }, + { authMode: 'userPool' } + ) + ) + } + + /** + * Elimina un producto de una colección + * @param productId - ID del producto a eliminar de la colección + * @returns El producto actualizado o null en caso de error + */ + const removeProductFromCollection = async (productId: string) => { + // Actualizar el producto para eliminar la referencia a la colección + return performOperation(() => + client.models.Product.update( + { + id: productId, + collectionId: null, // Eliminar la referencia a la colección + }, + { authMode: 'userPool' } + ) + ) + } + + return { + error, + useGetCollection, + useListCollections, + useGetCollectionProducts, + useCreateCollection, + useUpdateCollection, + useDeleteCollection, + addProductToCollection, + removeProductFromCollection, + } +} diff --git a/app/store/hooks/useProductImageUpload.ts b/app/store/hooks/useProductImageUpload.ts index ce4fc21e..f8ee0614 100644 --- a/app/store/hooks/useProductImageUpload.ts +++ b/app/store/hooks/useProductImageUpload.ts @@ -35,13 +35,14 @@ export function useProductImageUpload() { const result = await uploadData({ options: { bucket: 'productsImages', + contentType: file.type, }, path: `products/${storeId}/${uniqueFileName}`, data: file, }).result // Construir la URL pública correcta usando el nombre del bucket - const publicUrl = `https://${productBucket.bucket_name}.s3.${aws_region}.amazonaws.com/${result.path}` + const publicUrl = `https://d1etr7t5j9fzio.cloudfront.net/${result.path}` return { url: publicUrl, diff --git a/app/store/hooks/useS3Images.ts b/app/store/hooks/useS3Images.ts new file mode 100644 index 00000000..816b92cf --- /dev/null +++ b/app/store/hooks/useS3Images.ts @@ -0,0 +1,172 @@ +import { useState, useEffect } from 'react' +import { post } from 'aws-amplify/api' +import useStoreDataStore from '@/zustand-states/storeDataStore' + +export interface S3Image { + key: string + url: string + filename: string + lastModified?: Date + size?: number + type?: string +} + +interface UseS3ImagesOptions { + limit?: number + prefix?: string +} + +// Definir el tipo de la respuesta esperada del API +interface S3ImagesResponse { + images?: S3Image[] + success?: boolean + image?: S3Image +} + +export function useS3Images(options: UseS3ImagesOptions = {}) { + const [images, setImages] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const { storeId } = useStoreDataStore() + + useEffect(() => { + const fetchImages = async () => { + if (!storeId) { + setLoading(false) + setImages([]) + return + } + + setLoading(true) + try { + const restOperation = post({ + apiName: 'StoreImagesApi', + path: 'store-images', + options: { + body: { + action: 'list', + storeId, + limit: options.limit || 1000, + prefix: options.prefix || '', + }, + }, + }) + + const { body } = await restOperation.response + const response = (await body.json()) as S3ImagesResponse + + if (!response.images) { + setImages([]) + return + } + + const processedImages = response.images.map(img => ({ + ...img, + lastModified: img.lastModified ? new Date(img.lastModified) : undefined, + })) + + setImages(processedImages) + } catch (err) { + console.error('Error fetching S3 images:', err) + setError(err instanceof Error ? err : new Error('Unknown error occurred')) + } finally { + setLoading(false) + } + } + + fetchImages() + }, [storeId, options.prefix, options.limit]) + + const uploadImage = async (file: File): Promise => { + if (!storeId || !file) return null + + try { + const base64File = await fileToBase64(file) + + const restOperation = post({ + apiName: 'StoreImagesApi', + path: 'store-images', + options: { + body: { + action: 'upload', + storeId, + filename: file.name, + contentType: file.type, + fileContent: base64File, + }, + }, + }) + + const { body } = await restOperation.response + const response = (await body.json()) as S3ImagesResponse + + if (!response.image) { + throw new Error('Failed to upload image') + } + + const newImage = { + ...response.image, + lastModified: response.image.lastModified + ? new Date(response.image.lastModified) + : new Date(), + } + + setImages(prev => [newImage, ...prev]) + + return newImage + } catch (err) { + console.error('Error uploading image:', err) + return null + } + } + + const deleteImage = async (key: string): Promise => { + if (!storeId) return false + + try { + const restOperation = post({ + apiName: 'StoreImagesApi', + path: 'store-images', + options: { + body: { + action: 'delete', + storeId, + key, + }, + }, + }) + + const { body } = await restOperation.response + const response = (await body.json()) as S3ImagesResponse + + if (!response.success) { + throw new Error('Failed to delete image') + } + + setImages(prev => prev.filter(img => img.key !== key)) + + return true + } catch (err) { + console.error('Error deleting image:', err) + return false + } + } + + const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => { + if (typeof reader.result === 'string') { + const base64 = reader.result.split(',')[1] + resolve(base64) + } else { + reject(new Error('Failed to convert file to base64')) + } + } + reader.onerror = error => reject(error) + }) + } + + return { images, loading, error, uploadImage, deleteImage } +} diff --git a/components/ui/loader.tsx b/components/ui/loader.tsx new file mode 100644 index 00000000..bc00b82a --- /dev/null +++ b/components/ui/loader.tsx @@ -0,0 +1,116 @@ +import { cn } from '@/lib/utils' +import { HTMLAttributes } from 'react' + +interface LoaderProps extends HTMLAttributes { + /** + * Tamaño del loader (pequeño, mediano, grande) + * @default "medium" + */ + size?: 'small' | 'medium' | 'large' + + /** + * Color del loader + * @default "black" + */ + color?: 'black' | 'white' | 'primary' | 'secondary' | 'accent' + + /** + * Texto que se muestra junto al loader + */ + text?: string + + /** + * Si el loader debe ocupar todo el ancho disponible + * @default false + */ + fullWidth?: boolean + + /** + * Si el loader debe centrarse en su contenedor + * @default false + */ + centered?: boolean +} + +// Componente para el estado de carga +export function Loader({ + size = 'medium', + color = 'black', + text, + fullWidth = false, + centered = false, + className, + ...props +}: LoaderProps) { + // Mapeo de tamaños a dimensiones + const sizeMap = { + small: 'h-3 w-3', + medium: 'h-4 w-4', + large: 'h-6 w-6', + } + + // Mapeo de colores + const colorMap = { + black: 'text-black', + white: 'text-white', + primary: 'text-primary', + secondary: 'text-secondary', + accent: 'text-accent', + } + + return ( +
+ + + + + + {text && {text}} + +
+ ) +} + +// Variantes predefinidas para casos de uso comunes +export function SmallLoader(props: Omit) { + return +} + +export function LargeLoader(props: Omit) { + return +} + +export function FullPageLoader({ + text = 'Cargando...', + ...props +}: Omit) { + return ( +
+ +
+ ) +} diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx new file mode 100644 index 00000000..654ff1bf --- /dev/null +++ b/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from 'react' +import * as SwitchPrimitives from '@radix-ui/react-switch' + +import { cn } from '@/lib/utils' + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/components/ui/unsaved-changes-alert.tsx b/components/ui/unsaved-changes-alert.tsx index 83e7bed7..4b2658a9 100644 --- a/components/ui/unsaved-changes-alert.tsx +++ b/components/ui/unsaved-changes-alert.tsx @@ -60,7 +60,7 @@ export function UnsavedChangesAlert({ >
- Producto no guardado + Cambios no guardados