13 Commits

Author SHA1 Message Date
Teddy-1024
7b9e900cfe Merge pull request #4 from Teddy-1024/root_server
Root server
2025-03-15 17:59:03 +00:00
bade1f11dd Feat: Update CAPTCHA service to ALTCHA self-hosted. 2025-03-15 17:47:36 +00:00
b843849af9 Feat: Replace Google ReCAPTCHA with ALTCHA using API - non-tracking, GDPR compliant without cookies or fingerprinting. 2025-03-13 15:36:41 +00:00
29205de12f Fix(UI): Home page: improved content for all sections, including new CTAs for buttons. 2025-01-25 18:10:17 +00:00
a105372638 Fix(UI): Home page: improved content for all sections, including new CTAs for buttons. 2025-01-25 18:07:27 +00:00
7e8266d735 Fix: Run NPM build. 2025-01-25 16:59:47 +00:00
cc6c3699f6 Fix(UI): Contact form reCAPTCHA style correction for extreme zooming. 2025-01-25 16:55:19 +00:00
d84cc29edb Fix: Re-add requirements.txt after accidental delete in merging. 2025-01-25 16:27:13 +00:00
Teddy-1024
2fcd7f4276 Merge pull request #3 from Teddy-1024/oracle_vps
Feat(Security):  a. Update CORS settings  b. Update session cookie settings  c. Remove comments that expose project architecture (and that Claude made most of it :P) d. Remove server console logging from root server setup.
2025-01-25 16:10:02 +00:00
56ed26b3f0 Merge conflicts. 2025-01-25 16:07:58 +00:00
18a9a65f70 Merge conflicts. 2025-01-25 16:06:55 +00:00
Teddy-1024
d07f409426 Merge conflic: Update app.py 2025-01-25 16:05:07 +00:00
baa158fcd0 Feat(Security):
a. Update CORS settings
 b. Update session cookie settings
 c. Remove comments that expose project architecture (and that Claude made most of it :P)
2025-01-25 14:31:03 +00:00
21 changed files with 6620 additions and 952 deletions

25
app.py
View File

@@ -20,7 +20,7 @@ from config import app_config, Config
from controllers.core import routes_core
from controllers.legal import routes_legal
from controllers.user import routes_user
from extensions import db, csrf, cors, mail, oauth
from extensions import db, csrf, mail, oauth
from helpers.helper_app import Helper_App
# external
from flask import Flask, render_template, jsonify, request, render_template_string, send_from_directory, redirect, url_for, session
@@ -81,12 +81,13 @@ def make_session_permanent():
session.permanent = True
csrf = CSRFProtect()
"""
cors = CORS()
db = SQLAlchemy()
mail = Mail()
oauth = OAuth()
"""
cors = CORS(app, resources={
r"/static/*": {
"origins": [app.config["URL_HOST"]],
"methods": ["GET"],
"max_age": 3600
}
})
csrf.init_app(app)
cors.init_app(app)
@@ -123,3 +124,13 @@ app.register_blueprint(routes_user)
def console_log(value):
Helper_App.console_log(value)
return value
@app.after_request
def add_cache_headers(response):
if request.path.startswith('/static/'):
# Cache static assets
response.headers['Cache-Control'] = 'public, max-age=31536000'
else:
# No caching for dynamic content
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response

View File

