diff --git a/app/Controllers/ContactController.php b/app/Controllers/ContactController.php index b0ff36f..e31a2ad 100644 --- a/app/Controllers/ContactController.php +++ b/app/Controllers/ContactController.php @@ -1,9 +1,9 @@ Sanitizer::sanitizeString($_POST['first_name'] ?? ''), + 'last_name' => Sanitizer::sanitizeString($_POST['last_name'] ?? ''), + 'email' => Sanitizer::sanitizeString($_POST['email'] ?? ''), + 'phone' => Sanitizer::sanitizeString($_POST['phone'] ?? ''), + 'subject' => Sanitizer::sanitizeString($_POST['subject'] ?? ''), + 'message' => Sanitizer::sanitizeString($_POST['message'] ?? ''), + 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown', + ]; - try { - $formData = [ - 'first_name' => Sanitizer::sanitizeString($_POST['first_name'] ?? ''), - 'last_name' => Sanitizer::sanitizeString($_POST['last_name'] ?? ''), - 'email' => Sanitizer::sanitizeString($_POST['email'] ?? ''), - 'phone' => Sanitizer::sanitizeString($_POST['phone'] ?? ''), - 'subject' => Sanitizer::sanitizeString($_POST['subject'] ?? ''), - 'message' => Sanitizer::sanitizeString($_POST['message'] ?? ''), - 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', - 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown', - ]; + foreach ($formData as $key => $value) { + Logger::info("Sanitized input: {$key} = {$value}"); + } - foreach ($formData as $key => $value) { - Logger::info("Sanitized input: {$key} = {$value}"); - } + if ( + empty($formData['first_name']) || + empty($formData['last_name']) || + empty($formData['email']) || + empty($formData['phone']) || + empty($formData['subject']) || + empty($formData['message']) || + !Validator::isEmail($formData['email']) + ) { + Logger::info("Validation failed for contact form submission"); + $_SESSION['contact_error'] = 'An internal error occurred. Please try again later.'; + SessionHelper::writeClose(); + header("Location: /#contact"); + exit; + } - // Validate required fields - if ( - empty($formData['first_name']) || - empty($formData['last_name']) || - empty($formData['email']) || - empty($formData['phone']) || - empty($formData['subject']) || - empty($formData['message']) || - !Validator::isEmail($formData['email']) - ) { - Logger::info("Validation failed for contact form submission"); + $db = Database::getConnection(); + + $evaluation = SubmissionCheck::evaluate($db, $formData['email'], $formData['phone'], $formData['ip_address']); + Logger::info("Submission evaluation result: " . json_encode($evaluation)); + + if ($evaluation['action'] === 'block') { + $_SESSION['contact_error'] = "Submission blocked due to suspicious activity. If this is a mistake, please contact us directly."; + Logger::warning("Blocked submission from IP: {$formData['ip_address']}, Reason: {$evaluation['reason']}"); + EmailHelper::alertAdmins('Blocked Submission Detected', "A submission was blocked for the following reason: {$evaluation['reason']}", $formData); + SessionHelper::writeClose(); + header("Location: /#contact"); + exit; + } + + $logId = null; + try { + $logStmt = $db->prepare("INSERT INTO submission_logs (email, phone, ip_address, user_agent, was_saved, reason) VALUES (:email, :phone, :ip, :ua, :saved, :reason)"); + $logStmt->execute([ + ':email' => $formData['email'], + ':phone' => $formData['phone'], + ':ip' => $formData['ip_address'], + ':ua' => $formData['user_agent'], + ':saved' => 0, + ':reason' => $evaluation['reason'], + ]); + $logId = $db->lastInsertId(); + } catch (\Throwable $e) { + Logger::error("Failed to insert into submission_logs: " . $e->getMessage()); + } + + $contactModel = new ContactModel($db); + $saveSuccess = $contactModel->saveContactForm($formData); + + $contactId = $db->lastInsertId(); + $verificationCode = bin2hex(random_bytes(16)); + $expiresAt = (new \DateTime('+72 hours'))->format('Y-m-d H:i:s'); + + if ($saveSuccess) { + $stmt = $db->prepare("UPDATE contact_messages SET verification_code = ?, is_verified = 0, verification_expires_at = ? WHERE id = ?"); + $stmt->execute([$verificationCode, $expiresAt, $contactId]); + ContactService::sendVerificationEmail($formData['email'], $verificationCode); + } + + if ($saveSuccess && $logId) { + $update = $db->prepare("UPDATE submission_logs SET was_saved = 1 WHERE id = :id"); + $update->execute([':id' => $logId]); + } + + // Newsletter opt-in logic + if (!empty($_POST['subscribe_newsletter'])) { + Logger::info("Contact opted into newsletter: {$formData['email']}"); + NewsletterService::subscribeOrResend($formData['email']); + } + + Logger::info("✅ Writing session flag: contact_success = true"); + Logger::info("✅ Session content before redirect: " . json_encode($_SESSION)); + + SessionHelper::writeClose(); + View::render('pages/contact_check_email'); + return; + + } catch (\Throwable $e) { + Logger::error("Fatal error in ContactController::submit: " . $e->getMessage()); + EmailHelper::alertAdmins('ContactController::submit - Uncaught Exception', $e->getMessage(), $_POST ?? []); $_SESSION['contact_error'] = 'An internal error occurred. Please try again later.'; - - header("Location: /?contact_error=1#contact"); - + SessionHelper::writeClose(); + Logger::info("✅ Writing session flag: catch contact_error = " . $_SESSION['contact_error']); + Logger::info("✅ Session content before redirect: " . json_encode($_SESSION)); + header("Location: /#contact"); exit; - } - - // Save to DB - $db = Database::getConnection(); - $contactModel = new ContactModel($db); - $saveSuccess = $contactModel->saveContactForm($formData); - - // Send to sales team - $emailSuccess = EmailHelper::sendContactNotification($formData); - - // Send confirmation to user - $confirmationSuccess = EmailHelper::sendConfirmationToUser($formData); - - if ($saveSuccess && $emailSuccess) { - $_SESSION['contact_success'] = true; - - } else { - Logger::error("Form processed but saveSuccess={$saveSuccess}, emailSuccess={$emailSuccess}"); - $_SESSION['contact_error'] = 'Your message was received but an internal error occurred. A confirmation may not have been sent.'; - - EmailHelper::alertAdmins('ContactController::submit - DB or email failure', 'Partial failure', $formData); - } - - if (!$confirmationSuccess) { - Logger::error("Confirmation email failed to send to user: {$formData['email']}"); - // Don't show user error — it's non-critical - } - Logger::info("✅ Writing session flag: contact_success = true"); - Logger::info("✅ Session content before redirect: " . json_encode($_SESSION)); - - header("Location: /?contact_submitted=1#contact"); - - exit; - - } catch (\Throwable $e) { - Logger::error("Fatal error in ContactController::submit: " . $e->getMessage()); - EmailHelper::alertAdmins('ContactController::submit - Uncaught Exception', $e->getMessage(), $_POST ?? []); - $_SESSION['contact_error'] = 'An internal error occurred. Please try again later.'; - - Logger::info("✅ Writing session flag: catch contact_error = " . $_SESSION['contact_error']); - Logger::info("✅ Session content before redirect: " . json_encode($_SESSION)); - header("Location: /?contact_error=2#contact"); - - exit; } } - -} diff --git a/app/Controllers/ResendVerficationController.php b/app/Controllers/ResendVerficationController.php new file mode 100644 index 0000000..1742120 --- /dev/null +++ b/app/Controllers/ResendVerficationController.php @@ -0,0 +1,104 @@ + 'Invalid email or type.']); + return; + } + + try { + $db = Database::getConnection(); + + // Rate limit: no more than 3 per day + $dailyCheck = $db->prepare("SELECT COUNT(*) FROM verification_attempts WHERE email = ? AND type = ? AND attempted_at >= NOW() - INTERVAL 1 DAY"); + $dailyCheck->execute([$email, $type]); + $dailyCount = $dailyCheck->fetchColumn(); + + if ($dailyCount >= 3) { + View::render('pages/verify_failed', ['reason' => 'You have reached the daily resend limit. Please try again tomorrow.']); + return; + } + + // Rate limit: no more than 1 every 5 minutes + $recentCheck = $db->prepare("SELECT COUNT(*) FROM verification_attempts WHERE email = ? AND type = ? AND attempted_at >= NOW() - INTERVAL 5 MINUTE"); + $recentCheck->execute([$email, $type]); + $recentCount = $recentCheck->fetchColumn(); + + if ($recentCount > 0) { + View::render('pages/verify_failed', ['reason' => 'You must wait a few minutes before requesting another verification email.']); + return; + } + + // Log attempt + $log = $db->prepare("INSERT INTO verification_attempts (email, type, attempted_at, ip_address, user_agent) VALUES (?, ?, NOW(), ?, ?)"); + $log->execute([ + $email, + $type, + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + $_SERVER['HTTP_USER_AGENT'] ?? 'unknown', + ]); + + $code = bin2hex(random_bytes(16)); + $expiry = (new \DateTime())->modify('+72 hours')->format('Y-m-d H:i:s'); + + if ($type === 'newsletter') { + $stmt = $db->prepare("SELECT id, is_verified FROM subscribers WHERE email = ?"); + $stmt->execute([$email]); + $row = $stmt->fetch(); + + if (!$row || (int)$row['is_verified'] === 1) { + View::render('pages/verify_failed', ['reason' => 'Email is already verified or not found.']); + return; + } + + $update = $db->prepare("UPDATE subscribers SET verification_code = ?, is_verified = 0, verification_expires_at = ? WHERE id = ?"); + $update->execute([$code, $expiry, $row['id']]); + NewsletterService::sendVerificationEmail($email, $code); + } + + if ($type === 'contact') { + $stmt = $db->prepare("SELECT id, is_verified FROM contact_messages WHERE email = ? ORDER BY created_at DESC LIMIT 1"); + $stmt->execute([$email]); + $row = $stmt->fetch(); + + if (!$row || (int)$row['is_verified'] === 1) { + View::render('pages/verify_failed', ['reason' => 'Email is already verified or not found.']); + return; + } + + $update = $db->prepare("UPDATE contact_messages SET verification_code = ?, is_verified = 0, verification_expires_at = ? WHERE id = ?"); + $update->execute([$code, $expiry, $row['id']]); + ContactService::sendVerificationEmail($email, $code); + } + + View::render('pages/verify_success', [ + 'type' => $type, + 'message' => 'We just sent you a new verification link.' + ]); + } catch (\Throwable $e) { + Logger::error("Resend verification failed: " . $e->getMessage()); + View::render('pages/verify_failed', ['reason' => 'Unexpected error occurred.']); + } + } +} \ No newline at end of file diff --git a/app/Controllers/SubscriberController.php b/app/Controllers/SubscriberController.php new file mode 100644 index 0000000..6f8affb --- /dev/null +++ b/app/Controllers/SubscriberController.php @@ -0,0 +1,68 @@ + 'Missing email address.']); + return; + } + + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT id FROM subscribers WHERE email = ?"); + $stmt->execute([$email]); + $subscriber = $stmt->fetch(); + + if (!$subscriber) { + Logger::error("Subscriber update failed: not found [$email]."); + View::render('pages/verify_failed', ['reason' => 'Subscriber not found.']); + return; + } + + $stmt = $db->prepare("UPDATE subscribers SET first_name = ?, last_name = ? WHERE id = ?"); + $stmt->execute([$firstName, $lastName, $subscriber['id']]); + + Logger::info("Subscriber updated: $email"); + $_SESSION['update_success'] = true; + $_SESSION['update_type'] = 'newsletter'; + + header("Location: /verify-success"); + exit; + } catch (\Throwable $e) { + Logger::error("Subscriber update error for $email: " . $e->getMessage()); + ErrorHandler::exception($e); + View::render('pages/verify_failed', ['reason' => 'An error occurred while updating your info.']); + } + } +} diff --git a/app/Controllers/UnsubscribeController.php b/app/Controllers/UnsubscribeController.php new file mode 100644 index 0000000..8d8d161 --- /dev/null +++ b/app/Controllers/UnsubscribeController.php @@ -0,0 +1,96 @@ + 'No email provided.']); + return; + } + + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT is_verified, unsubscribed_at FROM subscribers WHERE email = ?"); + $stmt->execute([$email]); + $subscriber = $stmt->fetch(); + + if (!$subscriber) { + Logger::error("Unsubscribe: Subscriber not found [$email]"); + View::render('pages/unsubscribe_failed', ['reason' => 'Subscriber not found.']); + return; + } + + if ($subscriber['unsubscribed_at']) { + View::render('pages/unsubscribe_success', ['email' => $email, 'alreadyUnsubscribed' => true]); + return; + } + + View::render('pages/unsubscribe_confirm', ['email' => $email]); + } catch (\Throwable $e) { + Logger::error("Unsubscribe view error: " . $e->getMessage()); + ErrorHandler::exception($e); + View::render('pages/unsubscribe_failed', ['reason' => 'An unexpected error occurred.']); + } + } + + /** + * POST /unsubscribe + * Perform the actual unsubscribe action. + */ + public function process(): void + { + try { + $email = trim($_POST['email'] ?? ''); + $reason = trim($_POST['unsubscribe_reason'] ?? ''); + + if (empty($email)) { + Logger::error("Unsubscribe form submitted without email."); + View::render('pages/unsubscribe_failed', ['reason' => 'No email address was provided.']); + return; + } + + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT id FROM subscribers WHERE email = ?"); + $stmt->execute([$email]); + $subscriber = $stmt->fetch(); + + if (!$subscriber) { + Logger::error("Unsubscribe: Subscriber not found during processing [$email]"); + View::render('pages/unsubscribe_failed', ['reason' => 'Subscriber not found.']); + return; + } + + $stmt = $db->prepare("UPDATE subscribers SET unsubscribed_at = NOW(), unsubscribe_reason = ? WHERE id = ?"); + $stmt->execute([$reason, $subscriber['id']]); + + Logger::info("Subscriber unsubscribed: $email"); + View::render('pages/unsubscribe_success', ['email' => $email]); + } catch (\Throwable $e) { + Logger::error("Unsubscribe processing error for $email: " . $e->getMessage()); + ErrorHandler::exception($e); + View::render('pages/unsubscribe_failed', ['reason' => 'An error occurred while processing your unsubscribe.']); + } + } +} diff --git a/app/Controllers/VerificationController.php b/app/Controllers/VerificationController.php new file mode 100644 index 0000000..f372abc --- /dev/null +++ b/app/Controllers/VerificationController.php @@ -0,0 +1,71 @@ + 'No verification code provided.']); + return; + } + + $db = Database::getConnection(); + + // Check subscribers table + $stmt = $db->prepare("SELECT id, is_verified, email, verification_expires_at FROM subscribers WHERE verification_code = ?"); + $stmt->execute([$code]); + $subscriber = $stmt->fetch(); + + // Log verification attempt (even if failed) + $logAttempt = $db->prepare("INSERT INTO verification_attempts (email, type, attempted_at, ip_address, user_agent) VALUES (?, ?, NOW(), ?, ?)"); + $logAttempt->execute([ + $subscriber['email'] ?? '[unknown]', + 'newsletter', + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + $_SERVER['HTTP_USER_AGENT'] ?? 'unknown' + ]); + + if ($subscriber) { + if (!empty($subscriber['verification_expires_at']) && strtotime($subscriber['verification_expires_at']) < time()) { + View::render('pages/verify_failed', ['reason' => 'Your verification link has expired. Please request a new one.']); + return; + } + + if ((int) $subscriber['is_verified'] === 1) { + View::render('pages/verify_success', ['type' => 'newsletter', 'message' => 'This subscription has already been verified.']); + return; + } + + $update = $db->prepare("UPDATE subscribers SET is_verified = 1, verification_code = NULL WHERE id = ?"); + $update->execute([$subscriber['id']]); + + Logger::info("Subscriber verified: ID " . $subscriber['id']); + View::render('pages/verify_success', ['type' => 'newsletter']); + return; + } + + Logger::error("Invalid or expired verification code: $code"); + View::render('pages/verify_failed', ['reason' => 'Verification code is invalid or expired.']); + } catch (\Throwable $e) { + Logger::error("Verification exception: " . $e->getMessage()); + View::render('pages/verify_failed', ['reason' => 'An error occurred during verification.']); + } + } +} diff --git a/app/Core/Router.php b/app/Core/Router.php index 9f89ddd..f516352 100644 --- a/app/Core/Router.php +++ b/app/Core/Router.php @@ -3,10 +3,9 @@ * ============================================ * File: Router.php * Path: /app/Core/ - * Purpose: Core router handling HTTP method–specific route dispatching. - * Version: 1.1 + * Purpose: Core router handling HTTP method–specific route dispatching with dynamic path and closure support. + * Version: 1.3 * Author: Wizdom Networks - * Usage: Handles all GET/POST routing to controllers. * ============================================ */ @@ -22,16 +21,45 @@ class Router /** * Registers a new route. * - * @param string $path The URL path (e.g. /contact). + * @param string $path The URL path (e.g. /contact or /verify/{code}). * @param string $controller The fully qualified controller class. * @param string $method The method name in the controller. * @param string $httpMethod HTTP method (GET, POST, etc.), defaults to GET. */ public function add(string $path, string $controller, string $method, string $httpMethod = 'GET'): void { - $routeKey = strtoupper($httpMethod) . ':' . trim($path, '/'); + $normalizedPath = trim($path, '/'); + $routeKey = strtoupper($httpMethod) . ':' . $normalizedPath; + + // Convert path with {param} to regex and store original keys + $paramKeys = []; + $regexPath = preg_replace_callback('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', function ($matches) use (&$paramKeys) { + $paramKeys[] = $matches[1]; + return '([^\/]+)'; + }, $normalizedPath); + + $this->routes[$routeKey] = [ + 'controller' => $controller, + 'method' => $method, + 'pattern' => "#^" . $regexPath . "$#", + 'params' => $paramKeys + ]; + Logger::debug("Registering route: [$httpMethod] $path -> $controller::$method"); - $this->routes[$routeKey] = [$controller, $method]; + } + + /** + * Registers a closure-based route. + * + * @param string $path + * @param \Closure $callback + * @param string $httpMethod + */ + public function addClosure(string $path, \Closure $callback, string $httpMethod = 'GET'): void + { + $routeKey = strtoupper($httpMethod) . ':' . trim($path, '/'); + Logger::debug("Registering closure route: [$httpMethod] $path"); + $this->routes[$routeKey] = $callback; } /** @@ -41,41 +69,61 @@ class Router */ public function dispatch($path) { - $httpMethod = $_SERVER['REQUEST_METHOD']; - $routeKey = $httpMethod . ':' . trim($path, '/'); + $httpMethod = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + $cleanPath = trim($path, '/'); + $routeKeyBase = $httpMethod . ':'; - Logger::debug("Dispatching [$httpMethod] $path"); - - if (isset($this->routes[$routeKey])) { - [$controllerName, $method] = $this->routes[$routeKey]; - Logger::debug("Matched route -> $controllerName::$method"); - - try { - if (!class_exists($controllerName)) { - throw new \Exception("Controller not found: $controllerName"); - } - - $controller = new $controllerName(); - - if (!method_exists($controller, $method)) { - throw new \Exception("Method $method not found in $controllerName"); - } - - Logger::info("Executing controller: $controllerName::$method"); - $controller->$method(); - } catch (\Throwable $e) { - echo "
";
-                echo "Exception: " . $e->getMessage() . "\n";
-                echo "File: " . $e->getFile() . "\n";
-                echo "Line: " . $e->getLine() . "\n";
-                echo "Trace:\n" . $e->getTraceAsString();
-                echo "
"; - exit; + foreach ($this->routes as $key => $route) { + if (strpos($key, $routeKeyBase) !== 0) { + continue; + } + + $routePattern = $route['pattern'] ?? null; + + // Handle Closure + if ($route instanceof \Closure && $key === $routeKeyBase . $cleanPath) { + Logger::info("Executing closure route: [$httpMethod] $cleanPath"); + $route(); + return; + } + + // Handle dynamic path matches + if ($routePattern && preg_match($routePattern, $cleanPath, $matches)) { + array_shift($matches); // first match is the whole path + + $params = array_combine($route['params'], $matches) ?: []; + $controllerName = $route['controller']; + $method = $route['method']; + + try { + if (!class_exists($controllerName)) { + throw new \Exception("Controller not found: $controllerName"); + } + + $controller = new $controllerName(); + + if (!method_exists($controller, $method)) { + throw new \Exception("Method $method not found in $controllerName"); + } + + Logger::info("Executing controller: $controllerName::$method with params: " . json_encode($params)); + call_user_func_array([$controller, $method], $params); + return; + + } catch (\Throwable $e) { + echo "
";
+                    echo "Exception: " . $e->getMessage() . "\n";
+                    echo "File: " . $e->getFile() . "\n";
+                    echo "Line: " . $e->getLine() . "\n";
+                    echo "Trace:\n" . $e->getTraceAsString();
+                    echo "
"; + exit; + } } - } else { - Logger::error("Route not found: [$httpMethod] $path"); - http_response_code(404); - echo "404 Not Found"; } + + Logger::error("Route not found: [$httpMethod] $path"); + http_response_code(404); + echo "404 Not Found"; } } diff --git a/app/Core/View.php b/app/Core/View.php index 7288ef8..142ee2b 100644 --- a/app/Core/View.php +++ b/app/Core/View.php @@ -2,7 +2,7 @@ // ============================================ // File: View.php -// Version: 1.1 +// Version: 1.2 // Path: app/Core/View.php // Purpose: Handles dynamic view rendering with optional layout wrapping // Project: Wizdom Networks Website @@ -33,6 +33,10 @@ class View public static function render(string $view, array $data = [], ?string $layout = null): void { Logger::debug("Rendering view: $view"); + if (!class_exists('View')) { + class_alias(self::class, 'View'); + } + // Extract data to make variables available in the view extract($data); @@ -68,4 +72,39 @@ class View Logger::debug("Successfully rendered view: $view (no layout)"); } } + /** + * Renders a partial view without applying a layout. + * + * Use this for modular components like hero, services, faq, etc. + * Partial views must reside in: /resources/views/partials/ + * + * @param string $partial The name of the partial (e.g., 'hero', 'faq'). + * You may use dot notation for subdirectories (e.g., 'admin.nav'). + * @param array $data Optional associative array of data to be extracted into the view. + * + * @throws \Exception if the partial file does not exist. + * + * @return void + */ +public static function renderPartial(string $partial, array $data = []): void +{ + Logger::debug("Rendering partial: $partial"); + + // Convert dot notation to path and resolve to full filesystem path + $partialPath = realpath(__DIR__ . "/../../resources/views/partials/" . str_replace('.', '/', $partial) . ".php"); + + Logger::debug("Resolved partial path: $partialPath"); + + if (!$partialPath || !file_exists($partialPath)) { + Logger::error("Partial view not found: $partial | Resolved path: $partialPath"); + throw new \Exception("Partial view not found: $partial"); + } + + // Extract data and include partial + extract($data); + include $partialPath; + + Logger::debug("Successfully rendered partial: $partial"); +} + } diff --git a/app/Models/SubmissionLogModel.php b/app/Models/SubmissionLogModel.php new file mode 100644 index 0000000..1a39f8b --- /dev/null +++ b/app/Models/SubmissionLogModel.php @@ -0,0 +1,68 @@ +db = $db; + } + + /** + * Log a contact form submission attempt. + * + * @param array $data { + * @type string $email The submitted email address. + * @type string|null $phone The submitted phone number. + * @type string|null $ip_address The user's IP address. + * @type string|null $user_agent The user agent string. + * @type bool $was_saved True if saved to contact_messages. + * @type string $reason Classification reason (e.g. 'valid', 'blocked:honeypot'). + * } + * @return bool True on success, false on failure. + */ + public function logAttempt(array $data): bool + { + try { + $sql = "INSERT INTO submission_logs (email, phone, ip_address, user_agent, was_saved, reason) + VALUES (:email, :phone, :ip, :agent, :saved, :reason)"; + $stmt = $this->db->prepare($sql); + return $stmt->execute([ + ':email' => $data['email'], + ':phone' => $data['phone'] ?? null, + ':ip' => $data['ip_address'] ?? null, + ':agent' => $data['user_agent'] ?? null, + ':saved' => $data['was_saved'] ? 1 : 0, + ':reason' => $data['reason'] + ]); + } catch (\Throwable $e) { + Logger::error("Failed to log submission attempt."); + ErrorHandler::exception($e); + return false; + } + } +} diff --git a/app/Services/ContactService.php b/app/Services/ContactService.php new file mode 100644 index 0000000..e77052d --- /dev/null +++ b/app/Services/ContactService.php @@ -0,0 +1,42 @@ +Hello,

