@@ -19,10 +19,12 @@ app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
from blueprints.auth import auth_bp
|
||||
from blueprints.users import users_bp
|
||||
from blueprints.products import products_bp
|
||||
from blueprints.canvas import canvas_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(products_bp)
|
||||
app.register_blueprint(canvas_bp)
|
||||
|
||||
# Comment Change
|
||||
# Home route
|
||||
@@ -44,6 +46,7 @@ def home():
|
||||
<p>Current Location: {session.get('currentLocation', 'Not selected')}</p>
|
||||
<ul>
|
||||
<li><a href="{url_for('products.product_finder')}">🔍 Product Finder Quiz</a></li>
|
||||
<li><a href="{url_for('products.product_manager')}">📦 Manage Products</a></li>
|
||||
<li><a href="{url_for('users.user_manager')}">👥 Manage Users</a></li>
|
||||
<li><a href="{url_for('auth.select_location_page')}">📍 Change Location</a></li>
|
||||
<li><a href="{url_for('auth.logout')}">🚪 Logout</a></li>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Canvas Image API - Hierarchical Image Fallback System
|
||||
|
||||
This module provides intelligent image serving with fallback logic:
|
||||
1. Product-specific images
|
||||
2. Subtype-level fallbacks
|
||||
3. Type-level fallbacks
|
||||
4. Global fallbacks
|
||||
|
||||
URL Pattern: /api/canvas/<product_code>/<layer>?color=<color>&material=<material>
|
||||
"""
|
||||
|
||||
from flask import Blueprint, send_file, request, jsonify, current_app
|
||||
import os
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
canvas_bp = Blueprint('canvas', __name__)
|
||||
|
||||
# Load product data to get type/subtype information
|
||||
def get_product_data():
|
||||
"""Load product data from JSON file"""
|
||||
data_path = os.path.join(current_app.root_path, 'data', 'products.json')
|
||||
try:
|
||||
with open(data_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error loading product data: {e}")
|
||||
return []
|
||||
|
||||
def get_product_info(product_code):
|
||||
"""Get product type, subtype, and other metadata"""
|
||||
products = get_product_data()
|
||||
for product in products:
|
||||
if product.get('productCode') == product_code or product.get('id') == product_code:
|
||||
base_type = product.get('baseType', '').lower()
|
||||
sub_type = None
|
||||
|
||||
# Extract subtype
|
||||
subtype_obj = product.get('subType', {})
|
||||
if isinstance(subtype_obj, dict):
|
||||
if subtype_obj.get('door'):
|
||||
sub_type = subtype_obj['door'].lower().replace(' ', '-')
|
||||
elif subtype_obj.get('window'):
|
||||
sub_type = subtype_obj['window'].lower().replace(' ', '-')
|
||||
|
||||
return {
|
||||
'code': product_code,
|
||||
'type': base_type,
|
||||
'subtype': sub_type,
|
||||
'colors': product.get('colors', []),
|
||||
'materials': product.get('materials', [])
|
||||
}
|
||||
return None
|
||||
|
||||
def find_image_with_fallback(product_code, layer, color=None, material=None):
|
||||
"""
|
||||
Find image using hierarchical fallback system.
|
||||
|
||||
Priority order:
|
||||
1. /images/<type>/<subtype>/<code>/<layer>-<color>.<ext>
|
||||
2. /images/<type>/<subtype>/<code>/<layer>.<ext>
|
||||
3. /images/<type>/<subtype>/layers/<layer>-<color>.<ext>
|
||||
4. /images/<type>/<subtype>/layers/<layer>.<ext>
|
||||
5. /images/<type>/layers/<layer>.<ext>
|
||||
6. /images/layers/<layer>.<ext>
|
||||
|
||||
Returns: (file_path, mime_type) or (None, None)
|
||||
"""
|
||||
product_info = get_product_info(product_code)
|
||||
if not product_info:
|
||||
current_app.logger.warning(f"Product not found: {product_code}")
|
||||
return None, None
|
||||
|
||||
images_dir = os.path.join(current_app.root_path, 'static', 'images')
|
||||
base_type = product_info['type']
|
||||
sub_type = product_info['subtype']
|
||||
|
||||
# Supported extensions
|
||||
extensions = ['png', 'jpg', 'jpeg', 'webp']
|
||||
|
||||
# Build search paths in priority order
|
||||
search_paths = []
|
||||
|
||||
# 1. Product-specific with color
|
||||
if color and base_type and sub_type:
|
||||
for ext in extensions:
|
||||
search_paths.append(os.path.join(images_dir, base_type, sub_type, product_code, f"{layer}-{color.lower()}.{ext}"))
|
||||
|
||||
# 2. Product-specific without color
|
||||
if base_type and sub_type:
|
||||
for ext in extensions:
|
||||
search_paths.append(os.path.join(images_dir, base_type, sub_type, product_code, f"{layer}.{ext}"))
|
||||
|
||||
# 3. Subtype fallback with color
|
||||
if color and base_type and sub_type:
|
||||
for ext in extensions:
|
||||
search_paths.append(os.path.join(images_dir, base_type, sub_type, 'layers', f"{layer}-{color.lower()}.{ext}"))
|
||||
|
||||
# 4. Subtype fallback without color
|
||||
if base_type and sub_type:
|
||||
for ext in extensions:
|
||||
search_paths.append(os.path.join(images_dir, base_type, sub_type, 'layers', f"{layer}.{ext}"))
|
||||
|
||||
# 5. Type fallback
|
||||
if base_type:
|
||||
for ext in extensions:
|
||||
search_paths.append(os.path.join(images_dir, base_type, 'layers', f"{layer}.{ext}"))
|
||||
|
||||
# 6. Global fallback
|
||||
for ext in extensions:
|
||||
search_paths.append(os.path.join(images_dir, 'layers', f"{layer}.{ext}"))
|
||||
|
||||
# Search for first existing file
|
||||
for path in search_paths:
|
||||
if os.path.exists(path):
|
||||
# Determine MIME type
|
||||
ext = path.split('.')[-1].lower()
|
||||
mime_types = {
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'webp': 'image/webp'
|
||||
}
|
||||
mime_type = mime_types.get(ext, 'image/png')
|
||||
|
||||
current_app.logger.info(f"Found image: {path}")
|
||||
return path, mime_type
|
||||
|
||||
current_app.logger.warning(f"No image found for {product_code}/{layer} (color: {color})")
|
||||
return None, None
|
||||
|
||||
@canvas_bp.route('/api/canvas/<product_code>/<layer>')
|
||||
def get_layer_image(product_code, layer):
|
||||
"""
|
||||
Serve a specific layer image for a product with hierarchical fallback.
|
||||
|
||||
Query parameters:
|
||||
- color: Optional color variant
|
||||
- material: Optional material (for future use)
|
||||
|
||||
Returns: Image file or 404
|
||||
"""
|
||||
color = request.args.get('color')
|
||||
material = request.args.get('material')
|
||||
|
||||
# Validate layer name (prevent directory traversal)
|
||||
valid_layers = ['base', 'door', 'hardware', 'overlay', 'foreground']
|
||||
if layer not in valid_layers:
|
||||
return jsonify({'error': 'Invalid layer name'}), 400
|
||||
|
||||
# Find image with fallback
|
||||
image_path, mime_type = find_image_with_fallback(product_code, layer, color, material)
|
||||
|
||||
if image_path:
|
||||
return send_file(image_path, mimetype=mime_type)
|
||||
else:
|
||||
# Return placeholder or 404
|
||||
return jsonify({
|
||||
'error': 'Image not found',
|
||||
'product': product_code,
|
||||
'layer': layer,
|
||||
'color': color
|
||||
}), 404
|
||||
|
||||
@canvas_bp.route('/api/canvas/<product_code>/info')
|
||||
def get_canvas_info(product_code):
|
||||
"""
|
||||
Get information about available layers for a product.
|
||||
Useful for frontend to know which layers exist.
|
||||
"""
|
||||
product_info = get_product_info(product_code)
|
||||
|
||||
if not product_info:
|
||||
return jsonify({'error': 'Product not found'}), 404
|
||||
|
||||
# Check which layers are available
|
||||
available_layers = {}
|
||||
layers_to_check = ['base', 'door', 'hardware', 'overlay', 'foreground']
|
||||
|
||||
for layer in layers_to_check:
|
||||
# Check if layer exists (with or without color)
|
||||
path, _ = find_image_with_fallback(product_code, layer)
|
||||
if path:
|
||||
available_layers[layer] = True
|
||||
|
||||
# For door layer, check which colors are available
|
||||
if layer == 'door' and product_info['colors']:
|
||||
available_colors = []
|
||||
for color in product_info['colors']:
|
||||
color_path, _ = find_image_with_fallback(product_code, layer, color)
|
||||
if color_path:
|
||||
available_colors.append(color)
|
||||
available_layers['door_colors'] = available_colors
|
||||
|
||||
return jsonify({
|
||||
'product': product_code,
|
||||
'type': product_info['type'],
|
||||
'subtype': product_info['subtype'],
|
||||
'availableLayers': available_layers,
|
||||
'colors': product_info['colors'],
|
||||
'materials': product_info['materials']
|
||||
})
|
||||
|
||||
@canvas_bp.route('/api/canvas/test')
|
||||
def test_canvas():
|
||||
"""Test endpoint to verify canvas API is working"""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'message': 'Canvas API is running',
|
||||
'endpoints': {
|
||||
'get_layer': '/api/canvas/<product_code>/<layer>?color=<color>',
|
||||
'get_info': '/api/canvas/<product_code>/info',
|
||||
'test': '/api/canvas/test'
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
"""
|
||||
Product Finder Blueprint
|
||||
Handles the product quiz and recommendations
|
||||
"""
|
||||
from flask import Blueprint, render_template, send_from_directory
|
||||
from blueprints.auth import login_required
|
||||
from flask import Blueprint, render_template, send_from_directory, session, jsonify, redirect, url_for
|
||||
from blueprints.auth import login_required, get_current_user, can_user
|
||||
import os
|
||||
|
||||
products_bp = Blueprint('products', __name__, url_prefix='/quiz')
|
||||
@@ -29,6 +29,31 @@ def advanced_search():
|
||||
"""Serve the advanced search page"""
|
||||
return render_template('advanced_search.html')
|
||||
|
||||
@products_bp.route('/manage')
|
||||
@login_required
|
||||
def product_manager():
|
||||
"""Serve the product management page"""
|
||||
# Check if user has permission to manage products
|
||||
if not can_user('manage_products'):
|
||||
return redirect(url_for('products.product_finder'))
|
||||
return render_template('product_manager.html')
|
||||
|
||||
@products_bp.route('/api/user-session')
|
||||
@login_required
|
||||
def get_user_session():
|
||||
"""Return current user's session data including accessible locations"""
|
||||
user = get_current_user()
|
||||
permissions = user.get('permissions', {}) if user else {}
|
||||
|
||||
return jsonify({
|
||||
'username': session.get('username'),
|
||||
'userId': session.get('user_id'),
|
||||
'currentLocation': session.get('currentLocation'),
|
||||
'accessibleLocations': session.get('accessibleLocations', []),
|
||||
'role': session.get('role', 'user'),
|
||||
'permissions': permissions
|
||||
})
|
||||
|
||||
@products_bp.route('/css/<path:filename>')
|
||||
def serve_css(filename):
|
||||
"""Serve CSS files"""
|
||||
@@ -48,3 +73,12 @@ def serve_images(filename):
|
||||
def serve_data(filename):
|
||||
"""Serve data files (JSON)"""
|
||||
return send_from_directory(os.path.join(basedir, 'data'), filename)
|
||||
|
||||
@products_bp.route('/product/<product_code>')
|
||||
@login_required
|
||||
def product_detail(product_code):
|
||||
"""
|
||||
Serve product detail page with URL-based navigation
|
||||
This allows direct linking to products via /quiz/product/404
|
||||
"""
|
||||
return render_template('product_finder.html', initial_product=product_code)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Create simple test placeholder images for Canvas API testing
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
def create_placeholder_image(path, text, color, size=(800, 600)):
|
||||
"""Create a simple colored placeholder image with text"""
|
||||
# Create image with solid color
|
||||
img = Image.new('RGB', size, color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Add text
|
||||
try:
|
||||
# Try to use a default font
|
||||
font = ImageFont.truetype("arial.ttf", 48)
|
||||
except:
|
||||
# Fallback to default font
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Get text bounding box for centering
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
position = ((size[0] - text_width) // 2, (size[1] - text_height) // 2)
|
||||
|
||||
# Draw text with outline for visibility
|
||||
outline_color = 'white' if sum(color) < 400 else 'black'
|
||||
for offset_x in [-2, 0, 2]:
|
||||
for offset_y in [-2, 0, 2]:
|
||||
draw.text((position[0] + offset_x, position[1] + offset_y),
|
||||
text, font=font, fill=outline_color)
|
||||
|
||||
text_color = 'black' if sum(color) > 400 else 'white'
|
||||
draw.text(position, text, font=font, fill=text_color)
|
||||
|
||||
# Save image
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
img.save(path)
|
||||
print(f"✓ Created: {path}")
|
||||
|
||||
# Base directory
|
||||
base_dir = 'static/images'
|
||||
|
||||
# Create product-specific images for 404
|
||||
create_placeholder_image(
|
||||
f'{base_dir}/window/storm-window/404/door-white.png',
|
||||
'404\nDoor - White',
|
||||
(240, 240, 240) # Light gray
|
||||
)
|
||||
|
||||
create_placeholder_image(
|
||||
f'{base_dir}/window/storm-window/404/door-black.png',
|
||||
'404\nDoor - Black',
|
||||
(40, 40, 40) # Dark gray
|
||||
)
|
||||
|
||||
# Create shared images for storm windows
|
||||
create_placeholder_image(
|
||||
f'{base_dir}/window/storm-window/layers/base.jpg',
|
||||
'Storm Window\nBase Layer\n(Shared)',
|
||||
(200, 220, 240) # Light blue
|
||||
)
|
||||
|
||||
create_placeholder_image(
|
||||
f'{base_dir}/window/storm-window/layers/hardware.png',
|
||||
'Storm Window\nHardware\n(Shared)',
|
||||
(180, 180, 180) # Silver
|
||||
)
|
||||
|
||||
create_placeholder_image(
|
||||
f'{base_dir}/window/storm-window/layers/foreground.png',
|
||||
'Storm Window\nForeground\n(Shared Plants)',
|
||||
(100, 180, 100) # Green
|
||||
)
|
||||
|
||||
# Create global fallback images
|
||||
create_placeholder_image(
|
||||
f'{base_dir}/layers/base.jpg',
|
||||
'Global\nBase Layer\n(Fallback)',
|
||||
(220, 200, 180) # Beige
|
||||
)
|
||||
|
||||
print("\n✅ All test images created successfully!")
|
||||
print("\nNow test the Canvas API:")
|
||||
print(" http://localhost:8080/api/canvas/404/base")
|
||||
print(" http://localhost:8080/api/canvas/404/door?color=white")
|
||||
print(" http://localhost:8080/api/canvas/404/hardware")
|
||||
print(" http://localhost:8080/api/canvas/404/foreground")
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Create visual test images for the Canvas API layered system.
|
||||
Demonstrates base layer, door layer (with colors), hardware layer, and foreground layer.
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
# Canvas dimensions - all layers use the same size
|
||||
CANVAS_WIDTH = 800
|
||||
CANVAS_HEIGHT = 600
|
||||
|
||||
# Output directory
|
||||
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'static', 'images')
|
||||
|
||||
def create_base_layer():
|
||||
"""Create base layer - a simple house background"""
|
||||
img = Image.new('RGB', (CANVAS_WIDTH, CANVAS_HEIGHT), color='#87CEEB') # Sky blue
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw grass at bottom
|
||||
draw.rectangle([(0, 450), (CANVAS_WIDTH, CANVAS_HEIGHT)], fill='#90EE90') # Light green
|
||||
|
||||
# Draw house body (tan/beige)
|
||||
house_left = 150
|
||||
house_right = 650
|
||||
house_top = 200
|
||||
house_bottom = 500
|
||||
draw.rectangle([(house_left, house_top), (house_right, house_bottom)], fill='#D2B48C') # Tan
|
||||
|
||||
# Draw roof (dark brown triangle)
|
||||
roof_points = [
|
||||
(400, 100), # Top point
|
||||
(house_left - 30, house_top), # Left bottom
|
||||
(house_right + 30, house_top) # Right bottom
|
||||
]
|
||||
draw.polygon(roof_points, fill='#654321') # Dark brown
|
||||
|
||||
# Draw window openings (lighter rectangles where door/window will go)
|
||||
# Door opening in center
|
||||
door_left = 325
|
||||
door_right = 475
|
||||
door_top = 280
|
||||
door_bottom = 480
|
||||
draw.rectangle([(door_left, door_top), (door_right, door_bottom)], fill='#C9B896') # Lighter tan
|
||||
|
||||
# Add some house details (window on left side)
|
||||
draw.rectangle([(200, 280), (280, 360)], fill='#87CEEB') # Window
|
||||
draw.rectangle([(238, 280), (242, 360)], fill='#654321') # Window divider vertical
|
||||
draw.rectangle([(200, 318), (280, 322)], fill='#654321') # Window divider horizontal
|
||||
|
||||
# Window on right side
|
||||
draw.rectangle([(520, 280), (600, 360)], fill='#87CEEB') # Window
|
||||
draw.rectangle([(558, 280), (562, 360)], fill='#654321') # Window divider vertical
|
||||
draw.rectangle([(520, 318), (600, 322)], fill='#654321') # Window divider horizontal
|
||||
|
||||
return img
|
||||
|
||||
def create_door_layer(color_name, color_hex):
|
||||
"""Create door layer with transparency - colored rectangle positioned in door opening"""
|
||||
img = Image.new('RGBA', (CANVAS_WIDTH, CANVAS_HEIGHT), color=(0, 0, 0, 0)) # Transparent
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Door dimensions - positioned where the door opening is on the house
|
||||
door_left = 330
|
||||
door_right = 470
|
||||
door_top = 285
|
||||
door_bottom = 475
|
||||
|
||||
# Draw door
|
||||
draw.rectangle([(door_left, door_top), (door_right, door_bottom)], fill=color_hex)
|
||||
|
||||
# Draw door panels (decorative)
|
||||
panel_margin = 15
|
||||
panel_gap = 10
|
||||
|
||||
# Top panel
|
||||
draw.rectangle([
|
||||
(door_left + panel_margin, door_top + panel_margin),
|
||||
(door_right - panel_margin, door_top + panel_margin + 80)
|
||||
], outline='#000000', width=3)
|
||||
|
||||
# Bottom panel
|
||||
draw.rectangle([
|
||||
(door_left + panel_margin, door_top + panel_margin + 80 + panel_gap),
|
||||
(door_right - panel_margin, door_bottom - panel_margin)
|
||||
], outline='#000000', width=3)
|
||||
|
||||
# Door handle position marker (small circle to show where handle will go)
|
||||
# On the right side for left-hinge door
|
||||
handle_x = door_right - 40
|
||||
handle_y = door_top + (door_bottom - door_top) // 2
|
||||
draw.ellipse([
|
||||
(handle_x - 8, handle_y - 8),
|
||||
(handle_x + 8, handle_y + 8)
|
||||
], fill='#000000')
|
||||
|
||||
return img
|
||||
|
||||
def create_hardware_layer():
|
||||
"""Create hardware layer with transparency - gold circle for door handle"""
|
||||
img = Image.new('RGBA', (CANVAS_WIDTH, CANVAS_HEIGHT), color=(0, 0, 0, 0)) # Transparent
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Door handle position - matches the marker on the door
|
||||
# Right side for left-hinge door
|
||||
handle_x = 430 # door_right (470) - 40
|
||||
handle_y = 380 # middle of door
|
||||
|
||||
# Draw decorative backplate (bronze/gold rectangle)
|
||||
plate_width = 30
|
||||
plate_height = 80
|
||||
draw.rectangle([
|
||||
(handle_x - plate_width//2, handle_y - plate_height//2),
|
||||
(handle_x + plate_width//2, handle_y + plate_height//2)
|
||||
], fill='#B8860B') # Dark goldenrod
|
||||
|
||||
# Draw handle (gold circle)
|
||||
handle_radius = 20
|
||||
draw.ellipse([
|
||||
(handle_x - handle_radius, handle_y - handle_radius),
|
||||
(handle_x + handle_radius, handle_y + handle_radius)
|
||||
], fill='#FFD700') # Gold
|
||||
|
||||
# Add shine/highlight
|
||||
highlight_offset = 6
|
||||
draw.ellipse([
|
||||
(handle_x - highlight_offset, handle_y - highlight_offset),
|
||||
(handle_x - highlight_offset + 8, handle_y - highlight_offset + 8)
|
||||
], fill='#FFFFE0') # Light yellow highlight
|
||||
|
||||
return img
|
||||
|
||||
def create_foreground_layer():
|
||||
"""Create foreground layer with transparency - plants at bottom"""
|
||||
img = Image.new('RGBA', (CANVAS_WIDTH, CANVAS_HEIGHT), color=(0, 0, 0, 0)) # Transparent
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw plants/bushes at the bottom corners
|
||||
# Left plant
|
||||
left_plant_x = 120
|
||||
plant_y = 480
|
||||
for i in range(3):
|
||||
offset_x = i * 25
|
||||
offset_y = i * 10
|
||||
# Draw leaves (green circles)
|
||||
draw.ellipse([
|
||||
(left_plant_x + offset_x - 30, plant_y + offset_y - 30),
|
||||
(left_plant_x + offset_x + 30, plant_y + offset_y + 30)
|
||||
], fill='#228B22') # Forest green
|
||||
|
||||
# Right plant
|
||||
right_plant_x = 680
|
||||
for i in range(3):
|
||||
offset_x = -i * 25
|
||||
offset_y = i * 10
|
||||
# Draw leaves (green circles)
|
||||
draw.ellipse([
|
||||
(right_plant_x + offset_x - 30, plant_y + offset_y - 30),
|
||||
(right_plant_x + offset_x + 30, plant_y + offset_y + 30)
|
||||
], fill='#228B22') # Forest green
|
||||
|
||||
# Add some decorative flowers
|
||||
flower_positions = [
|
||||
(100, 520), (140, 510), (160, 530), # Left side
|
||||
(640, 530), (660, 510), (700, 520) # Right side
|
||||
]
|
||||
|
||||
for fx, fy in flower_positions:
|
||||
# Flower petals (pink circles)
|
||||
petal_radius = 8
|
||||
for angle in range(0, 360, 72): # 5 petals
|
||||
import math
|
||||
px = fx + int(12 * math.cos(math.radians(angle)))
|
||||
py = fy + int(12 * math.sin(math.radians(angle)))
|
||||
draw.ellipse([
|
||||
(px - petal_radius, py - petal_radius),
|
||||
(px + petal_radius, py + petal_radius)
|
||||
], fill='#FFB6C1') # Light pink
|
||||
|
||||
# Flower center (yellow circle)
|
||||
center_radius = 6
|
||||
draw.ellipse([
|
||||
(fx - center_radius, fy - center_radius),
|
||||
(fx + center_radius, fy + center_radius)
|
||||
], fill='#FFD700') # Gold
|
||||
|
||||
return img
|
||||
|
||||
def main():
|
||||
print("Creating visual test images for Canvas API...")
|
||||
|
||||
# Define paths
|
||||
base_path = os.path.join(OUTPUT_DIR, 'window', 'storm-window', '404')
|
||||
layers_path = os.path.join(OUTPUT_DIR, 'window', 'storm-window', 'layers')
|
||||
|
||||
# Create directories
|
||||
os.makedirs(base_path, exist_ok=True)
|
||||
os.makedirs(layers_path, exist_ok=True)
|
||||
|
||||
# Create base layer
|
||||
print("Creating base layer (house)...")
|
||||
base_img = create_base_layer()
|
||||
base_img.save(os.path.join(layers_path, 'base.jpg'), quality=90)
|
||||
print(f" ✓ Saved: {layers_path}/base.jpg")
|
||||
|
||||
# Create door layers in various colors
|
||||
door_colors = {
|
||||
'white': '#FFFFFF',
|
||||
'black': '#2C2C2C',
|
||||
'bronze': '#8B6914',
|
||||
'tan': '#D2B48C'
|
||||
}
|
||||
|
||||
print("\nCreating door layers...")
|
||||
for color_name, color_hex in door_colors.items():
|
||||
door_img = create_door_layer(color_name, color_hex)
|
||||
door_img.save(os.path.join(base_path, f'door-{color_name}.png'))
|
||||
print(f" ✓ Saved: {base_path}/door-{color_name}.png")
|
||||
|
||||
# Create hardware layer
|
||||
print("\nCreating hardware layer (gold handle)...")
|
||||
hardware_img = create_hardware_layer()
|
||||
hardware_img.save(os.path.join(layers_path, 'hardware.png'))
|
||||
print(f" ✓ Saved: {layers_path}/hardware.png")
|
||||
|
||||
# Create foreground layer
|
||||
print("\nCreating foreground layer (plants)...")
|
||||
foreground_img = create_foreground_layer()
|
||||
foreground_img.save(os.path.join(layers_path, 'foreground.png'))
|
||||
print(f" ✓ Saved: {layers_path}/foreground.png")
|
||||
|
||||
print("\n✅ All images created successfully!")
|
||||
print("\nTest URLs:")
|
||||
print(" Left-hinge: http://localhost:8080/quiz/product/404")
|
||||
print(" Right-hinge: http://localhost:8080/quiz/product/404R")
|
||||
print("\nThe right-hinge version will flip the door and hardware layers horizontally.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"locations": [
|
||||
{
|
||||
"code": "IOLA",
|
||||
"name": "Iola"
|
||||
},
|
||||
{
|
||||
"code": "LINDS",
|
||||
"name": "Lindsborg"
|
||||
},
|
||||
{
|
||||
"code": "KC",
|
||||
"name": "Kansas City"
|
||||
},
|
||||
{
|
||||
"code": "BMD",
|
||||
"name": "BMD"
|
||||
}
|
||||
],
|
||||
"statuses": [
|
||||
{
|
||||
"value": "default",
|
||||
"label": "Default"
|
||||
},
|
||||
{
|
||||
"value": "discontinued",
|
||||
"label": "Discontinued"
|
||||
},
|
||||
{
|
||||
"value": "hidden",
|
||||
"label": "Hide"
|
||||
}
|
||||
],
|
||||
"productTypes": [
|
||||
{
|
||||
"id": "storm_door",
|
||||
"type": "Storm Door",
|
||||
"category": "STORMS",
|
||||
"baseType": "Door",
|
||||
"subType": "Storm Door",
|
||||
"availableMaterials": ["Aluminum", "Steel"],
|
||||
"availableColors": ["White", "Bronze", "Black", "Sandstone"],
|
||||
"additionalOptions": []
|
||||
},
|
||||
{
|
||||
"id": "storm_window",
|
||||
"type": "Storm Window",
|
||||
"category": "STORMS",
|
||||
"baseType": "Window",
|
||||
"subType": "Storm Window",
|
||||
"availableMaterials": ["Aluminum"],
|
||||
"availableColors": ["White", "Bronze", "Black", "Sandstone"],
|
||||
"additionalOptions": []
|
||||
},
|
||||
{
|
||||
"id": "primary_window",
|
||||
"type": "Primary Window",
|
||||
"category": "SHPT",
|
||||
"baseType": "Window",
|
||||
"subType": "Primary Window",
|
||||
"availableMaterials": ["Vinyl", "Aluminum"],
|
||||
"availableColors": ["White", "Bronze", "Mill"],
|
||||
"additionalOptions": []
|
||||
},
|
||||
{
|
||||
"id": "sliding_window",
|
||||
"type": "Sliding Window",
|
||||
"category": "SHPW",
|
||||
"baseType": "Window",
|
||||
"subType": "Sliding Window",
|
||||
"availableMaterials": ["Vinyl", "Aluminum"],
|
||||
"availableColors": ["White", "Bronze", "Mill"],
|
||||
"additionalOptions": []
|
||||
},
|
||||
{
|
||||
"id": "fixed_lite",
|
||||
"type": "Fixed Lite",
|
||||
"category": "FPPW",
|
||||
"baseType": "Window",
|
||||
"subType": "Fixed Lite",
|
||||
"availableMaterials": ["Vinyl", "Aluminum"],
|
||||
"availableColors": ["White", "Bronze", "Mill"],
|
||||
"additionalOptions": []
|
||||
},
|
||||
{
|
||||
"id": "casement",
|
||||
"type": "Casement",
|
||||
"category": "CASEMNT",
|
||||
"baseType": "Window",
|
||||
"subType": "Casement",
|
||||
"availableMaterials": ["Aluminum"],
|
||||
"availableColors": ["White", "Bronze"],
|
||||
"additionalOptions": ["1-Panel", "2-Panel", "3-Panel"]
|
||||
},
|
||||
{
|
||||
"id": "double_hung",
|
||||
"type": "Double Hung",
|
||||
"category": "3000DHP",
|
||||
"baseType": "Window",
|
||||
"subType": "Double Hung",
|
||||
"availableMaterials": ["Aluminum"],
|
||||
"availableColors": ["White", "Bronze"],
|
||||
"additionalOptions": ["Thermal Break", "Standard"]
|
||||
},
|
||||
{
|
||||
"id": "screen_door",
|
||||
"type": "Screen Door",
|
||||
"category": "SCREENS",
|
||||
"baseType": "Door",
|
||||
"subType": "Screen Door",
|
||||
"availableMaterials": ["Aluminum"],
|
||||
"availableColors": ["White", "Bronze", "Black"],
|
||||
"additionalOptions": ["Self-Storing", "Fixed"]
|
||||
}
|
||||
],
|
||||
"allMaterials": [
|
||||
"Aluminum",
|
||||
"Vinyl",
|
||||
"Steel",
|
||||
"Wood",
|
||||
"Fiberglass"
|
||||
],
|
||||
"allColors": [
|
||||
"White",
|
||||
"Black",
|
||||
"Bronze",
|
||||
"Sandstone",
|
||||
"Mill",
|
||||
"Almond",
|
||||
"Clay",
|
||||
"Gray"
|
||||
],
|
||||
"categories": [
|
||||
{
|
||||
"code": "STORMS",
|
||||
"label": "Storm Products"
|
||||
},
|
||||
{
|
||||
"code": "SHPW",
|
||||
"label": "Single Hung Primary Windows"
|
||||
},
|
||||
{
|
||||
"code": "SHPT",
|
||||
"label": "Single Hung Primary Tilt"
|
||||
},
|
||||
{
|
||||
"code": "FPPW",
|
||||
"label": "Fixed Picture Primary Windows"
|
||||
},
|
||||
{
|
||||
"code": "CASEMNT",
|
||||
"label": "Casement Windows"
|
||||
},
|
||||
{
|
||||
"code": "3000DHP",
|
||||
"label": "3000 Series Double Hung Primary"
|
||||
},
|
||||
{
|
||||
"code": "3000FPP",
|
||||
"label": "3000 Series Fixed Picture Primary"
|
||||
},
|
||||
{
|
||||
"code": "SCREENS",
|
||||
"label": "Screen Doors"
|
||||
}
|
||||
],
|
||||
"codeModifiers": {
|
||||
"prefixes": [
|
||||
{
|
||||
"id": "replacement",
|
||||
"label": "Replacement (R-)",
|
||||
"value": "R",
|
||||
"appliesTo": ["Window"],
|
||||
"description": "For replacement windows"
|
||||
},
|
||||
{
|
||||
"id": "new_construction",
|
||||
"label": "New Construction (N-)",
|
||||
"value": "N",
|
||||
"appliesTo": ["Window"],
|
||||
"description": "For new construction windows"
|
||||
}
|
||||
],
|
||||
"suffixes": [
|
||||
{
|
||||
"id": "kickplate",
|
||||
"label": "Kick Plate (KP)",
|
||||
"value": "KP",
|
||||
"appliesTo": ["Door"],
|
||||
"description": "Add kick plate to door"
|
||||
},
|
||||
{
|
||||
"id": "vent",
|
||||
"label": "Vent (V)",
|
||||
"value": "V",
|
||||
"appliesTo": ["Door"],
|
||||
"description": "Add vent to door"
|
||||
},
|
||||
{
|
||||
"id": "screen",
|
||||
"label": "Screen (SCR)",
|
||||
"value": "SCR",
|
||||
"appliesTo": ["Door", "Window"],
|
||||
"description": "Include screen"
|
||||
},
|
||||
{
|
||||
"id": "tempered",
|
||||
"label": "Tempered Glass (T)",
|
||||
"value": "T",
|
||||
"appliesTo": ["Door", "Window"],
|
||||
"description": "Tempered glass option"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,41 @@
|
||||
"Sandstone"
|
||||
],
|
||||
"isAccessory": false,
|
||||
"compatibleAccessories": []
|
||||
"compatibleAccessories": [],
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "404R",
|
||||
"productCode": "404R",
|
||||
"category": "STORMS",
|
||||
"description": "#404 FALCON STORM WINDOWS (RIGHT-HINGE)",
|
||||
"discontinued": false,
|
||||
"location": "Iola",
|
||||
"baseType": "Window",
|
||||
"subType": {
|
||||
"door": null,
|
||||
"window": "Storm Window"
|
||||
},
|
||||
"materials": [
|
||||
"Aluminum"
|
||||
],
|
||||
"colors": [
|
||||
"Black",
|
||||
"White",
|
||||
"Bronze",
|
||||
"Sandstone"
|
||||
],
|
||||
"isAccessory": false,
|
||||
"compatibleAccessories": [],
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true,
|
||||
"layerTransforms": {
|
||||
"door": "flip-horizontal",
|
||||
"hardware": "flip-horizontal"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "450",
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"password": "pbkdf2:sha256:1000000$tPRmseRGKLveXTxS$87eda9a20db29d12838fcb607f1f3536141f743ec9879702dc2a0dd893b4078d",
|
||||
"defaultLocation": "KC",
|
||||
"permissions": {
|
||||
"manage_users": true
|
||||
"manage_users": true,
|
||||
"manage_products": true
|
||||
},
|
||||
"locationSettings": {
|
||||
"LINDS": {
|
||||
@@ -39,6 +40,7 @@
|
||||
},
|
||||
"permissions": {
|
||||
"manage_users": true,
|
||||
"manage_products": true,
|
||||
"create_quotes": true
|
||||
},
|
||||
"active": true
|
||||
|
||||
@@ -300,6 +300,24 @@ body {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.layer-foreground {
|
||||
z-index: 5;
|
||||
pointer-events: none; /* Allow clicks to pass through to controls below */
|
||||
}
|
||||
|
||||
/* Layer transform modifiers */
|
||||
.layer-flip-horizontal {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.layer-flip-vertical {
|
||||
transform: scaleY(-1);
|
||||
}
|
||||
|
||||
.layer-flip-both {
|
||||
transform: scale(-1, -1);
|
||||
}
|
||||
|
||||
.config-preview-notice {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -106,7 +106,13 @@ let history = ['start'];
|
||||
function initFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
// Priority 1: Direct product view
|
||||
// Priority 0: Direct product from Flask route (e.g., /quiz/product/404)
|
||||
if (window.INITIAL_PRODUCT && window.INITIAL_PRODUCT !== '') {
|
||||
showProductByCode(window.INITIAL_PRODUCT);
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 1: Direct product view from query param
|
||||
const productCode = params.get('p');
|
||||
if (productCode) {
|
||||
accumulatedBitValue = parseInt(params.get('b') || '0', 10);
|
||||
@@ -705,6 +711,7 @@ function showProductDetail(product) {
|
||||
|
||||
// Get product image configuration
|
||||
const productImage = product.image || `https://via.placeholder.com/400x400/e0e0e0/666?text=${encodeURIComponent(prodCode)}`;
|
||||
const useCanvasAPI = product.imageConfig && product.imageConfig.useCanvasAPI === true;
|
||||
const hasLayeredImages = product.imageConfig && product.imageConfig.layered === true;
|
||||
const hasFlatImages = product.flatImages === true || product.imagePattern;
|
||||
const useDynamicAPI = product.useDynamicAPI === true;
|
||||
@@ -795,20 +802,21 @@ function showProductDetail(product) {
|
||||
|
||||
const contentDiv = document.getElementById('content');
|
||||
|
||||
// Build the image display (layered, flat, or dynamic API)
|
||||
// Build the image display (Canvas API, layered, flat, or dynamic API)
|
||||
let imageDisplayHtml = '';
|
||||
|
||||
if (hasLayeredImages && !hasFlatImages && !useDynamicAPI) {
|
||||
// Use layered image system
|
||||
const basePath = product.imageConfig.basePath || '';
|
||||
const layers = product.imageConfig.layers || {};
|
||||
if (useCanvasAPI || (hasLayeredImages && !hasFlatImages && !useDynamicAPI)) {
|
||||
// Use layered image system (Canvas API or static paths)
|
||||
const basePath = product.imageConfig?.basePath || '';
|
||||
const layers = product.imageConfig?.layers || {};
|
||||
|
||||
imageDisplayHtml = `
|
||||
<div class="door-configurator" id="door-configurator">
|
||||
${layers.base ? `<img src="${basePath}${layers.base}" class="layer-base" alt="Base">` : ''}
|
||||
<img src="" class="layer-base" alt="Base">
|
||||
<img src="" class="layer-door" id="door-layer" alt="Door" style="display:none;">
|
||||
${layers.hardware ? `<img src="${basePath}${layers.hardware}" class="layer-hardware" id="hardware-layer" alt="Hardware">` : ''}
|
||||
<img src="" class="layer-hardware" id="hardware-layer" alt="Hardware" style="display:none;">
|
||||
<img src="" class="layer-overlay" id="overlay-layer" alt="View" style="display:none;">
|
||||
<img src="" class="layer-foreground" alt="Foreground" style="display:none;">
|
||||
<div class="config-preview-notice" id="config-notice" style="display:none;">
|
||||
⚠️ Preview not available for this configuration
|
||||
</div>
|
||||
@@ -958,7 +966,11 @@ function showProductDetail(product) {
|
||||
updateBreadcrumb();
|
||||
|
||||
// Initialize images based on type
|
||||
if (hasLayeredImages && !hasFlatImages && !useDynamicAPI) {
|
||||
if (useCanvasAPI) {
|
||||
// Use Canvas API with hierarchical fallback
|
||||
updateCanvasAPIPreview(prodCode);
|
||||
} else if (hasLayeredImages && !hasFlatImages && !useDynamicAPI) {
|
||||
// Use static layered images
|
||||
updateProductPreview(prodCode);
|
||||
} else if (hasFlatImages || useDynamicAPI) {
|
||||
// Flat images or dynamic API - will update on dropdown change
|
||||
@@ -969,18 +981,138 @@ function showProductDetail(product) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Canvas API Functions - Hierarchical Image Fallback System
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build Canvas API URL for a specific layer
|
||||
* @param {string} productCode - Product code
|
||||
* @param {string} layer - Layer name (base, door, hardware, overlay, foreground)
|
||||
* @param {string} color - Optional color
|
||||
* @param {string} material - Optional material
|
||||
* @returns {string} Canvas API URL
|
||||
*/
|
||||
function buildCanvasAPIUrl(productCode, layer, color = null, material = null) {
|
||||
let url = `${BASE_URL}/api/canvas/${productCode}/${layer}`;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (color) params.append('color', color.toLowerCase());
|
||||
if (material) params.append('material', material.toLowerCase());
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `${url}?${queryString}` : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update layered image preview using Canvas API
|
||||
* @param {string} productCode - Product code
|
||||
*/
|
||||
function updateCanvasAPIPreview(productCode) {
|
||||
const product = productData.find(p => p.productCode === productCode || p.id === productCode);
|
||||
if (!product) return;
|
||||
|
||||
// Get selected values
|
||||
const selectedColor = document.getElementById('config-color')?.value || '';
|
||||
const selectedMaterial = document.getElementById('config-material')?.value || '';
|
||||
|
||||
// Get layer transform configuration (for flipping, etc.)
|
||||
const layerTransforms = product.imageConfig?.layerTransforms || {};
|
||||
|
||||
// Helper function to apply transform classes
|
||||
function applyTransform(element, layerName) {
|
||||
if (!element) return;
|
||||
// Remove any existing transform classes
|
||||
element.classList.remove('layer-flip-horizontal', 'layer-flip-vertical', 'layer-flip-both');
|
||||
// Apply new transform if configured
|
||||
const transform = layerTransforms[layerName];
|
||||
if (transform) {
|
||||
element.classList.add(`layer-${transform}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update each layer
|
||||
const baseLayer = document.querySelector('.layer-base');
|
||||
const doorLayer = document.getElementById('door-layer');
|
||||
const hardwareLayer = document.getElementById('hardware-layer');
|
||||
const overlayLayer = document.getElementById('overlay-layer');
|
||||
const foregroundLayer = document.querySelector('.layer-foreground');
|
||||
|
||||
// Base layer (no color variation)
|
||||
if (baseLayer && baseLayer.tagName === 'IMG') {
|
||||
baseLayer.src = buildCanvasAPIUrl(productCode, 'base');
|
||||
applyTransform(baseLayer, 'base');
|
||||
baseLayer.onerror = function() {
|
||||
console.warn('Base layer not found via Canvas API');
|
||||
};
|
||||
}
|
||||
|
||||
// Door layer (with color)
|
||||
if (doorLayer) {
|
||||
applyTransform(doorLayer, 'door');
|
||||
if (selectedColor) {
|
||||
doorLayer.src = buildCanvasAPIUrl(productCode, 'door', selectedColor);
|
||||
doorLayer.style.display = 'block';
|
||||
doorLayer.onerror = function() {
|
||||
console.warn(`Door layer not found for color: ${selectedColor}`);
|
||||
this.style.display = 'none';
|
||||
showConfigNotice(true);
|
||||
};
|
||||
doorLayer.onload = function() {
|
||||
showConfigNotice(false);
|
||||
};
|
||||
} else {
|
||||
doorLayer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Hardware layer
|
||||
if (hardwareLayer) {
|
||||
hardwareLayer.src = buildCanvasAPIUrl(productCode, 'hardware');
|
||||
applyTransform(hardwareLayer, 'hardware');
|
||||
hardwareLayer.onerror = function() {
|
||||
console.warn('Hardware layer not found via Canvas API');
|
||||
this.style.display = 'none';
|
||||
};
|
||||
hardwareLayer.onload = function() {
|
||||
this.style.display = 'block';
|
||||
};
|
||||
}
|
||||
|
||||
// Foreground layer
|
||||
if (foregroundLayer && foregroundLayer.tagName === 'IMG') {
|
||||
foregroundLayer.src = buildCanvasAPIUrl(productCode, 'foreground');
|
||||
applyTransform(foregroundLayer, 'foreground');
|
||||
foregroundLayer.onerror = function() {
|
||||
console.warn('Foreground layer not found via Canvas API');
|
||||
// This is fine - foreground is optional
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Update product preview based on configuration
|
||||
// ============================================================================
|
||||
|
||||
function updateProductPreview(productCode) {
|
||||
console.log('updateProductPreview called with:', productCode);
|
||||
|
||||
const product = productData.find(p => p.productCode === productCode || p.id === productCode);
|
||||
if (!product) return;
|
||||
|
||||
// Check which system to use
|
||||
// Check which system to use - Canvas API has priority
|
||||
const useCanvasAPI = product.imageConfig && product.imageConfig.useCanvasAPI === true;
|
||||
const hasFlatImages = product.flatImages === true || product.imagePattern;
|
||||
const useDynamicAPI = product.useDynamicAPI === true;
|
||||
|
||||
console.log('Product flags - hasFlatImages:', hasFlatImages, 'useDynamicAPI:', useDynamicAPI);
|
||||
console.log('Product flags - useCanvasAPI:', useCanvasAPI, 'hasFlatImages:', hasFlatImages, 'useDynamicAPI:', useDynamicAPI);
|
||||
|
||||
// Priority: Canvas API > Flat Images > Dynamic API > Layered Static
|
||||
if (useCanvasAPI) {
|
||||
// Use Canvas API with hierarchical fallback
|
||||
updateCanvasAPIPreview(productCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasFlatImages) {
|
||||
// Use pre-rendered flat images
|
||||
@@ -1810,6 +1942,7 @@ startOver = function() {
|
||||
|
||||
// Initialize search bar when page loads
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
init(); // Load quiz data and initialize
|
||||
initSearchBar();
|
||||
toggleSearchBar();
|
||||
});
|
||||
|
||||
@@ -77,8 +77,16 @@
|
||||
locationDisplay = `📍 ${currentLoc}`;
|
||||
}
|
||||
|
||||
let manageProductsLink = '';
|
||||
if (data.permissions && data.permissions.manage_products) {
|
||||
manageProductsLink = ` | <a href="{{ url_for('products.product_manager') }}" style="color: #6a4c93; text-decoration: none; font-weight: 500; cursor: pointer;"
|
||||
onmouseover="this.style.textDecoration='underline'"
|
||||
onmouseout="this.style.textDecoration='none'"
|
||||
title="Manage Products">Manage Products</a>`;
|
||||
}
|
||||
|
||||
document.getElementById('userLocationInfo').innerHTML =
|
||||
`👤 ${data.username} | ${locationDisplay}`;
|
||||
`👤 ${data.username} | ${locationDisplay}${manageProductsLink}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user info:', error);
|
||||
@@ -93,6 +101,8 @@
|
||||
window.APP_BASE_URL = '';
|
||||
// Set data path for the quiz
|
||||
window.DATA_PATH = '{{ url_for("products.serve_data", filename="") }}';
|
||||
// Set initial product if provided via URL
|
||||
window.INITIAL_PRODUCT = '{{ initial_product or "" }}';
|
||||
</script>
|
||||
<script src="{{ url_for('products.serve_js', filename='script.js') }}"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Product Manager - CGW</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 30px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.form-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
label .optional {
|
||||
color: #999;
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.note {
|
||||
background: #fff9e6;
|
||||
border-left: 4px solid #ffd700;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.product-codes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.type-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.radio-option input[type="radio"] {
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radio-option label {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.dynamic-section {
|
||||
background: #f9f9f9;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.dynamic-section.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 30px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #e84052;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
margin-right: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.status-default {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.status-discontinued {
|
||||
background: #ff4757;
|
||||
}
|
||||
|
||||
.status-hidden {
|
||||
background: #ffa502;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.type-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-codes-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.product-codes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav-links">
|
||||
<a href="/">← Back to Home</a>
|
||||
<a href="/quiz/">Product Finder</a>
|
||||
</div>
|
||||
|
||||
<h1>Product Manager</h1>
|
||||
<p class="subtitle">Add or edit product information</p>
|
||||
|
||||
<form id="productForm">
|
||||
<!-- Product Location & Description -->
|
||||
<div class="form-section">
|
||||
<div class="form-group">
|
||||
<label for="location">Product Location:</label>
|
||||
<select id="location" name="location" required>
|
||||
<option value="">Select location...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" placeholder="Enter product description"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<option value="default">Default</option>
|
||||
<option value="discontinued">Discontinued</option>
|
||||
<option value="hidden">Hide</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Codes -->
|
||||
<div class="form-section">
|
||||
<div class="section-title">Product Codes</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="standardCode">Standardized Code <span class="optional">(auto-generated from selections below)</span></label>
|
||||
<input type="text" id="standardCode" name="standardCode" placeholder="Select type and modifiers to generate" readonly style="background-color: #f5f5f5;">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="baseCode">Base Code <span class="optional">(optional - e.g., 2000, 3000)</span></label>
|
||||
<input type="text" id="baseCode" name="baseCode" placeholder="Enter base product code">
|
||||
</div>
|
||||
|
||||
<div class="product-codes-grid" id="locationCodesContainer">
|
||||
<!-- Dynamic location code fields will be inserted here based on user's accessible locations -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Type -->
|
||||
<div class="form-section">
|
||||
<div class="section-title">Product Type</div>
|
||||
|
||||
<div class="type-group">
|
||||
<div class="form-group">
|
||||
<label for="productType">Type</label>
|
||||
<select id="productType" name="productType">
|
||||
<option value="">Select type...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="productGroup">Group [Category]</label>
|
||||
<input type="text" id="productGroup" name="productGroup" placeholder="" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code Prefixes -->
|
||||
<div class="form-section dynamic-section hidden" id="prefixesSection">
|
||||
<div class="section-title">Code Prefixes</div>
|
||||
<div class="radio-group" id="prefixesContainer">
|
||||
<!-- Dynamic prefix options will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code Suffixes -->
|
||||
<div class="form-section dynamic-section hidden" id="suffixesSection">
|
||||
<div class="section-title">Code Suffixes</div>
|
||||
<div style="margin-bottom: 10px; font-size: 13px; color: #666;">Select all that apply (multiple selections allowed)</div>
|
||||
<div class="radio-group" id="suffixesContainer">
|
||||
<!-- Dynamic suffix options will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Materials -->
|
||||
<div class="form-section dynamic-section hidden" id="materialsSection">
|
||||
<div class="section-title">Materials</div>
|
||||
<div class="radio-group" id="materialsContainer">
|
||||
<!-- Dynamic material options will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colors -->
|
||||
<div class="form-section dynamic-section hidden" id="colorsSection">
|
||||
<div class="section-title">Colors</div>
|
||||
<div class="radio-group" id="colorsContainer">
|
||||
<!-- Dynamic color options will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Options -->
|
||||
<div class="form-section dynamic-section hidden" id="additionalOptionsSection">
|
||||
<div class="section-title">Additional Options</div>
|
||||
<div class="radio-group" id="additionalOptionsContainer">
|
||||
<!-- Dynamic additional options will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="button-group">
|
||||
<button type="submit" class="btn-primary">Save Product</button>
|
||||
<button type="button" class="btn-secondary" onclick="resetForm()">Clear Form</button>
|
||||
<button type="button" class="btn-danger" onclick="deleteProduct()">Delete Product</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let productAttributes = null;
|
||||
let userAccessibleLocations = [];
|
||||
|
||||
// Load product attributes and user locations
|
||||
async function loadProductAttributes() {
|
||||
try {
|
||||
const response = await fetch('/quiz/data/product_attributes.json');
|
||||
productAttributes = await response.json();
|
||||
|
||||
// Get user's accessible locations from session
|
||||
await fetchUserLocations();
|
||||
|
||||
initializeForm();
|
||||
} catch (error) {
|
||||
console.error('Error loading product attributes:', error);
|
||||
alert('Failed to load product attributes');
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch user's accessible locations from session
|
||||
async function fetchUserLocations() {
|
||||
try {
|
||||
const response = await fetch('/quiz/api/user-session');
|
||||
const userData = await response.json();
|
||||
userAccessibleLocations = userData.accessibleLocations || [];
|
||||
|
||||
// If user has no accessible locations, default to their current location
|
||||
if (userAccessibleLocations.length === 0 && userData.currentLocation) {
|
||||
userAccessibleLocations = [userData.currentLocation];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user locations:', error);
|
||||
// Fallback to all location codes if API fails
|
||||
userAccessibleLocations = productAttributes.locations.map(loc => loc.code);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize form with data
|
||||
function initializeForm() {
|
||||
// Populate locations based on user's access
|
||||
// Match user's accessible location codes with full location details
|
||||
const locationSelect = document.getElementById('location');
|
||||
|
||||
productAttributes.locations.forEach(loc => {
|
||||
// Check if user has access to this location code
|
||||
if (userAccessibleLocations.includes(loc.code)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = loc.code;
|
||||
option.textContent = loc.name;
|
||||
locationSelect.appendChild(option);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize location code fields with user's accessible locations
|
||||
updateLocationCodes();
|
||||
|
||||
// Populate product types
|
||||
const typeSelect = document.getElementById('productType');
|
||||
productAttributes.productTypes.forEach(type => {
|
||||
const option = document.createElement('option');
|
||||
option.value = type.id;
|
||||
option.textContent = type.type;
|
||||
option.dataset.category = type.category;
|
||||
option.dataset.basetype = type.baseType;
|
||||
option.dataset.materials = JSON.stringify(type.availableMaterials);
|
||||
option.dataset.colors = JSON.stringify(type.availableColors);
|
||||
option.dataset.options = JSON.stringify(type.additionalOptions);
|
||||
typeSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Setup event listeners
|
||||
document.getElementById('productType').addEventListener('change', updateDynamicSections);
|
||||
document.getElementById('baseCode').addEventListener('input', updateStandardizedCode);
|
||||
}
|
||||
|
||||
// Update location code fields based on user's accessible locations
|
||||
function updateLocationCodes() {
|
||||
const container = document.getElementById('locationCodesContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Get full location details for each accessible code
|
||||
userAccessibleLocations.forEach(locCode => {
|
||||
const locationDetails = productAttributes.locations.find(loc => loc.code === locCode);
|
||||
if (locationDetails) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'form-group';
|
||||
div.innerHTML = `
|
||||
<label for="code_${locCode}">${locationDetails.name} code</label>
|
||||
<input type="text" id="code_${locCode}" name="code_${locCode}" placeholder="">
|
||||
`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update dynamic sections based on product type
|
||||
function updateDynamicSections() {
|
||||
const typeSelect = document.getElementById('productType');
|
||||
const selectedOption = typeSelect.options[typeSelect.selectedIndex];
|
||||
|
||||
if (!selectedOption.value) {
|
||||
hideAllDynamicSections();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update category
|
||||
document.getElementById('productGroup').value = selectedOption.dataset.category || '';
|
||||
|
||||
// Get the baseType from the selected option
|
||||
const baseType = selectedOption.dataset.basetype || '';
|
||||
|
||||
// Update code prefixes
|
||||
if (baseType && productAttributes.codeModifiers && productAttributes.codeModifiers.prefixes) {
|
||||
const applicablePrefixes = productAttributes.codeModifiers.prefixes.filter(
|
||||
p => p.appliesTo.includes(baseType)
|
||||
);
|
||||
if (applicablePrefixes.length > 0) {
|
||||
showPrefixesSection(applicablePrefixes);
|
||||
} else {
|
||||
hidePrefixesSection();
|
||||
}
|
||||
} else {
|
||||
hidePrefixesSection();
|
||||
}
|
||||
|
||||
// Update code suffixes
|
||||
if (baseType && productAttributes.codeModifiers && productAttributes.codeModifiers.suffixes) {
|
||||
const applicableSuffixes = productAttributes.codeModifiers.suffixes.filter(
|
||||
s => s.appliesTo.includes(baseType)
|
||||
);
|
||||
if (applicableSuffixes.length > 0) {
|
||||
showSuffixesSection(applicableSuffixes);
|
||||
} else {
|
||||
hideSuffixesSection();
|
||||
}
|
||||
} else {
|
||||
hideSuffixesSection();
|
||||
}
|
||||
|
||||
// Update materials
|
||||
const materials = JSON.parse(selectedOption.dataset.materials || '[]');
|
||||
if (materials.length > 0) {
|
||||
showMaterialsSection(materials);
|
||||
} else {
|
||||
hideMaterialsSection();
|
||||
}
|
||||
|
||||
// Update colors
|
||||
const colors = JSON.parse(selectedOption.dataset.colors || '[]');
|
||||
if (colors.length > 0) {
|
||||
showColorsSection(colors);
|
||||
} else {
|
||||
hideColorsSection();
|
||||
}
|
||||
|
||||
// Update additional options
|
||||
const options = JSON.parse(selectedOption.dataset.options || '[]');
|
||||
if (options.length > 0) {
|
||||
showAdditionalOptionsSection(options);
|
||||
} else {
|
||||
hideAdditionalOptionsSection();
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide dynamic sections
|
||||
function showMaterialsSection(materials) {
|
||||
const section = document.getElementById('materialsSection');
|
||||
const container = document.getElementById('materialsContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
materials.forEach((material, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'radio-option';
|
||||
div.innerHTML = `
|
||||
<input type="radio" id="material_${index}" name="material" value="${material}" ${index === 0 ? 'checked' : ''}>
|
||||
<label for="material_${index}">${material}</label>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
section.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideMaterialsSection() {
|
||||
document.getElementById('materialsSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showColorsSection(colors) {
|
||||
const section = document.getElementById('colorsSection');
|
||||
const container = document.getElementById('colorsContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
colors.forEach((color, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'radio-option';
|
||||
div.innerHTML = `
|
||||
<input type="radio" id="color_${index}" name="color" value="${color}" ${index === 0 ? 'checked' : ''}>
|
||||
<label for="color_${index}">${color}</label>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
section.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideColorsSection() {
|
||||
document.getElementById('colorsSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showAdditionalOptionsSection(options) {
|
||||
const section = document.getElementById('additionalOptionsSection');
|
||||
const container = document.getElementById('additionalOptionsContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
options.forEach((option, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'radio-option';
|
||||
div.innerHTML = `
|
||||
<input type="radio" id="option_${index}" name="additionalOption" value="${option}" ${index === 0 ? 'checked' : ''}>
|
||||
<label for="option_${index}">${option}</label>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
section.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideAdditionalOptionsSection() {
|
||||
document.getElementById('additionalOptionsSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showPrefixesSection(prefixes) {
|
||||
const section = document.getElementById('prefixesSection');
|
||||
const container = document.getElementById('prefixesContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Add "None" option first
|
||||
const noneDiv = document.createElement('div');
|
||||
noneDiv.className = 'radio-option';
|
||||
noneDiv.innerHTML = `
|
||||
<input type="radio" id="prefix_none" name="codePrefix" value="" checked onchange="updateStandardizedCode()">
|
||||
<label for="prefix_none">None</label>
|
||||
`;
|
||||
container.appendChild(noneDiv);
|
||||
|
||||
prefixes.forEach((prefix, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'radio-option';
|
||||
div.innerHTML = `
|
||||
<input type="radio" id="prefix_${index}" name="codePrefix" value="${prefix.value}" onchange="updateStandardizedCode()" title="${prefix.description}">
|
||||
<label for="prefix_${index}">${prefix.label}</label>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
section.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hidePrefixesSection() {
|
||||
document.getElementById('prefixesSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showSuffixesSection(suffixes) {
|
||||
const section = document.getElementById('suffixesSection');
|
||||
const container = document.getElementById('suffixesContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
suffixes.forEach((suffix, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'radio-option';
|
||||
div.innerHTML = `
|
||||
<input type="checkbox" id="suffix_${index}" name="codeSuffix" value="${suffix.value}" onchange="updateStandardizedCode()" title="${suffix.description}">
|
||||
<label for="suffix_${index}">${suffix.label}</label>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
section.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideSuffixesSection() {
|
||||
document.getElementById('suffixesSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
function updateStandardizedCode() {
|
||||
const baseCode = document.getElementById('baseCode').value.trim();
|
||||
|
||||
// Get selected prefix
|
||||
const prefixRadio = document.querySelector('input[name="codePrefix"]:checked');
|
||||
const prefix = prefixRadio ? prefixRadio.value : '';
|
||||
|
||||
// Get selected suffixes (checkboxes)
|
||||
const suffixCheckboxes = document.querySelectorAll('input[name="codeSuffix"]:checked');
|
||||
const suffixes = Array.from(suffixCheckboxes).map(cb => cb.value);
|
||||
|
||||
// Build the standardized code
|
||||
let standardizedCode = '';
|
||||
if (baseCode) {
|
||||
standardizedCode = prefix + baseCode;
|
||||
if (suffixes.length > 0) {
|
||||
standardizedCode += suffixes.join('');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('standardCode').value = standardizedCode;
|
||||
}
|
||||
|
||||
function hideAllDynamicSections() {
|
||||
document.getElementById('prefixesSection').classList.add('hidden');
|
||||
document.getElementById('suffixesSection').classList.add('hidden');
|
||||
document.getElementById('materialsSection').classList.add('hidden');
|
||||
document.getElementById('colorsSection').classList.add('hidden');
|
||||
document.getElementById('additionalOptionsSection').classList.add('hidden');
|
||||
document.getElementById('additionalOptionsSection').classList.add('hidden');
|
||||
document.getElementById('productGroup').value = '';
|
||||
}
|
||||
|
||||
// Form submission
|
||||
document.getElementById('productForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
// Get selected prefix
|
||||
const selectedPrefix = document.querySelector('input[name="codePrefix"]:checked');
|
||||
const codePrefix = selectedPrefix ? selectedPrefix.value : '';
|
||||
|
||||
// Get selected suffixes
|
||||
const selectedSuffixes = Array.from(document.querySelectorAll('input[name="codeSuffix"]:checked'))
|
||||
.map(cb => cb.value);
|
||||
|
||||
const productData = {
|
||||
location: formData.get('location'),
|
||||
description: formData.get('description'),
|
||||
status: formData.get('status'),
|
||||
baseCode: formData.get('baseCode'),
|
||||
standardCode: formData.get('standardCode'),
|
||||
codePrefix: codePrefix,
|
||||
codeSuffixes: selectedSuffixes,
|
||||
locationCodes: {},
|
||||
productType: formData.get('productType'),
|
||||
category: formData.get('productGroup'),
|
||||
material: formData.get('material'),
|
||||
color: formData.get('color'),
|
||||
additionalOption: formData.get('additionalOption')
|
||||
};
|
||||
|
||||
// Collect location codes for user's accessible locations
|
||||
userAccessibleLocations.forEach(locCode => {
|
||||
const code = formData.get(`code_${locCode}`);
|
||||
if (code) {
|
||||
productData.locationCodes[locCode] = code;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Product Data:', productData);
|
||||
|
||||
// Here you would send the data to your backend
|
||||
// For now, just show an alert
|
||||
alert('Product saved! (This is a demo - in production, this would save to the database)');
|
||||
});
|
||||
|
||||
// Utility functions
|
||||
function resetForm() {
|
||||
document.getElementById('productForm').reset();
|
||||
hideAllDynamicSections();
|
||||
}
|
||||
|
||||
function deleteProduct() {
|
||||
if (confirm('Are you sure you want to delete this product?')) {
|
||||
alert('Product deleted! (This is a demo)');
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', loadProductAttributes);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -269,6 +269,10 @@
|
||||
<input type="checkbox" id="perm_manage_users" value="manage_users">
|
||||
<label for="perm_manage_users">Manage Users</label>
|
||||
</div>
|
||||
<div class="location-item">
|
||||
<input type="checkbox" id="perm_manage_products" value="manage_products">
|
||||
<label for="perm_manage_products">Manage Products</label>
|
||||
</div>
|
||||
<div class="location-item">
|
||||
<input type="checkbox" id="perm_create_quotes" value="create_quotes">
|
||||
<label for="perm_create_quotes">Create Quotes</label>
|
||||
@@ -389,6 +393,9 @@
|
||||
if (document.getElementById('perm_manage_users').checked) {
|
||||
permissions.manage_users = true;
|
||||
}
|
||||
if (document.getElementById('perm_manage_products').checked) {
|
||||
permissions.manage_products = true;
|
||||
}
|
||||
if (document.getElementById('perm_create_quotes').checked) {
|
||||
permissions.create_quotes = true;
|
||||
}
|
||||
|
||||