diff --git a/Jenkinsfile b/Jenkinsfile index 91bd3d7..9b92104 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -6,6 +6,13 @@ pipeline { disableConcurrentBuilds() skipDefaultCheckout(true) } + parameters { + booleanParam( + name: 'RUN_DEMO_PURGE', + defaultValue: false, + description: 'Run a one-time demo catalogue purge before the normal idea marketplace seed and launch prep.' + ) + } environment { PYENVPIPELINE_VIRTUALENV = '1' GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new' @@ -81,8 +88,14 @@ PY cp "${JOB_BASE_NAME}/ocyan.json" "${JOB_BASE_NAME}/${JOB_BASE_NAME}.json" pip install ruff vdt.versionplugin.wheel pip install --upgrade "setuptools==69.5.1" wheel + python3 scripts/validate_payment_provider_config.py manage.py migrate --no-input --skip-checks - manage.py purge_demo_data --keep-only-idea-products --skip-checks + if [ "${RUN_DEMO_PURGE}" = "true" ]; then + manage.py purge_demo_data + fi + manage.py seed_idea_marketplace + manage.py prepare_idea_marketplace_launch --apply-homepage-copy --purge-demo-pages + manage.py validate_idea_marketplace_launch manage.py collectstatic --no-input --verbosity=0 pip install "httpx<0.28" ''' diff --git a/mandelstudio/content_hygiene.py b/mandelstudio/content_hygiene.py new file mode 100644 index 0000000..91287bf --- /dev/null +++ b/mandelstudio/content_hygiene.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Iterable + + +DEMO_MARKERS: tuple[str, ...] = ( + "demo", + "dummy", + "sample", + "lorem", + "placeholder", + "sandbox", + "staging", + "prototype", + "template-only", +) + +# Known legacy/demo pages that should never surface on production. +BLOCKED_DEMO_PAGE_SLUGS: tuple[str, ...] = ( + "starter-website-2", + "business-website-2", +) + + +def contains_demo_marker(values: Iterable[str | None]) -> bool: + for raw_value in values: + if not raw_value: + continue + lowered = raw_value.lower() + if any(marker in lowered for marker in DEMO_MARKERS): + return True + return False + + +def is_blocked_demo_slug(value: str | None) -> bool: + if not value: + return False + return value.lower() in BLOCKED_DEMO_PAGE_SLUGS diff --git a/mandelstudio/idea_marketplace.py b/mandelstudio/idea_marketplace.py new file mode 100644 index 0000000..07972ac --- /dev/null +++ b/mandelstudio/idea_marketplace.py @@ -0,0 +1,465 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from django.conf import settings +from django.db.models import Q +from django.utils.text import slugify + +from oscar.core.loading import get_model + +from mandelstudio.content_hygiene import DEMO_MARKERS + + +IDEA_PRODUCT_CLASS_NAME = "Idea Product" +DIGITAL_IDEAS_CATEGORY_NAME = "Digital Ideas" +SHORT_DESCRIPTION_ATTRIBUTE_CODE = "short_description" +FULL_DESCRIPTION_ATTRIBUTE_CODE = "full_description" +IDEA_PARTNER_NAME = "Mandel Blog Studio" + + +@dataclass(frozen=True) +class IdeaSeedItem: + title: str + short_description: str + full_description: str + price_eur: Decimal + + +IDEA_PRODUCTS: tuple[IdeaSeedItem, ...] = ( + IdeaSeedItem( + title="B2B Webshop Starter Blueprint", + short_description=( + "Launch a B2B webshop with a quote-first buying flow and enterprise-ready trust structure. " + "Get a clear execution path from positioning to first qualified orders." + ), + full_description=( + "Introduction\n" + "A practical B2B ecommerce blueprint for teams that need to sell complex offers with confidence.\n\n" + "Problem it solves\n" + "- Generic webshop setups underperform in B2B because they ignore quote-first journeys and multi-stakeholder buying.\n" + "- Sales and marketing handoff is often unclear, which slows deal velocity.\n\n" + "Step-by-step concept\n" + "1. Define ICP and buying committee signals.\n" + "2. Map quote-first vs direct checkout decision rules.\n" + "3. Build page architecture for trust, proof, and qualification.\n" + "4. Implement lead-to-order routing between website and sales ops.\n" + "5. Run a 90-day optimization loop with conversion checkpoints.\n\n" + "Tech stack\n" + "- Django + Oscar commerce core\n" + "- Wagtail CMS for structured sales content\n" + "- Analytics and event tracking for funnel visibility\n\n" + "Business value\n" + "- Faster sales-qualified lead capture\n" + "- Lower friction for enterprise buyers\n" + "- Higher conversion from product page to qualified pipeline\n\n" + "Who it is for\n" + "Founders, growth teams, and B2B operators launching or rebuilding a serious ecommerce motion." + ), + price_eur=Decimal("99.00"), + ), + IdeaSeedItem( + title="AI Product Description System", + short_description=( + "Scale product copy with AI while preserving brand tone, SEO intent, and quality control. " + "Turn catalog chaos into a repeatable content engine your team can trust." + ), + full_description=( + "Introduction\n" + "A production content system for generating and governing high-quality product descriptions at scale.\n\n" + "Problem it solves\n" + "- Manual copywriting does not scale across growing catalogs.\n" + "- Uncontrolled AI output introduces inconsistency and factual risk.\n\n" + "Step-by-step concept\n" + "1. Define attribute schema and content rules per category.\n" + "2. Build prompt templates linked to taxonomy fields.\n" + "3. Add QA gates for accuracy, tone, and compliance.\n" + "4. Localize with multilingual adaptation rules.\n" + "5. Monitor quality with an editorial review workflow.\n\n" + "Tech stack\n" + "- Django/Wagtail content governance\n" + "- AI model orchestration with prompt templates\n" + "- Validation layer for quality and policy checks\n\n" + "Business value\n" + "- Faster time-to-publish for new products\n" + "- Consistent conversion-focused copy\n" + "- Reduced editorial costs with better control\n\n" + "Who it is for\n" + "Ecommerce teams, marketplaces, and catalog-heavy brands that need reliable AI-assisted copy operations." + ), + price_eur=Decimal("49.00"), + ), + IdeaSeedItem( + title="High-Converting Landing Page Framework", + short_description=( + "Build landing pages that convert with a proven structure for message clarity, proof, and CTA flow. " + "Stop guessing and launch with a repeatable conversion framework." + ), + full_description=( + "Introduction\n" + "A practical landing-page framework focused on conversion, not visual noise.\n\n" + "Problem it solves\n" + "- Teams often launch pages without a clear conversion narrative.\n" + "- Weak proof and CTA sequencing create drop-off before action.\n\n" + "Step-by-step concept\n" + "1. Align offer with one core audience intent.\n" + "2. Build headline and subheadline hierarchy.\n" + "3. Add objection-handling proof blocks and trust signals.\n" + "4. Design CTA progression for low and high intent visitors.\n" + "5. Define test plan for copy, layout, and offer variants.\n\n" + "Tech stack\n" + "- Wagtail page composition\n" + "- Bootstrap 5 component patterns\n" + "- Event tracking for funnel diagnostics\n\n" + "Business value\n" + "- Higher lead quality from the same traffic\n" + "- Faster launch cycles with reusable page logic\n" + "- Better conversion through structured experimentation\n\n" + "Who it is for\n" + "Service businesses, SaaS teams, and agencies that rely on landing pages for growth." + ), + price_eur=Decimal("29.00"), + ), + IdeaSeedItem( + title="Subscription-Based Service Website Model", + short_description=( + "Design a subscription service website that improves activation, retention, and recurring revenue. " + "Package offers clearly and reduce churn with lifecycle-aware UX." + ), + full_description=( + "Introduction\n" + "A complete website model for subscription-first service businesses.\n\n" + "Problem it solves\n" + "- Subscription sites often sell features, not ongoing outcomes.\n" + "- Poor onboarding and renewal communication increases churn risk.\n\n" + "Step-by-step concept\n" + "1. Structure offer tiers by business outcome and support level.\n" + "2. Build onboarding pages for fast activation.\n" + "3. Add lifecycle messaging for renewal and expansion.\n" + "4. Map churn-risk touchpoints and intervention moments.\n" + "5. Track retention metrics and optimize plan positioning.\n\n" + "Tech stack\n" + "- Django + Oscar for billing-ready commerce foundations\n" + "- Wagtail for lifecycle content and onboarding assets\n" + "- Event instrumentation for retention analytics\n\n" + "Business value\n" + "- Improved activation-to-retention conversion\n" + "- More predictable recurring revenue\n" + "- Clearer upgrade path across plan tiers\n\n" + "Who it is for\n" + "Founders and operators running service subscriptions with monthly or annual plans." + ), + price_eur=Decimal("69.00"), + ), + IdeaSeedItem( + title="Marketplace Platform Architecture (Django)", + short_description=( + "Get a scalable marketplace architecture for Django from MVP to multi-vendor growth. " + "Includes domain boundaries, payments, moderation, and operations blueprint." + ), + full_description=( + "Introduction\n" + "A technical blueprint for launching and scaling a marketplace platform on Django.\n\n" + "Problem it solves\n" + "- Marketplace projects fail when core domains and workflows are not separated early.\n" + "- Teams underestimate moderation, payout, and operational complexity.\n\n" + "Step-by-step concept\n" + "1. Define bounded domains for buyers, sellers, listings, and transactions.\n" + "2. Design catalog and search architecture for growth.\n" + "3. Implement payment orchestration and settlement flow.\n" + "4. Add moderation, permissions, and abuse controls.\n" + "5. Plan observability and phased scaling from MVP to expansion.\n\n" + "Tech stack\n" + "- Django service layer and domain modules\n" + "- Oscar commerce primitives where applicable\n" + "- Queue/events for async marketplace operations\n" + "- Monitoring and operational alerting baseline\n\n" + "Business value\n" + "- Lower re-architecture risk at scale\n" + "- Faster delivery of revenue-critical flows\n" + "- Better reliability for multi-sided operations\n\n" + "Who it is for\n" + "Technical founders, CTOs, and product teams building marketplace businesses with Django." + ), + price_eur=Decimal("149.00"), + ), +) + + +def _get_attribute_text(product, code: str) -> str: + value = ( + product.attribute_values.select_related("attribute") + .filter(attribute__code=code) + .first() + ) + if value is None: + return "" + for field_name in ( + "value_text", + "value_richtext", + "value_option", + "value_file", + "value_image", + ): + field_value = getattr(value, field_name, None) + if field_value: + return str(field_value) + return "" + + +def _set_attribute_text(product, attribute, text: str) -> None: + ProductAttributeValue = get_model("catalogue", "ProductAttributeValue") + value_field = ( + "value_richtext" if getattr(attribute, "type", "text") == "richtext" else "value_text" + ) + value, _created = ProductAttributeValue.objects.get_or_create( + product=product, + attribute=attribute, + ) + if getattr(value, value_field, "") != text: + setattr(value, value_field, text) + value.save(update_fields=[value_field]) + + +def is_idea_product(product) -> bool: + product_class = getattr(product, "product_class", None) + return bool(product_class and product_class.name == IDEA_PRODUCT_CLASS_NAME) + + +def get_idea_short_description(product) -> str: + return _get_attribute_text(product, SHORT_DESCRIPTION_ATTRIBUTE_CODE) or ( + getattr(product, "description", "") or "" + ) + + +def get_idea_full_description(product) -> str: + return _get_attribute_text(product, FULL_DESCRIPTION_ATTRIBUTE_CODE) + + +def get_unlockable_description(product, user) -> tuple[str, bool]: + unlocked = user_has_unlocked_idea(user, product) + if unlocked: + return get_idea_full_description(product) or get_idea_short_description(product), True + return get_idea_short_description(product), False + + +def user_has_unlocked_idea(user, product) -> bool: + if not getattr(user, "is_authenticated", False): + return False + if not is_idea_product(product): + return True + + Line = get_model("order", "Line") + PaymentEventQuantity = get_model("order", "PaymentEventQuantity") + paid_statuses = { + getattr(settings, "OSCAR_PAID_ORDER_STATUS", None), + getattr(settings, "OSCAR_COMPLETE_ORDER_STATUS", None), + "paid", + "complete", + "payment-complete", + "delayed-payment", + } + paid_statuses = { + status.strip().lower() for status in paid_statuses if isinstance(status, str) and status.strip() + } + + status_match = Line.objects.filter( + order__user=user, + product_id=product.id, + ).filter( + Q(order__status__in=paid_statuses) + | Q(order__status__icontains="paid") + | Q(order__status__icontains="complete") + ) + if status_match.exists(): + return True + + # Fallback to payment event evidence so unlocking still works when status names differ per provider. + return PaymentEventQuantity.objects.filter( + line__order__user=user, + line__product_id=product.id, + quantity__gt=0, + ).exists() + + +def _ensure_digital_ideas_category(): + Category = get_model("catalogue", "Category") + existing = Category.objects.filter(name=DIGITAL_IDEAS_CATEGORY_NAME).first() + if existing: + return existing + + root = ( + Category.objects.filter(depth=1).order_by("path").first() + if hasattr(Category, "depth") + else None + ) + if root and hasattr(root, "add_child"): + return root.add_child(name=DIGITAL_IDEAS_CATEGORY_NAME) + if hasattr(Category, "add_root"): + return Category.add_root(name=DIGITAL_IDEAS_CATEGORY_NAME) + + category = Category(name=DIGITAL_IDEAS_CATEGORY_NAME) + if hasattr(category, "slug"): + category.slug = slugify(DIGITAL_IDEAS_CATEGORY_NAME) + category.save() + return category + + +def _ensure_product_class(): + ProductClass = get_model("catalogue", "ProductClass") + product_class, _created = ProductClass.objects.get_or_create( + name=IDEA_PRODUCT_CLASS_NAME, + defaults={ + "requires_shipping": False, + "track_stock": False, + }, + ) + if product_class.requires_shipping: + product_class.requires_shipping = False + product_class.save(update_fields=["requires_shipping"]) + return product_class + + +def _ensure_product_attributes(product_class): + ProductAttribute = get_model("catalogue", "ProductAttribute") + text_type = getattr(ProductAttribute, "TEXT", "text") + richtext_type = getattr(ProductAttribute, "RICHTEXT", "richtext") + + short_attr, _ = ProductAttribute.objects.get_or_create( + product_class=product_class, + code=SHORT_DESCRIPTION_ATTRIBUTE_CODE, + defaults={ + "name": "Short description", + "type": text_type, + "required": False, + }, + ) + full_attr, _ = ProductAttribute.objects.get_or_create( + product_class=product_class, + code=FULL_DESCRIPTION_ATTRIBUTE_CODE, + defaults={ + "name": "Full description", + "type": richtext_type, + "required": False, + }, + ) + return short_attr, full_attr + + +def _ensure_partner(): + Partner = get_model("partner", "Partner") + partner, _ = Partner.objects.get_or_create(name=IDEA_PARTNER_NAME) + return partner + + +def _upsert_stockrecord(product, partner, price_eur: Decimal): + StockRecord = get_model("partner", "StockRecord") + defaults = { + "partner_sku": f"idea-{product.id}", + "price_currency": "EUR", + "price_excl_tax": price_eur, + "num_in_stock": 99999, + } + stockrecord, _created = StockRecord.objects.get_or_create( + product=product, + partner=partner, + defaults=defaults, + ) + dirty_fields: list[str] = [] + for field_name, field_value in defaults.items(): + if getattr(stockrecord, field_name, None) != field_value: + setattr(stockrecord, field_name, field_value) + dirty_fields.append(field_name) + if dirty_fields: + stockrecord.save(update_fields=dirty_fields) + + +def seed_idea_marketplace_products( + *, purge_demo_products: bool = True, retire_non_idea_products: bool = True +) -> dict[str, int]: + Product = get_model("catalogue", "Product") + + product_class = _ensure_product_class() + category = _ensure_digital_ideas_category() + short_attr, full_attr = _ensure_product_attributes(product_class) + partner = _ensure_partner() + + created = 0 + updated = 0 + for item in IDEA_PRODUCTS: + product = Product.objects.filter(title=item.title).first() + if product is None: + product = Product( + title=item.title, + slug=slugify(item.title), + product_class=product_class, + description=item.short_description, + ) + if hasattr(Product, "STANDALONE") and hasattr(product, "structure"): + product.structure = Product.STANDALONE + if hasattr(product, "is_public") and not getattr(product, "is_public", False): + product.is_public = True + product.save() + created += 1 + else: + dirty_fields: list[str] = [] + if product.product_class_id != product_class.id: + product.product_class = product_class + dirty_fields.append("product_class") + if product.description != item.short_description: + product.description = item.short_description + dirty_fields.append("description") + if hasattr(product, "slug") and product.slug != slugify(item.title): + product.slug = slugify(item.title) + dirty_fields.append("slug") + if hasattr(product, "is_public") and not getattr(product, "is_public", False): + product.is_public = True + dirty_fields.append("is_public") + if dirty_fields: + product.save(update_fields=dirty_fields) + updated += 1 + + product.categories.add(category) + _set_attribute_text(product, short_attr, item.short_description) + _set_attribute_text(product, full_attr, item.full_description) + _upsert_stockrecord(product, partner, item.price_eur) + + deleted_demo = 0 + if purge_demo_products: + keep_titles = {item.title for item in IDEA_PRODUCTS} + demo_filter = Q() + for marker in DEMO_MARKERS: + demo_filter |= Q(title__icontains=marker) | Q(slug__icontains=marker) + demo_queryset = Product.objects.filter(demo_filter).exclude(title__in=keep_titles) + # Also purge any non-canonical products lingering in the Idea Product class + # or explicitly grouped under the Digital Ideas category. + non_canonical_ideas_queryset = ( + Product.objects.filter( + Q(product_class=product_class) + | Q(categories__name__iexact=DIGITAL_IDEAS_CATEGORY_NAME) + ) + .exclude(title__in=keep_titles) + .distinct() + ) + delete_ids = set(demo_queryset.values_list("id", flat=True)) | set( + non_canonical_ideas_queryset.values_list("id", flat=True) + ) + deleted_demo = len(delete_ids) + if deleted_demo: + Product.objects.filter(id__in=delete_ids).delete() + + retired_non_idea = 0 + if retire_non_idea_products: + keep_titles = {item.title for item in IDEA_PRODUCTS} + non_idea_public_qs = Product.objects.exclude(title__in=keep_titles).filter( + is_public=True + ) + retired_non_idea = non_idea_public_qs.update(is_public=False) + + return { + "created": created, + "updated": updated, + "deleted_demo": deleted_demo, + "retired_non_idea": retired_non_idea, + } diff --git a/mandelstudio/management/commands/_agency_content.py b/mandelstudio/management/commands/_agency_content.py index a507d9f..6dd9db6 100644 --- a/mandelstudio/management/commands/_agency_content.py +++ b/mandelstudio/management/commands/_agency_content.py @@ -1374,21 +1374,21 @@ STANDARD_COPY = { "

Voor technische ondersteuning, uitbreidingen of een vervolgfase na livegang.

", ), ], - "form_title": "Vertel kort wat u nodig heeft", - "form_sub": "

We reageren inhoudelijk en zonder verkooppraat op uw vraag.

", + "form_title": "Start uw volgende project met helderheid", + "form_sub": "

We helpen u ontwerpen, bouwen en opschalen - snel, gestructureerd en zonder giswerk.

", "form_fields": [ ("text", "Naam", "Uw naam"), ("email", "E-mail", "naam@bedrijf.nl"), ("company", "Bedrijf", "Bedrijfsnaam"), ("message", "Vraag of project", "Waar zoekt u hulp bij?"), ], - "benefits_title": "Wat u kunt verwachten", + "benefits_title": "Waarom dit gesprek werkt", "benefits": [ "Reactie binnen 24 uur", - "Intakegesprek van 15 minuten", - "Volledig vrijblijvend", + "Vrijblijvend strategiegesprek", + "Praktische vervolgstappen", ], - "privacy": "

We gebruiken uw gegevens alleen voor contact over deze aanvraag.

", + "privacy": "

We gebruiken uw gegevens alleen voor contact over deze aanvraag. Geen verplichtingen.

", "cta": "Klaar om een eerste stap te zetten?", }, "process": { @@ -4028,7 +4028,7 @@ def _standard_body( ) for field_type, label, placeholder in cfg["form_fields"] ], - "submit_button_text": cta["primary"], + "submit_button_text": "Plan mijn strategiegesprek", "form_action_url": urls["contact"], "benefits_title": cfg["benefits_title"], "benefits": [item(text) for text in cfg["benefits"]], diff --git a/mandelstudio/management/commands/apply_agency_website_refresh.py b/mandelstudio/management/commands/apply_agency_website_refresh.py index 9f86fc7..c348929 100644 --- a/mandelstudio/management/commands/apply_agency_website_refresh.py +++ b/mandelstudio/management/commands/apply_agency_website_refresh.py @@ -686,8 +686,8 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any] "cta": "Wilt u uw volgende project professioneel neerzetten?", }, "contact": { - "headline": "Laten we uw vraag concreet maken", - "sub": "

Vertel kort wat u nodig heeft. U krijgt een praktische terugkoppeling met haalbare vervolgstappen.

", + "headline": "Laten we uw volgende stap helder maken", + "sub": "

Deel kort waar u hulp bij nodig heeft. U krijgt een helder antwoord en een concreet voorstel voor de volgende stap.

", "features_title": "Waarvoor u contact kunt opnemen", "features_sub": "

Kies de route die past bij uw vraag of traject.

", "features": [ @@ -713,8 +713,8 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any] "layout_width": "container", "background_style": "light", "layout": "split", - "section_title": "Vertel kort wat u nodig heeft", - "section_subtitle": "

We reageren inhoudelijk en zonder verkooppraat op uw vraag.

", + "section_title": "Start uw volgende project met helderheid", + "section_subtitle": "

We helpen u ontwerpen, bouwen en opschalen - snel, gestructureerd en zonder giswerk.

", "form_fields": [ item( { @@ -749,16 +749,16 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any] } ), ], - "submit_button_text": primary, + "submit_button_text": "Plan mijn strategiegesprek", "form_action_url": urls["contact"], - "benefits_title": "Wat u kunt verwachten", + "benefits_title": "Waarom dit gesprek werkt", "benefits": [ item("Reactie binnen 24 uur"), - item("Intakegesprek van 15 minuten"), - item("Volledig vrijblijvend"), + item("Vrijblijvend strategiegesprek"), + item("Praktische vervolgstappen"), ], "side_image": 1, - "privacy_text": "

We gebruiken uw gegevens alleen voor contact over deze aanvraag.

", + "privacy_text": "

We gebruiken uw gegevens alleen voor contact over deze aanvraag. Geen verplichtingen.

", }, ), "cta": "Klaar om een eerste stap te zetten?", diff --git a/mandelstudio/management/commands/prepare_idea_marketplace_launch.py b/mandelstudio/management/commands/prepare_idea_marketplace_launch.py new file mode 100644 index 0000000..9d68a2a --- /dev/null +++ b/mandelstudio/management/commands/prepare_idea_marketplace_launch.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.db.models import Q +from wagtail.blocks import StreamValue +from wagtail.models import Page + +from mandelstudio.content_hygiene import BLOCKED_DEMO_PAGE_SLUGS, DEMO_MARKERS +from mandelstudio.idea_marketplace import seed_idea_marketplace_products + + +HOME_COPY = { + "nl": { + "badge": "IDEA MARKETPLACE", + "headline": "Premium ideeën die je direct kunt uitvoeren", + "sub_headline": "

Ontdek bewezen plannen, koop de strategie en ontgrendel het volledige implementatieplan.

", + "features_title": "Idea Marketplace", + "features_subtitle": "

Preview eerst. Koop alleen wat past. Ontgrendel daarna de complete blueprint.

", + "footer_headline": "Klaar om een premium idee te ontgrendelen?", + "footer_subheadline": "

Kies een plan, rond checkout af en krijg direct toegang tot de volledige strategie.

", + "cta_explore": "Explore Ideas", + "cta_buy": "Buy Strategy", + "cta_unlock": "Unlock Full Plan", + }, + "en": { + "badge": "IDEA MARKETPLACE", + "headline": "Premium ideas you can execute immediately", + "sub_headline": "

Explore proven plans, buy the strategy, and unlock the full implementation blueprint.

", + "features_title": "Idea Marketplace", + "features_subtitle": "

Preview first. Buy what fits. Unlock complete execution plans after checkout.

", + "footer_headline": "Ready to unlock a premium idea?", + "footer_subheadline": "

Select a plan, complete checkout, and get full strategy access instantly.

", + "cta_explore": "Explore Ideas", + "cta_buy": "Buy Strategy", + "cta_unlock": "Unlock Full Plan", + }, +} + +SUPPORTED_LANGUAGES = {"nl", "en", "de", "fr", "es", "it", "pt", "ru"} + + +def _copy_for(language_code: str) -> dict[str, str]: + normalized = (language_code or "nl").split("-")[0].lower() + if normalized not in SUPPORTED_LANGUAGES: + normalized = "nl" + return HOME_COPY["en"] if normalized != "nl" else HOME_COPY["nl"] + + +def _shop_url_for(language_code: str) -> str: + normalized = (language_code or "nl").split("-")[0].lower() + if normalized == "nl": + return "/shop/" + return f"/{normalized}/shop/" + + +def _update_homepage_stream(page) -> bool: + if not hasattr(page, "body"): + return False + body = page.body + if not body: + return False + + copy = _copy_for(getattr(page.locale, "language_code", "nl")) + shop_url = _shop_url_for(getattr(page.locale, "language_code", "nl")) + + stream_data = list(body.stream_data) + changed = False + for block in stream_data: + block_type = block.get("type") + value = block.get("value", {}) + if not isinstance(value, dict): + continue + + if block_type == "saas_hero_banner": + updates = { + "badge_text": copy["badge"], + "headline": copy["headline"], + "sub_headline": copy["sub_headline"], + "primary_cta_text": copy["cta_explore"], + "primary_cta_url": shop_url, + "secondary_cta_text": copy["cta_buy"], + "secondary_cta_url": shop_url, + } + for key, new_value in updates.items(): + if value.get(key) != new_value: + value[key] = new_value + changed = True + + if block_type == "saas_features": + updates = { + "section_title": copy["features_title"], + "section_subtitle": copy["features_subtitle"], + } + for key, new_value in updates.items(): + if value.get(key) != new_value: + value[key] = new_value + changed = True + + if block_type == "saas_cta_footer": + updates = { + "headline": copy["footer_headline"], + "subheadline": copy["footer_subheadline"], + "primary_cta_text": copy["cta_unlock"], + "primary_cta_url": shop_url, + "secondary_cta_text": copy["cta_explore"], + "secondary_cta_url": shop_url, + } + for key, new_value in updates.items(): + if value.get(key) != new_value: + value[key] = new_value + changed = True + + if not changed: + return False + + page.body = StreamValue(page.body.stream_block, stream_data, is_lazy=True) + page.search_description = ( + "Idea marketplace with premium plans. Preview each strategy and unlock full implementation after purchase." + ) + page.save() + return True + + +def _purge_demo_pages() -> int: + marker_filter = Q() + for marker in DEMO_MARKERS: + marker_filter |= ( + Q(title__icontains=marker) + | Q(slug__icontains=marker) + | Q(search_description__icontains=marker) + ) + candidate_ids = set( + Page.objects.exclude(depth__lte=2).filter(marker_filter).values_list("id", flat=True) + ) + candidate_ids.update( + Page.objects.exclude(depth__lte=2) + .filter(slug__in=BLOCKED_DEMO_PAGE_SLUGS) + .values_list("id", flat=True) + ) + candidates = Page.objects.filter(id__in=candidate_ids).specific() + deleted = 0 + for page in candidates: + page.delete() + deleted += 1 + return deleted + + +def _update_homepages() -> int: + updated = 0 + # In this architecture localized homepages are expected at depth=2. + for page in Page.objects.filter(depth=2).specific(): + if _update_homepage_stream(page): + updated += 1 + return updated + + +class Command(BaseCommand): + help = ( + "Prepare production idea marketplace launch: seed idea products, purge obvious demo pages, " + "and refresh homepage sections/CTAs to marketplace messaging." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--no-seed", + action="store_true", + help="Skip idea product seeding.", + ) + parser.add_argument( + "--purge-demo-pages", + action="store_true", + help="Delete pages with obvious demo/lorem/sample markers.", + ) + parser.add_argument( + "--skip-purge-demo-pages", + action="store_true", + help="Skip deleting obvious demo pages (enabled by default).", + ) + parser.add_argument( + "--apply-homepage-copy", + action="store_true", + help="Update homepage stream blocks to idea marketplace messaging and CTAs.", + ) + parser.add_argument( + "--skip-apply-homepage-copy", + action="store_true", + help="Skip homepage marketplace copy refresh (enabled by default).", + ) + + @transaction.atomic + def handle(self, *args, **options): + if not options["no_seed"]: + seed_stats = seed_idea_marketplace_products( + purge_demo_products=True, + retire_non_idea_products=True, + ) + self.stdout.write( + self.style.SUCCESS( + "Seeded idea products: " + f"created={seed_stats['created']}, " + f"updated={seed_stats['updated']}, " + f"deleted_demo_products={seed_stats['deleted_demo']}, " + f"retired_non_idea_products={seed_stats['retired_non_idea']}" + ) + ) + + should_purge_demo_pages = ( + options["purge_demo_pages"] or not options["skip_purge_demo_pages"] + ) + if should_purge_demo_pages: + deleted_pages = _purge_demo_pages() + self.stdout.write( + self.style.SUCCESS(f"Deleted demo pages: {deleted_pages}") + ) + + should_apply_homepage_copy = ( + options["apply_homepage_copy"] or not options["skip_apply_homepage_copy"] + ) + if should_apply_homepage_copy: + updated_pages = _update_homepages() + self.stdout.write( + self.style.SUCCESS( + f"Updated homepages with marketplace copy: {updated_pages}" + ) + ) diff --git a/mandelstudio/management/commands/purge_demo_data.py b/mandelstudio/management/commands/purge_demo_data.py index d13296b..2a47b0e 100644 --- a/mandelstudio/management/commands/purge_demo_data.py +++ b/mandelstudio/management/commands/purge_demo_data.py @@ -1,8 +1,12 @@ from __future__ import annotations -from dataclasses import dataclass +from typing import Iterable from django.core.management.base import BaseCommand +from django.db.models import Q +from oscar.core.loading import get_model +from wagtail.models import Page + IDEA_PRODUCT_TITLES = { "B2B Webshop Starter Blueprint", @@ -12,136 +16,105 @@ IDEA_PRODUCT_TITLES = { "Marketplace Platform Architecture (Django)", } +DEMO_PAGE_SLUGS = { + "starter-website-2", + "business-website-2", + "starter-website", + "business-website", +} -@dataclass(frozen=True) -class PurgeStats: - pages: int = 0 - products: int = 0 +DEMO_MARKERS = ( + "demo", + "dummy", + "sample", + "placeholder", + "starter website", + "business website", + "lorem ipsum", +) + + +def _build_demo_text_filter(fields: Iterable[str]) -> Q: + query = Q() + for field in fields: + for marker in DEMO_MARKERS: + query |= Q(**{f"{field}__icontains": marker}) + return query class Command(BaseCommand): help = ( - "Remove demo pages/products from Mandel Blog while preserving " - "idea marketplace content." + "Remove demo content from Wagtail pages and Oscar catalogue. " + "Use --keep-only-idea-products to retain only the five launch idea products." ) def add_arguments(self, parser): parser.add_argument( "--dry-run", action="store_true", - help="Show what would be removed without deleting.", + default=False, + help="Show what would be deleted without applying changes.", ) parser.add_argument( "--keep-only-idea-products", action="store_true", - help="Delete all products except the 5 idea marketplace products.", + default=False, + help="Delete every top-level product except the five launch idea products.", ) def handle(self, *args, **options): - dry_run = bool(options["dry_run"]) - keep_only_idea_products = bool(options["keep_only_idea_products"]) - - page_count = self._purge_pages(dry_run=dry_run) - product_count = self._purge_products( - dry_run=dry_run, - keep_only_idea_products=keep_only_idea_products, - ) - stats = PurgeStats(pages=page_count, products=product_count) - - mode = "DRY RUN" if dry_run else "APPLIED" - self.stdout.write( - self.style.SUCCESS( - f"[{mode}] Removed pages={stats.pages}, products={stats.products}" - ) - ) - - def _purge_pages(self, *, dry_run: bool) -> int: - from wagtail.models import Page - - slug_markers = [ - "starter-website", - "business-website", - "demo", - "voorbeeld", - "sample", - "template", - ] - title_markers = [ - "starter website", - "business website", - "demo", - "voorbeeld", - "sample", - "template", - ] - - qs = Page.objects.exclude(depth=1).exclude(slug__in=["home"]).specific() - - candidates = [] - for page in qs: - slug = (page.slug or "").lower() - title = (page.title or "").lower() - if any(marker in slug for marker in slug_markers) or any( - marker in title for marker in title_markers - ): - candidates.append(page) - - removed = 0 - for page in candidates: - removed += 1 - self.stdout.write( - f"PAGE {'[dry-run] ' if dry_run else ''}delete id={page.id} " - f"slug={page.slug} title={page.title}" - ) - if not dry_run: - page.delete() - return removed - - def _purge_products(self, *, dry_run: bool, keep_only_idea_products: bool) -> int: - from oscar.core.loading import get_model - - from oscar_elasticsearch.search.signal_handlers import ( - deregister_signal_handlers, - register_signal_handlers, - ) + dry_run: bool = options["dry_run"] + keep_only_ideas: bool = options["keep_only_idea_products"] Product = get_model("catalogue", "Product") - products = Product.objects.all() - if keep_only_idea_products: - candidates = products.exclude(title__in=IDEA_PRODUCT_TITLES) + product_filter = _build_demo_text_filter(("title",)) + product_filter |= Q(slug__in=DEMO_PAGE_SLUGS) + + top_level_products = Product.objects.filter(parent__isnull=True) + if keep_only_ideas: + products_to_delete = top_level_products.exclude(title__in=IDEA_PRODUCT_TITLES) else: - title_markers = [ - "demo", - "sample", - "placeholder", - "starter website", - "business website", - ] - candidates = [ - p - for p in products - if any(marker in (p.title or "").lower() for marker in title_markers) - ] + products_to_delete = top_level_products.filter(product_filter).exclude( + title__in=IDEA_PRODUCT_TITLES + ) + + pages_to_delete = ( + Page.objects.live() + .public() + .filter(depth__gt=2) + .filter(Q(slug__in=DEMO_PAGE_SLUGS) | _build_demo_text_filter(("title", "slug"))) + ) + + product_preview = list(products_to_delete.values_list("id", "title")[:30]) + page_preview = list(pages_to_delete.values_list("id", "slug", "title")[:30]) + + self.stdout.write(f"Products matched for deletion: {products_to_delete.count()}") + for item in product_preview: + self.stdout.write(f" - product#{item[0]}: {item[1]}") + if products_to_delete.count() > len(product_preview): + self.stdout.write(" - ...") + + self.stdout.write(f"Pages matched for deletion: {pages_to_delete.count()}") + for item in page_preview: + self.stdout.write(f" - page#{item[0]}: /{item[1]}/ ({item[2]})") + if pages_to_delete.count() > len(page_preview): + self.stdout.write(" - ...") - removed = 0 - iterable = candidates if isinstance(candidates, list) else list(candidates) if dry_run: - for product in iterable: - removed += 1 - self.stdout.write( - f"PRODUCT [dry-run] delete id={product.id} title={product.title}" - ) - return removed + self.stdout.write(self.style.WARNING("Dry run completed. No data was deleted.")) + return - deregister_signal_handlers() - try: - for product in iterable: - removed += 1 - self.stdout.write( - f"PRODUCT delete id={product.id} title={product.title}" - ) - product.delete() - finally: - register_signal_handlers() - return removed + deleted_products = products_to_delete.count() + deleted_pages = pages_to_delete.count() + + products_to_delete.delete() + for page in pages_to_delete: + # Use Wagtail's delete to remove descendants and revisions safely. + page.delete() + + self.stdout.write( + self.style.SUCCESS( + f"Demo purge complete. Deleted products={deleted_products}, pages={deleted_pages}." + ) + ) diff --git a/mandelstudio/management/commands/seed_idea_marketplace.py b/mandelstudio/management/commands/seed_idea_marketplace.py new file mode 100644 index 0000000..53ca0e9 --- /dev/null +++ b/mandelstudio/management/commands/seed_idea_marketplace.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from django.core.management.base import BaseCommand +from django.db import transaction + +from mandelstudio.idea_marketplace import seed_idea_marketplace_products + + +class Command(BaseCommand): + help = ( + "Seed production-ready Oscar idea products and remove obvious demo products " + "from the catalogue. By default, this also retires non-idea products from public " + "listing." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--keep-demo-products", + action="store_true", + help="Do not delete demo/sample products.", + ) + parser.add_argument( + "--keep-non-idea-products-public", + action="store_true", + help="Do not retire non-idea products from public listing.", + ) + + @transaction.atomic + def handle(self, *args, **options): + purge_demo = not options["keep_demo_products"] + retire_non_idea = not options["keep_non_idea_products_public"] + stats = seed_idea_marketplace_products( + purge_demo_products=purge_demo, + retire_non_idea_products=retire_non_idea, + ) + self.stdout.write( + self.style.SUCCESS( + "Idea marketplace seeded: " + f"created={stats['created']}, " + f"updated={stats['updated']}, " + f"deleted_demo={stats['deleted_demo']}, " + f"retired_non_idea={stats['retired_non_idea']}" + ) + ) diff --git a/mandelstudio/management/commands/validate_idea_marketplace_launch.py b/mandelstudio/management/commands/validate_idea_marketplace_launch.py new file mode 100644 index 0000000..20c38af --- /dev/null +++ b/mandelstudio/management/commands/validate_idea_marketplace_launch.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db.models import Q + +from oscar.core.loading import get_model + +from mandelstudio.content_hygiene import BLOCKED_DEMO_PAGE_SLUGS, DEMO_MARKERS +from mandelstudio.idea_marketplace import ( + FULL_DESCRIPTION_ATTRIBUTE_CODE, + IDEA_PRODUCT_CLASS_NAME, + IDEA_PRODUCTS, + SHORT_DESCRIPTION_ATTRIBUTE_CODE, +) + + +class Command(BaseCommand): + help = ( + "Fail-fast launch validation for idea marketplace: payment provider, " + "catalog integrity, digital/non-shipping behavior, and EUR pricing." + ) + + def handle(self, *args, **options): + Product = get_model("catalogue", "Product") + ProductClass = get_model("catalogue", "ProductClass") + ProductAttribute = get_model("catalogue", "ProductAttribute") + StockRecord = get_model("partner", "StockRecord") + Page = get_model("wagtailcore", "Page") + + installed_apps = list(settings.INSTALLED_APPS) + payment_apps = [app for app in installed_apps if "payment" in app.lower()] + checkout_apps = [app for app in installed_apps if "checkout" in app.lower()] + if not payment_apps: + raise CommandError("No payment app found in INSTALLED_APPS.") + if not checkout_apps: + raise CommandError("No checkout app found in INSTALLED_APPS.") + if not any("oscar_checkout" in app.lower() for app in checkout_apps): + raise CommandError("Oscar checkout app is not active.") + def _is_demo_data(value: str) -> bool: + normalized = "".join(ch for ch in str(value).lower() if ch.isalnum()) + return "demodata" in normalized + + if any(_is_demo_data(app) for app in installed_apps): + raise CommandError( + "Demo data plugin detected in INSTALLED_APPS. Remove all demodata plugins before launch." + ) + + if any("dummy" in app.lower() for app in payment_apps): + raise CommandError( + "Dummy payment app detected in INSTALLED_APPS. Use a real provider plugin before production launch." + ) + if any("mollie" in app.lower() for app in payment_apps): + mollie_settings = ( + getattr(settings, "PAYMENT_MOLLIE", None) + or getattr(settings, "payment_mollie", None) + or {} + ) + config_key = str(mollie_settings.get("api_key", "")).strip() + env_key = str(os.environ.get("MOLLIE_API_KEY", "")).strip() + effective_key = env_key or config_key + if not effective_key or effective_key.upper() == "CHANGE_ME": + raise CommandError( + "Mollie payment provider is enabled but no valid API key is configured. " + "Set MOLLIE_API_KEY or settings.payment_mollie.api_key to a real key." + ) + if not effective_key.startswith("live_"): + raise CommandError( + "Mollie key must be a live key for production launch (expected prefix 'live_')." + ) + + config_path = Path(__file__).resolve().parents[2] / "ocyan.json" + if config_path.exists(): + with config_path.open("r", encoding="utf-8") as handle: + config_payload = json.load(handle) + config_plugins = [str(plugin) for plugin in config_payload.get("ocyan_plugins", [])] + if any(_is_demo_data(plugin) for plugin in config_plugins): + raise CommandError( + "Demo data plugin detected in ocyan.json. Remove it before launch." + ) + settings_payload = config_payload.get("settings", {}) + domain = str(settings_payload.get("django", {}).get("domain", "")).strip() + shop_base_url = str( + settings_payload.get("oscar", {}).get("shop_base_url", "") + ).strip("/") + if not domain or domain.upper() == "CHANGE_ME": + raise CommandError( + "settings.django.domain is missing/placeholder in ocyan.json." + ) + if not shop_base_url: + raise CommandError( + "settings.oscar.shop_base_url is missing in ocyan.json." + ) + + currency = getattr(settings, "OSCAR_DEFAULT_CURRENCY", "EUR") + if currency != "EUR": + raise CommandError(f"OSCAR_DEFAULT_CURRENCY must be EUR, got '{currency}'.") + + product_class = ProductClass.objects.filter(name=IDEA_PRODUCT_CLASS_NAME).first() + if product_class is None: + raise CommandError(f"Missing ProductClass '{IDEA_PRODUCT_CLASS_NAME}'.") + if product_class.requires_shipping: + raise CommandError("Idea Product class requires_shipping must be False.") + + short_attr_exists = ProductAttribute.objects.filter( + product_class=product_class, code=SHORT_DESCRIPTION_ATTRIBUTE_CODE + ).exists() + full_attr_exists = ProductAttribute.objects.filter( + product_class=product_class, code=FULL_DESCRIPTION_ATTRIBUTE_CODE + ).exists() + if not short_attr_exists or not full_attr_exists: + raise CommandError( + "Missing required idea product attributes: short_description and/or full_description." + ) + + expected_titles = {item.title for item in IDEA_PRODUCTS} + expected_prices = {item.title: item.price_eur for item in IDEA_PRODUCTS} + found_products = Product.objects.filter(product_class=product_class) + found_titles = set(found_products.values_list("title", flat=True)) + missing_titles = sorted(expected_titles - found_titles) + if missing_titles: + raise CommandError(f"Missing seeded idea products: {', '.join(missing_titles)}.") + + non_public_idea_titles = list( + found_products.filter(title__in=expected_titles, is_public=False).values_list( + "title", flat=True + ) + ) + if non_public_idea_titles: + raise CommandError( + "Seeded idea products must be public to appear in the storefront. " + f"Examples: {', '.join(sorted(non_public_idea_titles))}" + ) + + invalid_shipping_products = [ + product.title + for product in found_products + if getattr(product, "is_shipping_required", False) + ] + if invalid_shipping_products: + raise CommandError( + "Some idea products still require shipping; expected digital-only products: " + + ", ".join(invalid_shipping_products) + ) + + # Validate each seeded idea has EUR stockrecord pricing in the expected range. + invalid_stockrecords: list[str] = [] + missing_stockrecords: list[str] = [] + for product in found_products.filter(title__in=expected_titles): + stockrecord = ( + StockRecord.objects.filter(product=product) + .order_by("id") + .first() + ) + if stockrecord is None: + missing_stockrecords.append(product.title) + continue + + if stockrecord.price_currency != "EUR": + invalid_stockrecords.append( + f"{product.title} (currency={stockrecord.price_currency})" + ) + continue + + expected = expected_prices[product.title] + actual = stockrecord.price_excl_tax + if actual is None or actual != expected: + invalid_stockrecords.append( + f"{product.title} (price_excl_tax={actual}, expected={expected})" + ) + continue + + if actual < 29 or actual > 149: + invalid_stockrecords.append( + f"{product.title} (out-of-range price_excl_tax={actual})" + ) + + if missing_stockrecords: + raise CommandError( + "Missing stockrecords for seeded idea products: " + + ", ".join(sorted(missing_stockrecords)) + ) + if invalid_stockrecords: + raise CommandError( + "Invalid stockrecord pricing for seeded idea products: " + + "; ".join(sorted(invalid_stockrecords)) + ) + + non_idea_public_titles = list( + Product.objects.exclude(title__in=expected_titles) + .filter(is_public=True) + .values_list("title", flat=True)[:10] + ) + if non_idea_public_titles: + raise CommandError( + "Non-idea products are still public. Retire them before launch. " + f"Examples: {', '.join(non_idea_public_titles)}" + ) + + demo_page_filter = Q() + for marker in DEMO_MARKERS: + demo_page_filter |= ( + Q(title__icontains=marker) + | Q(slug__icontains=marker) + | Q(search_description__icontains=marker) + ) + + live_demo_pages = ( + Page.objects.live() + .public() + .exclude(depth__lte=2) + .filter(demo_page_filter | Q(slug__in=BLOCKED_DEMO_PAGE_SLUGS)) + .values_list("title", "slug")[:10] + ) + if live_demo_pages: + formatted = ", ".join(f"{title} ({slug})" for title, slug in live_demo_pages) + raise CommandError( + "Demo-like pages are still live/public. Purge them before launch. " + f"Examples: {formatted}" + ) + + self.stdout.write( + self.style.SUCCESS( + "Idea marketplace launch validation passed: " + f"{len(found_titles)} products, EUR currency, checkout apps={checkout_apps}, " + f"payment apps={payment_apps}." + ) + ) diff --git a/mandelstudio/templatetags/idea_marketplace.py b/mandelstudio/templatetags/idea_marketplace.py new file mode 100644 index 0000000..84c2486 --- /dev/null +++ b/mandelstudio/templatetags/idea_marketplace.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from django import template + +from mandelstudio.idea_marketplace import ( + get_idea_full_description, + get_idea_short_description, + get_unlockable_description, + is_idea_product, + user_has_unlocked_idea, +) + +register = template.Library() + + +@register.simple_tag +def idea_is_product(product): + return is_idea_product(product) + + +@register.simple_tag +def idea_is_unlocked(product, user): + return user_has_unlocked_idea(user, product) + + +@register.simple_tag +def idea_payments_enabled(): + from django.conf import settings + + return getattr(settings, "IDEA_MARKETPLACE_PAYMENTS_ENABLED", False) + + +@register.simple_tag +def idea_short_description(product): + return get_idea_short_description(product) + + +@register.simple_tag +def idea_full_description(product): + return get_idea_full_description(product) + + +@register.simple_tag +def idea_description_for_user(product, user): + return get_unlockable_description(product, user)