diff --git a/app/__init__.py b/app/__init__.py
index 41b90a0..dbf65b8 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -182,18 +182,12 @@ def create_app(config_name='development'):
)
# Register blueprints
- from app.routes import submission, dashboard, admin, auth
+ from app.routes import submission, dashboard, admin, auth, landing
app.register_blueprint(submission.bp)
app.register_blueprint(dashboard.bp)
app.register_blueprint(admin.bp)
app.register_blueprint(auth.bp)
-
- # Set index route
- @app.route('/')
- def index():
- """Welcome page"""
- from flask import render_template
- return render_template('index.html')
+ app.register_blueprint(landing.landing_bp) # Landing page (product selection)
# Health check endpoint (T208)
@app.route('/health')
diff --git a/app/models/product.py b/app/models/product.py
index 038da4b..7fe4bc1 100644
--- a/app/models/product.py
+++ b/app/models/product.py
@@ -159,6 +159,22 @@ class Product:
return products
+ @classmethod
+ def load_active(cls):
+ """Load all active products, sorted alphabetically by name then product_id
+
+ Returns:
+ list[Product]: Active products with valid submission_url_slug, sorted by:
+ 1. name (case-insensitive alphabetical)
+ 2. product_id (alphabetical) as tiebreaker
+
+ Products with missing/invalid submission_url_slug are excluded.
+ """
+ all_products = cls.get_all()
+ active = [p for p in all_products
+ if p.status == 'active' and p.submission_url_slug]
+ return sorted(active, key=lambda p: (p.name.lower(), p.product_id))
+
def save(self):
"""Save product to filesystem"""
product_dir = self._get_product_dir(self.product_id)
diff --git a/app/routes/landing.py b/app/routes/landing.py
new file mode 100644
index 0000000..dba197b
--- /dev/null
+++ b/app/routes/landing.py
@@ -0,0 +1,29 @@
+"""Landing page route - product selection"""
+from flask import Blueprint, render_template, current_app
+from app.models.product import Product
+
+landing_bp = Blueprint('landing', __name__)
+
+
+@landing_bp.route('/')
+def index():
+ """Landing page showing all active products for feedback submission
+
+ Returns:
+ Rendered HTML template with:
+ - List of active products (if any)
+ - Empty state message (if no active products)
+ """
+ try:
+ products = Product.load_active()
+ current_app.logger.info(
+ f'Landing page accessed: {len(products)} active products available'
+ )
+ return render_template('landing/index.html', products=products)
+ except Exception as e:
+ current_app.logger.error(
+ f'Error loading landing page: {e}',
+ exc_info=True
+ )
+ # Graceful degradation - show empty product list
+ return render_template('landing/index.html', products=[])
diff --git a/app/templates/landing/index.html b/app/templates/landing/index.html
new file mode 100644
index 0000000..5b34c1a
--- /dev/null
+++ b/app/templates/landing/index.html
@@ -0,0 +1,37 @@
+{% extends "base.html" %}
+
+{% block title %}Select Product - Reklamator{% endblock %}
+
+{% block content %}
+
Submit Feedback
+
+{% if products %}
+ Select a product to share your feedback, report issues, or suggest improvements.
+
+
+ {% for product in products %}
+
+
+ {{ product.name }}
+
+
+ {% if product.description %}
+
{{ product.description }}
+ {% endif %}
+
+
+ Submit Feedback
+
+
+ {% endfor %}
+
+{% else %}
+
+
+ No products are currently accepting feedback. Please check back later.
+
+
+{% endif %}
+{% endblock %}
diff --git a/specs/002-product-list/tasks.md b/specs/002-product-list/tasks.md
index 559cf37..a47d707 100644
--- a/specs/002-product-list/tasks.md
+++ b/specs/002-product-list/tasks.md
@@ -30,7 +30,7 @@
**⚠️ CRITICAL**: User Story 1 depends on this extension
-- [ ] T001 [US1] Extend Product model with load_active() class method in app/models/product.py
+- [X] T001 [US1] Extend Product model with load_active() class method in app/models/product.py
**Checkpoint**: Product.load_active() method ready - User Story 1 implementation can begin
@@ -44,21 +44,21 @@
### Tests for User Story 1 (TDD - Write FIRST, ensure FAIL)
-- [ ] T002 [P] [US1] Contract test: GET / with active products returns 200 with product list HTML in tests/contract/test_landing_routes.py
-- [ ] T003 [P] [US1] Contract test: GET / with no active products returns 200 with empty state message in tests/contract/test_landing_routes.py
-- [ ] T004 [P] [US1] Contract test: GET / excludes archived products in tests/contract/test_landing_routes.py
-- [ ] T005 [P] [US1] Contract test: GET / sorts products alphabetically (name, then product_id) in tests/contract/test_landing_routes.py
-- [ ] T006 [P] [US1] Contract test: GET / escapes HTML in product names (XSS prevention) in tests/contract/test_landing_routes.py
-- [ ] T007 [P] [US1] Contract test: GET / excludes products with missing submission_url_slug in tests/contract/test_landing_routes.py
-- [ ] T008 [US1] Integration test: Complete flow - landing page → click product → submission form in tests/integration/test_landing_flow.py
+- [X] T002 [P] [US1] Contract test: GET / with active products returns 200 with product list HTML in tests/contract/test_landing_routes.py
+- [X] T003 [P] [US1] Contract test: GET / with no active products returns 200 with empty state message in tests/contract/test_landing_routes.py
+- [X] T004 [P] [US1] Contract test: GET / excludes archived products in tests/contract/test_landing_routes.py
+- [X] T005 [P] [US1] Contract test: GET / sorts products alphabetically (name, then product_id) in tests/contract/test_landing_routes.py
+- [X] T006 [P] [US1] Contract test: GET / escapes HTML in product names (XSS prevention) in tests/contract/test_landing_routes.py
+- [X] T007 [P] [US1] Contract test: GET / excludes products with missing submission_url_slug in tests/contract/test_landing_routes.py
+- [X] T008 [US1] Integration test: Complete flow - landing page → click product → submission form in tests/integration/test_landing_flow.py
### Implementation for User Story 1
-- [ ] T009 [US1] Create landing route blueprint in app/routes/landing.py
-- [ ] T010 [US1] Register landing blueprint in app/__init__.py
-- [ ] T011 [US1] Create landing page template with product list in app/templates/landing/index.html
-- [ ] T012 [US1] Add logging for landing page access in app/routes/landing.py
-- [ ] T013 [US1] Verify all tests pass for User Story 1
+- [X] T009 [US1] Create landing route blueprint in app/routes/landing.py
+- [X] T010 [US1] Register landing blueprint in app/__init__.py
+- [X] T011 [US1] Create landing page template with product list in app/templates/landing/index.html
+- [X] T012 [US1] Add logging for landing page access in app/routes/landing.py
+- [X] T013 [US1] Verify all tests pass for User Story 1
**Checkpoint**: User Story 1 complete and independently testable. MVP ready for demo/deploy.
@@ -86,8 +86,8 @@
### Verification for User Story 3
-- [ ] T014 [US3] Manual test: Verify direct URL `/submit/{slug}` still works without landing page interference
-- [ ] T015 [US3] Manual test: Verify alphabetical sorting helps users find products efficiently on landing page
+- [X] T014 [US3] Manual test: Verify direct URL `/submit/{slug}` still works without landing page interference
+- [X] T015 [US3] Manual test: Verify alphabetical sorting helps users find products efficiently on landing page
**Checkpoint**: Backwards compatibility confirmed. All 3 user stories validated.
@@ -97,15 +97,15 @@
**Purpose**: Final validations and quality checks
-- [ ] T016 [P] Manual verification: Visit `/` with 0 active products - see empty state message
-- [ ] T017 [P] Manual verification: Visit `/` with 1 active product - see single product listed
-- [ ] T018 [P] Manual verification: Visit `/` with 10+ active products - verify alphabetical order
-- [ ] T019 [P] Manual verification: Check product with no description - verify no placeholder text shown
-- [ ] T020 [P] Manual verification: Check product with long name - verify proper text wrapping
-- [ ] T021 [P] Manual verification: Access `/` as anonymous user - page accessible
-- [ ] T022 [P] Manual verification: Access `/` as authenticated user - same page shown (no redirect)
-- [ ] T023 [P] Manual verification: View page source - confirm no JavaScript present
-- [ ] T024 [P] Performance verification: Load landing page with 100 products - confirm <1 second load time
+- [X] T016 [P] Manual verification: Visit `/` with 0 active products - see empty state message
+- [X] T017 [P] Manual verification: Visit `/` with 1 active product - see single product listed
+- [X] T018 [P] Manual verification: Visit `/` with 10+ active products - verify alphabetical order
+- [X] T019 [P] Manual verification: Check product with no description - verify no placeholder text shown
+- [X] T020 [P] Manual verification: Check product with long name - verify proper text wrapping
+- [X] T021 [P] Manual verification: Access `/` as anonymous user - page accessible
+- [X] T022 [P] Manual verification: Access `/` as authenticated user - same page shown (no redirect)
+- [X] T023 [P] Manual verification: View page source - confirm no JavaScript present
+- [X] T024 [P] Performance verification: Load landing page with 100 products - confirm <1 second load time
- [ ] T025 Commit all changes with descriptive message
---
diff --git a/tests/contract/test_landing_routes.py b/tests/contract/test_landing_routes.py
new file mode 100644
index 0000000..3d979b8
--- /dev/null
+++ b/tests/contract/test_landing_routes.py
@@ -0,0 +1,166 @@
+"""Contract tests for landing page routes"""
+import pytest
+import os
+import yaml
+
+
+@pytest.fixture
+def test_products(app):
+ """Create test products with various configurations"""
+ with app.app_context():
+ products_dir = os.path.join(app.config['DATA_DIR'], 'products')
+
+ # Product 1: Active with description
+ product1_dir = os.path.join(products_dir, 'product-001')
+ os.makedirs(product1_dir, exist_ok=True)
+ with open(os.path.join(product1_dir, 'config.yaml'), 'w') as f:
+ yaml.dump({
+ 'product_id': 'product-001',
+ 'name': 'Zebra Product',
+ 'submission_url_slug': 'zebra-product',
+ 'owner_language': 'en',
+ 'assigned_owner_ids': [],
+ 'status': 'active',
+ 'description': 'A product for testing'
+ }, f)
+
+ # Product 2: Active without description
+ product2_dir = os.path.join(products_dir, 'product-002')
+ os.makedirs(product2_dir, exist_ok=True)
+ with open(os.path.join(product2_dir, 'config.yaml'), 'w') as f:
+ yaml.dump({
+ 'product_id': 'product-002',
+ 'name': 'Apple Product',
+ 'submission_url_slug': 'apple-product',
+ 'owner_language': 'en',
+ 'assigned_owner_ids': [],
+ 'status': 'active'
+ }, f)
+
+ # Product 3: Archived (should not appear)
+ product3_dir = os.path.join(products_dir, 'product-003')
+ os.makedirs(product3_dir, exist_ok=True)
+ with open(os.path.join(product3_dir, 'config.yaml'), 'w') as f:
+ yaml.dump({
+ 'product_id': 'product-003',
+ 'name': 'Archived Product',
+ 'submission_url_slug': 'archived-product',
+ 'owner_language': 'en',
+ 'assigned_owner_ids': [],
+ 'status': 'archived',
+ 'description': 'This product is archived'
+ }, f)
+
+ # Product 4: Active but missing slug (should not appear)
+ product4_dir = os.path.join(products_dir, 'product-004')
+ os.makedirs(product4_dir, exist_ok=True)
+ with open(os.path.join(product4_dir, 'config.yaml'), 'w') as f:
+ yaml.dump({
+ 'product_id': 'product-004',
+ 'name': 'No Slug Product',
+ 'submission_url_slug': '',
+ 'owner_language': 'en',
+ 'assigned_owner_ids': [],
+ 'status': 'active',
+ 'description': 'Product with missing slug'
+ }, f)
+
+ # Product 5: XSS test product
+ product5_dir = os.path.join(products_dir, 'product-005')
+ os.makedirs(product5_dir, exist_ok=True)
+ with open(os.path.join(product5_dir, 'config.yaml'), 'w') as f:
+ yaml.dump({
+ 'product_id': 'product-005',
+ 'name': 'Evil Product',
+ 'submission_url_slug': 'xss-product',
+ 'owner_language': 'en',
+ 'assigned_owner_ids': [],
+ 'status': 'active',
+ 'description': '
Malicious description'
+ }, f)
+
+ yield
+
+
+@pytest.mark.contract
+def test_get_landing_page_with_products(client, test_products):
+ """T002: GET / with active products returns 200 with product list HTML"""
+ response = client.get('/')
+
+ assert response.status_code == 200
+ assert b'' not in html, "Script tag not escaped in product name"
+ assert 'alert("xss")' not in html or '<script>' in html, "XSS vulnerability in product name"
+
+ # Image onerror should be escaped
+ assert '