+

Thank you for reaching out to Wizdom Networks. To complete your contact request, please verify your email address by clicking the link below:

+

Verify Email Address

+

If you did not submit this request, you can safely ignore this message.

+

– The Wizdom Networks Team

+ HTML; + + Logger::info("Sending contact verification email to: $email"); + return EmailHelper::send($email, $subject, $body); + } +} diff --git a/app/Services/NewsletterService.php b/app/Services/NewsletterService.php new file mode 100644 index 0000000..4446bf7 --- /dev/null +++ b/app/Services/NewsletterService.php @@ -0,0 +1,115 @@ +prepare("SELECT is_verified FROM subscribers WHERE email = ?"); + $stmt->execute([$email]); + $row = $stmt->fetch(); + + if ($row) { + if ((int) $row['is_verified'] === 1) { + Logger::info("Newsletter signup skipped (already verified): $email"); + return false; + } else { + Logger::info("Newsletter re-verification triggered for $email"); + // Optionally regenerate and resend code here + } + } + + $verificationCode = bin2hex(random_bytes(16)); + + if ($row) { + // Update existing unverified entry + $stmt = $db->prepare("UPDATE subscribers SET verification_code = ?, ip_address = ?, user_agent = ?, created_at = NOW() WHERE email = ?"); + $stmt->execute([$verificationCode, $ip, $userAgent, $email]); + } else { + // Insert new record + $stmt = $db->prepare(" + INSERT INTO subscribers (email, verification_code, is_verified, ip_address, user_agent, created_at) + VALUES (?, ?, 0, ?, ?, NOW()) + "); + $stmt->execute([$email, $verificationCode, $ip, $userAgent]); + } + + Logger::info("Newsletter subscription initiated for $email, verification code generated."); + + return self::sendVerificationEmail($email, $verificationCode); + } catch (\Throwable $e) { + Logger::error("Newsletter subscription failed for $email: " . $e->getMessage()); + ErrorHandler::exception($e); + return false; + } + } + + /** + * Sends the newsletter verification email. + * + * @param string $email + * @param string $code + * @return bool True if sent successfully, false otherwise + */ + private static function sendVerificationEmail(string $email, string $code): bool + { + try { + $verifyUrl = $_ENV['BASE_URL'] . "/verify?code=" . urlencode($code); + + $mail = new PHPMailer(true); + $mail->isSMTP(); + $mail->Host = $_ENV['SMTP_HOST']; + $mail->SMTPAuth = true; + $mail->Username = $_ENV['SMTP_USER']; + $mail->Password = $_ENV['SMTP_PASS']; + $mail->SMTPSecure = $_ENV['SMTP_SECURE'] ?? 'tls'; + $mail->Port = $_ENV['SMTP_PORT'] ?? 587; + + $mail->setFrom($_ENV['MAIL_FROM'], $_ENV['MAIL_FROM_NAME']); + $mail->addAddress($email); + $mail->Subject = 'Confirm your subscription to Wizdom Networks'; + $mail->isHTML(true); + $mail->Body = " +

Thank you for subscribing to the Wizdom Networks newsletter!

+

Please click the link below to confirm your subscription:

+

Confirm My Subscription

+

If you did not request this, you can safely ignore this email.

+ "; + + $mail->send(); + Logger::info("Verification email sent to $email"); + return true; + } catch (MailException $e) { + Logger::error("Failed to send verification email to $email: " . $e->getMessage()); + ErrorHandler::exception($e); + return false; + } + } +} diff --git a/app/Utilities/EmailHelper.php b/app/Utilities/EmailHelper.php index bb9e136..7ec3b39 100644 --- a/app/Utilities/EmailHelper.php +++ b/app/Utilities/EmailHelper.php @@ -1,7 +1,7 @@ addAddress($to); + $mail->Subject = $subject; + $mail->Body = $body; + $mail->isHTML(true); + + return $mail->send(); + } catch (\Throwable $e) { + Logger::error("Email send failed to $to: " . $e->getMessage()); + return false; + } +} + + private static function buildContactHtmlBody(array $data): string { return " diff --git a/app/Utilities/SessionHelper.php b/app/Utilities/SessionHelper.php index b956a8b..00bce6e 100644 --- a/app/Utilities/SessionHelper.php +++ b/app/Utilities/SessionHelper.php @@ -1,7 +1,7 @@ = NOW() - INTERVAL :days DAY) AS email_hits, + (SELECT COUNT(*) FROM submission_logs WHERE phone = :phone AND created_at >= NOW() - INTERVAL :days DAY) AS phone_hits, + (SELECT COUNT(*) FROM submission_logs WHERE ip_address = :ip AND created_at >= NOW() - INTERVAL :days DAY) AS ip_hits, + (SELECT COUNT(*) FROM submission_logs WHERE ip_address = :ip AND created_at >= NOW() - INTERVAL 1 HOUR) AS ip_hourly + "; - $sql = "SELECT $timestampField FROM $table WHERE $emailField = :email ORDER BY $timestampField DESC LIMIT 1"; - $stmt = $pdo->prepare($sql); - $stmt->execute(['email' => $email]); + $stmt = $pdo->prepare($query); + $stmt->bindValue(':email', $email); + $stmt->bindValue(':phone', $phone); + $stmt->bindValue(':ip', $ip); + $stmt->bindValue(':days', self::LOOKBACK_DAYS, PDO::PARAM_INT); + $stmt->execute(); + $data = $stmt->fetch(PDO::FETCH_ASSOC); - $row = $stmt->fetch(PDO::FETCH_ASSOC); + $emailHits = (int)($data['email_hits'] ?? 0); + $phoneHits = (int)($data['phone_hits'] ?? 0); + $ipHits = (int)($data['ip_hits'] ?? 0); + $ipHourly = (int)($data['ip_hourly'] ?? 0); - if ($row && isset($row[$timestampField])) { - $last = new \DateTime($row[$timestampField]); - $cutoff = (new \DateTime())->modify("-{$days} days"); + $totalScore = $emailHits + $phoneHits + $ipHits; - if ($last >= $cutoff) { - return ['submitted_at' => $last->format('Y-m-d H:i:s')]; + if ($emailHits >= 4 || $phoneHits >= 4 || $ipHits >= 5) { + return ['action' => 'block', 'reason' => 'IP/email/phone threshold exceeded', 'count' => $totalScore]; + } + if ($ipHourly >= 3) { + return ['action' => 'flag', 'reason' => 'Multiple submissions from IP in last hour', 'count' => $ipHourly]; + } + if ($totalScore >= 6) { + return ['action' => 'notify', 'reason' => 'Cumulative signal from all identifiers', 'count' => $totalScore]; + } + if ($emailHits >= 2 || $phoneHits >= 2 || $ipHits >= 2) { + return ['action' => 'flag', 'reason' => 'Repeated pattern detected', 'count' => $totalScore]; } - } - return null; + return ['action' => 'accept', 'reason' => 'accepted', 'count' => $totalScore]; + + } catch (\Throwable $e) { + Logger::error("SubmissionCheck evaluation failed: " . $e->getMessage()); + return ['action' => 'error', 'reason' => 'Evaluation error', 'count' => 0]; + } } } diff --git a/public/assets/images b/public/assets/images new file mode 120000 index 0000000..6aa9f65 --- /dev/null +++ b/public/assets/images @@ -0,0 +1 @@ +/var/www/html/dev.wizdom.ca/public/assets/img \ No newline at end of file diff --git a/public/assets/img/person/output_webp/sa-media-office.webp b/public/assets/img/person/output_webp/sa-media-office.webp new file mode 100644 index 0000000..0962a72 Binary files /dev/null and b/public/assets/img/person/output_webp/sa-media-office.webp differ diff --git a/public/assets/img/person/output_webp/sa-polka-dot.webp b/public/assets/img/person/output_webp/sa-polka-dot.webp new file mode 100644 index 0000000..1335416 Binary files /dev/null and b/public/assets/img/person/output_webp/sa-polka-dot.webp differ diff --git a/public/assets/img/person/output_webp/sa-shadows.webp b/public/assets/img/person/output_webp/sa-shadows.webp new file mode 100644 index 0000000..597ec51 Binary files /dev/null and b/public/assets/img/person/output_webp/sa-shadows.webp differ diff --git a/public/assets/img/person/output_webp/sb-linkedin2.webp b/public/assets/img/person/output_webp/sb-linkedin2.webp new file mode 100644 index 0000000..b48d9d1 Binary files /dev/null and b/public/assets/img/person/output_webp/sb-linkedin2.webp differ diff --git a/public/assets/img/person/sa-media-office.jpg b/public/assets/img/person/sa-media-office.jpg new file mode 100644 index 0000000..ef62b29 Binary files /dev/null and b/public/assets/img/person/sa-media-office.jpg differ diff --git a/public/assets/img/person/sa-media-office.webp b/public/assets/img/person/sa-media-office.webp new file mode 100644 index 0000000..0962a72 Binary files /dev/null and b/public/assets/img/person/sa-media-office.webp differ diff --git a/public/assets/img/person/sa-polka-dot.jpg b/public/assets/img/person/sa-polka-dot.jpg new file mode 100644 index 0000000..c722bce Binary files /dev/null and b/public/assets/img/person/sa-polka-dot.jpg differ diff --git a/public/assets/img/person/sa-polka-dot.webp b/public/assets/img/person/sa-polka-dot.webp new file mode 100644 index 0000000..1335416 Binary files /dev/null and b/public/assets/img/person/sa-polka-dot.webp differ diff --git a/public/assets/img/person/sa-shadows.webp b/public/assets/img/person/sa-shadows.webp new file mode 100644 index 0000000..597ec51 Binary files /dev/null and b/public/assets/img/person/sa-shadows.webp differ diff --git a/public/assets/img/person/sb-linkedin2.jpg b/public/assets/img/person/sb-linkedin2.jpg new file mode 100644 index 0000000..eff6625 Binary files /dev/null and b/public/assets/img/person/sb-linkedin2.jpg differ diff --git a/public/assets/img/person/sb-linkedin2.webp b/public/assets/img/person/sb-linkedin2.webp new file mode 100644 index 0000000..b48d9d1 Binary files /dev/null and b/public/assets/img/person/sb-linkedin2.webp differ diff --git a/public/assets/img/testimonials/Dionne.jpg b/public/assets/img/testimonials/Dionne.jpg new file mode 100644 index 0000000..9ef36a9 Binary files /dev/null and b/public/assets/img/testimonials/Dionne.jpg differ diff --git a/public/assets/img/testimonials/Dionne.webp b/public/assets/img/testimonials/Dionne.webp new file mode 100644 index 0000000..ecfa27d Binary files /dev/null and b/public/assets/img/testimonials/Dionne.webp differ diff --git a/public/assets/img/testimonials/dionne-bowers.webp b/public/assets/img/testimonials/dionne-bowers.webp new file mode 100644 index 0000000..cc47c80 Binary files /dev/null and b/public/assets/img/testimonials/dionne-bowers.webp differ diff --git a/public/assets/img/testimonials/richard-bailey.webp b/public/assets/img/testimonials/richard-bailey.webp new file mode 100644 index 0000000..271a714 Binary files /dev/null and b/public/assets/img/testimonials/richard-bailey.webp differ diff --git a/public/assets/img/testimonials/sheldon-williams.webp b/public/assets/img/testimonials/sheldon-williams.webp new file mode 100644 index 0000000..978e72e Binary files /dev/null and b/public/assets/img/testimonials/sheldon-williams.webp differ diff --git a/public/assets/js/contact-form.js b/public/assets/js/contact-form.js index 994359e..fffed69 100644 --- a/public/assets/js/contact-form.js +++ b/public/assets/js/contact-form.js @@ -1,26 +1,30 @@ +// File: public/assets/js/contact-form.js +// Version: 1.2 +// Purpose: Handles JS-based form submission with feedback and duplicate prevention + + document.addEventListener('DOMContentLoaded', function () { - const params = new URLSearchParams(window.location.search); - if (params.get('contact_submitted') || params.get('contact_error')) { - const contactSection = document.getElementById('contact'); - if (contactSection) { - contactSection.scrollIntoView({ behavior: 'smooth' }); - } - } - }); -document.addEventListener('DOMContentLoaded', function () { + const params = new URLSearchParams(window.location.search); + if (params.get('contact_submitted') || params.get('contact_error')) { + const contactSection = document.getElementById('contact'); + if (contactSection) { + contactSection.scrollIntoView({ behavior: 'smooth' }); + } + } + const form = document.querySelector('.php-email-form'); if (!form) return; + const submitButton = form.querySelector('button[type="submit"]'); + const loading = document.createElement('div'); const errorMsg = document.createElement('div'); const successMsg = document.createElement('div'); loading.className = 'loading mt-3'; loading.textContent = 'Sending message...'; - errorMsg.className = 'error-message mt-3'; errorMsg.style.display = 'none'; - successMsg.className = 'sent-message mt-3'; successMsg.style.display = 'none'; @@ -33,6 +37,9 @@ document.addEventListener('DOMContentLoaded', function () { form.addEventListener('submit', async function (e) { e.preventDefault(); + if (submitButton.disabled) return; // prevent double click + submitButton.disabled = true; + loading.style.display = 'block'; errorMsg.style.display = 'none'; successMsg.style.display = 'none'; @@ -47,6 +54,7 @@ document.addEventListener('DOMContentLoaded', function () { const result = await response.json(); loading.style.display = 'none'; + submitButton.disabled = false; if (response.ok && result.success) { successMsg.textContent = result.message || 'Your message was sent successfully.'; @@ -57,9 +65,17 @@ document.addEventListener('DOMContentLoaded', function () { } } catch (err) { loading.style.display = 'none'; + submitButton.disabled = false; errorMsg.textContent = err.message || 'An unexpected error occurred.'; errorMsg.style.display = 'block'; } }); }); + + window.prefillService = function (serviceName) { + const subjectField = document.getElementById('subject'); + if (subjectField) { + subjectField.value = `Inquiry about ${serviceName}`; + } + }; \ No newline at end of file diff --git a/public/index.php b/public/index.php index afaeb51..ab08efe 100644 --- a/public/index.php +++ b/public/index.php @@ -1,53 +1,89 @@ load(); -// Start session before any router logic +// ------------------------------------------------------------ +// Core Utilities & Session Boot +// ------------------------------------------------------------ use WizdomNetworks\WizeWeb\Utilities\SessionHelper; -SessionHelper::start(); - -// Import core system utilities -use WizdomNetworks\WizeWeb\Core\Router; use WizdomNetworks\WizeWeb\Utilities\Logger; use WizdomNetworks\WizeWeb\Utilities\ErrorHandler; +use WizdomNetworks\WizeWeb\Core\Router; +use WizdomNetworks\WizeWeb\Core\View; -// Import controllers use WizdomNetworks\WizeWeb\Controllers\LandingController; use WizdomNetworks\WizeWeb\Controllers\ContactController; +use WizdomNetworks\WizeWeb\Controllers\VerificationController; +use WizdomNetworks\WizeWeb\Controllers\ResendVerificationController; +use WizdomNetworks\WizeWeb\Controllers\SubscriberController; +use WizdomNetworks\WizeWeb\Controllers\UnsubscribeController; -// Boot the application +// Start session before routing +SessionHelper::start(); Logger::info("Bootstrapping application"); -// Initialize router and define routes +// ------------------------------------------------------------ +// Route Registration +// ------------------------------------------------------------ $router = new Router(); -// Landing page routes +// Landing Page Routes $router->add('', LandingController::class, 'index'); $router->add('/', LandingController::class, 'index'); $router->add('index.php', LandingController::class, 'index'); -// Contact form routes +// Contact Form Routes $router->add('/contact', ContactController::class, 'index', 'GET'); $router->add('/contact', ContactController::class, 'submit', 'POST'); -// Dispatch the incoming request +// Verification Routes +$router->add('/verify', VerificationController::class, 'verify', 'GET'); // e.g. /verify?email=... +$router->add('/verify/{code}', ContactController::class, 'verify', 'GET'); // e.g. /verify/abc123... + +// Resend / Newsletter / Unsubscribe +$router->add('/resend-verification', ResendVerificationController::class, 'handle', 'POST'); +$router->add('/subscriber/update', SubscriberController::class, 'update', 'POST'); +$router->add('/unsubscribe', UnsubscribeController::class, 'confirm', 'GET'); +$router->add('/unsubscribe', UnsubscribeController::class, 'process', 'POST'); + +// Post-Redirect-GET success confirmation +$router->addClosure('/verify-success', function () { + $type = $_SESSION['update_type'] ?? 'newsletter'; + $message = $_SESSION['update_success'] ? 'Thank you! Your information has been updated.' : null; + + View::render('pages/verify_success', [ + 'type' => $type, + 'message' => $message, + ]); + + unset($_SESSION['update_success'], $_SESSION['update_type']); +}, 'GET'); + +// ------------------------------------------------------------ +// Dispatch Request +// ------------------------------------------------------------ $requestedPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $router->dispatch($requestedPath); diff --git a/resources/views/layouts/arsha.php b/resources/views/layouts/arsha.php index b9b37db..eb269c7 100644 --- a/resources/views/layouts/arsha.php +++ b/resources/views/layouts/arsha.php @@ -1,75 +1,24 @@ - - - - <?= $pageTitle ?? 'Wizdom Networks' ?> - - - - - - - - - - - - - - - - - - - - - - +
- - - - - - + @@ -77,26 +26,31 @@ - - - - - - - - - + + + + + + + + + + + + + + + + - - diff --git a/resources/views/layouts/footer.php b/resources/views/layouts/footer.php index b46cdc1..978b7cb 100644 --- a/resources/views/layouts/footer.php +++ b/resources/views/layouts/footer.php @@ -1,23 +1,94 @@ - + + + diff --git a/resources/views/layouts/head.php b/resources/views/layouts/head.php index 9676eec..d8be090 100644 --- a/resources/views/layouts/head.php +++ b/resources/views/layouts/head.php @@ -1,49 +1,44 @@ - false]; -$sliderConfig = $sliderConfig ?? ['enabled' => false]; -$sidebarConfig = $sidebarConfig ?? ['enabled' => false]; -$pageId = $pageId ?? null; - + content across all layouts +// Project: Wizdom Networks Website +// ============================================ ?> - - - <?php echo $title ?? 'Wizdom Networks'; ?> + + + + <?= $pageTitle ?? 'Wizdom Networks' ?> - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/views/layouts/header.php b/resources/views/layouts/header.php index f2dedd8..1422657 100644 --- a/resources/views/layouts/header.php +++ b/resources/views/layouts/header.php @@ -1,3 +1,16 @@ -
-
+ \ No newline at end of file diff --git a/resources/views/pages/contact_check_email.php b/resources/views/pages/contact_check_email.php new file mode 100644 index 0000000..a97b382 --- /dev/null +++ b/resources/views/pages/contact_check_email.php @@ -0,0 +1,41 @@ + +
+
+
+ +
+

Please Verify Your Email

+

We’ve sent you a confirmation link. Please check your inbox to verify your message.

+ +
+ Check Email +
+ + + + +
+
Didn't get the email?
+

Enter your email again to receive a new verification link.

+
+ +
+ +
+
+ +
+
+
+
+
diff --git a/resources/views/pages/landing-.php b/resources/views/pages/landing-.php new file mode 100644 index 0000000..1959f6f --- /dev/null +++ b/resources/views/pages/landing-.php @@ -0,0 +1,130 @@ +"; +?> + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/views/pages/landing.php b/resources/views/pages/landing.php index 41a3f58..7b515ff 100644 --- a/resources/views/pages/landing.php +++ b/resources/views/pages/landing.php @@ -2,9 +2,9 @@ /** * ============================================ * File: landing.php - * Path: /resources/views/pages/home/ + * Path: /resources/views/pages/landing.php * Purpose: Loads the Arsha one-pager layout content - * Version: 1.2.2 + * Version: 1.3.3 * Author: Wizdom Networks * Usage: Rendered via LandingController::index() * ============================================ @@ -12,939 +12,17 @@ echo ""; ?> + + + + + + + + + + + + + - - -
-
-
- - -
-

Tech Strategy Meets Business Reality

-

Since 2002, Wizdom has helped organizations align IT with what actually matters: results.

