Merge branch 'explore/design-tokens' into 'master'
feat: integrate design tokens from www.makepostsell.com styleguide See merge request engineering/make-post-sell/make_post_sell!58
This commit is contained in:
commit
df8f4993c6
62 changed files with 6195 additions and 547 deletions
42
CLAUDE.md
42
CLAUDE.md
|
|
@ -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
1
GIT_HASH
Normal file
|
|
@ -0,0 +1 @@
|
|||
e2169ec
|
||||
|
|
@ -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
309
docs/design-system.md
Normal 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
79
docs/tickets/mps-10.md
Normal 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
76
docs/tickets/mps-11.md
Normal 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
84
docs/tickets/mps-12.md
Normal 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
74
docs/tickets/mps-13.md
Normal 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
128
docs/tickets/mps-14.md
Normal 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
142
docs/tickets/mps-15.md
Normal 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
206
docs/tickets/mps-16.md
Normal 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
|
||||
|
|
@ -1 +1 @@
|
|||
666ca41
|
||||
e2169ec
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
34
make_post_sell/models/cart_gift_card.py
Normal file
34
make_post_sell/models/cart_gift_card.py
Normal 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()
|
||||
108
make_post_sell/models/gift_card.py
Normal file
108
make_post_sell/models/gift_card.py
Normal 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())
|
||||
)
|
||||
46
make_post_sell/models/gift_card_transaction.py
Normal file
46
make_post_sell/models/gift_card_transaction.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:.*}")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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
774
make_post_sell/static/css/tokens.css
Normal file
774
make_post_sell/static/css/tokens.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@
|
|||
<br/>
|
||||
<br/>
|
||||
<a href="/s/{{ request.shop.id }}/coupons" class="mps-button product-edit-button">  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">  Gift Cards</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if request.is_saas_domain %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
107
make_post_sell/templates/gift_card.j2
Normal file
107
make_post_sell/templates/gift_card.j2
Normal 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 -%}
|
||||
72
make_post_sell/templates/gift_card_detail.j2
Normal file
72
make_post_sell/templates/gift_card_detail.j2
Normal 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 -%}
|
||||
69
make_post_sell/templates/gift_card_manage.j2
Normal file
69
make_post_sell/templates/gift_card_manage.j2
Normal 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 -%}
|
||||
|
|
@ -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">+ 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 & physical product sales. Open source. Public domain.</p>
|
||||
<div class="landing-hero-ctas">
|
||||
<a href="/s/new" class="mps-button mps-button-green">+ 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">🎭</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">💰</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">📨</div>
|
||||
<h3 class="type-title">Subscriptions & 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">🎨</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">📈</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">👥</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">+ 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>
|
||||
|
|
|
|||
|
|
@ -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 — 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 — 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 -%}
|
||||
|
|
|
|||
|
|
@ -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 ▶ 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 %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
<span class="related-content-index">▶</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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
238
make_post_sell/views/gift_card.py
Normal file
238
make_post_sell/views/gift_card.py
Normal 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))
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue