From 60d79db2601467a05d94f6873213d2b7c4518c97 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 16 Oct 2025 21:08:06 -0500 Subject: [PATCH 1/6] Refactor user store model authorization rules to enhance security This commit removes the 'authenticated' authorization for various fields in the user store model and updates the 'storeStatus' field to include specific authorization rules. These changes improve the security model by ensuring that only the owner and public API key can access certain operations, aligning with best practices in user data management. --- amplify/data/models/user-store.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/amplify/data/models/user-store.ts b/amplify/data/models/user-store.ts index 56097b9f..23a9ae9e 100644 --- a/amplify/data/models/user-store.ts +++ b/amplify/data/models/user-store.ts @@ -7,7 +7,6 @@ export const userStoreModel = a .required() .authorization((allow) => [ allow.ownerDefinedIn('userId').to(['create', 'read', 'delete']), - allow.authenticated().to(['create', 'read']), allow.publicApiKey().to(['read']), ]), storeId: a @@ -15,7 +14,6 @@ export const userStoreModel = a .required() .authorization((allow) => [ allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete']), - allow.authenticated().to(['create', 'read']), allow.publicApiKey().to(['read']), ]), storeName: a @@ -23,7 +21,6 @@ export const userStoreModel = a .required() .authorization((allow) => [ allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete']), - allow.authenticated().to(['create', 'read', 'update']), allow.publicApiKey().to(['read']), ]), storeDescription: a.string(), @@ -35,7 +32,12 @@ export const userStoreModel = a currencyLocale: a.string(), currencyDecimalPlaces: a.integer(), storeType: a.string(), - storeStatus: a.boolean(), + storeStatus: a + .boolean() + .authorization((allow) => [ + allow.ownerDefinedIn('userId').to(['read']), + allow.publicApiKey().to(['read', 'update']), + ]), storeAdress: a.string(), contactEmail: a.string(), contactPhone: a.string(), @@ -45,7 +47,6 @@ export const userStoreModel = a .required() .authorization((allow) => [ allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete']), - allow.authenticated().to(['create', 'read', 'update']), allow.publicApiKey().to(['read']), ]), onboardingData: a.json(), @@ -67,7 +68,6 @@ export const userStoreModel = a .identifier(['storeId']) .secondaryIndexes((index) => [index('userId'), index('storeName'), index('defaultDomain')]) .authorization((allow) => [ - allow.authenticated().to(['read', 'update', 'delete', 'create']), allow.publicApiKey().to(['read']), allow.ownerDefinedIn('userId').to(['read', 'update', 'delete', 'create']), ]); From 92f7bdfddc60152987e17ff27ad518c0a4fc5150 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 17 Oct 2025 13:12:49 -0500 Subject: [PATCH 2/6] Refactor environment variable handling and improve theme management components This commit updates the handling of environment variables across multiple files, changing references from 'NODE_ENV' to 'APP_ENV' for consistency. Additionally, it refactors the theme management components, enhancing the ThemeList and ThemeUploadForm for better user experience and functionality. The ThemeModals are also improved with streamlined modal actions, and new theme actions such as renaming and previewing themes are introduced, ensuring a more robust theme management process. --- amplify/data/models/user-store.ts | 2 +- .../controllers/get-asset-controller.ts | 2 +- .../themes/controllers/activate-controller.ts | 6 - .../_lib/services/s3-theme-files.service.ts | 2 +- .../store-config/components/LogoUploader.tsx | 37 +- .../store-config/components/ThemePreview.tsx | 59 +--- .../store-setup/components/EcommerceSetup.tsx | 1 - .../components/ImportThemeDropdown.tsx | 40 +++ .../components/InactiveThemesList.tsx | 122 +++++++ .../theme-management/components/ThemeList.tsx | 207 +++++------ .../components/ThemeModals.tsx | 74 ++-- .../components/ThemePreviewCard.tsx | 137 ++++++++ .../components/ThemeUploadForm.tsx | 328 ++++++------------ .../components/ThemeUploadModal.tsx | 80 +++++ .../theme-management/hooks/useThemeActions.ts | 73 +++- .../theme-management/hooks/useThemeUpload.ts | 14 +- .../theme-management/types/theme-types.ts | 38 ++ .../services/templates/template-loader.ts | 2 +- utils/server/cdn-url.ts | 2 +- 19 files changed, 769 insertions(+), 457 deletions(-) create mode 100644 app/store/components/theme-management/components/ImportThemeDropdown.tsx create mode 100644 app/store/components/theme-management/components/InactiveThemesList.tsx create mode 100644 app/store/components/theme-management/components/ThemePreviewCard.tsx create mode 100644 app/store/components/theme-management/components/ThemeUploadModal.tsx diff --git a/amplify/data/models/user-store.ts b/amplify/data/models/user-store.ts index 23a9ae9e..7a99a40a 100644 --- a/amplify/data/models/user-store.ts +++ b/amplify/data/models/user-store.ts @@ -35,7 +35,7 @@ export const userStoreModel = a storeStatus: a .boolean() .authorization((allow) => [ - allow.ownerDefinedIn('userId').to(['read']), + allow.ownerDefinedIn('userId').to(['create', 'read']), allow.publicApiKey().to(['read', 'update']), ]), storeAdress: a.string(), diff --git a/app/api/stores/_lib/assets/controllers/get-asset-controller.ts b/app/api/stores/_lib/assets/controllers/get-asset-controller.ts index 8a6f749f..3406823b 100644 --- a/app/api/stores/_lib/assets/controllers/get-asset-controller.ts +++ b/app/api/stores/_lib/assets/controllers/get-asset-controller.ts @@ -23,7 +23,7 @@ import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; const s3Client = new S3Client({ region: process.env.REGION_BUCKET }); const bucketName = process.env.BUCKET_NAME || ''; const cloudFrontDomain = process.env.CLOUDFRONT_DOMAIN_NAME || ''; -const appEnv = process.env.NODE_ENV || 'development'; +const appEnv = process.env.APP_ENV || 'development'; export async function getAsset(request: NextRequest, storeId: string, assetPath: string): Promise { const corsHeaders = await getNextCorsHeaders(request); diff --git a/app/api/stores/_lib/themes/controllers/activate-controller.ts b/app/api/stores/_lib/themes/controllers/activate-controller.ts index 4da2edd5..9ef036a1 100644 --- a/app/api/stores/_lib/themes/controllers/activate-controller.ts +++ b/app/api/stores/_lib/themes/controllers/activate-controller.ts @@ -76,12 +76,6 @@ export async function activateTheme(request: NextRequest, storeId: string): Prom ); } - logger.info( - 'Theme activation updated successfully', - { storeId, themeId, isActive: updatedTheme?.isActive }, - 'ThemeActivationAPI' - ); - return NextResponse.json( { success: true, diff --git a/app/api/themes/_lib/services/s3-theme-files.service.ts b/app/api/themes/_lib/services/s3-theme-files.service.ts index ca676276..27962216 100644 --- a/app/api/themes/_lib/services/s3-theme-files.service.ts +++ b/app/api/themes/_lib/services/s3-theme-files.service.ts @@ -42,7 +42,7 @@ export class ThemeS3Service { private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutos private constructor() { - this.isProduction = process.env.NODE_ENV === 'production'; + this.isProduction = process.env.APP_ENV === 'production'; this.bucket = process.env.BUCKET_NAME || ''; this.s3 = new S3Client({ region: process.env.REGION_BUCKET, diff --git a/app/store/components/store-config/components/LogoUploader.tsx b/app/store/components/store-config/components/LogoUploader.tsx index 23be1f35..41741966 100644 --- a/app/store/components/store-config/components/LogoUploader.tsx +++ b/app/store/components/store-config/components/LogoUploader.tsx @@ -1,17 +1,6 @@ import { useState, useCallback } from 'react'; -import { - Modal, - Tabs, - Button, - DropZone, - BlockStack, - Text, - Thumbnail, - Banner, - Toast, - Tooltip, - InlineStack, -} from '@shopify/polaris'; +import { Modal, Tabs, Button, DropZone, BlockStack, Text, Banner, Toast, Tooltip, InlineStack } from '@shopify/polaris'; +import Image from 'next/image'; import { useLogoUpload } from '@/app/store/hooks/storage/useLogoUpload'; import { useUserStoreData } from '@/app/(setup)/first-steps/hooks/useUserStoreData'; import useStoreDataStore from '@/context/core/storeDataStore'; @@ -157,7 +146,16 @@ export function LogoUploader() { {logoUrl ? ( - +
+ Logo preview +
@@ -177,7 +175,16 @@ export function LogoUploader() { {faviconUrl ? ( - +
+ Favicon preview +
diff --git a/app/store/components/store-config/components/ThemePreview.tsx b/app/store/components/store-config/components/ThemePreview.tsx index df0cb80d..096f0a91 100644 --- a/app/store/components/store-config/components/ThemePreview.tsx +++ b/app/store/components/store-config/components/ThemePreview.tsx @@ -1,61 +1,32 @@ import { LogoUploader } from '@/app/store/components/store-config/components/LogoUploader'; -import { ThemeUploader } from '@/app/store/components/store-config/components/ThemeUploader'; import { ThemeList } from '@/app/store/components/theme-management/components/ThemeList'; import { useThemeList } from '@/app/store/components/theme-management/hooks/useThemeList'; import useStoreDataStore from '@/context/core/storeDataStore'; -import { openStoreUrl } from '@/lib/utils/store-url'; -import { - Badge, - BlockStack, - Button, - ButtonGroup, - Card, - Layout, - MediaCard, - Page, - Spinner, - Tabs, - Text, -} from '@shopify/polaris'; -import { MoneyFilledIcon } from '@shopify/polaris-icons'; +import { BlockStack, Card, Layout, MediaCard, Page, Spinner, Tabs, Text } from '@shopify/polaris'; import Image from 'next/image'; import { useCallback, useMemo, useState } from 'react'; export function ThemePreview() { const { currentStore } = useStoreDataStore(); - const customDomain = currentStore?.defaultDomain || ''; const [selectedTab, setSelectedTab] = useState(0); const storeId = currentStore?.storeId || ''; const { themes, loading } = useThemeList(storeId); - // Encontrar el tema activo y su preview URL (HTTPS o data URI si aún no se propaga) const activeTheme = useMemo(() => themes.find((t: any) => t.isActive) || themes[0], [themes]); const activePreviewUrl = activeTheme?.previewUrl; const isLoadingPreview = !storeId || loading || themes.length === 0; const handleTabChange = useCallback((index: number) => setSelectedTab(index), []); - const handleViewStore = useCallback(() => { - openStoreUrl({ - storeId: storeId, - customDomain: customDomain, - }); - }, [storeId, customDomain]); - const tabs = [ { id: 'preview', content: 'Vista previa' }, { id: 'themes', content: 'Gestionar temas' }, ]; return ( - {}, - }}> + - + {selectedTab === 0 && ( @@ -74,7 +45,6 @@ export function ThemePreview() { display: 'flex', alignItems: 'center', justifyContent: 'center', - background: 'var(--p-color-bg-subdued)', }}> @@ -102,16 +72,8 @@ export function ThemePreview() { {activeTheme?.name || 'Tema'}
- - Diseño actual - - - - - - + } + onClose={() => setActivePopover(false)} + preferredPosition="below" + preferredAlignment="right"> + + + ); +} diff --git a/app/store/components/theme-management/components/InactiveThemesList.tsx b/app/store/components/theme-management/components/InactiveThemesList.tsx new file mode 100644 index 00000000..8c91cd2f --- /dev/null +++ b/app/store/components/theme-management/components/InactiveThemesList.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { ActionList, BlockStack, Button, Card, InlineStack, Popover, Text } from '@shopify/polaris'; +import { DeleteIcon, EditIcon, MenuHorizontalIcon, StatusActiveIcon, ViewIcon } from '@shopify/polaris-icons'; +import { useState } from 'react'; +import Image from 'next/image'; +import type { InactiveThemesListProps } from '@/app/store/components/theme-management/types/theme-types'; + +const THEME_PREVIEW_PLACEHOLDER = + 'https://images.unsplash.com/photo-1741482529153-a98d81235d06?q=80&w=2070&auto=format&fit=crop'; + +export function InactiveThemesList({ themes, onAction }: InactiveThemesListProps) { + const [activePopover, setActivePopover] = useState(null); + + const formatDate = (dateString: string): string => { + return new Date(dateString).toLocaleDateString('es-ES', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + const handleAction = (action: string, themeId: string): void => { + const theme = themes.find((t) => t.id === themeId); + if (theme) { + onAction(action, theme); + } + setActivePopover(null); + }; + + const getThemeActions = (themeId: string) => [ + { + content: 'Activar', + icon: StatusActiveIcon, + onAction: () => handleAction('activate', themeId), + }, + { + content: 'Vista previa', + icon: ViewIcon, + onAction: () => handleAction('preview', themeId), + }, + { + content: 'Renombrar', + icon: EditIcon, + onAction: () => handleAction('rename', themeId), + }, + { + content: 'Editar código', + icon: EditIcon, + onAction: () => handleAction('edit', themeId), + }, + { + content: 'Eliminar', + icon: DeleteIcon, + destructive: true, + onAction: () => handleAction('delete', themeId), + }, + ]; + + if (themes.length === 0) { + return null; + } + + return ( + + + + Otros temas + + + + {themes.map((theme) => ( + + + +
+ {theme.name} +
+ + + {theme.name} + + + v{theme.version} • {formatDate(theme.updatedAt)} + + +
+ + setActivePopover(activePopover === theme.id ? null : theme.id)} + /> + } + onClose={() => setActivePopover(null)} + preferredPosition="below" + preferredAlignment="right"> + + +
+
+ ))} +
+
+
+ ); +} diff --git a/app/store/components/theme-management/components/ThemeList.tsx b/app/store/components/theme-management/components/ThemeList.tsx index 89821ef3..0fa24305 100644 --- a/app/store/components/theme-management/components/ThemeList.tsx +++ b/app/store/components/theme-management/components/ThemeList.tsx @@ -1,13 +1,14 @@ 'use client'; -import { Banner, BlockStack, Button, Card, EmptyState, InlineStack, Modal, Spinner, Text } from '@shopify/polaris'; -import { AlertCircleIcon, PlusIcon } from '@shopify/polaris-icons'; +import { Banner, BlockStack, Button, Card, Spinner, Text } from '@shopify/polaris'; +import { AlertCircleIcon } from '@shopify/polaris-icons'; import { useState } from 'react'; -import { useThemeList } from '../hooks/useThemeList'; -import { useThemeActions } from '../hooks/useThemeActions'; -import { ThemeTable } from './ThemeTable'; -import { ThemeModals } from './ThemeModals'; -import { ThemeUploadForm } from './ThemeUploadForm'; +import { useThemeList } from '@/app/store/components/theme-management/hooks/useThemeList'; +import { useThemeActions } from '@/app/store/components/theme-management/hooks/useThemeActions'; +import { ThemeModals } from '@/app/store/components/theme-management/components/ThemeModals'; +import { ThemeUploadModal } from '@/app/store/components/theme-management/components/ThemeUploadModal'; +import { ThemePreviewCard } from '@/app/store/components/theme-management/components/ThemePreviewCard'; +import { InactiveThemesList } from '@/app/store/components/theme-management/components/InactiveThemesList'; export function ThemeList({ storeId }: { storeId: string }) { const [showUploadModal, setShowUploadModal] = useState(false); @@ -19,11 +20,9 @@ export function ThemeList({ storeId }: { storeId: string }) { showDeleteModal, isActivating, isDeleting, - openActivateModal, - openDeleteModal, - handleEditTheme, handleActivateTheme, handleDeleteTheme, + handleThemeAction, closeActivateModal, closeDeleteModal, } = useThemeActions({ storeId, activateTheme, deleteTheme, refreshThemes }); @@ -60,100 +59,112 @@ export function ThemeList({ storeId }: { storeId: string }) { ); } + const activeTheme = themes.find((theme) => theme.isActive); + const inactiveThemes = themes.filter((theme) => !theme.isActive); + + const handleCustomize = () => { + if (activeTheme) { + handleThemeAction('edit', activeTheme); + } + }; + + const handleUploadTheme = () => { + setShowUploadModal(true); + }; + return ( <> - -
- - - Gestión de Temas - - - - - -
- - {themes.length === 0 ? ( - -

Sube tu primer tema para personalizar la apariencia de tu tienda.

- -
- ) : ( - + {activeTheme ? ( + + ) : ( + +
+ + + No hay tema activo. Sube un tema para comenzar. + + + +
+
)} -
+ + {inactiveThemes.length > 0 && } + + {themes.length === 0 && ( + +
+ + No hay temas subidos. Sube tu primer tema para personalizar la apariencia de tu tienda. + +
+
+ )} +
{/* Modal de subida de tema */} - {showUploadModal && ( - setShowUploadModal(false)} title="Subir Tema Personalizado"> - - { - const formData = new FormData(); - formData.append('theme', file); - - const response = await fetch(`/api/stores/${storeId}/themes/upload`, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || 'Error al subir el tema'); - } - - const result = await response.json(); - return result.details || result; - }} - onConfirm={async (result, file) => { - try { - const formData = new FormData(); - formData.append('theme', file); - formData.append('themeData', JSON.stringify(result)); - - const response = await fetch(`/api/stores/${storeId}/themes/confirm`, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || 'Error al confirmar el tema'); - } - - const confirmResult = await response.json(); - - if (confirmResult.success && confirmResult.processId) { - setTimeout(() => refreshThemes(), 2000); - return { ok: true, processId: confirmResult.processId as string }; - } - return { ok: false }; - } catch (error) { - console.error('Error confirming theme:', error); - return { ok: false }; - } - }} - onCancel={() => { - setShowUploadModal(false); - refreshThemes(); - }} - /> - - - )} + setShowUploadModal(false)} + storeId={storeId} + onUpload={async (file: File) => { + const formData = new FormData(); + formData.append('theme', file); + + const response = await fetch(`/api/stores/${storeId}/themes/upload`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Error al subir el tema'); + } + + const result = await response.json(); + return result.details || result; + }} + onConfirm={async (result, file) => { + try { + const formData = new FormData(); + formData.append('theme', file); + formData.append('themeData', JSON.stringify(result)); + + const response = await fetch(`/api/stores/${storeId}/themes/confirm`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Error al confirmar el tema'); + } + + const confirmResult = await response.json(); + + if (confirmResult.success && confirmResult.processId) { + setTimeout(() => refreshThemes(), 2000); + return { ok: true, processId: confirmResult.processId as string }; + } + return { ok: false }; + } catch (error) { + console.error('Error confirming theme:', error); + return { ok: false }; + } + }} + onCancel={() => { + setShowUploadModal(false); + refreshThemes(); + }} + /> {/* Modal de confirmación de activación */} {showActivateModal && selectedTheme && ( - + @@ -71,30 +54,28 @@ export function ThemeModals({ + + + + + + )} {/* Modal de confirmación de eliminación */} {showDeleteModal && selectedTheme && ( - + @@ -105,6 +86,23 @@ export function ThemeModals({ + + + + + + )} diff --git a/app/store/components/theme-management/components/ThemePreviewCard.tsx b/app/store/components/theme-management/components/ThemePreviewCard.tsx new file mode 100644 index 00000000..7e32bf6c --- /dev/null +++ b/app/store/components/theme-management/components/ThemePreviewCard.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { ActionList, Badge, BlockStack, Button, Card, InlineStack, Popover, Text } from '@shopify/polaris'; +import { DeleteIcon, EditIcon, MenuHorizontalIcon, ViewIcon } from '@shopify/polaris-icons'; +import { useState } from 'react'; +import Image from 'next/image'; +import type { ThemePreviewCardProps } from '@/app/store/components/theme-management/types/theme-types'; +import { ImportThemeDropdown } from '@/app/store/components/theme-management/components/ImportThemeDropdown'; + +const THEME_PREVIEW_PLACEHOLDER = + 'https://images.unsplash.com/photo-1741482529153-a98d81235d06?q=80&w=2070&auto=format&fit=crop'; + +interface ThemePreviewCardPropsWithUpload extends ThemePreviewCardProps { + onUploadTheme: () => void; +} + +export function ThemePreviewCard({ theme, onCustomize, onAction, onUploadTheme }: ThemePreviewCardPropsWithUpload) { + const [activePopover, setActivePopover] = useState(false); + + const formatDate = (dateString: string): string => { + return new Date(dateString).toLocaleDateString('es-ES', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const handleAction = (action: string): void => { + onAction(action, theme); + setActivePopover(false); + }; + + const themeActions = [ + { + content: 'Vista previa', + icon: ViewIcon, + onAction: () => handleAction('preview'), + }, + { + content: 'Editar código', + icon: EditIcon, + onAction: () => handleAction('edit'), + }, + { + content: 'Eliminar', + icon: DeleteIcon, + destructive: true, + onAction: () => handleAction('delete'), + }, + ]; + + return ( + + + {/* Header con título y acciones */} + + + Temas + + + + + + + {/* Preview del tema */} +
+ {`Preview +
+ + {/* Información del tema */} + + + +
+ {theme.name} +
+ + + + {theme.name} + + Tema actual + + + Agregado: {formatDate(theme.createdAt)} + + + Versión {theme.version} + + +
+
+ + setActivePopover(!activePopover)} />} + onClose={() => setActivePopover(false)} + preferredPosition="below" + preferredAlignment="right"> + + +
+ +
+
+
+
+
+ ); +} diff --git a/app/store/components/theme-management/components/ThemeUploadForm.tsx b/app/store/components/theme-management/components/ThemeUploadForm.tsx index 3b116564..1cff0e49 100644 --- a/app/store/components/theme-management/components/ThemeUploadForm.tsx +++ b/app/store/components/theme-management/components/ThemeUploadForm.tsx @@ -1,37 +1,26 @@ -import { - Banner, - BlockStack, - Button, - Card, - Icon, - InlineStack, - ProgressBar, - Spinner, - Text, - Thumbnail, -} from '@shopify/polaris'; +import { Banner, BlockStack, Icon, InlineStack, ProgressBar, Spinner, Text } from '@shopify/polaris'; import { AlertCircleIcon, CheckCircleIcon, UploadIcon } from '@shopify/polaris-icons'; import { useCallback } from 'react'; -import { useThemeUpload } from '../hooks/useThemeUpload'; -import type { ThemeUploadFormProps } from '../types/theme-types'; -import { formatFileSize, getValidationTone } from '../utils/theme-utils'; - -export function ThemeUploadForm({ storeId, onUpload, onConfirm, onCancel: _onCancel }: ThemeUploadFormProps) { +import { useThemeUpload } from '@/app/store/components/theme-management/hooks/useThemeUpload'; +import type { ThemeUploadFormProps } from '@/app/store/components/theme-management/types/theme-types'; +import { formatFileSize } from '@/app/store/components/theme-management/utils/theme-utils'; + +export function ThemeUploadForm({ + storeId, + onUpload, + onConfirm, + onCancel: _onCancel, + onFileSelect, +}: ThemeUploadFormProps) { const { selectedFile, - uploadResult, isUploading, - isConfirming, isProcessing, processingStatus, processingError, uploadProgress, error, handleFileSelect, - handleUpload, - handleConfirm, - handleCancel, - handleClearFile, } = useThemeUpload({ storeId, onUpload, @@ -48,11 +37,10 @@ export function ThemeUploadForm({ storeId, onUpload, onConfirm, onCancel: _onCan const file = event.dataTransfer.files[0]; if (file) { handleFileSelect(file); - // Automáticamente iniciar el upload cuando se arrastra un archivo - setTimeout(() => handleUpload(), 100); + onFileSelect?.(file); } }, - [handleFileSelect, handleUpload] + [handleFileSelect, onFileSelect] ); const handleFileInputChange = useCallback( @@ -60,220 +48,104 @@ export function ThemeUploadForm({ storeId, onUpload, onConfirm, onCancel: _onCan const file = event.target.files?.[0]; if (file) { handleFileSelect(file); - setTimeout(() => handleUpload(), 100); + onFileSelect?.(file); } }, - [handleFileSelect, handleUpload] + [handleFileSelect, onFileSelect] ); return ( - - {/* Área de subida */} - - - - Seleccionar Archivo - + + {/* Instrucciones */} + + Sube un archivo ZIP de tu tema de Fasttify. Los temas subidos se publicarán por defecto. + + + Tamaño máximo del archivo: 50 MB + + + {/* Área de drop */} +
document.getElementById('theme-file-input')?.click()}> + + + {selectedFile ? ( + + + {selectedFile.name} + + + {formatFileSize(selectedFile.size)} + + + ) : ( + + + + Arrastra tu archivo ZIP aquí o haz clic para seleccionar + + + )} +
+ + {/* Error */} + {error && ( + +

{error}

+
+ )} - - Sube tu tema en formato ZIP. El archivo debe contener la estructura completa del tema. + {/* Progreso */} + {isUploading && ( + + + Procesando tema... - - {/* Área de drop */} -
document.getElementById('theme-file-input')?.click()}> - - - - - - {selectedFile ? selectedFile.name : 'Arrastra tu archivo ZIP aquí o haz clic para seleccionar'} - - - Máximo 50MB - - -
- - {/* Archivo seleccionado */} - {selectedFile && ( - - - - - - - {selectedFile.name} - - - {formatFileSize(selectedFile.size)} - - - - - {!uploadResult && !isUploading && ( - - )} - - - - - )} - - {/* Error */} - {error && ( - -

{error}

-
- )} - - {/* Progreso */} - {isUploading && ( - - - Procesando tema... - - - - )} +
-
- - {/* Resultado de la validación */} - {uploadResult && uploadResult.validation && ( - - - - - Resultado de la Validación - - - {uploadResult.validation?.isValid ? 'Tema válido' : 'Tema con problemas'} - - - - {/* Información del tema */} - - - {uploadResult.theme?.name || 'Tema sin nombre'} v{uploadResult.theme?.version || '1.0.0'} - - {uploadResult.theme?.author && ( - - Por {uploadResult.theme.author} - - )} - {uploadResult.theme?.description && ( - - {uploadResult.theme.description} - - )} - + )} - {/* Estadísticas */} - - - {uploadResult.theme?.fileCount || 0} archivos - - - {uploadResult.theme?.assetCount || 0} assets - - - {uploadResult.theme?.sectionCount || 0} secciones - - - {uploadResult.theme?.templateCount || 0} templates + {/* Estado de procesamiento */} + {isProcessing || processingStatus === 'processing' ? ( + + + + + + Procesando tema... Esto puede tardar unos segundos. - - {/* Errores y advertencias */} - {uploadResult.validation?.errorCount > 0 && ( - - - Errores ({uploadResult.validation.errorCount}) - - {uploadResult.validation.errors?.slice(0, 3).map((error, index) => ( - - • {error.message} - - ))} - - )} - - {uploadResult.validation?.warningCount > 0 && ( - - - Advertencias ({uploadResult.validation.warningCount}) - - {uploadResult.validation.warnings?.slice(0, 3).map((warning, index) => ( - - • {warning.message} - - ))} - - )} - - {/* Estado de confirmación y procesamiento */} - {processingStatus === 'completed' ? ( - -

¡Tema confirmado y almacenado correctamente! Cerrando…

-
- ) : isProcessing || processingStatus === 'processing' ? ( - - - - - - Confirmando y almacenando el tema... Esto puede tardar 30–60 segundos. - - - - Puedes mantener esta ventana abierta; se cerrará automáticamente al finalizar. - - - - ) : processingStatus === 'error' ? ( - -

{processingError || 'Ocurrió un error al confirmar el tema'}

-
- ) : ( - - - - - )}
-
- )} + + ) : processingStatus === 'completed' ? ( + +

¡Tema subido correctamente! Cerrando…

+
+ ) : processingStatus === 'error' ? ( + +

{processingError || 'Ocurrió un error al procesar el tema'}

+
+ ) : null}
); } diff --git a/app/store/components/theme-management/components/ThemeUploadModal.tsx b/app/store/components/theme-management/components/ThemeUploadModal.tsx new file mode 100644 index 00000000..36b5d132 --- /dev/null +++ b/app/store/components/theme-management/components/ThemeUploadModal.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { Modal } from '@shopify/polaris'; +import { useState } from 'react'; +import { ThemeUploadForm } from '@/app/store/components/theme-management/components/ThemeUploadForm'; +import type { ThemeUploadResult } from '@/app/store/components/theme-management/types/theme-types'; + +interface ThemeUploadModalProps { + isOpen: boolean; + onClose: () => void; + storeId: string; + onUpload: (file: File) => Promise; + onConfirm: (result: ThemeUploadResult, originalFile: File) => Promise<{ ok: boolean; processId?: string }>; + onCancel: () => void; +} + +export function ThemeUploadModal({ + isOpen, + onClose, + storeId, + onUpload, + onConfirm, + onCancel: _onCancel, +}: ThemeUploadModalProps) { + const [isProcessing, setIsProcessing] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + + const handleCompleteUpload = async () => { + if (!selectedFile) return; + + setIsProcessing(true); + try { + // 1. Upload del archivo + const uploadResult = await onUpload(selectedFile); + + // 2. Si es válido, confirmar automáticamente + if (uploadResult.validation?.isValid) { + const confirmResult = await onConfirm(uploadResult, selectedFile); + if (confirmResult.ok) { + setTimeout(() => onClose(), 2000); + } + } + } catch (error) { + console.error('Upload error:', error); + } finally { + setIsProcessing(false); + } + }; + + return ( + + + + + + ); +} diff --git a/app/store/components/theme-management/hooks/useThemeActions.ts b/app/store/components/theme-management/hooks/useThemeActions.ts index a69987aa..d8fe3ffd 100644 --- a/app/store/components/theme-management/hooks/useThemeActions.ts +++ b/app/store/components/theme-management/hooks/useThemeActions.ts @@ -1,17 +1,5 @@ import { useState } from 'react'; - -interface Theme { - id: string; - name: string; - version: string; - author: string; - description: string; - isActive: boolean; - fileCount: number; - totalSize: number; - createdAt: string; - updatedAt: string; -} +import type { Theme } from '@/app/store/components/theme-management/types/theme-types'; interface UseThemeActionsProps { storeId: string; @@ -81,6 +69,64 @@ export function useThemeActions({ } }; + const handlePreviewTheme = (theme: Theme) => { + const previewUrl = `/store/${_storeId}/${theme.name.toLowerCase().replace(/\s+/g, '-')}`; + const newWindow = window.open(previewUrl, '_blank', 'noopener,noreferrer'); + + if (!newWindow) { + throw new Error('could not open the preview window'); + } + }; + + const handleRenameTheme = async (theme: Theme, newName: string) => { + try { + const response = await fetch(`/api/stores/${_storeId}/themes/${theme.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: newName, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Error al renombrar el tema'); + } + + _refreshThemes(); + } catch (error) { + console.error('Error renaming theme:', error); + throw error; + } + }; + + const handleThemeAction = (action: string, theme: Theme) => { + switch (action) { + case 'preview': + handlePreviewTheme(theme); + break; + case 'edit': + handleEditTheme(); + break; + case 'activate': + openActivateModal(theme); + break; + case 'delete': + openDeleteModal(theme); + break; + case 'rename': + const newName = prompt('Nuevo nombre del tema:', theme.name); + if (newName && newName.trim() !== theme.name) { + handleRenameTheme(theme, newName.trim()); + } + break; + default: + console.warn('Acción no reconocida:', action); + } + }; + return { selectedTheme, showActivateModal, @@ -92,6 +138,7 @@ export function useThemeActions({ handleEditTheme, handleActivateTheme, handleDeleteTheme, + handleThemeAction, closeActivateModal: () => setShowActivateModal(false), closeDeleteModal: () => setShowDeleteModal(false), }; diff --git a/app/store/components/theme-management/hooks/useThemeUpload.ts b/app/store/components/theme-management/hooks/useThemeUpload.ts index 1a19ef14..579ecfa7 100644 --- a/app/store/components/theme-management/hooks/useThemeUpload.ts +++ b/app/store/components/theme-management/hooks/useThemeUpload.ts @@ -1,5 +1,5 @@ -import { useCallback, useState } from 'react'; -import type { ThemeUploadResult } from '../types/theme-types'; +import { useCallback, useRef, useState } from 'react'; +import type { ThemeUploadResult } from '@/app/store/components/theme-management/types/theme-types'; interface UseThemeUploadProps { storeId: string; @@ -81,6 +81,12 @@ export function useThemeUpload({ storeId, onUpload, onConfirm }: UseThemeUploadP if (!result.success) { setError('Error al procesar el tema'); + return; + } + + // Si el tema es válido, automáticamente confirmarlo + if (result.validation?.isValid) { + setTimeout(() => handleConfirmRef.current?.(), 500); } } catch (err) { setError('Error al subir el tema. Por favor intenta de nuevo.'); @@ -90,6 +96,8 @@ export function useThemeUpload({ storeId, onUpload, onConfirm }: UseThemeUploadP } }, [selectedFile, onUpload]); + const handleConfirmRef = useRef<() => void>(); + const handleConfirm = useCallback(async () => { if (!uploadResult || !selectedFile) return; @@ -171,6 +179,8 @@ export function useThemeUpload({ storeId, onUpload, onConfirm }: UseThemeUploadP } }, [uploadResult, selectedFile, onConfirm, storeId]); + handleConfirmRef.current = handleConfirm; + const handleCancel = useCallback(() => { setSelectedFile(null); setUploadResult(null); diff --git a/app/store/components/theme-management/types/theme-types.ts b/app/store/components/theme-management/types/theme-types.ts index ad1bb34d..29369a23 100644 --- a/app/store/components/theme-management/types/theme-types.ts +++ b/app/store/components/theme-management/types/theme-types.ts @@ -34,4 +34,42 @@ export interface ThemeUploadFormProps { onUpload: (file: File) => Promise; onConfirm: (result: ThemeUploadResult, originalFile: File) => Promise<{ ok: boolean; processId?: string }>; onCancel: () => void; + onFileSelect?: (file: File | null) => void; +} + +export interface Theme { + id: string; + name: string; + version: string; + author: string; + description: string; + isActive: boolean; + fileCount: number; + totalSize: number; + createdAt: string; + updatedAt: string; + previewUrl?: string; +} + +export interface ThemeAction { + content: string; + icon?: React.ComponentType; + onAction: () => void; + destructive?: boolean; + disabled?: boolean; +} + +export interface ThemePreviewCardProps { + theme: Theme; + onCustomize: () => void; + onAction: (action: string, theme: Theme) => void; +} + +export interface ImportThemeDropdownProps { + onUploadTheme: () => void; +} + +export interface InactiveThemesListProps { + themes: Theme[]; + onAction: (action: string, theme: Theme) => void; } diff --git a/packages/liquid-forge/services/templates/template-loader.ts b/packages/liquid-forge/services/templates/template-loader.ts index 70183006..2c6595ec 100644 --- a/packages/liquid-forge/services/templates/template-loader.ts +++ b/packages/liquid-forge/services/templates/template-loader.ts @@ -43,7 +43,7 @@ class TemplateLoader { private constructor() { this.bucketName = process.env.BUCKET_NAME || ''; - this.isProduction = process.env.NODE_ENV === 'production'; + this.isProduction = process.env.APP_ENV === 'production'; if (this.bucketName) { this.s3Client = new S3Client({ diff --git a/utils/server/cdn-url.ts b/utils/server/cdn-url.ts index 8b657ffc..6dddb694 100644 --- a/utils/server/cdn-url.ts +++ b/utils/server/cdn-url.ts @@ -22,7 +22,7 @@ */ export function getCdnBaseUrl(): string { - const appEnv = process.env.NODE_ENV || 'development'; + const appEnv = process.env.APP_ENV || 'development'; if (appEnv === 'production') { return 'https://cdn.fasttify.com'; From c622e73392a3d6be50da41ecaf94029eb63ebaeb Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 17 Oct 2025 14:22:14 -0500 Subject: [PATCH 3/6] Integrate Shopify Polaris components and enhance StoreSelector layout This commit introduces the Shopify Polaris AppProvider to the layout, enabling consistent styling and internationalization support. The StoreSelector component is refactored to improve the user experience by implementing a new layout structure, integrating a UserMenu, and enhancing the StoreData component with better loading states and error handling. Additionally, the useUserStores hook is updated to separate active and inactive stores, improving data management and user feedback. --- app/(setup)/layout.tsx | 30 +- .../my-store/components/EmptyStoreState.tsx | 43 +++ app/(setup)/my-store/components/StoreCard.tsx | 94 +++++++ .../my-store/components/StoreFilters.tsx | 37 +++ app/(setup)/my-store/components/StoreList.tsx | 36 +++ .../my-store/components/StoreListHeader.tsx | 53 ++++ .../my-store/components/StoreSelector.tsx | 258 ++++++++---------- app/(setup)/my-store/components/UserMenu.tsx | 113 ++++++++ app/(setup)/my-store/hooks/useUserStores.ts | 24 +- app/(setup)/my-store/types/store.types.ts | 83 ++++++ 10 files changed, 625 insertions(+), 146 deletions(-) create mode 100644 app/(setup)/my-store/components/EmptyStoreState.tsx create mode 100644 app/(setup)/my-store/components/StoreCard.tsx create mode 100644 app/(setup)/my-store/components/StoreFilters.tsx create mode 100644 app/(setup)/my-store/components/StoreList.tsx create mode 100644 app/(setup)/my-store/components/StoreListHeader.tsx create mode 100644 app/(setup)/my-store/components/UserMenu.tsx create mode 100644 app/(setup)/my-store/types/store.types.ts diff --git a/app/(setup)/layout.tsx b/app/(setup)/layout.tsx index 32b8cc09..c6150b99 100644 --- a/app/(setup)/layout.tsx +++ b/app/(setup)/layout.tsx @@ -2,6 +2,8 @@ import { inter } from '@/lib/fonts'; import { useAuthInitializer } from '@/hooks/auth/useAuthInitializer'; +import { AppProvider } from '@shopify/polaris'; +import '@shopify/polaris/build/esm/styles.css'; import '@/app/global.css'; export default function WithoutNavbarLayout({ children }: { children: React.ReactNode }) { @@ -9,7 +11,33 @@ export default function WithoutNavbarLayout({ children }: { children: React.Reac return ( - {children} + + + {children} + + ); } diff --git a/app/(setup)/my-store/components/EmptyStoreState.tsx b/app/(setup)/my-store/components/EmptyStoreState.tsx new file mode 100644 index 00000000..290b50a7 --- /dev/null +++ b/app/(setup)/my-store/components/EmptyStoreState.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { EmptyState } from '@shopify/polaris'; +import { PlusIcon } from '@shopify/polaris-icons'; +import type { EmptyStoreStateProps } from '../types/store.types'; + +export function EmptyStoreState({ canCreateStore, onCreateStore }: EmptyStoreStateProps) { + return ( + +

+ Crea tu primera tienda y comienza a vender online en minutos. Configura tu catálogo, personaliza tu diseño y + lanza tu negocio. +

+
+ ); +} diff --git a/app/(setup)/my-store/components/StoreCard.tsx b/app/(setup)/my-store/components/StoreCard.tsx new file mode 100644 index 00000000..835da353 --- /dev/null +++ b/app/(setup)/my-store/components/StoreCard.tsx @@ -0,0 +1,94 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { Text, BlockStack, InlineStack } from '@shopify/polaris'; +import type { StoreCardProps } from '../types/store.types'; + +function getInitialsFromName(name: string): string { + return name + .split(' ') + .filter(Boolean) + .map((word) => word[0]) + .join('') + .toUpperCase() + .slice(0, 2); +} + +export function StoreCard({ store, onClick }: StoreCardProps) { + const initials = getInitialsFromName(store.storeName); + const domain = store.defaultDomain || `${store.storeId}.fasttify.com`; + + return ( +
onClick(store.storeId)} + style={{ + cursor: 'pointer', + backgroundColor: 'white', + border: '1px solid #e5e7eb', + borderRadius: '8px', + padding: '16px 24px', + marginBottom: '6px', + transition: 'all 0.2s ease', + }} + onMouseEnter={(e) => { + e.currentTarget.style.borderColor = '#d1d5db'; + e.currentTarget.style.boxShadow = '0 1px 3px 0 rgba(0, 0, 0, 0.1)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = '#e5e7eb'; + e.currentTarget.style.boxShadow = 'none'; + }} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + onClick(store.storeId); + } + }} + aria-label={`Seleccionar tienda ${store.storeName}`}> + + +
+ {initials} +
+ + + {store.storeName} + + + {domain} + + +
+
+
+
+ ); +} diff --git a/app/(setup)/my-store/components/StoreFilters.tsx b/app/(setup)/my-store/components/StoreFilters.tsx new file mode 100644 index 00000000..ff20ffd7 --- /dev/null +++ b/app/(setup)/my-store/components/StoreFilters.tsx @@ -0,0 +1,37 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { Tabs } from '@shopify/polaris'; +import type { StoreFiltersProps } from '../types/store.types'; + +export function StoreFilters({ selected, onSelect }: StoreFiltersProps) { + const tabs = [ + { + id: 'active', + content: 'Activas', + panelID: 'active-panel', + }, + { + id: 'inactive', + content: 'Inactivas', + panelID: 'inactive-panel', + }, + ]; + + return ; +} diff --git a/app/(setup)/my-store/components/StoreList.tsx b/app/(setup)/my-store/components/StoreList.tsx new file mode 100644 index 00000000..dab94aec --- /dev/null +++ b/app/(setup)/my-store/components/StoreList.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { BlockStack } from '@shopify/polaris'; +import { StoreCard } from './StoreCard'; +import { EmptyStoreState } from './EmptyStoreState'; +import type { StoreListProps } from '../types/store.types'; + +export function StoreList({ stores, onStoreSelect, canCreateStore, onCreateStore }: StoreListProps) { + if (stores.length === 0) { + return ; + } + + return ( + + {stores.map((store) => ( + + ))} + + ); +} diff --git a/app/(setup)/my-store/components/StoreListHeader.tsx b/app/(setup)/my-store/components/StoreListHeader.tsx new file mode 100644 index 00000000..cc96799d --- /dev/null +++ b/app/(setup)/my-store/components/StoreListHeader.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { Button } from '@shopify/polaris'; +import { PlusIcon } from '@shopify/polaris-icons'; +import type { StoreListHeaderProps } from '../types/store.types'; + +export function StoreListHeader({ canCreateStore, onCreateStore }: StoreListHeaderProps) { + const primaryAction = canCreateStore + ? { + content: 'Crear tienda', + onAction: onCreateStore, + icon: PlusIcon, + } + : undefined; + + return ( +
+
+

+ Bienvenido de nuevo +

+ {primaryAction && ( + + )} +
+
+ ); +} diff --git a/app/(setup)/my-store/components/StoreSelector.tsx b/app/(setup)/my-store/components/StoreSelector.tsx index 440d0186..3d5752a2 100644 --- a/app/(setup)/my-store/components/StoreSelector.tsx +++ b/app/(setup)/my-store/components/StoreSelector.tsx @@ -1,103 +1,78 @@ 'use client'; +import { useState, Suspense, useEffect } from 'react'; +import { Page, Layout, Spinner } from '@shopify/polaris'; +import Image from 'next/image'; import { useUserStores } from '@/app/(setup)/my-store/hooks/useUserStores'; -import { StoreAvatar } from '@/app/(setup)/my-store/components/StoreAvatar'; -import { Button } from '@/components/ui/button'; -import { Loader } from '@/components/ui/loader'; import { routes } from '@/utils/client/routes'; -import { AnimatePresence, motion } from 'framer-motion'; -import { PlusCircle } from 'lucide-react'; -import { Suspense } from 'react'; import useAuthStore from '@/context/core/userStore'; -import Link from 'next/link'; -import Image from 'next/image'; +import { UserMenu } from './UserMenu'; +import { StoreListHeader } from './StoreListHeader'; +import { StoreFilters } from './StoreFilters'; +import { StoreList } from './StoreList'; function StoreError({ message }: { message: string }) { - return
{message}
; -} - -// Componente para mostrar la lista de tiendas -function StoreList({ stores, canCreateStore }: { stores: any[]; canCreateStore: boolean }) { return ( - <> - - - {stores.length > 0 ? ( - // Stores list - stores.map((store, index) => ( - - - - )) - ) : ( - // No stores state -
-
- -

No tienes tiendas configuradas aún

-
-
- )} -
-
- - - {canCreateStore ? ( - - - - ) : ( - stores.length > 0 && ( -
- Has alcanzado el límite máximo de tiendas para tu plan actual -
- ) - )} -
- +
{message}
); } // Componente que carga los datos con Suspense -function StoreData({ userId, userPlan }: { userId: string | null; userPlan?: string }) { - // Obtenemos los datos directamente +function StoreData({ + userId, + userPlan, + isLoading, + selectedTab, + setSelectedTab, +}: { + userId: string | null; + userPlan?: string; + isLoading: boolean; + selectedTab: number; + setSelectedTab: (tab: number) => void; +}) { const result = useUserStores(userId, userPlan); - const { - stores = [], - canCreateStore = false, - error, - } = result as { stores: unknown[]; canCreateStore: boolean; error?: string }; + const { activeStores, inactiveStores, canCreateStore, error } = result; + + const currentStores = selectedTab === 0 ? activeStores : inactiveStores; + + const handleStoreSelect = (storeId: string) => { + window.location.href = routes.store.dashboard.main(storeId); + }; + + const handleCreateStore = () => { + window.location.href = '/first-steps'; + }; + + // Si está cargando la autenticación, mostrar spinner + if (isLoading) { + return ( +
+ +
+ ); + } if (error) { return ; } - return ; + return ( + + + + + + + + + + ); } // Componente principal @@ -105,64 +80,73 @@ export function StoreSelector() { const { user, loading: isLoading } = useAuthStore(); const cognitoUsername = user?.userId; const userPlan = user?.plan; + const [selectedTab, setSelectedTab] = useState(0); + const [isClient, setIsClient] = useState(false); - if (isLoading) { - return ( - - ); - } + useEffect(() => { + setIsClient(true); + }, []); return ( - -
- Logo -

Selecciona una tienda

-

para continuar a tu dashboard

-
- - - }> - - +
+ {/* Card principal con todo el contenido */} +
+ {/* Header con logo y UserMenu dentro de la card */} +
+
+ Fasttify + Fasttify +
+ useAuthStore.getState().logout()} /> +
- - - Ayuda - - - Privacidad - - - Términos - - - + {/* Contenido principal con un solo Suspense */} + {!isClient ? ( +
+ +
+ ) : ( + + +
+ }> + + + )} +
+ ); } diff --git a/app/(setup)/my-store/components/UserMenu.tsx b/app/(setup)/my-store/components/UserMenu.tsx new file mode 100644 index 00000000..e783d373 --- /dev/null +++ b/app/(setup)/my-store/components/UserMenu.tsx @@ -0,0 +1,113 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { Popover, ActionList, Text, BlockStack, InlineStack } from '@shopify/polaris'; +import { ExitIcon, SettingsIcon, ReplaceIcon } from '@shopify/polaris-icons'; +import { useState } from 'react'; +import type { UserMenuProps } from '../types/store.types'; + +function getInitialsFromEmail(email: string): string { + return email.split('@')[0].slice(0, 2).toUpperCase(); +} + +export function UserMenu({ user, onLogout }: UserMenuProps) { + const [popoverActive, setPopoverActive] = useState(false); + + if (!user) { + return null; + } + + const initials = getInitialsFromEmail(user.email); + + const togglePopoverActive = () => setPopoverActive((prev) => !prev); + + const handleLogout = () => { + setPopoverActive(false); + onLogout(); + }; + + const actions = [ + { + content: 'Gestionar cuenta', + icon: SettingsIcon, + onAction: () => { + setPopoverActive(false); + // TODO: Implementar navegación a configuración de cuenta + }, + }, + { + content: 'Cambiar de cuenta', + icon: ReplaceIcon, + onAction: () => { + setPopoverActive(false); + // TODO: Implementar cambio de cuenta + }, + }, + { + content: 'Cerrar sesión', + icon: ExitIcon, + destructive: true, + onAction: handleLogout, + }, + ]; + + return ( + + {initials} + + } + onClose={() => setPopoverActive(false)} + preferredAlignment="right"> +
+ + + + + {user.email.length > 20 ? `${user.email.slice(0, 20)}...` : user.email} + + + {user.email} + + + + setPopoverActive(false)} /> + +
+
+ ); +} diff --git a/app/(setup)/my-store/hooks/useUserStores.ts b/app/(setup)/my-store/hooks/useUserStores.ts index 2d5ff3f4..f1a61180 100644 --- a/app/(setup)/my-store/hooks/useUserStores.ts +++ b/app/(setup)/my-store/hooks/useUserStores.ts @@ -1,5 +1,6 @@ import { use } from 'react'; import { client } from '@/lib/amplify-client'; +import type { Store, UseUserStoresResult } from '../types/store.types'; const STORE_LIMITS = { Imperial: 5, @@ -13,12 +14,13 @@ const storeCache = new Map(); * Hook para obtener las tiendas de un usuario * Esta función es compatible con Suspense */ -export function useUserStores(userId: string | null, userPlan?: string) { +export function useUserStores(userId: string | null, userPlan?: string): UseUserStoresResult { // Si no hay userId, devolver datos vacíos if (!userId) { return { stores: [], - allStores: [], + activeStores: [], + inactiveStores: [], canCreateStore: false, error: null, storeCount: 0, @@ -40,7 +42,7 @@ export function useUserStores(userId: string | null, userPlan?: string) { /** * Función que realiza la consulta a la base de datos */ -async function fetchUserStores(userId: string, userPlan?: string) { +async function fetchUserStores(userId: string, userPlan?: string): Promise { try { // Obtener todas las tiendas del usuario (para verificar límites) const { data: allUserStores } = await client.models.UserStore.listUserStoreByUserId( @@ -48,11 +50,15 @@ async function fetchUserStores(userId: string, userPlan?: string) { userId: userId, }, { - selectionSet: ['storeId', 'storeName', 'storeType', 'onboardingCompleted'], + selectionSet: ['storeId', 'storeName', 'storeType', 'defaultDomain', 'storeStatus', 'onboardingCompleted'], } ); - const completedStores = allUserStores || []; + const completedStores = (allUserStores || []) as Store[]; + + // Separar tiendas activas e inactivas + const activeStores = completedStores.filter((store) => store.storeStatus !== false); + const inactiveStores = completedStores.filter((store) => store.storeStatus === false); // Verificar límite de tiendas según el plan const currentCount = allUserStores?.length || 0; @@ -60,7 +66,8 @@ async function fetchUserStores(userId: string, userPlan?: string) { return { stores: completedStores, - allStores: allUserStores || [], + activeStores, + inactiveStores, canCreateStore: currentCount < limit, error: null, storeCount: allUserStores?.length || 0, @@ -69,9 +76,10 @@ async function fetchUserStores(userId: string, userPlan?: string) { console.error('getUserStores: Error fetching stores:', err); return { stores: [], - allStores: [], + activeStores: [], + inactiveStores: [], canCreateStore: false, - error: err, + error: err instanceof Error ? err.message : 'Error desconocido', storeCount: 0, }; } diff --git a/app/(setup)/my-store/types/store.types.ts b/app/(setup)/my-store/types/store.types.ts new file mode 100644 index 00000000..b8dde7c1 --- /dev/null +++ b/app/(setup)/my-store/types/store.types.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type StoreStatus = 'active' | 'inactive'; + +export interface Store { + storeId: string; + storeName: string; + defaultDomain?: string; + storeType: string; + storeStatus?: boolean; + storeDescription?: string; + storeLogo?: string; + storeFavicon?: string; + storeTheme?: string; + storeCurrency?: string; + currencyFormat?: string; + currencyLocale?: string; + currencyDecimalPlaces?: number; + storeAdress?: string; + contactEmail?: string; + contactPhone?: string; + onboardingCompleted: boolean; + onboardingData?: any; +} + +export interface UserMenuProps { + user: { + email: string; + nickName?: string; + picture?: string; + } | null; + onLogout: () => void; +} + +export interface StoreCardProps { + store: Store; + onClick: (storeId: string) => void; +} + +export interface StoreFiltersProps { + selected: number; + onSelect: (selectedTabIndex: number) => void; +} + +export interface StoreListHeaderProps { + canCreateStore: boolean; + onCreateStore: () => void; +} + +export interface StoreListProps { + stores: Store[]; + onStoreSelect: (storeId: string) => void; + canCreateStore: boolean; + onCreateStore: () => void; +} + +export interface EmptyStoreStateProps { + canCreateStore: boolean; + onCreateStore: () => void; +} + +export interface UseUserStoresResult { + stores: Store[]; + activeStores: Store[]; + inactiveStores: Store[]; + canCreateStore: boolean; + error: string | null; + storeCount: number; +} From 66c3433bcc80728f41162b9f14a2243bf342d5ef Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 17 Oct 2025 14:32:06 -0500 Subject: [PATCH 4/6] Refactor StoreSelector component for improved responsiveness and styling This commit updates the StoreSelector component to utilize Tailwind CSS for styling, enhancing the layout's responsiveness and visual appeal. Key changes include adjustments to the card structure, header alignment, and loading state presentation, ensuring a better user experience across different screen sizes. --- .../my-store/components/StoreSelector.tsx | 31 ++++++------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/app/(setup)/my-store/components/StoreSelector.tsx b/app/(setup)/my-store/components/StoreSelector.tsx index 3d5752a2..a6c91a0c 100644 --- a/app/(setup)/my-store/components/StoreSelector.tsx +++ b/app/(setup)/my-store/components/StoreSelector.tsx @@ -88,38 +88,25 @@ export function StoreSelector() { }, []); return ( -
- {/* Card principal con todo el contenido */} -
+
+ {/* Card principal con todo el contenido - responsive */} +
{/* Header con logo y UserMenu dentro de la card */} -
-
+
+
Fasttify Fasttify
useAuthStore.getState().logout()} /> @@ -127,13 +114,13 @@ export function StoreSelector() { {/* Contenido principal con un solo Suspense */} {!isClient ? ( -
+
) : ( +
}> From b2cc0f5a877280067dca7d596b34ed6b3293b050 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 17 Oct 2025 14:40:22 -0500 Subject: [PATCH 5/6] Add Jest setup for matchMedia and ResizeObserver mocks; update StoreSelector tests with AppProvider wrapper --- jest.setup.ts | 20 +++++ test/unit/components/StoreSelector.test.tsx | 81 ++++++++++++++++++--- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/jest.setup.ts b/jest.setup.ts index c2a72bc7..191e0416 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -5,3 +5,23 @@ process.env.APP_ENV = 'test'; process.env.DEV_CACHE_ENABLED = 'true'; global.console.warn = jest.fn(); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); diff --git a/test/unit/components/StoreSelector.test.tsx b/test/unit/components/StoreSelector.test.tsx index 06188ce3..5be17b1c 100644 --- a/test/unit/components/StoreSelector.test.tsx +++ b/test/unit/components/StoreSelector.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; +import { AppProvider } from '@shopify/polaris'; import { StoreSelector } from '@/app/(setup)/my-store/components/StoreSelector'; import { useAuth } from '@/context/hooks/useAuth'; import { useUserStores } from '@/app/(setup)/my-store/hooks/useUserStores'; @@ -38,6 +39,9 @@ jest.mock('framer-motion', () => ({ AnimatePresence: ({ children }: any) => <>{children}, })); +// Wrapper con AppProvider para los tests de Polaris +const TestWrapper = ({ children }: { children: React.ReactNode }) => {children}; + describe('StoreSelector', () => { beforeEach(() => { // Configuración por defecto para los mocks @@ -57,43 +61,73 @@ describe('StoreSelector', () => { storeName: 'Tienda de Prueba', storeType: 'Ropa', onboardingCompleted: true, + storeStatus: true, + }, + ], + activeStores: [ + { + storeId: 'store-1', + storeName: 'Tienda de Prueba', + storeType: 'Ropa', + onboardingCompleted: true, + storeStatus: true, }, ], + inactiveStores: [], canCreateStore: true, error: null, + storeCount: 1, }); }); it('renderiza el título correctamente', () => { - render(); - expect(screen.getByText('Selecciona una tienda')).toBeInTheDocument(); - expect(screen.getByText('para continuar a tu dashboard')).toBeInTheDocument(); + render( + + + + ); + expect(screen.getByText('Bienvenido de nuevo')).toBeInTheDocument(); }); it('muestra la lista de tiendas cuando hay tiendas disponibles', () => { - render(); + render( + + + + ); expect(screen.getByText('Tienda de Prueba')).toBeInTheDocument(); - expect(screen.getByText('Ropa')).toBeInTheDocument(); + expect(screen.getByText('store-1.fasttify.com')).toBeInTheDocument(); }); it('muestra el mensaje de no tiendas cuando no hay tiendas', () => { // Cambiamos el mock para simular que no hay tiendas (useUserStores as jest.Mock).mockReturnValue({ stores: [], + activeStores: [], + inactiveStores: [], canCreateStore: true, error: null, + storeCount: 0, }); - render(); + render( + + + + ); expect(screen.getByText('No tienes tiendas configuradas aún')).toBeInTheDocument(); }); it('muestra el botón para crear tienda cuando está permitido', () => { - render(); - expect(screen.getByText('Crear nueva tienda')).toBeInTheDocument(); + render( + + + + ); + expect(screen.getByText('Crear tienda')).toBeInTheDocument(); }); - it('muestra el mensaje de límite alcanzado cuando no se pueden crear más tiendas', () => { + it('no muestra el botón de crear tienda cuando se alcanza el límite', () => { // Cambiamos el mock para simular que no se pueden crear más tiendas (useUserStores as jest.Mock).mockReturnValue({ stores: [ @@ -102,25 +136,48 @@ describe('StoreSelector', () => { storeName: 'Tienda de Prueba', storeType: 'Ropa', onboardingCompleted: true, + storeStatus: true, + }, + ], + activeStores: [ + { + storeId: 'store-1', + storeName: 'Tienda de Prueba', + storeType: 'Ropa', + onboardingCompleted: true, + storeStatus: true, }, ], + inactiveStores: [], canCreateStore: false, error: null, + storeCount: 1, }); - render(); - expect(screen.getByText('Has alcanzado el límite máximo de tiendas para tu plan actual')).toBeInTheDocument(); + render( + + + + ); + expect(screen.queryByText('Crear tienda')).not.toBeInTheDocument(); }); it('muestra un mensaje de error cuando hay un error', () => { // Cambiamos el mock para simular un error (useUserStores as jest.Mock).mockReturnValue({ stores: [], + activeStores: [], + inactiveStores: [], canCreateStore: false, error: 'Error de prueba', + storeCount: 0, }); - render(); + render( + + + + ); expect(screen.getByText('Hubo un error al cargar tus tiendas. Por favor, intenta de nuevo.')).toBeInTheDocument(); }); }); From b5d5350c5c5c471c84b6ef1c401a274d540eff39 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 17 Oct 2025 14:55:18 -0500 Subject: [PATCH 6/6] Update StoreCard component layout for improved styling and semantics This commit modifies the StoreCard component by increasing the gap between elements for better visual spacing and changing the HTML structure to enhance semantic meaning. The store name is now displayed without inline styles, and the domain text is updated to use a paragraph tag for improved accessibility and readability. --- app/(setup)/my-store/components/StoreCard.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/(setup)/my-store/components/StoreCard.tsx b/app/(setup)/my-store/components/StoreCard.tsx index 835da353..99b58e93 100644 --- a/app/(setup)/my-store/components/StoreCard.tsx +++ b/app/(setup)/my-store/components/StoreCard.tsx @@ -78,11 +78,11 @@ export function StoreCard({ store, onClick }: StoreCardProps) { }}> {initials}
- - + + {store.storeName} - + {domain}