generated from westfarn/web_django_template
Align the public site with the PRINTFORGE banner for client demos.
CI / test (pull_request) Successful in 38s
CI / test (pull_request) Successful in 38s
Replace orange with cyan/lime/navy, swap the splash for a print animation, make smart-crop optional, and add a prod-safe seed_demo command. Closes #5. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,807 @@
|
|||||||
|
"""Populate a demo catalog, portal lists, and shop traffic for client walkthroughs.
|
||||||
|
|
||||||
|
Safe on DJANGO_ENV=dev and beta only. Never runs against prod. Does not send
|
||||||
|
mail or call EasyPost/Stripe. Re-runs are idempotent (DEMO- / demo+ keys).
|
||||||
|
Pass --reset to wipe tagged demo rows and seed again.
|
||||||
|
|
||||||
|
uv run python manage.py seed_demo
|
||||||
|
docker compose exec web uv run python manage.py seed_demo --reset
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
DEMO_NOTE = "[demo-seed]"
|
||||||
|
DEMO_UTM_CAMPAIGN = "printforge-demo"
|
||||||
|
DEMO_EMAIL_DOMAIN = "@example.com"
|
||||||
|
DEMO_EMAIL_PREFIX = "demo+"
|
||||||
|
|
||||||
|
|
||||||
|
def _django_env() -> str:
|
||||||
|
return (os.environ.get("DJANGO_ENV") or "dev").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hex(value: str) -> tuple[int, int, int]:
|
||||||
|
raw = (value or "#808080").strip().lstrip("#")
|
||||||
|
if len(raw) != 6:
|
||||||
|
return (128, 128, 128)
|
||||||
|
return int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16)
|
||||||
|
|
||||||
|
|
||||||
|
def _toy_png(hex_color: str) -> bytes:
|
||||||
|
"""Square RGBA figurine swatch so shop cards look like product photos."""
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
size = 800
|
||||||
|
rgb = _parse_hex(hex_color)
|
||||||
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
fill = (*rgb, 255)
|
||||||
|
shadow = tuple(max(0, c - 40) for c in rgb) + (255,)
|
||||||
|
highlight = tuple(min(255, c + 50) for c in rgb) + (220,)
|
||||||
|
draw.ellipse([250, 620, 550, 740], fill=(15, 20, 28, 40))
|
||||||
|
draw.rounded_rectangle([260, 300, 540, 660], radius=48, fill=fill)
|
||||||
|
draw.rounded_rectangle([280, 320, 400, 640], radius=36, fill=highlight)
|
||||||
|
draw.ellipse([230, 140, 570, 420], fill=fill)
|
||||||
|
draw.ellipse([250, 160, 430, 340], fill=highlight)
|
||||||
|
draw.polygon([(400, 80), (460, 200), (340, 200)], fill=shadow)
|
||||||
|
draw.ellipse([310, 230, 380, 300], fill=(255, 255, 255, 255))
|
||||||
|
draw.ellipse([420, 230, 490, 300], fill=(255, 255, 255, 255))
|
||||||
|
draw.ellipse([332, 250, 368, 286], fill=(10, 14, 20, 255))
|
||||||
|
draw.ellipse([442, 250, 478, 286], fill=(10, 14, 20, 255))
|
||||||
|
buf = BytesIO()
|
||||||
|
img.save(buf, format="PNG", optimize=True)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _cube_stl(size: float = 24.0) -> bytes:
|
||||||
|
"""Binary STL of a cube so the shop 3D viewer has something to spin."""
|
||||||
|
s = size / 2.0
|
||||||
|
faces = (
|
||||||
|
((0, 0, 1), ((-s, -s, s), (s, -s, s), (s, s, s)), ((-s, -s, s), (s, s, s), (-s, s, s))),
|
||||||
|
((0, 0, -1), ((-s, -s, -s), (-s, s, -s), (s, s, -s)), ((-s, -s, -s), (s, s, -s), (s, -s, -s))),
|
||||||
|
((0, 1, 0), ((-s, s, -s), (-s, s, s), (s, s, s)), ((-s, s, -s), (s, s, s), (s, s, -s))),
|
||||||
|
((0, -1, 0), ((-s, -s, -s), (s, -s, -s), (s, -s, s)), ((-s, -s, -s), (s, -s, s), (-s, -s, s))),
|
||||||
|
((1, 0, 0), ((s, -s, -s), (s, s, -s), (s, s, s)), ((s, -s, -s), (s, s, s), (s, -s, s))),
|
||||||
|
((-1, 0, 0), ((-s, -s, -s), (-s, -s, s), (-s, s, s)), ((-s, -s, -s), (-s, s, s), (-s, s, -s))),
|
||||||
|
)
|
||||||
|
triangles: list[tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]]] = []
|
||||||
|
for normal, tri_a, tri_b in faces:
|
||||||
|
triangles.append((normal, tri_a[0], tri_a[1], tri_a[2]))
|
||||||
|
triangles.append((normal, tri_b[0], tri_b[1], tri_b[2]))
|
||||||
|
buf = BytesIO()
|
||||||
|
buf.write(b"PrintForge demo cube".ljust(80, b"\x00"))
|
||||||
|
buf.write(struct.pack("<I", len(triangles)))
|
||||||
|
for normal, a, b, c in triangles:
|
||||||
|
buf.write(struct.pack("<12fH", *normal, *a, *b, *c, 0))
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp(model, pk, when) -> None:
|
||||||
|
model.objects.filter(pk=pk).update(created_at=when, updated_at=when)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Seed tagged fake catalog, contacts, leads, orders, campaigns, and "
|
||||||
|
"analytics for client demos. Allowed on DJANGO_ENV=dev and beta only."
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--reset",
|
||||||
|
action="store_true",
|
||||||
|
help="Delete previously tagged demo rows, then seed again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
env = _django_env()
|
||||||
|
if env == "prod":
|
||||||
|
raise CommandError(
|
||||||
|
"seed_demo refuses DJANGO_ENV=prod. Run it on dev or beta."
|
||||||
|
)
|
||||||
|
self.verbosity = int(options.get("verbosity", 1))
|
||||||
|
self.now = timezone.now()
|
||||||
|
self.owner = get_user_model().objects.order_by("pk").first()
|
||||||
|
if options["reset"]:
|
||||||
|
self._reset()
|
||||||
|
self.stdout.write("Cleared tagged demo rows.")
|
||||||
|
counts = {
|
||||||
|
"contacts": self._seed_contacts(),
|
||||||
|
"leads": self._seed_leads(),
|
||||||
|
"analytics": self._seed_analytics(),
|
||||||
|
}
|
||||||
|
if apps.is_installed("shop"):
|
||||||
|
counts["products"] = self._seed_products()
|
||||||
|
counts["orders"] = self._seed_orders()
|
||||||
|
if apps.is_installed("shipping"):
|
||||||
|
counts["shipments"] = self._seed_shipments()
|
||||||
|
if apps.is_installed("payments"):
|
||||||
|
counts["invoices"] = self._seed_invoices()
|
||||||
|
if apps.is_installed("email_sms"):
|
||||||
|
counts["campaigns"] = self._seed_campaigns()
|
||||||
|
summary = ", ".join(f"{key}={value}" for key, value in counts.items())
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Demo seed ready on {env} ({summary})."))
|
||||||
|
|
||||||
|
def _log(self, message: str) -> None:
|
||||||
|
if self.verbosity >= 2:
|
||||||
|
self.stdout.write(message)
|
||||||
|
|
||||||
|
def _reset(self) -> None:
|
||||||
|
from analytics.models import UTMVisit
|
||||||
|
from contacts.models import Contact
|
||||||
|
from leads.models import Lead
|
||||||
|
|
||||||
|
if apps.is_installed("shipping"):
|
||||||
|
from shipping.models import Shipment
|
||||||
|
|
||||||
|
Shipment.objects.filter(order__number__startswith="DEMO-").delete()
|
||||||
|
if apps.is_installed("shop"):
|
||||||
|
from core.models import StoredFile
|
||||||
|
from shop.models import Order, Product, ProductImage
|
||||||
|
|
||||||
|
Order.objects.filter(number__startswith="DEMO-").delete()
|
||||||
|
products = Product.objects.filter(sku__startswith="DEMO-")
|
||||||
|
file_ids = list(
|
||||||
|
ProductImage.objects.filter(product__in=products).values_list(
|
||||||
|
"file_id", flat=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
extra_ids = list(products.values_list("stl_id", "image_id"))
|
||||||
|
products.delete()
|
||||||
|
blob_ids = {pk for pk in file_ids if pk}
|
||||||
|
for stl_id, image_id in extra_ids:
|
||||||
|
blob_ids.update(pk for pk in (stl_id, image_id) if pk)
|
||||||
|
if blob_ids:
|
||||||
|
StoredFile.objects.filter(pk__in=blob_ids).delete()
|
||||||
|
if apps.is_installed("payments"):
|
||||||
|
from payments.models import Invoice
|
||||||
|
|
||||||
|
Invoice.objects.filter(number__startswith="DEMO-").delete()
|
||||||
|
if apps.is_installed("email_sms"):
|
||||||
|
from email_sms.models import Campaign
|
||||||
|
|
||||||
|
Campaign.objects.filter(name__startswith="DEMO:").delete()
|
||||||
|
Lead.objects.filter(message__startswith=DEMO_NOTE).delete()
|
||||||
|
Contact.objects.filter(
|
||||||
|
email__startswith=DEMO_EMAIL_PREFIX,
|
||||||
|
email__endswith=DEMO_EMAIL_DOMAIN,
|
||||||
|
).delete()
|
||||||
|
UTMVisit.objects.filter(utm_campaign=DEMO_UTM_CAMPAIGN).delete()
|
||||||
|
|
||||||
|
def _seed_contacts(self) -> int:
|
||||||
|
from contacts.consent import set_channel_consent
|
||||||
|
from contacts.models import Channel, Contact
|
||||||
|
|
||||||
|
people = (
|
||||||
|
(
|
||||||
|
"maya",
|
||||||
|
"Maya",
|
||||||
|
"Chen",
|
||||||
|
"630-555-0142",
|
||||||
|
Contact.Source.CONTACT_FORM,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
"Naperville",
|
||||||
|
"Looking for classroom STEM kits.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"jordan",
|
||||||
|
"Jordan",
|
||||||
|
"Walsh",
|
||||||
|
"630-555-0198",
|
||||||
|
Contact.Source.MANUAL,
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
"Aurora",
|
||||||
|
"Repeat buyer, prefers cyan filament.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"priya",
|
||||||
|
"Priya",
|
||||||
|
"Shah",
|
||||||
|
"847-555-0110",
|
||||||
|
Contact.Source.IMPORT,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
"Wheaton",
|
||||||
|
"Wedding favor quote, 40 pieces.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"evan",
|
||||||
|
"Evan",
|
||||||
|
"Brooks",
|
||||||
|
"312-555-0177",
|
||||||
|
Contact.Source.NOTIFY_ME,
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
"Chicago",
|
||||||
|
"Notify list from the coming-soon page.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sam",
|
||||||
|
"Sam",
|
||||||
|
"Ortiz",
|
||||||
|
"630-555-0166",
|
||||||
|
Contact.Source.CONTACT_FORM,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
"Lisle",
|
||||||
|
"Custom name keychains for a team.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"riley",
|
||||||
|
"Riley",
|
||||||
|
"Nguyen",
|
||||||
|
"708-555-0133",
|
||||||
|
Contact.Source.OTHER,
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
"Downers Grove",
|
||||||
|
"Asked about made-to-order dragons.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"alex",
|
||||||
|
"Alex",
|
||||||
|
"Patel",
|
||||||
|
"630-555-0121",
|
||||||
|
Contact.Source.MANUAL,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
"Naperville",
|
||||||
|
"Opted out of email after one campaign.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"casey",
|
||||||
|
"Casey",
|
||||||
|
"Miller",
|
||||||
|
"815-555-0188",
|
||||||
|
Contact.Source.IMPORT,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
"Geneva",
|
||||||
|
"PTA bulk order for a school fair.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
created = 0
|
||||||
|
for slug, first, last, phone, source, email_on, sms_on, city, note in people:
|
||||||
|
email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
|
||||||
|
contact, was_created = Contact.objects.update_or_create(
|
||||||
|
email=email,
|
||||||
|
defaults={
|
||||||
|
"first_name": first,
|
||||||
|
"last_name": last,
|
||||||
|
"phone": phone,
|
||||||
|
"source": source,
|
||||||
|
"notes": f"{DEMO_NOTE} {note}",
|
||||||
|
"postal_address": Contact.make_postal_address(
|
||||||
|
line1=f"{100 + len(slug) * 17} Demo Ave",
|
||||||
|
city=city,
|
||||||
|
state="IL",
|
||||||
|
zip_code="60540",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
set_channel_consent(
|
||||||
|
contact, Channel.EMAIL, opted_in=email_on, reason="demo_seed"
|
||||||
|
)
|
||||||
|
set_channel_consent(
|
||||||
|
contact, Channel.SMS, opted_in=sms_on, reason="demo_seed"
|
||||||
|
)
|
||||||
|
if was_created:
|
||||||
|
created += 1
|
||||||
|
self._log(f"contact {contact.email}")
|
||||||
|
return created
|
||||||
|
|
||||||
|
def _seed_leads(self) -> int:
|
||||||
|
from analytics.models import Attribution
|
||||||
|
from contacts.models import Contact
|
||||||
|
from leads.models import Lead, LeadNote
|
||||||
|
|
||||||
|
specs = (
|
||||||
|
(
|
||||||
|
"maya",
|
||||||
|
Lead.Status.NEW,
|
||||||
|
"Need 12 flexi dragons in school colors by next Friday.",
|
||||||
|
"instagram",
|
||||||
|
"social",
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"priya",
|
||||||
|
Lead.Status.CONTACTED,
|
||||||
|
"Quoted 40 keychains for wedding favors. Waiting on names.",
|
||||||
|
"google",
|
||||||
|
"cpc",
|
||||||
|
8,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"casey",
|
||||||
|
Lead.Status.WON,
|
||||||
|
"STEM night kits — 30 planters, paid via invoice.",
|
||||||
|
"google",
|
||||||
|
"organic",
|
||||||
|
18,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"riley",
|
||||||
|
Lead.Status.LOST,
|
||||||
|
"Wanted 200 units at $4. Could not hit that price.",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
12,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
created = 0
|
||||||
|
for slug, status, body, source, medium, days_ago in specs:
|
||||||
|
email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
|
||||||
|
contact = Contact.objects.filter(email=email).first()
|
||||||
|
if contact is None:
|
||||||
|
continue
|
||||||
|
message = f"{DEMO_NOTE} {body}"
|
||||||
|
lead, was_created = Lead.objects.get_or_create(
|
||||||
|
contact=contact,
|
||||||
|
message=message,
|
||||||
|
defaults={"status": status, "owner": self.owner},
|
||||||
|
)
|
||||||
|
if not was_created:
|
||||||
|
if lead.status != status:
|
||||||
|
lead.status = status
|
||||||
|
lead.owner = self.owner
|
||||||
|
lead.save(update_fields=["status", "owner", "updated_at"])
|
||||||
|
else:
|
||||||
|
created += 1
|
||||||
|
LeadNote.objects.create(
|
||||||
|
lead=lead,
|
||||||
|
author=self.owner,
|
||||||
|
body=f"{DEMO_NOTE} Demo walkthrough note.",
|
||||||
|
)
|
||||||
|
when = self.now - timedelta(days=days_ago)
|
||||||
|
_stamp(Lead, lead.pk, when)
|
||||||
|
if source:
|
||||||
|
Attribution.objects.update_or_create(
|
||||||
|
lead=lead,
|
||||||
|
defaults={
|
||||||
|
"utm_source": source,
|
||||||
|
"utm_medium": medium,
|
||||||
|
"utm_campaign": DEMO_UTM_CAMPAIGN,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._log(f"lead {slug} {status}")
|
||||||
|
return created
|
||||||
|
|
||||||
|
def _seed_analytics(self) -> int:
|
||||||
|
from analytics.models import PageView, UTMVisit
|
||||||
|
|
||||||
|
if UTMVisit.objects.filter(utm_campaign=DEMO_UTM_CAMPAIGN).exists():
|
||||||
|
return 0
|
||||||
|
paths = (
|
||||||
|
"/",
|
||||||
|
"/",
|
||||||
|
"/",
|
||||||
|
"/shop/",
|
||||||
|
"/shop/",
|
||||||
|
"/shop/demo-flexi-dragon/",
|
||||||
|
"/shop/demo-rocket/",
|
||||||
|
"/about/",
|
||||||
|
"/contact/",
|
||||||
|
)
|
||||||
|
created = 0
|
||||||
|
for offset, path in enumerate(paths * 6):
|
||||||
|
when = self.now - timedelta(hours=4 * offset + 3)
|
||||||
|
view = PageView.objects.create(path=path)
|
||||||
|
_stamp(PageView, view.pk, when)
|
||||||
|
created += 1
|
||||||
|
sources = (
|
||||||
|
("google", "cpc", "/shop/"),
|
||||||
|
("google", "organic", "/"),
|
||||||
|
("instagram", "social", "/shop/demo-flexi-dragon/"),
|
||||||
|
("facebook", "social", "/contact/"),
|
||||||
|
("direct", "", "/"),
|
||||||
|
)
|
||||||
|
for i, (source, medium, path) in enumerate(sources * 8):
|
||||||
|
when = self.now - timedelta(hours=6 * i + 2)
|
||||||
|
visit = UTMVisit.objects.create(
|
||||||
|
correlation_id=f"demo-{i:03d}",
|
||||||
|
path=path,
|
||||||
|
utm_source=source,
|
||||||
|
utm_medium=medium,
|
||||||
|
utm_campaign=DEMO_UTM_CAMPAIGN,
|
||||||
|
user_agent="PrintForge demo seed",
|
||||||
|
)
|
||||||
|
_stamp(UTMVisit, visit.pk, when)
|
||||||
|
created += 1
|
||||||
|
return created
|
||||||
|
|
||||||
|
def _seed_products(self) -> int:
|
||||||
|
from core.models import StoredFile
|
||||||
|
from shop.models import Product, ProductColor, ProductImage
|
||||||
|
from shop.services import refresh_listing_image
|
||||||
|
|
||||||
|
stl_bytes = _cube_stl()
|
||||||
|
stl, _ = StoredFile.objects.get_or_create(
|
||||||
|
filename="demo-cube.stl",
|
||||||
|
kind=StoredFile.Kind.PRODUCT_STL,
|
||||||
|
defaults={
|
||||||
|
"content_type": "model/stl",
|
||||||
|
"size": len(stl_bytes),
|
||||||
|
"data": stl_bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
catalog = (
|
||||||
|
{
|
||||||
|
"sku": "DEMO-FLEXI-DRAGON",
|
||||||
|
"name": "Articulated Flexi Dragon",
|
||||||
|
"price": Decimal("24.00"),
|
||||||
|
"fulfillment": Product.Fulfillment.STOCKED,
|
||||||
|
"stock_qty": 0,
|
||||||
|
"print_minutes": 180,
|
||||||
|
"filament_grams": 42,
|
||||||
|
"published": True,
|
||||||
|
"description": (
|
||||||
|
"Print-in-place flexi dragon. No assembly. A desk toy that "
|
||||||
|
"actually wiggles when you pick it up."
|
||||||
|
),
|
||||||
|
"colors": (
|
||||||
|
("Flame Red", "#c41e3a", 6),
|
||||||
|
("Forge Cyan", "#00aeef", 8),
|
||||||
|
("Lime", "#8dc63f", 4),
|
||||||
|
),
|
||||||
|
"stl": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sku": "DEMO-D20",
|
||||||
|
"name": "Oversized D20",
|
||||||
|
"price": Decimal("18.00"),
|
||||||
|
"fulfillment": Product.Fulfillment.STOCKED,
|
||||||
|
"stock_qty": 14,
|
||||||
|
"print_minutes": 90,
|
||||||
|
"filament_grams": 28,
|
||||||
|
"published": True,
|
||||||
|
"description": "Chunky twenty-sided die. Looks great in a dice tray.",
|
||||||
|
"colors": (("Obsidian", "#1f2937", 9), ("Gold", "#c4a574", 5)),
|
||||||
|
"stl": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sku": "DEMO-ROCKET",
|
||||||
|
"name": "Desktop Rocket",
|
||||||
|
"price": Decimal("32.00"),
|
||||||
|
"fulfillment": Product.Fulfillment.MADE_TO_ORDER,
|
||||||
|
"stock_qty": 0,
|
||||||
|
"print_minutes": 210,
|
||||||
|
"filament_grams": 55,
|
||||||
|
"published": True,
|
||||||
|
"description": "Two-color rocket on a stand. Printed to order.",
|
||||||
|
"colors": (("Navy", "#0a0e14", 0), ("Cyan", "#00aeef", 0)),
|
||||||
|
"stl": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sku": "DEMO-PLANTER",
|
||||||
|
"name": "Geometric Planter",
|
||||||
|
"price": Decimal("28.00"),
|
||||||
|
"fulfillment": Product.Fulfillment.STOCKED,
|
||||||
|
"stock_qty": 2,
|
||||||
|
"print_minutes": 240,
|
||||||
|
"filament_grams": 80,
|
||||||
|
"published": True,
|
||||||
|
"description": "Faceted succulent planter. Drainage hole included.",
|
||||||
|
"colors": (),
|
||||||
|
"stl": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sku": "DEMO-KEYCHAIN",
|
||||||
|
"name": "Custom Name Keychain",
|
||||||
|
"price": Decimal("12.00"),
|
||||||
|
"fulfillment": Product.Fulfillment.MADE_TO_ORDER,
|
||||||
|
"stock_qty": 0,
|
||||||
|
"print_minutes": 35,
|
||||||
|
"filament_grams": 8,
|
||||||
|
"published": True,
|
||||||
|
"description": "Tell us the name. We print it on a split ring loop.",
|
||||||
|
"colors": (("Cyan", "#00aeef", 0), ("Lime", "#8dc63f", 0)),
|
||||||
|
"stl": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sku": "DEMO-PROTO",
|
||||||
|
"name": "Prototype Mech (coming soon)",
|
||||||
|
"price": Decimal("45.00"),
|
||||||
|
"fulfillment": Product.Fulfillment.MADE_TO_ORDER,
|
||||||
|
"stock_qty": 0,
|
||||||
|
"print_minutes": 400,
|
||||||
|
"filament_grams": 120,
|
||||||
|
"published": False,
|
||||||
|
"description": "Unpublished prototype so the portal list is not empty of drafts.",
|
||||||
|
"colors": (),
|
||||||
|
"stl": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
created = 0
|
||||||
|
for spec in catalog:
|
||||||
|
product, was_created = Product.objects.update_or_create(
|
||||||
|
sku=spec["sku"],
|
||||||
|
defaults={
|
||||||
|
"name": spec["name"],
|
||||||
|
"slug": spec["sku"].lower(),
|
||||||
|
"description": spec["description"],
|
||||||
|
"price": spec["price"],
|
||||||
|
"currency": "usd",
|
||||||
|
"fulfillment": spec["fulfillment"],
|
||||||
|
"stock_qty": spec["stock_qty"],
|
||||||
|
"print_minutes": spec["print_minutes"],
|
||||||
|
"filament_grams": spec["filament_grams"],
|
||||||
|
"is_published": spec["published"],
|
||||||
|
"track_inventory": True,
|
||||||
|
"stl": stl if spec["stl"] else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if was_created:
|
||||||
|
created += 1
|
||||||
|
if spec["colors"] and not product.colors.exists():
|
||||||
|
for index, (name, hex_color, qty) in enumerate(spec["colors"]):
|
||||||
|
ProductColor.objects.create(
|
||||||
|
product=product,
|
||||||
|
name=name,
|
||||||
|
hex=hex_color,
|
||||||
|
stock_qty=qty,
|
||||||
|
sort_order=index,
|
||||||
|
)
|
||||||
|
if not product.images.exists():
|
||||||
|
hex_color = spec["colors"][0][1] if spec["colors"] else "#00aeef"
|
||||||
|
png = _toy_png(hex_color)
|
||||||
|
stored = StoredFile.objects.create(
|
||||||
|
kind=StoredFile.Kind.PRODUCT_IMAGE,
|
||||||
|
filename=f"{spec['sku'].lower()}.png",
|
||||||
|
content_type="image/png",
|
||||||
|
size=len(png),
|
||||||
|
data=png,
|
||||||
|
)
|
||||||
|
ProductImage.objects.create(product=product, file=stored, sort_order=0)
|
||||||
|
for index, color in enumerate(product.colors.all(), start=1):
|
||||||
|
color_png = _toy_png(color.hex)
|
||||||
|
color_file = StoredFile.objects.create(
|
||||||
|
kind=StoredFile.Kind.PRODUCT_IMAGE,
|
||||||
|
filename=f"{spec['sku'].lower()}-{slugify(color.name)}.png",
|
||||||
|
content_type="image/png",
|
||||||
|
size=len(color_png),
|
||||||
|
data=color_png,
|
||||||
|
)
|
||||||
|
ProductImage.objects.create(
|
||||||
|
product=product,
|
||||||
|
color=color,
|
||||||
|
file=color_file,
|
||||||
|
sort_order=index,
|
||||||
|
)
|
||||||
|
refresh_listing_image(product)
|
||||||
|
self._log(f"product {product.sku}")
|
||||||
|
return created
|
||||||
|
|
||||||
|
def _seed_orders(self) -> int:
|
||||||
|
from contacts.models import Contact
|
||||||
|
from shop.models import Order, OrderItem, Product
|
||||||
|
|
||||||
|
dragon = Product.objects.filter(sku="DEMO-FLEXI-DRAGON").first()
|
||||||
|
d20 = Product.objects.filter(sku="DEMO-D20").first()
|
||||||
|
rocket = Product.objects.filter(sku="DEMO-ROCKET").first()
|
||||||
|
planter = Product.objects.filter(sku="DEMO-PLANTER").first()
|
||||||
|
if not all([dragon, d20, rocket, planter]):
|
||||||
|
return 0
|
||||||
|
dragon_cyan = dragon.colors.filter(name="Forge Cyan").first()
|
||||||
|
specs = (
|
||||||
|
(
|
||||||
|
"DEMO-ORD-001",
|
||||||
|
"jordan",
|
||||||
|
Order.Status.PAID,
|
||||||
|
((dragon, dragon_cyan, 1),),
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"DEMO-ORD-002",
|
||||||
|
"casey",
|
||||||
|
Order.Status.FULFILLED,
|
||||||
|
((planter, None, 2), (d20, None, 1)),
|
||||||
|
11,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"DEMO-ORD-003",
|
||||||
|
"sam",
|
||||||
|
Order.Status.PAID,
|
||||||
|
((d20, None, 2),),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"DEMO-ORD-004",
|
||||||
|
"riley",
|
||||||
|
Order.Status.OPEN,
|
||||||
|
((rocket, None, 1),),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"DEMO-ORD-005",
|
||||||
|
"alex",
|
||||||
|
Order.Status.CANCELLED,
|
||||||
|
((dragon, None, 1),),
|
||||||
|
16,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
created = 0
|
||||||
|
for number, slug, status, lines, days_ago in specs:
|
||||||
|
contact = Contact.objects.filter(
|
||||||
|
email=f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
|
||||||
|
).first()
|
||||||
|
if contact is None:
|
||||||
|
continue
|
||||||
|
amount = sum(
|
||||||
|
(product.price * qty for product, _color, qty in lines),
|
||||||
|
Decimal("0.00"),
|
||||||
|
)
|
||||||
|
paid = status in {Order.Status.PAID, Order.Status.FULFILLED}
|
||||||
|
when = self.now - timedelta(days=days_ago, hours=5)
|
||||||
|
order, was_created = Order.objects.update_or_create(
|
||||||
|
number=number,
|
||||||
|
defaults={
|
||||||
|
"email": contact.email,
|
||||||
|
"customer_name": contact.full_name,
|
||||||
|
"status": status,
|
||||||
|
"amount": amount,
|
||||||
|
"currency": "usd",
|
||||||
|
"paid_at": when if paid else None,
|
||||||
|
"shipping_address": contact.postal_address,
|
||||||
|
"notes": f"{DEMO_NOTE} Demo order.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if was_created:
|
||||||
|
created += 1
|
||||||
|
if not order.items.exists():
|
||||||
|
for product, color, qty in lines:
|
||||||
|
OrderItem.objects.create(
|
||||||
|
order=order,
|
||||||
|
product=product,
|
||||||
|
color=color,
|
||||||
|
name=product.name,
|
||||||
|
sku=product.sku,
|
||||||
|
color_name=color.name if color else "",
|
||||||
|
quantity=qty,
|
||||||
|
unit_price=product.price,
|
||||||
|
print_minutes=product.print_minutes,
|
||||||
|
)
|
||||||
|
_stamp(Order, order.pk, when)
|
||||||
|
self._log(f"order {order.number} {status}")
|
||||||
|
return created
|
||||||
|
|
||||||
|
def _seed_shipments(self) -> int:
|
||||||
|
from shipping.models import Shipment
|
||||||
|
from shop.models import Order
|
||||||
|
|
||||||
|
order = Order.objects.filter(number="DEMO-ORD-002").first()
|
||||||
|
if order is None:
|
||||||
|
return 0
|
||||||
|
shipment, created = Shipment.objects.get_or_create(
|
||||||
|
order=order,
|
||||||
|
defaults={
|
||||||
|
"status": Shipment.Status.LABELED,
|
||||||
|
"carrier": "USPS",
|
||||||
|
"service": "Priority",
|
||||||
|
"tracking_number": "940011189922DEMO02",
|
||||||
|
"rate_amount": Decimal("8.45"),
|
||||||
|
"currency": "usd",
|
||||||
|
"weight_oz": 18,
|
||||||
|
"notes": f"{DEMO_NOTE} Stub label. No EasyPost call.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self._log(f"shipment {shipment.tracking_number}")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _seed_invoices(self) -> int:
|
||||||
|
from contacts.models import Contact
|
||||||
|
from payments.models import Invoice
|
||||||
|
|
||||||
|
specs = (
|
||||||
|
(
|
||||||
|
"DEMO-INV-001",
|
||||||
|
"casey",
|
||||||
|
Invoice.Status.PAID,
|
||||||
|
Decimal("210.00"),
|
||||||
|
"STEM night planter kits (30)",
|
||||||
|
14,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"DEMO-INV-002",
|
||||||
|
"priya",
|
||||||
|
Invoice.Status.OPEN,
|
||||||
|
Decimal("480.00"),
|
||||||
|
"Wedding favor keychains (40) — deposit due",
|
||||||
|
4,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
created = 0
|
||||||
|
for number, slug, status, amount, description, days_ago in specs:
|
||||||
|
contact = Contact.objects.filter(
|
||||||
|
email=f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
|
||||||
|
).first()
|
||||||
|
if contact is None:
|
||||||
|
continue
|
||||||
|
when = self.now - timedelta(days=days_ago)
|
||||||
|
paid = status == Invoice.Status.PAID
|
||||||
|
invoice, was_created = Invoice.objects.update_or_create(
|
||||||
|
number=number,
|
||||||
|
defaults={
|
||||||
|
"contact": contact,
|
||||||
|
"description": description,
|
||||||
|
"amount": amount,
|
||||||
|
"currency": "usd",
|
||||||
|
"status": status,
|
||||||
|
"paid_at": when if paid else None,
|
||||||
|
"created_by": self.owner,
|
||||||
|
"notes": f"{DEMO_NOTE} Demo invoice. No Stripe session.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if was_created:
|
||||||
|
created += 1
|
||||||
|
_stamp(Invoice, invoice.pk, when)
|
||||||
|
self._log(f"invoice {invoice.number} {status}")
|
||||||
|
return created
|
||||||
|
|
||||||
|
def _seed_campaigns(self) -> int:
|
||||||
|
from email_sms.models import Campaign, Message
|
||||||
|
from email_sms.services import create_campaign_draft
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
draft_name = "DEMO: New filament colors"
|
||||||
|
if not Campaign.objects.filter(name=draft_name).exists():
|
||||||
|
create_campaign_draft(
|
||||||
|
name=draft_name,
|
||||||
|
audience=Campaign.Audience.EMAIL_OPT_IN,
|
||||||
|
subject="Cyan and lime just landed",
|
||||||
|
body=(
|
||||||
|
"<p>We restocked Forge Cyan and Lime flexi dragons. "
|
||||||
|
"Shop the drop before they print out.</p>"
|
||||||
|
),
|
||||||
|
created_by=self.owner,
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
done_name = "DEMO: STEM night recap"
|
||||||
|
if not Campaign.objects.filter(name=done_name).exists():
|
||||||
|
campaign = create_campaign_draft(
|
||||||
|
name=done_name,
|
||||||
|
audience=Campaign.Audience.EMAIL_OPT_IN,
|
||||||
|
subject="Thanks for coming to STEM night",
|
||||||
|
body="<p>Your geometric planters are packed. Tracking goes out tomorrow.</p>",
|
||||||
|
created_by=self.owner,
|
||||||
|
)
|
||||||
|
campaign.status = Campaign.Status.COMPLETED
|
||||||
|
campaign.notify_sent_at = self.now - timedelta(days=9)
|
||||||
|
campaign.save(update_fields=["status", "notify_sent_at", "updated_at"])
|
||||||
|
messages = list(campaign.messages.all())
|
||||||
|
for index, message in enumerate(messages):
|
||||||
|
message.status = (
|
||||||
|
Message.Status.OPENED
|
||||||
|
if index % 3 == 0
|
||||||
|
else Message.Status.DELIVERED
|
||||||
|
)
|
||||||
|
message.sent_at = self.now - timedelta(days=10)
|
||||||
|
message.save(update_fields=["status", "sent_at", "updated_at"])
|
||||||
|
created += 1
|
||||||
|
self._log(f"campaign {done_name} completed")
|
||||||
|
sms_name = "DEMO: Print-ready SMS"
|
||||||
|
if not Campaign.objects.filter(name=sms_name).exists():
|
||||||
|
create_campaign_draft(
|
||||||
|
name=sms_name,
|
||||||
|
audience=Campaign.Audience.SMS_OPT_IN,
|
||||||
|
body="Your Print Forge order is on the bed. We will text when it ships.",
|
||||||
|
created_by=self.owner,
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
return created
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import os
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.test import Client, TestCase, override_settings
|
from django.test import Client, TestCase, override_settings
|
||||||
@@ -23,6 +25,9 @@ class UnderConstructionTests(TestCase):
|
|||||||
def test_home_ok_when_open(self):
|
def test_home_ok_when_open(self):
|
||||||
response = Client().get("/")
|
response = Client().get("/")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "pf-print-splash")
|
||||||
|
self.assertContains(response, "pf-printer")
|
||||||
|
self.assertNotContains(response, "cssload-container")
|
||||||
|
|
||||||
|
|
||||||
class PublicSmokeTests(TestCase):
|
class PublicSmokeTests(TestCase):
|
||||||
@@ -37,3 +42,52 @@ class DispatchDueTests(TestCase):
|
|||||||
out = StringIO()
|
out = StringIO()
|
||||||
call_command("dispatch_due", stdout=out)
|
call_command("dispatch_due", stdout=out)
|
||||||
self.assertEqual(out.getvalue(), "")
|
self.assertEqual(out.getvalue(), "")
|
||||||
|
|
||||||
|
|
||||||
|
class SeedDemoTests(TestCase):
|
||||||
|
def test_refuses_prod(self):
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"DJANGO_ENV": "prod"}):
|
||||||
|
with self.assertRaises(CommandError) as ctx:
|
||||||
|
call_command("seed_demo", stdout=StringIO())
|
||||||
|
self.assertIn("prod", str(ctx.exception).lower())
|
||||||
|
|
||||||
|
def test_seeds_shop_and_portal_rows(self):
|
||||||
|
out = StringIO()
|
||||||
|
call_command("seed_demo", stdout=out)
|
||||||
|
self.assertIn("Demo seed ready", out.getvalue())
|
||||||
|
from contacts.models import Contact
|
||||||
|
from leads.models import Lead
|
||||||
|
from shop.models import Order, Product
|
||||||
|
|
||||||
|
self.assertTrue(Product.objects.filter(sku="DEMO-FLEXI-DRAGON").exists())
|
||||||
|
self.assertTrue(Product.objects.filter(sku="DEMO-PROTO", is_published=False).exists())
|
||||||
|
self.assertGreaterEqual(Contact.objects.filter(email__startswith="demo+").count(), 8)
|
||||||
|
self.assertTrue(Lead.objects.filter(message__startswith="[demo-seed]").exists())
|
||||||
|
self.assertTrue(Order.objects.filter(number="DEMO-ORD-001", status="paid").exists())
|
||||||
|
listing = Client().get(reverse("shop:list"))
|
||||||
|
self.assertContains(listing, "Articulated Flexi Dragon")
|
||||||
|
|
||||||
|
def test_second_run_is_idempotent(self):
|
||||||
|
call_command("seed_demo", stdout=StringIO())
|
||||||
|
from shop.models import Product
|
||||||
|
|
||||||
|
count = Product.objects.filter(sku__startswith="DEMO-").count()
|
||||||
|
call_command("seed_demo", stdout=StringIO())
|
||||||
|
self.assertEqual(Product.objects.filter(sku__startswith="DEMO-").count(), count)
|
||||||
|
|
||||||
|
def test_reset_rebuilds_tagged_rows(self):
|
||||||
|
call_command("seed_demo", stdout=StringIO())
|
||||||
|
from shop.models import Product
|
||||||
|
|
||||||
|
Product.objects.filter(sku="DEMO-FLEXI-DRAGON").delete()
|
||||||
|
call_command("seed_demo", reset=True, stdout=StringIO())
|
||||||
|
self.assertTrue(Product.objects.filter(sku="DEMO-FLEXI-DRAGON").exists())
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"DJANGO_ENV": "beta"})
|
||||||
|
def test_allows_beta(self):
|
||||||
|
call_command("seed_demo", stdout=StringIO())
|
||||||
|
from shop.models import Product
|
||||||
|
|
||||||
|
self.assertTrue(Product.objects.filter(sku__startswith="DEMO-").exists())
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ class CampaignSendTests(TestCase):
|
|||||||
self.assertEqual(summary.to, [self.user.email])
|
self.assertEqual(summary.to, [self.user.email])
|
||||||
self.assertTrue(mail.outbox[0].alternatives)
|
self.assertTrue(mail.outbox[0].alternatives)
|
||||||
self.assertEqual(mail.outbox[0].alternatives[0][1], "text/html")
|
self.assertEqual(mail.outbox[0].alternatives[0][1], "text/html")
|
||||||
self.assertIn("#00626c", mail.outbox[0].alternatives[0][0])
|
self.assertIn("#00aeef", mail.outbox[0].alternatives[0][0])
|
||||||
self.assertTrue(summary.alternatives)
|
self.assertTrue(summary.alternatives)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
:root {
|
:root {
|
||||||
--monica-primary: #00626c;
|
--monica-primary: #00aeef;
|
||||||
--monica-primary-light: #008898;
|
--monica-primary-light: #33c4f4;
|
||||||
--monica-ink: #212121;
|
--monica-ink: #212121;
|
||||||
--monica-muted: #6b7280;
|
--monica-muted: #6b7280;
|
||||||
--monica-surface: #f4f7f7;
|
--monica-surface: #f4f8fb;
|
||||||
--monica-border: #d9e3e4;
|
--monica-border: #d5e3ea;
|
||||||
--monica-ok: #1a7f4b;
|
--monica-ok: #1a7f4b;
|
||||||
--monica-warn: #b45309;
|
--monica-warn: #b45309;
|
||||||
--monica-danger: #b91c1c;
|
--monica-danger: #b91c1c;
|
||||||
--portal-sidebar: 240px;
|
--portal-sidebar: 240px;
|
||||||
--exit-dark: #080808;
|
--exit-dark: #0a0e14;
|
||||||
--exit-teal: #00626c;
|
--exit-teal: #00aeef;
|
||||||
--exit-teal-bright: #008898;
|
--exit-teal-bright: #8dc63f;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Portal shell */
|
/* Portal shell */
|
||||||
@@ -24,7 +24,7 @@ body.portal {
|
|||||||
.portal-shell { display: flex; min-height: 100vh; }
|
.portal-shell { display: flex; min-height: 100vh; }
|
||||||
.portal-sidebar {
|
.portal-sidebar {
|
||||||
width: var(--portal-sidebar);
|
width: var(--portal-sidebar);
|
||||||
background: #080808;
|
background: #0a0e14;
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -47,7 +47,7 @@ body.portal {
|
|||||||
width: auto;
|
width: auto;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.portal-brand span { color: #7dd3da; font-size: 12px; letter-spacing: 0.04em; }
|
.portal-brand span { color: #8dc63f; font-size: 12px; letter-spacing: 0.04em; }
|
||||||
.portal-nav { padding: 12px 0; flex: 1; }
|
.portal-nav { padding: 12px 0; flex: 1; }
|
||||||
.portal-nav a {
|
.portal-nav a {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -58,7 +58,7 @@ body.portal {
|
|||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
}
|
}
|
||||||
.portal-nav a:hover { color: #fff; background: rgba(255,255,255,0.04); }
|
.portal-nav a:hover { color: #fff; background: rgba(255,255,255,0.04); }
|
||||||
.portal-nav a.active { color: #fff; background: rgba(0,98,108,0.28); border-left-color: #00a0ab; }
|
.portal-nav a.active { color: #fff; background: rgba(0,174,239,0.22); border-left-color: #8dc63f; }
|
||||||
.portal-nav .nav-section {
|
.portal-nav .nav-section {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -135,7 +135,7 @@ body.portal {
|
|||||||
font-family: "Work Sans", sans-serif;
|
font-family: "Work Sans", sans-serif;
|
||||||
}
|
}
|
||||||
.btn-primary { background: var(--monica-primary); color: #fff; }
|
.btn-primary { background: var(--monica-primary); color: #fff; }
|
||||||
.btn-primary:hover { background: #004e56; color: #fff; text-decoration: none; }
|
.btn-primary:hover { background: #0072bc; color: #fff; text-decoration: none; }
|
||||||
.btn-ghost { background: transparent; border: 1px solid var(--monica-border); color: var(--monica-ink); }
|
.btn-ghost { background: transparent; border: 1px solid var(--monica-border); color: var(--monica-ink); }
|
||||||
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
||||||
|
|
||||||
@@ -163,11 +163,11 @@ body.portal {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
.badge-new { background: #d0eef0; color: #00626c; }
|
.badge-new { background: #d6f3fc; color: #00aeef; }
|
||||||
.badge-contacted { background: #fef3c7; color: #92400e; }
|
.badge-contacted { background: #fef3c7; color: #92400e; }
|
||||||
.badge-won { background: #d1fae5; color: #065f46; }
|
.badge-won { background: #d1fae5; color: #065f46; }
|
||||||
.badge-lost { background: #fee2e2; color: #991b1b; }
|
.badge-lost { background: #fee2e2; color: #991b1b; }
|
||||||
.badge-sent { background: #d0eef0; color: #00626c; }
|
.badge-sent { background: #d6f3fc; color: #00aeef; }
|
||||||
.badge-scheduled { background: #ede9fe; color: #5b21b6; }
|
.badge-scheduled { background: #ede9fe; color: #5b21b6; }
|
||||||
.badge-delivered { background: #d1fae5; color: #065f46; }
|
.badge-delivered { background: #d1fae5; color: #065f46; }
|
||||||
.badge-opened { background: #dbeafe; color: #1e40af; }
|
.badge-opened { background: #dbeafe; color: #1e40af; }
|
||||||
@@ -178,11 +178,11 @@ body.portal {
|
|||||||
.badge-draft { background: #f3f4f6; color: #4b5563; }
|
.badge-draft { background: #f3f4f6; color: #4b5563; }
|
||||||
.badge-completed { background: #d1fae5; color: #065f46; }
|
.badge-completed { background: #d1fae5; color: #065f46; }
|
||||||
.badge-cancelled { background: #f3f4f6; color: #4b5563; }
|
.badge-cancelled { background: #f3f4f6; color: #4b5563; }
|
||||||
.badge-sending { background: #d0eef0; color: #00626c; }
|
.badge-sending { background: #d6f3fc; color: #00aeef; }
|
||||||
.badge-queued { background: #ede9fe; color: #5b21b6; }
|
.badge-queued { background: #ede9fe; color: #5b21b6; }
|
||||||
.badge-bounced { background: #fee2e2; color: #991b1b; }
|
.badge-bounced { background: #fee2e2; color: #991b1b; }
|
||||||
.badge-published { background: #d1fae5; color: #065f46; }
|
.badge-published { background: #d1fae5; color: #065f46; }
|
||||||
.badge-publishing { background: #d0eef0; color: #00626c; }
|
.badge-publishing { background: #d6f3fc; color: #00aeef; }
|
||||||
|
|
||||||
.form-grid { display: grid; gap: 16px; }
|
.form-grid { display: grid; gap: 16px; }
|
||||||
.form-grid.cols-2 { grid-template-columns: 1fr 1fr; }
|
.form-grid.cols-2 { grid-template-columns: 1fr 1fr; }
|
||||||
@@ -213,7 +213,7 @@ body.portal {
|
|||||||
|
|
||||||
.chart-placeholder {
|
.chart-placeholder {
|
||||||
height: 220px;
|
height: 220px;
|
||||||
background: linear-gradient(180deg, #e8f4f5 0%, #fff 100%);
|
background: linear-gradient(180deg, #e6f7fd 0%, #fff 100%);
|
||||||
border: 1px dashed #a8d8dc;
|
border: 1px dashed #a8d8dc;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -306,7 +306,7 @@ body.portal {
|
|||||||
}
|
}
|
||||||
.hbar-track {
|
.hbar-track {
|
||||||
height: 16px;
|
height: 16px;
|
||||||
background: #e8f4f5;
|
background: #e6f7fd;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.hbar-fill {
|
.hbar-fill {
|
||||||
@@ -344,7 +344,7 @@ body.portal {
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.calendar-grid .day.has-post { background: #e8f4f5; }
|
.calendar-grid .day.has-post { background: #e6f7fd; }
|
||||||
.calendar-grid .day .dot {
|
.calendar-grid .day .dot {
|
||||||
width: 6px; height: 6px; border-radius: 50%;
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
background: var(--monica-primary);
|
background: var(--monica-primary);
|
||||||
@@ -356,7 +356,7 @@ body.portal {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(160deg, #080808, #00626c 60%, #e8f4f5 60%);
|
background: linear-gradient(160deg, #0a0e14, #00aeef 55%, #8dc63f 60%, #e6f7fd 60%);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
.login-card {
|
.login-card {
|
||||||
@@ -376,7 +376,7 @@ body.portal {
|
|||||||
.portal-nav { display: flex; flex-wrap: wrap; padding: 8px; }
|
.portal-nav { display: flex; flex-wrap: wrap; padding: 8px; }
|
||||||
.portal-nav .nav-section { display: none; }
|
.portal-nav .nav-section { display: none; }
|
||||||
.portal-nav a { border-left: none; border-bottom: 2px solid transparent; padding: 8px 12px; }
|
.portal-nav a { border-left: none; border-bottom: 2px solid transparent; padding: 8px 12px; }
|
||||||
.portal-nav a.active { border-bottom-color: #00a0ab; }
|
.portal-nav a.active { border-bottom-color: #8dc63f; }
|
||||||
.split { grid-template-columns: 1fr; }
|
.split { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@ body.portal {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: #00626c;
|
color: #00aeef;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
@@ -546,7 +546,7 @@ body.portal {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
.pcm-designer .table tr.is-active td {
|
.pcm-designer .table tr.is-active td {
|
||||||
background: #e8f4f5;
|
background: #e6f7fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Size picker */
|
/* Size picker */
|
||||||
@@ -557,7 +557,7 @@ body.portal {
|
|||||||
}
|
}
|
||||||
.size-option:hover { border-color: var(--monica-primary); }
|
.size-option:hover { border-color: var(--monica-primary); }
|
||||||
.size-option.active {
|
.size-option.active {
|
||||||
border-color: var(--monica-primary); background: #e8f4f5;
|
border-color: var(--monica-primary); background: #e6f7fd;
|
||||||
box-shadow: inset 3px 0 0 var(--monica-primary);
|
box-shadow: inset 3px 0 0 var(--monica-primary);
|
||||||
}
|
}
|
||||||
.size-option .sz-name { font-weight: 700; font-size: 13px; font-family: Poppins, sans-serif; }
|
.size-option .sz-name { font-weight: 700; font-size: 13px; font-family: Poppins, sans-serif; }
|
||||||
@@ -603,7 +603,7 @@ body.portal {
|
|||||||
.ai-chat-h h2 { margin: 0; font-size: 15px; font-family: Poppins, sans-serif; font-weight: 600; }
|
.ai-chat-h h2 { margin: 0; font-size: 15px; font-family: Poppins, sans-serif; font-weight: 600; }
|
||||||
.ai-pill {
|
.ai-pill {
|
||||||
font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
|
font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
|
||||||
background: #e8f4f5; color: var(--monica-primary); padding: 3px 8px;
|
background: #e6f7fd; color: var(--monica-primary); padding: 3px 8px;
|
||||||
}
|
}
|
||||||
.ai-chat-messages {
|
.ai-chat-messages {
|
||||||
flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px;
|
flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px;
|
||||||
@@ -671,14 +671,14 @@ body.portal {
|
|||||||
}
|
}
|
||||||
.postcard-headline {
|
.postcard-headline {
|
||||||
font-family: Poppins, sans-serif; font-weight: 700; font-size: 28px; letter-spacing: 0.04em;
|
font-family: Poppins, sans-serif; font-weight: 700; font-size: 28px; letter-spacing: 0.04em;
|
||||||
border-left: 4px solid #00626c; padding-left: 10px; line-height: 1.1;
|
border-left: 4px solid #00aeef; padding-left: 10px; line-height: 1.1;
|
||||||
}
|
}
|
||||||
.postcard-sub { margin-top: 8px; font-size: 14px; opacity: 0.95; }
|
.postcard-sub { margin-top: 8px; font-size: 14px; opacity: 0.95; }
|
||||||
.postcard-back { background: #fafafa; color: #212121; }
|
.postcard-back { background: #fafafa; color: #212121; }
|
||||||
.postcard-back-grid { display: grid; grid-template-columns: 1.2fr 1fr; height: 100%; }
|
.postcard-back-grid { display: grid; grid-template-columns: 1.2fr 1fr; height: 100%; }
|
||||||
.postcard-back-msg { padding: 16px; display: flex; flex-direction: column; gap: 10px; border-right: 1px dashed #cbd5e1; }
|
.postcard-back-msg { padding: 16px; display: flex; flex-direction: column; gap: 10px; border-right: 1px dashed #cbd5e1; }
|
||||||
.postcard-back-body { font-size: 12px; white-space: pre-wrap; flex: 1; line-height: 1.45; }
|
.postcard-back-body { font-size: 12px; white-space: pre-wrap; flex: 1; line-height: 1.45; }
|
||||||
.postcard-agent { font-size: 11px; font-weight: 600; color: #00626c; }
|
.postcard-agent { font-size: 11px; font-weight: 600; color: #00aeef; }
|
||||||
.postcard-qr {
|
.postcard-qr {
|
||||||
width: 48px; height: 48px; background: #111; color: #fff; font-size: 10px;
|
width: 48px; height: 48px; background: #111; color: #fff; font-size: 10px;
|
||||||
display: flex; align-items: center; justify-content: center; letter-spacing: 0.05em;
|
display: flex; align-items: center; justify-content: center; letter-spacing: 0.05em;
|
||||||
@@ -709,7 +709,7 @@ body.portal {
|
|||||||
display: flex; align-items: center; gap: 10px; padding: 12px 14px; position: relative;
|
display: flex; align-items: center; gap: 10px; padding: 12px 14px; position: relative;
|
||||||
}
|
}
|
||||||
.soc-avatar {
|
.soc-avatar {
|
||||||
width: 40px; height: 40px; background: #00626c; color: #fff; display: flex;
|
width: 40px; height: 40px; background: #00aeef; color: #fff; display: flex;
|
||||||
align-items: center; justify-content: center; font-weight: 700; font-size: 13px; flex-shrink: 0;
|
align-items: center; justify-content: center; font-weight: 700; font-size: 13px; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.soc-avatar.round { border-radius: 50%; background: linear-gradient(45deg, #f58529, #dd2a7b); }
|
.soc-avatar.round { border-radius: 50%; background: linear-gradient(45deg, #f58529, #dd2a7b); }
|
||||||
@@ -737,7 +737,7 @@ body.portal {
|
|||||||
}
|
}
|
||||||
.portal-flash { list-style: none; margin: 0 0 16px; padding: 0; }
|
.portal-flash { list-style: none; margin: 0 0 16px; padding: 0; }
|
||||||
.portal-flash li {
|
.portal-flash li {
|
||||||
padding: 10px 14px; background: #e8f4f5; border-left: 3px solid var(--monica-primary);
|
padding: 10px 14px; background: #e6f7fd; border-left: 3px solid var(--monica-primary);
|
||||||
margin-bottom: 8px; font-size: 14px;
|
margin-bottom: 8px; font-size: 14px;
|
||||||
}
|
}
|
||||||
.portal-flash li.error { background: #fee2e2; border-left-color: #b91c1c; }
|
.portal-flash li.error { background: #fee2e2; border-left-color: #b91c1c; }
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,12 +1,207 @@
|
|||||||
/* Print Forge public brand overrides on the Ecoprint theme */
|
/* Print Forge public brand overrides on the Ecoprint theme */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--pf-primary: #ff6252;
|
--pf-primary: #00aeef;
|
||||||
--pf-primary-dark: #e24f41;
|
--pf-primary-dark: #0072bc;
|
||||||
|
--pf-accent: #8dc63f;
|
||||||
|
--pf-navy: #0a0e14;
|
||||||
--pf-ink: #151515;
|
--pf-ink: #151515;
|
||||||
--pf-muted: #6b7280;
|
--pf-muted: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Banner-matched splash: FDM printer building a model layer by layer. */
|
||||||
|
.preloader {
|
||||||
|
background: var(--pf-navy) !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pf-print-splash {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 28px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pf-print-glow {
|
||||||
|
position: absolute;
|
||||||
|
width: 280px;
|
||||||
|
height: 280px;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
.pf-print-glow-tl {
|
||||||
|
top: -80px;
|
||||||
|
left: -80px;
|
||||||
|
background: radial-gradient(circle, rgba(0, 174, 239, 0.55) 0%, transparent 68%);
|
||||||
|
}
|
||||||
|
.pf-print-glow-br {
|
||||||
|
right: -80px;
|
||||||
|
bottom: -80px;
|
||||||
|
background: radial-gradient(circle, rgba(141, 198, 63, 0.45) 0%, transparent 68%);
|
||||||
|
}
|
||||||
|
.pf-printer {
|
||||||
|
position: relative;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
.pf-printer-post,
|
||||||
|
.pf-printer-top,
|
||||||
|
.pf-printer-bed {
|
||||||
|
position: absolute;
|
||||||
|
background: linear-gradient(180deg, #1b2733, #0f141c);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 174, 239, 0.45), 0 0 18px rgba(0, 174, 239, 0.18);
|
||||||
|
}
|
||||||
|
.pf-printer-post {
|
||||||
|
top: 8px;
|
||||||
|
width: 10px;
|
||||||
|
height: 148px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.pf-printer-post-l { left: 18px; }
|
||||||
|
.pf-printer-post-r { right: 18px; }
|
||||||
|
.pf-printer-top {
|
||||||
|
top: 8px;
|
||||||
|
left: 18px;
|
||||||
|
right: 18px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 174, 239, 0.7), 0 0 16px rgba(0, 174, 239, 0.28);
|
||||||
|
}
|
||||||
|
.pf-printer-gantry {
|
||||||
|
position: absolute;
|
||||||
|
top: 22px;
|
||||||
|
left: 28px;
|
||||||
|
right: 28px;
|
||||||
|
height: 8px;
|
||||||
|
background: linear-gradient(90deg, #00aeef, #8dc63f);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 174, 239, 0.55);
|
||||||
|
animation: pf-gantry-y 4.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||||
|
}
|
||||||
|
.pf-printer-head {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 28px;
|
||||||
|
margin-left: -14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
animation: pf-head-x 1.2s linear infinite alternate;
|
||||||
|
}
|
||||||
|
.pf-nozzle {
|
||||||
|
display: block;
|
||||||
|
filter: drop-shadow(0 0 6px rgba(0, 174, 239, 0.8));
|
||||||
|
}
|
||||||
|
.pf-bead {
|
||||||
|
width: 5px;
|
||||||
|
height: 10px;
|
||||||
|
margin-top: -2px;
|
||||||
|
border-radius: 0 0 3px 3px;
|
||||||
|
background: linear-gradient(180deg, #00aeef, #8dc63f);
|
||||||
|
opacity: 0.9;
|
||||||
|
animation: pf-bead 0.35s ease-in infinite;
|
||||||
|
}
|
||||||
|
.pf-printer-bed {
|
||||||
|
left: 28px;
|
||||||
|
right: 28px;
|
||||||
|
bottom: 16px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(180deg, #243040, #10151c);
|
||||||
|
box-shadow: 0 0 22px rgba(141, 198, 63, 0.2), inset 0 0 0 1px rgba(141, 198, 63, 0.35);
|
||||||
|
}
|
||||||
|
.pf-model {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 28px;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin-left: -36px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
transform: perspective(180px) rotateX(58deg);
|
||||||
|
transform-origin: bottom center;
|
||||||
|
}
|
||||||
|
.pf-model i {
|
||||||
|
display: block;
|
||||||
|
width: 56px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: linear-gradient(90deg, #00aeef, #8dc63f);
|
||||||
|
box-shadow: 0 0 8px rgba(0, 174, 239, 0.45);
|
||||||
|
transform: scaleX(0);
|
||||||
|
opacity: 0;
|
||||||
|
animation: pf-layer 4.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.pf-model i:nth-child(1) { animation-delay: 0.15s; width: 58px; }
|
||||||
|
.pf-model i:nth-child(2) { animation-delay: 0.55s; width: 58px; }
|
||||||
|
.pf-model i:nth-child(3) { animation-delay: 0.95s; width: 58px; }
|
||||||
|
.pf-model i:nth-child(4) { animation-delay: 1.35s; width: 50px; }
|
||||||
|
.pf-model i:nth-child(5) { animation-delay: 1.75s; width: 50px; }
|
||||||
|
.pf-model i:nth-child(6) { animation-delay: 2.15s; width: 38px; }
|
||||||
|
.pf-model i:nth-child(7) { animation-delay: 2.55s; width: 28px; }
|
||||||
|
.pf-model i:nth-child(8) { animation-delay: 2.95s; width: 18px; }
|
||||||
|
.pf-print-caption {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Roboto, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.42em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #8dc63f;
|
||||||
|
text-shadow: 0 0 12px rgba(141, 198, 63, 0.45);
|
||||||
|
animation: pf-caption 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pf-gantry-y {
|
||||||
|
0%, 8% { top: 124px; }
|
||||||
|
82%, 88% { top: 28px; }
|
||||||
|
100% { top: 124px; }
|
||||||
|
}
|
||||||
|
@keyframes pf-head-x {
|
||||||
|
0% { left: 12%; }
|
||||||
|
100% { left: 88%; }
|
||||||
|
}
|
||||||
|
@keyframes pf-bead {
|
||||||
|
0% { transform: translateY(-4px); opacity: 0; }
|
||||||
|
40% { opacity: 1; }
|
||||||
|
100% { transform: translateY(10px); opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes pf-layer {
|
||||||
|
0%, 6% { transform: scaleX(0); opacity: 0; }
|
||||||
|
12%, 78% { transform: scaleX(1); opacity: 1; }
|
||||||
|
90%, 100% { transform: scaleX(0); opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes pf-caption {
|
||||||
|
0%, 100% { opacity: 0.55; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pf-printer-gantry,
|
||||||
|
.pf-printer-head,
|
||||||
|
.pf-bead,
|
||||||
|
.pf-model i,
|
||||||
|
.pf-print-caption {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.pf-printer-gantry { top: 70px; }
|
||||||
|
.pf-printer-head { left: 50%; }
|
||||||
|
.pf-bead { display: none; }
|
||||||
|
.pf-model i { transform: scaleX(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:hover,
|
||||||
|
.button-primary:active,
|
||||||
|
.button-primary.active {
|
||||||
|
background-color: var(--pf-primary-dark);
|
||||||
|
border-color: var(--pf-primary-dark);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* Theme CSS starts `.page` at opacity:0 until a `.preloader` runs.
|
/* Theme CSS starts `.page` at opacity:0 until a `.preloader` runs.
|
||||||
Keep this so a missed preloader never blanks the site. */
|
Keep this so a missed preloader never blanks the site. */
|
||||||
.page {
|
.page {
|
||||||
@@ -118,7 +313,7 @@
|
|||||||
color: var(--pf-primary-dark);
|
color: var(--pf-primary-dark);
|
||||||
}
|
}
|
||||||
.cookie-consent-link {
|
.cookie-consent-link {
|
||||||
color: #ffd3ce;
|
color: #9be4fb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
return QRCode.toCanvas(canvas, url, {
|
return QRCode.toCanvas(canvas, url, {
|
||||||
width: 192,
|
width: 192,
|
||||||
margin: 1,
|
margin: 1,
|
||||||
color: { dark: "#00626c", light: "#ffffff" },
|
color: { dark: "#0072bc", light: "#ffffff" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { STLLoader } from "three/addons/loaders/STLLoader.js";
|
|||||||
|
|
||||||
const instances = new WeakMap();
|
const instances = new WeakMap();
|
||||||
|
|
||||||
function hexColor(value, fallback = "#ff6252") {
|
function hexColor(value, fallback = "#00aeef") {
|
||||||
const raw = (value || "").trim();
|
const raw = (value || "").trim();
|
||||||
if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;
|
if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|||||||
@@ -44,16 +44,35 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="preloader">
|
<div class="preloader">
|
||||||
<div class="cssload-container">
|
<div class="pf-print-splash" role="status" aria-label="Loading">
|
||||||
<div class="load-circle-item"><div class="load-circle"></div></div>
|
<span class="pf-print-glow pf-print-glow-tl"></span>
|
||||||
<div class="load-circle-item"><div class="load-circle"></div></div>
|
<span class="pf-print-glow pf-print-glow-br"></span>
|
||||||
<div class="load-circle-item"><div class="load-circle"></div></div>
|
<div class="pf-printer" aria-hidden="true">
|
||||||
<div class="load-circle-item"><div class="load-circle"></div></div>
|
<span class="pf-printer-post pf-printer-post-l"></span>
|
||||||
<div class="load-circle-item"><div class="load-circle"></div></div>
|
<span class="pf-printer-post pf-printer-post-r"></span>
|
||||||
<div class="load-circle-item"><div class="load-circle"></div></div>
|
<span class="pf-printer-top"></span>
|
||||||
<div class="load-circle-item"><div class="load-circle"></div></div>
|
<span class="pf-printer-gantry">
|
||||||
<div class="load-circle-item"><div class="load-circle"></div></div>
|
<span class="pf-printer-head">
|
||||||
<div class="load-circle-item"><div class="load-circle"></div></div>
|
<svg class="pf-nozzle" viewBox="0 0 32 40" width="28" height="36">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="pf-nozzle-grad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#00aeef"/>
|
||||||
|
<stop offset="100%" stop-color="#8dc63f"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="8" y="0" width="16" height="8" rx="1.5" fill="url(#pf-nozzle-grad)"/>
|
||||||
|
<path d="M6 8h20l-4 10H10z" fill="url(#pf-nozzle-grad)"/>
|
||||||
|
<path d="M12 18h8l-4 10z" fill="#e8fbff"/>
|
||||||
|
</svg>
|
||||||
|
<span class="pf-bead"></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="pf-printer-bed"></span>
|
||||||
|
<span class="pf-model">
|
||||||
|
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="pf-print-caption">Printing</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ def plain_text_to_email_html(text: str) -> str:
|
|||||||
lines = [html.escape(line) for line in para.split("\n")]
|
lines = [html.escape(line) for line in para.split("\n")]
|
||||||
joined = "<br>\n".join(lines)
|
joined = "<br>\n".join(lines)
|
||||||
joined = _URL_RE.sub(
|
joined = _URL_RE.sub(
|
||||||
r'<a href="\1" style="color:#00626c;text-decoration:underline;">\1</a>',
|
r'<a href="\1" style="color:#00aeef;text-decoration:underline;">\1</a>',
|
||||||
joined,
|
joined,
|
||||||
)
|
)
|
||||||
blocks.append(
|
blocks.append(
|
||||||
|
|||||||
@@ -36,11 +36,11 @@
|
|||||||
color: #212121;
|
color: #212121;
|
||||||
font-family: "Work Sans", Poppins, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
font-family: "Work Sans", Poppins, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
a { color: #00626c; }
|
a { color: #00aeef; }
|
||||||
.email-btn {
|
.email-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
background-color: #00626c;
|
background-color: #00aeef;
|
||||||
color: #ffffff !important;
|
color: #ffffff !important;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
<img src="{{ logo_url }}" alt="{{ brand_name|default:'Print Forge' }}" width="160" style="display:block;width:160px;max-width:70%;height:auto;">
|
<img src="{{ logo_url }}" alt="{{ brand_name|default:'Print Forge' }}" width="160" style="display:block;width:160px;max-width:70%;height:auto;">
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="margin:0;font-size:20px;font-weight:700;letter-spacing:0.5px;color:#00626c;">
|
<p style="margin:0;font-size:20px;font-weight:700;letter-spacing:0.5px;color:#00aeef;">
|
||||||
{{ brand_name }}
|
{{ brand_name }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="height:3px;line-height:3px;font-size:0;background-color:#00626c;"> </td>
|
<td style="height:3px;line-height:3px;font-size:0;background-color:#00aeef;"> </td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:28px 28px 8px;color:#212121;font-size:15px;line-height:1.6;font-family:'Work Sans',Poppins,-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
<td style="padding:28px 28px 8px;color:#212121;font-size:15px;line-height:1.6;font-family:'Work Sans',Poppins,-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
{% if brand_tagline_html %}
|
{% if brand_tagline_html %}
|
||||||
{{ brand_tagline_html|safe }}
|
{{ brand_tagline_html|safe }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ site_url|default:'https://mkdrealtor.com' }}" style="color:#00626c;text-decoration:none;">{{ host_label|default:"mkdrealtor.com" }}</a>
|
<a href="{{ site_url|default:'https://mkdrealtor.com' }}" style="color:#00aeef;text-decoration:none;">{{ host_label|default:"mkdrealtor.com" }}</a>
|
||||||
{% if brand_tagline %}
|
{% if brand_tagline %}
|
||||||
· {{ brand_tagline }}
|
· {{ brand_tagline }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<p style="margin:0 0 16px;color:#212121;">Your {{ channel_display }} campaign has finished sending.</p>
|
<p style="margin:0 0 16px;color:#212121;">Your {{ channel_display }} campaign has finished sending.</p>
|
||||||
|
|
||||||
<p style="margin:0 0 8px;font-size:18px;font-weight:600;color:#00626c;">{{ campaign_name }}</p>
|
<p style="margin:0 0 8px;font-size:18px;font-weight:600;color:#00aeef;">{{ campaign_name }}</p>
|
||||||
|
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:20px 0;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:20px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
{% if report_url %}
|
{% if report_url %}
|
||||||
<p style="margin:24px 0 0;">
|
<p style="margin:24px 0 0;">
|
||||||
<a class="email-btn" href="{{ report_url }}" style="display:inline-block;padding:12px 24px;background-color:#00626c;color:#ffffff !important;text-decoration:none;border-radius:4px;font-weight:600;font-size:14px;">Open campaign report</a>
|
<a class="email-btn" href="{{ report_url }}" style="display:inline-block;padding:12px 24px;background-color:#00aeef;color:#ffffff !important;text-decoration:none;border-radius:4px;font-weight:600;font-size:14px;">Open campaign report</a>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Email</p>
|
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Email</p>
|
||||||
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;">
|
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;">
|
||||||
<a href="mailto:{{ email }}" style="color:#00626c;text-decoration:none;">{{ email }}</a>
|
<a href="mailto:{{ email }}" style="color:#00aeef;text-decoration:none;">{{ email }}</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Phone</p>
|
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Phone</p>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
{% if portal_url %}
|
{% if portal_url %}
|
||||||
<p style="margin:24px 0 0;">
|
<p style="margin:24px 0 0;">
|
||||||
<a class="email-btn" href="{{ portal_url }}" style="display:inline-block;padding:12px 24px;background-color:#00626c;color:#ffffff !important;text-decoration:none;border-radius:4px;font-weight:600;font-size:14px;">View in portal</a>
|
<a class="email-btn" href="{{ portal_url }}" style="display:inline-block;padding:12px 24px;background-color:#00aeef;color:#ffffff !important;text-decoration:none;border-radius:4px;font-weight:600;font-size:14px;">View in portal</a>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
{% block footer_note %}
|
{% block footer_note %}
|
||||||
{% if prefs_url or one_click_url %}
|
{% if prefs_url or one_click_url %}
|
||||||
{% if prefs_url %}
|
{% if prefs_url %}
|
||||||
<a href="{{ prefs_url }}" style="color:#00626c;text-decoration:underline;">Manage preferences</a>
|
<a href="{{ prefs_url }}" style="color:#00aeef;text-decoration:underline;">Manage preferences</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if prefs_url and one_click_url %} · {% endif %}
|
{% if prefs_url and one_click_url %} · {% endif %}
|
||||||
{% if one_click_url %}
|
{% if one_click_url %}
|
||||||
<a href="{{ one_click_url }}" style="color:#00626c;text-decoration:underline;">Unsubscribe from email</a>
|
<a href="{{ one_click_url }}" style="color:#00aeef;text-decoration:underline;">Unsubscribe from email</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+11
-5
@@ -418,7 +418,7 @@ def normalize_hex(value: str) -> str:
|
|||||||
return raw.lower()
|
return raw.lower()
|
||||||
|
|
||||||
|
|
||||||
def store_product_image(*, upload, user) -> StoredFile:
|
def store_product_image(*, upload, user, smart_crop: bool = True) -> StoredFile:
|
||||||
content_type = (getattr(upload, "content_type", None) or "").lower()
|
content_type = (getattr(upload, "content_type", None) or "").lower()
|
||||||
if content_type not in _ALLOWED_IMAGE_TYPES:
|
if content_type not in _ALLOWED_IMAGE_TYPES:
|
||||||
raise ShopError("Use a JPEG, PNG, GIF, or WebP image.")
|
raise ShopError("Use a JPEG, PNG, GIF, or WebP image.")
|
||||||
@@ -427,15 +427,18 @@ def store_product_image(*, upload, user) -> StoredFile:
|
|||||||
raise ShopError("Image must be 15 MB or smaller.")
|
raise ShopError("Image must be 15 MB or smaller.")
|
||||||
original = (getattr(upload, "name", None) or "product")[:255]
|
original = (getattr(upload, "name", None) or "product")[:255]
|
||||||
try:
|
try:
|
||||||
from shop.imaging import prepare_product_photo, product_image_filename
|
from shop.imaging import open_image, prepare_product_photo, product_image_filename
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
logger.exception("product photo processing dependencies missing")
|
logger.exception("product photo processing dependencies missing")
|
||||||
raise ShopError(
|
raise ShopError(
|
||||||
"Image processing is not installed. Rebuild the app container."
|
"Image processing is not installed. Rebuild the app container."
|
||||||
) from exc
|
) from exc
|
||||||
try:
|
try:
|
||||||
data, content_type = prepare_product_photo(data)
|
if smart_crop:
|
||||||
original = product_image_filename(original)
|
data, content_type = prepare_product_photo(data)
|
||||||
|
original = product_image_filename(original)
|
||||||
|
else:
|
||||||
|
open_image(data)
|
||||||
except ShopError:
|
except ShopError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -569,12 +572,15 @@ def append_product_images(
|
|||||||
uploads,
|
uploads,
|
||||||
user,
|
user,
|
||||||
color: ProductColor | None = None,
|
color: ProductColor | None = None,
|
||||||
|
smart_crop: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
existing = product.images.filter(color=color).count()
|
existing = product.images.filter(color=color).count()
|
||||||
for offset, upload in enumerate(uploads):
|
for offset, upload in enumerate(uploads):
|
||||||
if not upload:
|
if not upload:
|
||||||
continue
|
continue
|
||||||
stored = store_product_image(upload=upload, user=user)
|
stored = store_product_image(
|
||||||
|
upload=upload, user=user, smart_crop=smart_crop
|
||||||
|
)
|
||||||
ProductImage.objects.create(
|
ProductImage.objects.create(
|
||||||
product=product,
|
product=product,
|
||||||
color=color,
|
color=color,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<div class="stl-viewer"
|
<div class="stl-viewer"
|
||||||
data-stl-viewer
|
data-stl-viewer
|
||||||
data-src="{{ product.stl_url }}"
|
data-src="{{ product.stl_url }}"
|
||||||
data-color="{{ selected_color.hex|default:'#ff6252' }}"
|
data-color="{{ selected_color.hex|default:'#00aeef' }}"
|
||||||
data-label="3D model of {{ product.name }}. Drag to rotate, scroll to zoom."></div>
|
data-label="3D model of {{ product.name }}. Drag to rotate, scroll to zoom."></div>
|
||||||
<p class="product-media-hint">Drag to spin · scroll to zoom</p>
|
<p class="product-media-hint">Drag to spin · scroll to zoom</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,12 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Photos</label>
|
<label>Photos</label>
|
||||||
<input name="images" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple>
|
<input name="images" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple>
|
||||||
<p class="hint">JPEG, PNG, GIF, or WebP. Multiple photos. Used on the listing card and as the default gallery. Color-specific photos below replace these when that color is selected.</p>
|
<input type="hidden" name="smart_crop" value="off">
|
||||||
|
<label class="inline">
|
||||||
|
<input type="checkbox" name="smart_crop" value="on" checked>
|
||||||
|
Smart crop and background filter
|
||||||
|
</label>
|
||||||
|
<p class="hint">JPEG, PNG, GIF, or WebP. Multiple photos. Used on the listing card and as the default gallery. Color-specific photos below replace these when that color is selected. Leave the filter on to knock out the background and center the subject on a square canvas.</p>
|
||||||
{% if product.catalog_images %}
|
{% if product.catalog_images %}
|
||||||
<div class="photo-thumbs">
|
<div class="photo-thumbs">
|
||||||
{% for image in product.catalog_images %}
|
{% for image in product.catalog_images %}
|
||||||
@@ -114,7 +119,7 @@
|
|||||||
<div id="product-stl-preview"
|
<div id="product-stl-preview"
|
||||||
class="stl-viewer stl-viewer-compact"
|
class="stl-viewer stl-viewer-compact"
|
||||||
data-stl-viewer
|
data-stl-viewer
|
||||||
data-color="#ff6252"
|
data-color="#00aeef"
|
||||||
data-label="STL preview. Drag to rotate, scroll to zoom."
|
data-label="STL preview. Drag to rotate, scroll to zoom."
|
||||||
{% if product.stl_id %}data-src="{{ product.stl_url }}" data-existing="{{ product.stl_url }}"{% else %}hidden{% endif %}></div>
|
{% if product.stl_id %}data-src="{{ product.stl_url }}" data-existing="{{ product.stl_url }}"{% else %}hidden{% endif %}></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -414,6 +414,8 @@ class ShopPortalTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Shop card preview")
|
self.assertContains(response, "Shop card preview")
|
||||||
self.assertContains(response, 'name="images"')
|
self.assertContains(response, 'name="images"')
|
||||||
|
self.assertContains(response, 'name="smart_crop"')
|
||||||
|
self.assertContains(response, "Smart crop and background filter")
|
||||||
self.assertContains(response, 'name="stl"')
|
self.assertContains(response, 'name="stl"')
|
||||||
self.assertContains(response, "Add color")
|
self.assertContains(response, "Add color")
|
||||||
|
|
||||||
@@ -512,6 +514,32 @@ class ShopPortalTests(TestCase):
|
|||||||
b"".join(fetch.streaming_content), bytes(product.image.data)
|
b"".join(fetch.streaming_content), bytes(product.image.data)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_create_product_skips_smart_crop_when_unchecked(self):
|
||||||
|
raw = _tiny_png()
|
||||||
|
upload = SimpleUploadedFile("keep.png", raw, content_type="image/png")
|
||||||
|
with patch("shop.imaging.cutout_subject") as cut:
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("shop_portal:product_new"),
|
||||||
|
{
|
||||||
|
"name": "Raw photo toy",
|
||||||
|
"sku": "RAW-1",
|
||||||
|
"price": "10.00",
|
||||||
|
"stock_qty": "1",
|
||||||
|
"fulfillment": "stocked",
|
||||||
|
"track_inventory": "on",
|
||||||
|
"smart_crop": "off",
|
||||||
|
"images": upload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
cut.assert_not_called()
|
||||||
|
product = Product.objects.get(sku="RAW-1")
|
||||||
|
stored = product.images.get().file
|
||||||
|
self.assertEqual(bytes(stored.data), raw)
|
||||||
|
self.assertEqual(stored.filename, "keep.png")
|
||||||
|
framed = Image.open(BytesIO(bytes(stored.data)))
|
||||||
|
self.assertEqual(framed.size, (8, 8))
|
||||||
|
|
||||||
def test_create_product_with_color_photos_and_shared_stl(self):
|
def test_create_product_with_color_photos_and_shared_stl(self):
|
||||||
catalog = SimpleUploadedFile("card.png", _tiny_png(), content_type="image/png")
|
catalog = SimpleUploadedFile("card.png", _tiny_png(), content_type="image/png")
|
||||||
red_photo = SimpleUploadedFile("red.png", _tiny_png(), content_type="image/png")
|
red_photo = SimpleUploadedFile("red.png", _tiny_png(), content_type="image/png")
|
||||||
@@ -582,6 +610,20 @@ class ShopPortalTests(TestCase):
|
|||||||
self.assertTrue(bytes(stored.data))
|
self.assertTrue(bytes(stored.data))
|
||||||
self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_IMAGE)
|
self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_IMAGE)
|
||||||
|
|
||||||
|
def test_store_product_image_can_skip_smart_crop(self):
|
||||||
|
raw = _tiny_png()
|
||||||
|
with TemporaryUploadedFile("dot.png", "image/png", 0, "utf-8") as tmp:
|
||||||
|
tmp.write(raw)
|
||||||
|
tmp.seek(0)
|
||||||
|
with patch("shop.imaging.cutout_subject") as cut:
|
||||||
|
stored = store_product_image(
|
||||||
|
upload=tmp, user=self.user, smart_crop=False
|
||||||
|
)
|
||||||
|
cut.assert_not_called()
|
||||||
|
self.assertEqual(bytes(stored.data), raw)
|
||||||
|
self.assertEqual(stored.content_type, "image/png")
|
||||||
|
self.assertEqual(stored.filename, "dot.png")
|
||||||
|
|
||||||
def test_temporary_stl_upload_copied_into_database_then_unlinked(self):
|
def test_temporary_stl_upload_copied_into_database_then_unlinked(self):
|
||||||
with TemporaryUploadedFile(
|
with TemporaryUploadedFile(
|
||||||
"toy.stl", "application/octet-stream", 0, "utf-8"
|
"toy.stl", "application/octet-stream", 0, "utf-8"
|
||||||
|
|||||||
@@ -298,10 +298,12 @@ def portal_product_edit(request, pk=None):
|
|||||||
remove_product_images(
|
remove_product_images(
|
||||||
product, request.POST.getlist("remove_image")
|
product, request.POST.getlist("remove_image")
|
||||||
)
|
)
|
||||||
|
smart_crop = request.POST.get("smart_crop", "on") == "on"
|
||||||
append_product_images(
|
append_product_images(
|
||||||
product,
|
product,
|
||||||
uploads=request.FILES.getlist("images"),
|
uploads=request.FILES.getlist("images"),
|
||||||
user=request.user,
|
user=request.user,
|
||||||
|
smart_crop=smart_crop,
|
||||||
)
|
)
|
||||||
for key, color in colors.items():
|
for key, color in colors.items():
|
||||||
uploads = request.FILES.getlist(f"color_images_{key}")
|
uploads = request.FILES.getlist(f"color_images_{key}")
|
||||||
@@ -312,6 +314,7 @@ def portal_product_edit(request, pk=None):
|
|||||||
uploads=uploads,
|
uploads=uploads,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
color=color,
|
color=color,
|
||||||
|
smart_crop=smart_crop,
|
||||||
)
|
)
|
||||||
refresh_listing_image(product)
|
refresh_listing_image(product)
|
||||||
_delete_replaced_file(
|
_delete_replaced_file(
|
||||||
|
|||||||
Reference in New Issue
Block a user