@@ -40,8 +40,8 @@ class Config:
# Auth0
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
# SESSION_COOKIE_SAMESITE = 'Lax'
# PERMANENT_SESSION_LIFETIME = 3600
SESSION_COOKIE_SAMESITE = 'Strict'
REMEMBER_COOKIE_SECURE = True
WTF_CSRF_ENABLED = True
# WTF_CSRF_CHECK_DEFAULT = False # We'll check it manually for API routes
# WTF_CSRF_HEADERS = ['X-CSRFToken'] # Accept CSRF token from this header
@@ -52,7 +52,7 @@ class Config:
DOMAIN_AUTH0 = os.getenv('DOMAIN_AUTH0')
ID_TOKEN_USER = 'user'
# PostgreSQL
DB_NAME = os.getenv('partsltd')
DB_NAME = os.getenv('partsltd_prod')
DB_USER = os.getenv('DB_USER')
DB_PASSWORD = os.getenv('DB_PASSWORD')
DB_HOST = os.getenv('DB_HOST')
@@ -80,9 +80,15 @@ class Config:
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
MAIL_CONTACT_PUBLIC = os.getenv('MAIL_CONTACT_PUBLIC')
"""
# Recaptcha
RECAPTCHA_PUBLIC_KEY = os.getenv('RECAPTCHA_PUBLIC_KEY')
RECAPTCHA_PRIVATE_KEY = os.getenv('RECAPTCHA_PRIVATE_KEY')
"""
# ALTCHA
ALTCHA_API_KEY = os.getenv('ALTCHA_API_KEY')
ALTCHA_SECRET_KEY = os.getenv('ALTCHA_SECRET_KEY')
ALTCHA_REGION = 'eu'
class DevelopmentConfig(Config):
is_development = True

View File

@@ -14,18 +14,24 @@ Initializes the Flask application, sets the configuration based on the environme
# internal
from datastores.datastore_base import DataStore_Base
from forms.contact import Form_Contact
from helpers.helper_app import Helper_App
from models.model_view_contact import Model_View_Contact
from models.model_view_home import Model_View_Home
import lib.argument_validation as av
# external
from flask import Flask, render_template, jsonify, request, render_template_string, send_from_directory, redirect, url_for, session, Blueprint, current_app
from flask import Flask, render_template, jsonify, request, render_template_string, send_from_directory, redirect, url_for, session, Blueprint, current_app, flash
from flask_mail import Mail, Message
from extensions import db, oauth, mail
from urllib.parse import quote_plus, urlencode
from authlib.integrations.flask_client import OAuth
from authlib.integrations.base_client import OAuthError
from urllib.parse import quote, urlparse, parse_qs
import json
import base64
import hmac
import hashlib
import datetime
from altcha import ChallengeOptions, create_challenge, verify_solution
routes_core = Blueprint('routes_core', __name__)
@@ -53,10 +59,12 @@ def contact():
def contact_post():
try:
form = Form_Contact()
Helper_App.console_log(f"Form submitted: {request.form}")
Helper_App.console_log(f"ALTCHA data in request: {request.form.get('altcha')}")
if form.validate_on_submit():
# Handle form submission
try:
email = form.email.data
CC = form.CC.data # not in use
# CC = form.CC.data # not in use
contact_name = form.contact_name.data
company_name = form.company_name.data
message = form.message.data
@@ -66,8 +74,85 @@ def contact_post():
mailItem = Message("PARTS Website Contact Us Message", recipients=[current_app.config['MAIL_CONTACT_PUBLIC']])
mailItem.body = f"Dear Lord Edward Middleton-Smith,\n\n{message}\n{receive_marketing_text}\nKind regards,\n{contact_name}\n{company_name}\n{email}"
mail.send(mailItem)
flash('Thank you for your message. We will get back to you soon!', 'success')
return "Submitted."
except Exception as e:
return f"Error: {e}"
print(f"Form validation errors: {form.errors}")
return "Invalid. Failed to submit."
# html_body = render_template('pages/core/_contact.html', model = model)
except Exception as e:
return jsonify(error=str(e)), 403
@routes_core.route(Model_View_Contact.HASH_ALTCHA_CREATE_CHALLENGE, methods=['GET'])
def create_altcha_challenge():
options = ChallengeOptions(
expires = datetime.datetime.now() + datetime.timedelta(hours=1),
max_number = 100000, # The maximum random number
hmac_key = current_app.config["ALTCHA_SECRET_KEY"],
)
challenge = create_challenge(options)
print("Challenge created:", challenge)
# return jsonify({"challenge": challenge})
return jsonify({
"algorithm": challenge.algorithm,
"challenge": challenge.challenge,
"salt": challenge.salt,
"signature": challenge.signature,
})
"""
def verify_altcha_signature(payload):
"" "Verify the ALTCHA signature"" "
if 'algorithm' not in payload or 'signature' not in payload or 'verificationData' not in payload:
return False
algorithm = payload['algorithm']
signature = payload['signature']
verification_data = payload['verificationData']
# Calculate SHA hash of the verification data
if algorithm == 'SHA-256':
hash_func = hashlib.sha256
else:
# Fallback to SHA-256 if algorithm not specified
hash_func = hashlib.sha256
# Calculate the hash of verification_data
data_hash = hash_func(verification_data.encode('utf-8')).digest()
# Calculate the HMAC signature
calculated_signature = hmac.new(
current_app.config["ALTCHA_SECRET_KEY"].encode('utf-8'),
data_hash,
hash_func
).hexdigest()
# Compare the calculated signature with the provided signature
return hmac.compare_digest(calculated_signature, signature)
def create_altcha_dummy_signature(challenge):
# Example payload to verify
payload = {
"algorithm": challenge.algorithm,
"challenge": challenge.challenge,
"number": 12345, # Example number
"salt": challenge.salt,
"signature": challenge.signature,
}
return payload
@routes_core.route(Model_View_Contact.HASH_ALTCHA_VERIFY_SOLUTION, methods=['POST'])
def verify_altcha_challenge():
payload = request.json
ok, err = verify_solution(payload, current_app.config["ALTCHA_SECRET_KEY"], check_expires=True)
if err:
return jsonify({"error": err}), 400
elif ok:
return jsonify({"verified": True})
else:
return jsonify({"verified": False}), 403
"""

