feat: integrate design tokens from www.makepostsell.com styleguide #94

Merged
russellballestrini merged 31 commits from explore/design-tokens into master 2026-03-07 23:28:24 -05:00
62 changed files with 6195 additions and 547 deletions

View file

@ -20,6 +20,13 @@ Files are NEVER streamed through uwsgi. The server only generates presigned URLs
- **Uploads**: presigned `post` → client uploads directly to Spaces
- **Thumbnails**: public CDN URLs with `?ts=` cache busting
**BYOB (Bring Your Own Bucket)**: Shops can configure their own S3-compatible bucket (`bucket-settings` form section). When enabled, all presigned URLs and CDN references use the shop's bucket. Always use shop-aware request methods in views and templates:
- `request.shop_uploads_client` — S3 client (shop's or MPS default)
- `request.shop_bucket_name` — bucket name (shop's or MPS default)
- `request.shop_cdn_endpoint` — CDN URL (shop's or MPS default)
**NEVER** use `request.app["bucket.secure_uploads"]`, `request.app["bucket.secure_uploads.get_endpoint"]`, or `request.secure_uploads_client` directly in views or templates. These are only used internally by `request_methods.py` as fallbacks.
### Karaoke Pipeline (lib/karaoke.py)
Disk-backed vocal isolation pipeline. Downloads media from S3, builds a JSON
@ -69,6 +76,12 @@ This project uses a Makefile for most development operations. Use `make` command
- `make_post_sell/views/cart.py` - Cart and checkout logic
- `development.ini` - Configuration file
### Design System Files
- `static/css/tokens.css` — Design tokens (colors, typography, spacing, shape, elevation, motion, z-index), base resets, utility classes, animations. Single source of truth. Light mode `:root`, dark mode `[data-theme="dark"]`.
- `static/css/common.css` — Component styles consuming tokens via `var(--token, fallback)`.
- `templates/styleguide.j2` — Live component reference at `/styleguide` (view: `views/misc.py:23`).
- `docs/design-system.md` — Full design system reference doc (token tables, architecture diagram, conventions).
## Testing Notes
The project uses pytest with unittest framework. There are three types of tests:
@ -94,10 +107,12 @@ env/bin/py.test make_post_sell/tests/test_functional.py # Functional tests
env/bin/py.test --cov=make_post_sell.models.cart --cov-report=term-missing make_post_sell/tests/test_models.py::TestCart
```
### Current Coverage
### Current Coverage (712 tests)
- Cart model unit tests cover critical business logic like `requires_payment` threshold (64 cents)
- Integration tests verify the original AttributeError defect fix for free coupon checkout
- Functional tests provide end-to-end coverage of cart/checkout/payment flows
- Shop environment, trial, and BYOB model properties (TestShopEnvironment, TestShopTrial, TestShopBYOB)
- Gift card model unit tests (generation, validation, transactions)
- Integration tests verify free coupon checkout, gift card flows, and multi-model interactions
- Functional tests cover cart/checkout/payment, gift card settings, environment settings, bucket settings
## Database Location
@ -232,6 +247,10 @@ Always use `uuid_str` when you need a string copy of the identifier. Models inhe
**CSS LAYOUT REQUIREMENTS**: This project uses CSS Grid exclusively for layout. NEVER use Flexbox (flex) for layout. Always use CSS Grid properties for positioning and alignment.
**DESIGN TOKENS**: All new styles must consume tokens from `tokens.css` — never hardcode colors, spacing, radii, shadows, or font sizes. Use `var(--token-name)` or `var(--token-name, fallback)`. The token scale uses a 4px spacing base and major third (1.250) type scale.
**STYLEGUIDE**: When creating new UI components (buttons, wells, alerts, layout patterns, etc.), add a live example to `/styleguide` (`make_post_sell/templates/styleguide.j2`). The styleguide is the single source of truth for the component library. If it's not in the styleguide, it doesn't exist as a pattern.
**CSS MEDIA SIZING**: Never combine `width: 100%` with `max-height` on media elements (img, video). `width: 100%` forces the element to span the full container even when `max-height` constrains the rendered content, creating dead whitespace. Use `width: auto` + `max-width: 100%` + `max-height` instead — the element shrinks to match the actual content aspect ratio within both constraints.
**MOBILE USABILITY**: Never use hover-only interactions (`:hover` to reveal controls, `opacity: 0` with hover reveal, etc.). Mobile/touch devices have no hover state — controls hidden behind hover are invisible and unreachable. All interactive elements (buttons, toggles, links) must be always visible and tappable. Design touch-first, then optionally enhance for desktop hover.
@ -250,8 +269,25 @@ Elements that must stay in sync: CTA edit button, download button, comment form
Disabling or removing tests weakens the codebase and is unacceptable. Tests are critical safety nets that prevent regressions.
**MANDATORY TEST COVERAGE**: Every new feature, model property, view handler, or form section MUST have tests across all three layers:
- **Unit tests** (`test_models.py`) — Test new model properties, methods, and business logic in isolation using `mock.patch`. No DB required.
- **Integration tests** (`test_integration.py`) — Test interactions between models, especially multi-model workflows (e.g., cart + coupon + gift card).
- **Functional tests** (`test_functional.py`) — Test through the web interface using `webtest.TestApp`. Cover settings form POSTs, page loads, flash messages, and DB state changes.
If a feature touches all three layers (model + view + template), it needs tests in all three files. No exceptions. Untested code is incomplete code.
**AUTO-PUSH**: When you write new tests to cover new code paths and the full test suite passes, commit and push without asking. Bump GIT_HASH after pushing.
## Post-Work Chores
After completing a feature or significant change, always perform these chores before considering the work done:
1. **Tests** — Write unit tests (`test_models.py`), integration tests (`test_integration.py`), and functional tests (`test_functional.py`) covering the new code paths. All three layers are required for new features.
2. **Docs** — Update `docs/architecture.md` (feature toggle matrix, ticket index, diagrams) and `docs/design-system.md` (new components/sections) to reflect the change.
3. **Portal** — Update the marketing site at `~/git/www.makepostsell.com` (feature cards in `index.html`, includes list in `pricing.html`) when a user-facing feature is added.
4. **CLAUDE.md** — Update this file if the change introduces new patterns, form sections, model columns, or conventions that future work needs to know about.
5. **Commit & push** — Per AUTO-PUSH, commit and push when tests pass. Bump GIT_HASH.
## Commit Message Guidelines
**CRITICAL**: Do not include Claude Code attribution in commit messages. Attributing human work to Claude is inappropriate and misrepresents the actual authorship of the code. All code changes should be attributed to the human developer who reviewed, approved, and committed the work.

1
GIT_HASH Normal file
View file

@ -0,0 +1 @@
e2169ec

View file

@ -38,6 +38,7 @@
│ models │ │ media │ │ Stripe │ │ │
│ sessions│ │ thumbs │ │ PayPal │ │ Presigned │
│ signals │ │ assets │ │ Crypto │ │ URLs only │
│ │ │ │ │ Gift Cards │ │ │
└─────────┘ └────────────┘ └──────────────┘ └───────────────┘
```
@ -160,6 +161,19 @@ mps_page_session (raw rows)
│ Backfill on setup │
└─────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Shop Primary Bucket (BYOB) │
│ (shop.has_primary_s3 — MPS-16) │
│ │
│ When enabled, REPLACES MPS Main Bucket for this shop: │
│ - All presigned URLs use shop's S3 client │
│ - All CDN URLs use shop's cdn_endpoint │
│ - request.shop_uploads_client / shop_bucket_name / │
│ shop_cdn_endpoint fall back to MPS default when off │
│ │
│ Configured via bucket-settings form section │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ User Artifact Bucket │
│ (user.has_s3_bucket) │
@ -185,7 +199,11 @@ mps_page_session (raw rows)
| Stripe | `shop.stripe_enabled` | `stripe-settings` | On |
| PayPal | `shop.paypal_*` | `paypal-settings` | Off |
| Crypto | `shop.monero_*` / `shop.dogecoin_*` | `crypto-settings` | Off |
| Gift cards | `shop.gift_card_enabled` | `gift-card-settings` | Off |
| S3 mirror | `shop.mirror_s3_*` | `mirror-settings` | Off |
| BYOB (primary S3) | `shop.primary_s3_*` | `bucket-settings` | Off |
| Environment | `shop.environment` | `environment-settings` | 0 (production) |
| Trial | `shop.trial_started_timestamp` | Auto on creation | 21 days |
| Discovery ring | `shop.discovery_ring` | Automatic | Auto-computed |
| Subscriptions | `shop.subscription_*` | `ribbon-settings` | Off |
@ -203,3 +221,19 @@ mps_page_session (raw rows)
| [MPS-7](tickets/mps-7.md) | Sandbox Mode — Creative Filter System | Complete |
| [MPS-8](tickets/mps-8.md) | User S3 Bucket + Artifact Storage | Complete |
| [MPS-9](tickets/mps-9.md) | Shop S3 Mirror Bucket | Complete |
| [MPS-10](tickets/mps-10.md) | Gift Card — Models & Migration | Complete |
| [MPS-11](tickets/mps-11.md) | Gift Card — Purchase Flow | Complete |
| [MPS-12](tickets/mps-12.md) | Gift Card — Redemption at Checkout | Complete |
| [MPS-13](tickets/mps-13.md) | Gift Card — Shop Admin & Settings | Complete |
| [MPS-14](tickets/mps-14.md) | Shop Environment — Dev & Stage Shops | Complete |
| [MPS-15](tickets/mps-15.md) | 21-Day Free Trial | Complete |
| [MPS-16](tickets/mps-16.md) | Bring Your Own Bucket (BYOB) | Complete |
## Related Docs
| Doc | Purpose |
|-----|---------|
| [Design System](design-system.md) | Design tokens, CSS architecture, component library |
| [JavaScript](JAVASCRIPT.md) | Client-side JS architecture |
| [Sandbox Mode](sandbox-mode.md) | Creative filter system |
| [Testing Performance](testing-performance.md) | Test suite optimization |

309
docs/design-system.md Normal file
View file

@ -0,0 +1,309 @@
# MPS Design System
## Overview
Make Post Sell uses a design token architecture with CSS custom properties as the single source of truth. Mobile-first. CSS Grid only (no Flexbox). Accessible. Respects reduced motion. Supports light and dark themes.
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────┐
│ tokens.css │
│ Design Tokens (:root) │
│ │
│ Colors · Typography · Spacing · Shape · Elevation │
│ Motion · Z-index · Layout · State overlays │
│ │
│ [data-theme="dark"] overrides │
│ │
│ Base resets · Focus · Selection │
│ Typography utilities (.type-*) │
│ Elevation utilities (.elevation-*) │
│ Surface utilities (.surface-*) │
│ State layer · Ripple effect │
│ Animations · Skeleton loading · Spinner │
│ Spacing utilities (.mt-*, .mb-*, .p-*, .gap-*) │
│ Container utilities (.container, -narrow, -wide) │
└──────────────────────┬──────────────────────────────────┘
│ consumed by
┌─────────────────────────────────────────────────────────┐
│ common.css │
│ Component Styles │
│ │
│ Body layout · Navigation · Forms · Buttons │
│ Product grid · Cards · Cart · Comments │
│ Wells · Alerts · Ribbons · Footer │
│ Watch mode · Landing page · Login │
│ Responsive breakpoints │
│ │
│ References tokens via var(--token-name, fallback) │
└──────────────────────┬──────────────────────────────────┘
│ rendered in
┌─────────────────────────────────────────────────────────┐
│ templates/ │
│ Jinja2 Templates │
│ │
│ base.j2 → theme toggle, nav, footer │
│ styleguide.j2 → live component reference │
│ product.j2, content.j2, shop.j2, etc. │
└─────────────────────────────────────────────────────────┘
```
## File Map
| File | Lines | Purpose |
|------|-------|---------|
| `static/css/tokens.css` | 775 | Design tokens, utilities, animations, resets |
| `static/css/common.css` | 3879 | Component styles consuming tokens |
| `templates/styleguide.j2` | 1083 | Live styleguide at `/styleguide` |
| `views/misc.py:23` | — | Styleguide view controller |
## Token Categories
### Colors
```
Brand Surface Text Border
────────── ────────── ────────── ──────────
--color-green --surface-base --text-primary --border-default
--color-blue --surface-dim --text-body --border-light
--color-navy --surface-container --text-secondary --border-focus
--color-purple --surface-container- --text-muted --border-error
--color-danger high --text-faint
--color-gold --surface-inverse --text-disabled
--text-inverse
```
### Typography Scale (Major Third 1.250)
```
Token Size Use
────────── ────── ──────────────────────
--text-xs 12px Captions, overlines
--text-sm 14px Labels, small body
--text-base 16px Body text (root)
--text-md 18px Large body
--text-lg 20px Titles
--text-xl 24px Title large
--text-2xl 30px Headline 3
--text-3xl 36px Headline 2
--text-4xl 48px Headline 1
--text-5xl 60px Display
```
### Spacing Scale (4px base)
```
Token Value
────────── ──────
--space-0 0
--space-1 4px
--space-2 8px
--space-3 12px
--space-4 16px
--space-5 20px
--space-6 24px
--space-8 32px
--space-10 40px
--space-12 48px
--space-16 64px
--space-20 80px
--space-24 96px
```
### Shape (Border Radius)
```
--radius-none 0 Sharp corners
--radius-sm 4px Inputs, code blocks
--radius-md 8px Cards, buttons
--radius-lg 12px Modals, panels
--radius-xl 16px Large surfaces
--radius-2xl 24px Pills
--radius-full 9999px Circles
```
### Elevation (Box Shadow)
```
--elevation-0 none Flat
--elevation-1 subtle Cards at rest
--elevation-2 low Raised cards
--elevation-3 medium Dropdowns
--elevation-4 high Modals
--elevation-5 highest Popovers
```
### Motion
```
Durations Easing
────────────────── ──────────────────
--duration-instant 50ms --ease-standard general transitions
--duration-fast 100ms --ease-decelerate entrances
--duration-normal 200ms --ease-accelerate exits
--duration-slow 300ms --ease-emphasize emphasis
--duration-slower 400ms --ease-spring playful bounce
--duration-entrance 250ms
--duration-exit 200ms
```
### Z-Index Scale
```
--z-base 0 Default stacking
--z-dropdown 100 Dropdowns, popovers
--z-sticky 200 Sticky headers
--z-overlay 300 Overlays, backdrops
--z-modal 400 Modals
--z-toast 500 Toast notifications
--z-ribbon 600 Shop ribbon banner
```
### Layout Breakpoints
```
--content-narrow 400px Login forms, narrow content
--content-width 800px Default content width
--content-wide 1200px Wide layouts
--bp-tablet 800px Tablet breakpoint
--bp-desktop 1200px Desktop breakpoint
```
## Theme System
Light mode is default (`:root`). Dark mode activates via `[data-theme="dark"]` on the `<html>` element. The dark theme overrides all semantic tokens — surfaces, text, borders, brand colors — so components adapt automatically without per-component dark mode rules.
```
Light Dark
────────────────────── ──────────────────────
--surface-base: #FFFFFF --surface-base: #0d1117
--text-primary: #333333 --text-primary: #ffffff
--border-default: #e0e0e0 --border-default: #7ab9ff
--color-green: #a3c765 --color-green: #08e700
--color-navy: #5871ad --color-navy: #7ab9ff
```
Theme toggle: `window.setTheme('dark')` / `window.setTheme('light')`.
## Typography Utilities
CSS classes that compose token values into complete type styles:
| Class | Size | Weight | Use |
|-------|------|--------|-----|
| `.type-display` | clamp(36px, 5vw, 60px) | bold | Hero headlines |
| `.type-headline-1` | 48px | bold | Page titles |
| `.type-headline-2` | 36px | bold | Section titles |
| `.type-headline-3` | 30px | bold | Subsection titles |
| `.type-title-lg` | 24px | semibold | Card titles |
| `.type-title` | 20px | semibold | List titles |
| `.type-title-sm` | 16px | semibold | Small titles |
| `.type-body-lg` | 18px | regular | Lead paragraphs |
| `.type-body` | 16px | regular | Body text |
| `.type-body-sm` | 14px | regular | Secondary text |
| `.type-label-lg` | 14px | semibold | Form labels |
| `.type-label` | 12px | semibold, uppercase | Overline labels |
| `.type-caption` | 12px | regular | Captions |
| `.type-overline` | 11px | bold, uppercase | Section overlines |
| `.type-code` | 0.9em | mono | Inline code |
## Component Library
All components are documented with live examples at `/styleguide`. The styleguide is the single source of truth for the component library. If it is not in the styleguide, it does not exist as a pattern.
### Styleguide Sections
| Section | ID | Description |
|---------|-----|------------|
| Tokens | `#tokens` | Raw token reference table |
| Colors | `#colors` | Brand, surface, text, border, alert swatches |
| Typography | `#typography` | Type scale and utility classes |
| Elevation | `#elevation` | Shadow levels |
| Motion | `#motion` | Animations, transitions, easing |
| Spacing | `#spacing` | Spacing scale visualization |
| Shape | `#shape` | Border radius samples |
| States | `#states` | Interactive state layers |
| Loading | `#loading` | Skeleton and spinner patterns |
| Buttons | `#buttons` | Button variants (green, blue, red, navy, outline) |
| Forms | `#forms` | Input fields, textareas, selects |
| Wells | `#wells` | Content wells and containers |
| Alerts | `#alerts` | Success, info, warning, danger alerts |
| Status | `#status` | Status indicators |
| Product Cards | `#cards` | Product grid cards |
| Cart | `#cart` | Cart and checkout components |
| Gift Cards | `#gift-cards` | Gift card purchase, balance check, management |
| Comments | `#comments` | Comment form and list |
| Toggle | `#toggle` | Toggle switches |
| Ribbon | `#ribbon` | Shop ribbon banner |
| Environment Banner | `.environment-banner` | Staging/dev environment indicator |
| Trial Banner | `.trial-banner` | Trial countdown and expiry notice |
| Task Bar | `#taskbar` | Task bar component |
| Layout | `#layout` | Grid layout patterns |
| Theme System | `#theme` | Theme toggle and dark mode |
| Footer | `#footer` | Footer component |
## CSS Conventions
### Layout
- **CSS Grid only** — never use Flexbox for layout
- Mobile-first: base styles target mobile, `@media` queries enhance for larger screens
- Primary breakpoint: `max-width: 800px` for mobile
### Token Consumption
Components in `common.css` reference tokens with fallbacks:
```css
/* Good — token with fallback for resilience */
background-color: var(--surface-base, #ffffff);
border-radius: var(--radius-sm, 4px);
/* Good — token without fallback (tokens.css always loaded) */
padding: var(--space-4);
```
### Media Sizing
Never combine `width: 100%` with `max-height` on media elements. Use:
```css
/* Correct */
width: auto;
max-width: 100%;
max-height: 33vh;
/* Wrong — creates dead whitespace */
width: 100%;
max-height: 33vh;
```
### Mobile Usability
Never use hover-only interactions. All interactive elements must be always visible and tappable. Design touch-first, then optionally enhance for desktop hover.
### Reduced Motion
All animations respect `prefers-reduced-motion: reduce` via a global media query in `tokens.css` that collapses durations to near-zero.
### Capability-Driven Presentation
Follow Russell Ballestrini's capability-driven presentation practice. A page need not look identical across all browsers. Accommodate what the user's browser can do. Use the `js-only` / `<noscript>` pattern for progressive enhancement.
## Adding New Components
1. Define the component styles in `common.css`, consuming tokens from `tokens.css`
2. Add a live example to `/styleguide` (`templates/styleguide.j2`)
3. If the component participates in watch mode SPA navigation, update all three layers: template, `watch.js`, `watch.py`
## Load Order
```
base.j2
└─ <link> static/css/tokens.css ← tokens + utilities + resets
└─ <link> static/css/common.css ← components consuming tokens
└─ per-page <style> blocks page-specific overrides
```

79
docs/tickets/mps-10.md Normal file
View file

@ -0,0 +1,79 @@
# MPS-10: Gift Card System — Models & Migration
## Problem
Shops want to sell variable-amount gift cards. Buyers pick an amount (slider),
purchase with any payment method (including crypto), and receive a code. The
recipient enters the code at checkout (like a coupon) and the balance decrements
across purchases. Gift cards never expire (permacomputer rules).
This ticket covers the data layer only. Purchase flow (MPS-11), redemption flow
(MPS-12), and shop admin UI (MPS-13) are separate tickets.
## Solution
### New model: `MpsGiftCard`
| Column | Type | Notes |
|--------|------|-------|
| `id` | `UUIDType` | PK (uuid1) |
| `shop_id` | `UUIDType` | FK to Shop — card is scoped to one shop |
| `code` | `Unicode(64)` | Unique redemption code, uppercase alphanumeric |
| `initial_amount_in_cents` | `BigInteger` | Amount at time of purchase |
| `balance_in_cents` | `BigInteger` | Current remaining balance |
| `purchaser_email` | `Unicode(256)` | Email of the buyer |
| `gift_email` | `Unicode(256)` | Optional recipient email |
| `invoice_id` | `UUIDType` | FK to Invoice — the purchase transaction |
| `created_timestamp` | `BigInteger` | Milliseconds |
| `disabled` | `Boolean` | Admin kill switch, default False |
Properties:
- `is_valid` — not disabled and balance > 0
- `balance``cents_to_dollars(balance_in_cents)`
- `initial_amount``cents_to_dollars(initial_amount_in_cents)`
- `shop_uuid_str` — string form of shop_id
Code generation: 16-char uppercase alphanumeric (`secrets.token_hex(8).upper()`),
prefixed with `GC-` for human readability. Example: `GC-A1B2C3D4E5F6G7H8`.
### New model: `MpsGiftCardTransaction`
Tracks every time a gift card balance is used at checkout.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `UUIDType` | PK (uuid1) |
| `gift_card_id` | `UUIDType` | FK to MpsGiftCard |
| `invoice_id` | `UUIDType` | FK to Invoice — the purchase that used the card |
| `amount_in_cents` | `BigInteger` | Amount deducted from balance |
| `created_timestamp` | `BigInteger` | Milliseconds |
### Shop settings columns
Add to Shop model:
| Column | Type | Default |
|--------|------|---------|
| `gift_card_enabled` | `Boolean` | `False` |
| `gift_card_min_in_cents` | `BigInteger` | `500` ($5.00) |
| `gift_card_max_in_cents` | `BigInteger` | `25000` ($250.00) |
### Helper functions
- `get_gift_card_by_code(dbsession, code, shop=None)` — lookup by code, optionally scoped to shop
- `get_gift_card_by_id(dbsession, gift_card_id)` — standard ID lookup
- `get_gift_cards_by_shop(dbsession, shop)` — all cards for a shop (admin view)
## Files Changed
| File | Change |
|------|--------|
| `models/gift_card.py` | New: MpsGiftCard model |
| `models/gift_card_transaction.py` | New: MpsGiftCardTransaction model |
| `models/shop.py` | Add gift_card_enabled, gift_card_min_in_cents, gift_card_max_in_cents |
| `models/__init__.py` | Import new models |
| `scripts/alembic/versions/*_gift_card_tables.py` | Migration: new tables + shop columns |
## Depends On
Nothing. Foundation ticket.

76
docs/tickets/mps-11.md Normal file
View file

@ -0,0 +1,76 @@
# MPS-11: Gift Card System — Purchase Flow
## Problem
Buyers need a way to purchase gift cards for a shop. The shop owner sets a
min/max amount range, and the buyer picks any amount within that range using a
slider. The buyer can optionally enter a recipient email address so the gift
card code is delivered to someone else.
## Solution
### Gift card "product" page
Gift cards are not regular products — they are a shop-level feature. A shop with
`gift_card_enabled=True` gets a `/shop/{slug}/gift-card` page.
The page contains:
- Shop name and branding
- Amount slider (range input) with min/max from shop settings
- Manual amount text input (synced with slider for precise entry)
- Optional "Gift to" email field
- Optional gift message (short text, stored on the card)
- "Add to Cart" button
### Cart integration
Gift cards are added to the cart as a special line item. Since they have variable
pricing and are not regular products, they need a different storage approach in
`json_cart`:
Option A: Store gift card items in a separate `json_gift_cards` column on Cart.
Format: `[{"shop_id": "...", "amount_in_cents": 2500, "gift_email": "...", "gift_message": "..."}]`
This keeps gift cards cleanly separated from product line items and avoids
polluting the existing `json_cart` dictionary (which maps product UUIDs to
quantities).
### Checkout
When the cart contains gift card items:
1. Gift card amounts are included in the cart total
2. After successful payment (Stripe, PayPal, or crypto), generate a
`MpsGiftCard` record for each gift card line item
3. Generate the unique code (`GC-` prefix + 16 hex chars)
4. If `gift_email` is provided, send the code to the recipient
5. Always show the code to the purchaser in the order confirmation
### Email delivery
When `gift_email` is set, send a simple email to the recipient containing:
- Shop name
- Gift card amount
- The redemption code
- Optional gift message
- Link to the shop
The email is informational only — the code IS the value. No account required.
## Files Changed
| File | Change |
|------|--------|
| `views/gift_card.py` | New: gift card page + add-to-cart handler |
| `templates/gift_card.j2` | New: gift card purchase page with slider |
| `static/js/gift_card.js` | New: slider/input sync, amount formatting |
| `static/css/common.css` | Gift card page styles (using tokens) |
| `models/cart.py` | Add `json_gift_cards` column, gift card total methods |
| `views/cart.py` | Include gift card totals in checkout flow |
| `views/checkout.py` | Generate MpsGiftCard records after payment |
| `routes.py` | Add `/shop/{slug}/gift-card` route |
| `lib/email.py` | Gift card delivery email template |
| `templates/shop.j2` | "Gift Cards" link when enabled |
## Depends On
MPS-10 (models and migration).

84
docs/tickets/mps-12.md Normal file
View file

@ -0,0 +1,84 @@
# MPS-12: Gift Card System — Redemption at Checkout
## Problem
Recipients need to apply a gift card code at checkout, just like a coupon code.
The gift card balance should reduce the cart total for that shop. Partial use is
supported — remaining balance stays on the card for future purchases.
## Solution
### Cart: gift card code entry
Add a "Gift Card" code input field alongside the existing coupon code field on
the cart/checkout page. The flow mirrors coupon application:
1. User enters gift card code
2. Server validates: code exists, belongs to a shop in the cart, has balance, not disabled
3. If valid, attach to cart and show the discount
4. If invalid, show error message
### Cart model changes
Add gift card tracking to the Cart model, parallel to how coupons work:
- New association: `MpsCartGiftCard` (cart_id, gift_card_id) — many-to-many
- `cart.gift_cards` — association proxy to attached gift cards
- Gift card discount is applied AFTER coupon discounts (coupons reduce the
price first, then gift card balance covers the remainder)
### Discount calculation
In `Cart.discounted_shop_totals_in_cents`, after coupon discounts:
```python
# After coupon discounts are applied...
for gift_card in self.gift_cards:
shop_uuid = gift_card.shop_uuid_str
if shop_uuid in self._discounted_shop_totals_in_cents:
current = self._discounted_shop_totals_in_cents[shop_uuid]
deduction = min(gift_card.balance_in_cents, current)
self._discounted_shop_totals_in_cents[shop_uuid] = current - deduction
# Store deduction amount for checkout to record transaction
gift_card._pending_deduction = deduction
```
### Checkout: balance deduction
After successful payment (or if `requires_payment` is False because gift card
covered the full amount):
1. For each attached gift card, deduct `_pending_deduction` from `balance_in_cents`
2. Create a `MpsGiftCardTransaction` record
3. Detach gift card from cart
### Validation
`cart.validate_attached_gift_cards()` checks:
- Gift card is not disabled
- Gift card has balance > 0
- Gift card belongs to a shop in the cart
### Edge cases
- **Gift card covers full amount**: `requires_payment` returns False (total <= 64 cents
after gift card). Checkout proceeds without charging a card, same as a 100% coupon.
- **Gift card + coupon stacking**: Allowed. Coupon applies first (percentage or
dollar off), then gift card balance covers the remaining amount.
- **Multiple gift cards**: A buyer can apply one gift card per shop in the cart
(same constraint as coupons — one per shop keeps it simple).
## Files Changed
| File | Change |
|------|--------|
| `models/cart_gift_card.py` | New: MpsCartGiftCard association model |
| `models/cart.py` | Gift card association proxy, discount integration, validation |
| `views/cart.py` | Gift card code apply/remove handlers |
| `templates/cart.j2` | Gift card code input field, balance display |
| `templates/checkout.j2` | Show gift card discount in order summary |
| `views/checkout.py` | Deduct balance, create transactions on successful checkout |
## Depends On
MPS-10 (models), MPS-11 (gift card records exist to redeem).

74
docs/tickets/mps-13.md Normal file
View file

@ -0,0 +1,74 @@
# MPS-13: Gift Card System — Shop Admin & Settings
## Problem
Shop owners need to enable/configure gift cards and view issued cards with
their balances and transaction history.
## Solution
### Shop settings: gift card configuration
New `gift-card-settings` form section in shop settings:
- **Enable Gift Cards** toggle (`gift_card_enabled`)
- **Minimum Amount** input (`gift_card_min_in_cents`, displayed as dollars)
- **Maximum Amount** input (`gift_card_max_in_cents`, displayed as dollars)
Validation:
- Min must be >= $1.00 (100 cents)
- Max must be >= min
- Max must be <= $10,000.00 (1000000 cents) — reasonable upper bound
### Gift card management page
New route: `/shop/{slug}/gift-cards/manage` (shop owner only)
Displays a table of all issued gift cards:
| Code | Amount | Balance | Purchaser | Recipient | Date | Status |
|------|--------|---------|-----------|-----------|------|--------|
Features:
- Sort by date (newest first)
- Show active vs fully redeemed vs disabled
- Click a card to see its transaction history
- Disable/enable toggle per card (admin kill switch)
### Gift card detail view
`/shop/{slug}/gift-cards/{card_id}` (shop owner only)
Shows:
- Card details (code, amounts, emails)
- Transaction history table (date, invoice, amount deducted, remaining balance)
- Disable toggle
### Buyer's gift card view
Buyers who purchase gift cards can see their purchased cards and remaining
balances on their account page. Recipients (who redeem codes) can check balance
by entering the code on the shop's gift card page.
### Balance check
On the gift card purchase page (`/shop/{slug}/gift-card`), add a "Check Balance"
section where anyone can enter a code and see the remaining balance. No account
required — the code IS the identity.
## Files Changed
| File | Change |
|------|--------|
| `views/shop.py` | `gift-card-settings` form handler |
| `views/gift_card.py` | Management page, detail view, balance check |
| `templates/shop_settings.j2` | Gift card settings form section |
| `templates/gift_card_manage.j2` | New: gift card list for shop owner |
| `templates/gift_card_detail.j2` | New: single card detail + transactions |
| `templates/gift_card.j2` | Add balance check section |
| `routes.py` | Add management + detail routes |
| `tests/test_functional.py` | Settings save, gift card CRUD, balance check |
## Depends On
MPS-10 (models), MPS-11 (purchase flow creates cards to manage).

128
docs/tickets/mps-14.md Normal file
View file

@ -0,0 +1,128 @@
# MPS-14: Shop Environment — Dev & Stage Shops
## Summary
Add an `environment` column to Shop so owners can create development and staging
shops for practicing thumbnails, videos, product staging, and testing checkout
flows. Non-production shops are fully independent (no sync to production) and
invisible to the public.
Every paid production shop seat includes 2 free dev/stage shops.
## Model Changes
### Shop model (`models/shop.py`)
Add column:
```python
environment = Column(BigInteger, default=0)
# 0 = production (default)
# 1 = staging
# 2 = development
```
Add properties:
```python
@property
def is_production(self):
return self.environment == 0
@property
def is_staging(self):
return self.environment == 1
@property
def is_development(self):
return self.environment == 2
@property
def is_non_production(self):
return self.environment != 0
@property
def environment_label(self):
return {0: "Production", 1: "Staging", 2: "Development"}.get(self.environment, "Production")
```
### Migration
- Add `environment` column to `mps_shop` (BigInteger, server_default="0", NOT NULL)
- Idempotent guard with `_column_exists`
## Exclusion Points
Non-production shops (environment != 0) must be excluded from:
1. **Search results**`views/shop.py:129` `search()` — filter query to `shop.environment == 0`
2. **Discovery ring**`models/shop.py:656` `_build_discovery_ring()` — already scoped to shop's own products, but ring should not be reforged for non-production shops
3. **RSS/Atom/Sitemap**`views/feeds.py:204,222,242` — skip non-production shops entirely (return empty feed or 404)
4. **Subscription digests**`models/shop_subscription.py` query helpers — filter by `shop.environment == 0`
5. **Public shop listings** — any place shops are listed publicly
Non-production shops still fully function for the owner: product upload, cart,
checkout, settings, analytics — all work normally.
## Banner
Display a persistent environment banner for non-production shops, similar to
the ribbon pattern. In `base.j2` or `snippets/ribbon.j2`:
```html
{% if request.shop and request.shop.is_non_production %}
<div class="environment-banner environment-{{ request.shop.environment_label|lower }}">
{{ request.shop.environment_label }} Shop
</div>
{% endif %}
```
CSS in `common.css`:
- Staging banner: amber/yellow background
- Development banner: blue/purple background
- Always visible, not dismissible
## Settings UI
Add environment selector to shop settings. New form section `environment-settings`
or add to existing `shop-settings` section.
Radio buttons or select:
- Production (default)
- Staging
- Development
Changing from non-production to production should warn: "This shop will become
publicly visible."
## Shop Creation Flow
On `/s/new`, add an optional environment selector (default: production).
This lets users create dev/stage shops directly during onboarding.
## Enforcement: 2 Free Dev/Stage Per Production Shop
Each paid production shop seat entitles the user to 2 free non-production shops.
### Counting logic
```python
def non_production_shop_allowance(user):
production_count = sum(1 for s in user.shops if s.is_production)
allowed_non_production = production_count * 2
current_non_production = sum(1 for s in user.shops if s.is_non_production)
return allowed_non_production - current_non_production
```
### Enforcement points
- **Shop creation** (`views/shop.py` POST `/s/new`): if environment != 0 and
allowance <= 0, flash error and reject
- **Environment change** (`views/shop.py` settings POST): if changing to
non-production and allowance <= 0, reject; if changing to production, always allow
## Tests
- Unit: `test_models.py` — environment properties, environment_label
- Integration: `test_integration.py` — non-production shop excluded from discovery ring query, allowance counting
- Functional: `test_functional.py` — create dev shop, verify search excludes it, verify banner appears, verify allowance enforcement

142
docs/tickets/mps-15.md Normal file
View file

@ -0,0 +1,142 @@
# MPS-15: 21-Day Free Trial
## Summary
New shops get a 21-day free trial. Trial includes 1 shop, 1 seat, all features.
After 21 days the shop enters a grace period, then becomes read-only until a
plan is chosen.
## Model Changes
### Shop model (`models/shop.py`)
Add columns:
```python
trial_started_timestamp = Column(BigInteger, nullable=True)
trial_ended = Column(Boolean, default=False)
plan_active = Column(Boolean, default=False)
```
- `trial_started_timestamp`: set to current time (ms) on shop creation
- `trial_ended`: flipped to True when trial expires (background check or request-time check)
- `plan_active`: True when a paid plan is active (billing integration, future ticket)
### Migration
- Add 3 columns to `mps_shop` with idempotent guards
- `trial_started_timestamp` nullable (existing shops get NULL = pre-trial era, treated as paid)
- `trial_ended` server_default="0"
- `plan_active` server_default="0"
### Properties
```python
TRIAL_DURATION_MS = 21 * 24 * 60 * 60 * 1000 # 21 days in milliseconds
@property
def trial_expiry_timestamp(self):
if self.trial_started_timestamp is None:
return None
return self.trial_started_timestamp + self.TRIAL_DURATION_MS
@property
def is_trial_active(self):
if self.plan_active:
return False # paid plan supersedes trial
if self.trial_started_timestamp is None:
return False # pre-trial shop (existing shops)
now = int(time.time() * 1000)
return now < self.trial_expiry_timestamp
@property
def is_trial_expired(self):
if self.plan_active or self.trial_started_timestamp is None:
return False
now = int(time.time() * 1000)
return now >= self.trial_expiry_timestamp
@property
def trial_days_remaining(self):
if not self.is_trial_active:
return 0
remaining_ms = self.trial_expiry_timestamp - int(time.time() * 1000)
return max(0, remaining_ms // (24 * 60 * 60 * 1000))
@property
def is_active(self):
"""Shop can operate: either paid plan or active trial."""
return self.plan_active or self.is_trial_active
```
## Trial Enforcement
### What trial shops CAN do (all features)
- Create products, upload files, set prices
- Accept payments (all 5 methods)
- Use watch mode, analytics, subscriptions, gift cards
- Create 2 dev/stage shops (per MPS-14)
- Full settings access
### What happens when trial expires
- Shop becomes **read-only**: products visible, downloads work for existing purchases
- New purchases blocked (checkout disabled)
- Product creation/editing disabled
- Settings page shows "Trial expired — choose a plan to continue"
- Flash message on every page: "Your 21-day trial has expired. Choose a plan to keep selling."
### Enforcement points
Request-time check (middleware or request method):
```python
def shop_trial_check(request):
shop = request.shop
if shop and shop.is_trial_expired and not shop.plan_active:
# Allow read-only routes, block write routes
...
```
Write routes to block when trial expired:
- Product create/edit/delete
- Checkout completion (all 3 paths: Stripe, PayPal, Adyen)
- Gift card purchase
- Settings changes (except choosing a plan)
Read routes to allow:
- Product view, shop view, search
- Cart view (but not checkout)
- Settings view (read-only, plan selection enabled)
- Download (for existing purchases)
## Trial Banner
In `base.j2`, show trial status for shop owners:
```html
{% if request.shop and request.shop.is_trial_active and request.user in request.shop.owners %}
<div class="trial-banner">
Free trial: {{ request.shop.trial_days_remaining }} days remaining.
<a href="/s/{{ request.shop.id }}/settings#plan">Choose a plan</a>
</div>
{% endif %}
{% if request.shop and request.shop.is_trial_expired and not request.shop.plan_active %}
<div class="trial-banner trial-expired">
Your 21-day trial has expired.
<a href="/s/{{ request.shop.id }}/settings#plan">Choose a plan to keep selling</a>
</div>
{% endif %}
```
## Shop Creation Changes
In `views/shop.py` `shop_new()`:
- Set `shop.trial_started_timestamp = int(time.time() * 1000)` on creation
- Existing shops (NULL timestamp) are grandfathered as paid
## Tests
- Unit: trial properties (is_trial_active, is_trial_expired, trial_days_remaining, is_active)
- Integration: trial shop with real DB, verify expiry behavior
- Functional: create shop, verify trial banner, verify trial countdown

206
docs/tickets/mps-16.md Normal file
View file

@ -0,0 +1,206 @@
# MPS-16: Bring Your Own Bucket (BYOB) — Primary S3 Per Shop
## Summary
Allow shops to use their own S3-compatible bucket as the **primary** storage
for all media (products, thumbnails, previews, karaoke tracks). Files are
uploaded directly to the shop's bucket — MPS never stores them on the
platform bucket.
Free trial users must bring their own bucket during onboarding (zero storage
cost for MPS during trial). Paid plan users can use BYOB or the MPS bucket.
## Current Architecture
- All uploads go to MPS DigitalOcean Spaces bucket (global, configured in INI)
- `request.secure_uploads_client` is a single global boto3 client
- Presigned URLs always use `request.app["bucket.secure_uploads.get_endpoint"]`
- Mirror S3 (`shop.mirror_s3_*`) is an async secondary copy
## Target Architecture
- Each shop can optionally specify a **primary** S3 bucket
- If configured, all uploads, presigned URLs, and thumbnail CDN URLs use the shop's bucket
- MPS bucket is never touched for BYOB shops
- Mirror S3 continues to work as a secondary copy (shop can mirror from their primary to another bucket)
## Model Changes
### Shop model (`models/shop.py`)
Reuse existing `mirror_s3_*` columns but add a new flag to indicate primary vs mirror:
```python
primary_s3_enabled = Column(Boolean, default=False)
# When True: mirror_s3_* columns are used as the PRIMARY bucket
# When False: mirror_s3_* columns are used as mirror (current behavior)
```
Or add separate columns for clarity:
```python
primary_s3_endpoint = Column(Unicode(256), nullable=True)
primary_s3_region = Column(Unicode(64), nullable=True)
primary_s3_bucket = Column(Unicode(128), nullable=True)
primary_s3_access_key = Column(Unicode(128), nullable=True)
primary_s3_secret_key = Column(Unicode(128), nullable=True)
primary_s3_cdn_endpoint = Column(Unicode(256), nullable=True) # public CDN URL for thumbnails
primary_s3_enabled = Column(Boolean, default=False)
```
The CDN endpoint is critical — thumbnails and previews use public CDN URLs,
not presigned URLs. The shop owner must configure their bucket's CDN endpoint
(e.g., `https://mybucket.nyc3.cdn.digitaloceanspaces.com`).
### Properties
```python
@property
def has_primary_s3(self):
return bool(
self.primary_s3_enabled
and self.primary_s3_endpoint
and self.primary_s3_bucket
and self.primary_s3_access_key
and self.primary_s3_secret_key
and self.primary_s3_cdn_endpoint
)
@property
def media_cdn_endpoint(self):
"""Return the CDN endpoint for this shop's media."""
if self.has_primary_s3:
return self.primary_s3_cdn_endpoint
return None # caller falls back to request.app default
```
## Request Method Changes
### `request_methods.py`
Add a shop-aware S3 client factory:
```python
def add_shop_uploads_client(request):
"""Return S3 client for the current shop (BYOB or MPS default)."""
shop = request.shop
if shop and shop.has_primary_s3:
import boto3
session = boto3.session.Session()
return session.client(
"s3",
region_name=shop.primary_s3_region,
endpoint_url=shop.primary_s3_endpoint,
aws_access_key_id=shop.primary_s3_access_key,
aws_secret_access_key=shop.primary_s3_secret_key,
)
return request.secure_uploads_client # default MPS bucket
```
Add `request.shop_uploads_client` as a reified request method.
### Bucket name resolution
```python
def get_shop_bucket_name(request):
shop = request.shop
if shop and shop.has_primary_s3:
return shop.primary_s3_bucket
return request.app["bucket.secure_uploads"]
```
Add `request.shop_bucket_name` as a reified request method.
## View Changes
### `views/product.py`
All S3 operations must use `request.shop_uploads_client` and
`request.shop_bucket_name` instead of `request.secure_uploads_client` and
`request.app["bucket.secure_uploads"]`:
- **Presigned GET** (downloads, line 45-83): use shop client + shop bucket
- **Presigned POST** (uploads, line 429-456): use shop client + shop bucket
- **Copy object** (line 344): use shop client + shop bucket
- **Delete object**: use shop client + shop bucket
### Template changes
All thumbnail/media URLs must resolve through the shop's CDN endpoint:
```jinja2
{# Before #}
{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1
{# After #}
{{ product.shop.media_cdn_endpoint or request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1
```
Affected templates:
- `product.j2` (lines 13, 43, 75, 97, 112, 131-132)
- `cart.j2` (line 124)
- `shop.j2` (thumbnail rendering)
- `content.j2`
- `snippets/related_content.j2`
### `views/watch.py`
Watch mode JSON endpoint (line 96-99) must use shop CDN endpoint:
```python
cdn = shop.media_cdn_endpoint or request.app["bucket.secure_uploads.get_endpoint"]
thumbnail_url = f"{cdn}/{product.s3_path}/thumbnail1?ts={product.updated_timestamp}"
```
### `lib/karaoke.py`
Karaoke downloads/uploads must use shop client + shop bucket.
### `lib/s3_mirror.py`
When primary_s3 is enabled, mirror source becomes the shop's bucket (not MPS).
Mirror destination is still the mirror_s3_* config.
## Settings UI
### New form section: `bucket-settings`
In `shop_settings.j2`, add a "Storage" or "Media Bucket" section:
- Endpoint URL (text input)
- Region (text input)
- Bucket name (text input)
- Access key (text input)
- Secret key (password input)
- CDN endpoint (text input, with help text: "Public URL for thumbnails")
- Enable checkbox
- Test connection button (reuse `test_mirror_connection` pattern)
### Validation
- Endpoint must start with `https://`
- All 6 fields required if any provided
- Connection test: list bucket, attempt a test PUT/GET/DELETE cycle
- CDN endpoint must be reachable (optional HEAD request)
## Onboarding for Trial Users
During shop creation (`/s/new`), after the shop is created and the user is
redirected to `/s/{shop_id}/settings`:
- If trial user (no paid plan), show a prominent "Set Up Storage" step
- Guide them through configuring their S3 bucket
- Trial shops cannot upload files until BYOB is configured
- Provide documentation links for DigitalOcean Spaces, AWS S3, Backblaze B2, MinIO
## Migration
- Add `primary_s3_*` columns (6 columns) to `mps_shop`
- All nullable, `primary_s3_enabled` server_default="0"
- Idempotent guards
## Tests
- Unit: has_primary_s3 property, media_cdn_endpoint property
- Integration: shop with BYOB config, verify client resolution
- Functional: enable BYOB via settings, verify connection test, verify upload uses shop bucket

View file

@ -1 +1 @@
666ca41
e2169ec

View file

@ -122,6 +122,7 @@ def run(env, frequency_str, dry_run=False):
shops = (
dbsession.query(Shop)
.filter(Shop.subscriptions_enabled == True)
.filter(Shop.environment == 0) # MPS-14: exclude non-production shops
.all()
)

View file

@ -362,6 +362,15 @@ def backfill_karaoke_async(shop_id, session_factory, app_settings):
session = SASession(bind=engine)
def _make_s3():
# BYOB: use shop's own bucket if configured (MPS-16)
if shop and shop.has_primary_s3:
return boto3.session.Session().client(
"s3",
region_name=shop.primary_s3_region,
endpoint_url=shop.primary_s3_endpoint,
aws_access_key_id=shop.primary_s3_access_key,
aws_secret_access_key=shop.primary_s3_secret_key,
)
return boto3.session.Session().client(
"s3",
region_name=app_settings["bucket.secure_uploads.region"],
@ -370,14 +379,15 @@ def backfill_karaoke_async(shop_id, session_factory, app_settings):
aws_secret_access_key=app_settings["bucket.secure_uploads.secret_key"],
)
s3 = _make_s3()
bucket = app_settings["bucket.secure_uploads"]
try:
# Must load shop before _make_s3 can check has_primary_s3
shop = session.get(Shop, shop_id)
if not shop or not shop.unsandbox_public_key or not shop.unsandbox_secret_key:
return
s3 = _make_s3()
bucket = shop.primary_s3_bucket if shop.has_primary_s3 else app_settings["bucket.secure_uploads"]
pk, sk = shop.unsandbox_public_key, shop.unsandbox_secret_key
# Query account concurrency from unsandbox API — abort if keys are bad

View file

@ -200,7 +200,7 @@ def send_purchase_email(request, to_email, products, total_cost):
thumbnail = ""
if "thumbnail1" in p.extensions:
thumbnail = '<img src="{}/{}/thumbnail1?ts={}" style="border: 1px solid #ddd; border-radius: 4px; max-width: 184px; max-height: 184px; width: auto; height: auto;" />'.format(
request.app["bucket.secure_uploads.get_endpoint"],
request.shop_cdn_endpoint,
p.s3_path,
p.updated_timestamp,
)
@ -239,7 +239,7 @@ def send_sale_email(request, shop, products, total_cost):
thumbnail = ""
if "thumbnail1" in p.extensions:
thumbnail = '<img src="{}/{}/thumbnail1?ts={}" style="border: 1px solid #ddd; border-radius: 4px; max-width: 184px; max-height: 184px; width: auto; height: auto;" />'.format(
request.app["bucket.secure_uploads.get_endpoint"],
request.shop_cdn_endpoint,
p.s3_path,
p.updated_timestamp,
)
@ -531,3 +531,50 @@ def send_invite_email(request, to_email, user, shop):
message_text = INVITE_1_TEXT.format(user.email, shop.name, join_link)
message_html = INVITE_1_HTML.format(subject, user.email, shop.name, join_link)
send_pyramid_email(request, to_email, subject, message_text, message_html)
def send_gift_card_email(request, gift_card):
"""Send gift card code to the recipient email."""
from ..lib.currency import cents_to_dollars
to_email = gift_card.gift_email
shop_name = gift_card.shop.name
amount = f"${cents_to_dollars(gift_card.initial_amount_in_cents):,.2f}"
code = gift_card.code
shop_url = gift_card.shop.absolute_url(request)
gift_message = gift_card.gift_message or ""
subject = f"You received a {amount} gift card for {shop_name}"
message_parts = [
f"You received a {amount} gift card for {shop_name}!",
f"",
f"Your gift card code: {code}",
f"",
]
if gift_message:
message_parts.append(f"Message: {gift_message}")
message_parts.append("")
message_parts.extend([
f"To redeem, enter the code at checkout when shopping at {shop_name}.",
f"",
f"Visit: {shop_url}",
f"",
f"This gift card never expires.",
])
message_text = "\n".join(message_parts)
html_parts = [
f"<h2>You received a {amount} gift card for {shop_name}!</h2>",
f"<p><strong>Your gift card code:</strong></p>",
f"<p style='font-size: 24px; font-family: monospace; background: #f0f0f0; padding: 12px; display: inline-block;'>{code}</p>",
]
if gift_message:
html_parts.append(f"<p><em>{gift_message}</em></p>")
html_parts.extend([
f"<p>To redeem, enter the code at checkout when shopping at <a href='{shop_url}'>{shop_name}</a>.</p>",
f"<p><small>This gift card never expires.</small></p>",
])
message_html = "\n".join(html_parts)
send_pyramid_email(request, to_email, subject, message_text, message_html)

View file

@ -229,7 +229,16 @@ def backfill_mirror_async(shop_id, session_factory, app_settings):
engine = create_engine(db_url)
session = SASession(bind=engine)
def _make_src():
def _make_src(shop):
# BYOB: if shop has its own primary bucket, mirror from there (MPS-16)
if shop and shop.has_primary_s3:
return boto3.session.Session().client(
"s3",
region_name=shop.primary_s3_region,
endpoint_url=shop.primary_s3_endpoint,
aws_access_key_id=shop.primary_s3_access_key,
aws_secret_access_key=shop.primary_s3_secret_key,
)
return boto3.session.Session().client(
"s3",
region_name=app_settings["bucket.secure_uploads.region"],
@ -243,14 +252,14 @@ def backfill_mirror_async(shop_id, session_factory, app_settings):
if not shop or not shop.has_s3_mirror:
return
src = _make_src()
src = _make_src(shop)
dst = _make_mirror_client(
shop.mirror_s3_endpoint,
shop.mirror_s3_region,
shop.mirror_s3_access_key,
shop.mirror_s3_secret_key,
)
src_bucket = app_settings["bucket.secure_uploads"]
src_bucket = shop.primary_s3_bucket if shop.has_primary_s3 else app_settings["bucket.secure_uploads"]
dst_bucket = shop.mirror_s3_bucket
# List all objects under the shop's prefix

View file

@ -32,6 +32,10 @@ from .comment import *
from .shop_subscription import *
from .page_session import *
from .gift_card import *
from .gift_card_transaction import *
from .cart_gift_card import *
# run configure_mappers after defining all of the models
# to ensure all relationships can be setup.
configure_mappers()

View file

@ -22,6 +22,7 @@ from .product import get_products_by_ids
from .shop import get_shops_by_ids
from .cart_coupon import CartCoupon
from .cart_gift_card import CartGiftCard
from .inventory import get_inventory_by_product_and_shop_location
@ -47,6 +48,9 @@ class Cart(RBase, Base):
handling_option = Column(Unicode(64), nullable=True)
handling_cost_in_cents = Column(BigInteger, nullable=True, default=0)
# Gift card purchase items (variable-priced, not regular products)
json_gift_cards = Column(UnicodeText, default=unicode("[]"))
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
@ -57,6 +61,11 @@ class Cart(RBase, Base):
"cart_coupons", "coupon", creator=lambda c: CartCoupon(coupon=c)
)
# many to many uses association_proxy.
gift_cards = association_proxy(
"cart_gift_cards", "gift_card", creator=lambda gc: CartGiftCard(gift_card=gc)
)
user = relationship(argument="User", uselist=False, lazy="joined")
shop = relationship(argument="Shop", uselist=False, lazy="joined")
@ -67,6 +76,7 @@ class Cart(RBase, Base):
self.id = uuid.uuid4()
self.user = user
self.json_cart = unicode("{}")
self.json_gift_cards = unicode("[]")
self.created_timestamp = now_timestamp()
self.updated_timestamp = now_timestamp()
@ -93,6 +103,10 @@ class Cart(RBase, Base):
del self._discounted_shop_totals
if hasattr(self, "_line_totals"):
del self._line_totals
if hasattr(self, "_gift_card_deductions"):
del self._gift_card_deductions
if hasattr(self, "_gift_card_purchases"):
del self._gift_card_purchases
def set_cart(self, cart_dict):
"""Save cart_dict as JSON into json_cart."""
@ -155,10 +169,22 @@ class Cart(RBase, Base):
# this busts memoization.
self.cart = tmp_cart
@property
def gift_card_purchases(self):
"""Return list of gift card purchase items from json_gift_cards."""
if not hasattr(self, "_gift_card_purchases"):
self._gift_card_purchases = json.loads(self.json_gift_cards or "[]")
return self._gift_card_purchases
@property
def gift_card_purchases_total_in_cents(self):
"""Total cost of gift card purchases in this cart."""
return sum(item["amount_in_cents"] for item in self.gift_card_purchases)
@property
def count(self):
if hasattr(self, "_count") == False:
self._count = sum(self.cart.values())
self._count = sum(self.cart.values()) + len(self.gift_card_purchases)
return self._count
@property
@ -267,7 +293,7 @@ class Cart(RBase, Base):
@property
def discounted_shop_totals_in_cents(self):
"""Discounted shop totals in cents after applying coupons."""
"""Discounted shop totals in cents after applying coupons and gift cards."""
if hasattr(self, "_discounted_shop_totals_in_cents") == False:
from copy import deepcopy
@ -282,8 +308,27 @@ class Cart(RBase, Base):
self._discounted_shop_totals_in_cents[shop_uuid] = (
coupon.compute_discount(shop_total_in_cents)
)
# Apply gift card balances after coupons
self._gift_card_deductions = {}
if len(self.gift_cards) > 0:
for gift_card in self.gift_cards:
shop_uuid = gift_card.shop_uuid_str
if shop_uuid in self._discounted_shop_totals_in_cents:
current = self._discounted_shop_totals_in_cents[shop_uuid]
deduction = min(gift_card.balance_in_cents, current)
self._discounted_shop_totals_in_cents[shop_uuid] = current - deduction
self._gift_card_deductions[gift_card.uuid_str] = deduction
return self._discounted_shop_totals_in_cents
@property
def gift_card_deductions(self):
"""Dict of gift_card_uuid_str -> deduction amount in cents.
Populated as a side effect of discounted_shop_totals_in_cents."""
# Ensure discounted totals are computed first
_ = self.discounted_shop_totals_in_cents
return getattr(self, "_gift_card_deductions", {})
@property
def discounted_shop_totals(self):
if hasattr(self, "_discounted_shop_totals") == False:
@ -300,9 +345,10 @@ class Cart(RBase, Base):
@property
def total_price_in_cents(self):
"""
Calculate the total price in cents, including handling cost if applicable.
Calculate the total price in cents, including handling cost and gift card purchases.
"""
total = sum(self.line_totals_in_cents.values())
total += self.gift_card_purchases_total_in_cents
if self.handling_cost_in_cents:
total += self.handling_cost_in_cents
return total
@ -317,9 +363,10 @@ class Cart(RBase, Base):
@property
def total_discounted_price_in_cents(self):
"""
Calculate the total discounted price in cents, including handling cost if applicable.
Calculate the total discounted price in cents, including handling cost and gift card purchases.
"""
total = sum(self.discounted_shop_totals_in_cents.values())
total += self.gift_card_purchases_total_in_cents
if self.handling_cost_in_cents:
total += self.handling_cost_in_cents
return total
@ -418,6 +465,25 @@ class Cart(RBase, Base):
)
return error_messages
def validate_attached_gift_cards(self):
"""Make sure all attached gift cards are valid for this cart."""
error_messages = []
if self.gift_cards:
for gift_card in self.gift_cards:
if gift_card.disabled:
error_messages.append(
f"Gift card '{gift_card.code}' has been disabled."
)
if gift_card.balance_in_cents <= 0:
error_messages.append(
f"Gift card '{gift_card.code}' has no remaining balance."
)
if gift_card.shop_uuid_str not in self.shop_totals_in_cents:
error_messages.append(
f"Gift card '{gift_card.code}' is not valid for any shop in your cart."
)
return error_messages
def check_inventory(self, shop_location):
"""
Check if the shop location has enough quantity for each physical product in the cart.

View file

@ -0,0 +1,34 @@
import uuid
from sqlalchemy import Column, BigInteger
from sqlalchemy.orm import relationship, backref
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
class CartGiftCard(RBase, Base):
"""
Many to many, Carts to GiftCards.
A relationship signifies the application of a gift card to a cart.
"""
id = Column(UUIDType, primary_key=True, index=True)
cart_id = Column(UUIDType, foreign_key("Cart", "id"), nullable=False)
gift_card_id = Column(UUIDType, foreign_key("GiftCard", "id"), nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
cart = relationship(
argument="Cart",
backref=backref("cart_gift_cards", cascade="all, delete-orphan"),
)
gift_card = relationship(
argument="GiftCard",
backref=backref("gift_card_carts", cascade="all, delete-orphan"),
)
def __init__(self, cart=None, gift_card=None):
self.id = uuid.uuid1()
self.cart = cart
self.gift_card = gift_card
self.created_timestamp = now_timestamp()

View file

@ -0,0 +1,108 @@
import secrets
import uuid
from sqlalchemy import Column, BigInteger, Boolean, Unicode
from sqlalchemy.orm import relationship
from .meta import (
Base,
RBase,
UUIDType,
foreign_key,
now_timestamp,
get_object_by_id,
)
from ..lib.currency import cents_to_dollars
class GiftCard(RBase, Base):
"""
A gift card is scoped to a single shop. The code IS the value
no account required to redeem. Balance decrements across purchases.
Gift cards never expire (permacomputer rules).
"""
id = Column(UUIDType, primary_key=True, index=True)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
code = Column(Unicode(64), nullable=False, unique=True, index=True)
initial_amount_in_cents = Column(BigInteger, nullable=False)
balance_in_cents = Column(BigInteger, nullable=False)
purchaser_email = Column(Unicode(256), nullable=True)
gift_email = Column(Unicode(256), nullable=True)
gift_message = Column(Unicode(512), nullable=True)
invoice_id = Column(UUIDType, foreign_key("Invoice", "id"), nullable=True)
created_timestamp = Column(BigInteger, nullable=False)
disabled = Column(Boolean, default=False, nullable=False)
shop = relationship(argument="Shop", uselist=False, lazy="joined")
invoice = relationship(argument="Invoice", uselist=False, lazy="joined")
transactions = relationship(
argument="GiftCardTransaction",
lazy="dynamic",
back_populates="gift_card",
)
def __init__(self, shop, amount_in_cents, purchaser_email=None,
gift_email=None, gift_message=None, invoice=None):
self.id = uuid.uuid1()
self.shop = shop
self.code = generate_gift_card_code()
self.initial_amount_in_cents = amount_in_cents
self.balance_in_cents = amount_in_cents
self.purchaser_email = purchaser_email
self.gift_email = gift_email
self.gift_message = gift_message
self.invoice = invoice
self.created_timestamp = now_timestamp()
@property
def is_valid(self):
return not self.disabled and self.balance_in_cents > 0
@property
def balance(self):
return cents_to_dollars(self.balance_in_cents)
@property
def initial_amount(self):
return cents_to_dollars(self.initial_amount_in_cents)
@property
def shop_uuid_str(self):
return self.id_to_uuid_str(self.shop_id)
@property
def is_fully_redeemed(self):
return self.balance_in_cents <= 0
def deduct(self, amount_in_cents):
"""Deduct amount from balance. Returns actual amount deducted."""
deduction = min(amount_in_cents, self.balance_in_cents)
self.balance_in_cents -= deduction
return deduction
def generate_gift_card_code():
"""Generate a unique gift card code: GC- prefix + 16 hex chars uppercase."""
return "GC-" + secrets.token_hex(8).upper()
def get_gift_card_by_id(dbsession, gift_card_id):
return get_object_by_id(dbsession, gift_card_id, GiftCard)
def get_gift_card_by_code(dbsession, code, shop=None):
query = dbsession.query(GiftCard).filter(GiftCard.code == code.strip().upper())
if shop is not None:
query = query.filter(GiftCard.shop_id == shop.id)
return query.one_or_none()
def get_gift_cards_by_shop(dbsession, shop):
return (
dbsession.query(GiftCard)
.filter(GiftCard.shop_id == shop.id)
.order_by(GiftCard.created_timestamp.desc())
)

View file

@ -0,0 +1,46 @@
import uuid
from sqlalchemy import Column, BigInteger
from sqlalchemy.orm import relationship
from .meta import (
Base,
RBase,
UUIDType,
foreign_key,
now_timestamp,
get_object_by_id,
)
from ..lib.currency import cents_to_dollars
class GiftCardTransaction(RBase, Base):
"""Tracks each time a gift card balance is used at checkout."""
id = Column(UUIDType, primary_key=True, index=True)
gift_card_id = Column(UUIDType, foreign_key("GiftCard", "id"), nullable=False)
invoice_id = Column(UUIDType, foreign_key("Invoice", "id"), nullable=False)
amount_in_cents = Column(BigInteger, nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
gift_card = relationship(
argument="GiftCard", uselist=False, lazy="joined",
back_populates="transactions",
)
invoice = relationship(argument="Invoice", uselist=False, lazy="joined")
def __init__(self, gift_card, invoice, amount_in_cents):
self.id = uuid.uuid1()
self.gift_card = gift_card
self.invoice = invoice
self.amount_in_cents = amount_in_cents
self.created_timestamp = now_timestamp()
@property
def amount(self):
return cents_to_dollars(self.amount_in_cents)
def get_gift_card_transaction_by_id(dbsession, transaction_id):
return get_object_by_id(dbsession, transaction_id, GiftCardTransaction)

View file

@ -43,6 +43,9 @@ CLASS_TO_TABLE = {
"UserCryptoRefundAddress": "mps_user_crypto_refund_address",
"ShopSubscription": "mps_shop_subscription",
"PageSession": "mps_page_session",
"GiftCard": "mps_gift_card",
"GiftCardTransaction": "mps_gift_card_transaction",
"CartGiftCard": "mps_cart_gift_card",
}

View file

@ -677,9 +677,12 @@ def get_products_by_keywords(dbsession, keywords, shop=None):
for keyword in keywords:
keyword_filter = Product.title.ilike(f"%{keyword}%")
# the product _must_ be public (1).
products = (
product_query.filter(keyword_filter).filter(Product.visibility == 1).all()
)
query = product_query.filter(keyword_filter).filter(Product.visibility == 1)
# Exclude non-production shops from search results (MPS-14)
if not shop:
from .shop import Shop
query = query.join(Shop, Product.shop_id == Shop.id).filter(Shop.environment == 0)
products = query.all()
for product in products:
if product.id not in scores:

View file

@ -161,6 +161,28 @@ class Shop(RBase, Base):
# Sandbox mode: floating creative filter toolbar for visitors
sandbox_mode = Column(Boolean, default=False)
# Gift card settings
gift_card_enabled = Column(Boolean, default=False)
gift_card_min_in_cents = Column(BigInteger, nullable=False, default=500)
gift_card_max_in_cents = Column(BigInteger, nullable=False, default=25000)
# Shop environment: 0=production, 1=staging, 2=development
environment = Column(BigInteger, nullable=False, default=0)
# Trial and billing
trial_started_timestamp = Column(BigInteger, nullable=True)
trial_ended = Column(Boolean, default=False)
plan_active = Column(Boolean, default=False)
# Primary S3 bucket (Bring Your Own Bucket)
primary_s3_endpoint = Column(Unicode(256), nullable=True)
primary_s3_region = Column(Unicode(64), nullable=True)
primary_s3_bucket = Column(Unicode(128), nullable=True)
primary_s3_access_key = Column(Unicode(128), nullable=True)
primary_s3_secret_key = Column(Unicode(128), nullable=True)
primary_s3_cdn_endpoint = Column(Unicode(256), nullable=True)
primary_s3_enabled = Column(Boolean, default=False)
# Precomputed discovery ring: circular ordering of all public products
json_discovery_ring = Column(UnicodeText, nullable=True)
@ -191,6 +213,14 @@ class Shop(RBase, Base):
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
invoices = relationship("Invoice", back_populates="shop", lazy="dynamic")
# lazy='dynamic' returns a query object instead of collection.
gift_cards = relationship(
argument="GiftCard",
back_populates="shop",
lazy="dynamic",
order_by="desc(GiftCard.created_timestamp)",
)
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
shop_locations = relationship(
@ -235,6 +265,94 @@ class Shop(RBase, Base):
us.role_id = role_id
return us
# --- Environment properties (MPS-14) ---
@property
def is_production(self):
return self.environment == 0
@property
def is_staging(self):
return self.environment == 1
@property
def is_development(self):
return self.environment == 2
@property
def is_non_production(self):
return self.environment != 0
@property
def environment_label(self):
return {0: "Production", 1: "Staging", 2: "Development"}.get(
self.environment, "Production"
)
# --- Trial properties (MPS-15) ---
TRIAL_DURATION_MS = 21 * 24 * 60 * 60 * 1000 # 21 days
@property
def trial_expiry_timestamp(self):
if self.trial_started_timestamp is None:
return None
return self.trial_started_timestamp + self.TRIAL_DURATION_MS
@property
def is_trial_active(self):
if self.plan_active:
return False
if self.trial_started_timestamp is None:
return False
import time
now = int(time.time() * 1000)
return now < self.trial_expiry_timestamp
@property
def is_trial_expired(self):
if self.plan_active or self.trial_started_timestamp is None:
return False
import time
now = int(time.time() * 1000)
return now >= self.trial_expiry_timestamp
@property
def trial_days_remaining(self):
if not self.is_trial_active:
return 0
import time
remaining_ms = self.trial_expiry_timestamp - int(time.time() * 1000)
return max(0, remaining_ms // (24 * 60 * 60 * 1000))
@property
def is_active(self):
"""Shop can operate: either paid plan, active trial, or pre-trial (existing)."""
if self.plan_active:
return True
if self.trial_started_timestamp is None:
return True # grandfathered pre-trial shop
return self.is_trial_active
# --- BYOB properties (MPS-16) ---
@property
def has_primary_s3(self):
return bool(
self.primary_s3_enabled
and self.primary_s3_endpoint
and self.primary_s3_bucket
and self.primary_s3_access_key
and self.primary_s3_secret_key
and self.primary_s3_cdn_endpoint
)
@property
def media_cdn_endpoint(self):
if self.has_primary_s3:
return self.primary_s3_cdn_endpoint
return None
@property
def has_s3_mirror(self):
"""Return True if shop has S3 mirror bucket configured and enabled."""
@ -722,7 +840,14 @@ def compute_discovery_ring(shop):
def reforge_discovery_ring(shop):
"""Compute and store the discovery ring on the shop (synchronous)."""
"""Compute and store the discovery ring on the shop (synchronous).
Non-production shops (MPS-14) get an empty ring they are excluded
from public discovery.
"""
if shop.environment is not None and shop.is_non_production:
shop.discovery_ring = []
return []
ring = compute_discovery_ring(shop)
shop.discovery_ring = ring
return ring

View file

@ -155,6 +155,35 @@ def includeme(config):
aws_secret_access_key=request.app["bucket.secure_uploads.secret_key"],
)
def add_shop_uploads_client(request):
"""Return S3 client for the current shop (BYOB or MPS default)."""
shop = request.shop
if shop and shop.has_primary_s3:
import boto3
session = boto3.session.Session()
return session.client(
"s3",
region_name=shop.primary_s3_region,
endpoint_url=shop.primary_s3_endpoint,
aws_access_key_id=shop.primary_s3_access_key,
aws_secret_access_key=shop.primary_s3_secret_key,
)
return request.secure_uploads_client
def add_shop_bucket_name(request):
"""Return the bucket name for the current shop."""
shop = request.shop
if shop and shop.has_primary_s3:
return shop.primary_s3_bucket
return request.app["bucket.secure_uploads"]
def add_shop_cdn_endpoint(request):
"""Return the CDN endpoint for the current shop's media."""
shop = request.shop
if shop and shop.has_primary_s3:
return shop.primary_s3_cdn_endpoint
return request.app["bucket.secure_uploads.get_endpoint"]
def add_is_shop_domain(request):
"""
Returns True or False.
@ -181,8 +210,8 @@ def includeme(config):
"""
root_domain = request.app.get("make_post_sell.root_domain")
# For development, always treat localhost as SaaS domain for convenience
if request.domain == "localhost":
# For development, always treat localhost/127.0.0.1 as SaaS domain for convenience
if request.domain in ("localhost", "127.0.0.1"):
return True
# Standard SaaS domain check
@ -373,6 +402,11 @@ def includeme(config):
add_secure_uploads_client, "secure_uploads_client", reify=True
)
# BYOB: shop-aware S3 client and bucket
config.add_request_method(add_shop_uploads_client, "shop_uploads_client", reify=True)
config.add_request_method(add_shop_bucket_name, "shop_bucket_name", reify=True)
config.add_request_method(add_shop_cdn_endpoint, "shop_cdn_endpoint", reify=True)
# Payment method checks
config.add_request_method(add_stripe_enabled, "stripe_enabled", reify=True)
config.add_request_method(

View file

@ -106,6 +106,15 @@ def includeme(config):
config.add_route("coupon_apply_to_cart", "/coupon/apply")
config.add_route("coupon_remove_from_cart", "/coupon/remove")
# gift card routes.
config.add_route("gift_card_page", "/s/{shop_id}/gift-card")
config.add_route("gift_card_add_to_cart", "/gift-card/add-to-cart")
config.add_route("gift_card_apply_to_cart", "/gift-card/apply")
config.add_route("gift_card_remove_from_cart", "/gift-card/remove")
config.add_route("gift_card_manage", "/s/{shop_id}/gift-cards/manage")
config.add_route("gift_card_detail", "/s/{shop_id}/gift-cards/{gift_card_id}")
config.add_route("gift_card_toggle", "/s/{shop_id}/gift-cards/{gift_card_id}/toggle")
config.add_route("coupons", "/s/{shop_id}/coupons")
config.add_route("coupon1", "/s/{shop_id}/coupon/{coupon_id}")
config.add_route("coupon2", "/s/{shop_id}/coupon/{coupon_id}/{slug:.*}")

View file

@ -0,0 +1,109 @@
"""add environment trial and primary s3 columns to shop
Revision ID: 9884324a48e3
Revises: f8201a9ba045
Create Date: 2026-03-07 17:49:35.847633
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9884324a48e3'
down_revision = 'f8201a9ba045'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def _column_exists(table, column):
conn = op.get_bind()
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
return any(row[1] == column for row in result.fetchall())
def upgrade():
# MPS-14: Shop environment
if not _column_exists("mps_shop", "environment"):
op.add_column(
"mps_shop",
sa.Column("environment", sa.BigInteger(), nullable=False, server_default="0"),
)
# MPS-15: Trial and billing
if not _column_exists("mps_shop", "trial_started_timestamp"):
op.add_column(
"mps_shop",
sa.Column("trial_started_timestamp", sa.BigInteger(), nullable=True),
)
if not _column_exists("mps_shop", "trial_ended"):
op.add_column(
"mps_shop",
sa.Column("trial_ended", sa.Boolean(), nullable=False, server_default="0"),
)
if not _column_exists("mps_shop", "plan_active"):
op.add_column(
"mps_shop",
sa.Column("plan_active", sa.Boolean(), nullable=False, server_default="0"),
)
# MPS-16: Primary S3 bucket (BYOB)
if not _column_exists("mps_shop", "primary_s3_endpoint"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_endpoint", sa.Unicode(256), nullable=True),
)
if not _column_exists("mps_shop", "primary_s3_region"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_region", sa.Unicode(64), nullable=True),
)
if not _column_exists("mps_shop", "primary_s3_bucket"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_bucket", sa.Unicode(128), nullable=True),
)
if not _column_exists("mps_shop", "primary_s3_access_key"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_access_key", sa.Unicode(128), nullable=True),
)
if not _column_exists("mps_shop", "primary_s3_secret_key"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_secret_key", sa.Unicode(128), nullable=True),
)
if not _column_exists("mps_shop", "primary_s3_cdn_endpoint"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_cdn_endpoint", sa.Unicode(256), nullable=True),
)
if not _column_exists("mps_shop", "primary_s3_enabled"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_enabled", sa.Boolean(), nullable=False, server_default="0"),
)
def downgrade():
op.drop_column("mps_shop", "primary_s3_enabled")
op.drop_column("mps_shop", "primary_s3_cdn_endpoint")
op.drop_column("mps_shop", "primary_s3_secret_key")
op.drop_column("mps_shop", "primary_s3_access_key")
op.drop_column("mps_shop", "primary_s3_bucket")
op.drop_column("mps_shop", "primary_s3_region")
op.drop_column("mps_shop", "primary_s3_endpoint")
op.drop_column("mps_shop", "plan_active")
op.drop_column("mps_shop", "trial_ended")
op.drop_column("mps_shop", "trial_started_timestamp")
op.drop_column("mps_shop", "environment")

View file

@ -0,0 +1,108 @@
"""add gift card tables and shop settings
Revision ID: f8201a9ba045
Revises: 6b516114c393
Create Date: 2026-03-07 15:09:21.421155
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f8201a9ba045'
down_revision = '6b516114c393'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def _table_exists(name):
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:name"),
{"name": name},
)
return result.fetchone() is not None
def _column_exists(table, column):
conn = op.get_bind()
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
return any(row[1] == column for row in result.fetchall())
def upgrade():
# Gift card table
if not _table_exists("mps_gift_card"):
op.create_table(
"mps_gift_card",
sa.Column("id", UUIDType, primary_key=True),
sa.Column("shop_id", UUIDType, sa.ForeignKey("mps_shop.id"), nullable=False),
sa.Column("code", sa.Unicode(64), nullable=False, unique=True),
sa.Column("initial_amount_in_cents", sa.BigInteger(), nullable=False),
sa.Column("balance_in_cents", sa.BigInteger(), nullable=False),
sa.Column("purchaser_email", sa.Unicode(256), nullable=True),
sa.Column("gift_email", sa.Unicode(256), nullable=True),
sa.Column("gift_message", sa.Unicode(512), nullable=True),
sa.Column("invoice_id", UUIDType, sa.ForeignKey("mps_invoice.id"), nullable=True),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
sa.Column("disabled", sa.Boolean(), nullable=False, server_default="0"),
)
op.create_index("ix_mps_gift_card_code", "mps_gift_card", ["code"])
op.create_index("ix_mps_gift_card_shop_id", "mps_gift_card", ["shop_id"])
# Gift card transaction table
if not _table_exists("mps_gift_card_transaction"):
op.create_table(
"mps_gift_card_transaction",
sa.Column("id", UUIDType, primary_key=True),
sa.Column("gift_card_id", UUIDType, sa.ForeignKey("mps_gift_card.id"), nullable=False),
sa.Column("invoice_id", UUIDType, sa.ForeignKey("mps_invoice.id"), nullable=False),
sa.Column("amount_in_cents", sa.BigInteger(), nullable=False),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
)
# Cart-to-gift-card association table
if not _table_exists("mps_cart_gift_card"):
op.create_table(
"mps_cart_gift_card",
sa.Column("id", UUIDType, primary_key=True),
sa.Column("cart_id", UUIDType, sa.ForeignKey("mps_cart.id"), nullable=False),
sa.Column("gift_card_id", UUIDType, sa.ForeignKey("mps_gift_card.id"), nullable=False),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
)
# Cart gift card purchase items column
if not _column_exists("mps_cart", "json_gift_cards"):
op.add_column(
"mps_cart",
sa.Column("json_gift_cards", sa.UnicodeText(), nullable=True, server_default="[]"),
)
# Shop gift card settings
if not _column_exists("mps_shop", "gift_card_enabled"):
op.add_column(
"mps_shop",
sa.Column("gift_card_enabled", sa.Boolean(), nullable=False, server_default="0"),
)
if not _column_exists("mps_shop", "gift_card_min_in_cents"):
op.add_column(
"mps_shop",
sa.Column("gift_card_min_in_cents", sa.BigInteger(), nullable=False, server_default="500"),
)
if not _column_exists("mps_shop", "gift_card_max_in_cents"):
op.add_column(
"mps_shop",
sa.Column("gift_card_max_in_cents", sa.BigInteger(), nullable=False, server_default="25000"),
)
def downgrade():
op.drop_table("mps_cart_gift_card")
op.drop_table("mps_gift_card_transaction")
op.drop_table("mps_gift_card")
op.drop_column("mps_shop", "gift_card_enabled")
op.drop_column("mps_shop", "gift_card_min_in_cents")
op.drop_column("mps_shop", "gift_card_max_in_cents")

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,774 @@
/* ============================================
DESIGN TOKENS Make Post Sell
Single source of truth for the design system.
CSS custom properties, consumed everywhere.
============================================ */
:root {
/* ---- Color: Brand ---- */
--color-green: #a3c765;
--color-green-dark: #8ab34e;
--color-green-light: #e8f5d4;
--color-blue: #98b6fa;
--color-blue-dark: #7a9de8;
--color-navy: #5871ad;
--color-navy-light: #82A8FF;
--color-purple: #9B59B6;
--color-purple-dark: #7B2D8E;
--color-danger: #CC6958;
--color-danger-dark: #b5503f;
--color-check: #678731;
--color-cross: #c77765;
--color-gold: #d4a843;
/* ---- Color: Surface ---- */
--surface-base: #FFFFFF;
--surface-dim: #F9F9FA;
--surface-container: #F4F4F5;
--surface-container-high: #EEEEEF;
--surface-inverse: #0b0b0b;
/* ---- Color: Text ---- */
--text-primary: #0b0b0b;
--text-body: #515151;
--text-secondary: #666666;
--text-muted: #777777;
--text-faint: #999999;
--text-disabled: #BBBBBB;
--text-inverse: #FFFFFF;
--text-on-green: #FFFFFF;
--text-on-navy: #FFFFFF;
/* ---- Color: Border ---- */
--border-default: #e0e0e0;
--border-light: #eeeeee;
--border-focus: #5871ad;
--border-error: #CC6958;
/* ---- Color: State overlays ---- */
--state-hover: rgba(0, 0, 0, 0.04);
--state-focus: rgba(88, 113, 173, 0.12);
--state-pressed: rgba(0, 0, 0, 0.08);
--state-dragged: rgba(0, 0, 0, 0.12);
--state-disabled-bg: rgba(0, 0, 0, 0.04);
--state-disabled-text: rgba(0, 0, 0, 0.26);
/* ---- Color: Alerts ---- */
--alert-success-bg: #e8f5d4;
--alert-success-text: #155724;
--alert-success-border: #c3e6cb;
--alert-info-bg: #dce8ff;
--alert-info-text: #0c5460;
--alert-info-border: #bee5eb;
--alert-warning-bg: #fff3cd;
--alert-warning-text: #856404;
--alert-warning-border: #ffeeba;
--alert-danger-bg: #f8d7da;
--alert-danger-text: #721c24;
--alert-danger-border: #f5c6cb;
/* ---- Typography ---- */
--font-family: helvetica, arial, sans-serif;
--font-mono: 'SF Mono', 'Menlo', 'Consolas', monospace;
/* Type scale — major third (1.250) */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-md: 1.125rem; /* 18px */
--text-lg: 1.25rem; /* 20px */
--text-xl: 1.5rem; /* 24px */
--text-2xl: 1.875rem; /* 30px */
--text-3xl: 2.25rem; /* 36px */
--text-4xl: 3rem; /* 48px */
--text-5xl: 3.75rem; /* 60px */
/* Line heights */
--leading-none: 1;
--leading-tight: 1.15;
--leading-snug: 1.3;
--leading-normal: 1.5;
--leading-relaxed: 1.65;
--leading-loose: 1.8;
/* Font weights */
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* Letter spacing */
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.02em;
--tracking-wider: 0.05em;
--tracking-widest: 0.08em;
/* ---- Spacing scale (4px base) ---- */
--space-0: 0;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-20: 80px;
--space-24: 96px;
/* ---- Shape (border radius) ---- */
--radius-none: 0;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
--radius-full: 9999px;
/* ---- Elevation (box-shadow) ---- */
--elevation-0: none;
--elevation-1: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--elevation-2: 0 2px 6px rgba(0,0,0,0.06), 0 2px 4px rgba(0,0,0,0.04);
--elevation-3: 0 4px 12px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.04);
--elevation-4: 0 8px 24px rgba(0,0,0,0.08), 0 2px 6px rgba(0,0,0,0.04);
--elevation-5: 0 16px 48px rgba(0,0,0,0.10), 0 4px 12px rgba(0,0,0,0.05);
/* ---- Motion ---- */
/* Durations */
--duration-instant: 50ms;
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--duration-slower: 400ms;
--duration-entrance: 250ms;
--duration-exit: 200ms;
/* Easing — based on material curves */
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-decelerate: cubic-bezier(0, 0, 0, 1);
--ease-accelerate: cubic-bezier(0.3, 0, 1, 1);
--ease-emphasize: cubic-bezier(0.2, 0, 0, 1);
--ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
/* ---- Z-index scale ---- */
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
--z-ribbon: 600;
/* ---- Layout ---- */
--content-width: 800px;
--content-narrow: 400px;
--content-wide: 1200px;
--bp-tablet: 800px;
--bp-desktop: 1200px;
/* ---- App theme (light mode defaults) ---- */
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
--text-primary: #333333;
--text-secondary: #6c757d;
--text-muted: #666666;
--border-color: #dee2e6;
--border-light: #e9ecef;
--link-color: #5f6368;
--blue-color: #98b6fa;
--green-color: #a3c765;
--red-color: #bc2131;
--success-color: #28a745;
--warning-color: #ffc107;
--error-color: #dc3545;
--info-color: #17a2b8;
/* Form elements */
--input-bg: #ffffff;
--input-border: #cccccc;
--input-text: #333333;
/* Navigation */
--nav-bg: #f9f9fa;
--nav-text: #333333;
/* Cards and containers */
--card-bg: #f8f9fa;
--well-bg: #f9f9fa;
/* Dark mode button surface */
--dark-button-bg: #2d3748;
/* Status notices */
--notice-warning-bg: #fff3cd;
--notice-warning-border:#ffeaa7;
--notice-error-bg: #f8d7da;
--notice-error-border: #f5c6cb;
--notice-info-bg: #d1ecf1;
--notice-info-border: #bee5eb;
}
/* ============================================
DARK MODE
============================================ */
[data-theme="dark"] {
/* zealotrush.com inspired gaming theme */
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--text-primary: #ffffff;
--text-secondary: #cccccc;
--text-muted: #999999;
--border-color: #7ab9ff;
--border-light: #5599dd;
--link-color: #7ab9ff;
--blue-color: #7ab9ff;
--green-color: #08e700;
--red-color: #F34C31;
--success-color: #08e700;
--warning-color: #f34c31;
--error-color: #f34c31;
--info-color: #7ab9ff;
/* Form elements */
--input-bg: #161b22;
--input-border: #7ab9ff;
--input-text: #ffffff;
/* Navigation */
--nav-bg: #0d1117;
--nav-text: #ffffff;
/* Cards and containers */
--card-bg: #2d2d2d;
--well-bg: #2d2d2d;
/* Status notices */
--notice-warning-bg: #3e2723;
--notice-warning-border:#5d4037;
--notice-error-bg: #3f1a1a;
--notice-error-border: #5c2626;
--notice-info-bg: #1a365d;
--notice-info-border: #2c5282;
/* Surfaces and tokens override */
--surface-base: #0d1117;
--surface-dim: #161b22;
--surface-container: #21262d;
--surface-container-high:#2d3748;
--surface-inverse: #ffffff;
--text-body: #cccccc;
--text-faint: #666666;
--text-disabled: #444444;
--text-inverse: #0d1117;
--border-default: #7ab9ff;
--border-focus: #7ab9ff;
--border-error: #F34C31;
--color-blue: #7ab9ff;
--color-green: #08e700;
--color-navy: #7ab9ff;
--color-navy-light: #7ab9ff;
--color-danger: #F34C31;
--color-check: #08e700;
--color-cross: #F34C31;
--color-gold: #f59e0b;
/* Alert tokens */
--alert-success-bg: #1a3a1a;
--alert-success-text: #08e700;
--alert-success-border: #08e700;
--alert-info-bg: #1a365d;
--alert-info-text: #7ab9ff;
--alert-info-border: #7ab9ff;
--alert-warning-bg: #3e2723;
--alert-warning-text: #ffd93d;
--alert-warning-border: #ffd93d;
--alert-danger-bg: #3f1a1a;
--alert-danger-text: #F34C31;
--alert-danger-border: #F34C31;
}
/* ============================================
BASE RESETS & DEFAULTS
============================================ */
*, *::before, *::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: var(--font-family);
font-size: var(--text-base);
font-weight: var(--weight-regular);
line-height: var(--leading-normal);
color: var(--text-body);
background-color: var(--surface-dim);
margin: 0;
}
/* ============================================
FOCUS Accessible, visible, consistent
============================================ */
:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
/* ============================================
SELECTION
============================================ */
::selection {
background: var(--color-navy-light);
color: var(--text-inverse);
}
/* ============================================
TYPOGRAPHY UTILITIES
============================================ */
.type-display {
font-size: clamp(var(--text-3xl), 5vw, var(--text-5xl));
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--text-primary);
}
.type-headline-1 {
font-size: var(--text-4xl);
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--text-primary);
}
.type-headline-2 {
font-size: var(--text-3xl);
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--text-primary);
}
.type-headline-3 {
font-size: var(--text-2xl);
font-weight: var(--weight-bold);
line-height: var(--leading-snug);
color: var(--text-primary);
}
.type-title-lg {
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--text-primary);
}
.type-title {
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--text-primary);
}
.type-title-sm {
font-size: var(--text-base);
font-weight: var(--weight-semibold);
line-height: var(--leading-normal);
color: var(--text-primary);
}
.type-body-lg {
font-size: var(--text-md);
font-weight: var(--weight-regular);
line-height: var(--leading-relaxed);
color: var(--text-body);
}
.type-body {
font-size: var(--text-base);
font-weight: var(--weight-regular);
line-height: var(--leading-relaxed);
color: var(--text-body);
}
.type-body-sm {
font-size: var(--text-sm);
font-weight: var(--weight-regular);
line-height: var(--leading-relaxed);
color: var(--text-body);
}
.type-label-lg {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
line-height: var(--leading-normal);
letter-spacing: var(--tracking-wide);
color: var(--text-primary);
}
.type-label {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
line-height: var(--leading-normal);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--text-muted);
}
.type-caption {
font-size: var(--text-xs);
font-weight: var(--weight-regular);
line-height: var(--leading-normal);
color: var(--text-faint);
}
.type-overline {
font-size: 11px;
font-weight: var(--weight-bold);
line-height: var(--leading-normal);
letter-spacing: var(--tracking-widest);
text-transform: uppercase;
color: var(--text-muted);
}
.type-code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--surface-container);
padding: 2px 6px;
border-radius: var(--radius-sm);
}
/* ============================================
ELEVATION UTILITIES
============================================ */
.elevation-0 { box-shadow: var(--elevation-0); }
.elevation-1 { box-shadow: var(--elevation-1); }
.elevation-2 { box-shadow: var(--elevation-2); }
.elevation-3 { box-shadow: var(--elevation-3); }
.elevation-4 { box-shadow: var(--elevation-4); }
.elevation-5 { box-shadow: var(--elevation-5); }
/* ============================================
SURFACE UTILITIES
============================================ */
.surface-base { background-color: var(--surface-base); }
.surface-dim { background-color: var(--surface-dim); }
.surface-container { background-color: var(--surface-container); }
.surface-inverse { background-color: var(--surface-inverse); color: var(--text-inverse); }
/* ============================================
INTERACTIVE STATE LAYER
Overlay that reacts to hover/focus/press.
Apply to any interactive element.
============================================ */
.state-layer {
position: relative;
overflow: hidden;
}
.state-layer::after {
content: '';
position: absolute;
inset: 0;
background: currentColor;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-standard);
pointer-events: none;
border-radius: inherit;
}
.state-layer:hover::after {
opacity: 0.04;
}
.state-layer:focus-visible::after {
opacity: 0.08;
}
.state-layer:active::after {
opacity: 0.10;
}
/* ============================================
RIPPLE EFFECT
Add .ripple to any interactive element.
Requires JS initialization (see styleguide).
============================================ */
.ripple {
position: relative;
overflow: hidden;
}
.ripple-wave {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
transform: scale(0);
animation: ripple-expand var(--duration-slower) var(--ease-decelerate) forwards;
pointer-events: none;
}
@keyframes ripple-expand {
to {
transform: scale(2.5);
opacity: 0;
}
}
/* Dark ripple for light backgrounds */
.ripple-dark .ripple-wave {
background: rgba(0, 0, 0, 0.12);
}
/* ============================================
TRANSITION UTILITIES
============================================ */
.transition-all { transition: all var(--duration-normal) var(--ease-standard); }
.transition-colors { transition: color var(--duration-normal) var(--ease-standard), background-color var(--duration-normal) var(--ease-standard), border-color var(--duration-normal) var(--ease-standard); }
.transition-shadow { transition: box-shadow var(--duration-normal) var(--ease-standard); }
.transition-transform { transition: transform var(--duration-normal) var(--ease-standard); }
/* ============================================
ENTRANCE ANIMATIONS
============================================ */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in-down {
from { opacity: 0; transform: translateY(-16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in-scale {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes slide-in-right {
from { opacity: 0; transform: translateX(24px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes slide-in-left {
from { opacity: 0; transform: translateX(-24px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes scale-in {
from { transform: scale(0); }
to { transform: scale(1); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.animate-fade-in { animation: fade-in var(--duration-entrance) var(--ease-decelerate) both; }
.animate-fade-in-up { animation: fade-in-up var(--duration-entrance) var(--ease-decelerate) both; }
.animate-fade-in-down { animation: fade-in-down var(--duration-entrance) var(--ease-decelerate) both; }
.animate-fade-in-scale { animation: fade-in-scale var(--duration-entrance) var(--ease-decelerate) both; }
.animate-slide-in-right { animation: slide-in-right var(--duration-slow) var(--ease-decelerate) both; }
.animate-slide-in-left { animation: slide-in-left var(--duration-slow) var(--ease-decelerate) both; }
.animate-pulse { animation: pulse 2s var(--ease-standard) infinite; }
.animate-spin { animation: spin 1s linear infinite; }
/* Staggered delays for lists */
.stagger-1 { animation-delay: 50ms; }
.stagger-2 { animation-delay: 100ms; }
.stagger-3 { animation-delay: 150ms; }
.stagger-4 { animation-delay: 200ms; }
.stagger-5 { animation-delay: 250ms; }
.stagger-6 { animation-delay: 300ms; }
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ============================================
SKELETON LOADING
============================================ */
.skeleton {
background: linear-gradient(90deg,
var(--surface-container) 25%,
var(--surface-container-high) 50%,
var(--surface-container) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-md);
color: transparent !important;
}
.skeleton-text {
height: 1em;
margin-bottom: 0.5em;
border-radius: var(--radius-sm);
}
.skeleton-circle {
border-radius: var(--radius-full);
}
/* ============================================
SPINNER
============================================ */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--border-default);
border-top-color: var(--color-navy);
border-radius: var(--radius-full);
animation: spin 0.6s linear infinite;
}
.spinner-sm { width: 14px; height: 14px; }
.spinner-lg { width: 32px; height: 32px; border-width: 3px; }
/* ============================================
DISABLED STATE
============================================ */
[disabled],
.disabled {
opacity: 0.38;
pointer-events: none;
cursor: not-allowed;
}
/* ============================================
SCROLL-TRIGGERED REVEAL
Elements start hidden, JS adds .revealed
============================================ */
.reveal {
opacity: 0;
transform: translateY(20px);
transition: opacity var(--duration-slow) var(--ease-decelerate),
transform var(--duration-slow) var(--ease-decelerate);
}
.reveal.revealed {
opacity: 1;
transform: translateY(0);
}
.reveal-scale {
opacity: 0;
transform: scale(0.96);
transition: opacity var(--duration-slow) var(--ease-decelerate),
transform var(--duration-slow) var(--ease-decelerate);
}
.reveal-scale.revealed {
opacity: 1;
transform: scale(1);
}
/* ============================================
SPACING UTILITIES
============================================ */
.mt-0 { margin-top: var(--space-0); }
.mt-1 { margin-top: var(--space-1); }
.mt-2 { margin-top: var(--space-2); }
.mt-3 { margin-top: var(--space-3); }
.mt-4 { margin-top: var(--space-4); }
.mt-6 { margin-top: var(--space-6); }
.mt-8 { margin-top: var(--space-8); }
.mt-10 { margin-top: var(--space-10); }
.mb-0 { margin-bottom: var(--space-0); }
.mb-1 { margin-bottom: var(--space-1); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-3 { margin-bottom: var(--space-3); }
.mb-4 { margin-bottom: var(--space-4); }
.mb-6 { margin-bottom: var(--space-6); }
.mb-8 { margin-bottom: var(--space-8); }
.mb-10 { margin-bottom: var(--space-10); }
.p-0 { padding: var(--space-0); }
.p-2 { padding: var(--space-2); }
.p-4 { padding: var(--space-4); }
.p-6 { padding: var(--space-6); }
.p-8 { padding: var(--space-8); }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
.gap-6 { gap: var(--space-6); }
.gap-8 { gap: var(--space-8); }
/* ============================================
RESPONSIVE CONTAINER
============================================ */
.container {
width: 100%;
max-width: var(--content-width);
margin-left: auto;
margin-right: auto;
padding-left: var(--space-6);
padding-right: var(--space-6);
}
.container-narrow {
max-width: var(--content-narrow);
}
.container-wide {
max-width: var(--content-wide);
}

View file

@ -217,6 +217,16 @@
var initialId = initialProductEl.getAttribute('data-watch-product-id');
currentProductId = initialId;
markWatched(initialId);
// Invalidate stale ring (wrong shop or product removed from ring)
if (ringProductIds.length && ringProductIds.indexOf(initialId) === -1) {
ringProductIds = [];
ringPosition = 0;
ringHistory = {};
ringLoops = 0;
saveRingState();
}
syncRingPosition(initialId);
if (initialProductEl.getAttribute('data-watch-is-mod') === '1') {
isMod = true;
@ -1827,5 +1837,21 @@
rebindToggles();
renderQueue();
preloadNext();
// Seed ring from server if localStorage is empty (first visit, cache cleared)
if (!ringProductIds.length && currentProductId) {
fetchWatchData(currentProductId).then(function(data) {
if (data.ring && data.ring.length) {
ringProductIds = data.ring;
syncRingPosition(currentProductId);
saveRingState();
updateProgressDisplay();
}
preloadNext();
}).catch(function() {
preloadNext();
});
} else {
preloadNext();
}
})();

View file

@ -17,6 +17,12 @@
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/coupons" class="mps-button product-edit-button">&nbsp View Coupons</a>
{% if request.shop.gift_card_enabled %}
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/gift-cards/manage" class="mps-button product-edit-button">&nbsp Gift Cards</a>
{% endif %}
{% endif %}
{% if request.is_saas_domain %}

View file

@ -17,7 +17,8 @@
<html lang="en" data-theme="{{ theme_preference }}" data-user-theme="{{ user_theme_id or 'null' }}" data-shop-default="{{ shop_default_theme }}"{% if request.shop and request.shop.color_filter %} data-color-filter="{{ request.shop.color_filter }}"{% endif %}>
<head>
<!-- stylesheet to-end-all stylesheets, pico.fluid.classless.css ! woot -->
<!-- design tokens first, then app styles -->
<link rel="stylesheet" href="/static/css/tokens.css">
<link rel="stylesheet" href="/static/css/common.css">
<!-- Dark mode theme switching -->
@ -64,7 +65,7 @@
<meta charset="utf-8">
{% if request.is_saas_domain == false and request.shop and request.shop.favicon %}
<link rel="icon" href="{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ request.shop.uuid_str }}/meta/shop-favicon?ts={{ request.shop.updated_timestamp }}" />
<link rel="icon" href="{{ request.shop_cdn_endpoint }}/{{ request.shop.uuid_str }}/meta/shop-favicon?ts={{ request.shop.updated_timestamp }}" />
{% endif %}
{% if request.path == '/' and request.shop and request.shop.google_site_verification %}
@ -86,6 +87,20 @@
<style>.js-only {display: none;}</style>
</noscript>
{% include 'snippets/ribbon.j2' %}
{% if request.shop and request.shop.is_non_production %}
<div class="environment-banner environment-banner-{{ request.shop.environment_label }}">
{{ request.shop.environment_label|upper }} ENVIRONMENT — This shop is not visible to the public.
</div>
{% endif %}
{% if request.shop and request.shop.is_trial_active %}
<div class="trial-banner">
Trial: {{ request.shop.trial_days_remaining }} day{{ 's' if request.shop.trial_days_remaining != 1 else '' }} remaining — <a href="/actions/view">Choose a plan</a>
</div>
{% elif request.shop and request.shop.is_trial_expired %}
<div class="trial-banner trial-banner-expired">
Trial expired — <a href="/actions/view">Choose a plan</a> to continue editing
</div>
{% endif %}
<section class="main">
<section class="nav-grid">
@ -101,7 +116,7 @@
{% endif %}
{% elif request.shop and request.shop.logo_banner %}
<a href="/"><img class="logo" src="{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner?ts={{ request.shop.updated_timestamp }}" /></a>
<a href="/"><img class="logo" src="{{ request.shop_cdn_endpoint }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner?ts={{ request.shop.updated_timestamp }}" /></a>
{% else%}
Upload a logo in shop settings.
{% endif %}

View file

@ -62,6 +62,25 @@
<br/>
{% endfor %}
{% for gift_card in cart.gift_cards %}
<section class="coupon">
<b>Gift Card: {{ gift_card.code }}</b><br/>
Balance: <strong>${{ '{:,.2f}'.format(gift_card.balance) }}</strong>
({{ gift_card.shop.name }})
<div class="cart-float-right">
<form method="post" action="/gift-card/remove" onsubmit="submit.disabled = true; return true;">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="cart_id" value="{{ cart.uuid_str }}" />
<input type="hidden" name="gift_card_id" value="{{ gift_card.uuid_str }}" />
<input type="submit" name="submit" class="mps-submit" value="remove gift card" />
</form>
</div>
</section>
<br/>
<br/>
{% endfor %}
{% if cart.is_empty %}
<center>
@ -102,7 +121,7 @@
<div class="cart-item">
{% if "thumbnail1" in product.extensions %}
<a href="/p/{{ product.uuid_str }}" rel="nofollow">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-cart-thumbnail" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-cart-thumbnail" />
</a>
{% endif %}
</div>
@ -186,10 +205,27 @@
{% endfor %}
{% endif %}
{% for gc_item in cart.gift_card_purchases %}
<section class="cart-left-grid well">
<div class="cart-shop-grid-span">
<b class="cart-shop-name">Gift Card</b>
</div>
<div class="cart-item"></div>
<div class="cart-left-forms">
<span class="cart-left-item-title">Gift Card{% if gc_item.gift_email %} for {{ gc_item.gift_email }}{% endif %}</span>
{% if gc_item.gift_message %}<br/><em>{{ gc_item.gift_message }}</em>{% endif %}
</div>
<div class="cart-total-section">
<b>${{ '{:,.2f}'.format(gc_item.amount_in_cents / 100) }}</b>
</div>
</section>
<br>
{% endfor %}
{% endif %}
</section>
{% if not cart.is_empty %}
<section class="cart-right well">
@ -275,6 +311,16 @@
{% endif %}
<section class="cart-gift-card-apply">
<h3>Gift Card</h3>
<form method="POST" action="/gift-card/apply" class="cart-inline-form">
{% include "snippets/csrf.j2" %}
<input type="text" name="gift_card_code" placeholder="GC-XXXXXXXXXXXXXXXX" class="mps-text-input" />
<button type="submit" class="mps-button mps-button-small">Apply</button>
</form>
</section>
<br/>
<center>
<a href="/" class="mps-button cart-continue-shopping-button">Continue shopping</a>
</center>

View file

@ -10,11 +10,11 @@
<meta property="og:url" content="{{ product.absolute_url(request) }}" />
<meta property="og:site_name" content="{{ request.shop.name }}" />
{%- if "thumbnail1" in product.extensions %}
<meta property="og:image" content="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<meta property="og:image" content="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ product.title }}" />
<meta name="twitter:description" content="{{ product.description|truncate(500) }}" />
<meta name="twitter:image" content="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<meta name="twitter:image" content="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
{%- endif %}
{# Removed auto-refresh timer - better UX to let download links expire than interrupt reading #}
@ -39,7 +39,7 @@
{% set audio_extensions = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"] %}
{% if request.shop.watch_mode_enabled and product.extensions.get("product") in video_extensions %}
{# Watch mode: direct video render with autoplay #}
{% set watch_video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
{% set watch_video_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/product" %}
<div id="watch-media-container"{% if instrumentals_url %} data-instrumentals-url="{{ instrumentals_url }}"{% endif %}{% if vocals_url %} data-vocals-url="{{ vocals_url }}"{% endif %}>
<div class="watch-video-container">
<video id="watch-video" src="{{ watch_video_url }}" autoplay controls playsinline class="product-main"></video>
@ -60,11 +60,11 @@
</noscript>
{% elif request.shop.watch_mode_enabled and product.extensions.get("product") in audio_extensions %}
{# Watch mode: audio with album art #}
{% set watch_audio_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
{% set watch_audio_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/product" %}
<div id="watch-media-container"{% if instrumentals_url %} data-instrumentals-url="{{ instrumentals_url }}"{% endif %}{% if vocals_url %} data-vocals-url="{{ vocals_url }}"{% endif %}>
<div class="watch-audio-container">
{% if "thumbnail1" in product.extensions %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
{% endif %}
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
@ -84,9 +84,9 @@
{% elif "thumbnail1" in product.extensions %}
{% if product.extensions.get("product") in video_extensions %}
{# Video: play button overlay, click to play inline #}
{% set video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
{% set video_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/product" %}
<div id="video-container-{{ product.id }}" class="video-thumbnail-container" onclick="playInline(this, '{{ video_url }}')">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="video-play-overlay"></div>
</div>
<noscript>
@ -95,8 +95,8 @@
<div class="video-click-to-play">click ▶ to play</div>
{% else %}
{# Non-video: click to open in new window #}
<a target="_blank" href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/product?ts={{ product.updated_timestamp }}">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<a target="_blank" href="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/product?ts={{ product.updated_timestamp }}">
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
</a>
{% endif %}
{% endif %}

View file

@ -0,0 +1,107 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column">
<section class="well">
<h2>Gift Card for {{ shop.name }}</h2>
<p>Choose an amount and purchase a gift card. The recipient can redeem it at checkout.</p>
<form method="POST" action="/gift-card/add-to-cart" class="gift-card-form">
{% include "snippets/csrf.j2" %}
<label for="gift-card-amount">Amount</label>
<div class="gift-card-slider-group">
<input type="range"
id="gift-card-slider"
min="{{ min_dollars }}"
max="{{ max_dollars }}"
step="1"
value="{{ ((min_dollars + max_dollars) / 2)|round|int }}"
class="gift-card-slider" />
<div class="gift-card-amount-input-group">
<span class="gift-card-currency">$</span>
<input type="number"
id="gift-card-amount"
name="amount"
min="{{ min_dollars }}"
max="{{ max_dollars }}"
step="0.01"
value="{{ ((min_dollars + max_dollars) / 2)|round|int }}.00"
class="gift-card-amount-input"
required />
</div>
</div>
<label for="gift-email">Recipient Email (optional)</label>
<input type="email"
id="gift-email"
name="gift_email"
placeholder="friend@example.com"
class="mps-text-input" />
<small>If provided, the gift card code will be emailed to this address.</small>
<br/><br/>
<label for="gift-message">Gift Message (optional)</label>
<input type="text"
id="gift-message"
name="gift_message"
maxlength="500"
placeholder="Happy birthday!"
class="mps-text-input" />
<br/><br/>
<button type="submit" class="mps-button mps-button-green">Add Gift Card to Cart</button>
</form>
</section>
<br/>
<section class="well">
<h3>Check Gift Card Balance</h3>
<form method="GET" action="/s/{{ shop.uuid_str }}/gift-card" class="gift-card-balance-form">
<input type="text"
name="check_code"
placeholder="GC-XXXXXXXXXXXXXXXX"
class="mps-text-input"
value="{{ request.params.get('check_code', '') }}" />
<button type="submit" class="mps-button mps-button-small">Check Balance</button>
</form>
{% if balance_result %}
{% if balance_result.error %}
<p class="alert-error">{{ balance_result.error }}</p>
{% else %}
<p class="alert-success">
<strong>{{ balance_result.code }}</strong><br/>
Balance: <strong>${{ '{:,.2f}'.format(balance_result.balance) }}</strong>
(original: ${{ '{:,.2f}'.format(balance_result.initial_amount) }})
</p>
{% endif %}
{% endif %}
</section>
</section>
<script>
(function() {
var slider = document.getElementById('gift-card-slider');
var input = document.getElementById('gift-card-amount');
if (slider && input) {
slider.addEventListener('input', function() {
input.value = parseFloat(slider.value).toFixed(2);
});
input.addEventListener('input', function() {
var val = parseFloat(input.value);
if (!isNaN(val)) {
slider.value = val;
}
});
}
})();
</script>
{%- endblock -%}

View file

@ -0,0 +1,72 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
<a href="/s/{{ request.shop.uuid_str }}/gift-cards/manage" class="mps-button mps-button-small">All Gift Cards</a>
{%- endblock call_to_action -%}
{% block content -%}
<section class="one-column">
<section class="well">
<h2>Gift Card: {{ gift_card.code }}</h2>
<table class="mps-table">
<tr><td>Code</td><td><strong>{{ gift_card.code }}</strong></td></tr>
<tr><td>Initial Amount</td><td>${{ '{:,.2f}'.format(gift_card.initial_amount) }}</td></tr>
<tr><td>Balance</td><td>${{ '{:,.2f}'.format(gift_card.balance) }}</td></tr>
<tr><td>Purchaser</td><td>{{ gift_card.purchaser_email or '-' }}</td></tr>
<tr><td>Recipient</td><td>{{ gift_card.gift_email or '-' }}</td></tr>
{% if gift_card.gift_message %}
<tr><td>Message</td><td>{{ gift_card.gift_message }}</td></tr>
{% endif %}
<tr><td>Status</td><td>
{% if gift_card.disabled %}
<span class="badge-error">Disabled</span>
{% elif gift_card.is_fully_redeemed %}
<span class="badge-info">Fully Redeemed</span>
{% else %}
<span class="badge-success">Active</span>
{% endif %}
</td></tr>
</table>
<br/>
<form method="POST" action="/s/{{ request.shop.uuid_str }}/gift-cards/{{ gift_card.uuid_str }}/toggle">
{% include "snippets/csrf.j2" %}
<button type="submit" class="mps-button mps-button-small">
{{ "Enable" if gift_card.disabled else "Disable" }} Gift Card
</button>
</form>
</section>
<br/>
<section class="well">
<h3>Transaction History</h3>
{% if transactions %}
<table class="mps-table">
<thead>
<tr>
<th>Date</th>
<th>Amount</th>
<th>Invoice</th>
</tr>
</thead>
<tbody>
{% for txn in transactions %}
<tr>
<td>{{ txn.created_timestamp }}</td>
<td>-${{ '{:,.2f}'.format(txn.amount) }}</td>
<td><a href="/invoice/{{ txn.invoice.uuid_str }}">{{ txn.invoice.uuid_str[:8] }}...</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No transactions yet.</p>
{% endif %}
</section>
</section>
{%- endblock -%}

View file

@ -0,0 +1,69 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
<a href="/s/{{ request.shop.uuid_str }}/settings" class="mps-button mps-button-small">Settings</a>
{%- endblock call_to_action -%}
{% block content -%}
<section class="one-column">
<section class="well">
<h2>Gift Cards</h2>
<p>
Total issued: <strong>${{ '{:,.2f}'.format(total_issued_dollars) }}</strong> |
Outstanding balance: <strong>${{ '{:,.2f}'.format(total_balance_dollars) }}</strong>
</p>
</section>
<br/>
{% if gift_cards %}
<section class="well">
<table class="mps-table">
<thead>
<tr>
<th>Code</th>
<th>Amount</th>
<th>Balance</th>
<th>Recipient</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for gc in gift_cards %}
<tr>
<td><a href="/s/{{ request.shop.uuid_str }}/gift-cards/{{ gc.uuid_str }}">{{ gc.code }}</a></td>
<td>${{ '{:,.2f}'.format(gc.initial_amount) }}</td>
<td>${{ '{:,.2f}'.format(gc.balance) }}</td>
<td>{{ gc.gift_email or gc.purchaser_email or '-' }}</td>
<td>
{% if gc.disabled %}
<span class="badge-error">Disabled</span>
{% elif gc.is_fully_redeemed %}
<span class="badge-info">Redeemed</span>
{% else %}
<span class="badge-success">Active</span>
{% endif %}
</td>
<td>
<form method="POST" action="/s/{{ request.shop.uuid_str }}/gift-cards/{{ gc.uuid_str }}/toggle" style="display:inline;">
{% include "snippets/csrf.j2" %}
<button type="submit" class="mps-button mps-button-small">
{{ "Enable" if gc.disabled else "Disable" }}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% else %}
<section class="well">
<p>No gift cards issued yet.</p>
</section>
{% endif %}
</section>
{%- endblock -%}

View file

@ -4,10 +4,9 @@
{% if not request.shop %}
<section class="one-column well">
{% if request.user and request.user.authenticated %}
<section class="one-column well">
{% if request.user.active_shop %}
<b>{{ request.user.active_shop.name }}</b>
@ -19,20 +18,65 @@
<br />
<br />
<a href="/s/new" class="shop-new-button mps-button mps-button-small">&#65291; New Shop</a>
{% endif %}
</section>
{% else %}
<h2>Make. Post. Sell.</h2>
<p>Create your own shop and start selling digital products in minutes.</p>
<a href="/join-or-log-in" class="mps-button">Get Started</a>
<section class="landing-hero animate-fade-in-up">
<h1 class="type-display">Make it. Post it. Sell it.</h1>
<p class="type-body-lg landing-hero-sub">Commission-free digital downloads &amp; physical product sales. Open source. Public domain.</p>
<div class="landing-hero-ctas">
<a href="/s/new" class="mps-button mps-button-green">&#65291; Open a Shop</a>
<a href="/join-or-log-in" class="mps-button">Log In</a>
</div>
</section>
<section class="landing-features animate-fade-in-up stagger-2">
<div class="landing-feature-card well">
<div class="landing-feature-icon">&#127917;</div>
<h3 class="type-title">Watch Mode</h3>
<p class="type-body-sm">SPA media channel. Visitors browse your catalog without page reloads.</p>
</div>
<div class="landing-feature-card well">
<div class="landing-feature-icon">&#128176;</div>
<h3 class="type-title">Five Payment Options</h3>
<p class="type-body-sm">Stripe, PayPal, Monero, Dogecoin, and free coupons. Your money, your choice.</p>
</div>
<div class="landing-feature-card well">
<div class="landing-feature-icon">&#128232;</div>
<h3 class="type-title">Subscriptions &amp; Feeds</h3>
<p class="type-body-sm">Email updates, RSS, and Atom feeds. Keep your audience in the loop.</p>
</div>
<div class="landing-feature-card well">
<div class="landing-feature-icon">&#127912;</div>
<h3 class="type-title">Creative Tools</h3>
<p class="type-body-sm">Sandbox filters, karaoke vocal isolation, and more. Built for creators.</p>
</div>
<div class="landing-feature-card well">
<div class="landing-feature-icon">&#128200;</div>
<h3 class="type-title">Analytics Dashboard</h3>
<p class="type-body-sm">Views, sales, and traffic. Know what works without third-party trackers.</p>
</div>
<div class="landing-feature-card well">
<div class="landing-feature-icon">&#128101;</div>
<h3 class="type-title">Multi-tenant</h3>
<p class="type-body-sm">Run multiple shops from one account. Custom domains included.</p>
</div>
</section>
<section class="landing-cta-bottom animate-fade-in-up stagger-4">
<div class="well landing-cta-card">
<h2 class="type-headline-3">Ready to sell?</h2>
<p class="type-body">No credit card required. Set up your shop in minutes.</p>
<a href="/s/new" class="mps-button mps-button-green">&#65291; Open a Shop</a>
</div>
</section>
{% endif %}
</section>
{% else %}
{% if products %}
@ -43,7 +87,7 @@
<div class="serp-item">
{% if "thumbnail1" in product.extensions %}
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
</a>
{% endif %}
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>

View file

@ -1,40 +1,42 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column">
<section class="log-in-form well">
<h2>Log in</h2>
<b>New and Existing Users</b>
<br>
<br>
<form method="post" action="/join-or-log-in" onsubmit="submit.disabled = true; return true;">
<section class="login-card elevation-3 animate-fade-in-up">
<h2 class="type-headline-3 login-card-title">Welcome</h2>
<p class="type-body login-card-subtitle">New and existing users &mdash; one step to get in.</p>
<form method="post" action="/join-or-log-in" class="login-form" onsubmit="submit.disabled = true; return true;">
<input
name = "email2"
type = "email"
id = "email2_input"
class = "common-text-input"
value = ""
placeholder = "Your Email Address" />
<label for="email_input" class="type-label login-form-label">Email address</label>
<input
name = "email"
type = "email"
id = "email_input"
class = "common-text-input"
class = "login-form-input"
tabindex = "1"
value = ""
placeholder = "Your Email Address"
placeholder = "you@example.com"
required
autofocus />
<br/>
<br/>
<button type="submit" name="submit" id="submit" class="mps-button mps-button-green login-form-submit">
Send verification code
</button>
</form>
<div class="login-card-hint">
<span class="type-body-sm"><strong>What happens next?</strong> We email you a one-time code. Click it to log in &mdash; no password needed.</span>
</div>
<input type="submit" name="submit" id="submit" value="send verification code to email" />
</form>
<br/>
<p class="whats-next"><b>What's next?</b> check your email to log in!</p>
</section>
</section>
{%- endblock -%}

View file

@ -10,11 +10,11 @@
<meta property="og:url" content="{{ product.absolute_url(request) }}" />
<meta property="og:site_name" content="{{ request.shop.name }}" />
{%- if "thumbnail1" in product.extensions %}
<meta property="og:image" content="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<meta property="og:image" content="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ product.title }}" />
<meta name="twitter:description" content="{{ product.description|truncate(500) }}" />
<meta name="twitter:image" content="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<meta name="twitter:image" content="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
{%- endif %}
{# Removed auto-refresh timer - better UX to let download links expire than interrupt reading #}
@ -40,7 +40,7 @@
{% if request.shop.watch_mode_enabled and signed_get_object_url and product.extensions.get("product") in video_extensions %}
{# Watch mode: direct video render with autoplay #}
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
{% set watch_video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
{% set watch_video_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/preview" %}
{% else %}
{% set watch_video_url = signed_get_object_url %}
{% endif %}
@ -65,14 +65,14 @@
{% elif request.shop.watch_mode_enabled and signed_get_object_url and product.extensions.get("product") in audio_extensions %}
{# Watch mode: audio with album art #}
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
{% set watch_audio_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
{% set watch_audio_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/preview" %}
{% else %}
{% set watch_audio_url = signed_get_object_url %}
{% endif %}
<div id="watch-media-container">
<div class="watch-audio-container">
{% if "thumbnail1" in product.extensions %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
{% endif %}
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
@ -92,9 +92,9 @@
{% elif "thumbnail1" in product.extensions %}
{% if signed_get_object_url and product.extensions.get("product") in video_extensions %}
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
{% set preview_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
{% set preview_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/preview" %}
<div id="video-container-{{ product.id }}" class="video-thumbnail-container" onclick="playInline(this, '{{ preview_url }}')">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="video-play-overlay"></div>
</div>
<noscript>
@ -103,13 +103,13 @@
<div class="video-click-to-play">click ▶ to play</div>
{% else %}
<a href="{{ signed_get_object_url }}" target="_blank" class="video-thumbnail-container">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="video-play-overlay"></div>
</a>
<div class="video-click-to-play">click &#9654; to play</div>
{% endif %}
{% else %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
{% endif %}
{% endif %}
{% if request.shop.watch_mode_enabled and product.extensions.get("product") not in video_extensions and product.extensions.get("product") not in audio_extensions %}
@ -128,7 +128,7 @@
{% for file_key in product.file_thumbnail_keys %}
{% if file_key in product.s3_key_thumbnails %}
{% set get_endpoint = request.app["bucket.secure_uploads.get_endpoint"] + "/" + product.s3_key_thumbnails[file_key] %}
{% set get_endpoint = request.shop_cdn_endpoint + "/" + product.s3_key_thumbnails[file_key] %}
<a href="{{ get_endpoint }}" target="_blank"><img src="{{ get_endpoint }}?ts={{ product.updated_timestamp }}" class="product-thumbnail" /></a>
{% endif %}
{% endfor %}
@ -232,7 +232,7 @@
{% endif %}
{% if "preview" in product.extensions %}
<a href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/preview" target="_blank" class="product-preview-button mps-button">▶ Play Preview</a>
<a href="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/preview" target="_blank" class="product-preview-button mps-button">▶ Play Preview</a>
<br/>
{% endif %}

View file

@ -133,7 +133,7 @@ permanent link: <a href="{{ product.absolute_url(request) }}">{{ product.absolut
<p>File Size: {{ product.human_file_bytes("preview") }}</p>
{% set preview_ext = product.extensions.get("preview", "") %}
{% set preview_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview?ts=" ~ product.updated_timestamp %}
{% set preview_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/preview?ts=" ~ product.updated_timestamp %}
{% if preview_ext in video_extensions %}
<video src="{{ preview_url }}" controls playsinline class="edit-media-preview"></video>
{% elif preview_ext in audio_extensions %}
@ -177,7 +177,7 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
<div class="upload-thumbnail-item">
{% if thumbnail_key in s3_key_thumbnails %}
{% set get_endpoint = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ s3_key_thumbnails[thumbnail_key] %}
{% set get_endpoint = request.shop_cdn_endpoint ~ "/" ~ s3_key_thumbnails[thumbnail_key] %}
{% set thumb_ext = product.extensions.get(thumbnail_key, "") %}
{% if thumb_ext in video_extensions %}
<video src="{{ get_endpoint }}?ts={{ product.updated_timestamp }}" controls playsinline class="edit-media-preview"></video>

View file

@ -10,7 +10,7 @@
<div class="serp-item">
{% if "thumbnail1" in product.extensions %}
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
</a>
{% endif %}
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>

View file

@ -4,7 +4,7 @@
<section class="one-column well">
<img class="logo" src="{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner" />
<img class="logo" src="{{ request.shop_cdn_endpoint }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner" />
<br/>
<br/>

View file

@ -59,7 +59,20 @@
<br />
<br />
<fieldset>
<legend>Environment</legend>
<input type="radio" name="environment" id="env_production" value="0" checked />
<label for="env_production" class="inline-label">Production</label>
<input type="radio" name="environment" id="env_staging" value="1" />
<label for="env_staging" class="inline-label">Staging</label>
<input type="radio" name="environment" id="env_development" value="2" />
<label for="env_development" class="inline-label">Development</label>
</fieldset>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Create Shop" />
</form>
</section>

View file

@ -1122,6 +1122,165 @@ Existing sales honored for download buy purchasers.
</section>
<section class="well">
<h3>Gift Cards</h3>
<form action="/s/{{ request.shop.uuid_str }}/settings" method="POST">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="form_section" value="gift-card-settings" />
<label>
<input type="checkbox" name="gift-card-enabled-checkbox"
{% if gift_card_enabled %}checked{% endif %} />
Enable Gift Cards
</label>
<br /><br />
<label for="gift_card_min">Minimum Amount ($)</label>
<input type="number" name="gift_card_min" id="gift_card_min"
value="{{ '{:.2f}'.format(gift_card_min_dollars) }}"
min="1.00" step="0.01" class="mps-text-input" />
<br /><br />
<label for="gift_card_max">Maximum Amount ($)</label>
<input type="number" name="gift_card_max" id="gift_card_max"
value="{{ '{:.2f}'.format(gift_card_max_dollars) }}"
min="1.00" max="10000.00" step="0.01" class="mps-text-input" />
<br /><br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
<br /><br />
{% if gift_card_enabled %}
<a href="/s/{{ request.shop.uuid_str }}/gift-cards/manage" class="mps-button mps-button-small">Manage Gift Cards</a>
{% endif %}
</form>
</section>
</section>
<br />
<br />
<section class="one-column">
<section class="shop-settings well">
<h3>Environment Settings</h3>
<p>Non-production shops are hidden from search, feeds, and discovery. Use them to stage or test before going live.</p>
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="form_section" value="environment-settings" />
<fieldset>
<legend>Environment</legend>
<input type="radio" name="environment" id="env_production" value="0" {% if environment == 0 %}checked{% endif %} />
<label for="env_production" class="inline-label">Production</label>
<input type="radio" name="environment" id="env_staging" value="1" {% if environment == 1 %}checked{% endif %} />
<label for="env_staging" class="inline-label">Staging</label>
<input type="radio" name="environment" id="env_development" value="2" {% if environment == 2 %}checked{% endif %} />
<label for="env_development" class="inline-label">Development</label>
</fieldset>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
</form>
</section>
</section>
<br />
<br />
<section class="one-column">
<section class="shop-settings well">
<h3>Storage Bucket (BYOB)</h3>
<p>Configure your own S3-compatible storage bucket. When enabled, all media uploads and CDN URLs will use your bucket instead of the default MPS storage.</p>
{% if request.shop.is_trial_active %}
<p><strong>Trial tip:</strong> Setting up your own storage bucket during your trial ensures your media is always under your control. Any S3-compatible provider works (DigitalOcean Spaces, AWS S3, Backblaze B2, etc.).</p>
{% endif %}
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="form_section" value="bucket-settings" />
<label>
<input type="checkbox" name="primary_s3_enabled_checkbox"
{% if primary_s3_enabled %}checked{% endif %} />
Enable Custom Bucket
</label>
<br /><br />
<label for="primary_s3_endpoint">S3 Endpoint URL</label>
<input type="url" name="primary_s3_endpoint" id="primary_s3_endpoint"
value="{{ primary_s3_endpoint or '' }}"
placeholder="https://nyc3.digitaloceanspaces.com"
class="mps-text-input" />
<br /><br />
<label for="primary_s3_region">Region</label>
<input type="text" name="primary_s3_region" id="primary_s3_region"
value="{{ primary_s3_region or '' }}"
placeholder="nyc3"
class="mps-text-input" />
<br /><br />
<label for="primary_s3_bucket">Bucket Name</label>
<input type="text" name="primary_s3_bucket" id="primary_s3_bucket"
value="{{ primary_s3_bucket or '' }}"
placeholder="my-shop-media"
class="mps-text-input" />
<br /><br />
<label for="primary_s3_access_key">Access Key</label>
<input type="text" name="primary_s3_access_key" id="primary_s3_access_key"
value="{{ primary_s3_access_key or '' }}"
placeholder="Access Key"
class="mps-text-input" />
<br /><br />
<label for="primary_s3_secret_key">Secret Key</label>
<input type="password" name="primary_s3_secret_key" id="primary_s3_secret_key"
value="{{ primary_s3_secret_key or '' }}"
placeholder="Secret Key"
class="mps-text-input" />
<br /><br />
<label for="primary_s3_cdn_endpoint">CDN Endpoint URL</label>
<input type="url" name="primary_s3_cdn_endpoint" id="primary_s3_cdn_endpoint"
value="{{ primary_s3_cdn_endpoint or '' }}"
placeholder="https://my-shop-media.nyc3.cdn.digitaloceanspaces.com"
class="mps-text-input" />
<br /><br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
</form>
</section>
</section>

View file

@ -64,6 +64,9 @@
<nav class="mps-footer-links">
<a href="/">Home</a>
<a href="/cart">Cart</a>
{% if request.shop.gift_card_enabled %}
<a href="/s/{{ request.shop.uuid_str }}/gift-card">Gift Cards</a>
{% endif %}
{% if request.shop.subscriptions_enabled %}
<a href="/subscribe">Subscribe</a>
{% endif %}

View file

@ -55,7 +55,7 @@
<span class="related-content-index">&#9654;</span>
<span class="related-content-item related-content-now-playing">
{% if "thumbnail1" in product.extensions %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
{% else %}
<span class="related-content-no-thumb"></span>
{% endif %}
@ -65,7 +65,7 @@
data-product-id="{{ product.id }}"
data-title="{{ product.title }}"
data-url="{{ product.absolute_url(request) }}"
data-thumbnail="{% if 'thumbnail1' in product.extensions %}{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}{% endif %}"
data-thumbnail="{% if 'thumbnail1' in product.extensions %}{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}{% endif %}"
title="Add to queue">+</button>
</div>
{% endif %}
@ -73,7 +73,7 @@
<span class="related-content-index">{{ offset }}</span>
<a href="{{ related.absolute_url(request) }}" class="related-content-item" data-watch-id="{{ related.id }}">
{% if "thumbnail1" in related.extensions %}
<img {% if offset > 7 %}loading="lazy" {% endif %}src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}" />
<img {% if offset > 7 %}loading="lazy" {% endif %}src="{{ request.shop_cdn_endpoint }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}" />
{% else %}
<span class="related-content-no-thumb"></span>
{% endif %}
@ -83,7 +83,7 @@
data-product-id="{{ related.id }}"
data-title="{{ related.title }}"
data-url="{{ related.absolute_url(request) }}"
data-thumbnail="{% if 'thumbnail1' in related.extensions %}{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}{% endif %}"
data-thumbnail="{% if 'thumbnail1' in related.extensions %}{{ request.shop_cdn_endpoint }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}{% endif %}"
title="Add to queue">+</button>
</div>
{% endfor %}

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@
<div class="serp-item">
{% if "thumbnail1" in product.extensions %}
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
</a>
{% endif %}
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>

View file

@ -102,12 +102,13 @@ class UnauthenticatedFunctionalTests(FunctionalTests):
self.assertIn(b"Cart $0.00 (0)", res.body)
def test_root_home_page_landing_content(self):
"""Anonymous visitors see the MPS landing with tagline and signup CTA."""
"""Anonymous visitors see the MPS landing with hero, features, and CTAs."""
res = self.testapp.get("/", status=200)
body = res.body.decode()
self.assertIn("Make. Post. Sell.", body)
self.assertIn("start selling digital products", body)
self.assertIn("Make it. Post it. Sell it.", body)
self.assertIn("Commission-free", body)
self.assertIn("/join-or-log-in", body)
self.assertIn("/s/new", body)
def test_new_product_redirects(self):
redirect_res = self.testapp.get("/p/new", status=302)
@ -4429,3 +4430,318 @@ class TestAnalytics(_AuthenticatedBase):
# Now data-has-bucket="1" should be present
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}", status=200)
self.assertIn('data-has-bucket="1"', res.text)
class TestGiftCardFunctional(_AuthenticatedBase):
"""Functional tests for gift card features."""
def _enable_gift_cards(self, shop, min_dollars="5.00", max_dollars="250.00"):
"""Helper to enable gift cards on a shop via settings POST."""
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "gift-card-settings",
"gift-card-enabled-checkbox": "on",
"gift_card_min": min_dollars,
"gift_card_max": max_dollars,
"submit": "Save Settings",
},
status=302,
)
return res
def test_gift_card_page_not_enabled(self):
"""Gift card page redirects when gift cards are disabled (default)."""
shop = self._create_shop_helper()
# Gift cards are disabled by default, so GET should redirect
res = self.testapp.get(f"/s/{shop.id}/gift-card", status=302)
def test_gift_card_enable_settings(self):
"""Enable gift cards via shop settings and verify DB state."""
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "gift-card-settings",
"gift-card-enabled-checkbox": "on",
"gift_card_min": "5.00",
"gift_card_max": "250.00",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Gift cards enabled", flash)
self.dbsession.refresh(shop)
self.assertTrue(shop.gift_card_enabled)
self.assertEqual(shop.gift_card_min_in_cents, 500)
self.assertEqual(shop.gift_card_max_in_cents, 25000)
def test_gift_card_page_enabled(self):
"""Gift card page returns 200 when gift cards are enabled."""
shop = self._create_shop_helper()
self._enable_gift_cards(shop)
self.dbsession.refresh(shop)
res = self.testapp.get(f"/s/{shop.id}/gift-card", status=200)
self.assertIn("Gift Card", res.text)
def test_gift_card_manage_page(self):
"""Gift card manage page returns 200 for shop owner."""
shop = self._create_shop_helper()
self._enable_gift_cards(shop)
res = self.testapp.get(f"/s/{shop.id}/gift-cards/manage", status=200)
def test_gift_card_apply_invalid_code(self):
"""Applying a nonexistent gift card code shows error flash."""
shop = self._create_shop_helper()
# Create a product on the shop
redirect_res = self.testapp.post(
f"/p/new?shop_id={shop.id}", self.product1_params
)
res = redirect_res.follow()
product = get_all_products(self.dbsession).all()[0]
# Log out shop owner, log in as customer
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
# Add product to cart
csrf_token = self.get_csrf_token(shop.uuid_str)
self.testapp.post(
"/cart/add",
{
"product_id": product.id,
"shop_id": shop.id,
"csrf_token": csrf_token,
},
)
# Try to apply an invalid gift card code
csrf_token = self.get_csrf_token(shop.uuid_str)
res = self.testapp.post(
"/gift-card/apply",
{
"gift_card_code": "GC-DOESNOTEXIST",
"csrf_token": csrf_token,
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("does not exist", flash)
def test_gift_card_settings_validation(self):
"""Setting min > max shows validation error."""
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "gift-card-settings",
"gift-card-enabled-checkbox": "on",
"gift_card_min": "500.00",
"gift_card_max": "100.00",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Maximum must be greater than or equal to minimum", flash)
class TestEnvironmentSettings(_AuthenticatedBase):
"""MPS-14: Functional tests for environment settings."""
def test_change_to_staging(self):
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "environment-settings",
"environment": "1",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Staging", flash)
self.dbsession.refresh(shop)
self.assertEqual(shop.environment, 1)
def test_change_to_development(self):
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "environment-settings",
"environment": "2",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Development", flash)
self.dbsession.refresh(shop)
self.assertEqual(shop.environment, 2)
def test_change_back_to_production(self):
shop = self._create_shop_helper()
# First set to staging
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "environment-settings",
"environment": "1",
"submit": "Save Settings",
},
status=302,
)
# Then back to production
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "environment-settings",
"environment": "0",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Production", flash)
self.dbsession.refresh(shop)
self.assertEqual(shop.environment, 0)
def test_invalid_environment_value(self):
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "environment-settings",
"environment": "99",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Invalid", flash)
self.dbsession.refresh(shop)
self.assertEqual(shop.environment, 0)
def test_environment_banner_shows_for_staging(self):
shop = self._create_shop_helper()
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "environment-settings",
"environment": "1",
"submit": "Save Settings",
},
status=302,
)
res = self.testapp.get(f"/s/{shop.id}/settings")
self.assertIn("STAGING ENVIRONMENT", res.text)
class TestBucketSettings(_AuthenticatedBase):
"""MPS-16: Functional tests for BYOB bucket settings."""
def test_enable_bucket_settings(self):
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "bucket-settings",
"primary_s3_enabled_checkbox": "on",
"primary_s3_endpoint": "https://nyc3.digitaloceanspaces.com",
"primary_s3_region": "nyc3",
"primary_s3_bucket": "my-test-bucket",
"primary_s3_access_key": "AKID123",
"primary_s3_secret_key": "SECRET456",
"primary_s3_cdn_endpoint": "https://my-test-bucket.nyc3.cdn.digitaloceanspaces.com",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
# Connection test runs on save — may fail in test env but settings are saved
self.assertTrue(
"connection test failed" in flash or "Storage bucket settings" in flash,
f"Unexpected flash: {flash}"
)
self.dbsession.refresh(shop)
self.assertTrue(shop.primary_s3_enabled)
self.assertEqual(shop.primary_s3_bucket, "my-test-bucket")
def test_enable_bucket_missing_fields(self):
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "bucket-settings",
"primary_s3_enabled_checkbox": "on",
"primary_s3_endpoint": "https://nyc3.digitaloceanspaces.com",
"primary_s3_region": "nyc3",
"primary_s3_bucket": "",
"primary_s3_access_key": "",
"primary_s3_secret_key": "",
"primary_s3_cdn_endpoint": "",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("All bucket fields are required when enabling BYOB", flash)
self.dbsession.refresh(shop)
self.assertFalse(shop.primary_s3_enabled)
def test_disable_bucket(self):
shop = self._create_shop_helper()
# Enable first
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "bucket-settings",
"primary_s3_enabled_checkbox": "on",
"primary_s3_endpoint": "https://nyc3.digitaloceanspaces.com",
"primary_s3_region": "nyc3",
"primary_s3_bucket": "my-test-bucket",
"primary_s3_access_key": "AKID123",
"primary_s3_secret_key": "SECRET456",
"primary_s3_cdn_endpoint": "https://my-test-bucket.nyc3.cdn.digitaloceanspaces.com",
"submit": "Save Settings",
},
status=302,
)
# Then disable (checkbox not sent = off)
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "bucket-settings",
"primary_s3_endpoint": "https://nyc3.digitaloceanspaces.com",
"primary_s3_region": "nyc3",
"primary_s3_bucket": "my-test-bucket",
"primary_s3_access_key": "AKID123",
"primary_s3_secret_key": "SECRET456",
"primary_s3_cdn_endpoint": "https://my-test-bucket.nyc3.cdn.digitaloceanspaces.com",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Storage bucket settings updated", flash)
self.dbsession.refresh(shop)
self.assertFalse(shop.primary_s3_enabled)

View file

@ -27,6 +27,10 @@ from ..models.price import Price
from ..models.crypto_payment import CryptoPayment
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
from ..models.shop_location import ShopLocation
from ..models.gift_card import GiftCard, get_gift_card_by_id, get_gift_card_by_code, get_gift_cards_by_shop
from ..models.gift_card_transaction import GiftCardTransaction
from ..models.cart_gift_card import CartGiftCard
import json
import time
@ -3419,3 +3423,465 @@ class TestKaraokeTrackAclIntegration(DatabaseIntegrationTests):
product_acl,
f"vocals ACL mismatch at visibility={vis}",
)
class TestGiftCardIntegration(DatabaseIntegrationTests):
"""Integration tests for gift card functionality with real ORM objects."""
def _make_shop(self, gift_card_enabled=True):
"""Helper to create a shop with gift card support."""
shop = Shop(
name="Gift Card Shop",
phone_number="555-555-5555",
billing_address="123 Test St",
description="A shop with gift cards",
)
shop.stripe_public_api_key = "pk_test_123"
shop.stripe_secret_api_key = "sk_test_123"
shop.domain_name = "giftcards.test.com"
shop.gift_card_enabled = gift_card_enabled
self.dbsession.add(shop)
self.dbsession.flush()
return shop
def _make_product(self, shop, price_in_cents=1000):
"""Helper to create a product."""
product = Product(title="Test Product", description="Test product")
product.shop_id = shop.id
product.price_in_cents = price_in_cents
product.is_physical = False
self.dbsession.add(product)
self.dbsession.flush()
return product
def _make_user(self):
"""Helper to create a user."""
user = get_or_create_user_by_email(self.dbsession, "test@example.com")
self.dbsession.add(user)
self.dbsession.flush()
return user
def _make_cart(self, user, shop):
"""Helper to create a cart."""
cart = Cart(user=user)
cart.shop = shop
self.dbsession.add(cart)
self.dbsession.flush()
return cart
def test_gift_card_with_real_shop_integration(self):
"""Test gift card creation and lookup with real shop."""
shop = self._make_shop(gift_card_enabled=True)
gift_card = GiftCard(shop=shop, amount_in_cents=5000)
self.dbsession.add(gift_card)
self.dbsession.flush()
# Test get_gift_card_by_id
found = get_gift_card_by_id(self.dbsession, gift_card.id)
self.assertIsNotNone(found)
self.assertEqual(found.id, gift_card.id)
self.assertEqual(found.initial_amount_in_cents, 5000)
self.assertEqual(found.balance_in_cents, 5000)
# Test get_gift_card_by_code with shop filter
found_by_code = get_gift_card_by_code(
self.dbsession, gift_card.code, shop=shop
)
self.assertIsNotNone(found_by_code)
self.assertEqual(found_by_code.id, gift_card.id)
# Test get_gift_cards_by_shop returns this gift card
shop_cards = get_gift_cards_by_shop(self.dbsession, shop).all()
self.assertEqual(len(shop_cards), 1)
self.assertEqual(shop_cards[0].id, gift_card.id)
transaction.commit()
def test_cart_with_gift_card_integration(self):
"""Test cart with attached gift card reduces totals correctly."""
user = self._make_user()
shop = self._make_shop()
product = self._make_product(shop, price_in_cents=1000) # $10
cart = self._make_cart(user, shop)
cart.add_product(product)
# Create gift card with $50 balance
gift_card = GiftCard(shop=shop, amount_in_cents=5000)
self.dbsession.add(gift_card)
self.dbsession.flush()
# Attach gift card to cart via CartGiftCard
cart_gift_card = CartGiftCard(cart=cart, gift_card=gift_card)
self.dbsession.add(cart_gift_card)
self.dbsession.flush()
# Test gift card appears in cart.gift_cards
self.assertEqual(len(list(cart.gift_cards)), 1)
# Test cart is discounted
self.assertTrue(cart.is_discounted)
# Test discounted_shop_totals_in_cents shows reduced amount
shop_uuid = shop.uuid_str
discounted = cart.discounted_shop_totals_in_cents
self.assertEqual(discounted[shop_uuid], 0) # $10 - $50 gift card = $0
# Test gift_card_deductions dict has the gift card's uuid_str as key
deductions = cart.gift_card_deductions
self.assertIn(gift_card.uuid_str, deductions)
self.assertEqual(deductions[gift_card.uuid_str], 1000) # Deducted $10
transaction.commit()
def test_gift_card_deduction_integration(self):
"""Test gift card deduct method reduces balance correctly."""
shop = self._make_shop()
gift_card = GiftCard(shop=shop, amount_in_cents=2000) # $20
self.dbsession.add(gift_card)
self.dbsession.flush()
# Deduct $15
result = gift_card.deduct(1500)
self.assertEqual(result, 1500)
self.assertEqual(gift_card.balance_in_cents, 500)
self.assertTrue(gift_card.is_valid)
# Deduct remaining $5
result = gift_card.deduct(500)
self.assertEqual(result, 500)
self.assertEqual(gift_card.balance_in_cents, 0)
self.assertTrue(gift_card.is_fully_redeemed)
self.assertFalse(gift_card.is_valid)
transaction.commit()
def test_gift_card_transaction_integration(self):
"""Test gift card transaction records are linked correctly."""
user = self._make_user()
shop = self._make_shop()
product = self._make_product(shop, price_in_cents=1000)
gift_card = GiftCard(shop=shop, amount_in_cents=5000)
self.dbsession.add(gift_card)
self.dbsession.flush()
# Create an invoice for the transaction
invoice = Invoice(user=user)
self.dbsession.add(invoice)
self.dbsession.flush()
# Create a GiftCardTransaction
txn = GiftCardTransaction(
gift_card=gift_card, invoice=invoice, amount_in_cents=1000
)
self.dbsession.add(txn)
self.dbsession.flush()
# Assert transaction appears in gift_card.transactions
transactions = gift_card.transactions.all()
self.assertEqual(len(transactions), 1)
self.assertEqual(transactions[0].amount_in_cents, 1000)
transaction.commit()
def test_cart_with_gift_card_and_coupon_integration(self):
"""Test coupon applies first, then gift card reduces remaining total."""
user = self._make_user()
shop = self._make_shop()
product = self._make_product(shop, price_in_cents=2000) # $20
cart = self._make_cart(user, shop)
cart.add_product(product)
# Create 50% off coupon ($10 off via dollar-off for predictability)
coupon = Coupon(
shop=shop,
code="HALF",
description="$10 off",
action_type="dollar-off",
action_value=10, # $10.00 off
max_redemptions=100,
max_redemptions_per_user=1,
cart_qualifier=0,
)
self.dbsession.add(coupon)
self.dbsession.flush()
# Attach coupon to cart
cart_coupon = CartCoupon(cart=cart, coupon=coupon)
self.dbsession.add(cart_coupon)
self.dbsession.flush()
# Create gift card with $5 balance
gift_card = GiftCard(shop=shop, amount_in_cents=500)
self.dbsession.add(gift_card)
self.dbsession.flush()
# Attach gift card to cart
cart_gift_card = CartGiftCard(cart=cart, gift_card=gift_card)
self.dbsession.add(cart_gift_card)
self.dbsession.flush()
# Coupon reduces $20 to $10, gift card reduces $10 to $5
shop_uuid = shop.uuid_str
discounted = cart.discounted_shop_totals_in_cents
self.assertEqual(discounted[shop_uuid], 500) # $5.00
transaction.commit()
def test_gift_card_validate_attached_integration(self):
"""Test validation catches disabled gift cards attached to cart."""
user = self._make_user()
shop = self._make_shop()
product = self._make_product(shop, price_in_cents=1000)
cart = self._make_cart(user, shop)
cart.add_product(product)
# Create a disabled gift card
gift_card = GiftCard(shop=shop, amount_in_cents=5000)
gift_card.disabled = True
self.dbsession.add(gift_card)
self.dbsession.flush()
# Attach disabled gift card to cart
cart_gift_card = CartGiftCard(cart=cart, gift_card=gift_card)
self.dbsession.add(cart_gift_card)
self.dbsession.flush()
# Validate should return errors about disabled card
errors = cart.validate_attached_gift_cards()
self.assertTrue(len(errors) > 0)
self.assertTrue(
any("disabled" in err.lower() for err in errors),
f"Expected 'disabled' in error messages, got: {errors}",
)
transaction.commit()
def test_gift_card_purchases_in_cart_integration(self):
"""Test cart.json_gift_cards parses gift card purchase entries."""
user = self._make_user()
shop = self._make_shop()
cart = self._make_cart(user, shop)
# Set json_gift_cards with one purchase entry
cart.json_gift_cards = json.dumps([
{
"shop_id": shop.uuid_str,
"amount_in_cents": 2500,
"gift_email": "friend@test.com",
"gift_message": "Enjoy!",
}
])
self.dbsession.flush()
# Test gift_card_purchases returns parsed list
purchases = cart.gift_card_purchases
self.assertEqual(len(purchases), 1)
self.assertEqual(purchases[0]["amount_in_cents"], 2500)
self.assertEqual(purchases[0]["gift_email"], "friend@test.com")
# Test total
self.assertEqual(cart.gift_card_purchases_total_in_cents, 2500)
transaction.commit()
class TestTrialIntegration(DatabaseIntegrationTests):
"""MPS-15: Integration tests for trial system with real ORM objects."""
def _make_shop(self, **kwargs):
shop = Shop(
name="Trial Shop",
phone_number="555-555-5555",
billing_address="123 Test St",
description="A trial shop",
)
shop.domain_name = "trial.test.com"
for k, v in kwargs.items():
setattr(shop, k, v)
self.dbsession.add(shop)
self.dbsession.flush()
return shop
def test_grandfathered_shop_not_expired(self):
"""Pre-trial shops (NULL trial_started_timestamp) are never expired."""
shop = self._make_shop(trial_started_timestamp=None)
self.assertFalse(shop.is_trial_expired)
self.assertFalse(shop.is_trial_active)
self.assertTrue(shop.is_active)
transaction.commit()
def test_active_trial(self):
"""Shop within 21-day trial window is active."""
now_ms = int(time.time() * 1000)
shop = self._make_shop(trial_started_timestamp=now_ms)
self.assertTrue(shop.is_trial_active)
self.assertFalse(shop.is_trial_expired)
self.assertTrue(shop.is_active)
self.assertIn(shop.trial_days_remaining, (20, 21)) # depends on sub-day timing
transaction.commit()
def test_expired_trial(self):
"""Shop past 21-day window is expired."""
expired_ms = int(time.time() * 1000) - (22 * 24 * 60 * 60 * 1000)
shop = self._make_shop(trial_started_timestamp=expired_ms)
self.assertFalse(shop.is_trial_active)
self.assertTrue(shop.is_trial_expired)
self.assertFalse(shop.is_active)
self.assertEqual(shop.trial_days_remaining, 0)
transaction.commit()
def test_paid_plan_overrides_trial(self):
"""Paid plan makes shop active regardless of trial status."""
expired_ms = int(time.time() * 1000) - (22 * 24 * 60 * 60 * 1000)
shop = self._make_shop(trial_started_timestamp=expired_ms, plan_active=True)
self.assertFalse(shop.is_trial_active)
self.assertFalse(shop.is_trial_expired)
self.assertTrue(shop.is_active)
transaction.commit()
def test_trial_with_products(self):
"""Products in a trial shop are accessible but cannot be created when expired."""
expired_ms = int(time.time() * 1000) - (22 * 24 * 60 * 60 * 1000)
shop = self._make_shop(trial_started_timestamp=expired_ms)
# Existing products are still in the database and readable
product = Product(title="Existing Product", description="Created before trial expired")
product.shop_id = shop.id
product.price_in_cents = 1000
self.dbsession.add(product)
self.dbsession.flush()
# Shop is expired but product still exists
self.assertTrue(shop.is_trial_expired)
self.assertEqual(product.shop_id, shop.id)
transaction.commit()
def test_environment_with_trial(self):
"""Environment and trial are independent — non-prod shop can have trial."""
now_ms = int(time.time() * 1000)
shop = self._make_shop(trial_started_timestamp=now_ms, environment=1)
self.assertTrue(shop.is_trial_active)
self.assertTrue(shop.is_staging)
self.assertTrue(shop.is_non_production)
transaction.commit()
class TestBYOBIntegration(DatabaseIntegrationTests):
"""MPS-16: Integration tests for BYOB (Bring Your Own Bucket) with real ORM objects."""
def _make_shop(self, **kwargs):
shop = Shop(
name="BYOB Shop",
phone_number="555-555-5555",
billing_address="123 Test St",
description="A BYOB shop",
)
shop.domain_name = "byob.test.com"
for k, v in kwargs.items():
setattr(shop, k, v)
self.dbsession.add(shop)
self.dbsession.flush()
return shop
def test_has_primary_s3_false_by_default(self):
"""New shops do not have BYOB enabled."""
shop = self._make_shop()
self.assertFalse(shop.has_primary_s3)
transaction.commit()
def test_has_primary_s3_requires_all_fields(self):
"""BYOB requires all fields AND enabled flag."""
shop = self._make_shop(
primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
primary_s3_region="nyc3",
primary_s3_bucket="my-bucket",
primary_s3_access_key="AKIATEST",
primary_s3_secret_key="secret123",
primary_s3_cdn_endpoint="https://my-bucket.nyc3.cdn.digitaloceanspaces.com",
primary_s3_enabled=False, # not enabled
)
self.assertFalse(shop.has_primary_s3)
shop.primary_s3_enabled = True
self.assertTrue(shop.has_primary_s3)
transaction.commit()
def test_has_primary_s3_missing_field(self):
"""BYOB is false if any required field is missing."""
shop = self._make_shop(
primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
primary_s3_region="nyc3",
primary_s3_bucket="my-bucket",
primary_s3_access_key="AKIATEST",
primary_s3_secret_key="", # missing
primary_s3_cdn_endpoint="https://my-bucket.nyc3.cdn.digitaloceanspaces.com",
primary_s3_enabled=True,
)
self.assertFalse(shop.has_primary_s3)
transaction.commit()
def test_media_cdn_endpoint_byob(self):
"""media_cdn_endpoint returns shop's CDN when BYOB is active."""
shop = self._make_shop(
primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
primary_s3_region="nyc3",
primary_s3_bucket="my-bucket",
primary_s3_access_key="AKIATEST",
primary_s3_secret_key="secret123",
primary_s3_cdn_endpoint="https://custom-cdn.example.com",
primary_s3_enabled=True,
)
self.assertEqual(shop.media_cdn_endpoint, "https://custom-cdn.example.com")
# Disable BYOB — CDN returns None
shop.primary_s3_enabled = False
self.assertIsNone(shop.media_cdn_endpoint)
transaction.commit()
def test_byob_with_mirror(self):
"""BYOB and mirror can coexist on the same shop."""
shop = self._make_shop(
primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
primary_s3_region="nyc3",
primary_s3_bucket="primary-bucket",
primary_s3_access_key="AKIATEST",
primary_s3_secret_key="secret123",
primary_s3_cdn_endpoint="https://primary-cdn.example.com",
primary_s3_enabled=True,
mirror_s3_endpoint="https://sfo3.digitaloceanspaces.com",
mirror_s3_region="sfo3",
mirror_s3_bucket="mirror-bucket",
mirror_s3_access_key="AKIAMIRROR",
mirror_s3_secret_key="mirrorsecret",
mirror_s3_enabled=True,
)
self.assertTrue(shop.has_primary_s3)
self.assertTrue(shop.has_s3_mirror)
transaction.commit()
def test_byob_persists_after_flush(self):
"""BYOB fields round-trip through database correctly."""
shop = self._make_shop(
primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
primary_s3_region="nyc3",
primary_s3_bucket="test-bucket",
primary_s3_access_key="AKIATEST",
primary_s3_secret_key="secret123",
primary_s3_cdn_endpoint="https://cdn.example.com",
primary_s3_enabled=True,
)
shop_id = shop.id
self.dbsession.flush()
# Re-fetch from DB
fetched = self.dbsession.query(Shop).get(shop_id)
self.assertTrue(fetched.has_primary_s3)
self.assertEqual(fetched.primary_s3_bucket, "test-bucket")
self.assertEqual(fetched.primary_s3_cdn_endpoint, "https://cdn.example.com")
transaction.commit()

View file

@ -3399,3 +3399,227 @@ class TestSentiment(unittest.TestCase):
def test_short_negative(self):
self.assertEqual(self._classify("hate it"), -1)
class TestGiftCard(unittest.TestCase):
def test_gift_card_creation(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
self.assertEqual(gc.initial_amount_in_cents, 5000)
self.assertEqual(gc.balance_in_cents, 5000)
self.assertFalse(gc.disabled)
self.assertTrue(gc.code.startswith("GC-"))
self.assertEqual(len(gc.code), 19) # GC- + 16 hex chars
def test_gift_card_is_valid(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
self.assertTrue(gc.is_valid)
def test_gift_card_disabled_not_valid(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
gc.disabled = True
self.assertFalse(gc.is_valid)
def test_gift_card_zero_balance_not_valid(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
gc.balance_in_cents = 0
self.assertFalse(gc.is_valid)
def test_gift_card_deduct(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
deducted = gc.deduct(2000)
self.assertEqual(deducted, 2000)
self.assertEqual(gc.balance_in_cents, 3000)
def test_gift_card_deduct_more_than_balance(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=1000)
deducted = gc.deduct(5000)
self.assertEqual(deducted, 1000)
self.assertEqual(gc.balance_in_cents, 0)
def test_gift_card_balance_property(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
self.assertEqual(gc.balance, 50.00)
self.assertEqual(gc.initial_amount, 50.00)
def test_gift_card_is_fully_redeemed(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(shop=shop, amount_in_cents=5000)
self.assertFalse(gc.is_fully_redeemed)
gc.balance_in_cents = 0
self.assertTrue(gc.is_fully_redeemed)
def test_gift_card_code_uniqueness(self):
from make_post_sell.models.gift_card import generate_gift_card_code
codes = set()
for _ in range(100):
codes.add(generate_gift_card_code())
self.assertEqual(len(codes), 100)
def test_gift_card_with_gift_email(self):
from make_post_sell.models.gift_card import GiftCard
shop = mock.MagicMock()
shop.id = uuid.uuid1()
gc = GiftCard(
shop=shop,
amount_in_cents=2500,
purchaser_email="buyer@test.com",
gift_email="friend@test.com",
gift_message="Happy birthday!",
)
self.assertEqual(gc.gift_email, "friend@test.com")
self.assertEqual(gc.gift_message, "Happy birthday!")
self.assertEqual(gc.purchaser_email, "buyer@test.com")
class TestShopEnvironment(unittest.TestCase):
"""MPS-14: Dev & Stage environment properties."""
def _make_shop(self, environment=0):
shop = Shop("env-test", "555-0000", "123 Test St", "test shop")
shop.environment = environment
return shop
def test_default_is_production(self):
shop = self._make_shop()
self.assertTrue(shop.is_production)
self.assertFalse(shop.is_non_production)
self.assertEqual(shop.environment_label, "Production")
def test_staging_environment(self):
shop = self._make_shop(environment=1)
self.assertTrue(shop.is_staging)
self.assertTrue(shop.is_non_production)
self.assertFalse(shop.is_production)
self.assertEqual(shop.environment_label, "Staging")
def test_development_environment(self):
shop = self._make_shop(environment=2)
self.assertTrue(shop.is_development)
self.assertTrue(shop.is_non_production)
self.assertFalse(shop.is_production)
self.assertEqual(shop.environment_label, "Development")
def test_unknown_environment_defaults_to_production_label(self):
shop = self._make_shop(environment=99)
self.assertEqual(shop.environment_label, "Production")
self.assertTrue(shop.is_non_production)
class TestShopTrial(unittest.TestCase):
"""MPS-15: 21-day free trial properties."""
def _make_shop(self, trial_started_ms=None, plan_active=False):
shop = Shop("trial-test", "555-0000", "123 Test St", "test shop")
shop.trial_started_timestamp = trial_started_ms
shop.plan_active = plan_active
return shop
def test_grandfathered_shop_is_active(self):
"""Pre-trial shops (NULL timestamp) are always active."""
shop = self._make_shop(trial_started_ms=None)
self.assertTrue(shop.is_active)
self.assertFalse(shop.is_trial_active)
self.assertFalse(shop.is_trial_expired)
self.assertIsNone(shop.trial_expiry_timestamp)
@mock.patch("make_post_sell.models.shop.time")
def test_trial_active_within_21_days(self, mock_time):
import time as real_time
now_ms = int(real_time.time() * 1000)
# Started 10 days ago
started = now_ms - (10 * 24 * 60 * 60 * 1000)
shop = self._make_shop(trial_started_ms=started)
mock_time.time.return_value = now_ms / 1000.0
self.assertTrue(shop.is_trial_active)
self.assertFalse(shop.is_trial_expired)
self.assertTrue(shop.is_active)
self.assertGreater(shop.trial_days_remaining, 0)
@mock.patch("make_post_sell.models.shop.time")
def test_trial_expired_after_21_days(self, mock_time):
import time as real_time
now_ms = int(real_time.time() * 1000)
# Started 22 days ago
started = now_ms - (22 * 24 * 60 * 60 * 1000)
shop = self._make_shop(trial_started_ms=started)
mock_time.time.return_value = now_ms / 1000.0
self.assertFalse(shop.is_trial_active)
self.assertTrue(shop.is_trial_expired)
self.assertFalse(shop.is_active)
self.assertEqual(shop.trial_days_remaining, 0)
@mock.patch("make_post_sell.models.shop.time")
def test_paid_plan_overrides_trial(self, mock_time):
import time as real_time
now_ms = int(real_time.time() * 1000)
# Started 22 days ago but plan is active
started = now_ms - (22 * 24 * 60 * 60 * 1000)
shop = self._make_shop(trial_started_ms=started, plan_active=True)
mock_time.time.return_value = now_ms / 1000.0
self.assertFalse(shop.is_trial_active)
self.assertFalse(shop.is_trial_expired)
self.assertTrue(shop.is_active)
def test_trial_expiry_timestamp(self):
shop = self._make_shop(trial_started_ms=1000000)
expected = 1000000 + (21 * 24 * 60 * 60 * 1000)
self.assertEqual(shop.trial_expiry_timestamp, expected)
class TestShopBYOB(unittest.TestCase):
"""MPS-16: Bring Your Own Bucket properties."""
def _make_shop(self, enabled=False, **kwargs):
shop = Shop("byob-test", "555-0000", "123 Test St", "test shop")
shop.primary_s3_enabled = enabled
shop.primary_s3_endpoint = kwargs.get("endpoint", "https://nyc3.digitaloceanspaces.com")
shop.primary_s3_region = kwargs.get("region", "nyc3")
shop.primary_s3_bucket = kwargs.get("bucket", "my-bucket")
shop.primary_s3_access_key = kwargs.get("access_key", "AKID")
shop.primary_s3_secret_key = kwargs.get("secret_key", "SECRET")
shop.primary_s3_cdn_endpoint = kwargs.get("cdn_endpoint", "https://my-bucket.nyc3.cdn.digitaloceanspaces.com")
return shop
def test_has_primary_s3_when_enabled_and_configured(self):
shop = self._make_shop(enabled=True)
self.assertTrue(shop.has_primary_s3)
def test_has_primary_s3_false_when_disabled(self):
shop = self._make_shop(enabled=False)
self.assertFalse(shop.has_primary_s3)
def test_has_primary_s3_false_when_missing_fields(self):
shop = self._make_shop(enabled=True, access_key="")
self.assertFalse(shop.has_primary_s3)
def test_media_cdn_endpoint_returns_custom_when_configured(self):
shop = self._make_shop(enabled=True)
self.assertEqual(shop.media_cdn_endpoint, "https://my-bucket.nyc3.cdn.digitaloceanspaces.com")
def test_media_cdn_endpoint_returns_none_when_not_configured(self):
shop = self._make_shop(enabled=False)
self.assertIsNone(shop.media_cdn_endpoint)

View file

@ -88,6 +88,30 @@ def shop_owner_required(
return wrapped
# view decorator.
def trial_active_required(
flash_msg="Your 21-day trial has expired. Choose a plan to continue.",
flash_level="error",
):
"""Block write operations when shop trial has expired.
Grandfathered shops (NULL trial_started_timestamp) and paid shops pass through.
Only blocks shops that have an expired trial with no active plan.
"""
def wrapped(fn):
def inner(request):
shop = request.shop
if shop and shop.is_trial_expired:
request.session.flash((flash_msg, flash_level))
return HTTPFound(get_referer_or_home(request))
return fn(request)
return inner
return wrapped
# view decorator.
def shop_editor_required(
flash_msg="You must have a shop editor role to access that.",

View file

@ -4,11 +4,14 @@ from . import (
user_required,
get_referer_or_home,
shop_is_ready_required,
trial_active_required,
)
from ..models.cart import get_cart_by_id
from ..models.product import get_product_by_id
from ..models.invoice import Invoice, InvoiceLineItem
from ..models.gift_card import GiftCard, get_gift_card_by_code
from ..models.gift_card_transaction import GiftCardTransaction
from pyramid.httpexceptions import HTTPFound
@ -22,6 +25,76 @@ import traceback
from datetime import datetime
def _create_gift_card_transactions(cart, invoices, request):
"""After successful checkout, deduct gift card balances and create transaction records."""
import json
if cart.gift_cards:
deductions = cart.gift_card_deductions
for gift_card in cart.gift_cards:
deduction = deductions.get(gift_card.uuid_str, 0)
if deduction > 0:
# Find the invoice for this gift card's shop
invoice = None
for inv in invoices:
if inv.shop and str(inv.shop.id) == str(gift_card.shop_id):
invoice = inv
break
# Also check shop_id directly
if hasattr(inv, 'shop_id') and str(inv.shop_id) == str(gift_card.shop_id):
invoice = inv
break
if invoice:
gift_card.deduct(deduction)
txn = GiftCardTransaction(
gift_card=gift_card,
invoice=invoice,
amount_in_cents=deduction,
)
request.dbsession.add(gift_card)
request.dbsession.add(txn)
# Create GiftCard records for gift card purchases in the cart
gc_purchases = json.loads(cart.json_gift_cards or "[]")
created_gift_cards = []
if gc_purchases:
from ..models.shop import get_shop_by_id
from ..lib.mail import send_gift_card_email
for gc_item in gc_purchases:
shop = get_shop_by_id(request.dbsession, gc_item["shop_id"])
if shop:
# Find the invoice for this shop
invoice = None
for inv in invoices:
if str(inv.shop_id) == str(shop.id):
invoice = inv
break
new_gc = GiftCard(
shop=shop,
amount_in_cents=gc_item["amount_in_cents"],
purchaser_email=request.user.email if request.user else None,
gift_email=gc_item.get("gift_email"),
gift_message=gc_item.get("gift_message"),
invoice=invoice,
)
request.dbsession.add(new_gc)
created_gift_cards.append(new_gc)
# Send gift card email to recipient if gift_email is set
if gc_item.get("gift_email"):
try:
send_gift_card_email(request, new_gc)
except Exception:
pass # Don't fail checkout over email
# Clear gift card purchases from cart
cart.json_gift_cards = "[]"
return created_gift_cards
def get_cart_from_matchdict(request):
"""
This function uses the cart_id from the url path
@ -452,6 +525,7 @@ def cart_handling_option(request):
redirect_to_route_name="join-or-log-in",
)
@shop_is_ready_required()
@trial_active_required()
def cart_checkout(request):
stripe_user_shop = request.shop.stripe_user_shop(request.user)
paypal_user_shop = request.shop.paypal_user_shop(request.user)
@ -478,6 +552,13 @@ def cart_checkout(request):
request.session.flash((error_message, "error"))
return HTTPFound(get_referer_or_home(request))
# Validate gift cards
gc_errors = cart.validate_attached_gift_cards()
if gc_errors:
for error_message in gc_errors:
request.session.flash((error_message, "error"))
return HTTPFound(get_referer_or_home(request))
# Check handling options, inventory, and address for physical products
if cart.physical_products:
inventory_errors = cart.check_inventory(request.shop_location)
@ -601,6 +682,7 @@ def cart_checkout(request):
)
@user_required()
@shop_is_ready_required()
@trial_active_required()
def cart_complete_checkout(request):
stripe_enabled = request.stripe_enabled
@ -638,6 +720,7 @@ def cart_complete_checkout(request):
return HTTPFound("/billing")
error_messages = cart.validate_attached_coupons()
error_messages += cart.validate_attached_gift_cards()
if error_messages:
for error_message in error_messages:
request.session.flash((error_message, "error"))
@ -708,6 +791,7 @@ def cart_complete_checkout(request):
request.dbsession.add(invoice)
cart.update_inventory(request.shop_location)
_create_gift_card_transactions(cart, invoices, request)
msg = ("Success, you have completed the purchase!", "success")
request.session.flash(msg)
@ -747,6 +831,7 @@ def cart_complete_checkout(request):
)
@user_required()
@shop_is_ready_required()
@trial_active_required()
def paypal_complete_checkout(request):
"""Complete checkout using PayPal payment."""
if not request.paypal_enabled:
@ -768,6 +853,7 @@ def paypal_complete_checkout(request):
return HTTPFound(get_referer_or_home(request))
error_messages = cart.validate_attached_coupons()
error_messages += cart.validate_attached_gift_cards()
if error_messages:
for error_message in error_messages:
request.session.flash((error_message, "error"))
@ -920,6 +1006,9 @@ def paypal_complete_checkout(request):
invoice.total,
)
if successful_invoices:
_create_gift_card_transactions(cart, successful_invoices, request)
if successful_invoices and not failed_shops:
request.session.flash(("Success! You have completed the purchase.", "success"))
elif successful_invoices and failed_shops:
@ -1025,6 +1114,7 @@ def adyen_create_session(request):
)
@user_required()
@shop_is_ready_required()
@trial_active_required()
def adyen_complete_checkout(request):
"""Complete checkout using Adyen payment."""
if not getattr(request, "adyen_enabled", False):
@ -1046,6 +1136,7 @@ def adyen_complete_checkout(request):
return HTTPFound(get_referer_or_home(request))
error_messages = cart.validate_attached_coupons()
error_messages += cart.validate_attached_gift_cards()
if error_messages:
for error_message in error_messages:
request.session.flash((error_message, "error"))
@ -1147,6 +1238,9 @@ def adyen_complete_checkout(request):
invoice.total,
)
if successful_invoices:
_create_gift_card_transactions(cart, successful_invoices, request)
if successful_invoices and not failed_shops:
request.session.flash(("Success! You have completed the purchase.", "success"))
elif successful_invoices and failed_shops:

View file

@ -29,7 +29,7 @@ def content(request):
signed_get_object_url = None
bucket_name = request.app["bucket.secure_uploads"]
bucket_name = request.shop_bucket_name
# Params: Bucket, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince,
# Key, Range, ResponseCacheControl, ResponseContentDisposition, ResponseProductEncoding,
@ -55,7 +55,7 @@ def content(request):
if content_type:
params["ResponseContentType"] = content_type
signed_get_object_url = request.secure_uploads_client.generate_presigned_url(
signed_get_object_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
# 15 minutes.
@ -88,7 +88,7 @@ def content(request):
extension = product.extensions.get("product")
media_type = get_media_type(extension) if extension else None
if media_type in ("video", "audio"):
cdn_base = request.app["bucket.secure_uploads.get_endpoint"]
cdn_base = request.shop_cdn_endpoint
for track_name in ("instrumentals", "vocals"):
if track_name in product.extensions:
url = f"{cdn_base}/{product.s3_path}/{track_name}"

View file

@ -206,7 +206,7 @@ def sitemap_view(request):
"""Generate XML sitemap for the shop."""
shop = request.shop
if not shop:
if not shop or shop.is_non_production:
response = Response(body="<?xml version='1.0' encoding='UTF-8'?><urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'></urlset>")
response.content_type = "application/xml"
return response
@ -226,7 +226,7 @@ def rss_view(request):
"""Generate RSS 2.0 feed for the shop."""
shop = request.shop
if not shop:
if not shop or shop.is_non_production:
response = Response(body="<?xml version='1.0' encoding='UTF-8'?><rss version='2.0'><channel></channel></rss>")
response.content_type = "application/xml"
return response
@ -245,7 +245,7 @@ def atom_view(request):
"""Generate Atom feed for the shop."""
shop = request.shop
if not shop:
if not shop or shop.is_non_production:
response = Response(body="<?xml version='1.0' encoding='UTF-8'?><feed xmlns='http://www.w3.org/2005/Atom'></feed>")
response.content_type = "application/xml"
return response

View file

@ -0,0 +1,238 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from . import (
user_required,
shop_owner_required,
trial_active_required,
get_referer_or_home,
)
from ..models.gift_card import (
GiftCard,
get_gift_card_by_id,
get_gift_card_by_code,
get_gift_cards_by_shop,
)
from ..models.cart import get_cart_by_id
from ..lib.currency import validate_int, cents_to_dollars, dollars_to_cents
@view_config(route_name="gift_card_page", renderer="gift_card.j2")
def gift_card_page(request):
"""Gift card purchase page for a shop."""
shop = request.shop
if not shop or not shop.gift_card_enabled:
request.session.flash(("Gift cards are not available for this shop.", "error"))
return HTTPFound(get_referer_or_home(request))
# Handle balance check
balance_result = None
check_code = request.params.get("check_code", "").strip()
if check_code:
card = get_gift_card_by_code(request.dbsession, check_code, shop=shop)
if card and card.is_valid:
balance_result = {
"code": card.code,
"balance": card.balance,
"initial_amount": card.initial_amount,
}
elif card and card.disabled:
balance_result = {"error": "This gift card has been disabled."}
elif card and card.is_fully_redeemed:
balance_result = {"error": "This gift card has been fully redeemed."}
else:
balance_result = {"error": "Gift card not found for this shop."}
return {
"shop": shop,
"min_dollars": cents_to_dollars(shop.gift_card_min_in_cents),
"max_dollars": cents_to_dollars(shop.gift_card_max_in_cents),
"balance_result": balance_result,
}
@view_config(route_name="gift_card_add_to_cart", request_method="POST", require_csrf=True)
@user_required()
@trial_active_required()
def gift_card_add_to_cart(request):
"""Add a gift card to the active cart."""
shop = request.shop
if not shop or not shop.gift_card_enabled:
request.session.flash(("Gift cards are not available for this shop.", "error"))
return HTTPFound(get_referer_or_home(request))
try:
amount_dollars = float(request.params.get("amount", "0"))
amount_in_cents = int(amount_dollars * 100)
except (ValueError, TypeError):
request.session.flash(("Invalid amount.", "error"))
return HTTPFound(get_referer_or_home(request))
if amount_in_cents < shop.gift_card_min_in_cents:
request.session.flash(
(f"Minimum gift card amount is ${cents_to_dollars(shop.gift_card_min_in_cents):,.2f}.", "error")
)
return HTTPFound(get_referer_or_home(request))
if amount_in_cents > shop.gift_card_max_in_cents:
request.session.flash(
(f"Maximum gift card amount is ${cents_to_dollars(shop.gift_card_max_in_cents):,.2f}.", "error")
)
return HTTPFound(get_referer_or_home(request))
gift_email = request.params.get("gift_email", "").strip() or None
gift_message = request.params.get("gift_message", "").strip() or None
# Store gift card purchase intent in cart's json_gift_cards
import json
cart = request.active_cart
gift_cards_data = json.loads(cart.json_gift_cards) if hasattr(cart, 'json_gift_cards') and cart.json_gift_cards else []
gift_cards_data.append({
"shop_id": shop.uuid_str,
"amount_in_cents": amount_in_cents,
"gift_email": gift_email,
"gift_message": gift_message,
})
cart.json_gift_cards = json.dumps(gift_cards_data)
request.dbsession.add(cart)
request.dbsession.flush()
request.session.flash(
(f"${amount_dollars:,.2f} gift card added to cart.", "success")
)
return HTTPFound(f"/cart/{cart.id}")
@view_config(route_name="gift_card_apply_to_cart", request_method="POST", require_csrf=True)
def gift_card_apply_to_cart(request):
"""Apply a gift card code to the active cart (for redemption at checkout)."""
code = request.params.get("gift_card_code", "").strip()
if not code:
request.session.flash(("Please enter a gift card code.", "error"))
return HTTPFound(f"/cart/{request.active_cart.id}")
gift_card = get_gift_card_by_code(request.dbsession, code)
if gift_card is None:
request.session.flash(("That gift card code does not exist.", "error"))
return HTTPFound(f"/cart/{request.active_cart.id}")
if gift_card.disabled:
request.session.flash(("That gift card has been disabled.", "error"))
return HTTPFound(f"/cart/{request.active_cart.id}")
if gift_card.balance_in_cents <= 0:
request.session.flash(("That gift card has no remaining balance.", "error"))
return HTTPFound(f"/cart/{request.active_cart.id}")
# Check if gift card's shop is in the cart
if gift_card.shop_uuid_str not in request.active_cart.shop_totals_in_cents:
request.session.flash(
("That gift card is not valid for any shop in your cart.", "error")
)
return HTTPFound(f"/cart/{request.active_cart.id}")
if gift_card not in request.active_cart.gift_cards:
request.active_cart.gift_cards.append(gift_card)
request.active_cart._bust_memoized_attributes()
request.dbsession.add(request.active_cart)
request.dbsession.flush()
msg = (
f"Gift card applied! Balance: ${gift_card.balance:,.2f}",
"success",
)
else:
msg = ("That gift card is already applied to your cart.", "info")
request.session.flash(msg)
return HTTPFound(f"/cart/{request.active_cart.id}")
@view_config(route_name="gift_card_remove_from_cart", request_method="POST", require_csrf=True)
def gift_card_remove_from_cart(request):
"""Remove a gift card from a cart."""
cart_id = request.params.get("cart_id")
gift_card_id = request.params.get("gift_card_id")
cart = get_cart_by_id(request.dbsession, cart_id)
gift_card = get_gift_card_by_id(request.dbsession, gift_card_id)
if cart is None:
msg = ("That cart does not exist.", "error")
elif gift_card is None:
msg = ("That gift card does not exist.", "error")
elif request.user and request.user.does_not_own_cart(cart):
msg = ("You do not own that cart.", "error")
elif request.user is None and cart.user is not None:
msg = ("You do not own this cart.", "error")
else:
if gift_card in cart.gift_cards:
cart.gift_cards.remove(gift_card)
cart._bust_memoized_attributes()
request.dbsession.add(cart)
request.dbsession.flush()
msg = ("Gift card removed from your cart.", "success")
else:
msg = ("That gift card was already removed from your cart.", "info")
request.session.flash(msg)
return HTTPFound(f"/cart/{cart_id}")
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
# --- Shop Admin Views ---
@view_config(route_name="gift_card_manage", renderer="gift_card_manage.j2")
@shop_owner_required()
def gift_card_manage(request):
"""Gift card management page for shop owners."""
shop = request.shop
gift_cards = get_gift_cards_by_shop(request.dbsession, shop).all()
total_issued = sum(gc.initial_amount_in_cents for gc in gift_cards)
total_balance = sum(gc.balance_in_cents for gc in gift_cards)
return {
"gift_cards": gift_cards,
"total_issued_dollars": cents_to_dollars(total_issued),
"total_balance_dollars": cents_to_dollars(total_balance),
}
@view_config(route_name="gift_card_detail", renderer="gift_card_detail.j2")
@shop_owner_required()
def gift_card_detail(request):
"""Gift card detail page for shop owners."""
gift_card = get_gift_card_by_id(request.dbsession, request.matchdict["gift_card_id"])
if gift_card is None:
request.session.flash(("Gift card not found.", "error"))
return HTTPFound(get_referer_or_home(request))
from ..models.gift_card_transaction import GiftCardTransaction as GCT
transactions = list(gift_card.transactions)
return {
"gift_card": gift_card,
"transactions": transactions,
}
@view_config(route_name="gift_card_toggle", request_method="POST", require_csrf=True)
@shop_owner_required()
def gift_card_toggle(request):
"""Enable/disable a gift card (admin kill switch)."""
gift_card = get_gift_card_by_id(request.dbsession, request.matchdict["gift_card_id"])
if gift_card is None:
request.session.flash(("Gift card not found.", "error"))
return HTTPFound(get_referer_or_home(request))
gift_card.disabled = not gift_card.disabled
request.dbsession.add(gift_card)
request.dbsession.flush()
status = "disabled" if gift_card.disabled else "enabled"
request.session.flash((f"Gift card {gift_card.code} {status}.", "success"))
return HTTPFound(get_referer_or_home(request))

View file

@ -44,7 +44,7 @@ def player(request):
return HTTPBadRequest("Not a supported media file")
# Generate presigned URL
bucket_name = request.app["bucket.secure_uploads"]
bucket_name = request.shop_bucket_name
s3_key = f"{product.s3_path}/{file_key}"
params = {
@ -62,7 +62,7 @@ def player(request):
if content_type:
params["ResponseContentType"] = content_type
presigned_url = request.secure_uploads_client.generate_presigned_url(
presigned_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900, # 15 minutes
@ -127,7 +127,7 @@ def player(request):
track_ct = product.get_content_type(track_name) or (
"video/mp4" if media_type == "video" else "audio/wav"
)
url = request.secure_uploads_client.generate_presigned_url(
url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": bucket_name,
@ -192,7 +192,7 @@ def player_json(request):
return {"error": "Not a supported media file"}
# Generate presigned URL
bucket_name = request.app["bucket.secure_uploads"]
bucket_name = request.shop_bucket_name
s3_key = f"{product.s3_path}/{file_key}"
params = {
@ -209,7 +209,7 @@ def player_json(request):
if content_type:
params["ResponseContentType"] = content_type
presigned_url = request.secure_uploads_client.generate_presigned_url(
presigned_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900,
@ -259,7 +259,7 @@ def player_json(request):
track_ct = product.get_content_type(track_name) or (
"video/mp4" if media_type == "video" else "audio/wav"
)
url = request.secure_uploads_client.generate_presigned_url(
url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": bucket_name,

View file

@ -5,6 +5,7 @@ from pyramid.httpexceptions import HTTPFound
from . import (
user_required,
shop_editor_required,
trial_active_required,
get_referer_or_home,
)
@ -44,7 +45,7 @@ def product(request):
signed_get_object_url = None
bucket_name = request.app["bucket.secure_uploads"]
bucket_name = request.shop_bucket_name
if (
request.user
@ -75,7 +76,7 @@ def product(request):
if content_type:
params["ResponseContentType"] = content_type
signed_get_object_url = request.secure_uploads_client.generate_presigned_url(
signed_get_object_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
# 15 minutes.
@ -98,7 +99,7 @@ def product(request):
from ..models.product import get_ring_related_products, get_related_products
ring = product.shop.discovery_ring
if ring:
related_products = get_ring_related_products(product, ring, forward=42)
related_products = get_ring_related_products(product, ring, forward=len(ring))
else:
related_products = get_related_products(product)
@ -132,6 +133,7 @@ def product(request):
@view_config(route_name="content_new", renderer="content_new.j2")
@user_required()
@shop_editor_required()
@trial_active_required()
def product_new(request):
title = request.params.get("title", "").strip()
description = request.params.get("description", "").strip()
@ -242,6 +244,7 @@ def product_edit_description(request):
@view_config(route_name="content_edit", renderer="product_edit.j2")
@view_config(route_name="content_edit2", renderer="product_edit.j2")
@shop_editor_required()
@trial_active_required()
def product_edit(request):
product_modified = False
product = request.product
@ -284,8 +287,8 @@ def product_edit(request):
product_modified = True
product.set_visibility(
visibility,
request.secure_uploads_client,
request.app["bucket.secure_uploads"],
request.shop_uploads_client,
request.shop_bucket_name,
)
request.session.flash(("You updated the product's visibility.", "success"))
@ -297,7 +300,7 @@ def product_edit(request):
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
# Check if the file exists and has a non-zero size
try:
response = request.secure_uploads_client.head_object(
response = request.shop_uploads_client.head_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
@ -325,9 +328,9 @@ def product_edit(request):
# copy upload to our system defined s3 location.
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object
request.secure_uploads_client.copy_object(
request.shop_uploads_client.copy_object(
ACL=acl,
Bucket=request.app["bucket.secure_uploads"],
Bucket=request.shop_bucket_name,
CopySource={
"Bucket": s3_webhook_bucket,
"Key": s3_webhook_key,
@ -342,8 +345,8 @@ def product_edit(request):
# Mirror to shop's custom S3 bucket if configured
from ..lib.s3_mirror import mirror_key_async
mirror_key_async(
request.secure_uploads_client,
request.app["bucket.secure_uploads"],
request.shop_uploads_client,
request.shop_bucket_name,
f"{product.s3_path}/{file_key}",
product.shop,
content_type=product.get_content_type(file_key),
@ -351,14 +354,14 @@ def product_edit(request):
)
# delete original upload key.
request.secure_uploads_client.delete_object(
request.shop_uploads_client.delete_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
# get file size & store in our database.
response = request.secure_uploads_client.head_object(
Bucket=request.app["bucket.secure_uploads"],
response = request.shop_uploads_client.head_object(
Bucket=request.shop_bucket_name,
Key=f"{product.s3_path}/{file_key}",
)
@ -385,8 +388,8 @@ def product_edit(request):
is_video = (upload_media_type == "video")
ext = product.extensions.get(file_key)
sizes = process_karaoke(
request.secure_uploads_client,
request.app["bucket.secure_uploads"],
request.shop_uploads_client,
request.shop_bucket_name,
f"{product.s3_path}/{file_key}",
product.s3_path, is_video, ext,
public_key=shop.unsandbox_public_key,
@ -401,14 +404,14 @@ def product_edit(request):
product.file_bytes = tmp
request.dbsession.add(product)
request.dbsession.flush()
product.update_s3_acls(request.secure_uploads_client, request.app["bucket.secure_uploads"])
product.update_s3_acls(request.shop_uploads_client, request.shop_bucket_name)
# Mirror karaoke tracks to shop's custom bucket
if shop.has_s3_mirror:
from ..lib.s3_mirror import mirror_keys_async
mirror_keys_async(
request.secure_uploads_client,
request.app["bucket.secure_uploads"],
request.shop_uploads_client,
request.shop_bucket_name,
[f"{product.s3_path}/instrumentals", f"{product.s3_path}/vocals"],
shop,
)
@ -435,8 +438,8 @@ def product_edit(request):
["starts-with", "$key", key_starts_with],
]
signed_posts[file_key] = request.secure_uploads_client.generate_presigned_post(
Bucket=request.app["bucket.secure_uploads"],
signed_posts[file_key] = request.shop_uploads_client.generate_presigned_post(
Bucket=request.shop_bucket_name,
Key=key_starts_with + "${filename}",
ExpiresIn=900,
Conditions=conditions,
@ -482,14 +485,14 @@ def product_edit(request):
if product.has_product_file:
try:
params = {
"Bucket": request.app["bucket.secure_uploads"],
"Bucket": request.shop_bucket_name,
"Key": product.s3_key,
"ResponseContentDisposition": f"inline; filename={product.slug}.{product.extensions.get('product', '')}",
}
content_type = product.get_content_type("product")
if content_type:
params["ResponseContentType"] = content_type
signed_product_url = request.secure_uploads_client.generate_presigned_url(
signed_product_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900,

View file

@ -186,7 +186,28 @@ def shop_new(request):
request.session.flash(msg)
else:
# MPS-14: environment selector (default production)
environment = int(request.params.get("environment", "0"))
if environment not in (0, 1, 2):
environment = 0
# MPS-14: enforce 2 free dev/stage shops per production shop
if environment != 0:
prod_count = sum(1 for s in request.user.shops if s.is_production)
non_prod_count = sum(1 for s in request.user.shops if s.is_non_production)
allowed = prod_count * 2
if non_prod_count >= allowed:
request.session.flash((
"You need a production shop before creating dev/stage shops. "
"Each production shop includes 2 free dev/stage shops.",
"error",
))
return HTTPFound("/s/new")
import time as _time
shop = Shop(name, phone_number, billing_address, description)
shop.environment = environment
shop.trial_started_timestamp = int(_time.time() * 1000)
shop.add_user_to_shop(request.user)
request.user.set_active_shop(shop)
request.dbsession.add(shop)
@ -466,6 +487,16 @@ def shop_settings(request):
)
if request.method == "POST":
# MPS-15: Block settings changes when trial expired, except
# environment-settings and bucket-settings (needed for onboarding)
allowed_when_expired = ("environment-settings", "bucket-settings")
if shop.is_trial_expired and form_section not in allowed_when_expired:
request.session.flash((
"Your 21-day trial has expired. Choose a plan to continue editing settings.",
"error",
))
return HTTPFound(f"/s/{shop.id}/settings")
# Handle shop settings form
if form_section == "shop-settings":
if name != shop.name:
@ -1022,6 +1053,128 @@ def shop_settings(request):
except (ValueError, TypeError):
request.session.flash(("Invalid high risk threshold", "error"))
# Handle gift card settings
if form_section == "gift-card-settings":
gc_enabled_checkbox = request.params.get("gift-card-enabled-checkbox", "off")
gc_enabled = checkbox_to_bool(gc_enabled_checkbox)
if shop.gift_card_enabled != gc_enabled:
shop.gift_card_enabled = gc_enabled
status = "enabled" if gc_enabled else "disabled"
request.session.flash((f"Gift cards {status}.", "success"))
try:
gc_min = float(request.params.get("gift_card_min", "5.00"))
gc_max = float(request.params.get("gift_card_max", "250.00"))
gc_min_cents = int(gc_min * 100)
gc_max_cents = int(gc_max * 100)
if gc_min_cents < 100:
request.session.flash(("Minimum gift card amount must be at least $1.00.", "error"))
elif gc_max_cents < gc_min_cents:
request.session.flash(("Maximum must be greater than or equal to minimum.", "error"))
elif gc_max_cents > 1000000:
request.session.flash(("Maximum gift card amount cannot exceed $10,000.", "error"))
else:
if shop.gift_card_min_in_cents != gc_min_cents:
shop.gift_card_min_in_cents = gc_min_cents
request.session.flash((f"Gift card minimum set to ${gc_min:,.2f}.", "success"))
if shop.gift_card_max_in_cents != gc_max_cents:
shop.gift_card_max_in_cents = gc_max_cents
request.session.flash((f"Gift card maximum set to ${gc_max:,.2f}.", "success"))
except (ValueError, TypeError):
request.session.flash(("Invalid gift card amount.", "error"))
# Handle environment settings (MPS-14)
if form_section == "environment-settings":
env_value = int(request.params.get("environment", "0"))
if env_value not in (0, 1, 2):
request.session.flash(("Invalid environment value.", "error"))
elif env_value != 0 and shop.environment == 0:
# Changing from production to non-production — check allowance
# Count OTHER production shops (excluding this one being changed)
prod_count = sum(1 for s in request.user.shops if s.is_production and s.id != shop.id)
non_prod_count = sum(1 for s in request.user.shops if s.is_non_production)
# Each production shop allows 2 non-prod shops; minimum 2 non-prod allowed
max_non_prod = max(2, prod_count * 2)
if non_prod_count >= max_non_prod:
request.session.flash((
"You need more production shops before creating additional dev/stage shops. "
"Each production shop includes 2 free dev/stage shops.",
"error",
))
else:
shop.environment = env_value
request.session.flash((
f"Shop environment changed to {shop.environment_label}. "
"This shop is now hidden from public search and feeds.",
"success",
))
elif env_value == 0 and shop.environment != 0:
# Changing from non-production to production
shop.environment = env_value
request.session.flash((
"Shop environment changed to Production. "
"This shop is now publicly visible.",
"success",
))
elif env_value != shop.environment:
shop.environment = env_value
request.session.flash((
f"Shop environment changed to {shop.environment_label}.",
"success",
))
# Handle primary S3 bucket settings (MPS-16)
if form_section == "bucket-settings":
ps3_endpoint = request.params.get("primary_s3_endpoint", "").strip()
ps3_region = request.params.get("primary_s3_region", "").strip()
ps3_bucket = request.params.get("primary_s3_bucket", "").strip()
ps3_access_key = request.params.get("primary_s3_access_key", "").strip()
ps3_secret_key = request.params.get("primary_s3_secret_key", "").strip()
ps3_cdn_endpoint = request.params.get("primary_s3_cdn_endpoint", "").strip()
ps3_enabled = checkbox_to_bool(request.params.get("primary_s3_enabled_checkbox", "off"))
if ps3_enabled and not all([ps3_endpoint, ps3_region, ps3_bucket, ps3_access_key, ps3_secret_key, ps3_cdn_endpoint]):
request.session.flash(("All bucket fields are required when enabling BYOB.", "error"))
elif ps3_endpoint and not ps3_endpoint.startswith("https://"):
request.session.flash(("Bucket endpoint must start with https://", "error"))
elif ps3_cdn_endpoint and not ps3_cdn_endpoint.startswith("https://"):
request.session.flash(("CDN endpoint must start with https://", "error"))
else:
changed = False
for attr, val in [
("primary_s3_endpoint", ps3_endpoint),
("primary_s3_region", ps3_region),
("primary_s3_bucket", ps3_bucket),
("primary_s3_access_key", ps3_access_key),
("primary_s3_secret_key", ps3_secret_key),
("primary_s3_cdn_endpoint", ps3_cdn_endpoint),
]:
if getattr(shop, attr) != val:
setattr(shop, attr, val)
changed = True
if shop.primary_s3_enabled != ps3_enabled:
shop.primary_s3_enabled = ps3_enabled
changed = True
if changed:
if ps3_enabled:
# Test connection when enabling
try:
import boto3
test_client = boto3.session.Session().client(
"s3",
region_name=ps3_region,
endpoint_url=ps3_endpoint,
aws_access_key_id=ps3_access_key,
aws_secret_access_key=ps3_secret_key,
)
test_client.list_objects_v2(Bucket=ps3_bucket, MaxKeys=0)
request.session.flash(("Storage bucket settings saved and connection verified.", "success"))
except Exception as e:
request.session.flash((f"Bucket settings saved but connection test failed: {e}", "error"))
else:
request.session.flash(("Storage bucket settings updated.", "success"))
# If we processed any form submission, respond accordingly
if form_section:
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
@ -1036,7 +1189,7 @@ def shop_settings(request):
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
# Check if the file exists and has a non-zero size
try:
response = request.secure_uploads_client.head_object(
response = request.shop_uploads_client.head_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
@ -1065,9 +1218,9 @@ def shop_settings(request):
# copy upload to our system defined s3 location.
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object
request.secure_uploads_client.copy_object(
request.shop_uploads_client.copy_object(
ACL=acl,
Bucket=request.app["bucket.secure_uploads"],
Bucket=request.shop_bucket_name,
CopySource={
"Bucket": s3_webhook_bucket,
"Key": s3_webhook_key,
@ -1082,8 +1235,8 @@ def shop_settings(request):
# Mirror shop asset to custom S3 bucket if configured
from ..lib.s3_mirror import mirror_key_async
mirror_key_async(
request.secure_uploads_client,
request.app["bucket.secure_uploads"],
request.shop_uploads_client,
request.shop_bucket_name,
f"{shop.id}/meta/{file_key}",
shop,
content_type=content_type,
@ -1091,7 +1244,7 @@ def shop_settings(request):
)
# delete original upload key.
request.secure_uploads_client.delete_object(
request.shop_uploads_client.delete_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
@ -1122,15 +1275,15 @@ def shop_settings(request):
["starts-with", "$key", key_starts_with],
]
signed_posts[file_key] = request.secure_uploads_client.generate_presigned_post(
Bucket=request.app["bucket.secure_uploads"],
signed_posts[file_key] = request.shop_uploads_client.generate_presigned_post(
Bucket=request.shop_bucket_name,
# uploads to /<shop-uuid>/meta/shop-logo-banner.the-users-file.png
Key=key_starts_with + "${filename}",
ExpiresIn=900,
Conditions=conditions,
)
get_endpoints[file_key] = "{}/{}/meta/{}".format(
request.app["bucket.secure_uploads.get_endpoint"],
request.shop_cdn_endpoint,
shop.id,
file_key,
)
@ -1214,6 +1367,18 @@ def shop_settings(request):
"mirror_s3_access_key": shop.mirror_s3_access_key or "",
"mirror_s3_secret_key": shop.mirror_s3_secret_key or "",
"mirror_s3_enabled": shop.mirror_s3_enabled,
"gift_card_enabled": shop.gift_card_enabled,
"gift_card_min_dollars": cents_to_dollars(shop.gift_card_min_in_cents),
"gift_card_max_dollars": cents_to_dollars(shop.gift_card_max_in_cents),
"environment": shop.environment,
"environment_label": shop.environment_label,
"primary_s3_endpoint": shop.primary_s3_endpoint or "",
"primary_s3_region": shop.primary_s3_region or "",
"primary_s3_bucket": shop.primary_s3_bucket or "",
"primary_s3_access_key": shop.primary_s3_access_key or "",
"primary_s3_secret_key": shop.primary_s3_secret_key or "",
"primary_s3_cdn_endpoint": shop.primary_s3_cdn_endpoint or "",
"primary_s3_enabled": shop.primary_s3_enabled,
"signed_posts": signed_posts,
"get_endpoints": get_endpoints,
}

View file

@ -42,7 +42,7 @@ def watch_json(request):
media_type = get_media_type(extension) or "other"
# Generate presigned URL for media
bucket_name = request.app["bucket.secure_uploads"]
bucket_name = request.shop_bucket_name
s3_key = f"{product.s3_path}/{file_key}"
params = {
@ -59,7 +59,7 @@ def watch_json(request):
if content_type:
params["ResponseContentType"] = content_type
media_url = request.secure_uploads_client.generate_presigned_url(
media_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900,
@ -75,7 +75,7 @@ def watch_json(request):
track_ct = product.get_content_type(track_name) or (
"video/mp4" if media_type == "video" else "audio/wav"
)
url = request.secure_uploads_client.generate_presigned_url(
url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": bucket_name,
@ -94,7 +94,7 @@ def watch_json(request):
thumbnail_url = None
if "thumbnail1" in product.extensions:
thumbnail_url = (
f"{request.app['bucket.secure_uploads.get_endpoint']}"
f"{request.shop_cdn_endpoint}"
f"/{product.s3_path}/thumbnail1"
f"?ts={product.updated_timestamp}"
)
@ -113,9 +113,9 @@ def watch_json(request):
except (ValueError, TypeError):
direction = 1
if direction == -1:
related = get_ring_related_products(product, ring, forward=3, backward=42)
related = get_ring_related_products(product, ring, forward=3, backward=len(ring))
else:
related = get_ring_related_products(product, ring, forward=42, backward=3)
related = get_ring_related_products(product, ring, forward=len(ring), backward=3)
else:
related = get_related_products(product)
@ -132,7 +132,7 @@ def watch_json(request):
r_thumb = None
if "thumbnail1" in r.extensions:
r_thumb = (
f"{request.app['bucket.secure_uploads.get_endpoint']}"
f"{request.shop_cdn_endpoint}"
f"/{r.s3_path}/thumbnail1"
f"?ts={r.updated_timestamp}"
)
@ -164,7 +164,7 @@ def watch_json(request):
dl_params["ResponseContentType"] = dl_content_type
file_type_str = dl_content_type
file_size_str = product.human_file_bytes(file_key)
download_url = request.secure_uploads_client.generate_presigned_url(
download_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=dl_params,
ExpiresIn=900,
@ -174,7 +174,7 @@ def watch_json(request):
file_url = None
if has_product_file:
file_url = (
f"{request.app['bucket.secure_uploads.get_endpoint']}"
f"{request.shop_cdn_endpoint}"
f"/{product.s3_path}/{file_key}"
f"?ts={product.updated_timestamp}"
)