- -
- - -
- Wizdom Networks Hero Image -
- -
-
-
- - -
-
- -
- - - -
- -
Big Brothers & Big Sisters of Toronto
-
Bell Media
-
IBM
-
Microsoft
-
Nissan
-
Government of Ontario
- -
Celestica
-
SMART CENTRES REIT
-
Canadian Association of Black Insurance Professionals
-
Toronto Zoo
-
Victorian Order of Nurses
-
Canadian Caribbean Association of Halton
-
-
- -
-
- - -
- - -
-

About Us

-
- -
-
- -
-

- We’re not your average IT consultancy — and that’s the point. - At Wizdom, we blend sharp technical expertise with grounded business sense. - For over 20 years, we've helped clients design smarter infrastructure, manage complex projects, and adapt to change without missing a beat. -

-
    -
  • Infrastructure strategy meets execution — without the buzzwords.
  • -
  • We don’t just work with tech — we translate it into results.
  • -
  • Collaborative, transparent, and battle-tested since 2002.
  • -
-
- -
-

- Whether you need an architect, a troubleshooter, or a translator between tech and executive — we've been that partner. - Our engagements go beyond checklists — we help build what's next. -

- Explore Our Services -
- -
-
- -
- - -
-
-
- -
- -
-

Why Organizations Choose Wizdom Networks