View File

@@ -8,7 +8,7 @@ from authlib.integrations.flask_client import OAuth
csrf = CSRFProtect()
cors = CORS()
# cors = CORS()
db = SQLAlchemy()
mail = Mail()
oauth = OAuth()

View File

@@ -14,13 +14,65 @@ Defines Flask-WTF form for handling user input on Contact Us page.
# internal
# from business_objects.store.product_category import Filters_Product_Category # circular
# from models.model_view_store import Model_View_Store # circular
from models.model_view_base import Model_View_Base
from forms.base import Form_Base
# external
from flask import Flask, render_template, request, flash, redirect, url_for, current_app
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField, BooleanField, IntegerField, SelectField, FloatField
from wtforms.validators import InputRequired, NumberRange, Regexp, DataRequired, Optional
from wtforms import StringField, TextAreaField, SubmitField, HiddenField, BooleanField, Field
from wtforms.validators import DataRequired, Email, ValidationError
import markupsafe
from flask_wtf.recaptcha import RecaptchaField
from abc import ABCMeta, abstractmethod
import json
from altcha import verify_solution
import base64
class ALTCHAValidator:
def __init__(self, message=None):
self.message = message or 'ALTCHA verification failed'
def __call__(self, form, field):
altcha_data = field.data
if not altcha_data:
raise ValidationError(self.message)
try:
# The data is base64 encoded JSON
try:
# First try to decode it as JSON directly (if it's not base64 encoded)
altcha_payload = json.loads(altcha_data)
except json.JSONDecodeError:
# If direct JSON decoding fails, try base64 decoding first
decoded_data = base64.b64decode(altcha_data).decode('utf-8')
altcha_payload = json.loads(decoded_data)
ok, err = verify_solution(altcha_payload, current_app.config["ALTCHA_SECRET_KEY"], check_expires=True)
if err or not ok:
raise ValidationError(self.message + ': ' + (err or 'Invalid solution'))
except Exception as e:
raise ValidationError(f'Invalid ALTCHA data: {str(e)}')
class ALTCHAField(Field):
def __init__(self, label='', validators=None, **kwargs):
validators = validators or []
validators.append(ALTCHAValidator())
super(ALTCHAField, self).__init__(label, validators, **kwargs)
def __call__(self, **kwargs):
html = f"""
<altcha-widget
challengeurl="/get-challenge"
auto="onload"
id="{self.id}"
name="{self.name}">
</altcha-widget>
"""
return markupsafe.Markup(html)
class Form_Contact(FlaskForm):
@@ -29,5 +81,7 @@ class Form_Contact(FlaskForm):
company_name = StringField('Company')
message = TextAreaField('Message')
receive_marketing = BooleanField('I would like to receive marketing emails.')
recaptcha = RecaptchaField()
# recaptcha = RecaptchaField()
# altcha = HiddenField('ALTCHA') # , validators=[validate_altcha]
altcha = ALTCHAField('Verification')
submit = SubmitField('Send Message')

