const React = require("react"); const { Box, Text, useInput, useApp } = require("ink"); const { useAppState } = require("../../providers/AppProvider.jsx"); const InputField = require("../common/InputField.jsx"); const TextInput = require("ink-text-input").default; /** * Configuration Screen Component * Form-based interface for setting up Shopify credentials and operation parameters * Requirements: 5.2, 6.1, 6.2, 6.3 */ const ConfigurationScreen = () => { const { appState, updateConfiguration, navigateBack, updateUIState } = useAppState(); const { exit } = useApp(); // Enhanced form fields configuration with improved validation const formFields = [ { id: "shopDomain", label: "Shopify Shop Domain", placeholder: "your-store.myshopify.com", description: "Your Shopify store domain (without https://)", required: true, validator: (value) => { if (!value || value.trim() === "") { return { isValid: false, message: "Domain is required" }; } const trimmedValue = value.trim(); // Check for valid domain format if (!trimmedValue.includes(".")) { return { isValid: false, message: "Must be a valid domain" }; } // Check for Shopify domain or custom domain if ( !trimmedValue.includes(".myshopify.com") && !trimmedValue.match( /^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.[a-zA-Z]{2,}$/ ) ) { return { isValid: false, message: "Must be a valid Shopify domain (e.g., store.myshopify.com)", }; } // Check for protocol (should not include) if ( trimmedValue.includes("http://") || trimmedValue.includes("https://") ) { return { isValid: false, message: "Domain should not include http:// or https://", }; } return { isValid: true, message: "" }; }, }, { id: "accessToken", label: "Shopify Access Token", placeholder: "shpat_your_access_token_here", description: "Your Shopify Admin API access token", secret: true, required: true, validator: (value) => { if (!value || value.trim() === "") { return { isValid: false, message: "Access token is required" }; } const trimmedValue = value.trim(); if (trimmedValue.length < 10) { return { isValid: false, message: "Token appears to be too short" }; } // Basic format check for Shopify tokens if ( !trimmedValue.startsWith("shpat_") && !trimmedValue.startsWith("shpca_") && !trimmedValue.startsWith("shppa_") ) { return { isValid: false, message: "Token should start with shpat_, shpca_, or shppa_", }; } return { isValid: true, message: "" }; }, }, { id: "targetTag", label: "Target Product Tag", placeholder: "sale", description: "Products with this tag will be updated", required: true, validator: (value) => { if (!value || value.trim() === "") { return { isValid: false, message: "Target tag is required" }; } const trimmedValue = value.trim(); // Check for valid tag format (no special characters except hyphens and underscores) if (!/^[a-zA-Z0-9_-]+$/.test(trimmedValue)) { return { isValid: false, message: "Tag can only contain letters, numbers, hyphens, and underscores", }; } if (trimmedValue.length > 255) { return { isValid: false, message: "Tag must be 255 characters or less", }; } return { isValid: true, message: "" }; }, }, { id: "priceAdjustment", label: "Price Adjustment Percentage", placeholder: "10", description: "Percentage to adjust prices (positive for increase, negative for decrease)", required: true, validator: (value) => { if (!value || value.trim() === "") { return { isValid: false, message: "Percentage is required" }; } const num = parseFloat(value); if (isNaN(num)) { return { isValid: false, message: "Must be a valid number" }; } if (num < -100) { return { isValid: false, message: "Cannot decrease prices by more than 100%", }; } if (num > 1000) { return { isValid: false, message: "Price increase cannot exceed 1000%", }; } if (num === 0) { return { isValid: false, message: "Percentage cannot be zero" }; } return { isValid: true, message: "" }; }, }, { id: "operationMode", label: "Operation Mode", placeholder: "update", description: "Choose between updating prices or rolling back to compare-at prices", type: "select", required: true, options: [ { value: "update", label: "Update Prices" }, { value: "rollback", label: "Rollback Prices" }, ], validator: (value) => { const validModes = ["update", "rollback"]; if (!validModes.includes(value)) { return { isValid: false, message: "Must select a valid operation mode", }; } return { isValid: true, message: "" }; }, }, ]; // Enhanced state management for form inputs const [formValues, setFormValues] = React.useState(() => { const initialValues = {}; formFields.forEach((field) => { initialValues[field.id] = appState.configuration[field.id] || ""; }); return initialValues; }); const [fieldValidation, setFieldValidation] = React.useState({}); const [focusedField, setFocusedField] = React.useState(0); const [showValidation, setShowValidation] = React.useState(false); const [hasInteracted, setHasInteracted] = React.useState({}); // Real-time field validation const validateField = React.useCallback( (fieldId, value) => { const field = formFields.find((f) => f.id === fieldId); if (!field || !field.validator) { return { isValid: true, message: "" }; } const result = field.validator(value); return typeof result === "object" ? result : { isValid: !result, message: result || "" }; }, [formFields] ); // Validate all fields const validateForm = React.useCallback(() => { const newValidation = {}; let isFormValid = true; formFields.forEach((field) => { const validation = validateField(field.id, formValues[field.id]); newValidation[field.id] = validation; if (!validation.isValid) { isFormValid = false; } }); setFieldValidation(newValidation); return isFormValid; }, [formFields, formValues, validateField]); // Update validation when form values change React.useEffect(() => { if (showValidation) { validateForm(); } }, [formValues, showValidation, validateForm]); // Validate loaded configuration completeness const validateLoadedConfiguration = React.useCallback((config) => { if (!config) return false; // Check if all required fields have values const requiredFields = [ "shopDomain", "accessToken", "targetTag", "priceAdjustment", "operationMode", ]; return requiredFields.every((field) => { const value = config[field]; return value !== undefined && value !== null && value !== ""; }); }, []); // Load configuration from environment file on component mount React.useEffect(() => { const loadedConfig = loadFromEnvironment(); if (loadedConfig) { // Update form values with loaded configuration setFormValues(loadedConfig); // Validate the loaded configuration const isConfigComplete = validateLoadedConfiguration(loadedConfig); // Update app state with loaded configuration updateConfiguration({ ...loadedConfig, isValid: isConfigComplete, lastTested: appState.configuration.lastTested, // Preserve test status }); // If configuration is complete, validate all fields if (isConfigComplete) { const allInteracted = {}; formFields.forEach((field) => { allInteracted[field.id] = true; }); setHasInteracted(allInteracted); } } }, [ loadFromEnvironment, updateConfiguration, validateLoadedConfiguration, formFields, appState.configuration.lastTested, ]); // Handle keyboard input useInput((input, key) => { if (key.escape) { // Go back to main menu navigateBack(); } else if (key.tab || (key.tab && key.shift)) { // Navigate between fields and action buttons const totalFocusableItems = formFields.length + 2; // fields + test button + save button if (key.shift) { // Shift+Tab - previous field setFocusedField((prev) => prev > 0 ? prev - 1 : totalFocusableItems - 1 ); } else { // Tab - next field setFocusedField((prev) => prev < totalFocusableItems - 1 ? prev + 1 : 0 ); } } else if (key.return || key.enter) { // Handle Enter key if (focusedField === formFields.length) { // Test Connection button handleTestConnection(); } else if (focusedField === formFields.length + 1) { // Save button handleSave(); } else { // Move to next field setFocusedField((prev) => prev < formFields.length + 1 ? prev + 1 : 0 ); } } else if (key.upArrow) { // Navigate up const totalFocusableItems = formFields.length + 2; setFocusedField((prev) => prev > 0 ? prev - 1 : totalFocusableItems - 1 ); } else if (key.downArrow) { // Navigate down const totalFocusableItems = formFields.length + 2; setFocusedField((prev) => prev < totalFocusableItems - 1 ? prev + 1 : 0 ); } }); // Enhanced input change handler with real-time validation const handleInputChange = React.useCallback( (fieldId, value) => { setFormValues((prev) => ({ ...prev, [fieldId]: value, })); // Mark field as interacted setHasInteracted((prev) => ({ ...prev, [fieldId]: true, })); // Perform real-time validation for interacted fields if (hasInteracted[fieldId] || showValidation) { const validation = validateField(fieldId, value); setFieldValidation((prev) => ({ ...prev, [fieldId]: validation, })); } }, [hasInteracted, showValidation, validateField] ); // Enhanced save handler with comprehensive validation const handleSave = React.useCallback(() => { setShowValidation(true); // Mark all fields as interacted const allInteracted = {}; formFields.forEach((field) => { allInteracted[field.id] = true; }); setHasInteracted(allInteracted); if (validateForm()) { try { // Convert and validate price adjustment const priceAdjustment = parseFloat(formValues.priceAdjustment); const config = { shopDomain: formValues.shopDomain.trim(), accessToken: formValues.accessToken.trim(), targetTag: formValues.targetTag.trim(), priceAdjustment, operationMode: formValues.operationMode, isValid: true, lastTested: appState.configuration.lastTested, // Preserve last test time }; // Update application state updateConfiguration(config); // Save to environment file saveToEnvironment(config); // Navigate back to previous screen navigateBack(); } catch (error) { console.error("Error saving configuration:", error); // Could add error state here for user feedback } } }, [ formValues, formFields, validateForm, updateConfiguration, navigateBack, appState.configuration.lastTested, ]); // Load configuration from environment file const loadFromEnvironment = React.useCallback(() => { try { const fs = require("fs"); const path = require("path"); const envPath = path.resolve(process.cwd(), ".env"); // Check if .env file exists if (!fs.existsSync(envPath)) { return null; // No existing configuration } // Read and parse .env file const envContent = fs.readFileSync(envPath, "utf8"); const envVars = {}; // Parse environment variables envContent.split("\n").forEach((line) => { const trimmedLine = line.trim(); if (trimmedLine && !trimmedLine.startsWith("#")) { const [key, ...valueParts] = trimmedLine.split("="); if (key && valueParts.length > 0) { envVars[key.trim()] = valueParts.join("=").trim(); } } }); // Map environment variables to configuration const config = { shopDomain: envVars.SHOPIFY_SHOP_DOMAIN || "", accessToken: envVars.SHOPIFY_ACCESS_TOKEN || "", targetTag: envVars.TARGET_TAG || "", priceAdjustment: parseFloat(envVars.PRICE_ADJUSTMENT_PERCENTAGE) || 0, operationMode: envVars.OPERATION_MODE || "update", }; // Validate loaded configuration const hasValidData = config.shopDomain || config.accessToken || config.targetTag; return hasValidData ? config : null; } catch (error) { console.error("Failed to load configuration from environment:", error); return null; } }, []); // Enhanced configuration persistence with better error handling and validation const saveToEnvironment = React.useCallback( (config) => { try { const fs = require("fs"); const path = require("path"); const envPath = path.resolve(process.cwd(), ".env"); // Read existing .env file or create empty content let envContent = ""; try { envContent = fs.readFileSync(envPath, "utf8"); } catch (err) { // If file doesn't exist, start with empty content envContent = ""; } // Prepare environment variables mapping const envVars = { SHOPIFY_SHOP_DOMAIN: config.shopDomain, SHOPIFY_ACCESS_TOKEN: config.accessToken, TARGET_TAG: config.targetTag, PRICE_ADJUSTMENT_PERCENTAGE: config.priceAdjustment.toString(), OPERATION_MODE: config.operationMode, }; // Process each environment variable for (const [key, value] of Object.entries(envVars)) { const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(`^${escapedKey}=.*$`, "m"); const line = `${key}=${value}`; if (envContent.match(regex)) { // Update existing variable envContent = envContent.replace(regex, line); } else { // Add new variable if (envContent && !envContent.endsWith("\n")) { envContent += "\n"; } envContent += `${line}\n`; } } // Write updated content to .env file fs.writeFileSync(envPath, envContent, "utf8"); // Update UI state to show success updateUIState({ lastSaveStatus: "success", lastSaveTime: new Date(), }); } catch (error) { console.error("Failed to save configuration to environment:", error); // Update UI state to show error updateUIState({ lastSaveStatus: "error", lastSaveError: error.message, lastSaveTime: new Date(), }); throw error; // Re-throw to handle in calling function } }, [updateUIState] ); // Enhanced API connection testing with real Shopify service integration const handleTestConnection = React.useCallback(async () => { // Validate required fields first const requiredFields = ["shopDomain", "accessToken"]; const tempValidation = {}; let hasError = false; requiredFields.forEach((fieldId) => { const field = formFields.find((f) => f.id === fieldId); if (field && field.validator) { const validation = field.validator(formValues[fieldId]); const result = typeof validation === "object" ? validation : { isValid: !validation, message: validation || "" }; tempValidation[fieldId] = result; if (!result.isValid) { hasError = true; } } }); if (hasError) { setFieldValidation((prev) => ({ ...prev, ...tempValidation })); setShowValidation(true); // Update UI state to show validation error updateUIState({ lastTestStatus: "validation_error", lastTestError: "Please fix validation errors before testing connection", lastTestTime: new Date(), }); return; } try { // Update UI state to show testing in progress updateUIState({ lastTestStatus: "testing", lastTestError: null, lastTestTime: new Date(), }); // Create a temporary configuration for testing const testConfig = { shopDomain: formValues.shopDomain.trim(), accessToken: formValues.accessToken.trim(), targetTag: formValues.targetTag.trim(), priceAdjustment: parseFloat(formValues.priceAdjustment), operationMode: formValues.operationMode, }; // Test the connection using ShopifyService const ShopifyService = require("../../../services/shopify"); // Create a temporary service instance with test configuration const originalEnv = process.env; process.env = { ...originalEnv, SHOPIFY_SHOP_DOMAIN: testConfig.shopDomain, SHOPIFY_ACCESS_TOKEN: testConfig.accessToken, TARGET_TAG: testConfig.targetTag, PRICE_ADJUSTMENT_PERCENTAGE: testConfig.priceAdjustment.toString(), OPERATION_MODE: testConfig.operationMode, }; const shopifyService = new ShopifyService(); const testResult = await shopifyService.testConnection(); // Restore original environment process.env = originalEnv; if (testResult) { // Connection successful updateConfiguration({ ...testConfig, isValid: true, lastTested: new Date(), }); updateUIState({ lastTestStatus: "success", lastTestError: null, lastTestTime: new Date(), }); } else { // Connection failed updateConfiguration({ ...testConfig, isValid: false, lastTested: new Date(), }); updateUIState({ lastTestStatus: "failed", lastTestError: "Failed to connect to Shopify API. Please check your credentials.", lastTestTime: new Date(), }); } } catch (error) { console.error("Connection test error:", error); // Update configuration with test failure updateConfiguration({ ...formValues, priceAdjustment: parseFloat(formValues.priceAdjustment), isValid: false, lastTested: new Date(), }); updateUIState({ lastTestStatus: "error", lastTestError: error.message || "An unexpected error occurred during connection test", lastTestTime: new Date(), }); } }, [formFields, formValues, updateConfiguration, updateUIState]); return React.createElement( Box, { flexDirection: "column", padding: 1 }, // Header React.createElement( Box, { flexDirection: "column", marginBottom: 1 }, React.createElement( Text, { bold: true, color: "cyan" }, "⚙️ Configuration" ), React.createElement( Text, { color: "gray", fontSize: "small" }, "Set up your Shopify credentials and operation parameters" ) ), // Enhanced form fields with improved validation display React.createElement( Box, { flexDirection: "column", marginBottom: 2 }, formFields.map((field, index) => { const isFocused = focusedField === index; const validation = fieldValidation[field.id] || { isValid: true, message: "", }; const hasError = !validation.isValid && (hasInteracted[field.id] || showValidation); const currentValue = formValues[field.id] || ""; return React.createElement( Box, { key: field.id, borderStyle: "single", borderColor: hasError ? "red" : isFocused ? "blue" : "gray", paddingX: 1, paddingY: 1, marginBottom: 1, flexDirection: "column", }, // Field label and description React.createElement( Box, { flexDirection: "row", alignItems: "center", marginBottom: 1 }, React.createElement( Text, { bold: true, color: hasError ? "red" : isFocused ? "blue" : "white", }, `${field.label}${field.required ? "*" : ""}:` ), React.createElement( Box, { flexGrow: 1 }, React.createElement( Text, { color: "gray" }, ` ${field.description}` ) ) ), // Input field React.createElement( Box, { flexDirection: "row" }, field.type === "select" ? React.createElement( Box, { flexDirection: "column" }, field.options.map((option, optIndex) => React.createElement( Box, { key: optIndex, borderStyle: formValues[field.id] === option.value ? "single" : "none", borderColor: isFocused && formValues[field.id] === option.value ? "blue" : "gray", paddingX: 2, paddingY: 0.5, marginBottom: 0.5, backgroundColor: formValues[field.id] === option.value ? isFocused ? "blue" : "gray" : undefined, }, React.createElement( Text, { color: formValues[field.id] === option.value ? "white" : "gray", }, option.label ) ) ) ) : React.createElement(InputField, { value: currentValue, placeholder: field.placeholder, onChange: (value) => handleInputChange(field.id, value), validation: field.validator, showError: hasInteracted[field.id] || showValidation, mask: field.secret ? "*" : undefined, focus: isFocused, required: field.required, }) ), // Error message for select fields (InputField handles its own errors) hasError && field.type === "select" && React.createElement( Text, { color: "red", italic: true }, ` ⚠ ${validation.message}` ), // Success indicator for valid fields !hasError && hasInteracted[field.id] && currentValue && React.createElement( Text, { color: "green", italic: true }, ` ✓ Valid` ) ); }) ), // Action buttons React.createElement( Box, { flexDirection: "row", justifyContent: "space-between" }, React.createElement( Box, { flexDirection: "column", width: "48%" }, React.createElement( Box, { borderStyle: "single", borderColor: appState.uiState?.lastTestStatus === "testing" ? "yellow" : "gray", paddingX: 2, paddingY: 1, alignItems: "center", backgroundColor: focusedField === formFields.length ? "yellow" : undefined, }, React.createElement( Text, { color: focusedField === formFields.length ? "black" : "white", bold: true, }, appState.uiState?.lastTestStatus === "testing" ? "Testing..." : "Test Connection" ) ), React.createElement( Text, { color: "gray", italic: true, marginTop: 0.5 }, "Verify your credentials" ) ), React.createElement( Box, { flexDirection: "column", width: "48%" }, React.createElement( Box, { borderStyle: "single", borderColor: "green", paddingX: 2, paddingY: 1, alignItems: "center", backgroundColor: focusedField === formFields.length + 1 ? "green" : undefined, }, React.createElement( Text, { color: "white", bold: true, }, "Save & Exit" ) ), React.createElement( Text, { color: "gray", italic: true, marginTop: 0.5 }, "Save configuration and return to menu" ) ) ), // Enhanced configuration status with save information React.createElement( Box, { flexDirection: "column", marginTop: 2, borderTopStyle: "single", borderColor: "gray", paddingTop: 2, }, React.createElement( Text, { color: appState.configuration.isValid ? "green" : "red", bold: true, }, `Configuration Status: ${ appState.configuration.isValid ? "✓ Valid" : "⚠ Incomplete" }` ), appState.configuration.lastTested && React.createElement( Text, { color: "gray", fontSize: "small" }, `Last tested: ${appState.configuration.lastTested.toLocaleString()}` ), // Connection test status display appState.uiState?.lastTestStatus && React.createElement( Text, { color: appState.uiState.lastTestStatus === "success" ? "green" : appState.uiState.lastTestStatus === "testing" ? "yellow" : "red", fontSize: "small", }, appState.uiState.lastTestStatus === "success" ? `✓ Connection test successful at ${appState.uiState.lastTestTime?.toLocaleTimeString()}` : appState.uiState.lastTestStatus === "testing" ? "⏳ Testing connection..." : `⚠ Connection test failed: ${ appState.uiState.lastTestError || "Unknown error" }` ), // Save status display appState.uiState?.lastSaveStatus && React.createElement( Text, { color: appState.uiState.lastSaveStatus === "success" ? "green" : "red", fontSize: "small", }, appState.uiState.lastSaveStatus === "success" ? `✓ Saved to .env file at ${appState.uiState.lastSaveTime?.toLocaleTimeString()}` : `⚠ Save failed: ${ appState.uiState.lastSaveError || "Unknown error" }` ), // Configuration file status React.createElement( Text, { color: "gray", fontSize: "small" }, "Configuration will be saved to .env file in project root" ) ), // Instructions React.createElement( Box, { flexDirection: "column", marginTop: 2 }, React.createElement(Text, { color: "gray" }, "Navigation:"), React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate fields"), React.createElement( Text, { color: "gray" }, " Tab/Shift+Tab - Next/Previous field" ), React.createElement(Text, { color: "gray" }, " Enter - Save/Next field"), React.createElement(Text, { color: "gray" }, " Esc - Back to menu") ), // Enhanced validation summary showValidation && React.createElement( Box, { flexDirection: "column", marginTop: 2, padding: 1, borderStyle: "single", borderColor: Object.values(fieldValidation).some((v) => !v.isValid) ? "red" : "green", }, React.createElement( Text, { color: Object.values(fieldValidation).some((v) => !v.isValid) ? "red" : "green", bold: true, }, Object.values(fieldValidation).some((v) => !v.isValid) ? "⚠ Validation Errors:" : "✓ All fields are valid" ), Object.values(fieldValidation).some((v) => !v.isValid) && React.createElement( Text, { color: "red" }, "Please fix the errors above before saving." ), // Show count of valid/invalid fields React.createElement( Text, { color: "gray", fontSize: "small" }, `${Object.values(fieldValidation).filter((v) => v.isValid).length}/${ formFields.length } fields valid` ) ) ); }; module.exports = ConfigurationScreen;