-

- There’s no shortage of IT consultants out there. But few bring the same mix of creativity, pragmatism, and proven experience. We do. -

-
- -
- -
-

01 What makes Wizdom different?

-
-

We combine deep technical chops with strategic insight and hands-on execution. No fluff. No shortcuts.

-
- -
- -
-

02 Who do you typically work with?

-
-

We support SMBs and mid-market orgs across retail, public sector, professional services, and non-profits — but we scale easily into enterprise territory.

-
- -
- -
-

03 Do you offer flexible or project-based engagements?

-
-

Absolutely. From one-off projects to long-term partnerships, we adapt to your business needs and growth stage.

-
- -
- -
- -
- -
- -
- -
-
-
- -
-
- -
- -
- -
- -
-

Expertise Across Core IT Domains

-

- Our team is fluent in the systems, protocols, and technologies that keep your organization productive and secure. -

- -
- -
- Active Directory & Exchange 100% -
-
-
-
- -
- Virtualization & Infrastructure 95% -
-
-
-
- -
- Network Design & Security 90% -
-
-
-
- -
- Cloud Strategy & M365 85% -
-
-
-
- -
-
-
-
-
- - -
-
-

Services

-

Discover how Wizdom Networks transforms businesses through modern IT consulting and powerful digital infrastructure solutions.

-
- -
-
- -
-
-
-

IT Strategy & Alignment

-

Align your technology with your business roadmap — not the other way around.

-
-
- -
-
-
-

Project & Implementation Support

-

From kickoff to go-live, we keep your projects moving and your outcomes clear.

-
-
- -
-
-
-

Managed IT Services

-

Full-service support for infrastructure, endpoints, cloud, and more.

-
-
- -
-
-
-

Network & Infrastructure Design

-

Firewalls, cabling, wireless, data centers — optimized and future-ready.

-
-
- -
-
-
-

Training & Documentation

-

Executive briefings. End-user workshops. Tailored sessions that stick.

-
-
- -
-
-
-

Branding & Web Consulting

-

From domain to delivery — we help you tell your story and back it up with solid tech.

-
-
- -
-
-
- - -
- - -
-

Work Process

-

Our proven approach helps organizations move faster, stay secure, and grow smarter.

-
- -
-
- -
-
-
- Step 1 -
-
-
01
-

Discovery & Assessment

-

We begin with a collaborative review of your systems, needs, risks, and goals to chart the best path forward.

-
-
Infrastructure Audits
-
Cloud Readiness
-
Security Gaps
-
-
-
-
- -
-
-
- Step 2 -
-
-
02
-

Planning & Design

-

We design robust solutions aligned with your budget, timelines, and business objectives — built for scale and longevity.

-
-
System Architecture
-
Migration Planning
-
Compliance Considerations
-
-
-
-
- -
-
-
- Step 3 -
-
-
03
-

Implementation & Support

-

From deployment to post-project monitoring, Wizdom Networks ensures success with hands-on support every step of the way.

-
-
Project Execution
-
Training & Onboarding
-
HelpDesk+ Ongoing Support
-
-
-
-
- -
-
-
- -
- - Call to Action Background - -
- -
-
-

Let’s Make Tech Make Sense

-

Whether you're solving today’s fires or planning for tomorrow’s growth, we’d love to hear your story — and help write the next chapter.

-
- -
- -
- -
- - - - -
- - -
-

Team

-

The people behind Wizdom Networks — technical depth, business vision, and proven execution.

-
- -
- -
- -
-
-
Essae B.
-
-

Essae B.

- Founder & Principal Consultant -

20+ years leading IT strategy, infrastructure, and operations for business and public sector clients.

- -
-
-
- -
-
-
Operations Lead
-
-

Jodie R.

- Chief Operations Officer -

Drives delivery timelines, project planning, and stakeholder communications across key initiatives.

- -
-
-
- -
-
-
Senior Network Engineer
-
-

Martin C.

- Senior Network Engineer -

Expert in firewalls, routing, structured cabling, and end-to-end IT systems architecture.

- -
-
-
- -
-
-
Client Success Lead
-
-

Tina R.

- Client Success Lead -

Helps clients define priorities, align services, and ensure satisfaction across all engagements.

- -
-
-
- -
- -
- -
- -
- - -
-

Testimonials

-

Feedback from our clients and collaborators.

-
- -
- -
- - -
- -
-
- -

Saul Goodman

-

CEO & Founder

-
- -
-

- - Wizdom Networks delivered exactly what we needed—on time, on budget, and with serious technical insight. - -

-
-
- -
-
- -

Sara Wilsson

-

Designer

-
- -
-

- - Their attention to detail and responsiveness made this one of the smoothest projects I’ve ever been part of. - -

-
-
- -
-
- -

Jena Karlis

-

Store Owner

-
- -
-

- - When you want it done right the first time, these are the people you call. Incredible service. - -

-
-
- -
-
- -

Matt Brandon

-

Freelancer

-
- -
-

- - Reliable, honest, and technically brilliant. I’d recommend Wizdom to any business that depends on IT. - -

-
-
- -
-
- -

John Larson

-

Entrepreneur

-
- -
-

- - We worked with Wizdom Networks on three separate engagements. All of them exceeded expectations. - -

-
-
- -
-
-
- -
- -
- -
- -
-

HelpDesk+ Plans

-

Reliable remote support for your growing business — no surprises, just dependable IT coverage.

-
- -
- -
- - -
-
-

Starter

-

$299/mo

-
    -
  • Up to 5 devices
  • -
  • Email + remote desktop support
  • -
  • Business hours coverage
  • -
  • 24/7 emergency support
  • -
  • Onboarding assistance
  • -
- Get Started -
-
- - -
- -
- - -
-
-

Enterprise

-

$899/mo

-
    -
  • Unlimited users
  • -
  • Dedicated support lead
  • -
  • Quarterly strategy review
  • -
  • Full reporting & auditing
  • -
  • SLA guarantees
  • -
- Contact Sales -
-
- - -
-
-

Retail/Franchise

-

$249/store/mo

-
    -
  • Optimized for retail turnover
  • -
  • POS + Wi-Fi + camera support
  • -
  • Rapid seasonal onboarding
  • -
  • Custom site inventory
  • -
  • Multi-location reporting
  • -
- Talk to Us -
-
- -
- -
- -
- -
- - -
-

Contact

-

Let’s connect. Whether it’s a project, support request, or general inquiry — we’re ready to help.

-
- -
- -
- -
-
- -
- -
-

Address

-

Mississauga, ON, Canada

-
-
- -
- -
-

Call Us

-

+1 (416) 873-9473

-

+1 (416) USE-WISE

-
-
- -
- -
-

Email Us

-

concierge@wizdom.ca

-
-
- - - -
-
- -
- - - - - - - - - -
- -
-
- - -
First name is required.
-
- -
- - -
Last name is required.
-
-
- -
-
- - -
Please enter a valid email address.
-
- -
- - -
Phone number is required.
-
-
- -
-
- - -
Subject is required.
-
-
- -
-
- - -
Please enter your message.
-
-
- -
- -
- -
- -
- -
- -
- -
- -
- -
-

Frequently Asked Questions

-

Answers to common questions about our services, pricing, and process.

-
- -
- -
- -
-

1. What industries do you work with?

-
-

We work across sectors including healthcare, nonprofit, retail, cannabis, government, and professional services. Our team adapts quickly to unique industry needs.

-
-
-
- -
-

2. How do HelpDesk+ support plans work?

-
-

Our support plans include unlimited remote assistance, proactive patching, monitoring, and device onboarding — all tailored to your plan tier.

-
-
-
- -
-