View File

@@ -133,6 +133,7 @@ class Model_View_Base(BaseModel, ABC):
FLAG_PAGE_BODY: ClassVar[str] = 'page-body'
FLAG_PHONE_NUMBER: ClassVar[str] = Base.FLAG_PHONE_NUMBER
FLAG_POSTCODE: ClassVar[str] = Base.FLAG_POSTCODE
FLAG_CAPTCHA: ClassVar[str] = 'recaptcha'
FLAG_RIGHT_HAND_SIDE: ClassVar[str] = 'rhs'
FLAG_ROW: ClassVar[str] = 'row'
FLAG_ROW_NEW: ClassVar[str] = 'row-new'
@@ -148,6 +149,8 @@ class Model_View_Base(BaseModel, ABC):
FLAG_USER: ClassVar[str] = User.FLAG_USER
FLAG_WEBSITE: ClassVar[str] = Base.FLAG_WEBSITE
# flagIsDatePicker: ClassVar[str] = 'is-date-picker'
HASH_ALTCHA_CREATE_CHALLENGE: ClassVar[str] = '/altcha/create-challenge'
# HASH_ALTCHA_VERIFY_SOLUTION: ClassVar[str] = '/altcha/verify-solution'
HASH_APPLY_FILTERS_STORE_PRODUCT_PERMUTATION: ClassVar[str] = '/store/permutation_filter'
HASH_CALLBACK_LOGIN: ClassVar[str] = '/callback-login'
HASH_PAGE_ACCESSIBILITY_REPORT: ClassVar[str] = '/accessibility-report'

View File

@@ -22,18 +22,11 @@ from pydantic import BaseModel
from typing import ClassVar
class Model_View_Contact(Model_View_Base):
# Attributes
FLAG_ALTCHA_WIDGET: ClassVar[str] = 'altcha-widget'
FLAG_COMPANY_NAME: ClassVar[str] = 'company_name'
FLAG_CONTACT_NAME: ClassVar[str] = 'contact_name'
FLAG_RECEIVE_MARKETING: ClassVar[str] = 'receive_marketing'
"""
ID_EMAIL: ClassVar[str] = 'email'
ID_COMPANY_NAME: ClassVar[str] = 'company_name'
ID_CONTACT_NAME: ClassVar[str] = 'contact_name'
ID_MESSAGE: ClassVar[str] = 'msg'
ID_RECEIVE_MARKETING: ClassVar[str] = 'receive_marketing'
ID_NAME: ClassVar[str] = 'name'
"""
ID_CONTACT_FORM: ClassVar[str] = 'contact-form'
form_contact: Form_Contact

View File

@@ -14,3 +14,5 @@ authlib
pydantic
# psycopg2
requests
cryptography
altcha

View File

@@ -41,6 +41,10 @@ textarea.form-input {
padding-left: 200px;
}
.container.recaptcha {
margin-left: 15vw;
}
input[type="submit"] {
margin-left: 40%;
padding: 0.75rem 1.5rem;
@@ -88,3 +92,9 @@ input[type="submit"]:hover {
width: 100%;
}
}
@media (max-width: 400px) {
.container.recaptcha {
margin-left: -12vw;
}
}

View File

@@ -1,89 +1,6 @@
/*
h1, h2, h3 {
margin-bottom: 1rem;
align-self: center;
justify-self: center;
margin: 0 auto;
width: fit-content;
}
section {
display: flex;
margin: 0 auto;
width: 100vw;
}
.container {
display: flex;
flex-direction: column;
align-content: center;
}
.container-input {
display: flex;
flex-direction: column;
align-items: center;
}
.container-input label {
width: 100%;
margin: 0 auto;
}
/* Contact Form Section * /
.contact-form {
padding: 8rem 0 4rem;
background: linear-gradient(45deg, #f8fafc, #eff6ff);
}
.contact-form-content {
width: 50vw;
}
.contact-form h1 {
font-size: 3rem;
line-height: 1.2;
margin-bottom: 1.5rem;
color: var(--text);
}
.contact-form p {
font-size: 1.25rem;
margin-bottom: 2rem;
color: var(--subheading);
}
table {
margin: 0 auto;
}
th.lhs,
td.lhs {
min-width: 8vw;
max-width: 8vw;
}
th.rhs,
td.rhs {
min-width: 36vw;
max-width: 36vw;
}
td input,
td textarea {
width: 100%;
}
textarea {
resize: vertical;
}
.container-checkbox.receive_marketing {
margin-left: 22.25vw;
}
*/
.contact-section {
padding: 8rem 2rem 4rem;
padding: 2rem 2rem 4rem;
}
.contact-form {
@@ -124,6 +41,10 @@ textarea.form-input {
padding-left: 200px;
}
.container.recaptcha {
margin-left: 15vw;
}
input[type="submit"] {
margin-left: 40%;
padding: 0.75rem 1.5rem;
@@ -172,4 +93,10 @@ input[type="submit"]:hover {
}
}
@media (max-width: 400px) {
.container.recaptcha {
margin-left: -12vw;
}
}
/*# sourceMappingURL=core_contact.bundle.css.map*/

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,5 @@
import DOM from './dom.js';
// Module for API calls
export default class API {
static getCsrfToken() {
@@ -151,7 +150,6 @@ export default class API {
const api = new API();
export default api;
Example of using the API
document.addEventListener('DOMContentLoaded', () => {
initializeApp();
setupEventListeners();

View File

@@ -1,8 +1,6 @@
// Main entry point for the application
'use strict';
// import API from './api.js';
import DOM from './dom.js';
import Router from './router.js';
@@ -19,34 +17,24 @@ class App {
}
setupEventListeners() {
// Global event listeners
// document.addEventListener('click', this.handleGlobalClick.bind(this));
// Add more global event listeners as needed
}
handleGlobalClick(event) {
// Handle global click events
}
start() {
// Additional startup logic
this.initPageCurrent();
}
initPageCurrent() {
/*
_pageCurrent = Router.getPageCurrent();
_pageCurrent.initialize();
*/
this.router.loadPageCurrent();
}
}
// Application instance
const app = new App();
// DOM ready handler
function domReady(fn) {
if (document.readyState !== 'loading') {
fn();
@@ -55,13 +43,10 @@ function domReady(fn) {
}
}
// Initialize and start the app when DOM is ready
domReady(() => {
app.initialize();
});
// Expose app to window for debugging (optional)
window.app = app;
// Export app if using modules
export default app;

View File

@@ -1,7 +1,6 @@
import Validation from "./lib/validation.js";
// Module for DOM manipulation
export default class DOM {
static setElementAttributesValuesCurrentAndPrevious(element, data) {
DOM.setElementAttributeValueCurrent(element, data);

View File

@@ -1,5 +1,7 @@
// internal
import BasePage from "../base.js";
// vendor
import { Altcha } from "../../vendor/altcha.js";
export default class PageContact extends BasePage {
static hash = hashPageContact;
@@ -10,9 +12,49 @@ export default class PageContact extends BasePage {
initialize() {
this.sharedInitialize();
// this.hookupALTCHAByLocalServer();
this.hookupButtonSubmitFormContactUs();
}
/*
hookupALTCHAByAPI() {
const form = document.querySelector(idContactForm);
const altchaWidget = form.querySelector('altcha-widget');
// Listen for verification events from the ALTCHA widget
if (altchaWidget) {
altchaWidget.addEventListener('serververification', function(event) {
// Create or update the hidden input for ALTCHA
let altchaInput = form.querySelector('input[name="altcha"]');
if (!altchaInput) {
altchaInput = document.createElement('input');
altchaInput.type = 'hidden';
altchaInput.name = 'altcha';
form.appendChild(altchaInput);
}
// Set the verification payload
altchaInput.value = event.detail.payload;
});
}
}
*/
hookupALTCHAByLocalServer() {
window.ALTCHA = { init: (config) => {
document.querySelectorAll(config.selector).forEach(el => {
new Altcha({
target: el,
props: {
challengeurl: config.challenge.url,
auto: 'onload'
}
}).$on('verified', (e) => {
config.challenge.onSuccess(e.detail.payload, el);
});
});
}};
}
hookupButtonSubmitFormContactUs() {
const button = document.querySelector('form input[type="submit"]');
button.classList.add(flagButton);

View File

@@ -2,7 +2,6 @@
// internal
import BasePage from "../base.js";
// external
import AOS from 'aos';
export default class PageHome extends BasePage {
@@ -15,46 +14,10 @@ export default class PageHome extends BasePage {
initialize() {
this.sharedInitialize();
this.hookupButtonsNavContact();
// this.initialiseAOS();
this.initialiseAnimations();
}
/* AOS */
initialiseAOS() {
AOS.init({
duration: 1000,
once: true,
});
}
/* Manual animations *
initialiseAnimations() {
// Check if IntersectionObserver is supported
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('active');
}
});
}, {
threshold: 0.1,
rootMargin: '50px'
});
// Observe all elements with 'reveal' class
document.querySelectorAll('.reveal').forEach((element) => {
observer.observe(element);
});
} else {
// If IntersectionObserver is not supported, make all elements visible
document.querySelectorAll('.reveal').forEach((element) => {
element.style.opacity = 1;
});
}
}
*/
leave() {
super.leave();
}
}

View File

@@ -12,30 +12,11 @@ import PageLicense from './pages/legal/license.js';
// import PageUserLogout from './pages/user/logout.js';
// import PageUserAccount from './pages/user/account.js';
import API from './api.js';
import DOM from './dom.js';
import PagePrivacyPolicy from './pages/legal/privacy_policy.js';
import PageRetentionSchedule from './pages/legal/retention_schedule.js';
// Create a context for the pages
// const pagesContext = require.context('./pages', true, /\.js$/);
/*
const pageModules = {
// Core
[hashPageHome]: () => import('./pages/core/home.js'),
[hashPageContact]: () => import('./pages/core/contact.js'),
[hashPageServices]: () => import('./pages/core/services.js'),
[hashPageAdminHome]: () => import('./pages/core/admin_home.js'),
// Legal
[hashPageAccessibilityStatement]: () => import('./pages/legal/accessibility_statement.js'),
[hashPageLicense]: () => import('./pages/legal/license.js'),
// User
// Add other pages here...
};
*/
export default class Router {
constructor() {
@@ -146,11 +127,8 @@ export default class Router {
}
}
// Create and export a singleton instance
export const router = new Router();
// import this for navigation
// Usage example (you can put this in your main.js or app.js)
/*
router.addRoute('/', () => {
console.log('Home page');
@@ -162,7 +140,6 @@ router.addRoute('/about', () => {
// Load about page content
});
// Example of how to use the router in other parts of your application
export function setupNavigationEvents() {
document.querySelectorAll('a[data-nav]').forEach(link => {
link.addEventListener('click', (e) => {

2636
static/js/vendor/altcha.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -117,6 +117,7 @@
var flagPageBody = "{{ model.FLAG_PAGE_BODY }}";
var flagPhoneNumber = "{{ model.FLAG_PHONE_NUMBER }}";
var flagPostcode = "{{ model.FLAG_POSTCODE }}";
var flagCaptcha = "{{ model.FLAG_CAPTCHA }}";
var flagRightHandSide = "{{ model.FLAG_RIGHT_HAND_SIDE }}";
var flagRow = "{{ model.FLAG_ROW }}";
var flagRowNew = "{{ model.FLAG_ROW_NEW }}";
@@ -131,6 +132,7 @@
var flagTemporaryElement = "{{ model.FLAG_TEMPORARY_ELEMENT }}";
var flagUser = "{{ model.FLAG_USER }}";
var flagWebsite = "{{ model.FLAG_WEBSITE }}";
var hashALTCHACreateChallenge = "{{ model.HASH_ALTCHA_CREATE_CHALLENGE }}";
var hashApplyFiltersStoreProductPermutation = "{{ model.HASH_APPLY_FILTERS_STORE_PRODUCT_PERMUTATION }}";
var hashPageAccessibilityReport = "{{ model.HASH_PAGE_ACCESSIBILITY_REPORT }}";
var hashPageAccessibilityStatement = "{{ model.HASH_PAGE_ACCESSIBILITY_STATEMENT }}";

View File

@@ -2,6 +2,25 @@
{% block page_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='dist/css/core_contact.bundle.css') }}">
{#
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/@altcha/browser@latest/dist/index.js" defer></script>
#}
{# with CDN
<script src="https://cdn.jsdelivr.net/npm/@altcha/browser@1.1.0/dist/altcha.min.js"></script>
<style>
.altcha-widget {
margin: 15px 0;
}
</style>
#}
{# with locally stored vendor project - this is imported into contact.js
<script type="module" src="{{ url_for('static', filename='js/vendor/altcha.js')}}"></script>
#}
<style>
.altcha-widget {
margin: 15px 0;
}
</style>
{% endblock %}
{% block page_nav_links %}
@@ -12,6 +31,7 @@
{% endblock %}
{% block page_body %}
{#
<script>
function loadRecaptcha() {
var script = document.createElement('script');
@@ -22,15 +42,25 @@
window.addEventListener('load', loadRecaptcha);
</script>
#}
<!-- Divs -->
{% set form = model.form_contact %}
<section class="contact-section">
<div class="contact-form">
<h1>Contact Us</h1>
<p>Please fill in the form below and we'll get back to you as soon as possible.</p>
<form id="contact-form" method="POST" action="{{ url_for('routes_core.contact') }}">
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul class="flashes">
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
<form id="{{ model.ID_CONTACT_FORM }}" method="POST" action="{{ url_for('routes_core.contact') }}">
{{ form.csrf_token }}
<div class="form-grid">
@@ -59,8 +89,28 @@
{{ model.form_contact.receive_marketing() }}
{{ model.form_contact.receive_marketing.label }}
</div>
<div class="{{ model.FLAG_CONTAINER }}">
{{ model.form_contact.recaptcha() }}
<div class="{{ model.FLAG_CONTAINER }} {{ model.FLAG_CAPTCHA }}">
{# {{ model.form_contact.recaptcha() }} #}
{#
<altcha-widget
challengeurl="https://eu.altcha.org/api/v1/challenge?apiKey={{ model.app.app_config.ALTCHA_API_KEY }}"
spamfilter
></altcha-widget>
#}
<div>
{{ form.altcha.label }}
{#
{{ form.altcha }}
{{ form.altcha.hidden() }}
#}
<altcha-widget
class="altcha-widget"
challengeurl="{{ url_for('routes_core.create_altcha_challenge') }}"
auto="onload"
id="{{ form.altcha.id }}"
name="{{ form.altcha.name }}"
></altcha-widget>
</div>
</div>
<div class="{{ model.FLAG_CONTAINER_INPUT }}">
{{ model.form_contact.submit() }}
@@ -100,7 +150,46 @@
</section>
#}
{# with CDN
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize ALTCHA widget
ALTCHA.init({
selector: '.altcha-widget',
challenge: {
url: '/get-challenge',
onSuccess: function(result, element) {
// Store the result in the hidden input field
const hiddenInput = element.parentNode.querySelector('input[type="hidden"]');
hiddenInput.value = JSON.stringify(result);
}
}
});
});
</script>
#}
{# with locally stored vendor project - this is now in contact.js
<script type="module">
import { Altcha } from "{{ url_for('static', filename='js/vendor/altcha.js') }}";
window.ALTCHA = { init: (config) => {
document.querySelectorAll(config.selector).forEach(el => {
new Altcha({
target: el,
props: {
challengeurl: config.challenge.url,
auto: 'onload'
}
}).$on('verified', (e) => {
config.challenge.onSuccess(e.detail.payload, el);
});
});
}};
</script>
#}
<script>
var flagALTCHAWidget = "{{ model.FLAG_ALTCHA_WIDGET }}";
var idContactForm = "#{{ model.ID_CONTACT_FORM }}";
var idEmail = "#{{ model.ID_EMAIL }}";
var idMessage = "#{{ model.ID_MESSAGE }}";
var idContactName = "#{{ model.ID_CONTACT_NAME }}";

View File

@@ -19,8 +19,8 @@
<div class="container">
<div class="hero-content" data-aos="fade-up">
<h1>Transform Your Business with Modern ERP Solutions</h1>
<p>UK-based ERPNext implementation experts helping SMBs streamline operations with customized, cost-effective solutions.</p>
<a href="{{ model.HASH_PAGE_CONTACT }}" class="{{ model.FLAG_BUTTON }} {{ model.FLAG_BUTTON_PRIMARY }}">Get Started</a>
<p>UK-based ERPNext specialist providing integrated ERP and e-commerce solutions. 5+ years experience implementing systems for builders merchants and automotive companies.</p>
<a href="{{ model.HASH_PAGE_CONTACT }}" class="{{ model.FLAG_BUTTON }} {{ model.FLAG_BUTTON_PRIMARY }}">Book Consultation</a>
</div>
</div>
</section>
@@ -71,30 +71,29 @@
<section id="pricing" class="pricing">
<div class="container">
<h2 class="section-title text-center">Simple, Transparent Pricing</h2>
<p class="section-subtitle text-center">Everything you need to run your business efficiently</p>
<p class="section-subtitle text-center">Enterprise-grade solutions at SMB-friendly prices</p>
<div class="pricing-card" data-aos="fade-up">
<h3>Implementation</h3>
<div class="price">From £10,000</div>
<p>One-time implementation fee</p>
<p>One-time setup fee based on business size</p>
<ul style="list-style: none; margin: 2rem 0;">
<li>✓ Full system setup</li>
<li>✓ Full ERP & e-commerce setup</li>
<li>✓ Product catalogue configuration</li>
<li>✓ Staff training</li>
<li>✓ Data migration</li>
<li>✓ User training</li>
<li>✓ Custom configurations</li>
</ul>
<h3>Monthly Support & Hosting</h3>
<div class="price">£200</div>
<p>Fixed monthly fee</p>
<p>12-month minimum term, billed annually at £2,400</p>
<ul style="list-style: none; margin: 2rem 0;">
<li>✓ Unlimited support tickets</li>
<li>✓ Cloud hosting</li>
<li>✓ Regular maintenance</li>
<li>✓ System updates</li>
<li>✓ Cloud hosting (99.9% uptime)</li>
<li>✓ Regular maintenance and system updates</li>
</ul>
<a href="{{ model.HASH_PAGE_CONTACT }}" class="{{ model.FLAG_BUTTON }} {{ model.FLAG_BUTTON_PRIMARY }}">Get Started</a>
<a href="{{ model.HASH_PAGE_CONTACT }}" class="{{ model.FLAG_BUTTON }} {{ model.FLAG_BUTTON_PRIMARY }}">Get Custom Quote</a>
</div>
</div>
</section>
@@ -103,7 +102,7 @@
<div class="container">
<h2 class="section-title">Ready to Transform Your Business?</h2>
<p class="section-subtitle">Contact us today to discuss your ERP needs</p>
<a href="{{ model.HASH_PAGE_CONTACT }}" class="{{ model.FLAG_BUTTON }} {{ model.FLAG_BUTTON_LIGHT }}">Contact Us</a>
<a href="{{ model.HASH_PAGE_CONTACT }}" class="{{ model.FLAG_BUTTON }} {{ model.FLAG_BUTTON_LIGHT }}">Book Consultation</a>
</div>
</section>