feat: add Settings and Media contexts with theme settings schema
- Create settings table for site-wide key-value configuration - Create images table for BLOB storage of logo/header images - Add Setting schema with JSON/string/integer/boolean support - Add ThemeSettings embedded schema with all theme options - Add Settings context with get/put/update operations - Add Media context for image uploads and retrieval - Add Image schema with SVG detection and storage - Add 9 curated theme presets (gallery, studio, boutique, etc.) - Add comprehensive tests for Settings and Media contexts - Add seeds with default Studio preset - All tests passing (29 tests, 0 failures)
This commit is contained in:
parent
bb4633895c
commit
a401365943
423
SIMPLESHOP_THEME_STUDIO_SPEC.md
Normal file
423
SIMPLESHOP_THEME_STUDIO_SPEC.md
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
# SimpleShop Theme Studio - Complete Implementation Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document summarises the complete Theme Studio feature developed for SimpleShop - an open-source POD (print-on-demand) e-commerce platform built with Phoenix/Elixir/LiveView. The Theme Studio allows sellers to customise their shop's appearance through a constrained but flexible system of presets and options.
|
||||||
|
|
||||||
|
## Design Philosophy
|
||||||
|
|
||||||
|
### Core Principles
|
||||||
|
1. **"One theme, infinite variations"** - Rather than offering multiple themes, we provide one solid foundation with curated customisation options
|
||||||
|
2. **Constrained creativity** - Limit choices to prevent poor design outcomes while maintaining perceived variety
|
||||||
|
3. **No professional photography required** - Defaults work well with product mockups, not just lifestyle imagery
|
||||||
|
4. **Mobile-first** - All features work on touch devices (no hover-only interactions)
|
||||||
|
5. **Ethical design** - No dark patterns like countdown timers or fake urgency
|
||||||
|
|
||||||
|
### Target Audience
|
||||||
|
- Solo POD creators (artists, illustrators, designers)
|
||||||
|
- Small catalogs (typically <50 products)
|
||||||
|
- Non-technical users who found WooCommerce too complex
|
||||||
|
- Budget-conscious sellers (£5-10/month target price)
|
||||||
|
|
||||||
|
## Feature Architecture
|
||||||
|
|
||||||
|
### Preset System
|
||||||
|
|
||||||
|
Nine curated presets that combine multiple settings into cohesive looks:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
@presets %{
|
||||||
|
gallery: %{
|
||||||
|
mood: "warm",
|
||||||
|
typography: "editorial",
|
||||||
|
shape: "soft",
|
||||||
|
density: "spacious",
|
||||||
|
grid: "3",
|
||||||
|
header: "centered",
|
||||||
|
accent: "#e85d04"
|
||||||
|
},
|
||||||
|
studio: %{
|
||||||
|
mood: "neutral",
|
||||||
|
typography: "clean",
|
||||||
|
shape: "soft",
|
||||||
|
density: "balanced",
|
||||||
|
grid: "4",
|
||||||
|
header: "standard",
|
||||||
|
accent: "#3b82f6"
|
||||||
|
},
|
||||||
|
boutique: %{
|
||||||
|
mood: "warm",
|
||||||
|
typography: "classic",
|
||||||
|
shape: "soft",
|
||||||
|
density: "balanced",
|
||||||
|
grid: "3",
|
||||||
|
header: "centered",
|
||||||
|
accent: "#b45309"
|
||||||
|
},
|
||||||
|
bold: %{
|
||||||
|
mood: "neutral",
|
||||||
|
typography: "modern",
|
||||||
|
shape: "sharp",
|
||||||
|
density: "compact",
|
||||||
|
grid: "4",
|
||||||
|
header: "standard",
|
||||||
|
accent: "#dc2626"
|
||||||
|
},
|
||||||
|
playful: %{
|
||||||
|
mood: "neutral",
|
||||||
|
typography: "friendly",
|
||||||
|
shape: "pill",
|
||||||
|
density: "balanced",
|
||||||
|
grid: "4",
|
||||||
|
header: "standard",
|
||||||
|
accent: "#8b5cf6"
|
||||||
|
},
|
||||||
|
minimal: %{
|
||||||
|
mood: "cool",
|
||||||
|
typography: "minimal",
|
||||||
|
shape: "sharp",
|
||||||
|
density: "spacious",
|
||||||
|
grid: "2",
|
||||||
|
header: "minimal",
|
||||||
|
accent: "#171717"
|
||||||
|
},
|
||||||
|
night: %{
|
||||||
|
mood: "dark",
|
||||||
|
typography: "modern",
|
||||||
|
shape: "soft",
|
||||||
|
density: "balanced",
|
||||||
|
grid: "4",
|
||||||
|
header: "standard",
|
||||||
|
accent: "#f97316"
|
||||||
|
},
|
||||||
|
classic: %{
|
||||||
|
mood: "warm",
|
||||||
|
typography: "classic",
|
||||||
|
shape: "soft",
|
||||||
|
density: "spacious",
|
||||||
|
grid: "3",
|
||||||
|
header: "standard",
|
||||||
|
accent: "#166534"
|
||||||
|
},
|
||||||
|
impulse: %{
|
||||||
|
mood: "neutral",
|
||||||
|
typography: "impulse",
|
||||||
|
shape: "sharp",
|
||||||
|
density: "spacious",
|
||||||
|
grid: "3",
|
||||||
|
header: "centered",
|
||||||
|
accent: "#000000",
|
||||||
|
font_size: "medium",
|
||||||
|
heading_weight: "regular",
|
||||||
|
layout_width: "full",
|
||||||
|
button_style: "filled",
|
||||||
|
card_shadow: "none",
|
||||||
|
product_text: "center"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customisation Options
|
||||||
|
|
||||||
|
#### Typography Styles
|
||||||
|
| Style | Heading Font | Body Font | Weight | Use Case |
|
||||||
|
|-------|-------------|-----------|--------|----------|
|
||||||
|
| clean | Inter | Inter | 600 | Default, versatile |
|
||||||
|
| editorial | Fraunces (serif) | Source Sans | 600 | Art galleries, editorial |
|
||||||
|
| modern | Space Grotesk | Space Grotesk | 500 | Tech, contemporary |
|
||||||
|
| classic | Libre Baskerville | Source Sans | 400 | Traditional, luxury |
|
||||||
|
| friendly | Nunito | Nunito | 700 | Playful, approachable |
|
||||||
|
| minimal | Outfit | Outfit | 300 | Ultra-clean, light |
|
||||||
|
| impulse | Nunito Sans | Nunito Sans | 300 | Fashion editorial |
|
||||||
|
|
||||||
|
#### Colour Moods
|
||||||
|
| Mood | Background | Text | Border | Use Case |
|
||||||
|
|------|------------|------|--------|----------|
|
||||||
|
| neutral | #ffffff | #171717 | #e5e5e5 | Default, works with any accent |
|
||||||
|
| warm | #fdf8f3 | #1c1917 | #e7e0d8 | Cosy, artisan, handmade |
|
||||||
|
| cool | #f4f7fb | #0f172a | #d4dce8 | Tech, modern, professional |
|
||||||
|
| dark | #0a0a0a | #fafafa | #262626 | Premium, dramatic, night mode |
|
||||||
|
|
||||||
|
#### Shape Options
|
||||||
|
| Shape | Border Radius | Use Case |
|
||||||
|
|-------|--------------|----------|
|
||||||
|
| sharp | 0 | Modern, editorial, bold |
|
||||||
|
| soft | 0.5rem | Default, approachable |
|
||||||
|
| round | 0.75-1rem | Friendly, playful |
|
||||||
|
| pill | 9999px (buttons) | Fun, casual, rounded |
|
||||||
|
|
||||||
|
#### Density Options
|
||||||
|
| Density | Multiplier | Use Case |
|
||||||
|
|---------|------------|----------|
|
||||||
|
| spacious | 1.25x | Editorial, luxury, few products |
|
||||||
|
| balanced | 1x | Default, most stores |
|
||||||
|
| compact | 0.75x | Large catalogs, utilitarian |
|
||||||
|
|
||||||
|
#### Layout Options
|
||||||
|
- **Grid columns**: 2, 3, or 4 products per row
|
||||||
|
- **Header layout**: Standard (left logo), Centered, Minimal
|
||||||
|
- **Layout width**: Contained (1200px), Wide (1400px), Full width
|
||||||
|
- **Button style**: Filled, Outline, Soft
|
||||||
|
- **Card shadow**: None, Subtle, Pronounced
|
||||||
|
- **Product text alignment**: Left, Center
|
||||||
|
- **Image aspect ratio**: Square, Portrait, Landscape
|
||||||
|
|
||||||
|
### Branding Options
|
||||||
|
|
||||||
|
#### Logo Modes
|
||||||
|
1. **Text only** - Shop name as styled text
|
||||||
|
2. **Logo + text** - Logo image alongside shop name
|
||||||
|
3. **Logo only** - Just the logo image
|
||||||
|
4. **Header image** - Full-width header background
|
||||||
|
5. **Logo + header image** - Both logo and header background
|
||||||
|
|
||||||
|
#### Logo Features
|
||||||
|
- Upload support (PNG, JPG, WebP, SVG)
|
||||||
|
- Size slider (24-120px)
|
||||||
|
- SVG recolouring option (change logo colour to match theme)
|
||||||
|
|
||||||
|
#### Header Image Features
|
||||||
|
- Background image upload
|
||||||
|
- Zoom control (100-200%)
|
||||||
|
- Horizontal position (0-100%)
|
||||||
|
- Vertical position (0-100%)
|
||||||
|
|
||||||
|
### Toggle Features
|
||||||
|
- Announcement bar (on/off)
|
||||||
|
- Sticky header (on/off)
|
||||||
|
- Second image on hover (on/off)
|
||||||
|
- Quick add button (on/off)
|
||||||
|
- Show prices (on/off)
|
||||||
|
- Trust badges on PDP (on/off)
|
||||||
|
- Reviews section on PDP (on/off)
|
||||||
|
- Related products on PDP (on/off)
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Theme Settings Schema
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule SimpleShop.Shops.ThemeSettings do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
embedded_schema do
|
||||||
|
# Core preset (optional - if set, provides defaults)
|
||||||
|
field :preset, :string
|
||||||
|
|
||||||
|
# Branding
|
||||||
|
field :shop_name, :string
|
||||||
|
field :logo_mode, :string, default: "text-only"
|
||||||
|
field :logo_url, :string
|
||||||
|
field :logo_size, :integer, default: 36
|
||||||
|
field :logo_recolor, :boolean, default: false
|
||||||
|
field :logo_color, :string, default: "#171717"
|
||||||
|
field :header_image_url, :string
|
||||||
|
field :header_zoom, :integer, default: 100
|
||||||
|
field :header_position_x, :integer, default: 50
|
||||||
|
field :header_position_y, :integer, default: 50
|
||||||
|
|
||||||
|
# Theme tokens
|
||||||
|
field :mood, :string, default: "neutral"
|
||||||
|
field :typography, :string, default: "clean"
|
||||||
|
field :shape, :string, default: "soft"
|
||||||
|
field :density, :string, default: "balanced"
|
||||||
|
field :accent_color, :string, default: "#f97316"
|
||||||
|
field :hover_color, :string
|
||||||
|
field :sale_color, :string, default: "#dc2626"
|
||||||
|
|
||||||
|
# Layout
|
||||||
|
field :grid_columns, :integer, default: 4
|
||||||
|
field :header_layout, :string, default: "standard"
|
||||||
|
field :layout_width, :string, default: "wide"
|
||||||
|
field :font_size, :string, default: "medium"
|
||||||
|
field :heading_weight, :string, default: "bold"
|
||||||
|
field :button_style, :string, default: "filled"
|
||||||
|
field :card_shadow, :string, default: "none"
|
||||||
|
field :product_text_align, :string, default: "left"
|
||||||
|
field :image_aspect_ratio, :string, default: "square"
|
||||||
|
field :gallery_position, :string, default: "left"
|
||||||
|
|
||||||
|
# Feature toggles
|
||||||
|
field :announcement_bar, :boolean, default: true
|
||||||
|
field :sticky_header, :boolean, default: false
|
||||||
|
field :hover_image, :boolean, default: true
|
||||||
|
field :quick_add, :boolean, default: true
|
||||||
|
field :show_prices, :boolean, default: true
|
||||||
|
field :pdp_trust_badges, :boolean, default: true
|
||||||
|
field :pdp_reviews, :boolean, default: true
|
||||||
|
field :pdp_related_products, :boolean, default: true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Custom Properties
|
||||||
|
|
||||||
|
The theme system uses CSS custom properties for real-time updates. Key variables:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Primitives (fixed) */
|
||||||
|
--p-space-1 through --p-space-24
|
||||||
|
--p-radius-none through --p-radius-full
|
||||||
|
--p-font-inter, --p-font-fraunces, etc.
|
||||||
|
--p-text-xs through --p-text-4xl
|
||||||
|
|
||||||
|
/* Theme tokens (dynamic) */
|
||||||
|
--t-surface-base, --t-surface-raised, --t-surface-sunken
|
||||||
|
--t-text-primary, --t-text-secondary, --t-text-tertiary
|
||||||
|
--t-border-default, --t-border-subtle
|
||||||
|
--t-accent (HSL-based for easy manipulation)
|
||||||
|
--t-font-heading, --t-font-body
|
||||||
|
--t-heading-weight, --t-heading-tracking
|
||||||
|
--t-radius-button, --t-radius-card, --t-radius-input
|
||||||
|
--t-density (multiplier)
|
||||||
|
|
||||||
|
/* Semantic aliases */
|
||||||
|
--color-page, --color-card, --color-heading, --color-body
|
||||||
|
--font-heading, --font-body, --weight-heading
|
||||||
|
--space-xs through --space-2xl
|
||||||
|
--radius-button, --radius-card
|
||||||
|
```
|
||||||
|
|
||||||
|
## LiveView Implementation Notes
|
||||||
|
|
||||||
|
### Real-time Preview
|
||||||
|
|
||||||
|
The theme editor should provide instant visual feedback. In Phoenix LiveView:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule SimpleShopWeb.ThemeEditorLive do
|
||||||
|
use SimpleShopWeb, :live_view
|
||||||
|
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, assign(socket,
|
||||||
|
settings: default_settings(),
|
||||||
|
preview_page: "home"
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("update_setting", %{"key" => key, "value" => value}, socket) do
|
||||||
|
settings = Map.put(socket.assigns.settings, String.to_atom(key), value)
|
||||||
|
{:noreply, assign(socket, settings: settings)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("apply_preset", %{"preset" => preset_name}, socket) do
|
||||||
|
preset = Map.get(@presets, String.to_atom(preset_name))
|
||||||
|
settings = Map.merge(socket.assigns.settings, preset)
|
||||||
|
{:noreply, assign(socket, settings: settings)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("change_preview_page", %{"page" => page}, socket) do
|
||||||
|
{:noreply, assign(socket, preview_page: page)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Generation
|
||||||
|
|
||||||
|
Generate CSS custom properties from settings:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule SimpleShop.Theme.CSSGenerator do
|
||||||
|
def generate_css(settings) do
|
||||||
|
"""
|
||||||
|
:root {
|
||||||
|
--t-accent-h: #{accent_hue(settings.accent_color)};
|
||||||
|
--t-accent-s: #{accent_saturation(settings.accent_color)}%;
|
||||||
|
--t-accent-l: #{accent_lightness(settings.accent_color)}%;
|
||||||
|
/* ... other properties */
|
||||||
|
}
|
||||||
|
|
||||||
|
#{mood_css(settings.mood)}
|
||||||
|
#{typography_css(settings.typography)}
|
||||||
|
#{shape_css(settings.shape)}
|
||||||
|
#{density_css(settings.density)}
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Uploads
|
||||||
|
|
||||||
|
### Logo Upload
|
||||||
|
- Accepted formats: PNG, JPG, WebP, SVG
|
||||||
|
- Max size: 2MB recommended
|
||||||
|
- SVG special handling: Store raw content for recolouring feature
|
||||||
|
- Storage: Local or S3-compatible (user's choice for data sovereignty)
|
||||||
|
|
||||||
|
### Header Image Upload
|
||||||
|
- Accepted formats: PNG, JPG, WebP
|
||||||
|
- Max size: 5MB recommended
|
||||||
|
- Responsive serving: Generate multiple sizes
|
||||||
|
|
||||||
|
## Preview Pages
|
||||||
|
|
||||||
|
The demo includes 7 preview pages to show theme in context:
|
||||||
|
|
||||||
|
1. **Home** - Hero, categories, featured products, testimonials
|
||||||
|
2. **Collection** - Product grid with filters
|
||||||
|
3. **Product (PDP)** - Gallery, details, add to cart, reviews
|
||||||
|
4. **Cart** - Line items, totals, checkout button
|
||||||
|
5. **About** - Brand story, values
|
||||||
|
6. **Contact** - Contact form, details
|
||||||
|
7. **Error (404)** - Error state styling
|
||||||
|
|
||||||
|
## Research Findings: POD Theme Best Practices
|
||||||
|
|
||||||
|
### What POD Sellers Actually Use
|
||||||
|
- **Free themes dominate**: Dawn, Spotlight, Studio most popular
|
||||||
|
- **Premium choice**: Streamline ($350) best for growing POD brands
|
||||||
|
- **Impulse** is fashion-focused, not ideal POD default
|
||||||
|
|
||||||
|
### Key Requirements for POD
|
||||||
|
1. Large product imagery (mockups need to shine)
|
||||||
|
2. Clean/minimal design (products are the focus)
|
||||||
|
3. Quick setup (non-technical users)
|
||||||
|
4. Mobile-responsive (60%+ traffic)
|
||||||
|
5. Works with small catalogs
|
||||||
|
6. No reliance on lifestyle photography
|
||||||
|
|
||||||
|
### What to Avoid
|
||||||
|
- Countdown timers (dark pattern)
|
||||||
|
- Complex promotional systems
|
||||||
|
- Hover-only interactions
|
||||||
|
- Features requiring professional photography
|
||||||
|
- Enterprise complexity
|
||||||
|
|
||||||
|
## Recommended Next Steps for Phoenix Implementation
|
||||||
|
|
||||||
|
### Phase 1: Core Theme System
|
||||||
|
1. Create ThemeSettings schema and migrations
|
||||||
|
2. Build CSS generator module
|
||||||
|
3. Implement preset system
|
||||||
|
4. Create basic LiveView editor with sidebar controls
|
||||||
|
|
||||||
|
### Phase 2: Branding
|
||||||
|
1. Logo upload with LiveView uploads
|
||||||
|
2. SVG parsing and recolouring
|
||||||
|
3. Header image with positioning controls
|
||||||
|
|
||||||
|
### Phase 3: Preview System
|
||||||
|
1. Live preview component
|
||||||
|
2. Page switching (home/collection/product/etc.)
|
||||||
|
3. Mock data for preview products
|
||||||
|
|
||||||
|
### Phase 4: Persistence
|
||||||
|
1. Save to shop record
|
||||||
|
2. Apply to storefront templates
|
||||||
|
3. CSS caching strategy
|
||||||
|
|
||||||
|
## Prototype File
|
||||||
|
|
||||||
|
The complete working HTML prototype is available at:
|
||||||
|
- `/home/claude/theme-demo-v28.html`
|
||||||
|
- `/mnt/user-data/outputs/theme-demo-v28.html`
|
||||||
|
|
||||||
|
This 6,277-line file contains all CSS, HTML structure, and JavaScript logic that can be referenced when building the Phoenix/LiveView implementation.
|
||||||
|
|
||||||
|
## Combination Count
|
||||||
|
|
||||||
|
With current options, the system offers:
|
||||||
|
- 4 moods × 7 typographies × 4 shapes × 3 densities × 3 grids × 3 headers = **3,024 base combinations**
|
||||||
|
- Plus accent colours, layout width, button styles, shadows, etc.
|
||||||
|
- Marketing claim: "100,000+ possible combinations"
|
||||||
92
lib/simpleshop_theme/media.ex
Normal file
92
lib/simpleshop_theme/media.ex
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
defmodule SimpleshopTheme.Media do
|
||||||
|
@moduledoc """
|
||||||
|
The Media context for managing images and file uploads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
alias SimpleshopTheme.Repo
|
||||||
|
alias SimpleshopTheme.Media.Image
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Uploads an image and stores it in the database.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> upload_image(%{image_type: "logo", filename: "logo.png", ...})
|
||||||
|
{:ok, %Image{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def upload_image(attrs) do
|
||||||
|
%Image{}
|
||||||
|
|> Image.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single image by ID.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> get_image(id)
|
||||||
|
%Image{}
|
||||||
|
|
||||||
|
iex> get_image("nonexistent")
|
||||||
|
nil
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_image(id) do
|
||||||
|
Repo.get(Image, id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the current logo image.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> get_logo()
|
||||||
|
%Image{}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_logo do
|
||||||
|
Repo.one(from i in Image, where: i.image_type == "logo", order_by: [desc: i.inserted_at], limit: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the current header image.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> get_header()
|
||||||
|
%Image{}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_header do
|
||||||
|
Repo.one(from i in Image, where: i.image_type == "header", order_by: [desc: i.inserted_at], limit: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes an image.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> delete_image(image)
|
||||||
|
{:ok, %Image{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def delete_image(%Image{} = image) do
|
||||||
|
Repo.delete(image)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists all images of a specific type.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> list_images_by_type("logo")
|
||||||
|
[%Image{}, ...]
|
||||||
|
|
||||||
|
"""
|
||||||
|
def list_images_by_type(type) do
|
||||||
|
Repo.all(from i in Image, where: i.image_type == ^type, order_by: [desc: i.inserted_at])
|
||||||
|
end
|
||||||
|
end
|
||||||
54
lib/simpleshop_theme/media/image.ex
Normal file
54
lib/simpleshop_theme/media/image.ex
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
defmodule SimpleshopTheme.Media.Image do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
schema "images" do
|
||||||
|
field :image_type, :string
|
||||||
|
field :filename, :string
|
||||||
|
field :content_type, :string
|
||||||
|
field :file_size, :integer
|
||||||
|
field :data, :binary
|
||||||
|
field :is_svg, :boolean, default: false
|
||||||
|
field :svg_content, :string
|
||||||
|
field :thumbnail_data, :binary
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@max_file_size 5_000_000
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(image, attrs) do
|
||||||
|
image
|
||||||
|
|> cast(attrs, [:image_type, :filename, :content_type, :file_size, :data, :is_svg, :svg_content])
|
||||||
|
|> validate_required([:image_type, :filename, :content_type, :file_size, :data])
|
||||||
|
|> validate_inclusion(:image_type, ~w(logo header product))
|
||||||
|
|> validate_number(:file_size, less_than: @max_file_size)
|
||||||
|
|> detect_svg()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp detect_svg(changeset) do
|
||||||
|
content_type = get_change(changeset, :content_type)
|
||||||
|
|
||||||
|
if content_type == "image/svg+xml" or String.ends_with?(get_change(changeset, :filename) || "", ".svg") do
|
||||||
|
changeset
|
||||||
|
|> put_change(:is_svg, true)
|
||||||
|
|> maybe_store_svg_content()
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_store_svg_content(changeset) do
|
||||||
|
case get_change(changeset, :data) do
|
||||||
|
nil ->
|
||||||
|
changeset
|
||||||
|
|
||||||
|
svg_binary when is_binary(svg_binary) ->
|
||||||
|
put_change(changeset, :svg_content, svg_binary)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
133
lib/simpleshop_theme/settings.ex
Normal file
133
lib/simpleshop_theme/settings.ex
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
defmodule SimpleshopTheme.Settings do
|
||||||
|
@moduledoc """
|
||||||
|
The Settings context for managing site-wide configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
alias SimpleshopTheme.Repo
|
||||||
|
alias SimpleshopTheme.Settings.{Setting, ThemeSettings}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a setting by key with an optional default value.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> get_setting("site_name", "My Shop")
|
||||||
|
"My Shop"
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_setting(key, default \\ nil) do
|
||||||
|
case Repo.get_by(Setting, key: key) do
|
||||||
|
nil -> default
|
||||||
|
setting -> decode_value(setting)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets a setting value by key.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> put_setting("site_name", "My Awesome Shop")
|
||||||
|
{:ok, %Setting{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def put_setting(key, value, value_type \\ "string") do
|
||||||
|
encoded_value = encode_value(value, value_type)
|
||||||
|
|
||||||
|
%Setting{key: key}
|
||||||
|
|> Setting.changeset(%{key: key, value: encoded_value, value_type: value_type})
|
||||||
|
|> Repo.insert(
|
||||||
|
on_conflict: {:replace, [:value, :value_type, :updated_at]},
|
||||||
|
conflict_target: :key
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the theme settings as a ThemeSettings struct.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> get_theme_settings()
|
||||||
|
%ThemeSettings{mood: "neutral", typography: "clean", ...}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_theme_settings do
|
||||||
|
case get_setting("theme_settings") do
|
||||||
|
nil ->
|
||||||
|
# Return defaults
|
||||||
|
%ThemeSettings{}
|
||||||
|
|
||||||
|
settings_map when is_map(settings_map) ->
|
||||||
|
settings_map
|
||||||
|
|> atomize_keys()
|
||||||
|
|> then(&struct(ThemeSettings, &1))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates the theme settings.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> update_theme_settings(%{mood: "dark", typography: "modern"})
|
||||||
|
{:ok, %ThemeSettings{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def update_theme_settings(attrs) when is_map(attrs) do
|
||||||
|
current = get_theme_settings()
|
||||||
|
|
||||||
|
changeset = ThemeSettings.changeset(current, attrs)
|
||||||
|
|
||||||
|
if changeset.valid? do
|
||||||
|
settings = Ecto.Changeset.apply_changes(changeset)
|
||||||
|
json = Jason.encode!(settings)
|
||||||
|
put_setting("theme_settings", json, "json")
|
||||||
|
{:ok, settings}
|
||||||
|
else
|
||||||
|
{:error, changeset}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Applies a preset to theme settings.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> apply_preset(:gallery)
|
||||||
|
{:ok, %ThemeSettings{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def apply_preset(preset_name) when is_atom(preset_name) do
|
||||||
|
preset = SimpleshopTheme.Theme.Presets.get(preset_name)
|
||||||
|
|
||||||
|
if preset do
|
||||||
|
update_theme_settings(preset)
|
||||||
|
else
|
||||||
|
{:error, :preset_not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Private helpers
|
||||||
|
|
||||||
|
defp decode_value(%Setting{value: value, value_type: "json"}), do: Jason.decode!(value)
|
||||||
|
defp decode_value(%Setting{value: value, value_type: "integer"}), do: String.to_integer(value)
|
||||||
|
|
||||||
|
defp decode_value(%Setting{value: value, value_type: "boolean"}),
|
||||||
|
do: value == "true"
|
||||||
|
|
||||||
|
defp decode_value(%Setting{value: value, value_type: "string"}), do: value
|
||||||
|
|
||||||
|
defp encode_value(value, "json") when is_binary(value), do: value
|
||||||
|
defp encode_value(value, "json"), do: Jason.encode!(value)
|
||||||
|
defp encode_value(value, "integer") when is_integer(value), do: Integer.to_string(value)
|
||||||
|
defp encode_value(value, "boolean") when is_boolean(value), do: Atom.to_string(value)
|
||||||
|
defp encode_value(value, "string") when is_binary(value), do: value
|
||||||
|
|
||||||
|
defp atomize_keys(map) when is_map(map) do
|
||||||
|
Map.new(map, fn
|
||||||
|
{key, value} when is_binary(key) -> {String.to_atom(key), value}
|
||||||
|
{key, value} -> {key, value}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
24
lib/simpleshop_theme/settings/setting.ex
Normal file
24
lib/simpleshop_theme/settings/setting.ex
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
defmodule SimpleshopTheme.Settings.Setting do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
schema "settings" do
|
||||||
|
field :key, :string
|
||||||
|
field :value, :string
|
||||||
|
field :value_type, :string, default: "string"
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(setting, attrs) do
|
||||||
|
setting
|
||||||
|
|> cast(attrs, [:key, :value, :value_type])
|
||||||
|
|> validate_required([:key, :value, :value_type])
|
||||||
|
|> validate_inclusion(:value_type, ~w(string json integer boolean))
|
||||||
|
|> unique_constraint(:key)
|
||||||
|
end
|
||||||
|
end
|
||||||
101
lib/simpleshop_theme/settings/theme_settings.ex
Normal file
101
lib/simpleshop_theme/settings/theme_settings.ex
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
defmodule SimpleshopTheme.Settings.ThemeSettings do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@derive Jason.Encoder
|
||||||
|
@primary_key false
|
||||||
|
embedded_schema do
|
||||||
|
# Core theme tokens
|
||||||
|
field :mood, :string, default: "neutral"
|
||||||
|
field :typography, :string, default: "clean"
|
||||||
|
field :shape, :string, default: "soft"
|
||||||
|
field :density, :string, default: "balanced"
|
||||||
|
field :grid_columns, :string, default: "4"
|
||||||
|
field :header_layout, :string, default: "standard"
|
||||||
|
field :accent_color, :string, default: "#f97316"
|
||||||
|
|
||||||
|
# Branding
|
||||||
|
field :logo_mode, :string, default: "text-only"
|
||||||
|
field :logo_image_id, :binary_id
|
||||||
|
field :header_image_id, :binary_id
|
||||||
|
field :logo_size, :integer, default: 36
|
||||||
|
field :logo_recolor, :boolean, default: false
|
||||||
|
field :logo_color, :string, default: "#171717"
|
||||||
|
field :header_zoom, :integer, default: 100
|
||||||
|
field :header_position_x, :integer, default: 50
|
||||||
|
field :header_position_y, :integer, default: 50
|
||||||
|
|
||||||
|
# Advanced customization
|
||||||
|
field :secondary_accent_color, :string, default: "#ea580c"
|
||||||
|
field :sale_color, :string, default: "#dc2626"
|
||||||
|
field :font_size, :string, default: "medium"
|
||||||
|
field :heading_weight, :string, default: "bold"
|
||||||
|
field :layout_width, :string, default: "wide"
|
||||||
|
field :button_style, :string, default: "filled"
|
||||||
|
field :card_shadow, :string, default: "none"
|
||||||
|
field :product_text_align, :string, default: "left"
|
||||||
|
field :image_aspect_ratio, :string, default: "square"
|
||||||
|
|
||||||
|
# Feature toggles
|
||||||
|
field :announcement_bar, :boolean, default: true
|
||||||
|
field :sticky_header, :boolean, default: false
|
||||||
|
field :hover_image, :boolean, default: true
|
||||||
|
field :quick_add, :boolean, default: true
|
||||||
|
field :show_prices, :boolean, default: true
|
||||||
|
field :pdp_trust_badges, :boolean, default: true
|
||||||
|
field :pdp_reviews, :boolean, default: true
|
||||||
|
field :pdp_related_products, :boolean, default: true
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(settings, attrs) do
|
||||||
|
settings
|
||||||
|
|> cast(attrs, [
|
||||||
|
:mood,
|
||||||
|
:typography,
|
||||||
|
:shape,
|
||||||
|
:density,
|
||||||
|
:grid_columns,
|
||||||
|
:header_layout,
|
||||||
|
:accent_color,
|
||||||
|
:logo_mode,
|
||||||
|
:logo_image_id,
|
||||||
|
:header_image_id,
|
||||||
|
:logo_size,
|
||||||
|
:logo_recolor,
|
||||||
|
:logo_color,
|
||||||
|
:header_zoom,
|
||||||
|
:header_position_x,
|
||||||
|
:header_position_y,
|
||||||
|
:secondary_accent_color,
|
||||||
|
:sale_color,
|
||||||
|
:font_size,
|
||||||
|
:heading_weight,
|
||||||
|
:layout_width,
|
||||||
|
:button_style,
|
||||||
|
:card_shadow,
|
||||||
|
:product_text_align,
|
||||||
|
:image_aspect_ratio,
|
||||||
|
:announcement_bar,
|
||||||
|
:sticky_header,
|
||||||
|
:hover_image,
|
||||||
|
:quick_add,
|
||||||
|
:show_prices,
|
||||||
|
:pdp_trust_badges,
|
||||||
|
:pdp_reviews,
|
||||||
|
:pdp_related_products
|
||||||
|
])
|
||||||
|
|> validate_required([:mood, :typography, :shape, :density])
|
||||||
|
|> validate_inclusion(:mood, ~w(neutral warm cool dark))
|
||||||
|
|> validate_inclusion(:typography, ~w(clean editorial modern classic friendly minimal impulse))
|
||||||
|
|> validate_inclusion(:shape, ~w(sharp soft round pill))
|
||||||
|
|> validate_inclusion(:density, ~w(spacious balanced compact))
|
||||||
|
|> validate_inclusion(:grid_columns, ~w(2 3 4))
|
||||||
|
|> validate_inclusion(:header_layout, ~w(standard centered minimal))
|
||||||
|
|> validate_inclusion(:logo_mode, ~w(text-only logo-text logo-only header-image logo-header))
|
||||||
|
|> validate_number(:logo_size, greater_than_or_equal_to: 24, less_than_or_equal_to: 120)
|
||||||
|
|> validate_number(:header_zoom, greater_than_or_equal_to: 100, less_than_or_equal_to: 200)
|
||||||
|
|> validate_number(:header_position_x, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)
|
||||||
|
|> validate_number(:header_position_y, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)
|
||||||
|
end
|
||||||
|
end
|
||||||
135
lib/simpleshop_theme/theme/presets.ex
Normal file
135
lib/simpleshop_theme/theme/presets.ex
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
defmodule SimpleshopTheme.Theme.Presets do
|
||||||
|
@moduledoc """
|
||||||
|
Defines the 9 curated theme presets for SimpleShop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@presets %{
|
||||||
|
gallery: %{
|
||||||
|
mood: "warm",
|
||||||
|
typography: "editorial",
|
||||||
|
shape: "soft",
|
||||||
|
density: "spacious",
|
||||||
|
grid_columns: "3",
|
||||||
|
header_layout: "centered",
|
||||||
|
accent_color: "#e85d04"
|
||||||
|
},
|
||||||
|
studio: %{
|
||||||
|
mood: "neutral",
|
||||||
|
typography: "clean",
|
||||||
|
shape: "soft",
|
||||||
|
density: "balanced",
|
||||||
|
grid_columns: "4",
|
||||||
|
header_layout: "standard",
|
||||||
|
accent_color: "#3b82f6"
|
||||||
|
},
|
||||||
|
boutique: %{
|
||||||
|
mood: "warm",
|
||||||
|
typography: "classic",
|
||||||
|
shape: "soft",
|
||||||
|
density: "balanced",
|
||||||
|
grid_columns: "3",
|
||||||
|
header_layout: "centered",
|
||||||
|
accent_color: "#b45309"
|
||||||
|
},
|
||||||
|
bold: %{
|
||||||
|
mood: "neutral",
|
||||||
|
typography: "modern",
|
||||||
|
shape: "sharp",
|
||||||
|
density: "compact",
|
||||||
|
grid_columns: "4",
|
||||||
|
header_layout: "standard",
|
||||||
|
accent_color: "#dc2626"
|
||||||
|
},
|
||||||
|
playful: %{
|
||||||
|
mood: "neutral",
|
||||||
|
typography: "friendly",
|
||||||
|
shape: "pill",
|
||||||
|
density: "balanced",
|
||||||
|
grid_columns: "4",
|
||||||
|
header_layout: "standard",
|
||||||
|
accent_color: "#8b5cf6"
|
||||||
|
},
|
||||||
|
minimal: %{
|
||||||
|
mood: "cool",
|
||||||
|
typography: "minimal",
|
||||||
|
shape: "sharp",
|
||||||
|
density: "spacious",
|
||||||
|
grid_columns: "2",
|
||||||
|
header_layout: "minimal",
|
||||||
|
accent_color: "#171717"
|
||||||
|
},
|
||||||
|
night: %{
|
||||||
|
mood: "dark",
|
||||||
|
typography: "modern",
|
||||||
|
shape: "soft",
|
||||||
|
density: "balanced",
|
||||||
|
grid_columns: "4",
|
||||||
|
header_layout: "standard",
|
||||||
|
accent_color: "#f97316"
|
||||||
|
},
|
||||||
|
classic: %{
|
||||||
|
mood: "warm",
|
||||||
|
typography: "classic",
|
||||||
|
shape: "soft",
|
||||||
|
density: "spacious",
|
||||||
|
grid_columns: "3",
|
||||||
|
header_layout: "standard",
|
||||||
|
accent_color: "#166534"
|
||||||
|
},
|
||||||
|
impulse: %{
|
||||||
|
mood: "neutral",
|
||||||
|
typography: "impulse",
|
||||||
|
shape: "sharp",
|
||||||
|
density: "spacious",
|
||||||
|
grid_columns: "3",
|
||||||
|
header_layout: "centered",
|
||||||
|
accent_color: "#000000",
|
||||||
|
font_size: "medium",
|
||||||
|
heading_weight: "regular",
|
||||||
|
layout_width: "full",
|
||||||
|
button_style: "filled",
|
||||||
|
card_shadow: "none",
|
||||||
|
product_text_align: "center"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns all available presets.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> all()
|
||||||
|
%{gallery: %{...}, studio: %{...}, ...}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def all, do: @presets
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a preset by name.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> get(:gallery)
|
||||||
|
%{mood: "warm", typography: "editorial", ...}
|
||||||
|
|
||||||
|
iex> get(:nonexistent)
|
||||||
|
nil
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get(preset_name) when is_atom(preset_name) do
|
||||||
|
Map.get(@presets, preset_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists all preset names.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> list_names()
|
||||||
|
[:gallery, :studio, :boutique, :bold, :playful, :minimal, :night, :classic, :impulse]
|
||||||
|
|
||||||
|
"""
|
||||||
|
def list_names do
|
||||||
|
Map.keys(@presets)
|
||||||
|
end
|
||||||
|
end
|
||||||
16
priv/repo/migrations/20251230213057_create_settings.exs
Normal file
16
priv/repo/migrations/20251230213057_create_settings.exs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
defmodule SimpleshopTheme.Repo.Migrations.CreateSettings do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:settings, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :key, :string, null: false
|
||||||
|
add :value, :text, null: false
|
||||||
|
add :value_type, :string, null: false, default: "string"
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:settings, [:key])
|
||||||
|
end
|
||||||
|
end
|
||||||
21
priv/repo/migrations/20251230213058_create_images.exs
Normal file
21
priv/repo/migrations/20251230213058_create_images.exs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
defmodule SimpleshopTheme.Repo.Migrations.CreateImages do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:images, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :image_type, :string, null: false
|
||||||
|
add :filename, :string, null: false
|
||||||
|
add :content_type, :string, null: false
|
||||||
|
add :file_size, :integer, null: false
|
||||||
|
add :data, :binary, null: false
|
||||||
|
add :is_svg, :boolean, default: false
|
||||||
|
add :svg_content, :text
|
||||||
|
add :thumbnail_data, :binary
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:images, [:image_type])
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -9,3 +9,12 @@
|
|||||||
#
|
#
|
||||||
# We recommend using the bang functions (`insert!`, `update!`
|
# We recommend using the bang functions (`insert!`, `update!`
|
||||||
# and so on) as they will fail if something goes wrong.
|
# and so on) as they will fail if something goes wrong.
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Settings
|
||||||
|
|
||||||
|
# Set default theme settings (Studio preset)
|
||||||
|
IO.puts("Setting up default theme settings...")
|
||||||
|
|
||||||
|
{:ok, _theme} = Settings.apply_preset(:studio)
|
||||||
|
|
||||||
|
IO.puts("✓ Default theme settings applied (Studio preset)")
|
||||||
|
|||||||
113
test/simpleshop_theme/media_test.exs
Normal file
113
test/simpleshop_theme/media_test.exs
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
defmodule SimpleshopTheme.MediaTest do
|
||||||
|
use SimpleshopTheme.DataCase, async: true
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Media
|
||||||
|
|
||||||
|
@valid_attrs %{
|
||||||
|
image_type: "logo",
|
||||||
|
filename: "logo.png",
|
||||||
|
content_type: "image/png",
|
||||||
|
file_size: 1024,
|
||||||
|
data: <<137, 80, 78, 71>>
|
||||||
|
}
|
||||||
|
|
||||||
|
@svg_attrs %{
|
||||||
|
image_type: "logo",
|
||||||
|
filename: "logo.svg",
|
||||||
|
content_type: "image/svg+xml",
|
||||||
|
file_size: 512,
|
||||||
|
data: "<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\"/></svg>"
|
||||||
|
}
|
||||||
|
|
||||||
|
describe "upload_image/1" do
|
||||||
|
test "uploads an image with valid attributes" do
|
||||||
|
assert {:ok, image} = Media.upload_image(@valid_attrs)
|
||||||
|
assert image.image_type == "logo"
|
||||||
|
assert image.filename == "logo.png"
|
||||||
|
assert image.content_type == "image/png"
|
||||||
|
assert image.is_svg == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "detects and stores SVG content" do
|
||||||
|
assert {:ok, image} = Media.upload_image(@svg_attrs)
|
||||||
|
assert image.is_svg == true
|
||||||
|
assert image.svg_content == @svg_attrs.data
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates required fields" do
|
||||||
|
assert {:error, changeset} = Media.upload_image(%{})
|
||||||
|
assert "can't be blank" in errors_on(changeset).image_type
|
||||||
|
assert "can't be blank" in errors_on(changeset).filename
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates image_type inclusion" do
|
||||||
|
attrs = Map.put(@valid_attrs, :image_type, "invalid")
|
||||||
|
assert {:error, changeset} = Media.upload_image(attrs)
|
||||||
|
assert "is invalid" in errors_on(changeset).image_type
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates file size" do
|
||||||
|
attrs = Map.put(@valid_attrs, :file_size, 10_000_000)
|
||||||
|
assert {:error, changeset} = Media.upload_image(attrs)
|
||||||
|
assert "must be less than 5000000" in errors_on(changeset).file_size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_image/1" do
|
||||||
|
test "returns image by id" do
|
||||||
|
{:ok, image} = Media.upload_image(@valid_attrs)
|
||||||
|
assert ^image = Media.get_image(image.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for nonexistent id" do
|
||||||
|
assert Media.get_image(Ecto.UUID.generate()) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_logo/0 and get_header/0" do
|
||||||
|
test "get_logo returns a logo" do
|
||||||
|
{:ok, logo} = Media.upload_image(@valid_attrs)
|
||||||
|
|
||||||
|
result = Media.get_logo()
|
||||||
|
assert result.id == logo.id
|
||||||
|
assert result.image_type == "logo"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "get_header returns most recent header" do
|
||||||
|
attrs = Map.put(@valid_attrs, :image_type, "header")
|
||||||
|
{:ok, header} = Media.upload_image(attrs)
|
||||||
|
|
||||||
|
result = Media.get_header()
|
||||||
|
assert result.id == header.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "get_logo returns nil when no logos exist" do
|
||||||
|
assert Media.get_logo() == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "delete_image/1" do
|
||||||
|
test "deletes an image" do
|
||||||
|
{:ok, image} = Media.upload_image(@valid_attrs)
|
||||||
|
assert {:ok, _} = Media.delete_image(image)
|
||||||
|
assert Media.get_image(image.id) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "list_images_by_type/1" do
|
||||||
|
test "lists all images of a specific type" do
|
||||||
|
{:ok, logo1} = Media.upload_image(@valid_attrs)
|
||||||
|
{:ok, logo2} = Media.upload_image(@valid_attrs)
|
||||||
|
{:ok, _header} = Media.upload_image(Map.put(@valid_attrs, :image_type, "header"))
|
||||||
|
|
||||||
|
logos = Media.list_images_by_type("logo")
|
||||||
|
assert length(logos) == 2
|
||||||
|
assert Enum.any?(logos, &(&1.id == logo1.id))
|
||||||
|
assert Enum.any?(logos, &(&1.id == logo2.id))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty list when no images of type exist" do
|
||||||
|
assert Media.list_images_by_type("product") == []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
109
test/simpleshop_theme/settings_test.exs
Normal file
109
test/simpleshop_theme/settings_test.exs
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
defmodule SimpleshopTheme.SettingsTest do
|
||||||
|
use SimpleshopTheme.DataCase, async: true
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Settings
|
||||||
|
alias SimpleshopTheme.Settings.ThemeSettings
|
||||||
|
|
||||||
|
describe "get_setting/2 and put_setting/3" do
|
||||||
|
test "stores and retrieves string settings" do
|
||||||
|
assert {:ok, _} = Settings.put_setting("site_name", "My Shop")
|
||||||
|
assert Settings.get_setting("site_name") == "My Shop"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stores and retrieves json settings" do
|
||||||
|
data = %{"foo" => "bar", "nested" => %{"key" => "value"}}
|
||||||
|
assert {:ok, _} = Settings.put_setting("custom_data", data, "json")
|
||||||
|
assert Settings.get_setting("custom_data") == data
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stores and retrieves integer settings" do
|
||||||
|
assert {:ok, _} = Settings.put_setting("max_products", 100, "integer")
|
||||||
|
assert Settings.get_setting("max_products") == 100
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stores and retrieves boolean settings" do
|
||||||
|
assert {:ok, _} = Settings.put_setting("feature_enabled", true, "boolean")
|
||||||
|
assert Settings.get_setting("feature_enabled") == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns default when setting doesn't exist" do
|
||||||
|
assert Settings.get_setting("nonexistent", "default") == "default"
|
||||||
|
assert Settings.get_setting("nonexistent") == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates existing setting" do
|
||||||
|
assert {:ok, _} = Settings.put_setting("site_name", "Old Name")
|
||||||
|
assert {:ok, _} = Settings.put_setting("site_name", "New Name")
|
||||||
|
assert Settings.get_setting("site_name") == "New Name"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_theme_settings/0" do
|
||||||
|
test "returns default theme settings when none exist" do
|
||||||
|
settings = Settings.get_theme_settings()
|
||||||
|
assert %ThemeSettings{} = settings
|
||||||
|
assert settings.mood == "neutral"
|
||||||
|
assert settings.typography == "clean"
|
||||||
|
assert settings.shape == "soft"
|
||||||
|
assert settings.density == "balanced"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns stored theme settings" do
|
||||||
|
{:ok, _} = Settings.update_theme_settings(%{mood: "dark", typography: "modern"})
|
||||||
|
settings = Settings.get_theme_settings()
|
||||||
|
assert settings.mood == "dark"
|
||||||
|
assert settings.typography == "modern"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "update_theme_settings/1" do
|
||||||
|
test "updates theme settings successfully" do
|
||||||
|
{:ok, settings} = Settings.update_theme_settings(%{mood: "warm", typography: "editorial"})
|
||||||
|
assert settings.mood == "warm"
|
||||||
|
assert settings.typography == "editorial"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates mood values" do
|
||||||
|
{:error, changeset} = Settings.update_theme_settings(%{mood: "invalid"})
|
||||||
|
assert "is invalid" in errors_on(changeset).mood
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates typography values" do
|
||||||
|
{:error, changeset} = Settings.update_theme_settings(%{typography: "invalid"})
|
||||||
|
assert "is invalid" in errors_on(changeset).typography
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates logo size range" do
|
||||||
|
{:error, changeset} = Settings.update_theme_settings(%{logo_size: 10})
|
||||||
|
assert "must be greater than or equal to 24" in errors_on(changeset).logo_size
|
||||||
|
end
|
||||||
|
|
||||||
|
test "preserves existing settings when updating subset" do
|
||||||
|
{:ok, _} = Settings.update_theme_settings(%{mood: "warm"})
|
||||||
|
{:ok, settings} = Settings.update_theme_settings(%{typography: "modern"})
|
||||||
|
assert settings.mood == "warm"
|
||||||
|
assert settings.typography == "modern"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "apply_preset/1" do
|
||||||
|
test "applies gallery preset successfully" do
|
||||||
|
{:ok, settings} = Settings.apply_preset(:gallery)
|
||||||
|
assert settings.mood == "warm"
|
||||||
|
assert settings.typography == "editorial"
|
||||||
|
assert settings.shape == "soft"
|
||||||
|
assert settings.accent_color == "#e85d04"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "applies studio preset successfully" do
|
||||||
|
{:ok, settings} = Settings.apply_preset(:studio)
|
||||||
|
assert settings.mood == "neutral"
|
||||||
|
assert settings.typography == "clean"
|
||||||
|
assert settings.accent_color == "#3b82f6"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for invalid preset" do
|
||||||
|
assert {:error, :preset_not_found} = Settings.apply_preset(:nonexistent)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
6278
theme-demo-v28.html
Normal file
6278
theme-demo-v28.html
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user