277 lines
8.1 KiB
Markdown
277 lines
8.1 KiB
Markdown
# Quickstart: Product Selection Landing Page
|
|||
|
|
|
||
|
|
**Feature**: 002-product-list | **For**: Developers implementing this feature
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
Add a landing page at `/` that lists all active products for feedback submission. This is a simple addition to the existing Flask app: one route, one template, and corresponding tests.
|
||
|
|
|
||
|
|
## Implementation Checklist
|
||
|
|
|
||
|
|
### 1. Extend Product Model
|
||
|
|
|
||
|
|
**File**: `app/models/product.py`
|
||
|
|
|
||
|
|
**Add this class method**:
|
||
|
|
|
||
|
|
```python
|
||
|
|
@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.
|
||
|
|
"""
|
||
|
|
all_products = cls.load_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))
|
||
|
|
```
|
||
|
|
|
||
|
|
**Why**: Centralizes filtering and sorting logic. Reusable if other features need active product lists.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 2. Create Landing Route
|
||
|
|
|
||
|
|
**File**: `app/routes/landing.py` (new file)
|
||
|
|
|
||
|
|
**Implementation**:
|
||
|
|
|
||
|
|
```python
|
||
|
|
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."""
|
||
|
|
try:
|
||
|
|
products = Product.load_active()
|
||
|
|
current_app.logger.info(
|
||
|
|
f'Landing page accessed: {len(products)} active products'
|
||
|
|
)
|
||
|
|
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)
|
||
|
|
return render_template('landing/index.html', products=[])
|
||
|
|
```
|
||
|
|
|
||
|
|
**Register blueprint** in `app/__init__.py`:
|
||
|
|
|
||
|
|
```python
|
||
|
|
from app.routes.landing import landing_bp
|
||
|
|
app.register_blueprint(landing_bp)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 3. Create Landing Template
|
||
|
|
|
||
|
|
**File**: `app/templates/landing/index.html` (new file)
|
||
|
|
|
||
|
|
**Template structure**:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>Select a Product - Reklamator</title>
|
||
|
|
<style>
|
||
|
|
body { font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }
|
||
|
|
h1 { color: #333; }
|
||
|
|
ul { list-style: none; padding: 0; }
|
||
|
|
li { margin: 20px 0; padding: 15px; border: 1px solid #ddd; border-radius: 4px; }
|
||
|
|
a { font-size: 1.2em; color: #0066cc; text-decoration: none; }
|
||
|
|
a:hover { text-decoration: underline; }
|
||
|
|
p { margin: 5px 0 0 0; color: #666; }
|
||
|
|
.empty-state { color: #666; padding: 20px; text-align: center; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<h1>Select a Product for Feedback</h1>
|
||
|
|
|
||
|
|
{% if products %}
|
||
|
|
<ul>
|
||
|
|
{% for product in products %}
|
||
|
|
<li>
|
||
|
|
<a href="/submit/{{ product.submission_url_slug }}">
|
||
|
|
{{ product.name }}
|
||
|
|
</a>
|
||
|
|
{% if product.description %}
|
||
|
|
<p>{{ product.description }}</p>
|
||
|
|
{% endif %}
|
||
|
|
</li>
|
||
|
|
{% endfor %}
|
||
|
|
</ul>
|
||
|
|
{% else %}
|
||
|
|
<p class="empty-state">
|
||
|
|
No products are currently accepting feedback. Please check back later.
|
||
|
|
</p>
|
||
|
|
{% endif %}
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
```
|
||
|
|
|
||
|
|
**Key points**:
|
||
|
|
- Jinja2 auto-escaping prevents XSS (product.name, product.description)
|
||
|
|
- No JavaScript (pure server-side rendering)
|
||
|
|
- Minimal inline CSS (no frameworks)
|
||
|
|
- Conditional rendering for empty state
|
||
|
|
- Only shows description if present (no placeholder text)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 4. Write Contract Tests
|
||
|
|
|
||
|
|
**File**: `tests/contract/test_landing_routes.py` (new file)
|
||
|
|
|
||
|
|
**Test scenarios to implement**:
|
||
|
|
|
||
|
|
```python
|
||
|
|
import pytest
|
||
|
|
import os
|
||
|
|
import yaml
|
||
|
|
from app.models.product import Product
|
||
|
|
|
||
|
|
@pytest.mark.contract
|
||
|
|
def test_get_landing_page_with_products(client, temp_data_dir):
|
||
|
|
"""T301: GET / returns 200 with product list"""
|
||
|
|
# Setup: Create 2 active products
|
||
|
|
# Assert: 200 OK, both products in HTML
|
||
|
|
pass
|
||
|
|
|
||
|
|
@pytest.mark.contract
|
||
|
|
def test_get_landing_page_no_products(client, temp_data_dir):
|
||
|
|
"""T302: GET / with no active products shows empty state"""
|
||
|
|
# Assert: 200 OK, contains "No products are currently accepting feedback"
|
||
|
|
pass
|
||
|
|
|
||
|
|
@pytest.mark.contract
|
||
|
|
def test_get_landing_page_filters_archived(client, temp_data_dir):
|
||
|
|
"""T303: GET / excludes archived products"""
|
||
|
|
# Setup: 1 active, 1 archived
|
||
|
|
# Assert: Only active product shown
|
||
|
|
pass
|
||
|
|
|
||
|
|
@pytest.mark.contract
|
||
|
|
def test_get_landing_page_sorting(client, temp_data_dir):
|
||
|
|
"""T304: GET / sorts products alphabetically (name, then product_id)"""
|
||
|
|
# Setup: Products with names "Zebra", "Apple", "apple" (different product_ids)
|
||
|
|
# Assert: Correct alphabetical order
|
||
|
|
pass
|
||
|
|
|
||
|
|
@pytest.mark.contract
|
||
|
|
def test_get_landing_page_xss_prevention(client, temp_data_dir):
|
||
|
|
"""T305: GET / escapes HTML in product names"""
|
||
|
|
# Setup: Product with name "<script>alert('xss')</script>"
|
||
|
|
# Assert: HTML is escaped, script not executed
|
||
|
|
pass
|
||
|
|
|
||
|
|
@pytest.mark.contract
|
||
|
|
def test_get_landing_page_missing_slug(client, temp_data_dir):
|
||
|
|
"""T306: GET / excludes products with missing submission_url_slug"""
|
||
|
|
# Setup: Product with submission_url_slug = None
|
||
|
|
# Assert: Product not shown in list
|
||
|
|
pass
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 5. Write Integration Tests
|
||
|
|
|
||
|
|
**File**: `tests/integration/test_landing_flow.py` (new file)
|
||
|
|
|
||
|
|
**User journey test**:
|
||
|
|
|
||
|
|
```python
|
||
|
|
@pytest.mark.integration
|
||
|
|
def test_landing_to_submission_flow(client, temp_data_dir):
|
||
|
|
"""T307: Complete flow - landing page → product selection → submission form"""
|
||
|
|
# Step 1: Visit landing page, see products
|
||
|
|
# Step 2: Click product link
|
||
|
|
# Step 3: Verify redirected to /submit/{slug}
|
||
|
|
pass
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Key Implementation Notes
|
||
|
|
|
||
|
|
### XSS Prevention
|
||
|
|
- ✅ Jinja2 auto-escaping handles product names and descriptions
|
||
|
|
- ✅ No manual HTML escaping needed
|
||
|
|
- ✅ Test with `<script>` tags in product names to verify
|
||
|
|
|
||
|
|
### Empty State Handling
|
||
|
|
- ✅ Check `{% if products %}` in template
|
||
|
|
- ✅ Display message: "No products are currently accepting feedback. Please check back later."
|
||
|
|
- ✅ No blank page or error
|
||
|
|
|
||
|
|
### Logging
|
||
|
|
- ✅ Log landing page access with product count
|
||
|
|
- ✅ Log errors if product loading fails
|
||
|
|
- ✅ Use `current_app.logger.info()` for access logs
|
||
|
|
|
||
|
|
### Backwards Compatibility
|
||
|
|
- ✅ Existing `/submit/{slug}` routes unchanged
|
||
|
|
- ✅ Direct product URLs still work
|
||
|
|
- ✅ Landing page is additive only
|
||
|
|
|
||
|
|
### Performance
|
||
|
|
- ✅ Target: <1 second for up to 100 products
|
||
|
|
- ✅ File I/O ~10-50ms for 100 YAML files
|
||
|
|
- ✅ No caching needed for MVP
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Testing Workflow (TDD)
|
||
|
|
|
||
|
|
**Follow this order** (Constitution Principle II):
|
||
|
|
|
||
|
|
1. **Write contract tests** (test_landing_routes.py) - all should FAIL
|
||
|
|
2. **Verify tests fail** - proves they test something meaningful
|
||
|
|
3. **Implement Product.load_active()** method
|
||
|
|
4. **Implement landing route** (landing.py)
|
||
|
|
5. **Create landing template** (index.html)
|
||
|
|
6. **Run tests** - contract tests should PASS
|
||
|
|
7. **Write integration tests** (test_landing_flow.py) - should FAIL
|
||
|
|
8. **Fix any issues** - integration tests should PASS
|
||
|
|
9. **Refactor** while keeping tests green
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Manual Verification Checklist
|
||
|
|
|
||
|
|
After all tests pass, manually verify:
|
||
|
|
|
||
|
|
- [ ] Visit `/` - see product list or empty state
|
||
|
|
- [ ] Click product link - redirected to `/submit/{slug}`
|
||
|
|
- [ ] Check with 0 active products - see empty message
|
||
|
|
- [ ] Check with 1 active product - see single product
|
||
|
|
- [ ] Check with 10+ active products - alphabetical order
|
||
|
|
- [ ] Check product with no description - no placeholder text
|
||
|
|
- [ ] Check product with long name - proper wrapping
|
||
|
|
- [ ] Check as anonymous user - page accessible
|
||
|
|
- [ ] Check as authenticated user - same page shown
|
||
|
|
- [ ] Check page source - no JavaScript present
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Completion Criteria
|
||
|
|
|
||
|
|
✅ All contract tests passing
|
||
|
|
✅ All integration tests passing
|
||
|
|
✅ Product.load_active() method implemented
|
||
|
|
✅ Landing route registered and functional
|
||
|
|
✅ Landing template created with proper escaping
|
||
|
|
✅ Manual verification completed
|
||
|
|
✅ Code follows existing Flask/Jinja2 patterns
|
||
|
|
✅ No new dependencies added
|
||
|
|
✅ Documentation updated (this file)
|
||
|
|
|
||
|
|
**Next**: Commit to branch `002-product-list` and create pull request
|