Ship Print Forge shop site (colors, photos, 3D viewer) #2

Merged
westfarn merged 2 commits from feature/print-forge-shop-site into master 2026-09-06 18:40:43 -07:00
336 changed files with 9241 additions and 2537 deletions
Showing only changes of commit b2029b6c0f - Show all commits
+32 -13
View File
@@ -10,17 +10,17 @@ DJANGO_SECRET_KEY=dev-only-change-me
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# Leave empty for SQLite, or point at local compose Postgres:
# DATABASE_URL=postgres://client_site:client_site@127.0.0.1:5432/client_site
# DATABASE_URL=postgres://print_forge:print_forge@127.0.0.1:5432/print_forge
SITE_UNDER_CONSTRUCTION=false
SITE_NAME=Your Company
SITE_TAGLINE=A tagline for the public site
SITE_NAME="Print Forge"
SITE_TAGLINE="Handcrafted 3D printed toys"
PUBLIC_SITE_URL=http://127.0.0.1:8000
CONTACT_PHONE=
CONTACT_EMAIL=hello@example.com
CONTACT_PHONE=630-632-0202
CONTACT_EMAIL=Printforgeprints@gmail.com
CONTACT_ADDRESS=
CONTACT_SERVICE_AREA=
CREDIT_NAME=AI ML Operations, LLC
CONTACT_SERVICE_AREA=Chicagoland
CREDIT_NAME="AI ML Operations, LLC"
CREDIT_URL=https://aimloperations.com
# ---------------------------------------------------------------------------
@@ -30,11 +30,15 @@ CREDIT_URL=https://aimloperations.com
# Disabled apps are omitted from INSTALLED_APPS (no models, admin, URLs, nav).
# ---------------------------------------------------------------------------
FEATURE_EMAIL_SMS=true
FEATURE_DIRECT_MAIL=true
FEATURE_BLOG=true
FEATURE_DIRECT_MAIL=false
FEATURE_BLOG=false
FEATURE_PAYMENTS=true
FEATURE_SOCIAL=true
FEATURE_SOCIAL_AI=true
FEATURE_SOCIAL=false
FEATURE_SOCIAL_AI=false
FEATURE_SHOP=true
FEATURE_POS_SYNC=false
FEATURE_EVENTS=false
FEATURE_SHIPPING=true
# reCAPTCHA (optional locally — form skips captcha when empty)
RECAPTCHA_PUBLIC_KEY=
@@ -48,8 +52,8 @@ EMAIL_PORT=2525
EMAIL_USE_TLS=true
# Inbox From. Docker Compose .env cannot parse: "Name" <addr>
# Use bare address (SITE_NAME is wrapped) OR one quoted string:
# DEFAULT_FROM_EMAIL="Your Company <noreply@example.com>"
DEFAULT_FROM_EMAIL=noreply@example.com
# DEFAULT_FROM_EMAIL="Your Company <noreply@printforgeprints.com>"
DEFAULT_FROM_EMAIL=Printforgeprints@gmail.com
# Console (default in DJANGO_ENV=dev) prints mail to container logs.
# EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
SMTP2GO_SMS_API_KEY=
@@ -74,6 +78,21 @@ STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_CURRENCY=usd
# Shop — FEATURE_SHOP (requires email/SMS + payments)
# SHOP_PRINT_QUEUE_LIMIT_MINUTES=0
# POS sync — FEATURE_POS_SYNC (requires shop). Required in beta/prod.
POS_WEBHOOK_SECRET=
POS_API_BASE_URL=
POS_API_TOKEN=
# Shipping — FEATURE_SHIPPING (requires shop). EasyPost optional; CSV export always works.
EASYPOST_API_KEY=
SHIP_FROM_LINE1=
SHIP_FROM_CITY=
SHIP_FROM_STATE=
SHIP_FROM_ZIP=
# Social — SOCIAL_TOKEN_ENCRYPTION_KEY required when FEATURE_SOCIAL=true
# LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env).
META_APP_ID=
+41 -22
View File
@@ -1,7 +1,7 @@
# Secret env files for server-infra deploy. Never commit.
# Copy on the control node:
# ~/Documents/secrets/<slug>_site/<slug>_site_prod.env
# ~/Documents/secrets/<slug>_site/<slug>_site_beta.env
# ~/Documents/secrets/print_forge/print_forge_prod.env
# ~/Documents/secrets/print_forge/print_forge_beta.env
#
# Docker Compose: if a secret contains $ (e.g. DATABASE_URL password), escape
# each $ as $$ or compose will treat $word as a variable.
@@ -9,34 +9,38 @@
DJANGO_ENV=prod
DJANGO_DEBUG=false
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
DJANGO_ALLOWED_HOSTS=printforgeprints.com,www.printforgeprints.com
SITE_UNDER_CONSTRUCTION=true
SITE_NAME=Your Company
SITE_TAGLINE=A tagline for the public site
PUBLIC_SITE_URL=https://example.com
DEFAULT_FROM_EMAIL=noreply@example.com
CONTACT_PHONE=
CONTACT_EMAIL=
SITE_NAME="Print Forge"
SITE_TAGLINE="Handcrafted 3D printed toys"
PUBLIC_SITE_URL=https://printforgeprints.com
DEFAULT_FROM_EMAIL=Printforgeprints@gmail.com
CONTACT_PHONE=630-632-0202
CONTACT_EMAIL=Printforgeprints@gmail.com
CONTACT_ADDRESS=
CONTACT_SERVICE_AREA=
CREDIT_NAME=AI ML Operations, LLC
CONTACT_SERVICE_AREA=Chicagoland
CREDIT_NAME="AI ML Operations, LLC"
CREDIT_URL=https://aimloperations.com
# Optional catalog features — false unless purchased.
# Turning a flag on later: set true, redeploy, migrate. Do not auto-drop tables.
FEATURE_EMAIL_SMS=false
FEATURE_EMAIL_SMS=true
FEATURE_DIRECT_MAIL=false
FEATURE_BLOG=false
FEATURE_PAYMENTS=false
FEATURE_PAYMENTS=true
FEATURE_SOCIAL=false
FEATURE_SOCIAL_AI=false
FEATURE_SHOP=true
FEATURE_POS_SYNC=false
FEATURE_EVENTS=false
FEATURE_SHIPPING=true
# Shared external Postgres (10.0.0.230)
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/print_forge
WEB_PORT=8004
WEB_PORT=8007
RECAPTCHA_PUBLIC_KEY=replace-me
RECAPTCHA_PRIVATE_KEY=replace-me
@@ -63,6 +67,21 @@ STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_CURRENCY=usd
# Required when FEATURE_SHOP=true (also requires FEATURE_EMAIL_SMS + FEATURE_PAYMENTS)
# SHOP_PRINT_QUEUE_LIMIT_MINUTES=0
# Required when FEATURE_POS_SYNC=true in beta/prod (also requires FEATURE_SHOP)
POS_WEBHOOK_SECRET=
POS_API_BASE_URL=
POS_API_TOKEN=
# Optional when FEATURE_SHIPPING=true (also requires FEATURE_SHOP)
EASYPOST_API_KEY=
SHIP_FROM_LINE1=
SHIP_FROM_CITY=
SHIP_FROM_STATE=
SHIP_FROM_ZIP=
# Required when FEATURE_SOCIAL=true
META_APP_ID=
META_APP_SECRET=
@@ -78,8 +97,8 @@ OLLAMA_TIMEOUT_SECONDS=120
# Token must match SHORTENER_API_TOKENS on url_shortening_service (name:secret).
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
SHORTENER_BASE_URL=https://shortener.aimloperations.com
SHORTENER_API_TOKEN=acme:replace-me
# UTM_SOURCE=acme # optional; default is a slug of SITE_NAME
SHORTENER_API_TOKEN=print_forge:replace-me
# UTM_SOURCE=print-forge # optional; default is a slug of SITE_NAME
NOMINATIM_BASE_URL=http://10.0.0.128:8089
NOMINATIM_TIMEOUT_SECONDS=8
@@ -92,11 +111,11 @@ TIANJI_WEBSITE_ID=
GUNICORN_WORKERS=2
GUNICORN_BIND=0.0.0.0:8000
# BETA overrides — <slug>-preview.aimloperations.com
# BETA overrides — print-forge-preview.aimloperations.com
# DJANGO_ENV=beta
# SITE_UNDER_CONSTRUCTION=false
# PUBLIC_SITE_URL=https://example-preview.aimloperations.com
# DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site_beta
# WEB_PORT=8014
# PUBLIC_SITE_URL=https://print-forge-preview.aimloperations.com
# DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/print_forge_beta
# WEB_PORT=8019
# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com
# SHORTENER_API_TOKEN=acme:replace-with-a-different-token
# SHORTENER_API_TOKEN=print_forge:replace-with-a-different-token
+4 -4
View File
@@ -3,7 +3,7 @@ name: Deploy Beta
on:
push:
branches:
- main
- master
jobs:
unit-tests:
@@ -55,7 +55,7 @@ jobs:
-e DJANGO_SECRET_KEY=test-secret-key \
-e DJANGO_DEBUG=true \
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
-e DATABASE_URL=postgres://client_site:client_site@db:5432/client_site \
-e DATABASE_URL=postgres://print_forge:print_forge@db:5432/print_forge \
-e SITE_UNDER_CONSTRUCTION=false \
web uv run python manage.py test
@@ -66,9 +66,9 @@ jobs:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
steps:
- name: Deploy client_site beta to all webservers
- name: Deploy print_forge beta to all webservers
run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
--app client_site \
--app print_forge \
--env beta \
--ref "${{ gitea.sha }}"
+3 -3
View File
@@ -53,7 +53,7 @@ jobs:
-e DJANGO_SECRET_KEY=test-secret-key \
-e DJANGO_DEBUG=true \
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
-e DATABASE_URL=postgres://client_site:client_site@db:5432/client_site \
-e DATABASE_URL=postgres://print_forge:print_forge@db:5432/print_forge \
-e SITE_UNDER_CONSTRUCTION=false \
web uv run python manage.py test
@@ -64,9 +64,9 @@ jobs:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
steps:
- name: Deploy client_site prod to all webservers
- name: Deploy print_forge prod to all webservers
run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
--app client_site \
--app print_forge \
--env prod \
--ref "${{ gitea.sha }}"
+3
View File
@@ -177,3 +177,6 @@ cython_debug/
# Django collectstatic output
staticfiles/
# Downloaded Pexels originals used to regenerate public photos
.pexels-cache/
+1 -1
View File
@@ -9,7 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& apt-get install -y --no-install-recommends libpq5 libgomp1 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
+26 -55
View File
@@ -1,49 +1,31 @@
# web_django_template
# Print Forge
Gitea template for AI ML Operations client websites
3D printed toy shop for Print Forge
([Web Design & Hosting](https://aimloperations.com/web_design)).
Clone this repo (or **Use this template**), run `scripts/bootstrap-client.sh`,
set branding + `FEATURE_*` flags in the secret env file, deploy.
Public site + client portal, plus purchased catalog add-ons: email & SMS,
payments, e-commerce, and shipping automation.
Always included: public site (landing, about, contact) and client portal
(login, dashboard, leads, UTM analytics). Optional catalog add-ons are
separate Django apps. A flag that is off keeps the app out of
`INSTALLED_APPS` — no models, admin, URLs, or portal nav.
## Catalog → flags
## Catalog
| Catalog item | Flag | App |
|---|---|---|
| Public site | (always on) | `public` |
| Client portal + UTM | (always on) | `accounts`, `dashboard`, `leads`, `analytics`, `contacts`, `core` |
| Email & SMS | `FEATURE_EMAIL_SMS` | `email_sms` |
| Direct mail (PCM) | `FEATURE_DIRECT_MAIL` | `directmail` |
| Blog | `FEATURE_BLOG` | `blog` |
| Payments (Stripe) | `FEATURE_PAYMENTS` | `payments` (requires email/SMS) |
| Social consolidation | `FEATURE_SOCIAL` | `social` |
| AI social generator | `FEATURE_SOCIAL_AI` | `social_ai` (requires social) |
| Payments (Stripe) | `FEATURE_PAYMENTS` | `payments` |
| E-commerce storefront | `FEATURE_SHOP` | `shop` |
| Shipping automation | `FEATURE_SHIPPING` | `shipping` |
## New client
Off for this client: direct mail, blog, social, social AI, POS sync, event ticketing.
```bash
# 1. Create repo from this Gitea template, clone it.
# 2. Rename package + branding placeholders:
./scripts/bootstrap-client.sh acme_site "Acme HVAC" acmehvac.com
## Contact
# 3. Brand public templates and static/brand/ (logo, colors, copy).
- Phone: 630-632-0202
- Email: Printforgeprints@gmail.com
- Public URL (placeholder until DNS): https://printforgeprints.com
# 4. Secrets on the control node:
mkdir -p ~/Documents/secrets/acme_site
cp .env.prod.example ~/Documents/secrets/acme_site/acme_site_prod.env
# Flip only purchased FEATURE_* flags. Fill provider secrets.
./scripts/validate-env.sh ~/Documents/secrets/acme_site/acme_site_prod.env
# 5. Infra: Postgres on 10.0.0.230, server-infra app_catalog, NPM, CI.
# 6. Deploy. Create a portal user (createsuperuser).
```
Local:
## Local
```bash
cp .env.example .env
@@ -54,30 +36,19 @@ uv run python manage.py runserver
# or: docker compose up
```
## Deploy
```bash
mkdir -p ~/Documents/secrets/print_forge
cp .env.prod.example ~/Documents/secrets/print_forge/print_forge_prod.env
# Fill Stripe, SMTP2GO, shortener, and DB secrets.
./scripts/validate-env.sh ~/Documents/secrets/print_forge/print_forge_prod.env
```
Infra: Postgres on 10.0.0.230, server-infra app_catalog, NPM, CI.
Create a portal user with `createsuperuser` after first deploy.
## Stack
Django 6 + uv · Postgres (prod/beta); SQLite locally · dj-queue · Docker ·
server-infra Ansible deploy.
Ollama for social drafts lives on the LAN at `http://10.0.0.128:11434`.
Do not call a public Ollama host.
## Campaign tracked links (piha.li)
When `FEATURE_EMAIL_SMS` or `FEATURE_DIRECT_MAIL` is on, composers auto-insert a
homepage URL with `utm_source` (slug of `SITE_NAME`, or `UTM_SOURCE`),
`utm_medium` = channel, and `utm_campaign` = campaign name. Configured
`SHORTENER_BASE_URL` + `SHORTENER_API_TOKEN` mint that URL via
[url_shortening_service](https://git.aimloperations.com/ai_ml_operations/url_shortening_service)
and put `piha.li` / `beta.piha.li` short links in SMS, email hrefs, and postcard QR codes.
Caller secrets (this repo's env file):
```text
SHORTENER_BASE_URL=https://shortener.aimloperations.com
SHORTENER_API_TOKEN=<slug>:<secret>
```
Operator secrets (`url_shortening_service_*`): `SHORTENER_API_TOKENS=<slug>:<same-secret>`
and `SHORT_ALLOWED_HOSTS` must include the client domain. Mint against the **API host**,
never `piha.li`.
+21 -12
View File
@@ -2,13 +2,13 @@ services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: client_site
POSTGRES_USER: client_site
POSTGRES_PASSWORD: client_site
POSTGRES_DB: print_forge
POSTGRES_USER: print_forge
POSTGRES_PASSWORD: print_forge
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U client_site -d client_site"]
test: ["CMD-SHELL", "pg_isready -U print_forge -d print_forge"]
interval: 5s
timeout: 5s
retries: 10
@@ -19,8 +19,11 @@ services:
ports:
- "8000:8000"
# Bind-mount source so edit → runserver auto-reload (no rebuild).
# Lockfile mounts + entrypoint `uv sync` pick up new deps without a rebuild.
volumes:
- ./site:/app/site
- ./pyproject.toml:/app/pyproject.toml
- ./uv.lock:/app/uv.lock
# No required env_file — CI has no .env. Defaults below; for local secrets:
# docker compose --env-file .env up
#
@@ -32,21 +35,25 @@ services:
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://client_site:client_site@db:5432/client_site}
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://print_forge:print_forge@db:5432/print_forge}
SITE_UNDER_CONSTRUCTION: ${SITE_UNDER_CONSTRUCTION:-false}
FEATURE_EMAIL_SMS: ${FEATURE_EMAIL_SMS:-true}
FEATURE_DIRECT_MAIL: ${FEATURE_DIRECT_MAIL:-true}
FEATURE_BLOG: ${FEATURE_BLOG:-true}
FEATURE_DIRECT_MAIL: ${FEATURE_DIRECT_MAIL:-false}
FEATURE_BLOG: ${FEATURE_BLOG:-false}
FEATURE_PAYMENTS: ${FEATURE_PAYMENTS:-true}
FEATURE_SOCIAL: ${FEATURE_SOCIAL:-true}
FEATURE_SOCIAL_AI: ${FEATURE_SOCIAL_AI:-true}
FEATURE_SOCIAL: ${FEATURE_SOCIAL:-false}
FEATURE_SOCIAL_AI: ${FEATURE_SOCIAL_AI:-false}
FEATURE_SHOP: ${FEATURE_SHOP:-true}
FEATURE_POS_SYNC: ${FEATURE_POS_SYNC:-false}
FEATURE_EVENTS: ${FEATURE_EVENTS:-false}
FEATURE_SHIPPING: ${FEATURE_SHIPPING:-true}
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
EMAIL_PORT: ${EMAIL_PORT:-2525}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-true}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@example.com}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-Printforgeprints@gmail.com}
# Dev default = console. Set smtp backend in `.env` for real SMTP2GO sends.
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
@@ -78,12 +85,14 @@ services:
entrypoint: ["/worker-entrypoint.sh"]
volumes:
- ./site:/app/site
- ./pyproject.toml:/app/pyproject.toml
- ./uv.lock:/app/uv.lock
environment:
DJANGO_ENV: ${DJANGO_ENV:-dev}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://client_site:client_site@db:5432/client_site}
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://print_forge:print_forge@db:5432/print_forge}
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
DISPATCH_DUE_INTERVAL_SECONDS: ${DISPATCH_DUE_INTERVAL_SECONDS:-15}
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
@@ -91,7 +100,7 @@ services:
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
EMAIL_PORT: ${EMAIL_PORT:-2525}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-true}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@example.com}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-Printforgeprints@gmail.com}
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-}
+4 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "web-django-template"
name = "print-forge"
version = "0.1.0"
description = "Template for AI ML Operations client websites (public site + portal + optional catalog apps)"
description = "Print Forge — 3D printed toy shop (public site, portal, shop, shipping)"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
@@ -16,6 +16,8 @@ dependencies = [
"requests>=2.32.0",
"stripe>=11.0.0",
"whitenoise>=6.9.0",
"pillow>=12.3.0",
"rembg[cpu]>=2.0.83",
]
[dependency-groups]
+7 -7
View File
@@ -16,19 +16,19 @@ fi
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if [[ "$SLUG" == "client_site" ]]; then
echo "slug is already client_site — branding defaults only."
if [[ "$SLUG" == "print_forge" ]]; then
echo "slug is already print_forge — branding defaults only."
else
echo "Renaming package client_site → $SLUG"
if [[ -d site/client_site ]]; then
mv site/client_site "site/$SLUG"
echo "Renaming package print_forge → $SLUG"
if [[ -d site/print_forge ]]; then
mv site/print_forge "site/$SLUG"
fi
grep -rl 'client_site' \
grep -rl 'print_forge' \
--exclude-dir=.git --exclude-dir=.venv --exclude-dir=__pycache__ \
--exclude='*.png' --exclude='*.jpg' --exclude='*.woff*' --exclude='*.eot' \
--exclude='uv.lock' . \
| while read -r f; do
sed -i "s/client_site/${SLUG}/g" "$f"
sed -i "s/print_forge/${SLUG}/g" "$f"
done
fi
+7 -2
View File
@@ -1,6 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd /app
if [[ "${DJANGO_ENV:-}" == "dev" || "${DJANGO_USE_RUNSERVER:-}" == "true" ]]; then
echo "Syncing Python deps..."
uv sync --frozen
fi
cd /app/site
wait_for_database() {
@@ -14,7 +19,7 @@ wait_for_database() {
import os
import sys
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "print_forge.settings")
import django
from django.db import connections
@@ -51,6 +56,6 @@ fi
uv run python manage.py collectstatic --noinput
exec uv run gunicorn client_site.wsgi:application \
exec uv run gunicorn print_forge.wsgi:application \
--bind "${GUNICORN_BIND:-0.0.0.0:8000}" \
--workers "${GUNICORN_WORKERS:-2}"
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Replace public-site t-shirt mockups with 3D printer / printed-toy photos."""
from __future__ import annotations
import io
import urllib.request
from pathlib import Path
from PIL import Image, ImageOps
ROOT = Path(__file__).resolve().parent.parent
IMAGES = ROOT / "site" / "print_forge" / "static" / "images"
CACHE = ROOT / ".pexels-cache"
USER_HERO = [
Path(
"/home/westfarn/.cursor/projects/home-westfarn-Documents-repos-print-forge/assets/"
"pexels-jakubzerdzicki-20688553-82987c3e-5bc2-4514-9eb0-2eb9518d5d63.png"
),
Path(
"/home/westfarn/.cursor/projects/home-westfarn-Documents-repos-print-forge/assets/"
"pexels-jakubzerdzicki-24859620-43b11020-90b7-4d02-9c78-d28b2cc14a4e.png"
),
]
PEXELS = {
"figurine": 13624760,
"miniatures": 35063297,
"tray": 19583534,
"neon": 30720501,
"extruder": 31336922,
"spool": 30658376,
"lab": 23533991,
"neon2": 18296466,
"workshop": 30482193,
"nozzle": 23533982,
"hex": 30620861,
"orange_parts": 20688553,
"maker": 24859620,
}
UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
)
def fetch_pexels(photo_id: int) -> Path:
CACHE.mkdir(parents=True, exist_ok=True)
dest = CACHE / f"pexels-{photo_id}.jpg"
if dest.exists() and dest.stat().st_size > 20_000:
return dest
url = (
f"https://images.pexels.com/photos/{photo_id}/"
f"pexels-photo-{photo_id}.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=2000"
)
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "image/*"})
with urllib.request.urlopen(req, timeout=60) as resp:
data = resp.read()
if len(data) < 5000:
raise RuntimeError(f"Pexels {photo_id} too small ({len(data)} bytes)")
dest.write_bytes(data)
return dest
def open_image(path: Path) -> Image.Image:
image = Image.open(path)
image.load()
image = ImageOps.exif_transpose(image) or image
return image.convert("RGB")
def cover_crop(image: Image.Image, width: int, height: int, focus: str = "center") -> Image.Image:
src_w, src_h = image.size
scale = max(width / src_w, height / src_h)
new_w = max(width, int(src_w * scale + 0.5))
new_h = max(height, int(src_h * scale + 0.5))
resized = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
if focus == "left":
left = 0
elif focus == "right":
left = new_w - width
else:
left = (new_w - width) // 2
if focus == "top":
top = 0
elif focus == "bottom":
top = new_h - height
else:
top = (new_h - height) // 2
return resized.crop((left, top, left + width, top + height))
def save_jpeg(image: Image.Image, dest: Path, quality: int = 86) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
image.save(dest, format="JPEG", quality=quality, optimize=True, progressive=True)
def save_png(image: Image.Image, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
image.save(dest, format="PNG", optimize=True)
def main() -> None:
sources: dict[str, Image.Image] = {
"hero1": open_image(USER_HERO[0]),
"hero2": open_image(USER_HERO[1]),
}
failed = []
for name, photo_id in PEXELS.items():
try:
sources[name] = open_image(fetch_pexels(photo_id))
print(f"ok {name} {photo_id} {sources[name].size}")
except Exception as exc: # noqa: BLE001
failed.append(f"{name}/{photo_id}: {exc}")
print(f"fail {name} {photo_id}: {exc}")
def src(*names: str) -> Image.Image:
for name in names:
if name in sources:
return sources[name]
raise KeyError(names)
jobs = [
("slider-image-01-1132x657.jpg", src("hero1"), (1132, 657), "center", "jpeg"),
("slider-image-02-1132x657.jpg", src("hero2"), (1132, 657), "center", "jpeg"),
("box-nina-img-364x474.jpg", src("figurine", "tray", "hero1"), (364, 474), "center", "jpeg"),
("box-nina-img-mini-217x328.jpg", src("spool", "extruder", "hero2"), (217, 328), "center", "jpeg"),
("gallery-22-1200x800-original.jpg", src("figurine", "hero1"), (1200, 800), "center", "jpeg"),
("gallery-22-582x299.jpg", src("figurine", "hero1"), (582, 299), "center", "jpeg"),
("gallery-22-582x558.jpg", src("workshop", "hero2"), (582, 558), "center", "jpeg"),
("gallery-4-1200x800-original.jpg", src("miniatures", "tray", "hero1"), (1200, 800), "center", "jpeg"),
("gallery-23-287x252.jpg", src("miniatures", "tray", "hero1"), (287, 252), "center", "jpeg"),
("gallery-23-1200x800-original.jpg", src("tray", "miniatures", "hero1"), (1200, 800), "center", "jpeg"),
("quote-1-287x252.jpg", src("tray", "miniatures", "hero1"), (287, 252), "center", "jpeg"),
("gallery-20-800x1200-original.jpg", src("spool", "extruder", "hero2"), (800, 1200), "center", "jpeg"),
("gallery-20-287x252.jpg", src("spool", "extruder", "hero2"), (287, 252), "center", "jpeg"),
("gallery-2-1200x800-original.jpg", src("neon", "neon2", "hero2"), (1200, 800), "center", "jpeg"),
("gallery-21-287x252.jpg", src("neon", "neon2", "hero2"), (287, 252), "center", "jpeg"),
("gallery-21-1200x800-original.jpg", src("lab", "workshop", "hero2"), (1200, 800), "center", "jpeg"),
("video-1-549x384.jpg", src("nozzle", "extruder", "hero1"), (549, 384), "center", "jpeg"),
("image-1-333x262.jpg", src("tray", "miniatures", "hero1"), (333, 262), "center", "jpeg"),
("bg-breadcrumbs.jpg", src("hero2", "workshop", "lab"), (1920, 414), "center", "jpeg"),
("bg-image-1.jpg", src("hero2", "workshop", "lab"), (1920, 950), "center", "jpeg"),
("product-1-292x256.png", src("figurine", "hero1"), (292, 256), "center", "png"),
("product-2-292x256.png", src("miniatures", "tray", "hero1"), (292, 256), "center", "png"),
("product-3-292x256.png", src("tray", "hero1"), (292, 256), "center", "png"),
("product-4-292x256.png", src("hex", "neon", "hero2"), (292, 256), "center", "png"),
("product-5-292x256.png", src("spool", "extruder", "hero2"), (292, 256), "center", "png"),
("product-6-292x256.png", src("lab", "workshop", "hero2"), (292, 256), "center", "png"),
("product-mini-1-146x132.png", src("figurine", "hero1"), (146, 132), "center", "png"),
("product-mini-2-146x132.png", src("tray", "hero1"), (146, 132), "center", "png"),
("single-product-1-530x480.png", src("figurine", "hero1"), (530, 480), "center", "png"),
("single-product-2-530x480.png", src("miniatures", "tray", "hero1"), (530, 480), "center", "png"),
("single-product-3-530x480.png", src("hero1", "tray"), (530, 480), "center", "png"),
]
for filename, image, size, focus, kind in jobs:
framed = cover_crop(image, *size, focus=focus)
dest = IMAGES / filename
if kind == "png":
save_png(framed, dest)
else:
save_jpeg(framed, dest)
print(f"wrote {filename} {framed.size}")
if failed:
print("download warnings:")
for item in failed:
print(" ", item)
if __name__ == "__main__":
main()
+35
View File
@@ -82,6 +82,41 @@ if _true "${FEATURE_SOCIAL_AI:-}"; then
exit 1
fi
fi
if _true "${FEATURE_SHOP:-}"; then
if ! _true "${FEATURE_EMAIL_SMS:-}"; then
echo "FEATURE_SHOP requires FEATURE_EMAIL_SMS" >&2
exit 1
fi
if ! _true "${FEATURE_PAYMENTS:-}"; then
echo "FEATURE_SHOP requires FEATURE_PAYMENTS" >&2
exit 1
fi
fi
if _true "${FEATURE_POS_SYNC:-}"; then
if ! _true "${FEATURE_SHOP:-}"; then
echo "FEATURE_POS_SYNC requires FEATURE_SHOP" >&2
exit 1
fi
if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then
required_vars+=(POS_WEBHOOK_SECRET)
fi
fi
if _true "${FEATURE_EVENTS:-}"; then
if ! _true "${FEATURE_EMAIL_SMS:-}"; then
echo "FEATURE_EVENTS requires FEATURE_EMAIL_SMS" >&2
exit 1
fi
if ! _true "${FEATURE_PAYMENTS:-}"; then
echo "FEATURE_EVENTS requires FEATURE_PAYMENTS" >&2
exit 1
fi
fi
if _true "${FEATURE_SHIPPING:-}"; then
if ! _true "${FEATURE_SHOP:-}"; then
echo "FEATURE_SHIPPING requires FEATURE_SHOP" >&2
exit 1
fi
fi
missing=()
for var in "${required_vars[@]}"; do
+6 -1
View File
@@ -1,6 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd /app
if [[ "${DJANGO_ENV:-}" == "dev" ]]; then
echo "Syncing Python deps..."
uv sync --frozen
fi
cd /app/site
wait_for_database() {
@@ -14,7 +19,7 @@ wait_for_database() {
import os
import sys
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "print_forge.settings")
import django
from django.db import connections
+2 -1
View File
@@ -5,7 +5,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · Portal · {{ SITE_NAME }}</title>
<link rel="icon" href="{% static 'brand/favicon-32.png' %}" type="image/png">
<link rel="icon" href="{% static 'brand/favicon.ico' %}" type="image/x-icon">
<link rel="apple-touch-icon" href="{% static 'brand/apple-touch-icon.png' %}">
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Work+Sans:300,400,500,700%7CPoppins:400,600,700">
<link rel="stylesheet" href="{% static 'css/portal.css' %}">
</head>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,373 +0,0 @@
/* EXIT Realty / MKDRealtor public brand overrides on Real Estate theme */
:root {
--monica-primary: #00626c;
--monica-primary-light: #008898;
--monica-ink: #212121;
--monica-muted: #6b7280;
--monica-surface: #f4f7f7;
--monica-border: #d9e3e4;
}
.footer-brand-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.footer-brand-row img {
height: 40px;
width: auto;
}
.about-subtitle {
font-size: 18px;
color: var(--monica-muted);
}
.page .button-primary,
.page .button-primary:focus,
.page .button-primary:active,
.cookie-consent-banner .button-primary,
.cookie-consent-banner .button-primary:focus,
.cookie-consent-banner .button-primary:active {
background-color: var(--monica-primary) !important;
border-color: var(--monica-primary) !important;
color: #fff !important;
}
.page .button-primary:hover,
.cookie-consent-banner .button-primary:hover {
background-color: var(--monica-primary-light) !important;
border-color: var(--monica-primary-light) !important;
color: #fff !important;
}
/* Theme default blue (#0045b6) → EXIT Realty teal on public chrome */
html .page .text-primary {
color: var(--monica-primary) !important;
}
.page .breadcrumbs-custom-path a,
.page a.link-default {
color: var(--monica-primary);
}
.page a.link-default:hover {
color: var(--monica-primary-light);
}
.page .button-gray-bordered:hover,
.page .button-gray-bordered:active,
.page .button-default:hover,
.page .button-default:active,
.page .button-accent-outline:hover,
.page .button-accent-outline:active,
.page .button-primary-outline:hover,
.page .button-primary-outline:active,
.page .button-primary-white:hover,
.page .button-primary-white:active,
.page .button.button-primary-lighten,
.page .button.button-primary-lighten:focus {
background-color: var(--monica-primary) !important;
border-color: var(--monica-primary) !important;
color: #fff !important;
}
.page .button.button-primary-lighten:hover,
.page .button.button-primary-lighten:active {
background-color: var(--monica-ink) !important;
border-color: var(--monica-ink) !important;
}
.page .button-accent-outline,
.page .button-accent-outline:focus {
border-color: var(--monica-primary) !important;
}
.page .button-video:hover {
color: var(--monica-primary) !important;
}
.page .btn-primary,
.page .btn-primary:active,
.page .btn-primary:focus {
background: var(--monica-primary) !important;
border-color: var(--monica-primary) !important;
}
.page .box-chloe__icon,
.page .box-light-icon,
.page .box-minimal-icon .box-chloe__icon {
color: var(--monica-primary);
}
/* Mobile drawer active/hover — theme uses #0045b6 */
.rd-navbar-fixed .rd-nav-item:hover .rd-nav-link,
.rd-navbar-fixed .rd-nav-item.focus .rd-nav-link,
.rd-navbar-fixed .rd-nav-item.active .rd-nav-link,
.rd-navbar-fixed .rd-nav-item.opened .rd-nav-link {
background: var(--monica-primary) !important;
color: #fff !important;
}
/* Scroll-to-top (injected outside .page) */
.ui-to-top {
background: var(--monica-primary) !important;
color: #fff !important;
box-shadow: 0 6px 16px rgba(0, 98, 108, 0.35);
}
.ui-to-top:hover,
.ui-to-top:focus {
background: var(--monica-primary-light) !important;
color: #fff !important;
}
/* Django flash messages */
.flash {
list-style: none;
margin: 0;
padding: 12px 0 0;
}
.flash li {
max-width: 1200px;
margin: 0 auto 8px;
padding: 12px 16px;
background: #e8f4f5;
border-left: 3px solid #00626c;
color: #0d4a52;
font-size: 14px;
}
.flash li.error,
.flash li.danger {
background: #fee2e2;
border-left-color: #b91c1c;
color: #991b1b;
}
/* Form field errors */
.form-wrap .errorlist {
list-style: none;
margin: 6px 0 0;
padding: 0;
color: #b91c1c;
font-size: 13px;
}
/* Cookie consent — match public EXIT Realty teal brand */
.cookie-consent-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 11000;
padding: 1rem 1.25rem;
background: rgba(17, 24, 28, 0.96);
border-top: 2px solid var(--monica-primary);
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.28);
font-family: "Work Sans", sans-serif;
}
.cookie-consent-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.cookie-consent-text {
flex: 1 1 420px;
color: #d7dee3;
font-size: 0.9375rem;
line-height: 1.45;
margin: 0;
font-family: "Work Sans", sans-serif;
font-weight: 400;
}
.cookie-consent-text-short {
display: none;
}
.cookie-consent-actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
.cookie-consent-banner .button {
min-width: 10rem;
padding-left: 1.5rem;
padding-right: 1.5rem;
letter-spacing: 0.04em;
text-transform: uppercase;
font-family: "Work Sans", sans-serif;
font-weight: 500;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.footer-link-btn {
background: none;
border: none;
padding: 0;
color: #7dd3da;
font: inherit;
font-size: 13px;
cursor: pointer;
text-decoration: none;
}
.footer-link-btn:hover {
text-decoration: underline;
color: #a8e8ed;
}
.cookie-consent-link {
color: #7dd3da;
text-decoration: underline;
text-underline-offset: 2px;
}
.cookie-consent-link:hover {
color: #fff;
}
.legal-prose {
max-width: 42rem;
}
.legal-prose .legal-lead {
font-size: 1.05rem;
color: var(--monica-ink);
margin-bottom: 1.75rem;
}
.legal-prose h2 {
font-size: 1.35rem;
margin-top: 2rem;
margin-bottom: 0.75rem;
text-transform: none;
letter-spacing: 0;
}
.legal-prose p {
margin-bottom: 1rem;
color: #4b5563;
line-height: 1.65;
}
.legal-prose .legal-updated {
margin-top: 2.5rem;
font-size: 0.875rem;
color: var(--monica-muted);
}
/* Contact form — breathe on narrow viewports */
@media (max-width: 767.98px) {
.contact-form-section > .container {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.contact-form-section .section-lg {
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}
.contact-form-section h3 {
margin-bottom: 1.25rem;
padding-right: 0.25rem;
}
.contact-message-form .form-wrap {
margin-bottom: 0.35rem;
}
.contact-message-form .form-label-outside {
margin-bottom: 0.35rem;
}
.contact-message-form .row-10 {
margin-left: 0;
margin-right: 0;
}
.contact-message-form .row-10 > [class*="col-"] {
padding-left: 0;
padding-right: 0;
}
}
.footer-aside-copy {
text-align: right;
}
.footer-aside-copy .rights {
margin: 0;
}
.footer-made-by {
margin: 6px 0 0;
font-size: 13px;
color: #9ca3af;
font-family: "Work Sans", sans-serif;
}
.footer-made-by a {
color: #d1d5db;
text-decoration: underline;
}
.footer-made-by a:hover {
color: #fff;
}
.footer-minimal .footer-made-by {
margin-top: 8px;
}
.footer-minimal-inner {
flex-wrap: wrap;
gap: 12px;
}
.pref-check {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
color: var(--monica-ink);
cursor: pointer;
}
.pref-check input {
width: 16px;
height: 16px;
}
@media (max-width: 640px) {
.cookie-consent-banner {
padding: 0.625rem 0.75rem;
padding-bottom: max(0.625rem, env(safe-area-inset-bottom));
}
.cookie-consent-content {
flex-direction: column;
align-items: stretch;
gap: 0.625rem;
}
.cookie-consent-text-short {
display: inline;
}
.cookie-consent-text-full {
display: none;
}
.cookie-consent-text {
flex: none;
font-size: 0.8125rem;
line-height: 1.35;
}
.cookie-consent-actions {
width: 100%;
}
}
/* Keep hero slide-2 heading visually aligned with slide-1 h1 (SEO: one h1). */
.swiper-slider-minimal .jumbotron-classic-content h1.font-weight-bold,
.swiper-slider-minimal .jumbotron-classic-content h2.font-weight-bold {
font-size: 2.5rem;
line-height: 1.15;
margin-bottom: 0.5rem;
}
@media (min-width: 768px) {
.swiper-slider-minimal .jumbotron-classic-content h1.font-weight-bold,
.swiper-slider-minimal .jumbotron-classic-content h2.font-weight-bold {
font-size: 3.5rem;
}
}
@media (min-width: 1200px) {
.swiper-slider-minimal .jumbotron-classic-content h1.font-weight-bold,
.swiper-slider-minimal .jumbotron-classic-content h2.font-weight-bold {
font-size: 4.5rem;
}
}
Binary file not shown.
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

File diff suppressed because one or more lines are too long
-193
View File
@@ -1,193 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html class="wide wow-animation" lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ SITE_NAME }}{% endblock %}</title>
<meta name="description" content="{% block meta_description %}{{ SITE_NAME }} — {{ SITE_TAGLINE }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %}">
<meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}">
<link rel="canonical" href="{% block canonical_url %}{{ PUBLIC_SITE_URL }}{{ request.path }}{% endblock %}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{ SITE_NAME }}">
<meta property="og:locale" content="en_US">
<meta property="og:title" content="{% block og_title %}{{ SITE_NAME }}{% endblock %}">
<meta property="og:description" content="{% block og_description %}{{ SITE_NAME }} — {{ SITE_TAGLINE }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %}">
<meta property="og:url" content="{% block og_url %}{{ PUBLIC_SITE_URL }}{{ request.path }}{% endblock %}">
{% block og_image %}
<meta property="og:image" content="{{ PUBLIC_SITE_URL }}{% static 'images/slider-minimal-slide-1-1920x968.jpg' %}">
<meta property="og:image:alt" content="{{ SITE_NAME }}">
{% endblock %}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{% block twitter_title %}{{ SITE_NAME }}{% endblock %}">
<meta name="twitter:description" content="{% block twitter_description %}{{ SITE_NAME }} — {{ SITE_TAGLINE }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %}">
<link rel="icon" href="{% static 'brand/favicon-32.png' %}" type="image/png">
<link rel="apple-touch-icon" href="{% static 'brand/apple-touch-icon.png' %}">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Work+Sans:300,400,500,700,800%7CPoppins:300,400,700">
<link rel="stylesheet" href="{% static 'css/bootstrap.css' %}">
<link rel="stylesheet" href="{% static 'css/fonts.css' %}">
<link rel="stylesheet" href="{% static 'css/style.css' %}" id="main-styles-link">
<link rel="stylesheet" href="{% static 'css/theme-overrides.css' %}">
{% block structured_data %}{% endblock %}
{% block extra_head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
{% if tianji_enabled %}
<div id="tianji-config"
data-tracker-url="{{ tianji_tracker_url }}"
data-website-id="{{ tianji_website_id }}"
data-page-name="{{ page_name }}"
{% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %}
hidden></div>
{% endif %}
{% block body %}
<div class="page">
{% block header %}
<header class="section page-header">
<div class="rd-navbar-wrap">
<nav class="rd-navbar rd-navbar-corporate" data-layout="rd-navbar-fixed" data-sm-layout="rd-navbar-fixed" data-md-layout="rd-navbar-fixed" data-md-device-layout="rd-navbar-fixed" data-lg-layout="rd-navbar-static" data-lg-device-layout="rd-navbar-static" data-lg-stick-up="true" data-lg-stick-up-offset="118px" data-xl-layout="rd-navbar-static" data-xl-device-layout="rd-navbar-static" data-xl-stick-up="true" data-xl-stick-up-offset="118px" data-xxl-layout="rd-navbar-static" data-xxl-device-layout="rd-navbar-static" data-xxl-stick-up-offset="118px" data-xxl-stick-up="true">
<div class="rd-navbar-aside-outer">
<div class="rd-navbar-aside">
<div class="rd-navbar-panel">
<button class="rd-navbar-toggle" data-rd-navbar-toggle="#rd-navbar-nav-wrap-1"><span></span></button>
<a class="rd-navbar-brand" href="{% url 'public:home' %}" data-tianji-event="nav_home">
<img src="{% static 'brand/logo.png' %}" alt="{{ SITE_NAME }}" width="151" height="44"/>
</a>
</div>
<div class="rd-navbar-collapse">
<button class="rd-navbar-collapse-toggle rd-navbar-fixed-element-1" data-rd-navbar-toggle="#rd-navbar-collapse-content-1"><span></span></button>
<div class="rd-navbar-collapse-content" id="rd-navbar-collapse-content-1">
{% if CONTACT_PHONE %}
<article class="unit align-items-center">
<div class="unit-left"><span class="icon icon-md icon-modern mdi mdi-phone"></span></div>
<div class="unit-body"><a class="link-default" href="tel:{{ CONTACT_PHONE_TEL }}">{{ CONTACT_PHONE }}</a></div>
</article>
{% endif %}
<article class="unit align-items-center">
<div class="unit-left"><span class="icon icon-md icon-modern mdi mdi-map-marker"></span></div>
<div class="unit-body"><span class="link-default">{% if CONTACT_ADDRESS %}{{ CONTACT_ADDRESS }}{% else %}{{ CONTACT_SERVICE_AREA }}{% endif %}</span></div>
</article>
<a class="button button-gray-bordered button-winona" href="{% url 'public:contact' %}" data-tianji-event="nav_get_in_touch">Get in touch</a>
</div>
</div>
</div>
</div>
<div class="rd-navbar-main-outer">
<div class="rd-navbar-main">
<div class="rd-navbar-nav-wrap" id="rd-navbar-nav-wrap-1">
<ul class="rd-navbar-nav">
<li class="rd-nav-item {% if page_name == 'home' %}active{% endif %}">
<a class="rd-nav-link" href="{% url 'public:home' %}" data-tianji-event="nav_home">Home</a>
</li>
<li class="rd-nav-item {% if page_name == 'about' %}active{% endif %}">
<a class="rd-nav-link" href="{% url 'public:about' %}" data-tianji-event="nav_about">About</a>
</li>
<li class="rd-nav-item {% if page_name == 'contact' %}active{% endif %}">
<a class="rd-nav-link" href="{% url 'public:contact' %}" data-tianji-event="nav_contact">Contact</a>
</li>
{% for item in public_nav_extra %}
<li class="rd-nav-item {% if page_name == item.section %}active{% endif %}">
<a class="rd-nav-link" href="{% url item.url_name %}">{{ item.label }}</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</nav>
</div>
</header>
{% endblock %}
{% if messages %}
<ul class="flash">
{% for message in messages %}
<li class="{{ message.tags }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% block content %}{% endblock %}
{% block footer %}
<footer class="section footer-advanced bg-gray-700">
<div class="footer-advanced-main">
<div class="container">
<div class="row row-50">
<div class="col-lg-6">
<div class="footer-brand-row">
<img src="{% static 'brand/logo.png' %}" alt="{{ SITE_NAME }}">
<div>
<h5 class="font-weight-bold text-uppercase text-white" style="margin:0">{{ SITE_NAME }}</h5>
<span style="color:#7dd3da;font-size:12px">{{ SITE_TAGLINE }}</span>
</div>
</div>
<p class="footer-advanced-text">Replace this footer blurb with the clients positioning.{% if CONTACT_ADDRESS %} {{ CONTACT_ADDRESS }}.{% endif %}</p>
</div>
<div class="col-lg-6">
<h5 class="font-weight-bold text-uppercase text-white">Navigate</h5>
<ul class="list-marked list-marked-sm">
<li><a href="{% url 'public:home' %}">Home</a></li>
<li><a href="{% url 'public:about' %}">About</a></li>
<li><a href="{% url 'public:contact' %}">Contact</a></li>
<li><a href="{% url 'public:terms' %}">Terms of Service</a></li>
{% for item in public_nav_extra %}
<li><a href="{% url item.url_name %}">{{ item.label }}</a></li>
{% endfor %}
{% if user.is_authenticated %}
<li><a href="{% url 'dashboard:home' %}">Client portal</a></li>
{% else %}
<li><a href="{% url 'accounts:login' %}">Client portal</a></li>
{% endif %}
</ul>
<p style="margin-top:16px">
<a class="footer-link-btn" href="{% url 'public:terms' %}" data-tianji-event="footer_cookie_preferences">Cookie Preferences</a>
</p>
</div>
</div>
</div>
</div>
<div class="footer-advanced-aside">
<div class="container">
<div class="footer-advanced-layout">
<a class="brand" href="{% url 'public:home' %}">
<img src="{% static 'brand/logo.png' %}" alt="{{ SITE_NAME }}" width="151" height="44"/>
</a>
<div class="footer-aside-copy">
<p class="rights">
<span>&copy;&nbsp;</span><span>{% now "Y" %}&nbsp;</span><span>{{ SITE_NAME }}</span><span>.&nbsp;</span>
</p>
<p class="footer-made-by">
Made by <a href="{{ CREDIT_URL }}" target="_blank" rel="noopener noreferrer">{{ CREDIT_NAME }}</a>
</p>
</div>
</div>
</div>
</div>
</footer>
{% endblock %}
</div>
{% endblock %}
{% if tianji_enabled %}
<div id="cookie-consent-banner" class="cookie-consent-banner" hidden role="dialog" aria-modal="true"
aria-labelledby="cookie-consent-title" aria-describedby="cookie-consent-description">
<div class="cookie-consent-content">
<h2 id="cookie-consent-title" class="visually-hidden">Analytics notice</h2>
<p class="cookie-consent-text" id="cookie-consent-description">
<span class="cookie-consent-text-full">We use analytics to understand how visitors use our site. This helps us improve performance and content. We never sell or share your personal data with third parties for marketing. <a class="cookie-consent-link" href="{% url 'public:terms' %}">Terms of Service</a></span>
<span class="cookie-consent-text-short">We use analytics on this site. <a class="cookie-consent-link" href="{% url 'public:terms' %}">Terms</a></span>
</p>
<div class="cookie-consent-actions">
<button type="button" id="cookie-consent-acknowledge" class="button button-primary button-winona">Acknowledge</button>
</div>
</div>
</div>
<script src="{% static 'js/tianji-consent.js' %}"></script>
{% endif %}
{% block tracking_events %}{% endblock %}
<script src="{% static 'js/core.min.js' %}"></script>
<script src="{% static 'js/script.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
+3 -1
View File
@@ -10,7 +10,9 @@
<div style="display:flex;gap:8px;flex-wrap:wrap">
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:import' %}">Import CSV / Excel</a>
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:create' %}">New contact</a>
<a class="btn btn-primary btn-sm" href="{% url 'messaging:campaign_list' %}">New campaign</a>
{% if 'email_sms' in enabled_features %}
<a class="btn btn-primary btn-sm" href="{% url 'email_sms:campaign_list' %}">New campaign</a>
{% endif %}
</div>
</div>
+16
View File
@@ -219,3 +219,19 @@ class PortalCreateMatchPromptTests(TestCase):
self.existing.refresh_from_db()
self.assertEqual(self.existing.first_name, "Alexander")
self.assertIn("alt@example.com", self.existing.notes)
class ContactListTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="lister", password="test-pass-123"
)
self.client = Client()
self.client.login(username="lister", password="test-pass-123")
def test_list_renders_and_links_campaigns(self):
response = self.client.get(reverse("contacts:list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Mailing list")
self.assertContains(response, reverse("email_sms:campaign_list"))
@@ -0,0 +1,27 @@
# Generated by Django 6.1
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="storedfile",
name="kind",
field=models.CharField(
choices=[
("campaign_image", "Campaign image"),
("social_image", "Social image"),
("social_video", "Social video"),
("invoice_pdf", "Invoice PDF"),
("product_image", "Product image"),
],
default="campaign_image",
max_length=32,
),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.1
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0002_storedfile_product_image"),
]
operations = [
migrations.AlterField(
model_name="storedfile",
name="kind",
field=models.CharField(
choices=[
("campaign_image", "Campaign image"),
("social_image", "Social image"),
("social_video", "Social video"),
("invoice_pdf", "Invoice PDF"),
("product_image", "Product image"),
("product_stl", "Product STL"),
],
default="campaign_image",
max_length=32,
),
),
]
+2
View File
@@ -29,6 +29,8 @@ class StoredFile(UUIDPrimaryKeyModel, TimeStampedModel):
SOCIAL_IMAGE = "social_image", "Social image"
SOCIAL_VIDEO = "social_video", "Social video"
INVOICE_PDF = "invoice_pdf", "Invoice PDF"
PRODUCT_IMAGE = "product_image", "Product image"
PRODUCT_STL = "product_stl", "Product STL"
kind = models.CharField(
max_length=32, choices=Kind.choices, default=Kind.CAMPAIGN_IMAGE
+1
View File
@@ -22,6 +22,7 @@ GROUP_ORDER = {
"Outreach": 20,
"Content": 30,
"Social": 40,
"Retail": 45,
"Billing": 50,
}
+39 -4
View File
@@ -10,7 +10,7 @@ from django.urls import reverse
def _read_settings_source() -> str:
return (Path(__file__).resolve().parent.parent / "client_site/settings/base.py").read_text()
return (Path(__file__).resolve().parent.parent / "print_forge/settings/base.py").read_text()
class FeatureFlagSettingsTests(SimpleTestCase):
@@ -22,16 +22,30 @@ class FeatureFlagSettingsTests(SimpleTestCase):
self.assertIn("FEATURE_PAYMENTS", src)
self.assertIn("FEATURE_SOCIAL", src)
self.assertIn("FEATURE_SOCIAL_AI", src)
self.assertIn("FEATURE_SHOP", src)
self.assertIn("FEATURE_POS_SYNC", src)
self.assertIn("FEATURE_EVENTS", src)
self.assertIn("FEATURE_SHIPPING", src)
self.assertIn("email_sms.apps.EmailSmsConfig", src)
self.assertIn("directmail.apps.DirectmailConfig", src)
self.assertIn("blog.apps.BlogConfig", src)
self.assertIn("payments.apps.PaymentsConfig", src)
self.assertIn("social_ai.apps.SocialAiConfig", src)
self.assertIn("shop.apps.ShopConfig", src)
self.assertIn("pos_sync.apps.PosSyncConfig", src)
self.assertIn("events.apps.EventsConfig", src)
self.assertIn("shipping.apps.ShippingConfig", src)
def test_payments_requires_email_sms_in_source(self):
src = _read_settings_source()
self.assertIn("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS", src)
self.assertIn("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL", src)
self.assertIn("FEATURE_SHOP requires FEATURE_EMAIL_SMS", src)
self.assertIn("FEATURE_SHOP requires FEATURE_PAYMENTS", src)
self.assertIn("FEATURE_POS_SYNC requires FEATURE_SHOP", src)
self.assertIn("FEATURE_EVENTS requires FEATURE_EMAIL_SMS", src)
self.assertIn("FEATURE_EVENTS requires FEATURE_PAYMENTS", src)
self.assertIn("FEATURE_SHIPPING requires FEATURE_SHOP", src)
class AlwaysOnImportIsolationTests(SimpleTestCase):
@@ -44,6 +58,10 @@ class AlwaysOnImportIsolationTests(SimpleTestCase):
"payments",
"social",
"social_ai",
"shop",
"pos_sync",
"events",
"shipping",
}
ALWAYS_ON = [
@@ -52,7 +70,7 @@ class AlwaysOnImportIsolationTests(SimpleTestCase):
"core/management/commands/dispatch_due.py",
"core/shortener.py",
"core/campaign_utm.py",
"client_site/urls.py",
"print_forge/urls.py",
]
def test_no_module_level_optional_imports(self):
@@ -78,11 +96,22 @@ class AlwaysOnImportIsolationTests(SimpleTestCase):
class InstalledOptionalAppsTests(TestCase):
"""Dev defaults install optional apps so the template is a working seed."""
"""Dev defaults install optional apps so the catalog seed still tests."""
def test_dev_installs_catalog_apps(self):
labels = {c.label for c in apps.get_app_configs()}
for label in ("email_sms", "directmail", "blog", "payments", "social", "social_ai"):
for label in (
"email_sms",
"directmail",
"blog",
"payments",
"social",
"social_ai",
"shop",
"pos_sync",
"events",
"shipping",
):
self.assertIn(label, labels)
def test_optional_urls_resolve_when_installed(self):
@@ -92,3 +121,9 @@ class InstalledOptionalAppsTests(TestCase):
self.assertTrue(reverse("payments:invoice_list").startswith("/portal/payments/"))
self.assertTrue(reverse("social:composer").startswith("/portal/social/"))
self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/"))
self.assertTrue(reverse("shop:list").startswith("/shop/"))
self.assertTrue(reverse("shop_portal:product_list").startswith("/portal/shop/"))
self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/"))
self.assertTrue(reverse("events:list").startswith("/events/"))
self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/"))
self.assertTrue(reverse("shipping:shipment_list").startswith("/portal/shipping/"))
@@ -27,6 +27,28 @@
<div class="value">{{ open_invoices|default:0 }}</div>
</div>
{% endif %}
{% if 'shop' in enabled_features %}
<div class="stat-card">
<div class="label">Open shop orders</div>
<div class="value">{{ open_shop_orders|default:0 }}</div>
</div>
<div class="stat-card">
<div class="label">Low stock</div>
<div class="value">{{ low_stock_products|default:0 }}</div>
</div>
{% endif %}
{% if 'events' in enabled_features %}
<div class="stat-card">
<div class="label">Upcoming events</div>
<div class="value">{{ upcoming_events|default:0 }}</div>
</div>
{% endif %}
{% if 'shipping' in enabled_features %}
<div class="stat-card">
<div class="label">Unshipped orders</div>
<div class="value">{{ unshipped_orders|default:0 }}</div>
</div>
{% endif %}
</div>
<div class="split">
View File
+24
View File
@@ -0,0 +1,24 @@
from django.contrib import admin
from events.models import Event, Ticket, TicketOrder
@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
list_display = ("title", "starts_at", "capacity", "price", "is_published")
list_filter = ("is_published",)
search_fields = ("title", "venue")
prepopulated_fields = {"slug": ("title",)}
@admin.register(TicketOrder)
class TicketOrderAdmin(admin.ModelAdmin):
list_display = ("number", "event", "email", "quantity", "status")
list_filter = ("status",)
search_fields = ("number", "email")
@admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin):
list_display = ("code", "event", "order")
search_fields = ("code",)
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class EventsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "events"
verbose_name = "Event ticketing"
def ready(self):
from events import hooks
hooks.register()
+33
View File
@@ -0,0 +1,33 @@
from core.registry import (
register_dashboard_collector,
register_feature,
register_portal_nav,
register_public_nav,
)
def register() -> None:
register_feature("events")
register_public_nav(
section="events", label="Events", url_name="events:list", order=35
)
register_portal_nav(
section="events",
label="Events",
url_name="events_portal:event_list",
group="Retail",
order=30,
)
register_dashboard_collector(_dashboard)
def _dashboard(request) -> dict:
from django.utils import timezone
from events.models import Event
return {
"upcoming_events": Event.objects.filter(
is_published=True, starts_at__gte=timezone.now()
).count()
}
+74
View File
@@ -0,0 +1,74 @@
# Generated by Django 6.1 on 2026-09-06 11:17
import django.db.models.deletion
import uuid
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Event',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=200)),
('slug', models.SlugField(max_length=220, unique=True)),
('description', models.TextField(blank=True)),
('starts_at', models.DateTimeField()),
('venue', models.CharField(blank=True, max_length=200)),
('capacity', models.PositiveIntegerField(default=0)),
('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10)),
('currency', models.CharField(default='usd', max_length=8)),
('is_published', models.BooleanField(default=False)),
],
options={
'ordering': ['starts_at'],
},
),
migrations.CreateModel(
name='TicketOrder',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('number', models.CharField(max_length=32, unique=True)),
('email', models.EmailField(max_length=254)),
('customer_name', models.CharField(blank=True, max_length=200)),
('quantity', models.PositiveIntegerField(default=1)),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
('currency', models.CharField(default='usd', max_length=8)),
('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('cancelled', 'Cancelled')], default='draft', max_length=16)),
('stripe_checkout_session_id', models.CharField(blank=True, max_length=255)),
('hosted_checkout_url', models.URLField(blank=True)),
('paid_at', models.DateTimeField(blank=True, null=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ticket_orders', to='events.event')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Ticket',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(max_length=16, unique=True)),
('attendee_name', models.CharField(blank=True, max_length=200)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='events.event')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to='events.ticketorder')),
],
options={
'ordering': ['created_at'],
},
),
]
View File
+111
View File
@@ -0,0 +1,111 @@
from decimal import Decimal
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class Event(UUIDPrimaryKeyModel, TimeStampedModel):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
description = models.TextField(blank=True)
starts_at = models.DateTimeField()
venue = models.CharField(max_length=200, blank=True)
capacity = models.PositiveIntegerField(default=0)
price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0"))
currency = models.CharField(max_length=8, default="usd")
is_published = models.BooleanField(default=False)
class Meta:
ordering = ["starts_at"]
def __str__(self) -> str:
return self.title
def get_absolute_url(self) -> str:
return reverse("events:detail", kwargs={"slug": self.slug})
def save(self, *args, **kwargs):
if not self.slug:
base = slugify(self.title)[:200] or "event"
slug = base
n = 2
while Event.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base}-{n}"
n += 1
self.slug = slug
super().save(*args, **kwargs)
def tickets_held(self, *, exclude_order_id=None) -> int:
qs = self.ticket_orders.filter(
status__in=[
TicketOrder.Status.DRAFT,
TicketOrder.Status.OPEN,
TicketOrder.Status.PAID,
]
)
if exclude_order_id:
qs = qs.exclude(pk=exclude_order_id)
return qs.aggregate(total=models.Sum("quantity"))["total"] or 0
@property
def tickets_sold(self) -> int:
return self.tickets_held()
@property
def seats_remaining(self) -> int | None:
if not self.capacity:
return None
return max(self.capacity - self.tickets_sold, 0)
@property
def is_upcoming(self) -> bool:
return self.starts_at >= timezone.now()
class TicketOrder(UUIDPrimaryKeyModel, TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
OPEN = "open", "Open"
PAID = "paid", "Paid"
CANCELLED = "cancelled", "Cancelled"
number = models.CharField(max_length=32, unique=True)
event = models.ForeignKey(
Event, on_delete=models.PROTECT, related_name="ticket_orders"
)
email = models.EmailField()
customer_name = models.CharField(max_length=200, blank=True)
quantity = models.PositiveIntegerField(default=1)
amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=8, default="usd")
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.DRAFT
)
stripe_checkout_session_id = models.CharField(max_length=255, blank=True)
hosted_checkout_url = models.URLField(blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.number} · {self.event}"
class Ticket(UUIDPrimaryKeyModel, TimeStampedModel):
order = models.ForeignKey(
TicketOrder, on_delete=models.CASCADE, related_name="tickets"
)
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name="tickets")
code = models.CharField(max_length=16, unique=True)
attendee_name = models.CharField(max_length=200, blank=True)
class Meta:
ordering = ["created_at"]
def __str__(self) -> str:
return self.code
+13
View File
@@ -0,0 +1,13 @@
from django.urls import path
from events import views
app_name = "events_portal"
urlpatterns = [
path("", views.portal_event_list, name="event_list"),
path("new/", views.portal_event_edit, name="event_new"),
path("<uuid:pk>/", views.portal_event_detail, name="event_detail"),
path("<uuid:pk>/edit/", views.portal_event_edit, name="event_edit"),
path("webhooks/stripe/", views.stripe_webhook, name="stripe_webhook"),
]
+13
View File
@@ -0,0 +1,13 @@
from django.urls import path
from events import views
app_name = "events"
urlpatterns = [
path("", views.event_list, name="list"),
path("order/<uuid:pk>/success/", views.checkout_success, name="checkout_success"),
path("order/<uuid:pk>/cancel/", views.checkout_cancel, name="checkout_cancel"),
path("<slug:slug>/", views.event_detail, name="detail"),
path("<slug:slug>/buy/", views.buy_tickets, name="buy"),
]
+188
View File
@@ -0,0 +1,188 @@
"""Event capacity, Stripe ticket checkout, and confirmation email."""
from __future__ import annotations
import logging
import secrets
from datetime import date
from decimal import Decimal
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from events.models import Event, Ticket, TicketOrder
logger = logging.getLogger(__name__)
class EventsError(RuntimeError):
pass
def _stripe():
secret = (settings.STRIPE_SECRET_KEY or "").strip()
if not secret:
raise EventsError("STRIPE_SECRET_KEY is not configured")
try:
import stripe
except ImportError as exc:
raise EventsError("stripe package is not installed") from exc
stripe.api_key = secret
return stripe
def next_ticket_order_number() -> str:
today = date.today().strftime("%Y%m%d")
prefix = f"TIX-{today}-"
existing = TicketOrder.objects.filter(number__startswith=prefix).count()
return f"{prefix}{existing + 1:03d}"
def new_ticket_code() -> str:
while True:
code = secrets.token_hex(4).upper()
if not Ticket.objects.filter(code=code).exists():
return code
def assert_capacity(
event: Event, quantity: int, *, exclude_order_id=None
) -> None:
if quantity < 1:
raise EventsError("Quantity must be at least 1.")
if not event.capacity:
return
held = event.tickets_held(exclude_order_id=exclude_order_id)
remaining = max(event.capacity - held, 0)
if quantity > remaining:
raise EventsError(f"Only {remaining} seats left for {event.title}.")
def create_ticket_order(
event: Event, *, email: str, customer_name: str = "", quantity: int = 1
) -> TicketOrder:
email = (email or "").strip()
if not email:
raise EventsError("Email is required.")
if not event.is_published:
raise EventsError("This event is not on sale.")
assert_capacity(event, quantity)
amount = event.price * quantity
return TicketOrder.objects.create(
number=next_ticket_order_number(),
event=event,
email=email,
customer_name=(customer_name or "").strip(),
quantity=quantity,
amount=amount,
currency=(event.currency or settings.STRIPE_CURRENCY or "usd").lower(),
status=TicketOrder.Status.DRAFT,
)
def create_checkout_session(
order: TicketOrder, *, success_url: str, cancel_url: str
) -> str:
stripe = _stripe()
session = stripe.checkout.Session.create(
mode="payment",
customer_email=order.email or None,
line_items=[
{
"quantity": order.quantity,
"price_data": {
"currency": (order.currency or "usd").lower(),
"unit_amount": int(
(order.event.price * Decimal("100")).quantize(Decimal("1"))
),
"product_data": {"name": order.event.title},
},
}
],
metadata={
"ticket_order_id": str(order.pk),
"ticket_order_number": order.number,
},
success_url=success_url,
cancel_url=cancel_url,
)
order.stripe_checkout_session_id = session.id
order.hosted_checkout_url = session.url or ""
order.status = TicketOrder.Status.OPEN
order.save(
update_fields=[
"stripe_checkout_session_id",
"hosted_checkout_url",
"status",
"updated_at",
]
)
return session.url or ""
def _issue_tickets(order: TicketOrder) -> list[Ticket]:
created = []
for _ in range(order.quantity):
created.append(
Ticket.objects.create(
order=order,
event=order.event,
code=new_ticket_code(),
attendee_name=order.customer_name,
)
)
return created
def mark_paid(order: TicketOrder, *, stripe_id: str = "") -> None:
if order.status == TicketOrder.Status.PAID:
return
with transaction.atomic():
locked = TicketOrder.objects.select_for_update().get(pk=order.pk)
if locked.status == TicketOrder.Status.PAID:
return
assert_capacity(
locked.event, locked.quantity, exclude_order_id=locked.pk
)
_issue_tickets(locked)
locked.status = TicketOrder.Status.PAID
locked.paid_at = timezone.now()
locked.save(update_fields=["status", "paid_at", "updated_at"])
order.refresh_from_db()
try:
send_ticket_email(order)
except Exception:
logger.exception("ticket email failed for %s", order.number)
def send_ticket_email(order: TicketOrder) -> bool:
to_email = (order.email or "").strip()
if not to_email:
raise EventsError("Order has no email address")
from django.core.mail import EmailMultiAlternatives
codes = ", ".join(t.code for t in order.tickets.all())
name = order.customer_name or "there"
when = timezone.localtime(order.event.starts_at).strftime("%b %d, %Y %-I:%M %p")
subject = f"Tickets for {order.event.title}{settings.SITE_NAME}"
text = (
f"Hi {name},\n\n"
f"Your tickets for {order.event.title} on {when}:\n"
f"{codes}\n\nOrder {order.number}\n"
)
html = (
f"<p>Hi {name},</p>"
f"<p>Tickets for <strong>{order.event.title}</strong> on {when}:</p>"
f"<p><strong>{codes}</strong></p>"
f"<p>Order {order.number}</p>"
)
mail = EmailMultiAlternatives(
subject=subject,
body=text,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email],
)
mail.attach_alternative(html, "text/html")
mail.send(fail_silently=False)
return True
+17
View File
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Tickets · {{ event.title }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<h1>Tickets · {{ event.title }}</h1>
<p>{{ event.price }} {{ event.currency|upper }} each{% if event.seats_remaining is not None %} · {{ event.seats_remaining }} left{% endif %}</p>
<form method="post">
{% csrf_token %}
<p><label>Email <input name="email" type="email" required></label></p>
<p><label>Name <input name="customer_name"></label></p>
<p><label>Quantity <input name="quantity" type="number" min="1" value="1"></label></p>
<button class="button button-primary" type="submit">Pay with Stripe</button>
</form>
</div>
</section>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}Checkout cancelled{% endblock %}
{% block content %}
<section class="section section-lg"><div class="container">
<h1>Checkout cancelled</h1>
<p>No charge for {{ order.event.title }}. <a href="{% url 'events:buy' order.event.slug %}">Try again</a>.</p>
</div></section>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}{{ event.title }} · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<p><a href="{% url 'events:list' %}">← Events</a></p>
<h1>{{ event.title }}</h1>
<p class="muted">{{ event.starts_at|date:"F j, Y g:i A" }}{% if event.venue %} · {{ event.venue }}{% endif %}</p>
<div>{{ event.description|linebreaks }}</div>
<p>{{ event.price }} {{ event.currency|upper }}</p>
{% if event.seats_remaining == 0 %}
<p>Sold out.</p>
{% else %}
<p><a class="button button-primary" href="{% url 'events:buy' event.slug %}">Buy tickets</a></p>
{% endif %}
</div>
</section>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Events · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<h1 class="text-uppercase">Events</h1>
{% for event in events %}
<article style="margin:0 0 32px">
<h2><a href="{{ event.get_absolute_url }}">{{ event.title }}</a></h2>
<p class="muted">{{ event.starts_at|date:"F j, Y g:i A" }}{% if event.venue %} · {{ event.venue }}{% endif %}</p>
<p>{{ event.price }} {{ event.currency|upper }}{% if event.seats_remaining is not None %} · {{ event.seats_remaining }} seats left{% endif %}</p>
</article>
{% empty %}
<p>No upcoming events.</p>
{% endfor %}
</div>
</section>
{% endblock %}
@@ -0,0 +1,23 @@
{% extends "portal_base.html" %}
{% block title %}{{ event.title }} · Portal{% endblock %}
{% block topbar_title %}{{ event.title }}{% endblock %}
{% block portal_content %}
<p><a class="btn btn-ghost" href="{% url 'events_portal:event_edit' event.pk %}">Edit</a></p>
<p>{{ event.starts_at }} · {{ event.tickets_sold }} / {{ event.capacity|default:"∞" }} seats · {{ event.price }} {{ event.currency|upper }}</p>
<table class="table">
<thead><tr><th>Order</th><th>Email</th><th>Qty</th><th>Status</th><th>Codes</th></tr></thead>
<tbody>
{% for order in orders %}
<tr>
<td>{{ order.number }}</td>
<td>{{ order.email }}</td>
<td>{{ order.quantity }}</td>
<td>{{ order.get_status_display }}</td>
<td>{% for ticket in order.tickets.all %}{{ ticket.code }}{% if not forloop.last %}, {% endif %}{% endfor %}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="empty-state">No ticket orders yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,17 @@
{% extends "portal_base.html" %}
{% block title %}{% if event %}Edit{% else %}New{% endif %} event · Portal{% endblock %}
{% block topbar_title %}{% if event %}Edit event{% else %}New event{% endif %}{% endblock %}
{% block portal_content %}
<form method="post" class="form-grid">
{% csrf_token %}
<div class="field"><label>Title</label><input name="title" required value="{{ event.title|default:'' }}"></div>
<div class="field"><label>Slug</label><input name="slug" value="{{ event.slug|default:'' }}" placeholder="auto from title"></div>
<div class="field"><label>Starts</label><input name="starts_at" type="datetime-local" required value="{% if event %}{{ event.starts_at|date:'Y-m-d\\TH:i' }}{% endif %}"></div>
<div class="field"><label>Venue</label><input name="venue" value="{{ event.venue|default:'' }}"></div>
<div class="field"><label>Capacity</label><input name="capacity" type="number" min="0" value="{{ event.capacity|default:0 }}"></div>
<div class="field"><label>Price</label><input name="price" type="number" step="0.01" min="0" required value="{{ event.price|default:'0' }}"></div>
<div class="field"><label>Description</label><textarea name="description" style="min-height:120px">{{ event.description|default:'' }}</textarea></div>
<label><input type="checkbox" name="is_published" {% if event.is_published %}checked{% endif %}> Published</label>
<button class="btn btn-primary" type="submit">Save</button>
</form>
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "portal_base.html" %}
{% block title %}Events · Portal{% endblock %}
{% block topbar_title %}Events{% endblock %}
{% block portal_content %}
<p><a class="btn btn-primary" href="{% url 'events_portal:event_new' %}">New event</a></p>
<table class="table">
<thead><tr><th>Title</th><th>When</th><th>Capacity</th><th>Status</th></tr></thead>
<tbody>
{% for event in events %}
<tr>
<td><a href="{% url 'events_portal:event_detail' event.pk %}">{{ event.title }}</a></td>
<td>{{ event.starts_at }}</td>
<td>{{ event.tickets_sold }} / {{ event.capacity|default:"∞" }}</td>
<td>{% if event.is_published %}Published{% else %}Draft{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No events yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Tickets {{ order.number }}{% endblock %}
{% block content %}
<section class="section section-lg"><div class="container">
<h1>You're in</h1>
<p>Order {{ order.number }} for {{ order.event.title }} is {{ order.get_status_display|lower }}.</p>
<p>Ticket codes will be emailed to {{ order.email }}.</p>
</div></section>
{% endblock %}
+95
View File
@@ -0,0 +1,95 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from events.models import Event, Ticket, TicketOrder
from events.services import EventsError, create_ticket_order, mark_paid
def _event(**kwargs):
defaults = dict(
title="Friday Night Prerelease",
starts_at=timezone.now() + timezone.timedelta(days=7),
venue="Main shop",
capacity=8,
price=Decimal("25.00"),
is_published=True,
)
defaults.update(kwargs)
return Event.objects.create(**defaults)
class EventPublicTests(TestCase):
def test_list_hides_drafts_and_past(self):
_event(title="Live prerelease")
_event(title="Draft night", is_published=False)
_event(
title="Last week",
starts_at=timezone.now() - timezone.timedelta(days=2),
)
response = Client().get(reverse("events:list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Live prerelease")
self.assertNotContains(response, "Draft night")
self.assertNotContains(response, "Last week")
def test_buy_form_renders(self):
event = _event()
response = Client().get(reverse("events:buy", kwargs={"slug": event.slug}))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Pay with Stripe")
class EventCapacityTests(TestCase):
def test_capacity_blocks_overbook(self):
event = _event(capacity=2)
create_ticket_order(event, email="a@example.com", quantity=2)
with self.assertRaises(EventsError):
create_ticket_order(event, email="b@example.com", quantity=1)
def test_mark_paid_issues_tickets_and_email(self):
event = _event(capacity=4)
order = create_ticket_order(
event, email="fan@example.com", customer_name="Lee", quantity=2
)
mark_paid(order)
order.refresh_from_db()
self.assertEqual(order.status, TicketOrder.Status.PAID)
self.assertEqual(Ticket.objects.filter(order=order).count(), 2)
self.assertEqual(event.tickets_sold, 2)
self.assertEqual(len(mail.outbox), 1)
self.assertIn(event.title, mail.outbox[0].subject)
class EventPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("host", password="test-pass-123")
self.client = Client()
self.client.login(username="host", password="test-pass-123")
def test_list_requires_login(self):
self.assertEqual(Client().get(reverse("events_portal:event_list")).status_code, 302)
def test_create_published_event(self):
starts = (timezone.now() + timezone.timedelta(days=3)).strftime("%Y-%m-%dT%H:%M")
response = self.client.post(
reverse("events_portal:event_new"),
{
"title": "Commander night",
"starts_at": starts,
"venue": "Back room",
"capacity": "16",
"price": "10.00",
"is_published": "on",
},
)
self.assertEqual(response.status_code, 302)
event = Event.objects.get()
self.assertEqual(event.slug, "commander-night")
self.assertTrue(event.is_published)
self.assertEqual(event.capacity, 16)
+194
View File
@@ -0,0 +1,194 @@
import logging
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from datetime import datetime
from django.utils.text import slugify
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from events.models import Event, TicketOrder
from events.services import (
EventsError,
create_checkout_session,
create_ticket_order,
mark_paid,
)
logger = logging.getLogger(__name__)
def _site_base(request) -> str:
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
if base:
return base
return request.build_absolute_uri("/").rstrip("/")
def event_list(request):
events = Event.objects.filter(is_published=True, starts_at__gte=timezone.now())
return render(request, "events/list.html", {"events": events})
def event_detail(request, slug):
event = get_object_or_404(Event, slug=slug, is_published=True)
return render(request, "events/detail.html", {"event": event})
@require_http_methods(["GET", "POST"])
def buy_tickets(request, slug):
event = get_object_or_404(Event, slug=slug, is_published=True)
if request.method == "POST":
email = (request.POST.get("email") or "").strip()
name = (request.POST.get("customer_name") or "").strip()
try:
quantity = int(request.POST.get("quantity") or "1")
except ValueError:
quantity = 0
try:
order = create_ticket_order(
event, email=email, customer_name=name, quantity=quantity
)
base = _site_base(request)
success = base + reverse(
"events:checkout_success", kwargs={"pk": order.pk}
)
cancel = base + reverse("events:checkout_cancel", kwargs={"pk": order.pk})
url = create_checkout_session(
order,
success_url=success + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=cancel,
)
except EventsError as exc:
messages.error(request, str(exc))
except Exception as exc: # noqa: BLE001
logger.exception("ticket checkout failed")
messages.error(request, f"Could not start checkout: {exc}")
else:
return redirect(url)
return render(request, "events/buy.html", {"event": event})
def checkout_success(request, pk):
order = get_object_or_404(TicketOrder.objects.select_related("event"), pk=pk)
return render(request, "events/success.html", {"order": order})
def checkout_cancel(request, pk):
order = get_object_or_404(TicketOrder.objects.select_related("event"), pk=pk)
return render(request, "events/cancel.html", {"order": order})
@login_required
def portal_event_list(request):
events = Event.objects.all().order_by("-starts_at")
return render(request, "events/portal/list.html", {"events": events})
@login_required
@require_http_methods(["GET", "POST"])
def portal_event_edit(request, pk=None):
event = get_object_or_404(Event, pk=pk) if pk else None
if request.method == "POST":
title = (request.POST.get("title") or "").strip()
description = (request.POST.get("description") or "").strip()
venue = (request.POST.get("venue") or "").strip()
slug = (request.POST.get("slug") or "").strip()
starts_raw = (request.POST.get("starts_at") or "").strip()
errors = []
if not title:
errors.append("Title is required.")
starts_at = None
if starts_raw:
try:
starts_at = datetime.fromisoformat(starts_raw)
except ValueError:
starts_at = None
if starts_at is None:
errors.append("Start date/time is required.")
elif timezone.is_naive(starts_at):
starts_at = timezone.make_aware(starts_at, timezone.get_current_timezone())
try:
capacity = int(request.POST.get("capacity") or "0")
except ValueError:
capacity = 0
errors.append("Capacity must be a number.")
try:
price = Decimal(request.POST.get("price") or "0")
if price < 0:
raise InvalidOperation
except Exception:
price = None
errors.append("Enter a valid price.")
if errors:
for err in errors:
messages.error(request, err)
else:
if event is None:
event = Event()
event.title = title
event.description = description
event.venue = venue
event.slug = slugify(slug)[:220] if slug else ""
event.starts_at = starts_at
event.capacity = max(capacity, 0)
event.price = price
event.currency = (settings.STRIPE_CURRENCY or "usd").lower()
event.is_published = request.POST.get("is_published") == "on"
event.save()
messages.success(request, f"Saved {event.title}.")
return redirect("events_portal:event_list")
return render(request, "events/portal/edit.html", {"event": event})
@login_required
def portal_event_detail(request, pk):
event = get_object_or_404(Event, pk=pk)
orders = event.ticket_orders.select_related().prefetch_related("tickets")
return render(
request,
"events/portal/detail.html",
{"event": event, "orders": orders},
)
@csrf_exempt
@require_http_methods(["POST"])
def stripe_webhook(request):
secret = (settings.STRIPE_WEBHOOK_SECRET or "").strip()
if not secret:
logger.error("STRIPE_WEBHOOK_SECRET unset")
return HttpResponseBadRequest("webhook not configured")
try:
import stripe
except ImportError:
return HttpResponseBadRequest("stripe not installed")
sig = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(request.body, sig, secret)
except Exception:
logger.exception("events stripe webhook signature failed")
return HttpResponseBadRequest("invalid signature")
obj = event.get("data", {}).get("object", {}) or {}
if event.get("type") != "checkout.session.completed":
return HttpResponse("ok")
order_id = (obj.get("metadata") or {}).get("ticket_order_id") or ""
order = None
if order_id:
order = TicketOrder.objects.filter(pk=order_id).first()
if order is None:
session_id = obj.get("id") or ""
order = TicketOrder.objects.filter(
stripe_checkout_session_id=session_id
).first()
if order and order.status != TicketOrder.Status.PAID:
mark_paid(order, stripe_id=obj.get("id") or "")
logger.info("ticket order %s marked paid", order.number)
return HttpResponse("ok")
+1 -1
View File
@@ -7,7 +7,7 @@ import sys
def main() -> None:
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "print_forge.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
View File
+16
View File
@@ -0,0 +1,16 @@
from django.contrib import admin
from pos_sync.models import POSConnection, SyncEvent
@admin.register(POSConnection)
class POSConnectionAdmin(admin.ModelAdmin):
list_display = ("name", "provider", "is_active")
list_filter = ("provider", "is_active")
@admin.register(SyncEvent)
class SyncEventAdmin(admin.ModelAdmin):
list_display = ("direction", "sku", "quantity", "status", "created_at")
list_filter = ("direction", "status")
search_fields = ("sku", "external_id")
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class PosSyncConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "pos_sync"
verbose_name = "POS API sync"
def ready(self):
from pos_sync import hooks
hooks.register()
+35
View File
@@ -0,0 +1,35 @@
from core.registry import (
register_dashboard_collector,
register_dispatcher,
register_feature,
register_portal_nav,
)
def register() -> None:
register_feature("pos_sync")
register_portal_nav(
section="pos_sync",
label="POS sync",
url_name="pos_sync:event_list",
group="Retail",
order=40,
)
register_dashboard_collector(_dashboard)
register_dispatcher(_dispatch)
def _dashboard(request) -> dict:
from pos_sync.models import SyncEvent
return {
"pos_failed_syncs": SyncEvent.objects.filter(
status=SyncEvent.Status.FAILED
).count()
}
def _dispatch() -> int:
from pos_sync.services import dispatch_pending_outbound
return dispatch_pending_outbound()
+52
View File
@@ -0,0 +1,52 @@
# Generated by Django 6.1 on 2026-09-06 11:17
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='POSConnection',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=120)),
('provider', models.CharField(choices=[('generic', 'Generic REST'), ('square', 'Square'), ('lightspeed', 'Lightspeed'), ('binderpos', 'BinderPOS'), ('crystalcommerce', 'CrystalCommerce')], default='generic', max_length=32)),
('api_base_url', models.URLField(blank=True)),
('api_token', models.CharField(blank=True, max_length=255)),
('webhook_secret', models.CharField(blank=True, max_length=255)),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='SyncEvent',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('direction', models.CharField(choices=[('inbound', 'POS → site'), ('outbound', 'Site → POS')], max_length=16)),
('status', models.CharField(choices=[('pending', 'Pending'), ('done', 'Done'), ('failed', 'Failed')], default='pending', max_length=16)),
('sku', models.CharField(max_length=64)),
('quantity', models.IntegerField(default=0)),
('external_id', models.CharField(blank=True, max_length=255)),
('payload', models.JSONField(blank=True, default=dict)),
('error', models.TextField(blank=True)),
('connection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='pos_sync.posconnection')),
],
options={
'ordering': ['-created_at'],
},
),
]
+61
View File
@@ -0,0 +1,61 @@
from django.db import models
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class POSConnection(UUIDPrimaryKeyModel, TimeStampedModel):
class Provider(models.TextChoices):
GENERIC = "generic", "Generic REST"
SQUARE = "square", "Square"
LIGHTSPEED = "lightspeed", "Lightspeed"
BINDERPOS = "binderpos", "BinderPOS"
CRYSTALCOMMERCE = "crystalcommerce", "CrystalCommerce"
name = models.CharField(max_length=120)
provider = models.CharField(
max_length=32, choices=Provider.choices, default=Provider.GENERIC
)
api_base_url = models.URLField(blank=True)
api_token = models.CharField(max_length=255, blank=True)
webhook_secret = models.CharField(max_length=255, blank=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return self.name
class SyncEvent(UUIDPrimaryKeyModel, TimeStampedModel):
class Direction(models.TextChoices):
INBOUND = "inbound", "POS → site"
OUTBOUND = "outbound", "Site → POS"
class Status(models.TextChoices):
PENDING = "pending", "Pending"
DONE = "done", "Done"
FAILED = "failed", "Failed"
connection = models.ForeignKey(
POSConnection,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="events",
)
direction = models.CharField(max_length=16, choices=Direction.choices)
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.PENDING
)
sku = models.CharField(max_length=64)
quantity = models.IntegerField(default=0)
external_id = models.CharField(max_length=255, blank=True)
payload = models.JSONField(default=dict, blank=True)
error = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.direction} {self.sku} × {self.quantity} · {self.status}"
+131
View File
@@ -0,0 +1,131 @@
"""POS inventory sync. Inbound webhooks decrement shop stock; outbound queues reserve POS."""
from __future__ import annotations
import logging
import requests
from django.conf import settings
from django.db import transaction
from pos_sync.models import POSConnection, SyncEvent
from shop.models import Product
from shop.services import ShopError, adjust_stock
logger = logging.getLogger(__name__)
class POSSyncError(RuntimeError):
pass
def active_connection() -> POSConnection | None:
return POSConnection.objects.filter(is_active=True).order_by("created_at").first()
def apply_inbound_sale(
*,
sku: str,
quantity: int,
external_id: str = "",
payload: dict | None = None,
connection: POSConnection | None = None,
) -> SyncEvent:
sku = (sku or "").strip()
if not sku:
raise POSSyncError("SKU is required.")
if quantity < 1:
raise POSSyncError("Quantity must be at least 1.")
product = Product.objects.filter(sku__iexact=sku).first()
if product is None:
raise POSSyncError(f"Unknown SKU {sku}.")
event = SyncEvent.objects.create(
connection=connection or active_connection(),
direction=SyncEvent.Direction.INBOUND,
status=SyncEvent.Status.PENDING,
sku=product.sku,
quantity=quantity,
external_id=external_id,
payload=payload or {},
)
try:
with transaction.atomic():
locked = Product.objects.select_for_update().get(pk=product.pk)
if locked.track_inventory and locked.fulfillment == Product.Fulfillment.STOCKED:
adjust_stock(locked, -quantity)
event.status = SyncEvent.Status.DONE
event.save(update_fields=["status", "updated_at"])
except ShopError as exc:
event.status = SyncEvent.Status.FAILED
event.error = str(exc)
event.save(update_fields=["status", "error", "updated_at"])
raise POSSyncError(str(exc)) from exc
return event
def enqueue_online_sale(order) -> list[SyncEvent]:
"""Queue outbound POS reserves after an online shop sale."""
events = []
connection = active_connection()
for item in order.items.all():
sku = item.sku
if not sku:
continue
events.append(
SyncEvent.objects.create(
connection=connection,
direction=SyncEvent.Direction.OUTBOUND,
status=SyncEvent.Status.PENDING,
sku=sku,
quantity=item.quantity,
external_id=order.number,
payload={"order_id": str(order.pk), "order_number": order.number},
)
)
return events
def _post_reserve(connection: POSConnection, event: SyncEvent) -> None:
base = (connection.api_base_url or settings.POS_API_BASE_URL or "").rstrip("/")
token = connection.api_token or (settings.POS_API_TOKEN or "")
if not base:
raise POSSyncError("POS API base URL is not configured.")
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
response = requests.post(
f"{base}/inventory/reserve",
json={
"sku": event.sku,
"quantity": event.quantity,
"external_id": event.external_id,
"source": "online",
},
headers=headers,
timeout=15,
)
response.raise_for_status()
def dispatch_pending_outbound() -> int:
sent = 0
for event in SyncEvent.objects.filter(
direction=SyncEvent.Direction.OUTBOUND,
status=SyncEvent.Status.PENDING,
)[:50]:
connection = event.connection or active_connection()
try:
if connection is None:
raise POSSyncError("No active POS connection.")
_post_reserve(connection, event)
except Exception as exc: # noqa: BLE001
logger.exception("POS outbound failed for %s", event.sku)
event.status = SyncEvent.Status.FAILED
event.error = str(exc)
event.save(update_fields=["status", "error", "updated_at"])
else:
event.status = SyncEvent.Status.DONE
event.error = ""
event.save(update_fields=["status", "error", "updated_at"])
sent += 1
return sent
@@ -0,0 +1,22 @@
{% extends "portal_base.html" %}
{% block title %}POS sync · Portal{% endblock %}
{% block topbar_title %}POS sync{% endblock %}
{% block portal_content %}
<p class="muted">Inbound POS sales decrement shop stock. Online sales queue outbound reserves.</p>
<table class="table">
<thead><tr><th>When</th><th>Direction</th><th>SKU</th><th>Qty</th><th>Status</th></tr></thead>
<tbody>
{% for event in events %}
<tr>
<td>{{ event.created_at }}</td>
<td>{{ event.get_direction_display }}</td>
<td>{{ event.sku }}</td>
<td>{{ event.quantity }}</td>
<td>{{ event.get_status_display }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="empty-state">No sync events yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+115
View File
@@ -0,0 +1,115 @@
from decimal import Decimal
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from pos_sync.models import POSConnection, SyncEvent
from pos_sync.services import apply_inbound_sale, dispatch_pending_outbound, enqueue_online_sale
from shop.models import Product
from shop.services import add_to_cart, create_order_from_cart, mark_paid
class POSInboundTests(TestCase):
def setUp(self):
self.product = Product.objects.create(
name="Booster pack",
sku="TCG-PACK",
price=Decimal("4.99"),
stock_qty=10,
is_published=True,
)
def test_inbound_sale_decrements_stock(self):
event = apply_inbound_sale(sku="tcg-pack", quantity=3, external_id="pos-1")
self.product.refresh_from_db()
self.assertEqual(self.product.stock_qty, 7)
self.assertEqual(event.status, SyncEvent.Status.DONE)
@override_settings(POS_WEBHOOK_SECRET="pos-secret")
def test_webhook_requires_secret_and_updates_stock(self):
url = reverse("pos_sync:inventory_webhook")
anon = Client().post(
url,
data='{"sku":"TCG-PACK","quantity":1}',
content_type="application/json",
)
self.assertEqual(anon.status_code, 401)
ok = Client().post(
url,
data='{"sku":"TCG-PACK","quantity":2,"external_id":"reg-9"}',
content_type="application/json",
HTTP_AUTHORIZATION="Bearer pos-secret",
)
self.assertEqual(ok.status_code, 200)
self.product.refresh_from_db()
self.assertEqual(self.product.stock_qty, 8)
class POSOutboundTests(TestCase):
def setUp(self):
self.product = Product.objects.create(
name="Figure",
sku="FIG-1",
price=Decimal("12.00"),
stock_qty=5,
is_published=True,
)
self.connection = POSConnection.objects.create(
name="Counter",
api_base_url="https://pos.example.com",
api_token="tok",
is_active=True,
)
def test_paid_order_enqueues_outbound(self):
session = self.client.session
add_to_cart(session, self.product, 1)
session.save()
order = create_order_from_cart(self.client.session, email="a@example.com")
mark_paid(order)
event = SyncEvent.objects.get(direction=SyncEvent.Direction.OUTBOUND)
self.assertEqual(event.sku, "FIG-1")
self.assertEqual(event.status, SyncEvent.Status.PENDING)
def test_dispatch_posts_reserve(self):
enqueue_online_sale(
type(
"O",
(),
{
"number": "ORD-1",
"pk": "x",
"items": type(
"M",
(),
{
"all": lambda self: [
type("I", (), {"sku": "FIG-1", "quantity": 1})()
]
},
)(),
},
)()
)
with patch("pos_sync.services.requests.post") as post:
post.return_value.raise_for_status = lambda: None
sent = dispatch_pending_outbound()
self.assertEqual(sent, 1)
post.assert_called_once()
self.assertIn("/inventory/reserve", post.call_args.args[0])
event = SyncEvent.objects.get()
self.assertEqual(event.status, SyncEvent.Status.DONE)
class POSPortalTests(TestCase):
def test_list_requires_login(self):
self.assertEqual(Client().get(reverse("pos_sync:event_list")).status_code, 302)
def test_list_ok_when_logged_in(self):
User = get_user_model()
User.objects.create_user("clerk", password="test-pass-123")
client = Client()
client.login(username="clerk", password="test-pass-123")
self.assertEqual(client.get(reverse("pos_sync:event_list")).status_code, 200)
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from pos_sync import views
app_name = "pos_sync"
urlpatterns = [
path("", views.event_list, name="event_list"),
path("webhooks/inventory/", views.inventory_webhook, name="inventory_webhook"),
]
+70
View File
@@ -0,0 +1,70 @@
import json
import logging
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from pos_sync.models import POSConnection, SyncEvent
from pos_sync.services import POSSyncError, apply_inbound_sale
logger = logging.getLogger(__name__)
def _authorized(request) -> bool:
expected = (settings.POS_WEBHOOK_SECRET or "").strip()
if not expected:
conn = POSConnection.objects.filter(is_active=True).exclude(
webhook_secret=""
).first()
expected = (conn.webhook_secret if conn else "").strip()
if not expected:
return False
header = request.headers.get("Authorization", "")
token = ""
if header.startswith("Bearer "):
token = header[7:].strip()
token = token or request.GET.get("token") or ""
return token == expected
@login_required
def event_list(request):
events = SyncEvent.objects.select_related("connection")[:200]
connections = POSConnection.objects.all()
return render(
request,
"pos_sync/list.html",
{"events": events, "connections": connections},
)
@csrf_exempt
@require_http_methods(["POST"])
def inventory_webhook(request):
if not _authorized(request):
return HttpResponse("unauthorized", status=401)
try:
payload = json.loads(request.body.decode() or "{}")
except json.JSONDecodeError:
return HttpResponseBadRequest("invalid json")
sku = payload.get("sku") or payload.get("SKU") or ""
try:
quantity = int(payload.get("quantity") or payload.get("qty") or 0)
except (TypeError, ValueError):
return HttpResponseBadRequest("invalid quantity")
external_id = str(payload.get("external_id") or payload.get("id") or "")
try:
event = apply_inbound_sale(
sku=sku,
quantity=quantity,
external_id=external_id,
payload=payload,
)
except POSSyncError as exc:
logger.warning("POS inbound rejected: %s", exc)
return JsonResponse({"ok": False, "error": str(exc)}, status=400)
return JsonResponse({"ok": True, "id": str(event.pk), "status": event.status})
@@ -1,9 +1,9 @@
"""ASGI config for client_site."""
"""ASGI config for print_forge."""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "print_forge.settings")
application = get_asgi_application()
@@ -9,6 +9,28 @@ from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent.parent.parent
def _load_dotenv(path: Path) -> None:
"""Load repo-root `.env` for local runserver. Does not override existing env."""
if not path.is_file():
return
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
if not key or key in os.environ:
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"\"", "'"}:
value = value[1:-1]
os.environ[key] = value
if "test" not in sys.argv:
_load_dotenv(BASE_DIR.parent / ".env")
def env(key: str, default: str | None = None) -> str | None:
return os.environ.get(key, default)
@@ -54,8 +76,8 @@ def database_config() -> dict:
return {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": env("DB_NAME", "client_site"),
"USER": env("DB_USER", "client_site"),
"NAME": env("DB_NAME", "print_forge"),
"USER": env("DB_USER", "print_forge"),
"PASSWORD": env("DB_PASSWORD", ""),
"HOST": env("DB_HOST"),
"PORT": env("DB_PORT", "5432"),
@@ -115,11 +137,27 @@ FEATURE_BLOG = env_bool("FEATURE_BLOG", _feature_default)
FEATURE_PAYMENTS = env_bool("FEATURE_PAYMENTS", _feature_default)
FEATURE_SOCIAL = env_bool("FEATURE_SOCIAL", _feature_default)
FEATURE_SOCIAL_AI = env_bool("FEATURE_SOCIAL_AI", _feature_default)
FEATURE_SHOP = env_bool("FEATURE_SHOP", _feature_default)
FEATURE_POS_SYNC = env_bool("FEATURE_POS_SYNC", _feature_default)
FEATURE_EVENTS = env_bool("FEATURE_EVENTS", _feature_default)
FEATURE_SHIPPING = env_bool("FEATURE_SHIPPING", _feature_default)
if FEATURE_PAYMENTS and not FEATURE_EMAIL_SMS:
raise ImproperlyConfigured("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS")
if FEATURE_SOCIAL_AI and not FEATURE_SOCIAL:
raise ImproperlyConfigured("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL")
if FEATURE_SHOP and not FEATURE_EMAIL_SMS:
raise ImproperlyConfigured("FEATURE_SHOP requires FEATURE_EMAIL_SMS")
if FEATURE_SHOP and not FEATURE_PAYMENTS:
raise ImproperlyConfigured("FEATURE_SHOP requires FEATURE_PAYMENTS")
if FEATURE_POS_SYNC and not FEATURE_SHOP:
raise ImproperlyConfigured("FEATURE_POS_SYNC requires FEATURE_SHOP")
if FEATURE_EVENTS and not FEATURE_EMAIL_SMS:
raise ImproperlyConfigured("FEATURE_EVENTS requires FEATURE_EMAIL_SMS")
if FEATURE_EVENTS and not FEATURE_PAYMENTS:
raise ImproperlyConfigured("FEATURE_EVENTS requires FEATURE_PAYMENTS")
if FEATURE_SHIPPING and not FEATURE_SHOP:
raise ImproperlyConfigured("FEATURE_SHIPPING requires FEATURE_SHOP")
CORE_APPS = [
"core.apps.CoreConfig",
@@ -137,6 +175,10 @@ OPTIONAL_APPS = [
(FEATURE_PAYMENTS, "payments.apps.PaymentsConfig"),
(FEATURE_SOCIAL, "social.apps.SocialConfig"),
(FEATURE_SOCIAL_AI, "social_ai.apps.SocialAiConfig"),
(FEATURE_SHOP, "shop.apps.ShopConfig"),
(FEATURE_POS_SYNC, "pos_sync.apps.PosSyncConfig"),
(FEATURE_EVENTS, "events.apps.EventsConfig"),
(FEATURE_SHIPPING, "shipping.apps.ShippingConfig"),
]
DJANGO_APPS = [
"django.contrib.admin",
@@ -170,12 +212,12 @@ MIDDLEWARE = [
"public.middleware.UnderConstructionMiddleware",
]
ROOT_URLCONF = "client_site.urls"
ROOT_URLCONF = "print_forge.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "client_site" / "templates"],
"DIRS": [BASE_DIR / "print_forge" / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
@@ -185,13 +227,14 @@ TEMPLATES = [
"django.contrib.messages.context_processors.messages",
"public.context_processors.site_branding",
"public.context_processors.tianji_tracking",
"public.context_processors.shop_cart",
"core.context_processors.feature_registry",
],
},
},
]
WSGI_APPLICATION = "client_site.wsgi.application"
WSGI_APPLICATION = "print_forge.wsgi.application"
DATABASES = database_config()
@@ -218,7 +261,7 @@ USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [
BASE_DIR / "client_site" / "static",
BASE_DIR / "print_forge" / "static",
]
# Uploaded blobs live in the DB (core.StoredFile). Default storage is
# in-memory only so nothing is written to disk accidentally.
@@ -230,6 +273,10 @@ STORAGES = {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# Keep product photos (15 MB) and STLs (25 MB) in RAM so Django never
# spools them to /tmp. Larger social videos still use a request temp
# file, then the bytes go into StoredFile.
FILE_UPLOAD_MAX_MEMORY_SIZE = 26 * 1024 * 1024
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@@ -244,13 +291,13 @@ LOGOUT_REDIRECT_URL = "accounts:login"
SITE_UNDER_CONSTRUCTION = env_bool("SITE_UNDER_CONSTRUCTION", False)
# --- Branding / public site ---
SITE_NAME = env("SITE_NAME", "Your Company")
SITE_TAGLINE = env("SITE_TAGLINE", "A tagline for the public site")
SITE_NAME = env("SITE_NAME", "Print Forge")
SITE_TAGLINE = env("SITE_TAGLINE", "Handcrafted 3D printed toys")
PUBLIC_SITE_URL = env("PUBLIC_SITE_URL", "")
CONTACT_PHONE = env("CONTACT_PHONE", "")
CONTACT_EMAIL = env("CONTACT_EMAIL", "hello@example.com")
CONTACT_PHONE = env("CONTACT_PHONE", "630-632-0202")
CONTACT_EMAIL = env("CONTACT_EMAIL", "Printforgeprints@gmail.com")
CONTACT_ADDRESS = env("CONTACT_ADDRESS", "")
CONTACT_SERVICE_AREA = env("CONTACT_SERVICE_AREA", "")
CONTACT_SERVICE_AREA = env("CONTACT_SERVICE_AREA", "Chicagoland")
CREDIT_NAME = env("CREDIT_NAME", "AI ML Operations, LLC")
CREDIT_URL = env("CREDIT_URL", "https://aimloperations.com")
@@ -352,6 +399,20 @@ STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "")
STRIPE_CURRENCY = env("STRIPE_CURRENCY", "usd")
# --- Shop / POS / shipping (FEATURE_SHOP and add-ons) ---
# 0 = unlimited made-to-order print queue.
SHOP_PRINT_QUEUE_LIMIT_MINUTES = int(
env("SHOP_PRINT_QUEUE_LIMIT_MINUTES", "0") or "0"
)
POS_WEBHOOK_SECRET = env("POS_WEBHOOK_SECRET", "")
POS_API_BASE_URL = env("POS_API_BASE_URL", "")
POS_API_TOKEN = env("POS_API_TOKEN", "")
EASYPOST_API_KEY = env("EASYPOST_API_KEY", "")
SHIP_FROM_LINE1 = env("SHIP_FROM_LINE1", "")
SHIP_FROM_CITY = env("SHIP_FROM_CITY", "")
SHIP_FROM_STATE = env("SHIP_FROM_STATE", "")
SHIP_FROM_ZIP = env("SHIP_FROM_ZIP", "")
# --- Nominatim (address suggest; server-side proxy only) ---
# Self-hosted on ai-server-4080. Nominatim has no native API keys — gate with
# LAN UFW + this Django proxy. Optional NOMINATIM_API_KEY is forwarded as
@@ -361,7 +422,7 @@ NOMINATIM_TIMEOUT_SECONDS = int(env("NOMINATIM_TIMEOUT_SECONDS", "8") or "8")
NOMINATIM_COUNTRY_CODES = env("NOMINATIM_COUNTRY_CODES", "us")
NOMINATIM_USER_AGENT = env(
"NOMINATIM_USER_AGENT",
"client_site/1.0 (address-suggest; contact admin)",
"print_forge/1.0 (address-suggest; contact admin)",
)
NOMINATIM_API_KEY = env("NOMINATIM_API_KEY", "")

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -726,3 +726,162 @@ body.portal {
.utm-qr-actions { min-width: 160px; }
.utm-qr-actions .btn { margin: 0 8px 8px 0; }
#preview-body a { color: var(--monica-primary); }
.product-edit-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 320px);
gap: 24px;
align-items: start;
}
@media (max-width: 900px) {
.product-edit-layout { grid-template-columns: 1fr; }
}
.product-preview-col {
position: sticky;
top: 16px;
}
.product-card-preview {
background: #fff;
border: 1px solid #dddddd;
text-align: center;
overflow: hidden;
}
.product-card-preview-body {
padding: 20px 15px;
background: #f5f5f5;
}
.product-card-preview-figure {
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
.product-card-preview-figure img {
max-width: 100%;
max-height: 200px;
height: auto;
display: block;
}
.product-card-preview-title {
margin: 12px 0 6px;
font-size: 18px;
font-weight: 500;
}
.product-card-preview-price {
font-weight: 600;
color: var(--monica-ink);
}
.product-card-preview-desc {
margin: 10px 0 0;
font-size: 13px;
color: var(--monica-muted);
min-height: 1.2em;
}
.product-card-preview .product-color-dots {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
margin-top: 10px;
}
.product-card-preview .product-color-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #d1d5db;
display: inline-block;
}
.product-card-preview-panel {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid #dddddd;
font-size: 13px;
font-weight: 600;
}
.color-rows { display: grid; gap: 12px; margin-bottom: 10px; }
.color-row {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid var(--monica-border);
background: #fff;
}
.color-row-main {
display: grid;
grid-template-columns: 1fr 48px 72px auto;
gap: 8px;
align-items: center;
}
.color-row input[type="color"] {
width: 48px;
height: 38px;
padding: 2px;
cursor: pointer;
}
.color-row-photos {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.photo-thumbs,
.color-row-photos {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.photo-thumb {
width: 72px;
font-size: 11px;
color: var(--monica-muted);
display: grid;
gap: 4px;
justify-items: center;
}
.photo-thumb img {
width: 72px;
height: 72px;
object-fit: contain;
background: #f3f4f6;
border: 1px solid var(--monica-border);
display: block;
}
.field .inline { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-size: 13px; }
.product-thumb {
width: 40px;
height: 40px;
object-fit: contain;
display: block;
background: #f3f4f6;
}
.product-thumb-empty { display: inline-block; }
.stl-viewer {
position: relative;
width: 100%;
min-height: 220px;
height: 260px;
background: #f5f5f5;
border: 1px solid var(--monica-border);
overflow: hidden;
}
.stl-viewer canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.stl-viewer-status {
position: absolute;
inset: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: var(--monica-muted);
pointer-events: none;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,352 @@
/* Print Forge public brand overrides on the Ecoprint theme */
:root {
--pf-primary: #ff6252;
--pf-primary-dark: #e24f41;
--pf-ink: #151515;
--pf-muted: #6b7280;
}
/* Theme CSS starts `.page` at opacity:0 until a `.preloader` runs.
Keep this so a missed preloader never blanks the site. */
.page {
opacity: 1;
}
.rd-navbar-brand .brand img,
.rd-navbar-brand .brand-logo-dark,
.rd-navbar-brand .brand-logo-light,
.footer-classic-brand .brand img {
height: 39px;
width: auto;
max-width: 180px;
}
.flash {
list-style: none;
margin: 0;
padding: 12px 0 0;
}
.flash li {
max-width: 1200px;
margin: 0 auto 8px;
padding: 12px 16px;
background: #fde8e8;
border-left: 3px solid var(--pf-primary);
color: #7f1d1d;
font-size: 14px;
}
.flash li.success {
background: #ecfdf5;
border-left-color: #059669;
color: #065f46;
}
.flash li.error,
.flash li.danger {
background: #fee2e2;
border-left-color: #b91c1c;
color: #991b1b;
}
.form-wrap .errorlist,
.errorlist {
list-style: none;
margin: 6px 0 0;
padding: 0;
color: #b91c1c;
font-size: 13px;
}
.cookie-consent-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 11000;
padding: 1rem 1.25rem;
background: rgba(21, 21, 21, 0.96);
border-top: 2px solid var(--pf-primary);
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.28);
}
.cookie-consent-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.cookie-consent-text {
flex: 1 1 420px;
color: #d7dee3;
font-size: 0.9375rem;
line-height: 1.45;
margin: 0;
}
.cookie-consent-text-short {
display: none;
}
.cookie-consent-actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.footer-link-btn {
background: none;
border: none;
padding: 0;
color: var(--pf-primary);
font: inherit;
font-size: 13px;
cursor: pointer;
text-decoration: none;
}
.footer-link-btn:hover {
text-decoration: underline;
color: var(--pf-primary-dark);
}
.cookie-consent-link {
color: #ffd3ce;
text-decoration: underline;
text-underline-offset: 2px;
}
.cookie-consent-link:hover {
color: #fff;
}
.legal-prose {
max-width: 42rem;
}
.legal-prose .legal-lead {
font-size: 1.05rem;
color: var(--pf-ink);
margin-bottom: 1.75rem;
}
.legal-prose h2 {
font-size: 1.35rem;
margin-top: 2rem;
margin-bottom: 0.75rem;
text-transform: none;
letter-spacing: 0;
}
.legal-prose p {
margin-bottom: 1rem;
color: #4b5563;
line-height: 1.65;
}
.legal-prose .legal-updated {
margin-top: 2.5rem;
font-size: 0.875rem;
color: var(--pf-muted);
}
.footer-made-by {
margin: 10px 0 0;
font-size: 13px;
color: #6b7280;
}
.footer-made-by a {
color: var(--pf-primary);
text-decoration: underline;
}
.form-label-outside {
display: block;
margin-bottom: 6px;
font-weight: 500;
}
.product-figure {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
}
.product-figure img,
.slick-product-figure img {
max-width: 100%;
height: auto;
object-fit: contain;
}
.product-color-dots {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
margin-top: 10px;
}
.product-color-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #d1d5db;
display: inline-block;
}
.product-color-picker {
margin: 8px 0 20px;
}
.product-color-label {
margin: 0 0 10px;
font-weight: 500;
color: #151515;
}
.product-color-swatches {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.product-color-swatch {
cursor: pointer;
display: inline-flex;
}
.product-color-swatch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.product-color-swatch span {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #d1d5db;
display: block;
}
.product-color-swatch input:checked + span,
.product-color-swatch input:focus-visible + span {
box-shadow: 0 0 0 2px var(--pf-primary);
}
.product-gallery-layout {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.product-gallery-thumbs {
display: flex;
flex-direction: column;
gap: 8px;
}
.product-gallery-thumbs button {
width: 72px;
height: 72px;
padding: 4px;
border: 2px solid #dddddd;
background: #fff;
cursor: pointer;
overflow: hidden;
}
.product-gallery-thumbs button.active,
.product-gallery-thumbs button[aria-selected="true"] {
border-color: var(--pf-primary);
}
.product-gallery-thumbs img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.product-gallery-thumb-3d {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
color: #151515;
background: #f5f5f5;
}
.product-gallery-stage {
min-width: 0;
}
.product-media-hint {
margin: 8px 0 0;
font-size: 13px;
color: #6b7280;
text-align: center;
}
.stl-viewer {
position: relative;
width: 100%;
min-height: 360px;
height: 420px;
background: #f5f5f5;
border: 1px solid #dddddd;
overflow: hidden;
}
.stl-viewer canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.stl-viewer-status {
position: absolute;
inset: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #6b7280;
pointer-events: none;
}
.stl-viewer.is-error .stl-viewer-status {
color: #991b1b;
}
@media (max-width: 767px) {
.product-gallery-layout {
grid-template-columns: 1fr;
}
.product-gallery-stage {
order: -1;
}
.product-gallery-thumbs {
flex-direction: row;
overflow-x: auto;
}
}
@media (max-width: 640px) {
.cookie-consent-banner {
padding: 0.625rem 0.75rem;
padding-bottom: max(0.625rem, env(safe-area-inset-bottom));
}
.cookie-consent-content {
flex-direction: column;
align-items: stretch;
gap: 0.625rem;
}
.cookie-consent-text-short {
display: inline;
}
.cookie-consent-text-full {
display: none;
}
.cookie-consent-text {
flex: none;
font-size: 0.8125rem;
line-height: 1.35;
}
.cookie-consent-actions {
width: 100%;
}
}

Some files were not shown because too many files have changed in this diff Show More