3. Can I get a one-time Stealthascope audit?

-
-

Yes. Our Basic and Pro plans are one-time engagements, and Enterprise audits can include recurring follow-ups if needed.

-
-
-
- -
-

4. Do you provide onsite service?

-
-

We primarily offer remote support across North America. Onsite visits are available in the GTA or by special arrangement elsewhere.

-
-
-
- -
-

5. How fast is your response time?

-
-

Our average first response is under 1 hour during business hours. Emergency support (24/7) is included in Business+ plans and above.

-
-
-
- -
- -
- -
- - - - diff --git a/resources/views/pages/unsubscribe_confirm.php b/resources/views/pages/unsubscribe_confirm.php new file mode 100644 index 0000000..5781008 --- /dev/null +++ b/resources/views/pages/unsubscribe_confirm.php @@ -0,0 +1,29 @@ +
+
+
+ +
+

Unsubscribe from “Words of Wizdom”

+

We’re sorry to see you go. If you’d like to stop receiving our emails, confirm below.

+ +
+ Unsubscribe +
+ +
+ + +
+ +
+ +
+ +
+
+ + +
+
diff --git a/resources/views/pages/unsubscribe_failed.php b/resources/views/pages/unsubscribe_failed.php new file mode 100644 index 0000000..b462300 --- /dev/null +++ b/resources/views/pages/unsubscribe_failed.php @@ -0,0 +1,17 @@ +
+
+
+ +
+

Unsubscribe Failed

+

+ +
+ Error +
+ + +
+
diff --git a/resources/views/pages/unsubscribe_success.php b/resources/views/pages/unsubscribe_success.php new file mode 100644 index 0000000..53f87fd --- /dev/null +++ b/resources/views/pages/unsubscribe_success.php @@ -0,0 +1,22 @@ +
+
+
+ +
+

You’ve Been Unsubscribed

+

+ We’ve removed from our mailing list. + + (You were already unsubscribed.) + +

+ +
+ Unsubscribed +
+ + +
+
diff --git a/resources/views/pages/verify_failed.php b/resources/views/pages/verify_failed.php new file mode 100644 index 0000000..784540e --- /dev/null +++ b/resources/views/pages/verify_failed.php @@ -0,0 +1,49 @@ + +
+
+
+ +
+

Verification Failed

+

+ +

+ +
+ Error +
+ + +
+
Resend Verification
+

If you entered the correct email and didn't receive your verification link, you can request a new one below.

+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
diff --git a/resources/views/pages/verify_success.php b/resources/views/pages/verify_success.php new file mode 100644 index 0000000..6a29d80 --- /dev/null +++ b/resources/views/pages/verify_success.php @@ -0,0 +1,53 @@ + +
+
+
+ +
+

Email Verified

+

+ Your email has been successfully verified.
+ Thank you for confirming your email address. +

+ + +
+ Welcome! +
+ + + +
+ + + + +
+

Would you like us to personalize future emails with your name?

+
+ + +
+ +
+
+ +
+ +
+ +
+
+
+ + + + +
+
diff --git a/resources/views/partials/about.php b/resources/views/partials/about.php new file mode 100644 index 0000000..d64e6f6 --- /dev/null +++ b/resources/views/partials/about.php @@ -0,0 +1,31 @@ + +
+
+

About Us

+
+ +
+
+
+

+ We’re not your average IT consultancy — and that’s the point. + At Wizdom, we blend sharp technical expertise with grounded business sense. + For over 20 years, we've helped clients design smarter infrastructure, manage complex projects, and adapt to change without missing a beat. +

+
    +
  • Infrastructure strategy meets execution — without the buzzwords.
  • +
  • We don’t just work with tech — we translate it into results.
  • +
  • Collaborative, transparent, and battle-tested since 2002.
  • +
+
+ +
+

+ Whether you need an architect, a troubleshooter, or a translator between tech and executive — we've been that partner. + Our engagements go beyond checklists — we help build what's next. +

+ Explore Our Services +
+
+
+
diff --git a/resources/views/partials/call-to-action.php b/resources/views/partials/call-to-action.php new file mode 100644 index 0000000..1071f8a --- /dev/null +++ b/resources/views/partials/call-to-action.php @@ -0,0 +1,18 @@ + +
+ + Call to Action Background + +
+
+
+

Let’s Make Tech Make Sense

+

Whether you're solving today’s fires or planning for tomorrow’s growth, we’d love to hear your story — and help write the next chapter.

+
+ +
+
+ +
diff --git a/resources/views/partials/clients.php b/resources/views/partials/clients.php new file mode 100644 index 0000000..4e5b2ee --- /dev/null +++ b/resources/views/partials/clients.php @@ -0,0 +1,41 @@ + +
+
+ +
+ + +
+
Big Brothers & Big Sisters of Toronto
+
Bell Media
+
IBM
+
Microsoft
+
Nissan
+
Government of Ontario
+
Celestica
+
SMART CENTRES REIT
+
Canadian Association of Black Insurance Professionals
+
Toronto Zoo
+
Victorian Order of Nurses
+
Canadian Caribbean Association of Halton
+
+
+ +
+
diff --git a/resources/views/partials/contact.php b/resources/views/partials/contact.php new file mode 100644 index 0000000..02b6099 --- /dev/null +++ b/resources/views/partials/contact.php @@ -0,0 +1,124 @@ + + + +
+
+

Contact

+

Let’s connect. Reach out with questions, ideas, or project inquiries — we’ll respond quickly.

+
+ +
+
+ + +
+
+ +
+ +
+

Address

+

Mississauga, Ontario
Canada

+
+
+ +
+ +
+

Call Us

+

416-USE-WISE
(416-873-9473)

+
+
+ +
+ +
+

Email Us

+

concierge@wizdom.ca

+
+
+ + + +
+
+ + +
+
+
+ + + +
+
+ +
+
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
Loading
+
+
Your message has been sent. Thank you!
+ +
+ +
+
+
+ +
+
+
diff --git a/resources/views/partials/faq.php b/resources/views/partials/faq.php new file mode 100644 index 0000000..aa53986 --- /dev/null +++ b/resources/views/partials/faq.php @@ -0,0 +1,57 @@ + +
+ +
+

Frequently Asked Questions

+

Answers to common questions about our services, pricing, and process.

+
+ +
+ +
+ +
+

1. What industries do you work with?

+
+

We work across sectors including healthcare, nonprofit, retail, cannabis, government, and professional services. Our team adapts quickly to unique industry needs.

+
+
+
+ +
+

2. How do HelpDesk+ support plans work?

+
+

Our support plans include unlimited remote assistance, proactive patching, monitoring, and device onboarding — all tailored to your plan tier.

+
+
+
+ +
+

3. Can I get a one-time Stealthascope audit?

+
+

Yes. Our Basic and Pro plans are one-time engagements, and Enterprise audits can include recurring follow-ups if needed.

+
+
+
+ +
+

4. Do you provide onsite service?

+
+

We primarily offer remote support across North America. Onsite visits are available in the GTA or by special arrangement elsewhere.

+
+
+
+ +
+

5. How fast is your response time?

+
+

Our average first response is under 1 hour during business hours. Emergency support (24/7) is included in Business+ plans and above.

+
+
+
+ +
+ +
+ +
\ No newline at end of file diff --git a/resources/views/pages/helpdeskplus-pricing.html b/resources/views/partials/helpdeskplus-pricing.php similarity index 100% rename from resources/views/pages/helpdeskplus-pricing.html rename to resources/views/partials/helpdeskplus-pricing.php diff --git a/resources/views/partials/hero.php b/resources/views/partials/hero.php index 886d2c1..9ab6a7c 100644 --- a/resources/views/partials/hero.php +++ b/resources/views/partials/hero.php @@ -1,40 +1,25 @@ - +
+
+
-/** - * Hero Partial (v2) - * - * This partial dynamically loads a hero section based on the provided configuration. - * It allows pages to enable or disable the hero and define custom styles. - * - * ## Configuration Options: - * - `enabled` (bool) - Determines whether the hero is displayed (default: false) - * - `title` (string) - Hero title text - * - `description` (string) - Hero subtitle or description text - * - `image` (string) - Background image URL (default: 'wizdom-about-definitions.jpg') - * - `height` (string) - Height of the hero section (default: '50vh') - * - `text_align` (string) - Text alignment ('left', 'center', 'right') (default: 'center') - * - `overlay` (bool) - Apply a dark overlay to improve text readability (default: true) - */ + +
+

Tech Strategy Meets Business Reality

+

Since 2002, Wizdom has helped organizations align IT with what actually matters: results.

+ +
-// Ensure hero visibility is explicitly enabled -if (!isset($heroConfig) || empty($heroConfig['enabled'])) { - return; -} + +
+ Wizdom Networks Hero Image +
-$heroTitle = htmlspecialchars($heroConfig['title'] ?? '', ENT_QUOTES, 'UTF-8'); -$heroDescription = htmlspecialchars($heroConfig['description'] ?? '', ENT_QUOTES, 'UTF-8'); -$heroImage = htmlspecialchars($heroConfig['image'] ?? '/assets/images/wizdom-about-definitions.jpg', ENT_QUOTES, 'UTF-8'); -$heroHeight = htmlspecialchars($heroConfig['height'] ?? '50vh', ENT_QUOTES, 'UTF-8'); -$textAlign = htmlspecialchars($heroConfig['text_align'] ?? 'center', ENT_QUOTES, 'UTF-8'); -$overlay = $heroConfig['overlay'] ?? false; -?> - -
- -
- -
-

-

-
+
+
diff --git a/resources/views/partials/navbar.php b/resources/views/partials/navbar.php index 73065ea..f2f4cbe 100644 --- a/resources/views/partials/navbar.php +++ b/resources/views/partials/navbar.php @@ -1,16 +1,13 @@ - + + + diff --git a/resources/views/partials/services.php b/resources/views/partials/services.php new file mode 100644 index 0000000..8308701 --- /dev/null +++ b/resources/views/partials/services.php @@ -0,0 +1,61 @@ + +
+
+

Services

+

Discover how Wizdom Networks transforms businesses through modern IT consulting and powerful digital infrastructure solutions.

+
+ +
+
+ +
+
+
+

IT Strategy & Alignment

+

Align your technology with your business roadmap — not the other way around.

+
+
+ +
+
+
+

Project & Implementation Support

+

From kickoff to go-live, we keep your projects moving and your outcomes clear.

+
+
+ +
+
+
+

Managed IT Services

+

Full-service support for infrastructure, endpoints, cloud, and more.

+
+
+ +
+
+
+

Network & Infrastructure Design

+

Firewalls, cabling, wireless, data centers — optimized and future-ready.

+
+
+ +
+
+
+

Training & Documentation

+

Executive briefings. End-user workshops. Tailored sessions that stick.

+
+
+ +
+
+
+

Branding & Web Consulting

+

From domain to delivery — we help you tell your story and back it up with solid tech.

+
+
+ +
+
+
diff --git a/resources/views/partials/skills.php b/resources/views/partials/skills.php new file mode 100644 index 0000000..371ffa4 --- /dev/null +++ b/resources/views/partials/skills.php @@ -0,0 +1,43 @@ + +
+
+ +
+
+ +
+ +
+

Expertise Across Core IT Domains

+

+ Our team is fluent in the systems, protocols, and technologies that keep your organization productive and secure. +

+ +
+ +
+ Active Directory & Exchange 100% +
+
+ +
+ Virtualization & Infrastructure 95% +
+
+ +
+ Network Design & Security 90% +
+
+ +
+ Cloud Strategy & M365 85% +
+
+ +
+
+
+ +
+
diff --git a/resources/views/partials/team.php b/resources/views/partials/team.php new file mode 100644 index 0000000..03c5c4d --- /dev/null +++ b/resources/views/partials/team.php @@ -0,0 +1,60 @@ + +
+
+

Team

+

The people behind Wizdom Networks — technical depth, business vision, and proven execution.

+
+ +
+
+ +
+
+
Essae B.
+
+

Essae B.

+ Founder & Principal Consultant +

20+ years leading IT strategy, infrastructure, and operations for business and public sector clients.

+ +
+
+
+ +
+
+
Jodie R.
+
+

Jodie R.

+ Chief Operations Officer +

Drives delivery timelines, project planning, and stakeholder communications across key initiatives.

+ +
+
+
+ +
+
+
Martin C.
+
+

Martin C.

+ Senior Network Engineer +

Expert in firewalls, routing, structured cabling, and end-to-end IT systems architecture.

+ +
+
+
+ + + +
+
+
\ No newline at end of file diff --git a/resources/views/partials/testimonials.php b/resources/views/partials/testimonials.php new file mode 100644 index 0000000..75abf3d --- /dev/null +++ b/resources/views/partials/testimonials.php @@ -0,0 +1,61 @@ + +
+
+

Testimonials

+

What our clients say about working with Wizdom Networks.

+
+ +
+
+ + + + +
+ +
+
+ Dionne B. +

"Wizdom delivered exactly what we needed — on time and without surprises. The team was professional, communicative, and solutions-driven."

+

Dionne B.

+

President of the Board, CABIP

+
+
+ +
+
+ Sheldon W. +

"They didn’t just implement our tech — they helped us rethink our processes. Their strategic input was invaluable."

+

Sheldon W.

+

President of the Board, CCAH

+
+
+ +
+
+ Richard B. +

"Wizdom has been instrumental in keeping our cannabis store running smoothly — especially during compliance and tech transitions."

+

Richard B.

+

Retail Store Owner, Cannabis Industry

+
+
+ +
+ +
+
+
+
diff --git a/resources/views/partials/why-us.php b/resources/views/partials/why-us.php new file mode 100644 index 0000000..d300dda --- /dev/null +++ b/resources/views/partials/why-us.php @@ -0,0 +1,51 @@ + +
+
+
+ +
+ +
+

Why Organizations Choose Wizdom Networks

+

+ There’s no shortage of IT consultants out there. But few bring the same mix of creativity, pragmatism, and proven experience. We do. +

+
+ +
+ +
+

01 What makes Wizdom different?

+
+

We combine deep technical chops with strategic insight and hands-on execution. No fluff. No shortcuts.

+
+ +
+ +
+

02 Who do you typically work with?

+
+

We support SMBs and mid-market orgs across retail, public sector, professional services, and non-profits — but we scale easily into enterprise territory.

+
+ +
+ +
+

03 Do you offer flexible or project-based engagements?

+
+

Absolutely. From one-off projects to long-term partnerships, we adapt to your business needs and growth stage.

+
+ +
+ +
+ +
+ +
+ +
+ +
+
+
\ No newline at end of file diff --git a/resources/views/partials/work-process.php b/resources/views/partials/work-process.php new file mode 100644 index 0000000..e4c673e --- /dev/null +++ b/resources/views/partials/work-process.php @@ -0,0 +1,67 @@ + +
+
+

Work Process

+

Our proven approach helps organizations move faster, stay secure, and grow smarter.

+
+ +
+
+ +
+
+
+ Step 1 +
+
+
01
+

Discovery & Assessment

+

We begin with a collaborative review of your systems, needs, risks, and goals to chart the best path forward.

+
+
Infrastructure Audits
+
Cloud Readiness
+
Security Gaps
+
+
+
+
+ +
+
+
+ Step 2 +
+
+
02
+

Planning & Design

+

We design robust solutions aligned with your budget, timelines, and business objectives — built for scale and longevity.

+
+
System Architecture
+
Migration Planning
+
Compliance Considerations
+
+
+
+
+ +
+
+
+ Step 3 +
+
+
03
+

Implementation & Support

+

From deployment to post-project monitoring, Wizdom Networks ensures success with hands-on support every step of the way.

+
+
Project Execution
+
Training & Onboarding
+
HelpDesk+ Ongoing Support
+
+
+
+
+ +
+
+
diff --git a/scripts/convert-to-square-webp.sh b/scripts/convert-to-square-webp.sh new file mode 100755 index 0000000..5535f9c --- /dev/null +++ b/scripts/convert-to-square-webp.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +# File: convert-to-webp.sh +# Version: 1.1 +# Purpose: Convert JPG/PNG to square WebP (centered or top-aligned) +# Usage: +# ./convert-to-webp.sh image1.jpg image2.png ... +# ./convert-to-webp.sh --top image1.jpg image2.png ... + +set -e + +# Default to center crop +CROP_MODE="center" + +# Check for --top flag +if [[ "$1" == "--top" ]]; then + CROP_MODE="top" + shift +fi + +if [ "$#" -eq 0 ]; then + echo "Usage:" + echo " $0 image1.jpg image2.png ..." + echo " $0 --top image1.jpg image2.png ..." + echo + echo "Use --top to crop from bottom up (better for portraits)." + exit 1 +fi + +for input in "$@"; do + if [[ ! -f "$input" ]]; then + echo "Skipping '$input': not a file" + continue + fi + + ext="${input##*.}" + ext_lc="$(echo "$ext" | tr '[:upper:]' '[:lower:]')" + if [[ "$ext_lc" != "jpg" && "$ext_lc" != "jpeg" && "$ext_lc" != "png" ]]; then + echo "Skipping '$input': unsupported format" + continue + fi + + base_name="$(basename "$input" .${ext})" + output="${base_name}.webp" + + dims=$(identify -format "%w %h" "$input") + width=$(echo "$dims" | cut -d' ' -f1) + height=$(echo "$dims" | cut -d' ' -f2) + + if [ "$width" -gt "$height" ]; then + # Landscape + offset_x=$(( (width - height) / 2 )) + offset_y=0 + crop="${height}x${height}+${offset_x}+${offset_y}" + else + # Portrait or square + offset_x=0 + if [[ "$CROP_MODE" == "top" ]]; then + offset_y=0 + else + offset_y=$(( (height - width) / 2 )) + fi + crop="${width}x${width}+${offset_x}+${offset_y}" + fi + + echo "Converting '$input' to '$output' (crop: $crop, mode: $CROP_MODE)..." + convert "$input" -crop "$crop" +repage -quality 90 "$output" +done + +echo "All done."